Creating a watch face with Android Wear API | Part 2

Creating a watch face with Android Wear API | Part 2

In the first part of the tutorial we've covered the basics of creating a digital Android Wear watchface. In this part we will see how one can add a settings panel for her watchface in the Android Wear mobile application. By the end of this article you will be able to control the background colour and the time and date colours of your watchface from inside the Android Wear application on your mobile device.


In order to establish a communication channel between the watch and the mobile device, you must use the Wearable Data Layer API which is part of Google Play services. As the official documentation states:

Because these APIs are designed for communication between handhelds and wearables, these are the only APIs you should use to set up communication between these devices. For instance, don't try to open low-level sockets to create a communication channel.

With the help of the Wearable Data Layer API you can send accross your devices multiple types of objects:

  • DataItems - provides data storage with automatic syncing
  • Messages - used mainly for remote procedure calls and one way requests
  • Assets - used for sending binary blobs of data

For our communication channel we will be using the DataApi to send DataItems from the mobile device to the data layer while the watchface will be listening for any modifications.

Creating the mobile settings activity

As we have seen above, the API we will be using is part of Google Play services, so you will have to specify a meta-data entry in your mobile/src/main/AndroidManifest.xml file under the <application> tag:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
  package="com.catinean.simpleandroidwatchface">

  ...

  <application
    android:allowBackup="true"
    android:label="@string/app_name"
    android:icon="@mipmap/ic_launcher"
    android:theme="@style/AppTheme">

    <meta-data
      android:name="com.google.android.gms.version"
      android:value="@integer/google_play_services_version" />

  </application>

</manifest>

Next, create an empty activity in mobile/src/main/java/your_package that will serve as the settings activity for your watchface. Let's call it SimpleWatchFaceConfigurationActivity. In order the activity to be perceived as a settings activity for your watch face, you will have to specify an <intent-filter> in your mobile AndroidManifest.xml:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
  package="com.catinean.simpleandroidwatchface">

  ...

  <application
    android:allowBackup="true"
    android:label="@string/app_name"
    android:icon="@mipmap/ic_launcher"
    android:theme="@style/AppTheme">

    <activity
      android:name="com.catinean.simpleandroidwatchface.SimpleWatchFaceConfigurationActivity"
      android:label="@string/app_name"
      android:theme="@style/AppTheme">
      <intent-filter>
        <action android:name="com.catinean.simpleandroidwatchface.CONFIG_DIGITAL" />
        <category android:name="com.google.android.wearable.watchface.category.COMPANION_CONFIGURATION" />
        <category android:name="android.intent.category.DEFAULT" />
      </intent-filter>
    </activity>

    <meta-data
      android:name="com.google.android.gms.version"
      android:value="@integer/google_play_services_version" />
  </application>
</manifest>

As you can see, you have to specify a custom <action> for your intent filter alongside the categories. In our case, the action name is formed out of the package name and a config string: com.catinean.simpleandroidwatchface.CONFIG_DIGITAL.

On the wear module side, you will have to add an additional <meta-data> field to your previously created <service> entry in the AndroidManifest.xml file:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
  package="com.catinean.simpleandroidwatchface">

  ...

  <application
    android:allowBackup="true"
    android:label="@string/app_name"
    android:icon="@mipmap/ic_launcher"
    android:theme="@android:style/Theme.DeviceDefault">

    <service
      android:name=".SimpleWatchFaceService"
      android:label="@string/app_name"
      android:permission="android.permission.BIND_WALLPAPER">
      
      ...

      <meta-data
        android:name="com.google.android.wearable.watchface.companionConfigurationAction"
        android:value="com.catinean.simpleandroidwatchface.CONFIG_DIGITAL" />

      <intent-filter>
        <action android:name="android.service.wallpaper.WallpaperService" />
        <category android:name="com.google.android.wearable.watchface.category.WATCH_FACE" />
      </intent-filter>
    </service>
  </application>
</manifest>

See how the meta data value corresponds with the action name of our activity.

For now, our activity is empty. Let's populate it with two entries in order the user to be able to configure the watchface background colour and the date and time colours. It's layout will be simple, formed of a LinearLayout with two elements (one for the background colour and one for the date and time colours). The mobile/src/main/res/layout/activity_configuration.xml will look like this:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_height="match_parent"
  android:layout_width="match_parent">

  <android.support.v7.widget.Toolbar
    android:id="@+id/toolbar"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:minHeight="?attr/actionBarSize"
    style="@style/MyActionBarStyle" />

  <LinearLayout
    android:layout_below="@+id/toolbar"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:divider="@drawable/delimiter"
    android:showDividers="middle"
    android:paddingStart="16dp"
    android:paddingEnd="16dp">

    <RelativeLayout
      android:id="@+id/configuration_background_colour"
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      android:background="@drawable/selector_preference_background"
      android:paddingTop="16dp"
      android:paddingBottom="16dp">

      <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentStart="true"
        android:textColor="@android:color/black"
        android:textSize="18sp"
        android:text="@string/background_colour" />

      <View
        android:id="@+id/configuration_background_colour_preview"
        android:layout_width="30dp"
        android:layout_height="30dp"
        android:layout_alignParentEnd="true" />

    </RelativeLayout>

    <RelativeLayout
      android:id="@+id/configuration_time_colour"
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      android:background="@drawable/selector_preference_background"
      android:paddingTop="16dp"
      android:paddingBottom="16dp">

      <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentStart="true"
        android:textSize="18sp"
        android:textColor="@android:color/black"
        android:text="@string/date_and_time_colour" />

      <View
        android:id="@+id/configuration_date_and_time_colour_preview"
        android:layout_width="30dp"
        android:layout_height="30dp"
        android:layout_alignParentEnd="true" />

    </RelativeLayout>
  </LinearLayout>
</RelativeLayout>

The toolbar element will act as an action bar for our activity (here you can find more information about toolbar). Below the toolbar we have our vertical LinearLayout that contains two RelativeLayouts as the rows (each of them containing a TextView and a preview colour represented by a simple View).

Going back to our activity, we would want to display a dialog with colour names for each of the elements. For this, we will create a simple ColourChooserDialog that will contain a simple list of colours and will be displayed whenever the user clicks on an element from the activity.

The ColourChooserDialog will look like this:

package com.catinean.simpleandroidwatchface;

import android.app.Activity;
import android.app.AlertDialog;
import android.app.Dialog;
import android.app.DialogFragment;
import android.content.DialogInterface;
import android.os.Bundle;

public class ColourChooserDialog extends DialogFragment {

    private static final String ARG_TITLE = "ARG_TITLE";
    private Listener colourSelectedListener;

    public static ColourChooserDialog newInstance(String dialogTitle) {
        Bundle arguments = new Bundle();
        arguments.putString(ARG_TITLE, dialogTitle);
        ColourChooserDialog dialog = new ColourChooserDialog();
        dialog.setArguments(arguments);
        return dialog;
    }

    @Override
    public void onAttach(Activity activity) {
        super.onAttach(activity);
        colourSelectedListener = (Listener) activity;
    }

    @Override
    public Dialog onCreateDialog(Bundle savedInstanceState) {
        String title = getArguments().getString(ARG_TITLE);
        AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
        builder.setTitle(title)
                .setItems(R.array.colors_array, new DialogInterface.OnClickListener() {
                    public void onClick(DialogInterface dialog, int which) {
                        String[] colours = getResources().getStringArray(R.array.colors_array);
                        colourSelectedListener.onColourSelected(colours[which], getTag());
                    }
                });
        return builder.create();
    }

    interface Listener {
        void onColourSelected(String colour, String tag);
    }
}

You can see that it is a simple DialogFragment that displays a list AlertDialog backed by a string array R.array.colors_array (for more information about dialogs in general, you can read here the official documentation).

The string array is just a resource inside mobile/src/main/res/values/arrays.xml:

<?xml version="1.0" encoding="utf-8"?>
<resources>
  <string-array name="colors_array">
    <item name="black">Black</item>
    <item name="white">White</item>
    <item name="red">Red</item>
    <item name="green">Green</item>
    <item name="cyan">Cyan</item>
    <item name="magenta">Magenta</item>
  </string-array>
</resources>

The dialog provides an interface in order to notify the activity of the chosen colour. Since we will have multiple dialogs created (one for the background colour and one for the date and time colours) we will have to differentiate them by their tag.

Our SimpleWatchFaceConfigurationActivity will look like this:

public class SimpleWatchFaceConfigurationActivity extends ActionBarActivity implements ColourChooserDialog.Listener {

    private static final String TAG_BACKGROUND_COLOUR_CHOOSER = "background_chooser";
    private static final String TAG_DATE_AND_TIME_COLOUR_CHOOSER = "date_time_chooser";

    private View backgroundColourImagePreview;
    private View dateAndTimeColourImagePreview;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_configuration);

        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
        getSupportActionBar().setDisplayHomeAsUpEnabled(true);

        findViewById(R.id.configuration_background_colour).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                ColourChooserDialog.newInstance(getString(R.string.pick_background_colour))
                        .show(getFragmentManager(), TAG_BACKGROUND_COLOUR_CHOOSER);
            }
        });

        findViewById(R.id.configuration_time_colour).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                ColourChooserDialog.newInstance(getString(R.string.pick_date_time_colour))
                        .show(getFragmentManager(), TAG_DATE_AND_TIME_COLOUR_CHOOSER);
            }
        });

        backgroundColourImagePreview = findViewById(R.id.configuration_background_colour_preview);
        dateAndTimeColourImagePreview = findViewById(R.id.configuration_date_and_time_colour_preview);

    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        if (item.getItemId() == android.R.id.home) {
            finish();
            return true;
        }

        return super.onOptionsItemSelected(item);
    }

    @Override
    public void onColourSelected(String colour, String tag) {

        if (TAG_BACKGROUND_COLOUR_CHOOSER.equals(tag)) {
            backgroundColourImagePreview.setBackgroundColor(Color.parseColor(colour));
        } else {
            dateAndTimeColourImagePreview.setBackgroundColor(Color.parseColor(colour));
        }
    }
}

If now you run your wear module on your wear device and then the mobile module on the mobile device, navigate to the Android Wear application, you will be able to access the settings activity.

Settings Activity

Sending data to the Data Layer API

Now that we are able to capture the chosen colour, it is time to see how we can send it to the Data Layer API. You will have to use the GoogleApiClient class in order to connect and to send the data. In onCreate() of our activity we create the object, in onStart() we connect to the data layer and in onStop() we disconnect. Our enhanced activity will look like this:

public class SimpleWatchFaceConfigurationActivity extends ActionBarActivity implements ColourChooserDialog.Listener,
        GoogleApiClient.ConnectionCallbacks,
        GoogleApiClient.OnConnectionFailedListener {

    ... 
    private static final String TAG = "SimpleWatchface";
    
    private GoogleApiClient googleApiClient;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
       
       ...

        googleApiClient = new GoogleApiClient.Builder(this)
                .addConnectionCallbacks(this)
                .addOnConnectionFailedListener(this)
                .addApi(Wearable.API)
                .build();

        ...
    }

    @Override
    protected void onStart() {
        super.onStart();
        googleApiClient.connect();
    }

   ...

    @Override
    public void onConnected(Bundle bundle) {
        Log.d(TAG, "onConnected");
    }

    @Override
    public void onConnectionSuspended(int i) {
        Log.e(TAG, "onConnectionSuspended");
    }

    @Override
    public void onConnectionFailed(ConnectionResult connectionResult) {
        Log.e(TAG, "onConnectionFailed");
    }

    @Override
    protected void onStop() {
        if (googleApiClient != null && googleApiClient.isConnected()) {
            googleApiClient.disconnect();
        }
        super.onStop();
    }
}

In order to be sure that a connection is made, I added some logs in the corresponding callbacks.

A synchronization to the data layer consists of two elements:

  • a payload : the actual data to send
  • a path : an unique identifier for the data item you want to send (the path must begin with a forward slash, e.g. "/simple_watch_face_config")

We will send the data with the help of the PutDataMapRequest that provides us a key-value alike behaviour. Our onColourSelected(String colour, String tag) method will look like this:

@Override
public void onColourSelected(String colour, String tag) {
     PutDataMapRequest putDataMapReq = PutDataMapRequest.create("/simple_watch_face_config");

     if (TAG_BACKGROUND_COLOUR_CHOOSER.equals(tag)) {
         backgroundColourImagePreview.setBackgroundColor(Color.parseColor(colour));
         putDataMapReq.getDataMap().putString("KEY_BACKGROUND_COLOUR", colour);
     } else {
         dateAndTimeColourImagePreview.setBackgroundColor(Color.parseColor(colour));
         putDataMapReq.getDataMap().putString("KEY_DATE_TIME_COLOUR", colour);
     }

     PutDataRequest putDataReq = putDataMapReq.asPutDataRequest();
     Wearable.DataApi.putDataItem(googleApiClient, putDataReq);
}

You can see how a PutDataRequest is created on the specific path and every time we receive a colour we populate the map with it at a specific key. In the end, we send the request with the help of Wearable.DataApi.putDataItem(googleApiClient, putDataReq) method.

Now that we are able to send the colours, we have to handle them into the wear module, specifically in the previously created SimpleWatchFaceService.

Handling the received configuration in the SimpleWatchFaceService

Back in the wear module, we have to handle the configuration sent by the previously created SimpleWatchFaceConfigurationActivity in the SimpleWatchFaceService.

As we did in the configuration activity, in order to synchronize with the data layer API, we have to firstly connect to it through a GoogleApiClient object. We'll start by connecting to the API when the watchface is visible and disconnecting when not. Your SimpleWatchFaceService will look like this:

public class SimpleWatchFaceService extends CanvasWatchFaceService {

    ...

    private class SimpleEngine extends CanvasWatchFaceService.Engine implements
            GoogleApiClient.ConnectionCallbacks, GoogleApiClient.OnConnectionFailedListener {

        ...

        private GoogleApiClient googleApiClient;

        @Override
        public void onCreate(SurfaceHolder holder) {
            ...
            googleApiClient = new GoogleApiClient.Builder(SimpleWatchFaceService.this)
                    .addApi(Wearable.API)
                    .addConnectionCallbacks(this)
                    .addOnConnectionFailedListener(this)
                    .build();
        }

        ...

        @Override
        public void onVisibilityChanged(boolean visible) {
            super.onVisibilityChanged(visible);
            if (visible) {
                ...
                googleApiClient.connect();
            } else {
                ...
                releaseGoogleApiClient();
            }

            ...
        }

        private void releaseGoogleApiClient() {
            if (googleApiClient != null && googleApiClient.isConnected()) {
                googleApiClient.disconnect();
            }
        }

        ...

        @Override
        public void onConnected(Bundle bundle) {
            Log.d(TAG, "connected GoogleAPI");
        }

        @Override
        public void onConnectionSuspended(int i) {
            Log.e(TAG, "suspended GoogleAPI");
        }

        @Override
        public void onConnectionFailed(ConnectionResult connectionResult) {
            Log.e(TAG, "connectionFailed GoogleAPI");
        }

        @Override
        public void onDestroy() {
            ...
            releaseGoogleApiClient();

            super.onDestroy();
        }
    }
}

We create a GoogleApiClient object in onCreate() method of the SimpleEngine and connect when the watch face becomes visible and relase the client when the watch face is not visible anymore.

Next, we actually want to be notified when the background colour and the date and time colour values are changed in the mobile activity and every time the watch face connects to the data layer. In order to achieve this, we will have to use:

  • the DataApi.DataListener - this will get notified every time we change something in the data layer
  • a ResultCallback - this will notify us every time the googleApiClient connects
public class SimpleWatchFaceService extends CanvasWatchFaceService {

    ...

    private class SimpleEngine extends CanvasWatchFaceService.Engine implements
            GoogleApiClient.ConnectionCallbacks, GoogleApiClient.OnConnectionFailedListener {

        ...

        private GoogleApiClient googleApiClient;

        @Override
        public void onCreate(SurfaceHolder holder) {
            ...
            googleApiClient = new GoogleApiClient.Builder(SimpleWatchFaceService.this)
                    .addApi(Wearable.API)
                    .addConnectionCallbacks(this)
                    .addOnConnectionFailedListener(this)
                    .build();
        }

        ...

        @Override
        public void onVisibilityChanged(boolean visible) {
            super.onVisibilityChanged(visible);
            if (visible) {
                ...
                googleApiClient.connect();
            } else {
                ...
                releaseGoogleApiClient();
            }

            ...
        }

        private void releaseGoogleApiClient() {
            if (googleApiClient != null && googleApiClient.isConnected()) {
                Wearable.DataApi.removeListener(googleApiClient, onDataChangedListener);
                googleApiClient.disconnect();
            }
        }

        ...

        @Override
        public void onConnected(Bundle bundle) {
            Log.d(TAG, "connected GoogleAPI");
            Wearable.DataApi.addListener(googleApiClient, onDataChangedListener);
            Wearable.DataApi.getDataItems(googleApiClient).setResultCallback(onConnectedResultCallback);
        }

        private final DataApi.DataListener onDataChangedListener = new DataApi.DataListener() {
            @Override
            public void onDataChanged(DataEventBuffer dataEvents) {
                for (DataEvent event : dataEvents) {
                    if (event.getType() == DataEvent.TYPE_CHANGED) {
                        DataItem item = event.getDataItem();
                        processConfigurationFor(item);
                    }
                }

                dataEvents.release();
                invalidateIfNecessary();
            }
        };

        private void processConfigurationFor(DataItem item) {
            if ("/simple_watch_face_config".equals(item.getUri().getPath())) {
                DataMap dataMap = DataMapItem.fromDataItem(item).getDataMap();
                if (dataMap.containsKey("KEY_BACKGROUND_COLOUR")) {
                    String backgroundColour = dataMap.getString("KEY_BACKGROUND_COLOUR");
                    watchFace.updateBackgroundColourTo(Color.parseColor(backgroundColour));
                }

                if (dataMap.containsKey("KEY_DATE_TIME_COLOUR")) {
                    String timeColour = dataMap.getString("KEY_DATE_TIME_COLOUR");
                    watchFace.updateDateAndTimeColourTo(Color.parseColor(timeColour));
                }
            }
        }

        private final ResultCallback<DataItemBuffer> onConnectedResultCallback = new ResultCallback<DataItemBuffer>() {
            @Override
            public void onResult(DataItemBuffer dataItems) {
                for (DataItem item : dataItems) {
                    processConfigurationFor(item);
                }

                dataItems.release();
                invalidateIfNecessary();
            }
        };

        @Override
        public void onConnectionSuspended(int i) {
            Log.e(TAG, "suspended GoogleAPI");
        }

        @Override
        public void onConnectionFailed(ConnectionResult connectionResult) {
            Log.e(TAG, "connectionFailed GoogleAPI");
        }

        @Override
        public void onDestroy() {
            ...
            releaseGoogleApiClient();

            super.onDestroy();
        }
    }
}

We can see that every time the watch face is visible we connect to the data layer and once connected we add the two listeners. onDataChangedListener will get notified every time there is a change in the data layer and onConnectedResultCallback is only notified when the service is firstly connected. In both cases we want to process the received DataItem - processConfigurationFor(DataItem). While processing the itmes we use the path (/simple_watch_face_config) and the keys associated with every item (we defined the keys in the SimpleWatchFaceConfigurationActivity) in order to get hold of the sent values. Once the items are identified, we pass them to the SimpleWatchFace to update the colours:

  • updateBackgroundColourTo(int colour): responsible with updating the background colour
  • updateDateAndTimeColourTo(int colour): responsible with updating the data and time colour
public class SimpleWatchFace {
    ...

    private static final int DATE_AND_TIME_DEFAULT_COLOUR = Color.WHITE;
    private static final int BACKGROUND_DEFAULT_COLOUR = Color.BLACK;

    private int backgroundColour = BACKGROUND_DEFAULT_COLOUR;
    private int dateAndTimeColour = DATE_AND_TIME_DEFAULT_COLOUR;

    public static SimpleWatchFace newInstance(Context context) {
        Paint timePaint = new Paint();
        timePaint.setColor(DATE_AND_TIME_DEFAULT_COLOUR);
        timePaint.setTextSize(context.getResources().getDimension(R.dimen.time_size));
        timePaint.setAntiAlias(true);

        Paint datePaint = new Paint();
        datePaint.setColor(DATE_AND_TIME_DEFAULT_COLOUR);
        datePaint.setTextSize(context.getResources().getDimension(R.dimen.date_size));
        datePaint.setAntiAlias(true);

        Paint backgroundPaint = new Paint();
        backgroundPaint.setColor(BACKGROUND_DEFAULT_COLOUR);

        return new SimpleWatchFace(timePaint, datePaint, backgroundPaint, new Time());
    }

    SimpleWatchFace(Paint timePaint, Paint datePaint, Paint backgroundPaint, Time time) {
        this.timePaint = timePaint;
        this.datePaint = datePaint;
        this.backgroundPaint = backgroundPaint;
        this.time = time;
    }

    public void draw(Canvas canvas, Rect bounds) {
        time.setToNow();
        canvas.drawRect(0, 0, bounds.width(), bounds.height(), backgroundPaint);

        ...
    }

    public void updateDateAndTimeColourTo(int colour) {
        dateAndTimeColour = colour;
        timePaint.setColor(colour);
        datePaint.setColor(colour);
    }

    public void updateBackgroundColourTo(int colour) {
        backgroundColour = colour;
        backgroundPaint.setColor(colour);
    }

    ...
}

Last thing remaining is to handle the ambient mode. In ambient mode, we would want to default to black and white colours.

@Override
public void onAmbientModeChanged(boolean inAmbientMode) {
    super.onAmbientModeChanged(inAmbientMode);
    watchFace.setAntiAlias(!inAmbientMode);
    watchFace.setShowSeconds(!isInAmbientMode());

    if (inAmbientMode) {
        watchFace.updateBackgroundColourToDefault();
        watchFace.updateDateAndTimeColourToDefault();
    } else {
        watchFace.restoreBackgroundColour();
        watchFace.restoreDateAndTimeColour();
    }

    invalidate();

    startTimerIfNecessary();
}

Every time the watch face enters ambient mode we update its colours to default and when it exists ambient mode we have to restore its colours to the selected ones:

public class SimpleWatchFace {
	...
    public void updateBackgroundColourToDefault() {
        backgroundPaint.setColor(BACKGROUND_DEFAULT_COLOUR);
    }

    public void updateDateAndTimeColourToDefault() {
        timePaint.setColor(DATE_AND_TIME_DEFAULT_COLOUR);
        datePaint.setColor(DATE_AND_TIME_DEFAULT_COLOUR);
    }

    public void restoreDateAndTimeColour() {
        timePaint.setColor(dateAndTimeColour);
        datePaint.setColor(dateAndTimeColour);
    }

    public void restoreBackgroundColour() {
        backgroundPaint.setColor(backgroundColour);
    }
}

You are now set to test the whole configuration end to end. First run the wear module on your watch, then the mobile module on your phone. Jump into the Android Wear application on your phone and you can select the installed watch and access the settings screen for it. From here you can play with changing the colours for both the background and the date and time.

As usual, all the code is pushed to GitHub. I will appreciate any feedback provided.

Show Comments