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 {