On 1st April 2016 I published Something O’Clock, a watch face app for Android Wear, to Play Store. The app is lighthearted in nature (because of the date of publication), it allows the user to set the time to “beer o’clock”, or “sleep o’clock”, or even “burger o’clock”. Although the app itself is quite lighthearted the code behind it is worthy of study and, in this series we’ll take a look at various aspects of developing custom watch faces for Android Wear. In this article we’ll look at handling configuration changes from the companion mobile app on the Wear device itself.
In the previous article we looked at how we can send configuration changes from the mobile companion app, yet the Wear app was blissfully unaware of those changes, simply because it isn’t yet listening for them. To do this we need to implement a subclass of WearableListenerService which will listen for the DataItem that we’re sending from DataClient in the mobile app. The suffix ‘Service’ on WearableListenerService should give a pretty strong hint that this is an Android Service that we’re implementing, so the first thing that we will need to do is register this in the manifest of the wear app:
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" package="com.stylingandroid.something.oclock"> <uses-feature android:name="android.hardware.type.watch" /> <!-- Required to act as a custom watch face. --> <uses-permission android:name="android.permission.WAKE_LOCK" /> <application android:allowBackup="false" android:fullBackupOnly="false" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:supportsRtl="true" android:theme="@android:style/Theme.DeviceDefault" tools:ignore="GoogleAppIndexingWarning"> <meta-data android:name="com.google.android.gms.version" android:value="@integer/google_play_services_version" /> <service android:name="com.stylingandroid.something.oclock.SomethingOClockFace" android:allowEmbedded="true" android:label="@string/face_name" android:permission="android.permission.BIND_WALLPAPER" android:taskAffinity=""> <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" /> <meta-data android:name="com.google.android.wearable.watchface.companionConfigurationAction" android:value="com.stylingandroid.customoclock.CONFIGURATION" /> <intent-filter> <action android:name="android.service.wallpaper.WallpaperService" /> <category android:name="com.google.android.wearable.watchface.category.WATCH_FACE" /> </intent-filter> </service> <service android:name="com.stylingandroid.something.oclock.DataListenerService" android:exported="true" tools:ignore="ExportedService"> <intent-filter> <action android:name="com.google.android.gms.wearable.DATA_CHANGED" /> <data android:host="*" android:path="/mobile" android:scheme="wear" /> </intent-filter> </service> </application> </manifest>
The intent filter is quite interesting. The action
is one which is defined in the DataApi within the GMS wearable library, but the data
allows us to only listen to specific data – so we could have separate listeners listening for changes to different pieces of data all within the same app. In our case we only have the one listener which listens on <scheme>://<host>/<path>
, or specifically wear://*/mobile
. The scheme is fixed and defines the GMS Data API transport being used; we’re using a wildcard to listen to data from any connected host device, and the path of “/mobile” matches the path that we used for our PutDataRequest in DataClient in the mobile companion app:
public class DataClient { . . . private void sendData(ResultCallback<DataApi.DataItemResult> callback) { PutDataMapRequest putDataMapRequest = PutDataMapRequest.create(CommonData.PATH_WORDS); putDataMapRequest.setUrgent(); DataMap dataMap = putDataMapRequest.getDataMap(); dataMap.putString(CommonData.KEY_WORD, word); dataMap.putStringArrayList(CommonData.KEY_WORDS, words); PutDataRequest putDataRequest = putDataMapRequest.asPutDataRequest(); PendingResult<DataApi.DataItemResult> pendingResult = Wearable.DataApi.putDataItem(googleApiClient, putDataRequest); pendingResult.setResultCallback(callback); } }
public final class CommonData { public static final String KEY_WORD = "word"; public static final String KEY_WORDS = "words"; public static final String PATH_WORDS = "/mobile"; private CommonData() { //NO-OP } public static String[] getTimeStrings(Context context) { Resources resources = context.getResources(); return resources.getStringArray(R.array.default_words); } }
The actual DataListenerService itself is pretty straightforward:
public class DataListenerService extends WearableListenerService { private static final String TAG = "DataListenerService"; @Override public void onDataChanged(DataEventBuffer dataEvents) { final List<DataEvent> events = FreezableUtils.freezeIterable(dataEvents); GoogleApiClient googleApiClient = new GoogleApiClient.Builder(this) .addApi(Wearable.API) .build(); ConnectionResult connectionResult = googleApiClient.blockingConnect(); if (!connectionResult.isSuccess()) { Log.e(TAG, "Error connecting to GoogleApiClient: " + connectionResult.getErrorMessage()); return; } LocalDataMap localDataMap = LocalDataMap.newInstance(this, googleApiClient); for (DataEvent dataEvent : events) { DataItem dataItem = dataEvent.getDataItem(); parseDataItem(localDataMap, dataItem); } } private void parseDataItem(LocalDataMap localDataMap, DataItem dataItem) { Uri uri = dataItem.getUri(); if (uri.getPath().equals(CommonData.PATH_WORDS)) { parseWords(localDataMap, dataItem); } } private void parseWords(LocalDataMap localDataMap, DataItem dataItem) { DataMap newDataMap = LocalDataMap.dataMapFrom(dataItem); String word = newDataMap.getString(CommonData.KEY_WORD); localDataMap.overwriteConfig(word, new ResultCallback<DataApi.DataItemResult>() { @Override public void onResult(@NonNull DataApi.DataItemResult dataItemResult) { } }); } }
When data matched by the intent filter is received then our onDataChanged()
method will be invoked. In here we grab a handle to the GoogleApiClient instance and we actually do so using a blocking connection call – in other words we’re connecting synchronously. This is usually best avoided (and we definitely used asynchronous connection in the mobile companion app) but there are some valid reasons why we can safely make a blocking call here. This method is actually being invoked from GMS so there will already be an instance of GoogleApiClient which is actually responsible to invoking us in the first place. Therefore we’re not going to have to wait for a new instance be be instantiated, so we can be sure that this blocking connection call is actually going to return pretty quickly.
Another reason why we made a non-blocking call in the companion app was to enable GMS to check that the required GMS components were installed, and it could prompt the user if they weren’t. In this case the required component (Data API) is the very component which is invoking our DataListenerService so we really don’t need to worry that it’s not going to be available.
Once we have a handle to the GoogleApiClient we parse the data in to one or more DataItems, and then parse the actual data from those. However, the eagle eyed will have noticed this thing named LocalDataMap that we’re storing the data to, and this is going to add a little complexity to proceedings. So what is it and why we do need it?
Let’s first understand our requirements. In simple terms, for now, we simply want to respond to configuration changes from the mobile companion app. So we could actually get away with triggering changes to the UI from within our DataListenerService implementation. However, we’ve already hinted at potential future requirements such as changing the text of the list items. Another potential addition is having a configuration companion on the Wear device as well as the existing one we have on the mobile device. In this case it is obvious that we need to have shared data which both companion apps update independently of each other. When we consider this approach then we need to envisage our DataListenerService as a local proxy for the mobile companion app. In other words DataListenerService is responsible for updating the local data in response to changes from the mobile companion app. This local data is represented by LocalDataMap.
LocalDataMap is actually wrapping the GMS Wearable Data API which is used for the local storage, so on the surface it appears to be doing a similar job to our DataListenerService. But actually it serves a rather different purpose so looking at the implementation without understanding that can make things a little confusing.
In the next article in this series we’ll look at LocalDataMap and see how we use that to persist data locally on the wear device.
The additions to the source code in this article really don’t add much in terms of functionality without the components which are coming in the next article, so I’ll defer publishing the source until then.
Many thanks to Daniele Bonaldo, Sebastiano Poggi, Erik Hellman, Hasan Hosgel, Said Tahsin Dane & Murat Yener – my beta testers.
© 2016, Mark Allison. All rights reserved.
Copyright © 2016 Styling Android. All Rights Reserved.
Information about how to reuse or republish this work may be available at http://blog.stylingandroid.com/license-information.