Android Wear / Data API / MobileCompanion / WatchFace

Something O’Clock – Part 5

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 consider storing the current configuration within the wear app.

round_wear_screenshotPreviously we looked at how the wear app receives the configuration changes made in the mobile companion app, and this component was effectively acting as the local proxy for the mobile companion app. The next thing that we need to consider is how to store the current configuration within the wear app. The mechanism that we’ll use for doing this is the same DataApi that we used to marshall the configuration changes – we’ll just use a different path within the URI that we use so that it is distinct from the remote data. For the remote transfer we use the URI wear://*/mobile, but for the local storage we’ll use wear://<actual host id>/wear instead. Let’s take a look at LocalDataMap which is responsible for this:

public class LocalDataMap {

    public static final String PATH_WORDS = "/wear";

    private final GoogleApiClient googleApiClient;

    private final LocalUriFetcher localUriFetcher;
    private final MissingDataPopulator missingDataPopulator;

    public static LocalDataMap newInstance(Context context, GoogleApiClient googleApiClient) {
        LocalUriFetcher localUriFetcher = new LocalUriFetcher(googleApiClient);
        MissingDataPopulator missingDataPopulator = new MissingDataPopulator(context);
        return new LocalDataMap(googleApiClient, localUriFetcher, missingDataPopulator);
    }

    public LocalDataMap(GoogleApiClient googleApiClient, LocalUriFetcher localUriFetcher, MissingDataPopulator missingDataPopulator) {
        this.googleApiClient = googleApiClient;
        this.localUriFetcher = localUriFetcher;
        this.missingDataPopulator = missingDataPopulator;
    }

    public static DataMap dataMapFrom(DataItem dataItem) {
        DataMapItem dataMapItem = DataMapItem.fromDataItem(dataItem);
        return dataMapItem.getDataMap();
    }

    public void overwriteConfig(final String word, final ResultCallback<DataApi.DataItemResult> callback) {
        fetchConfig(new ResultCallback<DataMapResult>() {
            @Override
            public void onResult(@NonNull DataMapResult dataMapResult) {
                if (dataMapResult.getStatus().isSuccess()) {
                    DataMap currentDataMap = dataMapResult.getDataMap();
                    DataMap newDataMap = new DataMap();
                    newDataMap.putAll(currentDataMap);
                    newDataMap.putString(CommonData.KEY_WORD, word);
                    missingDataPopulator.populateMissingKeys(newDataMap);
                    writeConfig(newDataMap, callback);
                }
            }
        });
    }

    public void fetchConfig(final ResultCallback callback) {
        getLocalStorageUri(new ResultCallback() {
            @Override
            public void onResult(@NonNull UriResult uriResult) {
                Uri uri = uriResult.getUri();
                ResultCallback populator = missingDataPopulator.getPopulator(LocalDataMap.this, callback);
                Wearable.DataApi.getDataItem(googleApiClient, uri).setResultCallback(populator);
            }
        });
    }

    public void getLocalStorageUri(ResultCallback callback) {
        localUriFetcher.getLocalStorageUri(PATH_WORDS, callback);
    }

    public void writeConfig(DataMap dataMap, ResultCallback callback) {
        PutDataMapRequest putDataMapRequest = PutDataMapRequest.create(PATH_WORDS);
        putDataMapRequest.setUrgent();
        putDataMapRequest.getDataMap().putAll(dataMap);
        Wearable.DataApi.putDataItem(googleApiClient, putDataMapRequest.asPutDataRequest())
                .setResultCallback(callback);
    }
}

The main method that we call from our DataListenerService is overwriteConfig() which is responsible for applying changes. All of the calls are asynchronous so we just chain then up. First we need to fetch the existing data, then we write the new settings to this DataMap. We then make sure that we populate any missing values before finally writing the updated data.

Some ancillary classes are required by all of this. The LocalUriFetcher class is used to build the a URI for the local device with the local device id used in the host of the Uri to ensure that we only use local data:

class LocalUriFetcher {

    private static final Status SUCCESS = new Status(0);

    private final GoogleApiClient googleApiClient;

    public LocalUriFetcher(GoogleApiClient googleApiClient) {
        this.googleApiClient = googleApiClient;
    }

    public void getLocalStorageUri(final String path, final ResultCallback<UriResult> callback) {
        Wearable.NodeApi.getLocalNode(googleApiClient).setResultCallback(new ResultCallback<NodeApi.GetLocalNodeResult>() {
            @Override
            public void onResult(@NonNull NodeApi.GetLocalNodeResult getLocalNodeResult) {
                String localNode = getLocalNodeResult.getNode().getId();
                Uri uri = new Uri.Builder()
                        .scheme("wear")
                        .path(path)
                        .authority(localNode)
                        .build();
                callback.onResult(new LocalStorageUriResult(uri));
            }
        });
    }

    private class LocalStorageUriResult implements UriResult {
        private final Uri uri;

        LocalStorageUriResult(Uri uri) {
            this.uri = uri;
        }

        @Override
        public Uri getUri() {
            return uri;
        }

        @Override
        public Status getStatus() {
            return SUCCESS;
        }
    }
}

Next we have MissingDataPopulator which is responsible for filling in any missing data from a DataMap. There are two main use-cases where we need to do this: first before we save the data we want to fill in any missing fields, and this can be done using a synchronous call:

class MissingDataPopulator {

    private final Context context;

    public MissingDataPopulator(Context context) {
        this.context = context;
    }

    public boolean populateMissingKeys(@NonNull DataMap dataMap) {
        boolean shouldSave = false;
        if (!dataMap.containsKey(CommonData.KEY_WORDS)) {
            String[] wordsArray = context.getResources().getStringArray(R.array.default_words);
            ArrayList<String> words = new ArrayList<>(Arrays.asList(wordsArray));
            dataMap.putStringArrayList(CommonData.KEY_WORDS, words);
            shouldSave = true;
        }
        if (!dataMap.containsKey(CommonData.KEY_WORD)) {
            List<String> words = dataMap.getStringArrayList(CommonData.KEY_WORDS);
            String defaultWord = words.get(0);
            dataMap.putString(CommonData.KEY_WORD, defaultWord);
            shouldSave = true;
        }
        return shouldSave;
    }
    .
    .
    .
}

If any data is missing then we populate it from the resources we created in the common module.

The second, slightly more complex use-case is when we load data we want to ensure that we add any missing data before passing it back to the caller. We do this in LocalDataMap by adding an additional step in the chain:

    public void fetchConfig(final ResultCallback<DataMapResult> callback) {
        getLocalStorageUri(new ResultCallback<UriResult>() {
            @Override
            public void onResult(@NonNull UriResult uriResult) {
                Uri uri = uriResult.getUri();
                ResultCallback<DataApi.DataItemResult> populator = missingDataPopulator.getPopulator(LocalDataMap.this, callback);
                Wearable.DataApi.getDataItem(googleApiClient, uri).setResultCallback(populator);
            }
        });
    }

This retrieves the raw data, then passes it to populator (which populates any missing items), and when populator completes it will call the original callback. The populator implementation is also part of MissingDataPopulator:

class MissingDataPopulator {

    private static final Status SUCCESS = new Status(0);
    .
    .
    .
    public ResultCallback<DataApi.DataItemResult> getPopulator(LocalDataMap localDataMap, ResultCallback<DataMapResult> callback) {
        return new Populator(localDataMap, callback);
    }

    private final class Populator implements ResultCallback<DataApi.DataItemResult> {
        private final LocalDataMap localDataMap;
        private final ResultCallback<DataMapResult> callback;

        private Populator(LocalDataMap localDataMap, ResultCallback<DataMapResult> callback) {
            this.localDataMap = localDataMap;
            this.callback = callback;
        }

        @Override
        public void onResult(@NonNull DataApi.DataItemResult dataItemResult) {
            final DataMap dataMap;
            if (hasConfig(dataItemResult)) {
                DataItem dataItem = dataItemResult.getDataItem();
                dataMap = LocalDataMap.dataMapFrom(dataItem);
            } else {
                dataMap = new DataMap();
            }
            if (populateMissingKeys(dataMap)) {
                localDataMap.writeConfig(dataMap, new ResultCallback<DataApi.DataItemResult>() {
                    @Override
                    public void onResult(@NonNull DataApi.DataItemResult dataItemResult) {
                        callback.onResult(new PopulatorResult(dataMap));
                    }
                });
                return;
            }
            callback.onResult(new PopulatorResult(dataMap));
        }

        private boolean hasConfig(@NonNull DataApi.DataItemResult dataItemResult) {
            return dataItemResult.getStatus().isSuccess() && dataItemResult.getDataItem() != null;
        }

        private final class PopulatorResult implements DataMapResult {
            private final DataMap dataMap;

            private PopulatorResult(DataMap dataMap) {
                this.dataMap = dataMap;
            }

            @Override
            public DataMap getDataMap() {
                return dataMap;
            }

            @Override
            public Status getStatus() {
                return SUCCESS;
            }

        }
    }
}

So if the actual data has been loaded then it will be used, otherwise we create an empty DataMap. Then we use the same populateMissingKeys() method we looked at previously to populate any missing data from the resources in the common module. If we have actually overridden anything then it will be saved before we make the callback with the loaded & populated DataMap. The nice thing that this gives us is that whenever we call fetchConfig() on LocalDataMap we know that we’ll receive valid data.

So now we are storing our data locally all that remains is to go back to where we started and hook it up the the watch-face UI, and we’ll look at this in the concluding article to this series.

Although we don’t yet have it hooked up to the UI, the source will actually compile and is available here.

Many thanks to Daniele Bonaldo, Sebastiano Poggi, Erik Hellman, Hasan Hosgel, Said Tahsin Dane & Murat Yener – my beta testers.

Get it on Google Play

© 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.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.