目的
様々なサービス上のAPIで取得できる数値データ等を一元的に管理した上でAndroidWearに表示したい。
新たに利用したAPIが増えたり、変更された時にメンテナンスコストを低く、セキュリティ面を担保した上でAndroid側のプログラムを簡単な物にするために今年の7月に使えるようになったAmazon API Gatewayを利用する。(日本リージョンはまだ未対応)
関連記事
- AndroidWear Huawei Watchの簡易レビュー
- AndroidWear Huawei WatchでWatchFaceを作成
- AndroidWearとAmazon API GatewayでGithub Followerを見える化 ← 現在こちらの記事
- Amazon API Gatewayを利用しAndroidアプリから簡単にWeb上のAPIを叩く方法
動作概要図
AndroidWearがインターネット上のデータを取得するには、親機を経由しなければならないため、親機でAmazon API Gatewayへアクセス -> Wearに取得したデータをmessageとして送信という全体像となります。
本来であればwear側から定期的にmobile側へ情報更新要求を出し、wear側へ送信や mobile側からnodeが存在すればwear側へ定期的に送信等処理を実装して完成に至ると思いますが、通信可能なWatchFace作成方法の情報が英語・日本語共に少なかったので、土台部分の実装のみひとまずデモ的に動いたので公開致します。
Amazon API Gatewayの利用
Android側のアプリケーションでHTTPリクエスト組み立てたり複数のAPI管理が面倒だな、、、と考えた時にAPIをまとめて管理出来て、Android/iOS/Javascript用のAPIアクセスライブラリ/SDKを吐き出してくれる「Amazon API Gateway」が頭をよぎりましたので利用してみました、ほんの触りでプロクシ機能しか使っておりませんが非常に短いコードで行けたので利用しない手は無いと思いました。
これまでイマイチ使い処が見えにくかった Lambda がAPI Gatewayとの組み合わせで色々使えるかなぁと感じております。下記を今後実施してみる予定、、、
API Gateway -> Lambda -> SNS -> Android端末等へ通知/Twilio 等々
Android Wearへのデータ送信
下記方法で mobile側から wearにデータを送信し WatchFaceを更新しています
mobile側処理
- アプリ起動時にGoogleApiClientを利用可能にする
- 通信可能な端末を getNodes で取得
- GoogleApiClient の sendMessage で Wearにメッセージを送信
wear側処理
- Messageを受信すると DataLayerListenerService の onMessageReceived が呼び出される
- Preferencesに受信したメッセージ内容を書き込み
- WatchFace の 描画部分でPreferenceより取得した値を表示
完成品
母艦でRefreshボタンを押すと、弊社の現在のgithub follower数がWatchFaceに表示されます
Android Studioでプロジェクトを作成
Mobile / Wear両方のチェックボックスを付けたプロジェクトを作成します。
2つのモジュールを跨ぐクラスの作成については、New Module -> Common等の名前で作成しdependenciesの設定を下記のように行います。
Amazon API Gatewayの設定
API Gateway の詳細設定は別記事としました下記を参考に下さい
Amazon API Gatewayを利用しAndroidアプリから簡単にWeb上のAPIを叩く方法
下記GitHub APIを API Gatewayに登録して利用しました。
https://api.github.com/users/xxxx
Android Studioに下記コードを記載
common module
WearConstants.java
package net.skyarch.common; public class WearConstants { public static final String PREFS = "net.skyarch.test"; public static final String PREFS_KEY_MESSAGE_TEXT = "net.skyarch.test.message"; public static final String DATA_CHANGED_ACTION = "net.skyarch.test.data.changed"; }
mobile (スマホ/タブレット)
res/layout/activity_main.xmlにButton btRefreshを配置して下さい。
MainActivity.java
package net.skyarch.testapp; import java.util.Collection; import java.util.HashSet; import android.app.Activity; import android.os.AsyncTask; import android.os.Bundle; import android.os.StrictMode; import android.util.Log; import android.view.View; import android.view.View.OnClickListener; import android.widget.Button; //Wearとの通信に必要 import com.google.android.gms.common.ConnectionResult; import com.google.android.gms.common.api.GoogleApiClient; import com.google.android.gms.common.api.GoogleApiClient.ConnectionCallbacks; import com.google.android.gms.common.api.GoogleApiClient.OnConnectionFailedListener; import com.google.android.gms.common.api.ResultCallback; import com.google.android.gms.wearable.DataApi.DataItemResult; import com.google.android.gms.wearable.MessageApi; import com.google.android.gms.wearable.MessageEvent; import com.google.android.gms.wearable.Node; import com.google.android.gms.wearable.NodeApi; import com.google.android.gms.wearable.Wearable; //MobileとWearで共通で利用するクラス import net.skyarch.common.WearConstants; //Amazon API Gatewayの利用に必要 import com.amazonaws.mobileconnectors.apigateway.ApiClientFactory; import net.skyarch.api.SkyarchKPIClient; public class MainActivity extends Activity implements OnClickListener, ConnectionCallbacks, ResultCallback<DataItemResult>, OnConnectionFailedListener { private final String TAG = "Mobile"; //Google Play Service private GoogleApiClient mGoogleApiClient; //Wear Node private Collection<String> mNodes; //Button private Button _btRefresh; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); //GoogleApiClientの作成 mGoogleApiClient = new GoogleApiClient.Builder(this) .addApi(Wearable.API) .addConnectionCallbacks(this) .addOnConnectionFailedListener(this) .build(); mGoogleApiClient.connect(); //Refreshボタン _btRefresh = (Button) findViewById(R.id.btRefresh); _btRefresh.setOnClickListener(this); _btRefresh.setEnabled(true); } @Override public void onClick(View v) { Log.d(TAG, "Clicked"); switch (v.getId()) { case R.id.btRefresh: Log.d(TAG, "Refresh Button Pushed"); new AsyncTask<Void, Void, Void>() { @Override protected Void doInBackground(Void... params) { //Githubから follower数を取得 ApiClientFactory factory = new ApiClientFactory(); final SkyarchKPIClient client = factory.build(SkyarchKPIClient.class); int followerCount = client.githubGet().getFollowers(); Log.d(TAG, "git followers" + Integer.toString(followerCount)); //Wear Nodeを取得 mNodes = getNodes(); //メッセージをWearに送信 sendMessageToWear(mNodes, WearConstants.DATA_CHANGED_ACTION, Integer.toString(followerCount)); return null; } }.execute(); break; } } @Override public void onConnected(Bundle connectionHint) { Log.d(TAG, "onConnected:"); } @Override public void onConnectionSuspended(int cause) { Log.d(TAG, "onConnectionSuspended:"); } @Override public void onConnectionFailed(ConnectionResult result) { Log.d(TAG, "onConnectionFailed:"); } @Override public void onResult(DataItemResult result) { Log.d(TAG, "Result:" + result.getStatus().isSuccess()); } /** * Wear nodeを取得 * * @return WearNode */ private Collection<String> getNodes() { HashSet<String> results = new HashSet<String>(); NodeApi.GetConnectedNodesResult nodes = Wearable.NodeApi .getConnectedNodes(mGoogleApiClient).await(); String nodesStr = ""; for (Node node : nodes.getNodes()) { Log.d(TAG, "node.getId():" + node.getId()); nodesStr += node.getId() + ","; results.add(node.getId()); } final String nodesTmp = nodesStr; runOnUiThread(new Runnable() { public void run() { //UIの更新 } }); return results; } /** * Wearにメッセージを送信 */ public void sendMessageToWear(Collection<String> nodes, String action, String message) { for (String node : nodes) { Wearable.MessageApi.addListener(mGoogleApiClient, new MessageApi.MessageListener() { @Override public void onMessageReceived(MessageEvent messageEvent) { final String data = new String(messageEvent.getData()); Log.d(TAG, "Message received: " + messageEvent); Log.d(TAG, "Data: " + data); runOnUiThread(new Runnable() { public void run() { //UIの更新 } }); } }); MessageApi.SendMessageResult result = Wearable.MessageApi .sendMessage(mGoogleApiClient, node, action, message.getBytes()).await(); if (!result.getStatus().isSuccess()) { Log.d(TAG, "ERROR: failed to send Message: " + result.getStatus()); runOnUiThread(new Runnable() { public void run() { //UIの更新 } }); } else { Log.d(TAG, "result.getStatus():" + result.getStatus()); runOnUiThread(new Runnable() { public void run() { //UIの更新 } }); } } } }
Manifest
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="net.skyarch.testapp" > <application android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:theme="@style/AppTheme" > <uses-permission android:name="android.permission.INTERNET"/> <meta-data android:name="com.google.android.gms.version" android:value="@integer/google_play_services_version"/> <activity android:name=".MainActivity" android:label="@string/app_name" > <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application> </manifest>
build.gradle
... dependencies { compile fileTree(dir: 'libs', include: ['*.jar']) wearApp project(':wear') compile 'com.android.support:appcompat-v7:23.0.1' compile 'com.google.android.gms:play-services:7.8.0' compile project(':common') }
wear (スマートウォッチ)
res/drawable に test.png を入れて下さい
DataLayerListenerService.java
package net.skyarch.testapp; import android.content.Intent; import android.util.Log; import com.google.android.gms.wearable.MessageEvent; import com.google.android.gms.wearable.Node; import com.google.android.gms.wearable.WearableListenerService; import net.skyarch.common.WearConstants; public class DataLayerListenerService extends WearableListenerService { private static final String TAG = "DataLayerService"; @Override public void onMessageReceived(MessageEvent messageEvent) { super.onMessageReceived(messageEvent); String strMessage = new String(messageEvent.getData()); Log.d(TAG, "onMessageReceived"); Log.d(TAG, strMessage); if (strMessage != null) { getBaseContext().getSharedPreferences(WearConstants.PREFS, MODE_PRIVATE).edit().putString(WearConstants.PREFS_KEY_MESSAGE_TEXT, strMessage).commit(); getBaseContext().sendBroadcast(new Intent(WearConstants.DATA_CHANGED_ACTION)); } } @Override public void onPeerConnected(Node peer) {} @Override public void onPeerDisconnected(Node peer){} }
TestWatch.java
package net.skyarch.testapp; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Rect; import android.graphics.Typeface; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.os.Bundle; import android.os.Handler; import android.os.Message; import android.support.wearable.watchface.CanvasWatchFaceService; import android.support.wearable.watchface.WatchFaceStyle; import android.text.format.Time; import android.util.Log; import android.view.SurfaceHolder; import android.view.WindowInsets; import net.skyarch.common.WearConstants; import java.lang.ref.WeakReference; import java.text.DateFormat; import java.util.Date; import java.util.TimeZone; import java.util.concurrent.TimeUnit; /** * Digital watch face with seconds. In ambient mode, the seconds aren't displayed. On devices with * low-bit ambient mode, the text is drawn without anti-aliasing in ambient mode. */ public class TestWatch extends CanvasWatchFaceService { private static final String TAG = "TestWatch"; private static final Typeface NORMAL_TYPEFACE = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL); /** * Update rate in milliseconds for interactive mode. We update once a second since seconds are * displayed in interactive mode. */ private static final long INTERACTIVE_UPDATE_RATE_MS = TimeUnit.SECONDS.toMillis(1); /** * Handler message id for updating the time periodically in interactive mode. */ private static final int MSG_UPDATE_TIME = 0; @Override public Engine onCreateEngine() { return new Engine(); } private class Engine extends CanvasWatchFaceService.Engine { final Handler mUpdateTimeHandler = new EngineHandler(this); final BroadcastReceiver mTimeZoneReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { mTime.clear(intent.getStringExtra("time-zone")); mTime.setToNow(); } }; boolean mRegisteredTimeZoneReceiver = false; Paint mBackgroundPaint; //背景に表示するpng Bitmap mBackgroundBitmap; boolean mAmbient; Time mTime; //日付 Paint mTextPaintDate; float mXOffsetDate; float mYOffsetDate; //時刻 Paint mTextPaintTime; float mXOffsetTime; float mXOffsetTimeAmbient; float mYOffsetTime; //mobileアプリからのMessage Paint mTextPaintMessage; float mXOffsetMessage; float mYOffsetMessage; /** * Whether the display supports fewer bits for each color in ambient mode. When true, we * disable anti-aliasing in ambient mode. */ boolean mLowBitAmbient; @Override public void onCreate(SurfaceHolder holder) { super.onCreate(holder); setWatchFaceStyle(new WatchFaceStyle.Builder(TestWatch.this) .setCardPeekMode(WatchFaceStyle.PEEK_MODE_VARIABLE) .setBackgroundVisibility(WatchFaceStyle.BACKGROUND_VISIBILITY_INTERRUPTIVE) .setShowSystemUiTime(false) .build()); Resources resources = TestWatch.this.getResources(); mYOffsetTime = resources.getDimension(R.dimen.digital_y_offset_time); mBackgroundPaint = new Paint(); mBackgroundPaint.setColor(resources.getColor(R.color.digital_background)); Drawable backgroundDrawable = resources.getDrawable(R.drawable.test, null); mBackgroundBitmap = ((BitmapDrawable) backgroundDrawable).getBitmap(); //Date mTextPaintDate = new Paint(); mTextPaintDate = createTextPaint(resources.getColor(R.color.digital_text_date)); //Time mTextPaintTime = new Paint(); mTextPaintTime = createTextPaint(resources.getColor(R.color.digital_text_time)); //Message mTextPaintMessage = new Paint(); mTextPaintMessage = createTextPaint(resources.getColor(R.color.digital_text_message)); mTime = new Time(); } @Override public void onDestroy() { mUpdateTimeHandler.removeMessages(MSG_UPDATE_TIME); super.onDestroy(); } private Paint createTextPaint(int textColor) { Paint paint = new Paint(); paint.setColor(textColor); paint.setTypeface(NORMAL_TYPEFACE); paint.setAntiAlias(true); return paint; } @Override public void onVisibilityChanged(boolean visible) { super.onVisibilityChanged(visible); if (visible) { registerReceiver(); // Update time zone in case it changed while we weren't visible. mTime.clear(TimeZone.getDefault().getID()); mTime.setToNow(); } else { unregisterReceiver(); } // Whether the timer should be running depends on whether we're visible (as well as // whether we're in ambient mode), so we may need to start or stop the timer. updateTimer(); } private void registerReceiver() { if (mRegisteredTimeZoneReceiver) { return; } mRegisteredTimeZoneReceiver = true; IntentFilter filter = new IntentFilter(Intent.ACTION_TIMEZONE_CHANGED); TestWatch.this.registerReceiver(mTimeZoneReceiver, filter); } private void unregisterReceiver() { if (!mRegisteredTimeZoneReceiver) { return; } mRegisteredTimeZoneReceiver = false; TestWatch.this.unregisterReceiver(mTimeZoneReceiver); } @Override public void onApplyWindowInsets(WindowInsets insets) { super.onApplyWindowInsets(insets); // Load resources that have alternate values for round watches. Resources resources = TestWatch.this.getResources(); boolean isRound = insets.isRound(); // for Date 時刻の上に表示 mXOffsetDate = resources.getDimension(isRound ? R.dimen.digital_x_offset_date_round : R.dimen.digital_x_offset_date); mYOffsetDate = resources.getDimension(R.dimen.digital_y_offset_date); float textSizeDate = resources.getDimension(isRound ? R.dimen.digital_text_size_date_round : R.dimen.digital_text_size_date); mTextPaintDate.setTextSize(textSizeDate); // for Time mXOffsetTime = resources.getDimension(isRound ? R.dimen.digital_x_offset_time_round : R.dimen.digital_x_offset_time); mXOffsetTimeAmbient = resources.getDimension(isRound ? R.dimen.digital_x_offset_time_round : R.dimen.digital_x_offset_time) + resources.getDimension(R.dimen.digital_x_offset_time_ambient_shift); mYOffsetTime = resources.getDimension(R.dimen.digital_y_offset_time); float textSizeTime = resources.getDimension(isRound ? R.dimen.digital_text_size_time_round : R.dimen.digital_text_size_time); mTextPaintTime.setTextSize(textSizeTime); // for Message mXOffsetMessage = resources.getDimension(isRound ? R.dimen.digital_x_offset_message_round : R.dimen.digital_x_offset_message); mYOffsetMessage = resources.getDimension(R.dimen.digital_y_offset_message); float textSizeMessage = resources.getDimension(isRound ? R.dimen.digital_text_size_message_round : R.dimen.digital_text_size_message); mTextPaintMessage.setTextSize(textSizeMessage); } @Override public void onPropertiesChanged(Bundle properties) { super.onPropertiesChanged(properties); mLowBitAmbient = properties.getBoolean(PROPERTY_LOW_BIT_AMBIENT, false); } @Override public void onTimeTick() { super.onTimeTick(); invalidate(); } @Override public void onAmbientModeChanged(boolean inAmbientMode) { super.onAmbientModeChanged(inAmbientMode); if (mAmbient != inAmbientMode) { mAmbient = inAmbientMode; if (mLowBitAmbient) { mTextPaintTime.setAntiAlias(!inAmbientMode); mTextPaintDate.setAntiAlias(!inAmbientMode); } invalidate(); } // Whether the timer should be running depends on whether we're visible (as well as // whether we're in ambient mode), so we may need to start or stop the timer. updateTimer(); } @Override public void onDraw(Canvas canvas, Rect bounds) { //draw the background. canvas.drawRect(0, 0, bounds.width(), bounds.height(), mBackgroundPaint); canvas.drawBitmap(mBackgroundBitmap, bounds.width() / 2 - mBackgroundBitmap.getWidth() / 2, 20, null); //draw date DateFormat format = android.text.format.DateFormat.getDateFormat(getApplicationContext()); canvas.drawText(format.format(new Date()), mXOffsetDate, mYOffsetDate, mTextPaintDate); //draw H:MM in ambient mode or H:MM:SS in interactive mode. mTime.setToNow(); //draw time String strTime = mAmbient ? String.format("%d:%02d", mTime.hour, mTime.minute) : String.format("%d:%02d:%02d", mTime.hour, mTime.minute, mTime.second); if (mAmbient) { canvas.drawText(strTime, mXOffsetTimeAmbient, mYOffsetTime, mTextPaintTime); } else { canvas.drawText(strTime, mXOffsetTime, mYOffsetTime, mTextPaintTime); } //draw message String strMessage = TestWatch.this.getSharedPreferences(WearConstants.PREFS, MODE_PRIVATE) .getString(WearConstants.PREFS_KEY_MESSAGE_TEXT, "REFRESH"); canvas.drawText(strMessage, mXOffsetMessage, mYOffsetMessage, mTextPaintMessage); } /** * Starts the {@link #mUpdateTimeHandler} timer if it should be running and isn't currently * or stops it if it shouldn't be running but currently is. */ private void updateTimer() { mUpdateTimeHandler.removeMessages(MSG_UPDATE_TIME); if (shouldTimerBeRunning()) { mUpdateTimeHandler.sendEmptyMessage(MSG_UPDATE_TIME); } } /** * Returns whether the {@link #mUpdateTimeHandler} timer should be running. The timer should * only run when we're visible and in interactive mode. */ private boolean shouldTimerBeRunning() { return isVisible() && !isInAmbientMode(); } /** * Handle updating the time periodically in interactive mode. */ private void handleUpdateTimeMessage() { invalidate(); if (shouldTimerBeRunning()) { long timeMs = System.currentTimeMillis(); long delayMs = INTERACTIVE_UPDATE_RATE_MS - (timeMs % INTERACTIVE_UPDATE_RATE_MS); mUpdateTimeHandler.sendEmptyMessageDelayed(MSG_UPDATE_TIME, delayMs); } } } private static class EngineHandler extends Handler { private final WeakReference<TestWatch.Engine> mWeakReference; public EngineHandler(TestWatch.Engine reference) { mWeakReference = new WeakReference<>(reference); } @Override public void handleMessage(Message msg) { TestWatch.Engine engine = mWeakReference.get(); if (engine != null) { switch (msg.what) { case MSG_UPDATE_TIME: engine.handleUpdateTimeMessage(); break; } } } } }
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="net.skyarch.testapp" > <uses-feature android:name="android.hardware.type.watch" /> <!-- Required to act as a custom watch face. --> <uses-permission android:name="com.google.android.permission.PROVIDE_BACKGROUND" /> <uses-permission android:name="android.permission.WAKE_LOCK" /> <application android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:theme="@android:style/Theme.DeviceDefault" > <meta-data android:name="com.google.android.gms.version" android:value="@integer/google_play_services_version" /> <service android:name=".DataLayerListenerService" > <intent-filter> <action android:name="com.google.android.gms.wearable.BIND_LISTENER" /> </intent-filter> </service> <service android:name=".TestWatch" android:label="@string/my_digital_name" android:permission="android.permission.BIND_WALLPAPER" > <meta-data android:name="android.service.wallpaper" android:resource="@xml/watch_face" /> <meta-data android:name="com.google.android.wearable.watchface.preview" android:resource="@drawable/preview_digital" /> <meta-data android:name="com.google.android.wearable.watchface.preview_circular" android:resource="@drawable/preview_digital_circular" /> <intent-filter> <action android:name="android.service.wallpaper.WallpaperService" /> <category android:name="com.google.android.wearable.watchface.category.WATCH_FACE" /> </intent-filter> </service> <meta-data android:name="com.google.android.gms.version" android:value="@integer/google_play_services_version" /> </application> </manifest>
res/values/strings.xml
<resources> <string name="app_name">TestApp</string> <string name="my_digital_name">SkyarchTest</string> </resources>
res/values/dimens.xml
<?xml version="1.0" encoding="utf-8"?> <resources> <dimen name="digital_text_size_date">10dp</dimen> <dimen name="digital_text_size_date_round">10dp</dimen> <dimen name="digital_text_size_time">20dp</dimen> <dimen name="digital_text_size_time_round">20dp</dimen> <dimen name="digital_text_size_message">40dp</dimen> <dimen name="digital_text_size_message_round">40dp</dimen> <dimen name="digital_x_offset_date">85dp</dimen> <dimen name="digital_x_offset_date_round">85dp</dimen> <dimen name="digital_y_offset_date">180dp</dimen> <dimen name="digital_x_offset_time">70dp</dimen> <dimen name="digital_x_offset_time_round">70dp</dimen> <dimen name="digital_x_offset_time_ambient_shift">13dp</dimen> <dimen name="digital_y_offset_time">200dp</dimen> <dimen name="digital_x_offset_message">75dp</dimen> <dimen name="digital_x_offset_message_round">75dp</dimen> <dimen name="digital_y_offset_message">130dp</dimen> </resources>
res/values/colors.xml
<?xml version="1.0" encoding="utf-8"?> <resources> <color name="digital_background">#000000</color> <color name="digital_text_date">#ffffff</color> <color name="digital_text_time">#ffffff</color> <color name="digital_text_message">#cc0000</color> </resources>
参考記事
Amazon API Gatewayの利用方法
http://dev.classmethod.jp/cloud/aws/api-gateway-with-android/
公式ページデータ転送方法について
https://developer.android.com/intl/ja/training/wearables/data-layer/messages.html
天気予報をAndroidWearのWatchFaceに表示
http://swarmnyc.com/whiteboard/how-to-design-and-develop-an-android-watch-face-app-wearables-overview/
Data APIを使ったデータ転送
https://sites.google.com/a/gclue.jp/android-docs/ni-yinkiwear/data-apiwo-shittadeta-zhuan-song
Android Wearのアプリの作り方
http://www.buildinsider.net/mobile/androidwear/02
ありがとうございました!