From 4973a88c5366498714c254f090832e55cd4e5a06 Mon Sep 17 00:00:00 2001 From: garan Date: Mon, 29 Sep 2025 10:19:29 +0100 Subject: [PATCH 1/2] Adds default watch face updater --- gradle/libs.versions.toml | 2 + wear/build.gradle.kts | 1 + wear/src/main/AndroidManifest.xml | 9 ++ .../androidify/updater/UpdateReceiver.kt | 43 +++++++++ .../androidify/updater/UpdateWorker.kt | 95 +++++++++++++++++++ wear/watchface/build.gradle.kts | 5 +- 6 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 wear/src/main/java/com/android/developers/androidify/updater/UpdateReceiver.kt create mode 100644 wear/src/main/java/com/android/developers/androidify/updater/UpdateWorker.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a9798b8c..60f859f0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -83,6 +83,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 +133,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..b3bde3be 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -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..e2dedc92 --- /dev/null +++ b/wear/src/main/java/com/android/developers/androidify/updater/UpdateReceiver.kt @@ -0,0 +1,43 @@ +/* + * 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 (context == null || intent == null) { + return + } + + 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..1977e498 --- /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(val appContext: Context, workerParams: WorkerParameters) : + CoroutineWorker(appContext, workerParams) { + + override suspend fun doWork(): Result { + val watchFacePushManager = WatchFacePushManagerFactory.createWatchFacePushManager(appContext) + + val watchFaces = watchFacePushManager.listWatchFaces().installedWatchFaceDetails + .associateBy { it.packageName } + + val copiedFile = File.createTempFile("tmp", ".apk", appContext.cacheDir) + try { + copiedFile.deleteOnExit() + appContext.assets.open(defaultWatchFaceName).use { inputStream -> + FileOutputStream(copiedFile).use { outputStream -> inputStream.copyTo(outputStream) } + } + val packageInfo = + appContext.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) { + val pfd = ParcelFileDescriptor.open( + copiedFile, + ParcelFileDescriptor.MODE_READ_ONLY, + ) + 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") + } + pfd.close() + } + } + } + } catch (e: IOException) { + Log.w(TAG, "Watch face not updated", e) + } finally { + copiedFile.delete() + } + return Result.success() + } + + private fun getDefaultWatchFaceToken(): String? { + val appInfo = appContext.packageManager.getApplicationInfo( + appContext.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..aaed7913 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 = 60_000_000 + libs.versions.appVersionCode.get().toInt() + versionName = libs.versions.appVersionName.get() } buildTypes { From 402a5c7de604cd667c3e7825a657c11fc45fd4bd Mon Sep 17 00:00:00 2001 From: garan Date: Mon, 29 Sep 2025 10:55:47 +0100 Subject: [PATCH 2/2] Addresses comments --- gradle/libs.versions.toml | 1 + wear/build.gradle.kts | 2 +- .../androidify/updater/UpdateReceiver.kt | 6 +--- .../androidify/updater/UpdateWorker.kt | 34 +++++++++---------- wear/watchface/build.gradle.kts | 2 +- 5 files changed, 21 insertions(+), 24 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index aa1deafc..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" diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index b3bde3be..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() } 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 index e2dedc92..08d324e2 100644 --- a/wear/src/main/java/com/android/developers/androidify/updater/UpdateReceiver.kt +++ b/wear/src/main/java/com/android/developers/androidify/updater/UpdateReceiver.kt @@ -29,11 +29,7 @@ import androidx.work.WorkManager * onReceive. */ class UpdateReceiver : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - if (context == null || intent == null) { - return - } - + override fun onReceive(context: Context, intent: Intent) { if (Intent.ACTION_MY_PACKAGE_REPLACED == intent.action) { val updateRequest = OneTimeWorkRequestBuilder().build() val workManager = WorkManager.getInstance(context) 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 index 1977e498..387efab0 100644 --- a/wear/src/main/java/com/android/developers/androidify/updater/UpdateWorker.kt +++ b/wear/src/main/java/com/android/developers/androidify/updater/UpdateWorker.kt @@ -39,46 +39,46 @@ private const val TAG = "UpdateWorker" * and if so, updates the default watch face, taking also the new watch face validation token from * the manifest file. */ -class UpdateWorker(val appContext: Context, workerParams: WorkerParameters) : +class UpdateWorker(appContext: Context, workerParams: WorkerParameters) : CoroutineWorker(appContext, workerParams) { override suspend fun doWork(): Result { - val watchFacePushManager = WatchFacePushManagerFactory.createWatchFacePushManager(appContext) + val watchFacePushManager = WatchFacePushManagerFactory.createWatchFacePushManager(applicationContext) val watchFaces = watchFacePushManager.listWatchFaces().installedWatchFaceDetails .associateBy { it.packageName } - val copiedFile = File.createTempFile("tmp", ".apk", appContext.cacheDir) + val copiedFile = File.createTempFile("tmp", ".apk", applicationContext.cacheDir) try { - copiedFile.deleteOnExit() - appContext.assets.open(defaultWatchFaceName).use { inputStream -> + applicationContext.assets.open(defaultWatchFaceName).use { inputStream -> FileOutputStream(copiedFile).use { outputStream -> inputStream.copyTo(outputStream) } } val packageInfo = - appContext.packageManager.getPackageArchiveInfo(copiedFile.absolutePath, 0) + 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) { - val pfd = ParcelFileDescriptor.open( + ParcelFileDescriptor.open( copiedFile, ParcelFileDescriptor.MODE_READ_ONLY, - ) - 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") + ).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") + } } - pfd.close() } } } } catch (e: IOException) { Log.w(TAG, "Watch face not updated", e) + return Result.failure() } finally { copiedFile.delete() } @@ -86,8 +86,8 @@ class UpdateWorker(val appContext: Context, workerParams: WorkerParameters) : } private fun getDefaultWatchFaceToken(): String? { - val appInfo = appContext.packageManager.getApplicationInfo( - appContext.packageName, + 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 aaed7913..c8bb7f99 100644 --- a/wear/watchface/build.gradle.kts +++ b/wear/watchface/build.gradle.kts @@ -27,7 +27,7 @@ android { minSdk = 36 targetSdk = 36 // The default watch face version is kept in lock step with the Wear OS app. - 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() }