Writing a Wear OS watch face

24 August 2022 by Lincoln Ramsay

I got a Wear OS watch a while back. I had initially thought I’d do some development for it, but it just kinda didn’t happen.

Recently, I played The Division and got the urge to make a watch face again. Samsung made a “no code” app for this, but it requires “Wear OS 2”, which really means Android 9. My watch has Wear OS 2 but only Android 8 so I gotta do this the old fashioned way.

Deploying to the actual watch is a bit of a pain. Getting a wifi adb link up isn’t too bad, but the major hit to battery when keeping the screen lit up or when writing to storage means I really need to grab the USB cable. And the C to A dongle (sigh). But of course then the watch isn’t on my wrist.

My new Mac meant the matching android emulator image was busted, but I found an API 31 Wear OS “preview” image that works well enough. I made a custom skin for it because the white bezel on the actual watch affects how things at the edge of the display look.

I got fed up with the way Android Studio “runs” new watch faces. Namely, it can’t. Instead it just installs the new apk, which causes the running watch face to crash, requiring you to change to another watch face, then change back. Ugh. I added an activity to my watch face so I can “run” the activity instead. The activity just embeds the watch face. It needed a few hacks but it works quite well. I’m even feeding in complication data, since the emulator is missing complication data providers that match what the actual watch has.

The first trick was to make the activity only apply to debug builds. app/src/debug/AndroidManifest.xml adds the activity entry, just like any old launcher app. It feels weird writing partial manifest files but this is merged into the regular AndroidManifest.xml so only the new stuff is needed.

    <application>

        <activity
            android:name=".WatchTestActivity"
            android:label="@string/app_name"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

    </application>

Then app/src/debug/java/package/WatchTestActivity.java defines the activity. Sorry Kotlin lovers.

I should also mention that I looked at the new AndroidX wearable code and ran away. So much complexity for no apparent gain. This code is based on the wearable support library version 2.8.1.

I’ll start with the modifications I needed to make to the watch face itself.

public class MyWatchFace
        extends CanvasWatchFaceService {

    public final String TAG;
    final boolean runningNatively;
    Context context;
    Runnable invalidateCallback;

    public MyWatchFace() {
        super();
        TAG = "MyWatchFace";
        runningNatively = true;
    }

    MyWatchFace(Context context, Runnable invalidateCallback) {
        super();
        TAG = "MyWatchFacePreview";
        runningNatively = false;
        this.context = context;
        this.invalidateCallback = invalidateCallback;
    }

The default constructor is called when the watch face starts as a watch face. The second one will be called by my test activity. I think having a TAG (for passing to Log methods) is a common pattern? Here, I’m changing the value for test activity vs watch face since both can be running at the same time and I’d like to know which of the two is logging!

    @Override
    public Engine onCreateEngine() {
        return new Engine();
    }

    private class Engine
            extends CanvasWatchFaceService.Engine {

        @Override
        public void onCreate(SurfaceHolder holder) {
            if (runningNatively) {
                super.onCreate(holder);

                context = getBaseContext();

                setWatchFaceStyle(...);
            }

Here we see the first use of a common pattern. Invoking some of the superclass’ methods kills the app dead, so I just don’t do it from the test activity. I also save context here when running as a watch face, then everything else can just use context and get the right thing. There are 2 more superclass methods I had to avoid.

        @Override
        public void onSurfaceChanged(SurfaceHolder holder, int format, int width, int height) {
            if (runningNatively) {
                super.onSurfaceChanged(holder, format, width, height);
            }

and

        @Override
        public void onVisibilityChanged(boolean visible) {
            if (runningNatively) {
                super.onVisibilityChanged(visible);
            }

Finally, there’s 2 methods I had to override because they just didn’t work.

        @Override
        public boolean isVisible() {
            if (runningNatively) {
                return super.isVisible();
            } else {
                return true;
            }
        }

I guess onVisibilityChanged might have given this away, but isVisible() always returns false, so the test activity just considers the watch face to always be visible.

        @Override
        public void invalidate() {
            if (runningNatively) {
                super.invalidate();
            } else {
                invalidateCallback.run();
            }
        }

As foreshadowed at the top, invalidate() doesn’t work, so here we invoke a callback instead when running in the test activity.

So… pretty minor changes right? Now let’s see the hosting activity.

public class WatchTestActivity
        extends Activity {

    class WrapperView
            extends View {
        WrapperView(Context context) {
            super(context);
        }
        @Override
        protected void onSizeChanged(int w, int h, int oldw, int oldh) {
            super.onSizeChanged(w, h, oldw, oldh);
            WatchTestActivity.this.onSizeChanged(w, h);
        }
        @Override
        protected void onDraw(Canvas canvas) {
            super.onDraw(canvas);
            WatchTestActivity.this.onDraw(canvas);
        }
    }

    CanvasWatchFaceService.Engine canvasEngine;
    Rect bounds = new Rect();
    WrapperView wrapperView;
    boolean ambient = false;
    Handler handler = new Handler();

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

        wrapperView = new WrapperView(this);
        setContentView(wrapperView);
        wrapperView.setOnClickListener((v) -> onUiClick());

        MyWatchFace watchFace = new MyWatchFace(this, this::onInvalidate);
        canvasEngine = watchFace.onCreateEngine();
        canvasEngine.onCreate(null);

        updateComplications();

        // start the "every minute" loop
        nextTick();
    }

Well that was a lot. But there shouldn’t be anything there too odd looking. I’ve got a view subclass so I can hook some methods, and have a surface to draw on. I’m instantiating the watch face using its new constructor, then calling the standard lifecycle methods. Here, I’m passing null for the SurfaceHolder in onCreate since it’s going to be ignored anyway.

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

    @Override
    protected void onResume() {
        super.onResume();
        canvasEngine.onVisibilityChanged(true);
    }

    @Override
    protected void onPause() {
        super.onPause();
        canvasEngine.onVisibilityChanged(false);
    }

Just some standard lifecycle methods, with calls through to the watch face as appropriate.

Remember the WrapperView class? Here’s the methods it calls.

    void onSizeChanged(int width, int height) {
        canvasEngine.onSurfaceChanged(null, 0, width, height);
        bounds.set(0, 0, width, height);
    }

    void onDraw(Canvas canvas) {
        canvasEngine.onDraw(canvas, bounds);
    }

onSurfaceChanged seems to be the watch face equivalent to onSizeChanged. I’m setting the bounds there so I can pass it in to the onDraw method. This lets the watch face draw onto the view.

    void onUiClick() {
        // for now, we just toggle ambient mode off and on
        ambient = !ambient;
        canvasEngine.onAmbientModeChanged(ambient);
        canvasEngine.invalidate();

        // now start updating the complications regularly
        updatingComplications = false;
    }

I could clearly do more when clicking the screen but for now, I just use that as a signal to toggle ambient mode on and off. Interestingly, this is more reliable than pressing the emulator’s power button with the watch face showing. For me, that’d often incorrectly detect a “hold” rather than a “press”.

    Calendar mCalendar = Calendar.getInstance();
    void nextTick() {
        // call tick as the seconds wrap back to 0 and the minutes increment
        long now = System.currentTimeMillis();
        mCalendar.setTimeInMillis(now);
        int seconds = mCalendar.get(Calendar.SECOND);
        int millis = (60 - seconds) * 1000;
        handler.postDelayed(() -> {
            canvasEngine.onTimeTick();
            nextTick();
        }, millis);
    }

So… I’m not sure this is 100% correct, but my watch never fails to show the correct time to the minute, and this will call onTimeTick() when the minute changes. It’s mostly important when the watch is in ambient mode and not setting its own update timer.

Now we get to complications. Here I had to cheat. There’s no way to trigger the config activity, and I didn’t care about making the test activity generic to handle that properly anyway. I was able to call the standard onComplicationDataUpdate method. Despite this, the code here is highly specific to the watch face, even going as far as to reference a constant directly. Because it’s so specific, I’m not even going to show the whole code, just the code for one complication.

    int batteryPercent = 100;

    void advanceComplications() {
        batteryPercent -= 1;
        if (batteryPercent < 5) {
            batteryPercent = 100;
        }
    }

    void updateComplications() {
        // This isn't very generic... but I don't care to implement the plumbing
        // required to make the config activity work!
        ComplicationData data;
        data = new ComplicationData.Builder(ComplicationData.TYPE_RANGED_VALUE)
                .setMinValue(0f)
                .setMaxValue(100f)
                .setValue((float)batteryPercent)
                .build();
        canvasEngine.onComplicationDataUpdate(MyWatchFace.COMPLICATION_ID, data);
    }

One last method, the invalidate callback. My version here is pulling double duty.

    boolean updatingComplications = true;
    void onInvalidate() {
        // the watch face calls invalidate() when complication data is updated
        if (!updatingComplications) {
            updatingComplications = true;
            advanceComplications();
            updateComplications();
            updatingComplications = false;
        }
        wrapperView.invalidate();
    }

The real magic is the call through to the wrapper view’s invalidate method. But since this method is called regularly (at least when not in ambient mode) I’m using it to change complication data as well. Which means I need to do a dance to avoid infinite recursion. In case it’s not clear, the updating of complication data only starts after the screen has been clicked.