diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index da934bb6..060103ec 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -2,6 +2,7 @@
# build
appVersionCode = "10"
appVersionName = "1.3.0"
+appVersionWearOffset = "60000000"
agp = "8.11.1"
bcpkixJdk18on = "1.81"
compileSdk = "36"
@@ -83,6 +84,7 @@ mlkitCommon = "18.11.0"
mlkitSegmentation = "16.0.0-beta1"
playServicesBase = "18.7.2"
timber = "5.0.1"
+workRuntimeKtx = "2.10.4"
xr-compose = "1.0.0-alpha06"
[libraries]
@@ -132,6 +134,7 @@ androidx-wear-compose-ui-tooling = { group = "androidx.wear.compose", name = "co
androidx-wear-remote-interactions = { module = "androidx.wear:wear-remote-interactions", version.ref = "wearRemoteInteractions" }
androidx-window = { module = "androidx.window:window", version.ref = "window" }
androidx-window-core = { module = "androidx.window:window-core", version.ref = "window" }
+androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" }
apksig = { module = "com.android.tools.build:apksig", version.ref = "apksig" }
bcpkix-jdk18on = { module = "org.bouncycastle:bcpkix-jdk18on", version.ref = "bcpkixJdk18on" }
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coilCompose" }
diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts
index 4e2dea7d..7d5c3971 100644
--- a/wear/build.gradle.kts
+++ b/wear/build.gradle.kts
@@ -35,7 +35,7 @@ android {
applicationId = "com.android.developers.androidify"
targetSdk = 36
// Ensure Wear OS app has its own version space
- versionCode = 60_000_000 + libs.versions.appVersionCode.get().toInt()
+ versionCode = libs.versions.appVersionWearOffset.get().toInt() + libs.versions.appVersionCode.get().toInt()
versionName = libs.versions.appVersionName.get()
}
@@ -85,6 +85,7 @@ dependencies {
implementation(libs.androidx.wear.remote.interactions)
implementation(libs.horologist.compose.layout)
implementation(libs.accompanist.permissions)
+ implementation(libs.androidx.work.runtime.ktx)
"cliToolConfiguration"(libs.validator.push.cli)
}
diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml
index 752c7bb0..a492bd22 100644
--- a/wear/src/main/AndroidManifest.xml
+++ b/wear/src/main/AndroidManifest.xml
@@ -77,5 +77,14 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/wear/src/main/java/com/android/developers/androidify/updater/UpdateReceiver.kt b/wear/src/main/java/com/android/developers/androidify/updater/UpdateReceiver.kt
new file mode 100644
index 00000000..08d324e2
--- /dev/null
+++ b/wear/src/main/java/com/android/developers/androidify/updater/UpdateReceiver.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.developers.androidify.updater
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkManager
+
+/**
+ * Updates the watch face, if necessary, when the overall app is updated, if the app contains a
+ * newer default watch face within it.
+ *
+ * Uses a WorkManager job to avoid trying to complete this within the time allowed for the
+ * onReceive.
+ */
+class UpdateReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (Intent.ACTION_MY_PACKAGE_REPLACED == intent.action) {
+ val updateRequest = OneTimeWorkRequestBuilder().build()
+ val workManager = WorkManager.getInstance(context)
+ workManager.enqueue(updateRequest)
+ }
+ }
+}
diff --git a/wear/src/main/java/com/android/developers/androidify/updater/UpdateWorker.kt b/wear/src/main/java/com/android/developers/androidify/updater/UpdateWorker.kt
new file mode 100644
index 00000000..387efab0
--- /dev/null
+++ b/wear/src/main/java/com/android/developers/androidify/updater/UpdateWorker.kt
@@ -0,0 +1,95 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.developers.androidify.updater
+
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.ParcelFileDescriptor
+import android.util.Log
+import androidx.wear.watchfacepush.WatchFacePushManagerFactory
+import androidx.work.CoroutineWorker
+import androidx.work.WorkerParameters
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+
+private const val defaultWatchFaceName = "default_watchface.apk"
+private const val manifestTokenKey = "com.google.android.wearable.marketplace.DEFAULT_WATCHFACE_VALIDATION_TOKEN"
+
+private const val TAG = "UpdateWorker"
+
+/**
+ * WorkManager worker that tries to update the default watch face, if installed.
+ *
+ * Checks which watch faces the package already has installed, and if there is a default watch face
+ * in the assets bundle. Compares the versions of these to determine whether an update is necessary
+ * and if so, updates the default watch face, taking also the new watch face validation token from
+ * the manifest file.
+ */
+class UpdateWorker(appContext: Context, workerParams: WorkerParameters) :
+ CoroutineWorker(appContext, workerParams) {
+
+ override suspend fun doWork(): Result {
+ val watchFacePushManager = WatchFacePushManagerFactory.createWatchFacePushManager(applicationContext)
+
+ val watchFaces = watchFacePushManager.listWatchFaces().installedWatchFaceDetails
+ .associateBy { it.packageName }
+
+ val copiedFile = File.createTempFile("tmp", ".apk", applicationContext.cacheDir)
+ try {
+ applicationContext.assets.open(defaultWatchFaceName).use { inputStream ->
+ FileOutputStream(copiedFile).use { outputStream -> inputStream.copyTo(outputStream) }
+ }
+ val packageInfo =
+ applicationContext.packageManager.getPackageArchiveInfo(copiedFile.absolutePath, 0)
+
+ packageInfo?.let { newPkg ->
+ // Check if the default watch face is currently installed and should therefore be
+ // updated if the one in the assets folder has a higher version code.
+ watchFaces[newPkg.packageName]?.let { curPkg ->
+ if (newPkg.longVersionCode > curPkg.versionCode) {
+ ParcelFileDescriptor.open(
+ copiedFile,
+ ParcelFileDescriptor.MODE_READ_ONLY,
+ ).use { pfd ->
+ val token = getDefaultWatchFaceToken()
+ if (token != null) {
+ watchFacePushManager.updateWatchFace(curPkg.slotId, pfd, token)
+ Log.d(TAG, "Watch face updated from ${curPkg.versionCode} to ${newPkg.longVersionCode}")
+ } else {
+ Log.w(TAG, "Watch face not updated, no token found")
+ }
+ }
+ }
+ }
+ }
+ } catch (e: IOException) {
+ Log.w(TAG, "Watch face not updated", e)
+ return Result.failure()
+ } finally {
+ copiedFile.delete()
+ }
+ return Result.success()
+ }
+
+ private fun getDefaultWatchFaceToken(): String? {
+ val appInfo = applicationContext.packageManager.getApplicationInfo(
+ applicationContext.packageName,
+ PackageManager.GET_META_DATA,
+ )
+ return appInfo.metaData?.getString(manifestTokenKey)
+ }
+}
diff --git a/wear/watchface/build.gradle.kts b/wear/watchface/build.gradle.kts
index 99ecd753..c8bb7f99 100644
--- a/wear/watchface/build.gradle.kts
+++ b/wear/watchface/build.gradle.kts
@@ -26,8 +26,9 @@ android {
applicationId = "com.android.developers.androidify.watchfacepush.defaultwf"
minSdk = 36
targetSdk = 36
- versionCode = 1
- versionName = "1.0"
+ // The default watch face version is kept in lock step with the Wear OS app.
+ versionCode = libs.versions.appVersionWearOffset.get().toInt() + libs.versions.appVersionCode.get().toInt()
+ versionName = libs.versions.appVersionName.get()
}
buildTypes {