Android Thingsで勤怠連絡ボタンで作ってみる

## 概要
下記3つの記事の**Android Things** × Raspberry Pi3(以後ラズパイ)版

* [myThingsで勤怠連絡ボタンを作ってみた](https://techblog.yahoo.co.jp/advent-calendar-2016/mythings-attendance-contact-button/)
* [myThingsとAmazon Dash ButtonをAWS経由で繋げてみる](http://qiita.com/yumatsud/items/0771217c9c3efe44594b)
* [Raspberry Pi3×SORACOM×AWS IoT×myThingsで勤怠連絡ボタンを作ってみる](http://qiita.com/yumatsud/items/3b766b2997b411633ff5)

3つ目の記事と同様、ラズパイでボタンを押したら、勤怠連絡が飛ぶようにする

## 前提
* [Android ThingsをRaspberry Pi3で使ってみた](http://qiita.com/yumatsud/items/189d2b18ccffcd6ac1da)でAndroid Thingsとラズパイの準備が終わっている
* [ボタンでLチカサンプル](https://github.com/androidthings/sample-button)を実際にやってみている
* Google Cloud Platform(以後GCP)の登録が完了している

## 検討フロー
下記のどちらかでやってみようと考え、

1. ラズパイ -> AWS API Gateway -> AWS Lambda -> myThings Developers
    * [myThingsとAmazon Dash ButtonをAWS経由で繋げてみる](http://qiita.com/yumatsud/items/0771217c9c3efe44594b)」のAWS API Gatewayを使用
    * [ボタンでLチカサンプル](https://github.com/androidthings/sample-button)ボタンを押したタイミングでHTTPリクエスト
    * ただし、keydownの間に通信としてしまうと、ずっと通信しちゃうので要注意
2. ラズパイ -> Google Cloud Pub/Sub -> Google Cloud Funtions -> myThings Developers
    * [天気サンプル](https://github.com/androidthings/weatherstation)のPub/Subを参考にする
    * ボタンを押したらPublishし、subscribeして受けた方はFunctionsを呼び出す
    * そのFuntionsの中でmyThings Developersを呼び出す

今回は2のGCPを使うパターンでやってみることにします。
ベースのソースコードは[ボタンでLチカサンプル](https://github.com/androidthings/sample-button)を使い、必要に応じて、[天気サンプル](https://github.com/androidthings/weatherstation)のPub/Sub部分を使う

## Google Cloud Pub/Sub
[github上の説明](https://github.com/androidthings/weatherstation#google-cloud-platform-configuration-optional)を参考にして、GCPの準備をする

1. APIマネージャーでPub/Subを有効にする
2. IAMと管理ページで、新しいサービスアカウントを作成(役割は特に入れなくて問題無し)、その時に新しい秘密鍵をjson形式で作成しておく(完了するとjsonファイルがダウンロードされる)
3. 新しいトピックを作成して、2で作成したサービスアカウントに権限を付与、その際の役割は「Pub/Subパブリッシャー」にしておく
4. 作成したトピック配下で、新しいサブスクリプションを作成
5. 2で作成した際にダウンロードされたjsonファイルを「credentials.json」にリネームし、res配下にrawフォルダを作成してそこに置く
6. app/build.gradleを下記のように修正

```app/build.gradle
・・・・・
    buildTypes {
        debug {
            buildConfigField "String", "PROJECT_ID", '"<<プロジェクトID>>"'
            buildConfigField "String", "PUBSUB_TOPIC", '"<<topic>>"'
        }
        release {
            initWith(buildTypes.debug)

            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }
・・・・・
dependencies {
    compile 'com.google.android.things.contrib:driver-button:0.2'
    provided 'com.google.android.things:androidthings:0.3-devpreview'

    compile('com.google.api-client:google-api-client-android:1.22.0') {
        exclude group: 'org.apache.httpcomponents'
    }
    compile('com.google.apis:google-api-services-pubsub:v1-rev12-1.22.0') {
        exclude group: 'org.apache.httpcomponents'
    }
}
```

## Google Cloud Functions
発火タイミングは、上記で準備したCloud Pub/Subのトピックに対するメッセージの着信時とする。

1. Cloud Functionsで関数を作成をクリック
2. 各設定は下記のようにする
<img width="502" alt="スクリーンショット 2017-04-16 0.27.03.png" src="https://qiita-image-store.s3.amazonaws.com/0/71637/c6c3d742-73b9-ec7c-2eb4-a31dc33963ef.png">

3. 作成したコードは下記([myThingsとAmazon Dash ButtonをAWS経由で繋げてみる](http://qiita.com/yumatsud/items/0771217c9c3efe44594b)で使用したjsとほぼ一緒)

```index.js
'use strict';

const qs = require("querystring");
var https = require("https");
var date = new Date();
var dateString = createPostTimeString();

// myThings Developersに必要なリクエスト項目
var appid = "<<appid>>";
var secret = "<<secret>>";
var accessToken = "<<accessToken>>";
var refreshToken = "<<refreshToken>>";

/**
 * Triggered from a message on a Cloud Pub/Sub topic.
 *
 * @param {!Object} event The Cloud Functions event.
 * @param {!Function} The callback function.
 */
exports.subscribe = function subscribe(event, callback) {
  // myThings Developersへリクエスト
  requestDevelopers(callback);
};

/**
 * "2016/12/21(水)"の形式の文字列を返す
 * @return string 日付文字列
 */
function createPostTimeString() {
    // 年
    var year = date.getFullYear();
    // 月
    var month = date.getMonth() + 1;
    if (month < 10) {
        month = '0' + month;
    }
    // 日
    var day = date.getDate();
    if (day < 10) {
        day = '0' + day;
    }
    // 曜日
    var weekDayList = [ "日", "月", "火", "水", "木", "金", "土" ];
    var weekDay = weekDayList[ date.getDay() ];
    
    return year+"/"+month+"/"+day+"("+weekDay+")";
}

/**
 * myThings Developersへのリクエスト
 * @return void
 */
function requestDevelopers(callback) {
    // リクエストパラメータの生成
    var postArgs = {
        date: dateString
    }
    var postData = qs.stringify({
        "entry": JSON.stringify(postArgs),
    });
    
    // リクエスト設定
    var options = {
        hostname: "mythings-developers.yahooapis.jp",
        path: "/v2/services/<<hogehoge>>/mythings/<<hugahuga>>/run",
        port: 443,
        method: "POST",
        headers: {
            "Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
            "Authorization": "Bearer " + accessToken,
        },
    };
    
    // リクエスト
    var req = https.request(options, function(res){
        // 401のとき
        if (res.statusCode == 401) {
            // コールバック付きのrefreshAccessTokenを呼ぶ
            refreshAccessToken(callback);
            return;
        }

        // レスポンス処理
        res.on("data", function(body){
            var parseData = JSON.parse(body);
            if(typeof( parseData["flag"] ) != "undefined") {
                callback();
            } else {
                console.log("カスタムトリガーの実行リクエストの受付に失敗しました。:"+body);
            }
        });
    })
    .on("error", function(res){
        callback();
    });
    req.end(postData)
}

/**
 * アクセストークンのリフレッシュ
 */
function refreshAccessToken(callback) {
    console.log("refreshAccessTokenにきたよ");
    // リフレッシュ用データのセット
    var reqData = qs.stringify({
        "grant_type": "refresh_token",
        "refresh_token": refreshToken
    });
    // リクエスト設定
    var buffer = new Buffer(appid + ":" + secret, "ascii");
    var options = {
        hostname: "auth.login.yahoo.co.jp",
        path: "/yconnect/v1/token",
        port: 443,
        method: "POST",
        headers: {
            "Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
            "Authorization": "Basic " + buffer.toString("base64"),
        }
    };

    // リクエスト実行
    var req = https.request(options, function(res) {
        console.log("refreshAccessTokenのrequestのなかにきたよ");
        // 401の場合
        if(res.statusCode == 401) {
            callback();
        } else if(res.statusCode != 200) {
            callback();
        }

        // レスポンス処理
        res.on('data', function(body){
            var parseData = JSON.parse(body);
            accessToken = parseData['access_token'];
            requestDevelopers(callback);
        }); 
    });

    // POSTデータのリクエスト
    req.end(reqData);
}
```

## Android Things側の修正
* AndroidManifest.xmlは、下記の部分を追記

```AndroidManifest.xml
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
```
* Pub/Sub部分は[天気サンプル](https://github.com/androidthings/weatherstation)の[PubsubPublisher.java]をベースにして、下記のように実装。

```PubsubPublisher.java
class PubsubPublisher {
    private static final String TAG = PubsubPublisher.class.getSimpleName();

    private final Context mContext;
    private final String mAppname;
    private final String mTopic;

    private Pubsub mPubsub;
    private HttpTransport mHttpTransport;

    private Handler mHandler;
    private HandlerThread mHandlerThread;

    PubsubPublisher(Context context, String appname, String project, String topic,
                    int credentialResourceId) throws IOException {
        mContext = context;
        mAppname = appname;
        mTopic = "projects/" + project + "/topics/" + topic;

        mHandlerThread = new HandlerThread("pubsubPublisherThread");
        mHandlerThread.start();
        mHandler = new Handler(mHandlerThread.getLooper());

        InputStream jsonCredentials = mContext.getResources().openRawResource(credentialResourceId);
        final GoogleCredential credentials;
        try {
            credentials = GoogleCredential.fromStream(jsonCredentials).createScoped(
                    Collections.singleton(PubsubScopes.PUBSUB));
        } finally {
            try {
                jsonCredentials.close();
            } catch (IOException e) {
                Log.e(TAG, "Error closing input stream", e);
            }
        }
        mHandler.post(new Runnable() {
            @Override
            public void run() {
                mHttpTransport = AndroidHttp.newCompatibleTransport();
                JsonFactory jsonFactory = JacksonFactory.getDefaultInstance();
                mPubsub = new Pubsub.Builder(mHttpTransport, jsonFactory, credentials)
                        .setApplicationName(mAppname).build();
            }
        });
    }

    public void start() {
        mHandler.post(mPublishRunnable);
    }

    public void close() {
        mHandler.removeCallbacks(mPublishRunnable);
        mHandler.post(new Runnable() {
            @Override
            public void run() {
                try {
                    mHttpTransport.shutdown();
                } catch (IOException e) {
                    Log.d(TAG, "error destroying http transport");
                } finally {
                    mHttpTransport = null;
                    mPubsub = null;
                }
            }
        });
        mHandlerThread.quitSafely();
    }


    private Runnable mPublishRunnable = new Runnable() {
        @Override
        public void run() {
            ConnectivityManager connectivityManager =
                    (ConnectivityManager) mContext.getSystemService(Context.CONNECTIVITY_SERVICE);
            NetworkInfo activeNetwork = connectivityManager.getActiveNetworkInfo();
            if (activeNetwork == null || !activeNetwork.isConnectedOrConnecting()) {
                Log.e(TAG, "no active network");
                return;
            }

            try {
                JSONObject messagePayload = createMessagePayload();
                Log.d(TAG, "publishing message: " + messagePayload);
                PubsubMessage m = new PubsubMessage();
                m.setData(Base64.encodeToString(messagePayload.toString().getBytes(),
                        Base64.NO_WRAP));
                PublishRequest request = new PublishRequest();
                request.setMessages(Collections.singletonList(m));
                mPubsub.projects().topics().publish(mTopic, request).execute();
            } catch (JSONException | IOException e) {
                Log.e(TAG, "Error publishing message", e);
            } finally {
                // 天気情報のように定期的に情報をpublishしたいときは、下記のように1分おきにやるなどをする
                //mHandler.postDelayed(mPublishRunnable, PUBLISH_INTERVAL_MS);
            }
        }

        private JSONObject createMessagePayload()
                throws JSONException {
            JSONObject messagePayload = new JSONObject();
            messagePayload.put("deviceId", Build.DEVICE);
            messagePayload.put("channel", "pubsub");
            messagePayload.put("timestamp", System.currentTimeMillis());
            return messagePayload;
        }
    };
}
```
* Activityは[ボタンでLチカサンプル](https://github.com/androidthings/sample-button)の[ButtonActivity](https://github.com/androidthings/sample-button/blob/master/app/src/main/java/com/example/androidthings/button/ButtonActivity.java)に対して下記のように追記

```ButtonActivity.java
public class ButtonActivity extends Activity {
    private static final String TAG = ButtonActivity.class.getSimpleName();

    private Gpio mLedGpio;
    private ButtonInputDriver mButtonInputDriver;
    private PubsubPublisher mPubsubPublisher;
    private boolean mLock = false;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Log.i(TAG, "Starting ButtonActivity");

        PeripheralManagerService pioService = new PeripheralManagerService();
        try {
            Log.i(TAG, "Configuring GPIO pins");
            mLedGpio = pioService.openGpio(BoardDefaults.getGPIOForLED());
            mLedGpio.setDirection(Gpio.DIRECTION_OUT_INITIALLY_LOW);

            Log.i(TAG, "Registering button driver");
            // Initialize and register the InputDriver that will emit SPACE key events
            // on GPIO state changes.
            mButtonInputDriver = new ButtonInputDriver(
                    BoardDefaults.getGPIOForButton(),
                    Button.LogicState.PRESSED_WHEN_LOW,
                    KeyEvent.KEYCODE_SPACE);
            mButtonInputDriver.register();
        } catch (IOException e) {
            Log.e(TAG, "Error configuring GPIO pins", e);
        }

        // start Cloud PubSub Publisher if cloud credentials are present.
        int credentialId = getResources().getIdentifier("credentials", "raw", getPackageName());
        if (credentialId != 0) {
            try {
                mPubsubPublisher = new PubsubPublisher(this, "sample-button",
                        BuildConfig.PROJECT_ID, BuildConfig.PUBSUB_TOPIC, credentialId);
            } catch (IOException e) {
                Log.e(TAG, "error creating pubsub publisher", e);
            }
        }
    }

    @Override
    public boolean onKeyDown(int keyCode, KeyEvent event) {
        if (keyCode == KeyEvent.KEYCODE_SPACE) {
            // Turn on the LED
            setLedValue(true);
            sendrequest();
            return true;
        }

        return super.onKeyDown(keyCode, event);
    }

    @Override
    public boolean onKeyUp(int keyCode, KeyEvent event) {
        if (keyCode == KeyEvent.KEYCODE_SPACE) {
            // Turn off the LED
            setLedValue(false);
            mLock = false;
            return true;
        }

        return super.onKeyUp(keyCode, event);
    }

    /**
     * Update the value of the LED output.
     */
    private void setLedValue(boolean value) {
        try {
            mLedGpio.setValue(value);
        } catch (IOException e) {
            Log.e(TAG, "Error updating GPIO value", e);
        }
    }

    /**
     * リクエスト送信しちゃうぞ
     */
    private void sendrequest() {
        // onKeyDownの間、ずっと通信されない用の対応
        if (!mLock) {
            mLock = true;
　　　　　　　　　　　　　　　　　　　　　　　　Toast.makeText(this, "Press attendance contact button!!", Toast.LENGTH_LONG).show();![IMG_5206.GIF](https://qiita-image-store.s3.amazonaws.com/0/71637/74344646-e10d-8170-22a0-9adfeb48f586.gif)

            if (mPubsubPublisher != null) {
                mPubsubPublisher.start();
            }
        }
    }

    @Override
    protected void onDestroy(){
        super.onDestroy();

        if (mButtonInputDriver != null) {
            mButtonInputDriver.unregister();
            try {
                mButtonInputDriver.close();
            } catch (IOException e) {
                Log.e(TAG, "Error closing Button driver", e);
            } finally{
                mButtonInputDriver = null;
            }
        }

        if (mLedGpio != null) {
            try {
                mLedGpio.close();
            } catch (IOException e) {
                Log.e(TAG, "Error closing LED GPIO", e);
            } finally{
                mLedGpio = null;
            }
            mLedGpio = null;
        }

        if (mPubsubPublisher != null) {
            mPubsubPublisher.close();
            mPubsubPublisher = null;
        }
    }
}
```

## 実際に動かしてみた
<img width="350" alt="IMG_5207.GIF" src="https://qiita-image-store.s3.amazonaws.com/0/71637/5e46d776-6f7f-2b4d-14f9-c88a493f33fa.gif">


## 感想
* Android Studioで開発して、そのままビルドできるので、Androidエンジニアの方だと取っ付きやすそう
* Googleの提供ライブラリで簡単に開発ができる
* GCP(Pub/Sub、Dataflow、Funtionsなど)との親和性があるのでクラウド連携はとてもしやすい

## 後々やりたいこと
* MQTTを使った通信
* GCPのSpannerにデータを保存
* Dataflowにデータを流してみる
* Firebaseとの連携
* SORACOMを使っての接続

## 参考
* Android Things
    * [Raspberry Pi 3](https://developer.android.com/things/hardware/raspberrypi.html)
    * [サンプル](https://developer.android.com/things/sdk/samples.html)
    * [github/androidthings](https://github.com/androidthings)
* GCP
    * [Pub/Sub](https://cloud.google.com/pubsub/)
    * [Functions](https://cloud.google.com/functions/)
    * [Endpoints](https://cloud.google.com/endpoints/)
    * [Spanner](https://cloud.google.com/spanner/)
    * [GCPのgcloudコマンドをインストールする](http://qiita.com/kentarosasaki/items/2232113b44b016a56adc)