diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 58035461..8b44b8a5 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -37,8 +37,8 @@ android {
applicationId = "com.android.developers.androidify"
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = 36
- versionCode = 5
- versionName = "1.1.3"
+ versionCode = libs.versions.appVersionCode.get().toInt()
+ versionName = libs.versions.appVersionName.get()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -96,6 +96,12 @@ android {
isIncludeAndroidResources = true
}
}
+ // To avoid packaging conflicts when using bouncycastle
+ packaging {
+ resources {
+ excludes.add("META-INF/versions/9/OSGI-INF/MANIFEST.MF")
+ }
+ }
}
baselineProfile() {
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 8535bf98..a80b8bb0 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -33,3 +33,13 @@
-keepattributes Signature
-keepattributes *Annotation*
-keepattributes InnerClasses
+
+# Ignore missing Java SE image classes from TwelveMonkeys ImageIO
+-dontwarn javax.imageio.**
+
+# Ignore missing Java SE XML classes from Xerces and other XML processors
+-dontwarn org.apache.xml.resolver.**
+-dontwarn org.eclipse.wst.xml.xpath2.processor.**
+
+# Ignore missing Java SE annotation processing classes, often from libraries like AutoValue
+-dontwarn javax.lang.model.**
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 968b3708..9880f60b 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -78,6 +78,14 @@
+
+
+
+
+
+
+
+
+ watchface_feature_enabled
+ false
+
background_vibes_feature_enabled
false
diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts
index 98a27156..006bd12e 100644
--- a/core/testing/build.gradle.kts
+++ b/core/testing/build.gradle.kts
@@ -59,6 +59,8 @@ dependencies {
implementation(projects.core.network)
implementation(projects.core.util)
implementation(projects.feature.results)
+ implementation(projects.watchface)
+ implementation(projects.wear.common)
ksp(libs.hilt.compiler)
diff --git a/core/testing/src/main/java/com/android/developers/testing/network/TestRemoteConfigDataSource.kt b/core/testing/src/main/java/com/android/developers/testing/network/TestRemoteConfigDataSource.kt
index 60482e33..eec9e976 100644
--- a/core/testing/src/main/java/com/android/developers/testing/network/TestRemoteConfigDataSource.kt
+++ b/core/testing/src/main/java/com/android/developers/testing/network/TestRemoteConfigDataSource.kt
@@ -79,4 +79,8 @@ class TestRemoteConfigDataSource(private val useGeminiNano: Boolean) : RemoteCon
override fun getBotBackgroundInstructionPrompt(): String {
return "bot_background_instruction_prompt"
}
+
+ override fun watchfaceFeatureEnabled(): Boolean {
+ return true
+ }
}
diff --git a/core/testing/src/main/java/com/android/developers/testing/repository/FakeWatchFaceInstallationRepository.kt b/core/testing/src/main/java/com/android/developers/testing/repository/FakeWatchFaceInstallationRepository.kt
new file mode 100644
index 00000000..923c643c
--- /dev/null
+++ b/core/testing/src/main/java/com/android/developers/testing/repository/FakeWatchFaceInstallationRepository.kt
@@ -0,0 +1,68 @@
+package com.android.developers.testing.repository
+
+import android.graphics.Bitmap
+import com.android.developers.androidify.watchface.WatchFaceAsset
+import com.android.developers.androidify.watchface.transfer.WatchFaceInstallationRepository
+import com.android.developers.androidify.wear.common.ConnectedWatch
+import com.android.developers.androidify.wear.common.WatchFaceActivationStrategy
+import com.android.developers.androidify.wear.common.WatchFaceInstallError
+import com.android.developers.androidify.wear.common.WatchFaceInstallationStatus
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import java.util.UUID
+
+class FakeWatchFaceInstallationRepository : WatchFaceInstallationRepository {
+ private val watch = ConnectedWatch(
+ nodeId = "1234",
+ displayName = "Pixel Watch",
+ hasAndroidify = true,
+ )
+
+ private val watchFaceAsset = WatchFaceAsset(
+ id = "watch_face_1",
+ previewPath = com.android.developers.androidify.results.R.drawable.watch_face_preview,
+ )
+
+ private var transferId = generateTransferId()
+
+ private val _connectedWatch = MutableStateFlow(null)
+ override val connectedWatch = _connectedWatch.asStateFlow()
+
+ private val _watchFaceInstallationStatus =
+ MutableStateFlow(WatchFaceInstallationStatus.NotStarted)
+ override val watchFaceInstallationUpdates = _watchFaceInstallationStatus.asStateFlow()
+
+ override suspend fun createAndTransferWatchFace(
+ connectedWatch: ConnectedWatch,
+ watchFace: WatchFaceAsset,
+ bitmap: Bitmap,
+ ): WatchFaceInstallError {
+ transferId = generateTransferId()
+ delay(5_000)
+ _watchFaceInstallationStatus.value = WatchFaceInstallationStatus.Complete(
+ success = true,
+ otherNodeId = "5678",
+ transferId = transferId,
+ activationStrategy = WatchFaceActivationStrategy.NO_ACTION_NEEDED,
+ validationToken = "1234abcd",
+ installError = WatchFaceInstallError.NO_ERROR,
+ )
+ return WatchFaceInstallError.NO_ERROR
+ }
+
+ override suspend fun getAvailableWatchFaces(): Result> {
+ return Result.success(listOf(watchFaceAsset))
+ }
+
+ override suspend fun resetInstallationStatus() {
+ transferId = generateTransferId()
+ _watchFaceInstallationStatus.value = WatchFaceInstallationStatus.NotStarted
+ }
+
+ private fun generateTransferId() = UUID.randomUUID().toString().take(8)
+
+ public fun setWatchAsConnected() {
+ _connectedWatch.value = watch
+ }
+}
\ No newline at end of file
diff --git a/core/theme/build.gradle.kts b/core/theme/build.gradle.kts
index 5740fc0e..16f06e70 100644
--- a/core/theme/build.gradle.kts
+++ b/core/theme/build.gradle.kts
@@ -48,7 +48,12 @@ android {
kotlinOptions {
jvmTarget = libs.versions.jvmTarget.get()
}
-
+ // To avoid packaging conflicts when using bouncycastle
+ packaging {
+ resources {
+ excludes.add("META-INF/versions/9/OSGI-INF/MANIFEST.MF")
+ }
+ }
}
dependencies {
@@ -57,6 +62,7 @@ dependencies {
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(projects.core.util)
+ implementation(libs.guava)
implementation(libs.androidx.adaptive)
implementation(libs.androidx.adaptive.layout)
diff --git a/data/build.gradle.kts b/data/build.gradle.kts
index cab95799..a37ecd93 100644
--- a/data/build.gradle.kts
+++ b/data/build.gradle.kts
@@ -16,7 +16,6 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
- alias(libs.plugins.serialization)
alias(libs.plugins.kotlin.ksp)
alias(libs.plugins.hilt)
}
@@ -59,5 +58,6 @@ dependencies {
implementation(libs.ai.edge) {
exclude(group = "com.google.guava")
}
+
ksp(libs.hilt.compiler)
}
diff --git a/feature/camera/build.gradle.kts b/feature/camera/build.gradle.kts
index ac4af4d7..0bb44abb 100644
--- a/feature/camera/build.gradle.kts
+++ b/feature/camera/build.gradle.kts
@@ -47,6 +47,12 @@ android {
testOptions {
targetSdk = 36
}
+ // To avoid packaging conflicts when using bouncycastle
+ packaging {
+ resources {
+ excludes.add("META-INF/versions/9/OSGI-INF/MANIFEST.MF")
+ }
+ }
}
dependencies {
@@ -66,6 +72,7 @@ dependencies {
implementation(libs.coil.compose)
implementation(libs.kotlinx.coroutines.play.services)
implementation(libs.mlkit.pose.detection)
+ implementation(libs.guava)
ksp(libs.hilt.compiler)
implementation(libs.androidx.ui.tooling)
diff --git a/feature/creation/build.gradle.kts b/feature/creation/build.gradle.kts
index 1fee66fc..bfcbc42c 100644
--- a/feature/creation/build.gradle.kts
+++ b/feature/creation/build.gradle.kts
@@ -53,6 +53,12 @@ android {
}
targetSdk = 36
}
+ // To avoid packaging conflicts when using bouncycastle
+ packaging {
+ resources {
+ excludes.add("META-INF/versions/9/OSGI-INF/MANIFEST.MF")
+ }
+ }
}
dependencies {
diff --git a/feature/home/build.gradle.kts b/feature/home/build.gradle.kts
index 61ff0d12..0e2dc23d 100644
--- a/feature/home/build.gradle.kts
+++ b/feature/home/build.gradle.kts
@@ -47,6 +47,12 @@ android {
testOptions {
targetSdk = 36
}
+ // To avoid packaging conflicts when using bouncycastle
+ packaging {
+ resources {
+ excludes.add("META-INF/versions/9/OSGI-INF/MANIFEST.MF")
+ }
+ }
}
dependencies {
diff --git a/feature/results/build.gradle.kts b/feature/results/build.gradle.kts
index 14472d6f..0a02b2a5 100644
--- a/feature/results/build.gradle.kts
+++ b/feature/results/build.gradle.kts
@@ -48,6 +48,12 @@ android {
testOptions {
targetSdk = 36
}
+ // To avoid packaging conflicts when using bouncycastle
+ packaging {
+ resources {
+ excludes.add("META-INF/versions/9/OSGI-INF/MANIFEST.MF")
+ }
+ }
}
dependencies {
@@ -72,6 +78,8 @@ dependencies {
implementation(projects.core.theme)
implementation(projects.core.util)
implementation(projects.data)
+ implementation(projects.wear.common)
+ implementation(projects.watchface)
testImplementation(kotlin("test"))
// Android Instrumented Tests
diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/AllDoneWatchFacePanel.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/AllDoneWatchFacePanel.kt
new file mode 100644
index 00000000..46d19bd5
--- /dev/null
+++ b/feature/results/src/main/java/com/android/developers/androidify/customize/AllDoneWatchFacePanel.kt
@@ -0,0 +1,84 @@
+/*
+ * 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.customize
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.android.developers.androidify.results.R
+import com.android.developers.androidify.theme.AndroidifyTheme
+import com.android.developers.androidify.watchface.WatchFaceAsset
+
+@Composable
+fun AllDoneWatchFacePanel(
+ modifier: Modifier = Modifier,
+ selectedWatchFace: WatchFaceAsset?,
+ onAllDoneClick: () -> Unit = { },
+) {
+ Column(
+ modifier = modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .weight(1f),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ WatchFacePreviewItem(
+ watchFace = selectedWatchFace,
+ isSelected = true,
+ onClick = { },
+ )
+ }
+ Spacer(modifier = Modifier.height(24.dp))
+ WatchFacePanelButton(
+ modifier = modifier.padding(horizontal = 16.dp),
+ buttonText = stringResource(R.string.complete_all_done),
+ iconResId = R.drawable.check_24,
+ onClick = onAllDoneClick,
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Preview(showBackground = true)
+@Composable
+private fun AllDoneWatchFacePanelPreview() {
+ val watchFace1 = WatchFaceAsset(
+ id = "watch_face_1",
+ previewPath = R.drawable.watch_face_preview,
+ )
+ AndroidifyTheme {
+ AllDoneWatchFacePanel(
+ selectedWatchFace = watchFace1,
+ )
+ }
+}
diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportScreen.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportScreen.kt
index 0778e228..9b5f8736 100644
--- a/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportScreen.kt
+++ b/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportScreen.kt
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-@file:OptIn(ExperimentalPermissionsApi::class)
+@file:OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
package com.android.developers.androidify.customize
@@ -49,6 +49,7 @@ import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarDefaults
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
@@ -88,6 +89,8 @@ import com.android.developers.androidify.util.LargeScreensPreview
import com.android.developers.androidify.util.PhonePreview
import com.android.developers.androidify.util.allowsFullContent
import com.android.developers.androidify.util.isAtLeastMedium
+import com.android.developers.androidify.watchface.WatchFaceAsset
+import com.android.developers.androidify.wear.common.ConnectedWatch
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
@@ -108,6 +111,7 @@ fun CustomizeAndExportScreen(
viewModel.setArguments(resultImage, originalImageUri)
}
val state = viewModel.state.collectAsStateWithLifecycle()
+
val context = LocalContext.current
LaunchedEffect(state.value.savedUri) {
val savedImageUri = state.value.savedUri
@@ -126,8 +130,16 @@ fun CustomizeAndExportScreen(
onShareClicked = viewModel::shareClicked,
onDownloadClicked = viewModel::downloadClicked,
onSelectedToolStateChanged = viewModel::selectedToolStateChanged,
+ onInstallWatchFaceClicked = {
+ viewModel.installWatchFace()
+ },
+ onResetWatchFaceSend = {
+ viewModel.resetWatchFaceSend()
+ },
isMediumWindowSize = isMediumWindowSize,
snackbarHostState = viewModel.snackbarHostState.collectAsStateWithLifecycle().value,
+ loadWatchFaces = viewModel::loadWatchFaces,
+ onWatchFaceSelect = viewModel::onWatchFaceSelected,
)
}
@@ -141,8 +153,12 @@ private fun CustomizeExportContents(
onDownloadClicked: () -> Unit,
onToolSelected: (CustomizeTool) -> Unit,
onSelectedToolStateChanged: (ToolState) -> Unit,
+ onInstallWatchFaceClicked: () -> Unit,
+ onResetWatchFaceSend: () -> Unit,
isMediumWindowSize: Boolean,
snackbarHostState: SnackbarHostState,
+ loadWatchFaces: () -> Unit,
+ onWatchFaceSelect: (WatchFaceAsset) -> Unit,
) {
Scaffold(
snackbarHost = {
@@ -164,6 +180,10 @@ private fun CustomizeExportContents(
},
containerColor = MaterialTheme.colorScheme.surface,
) { paddingValues ->
+ var showWatchFaceBottomSheet by remember { mutableStateOf(false) }
+ val watchFaceSheetState = rememberModalBottomSheetState(
+ skipPartiallyExpanded = true,
+ )
val imageResult = remember(state.showImageEditProgress) {
movableContentWithReceiverOf {
val chromeModifier = if (this.showSticker) {
@@ -225,9 +245,32 @@ private fun CustomizeExportContents(
onDownloadClicked = {
onDownloadClicked()
},
+ onWearDeviceClick = {
+ showWatchFaceBottomSheet = true
+ },
+ hasWearDevice = state.connectedWatch != null,
modifier = modifier,
)
}
+ state.connectedWatch?.let { device ->
+ if (showWatchFaceBottomSheet) {
+ WatchFaceModalSheet(
+ sheetState = watchFaceSheetState,
+ onDismiss = {
+ onResetWatchFaceSend()
+ showWatchFaceBottomSheet = false
+ },
+ connectedWatch = device,
+ installationStatus = state.watchFaceInstallationStatus,
+ onWatchFaceInstallClick = {
+ onInstallWatchFaceClicked()
+ },
+ onLoad = loadWatchFaces,
+ watchFaceSelectionState = state.watchFaceSelectionState,
+ onWatchFaceSelect = onWatchFaceSelect,
+ )
+ }
+ }
LookaheadScope {
CompositionLocalProvider(LocalAnimateBoundsScope provides this) {
if (isMediumWindowSize) {
@@ -356,8 +399,10 @@ fun SelectedToolDetail(
@Composable
private fun BotActionsButtonRow(
+ onWearDeviceClick: () -> Unit,
onShareClicked: () -> Unit,
onDownloadClicked: () -> Unit,
+ hasWearDevice: Boolean,
modifier: Modifier = Modifier,
verboseLayout: Boolean = allowsFullContent(),
) {
@@ -412,6 +457,20 @@ private fun BotActionsButtonRow(
},
modifier = Modifier.fillMaxHeight(),
)
+ if (hasWearDevice) {
+ Spacer(Modifier.width(8.dp))
+ SecondaryOutlinedButton(
+ onClick = onWearDeviceClick,
+ leadingIcon = {
+ Icon(
+ ImageVector
+ .vectorResource(R.drawable.watch_24),
+ contentDescription = stringResource(R.string.send_to_watch),
+ )
+ },
+ modifier = Modifier.fillMaxHeight(),
+ )
+ }
PermissionRationaleDialog(
showRationaleDialog,
onDismiss = {
@@ -430,9 +489,15 @@ fun CustomizeExportPreview() {
AnimatedContent(true) { targetState ->
targetState
CompositionLocalProvider(LocalNavAnimatedContentScope provides this@AnimatedContent) {
+ val connectedWatch = ConnectedWatch(
+ nodeId = "1234",
+ displayName = "Pixel Watch 3",
+ hasAndroidify = true,
+ )
val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot)
val state = CustomizeExportState(
exportImageCanvas = ExportImageCanvas(imageBitmap = bitmap.asAndroidBitmap()),
+ connectedWatch = connectedWatch,
)
CustomizeExportContents(
state = state,
@@ -444,6 +509,10 @@ fun CustomizeExportPreview() {
snackbarHostState = SnackbarHostState(),
isMediumWindowSize = false,
onSelectedToolStateChanged = {},
+ onInstallWatchFaceClicked = {},
+ onResetWatchFaceSend = {},
+ loadWatchFaces = {},
+ onWatchFaceSelect = {},
)
}
}
@@ -458,12 +527,18 @@ fun CustomizeExportPreviewLarge() {
targetState
CompositionLocalProvider(LocalNavAnimatedContentScope provides this@AnimatedContent) {
val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot)
+ val connectedWatch = ConnectedWatch(
+ nodeId = "1234",
+ displayName = "Pixel Watch 3",
+ hasAndroidify = true,
+ )
val state = CustomizeExportState(
exportImageCanvas = ExportImageCanvas(
imageBitmap = bitmap.asAndroidBitmap(),
aspectRatioOption = SizeOption.Square,
),
selectedTool = CustomizeTool.Background,
+ connectedWatch = connectedWatch,
)
CustomizeExportContents(
state = state,
@@ -475,6 +550,10 @@ fun CustomizeExportPreviewLarge() {
snackbarHostState = SnackbarHostState(),
isMediumWindowSize = true,
onSelectedToolStateChanged = {},
+ onInstallWatchFaceClicked = {},
+ onResetWatchFaceSend = {},
+ loadWatchFaces = {},
+ onWatchFaceSelect = {},
)
}
}
diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportViewModel.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportViewModel.kt
index 994ee956..4753afae 100644
--- a/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportViewModel.kt
+++ b/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportViewModel.kt
@@ -27,10 +27,20 @@ import androidx.lifecycle.viewModelScope
import com.android.developers.androidify.RemoteConfigDataSource
import com.android.developers.androidify.data.ImageGenerationRepository
import com.android.developers.androidify.util.LocalFileProvider
+import com.android.developers.androidify.watchface.WatchFaceAsset
+import com.android.developers.androidify.watchface.transfer.WatchFaceInstallationRepository
+import com.android.developers.androidify.wear.common.WatchFaceInstallError
+import com.android.developers.androidify.wear.common.WatchFaceInstallationStatus
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -39,13 +49,30 @@ import javax.inject.Inject
class CustomizeExportViewModel @Inject constructor(
val imageGenerationRepository: ImageGenerationRepository,
val composableBitmapRenderer: ComposableBitmapRenderer,
+ val watchfaceInstallationRepository: WatchFaceInstallationRepository,
val localFileProvider: LocalFileProvider,
val remoteConfigDataSource: RemoteConfigDataSource,
application: Application,
) : AndroidViewModel(application) {
private val _state = MutableStateFlow(CustomizeExportState())
- val state = _state.asStateFlow()
+ val state: StateFlow = combine(
+ _state,
+ watchfaceInstallationRepository.connectedWatch,
+ watchfaceInstallationRepository.watchFaceInstallationUpdates,
+ ) {
+ currentState, watch, installationStatus ->
+ currentState.copy(
+ connectedWatch = watch,
+ watchFaceInstallationStatus = installationStatus,
+ )
+ }.stateIn(
+ scope = viewModelScope,
+ started = WhileSubscribed(5000),
+ initialValue = _state.value,
+ )
+
+ private var transferJob: Job? = null
private var _snackbarHostState = MutableStateFlow(SnackbarHostState())
@@ -294,4 +321,77 @@ class CustomizeExportViewModel @Inject constructor(
it.copy(selectedTool = tool)
}
}
+
+ fun loadWatchFaces() {
+ if (_state.value.watchFaceSelectionState.watchFaces.isNotEmpty()) return
+
+ _state.update { it.copy(watchFaceSelectionState = it.watchFaceSelectionState.copy(isLoadingWatchFaces = true)) }
+
+ viewModelScope.launch {
+ watchfaceInstallationRepository.getAvailableWatchFaces()
+ .onSuccess { faces ->
+ _state.update {
+ it.copy(
+ watchFaceSelectionState = WatchFaceSelectionState(
+ watchFaces = faces,
+ isLoadingWatchFaces = false,
+ selectedWatchFace = faces.firstOrNull(),
+ ),
+ )
+ }
+ }
+ .onFailure { error ->
+ _state.update {
+ it.copy(
+ watchFaceSelectionState = it.watchFaceSelectionState.copy(
+ isLoadingWatchFaces = false,
+ ),
+ )
+ }
+ }
+ }
+ }
+
+ fun onWatchFaceSelected(watchFace: WatchFaceAsset) {
+ _state.update {
+ it.copy(
+ watchFaceSelectionState = it.watchFaceSelectionState.copy(
+ selectedWatchFace = watchFace,
+ ),
+ )
+ }
+ }
+
+ fun installWatchFace() {
+ val watchFaceToInstall = _state.value.watchFaceSelectionState.selectedWatchFace ?: return
+ transferJob = viewModelScope.launch {
+ val bitmap = state.value.exportImageCanvas.imageBitmap
+ val watch = state.value.connectedWatch
+ if (watch != null && bitmap != null) {
+ val wfBitmap = imageGenerationRepository.removeBackground(bitmap)
+ val response = watchfaceInstallationRepository
+ .createAndTransferWatchFace(watch, watchFaceToInstall, wfBitmap)
+
+ if (response != WatchFaceInstallError.NO_ERROR) {
+ _state.update {
+ it.copy(
+ watchFaceInstallationStatus = WatchFaceInstallationStatus.Complete(
+ success = false,
+ installError = response,
+ otherNodeId = watch.nodeId,
+ ),
+ )
+ }
+ }
+ }
+ }
+ }
+
+ fun resetWatchFaceSend() {
+ transferJob?.cancel()
+ transferJob = null
+ viewModelScope.launch {
+ watchfaceInstallationRepository.resetInstallationStatus()
+ }
+ }
}
diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeState.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeState.kt
index 5137227c..06c9cc1a 100644
--- a/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeState.kt
+++ b/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeState.kt
@@ -21,6 +21,8 @@ import androidx.annotation.DrawableRes
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
+import com.android.developers.androidify.wear.common.ConnectedWatch
+import com.android.developers.androidify.wear.common.WatchFaceInstallationStatus
data class CustomizeExportState(
val originalImageUrl: Uri? = null,
@@ -35,6 +37,9 @@ data class CustomizeExportState(
),
val exportImageCanvas: ExportImageCanvas = ExportImageCanvas(),
val showImageEditProgress: Boolean = false,
+ val connectedWatch: ConnectedWatch? = null,
+ val watchFaceInstallationStatus: WatchFaceInstallationStatus = WatchFaceInstallationStatus.NotStarted,
+ val watchFaceSelectionState: WatchFaceSelectionState = WatchFaceSelectionState(),
)
interface ToolState {
diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/ErrorWatchFacePanel.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/ErrorWatchFacePanel.kt
new file mode 100644
index 00000000..0ae80e28
--- /dev/null
+++ b/feature/results/src/main/java/com/android/developers/androidify/customize/ErrorWatchFacePanel.kt
@@ -0,0 +1,106 @@
+/*
+ * 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.customize
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.android.developers.androidify.results.R
+import com.android.developers.androidify.theme.AndroidifyTheme
+import com.android.developers.androidify.watchface.WatchFaceAsset
+
+@Composable
+fun ErrorWatchFacePanel(
+ modifier: Modifier = Modifier,
+ selectedWatchFace: WatchFaceAsset?,
+ errorTextResId: Int,
+ onAllDoneClick: () -> Unit = { },
+) {
+ Column(
+ modifier = modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .weight(1f),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ WatchFacePreviewItem(
+ watchFace = selectedWatchFace,
+ isSelected = true,
+ onClick = { },
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ text = stringResource(R.string.complete_error_headline),
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center,
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = stringResource(errorTextResId),
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+ WatchFacePanelButton(
+ modifier = modifier.padding(horizontal = 16.dp),
+ buttonText = stringResource(R.string.error_dismiss),
+ iconResId = R.drawable.watch_error_24,
+ onClick = onAllDoneClick,
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Preview(showBackground = true)
+@Composable
+private fun ErrorWatchFacePanelPreview() {
+ val watchFace1 = WatchFaceAsset(
+ id = "watch_face_1",
+ previewPath = R.drawable.watch_face_preview,
+ )
+ AndroidifyTheme {
+ ErrorWatchFacePanel(
+ selectedWatchFace = watchFace1,
+ errorTextResId = R.string.complete_error_message,
+ )
+ }
+}
diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/GuidanceWatchFacePanel.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/GuidanceWatchFacePanel.kt
new file mode 100644
index 00000000..fcf2688c
--- /dev/null
+++ b/feature/results/src/main/java/com/android/developers/androidify/customize/GuidanceWatchFacePanel.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.customize
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.android.developers.androidify.results.R
+import com.android.developers.androidify.theme.AndroidifyTheme
+import com.android.developers.androidify.watchface.WatchFaceAsset
+
+@Composable
+fun GuidanceWatchFacePanel(
+ modifier: Modifier = Modifier,
+ selectedWatchFace: WatchFaceAsset?,
+ guidanceTextResId: Int,
+ dismissClick: () -> Unit = { },
+) {
+ Column(
+ modifier = modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .weight(1f),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ WatchFacePreviewItem(
+ watchFace = selectedWatchFace,
+ isSelected = true,
+ onClick = { },
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ Text(
+ text = stringResource(guidanceTextResId),
+ textAlign = TextAlign.Center,
+ )
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+ WatchFacePanelButton(
+ modifier = modifier.padding(horizontal = 16.dp),
+ buttonText = stringResource(R.string.error_dismiss),
+ iconResId = R.drawable.check_24,
+ onClick = dismissClick,
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Preview(showBackground = true)
+@Composable
+private fun GuidanceWatchFacePanelPreview() {
+ val watchFace1 = WatchFaceAsset(
+ id = "watch_face_1",
+ previewPath = R.drawable.watch_face_preview,
+ )
+ AndroidifyTheme {
+ GuidanceWatchFacePanel(
+ selectedWatchFace = watchFace1,
+ guidanceTextResId = R.string.complete_long_press,
+ )
+ }
+}
diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/InstallAndroidifyPanel.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/InstallAndroidifyPanel.kt
new file mode 100644
index 00000000..7a8f831c
--- /dev/null
+++ b/feature/results/src/main/java/com/android/developers/androidify/customize/InstallAndroidifyPanel.kt
@@ -0,0 +1,89 @@
+/*
+ * 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.customize
+
+import android.content.Intent
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.core.net.toUri
+import com.android.developers.androidify.results.R
+import com.android.developers.androidify.theme.AndroidifyTheme
+import com.android.developers.androidify.watchface.WatchFaceAsset
+
+@Composable
+fun InstallAndroidifyPanel(
+ modifier: Modifier = Modifier,
+) {
+ val context = LocalContext.current
+ val placeholderWatchFace = WatchFaceAsset(
+ id = "watch_face_1",
+ previewPath = R.drawable.watch_app_placeholder,
+ )
+ Column(
+ modifier = modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .weight(1f),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ WatchFacePreviewItem(
+ watchFace = placeholderWatchFace,
+ isSelected = false,
+ onClick = { },
+ )
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+ WatchFacePanelButton(
+ modifier = modifier.padding(horizontal = 16.dp),
+ buttonText = stringResource(R.string.install_androidify),
+ iconResId = R.drawable.watch_arrow_24,
+ onClick = {
+ val uri = "market://details?id=${context.packageName}".toUri()
+ val intent = Intent(Intent.ACTION_VIEW, uri)
+ context.startActivity(intent)
+ },
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Preview(showBackground = true)
+@Composable
+private fun InstallAndroidifyPanelPreview() {
+ AndroidifyTheme {
+ InstallAndroidifyPanel()
+ }
+}
diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/InstallWatchFacePanel.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/InstallWatchFacePanel.kt
new file mode 100644
index 00000000..55a36fef
--- /dev/null
+++ b/feature/results/src/main/java/com/android/developers/androidify/customize/InstallWatchFacePanel.kt
@@ -0,0 +1,155 @@
+/*
+ * 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.customize
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.android.developers.androidify.results.R
+import com.android.developers.androidify.theme.AndroidifyTheme
+import com.android.developers.androidify.watchface.WatchFaceAsset
+
+@Composable
+fun InstallWatchFacePanel(
+ modifier: Modifier = Modifier,
+ watchFaceSelectionState: WatchFaceSelectionState,
+ onWatchFaceSelect: (WatchFaceAsset) -> Unit,
+ onInstallClick: () -> Unit = { },
+) {
+ Column(
+ modifier = modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ val noAvailableWatchFaces = watchFaceSelectionState.watchFaces.isEmpty() &&
+ !watchFaceSelectionState.isLoadingWatchFaces
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (noAvailableWatchFaces) {
+ Text(
+ modifier = Modifier.weight(1f),
+ text = stringResource(R.string.no_watch_faces),
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ )
+ } else {
+ WatchFacesRow(
+ watchFaces = watchFaceSelectionState.watchFaces,
+ selectedWatchFace = watchFaceSelectionState.selectedWatchFace,
+ onWatchFaceSelect = onWatchFaceSelect,
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+ WatchFacePanelButton(
+ modifier = modifier.padding(horizontal = 16.dp),
+ buttonText = stringResource(R.string.send_to_watch),
+ iconResId = R.drawable.watch_arrow_24,
+ onClick = onInstallClick,
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Preview(showBackground = true)
+@Composable
+private fun InstallWatchFacePanelPreview() {
+ val watchFace1 = WatchFaceAsset(
+ id = "watch_face_1",
+ previewPath = R.drawable.watch_face_preview,
+ )
+ val watchFace2 = WatchFaceAsset(
+ id = "watch_face_2",
+ previewPath = R.drawable.watch_face_preview,
+ )
+ val watchFaceSelectionState = WatchFaceSelectionState(
+ watchFaces = listOf(watchFace1, watchFace2),
+ selectedWatchFace = watchFace1,
+ isLoadingWatchFaces = false,
+ )
+ AndroidifyTheme {
+ InstallWatchFacePanel(
+ watchFaceSelectionState = watchFaceSelectionState,
+ onWatchFaceSelect = {},
+ onInstallClick = {},
+ )
+ }
+}
+
+@Composable
+fun WatchFacesRow(
+ watchFaces: List,
+ selectedWatchFace: WatchFaceAsset? = null,
+ onWatchFaceSelect: (WatchFaceAsset) -> Unit = {},
+ modifier: Modifier = Modifier,
+) {
+ LazyRow(
+ modifier = Modifier,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ contentPadding = PaddingValues(horizontal = 8.dp),
+ ) {
+ items(watchFaces, key = { it.id }) { watchFace ->
+ WatchFacePreviewItem(
+ watchFace = watchFace,
+ isSelected = watchFace.id == selectedWatchFace?.id,
+ onClick = {
+ onWatchFaceSelect(watchFace)
+ },
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Preview(showBackground = true)
+@Composable
+private fun InstallWatchFacePanelNoWatchFacesPreview() {
+ val watchFaceSelectionState = WatchFaceSelectionState(
+ isLoadingWatchFaces = false,
+ watchFaces = listOf(),
+ selectedWatchFace = null,
+ )
+ AndroidifyTheme {
+ InstallWatchFacePanel(
+ watchFaceSelectionState = watchFaceSelectionState,
+ onWatchFaceSelect = {},
+ onInstallClick = {},
+ )
+ }
+}
diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/SendingWatchFacePanel.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/SendingWatchFacePanel.kt
new file mode 100644
index 00000000..f474b005
--- /dev/null
+++ b/feature/results/src/main/java/com/android/developers/androidify/customize/SendingWatchFacePanel.kt
@@ -0,0 +1,88 @@
+/*
+ * 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.customize
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.android.developers.androidify.results.R
+import com.android.developers.androidify.theme.AndroidifyTheme
+import com.android.developers.androidify.watchface.WatchFaceAsset
+
+@Composable
+fun SendingWatchFacePanel(
+ modifier: Modifier = Modifier,
+ selectedWatchFace: WatchFaceAsset?,
+) {
+ Column(
+ modifier = modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .weight(1f),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ WatchFacePreviewItem(
+ watchFace = selectedWatchFace,
+ isSelected = true,
+ onClick = { },
+ )
+ }
+ Spacer(modifier = Modifier.height(24.dp))
+ WatchFacePanelButton(
+ modifier = modifier.padding(horizontal = 16.dp),
+ buttonText = stringResource(R.string.sending_to_watch),
+ isSending = true,
+ colors = ButtonDefaults.buttonColors(
+ contentColor = MaterialTheme.colorScheme.onSurface,
+ containerColor = MaterialTheme.colorScheme.secondaryContainer,
+ ),
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Preview(showBackground = true)
+@Composable
+private fun SendingWatchFacePanelPreview() {
+ val watchFace1 = WatchFaceAsset(
+ id = "watch_face_1",
+ previewPath = R.drawable.watch_face_preview,
+ )
+ AndroidifyTheme {
+ SendingWatchFacePanel(
+ selectedWatchFace = watchFace1,
+ )
+ }
+}
diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/WatchFaceModalSheet.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/WatchFaceModalSheet.kt
new file mode 100644
index 00000000..c6046646
--- /dev/null
+++ b/feature/results/src/main/java/com/android/developers/androidify/customize/WatchFaceModalSheet.kt
@@ -0,0 +1,231 @@
+/*
+ * 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.customize
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.ContentTransform
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.SheetState
+import androidx.compose.material3.SheetValue
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.android.developers.androidify.results.R
+import com.android.developers.androidify.theme.AndroidifyTheme
+import com.android.developers.androidify.watchface.WatchFaceAsset
+import com.android.developers.androidify.wear.common.ConnectedWatch
+import com.android.developers.androidify.wear.common.WatchFaceActivationStrategy
+import com.android.developers.androidify.wear.common.WatchFaceInstallationStatus
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun WatchFaceModalSheet(
+ connectedWatch: ConnectedWatch,
+ onWatchFaceInstallClick: (String) -> Unit,
+ installationStatus: WatchFaceInstallationStatus,
+ sheetState: SheetState,
+ watchFaceSelectionState: WatchFaceSelectionState,
+ onDismiss: () -> Unit,
+ onLoad: () -> Unit,
+ onWatchFaceSelect: (WatchFaceAsset) -> Unit,
+) {
+ LaunchedEffect(Unit) {
+ onLoad()
+ }
+
+ ModalBottomSheet(
+ modifier = Modifier.fillMaxWidth(),
+ onDismissRequest = onDismiss,
+ sheetState = sheetState,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxHeight(0.5f)
+ .padding(bottom = 16.dp, top = 0.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Bottom,
+ ) {
+ Box(
+ modifier = Modifier
+ .size(36.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.secondaryContainer),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ ImageVector.vectorResource(R.drawable.watch_24),
+ contentDescription = null,
+ )
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = stringResource(R.string.send_to_watch_device, connectedWatch.displayName),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ when {
+ connectedWatch.hasAndroidify -> {
+ AnimatedContent(
+ targetState = installationStatus,
+ transitionSpec = {
+ ContentTransform(
+ targetContentEnter = fadeIn(
+ animationSpec = tween(durationMillis = 500),
+ ),
+ initialContentExit = fadeOut(
+ animationSpec = tween(durationMillis = 500),
+ ),
+ )
+ },
+ ) { installationStatus ->
+ when (installationStatus) {
+ is WatchFaceInstallationStatus.Complete -> {
+ if (installationStatus.success) {
+ when (installationStatus.activationStrategy) {
+ WatchFaceActivationStrategy.LONG_PRESS_TO_SET -> {
+ GuidanceWatchFacePanel(
+ selectedWatchFace = watchFaceSelectionState.selectedWatchFace,
+ guidanceTextResId = R.string.complete_long_press,
+ dismissClick = onDismiss,
+ )
+ }
+
+ WatchFaceActivationStrategy.FOLLOW_PROMPT_ON_WATCH -> {
+ GuidanceWatchFacePanel(
+ selectedWatchFace = watchFaceSelectionState.selectedWatchFace,
+ guidanceTextResId = R.string.complete_permissions,
+ dismissClick = onDismiss,
+ )
+ }
+
+ WatchFaceActivationStrategy.NO_ACTION_NEEDED,
+ WatchFaceActivationStrategy.CALL_SET_ACTIVE_NO_USER_ACTION,
+ -> {
+ AllDoneWatchFacePanel(
+ selectedWatchFace = watchFaceSelectionState.selectedWatchFace,
+ onAllDoneClick = onDismiss,
+ )
+ }
+
+ WatchFaceActivationStrategy.GO_TO_WATCH_SETTINGS -> {
+ GuidanceWatchFacePanel(
+ selectedWatchFace = watchFaceSelectionState.selectedWatchFace,
+ guidanceTextResId = R.string.complete_settings,
+ dismissClick = onDismiss,
+ )
+ }
+ }
+ } else {
+ ErrorWatchFacePanel(
+ selectedWatchFace = watchFaceSelectionState.selectedWatchFace,
+ errorTextResId = R.string.complete_error_message,
+ onAllDoneClick = onDismiss,
+ )
+ }
+ }
+
+ is WatchFaceInstallationStatus.Sending -> {
+ SendingWatchFacePanel(
+ selectedWatchFace = watchFaceSelectionState.selectedWatchFace,
+ )
+ }
+
+ else -> {
+ InstallWatchFacePanel(
+ onInstallClick = {
+ onWatchFaceInstallClick(connectedWatch.nodeId)
+ },
+ watchFaceSelectionState = watchFaceSelectionState,
+ onWatchFaceSelect = onWatchFaceSelect,
+ )
+ }
+ }
+ }
+ }
+
+ else -> {
+ InstallAndroidifyPanel()
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Preview(showBackground = true, showSystemUi = true)
+@Composable
+private fun WatchFaceModalSheetPreview() {
+ val device = ConnectedWatch(
+ nodeId = "1234",
+ displayName = "Pixel Watch",
+ hasAndroidify = true,
+ )
+ val watchface = WatchFaceAsset(
+ id = "watch_face_1",
+ previewPath = R.drawable.watch_face_preview,
+ )
+ val sheetState = SheetState(
+ initialValue = SheetValue.Expanded,
+ skipHiddenState = true,
+ skipPartiallyExpanded = true,
+ positionalThreshold = { 0f },
+ velocityThreshold = { 0f },
+ )
+ val watchFaceSelectionState = WatchFaceSelectionState(
+ watchFaces = listOf(watchface),
+ selectedWatchFace = watchface,
+ isLoadingWatchFaces = false,
+ )
+ AndroidifyTheme {
+ WatchFaceModalSheet(
+ connectedWatch = device,
+ installationStatus = WatchFaceInstallationStatus.NotStarted,
+ watchFaceSelectionState = watchFaceSelectionState,
+ onWatchFaceSelect = {},
+ onLoad = {},
+ onDismiss = {},
+ onWatchFaceInstallClick = {},
+ sheetState = sheetState,
+ )
+ }
+}
diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/WatchFacePanelButton.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/WatchFacePanelButton.kt
new file mode 100644
index 00000000..706adf6f
--- /dev/null
+++ b/feature/results/src/main/java/com/android/developers/androidify/customize/WatchFacePanelButton.kt
@@ -0,0 +1,132 @@
+/*
+ * 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.customize
+
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonColors
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ContainedLoadingIndicator
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.android.developers.androidify.results.R
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun WatchFacePanelButton(
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit = {},
+ buttonText: String,
+ isSending: Boolean = false,
+ iconResId: Int? = null,
+ colors: ButtonColors = ButtonDefaults.buttonColors(
+ contentColor = MaterialTheme.colorScheme.surface,
+ containerColor = MaterialTheme.colorScheme.onSurface,
+ ),
+) {
+ Button(
+ colors = colors,
+ modifier = modifier
+ .heightIn(min = 64.dp)
+ .fillMaxWidth()
+ .animateContentSize(),
+ border = BorderStroke(2.dp, color = colors.contentColor),
+ onClick = onClick,
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (isSending) {
+ ContainedLoadingIndicator(
+ modifier = Modifier.size(24.dp),
+ containerColor = colors.containerColor,
+ indicatorColor = colors.contentColor,
+ )
+ } else if (iconResId != null) {
+ Icon(
+ ImageVector.vectorResource(iconResId),
+ contentDescription = null,
+ )
+ }
+ Spacer(modifier.width(8.dp))
+ Text(buttonText, fontSize = 18.sp)
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun WatchFaceInstallButtonPreview() {
+ MaterialTheme {
+ WatchFacePanelButton(
+ onClick = { },
+ buttonText = stringResource(R.string.send_to_watch),
+ isSending = false,
+ iconResId = R.drawable.watch_arrow_24,
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun WatchFaceInstalledButtonPreview() {
+ MaterialTheme {
+ WatchFacePanelButton(
+ onClick = { },
+ buttonText = stringResource(R.string.watch_face_sent),
+ isSending = false,
+ iconResId = R.drawable.check_24,
+ colors = ButtonDefaults.buttonColors(
+ contentColor = MaterialTheme.colorScheme.onSurface,
+ containerColor = MaterialTheme.colorScheme.secondaryContainer,
+ ),
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun WatchFaceInstallingButtonPreview() {
+ MaterialTheme {
+ WatchFacePanelButton(
+ onClick = { },
+ buttonText = stringResource(R.string.sending_to_watch),
+ isSending = true,
+ colors = ButtonDefaults.buttonColors(
+ contentColor = MaterialTheme.colorScheme.onSurface,
+ containerColor = MaterialTheme.colorScheme.secondaryContainer,
+ ),
+ )
+ }
+}
diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/WatchFacePreviewItem.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/WatchFacePreviewItem.kt
new file mode 100644
index 00000000..6f7373e1
--- /dev/null
+++ b/feature/results/src/main/java/com/android/developers/androidify/customize/WatchFacePreviewItem.kt
@@ -0,0 +1,62 @@
+/*
+ * 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.customize
+
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.ColorMatrix
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.unit.dp
+import coil3.compose.AsyncImage
+import com.android.developers.androidify.watchface.R
+import com.android.developers.androidify.watchface.WatchFaceAsset
+
+@Composable
+fun WatchFacePreviewItem(
+ watchFace: WatchFaceAsset?,
+ isSelected: Boolean,
+ onClick: () -> Unit,
+) {
+ val borderColor = if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent
+
+ Box(
+ modifier = Modifier
+ .size(160.dp) // Adjust size as needed
+ .clip(CircleShape)
+ .border(4.dp, borderColor, CircleShape)
+ .clickable(onClick = onClick),
+ ) {
+ AsyncImage(
+ model = watchFace?.previewPath ?: com.android.developers.androidify.results.R.drawable.watch_face_preview,
+ contentDescription = "Preview of ${watchFace?.id}",
+ contentScale = ContentScale.Crop,
+ colorFilter = if (!isSelected) greyScaleFilter else null,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+}
+
+val greyScaleFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) })
diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/WatchFaceSelectionState.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/WatchFaceSelectionState.kt
new file mode 100644
index 00000000..09e0c3d4
--- /dev/null
+++ b/feature/results/src/main/java/com/android/developers/androidify/customize/WatchFaceSelectionState.kt
@@ -0,0 +1,24 @@
+/*
+ * 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.customize
+
+import com.android.developers.androidify.watchface.WatchFaceAsset
+
+data class WatchFaceSelectionState(
+ val watchFaces: List = emptyList(),
+ val selectedWatchFace: WatchFaceAsset? = null,
+ val isLoadingWatchFaces: Boolean = true,
+)
diff --git a/feature/results/src/main/res/drawable/check_24.xml b/feature/results/src/main/res/drawable/check_24.xml
new file mode 100644
index 00000000..5f19fa3b
--- /dev/null
+++ b/feature/results/src/main/res/drawable/check_24.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/feature/results/src/main/res/drawable/watch_24.xml b/feature/results/src/main/res/drawable/watch_24.xml
new file mode 100644
index 00000000..84159810
--- /dev/null
+++ b/feature/results/src/main/res/drawable/watch_24.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/feature/results/src/main/res/drawable/watch_app_placeholder.png b/feature/results/src/main/res/drawable/watch_app_placeholder.png
new file mode 100644
index 00000000..4b1d4515
Binary files /dev/null and b/feature/results/src/main/res/drawable/watch_app_placeholder.png differ
diff --git a/feature/results/src/main/res/drawable/watch_arrow_24.xml b/feature/results/src/main/res/drawable/watch_arrow_24.xml
new file mode 100644
index 00000000..4b6763e9
--- /dev/null
+++ b/feature/results/src/main/res/drawable/watch_arrow_24.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/feature/results/src/main/res/drawable/watch_check_24.xml b/feature/results/src/main/res/drawable/watch_check_24.xml
new file mode 100644
index 00000000..e4c49379
--- /dev/null
+++ b/feature/results/src/main/res/drawable/watch_check_24.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/feature/results/src/main/res/drawable/watch_error_24.xml b/feature/results/src/main/res/drawable/watch_error_24.xml
new file mode 100644
index 00000000..0dcab62e
--- /dev/null
+++ b/feature/results/src/main/res/drawable/watch_error_24.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/feature/results/src/main/res/drawable/watch_face_preview.png b/feature/results/src/main/res/drawable/watch_face_preview.png
new file mode 100644
index 00000000..10814b02
Binary files /dev/null and b/feature/results/src/main/res/drawable/watch_face_preview.png differ
diff --git a/feature/results/src/main/res/values/strings.xml b/feature/results/src/main/res/values/strings.xml
index b437a279..0f6e36f5 100644
--- a/feature/results/src/main/res/values/strings.xml
+++ b/feature/results/src/main/res/values/strings.xml
@@ -41,4 +41,21 @@
- Dressed to impress!
- Hey good looking!
+ Send to watch
+ Sending to watchā¦
+ Watch face sent
+ %s detected.
+ Tap to continue on the watch.
+ Androidify not detected on the %s. Tap to install Androidify on the watch
+ Install Androidify
+ Continue on your watch
+ You\'re all set!
+ Long-press the current watch face to set the watch face manually
+ Update permissions for Androidify to set the active watch face
+ Grant permission for Androidify to set the active watch face
+ All done!
+ Something went wrong
+ Check your watch is connected and try again
+ "No available watch faces"
+ OK
diff --git a/feature/results/src/test/kotlin/com/android/developers/androidify/customize/CustomizeViewModelTest.kt b/feature/results/src/test/kotlin/com/android/developers/androidify/customize/CustomizeViewModelTest.kt
index 7a5df4b9..f15b6af0 100644
--- a/feature/results/src/test/kotlin/com/android/developers/androidify/customize/CustomizeViewModelTest.kt
+++ b/feature/results/src/test/kotlin/com/android/developers/androidify/customize/CustomizeViewModelTest.kt
@@ -23,9 +23,11 @@ import androidx.test.core.app.ApplicationProvider
import com.android.developers.testing.data.TestFileProvider
import com.android.developers.testing.network.TestRemoteConfigDataSource
import com.android.developers.testing.repository.FakeImageGenerationRepository
+import com.android.developers.testing.repository.FakeWatchFaceInstallationRepository
import com.android.developers.testing.util.FakeComposableBitmapRenderer
import com.android.developers.testing.util.MainDispatcherRule
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
@@ -58,6 +60,7 @@ class CustomizeViewModelTest {
viewModel = CustomizeExportViewModel(
FakeImageGenerationRepository(),
composableBitmapRenderer = FakeComposableBitmapRenderer(),
+ watchfaceInstallationRepository = FakeWatchFaceInstallationRepository(),
application = ApplicationProvider.getApplicationContext(),
localFileProvider = TestFileProvider(),
remoteConfigDataSource = remoteConfigDataSource,
@@ -74,16 +77,22 @@ class CustomizeViewModelTest {
@Test
fun setArgumentsWithOriginalImage() = runTest {
+ val initialState = viewModel.state.value
+
viewModel.setArguments(
fakeBitmap,
originalFakeUri,
)
+
+ // Ensure state has changed - view model uses combine to combine state flows so state
+ // update is not immediate
+ val newState = viewModel.state.first { it != initialState }
assertEquals(
CustomizeExportState(
exportImageCanvas = ExportImageCanvas(imageBitmap = fakeBitmap),
originalImageUrl = originalFakeUri,
),
- viewModel.state.value,
+ newState,
)
}
@@ -91,23 +100,32 @@ class CustomizeViewModelTest {
fun setArgumentsWithPrompt() = runTest {
val remoteConfigDataSource = TestRemoteConfigDataSource(true)
remoteConfigDataSource.backgroundVibeEnabled = false
+ val initialState = viewModel.state.value
+
val viewModel = CustomizeExportViewModel(
FakeImageGenerationRepository(),
composableBitmapRenderer = FakeComposableBitmapRenderer(),
application = ApplicationProvider.getApplicationContext(),
localFileProvider = TestFileProvider(),
+ watchfaceInstallationRepository = FakeWatchFaceInstallationRepository(),
remoteConfigDataSource = remoteConfigDataSource,
)
+
viewModel.setArguments(
fakeBitmap,
null,
)
+
+ // Ensure state has changed - view model uses combine to combine state flows so state
+ // update is not immediate
+ val newState = viewModel.state.first { it != initialState }
+
assertEquals(
CustomizeExportState(
exportImageCanvas = ExportImageCanvas(imageBitmap = fakeBitmap),
originalImageUrl = null,
),
- viewModel.state.value,
+ newState,
)
}
@@ -158,6 +176,7 @@ class CustomizeViewModelTest {
val viewModel = CustomizeExportViewModel(
FakeImageGenerationRepository(),
composableBitmapRenderer = FakeComposableBitmapRenderer(),
+ watchfaceInstallationRepository = FakeWatchFaceInstallationRepository(),
application = ApplicationProvider.getApplicationContext(),
localFileProvider = TestFileProvider(),
remoteConfigDataSource = TestRemoteConfigDataSource(false),
@@ -228,15 +247,21 @@ class CustomizeViewModelTest {
composableBitmapRenderer = FakeComposableBitmapRenderer(),
application = ApplicationProvider.getApplicationContext(),
localFileProvider = TestFileProvider(),
+ watchfaceInstallationRepository = FakeWatchFaceInstallationRepository(),
remoteConfigDataSource = remoteConfigDataSource,
)
+
+ val initialState = viewModel.state.value
+
viewModel.setArguments(
fakeBitmap,
null,
)
- val state = viewModel.state.value.toolState[CustomizeTool.Background] as BackgroundToolState
- assertTrue(state.options.size > 5)
- assertTrue(state.options.any { it.aiBackground })
+ val newState = viewModel.state.first { it != initialState }
+ val toolState = newState.toolState[CustomizeTool.Background] as BackgroundToolState
+
+ assertTrue(toolState.options.size > 5)
+ assertTrue(toolState.options.any { it.aiBackground })
}
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index e5729ba9..50188765 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,18 +1,23 @@
[versions]
# build
+appVersionCode = "5"
+appVersionName = "1.1.3"
agp = "8.11.1"
+bcpkixJdk18on = "1.81"
compileSdk = "36"
core = "1.5.0"
leakcanaryAndroid = "2.14"
minSdk = "26"
javaVersion = "17"
jvmTarget = "17"
+wearMinSdk = "36"
#dependencies
accompanist = "0.37.3"
activityCompose = "1.10.1"
adaptive = "1.1.0"
animationAndroid = "1.8.3"
+apksig = "9.0.0-alpha02"
appcompat = "1.7.1"
baselineprofile = "1.4.0"
benchmarkMacroJunit4 = "1.4.0"
@@ -25,14 +30,17 @@ converterGson = "2.11.0"
coreKtx = "1.16.0"
coreSplashscreen = "1.0.1"
crashlytics = "3.0.4"
+datastore = "1.1.7"
espressoCore = "3.6.1"
firebaseBom = "33.16.0"
googleServices = "4.4.3"
googleOss = "17.2.0"
googleOssPlugin = "0.10.6"
+guava = "33.4.8-android"
hiltAndroid = "2.56.2"
hiltLifecycleViewmodel = "1.0.0-alpha03"
hiltNavigationCompose = "1.2.0"
+horologist = "0.7.15"
junit = "4.13.2"
junitVersion = "1.3.0"
kotlin = "2.2.0"
@@ -40,6 +48,7 @@ ksp = "2.2.0-2.0.2"
kotlinxCoroutines = "1.10.2"
kotlinxSerialization = "2.2.0"
kotlinxSerializationJson = "1.9.0"
+kotlinxSerializationProtobuf = "1.8.1"
ktlint = "1.5.0"
lifecycleRuntimeKtx = "2.9.1"
lifecycleViewmodelNavigation3 = "1.0.0-alpha03"
@@ -48,6 +57,7 @@ material = "1.12.0"
media3 = "1.7.1"
navigation3 = "1.0.0-alpha05"
okhttp = "4.12.0"
+playServicesWearable = "19.0.0"
playServicesBaseTesting = "16.1.0"
poseDetection = "18.0.0-beta5"
profileinstaller = "1.4.1"
@@ -59,6 +69,12 @@ runner = "1.6.2"
uiTextGoogleFonts = "1.8.3"
uiautomator = "2.4.0-alpha05"
uiTooling = "1.8.3"
+validatorPush = "1.0.0-alpha07"
+watchFacePush = "1.0.0-alpha01"
+wear = "1.3.0"
+wearCompose = "1.5.0"
+wearComposeTooling = "1.4.1"
+wearRemoteInteractions = "1.1.0"
window = "1.4.0"
aiEdge = "0.0.1-exp02"
lifecycleProcess = "2.9.1"
@@ -84,6 +100,8 @@ androidx-concurrent-futures-ktx = { module = "androidx.concurrent:concurrent-fut
androidx-core = { module = "androidx.test:core", version.ref = "core" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
+androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
+androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
@@ -104,8 +122,15 @@ androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-man
androidx-ui-text-google-fonts = { module = "androidx.compose.ui:ui-text-google-fonts", version.ref = "uiTextGoogleFonts" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+androidx-wear = { module = "androidx.wear:wear", version.ref = "wear" }
+androidx-wear-compose-material = { group = "androidx.wear.compose", name = "compose-material3", version.ref = "wearCompose" }
+androidx-wear-compose-foundation = { group = "androidx.wear.compose", name = "compose-foundation", version.ref = "wearCompose" }
+androidx-wear-compose-ui-tooling = { group = "androidx.wear.compose", name = "compose-ui-tooling", version.ref = "wearComposeTooling" }
+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" }
+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" }
coil-compose-http = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coilCompose" }
coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coilGif" }
@@ -117,19 +142,23 @@ firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "fir
firebase-config = { module = "com.google.firebase:firebase-config" }
firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics" }
firebase-ai = { module = "com.google.firebase:firebase-ai" }
+guava = { module = "com.google.guava:guava", version.ref = "guava" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hiltAndroid" }
hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hiltAndroid" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hiltAndroid" }
+horologist-compose-layout = { module = "com.google.android.horologist:horologist-compose-layout", version.ref = "horologist" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "kotlinxCoroutines"}
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
+kotlinx-serialization-protobuf = { module = "org.jetbrains.kotlinx:kotlinx-serialization-protobuf", version.ref = "kotlinxSerializationProtobuf" }
leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanaryAndroid" }
logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "loggingInterceptor" }
mlkit-common = { group = "com.google.mlkit", name = "common", version.ref = "mlkitCommon" }
mlkit-pose-detection = { module = "com.google.mlkit:pose-detection", version.ref = "poseDetection" }
mlkit-segmentation = { module = "com.google.android.gms:play-services-mlkit-subject-segmentation", version.ref = "mlkitSegmentation" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
+play-services-wearable = { module = "com.google.android.gms:play-services-wearable", version.ref = "playServicesWearable" }
play-services-base-testing = { module = "com.google.android.gms:play-services-base-testing", version.ref = "playServicesBaseTesting" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-kotlin-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" }
@@ -142,6 +171,9 @@ ai-edge = { group = "com.google.ai.edge.aicore", name = "aicore", version.ref =
google-oss-licenses = { group = "com.google.android.gms", name = "play-services-oss-licenses", version.ref = "googleOss" }
google-oss-licenses-plugin = { group = "com.google.android.gms", name = "oss-licenses-plugin", version.ref = "googleOssPlugin" }
androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleProcess" }
+validator-push-android = { module = "com.google.android.wearable.watchface.validator:validator-push-android", version.ref = "validatorPush" }
+validator-push-cli = { module = "com.google.android.wearable.watchface.validator:validator-push-cli", version.ref = "validatorPush" }
+watchface-push = { group = "androidx.wear.watchfacepush", name="watchfacepush", version.ref = "watchFacePush" }
play-services-base = { group = "com.google.android.gms", name = "play-services-base", version.ref = "playServicesBase" }
google-firebase-appcheck-debug = { group = "com.google.firebase", name = "firebase-appcheck-debug"}
[plugins]
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 9cf587c9..d9ff8b8c 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -16,6 +16,13 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
+ maven {
+ url = uri("https://jitpack.io")
+ content {
+ // This is required to use com.google.android.wearable.watchface.validator
+ includeGroup("com.github.xgouchet")
+ }
+ }
}
}
@@ -33,3 +40,7 @@ include(":core:util")
include(":core:theme")
include(":core:testing")
include(":benchmark")
+include(":watchface")
+include(":wear")
+include(":wear:watchface")
+include(":wear:common")
diff --git a/watchface/.gitignore b/watchface/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/watchface/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/watchface/README.md b/watchface/README.md
new file mode 100644
index 00000000..3cb14ac3
--- /dev/null
+++ b/watchface/README.md
@@ -0,0 +1,30 @@
+# Watch face generation
+
+Some details about how this module is used for watch face generation.
+
+## Modifying the watch face
+
+The `assets` directory holds the necessary Watch Face Format resources, namely AndroidManifest.xml,
+watchface.xml, watch_face_info.xml and so on. These files are exactly as output by a tool such as
+the Figma plugin, Watch Face Designer.
+
+In order to modify these to show the Androidify bot, adjust an `` tag to point to the "bot"
+resource, e.g. ``: In compiling the APK, the `bot.png` file will be added, so
+this resource will be available to the watch face.
+
+You should ensure that any unnecessary images are removed from `res/drawable`, for example if Watch
+Face Designer outputted a placeholder image that you are replacing for `bot.png`, remove the
+placeholder image (it's likely large!). Furthermore, ensure that the images are optimized, for
+example, using `pngquant` on all images, to help keep the watch face size to a minimum.
+
+## Packaging the watch face
+
+To package the watch face, the [Pack](https://github.com/google/pack) is used. This is a native
+library, so the pre-builts are provided in `jniLibs`. A script is also included for building these
+fresh, but that should not be necessary.
+
+## Signing the APK
+
+For the purposes of this project, a key is generated at runtime and used to sign the APK. This is
+not the approach to take in production, but the watch face APK must be signed, and it doesn't so
+much matter what key is used to do it.
\ No newline at end of file
diff --git a/watchface/build.gradle.kts b/watchface/build.gradle.kts
new file mode 100644
index 00000000..ff1dd2dd
--- /dev/null
+++ b/watchface/build.gradle.kts
@@ -0,0 +1,77 @@
+/*
+ * 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.
+ */
+
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.hilt)
+ alias(libs.plugins.kotlin.ksp)
+ alias(libs.plugins.serialization)
+}
+
+android {
+ namespace = "com.android.developers.androidify.watchface"
+ compileSdk = libs.versions.compileSdk.get().toInt()
+
+ defaultConfig {
+ minSdk = libs.versions.minSdk.get().toInt()
+ targetSdk = 36
+ testInstrumentationRunner = "com.android.developers.testing.AndroidifyTestRunner"
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.toVersion(libs.versions.javaVersion.get())
+ targetCompatibility = JavaVersion.toVersion(libs.versions.javaVersion.get())
+ }
+ kotlinOptions {
+ jvmTarget = libs.versions.jvmTarget.get()
+ }
+ sourceSets {
+ getByName("androidTest") {
+ assets.srcDir("src/androidTest/assets")
+ }
+ }
+ // To avoid packaging conflicts when using bouncycastle
+ packaging {
+ resources {
+ excludes.add("META-INF/versions/9/OSGI-INF/MANIFEST.MF")
+ }
+ }
+}
+
+dependencies {
+ implementation(libs.hilt.android)
+ ksp(libs.hilt.compiler)
+ implementation(libs.validator.push.android) {
+ exclude(group = "com.google.guava", "listenablefuture")
+ }
+ implementation(libs.bcpkix.jdk18on)
+ implementation(libs.play.services.wearable)
+ implementation(libs.kotlinx.coroutines.play.services)
+ implementation(libs.kotlinx.serialization.protobuf)
+ implementation(projects.wear.common)
+ implementation(projects.core.network)
+ implementation(libs.apksig)
+
+ // For testing
+ androidTestImplementation(libs.robolectric)
+ androidTestImplementation(libs.androidx.ui.test.junit4)
+ androidTestImplementation(libs.hilt.android.testing)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+ androidTestImplementation(projects.core.testing)
+ kspAndroidTest(libs.hilt.compiler)
+}
\ No newline at end of file
diff --git a/watchface/lint.xml b/watchface/lint.xml
new file mode 100644
index 00000000..5c43a4ab
--- /dev/null
+++ b/watchface/lint.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/watchface/pack-java/.gitignore b/watchface/pack-java/.gitignore
new file mode 100644
index 00000000..dcafa97f
--- /dev/null
+++ b/watchface/pack-java/.gitignore
@@ -0,0 +1,2 @@
+/target
+.cargo/
diff --git a/watchface/pack-java/Cargo.lock b/watchface/pack-java/Cargo.lock
new file mode 100644
index 00000000..f8f0a212
--- /dev/null
+++ b/watchface/pack-java/Cargo.lock
@@ -0,0 +1,1569 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "android_log-sys"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d"
+
+[[package]]
+name = "android_logger"
+version = "0.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c494134f746c14dc653a35a4ea5aca24ac368529da5370ecf41fe0341c35772f"
+dependencies = [
+ "android_log-sys",
+ "env_logger",
+ "log",
+ "once_cell",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.99"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100"
+
+[[package]]
+name = "arbitrary"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
+dependencies = [
+ "derive_arbitrary",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "base64ct"
+version = "1.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
+
+[[package]]
+name = "bitflags"
+version = "2.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d"
+
+[[package]]
+name = "bitvec"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c"
+dependencies = [
+ "funty",
+ "radium",
+ "tap",
+ "wyz",
+]
+
+[[package]]
+name = "bitvec-nom2"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d988fcc40055ceaa85edc55875a08f8abd29018582647fd82ad6128dba14a5f0"
+dependencies = [
+ "bitvec",
+ "nom",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
+
+[[package]]
+name = "cesu8"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
+
+[[package]]
+name = "chrono"
+version = "0.4.41"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "combine"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
+[[package]]
+name = "const-oid"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "darling"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
+dependencies = [
+ "darling_core",
+ "darling_macro",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
+dependencies = [
+ "fnv",
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
+dependencies = [
+ "darling_core",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "deku"
+version = "0.19.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f476a022dcfbb013d1365734a42e05b6aca967ebe0d3bb38170086abd9ea3324"
+dependencies = [
+ "bitvec",
+ "deku_derive",
+ "no_std_io2",
+ "rustversion",
+]
+
+[[package]]
+name = "deku_derive"
+version = "0.19.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb216d425bdf810c165a8ae1649523033e88b5f795480ccec63926295541b084"
+dependencies = [
+ "darling",
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "der"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+dependencies = [
+ "const-oid",
+ "pem-rfc7468",
+ "zeroize",
+]
+
+[[package]]
+name = "derive_arbitrary"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "const-oid",
+ "crypto-common",
+]
+
+[[package]]
+name = "either"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
+
+[[package]]
+name = "env_logger"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580"
+dependencies = [
+ "log",
+ "regex",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
+dependencies = [
+ "libc",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
+
+[[package]]
+name = "fixedbitset"
+version = "0.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
+
+[[package]]
+name = "flate2"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d"
+dependencies = [
+ "crc32fast",
+ "libz-rs-sys",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "funty"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi 0.11.1+wasi-snapshot-preview1",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasi 0.14.3+wasi-0.2.4",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
+[[package]]
+name = "indexmap"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2481980430f9f78649238835720ddccc57e52df14ffce1c6f37391d61b563e9"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "itertools"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itertools"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
+
+[[package]]
+name = "jni"
+version = "0.21.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
+dependencies = [
+ "cesu8",
+ "cfg-if",
+ "combine",
+ "jni-sys",
+ "log",
+ "thiserror",
+ "walkdir",
+ "windows-sys 0.45.0",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+dependencies = [
+ "spin",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.175"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543"
+
+[[package]]
+name = "libm"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de"
+
+[[package]]
+name = "libz-rs-sys"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "172a788537a2221661b480fee8dc5f96c580eb34fa88764d3205dc356c7e4221"
+dependencies = [
+ "zlib-rs",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12"
+
+[[package]]
+name = "log"
+version = "0.4.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
+
+[[package]]
+name = "memchr"
+version = "2.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
+
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+]
+
+[[package]]
+name = "multimap"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
+
+[[package]]
+name = "no_std_io2"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c2b9acd47481ab557a89a5665891be79e43cce8a29ad77aa9419d7be5a7c06a"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
+name = "num-bigint"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-bigint-dig"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151"
+dependencies = [
+ "byteorder",
+ "lazy_static",
+ "libm",
+ "num-integer",
+ "num-iter",
+ "num-traits",
+ "rand",
+ "smallvec",
+ "zeroize",
+]
+
+[[package]]
+name = "num-integer"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-iter"
+version = "0.1.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
+dependencies = [
+ "autocfg",
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+ "libm",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
+
+[[package]]
+name = "pack-aab"
+version = "0.1.0"
+dependencies = [
+ "deku",
+ "pack-asset-compiler",
+ "pack-common",
+ "pack-zip",
+ "prost",
+ "prost-build",
+ "xml",
+]
+
+[[package]]
+name = "pack-api"
+version = "0.1.0"
+dependencies = [
+ "deku",
+ "pack-aab",
+ "pack-asset-compiler",
+ "pack-common",
+ "pack-sign",
+ "pack-zip",
+]
+
+[[package]]
+name = "pack-asset-compiler"
+version = "0.1.0"
+dependencies = [
+ "deku",
+ "pack-common",
+ "phf",
+ "xml",
+]
+
+[[package]]
+name = "pack-common"
+version = "0.1.0"
+dependencies = [
+ "deku",
+ "pem",
+ "rasn",
+ "rsa",
+ "xml",
+ "zip",
+]
+
+[[package]]
+name = "pack-java"
+version = "0.1.0"
+dependencies = [
+ "android_logger",
+ "base64",
+ "jni",
+ "log",
+ "pack-api",
+]
+
+[[package]]
+name = "pack-sign"
+version = "0.1.0"
+dependencies = [
+ "base64",
+ "byteorder",
+ "deku",
+ "pack-common",
+ "pack-zip",
+ "pem",
+ "rasn",
+ "rasn-cms",
+ "rasn-pkix",
+ "rsa",
+ "sha2",
+]
+
+[[package]]
+name = "pack-zip"
+version = "0.1.0"
+dependencies = [
+ "pack-common",
+ "zip",
+]
+
+[[package]]
+name = "pem"
+version = "3.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3"
+dependencies = [
+ "base64",
+ "serde",
+]
+
+[[package]]
+name = "pem-rfc7468"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
+dependencies = [
+ "base64ct",
+]
+
+[[package]]
+name = "petgraph"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
+dependencies = [
+ "fixedbitset",
+ "indexmap",
+]
+
+[[package]]
+name = "phf"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
+dependencies = [
+ "phf_macros",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
+dependencies = [
+ "phf_shared",
+ "rand",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
+dependencies = [
+ "siphasher",
+]
+
+[[package]]
+name = "pkcs1"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
+dependencies = [
+ "der",
+ "pkcs8",
+ "spki",
+]
+
+[[package]]
+name = "pkcs8"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
+dependencies = [
+ "der",
+ "spki",
+]
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "3.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35"
+dependencies = [
+ "toml_edit",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.101"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "prost"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7231bd9b3d3d33c86b58adbac74b5ec0ad9f496b19d22801d773636feaa95f3d"
+dependencies = [
+ "bytes",
+ "prost-derive",
+]
+
+[[package]]
+name = "prost-build"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac6c3320f9abac597dcbc668774ef006702672474aad53c6d596b62e487b40b1"
+dependencies = [
+ "heck",
+ "itertools 0.14.0",
+ "log",
+ "multimap",
+ "once_cell",
+ "petgraph",
+ "prettyplease",
+ "prost",
+ "prost-types",
+ "regex",
+ "syn",
+ "tempfile",
+]
+
+[[package]]
+name = "prost-derive"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425"
+dependencies = [
+ "anyhow",
+ "itertools 0.14.0",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "prost-types"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9b4db3d6da204ed77bb26ba83b6122a73aeb2e87e25fbf7ad2e84c4ccbf8f72"
+dependencies = [
+ "prost",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "radium"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
+
+[[package]]
+name = "rand"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
+dependencies = [
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.16",
+]
+
+[[package]]
+name = "rasn"
+version = "0.27.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83235a41e6f6c118df9839b56a87e3a2812a717a3a1b3b1f5afcdd9ef75ce4bf"
+dependencies = [
+ "bitvec",
+ "bitvec-nom2",
+ "bytes",
+ "cfg-if",
+ "chrono",
+ "either",
+ "nom",
+ "num-bigint",
+ "num-integer",
+ "num-traits",
+ "once_cell",
+ "rasn-derive",
+ "serde_json",
+ "snafu",
+ "xml-no-std",
+]
+
+[[package]]
+name = "rasn-cms"
+version = "0.27.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a110a412529e8453003b76b5bc9e72ea67627db6ffe7b83edf251c36c6cb3af7"
+dependencies = [
+ "rasn",
+ "rasn-pkix",
+]
+
+[[package]]
+name = "rasn-derive"
+version = "0.27.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8d1c20f94d8bceee8d14e18fcf823110a873d396877f3baa55c119c12a3af5c"
+dependencies = [
+ "proc-macro2",
+ "rasn-derive-impl",
+ "syn",
+]
+
+[[package]]
+name = "rasn-derive-impl"
+version = "0.27.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bded21caf3a1a0647947c2b22fc14b535213d9c3a5646968171a39b012ebaaaa"
+dependencies = [
+ "either",
+ "itertools 0.13.0",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "uuid",
+]
+
+[[package]]
+name = "rasn-pkix"
+version = "0.27.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95a785651a36bb63e36aece1cc08abe95d7f3aa04845a40f10acc32543808695"
+dependencies = [
+ "rasn",
+]
+
+[[package]]
+name = "regex"
+version = "1.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001"
+
+[[package]]
+name = "rsa"
+version = "0.9.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b"
+dependencies = [
+ "const-oid",
+ "digest",
+ "num-bigint-dig",
+ "num-integer",
+ "num-traits",
+ "pkcs1",
+ "pkcs8",
+ "rand_core",
+ "sha2",
+ "signature",
+ "spki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustix"
+version = "1.0.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "ryu"
+version = "1.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.219"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.219"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.143"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a"
+dependencies = [
+ "itoa",
+ "memchr",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "signature"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
+dependencies = [
+ "digest",
+ "rand_core",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe"
+
+[[package]]
+name = "siphasher"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "snafu"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4800ae0e2ebdfaea32ffb9745642acdc378740dcbd74d3fb3cd87572a34810c6"
+dependencies = [
+ "snafu-derive",
+]
+
+[[package]]
+name = "snafu-derive"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "186f5ba9999528053fb497fdf0dd330efcc69cfe4ad03776c9d704bc54fee10f"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "spin"
+version = "0.9.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
+
+[[package]]
+name = "spki"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+dependencies = [
+ "base64ct",
+ "der",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "tap"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
+
+[[package]]
+name = "tempfile"
+version = "3.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15b61f8f20e3a6f7e0649d825294eaf317edce30f82cf6026e7e4cb9222a7d1e"
+dependencies = [
+ "fastrand",
+ "getrandom 0.3.3",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
+
+[[package]]
+name = "toml_edit"
+version = "0.22.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
+dependencies = [
+ "indexmap",
+ "toml_datetime",
+ "winnow",
+]
+
+[[package]]
+name = "typenum"
+version = "1.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
+
+[[package]]
+name = "uuid"
+version = "1.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f33196643e165781c20a5ead5582283a7dacbb87855d867fbc2df3f81eddc1be"
+dependencies = [
+ "getrandom 0.3.3",
+]
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasi"
+version = "0.14.3+wasi-0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a51ae83037bdd272a9e28ce236db8c07016dd0d50c27038b3f407533c030c95"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "winapi-util"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0978bf7171b3d90bac376700cb56d606feb40f251a475a5d6634613564460b22"
+dependencies = [
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
+
+[[package]]
+name = "windows-sys"
+version = "0.45.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
+dependencies = [
+ "windows-targets 0.42.2",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets 0.53.3",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
+dependencies = [
+ "windows_aarch64_gnullvm 0.42.2",
+ "windows_aarch64_msvc 0.42.2",
+ "windows_i686_gnu 0.42.2",
+ "windows_i686_msvc 0.42.2",
+ "windows_x86_64_gnu 0.42.2",
+ "windows_x86_64_gnullvm 0.42.2",
+ "windows_x86_64_msvc 0.42.2",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.53.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91"
+dependencies = [
+ "windows-link",
+ "windows_aarch64_gnullvm 0.53.0",
+ "windows_aarch64_msvc 0.53.0",
+ "windows_i686_gnu 0.53.0",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc 0.53.0",
+ "windows_x86_64_gnu 0.53.0",
+ "windows_x86_64_gnullvm 0.53.0",
+ "windows_x86_64_msvc 0.53.0",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.53.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.53.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.53.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.53.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.53.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.53.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.53.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.53.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
+
+[[package]]
+name = "winnow"
+version = "0.7.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.45.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "052283831dbae3d879dc7f51f3d92703a316ca49f91540417d38591826127814"
+
+[[package]]
+name = "wyz"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
+dependencies = [
+ "tap",
+]
+
+[[package]]
+name = "xml"
+version = "0.8.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ede1c99c55b4b3ad0349018ef0eccbe954ce9c342334410707ee87177fcf2ab4"
+dependencies = [
+ "xml-rs",
+]
+
+[[package]]
+name = "xml-no-std"
+version = "0.8.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd223bc94c615fc02bf2f4bbc22a4a9bfe489c2add3ec10b1038df3aca44cac7"
+
+[[package]]
+name = "xml-rs"
+version = "0.8.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6fd8403733700263c6eb89f192880191f1b83e332f7a20371ddcf421c4a337c7"
+
+[[package]]
+name = "zerocopy"
+version = "0.8.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
+
+[[package]]
+name = "zip"
+version = "4.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8835eb39822904d39cb19465de1159e05d371973f0c6df3a365ad50565ddc8b9"
+dependencies = [
+ "arbitrary",
+ "crc32fast",
+ "flate2",
+ "indexmap",
+ "memchr",
+ "zopfli",
+]
+
+[[package]]
+name = "zlib-rs"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "626bd9fa9734751fc50d6060752170984d7053f5a39061f524cda68023d4db8a"
+
+[[package]]
+name = "zopfli"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edfc5ee405f504cd4984ecc6f14d02d55cfda60fa4b689434ef4102aae150cd7"
+dependencies = [
+ "bumpalo",
+ "crc32fast",
+ "log",
+ "simd-adler32",
+]
diff --git a/watchface/pack-java/Cargo.toml b/watchface/pack-java/Cargo.toml
new file mode 100644
index 00000000..5cb80ea6
--- /dev/null
+++ b/watchface/pack-java/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "pack-java"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+crate-type = ["dylib"]
+
+[dependencies]
+pack-api = { git = "https://github.com/google/pack.git" }
+#pack-api = { path = "../pack/pack-api" }
+jni = "0.21.1"
+base64 = "0.22.1"
+android_logger = "0.13"
+log = "0.4"
+
+[workspace]
diff --git a/watchface/pack-java/src/lib.rs b/watchface/pack-java/src/lib.rs
new file mode 100644
index 00000000..515c93df
--- /dev/null
+++ b/watchface/pack-java/src/lib.rs
@@ -0,0 +1,76 @@
+// Copyright 2025 Google LLC
+//
+// 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
+//
+// http://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.
+
+use base64::{engine::general_purpose, Engine};
+use jni::{
+ objects::{JClass, JObject, JObjectArray, JString},
+ sys::{jstring},
+ JNIEnv
+};
+use pack_api::{compile_apk, FileResource, Package};
+
+// Name (MUST) follow Java_packageName_className_methodName
+/// # Safety
+/// Function must be unsafe because it is called via Java JNI
+#[no_mangle]
+pub unsafe extern "C" fn Java_com_android_developers_androidify_watchface_creator_PackPackage_nativeCompilePackage(
+ mut env: JNIEnv,
+ _this: JClass,
+ manifest_jstring: JString,
+ resources: JObjectArray
+) -> jstring {
+ let manifest: String = env.get_string(&manifest_jstring).unwrap().into();
+
+ let mut pack_resources = vec![];
+ let resource_len = env.get_array_length(&resources).unwrap();
+ for index in 0..resource_len {
+ let resource = env.get_object_array_element(&resources, index).unwrap();
+ let name = get_string_field_from_java_class(&mut env, &resource, "name");
+ let subdirectory = get_string_field_from_java_class(&mut env, &resource, "subdirectory");
+ let contents_b64 = get_string_field_from_java_class(&mut env, &resource, "contentsBase64");
+ let contents = b64_to_bytes(&contents_b64);
+
+ let pack_resource = FileResource::new(subdirectory, name, contents);
+ pack_resources.push(pack_resource);
+ }
+
+ let package = Package {
+ android_manifest: manifest.as_bytes().to_vec(),
+ resources: pack_resources
+ };
+
+ let finished_package = compile_apk(&package).unwrap();
+ let pkg_b64 = bytes_to_b64(&finished_package);
+
+ env.new_string(pkg_b64).unwrap().into_raw()
+}
+
+fn b64_to_bytes(b64: &str) -> Vec {
+ general_purpose::STANDARD.decode(b64.as_bytes()).unwrap()
+}
+
+fn bytes_to_b64(bytes: &Vec) -> String {
+ general_purpose::STANDARD.encode(bytes)
+}
+
+const JAVA_STRING_TYPE: &str = "Ljava/lang/String;";
+
+fn get_string_field_from_java_class(env: &mut JNIEnv, class: &JObject, field_name: &str) -> String {
+ let field_object = env
+ .get_field(class, field_name, JAVA_STRING_TYPE)
+ .unwrap()
+ .l()
+ .unwrap();
+ env.get_string(&field_object.into()).unwrap().into()
+}
\ No newline at end of file
diff --git a/watchface/proguard-rules.pro b/watchface/proguard-rules.pro
new file mode 100644
index 00000000..3328ea7a
--- /dev/null
+++ b/watchface/proguard-rules.pro
@@ -0,0 +1,9 @@
+# Ignore missing Java SE classes from TwelveMonkeys ImageIO
+-dontwarn javax.imageio.**
+
+# Ignore missing Java SE classes from XML libraries (Xerces, etc.)
+-dontwarn org.apache.xml.resolver.**
+-dontwarn org.eclipse.wst.xml.xpath2.processor.**
+
+# Ignore missing Java SE annotation processing classes, often from libraries like AutoValue/JavaPoet
+-dontwarn javax.lang.model.**
\ No newline at end of file
diff --git a/watchface/provide-libraries-to-androidify-project.sh b/watchface/provide-libraries-to-androidify-project.sh
new file mode 100755
index 00000000..217a9711
--- /dev/null
+++ b/watchface/provide-libraries-to-androidify-project.sh
@@ -0,0 +1,31 @@
+#!/bin/bash
+# Copyright 2025 Google LLC
+#
+# 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
+#
+# http://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.
+
+cd pack-java
+cargo build --release --target aarch64-linux-android && \
+cargo build --release --target x86_64-linux-android && \
+cargo build --release --target armv7-linux-androideabi && \
+cargo build --release --target i686-linux-android && \
+\
+mkdir -p ../src/main/jniLibs/arm64-v8a/ && \
+cp ./target/aarch64-linux-android/release/libpack_java.so ../src/main/jniLibs/arm64-v8a/libpack_java.so && \
+mkdir -p ../src/main/jniLibs/x86_64/ && \
+cp ./target/x86_64-linux-android/release/libpack_java.so ../src/main/jniLibs/x86_64/libpack_java.so && \
+mkdir -p ../src/main/jniLibs/armeabi-v7a/ && \
+cp ./target/armv7-linux-androideabi/release/libpack_java.so ../src/main/jniLibs/armeabi-v7a/libpack_java.so && \
+mkdir -p ../src/main/jniLibs/x86/ && \
+cp ./target/i686-linux-android/release/libpack_java.so ../src/main/jniLibs/x86/libpack_java.so && \
+\
+echo "Compiled and saved API for Android ARM32, ARM64, x86 and x86_64"
diff --git a/watchface/src/androidTest/assets/invalid_watchface_xml/AndroidManifest.xml b/watchface/src/androidTest/assets/invalid_watchface_xml/AndroidManifest.xml
new file mode 100644
index 00000000..2fef66db
--- /dev/null
+++ b/watchface/src/androidTest/assets/invalid_watchface_xml/AndroidManifest.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_0.png b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_0.png
new file mode 100644
index 00000000..ce7d4476
Binary files /dev/null and b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_0.png differ
diff --git a/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_1.png b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_1.png
new file mode 100644
index 00000000..f5b13d9c
Binary files /dev/null and b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_1.png differ
diff --git a/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_2.png b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_2.png
new file mode 100644
index 00000000..77ef2696
Binary files /dev/null and b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_2.png differ
diff --git a/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_3.png b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_3.png
new file mode 100644
index 00000000..aae3b5bd
Binary files /dev/null and b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_3.png differ
diff --git a/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_4.png b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_4.png
new file mode 100644
index 00000000..5762401c
Binary files /dev/null and b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_4.png differ
diff --git a/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_5.png b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_5.png
new file mode 100644
index 00000000..fdc8aaa7
Binary files /dev/null and b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_5.png differ
diff --git a/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_6.png b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_6.png
new file mode 100644
index 00000000..4e0df6f8
Binary files /dev/null and b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_6.png differ
diff --git a/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_7.png b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_7.png
new file mode 100644
index 00000000..5f9e6f56
Binary files /dev/null and b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_7.png differ
diff --git a/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_8.png b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_8.png
new file mode 100644
index 00000000..72a27d64
Binary files /dev/null and b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_8.png differ
diff --git a/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_9.png b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_9.png
new file mode 100644
index 00000000..be05c16f
Binary files /dev/null and b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_9.png differ
diff --git a/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_a.png b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_a.png
new file mode 100644
index 00000000..d7ca4fa4
Binary files /dev/null and b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_a.png differ
diff --git a/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_a_upper.png b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_a_upper.png
new file mode 100644
index 00000000..4747bfd1
Binary files /dev/null and b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_a_upper.png differ
diff --git a/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_colon.png b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_colon.png
new file mode 100644
index 00000000..66ab8dc7
Binary files /dev/null and b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_colon.png differ
diff --git a/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_dot.png b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_dot.png
new file mode 100644
index 00000000..26ac6bd6
Binary files /dev/null and b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_dot.png differ
diff --git a/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_hyphen.png b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_hyphen.png
new file mode 100644
index 00000000..24be7fcf
Binary files /dev/null and b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_hyphen.png differ
diff --git a/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_m.png b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_m.png
new file mode 100644
index 00000000..d441176b
Binary files /dev/null and b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_m.png differ
diff --git a/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_m_upper.png b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_m_upper.png
new file mode 100644
index 00000000..74b85738
Binary files /dev/null and b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_m_upper.png differ
diff --git a/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_p.png b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_p.png
new file mode 100644
index 00000000..32e26758
Binary files /dev/null and b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_p.png differ
diff --git a/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_p_upper.png b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_p_upper.png
new file mode 100644
index 00000000..e27119af
Binary files /dev/null and b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/font_googlesansflexmedium_p_upper.png differ
diff --git a/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/preview.png b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/preview.png
new file mode 100644
index 00000000..862bdf40
Binary files /dev/null and b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/preview.png differ
diff --git a/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/res_16041319.png b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/res_16041319.png
new file mode 100644
index 00000000..482e41de
Binary files /dev/null and b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/res_16041319.png differ
diff --git a/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/res_16041321.png b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/res_16041321.png
new file mode 100644
index 00000000..f7cc21bc
Binary files /dev/null and b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/res_16041321.png differ
diff --git a/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/res_16041324.png b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/res_16041324.png
new file mode 100644
index 00000000..482e41de
Binary files /dev/null and b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/res_16041324.png differ
diff --git a/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/res_16041416.png b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/res_16041416.png
new file mode 100644
index 00000000..1461ca4e
Binary files /dev/null and b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/res_16041416.png differ
diff --git a/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/res_194662175.png b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/res_194662175.png
new file mode 100644
index 00000000..e52cf6b1
Binary files /dev/null and b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/res_194662175.png differ
diff --git a/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/res_194662176.png b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/res_194662176.png
new file mode 100644
index 00000000..157c5675
Binary files /dev/null and b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/res_194662176.png differ
diff --git a/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/res_194662178.png b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/res_194662178.png
new file mode 100644
index 00000000..3caf6775
Binary files /dev/null and b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/res_194662178.png differ
diff --git a/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/res_40011676.png b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/res_40011676.png
new file mode 100644
index 00000000..e52cf6b1
Binary files /dev/null and b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/res_40011676.png differ
diff --git a/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/res_40011677.png b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/res_40011677.png
new file mode 100644
index 00000000..157c5675
Binary files /dev/null and b/watchface/src/androidTest/assets/invalid_watchface_xml/res/drawable/res_40011677.png differ
diff --git a/watchface/src/androidTest/assets/invalid_watchface_xml/res/raw/watchface.xml b/watchface/src/androidTest/assets/invalid_watchface_xml/res/raw/watchface.xml
new file mode 100644
index 00000000..adfb5b29
--- /dev/null
+++ b/watchface/src/androidTest/assets/invalid_watchface_xml/res/raw/watchface.xml
@@ -0,0 +1,243 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %s
+
+
+
+
+
+
+
+
+
+
+ %s
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %s
+
+
+
+
+
+
+
+
+
+
+ %s
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %s%s
+
+
+
+
+
+
+
+
+
+
+
+ %s%s
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %s%s
+
+
+
+
+
+
+
+
+
+
+
+ %s%s
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/watchface/src/androidTest/assets/invalid_watchface_xml/res/values/strings.xml b/watchface/src/androidTest/assets/invalid_watchface_xml/res/values/strings.xml
new file mode 100644
index 00000000..652eb879
--- /dev/null
+++ b/watchface/src/androidTest/assets/invalid_watchface_xml/res/values/strings.xml
@@ -0,0 +1,17 @@
+
+
+
\ No newline at end of file
diff --git a/watchface/src/androidTest/assets/invalid_watchface_xml/res/xml/watch_face_info.xml b/watchface/src/androidTest/assets/invalid_watchface_xml/res/xml/watch_face_info.xml
new file mode 100644
index 00000000..8d44e33e
--- /dev/null
+++ b/watchface/src/androidTest/assets/invalid_watchface_xml/res/xml/watch_face_info.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/watchface/src/androidTest/java/com/android/developers/androidify/watchface/WatchFaceCreatorImplTest.kt b/watchface/src/androidTest/java/com/android/developers/androidify/watchface/WatchFaceCreatorImplTest.kt
new file mode 100644
index 00000000..e75f3375
--- /dev/null
+++ b/watchface/src/androidTest/java/com/android/developers/androidify/watchface/WatchFaceCreatorImplTest.kt
@@ -0,0 +1,112 @@
+/*
+ * 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.watchface
+
+import android.content.Context
+import android.graphics.Bitmap
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import com.android.developers.androidify.watchface.creator.WatchFaceCreatorImpl
+import com.android.developers.androidify.watchface.creator.WatchFacePackage
+import com.android.developers.androidify.watchface.transfer.MIN_WATCH_FACE_SDK_VERSION
+import junit.framework.TestCase.assertFalse
+import junit.framework.TestCase.assertNotNull
+import junit.framework.TestCase.assertNull
+import junit.framework.TestCase.assertTrue
+import junit.framework.TestCase.fail
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.File
+import java.io.IOException
+
+@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = MIN_WATCH_FACE_SDK_VERSION)
+class WatchFaceCreatorImplTest {
+ private lateinit var context: Context
+ private lateinit var watchFaceCreator: WatchFaceCreatorImpl
+ private var tempWatchFaceFile: File? = null
+
+ @Before
+ fun setUp() {
+ context = ApplicationProvider.getApplicationContext()
+ watchFaceCreator = WatchFaceCreatorImpl(context)
+ }
+
+ @Test
+ fun createWatchFacePackage_withRealAssetsAndRealValidator_success() {
+ val sampleBitmap = Bitmap.createBitmap(312, 312, Bitmap.Config.ARGB_8888)
+ val watchFaceName = "androiddigital"
+
+ var watchFacePackage: WatchFacePackage? = null
+ var exception: Exception? = null
+
+ try {
+ watchFacePackage = watchFaceCreator.createWatchFacePackage(sampleBitmap, watchFaceName)
+ tempWatchFaceFile = watchFacePackage.file
+ } catch (e: IllegalStateException) {
+ exception = e
+ println("Watch face validation failed: ${e.message}")
+ } catch (e: IOException) {
+ exception = e
+ println("Asset loading failed: ${e.message}")
+ } catch (e: Exception) {
+ exception = e
+ println("An unexpected error occurred: ${e.message}")
+ e.printStackTrace()
+ }
+
+ assertNull("Test failed due to an exception: ${exception?.message}", exception)
+ assertNotNull("WatchFacePackage should not be null", watchFacePackage)
+ assertNotNull("Watch face file should not be null", watchFacePackage!!.file)
+ assertTrue("Watch face file should exist", watchFacePackage.file.exists())
+ assertTrue("File name should start with 'watchface'", watchFacePackage.file.name.startsWith("watchface"))
+ assertTrue("File name should end with '.apk'", watchFacePackage.file.name.endsWith(".apk"))
+
+ assertNotNull("Validation token should not be null", watchFacePackage.validationToken)
+ assertFalse("Validation token should not be empty", watchFacePackage.validationToken.isEmpty())
+ }
+
+ @Test
+ fun createWatchFacePackage_invalidAssets_throwsIllegalStateException() {
+ val watchFaceNameForFailureTest = "invalid_watchface_xml"
+ val sampleBitmap = Bitmap.createBitmap(312, 312, Bitmap.Config.ARGB_8888)
+ var exception: IllegalStateException? = null
+
+ try {
+ val watchFacePackage = watchFaceCreator.createWatchFacePackage(sampleBitmap, watchFaceNameForFailureTest)
+ tempWatchFaceFile = watchFacePackage.file
+ } catch (e: IllegalStateException) {
+ exception = e
+ } catch (e: Exception) {
+ fail("Expected IllegalStateException for validation failure, but got ${e.javaClass.simpleName}: ${e.message}")
+ }
+
+ assertNotNull(
+ "Expected IllegalStateException because watch face validation should fail for '$watchFaceNameForFailureTest'",
+ exception,
+ )
+ }
+
+ @After
+ fun tearDown() {
+ tempWatchFaceFile?.deleteOnExit()
+ tempWatchFaceFile?.delete()
+ tempWatchFaceFile = null
+ }
+}
diff --git a/watchface/src/main/AndroidManifest.xml b/watchface/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..c2958604
--- /dev/null
+++ b/watchface/src/main/AndroidManifest.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/watchface/src/main/assets/androidanalogue/AndroidManifest.xml b/watchface/src/main/assets/androidanalogue/AndroidManifest.xml
new file mode 100644
index 00000000..10988f38
--- /dev/null
+++ b/watchface/src/main/assets/androidanalogue/AndroidManifest.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/watchface/src/main/assets/androidanalogue/res/drawable/preview.png b/watchface/src/main/assets/androidanalogue/res/drawable/preview.png
new file mode 100644
index 00000000..24e3cf8c
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue/res/drawable/preview.png differ
diff --git a/watchface/src/main/assets/androidanalogue/res/drawable/res_9506934.png b/watchface/src/main/assets/androidanalogue/res/drawable/res_9506934.png
new file mode 100644
index 00000000..3f5869c3
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue/res/drawable/res_9506934.png differ
diff --git a/watchface/src/main/assets/androidanalogue/res/drawable/res_9506948.png b/watchface/src/main/assets/androidanalogue/res/drawable/res_9506948.png
new file mode 100644
index 00000000..d226db03
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue/res/drawable/res_9506948.png differ
diff --git a/watchface/src/main/assets/androidanalogue/res/drawable/res_9506950.png b/watchface/src/main/assets/androidanalogue/res/drawable/res_9506950.png
new file mode 100644
index 00000000..e4c4a095
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue/res/drawable/res_9506950.png differ
diff --git a/watchface/src/main/assets/androidanalogue/res/drawable/res_9506952.png b/watchface/src/main/assets/androidanalogue/res/drawable/res_9506952.png
new file mode 100644
index 00000000..fbf14b08
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue/res/drawable/res_9506952.png differ
diff --git a/watchface/src/main/assets/androidanalogue/res/drawable/res_9506972.png b/watchface/src/main/assets/androidanalogue/res/drawable/res_9506972.png
new file mode 100644
index 00000000..e4c4a095
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue/res/drawable/res_9506972.png differ
diff --git a/watchface/src/main/assets/androidanalogue/res/drawable/res_9506974.png b/watchface/src/main/assets/androidanalogue/res/drawable/res_9506974.png
new file mode 100644
index 00000000..fbf14b08
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue/res/drawable/res_9506974.png differ
diff --git a/watchface/src/main/assets/androidanalogue/res/raw/watchface.xml b/watchface/src/main/assets/androidanalogue/res/raw/watchface.xml
new file mode 100644
index 00000000..f293e87e
--- /dev/null
+++ b/watchface/src/main/assets/androidanalogue/res/raw/watchface.xml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/watchface/src/main/assets/androidanalogue/res/values/strings.xml b/watchface/src/main/assets/androidanalogue/res/values/strings.xml
new file mode 100644
index 00000000..652eb879
--- /dev/null
+++ b/watchface/src/main/assets/androidanalogue/res/values/strings.xml
@@ -0,0 +1,17 @@
+
+
+
\ No newline at end of file
diff --git a/watchface/src/main/assets/androidanalogue/res/xml/watch_face_info.xml b/watchface/src/main/assets/androidanalogue/res/xml/watch_face_info.xml
new file mode 100644
index 00000000..b0893286
--- /dev/null
+++ b/watchface/src/main/assets/androidanalogue/res/xml/watch_face_info.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/watchface/src/main/assets/androidanalogue2/AndroidManifest.xml b/watchface/src/main/assets/androidanalogue2/AndroidManifest.xml
new file mode 100644
index 00000000..10988f38
--- /dev/null
+++ b/watchface/src/main/assets/androidanalogue2/AndroidManifest.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/watchface/src/main/assets/androidanalogue2/res/drawable/preview.png b/watchface/src/main/assets/androidanalogue2/res/drawable/preview.png
new file mode 100644
index 00000000..10814b02
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue2/res/drawable/preview.png differ
diff --git a/watchface/src/main/assets/androidanalogue2/res/drawable/res_8776906.png b/watchface/src/main/assets/androidanalogue2/res/drawable/res_8776906.png
new file mode 100644
index 00000000..ea67a49e
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue2/res/drawable/res_8776906.png differ
diff --git a/watchface/src/main/assets/androidanalogue2/res/drawable/res_8776907.png b/watchface/src/main/assets/androidanalogue2/res/drawable/res_8776907.png
new file mode 100644
index 00000000..e9a6c498
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue2/res/drawable/res_8776907.png differ
diff --git a/watchface/src/main/assets/androidanalogue2/res/drawable/res_8776920.png b/watchface/src/main/assets/androidanalogue2/res/drawable/res_8776920.png
new file mode 100644
index 00000000..5a766a3b
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue2/res/drawable/res_8776920.png differ
diff --git a/watchface/src/main/assets/androidanalogue2/res/drawable/res_8776922.png b/watchface/src/main/assets/androidanalogue2/res/drawable/res_8776922.png
new file mode 100644
index 00000000..918f4388
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue2/res/drawable/res_8776922.png differ
diff --git a/watchface/src/main/assets/androidanalogue2/res/drawable/res_8776924.png b/watchface/src/main/assets/androidanalogue2/res/drawable/res_8776924.png
new file mode 100644
index 00000000..1ed2a408
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue2/res/drawable/res_8776924.png differ
diff --git a/watchface/src/main/assets/androidanalogue2/res/drawable/res_8776946.png b/watchface/src/main/assets/androidanalogue2/res/drawable/res_8776946.png
new file mode 100644
index 00000000..e4c4a095
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue2/res/drawable/res_8776946.png differ
diff --git a/watchface/src/main/assets/androidanalogue2/res/drawable/res_8776948.png b/watchface/src/main/assets/androidanalogue2/res/drawable/res_8776948.png
new file mode 100644
index 00000000..fbf14b08
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue2/res/drawable/res_8776948.png differ
diff --git a/watchface/src/main/assets/androidanalogue2/res/raw/watchface.xml b/watchface/src/main/assets/androidanalogue2/res/raw/watchface.xml
new file mode 100644
index 00000000..137106c9
--- /dev/null
+++ b/watchface/src/main/assets/androidanalogue2/res/raw/watchface.xml
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/watchface/src/main/assets/androidanalogue2/res/values/strings.xml b/watchface/src/main/assets/androidanalogue2/res/values/strings.xml
new file mode 100644
index 00000000..652eb879
--- /dev/null
+++ b/watchface/src/main/assets/androidanalogue2/res/values/strings.xml
@@ -0,0 +1,17 @@
+
+
+
\ No newline at end of file
diff --git a/watchface/src/main/assets/androidanalogue2/res/xml/watch_face_info.xml b/watchface/src/main/assets/androidanalogue2/res/xml/watch_face_info.xml
new file mode 100644
index 00000000..b0893286
--- /dev/null
+++ b/watchface/src/main/assets/androidanalogue2/res/xml/watch_face_info.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/watchface/src/main/assets/androidanalogue3/AndroidManifest.xml b/watchface/src/main/assets/androidanalogue3/AndroidManifest.xml
new file mode 100644
index 00000000..10988f38
--- /dev/null
+++ b/watchface/src/main/assets/androidanalogue3/AndroidManifest.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/watchface/src/main/assets/androidanalogue3/res/drawable/preview.png b/watchface/src/main/assets/androidanalogue3/res/drawable/preview.png
new file mode 100644
index 00000000..3e369c86
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue3/res/drawable/preview.png differ
diff --git a/watchface/src/main/assets/androidanalogue3/res/drawable/res_87612002.png b/watchface/src/main/assets/androidanalogue3/res/drawable/res_87612002.png
new file mode 100644
index 00000000..1efe916d
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue3/res/drawable/res_87612002.png differ
diff --git a/watchface/src/main/assets/androidanalogue3/res/drawable/res_87612008.png b/watchface/src/main/assets/androidanalogue3/res/drawable/res_87612008.png
new file mode 100644
index 00000000..d226db03
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue3/res/drawable/res_87612008.png differ
diff --git a/watchface/src/main/assets/androidanalogue3/res/drawable/res_87612010.png b/watchface/src/main/assets/androidanalogue3/res/drawable/res_87612010.png
new file mode 100644
index 00000000..e4c4a095
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue3/res/drawable/res_87612010.png differ
diff --git a/watchface/src/main/assets/androidanalogue3/res/drawable/res_87612012.png b/watchface/src/main/assets/androidanalogue3/res/drawable/res_87612012.png
new file mode 100644
index 00000000..fbf14b08
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue3/res/drawable/res_87612012.png differ
diff --git a/watchface/src/main/assets/androidanalogue3/res/drawable/res_87619278.png b/watchface/src/main/assets/androidanalogue3/res/drawable/res_87619278.png
new file mode 100644
index 00000000..e4c4a095
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue3/res/drawable/res_87619278.png differ
diff --git a/watchface/src/main/assets/androidanalogue3/res/drawable/res_87619280.png b/watchface/src/main/assets/androidanalogue3/res/drawable/res_87619280.png
new file mode 100644
index 00000000..fbf14b08
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue3/res/drawable/res_87619280.png differ
diff --git a/watchface/src/main/assets/androidanalogue3/res/raw/watchface.xml b/watchface/src/main/assets/androidanalogue3/res/raw/watchface.xml
new file mode 100644
index 00000000..99a40f5c
--- /dev/null
+++ b/watchface/src/main/assets/androidanalogue3/res/raw/watchface.xml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/watchface/src/main/assets/androidanalogue3/res/values/strings.xml b/watchface/src/main/assets/androidanalogue3/res/values/strings.xml
new file mode 100644
index 00000000..652eb879
--- /dev/null
+++ b/watchface/src/main/assets/androidanalogue3/res/values/strings.xml
@@ -0,0 +1,17 @@
+
+
+
\ No newline at end of file
diff --git a/watchface/src/main/assets/androidanalogue3/res/xml/watch_face_info.xml b/watchface/src/main/assets/androidanalogue3/res/xml/watch_face_info.xml
new file mode 100644
index 00000000..b0893286
--- /dev/null
+++ b/watchface/src/main/assets/androidanalogue3/res/xml/watch_face_info.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/watchface/src/main/assets/androidanalogue4/AndroidManifest.xml b/watchface/src/main/assets/androidanalogue4/AndroidManifest.xml
new file mode 100644
index 00000000..10988f38
--- /dev/null
+++ b/watchface/src/main/assets/androidanalogue4/AndroidManifest.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/watchface/src/main/assets/androidanalogue4/res/drawable/preview.png b/watchface/src/main/assets/androidanalogue4/res/drawable/preview.png
new file mode 100644
index 00000000..e511f893
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue4/res/drawable/preview.png differ
diff --git a/watchface/src/main/assets/androidanalogue4/res/drawable/res_19462929.png b/watchface/src/main/assets/androidanalogue4/res/drawable/res_19462929.png
new file mode 100644
index 00000000..3bcdbe46
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue4/res/drawable/res_19462929.png differ
diff --git a/watchface/src/main/assets/androidanalogue4/res/drawable/res_19462930.png b/watchface/src/main/assets/androidanalogue4/res/drawable/res_19462930.png
new file mode 100644
index 00000000..10bf4b18
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue4/res/drawable/res_19462930.png differ
diff --git a/watchface/src/main/assets/androidanalogue4/res/drawable/res_19462932.png b/watchface/src/main/assets/androidanalogue4/res/drawable/res_19462932.png
new file mode 100644
index 00000000..e413409b
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue4/res/drawable/res_19462932.png differ
diff --git a/watchface/src/main/assets/androidanalogue4/res/drawable/res_19462939.png b/watchface/src/main/assets/androidanalogue4/res/drawable/res_19462939.png
new file mode 100644
index 00000000..acf31776
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue4/res/drawable/res_19462939.png differ
diff --git a/watchface/src/main/assets/androidanalogue4/res/drawable/res_19462940.png b/watchface/src/main/assets/androidanalogue4/res/drawable/res_19462940.png
new file mode 100644
index 00000000..0a34257b
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue4/res/drawable/res_19462940.png differ
diff --git a/watchface/src/main/assets/androidanalogue4/res/drawable/res_19462941.png b/watchface/src/main/assets/androidanalogue4/res/drawable/res_19462941.png
new file mode 100644
index 00000000..35107cc7
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue4/res/drawable/res_19462941.png differ
diff --git a/watchface/src/main/assets/androidanalogue4/res/drawable/res_19462950.png b/watchface/src/main/assets/androidanalogue4/res/drawable/res_19462950.png
new file mode 100644
index 00000000..1914f3d2
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue4/res/drawable/res_19462950.png differ
diff --git a/watchface/src/main/assets/androidanalogue4/res/drawable/res_19462953.png b/watchface/src/main/assets/androidanalogue4/res/drawable/res_19462953.png
new file mode 100644
index 00000000..3bcdbe46
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue4/res/drawable/res_19462953.png differ
diff --git a/watchface/src/main/assets/androidanalogue4/res/drawable/res_19462956.png b/watchface/src/main/assets/androidanalogue4/res/drawable/res_19462956.png
new file mode 100644
index 00000000..1914f3d2
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue4/res/drawable/res_19462956.png differ
diff --git a/watchface/src/main/assets/androidanalogue4/res/drawable/res_19462959.png b/watchface/src/main/assets/androidanalogue4/res/drawable/res_19462959.png
new file mode 100644
index 00000000..3bcdbe46
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue4/res/drawable/res_19462959.png differ
diff --git a/watchface/src/main/assets/androidanalogue4/res/drawable/res_78529339.png b/watchface/src/main/assets/androidanalogue4/res/drawable/res_78529339.png
new file mode 100644
index 00000000..5414b519
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue4/res/drawable/res_78529339.png differ
diff --git a/watchface/src/main/assets/androidanalogue4/res/drawable/res_78529341.png b/watchface/src/main/assets/androidanalogue4/res/drawable/res_78529341.png
new file mode 100644
index 00000000..1c23ca3c
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue4/res/drawable/res_78529341.png differ
diff --git a/watchface/src/main/assets/androidanalogue4/res/drawable/res_78541654.png b/watchface/src/main/assets/androidanalogue4/res/drawable/res_78541654.png
new file mode 100644
index 00000000..d6fdb6f9
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue4/res/drawable/res_78541654.png differ
diff --git a/watchface/src/main/assets/androidanalogue4/res/drawable/res_78544118.png b/watchface/src/main/assets/androidanalogue4/res/drawable/res_78544118.png
new file mode 100644
index 00000000..ec4ec2bd
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue4/res/drawable/res_78544118.png differ
diff --git a/watchface/src/main/assets/androidanalogue4/res/drawable/res_78544120.png b/watchface/src/main/assets/androidanalogue4/res/drawable/res_78544120.png
new file mode 100644
index 00000000..1d293fb4
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue4/res/drawable/res_78544120.png differ
diff --git a/watchface/src/main/assets/androidanalogue4/res/drawable/res_8767184.png b/watchface/src/main/assets/androidanalogue4/res/drawable/res_8767184.png
new file mode 100644
index 00000000..bb022f95
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue4/res/drawable/res_8767184.png differ
diff --git a/watchface/src/main/assets/androidanalogue4/res/raw/watchface.xml b/watchface/src/main/assets/androidanalogue4/res/raw/watchface.xml
new file mode 100644
index 00000000..40d10060
--- /dev/null
+++ b/watchface/src/main/assets/androidanalogue4/res/raw/watchface.xml
@@ -0,0 +1,300 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %s
+
+
+
+
+
+
+
+
+
+
+ %s
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %s
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %s
+
+
+
+
+
+
+
+
+
+
+ %s
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %s
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/watchface/src/main/assets/androidanalogue4/res/values/strings.xml b/watchface/src/main/assets/androidanalogue4/res/values/strings.xml
new file mode 100644
index 00000000..652eb879
--- /dev/null
+++ b/watchface/src/main/assets/androidanalogue4/res/values/strings.xml
@@ -0,0 +1,17 @@
+
+
+
\ No newline at end of file
diff --git a/watchface/src/main/assets/androidanalogue4/res/xml/watch_face_info.xml b/watchface/src/main/assets/androidanalogue4/res/xml/watch_face_info.xml
new file mode 100644
index 00000000..8d44e33e
--- /dev/null
+++ b/watchface/src/main/assets/androidanalogue4/res/xml/watch_face_info.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/watchface/src/main/assets/androidanalogue5/AndroidManifest.xml b/watchface/src/main/assets/androidanalogue5/AndroidManifest.xml
new file mode 100644
index 00000000..10988f38
--- /dev/null
+++ b/watchface/src/main/assets/androidanalogue5/AndroidManifest.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/watchface/src/main/assets/androidanalogue5/res/drawable/preview.png b/watchface/src/main/assets/androidanalogue5/res/drawable/preview.png
new file mode 100644
index 00000000..fe01985c
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue5/res/drawable/preview.png differ
diff --git a/watchface/src/main/assets/androidanalogue5/res/drawable/res_8786479.png b/watchface/src/main/assets/androidanalogue5/res/drawable/res_8786479.png
new file mode 100644
index 00000000..d226db03
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue5/res/drawable/res_8786479.png differ
diff --git a/watchface/src/main/assets/androidanalogue5/res/drawable/res_8786481.png b/watchface/src/main/assets/androidanalogue5/res/drawable/res_8786481.png
new file mode 100644
index 00000000..e4c4a095
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue5/res/drawable/res_8786481.png differ
diff --git a/watchface/src/main/assets/androidanalogue5/res/drawable/res_8786483.png b/watchface/src/main/assets/androidanalogue5/res/drawable/res_8786483.png
new file mode 100644
index 00000000..fbf14b08
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue5/res/drawable/res_8786483.png differ
diff --git a/watchface/src/main/assets/androidanalogue5/res/drawable/res_8786484.png b/watchface/src/main/assets/androidanalogue5/res/drawable/res_8786484.png
new file mode 100644
index 00000000..1efe916d
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue5/res/drawable/res_8786484.png differ
diff --git a/watchface/src/main/assets/androidanalogue5/res/drawable/res_8786514.png b/watchface/src/main/assets/androidanalogue5/res/drawable/res_8786514.png
new file mode 100644
index 00000000..e4c4a095
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue5/res/drawable/res_8786514.png differ
diff --git a/watchface/src/main/assets/androidanalogue5/res/drawable/res_8786516.png b/watchface/src/main/assets/androidanalogue5/res/drawable/res_8786516.png
new file mode 100644
index 00000000..fbf14b08
Binary files /dev/null and b/watchface/src/main/assets/androidanalogue5/res/drawable/res_8786516.png differ
diff --git a/watchface/src/main/assets/androidanalogue5/res/raw/watchface.xml b/watchface/src/main/assets/androidanalogue5/res/raw/watchface.xml
new file mode 100644
index 00000000..6f414837
--- /dev/null
+++ b/watchface/src/main/assets/androidanalogue5/res/raw/watchface.xml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/watchface/src/main/assets/androidanalogue5/res/values/strings.xml b/watchface/src/main/assets/androidanalogue5/res/values/strings.xml
new file mode 100644
index 00000000..652eb879
--- /dev/null
+++ b/watchface/src/main/assets/androidanalogue5/res/values/strings.xml
@@ -0,0 +1,17 @@
+
+
+
\ No newline at end of file
diff --git a/watchface/src/main/assets/androidanalogue5/res/xml/watch_face_info.xml b/watchface/src/main/assets/androidanalogue5/res/xml/watch_face_info.xml
new file mode 100644
index 00000000..b0893286
--- /dev/null
+++ b/watchface/src/main/assets/androidanalogue5/res/xml/watch_face_info.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/watchface/src/main/assets/androiddigital/AndroidManifest.xml b/watchface/src/main/assets/androiddigital/AndroidManifest.xml
new file mode 100644
index 00000000..6b699184
--- /dev/null
+++ b/watchface/src/main/assets/androiddigital/AndroidManifest.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_0.png b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_0.png
new file mode 100644
index 00000000..5c28d2a0
Binary files /dev/null and b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_0.png differ
diff --git a/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_1.png b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_1.png
new file mode 100644
index 00000000..6680fd70
Binary files /dev/null and b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_1.png differ
diff --git a/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_2.png b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_2.png
new file mode 100644
index 00000000..6d7b96d0
Binary files /dev/null and b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_2.png differ
diff --git a/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_3.png b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_3.png
new file mode 100644
index 00000000..f122d167
Binary files /dev/null and b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_3.png differ
diff --git a/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_4.png b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_4.png
new file mode 100644
index 00000000..07d40632
Binary files /dev/null and b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_4.png differ
diff --git a/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_5.png b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_5.png
new file mode 100644
index 00000000..22658110
Binary files /dev/null and b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_5.png differ
diff --git a/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_6.png b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_6.png
new file mode 100644
index 00000000..b3c1f515
Binary files /dev/null and b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_6.png differ
diff --git a/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_7.png b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_7.png
new file mode 100644
index 00000000..0ac3ca42
Binary files /dev/null and b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_7.png differ
diff --git a/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_8.png b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_8.png
new file mode 100644
index 00000000..530f487a
Binary files /dev/null and b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_8.png differ
diff --git a/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_9.png b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_9.png
new file mode 100644
index 00000000..adc42747
Binary files /dev/null and b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_9.png differ
diff --git a/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_a.png b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_a.png
new file mode 100644
index 00000000..e56a57b2
Binary files /dev/null and b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_a.png differ
diff --git a/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_a_upper.png b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_a_upper.png
new file mode 100644
index 00000000..22bb4139
Binary files /dev/null and b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_a_upper.png differ
diff --git a/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_colon.png b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_colon.png
new file mode 100644
index 00000000..1454f50d
Binary files /dev/null and b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_colon.png differ
diff --git a/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_dot.png b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_dot.png
new file mode 100644
index 00000000..1c4553cc
Binary files /dev/null and b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_dot.png differ
diff --git a/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_hyphen.png b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_hyphen.png
new file mode 100644
index 00000000..350882d0
Binary files /dev/null and b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_hyphen.png differ
diff --git a/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_m.png b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_m.png
new file mode 100644
index 00000000..11ec5c10
Binary files /dev/null and b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_m.png differ
diff --git a/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_m_upper.png b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_m_upper.png
new file mode 100644
index 00000000..c4c9dd2a
Binary files /dev/null and b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_m_upper.png differ
diff --git a/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_p.png b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_p.png
new file mode 100644
index 00000000..3e886e68
Binary files /dev/null and b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_p.png differ
diff --git a/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_p_upper.png b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_p_upper.png
new file mode 100644
index 00000000..1981839c
Binary files /dev/null and b/watchface/src/main/assets/androiddigital/res/drawable/font_googlesansflexmedium_p_upper.png differ
diff --git a/watchface/src/main/assets/androiddigital/res/drawable/preview.png b/watchface/src/main/assets/androiddigital/res/drawable/preview.png
new file mode 100644
index 00000000..fc97b633
Binary files /dev/null and b/watchface/src/main/assets/androiddigital/res/drawable/preview.png differ
diff --git a/watchface/src/main/assets/androiddigital/res/drawable/res_16041321.png b/watchface/src/main/assets/androiddigital/res/drawable/res_16041321.png
new file mode 100644
index 00000000..49ed634a
Binary files /dev/null and b/watchface/src/main/assets/androiddigital/res/drawable/res_16041321.png differ
diff --git a/watchface/src/main/assets/androiddigital/res/drawable/res_16041416.png b/watchface/src/main/assets/androiddigital/res/drawable/res_16041416.png
new file mode 100644
index 00000000..4cadf50e
Binary files /dev/null and b/watchface/src/main/assets/androiddigital/res/drawable/res_16041416.png differ
diff --git a/watchface/src/main/assets/androiddigital/res/drawable/res_194662175.png b/watchface/src/main/assets/androiddigital/res/drawable/res_194662175.png
new file mode 100644
index 00000000..2ea6e515
Binary files /dev/null and b/watchface/src/main/assets/androiddigital/res/drawable/res_194662175.png differ
diff --git a/watchface/src/main/assets/androiddigital/res/drawable/res_194662176.png b/watchface/src/main/assets/androiddigital/res/drawable/res_194662176.png
new file mode 100644
index 00000000..4dc43a3f
Binary files /dev/null and b/watchface/src/main/assets/androiddigital/res/drawable/res_194662176.png differ
diff --git a/watchface/src/main/assets/androiddigital/res/drawable/res_194662178.png b/watchface/src/main/assets/androiddigital/res/drawable/res_194662178.png
new file mode 100644
index 00000000..bbe28906
Binary files /dev/null and b/watchface/src/main/assets/androiddigital/res/drawable/res_194662178.png differ
diff --git a/watchface/src/main/assets/androiddigital/res/drawable/res_40011676.png b/watchface/src/main/assets/androiddigital/res/drawable/res_40011676.png
new file mode 100644
index 00000000..2ea6e515
Binary files /dev/null and b/watchface/src/main/assets/androiddigital/res/drawable/res_40011676.png differ
diff --git a/watchface/src/main/assets/androiddigital/res/drawable/res_40011677.png b/watchface/src/main/assets/androiddigital/res/drawable/res_40011677.png
new file mode 100644
index 00000000..4dc43a3f
Binary files /dev/null and b/watchface/src/main/assets/androiddigital/res/drawable/res_40011677.png differ
diff --git a/watchface/src/main/assets/androiddigital/res/raw/watchface.xml b/watchface/src/main/assets/androiddigital/res/raw/watchface.xml
new file mode 100644
index 00000000..ed5313a5
--- /dev/null
+++ b/watchface/src/main/assets/androiddigital/res/raw/watchface.xml
@@ -0,0 +1,243 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %s
+
+
+
+
+
+
+
+
+
+
+ %s
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %s
+
+
+
+
+
+
+
+
+
+
+ %s
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %s%s
+
+
+
+
+
+
+
+
+
+
+
+ %s%s
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %s%s
+
+
+
+
+
+
+
+
+
+
+
+ %s%s
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/watchface/src/main/assets/androiddigital/res/values/strings.xml b/watchface/src/main/assets/androiddigital/res/values/strings.xml
new file mode 100644
index 00000000..652eb879
--- /dev/null
+++ b/watchface/src/main/assets/androiddigital/res/values/strings.xml
@@ -0,0 +1,17 @@
+
+
+
\ No newline at end of file
diff --git a/watchface/src/main/assets/androiddigital/res/xml/watch_face_info.xml b/watchface/src/main/assets/androiddigital/res/xml/watch_face_info.xml
new file mode 100644
index 00000000..8d44e33e
--- /dev/null
+++ b/watchface/src/main/assets/androiddigital/res/xml/watch_face_info.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/watchface/src/main/assets/androiddigital2/AndroidManifest.xml b/watchface/src/main/assets/androiddigital2/AndroidManifest.xml
new file mode 100644
index 00000000..ed206955
--- /dev/null
+++ b/watchface/src/main/assets/androiddigital2/AndroidManifest.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_0.png b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_0.png
new file mode 100644
index 00000000..f79739ef
Binary files /dev/null and b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_0.png differ
diff --git a/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_1.png b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_1.png
new file mode 100644
index 00000000..9db1ead9
Binary files /dev/null and b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_1.png differ
diff --git a/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_2.png b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_2.png
new file mode 100644
index 00000000..923f5949
Binary files /dev/null and b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_2.png differ
diff --git a/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_3.png b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_3.png
new file mode 100644
index 00000000..1670c237
Binary files /dev/null and b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_3.png differ
diff --git a/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_4.png b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_4.png
new file mode 100644
index 00000000..08f1388d
Binary files /dev/null and b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_4.png differ
diff --git a/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_5.png b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_5.png
new file mode 100644
index 00000000..fae6aedd
Binary files /dev/null and b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_5.png differ
diff --git a/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_6.png b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_6.png
new file mode 100644
index 00000000..1ea80507
Binary files /dev/null and b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_6.png differ
diff --git a/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_7.png b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_7.png
new file mode 100644
index 00000000..cd873d83
Binary files /dev/null and b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_7.png differ
diff --git a/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_8.png b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_8.png
new file mode 100644
index 00000000..901873e3
Binary files /dev/null and b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_8.png differ
diff --git a/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_9.png b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_9.png
new file mode 100644
index 00000000..21551c13
Binary files /dev/null and b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_9.png differ
diff --git a/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_a.png b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_a.png
new file mode 100644
index 00000000..9ff0629b
Binary files /dev/null and b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_a.png differ
diff --git a/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_a_upper.png b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_a_upper.png
new file mode 100644
index 00000000..8cbd2ba5
Binary files /dev/null and b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_a_upper.png differ
diff --git a/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_colon.png b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_colon.png
new file mode 100644
index 00000000..78d68198
Binary files /dev/null and b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_colon.png differ
diff --git a/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_dot.png b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_dot.png
new file mode 100644
index 00000000..feac5ada
Binary files /dev/null and b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_dot.png differ
diff --git a/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_hyphen.png b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_hyphen.png
new file mode 100644
index 00000000..aac5e6d4
Binary files /dev/null and b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_hyphen.png differ
diff --git a/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_m.png b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_m.png
new file mode 100644
index 00000000..81e3c451
Binary files /dev/null and b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_m.png differ
diff --git a/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_m_upper.png b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_m_upper.png
new file mode 100644
index 00000000..290a3aef
Binary files /dev/null and b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_m_upper.png differ
diff --git a/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_p.png b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_p.png
new file mode 100644
index 00000000..23339b4f
Binary files /dev/null and b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_p.png differ
diff --git a/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_p_upper.png b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_p_upper.png
new file mode 100644
index 00000000..0a2f8bfe
Binary files /dev/null and b/watchface/src/main/assets/androiddigital2/res/drawable/font_googlesansflexsemibold_p_upper.png differ
diff --git a/watchface/src/main/assets/androiddigital2/res/drawable/preview.png b/watchface/src/main/assets/androiddigital2/res/drawable/preview.png
new file mode 100644
index 00000000..0c6e849e
Binary files /dev/null and b/watchface/src/main/assets/androiddigital2/res/drawable/preview.png differ
diff --git a/watchface/src/main/assets/androiddigital2/res/drawable/res_194623869.png b/watchface/src/main/assets/androiddigital2/res/drawable/res_194623869.png
new file mode 100644
index 00000000..8658be84
Binary files /dev/null and b/watchface/src/main/assets/androiddigital2/res/drawable/res_194623869.png differ
diff --git a/watchface/src/main/assets/androiddigital2/res/drawable/res_19462929.png b/watchface/src/main/assets/androiddigital2/res/drawable/res_19462929.png
new file mode 100644
index 00000000..3bcdbe46
Binary files /dev/null and b/watchface/src/main/assets/androiddigital2/res/drawable/res_19462929.png differ
diff --git a/watchface/src/main/assets/androiddigital2/res/drawable/res_19462930.png b/watchface/src/main/assets/androiddigital2/res/drawable/res_19462930.png
new file mode 100644
index 00000000..10bf4b18
Binary files /dev/null and b/watchface/src/main/assets/androiddigital2/res/drawable/res_19462930.png differ
diff --git a/watchface/src/main/assets/androiddigital2/res/drawable/res_19462932.png b/watchface/src/main/assets/androiddigital2/res/drawable/res_19462932.png
new file mode 100644
index 00000000..e413409b
Binary files /dev/null and b/watchface/src/main/assets/androiddigital2/res/drawable/res_19462932.png differ
diff --git a/watchface/src/main/assets/androiddigital2/res/drawable/res_19462939.png b/watchface/src/main/assets/androiddigital2/res/drawable/res_19462939.png
new file mode 100644
index 00000000..acf31776
Binary files /dev/null and b/watchface/src/main/assets/androiddigital2/res/drawable/res_19462939.png differ
diff --git a/watchface/src/main/assets/androiddigital2/res/drawable/res_19462940.png b/watchface/src/main/assets/androiddigital2/res/drawable/res_19462940.png
new file mode 100644
index 00000000..0a34257b
Binary files /dev/null and b/watchface/src/main/assets/androiddigital2/res/drawable/res_19462940.png differ
diff --git a/watchface/src/main/assets/androiddigital2/res/drawable/res_19462941.png b/watchface/src/main/assets/androiddigital2/res/drawable/res_19462941.png
new file mode 100644
index 00000000..35107cc7
Binary files /dev/null and b/watchface/src/main/assets/androiddigital2/res/drawable/res_19462941.png differ
diff --git a/watchface/src/main/assets/androiddigital2/res/drawable/res_19462950.png b/watchface/src/main/assets/androiddigital2/res/drawable/res_19462950.png
new file mode 100644
index 00000000..1914f3d2
Binary files /dev/null and b/watchface/src/main/assets/androiddigital2/res/drawable/res_19462950.png differ
diff --git a/watchface/src/main/assets/androiddigital2/res/drawable/res_19462953.png b/watchface/src/main/assets/androiddigital2/res/drawable/res_19462953.png
new file mode 100644
index 00000000..3bcdbe46
Binary files /dev/null and b/watchface/src/main/assets/androiddigital2/res/drawable/res_19462953.png differ
diff --git a/watchface/src/main/assets/androiddigital2/res/drawable/res_19462956.png b/watchface/src/main/assets/androiddigital2/res/drawable/res_19462956.png
new file mode 100644
index 00000000..1914f3d2
Binary files /dev/null and b/watchface/src/main/assets/androiddigital2/res/drawable/res_19462956.png differ
diff --git a/watchface/src/main/assets/androiddigital2/res/drawable/res_19462959.png b/watchface/src/main/assets/androiddigital2/res/drawable/res_19462959.png
new file mode 100644
index 00000000..3bcdbe46
Binary files /dev/null and b/watchface/src/main/assets/androiddigital2/res/drawable/res_19462959.png differ
diff --git a/watchface/src/main/assets/androiddigital2/res/drawable/res_8774180.png b/watchface/src/main/assets/androiddigital2/res/drawable/res_8774180.png
new file mode 100644
index 00000000..8658be84
Binary files /dev/null and b/watchface/src/main/assets/androiddigital2/res/drawable/res_8774180.png differ
diff --git a/watchface/src/main/assets/androiddigital2/res/raw/watchface.xml b/watchface/src/main/assets/androiddigital2/res/raw/watchface.xml
new file mode 100644
index 00000000..96ff3804
--- /dev/null
+++ b/watchface/src/main/assets/androiddigital2/res/raw/watchface.xml
@@ -0,0 +1,325 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %s
+
+
+
+
+
+
+
+
+
+
+ %s
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %s
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %s
+
+
+
+
+
+
+
+
+
+
+ %s
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %s
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %s%s:%s%s
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %s%s:%s%s
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/watchface/src/main/assets/androiddigital2/res/values/strings.xml b/watchface/src/main/assets/androiddigital2/res/values/strings.xml
new file mode 100644
index 00000000..652eb879
--- /dev/null
+++ b/watchface/src/main/assets/androiddigital2/res/values/strings.xml
@@ -0,0 +1,17 @@
+
+
+
\ No newline at end of file
diff --git a/watchface/src/main/assets/androiddigital2/res/xml/watch_face_info.xml b/watchface/src/main/assets/androiddigital2/res/xml/watch_face_info.xml
new file mode 100644
index 00000000..8d44e33e
--- /dev/null
+++ b/watchface/src/main/assets/androiddigital2/res/xml/watch_face_info.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/watchface/src/main/assets/androiddigital3/AndroidManifest.xml b/watchface/src/main/assets/androiddigital3/AndroidManifest.xml
new file mode 100644
index 00000000..6b699184
--- /dev/null
+++ b/watchface/src/main/assets/androiddigital3/AndroidManifest.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_0.png b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_0.png
new file mode 100644
index 00000000..7da59dc5
Binary files /dev/null and b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_0.png differ
diff --git a/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_1.png b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_1.png
new file mode 100644
index 00000000..e6205f25
Binary files /dev/null and b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_1.png differ
diff --git a/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_2.png b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_2.png
new file mode 100644
index 00000000..4afc7769
Binary files /dev/null and b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_2.png differ
diff --git a/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_3.png b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_3.png
new file mode 100644
index 00000000..d46b5896
Binary files /dev/null and b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_3.png differ
diff --git a/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_4.png b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_4.png
new file mode 100644
index 00000000..67517d6d
Binary files /dev/null and b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_4.png differ
diff --git a/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_5.png b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_5.png
new file mode 100644
index 00000000..8f808157
Binary files /dev/null and b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_5.png differ
diff --git a/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_6.png b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_6.png
new file mode 100644
index 00000000..4109db06
Binary files /dev/null and b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_6.png differ
diff --git a/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_7.png b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_7.png
new file mode 100644
index 00000000..8a80f6c2
Binary files /dev/null and b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_7.png differ
diff --git a/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_8.png b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_8.png
new file mode 100644
index 00000000..93cefb93
Binary files /dev/null and b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_8.png differ
diff --git a/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_9.png b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_9.png
new file mode 100644
index 00000000..6f205558
Binary files /dev/null and b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_9.png differ
diff --git a/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_a.png b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_a.png
new file mode 100644
index 00000000..e9e333b2
Binary files /dev/null and b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_a.png differ
diff --git a/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_a_upper.png b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_a_upper.png
new file mode 100644
index 00000000..998020d8
Binary files /dev/null and b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_a_upper.png differ
diff --git a/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_colon.png b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_colon.png
new file mode 100644
index 00000000..434d5fc7
Binary files /dev/null and b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_colon.png differ
diff --git a/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_dot.png b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_dot.png
new file mode 100644
index 00000000..9d6b245d
Binary files /dev/null and b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_dot.png differ
diff --git a/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_hyphen.png b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_hyphen.png
new file mode 100644
index 00000000..a306173c
Binary files /dev/null and b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_hyphen.png differ
diff --git a/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_m.png b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_m.png
new file mode 100644
index 00000000..ccdaa980
Binary files /dev/null and b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_m.png differ
diff --git a/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_m_upper.png b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_m_upper.png
new file mode 100644
index 00000000..002babc9
Binary files /dev/null and b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_m_upper.png differ
diff --git a/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_p.png b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_p.png
new file mode 100644
index 00000000..12f2206d
Binary files /dev/null and b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_p.png differ
diff --git a/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_p_upper.png b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_p_upper.png
new file mode 100644
index 00000000..b25159e9
Binary files /dev/null and b/watchface/src/main/assets/androiddigital3/res/drawable/font_googlesansflexbold_p_upper.png differ
diff --git a/watchface/src/main/assets/androiddigital3/res/drawable/preview.png b/watchface/src/main/assets/androiddigital3/res/drawable/preview.png
new file mode 100644
index 00000000..b0374d65
Binary files /dev/null and b/watchface/src/main/assets/androiddigital3/res/drawable/preview.png differ
diff --git a/watchface/src/main/assets/androiddigital3/res/drawable/res_19462929.png b/watchface/src/main/assets/androiddigital3/res/drawable/res_19462929.png
new file mode 100644
index 00000000..54fffae9
Binary files /dev/null and b/watchface/src/main/assets/androiddigital3/res/drawable/res_19462929.png differ
diff --git a/watchface/src/main/assets/androiddigital3/res/drawable/res_19462930.png b/watchface/src/main/assets/androiddigital3/res/drawable/res_19462930.png
new file mode 100644
index 00000000..25f39701
Binary files /dev/null and b/watchface/src/main/assets/androiddigital3/res/drawable/res_19462930.png differ
diff --git a/watchface/src/main/assets/androiddigital3/res/drawable/res_19462932.png b/watchface/src/main/assets/androiddigital3/res/drawable/res_19462932.png
new file mode 100644
index 00000000..d8a8b4f9
Binary files /dev/null and b/watchface/src/main/assets/androiddigital3/res/drawable/res_19462932.png differ
diff --git a/watchface/src/main/assets/androiddigital3/res/drawable/res_19462939.png b/watchface/src/main/assets/androiddigital3/res/drawable/res_19462939.png
new file mode 100644
index 00000000..07e263ed
Binary files /dev/null and b/watchface/src/main/assets/androiddigital3/res/drawable/res_19462939.png differ
diff --git a/watchface/src/main/assets/androiddigital3/res/drawable/res_19462940.png b/watchface/src/main/assets/androiddigital3/res/drawable/res_19462940.png
new file mode 100644
index 00000000..36a6da0e
Binary files /dev/null and b/watchface/src/main/assets/androiddigital3/res/drawable/res_19462940.png differ
diff --git a/watchface/src/main/assets/androiddigital3/res/drawable/res_19462941.png b/watchface/src/main/assets/androiddigital3/res/drawable/res_19462941.png
new file mode 100644
index 00000000..4d18310d
Binary files /dev/null and b/watchface/src/main/assets/androiddigital3/res/drawable/res_19462941.png differ
diff --git a/watchface/src/main/assets/androiddigital3/res/drawable/res_19462950.png b/watchface/src/main/assets/androiddigital3/res/drawable/res_19462950.png
new file mode 100644
index 00000000..d63dada8
Binary files /dev/null and b/watchface/src/main/assets/androiddigital3/res/drawable/res_19462950.png differ
diff --git a/watchface/src/main/assets/androiddigital3/res/drawable/res_19462953.png b/watchface/src/main/assets/androiddigital3/res/drawable/res_19462953.png
new file mode 100644
index 00000000..54fffae9
Binary files /dev/null and b/watchface/src/main/assets/androiddigital3/res/drawable/res_19462953.png differ
diff --git a/watchface/src/main/assets/androiddigital3/res/drawable/res_19462956.png b/watchface/src/main/assets/androiddigital3/res/drawable/res_19462956.png
new file mode 100644
index 00000000..d63dada8
Binary files /dev/null and b/watchface/src/main/assets/androiddigital3/res/drawable/res_19462956.png differ
diff --git a/watchface/src/main/assets/androiddigital3/res/drawable/res_19462959.png b/watchface/src/main/assets/androiddigital3/res/drawable/res_19462959.png
new file mode 100644
index 00000000..54fffae9
Binary files /dev/null and b/watchface/src/main/assets/androiddigital3/res/drawable/res_19462959.png differ
diff --git a/watchface/src/main/assets/androiddigital3/res/raw/watchface.xml b/watchface/src/main/assets/androiddigital3/res/raw/watchface.xml
new file mode 100644
index 00000000..9884cdb6
--- /dev/null
+++ b/watchface/src/main/assets/androiddigital3/res/raw/watchface.xml
@@ -0,0 +1,439 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %s
+
+
+
+
+
+
+
+
+
+
+ %s
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %s
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %s
+
+
+
+
+
+
+
+
+
+
+ %s
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %s
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %s
+
+
+
+
+
+
+
+
+
+
+ %s
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %s
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %s%s:%s%s
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %s%s:%s%s
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/watchface/src/main/assets/androiddigital3/res/values/strings.xml b/watchface/src/main/assets/androiddigital3/res/values/strings.xml
new file mode 100644
index 00000000..652eb879
--- /dev/null
+++ b/watchface/src/main/assets/androiddigital3/res/values/strings.xml
@@ -0,0 +1,17 @@
+
+
+
\ No newline at end of file
diff --git a/watchface/src/main/assets/androiddigital3/res/xml/watch_face_info.xml b/watchface/src/main/assets/androiddigital3/res/xml/watch_face_info.xml
new file mode 100644
index 00000000..8d44e33e
--- /dev/null
+++ b/watchface/src/main/assets/androiddigital3/res/xml/watch_face_info.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/watchface/src/main/assets/androiddigital4/AndroidManifest.xml b/watchface/src/main/assets/androiddigital4/AndroidManifest.xml
new file mode 100644
index 00000000..6b699184
--- /dev/null
+++ b/watchface/src/main/assets/androiddigital4/AndroidManifest.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_0.png b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_0.png
new file mode 100644
index 00000000..f6c69d97
Binary files /dev/null and b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_0.png differ
diff --git a/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_1.png b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_1.png
new file mode 100644
index 00000000..abb89729
Binary files /dev/null and b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_1.png differ
diff --git a/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_2.png b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_2.png
new file mode 100644
index 00000000..d8790383
Binary files /dev/null and b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_2.png differ
diff --git a/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_3.png b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_3.png
new file mode 100644
index 00000000..f7dc969f
Binary files /dev/null and b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_3.png differ
diff --git a/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_4.png b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_4.png
new file mode 100644
index 00000000..e8473787
Binary files /dev/null and b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_4.png differ
diff --git a/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_5.png b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_5.png
new file mode 100644
index 00000000..7a135aa1
Binary files /dev/null and b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_5.png differ
diff --git a/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_6.png b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_6.png
new file mode 100644
index 00000000..cd91cffb
Binary files /dev/null and b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_6.png differ
diff --git a/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_7.png b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_7.png
new file mode 100644
index 00000000..08a0136d
Binary files /dev/null and b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_7.png differ
diff --git a/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_8.png b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_8.png
new file mode 100644
index 00000000..2df5997d
Binary files /dev/null and b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_8.png differ
diff --git a/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_9.png b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_9.png
new file mode 100644
index 00000000..38aa5253
Binary files /dev/null and b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_9.png differ
diff --git a/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_a.png b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_a.png
new file mode 100644
index 00000000..804eac8a
Binary files /dev/null and b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_a.png differ
diff --git a/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_a_upper.png b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_a_upper.png
new file mode 100644
index 00000000..071d5244
Binary files /dev/null and b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_a_upper.png differ
diff --git a/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_colon.png b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_colon.png
new file mode 100644
index 00000000..f83db0a3
Binary files /dev/null and b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_colon.png differ
diff --git a/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_dot.png b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_dot.png
new file mode 100644
index 00000000..b18ccb3f
Binary files /dev/null and b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_dot.png differ
diff --git a/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_hyphen.png b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_hyphen.png
new file mode 100644
index 00000000..6ed0a27d
Binary files /dev/null and b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_hyphen.png differ
diff --git a/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_m.png b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_m.png
new file mode 100644
index 00000000..6d27b679
Binary files /dev/null and b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_m.png differ
diff --git a/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_m_upper.png b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_m_upper.png
new file mode 100644
index 00000000..8d249b57
Binary files /dev/null and b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_m_upper.png differ
diff --git a/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_p.png b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_p.png
new file mode 100644
index 00000000..ab582f4f
Binary files /dev/null and b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_p.png differ
diff --git a/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_p_upper.png b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_p_upper.png
new file mode 100644
index 00000000..32583c6c
Binary files /dev/null and b/watchface/src/main/assets/androiddigital4/res/drawable/font_androideuclidbold_p_upper.png differ
diff --git a/watchface/src/main/assets/androiddigital4/res/drawable/preview.png b/watchface/src/main/assets/androiddigital4/res/drawable/preview.png
new file mode 100644
index 00000000..c199a6cb
Binary files /dev/null and b/watchface/src/main/assets/androiddigital4/res/drawable/preview.png differ
diff --git a/watchface/src/main/assets/androiddigital4/res/drawable/res_19462929.png b/watchface/src/main/assets/androiddigital4/res/drawable/res_19462929.png
new file mode 100644
index 00000000..3bcdbe46
Binary files /dev/null and b/watchface/src/main/assets/androiddigital4/res/drawable/res_19462929.png differ
diff --git a/watchface/src/main/assets/androiddigital4/res/drawable/res_19462930.png b/watchface/src/main/assets/androiddigital4/res/drawable/res_19462930.png
new file mode 100644
index 00000000..10bf4b18
Binary files /dev/null and b/watchface/src/main/assets/androiddigital4/res/drawable/res_19462930.png differ
diff --git a/watchface/src/main/assets/androiddigital4/res/drawable/res_19462932.png b/watchface/src/main/assets/androiddigital4/res/drawable/res_19462932.png
new file mode 100644
index 00000000..e413409b
Binary files /dev/null and b/watchface/src/main/assets/androiddigital4/res/drawable/res_19462932.png differ
diff --git a/watchface/src/main/assets/androiddigital4/res/drawable/res_19462939.png b/watchface/src/main/assets/androiddigital4/res/drawable/res_19462939.png
new file mode 100644
index 00000000..acf31776
Binary files /dev/null and b/watchface/src/main/assets/androiddigital4/res/drawable/res_19462939.png differ
diff --git a/watchface/src/main/assets/androiddigital4/res/drawable/res_19462940.png b/watchface/src/main/assets/androiddigital4/res/drawable/res_19462940.png
new file mode 100644
index 00000000..0a34257b
Binary files /dev/null and b/watchface/src/main/assets/androiddigital4/res/drawable/res_19462940.png differ
diff --git a/watchface/src/main/assets/androiddigital4/res/drawable/res_19462941.png b/watchface/src/main/assets/androiddigital4/res/drawable/res_19462941.png
new file mode 100644
index 00000000..35107cc7
Binary files /dev/null and b/watchface/src/main/assets/androiddigital4/res/drawable/res_19462941.png differ
diff --git a/watchface/src/main/assets/androiddigital4/res/drawable/res_19462950.png b/watchface/src/main/assets/androiddigital4/res/drawable/res_19462950.png
new file mode 100644
index 00000000..1914f3d2
Binary files /dev/null and b/watchface/src/main/assets/androiddigital4/res/drawable/res_19462950.png differ
diff --git a/watchface/src/main/assets/androiddigital4/res/drawable/res_19462953.png b/watchface/src/main/assets/androiddigital4/res/drawable/res_19462953.png
new file mode 100644
index 00000000..3bcdbe46
Binary files /dev/null and b/watchface/src/main/assets/androiddigital4/res/drawable/res_19462953.png differ
diff --git a/watchface/src/main/assets/androiddigital4/res/drawable/res_19462956.png b/watchface/src/main/assets/androiddigital4/res/drawable/res_19462956.png
new file mode 100644
index 00000000..1914f3d2
Binary files /dev/null and b/watchface/src/main/assets/androiddigital4/res/drawable/res_19462956.png differ
diff --git a/watchface/src/main/assets/androiddigital4/res/drawable/res_19462959.png b/watchface/src/main/assets/androiddigital4/res/drawable/res_19462959.png
new file mode 100644
index 00000000..3bcdbe46
Binary files /dev/null and b/watchface/src/main/assets/androiddigital4/res/drawable/res_19462959.png differ
diff --git a/watchface/src/main/assets/androiddigital4/res/raw/watchface.xml b/watchface/src/main/assets/androiddigital4/res/raw/watchface.xml
new file mode 100644
index 00000000..2061e295
--- /dev/null
+++ b/watchface/src/main/assets/androiddigital4/res/raw/watchface.xml
@@ -0,0 +1,337 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %s
+
+
+
+
+
+
+
+
+
+
+ %s
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %s
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %s
+
+
+
+
+
+
+
+
+
+
+ %s
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %s
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %s%s
+
+
+
+
+
+
+
+
+
+
+
+ %s%s
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %s%s
+
+
+
+
+
+
+
+
+
+
+
+ %s%s
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/watchface/src/main/assets/androiddigital4/res/values/strings.xml b/watchface/src/main/assets/androiddigital4/res/values/strings.xml
new file mode 100644
index 00000000..652eb879
--- /dev/null
+++ b/watchface/src/main/assets/androiddigital4/res/values/strings.xml
@@ -0,0 +1,17 @@
+
+
+
\ No newline at end of file
diff --git a/watchface/src/main/assets/androiddigital4/res/xml/watch_face_info.xml b/watchface/src/main/assets/androiddigital4/res/xml/watch_face_info.xml
new file mode 100644
index 00000000..8d44e33e
--- /dev/null
+++ b/watchface/src/main/assets/androiddigital4/res/xml/watch_face_info.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/watchface/src/main/java/com/android/developers/androidify/watchface/WatchFaceAsset.kt b/watchface/src/main/java/com/android/developers/androidify/watchface/WatchFaceAsset.kt
new file mode 100644
index 00000000..f1eedd32
--- /dev/null
+++ b/watchface/src/main/java/com/android/developers/androidify/watchface/WatchFaceAsset.kt
@@ -0,0 +1,21 @@
+/*
+ * 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.watchface
+
+data class WatchFaceAsset(
+ val id: String,
+ val previewPath: Any,
+)
diff --git a/watchface/src/main/java/com/android/developers/androidify/watchface/creator/PackPackage.kt b/watchface/src/main/java/com/android/developers/androidify/watchface/creator/PackPackage.kt
new file mode 100644
index 00000000..93c5a941
--- /dev/null
+++ b/watchface/src/main/java/com/android/developers/androidify/watchface/creator/PackPackage.kt
@@ -0,0 +1,77 @@
+/*
+ * 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.watchface.creator
+
+import android.util.Base64
+import java.nio.charset.StandardCharsets
+
+data class PackPackage(
+ val androidManifest: String,
+) {
+ var resources: MutableList = mutableListOf()
+
+ data class Resource(
+ val subdirectory: String,
+ val name: String,
+ val contentsBase64: String,
+ ) {
+ companion object {
+ fun fromBase64Contents(
+ subdirectory: String,
+ name: String,
+ contentsBase64: String,
+ ) = Resource(subdirectory, name, contentsBase64)
+
+ fun fromByteArrayContents(
+ subdirectory: String,
+ name: String,
+ contentsBytes: ByteArray,
+ ): Resource {
+ val encodedContents = Base64.encodeToString(contentsBytes, Base64.NO_WRAP)
+ return fromBase64Contents(subdirectory, name, encodedContents)
+ }
+
+ fun fromStringContents(
+ subdirectory: String,
+ name: String,
+ contentsString: String,
+ ): Resource {
+ val bytes = contentsString.toByteArray(StandardCharsets.UTF_8)
+ return fromByteArrayContents(subdirectory, name, bytes)
+ }
+ }
+ }
+
+ fun compileApk(): ByteArray {
+ val resultBase64 = nativeCompilePackage(
+ androidManifest,
+ resources.toTypedArray(),
+ )
+ return Base64.decode(resultBase64, Base64.DEFAULT)
+ }
+
+ companion object {
+ init {
+ System.loadLibrary("pack_java")
+ }
+
+ @JvmStatic
+ private external fun nativeCompilePackage(
+ androidManifest: String,
+ resources: Array,
+ ): String
+ }
+}
diff --git a/watchface/src/main/java/com/android/developers/androidify/watchface/creator/Signer.kt b/watchface/src/main/java/com/android/developers/androidify/watchface/creator/Signer.kt
new file mode 100644
index 00000000..c1e3b348
--- /dev/null
+++ b/watchface/src/main/java/com/android/developers/androidify/watchface/creator/Signer.kt
@@ -0,0 +1,255 @@
+/*
+ * 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.watchface.creator
+
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyProperties
+import com.android.apksig.ApkSigner
+import com.android.apksig.KeyConfig
+import com.android.apksig.util.DataSink
+import com.android.apksig.util.DataSource
+import com.android.apksig.util.DataSources
+import com.android.apksig.util.ReadableDataSink
+import org.bouncycastle.asn1.x500.X500Name
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter
+import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder
+import java.math.BigInteger
+import java.nio.ByteBuffer
+import java.security.KeyPair
+import java.security.KeyPairGenerator
+import java.security.KeyStore
+import java.security.PrivateKey
+import java.security.SecureRandom
+import java.security.cert.X509Certificate
+import java.util.Calendar
+import java.util.Date
+
+private const val KEY_ALIAS = "com.android.developers.androidify.ApkSigningKey"
+private const val CERT_ALIAS = "com.android.developers.androidify.Cert"
+private const val ANDROID_KEYSTORE = "AndroidKeyStore"
+
+/**
+ * Signs an APK file using a key pair and certificate retrieved from or created in the
+ * Android KeyStore.
+ *
+ * This function first retrieves or creates a signing key pair and a corresponding certificate.
+ * It then uses these to sign the provided unsigned APK byte array.
+ *
+ * @param unsignedApk The byte array of the unsigned APK file.
+ * @return A byte array representing the signed APK file.
+ */
+fun signApk(unsignedApk: ByteArray): ByteArray {
+ val keyPair = getOrCreateSigningKeyPair()
+ val certificate = getOrCreateCertificate(keyPair)
+ return signApkWithCredentials(unsignedApk, keyPair.private, certificate)
+}
+
+/**
+ * Retrieves an existing signing key pair from the Android KeyStore or creates a new one
+ * if it doesn't exist.
+ *
+ * The key pair is stored under a specific alias in the Android KeyStore.
+ * If the key pair does not exist, it generates a new RSA key pair with SHA256withRSA
+ * signature padding.
+ *
+ * @return The [KeyPair] containing the public and private keys.
+ */
+private fun getOrCreateSigningKeyPair(): KeyPair {
+ val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
+
+ if (keyStore.containsAlias(KEY_ALIAS)) {
+ val entry = keyStore.getEntry(KEY_ALIAS, null) as KeyStore.PrivateKeyEntry
+ return KeyPair(entry.certificate.publicKey, entry.privateKey)
+ }
+
+ val parameterSpec = KeyGenParameterSpec.Builder(
+ KEY_ALIAS,
+ KeyProperties.PURPOSE_SIGN,
+ ).run {
+ setDigests(KeyProperties.DIGEST_SHA256)
+ setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1)
+ build()
+ }
+
+ val keyPairGenerator = KeyPairGenerator.getInstance(
+ KeyProperties.KEY_ALGORITHM_RSA,
+ ANDROID_KEYSTORE,
+ ).apply {
+ initialize(parameterSpec)
+ }
+
+ return keyPairGenerator.generateKeyPair()
+}
+
+/**
+ * Creates a self-signed X.509 certificate using the provided key pair.
+ *
+ * The certificate is configured with:
+ * - A validity period of 25 years from the current date.
+ * - A randomly generated 64-bit serial number.
+ * - The subject and issuer name set to "CN=Androidify".
+ * - A SHA256WithRSA signature algorithm.
+ *
+ * After creation, the certificate is stored in the Android KeyStore.
+ *
+ * @param keyPair The [KeyPair] containing the public and private keys to be used for signing
+ * and embedding in the certificate.
+ * @return The generated [X509Certificate].
+ */
+private fun createCertificate(keyPair: KeyPair): X509Certificate {
+ val now = Date()
+ val expiryDate = Calendar.getInstance().apply {
+ time = now
+ add(Calendar.YEAR, 25)
+ }.time
+
+ val serialNumber = BigInteger(64, SecureRandom())
+ val subjectName = X500Name("CN=Androidify")
+
+ val certificateBuilder = JcaX509v3CertificateBuilder(
+ subjectName,
+ serialNumber,
+ now,
+ expiryDate,
+ subjectName,
+ keyPair.public,
+ )
+
+ val contentSigner = JcaContentSignerBuilder("SHA256WithRSA")
+ .build(keyPair.private)
+
+ val certificateHolder = certificateBuilder.build(contentSigner)
+ val certificate = JcaX509CertificateConverter().getCertificate(certificateHolder)
+
+ storeCertificate(certificate)
+
+ return certificate
+}
+
+/**
+ * Stores an X509Certificate in the Android Keystore.
+ *
+ * @param certificate The X509Certificate object to be stored.
+ * @return True if storage was successful, false otherwise.
+ */
+private fun storeCertificate(certificate: X509Certificate): Boolean {
+ return try {
+ val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply {
+ load(null)
+ }
+ keyStore.setCertificateEntry(CERT_ALIAS, certificate)
+ true
+ } catch (e: Exception) {
+ // Log the exception for debugging
+ e.printStackTrace()
+ false
+ }
+}
+
+/**
+ * Retrieves an X509Certificate from the Android Keystore.
+ *
+ * @return The X509Certificate if found, or null if it doesn't exist or an error occurs.
+ */
+private fun getOrCreateCertificate(keyPair: KeyPair): X509Certificate {
+ val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply {
+ load(null)
+ }
+ return (keyStore.getCertificate(CERT_ALIAS) ?: createCertificate(keyPair)) as X509Certificate
+}
+
+/**
+ * Signs an APK file with the provided private key and certificate.
+ *
+ * This function uses the `ApkSigner` library to sign the APK.
+ * It enables both v2 and v3 signing schemes.
+ *
+ * @param unsignedApkBytes The byte array of the unsigned APK file.
+ * @param privateKey The private key to use for signing.
+ * @param certificate The certificate corresponding to the private key.
+ * @return A byte array representing the signed APK.
+ */
+private fun signApkWithCredentials(
+ unsignedApkBytes: ByteArray,
+ privateKey: PrivateKey,
+ certificate: X509Certificate,
+): ByteArray {
+ val keyConfig = KeyConfig.Jca(privateKey)
+ val signerConfig = ApkSigner.SignerConfig.Builder(
+ "CERT",
+ keyConfig,
+ listOf(certificate),
+ ).build()
+ val dataSource = DataSources.asDataSource(ByteBuffer.wrap(unsignedApkBytes))
+ val dataSink = InMemoryDataSink()
+
+ ApkSigner.Builder(listOf(signerConfig)).run {
+ setInputApk(dataSource)
+ setOutputApk(dataSink)
+ setV2SigningEnabled(true)
+ setV3SigningEnabled(true)
+ build()
+ }.sign()
+
+ return dataSink.toByteArray()
+}
+
+/**
+ * A custom DataSink that writes all consumed data into an in-memory byte array.
+ */
+private class InMemoryDataSink : ReadableDataSink {
+ private var buffer: ByteArray = ByteArray(0)
+
+ override fun consume(buf: ByteArray, offset: Int, length: Int) {
+ val newBuffer = ByteArray(buffer.size + length)
+ System.arraycopy(buffer, 0, newBuffer, 0, buffer.size)
+ System.arraycopy(buf, offset, newBuffer, buffer.size, length)
+ buffer = newBuffer
+ }
+
+ override fun consume(buf: ByteBuffer) {
+ val newBuffer = ByteArray(buffer.size + buf.remaining())
+ System.arraycopy(buffer, 0, newBuffer, 0, buffer.size)
+ buf.get(newBuffer, buffer.size, buf.remaining())
+ buffer = newBuffer
+ }
+
+ override fun copyTo(offset: Long, size: Int, dest: ByteBuffer) {
+ dest.put(buffer, offset.toInt(), size)
+ }
+
+ override fun size(): Long {
+ return buffer.size.toLong()
+ }
+
+ override fun feed(offset: Long, size: Long, sink: DataSink) {
+ sink.consume(buffer, offset.toInt(), size.toInt())
+ }
+
+ override fun getByteBuffer(offset: Long, size: Int): ByteBuffer {
+ return ByteBuffer.wrap(buffer, offset.toInt(), size)
+ }
+
+ override fun slice(offset: Long, size: Long): DataSource {
+ val sliceBuffer = ByteBuffer.wrap(buffer, offset.toInt(), size.toInt())
+ return com.android.apksig.util.DataSources.asDataSource(sliceBuffer)
+ }
+
+ fun toByteArray(): ByteArray {
+ return buffer
+ }
+}
diff --git a/watchface/src/main/java/com/android/developers/androidify/watchface/creator/WatchFaceCreator.kt b/watchface/src/main/java/com/android/developers/androidify/watchface/creator/WatchFaceCreator.kt
new file mode 100644
index 00000000..f1c835c9
--- /dev/null
+++ b/watchface/src/main/java/com/android/developers/androidify/watchface/creator/WatchFaceCreator.kt
@@ -0,0 +1,144 @@
+/*
+ * 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.watchface.creator
+
+import android.content.Context
+import android.graphics.Bitmap
+import androidx.core.graphics.scale
+import com.google.android.wearable.watchface.validator.client.DwfValidatorFactory
+import dagger.hilt.android.qualifiers.ApplicationContext
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.util.UUID
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Class to create a watch face package for transmission to the watch for installation.
+ */
+interface WatchFaceCreator {
+ /**
+ * Creates a watch face package.
+ *
+ * @param botBitmap The bitmap to use as the bot icon on the watch face. This image is added to
+ * the package as res/drawable/bot.png, and can therefore be references in the watchface.xml
+ * file, for example as .
+ * @param watchFaceName The name of the directory within the assets folder containing the watch
+ * face resources, for example, as exported from Watch Face Designer.
+ */
+ fun createWatchFacePackage(botBitmap: Bitmap, watchFaceName: String): WatchFacePackage
+}
+
+@Singleton
+class WatchFaceCreatorImpl @Inject constructor(
+ @ApplicationContext val context: Context,
+) : WatchFaceCreator {
+ override fun createWatchFacePackage(botBitmap: Bitmap, watchFaceName: String): WatchFacePackage {
+ val watchFacePackageName = createUniqueWatchFaceName()
+ val manifest = readStringAsset("$watchFaceName/AndroidManifest.xml")
+
+ val wfPackage = PackPackage(
+ androidManifest = replacePackageName(manifest, watchFacePackageName),
+ )
+
+ addTextFiles(watchFaceName, "raw", wfPackage)
+ addTextFiles(watchFaceName, "values", wfPackage)
+ addTextFiles(watchFaceName, "xml", wfPackage)
+ addBinaryFiles(watchFaceName, "drawable", wfPackage)
+
+ val bot = PackPackage.Resource.Companion.fromByteArrayContents(
+ "drawable",
+ "bot.webp",
+ botBitmap
+ .scale(555, 555)
+ .toByteArray(),
+ )
+ wfPackage.resources.add(bot)
+
+ val bytes = wfPackage.compileApk()
+ val signedBytes = signApk(bytes)
+ val watchFaceFile = File.createTempFile("watchface", ".apk")
+ watchFaceFile.deleteOnExit()
+ watchFaceFile.writeBytes(signedBytes)
+
+ val validator = DwfValidatorFactory.create()
+ val validationResult = validator.validate(watchFaceFile, context.packageName)
+
+ if (validationResult.failures().isNotEmpty()) {
+ throw IllegalStateException("Watch face validation failed: ${validationResult.failures()}")
+ }
+
+ return WatchFacePackage(
+ file = watchFaceFile,
+ validationToken = validationResult.validationToken(),
+ )
+ }
+
+ private fun addTextFiles(watchFaceName: String, subdirectory: String, packPackage: PackPackage) {
+ val assetPath = "$watchFaceName/res/$subdirectory"
+ val files = context.assets.list(assetPath)
+ if (files != null) {
+ for (file in files) {
+ val contents = readStringAsset("$assetPath/$file").trim()
+ val resource =
+ PackPackage.Resource.Companion.fromStringContents(subdirectory, file, contents)
+ packPackage.resources.add(resource)
+ }
+ }
+ }
+
+ private fun addBinaryFiles(watchFaceName: String, subdirectory: String, packPackage: PackPackage) {
+ val assetPath = "$watchFaceName/res/$subdirectory"
+ val files = context.assets.list(assetPath)
+ if (files != null) {
+ for (file in files) {
+ val contents = readBinaryAsset("$assetPath/$file")
+ val resource = PackPackage.Resource.Companion.fromByteArrayContents(
+ subdirectory,
+ file,
+ contents,
+ )
+ packPackage.resources.add(resource)
+ }
+ }
+ }
+
+ private fun readStringAsset(fileName: String): String {
+ return context.assets.open(fileName).bufferedReader().use { it.readText() }
+ }
+
+ private fun readBinaryAsset(fileName: String): ByteArray {
+ return context.assets.open(fileName).readBytes()
+ }
+
+ private fun replacePackageName(text: String, newPackageName: String): String {
+ val regex = Regex("""package="([^"]*)"""")
+ return regex.replace(text) {
+ "package=\"$newPackageName\""
+ }
+ }
+
+ private fun createUniqueWatchFaceName() =
+ context.packageName + ".watchfacepush.bot" + UUID.randomUUID().toString()
+ // '-' is not allowed in valid package names, but is present in UUIDs.
+ .replace("-", "").take(12)
+
+ private fun Bitmap.toByteArray(format: Bitmap.CompressFormat = Bitmap.CompressFormat.WEBP, quality: Int = 85): ByteArray {
+ val stream = ByteArrayOutputStream()
+ this.compress(format, quality, stream)
+ return stream.toByteArray()
+ }
+}
diff --git a/watchface/src/main/java/com/android/developers/androidify/watchface/creator/WatchFacePackage.kt b/watchface/src/main/java/com/android/developers/androidify/watchface/creator/WatchFacePackage.kt
new file mode 100644
index 00000000..7ae95b45
--- /dev/null
+++ b/watchface/src/main/java/com/android/developers/androidify/watchface/creator/WatchFacePackage.kt
@@ -0,0 +1,27 @@
+/*
+ * 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.watchface.creator
+
+import java.io.File
+
+/**
+ * Data class representing a watch face package. This includes both the file for transmission and
+ * the validation key.
+ */
+data class WatchFacePackage(
+ val file: File,
+ val validationToken: String,
+)
diff --git a/watchface/src/main/java/com/android/developers/androidify/watchface/di/WatchFaceModule.kt b/watchface/src/main/java/com/android/developers/androidify/watchface/di/WatchFaceModule.kt
new file mode 100644
index 00000000..32118f32
--- /dev/null
+++ b/watchface/src/main/java/com/android/developers/androidify/watchface/di/WatchFaceModule.kt
@@ -0,0 +1,79 @@
+/*
+ * 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.watchface.di
+
+import android.content.Context
+import android.os.Build
+import com.android.developers.androidify.RemoteConfigDataSource
+import com.android.developers.androidify.watchface.creator.WatchFaceCreator
+import com.android.developers.androidify.watchface.creator.WatchFaceCreatorImpl
+import com.android.developers.androidify.watchface.transfer.EmptyWatchFaceInstallationRepositoryImpl
+import com.android.developers.androidify.watchface.transfer.MIN_WATCH_FACE_SDK_VERSION
+import com.android.developers.androidify.watchface.transfer.WatchFaceInstallationRepository
+import com.android.developers.androidify.watchface.transfer.WatchFaceInstallationRepositoryImpl
+import com.android.developers.androidify.watchface.transfer.WearAssetTransmitter
+import com.android.developers.androidify.watchface.transfer.WearAssetTransmitterImpl
+import com.android.developers.androidify.watchface.transfer.WearDeviceRepository
+import com.android.developers.androidify.watchface.transfer.WearDeviceRepositoryImpl
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+class WatchFaceModule {
+ @Provides
+ @Singleton
+ fun provideWatchFaceCreator(
+ @ApplicationContext context: Context,
+ ): WatchFaceCreator {
+ return WatchFaceCreatorImpl(context)
+ }
+
+ @Provides
+ @Singleton
+ fun provideWearAssetTransmitter(
+ @ApplicationContext context: Context,
+ ): WearAssetTransmitter {
+ return WearAssetTransmitterImpl(context)
+ }
+
+ @Provides
+ @Singleton
+ fun providesWearDeviceRepository(
+ @ApplicationContext context: Context,
+ ): WearDeviceRepository {
+ return WearDeviceRepositoryImpl(context)
+ }
+
+ @Provides
+ @Singleton
+ fun provideWatchFaceInstallationRepository(
+ supportedImpl: WatchFaceInstallationRepositoryImpl,
+ noSupportImpl: EmptyWatchFaceInstallationRepositoryImpl,
+ remoteConfigDataSource: RemoteConfigDataSource,
+ ): WatchFaceInstallationRepository {
+ val watchFacesEnabled = remoteConfigDataSource.watchfaceFeatureEnabled()
+ return if (Build.VERSION.SDK_INT >= MIN_WATCH_FACE_SDK_VERSION && watchFacesEnabled) {
+ supportedImpl
+ } else {
+ noSupportImpl
+ }
+ }
+}
diff --git a/watchface/src/main/java/com/android/developers/androidify/watchface/transfer/EmptyWatchFaceInstallationRepository.kt b/watchface/src/main/java/com/android/developers/androidify/watchface/transfer/EmptyWatchFaceInstallationRepository.kt
new file mode 100644
index 00000000..c908fbe2
--- /dev/null
+++ b/watchface/src/main/java/com/android/developers/androidify/watchface/transfer/EmptyWatchFaceInstallationRepository.kt
@@ -0,0 +1,49 @@
+/*
+ * 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.watchface.transfer
+
+import android.graphics.Bitmap
+import com.android.developers.androidify.watchface.WatchFaceAsset
+import com.android.developers.androidify.wear.common.ConnectedWatch
+import com.android.developers.androidify.wear.common.WatchFaceInstallError
+import com.android.developers.androidify.wear.common.WatchFaceInstallationStatus
+import kotlinx.coroutines.flow.flowOf
+import javax.inject.Inject
+
+// The minimum supported version of Android for a watch face installation support. This is currently
+const val MIN_WATCH_FACE_SDK_VERSION = 28
+
+class EmptyWatchFaceInstallationRepositoryImpl @Inject constructor() : WatchFaceInstallationRepository {
+ override val connectedWatch = flowOf(null)
+
+ override val watchFaceInstallationUpdates = flowOf(
+ WatchFaceInstallationStatus.NotStarted,
+ )
+
+ override suspend fun createAndTransferWatchFace(
+ connectedWatch: ConnectedWatch,
+ watchFace: WatchFaceAsset,
+ bitmap: Bitmap,
+ ): WatchFaceInstallError {
+ return WatchFaceInstallError.WATCH_FACE_INSTALL_ERROR
+ }
+
+ override suspend fun getAvailableWatchFaces(): Result> {
+ return Result.success(emptyList())
+ }
+
+ override suspend fun resetInstallationStatus() { }
+}
diff --git a/watchface/src/main/java/com/android/developers/androidify/watchface/transfer/WatchFaceInstallationRepository.kt b/watchface/src/main/java/com/android/developers/androidify/watchface/transfer/WatchFaceInstallationRepository.kt
new file mode 100644
index 00000000..f2f5a8af
--- /dev/null
+++ b/watchface/src/main/java/com/android/developers/androidify/watchface/transfer/WatchFaceInstallationRepository.kt
@@ -0,0 +1,130 @@
+/*
+ * 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.watchface.transfer
+
+import android.content.Context
+import android.graphics.Bitmap
+import com.android.developers.androidify.watchface.WatchFaceAsset
+import com.android.developers.androidify.watchface.creator.WatchFaceCreator
+import com.android.developers.androidify.wear.common.ConnectedWatch
+import com.android.developers.androidify.wear.common.WatchFaceInstallError
+import com.android.developers.androidify.wear.common.WatchFaceInstallationStatus
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.merge
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+
+/**
+ * Repository for watch face installation.
+ */
+interface WatchFaceInstallationRepository {
+ /**
+ * Flow of currently connected device. Only one device is reported - the scenario of having
+ * multiple devices connected is not currently supported.
+ */
+ val connectedWatch: Flow
+
+ /**
+ * Flow of status updates from the watch as installation proceeds.
+ */
+ val watchFaceInstallationUpdates: Flow
+
+ /**
+ * Creates and transmits a watch face to the connected device. The bitmap is added into the
+ * template watch face.
+ *
+ * @param connectedWatch The device to install the watch face on.
+ * @param bitmap The bitmap to add to the watch face.
+ * @return The result of the transfer.
+ */
+ suspend fun createAndTransferWatchFace(
+ connectedWatch: ConnectedWatch,
+ watchFace: WatchFaceAsset,
+ bitmap: Bitmap,
+ ): WatchFaceInstallError
+
+ /**
+ * Retrieves a list of available watch faces.
+ *
+ * @return A result containing a list of watch face assets.
+ */
+ suspend fun getAvailableWatchFaces(): Result>
+
+ suspend fun resetInstallationStatus()
+}
+
+class WatchFaceInstallationRepositoryImpl @Inject constructor(
+ private val wearAssetTransmitter: WearAssetTransmitter,
+ private val wearDeviceRepository: WearDeviceRepository,
+ private val watchFaceCreator: WatchFaceCreator,
+ @ApplicationContext val context: Context,
+) : WatchFaceInstallationRepository {
+ override val connectedWatch = wearDeviceRepository.connectedWatch
+
+ private val manualStatusUpdates = MutableSharedFlow(
+ replay = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
+ )
+
+ override val watchFaceInstallationUpdates: Flow = merge(
+ manualStatusUpdates,
+ wearAssetTransmitter.watchFaceInstallationUpdates,
+ )
+
+ override suspend fun createAndTransferWatchFace(
+ connectedWatch: ConnectedWatch,
+ watchFace: WatchFaceAsset,
+ bitmap: Bitmap,
+ ): WatchFaceInstallError {
+ return withContext(Dispatchers.IO) {
+ manualStatusUpdates.tryEmit(WatchFaceInstallationStatus.Sending)
+ val watchFacePackage = watchFaceCreator
+ .createWatchFacePackage(watchFaceName = watchFace.id, botBitmap = bitmap)
+
+ wearAssetTransmitter.doTransfer(connectedWatch.nodeId, watchFacePackage)
+ }
+ }
+
+ override suspend fun getAvailableWatchFaces(): Result> {
+ return withContext(Dispatchers.IO) { // Move asset scanning to a background thread
+ try {
+ val assetManager = context.assets
+ val rootFolders = assetManager.list("") ?: emptyArray()
+
+ val watchFaceList = rootFolders.filter { folderName ->
+ (assetManager.list(folderName)?.contains("res") == true)
+ }.map { watchFaceId ->
+ WatchFaceAsset(
+ id = watchFaceId,
+ previewPath = "file:///android_asset/$watchFaceId/res/drawable/preview.png",
+ )
+ }
+ Result.success(watchFaceList)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+ }
+
+ override suspend fun resetInstallationStatus() {
+ wearAssetTransmitter.resetTransferId()
+ manualStatusUpdates.tryEmit(WatchFaceInstallationStatus.NotStarted)
+ }
+}
diff --git a/watchface/src/main/java/com/android/developers/androidify/watchface/transfer/WearAssetTransmitter.kt b/watchface/src/main/java/com/android/developers/androidify/watchface/transfer/WearAssetTransmitter.kt
new file mode 100644
index 00000000..b2816d12
--- /dev/null
+++ b/watchface/src/main/java/com/android/developers/androidify/watchface/transfer/WearAssetTransmitter.kt
@@ -0,0 +1,177 @@
+/*
+ * 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.
+ */
+@file:OptIn(ExperimentalSerializationApi::class)
+
+package com.android.developers.androidify.watchface.transfer
+
+import android.content.Context
+import android.net.Uri
+import android.util.Log
+import com.android.developers.androidify.watchface.creator.WatchFacePackage
+import com.android.developers.androidify.wear.common.InitialRequest
+import com.android.developers.androidify.wear.common.InitialResponse
+import com.android.developers.androidify.wear.common.WatchFaceInstallError
+import com.android.developers.androidify.wear.common.WatchFaceInstallationStatus
+import com.android.developers.androidify.wear.common.WearableConstants
+import com.android.developers.androidify.wear.common.WearableConstants.SETUP_TIMEOUT_MS
+import com.android.developers.androidify.wear.common.WearableConstants.TRANSFER_TIMEOUT_MS
+import com.google.android.gms.wearable.ChannelClient
+import com.google.android.gms.wearable.MessageClient
+import com.google.android.gms.wearable.Wearable
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.TimeoutCancellationException
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.tasks.await
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeout
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.decodeFromByteArray
+import kotlinx.serialization.encodeToByteArray
+import kotlinx.serialization.protobuf.ProtoBuf
+import java.io.File
+import java.util.UUID
+import javax.inject.Inject
+import javax.inject.Singleton
+
+interface WearAssetTransmitter {
+ val watchFaceInstallationUpdates: Flow
+
+ suspend fun doTransfer(
+ nodeId: String,
+ watchFacePackage: WatchFacePackage,
+ ): WatchFaceInstallError
+
+ fun resetTransferId()
+}
+
+@Singleton
+class WearAssetTransmitterImpl @Inject constructor(
+ @ApplicationContext val context: Context,
+) : WearAssetTransmitter {
+ private val channelClient: ChannelClient by lazy { Wearable.getChannelClient(context) }
+ private val messageClient: MessageClient by lazy { Wearable.getMessageClient(context) }
+
+ private var transferId = generateTransferId()
+
+ /** Sends the watch face to the watch. The approach taken is as follows:
+ *
+ * 1. Setup - the phone sends a message to the watch asking for the go-ahead to send the
+ * watch face. The watch only accepts one ongoing watch face transmission at a
+ * time. As part of the setup, the phone sends a unique ID which is used in
+ * the subsequent exchanges, as well as the validation token.
+ * 2. Transfer - the phone sends the watch face
+ * 3. Confirmation - The watch sends a confirmation message back to the phone, indicating
+ * success as well as the activation strategy to use. If there was an error
+ * then the details of the error are sent.
+ */
+ override val watchFaceInstallationUpdates = callbackFlow {
+ trySend(WatchFaceInstallationStatus.NotStarted)
+ val listener = MessageClient.OnMessageReceivedListener { event ->
+ if (event.path.contains(transferId)) {
+ val response =
+ ProtoBuf.decodeFromByteArray(event.data)
+ if (response.transferId == transferId) {
+ trySend(response)
+ }
+ }
+ }
+
+ messageClient.addListener(listener).await()
+
+ awaitClose {
+ messageClient.removeListener(listener).addOnSuccessListener { }
+ }
+ }
+
+ override suspend fun doTransfer(
+ nodeId: String,
+ watchFacePackage: WatchFacePackage,
+ ): WatchFaceInstallError {
+ var readyToReceive = false
+ val maybeTransferId = generateTransferId()
+ try {
+ readyToReceive =
+ assetTransferSetup(nodeId, maybeTransferId, watchFacePackage.validationToken)
+ } catch (e: TimeoutCancellationException) {
+ return WatchFaceInstallError.SEND_SETUP_TIMEOUT
+ } catch (e: Exception) {
+ Log.e(TAG, "Error sending request.", e)
+ return WatchFaceInstallError.SEND_SETUP_REQUEST_ERROR
+ }
+
+ if (!readyToReceive) {
+ return WatchFaceInstallError.WATCH_NOT_READY
+ }
+ transferId = maybeTransferId
+
+ try {
+ assetTransfer(nodeId, watchFacePackage.file)
+ } catch (e: TimeoutCancellationException) {
+ return WatchFaceInstallError.TRANSFER_TIMEOUT
+ } catch (e: Exception) {
+ Log.e(TAG, "Error transferring watch face", e)
+ return WatchFaceInstallError.TRANSFER_ERROR
+ } finally {
+ watchFacePackage.file.delete()
+ }
+ return WatchFaceInstallError.NO_ERROR
+ }
+
+ override fun resetTransferId() {
+ transferId = generateTransferId()
+ }
+
+ private suspend fun assetTransferSetup(
+ nodeId: String,
+ transferId: String,
+ token: String,
+ ): Boolean {
+ val initialRequest = InitialRequest(transferId = transferId, token = token)
+ val requestBytes = ProtoBuf.encodeToByteArray(initialRequest)
+
+ val response = withContext(Dispatchers.IO) {
+ withTimeout(SETUP_TIMEOUT_MS) {
+ val responseBytes = messageClient.sendRequest(
+ nodeId,
+ WearableConstants.ANDROIDIFY_INITIATE_TRANSFER_PATH,
+ requestBytes,
+ ).await()
+ ProtoBuf.decodeFromByteArray(responseBytes)
+ }
+ }
+ return response.proceed
+ }
+
+ private suspend fun assetTransfer(nodeId: String, file: File) {
+ withContext(Dispatchers.IO) {
+ withTimeout(TRANSFER_TIMEOUT_MS) {
+ val channelPath =
+ WearableConstants.ANDROIDIFY_TRANSFER_PATH_TEMPLATE.format(transferId)
+ val channel = channelClient.openChannel(nodeId, channelPath).await()
+ channelClient.sendFile(channel, Uri.fromFile(file)).await()
+ }
+ }
+ }
+
+ private fun generateTransferId() = UUID.randomUUID().toString().take(8)
+
+ companion object {
+ private val TAG = WearAssetTransmitterImpl::class.java.simpleName
+ }
+}
diff --git a/watchface/src/main/java/com/android/developers/androidify/watchface/transfer/WearDeviceRepository.kt b/watchface/src/main/java/com/android/developers/androidify/watchface/transfer/WearDeviceRepository.kt
new file mode 100644
index 00000000..2cb74f5c
--- /dev/null
+++ b/watchface/src/main/java/com/android/developers/androidify/watchface/transfer/WearDeviceRepository.kt
@@ -0,0 +1,92 @@
+/*
+ * 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.
+ */
+@file:OptIn(ExperimentalSerializationApi::class)
+
+package com.android.developers.androidify.watchface.transfer
+
+import android.content.Context
+import com.android.developers.androidify.wear.common.ConnectedWatch
+import com.android.developers.androidify.wear.common.WearableConstants.ANDROIDIFY_INSTALLED
+import com.google.android.gms.wearable.CapabilityClient
+import com.google.android.gms.wearable.Node
+import com.google.android.gms.wearable.NodeClient
+import com.google.android.gms.wearable.Wearable
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.tasks.await
+import kotlinx.serialization.ExperimentalSerializationApi
+import javax.inject.Inject
+import javax.inject.Singleton
+
+interface WearDeviceRepository {
+ val connectedWatch: Flow
+}
+
+@Singleton
+class WearDeviceRepositoryImpl @Inject constructor(
+ @ApplicationContext val context: Context,
+) : WearDeviceRepository {
+ private val nodeClient: NodeClient by lazy { Wearable.getNodeClient(context) }
+ private val capabilityClient: CapabilityClient by lazy { Wearable.getCapabilityClient(context) }
+
+ override val connectedWatch = callbackFlow {
+ val allDevices = nodeClient.connectedNodes.await().toSet()
+ val reachableCapability =
+ capabilityClient.getCapability(ANDROIDIFY_INSTALLED, CapabilityClient.FILTER_REACHABLE)
+ .await()
+
+ val installedDevices = reachableCapability.nodes.toSet()
+
+ trySend(selectConnectedDevice(installedDevices, allDevices))
+
+ val capabilityListener = CapabilityClient.OnCapabilityChangedListener { capabilityInfo ->
+ val installedDevicesUpdated = capabilityInfo.nodes.toSet()
+
+ trySend(selectConnectedDevice(installedDevicesUpdated, allDevices))
+ }
+ capabilityClient.addListener(capabilityListener, ANDROIDIFY_INSTALLED)
+ awaitClose {
+ capabilityClient.removeListener(capabilityListener)
+ }
+ }
+
+ /**
+ * Selects a [com.android.developers.androidify.wear.common.ConnectedWatch] if one is available, prioritizing devices with Androidify.
+ * Returns null where no device at all is available.
+ */
+ private fun selectConnectedDevice(
+ installedDevices: Set,
+ allDevices: Set,
+ ): ConnectedWatch? {
+ return if (installedDevices.isNotEmpty()) {
+ ConnectedWatch(
+ nodeId = installedDevices.first().id,
+ displayName = installedDevices.first().displayName,
+ hasAndroidify = true,
+ )
+ } else if (allDevices.isNotEmpty()) {
+ ConnectedWatch(
+ nodeId = allDevices.first().id,
+ displayName = allDevices.first().displayName,
+ hasAndroidify = false,
+ )
+ } else {
+ null
+ }
+ }
+}
diff --git a/watchface/src/main/jniLibs/arm64-v8a/libpack_java.so b/watchface/src/main/jniLibs/arm64-v8a/libpack_java.so
new file mode 100755
index 00000000..515c1f24
Binary files /dev/null and b/watchface/src/main/jniLibs/arm64-v8a/libpack_java.so differ
diff --git a/watchface/src/main/jniLibs/armeabi-v7a/libpack_java.so b/watchface/src/main/jniLibs/armeabi-v7a/libpack_java.so
new file mode 100755
index 00000000..a7798182
Binary files /dev/null and b/watchface/src/main/jniLibs/armeabi-v7a/libpack_java.so differ
diff --git a/watchface/src/main/jniLibs/x86/libpack_java.so b/watchface/src/main/jniLibs/x86/libpack_java.so
new file mode 100755
index 00000000..c932e63e
Binary files /dev/null and b/watchface/src/main/jniLibs/x86/libpack_java.so differ
diff --git a/watchface/src/main/jniLibs/x86_64/libpack_java.so b/watchface/src/main/jniLibs/x86_64/libpack_java.so
new file mode 100755
index 00000000..6ee4f70f
Binary files /dev/null and b/watchface/src/main/jniLibs/x86_64/libpack_java.so differ
diff --git a/wear/.gitignore b/wear/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/wear/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts
new file mode 100644
index 00000000..4e2dea7d
--- /dev/null
+++ b/wear/build.gradle.kts
@@ -0,0 +1,191 @@
+/*
+ * 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.
+ */
+import java.io.ByteArrayOutputStream
+import java.util.regex.Pattern
+
+evaluationDependsOn(":wear:watchface")
+
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.compose)
+ alias(libs.plugins.serialization)
+ alias(libs.plugins.kotlin.ksp)
+}
+
+android {
+ namespace = "com.android.developers.androidify"
+ compileSdk = libs.versions.compileSdk.get().toInt()
+
+ defaultConfig {
+ minSdk = libs.versions.wearMinSdk.get().toInt()
+ 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()
+ versionName = libs.versions.appVersionName.get()
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.toVersion(libs.versions.javaVersion.get())
+ targetCompatibility = JavaVersion.toVersion(libs.versions.javaVersion.get())
+ }
+ kotlinOptions {
+ jvmTarget = libs.versions.jvmTarget.get()
+ }
+ buildFeatures {
+ compose = true
+ }
+ sourceSets {
+ getByName("release") {
+ assets.srcDirs(layout.buildDirectory.dir("intermediates/watchfaceAssets/release"))
+ res.srcDirs(layout.buildDirectory.file("generated/wfTokenRes/release/res/"))
+ }
+ getByName("debug") {
+ assets.srcDirs(layout.buildDirectory.dir("intermediates/watchfaceAssets/debug"))
+ res.srcDirs(layout.buildDirectory.file("generated/wfTokenRes/debug/res/"))
+ }
+ }
+}
+
+configurations {
+ create("cliToolConfiguration") {
+ isCanBeConsumed = false
+ isCanBeResolved = true
+ }
+}
+
+dependencies {
+ implementation(projects.wear.common)
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.wear.compose.foundation)
+ implementation(libs.androidx.wear.compose.material)
+ implementation(libs.androidx.wear.compose.ui.tooling)
+ implementation(libs.androidx.activity.compose)
+ implementation(libs.play.services.wearable)
+ implementation(libs.kotlinx.coroutines.play.services)
+ implementation(libs.watchface.push)
+ implementation(libs.androidx.datastore)
+ implementation(libs.androidx.datastore.preferences)
+ implementation(libs.kotlinx.serialization.protobuf)
+ implementation(libs.androidx.wear)
+ implementation(libs.androidx.wear.remote.interactions)
+ implementation(libs.horologist.compose.layout)
+ implementation(libs.accompanist.permissions)
+
+ "cliToolConfiguration"(libs.validator.push.cli)
+}
+
+androidComponents.onVariants { variant ->
+ val capsVariant = variant.name.replaceFirstChar { it.uppercase() }
+
+ val copyTaskProvider = tasks.register("copyWatchface${capsVariant}Output") {
+ val wfTask = project(":wear:watchface").tasks.named("assemble$capsVariant")
+ dependsOn(wfTask)
+ val buildDir = project(":wear:watchface").layout.buildDirectory.asFileTree.matching {
+ include("**/${variant.name}/**/*.apk")
+ exclude("**/*androidTest*")
+ }
+ from(buildDir)
+ into(layout.buildDirectory.dir("intermediates/watchfaceAssets/${variant.name}"))
+
+ duplicatesStrategy = DuplicatesStrategy.EXCLUDE
+
+ eachFile {
+ path = "default_watchface.apk"
+ }
+ includeEmptyDirs = false
+ }
+
+ val tokenTask = tasks.register("generateToken${capsVariant}Res") {
+ val tokenFile =
+ layout.buildDirectory.file("generated/wfTokenRes/${variant.name}/res/values/wf_token.xml")
+
+ inputFile.from(copyTaskProvider.map { it.outputs.files.singleFile })
+ outputFile.set(tokenFile)
+ cliToolClasspath.set(project.configurations["cliToolConfiguration"])
+ }
+
+ afterEvaluate {
+ tasks.named("pre${capsVariant}Build").configure {
+ dependsOn(tokenTask)
+ }
+ }
+}
+
+abstract class ProcessFilesTask : DefaultTask() {
+ @get:InputFiles
+ abstract val inputFile: ConfigurableFileCollection
+
+ @get:OutputFile
+ abstract val outputFile: RegularFileProperty
+
+ @get:InputFiles
+ @get:PathSensitive(PathSensitivity.RELATIVE)
+ abstract val cliToolClasspath: Property
+
+ @get:Inject
+ abstract val execOperations: ExecOperations
+
+ @TaskAction
+ fun taskAction() {
+ val apkFile = inputFile.singleFile.resolve("default_watchface.apk")
+
+ val stdOut = ByteArrayOutputStream()
+ val stdErr = ByteArrayOutputStream()
+
+ execOperations.javaexec {
+ classpath = cliToolClasspath.get()
+ mainClass = "com.google.android.wearable.watchface.validator.cli.DwfValidation"
+
+ args(
+ "--apk_path=${apkFile.absolutePath}",
+ "--package_name=com.android.developers.androidify",
+ )
+ standardOutput = stdOut
+ errorOutput = stdErr
+ isIgnoreExitValue = true
+ }
+
+ val outputAsText = stdOut.toString()
+ val errorAsText = stdErr.toString()
+
+ if (outputAsText.contains("Failed check")) {
+ println(outputAsText)
+ if (errorAsText.isNotEmpty()) {
+ println(errorAsText)
+ }
+ throw GradleException("Watch face validation failed")
+ }
+
+ val match = Pattern.compile("generated token: (\\S+)").matcher(stdOut.toString())
+ if (match.find()) {
+ val token = match.group(1)
+ val output = outputFile.get().asFile
+ output.parentFile.mkdirs()
+ val tokenResText = """
+ | $token
+ |
+ """.trimMargin()
+ output.writeText(tokenResText)
+ } else {
+ throw TaskExecutionException(
+ this,
+ GradleException("No token generated for watch face!"),
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/wear/common/.gitignore b/wear/common/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/wear/common/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/wear/common/build.gradle.kts b/wear/common/build.gradle.kts
new file mode 100644
index 00000000..7f02b064
--- /dev/null
+++ b/wear/common/build.gradle.kts
@@ -0,0 +1,42 @@
+/*
+ * 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.
+ */
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.serialization)
+}
+
+android {
+ namespace = "com.android.developers.androidify.wear.common"
+ compileSdk = libs.versions.compileSdk.get().toInt()
+
+ defaultConfig {
+ minSdk = libs.versions.minSdk.get().toInt()
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.toVersion(libs.versions.javaVersion.get())
+ targetCompatibility = JavaVersion.toVersion(libs.versions.javaVersion.get())
+ }
+ kotlinOptions {
+ jvmTarget = libs.versions.jvmTarget.get()
+ }
+}
+
+dependencies {
+ implementation(libs.kotlinx.serialization.protobuf)
+ implementation(libs.androidx.datastore)
+}
\ No newline at end of file
diff --git a/wear/common/src/main/java/com/android/developers/androidify/wear/common/ConnectedWatch.kt b/wear/common/src/main/java/com/android/developers/androidify/wear/common/ConnectedWatch.kt
new file mode 100644
index 00000000..21305517
--- /dev/null
+++ b/wear/common/src/main/java/com/android/developers/androidify/wear/common/ConnectedWatch.kt
@@ -0,0 +1,22 @@
+/*
+ * 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.wear.common
+
+data class ConnectedWatch(
+ val nodeId: String,
+ val displayName: String,
+ val hasAndroidify: Boolean,
+)
diff --git a/wear/common/src/main/java/com/android/developers/androidify/wear/common/Messages.kt b/wear/common/src/main/java/com/android/developers/androidify/wear/common/Messages.kt
new file mode 100644
index 00000000..93ab4208
--- /dev/null
+++ b/wear/common/src/main/java/com/android/developers/androidify/wear/common/Messages.kt
@@ -0,0 +1,42 @@
+/*
+ * 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.wear.common
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class InitialRequest(
+ val transferId: String = "",
+ val token: String = "",
+)
+
+@Serializable
+data class InitialResponse(
+ val proceed: Boolean,
+)
+
+@Serializable
+data class InstallResponse(
+ val success: Boolean = false,
+ val activationStrategy: Int = 0,
+ val errorCode: Int = 0,
+)
+
+// TODO move
+sealed class WatchFaceInstallResult {
+ data class Success(val activationStrategy: WatchFaceActivationStrategy) : WatchFaceInstallResult()
+ data class Failure(val error: WatchFaceInstallError) : WatchFaceInstallResult()
+}
diff --git a/wear/common/src/main/java/com/android/developers/androidify/wear/common/WatchFaceActivationStrategy.kt b/wear/common/src/main/java/com/android/developers/androidify/wear/common/WatchFaceActivationStrategy.kt
new file mode 100644
index 00000000..7f1b7c51
--- /dev/null
+++ b/wear/common/src/main/java/com/android/developers/androidify/wear/common/WatchFaceActivationStrategy.kt
@@ -0,0 +1,42 @@
+/*
+ * 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.wear.common
+
+enum class WatchFaceActivationStrategy {
+ NO_ACTION_NEEDED,
+ CALL_SET_ACTIVE_NO_USER_ACTION,
+ FOLLOW_PROMPT_ON_WATCH,
+ LONG_PRESS_TO_SET,
+ GO_TO_WATCH_SETTINGS,
+ ;
+
+ companion object {
+ fun fromWatchFaceState(
+ hasActiveWatchFace: Boolean = false,
+ hasGrantedSetActivePermission: Boolean = false,
+ canRequestSetActivePermission: Boolean = true,
+ hasUsedSetActiveApi: Boolean = false,
+ ): WatchFaceActivationStrategy {
+ return when {
+ hasActiveWatchFace -> NO_ACTION_NEEDED
+ hasGrantedSetActivePermission && !hasUsedSetActiveApi -> CALL_SET_ACTIVE_NO_USER_ACTION
+ canRequestSetActivePermission && !hasUsedSetActiveApi -> FOLLOW_PROMPT_ON_WATCH
+ !canRequestSetActivePermission && !hasGrantedSetActivePermission && !hasUsedSetActiveApi -> GO_TO_WATCH_SETTINGS
+ else -> return LONG_PRESS_TO_SET
+ }
+ }
+ }
+}
diff --git a/wear/common/src/main/java/com/android/developers/androidify/wear/common/WatchFaceInstallError.kt b/wear/common/src/main/java/com/android/developers/androidify/wear/common/WatchFaceInstallError.kt
new file mode 100644
index 00000000..7b32926f
--- /dev/null
+++ b/wear/common/src/main/java/com/android/developers/androidify/wear/common/WatchFaceInstallError.kt
@@ -0,0 +1,26 @@
+/*
+ * 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.wear.common
+
+enum class WatchFaceInstallError {
+ NO_ERROR,
+ WATCH_NOT_READY,
+ SEND_SETUP_REQUEST_ERROR,
+ SEND_SETUP_TIMEOUT,
+ TRANSFER_ERROR,
+ TRANSFER_TIMEOUT,
+ WATCH_FACE_INSTALL_ERROR,
+}
diff --git a/wear/common/src/main/java/com/android/developers/androidify/wear/common/WatchFaceInstallationStatus.kt b/wear/common/src/main/java/com/android/developers/androidify/wear/common/WatchFaceInstallationStatus.kt
new file mode 100644
index 00000000..5f1143da
--- /dev/null
+++ b/wear/common/src/main/java/com/android/developers/androidify/wear/common/WatchFaceInstallationStatus.kt
@@ -0,0 +1,82 @@
+/*
+ * 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.wear.common
+
+import android.content.Context
+import androidx.datastore.core.CorruptionException
+import androidx.datastore.core.DataStore
+import androidx.datastore.core.Serializer
+import androidx.datastore.dataStore
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.decodeFromByteArray
+import kotlinx.serialization.encodeToByteArray
+import kotlinx.serialization.protobuf.ProtoBuf
+import java.io.InputStream
+import java.io.OutputStream
+
+@Serializable
+sealed class WatchFaceInstallationStatus() {
+ @Serializable
+ object Unknown : WatchFaceInstallationStatus()
+
+ @Serializable
+ object NotStarted : WatchFaceInstallationStatus()
+
+ @Serializable
+ data class Receiving(
+ val otherNodeId: String,
+ val transferId: String,
+ val validationToken: String,
+ val activationStrategy: WatchFaceActivationStrategy,
+ ) : WatchFaceInstallationStatus()
+ object Sending : WatchFaceInstallationStatus()
+
+ @Serializable
+ data class Complete(
+ val success: Boolean,
+ val otherNodeId: String,
+ val transferId: String = "",
+ val validationToken: String = "",
+ val activationStrategy: WatchFaceActivationStrategy = WatchFaceActivationStrategy.NO_ACTION_NEEDED,
+ val installError: WatchFaceInstallError,
+ ) : WatchFaceInstallationStatus()
+}
+
+@OptIn(ExperimentalSerializationApi::class)
+object WatchFaceInstallationStatusSerializer : Serializer {
+ override val defaultValue = WatchFaceInstallationStatus.NotStarted
+
+ override suspend fun readFrom(input: InputStream): WatchFaceInstallationStatus {
+ try {
+ return ProtoBuf.decodeFromByteArray(input.readBytes())
+ } catch (serialization: SerializationException) {
+ throw CorruptionException("Unable to read Palette", serialization)
+ }
+ }
+
+ override suspend fun writeTo(t: WatchFaceInstallationStatus, output: OutputStream) {
+ output.write(
+ ProtoBuf.encodeToByteArray(t),
+ )
+ }
+}
+
+val Context.watchFaceInstallationStatusDataStore: DataStore by dataStore(
+ fileName = "watch_face_installation_status_data_store",
+ serializer = WatchFaceInstallationStatusSerializer,
+)
diff --git a/wear/common/src/main/java/com/android/developers/androidify/wear/common/WearableConstants.kt b/wear/common/src/main/java/com/android/developers/androidify/wear/common/WearableConstants.kt
new file mode 100644
index 00000000..b998b711
--- /dev/null
+++ b/wear/common/src/main/java/com/android/developers/androidify/wear/common/WearableConstants.kt
@@ -0,0 +1,27 @@
+/*
+ * 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.wear.common
+
+object WearableConstants {
+ const val ANDROIDIFY_INITIATE_TRANSFER_PATH = "/initiate_transfer"
+ const val ANDROIDIFY_FINALIZE_TRANSFER_TEMPLATE = "/finalize_transfer/%s"
+
+ const val ANDROIDIFY_INSTALLED = "androidify"
+ const val ANDROIDIFY_TRANSFER_PATH_TEMPLATE = "/transfer_apk/%s"
+
+ const val SETUP_TIMEOUT_MS = 60_000L
+ const val TRANSFER_TIMEOUT_MS = 60_000L
+}
diff --git a/wear/lint.xml b/wear/lint.xml
new file mode 100644
index 00000000..5c43a4ab
--- /dev/null
+++ b/wear/lint.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/wear/proguard-rules.pro b/wear/proguard-rules.pro
new file mode 100644
index 00000000..ff59496d
--- /dev/null
+++ b/wear/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.kts.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..752c7bb0
--- /dev/null
+++ b/wear/src/main/AndroidManifest.xml
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/wear/src/main/ic_launcher-playstore.png b/wear/src/main/ic_launcher-playstore.png
new file mode 100644
index 00000000..e5964d06
Binary files /dev/null and b/wear/src/main/ic_launcher-playstore.png differ
diff --git a/wear/src/main/java/com/android/developers/androidify/LaunchOnPhoneActivity.kt b/wear/src/main/java/com/android/developers/androidify/LaunchOnPhoneActivity.kt
new file mode 100644
index 00000000..56f7caa1
--- /dev/null
+++ b/wear/src/main/java/com/android/developers/androidify/LaunchOnPhoneActivity.kt
@@ -0,0 +1,63 @@
+/*
+ * 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
+
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import androidx.activity.ComponentActivity
+import androidx.core.net.toUri
+import androidx.wear.remote.interactions.RemoteActivityHelper
+import androidx.wear.widget.ConfirmationOverlay
+import androidx.wear.widget.ConfirmationOverlay.OPEN_ON_PHONE_ANIMATION
+import kotlinx.coroutines.guava.await
+import kotlinx.coroutines.runBlocking
+
+/**
+ * A helper activity that launches the phone Androidify app. This Activity is only started from the
+ * default watch face and does not form part of the rest of the Wear OS app experience.
+ */
+class LaunchOnPhoneActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val intent = Intent(Intent.ACTION_VIEW, "androidify://launch".toUri())
+ intent.addCategory(Intent.CATEGORY_BROWSABLE)
+ val helper = RemoteActivityHelper(this)
+ val message: CharSequence = getString(R.string.continue_on_phone)
+
+ ConfirmationOverlay()
+ .setType(OPEN_ON_PHONE_ANIMATION)
+ .setDuration(5000)
+ .setMessage(message)
+ .setOnAnimationFinishedListener {
+ onAnimationFinished()
+ }
+ .showOn(this)
+
+ runBlocking {
+ try {
+ helper.startRemoteActivity(intent).await()
+ } catch (e: RemoteActivityHelper.RemoteIntentException) {
+ Log.e("LaunchOnPhoneActivity", "Error launching on phone", e)
+ }
+ }
+ }
+
+ fun onAnimationFinished() {
+ finish()
+ }
+}
diff --git a/wear/src/main/java/com/android/developers/androidify/MainActivity.kt b/wear/src/main/java/com/android/developers/androidify/MainActivity.kt
new file mode 100644
index 00000000..b11e4fec
--- /dev/null
+++ b/wear/src/main/java/com/android/developers/androidify/MainActivity.kt
@@ -0,0 +1,72 @@
+/*
+ * 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
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.lifecycle.lifecycleScope
+import androidx.wear.ambient.AmbientLifecycleObserver
+import com.android.developers.androidify.ui.WatchFaceOnboardingScreen
+import com.android.developers.androidify.ui.theme.AndroidifyWearTheme
+import com.android.developers.androidify.watchfacepush.LAUNCHED_FROM_WATCH_FACE_TRANSFER
+import com.android.developers.androidify.watchfacepush.WatchFaceOnboardingRepository
+import kotlinx.coroutines.launch
+
+class MainActivity : ComponentActivity() {
+ val ambientCallback = object : AmbientLifecycleObserver.AmbientLifecycleCallback {
+ override fun onEnterAmbient(ambientDetails: AmbientLifecycleObserver.AmbientDetails) { }
+
+ override fun onExitAmbient() { }
+
+ override fun onUpdateAmbient() { }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val launchedFromWatchFaceTransfer = intent.extras?.getBoolean(LAUNCHED_FROM_WATCH_FACE_TRANSFER) ?: false
+
+ // If manually launched and the last time used, the watch face transfer was completed,
+ // reset this state.
+ if (!launchedFromWatchFaceTransfer) {
+ resetWatchFaceTransferStateIfComplete()
+ }
+
+ lifecycle.addObserver(AmbientLifecycleObserver(this, ambientCallback))
+
+ setTheme(android.R.style.Theme_DeviceDefault)
+
+ setContent {
+ AndroidifyWearTheme {
+ WatchFaceOnboardingScreen(
+ launchedFromWatchFaceTransfer = launchedFromWatchFaceTransfer,
+ )
+ }
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ lifecycle.removeObserver(AmbientLifecycleObserver(this, ambientCallback))
+ }
+
+ private fun resetWatchFaceTransferStateIfComplete() {
+ val watchFaceOnboardingRepository = WatchFaceOnboardingRepository(this)
+ lifecycleScope.launch {
+ watchFaceOnboardingRepository.resetWatchFaceTransferStateIfComplete()
+ }
+ }
+}
diff --git a/wear/src/main/java/com/android/developers/androidify/MainApplication.kt b/wear/src/main/java/com/android/developers/androidify/MainApplication.kt
new file mode 100644
index 00000000..4683de67
--- /dev/null
+++ b/wear/src/main/java/com/android/developers/androidify/MainApplication.kt
@@ -0,0 +1,25 @@
+/*
+ * 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
+
+import android.app.Application
+import com.android.developers.androidify.data.WatchFacePushStateManager
+import com.android.developers.androidify.watchfacepush.WatchFaceOnboardingRepository
+
+class MainApplication : Application() {
+ val watchFacePushStateManager by lazy { WatchFacePushStateManager(this) }
+ val watchFaceOnboardingRepository by lazy { WatchFaceOnboardingRepository(this) }
+}
diff --git a/wear/src/main/java/com/android/developers/androidify/WatchFaceOnboardingViewModel.kt b/wear/src/main/java/com/android/developers/androidify/WatchFaceOnboardingViewModel.kt
new file mode 100644
index 00000000..19eda6f7
--- /dev/null
+++ b/wear/src/main/java/com/android/developers/androidify/WatchFaceOnboardingViewModel.kt
@@ -0,0 +1,117 @@
+/*
+ * 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.
+ */
+@file:OptIn(ExperimentalPermissionsApi::class)
+
+package com.android.developers.androidify
+
+/*
+ * Copyright 2025 Google LLC
+ *
+ * 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
+ *
+ * http://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.
+ */
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
+import androidx.lifecycle.viewModelScope
+import androidx.lifecycle.viewmodel.initializer
+import androidx.lifecycle.viewmodel.viewModelFactory
+import com.android.developers.androidify.watchfacepush.WatchFaceOnboardingRepository
+import com.android.developers.androidify.wear.common.WatchFaceActivationStrategy
+import com.android.developers.androidify.wear.common.WatchFaceInstallationStatus
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+
+class WatchFaceOnboardingViewModel(
+ val watchFaceOnboardingRepository: WatchFaceOnboardingRepository,
+) : ViewModel() {
+ val state = watchFaceOnboardingRepository.watchFaceTransferState
+ .stateIn(viewModelScope, kotlinx.coroutines.flow.SharingStarted.Eagerly, WatchFaceInstallationStatus.Unknown)
+
+ /**
+ * As a result of permissions changes, for example, the user completing the permissions flow, or
+ * the user going into settings and changing the permission, the activation strategy may now
+ * have changed. For example, it may now be possible for the app to directly set the watch face
+ * as active.
+ *
+ * This method re-evaluates the strategy and acts on it if necessary, communicating the new
+ * strategy back to the phone.
+ */
+ fun maybeSendUpdateOnPermissionsChange(
+ granted: Boolean,
+ shouldShowRationale: Boolean,
+ ) {
+ viewModelScope.launch {
+ val currentState = state.value
+ if (granted) {
+ watchFaceOnboardingRepository.updatePermissionStatus(true)
+ } else if (!shouldShowRationale) {
+ watchFaceOnboardingRepository.updatePermissionStatus(false)
+ }
+
+ var newStrategy = watchFaceOnboardingRepository.getWatchFaceActivationStrategy()
+ // In the special case where the watch face can now be directly activated by the app.
+ if (newStrategy == WatchFaceActivationStrategy.CALL_SET_ACTIVE_NO_USER_ACTION) {
+ watchFaceOnboardingRepository.setActiveWatchFace()
+ newStrategy = WatchFaceActivationStrategy.NO_ACTION_NEEDED
+ }
+
+ if (currentState is WatchFaceInstallationStatus.Complete &&
+ currentState.success
+ ) {
+ val newStatus = WatchFaceInstallationStatus.Complete(
+ success = true,
+ activationStrategy = newStrategy,
+ installError = currentState.installError,
+ otherNodeId = currentState.otherNodeId,
+ transferId = currentState.transferId,
+ validationToken = currentState.validationToken,
+ )
+ watchFaceOnboardingRepository.setWatchFaceTransferState(newStatus)
+ watchFaceOnboardingRepository.sendInstallUpdate(newStatus)
+ }
+ }
+ }
+
+ fun resetWatchFaceTransferState() {
+ viewModelScope.launch {
+ watchFaceOnboardingRepository.resetWatchFaceTransferState()
+ }
+ }
+
+ companion object {
+ private const val TAG = "WatchFaceOnboardingViewModel"
+ val Factory: ViewModelProvider.Factory = viewModelFactory {
+ initializer {
+ val app = (this[APPLICATION_KEY] as MainApplication)
+ WatchFaceOnboardingViewModel(
+ watchFaceOnboardingRepository = app.watchFaceOnboardingRepository,
+ )
+ }
+ }
+ }
+}
diff --git a/wear/src/main/java/com/android/developers/androidify/data/WatchFacePushStateManager.kt b/wear/src/main/java/com/android/developers/androidify/data/WatchFacePushStateManager.kt
new file mode 100644
index 00000000..fcf6dd35
--- /dev/null
+++ b/wear/src/main/java/com/android/developers/androidify/data/WatchFacePushStateManager.kt
@@ -0,0 +1,85 @@
+/*
+ * 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.data
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.preferencesDataStore
+import com.android.developers.androidify.wear.common.WatchFaceInstallationStatus
+import com.android.developers.androidify.wear.common.watchFaceInstallationStatusDataStore
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+private val Context.dataStore: DataStore by preferencesDataStore(name = "wfp")
+
+/**
+ * Storage for the state of Watch Face Push, namely whether permissions and one-shot APIs have
+ * already been used / set.
+ */
+class WatchFacePushStateManager(val context: Context) {
+ private val activeWatchFaceApiUsedKey = booleanPreferencesKey("setActiveUsed")
+ private val permissionDeniedKey = booleanPreferencesKey("permissionDenied")
+
+ /**
+ * The [setWatchFaceAsActive] API is a single-shot API - after one once it will not
+ * work again. This indicates whether the API has already been used.
+ */
+ val activeWatchFaceApiUsed: Flow = context.dataStore.data
+ .map { preferences ->
+ preferences[activeWatchFaceApiUsedKey] == true
+ }
+
+ /**
+ * Marks that the [setWatchFaceAsActive] API call has already been used.
+ */
+ suspend fun setActiveWatchFaceApiUsedKey(value: Boolean) {
+ context.dataStore.edit { preferences ->
+ preferences[activeWatchFaceApiUsedKey] = value
+ }
+ }
+
+ /**
+ * Indicates whether the SET_PUSHED_WATCH_FACE_AS_ACTIVE permission has been denied already.
+ */
+ val watchFacePermissionDenied: Flow = context.dataStore.data
+ .map { preferences ->
+ preferences[permissionDeniedKey] == true
+ }
+
+ /**
+ * Sets whether the SET_PUSHED_WATCH_FACE_AS_ACTIVE permission has already been denied once.
+ */
+ suspend fun setWatchFacePermissionDenied(value: Boolean) {
+ context.dataStore.edit { preferences ->
+ preferences[permissionDeniedKey] = value
+ }
+ }
+
+ /**
+ * Sets the current status of a watch face installation.
+ */
+ suspend fun setWatchFaceInstallationStatus(watchFaceInstallationStatus: WatchFaceInstallationStatus) {
+ context.watchFaceInstallationStatusDataStore.updateData { watchFaceInstallationStatus }
+ }
+
+ /**
+ * The current status of a watch face installation.
+ */
+ val watchFaceInstallationStatus = context.watchFaceInstallationStatusDataStore.data
+}
diff --git a/wear/src/main/java/com/android/developers/androidify/service/AndroidifyDataListenerService.kt b/wear/src/main/java/com/android/developers/androidify/service/AndroidifyDataListenerService.kt
new file mode 100644
index 00000000..46730be9
--- /dev/null
+++ b/wear/src/main/java/com/android/developers/androidify/service/AndroidifyDataListenerService.kt
@@ -0,0 +1,305 @@
+/*
+ * 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.
+ */
+@file:OptIn(ExperimentalAtomicApi::class)
+
+package com.android.developers.androidify.service
+
+import android.annotation.SuppressLint
+import android.content.Intent
+import android.os.ParcelFileDescriptor
+import android.os.PowerManager
+import androidx.core.net.toUri
+import com.android.developers.androidify.MainActivity
+import com.android.developers.androidify.data.WatchFacePushStateManager
+import com.android.developers.androidify.watchfacepush.LAUNCHED_FROM_WATCH_FACE_TRANSFER
+import com.android.developers.androidify.watchfacepush.WatchFaceOnboardingRepository
+import com.android.developers.androidify.wear.common.InitialRequest
+import com.android.developers.androidify.wear.common.InitialResponse
+import com.android.developers.androidify.wear.common.WatchFaceActivationStrategy
+import com.android.developers.androidify.wear.common.WatchFaceInstallError
+import com.android.developers.androidify.wear.common.WatchFaceInstallationStatus
+import com.android.developers.androidify.wear.common.WearableConstants
+import com.android.developers.androidify.wear.common.WearableConstants.TRANSFER_TIMEOUT_MS
+import com.google.android.gms.tasks.Task
+import com.google.android.gms.tasks.Tasks
+import com.google.android.gms.wearable.ChannelClient
+import com.google.android.gms.wearable.Wearable
+import com.google.android.gms.wearable.WearableListenerService
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.tasks.await
+import kotlinx.coroutines.withTimeoutOrNull
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.decodeFromByteArray
+import kotlinx.serialization.encodeToByteArray
+import kotlinx.serialization.protobuf.ProtoBuf
+import java.io.File
+import java.io.FileInputStream
+import kotlin.concurrent.atomics.AtomicBoolean
+import kotlin.concurrent.atomics.ExperimentalAtomicApi
+import kotlin.coroutines.resume
+
+/**
+ * Receives incoming connections from the phone, used for transferring watch faces.
+ */
+@OptIn(ExperimentalSerializationApi::class)
+class AndroidifyDataListenerService : WearableListenerService() {
+ private val serviceJob = SupervisorJob()
+ private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
+ private val messageClient by lazy { Wearable.getMessageClient(this) }
+ private val channelClient by lazy { Wearable.getChannelClient(this) }
+
+ private var transferTimeoutJob: Job? = null
+ private var receiverJob: Job? = null
+
+ private val watchFacePushStateManager by lazy { WatchFacePushStateManager(this) }
+ private val watchFaceOnboardingRepository by lazy { WatchFaceOnboardingRepository(this) }
+
+ /**
+ * The initial request to start a transfer is sent via message client from the phone to the
+ * watch. The watch responds to the phone to either confirm or deny the transfer.
+ *
+ * The watch will only accept one transfer at a time. Each transfer is started with a unique
+ * transfer ID which is passed in the [InitialRequest].
+ */
+ override fun onRequest(nodeId: String, path: String, data: ByteArray): Task? {
+ super.onRequest(nodeId, path, data)
+ if (path == WearableConstants.ANDROIDIFY_INITIATE_TRANSFER_PATH) {
+ return doTransferReceiveSetup(nodeId, data)
+ }
+ return Tasks.forResult(null)
+ }
+
+ /**
+ * Receives the watch face payload and initiates installation.
+ */
+ override fun onChannelOpened(channel: ChannelClient.Channel) {
+ super.onChannelOpened(channel)
+ receiverJob = serviceScope.launch {
+ // Check that the watch is expecting a watch face transfer and that the transfer ID of
+ // the incoming APK matches that which was sent in the initial request.
+ val transferState = watchFacePushStateManager.watchFaceInstallationStatus.first()
+ if (transferState !is WatchFaceInstallationStatus.Receiving ||
+ !channel.path.contains(transferState.transferId)
+ ) {
+ return@launch
+ }
+ // This job was set after the initial request in case the phone never followed up with
+ // the APK payload, so it's ok to now cancel that.
+ transferTimeoutJob?.cancel()
+ transferTimeoutJob = null
+
+ val result = withTimeoutOrNull(TRANSFER_TIMEOUT_MS) {
+ suspendCancellableCoroutine { continuation ->
+ val resultChannel = Channel(Channel.CONFLATED)
+
+ val tempFile = File.createTempFile("temp", ".apk")
+ tempFile.deleteOnExit()
+
+ // The [onInputClosed] callback method is called when a file has successfully
+ // been received by the device, or the channel closed for another reason.
+ val callback = object : ChannelClient.ChannelCallback() {
+ override fun onInputClosed(
+ channel: ChannelClient.Channel,
+ closeReason: Int,
+ appErrorCode: Int,
+ ) {
+ super.onInputClosed(channel, closeReason, appErrorCode)
+ val transferResult = if (closeReason == CLOSE_REASON_NORMAL) {
+ WatchFaceInstallError.NO_ERROR
+ } else {
+ WatchFaceInstallError.TRANSFER_ERROR
+ }
+ resultChannel.trySend(transferResult)
+ }
+ }
+
+ continuation.invokeOnCancellation {
+ isTransferInProgress.store(false)
+ channelClient.unregisterChannelCallback(callback)
+ }
+
+ val continuationScope = CoroutineScope(continuation.context)
+ continuationScope.launch {
+ try {
+ channelClient.registerChannelCallback(channel, callback).await()
+ channelClient.receiveFile(channel, tempFile.toUri(), false)
+
+ val finalResult = resultChannel.receive()
+
+ if (finalResult == WatchFaceInstallError.NO_ERROR) {
+ installAndSetWatchFace(
+ tempFile,
+ transferState.validationToken,
+ transferState.activationStrategy,
+ )
+ }
+
+ if (continuation.isActive) {
+ continuation.resume(finalResult)
+ }
+ } catch (e: Exception) {
+ if (continuation.isActive) {
+ continuation.resume(WatchFaceInstallError.TRANSFER_ERROR)
+ }
+ } finally {
+ channelClient.unregisterChannelCallback(callback)
+ resultChannel.close()
+ }
+ }
+ }
+ } ?: WatchFaceInstallError.TRANSFER_TIMEOUT
+
+ val completedStatus = WatchFaceInstallationStatus.Complete(
+ success = result == WatchFaceInstallError.NO_ERROR,
+ transferId = transferState.transferId,
+ validationToken = transferState.validationToken,
+ activationStrategy = transferState.activationStrategy,
+ installError = result,
+ otherNodeId = channel.nodeId,
+ )
+ // Update the local status of the transfer, which is then reflected on the watch UI.
+ watchFaceOnboardingRepository.watchFacePushStateManager
+ .setWatchFaceInstallationStatus(completedStatus)
+ sendInstallResponse(channel.nodeId, completedStatus)
+ isTransferInProgress.store(false)
+ }
+ }
+
+ private fun doTransferReceiveSetup(nodeId: String, data: ByteArray): Task? {
+ val initialRequest = ProtoBuf.decodeFromByteArray(data)
+ // An atomic boolean is used to ensure only one transfer is in progress.
+ val canProceed = isTransferInProgress.compareAndSet(false, true)
+
+ val response = runBlocking {
+ if (canProceed) {
+ launchWatchFaceGuidance()
+
+ // The activation strategy is determined *before* the watch is transferred and
+ // installed, because installing / changing watch faces can lead to temporary
+ // inaccuracy in the Watch Face Push API reporting whether the app has the
+ // active watch face or not. So this is determined ahead of time and stored.
+ val strategy = watchFaceOnboardingRepository.getWatchFaceActivationStrategy()
+
+ watchFacePushStateManager.setWatchFaceInstallationStatus(
+ WatchFaceInstallationStatus.Receiving(
+ activationStrategy = strategy,
+ transferId = initialRequest.transferId,
+ validationToken = initialRequest.token,
+ otherNodeId = nodeId,
+ ),
+ )
+
+ // A timeout job is started in case, having initiated the transfer, the phone
+ // does not follow up by actually sending the watch face APK payload.
+ transferTimeoutJob = serviceScope.launch {
+ configureTransferTimeout(initialRequest, nodeId, strategy)
+ }
+ }
+ InitialResponse(proceed = canProceed)
+ }
+ return Tasks.forResult(ProtoBuf.encodeToByteArray(response))
+ }
+
+ private suspend fun configureTransferTimeout(initialRequest: InitialRequest, nodeId: String, strategy: WatchFaceActivationStrategy) {
+ delay(TRANSFER_TIMEOUT_MS)
+ val transferState = watchFacePushStateManager.watchFaceInstallationStatus.first()
+ if (transferState is WatchFaceInstallationStatus.Receiving &&
+ transferState.transferId == initialRequest.transferId) {
+ watchFacePushStateManager.setWatchFaceInstallationStatus(
+ WatchFaceInstallationStatus.Complete(
+ success = false,
+ installError = WatchFaceInstallError.TRANSFER_TIMEOUT,
+ activationStrategy = strategy,
+ transferId = initialRequest.transferId,
+ validationToken = initialRequest.token,
+ otherNodeId = nodeId,
+ ),
+ )
+ isTransferInProgress.store(false)
+ }
+ }
+
+ private suspend fun sendInstallResponse(nodeId: String, state: WatchFaceInstallationStatus.Complete) {
+ val byteArray = ProtoBuf.encodeToByteArray(state)
+ val path = WearableConstants.ANDROIDIFY_FINALIZE_TRANSFER_TEMPLATE.format(state.transferId)
+ messageClient.sendMessage(nodeId, path, byteArray).await()
+ }
+
+ /**
+ * Installs the watch face. If it is possible to set it as active watch face with no further
+ * user interaction, then this is also done.
+ */
+ private suspend fun installAndSetWatchFace(apkFile: File, token: String, strategy: WatchFaceActivationStrategy): WatchFaceInstallError {
+ return FileInputStream(apkFile).use { stream ->
+ val installResult = watchFaceOnboardingRepository.updateOrInstallWatchFace(ParcelFileDescriptor.dup(stream.fd), token)
+ if (installResult == WatchFaceInstallError.NO_ERROR) {
+ if (strategy == WatchFaceActivationStrategy.CALL_SET_ACTIVE_NO_USER_ACTION) {
+ watchFaceOnboardingRepository.setActiveWatchFace()
+ }
+ }
+ installResult
+ }
+ }
+
+ @SuppressLint("WearRecents")
+ private fun launchWatchFaceGuidance() {
+ wakeDevice()
+ val intent = Intent(this, MainActivity::class.java)
+ intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ intent.putExtra(LAUNCHED_FROM_WATCH_FACE_TRANSFER, true)
+ startActivity(intent)
+ }
+
+ /**
+ * Wakes the device. This is important to do when a transfer is incoming as otherwise the UI
+ * will not necessarily show to the user.
+ */
+ private fun wakeDevice() {
+ val powerManager = getSystemService(POWER_SERVICE) as PowerManager
+
+ // FULL_WAKE_LOCK and ACQUIRE_CAUSES_WAKEUP are deprecated, but they remain in use as the
+ // approach for achieving screen wakeup across mainstream apps, so are the approach to use
+ // for now.
+ @Suppress("DEPRECATION")
+ val wakeLock = powerManager.newWakeLock(
+ PowerManager.FULL_WAKE_LOCK
+ or PowerManager.ACQUIRE_CAUSES_WAKEUP
+ or PowerManager.ON_AFTER_RELEASE,
+ WAKELOCK_TAG,
+ )
+
+ // Wakelock timeout should not be required as it is being immediately released but
+ // linting guidance recommends one so setting it nonetheless.
+ wakeLock.acquire(WAKELOCK_TIMEOUT_MS)
+ wakeLock.release()
+ }
+
+ companion object {
+ private val TAG = AndroidifyDataListenerService::class.java.simpleName
+ private val isTransferInProgress = AtomicBoolean(false)
+ private const val WAKELOCK_TAG = "androidify:wear"
+ private const val WAKELOCK_TIMEOUT_MS = 1000L
+ }
+}
diff --git a/wear/src/main/java/com/android/developers/androidify/ui/AllDoneScreen.kt b/wear/src/main/java/com/android/developers/androidify/ui/AllDoneScreen.kt
new file mode 100644
index 00000000..cdf0857a
--- /dev/null
+++ b/wear/src/main/java/com/android/developers/androidify/ui/AllDoneScreen.kt
@@ -0,0 +1,51 @@
+/*
+ * 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.ui
+
+import androidx.activity.compose.LocalActivity
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices
+import com.android.developers.androidify.R
+import com.android.developers.androidify.ui.theme.AndroidifyWearTheme
+
+@Composable
+fun AllDoneScreen(
+ onAllDone: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val activity = LocalActivity.current
+ val finalAction = {
+ onAllDone()
+ activity?.finishAndRemoveTask()
+ Unit
+ }
+
+ CallToActionScreen(
+ callToActionText = stringResource(R.string.all_done_prompt),
+ buttonText = stringResource(R.string.all_done_button_text),
+ onCallToActionClick = finalAction,
+ )
+}
+
+@WearPreviewDevices
+@Composable
+fun AllDoneScreenPreview() {
+ AndroidifyWearTheme {
+ AllDoneScreen({})
+ }
+}
diff --git a/wear/src/main/java/com/android/developers/androidify/ui/CallToActionButton.kt b/wear/src/main/java/com/android/developers/androidify/ui/CallToActionButton.kt
new file mode 100644
index 00000000..e5349fe6
--- /dev/null
+++ b/wear/src/main/java/com/android/developers/androidify/ui/CallToActionButton.kt
@@ -0,0 +1,45 @@
+/*
+ * 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.ui
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.wear.compose.material3.FilledTonalButton
+import androidx.wear.compose.material3.Text
+
+@Composable
+fun CallToActionButton(
+ modifier: Modifier = Modifier,
+ buttonText: String,
+ onClick: () -> Unit,
+) {
+ FilledTonalButton(
+ modifier = Modifier.fillMaxWidth(0.85f),
+ onClick = onClick,
+ ) {
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ text = buttonText,
+ textAlign = TextAlign.Center,
+ )
+ }
+}
diff --git a/wear/src/main/java/com/android/developers/androidify/ui/CallToActionScreen.kt b/wear/src/main/java/com/android/developers/androidify/ui/CallToActionScreen.kt
new file mode 100644
index 00000000..7fdde8ed
--- /dev/null
+++ b/wear/src/main/java/com/android/developers/androidify/ui/CallToActionScreen.kt
@@ -0,0 +1,94 @@
+/*
+ * 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.ui
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.wear.compose.foundation.lazy.TransformingLazyColumn
+import androidx.wear.compose.foundation.lazy.rememberTransformingLazyColumnState
+import androidx.wear.compose.material3.MaterialTheme
+import androidx.wear.compose.material3.ScreenScaffold
+import androidx.wear.compose.material3.Text
+import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices
+import com.android.developers.androidify.R
+import com.android.developers.androidify.ui.theme.AndroidifyWearTheme
+import com.google.android.horologist.compose.layout.ColumnItemType
+import com.google.android.horologist.compose.layout.rememberResponsiveColumnPadding
+
+@Composable
+fun CallToActionScreen(
+ callToActionText: String,
+ buttonText: String,
+ onCallToActionClick: () -> Unit,
+) {
+ val listState = rememberTransformingLazyColumnState()
+ ScreenScaffold(
+ scrollState = listState,
+ // Use Horologist for now to get correct top and bottom padding in list.
+ contentPadding = rememberResponsiveColumnPadding(
+ first = ColumnItemType.IconButton,
+ last = ColumnItemType.Button,
+ ),
+ ) { contentPadding ->
+ TransformingLazyColumn(
+ state = listState,
+ contentPadding = contentPadding,
+ ) {
+ item {
+ Image(
+ modifier = Modifier.fillMaxWidth(0.3f),
+ painter = painterResource(id = R.drawable.logo),
+ contentDescription = stringResource(R.string.logo_description),
+ )
+ }
+ item {
+ Text(
+ modifier = Modifier
+ .padding(top = 8.dp, bottom = 8.dp)
+ .fillMaxWidth(),
+ textAlign = TextAlign.Center,
+ text = callToActionText,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ item {
+ CallToActionButton(
+ buttonText = buttonText,
+ onClick = onCallToActionClick,
+ )
+ }
+ }
+ }
+}
+
+@WearPreviewDevices
+@Composable
+fun CallToActionScreenPreview() {
+ AndroidifyWearTheme {
+ CallToActionScreen(
+ callToActionText = "Call to action text",
+ buttonText = "Button text",
+ onCallToActionClick = {},
+ )
+ }
+}
diff --git a/wear/src/main/java/com/android/developers/androidify/ui/ErrorScreen.kt b/wear/src/main/java/com/android/developers/androidify/ui/ErrorScreen.kt
new file mode 100644
index 00000000..701c443d
--- /dev/null
+++ b/wear/src/main/java/com/android/developers/androidify/ui/ErrorScreen.kt
@@ -0,0 +1,48 @@
+/*
+ * 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.ui
+
+import androidx.activity.compose.LocalActivity
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices
+import com.android.developers.androidify.R
+import com.android.developers.androidify.ui.theme.AndroidifyWearTheme
+
+@Composable
+fun ErrorScreen(
+ onAllDoneClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val activity = LocalActivity.current
+ CallToActionScreen(
+ callToActionText = stringResource(R.string.error),
+ buttonText = stringResource(R.string.error_ack),
+ onCallToActionClick = {
+ onAllDoneClick()
+ activity?.finish()
+ },
+ )
+}
+
+@WearPreviewDevices
+@Composable
+fun ErrorScreenPreview() {
+ AndroidifyWearTheme {
+ ErrorScreen({})
+ }
+}
diff --git a/wear/src/main/java/com/android/developers/androidify/ui/LongPressScreen.kt b/wear/src/main/java/com/android/developers/androidify/ui/LongPressScreen.kt
new file mode 100644
index 00000000..9a4159a3
--- /dev/null
+++ b/wear/src/main/java/com/android/developers/androidify/ui/LongPressScreen.kt
@@ -0,0 +1,48 @@
+/*
+ * 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.ui
+
+import androidx.activity.compose.LocalActivity
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices
+import com.android.developers.androidify.R
+import com.android.developers.androidify.ui.theme.AndroidifyWearTheme
+
+@Composable
+fun LongPressScreen(
+ onAllDoneClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val activity = LocalActivity.current
+ CallToActionScreen(
+ callToActionText = stringResource(R.string.long_press_prompt),
+ buttonText = stringResource(R.string.long_press_button_text),
+ onCallToActionClick = {
+ onAllDoneClick()
+ activity?.finish()
+ },
+ )
+}
+
+@WearPreviewDevices
+@Composable
+fun LongPressScreenPreview() {
+ AndroidifyWearTheme {
+ LongPressScreen({})
+ }
+}
diff --git a/wear/src/main/java/com/android/developers/androidify/ui/OpenSettingsScreen.kt b/wear/src/main/java/com/android/developers/androidify/ui/OpenSettingsScreen.kt
new file mode 100644
index 00000000..e021a4ff
--- /dev/null
+++ b/wear/src/main/java/com/android/developers/androidify/ui/OpenSettingsScreen.kt
@@ -0,0 +1,55 @@
+/*
+ * 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.ui
+
+import android.content.Intent
+import android.net.Uri
+import android.provider.Settings
+import androidx.activity.compose.LocalActivity
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices
+import com.android.developers.androidify.R
+import com.android.developers.androidify.ui.theme.AndroidifyWearTheme
+
+/**
+ * If the user has already denied the permission to set the watch face, then this screen is shown.
+ */
+@Composable
+fun OpenSettingsScreen(modifier: Modifier = Modifier) {
+ val activity = LocalActivity.current
+ CallToActionScreen(
+ callToActionText = stringResource(R.string.open_settings_prompt),
+ buttonText = stringResource(R.string.open_settings_button_text),
+ onCallToActionClick = {
+ val intent =
+ Intent().apply {
+ action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS
+ data = Uri.fromParts("package", activity?.packageName, null)
+ }
+ activity?.startActivity(intent)
+ },
+ )
+}
+
+@WearPreviewDevices
+@Composable
+fun OpenSettingsPreview() {
+ AndroidifyWearTheme {
+ OpenSettingsScreen()
+ }
+}
diff --git a/wear/src/main/java/com/android/developers/androidify/ui/PermissionsPromptScreen.kt b/wear/src/main/java/com/android/developers/androidify/ui/PermissionsPromptScreen.kt
new file mode 100644
index 00000000..bf6bd9a4
--- /dev/null
+++ b/wear/src/main/java/com/android/developers/androidify/ui/PermissionsPromptScreen.kt
@@ -0,0 +1,45 @@
+/*
+ * 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.ui
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices
+import com.android.developers.androidify.R
+import com.android.developers.androidify.ui.theme.AndroidifyWearTheme
+
+@Composable
+fun PermissionsPromptScreen(
+ modifier: Modifier = Modifier,
+ launchPermissionRequest: () -> Unit,
+) {
+ CallToActionScreen(
+ callToActionText = stringResource(R.string.permissions_prompt),
+ buttonText = stringResource(R.string.permissions_button_text),
+ onCallToActionClick = launchPermissionRequest,
+ )
+}
+
+@WearPreviewDevices
+@Composable
+fun PermissionsPromptScreenPreview() {
+ AndroidifyWearTheme {
+ PermissionsPromptScreen(
+ launchPermissionRequest = {},
+ )
+ }
+}
diff --git a/wear/src/main/java/com/android/developers/androidify/ui/TransmissionScreen.kt b/wear/src/main/java/com/android/developers/androidify/ui/TransmissionScreen.kt
new file mode 100644
index 00000000..00bd7f19
--- /dev/null
+++ b/wear/src/main/java/com/android/developers/androidify/ui/TransmissionScreen.kt
@@ -0,0 +1,141 @@
+/*
+ * 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.ui
+
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.lerp
+import androidx.compose.ui.keepScreenOn
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.wear.compose.foundation.lazy.TransformingLazyColumn
+import androidx.wear.compose.foundation.lazy.rememberTransformingLazyColumnState
+import androidx.wear.compose.material.CircularProgressIndicator
+import androidx.wear.compose.material3.MaterialTheme
+import androidx.wear.compose.material3.ScreenScaffold
+import androidx.wear.compose.material3.Text
+import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices
+import com.android.developers.androidify.R
+import com.android.developers.androidify.ui.theme.AndroidifyWearTheme
+import com.android.developers.androidify.ui.theme.Blue
+import com.android.developers.androidify.ui.theme.LimeGreen
+import com.android.developers.androidify.ui.theme.Primary80
+import com.android.developers.androidify.ui.theme.Primary90
+import com.google.android.horologist.compose.layout.ColumnItemType
+import com.google.android.horologist.compose.layout.rememberResponsiveColumnPadding
+import kotlin.math.floor
+
+@Composable
+fun TransmissionScreen(modifier: Modifier = Modifier) {
+ val listState = rememberTransformingLazyColumnState()
+ ScreenScaffold(
+ modifier = modifier.keepScreenOn(),
+ scrollState = listState,
+ // Use Horologist for now to get correct top and bottom padding in list.
+ contentPadding = rememberResponsiveColumnPadding(
+ first = ColumnItemType.IconButton,
+ last = ColumnItemType.Button,
+ ),
+ ) { contentPadding ->
+ TransformingLazyColumn(
+ state = listState,
+ contentPadding = contentPadding,
+ ) {
+ item {
+ Image(
+ modifier = Modifier.fillMaxWidth(0.3f),
+ painter = painterResource(id = R.drawable.logo),
+ contentDescription = stringResource(R.string.logo_description),
+ )
+ }
+ item {
+ Spacer(modifier = Modifier.height(4.dp))
+ }
+ item {
+ FourColorProgressIndicator()
+ }
+ item {
+ Spacer(modifier = Modifier.height(4.dp))
+ }
+ item {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ textAlign = TextAlign.Center,
+ text = stringResource(R.string.receiving),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun FourColorProgressIndicator() {
+ val colors = remember { listOf(
+ LimeGreen,
+ Primary80,
+ Primary90,
+ Blue,
+ ) }
+
+ val infiniteTransition = rememberInfiniteTransition(label = "transition")
+ val progress by infiniteTransition.animateFloat(
+ initialValue = 0f,
+ targetValue = colors.size.toFloat(),
+ animationSpec = infiniteRepeatable(
+ animation = tween(durationMillis = 4000, easing = LinearEasing),
+ ),
+ label = "progress",
+ )
+
+ val colorIndex = floor(progress).toInt()
+ val progressFraction = progress - colorIndex
+
+ val currentColor = colors[colorIndex % colors.size]
+ val nextColor = colors[(colorIndex + 1) % colors.size]
+ val animatedColor = lerp(start = currentColor, stop = nextColor, fraction = progressFraction)
+
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ CircularProgressIndicator(indicatorColor = animatedColor)
+ }
+}
+
+@WearPreviewDevices
+@Composable
+fun TransmissionScreenPreview() {
+ AndroidifyWearTheme {
+ TransmissionScreen()
+ }
+}
diff --git a/wear/src/main/java/com/android/developers/androidify/ui/WatchFaceOnboardingScreen.kt b/wear/src/main/java/com/android/developers/androidify/ui/WatchFaceOnboardingScreen.kt
new file mode 100644
index 00000000..7b37c321
--- /dev/null
+++ b/wear/src/main/java/com/android/developers/androidify/ui/WatchFaceOnboardingScreen.kt
@@ -0,0 +1,143 @@
+/*
+ * 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.
+ */
+@file:OptIn(ExperimentalPermissionsApi::class)
+
+package com.android.developers.androidify.ui
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.wear.compose.material3.AppScaffold
+import com.android.developers.androidify.WatchFaceOnboardingViewModel
+import com.android.developers.androidify.wear.common.WatchFaceActivationStrategy
+import com.android.developers.androidify.wear.common.WatchFaceInstallationStatus
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.isGranted
+import com.google.accompanist.permissions.rememberPermissionState
+import com.google.accompanist.permissions.shouldShowRationale
+
+@Composable
+fun WatchFaceOnboardingScreen(
+ modifier: Modifier = Modifier,
+ launchedFromWatchFaceTransfer: Boolean,
+ viewModel: WatchFaceOnboardingViewModel = viewModel(factory = WatchFaceOnboardingViewModel.Factory),
+) {
+ AppScaffold {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+
+ when (state) {
+ is WatchFaceInstallationStatus.Receiving,
+ is WatchFaceInstallationStatus.Sending,
+ -> {
+ TransmissionScreen()
+ }
+
+ is WatchFaceInstallationStatus.Unknown,
+ WatchFaceInstallationStatus.NotStarted,
+ -> {
+ if (launchedFromWatchFaceTransfer) {
+ TransmissionScreen()
+ } else {
+ WelcomeToAndroidifyScreen()
+ }
+ }
+
+ is WatchFaceInstallationStatus.Complete -> {
+ val completeStatus = state as WatchFaceInstallationStatus.Complete
+ if (completeStatus.success) {
+ WatchFaceGuidance(
+ strategy = completeStatus.activationStrategy,
+ onPermissionsChange = { granted, shouldShowRationale ->
+ viewModel.maybeSendUpdateOnPermissionsChange(
+ granted,
+ shouldShowRationale,
+ )
+ },
+ onAllDone = {
+ viewModel.resetWatchFaceTransferState()
+ },
+ )
+ } else {
+ ErrorScreen(
+ onAllDoneClick = {
+ viewModel.resetWatchFaceTransferState()
+ },
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun WatchFaceGuidance(
+ strategy: WatchFaceActivationStrategy,
+ onPermissionsChange: (Boolean, Boolean) -> Unit,
+ onAllDone: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val activePermission =
+ rememberPermissionState("com.google.wear.permission.SET_PUSHED_WATCH_FACE_AS_ACTIVE") { }
+ var previousPermissionStatus by remember {
+ mutableStateOf(activePermission.status)
+ }
+ var previousShouldShowRationale by remember {
+ mutableStateOf(activePermission.status.shouldShowRationale)
+ }
+
+ val lifecycleOwner = LocalLifecycleOwner.current
+ DisposableEffect(lifecycleOwner) {
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME) {
+ if (activePermission.status != previousPermissionStatus ||
+ activePermission.status.shouldShowRationale != previousShouldShowRationale
+ ) {
+ onPermissionsChange(
+ activePermission.status.isGranted,
+ activePermission.status.shouldShowRationale,
+ )
+ previousPermissionStatus = activePermission.status
+ previousShouldShowRationale = activePermission.status.shouldShowRationale
+ }
+ }
+ }
+
+ lifecycleOwner.lifecycle.addObserver(observer)
+
+ onDispose {
+ lifecycleOwner.lifecycle.removeObserver(observer)
+ }
+ }
+
+ when (strategy) {
+ WatchFaceActivationStrategy.GO_TO_WATCH_SETTINGS -> OpenSettingsScreen()
+ WatchFaceActivationStrategy.LONG_PRESS_TO_SET -> LongPressScreen(onAllDoneClick = onAllDone)
+ WatchFaceActivationStrategy.FOLLOW_PROMPT_ON_WATCH -> PermissionsPromptScreen(
+ launchPermissionRequest = { activePermission.launchPermissionRequest() },
+ )
+
+ else -> AllDoneScreen(onAllDone = onAllDone)
+ }
+}
diff --git a/wear/src/main/java/com/android/developers/androidify/ui/WelcomeToAndroidify.kt b/wear/src/main/java/com/android/developers/androidify/ui/WelcomeToAndroidify.kt
new file mode 100644
index 00000000..ad040ff1
--- /dev/null
+++ b/wear/src/main/java/com/android/developers/androidify/ui/WelcomeToAndroidify.kt
@@ -0,0 +1,47 @@
+/*
+ * 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.ui
+
+import android.content.Intent
+import androidx.activity.compose.LocalActivity
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices
+import com.android.developers.androidify.LaunchOnPhoneActivity
+import com.android.developers.androidify.R
+import com.android.developers.androidify.ui.theme.AndroidifyWearTheme
+
+@Composable
+fun WelcomeToAndroidifyScreen(modifier: Modifier = Modifier) {
+ val activity = LocalActivity.current
+ CallToActionScreen(
+ callToActionText = stringResource(R.string.welcome),
+ buttonText = stringResource(R.string.continue_on_phone),
+ onCallToActionClick = {
+ val intent = Intent(activity, LaunchOnPhoneActivity::class.java)
+ activity?.startActivity(intent)
+ },
+ )
+}
+
+@WearPreviewDevices
+@Composable
+fun WelcomeToAndroidifyScreenPreview() {
+ AndroidifyWearTheme {
+ WelcomeToAndroidifyScreen()
+ }
+}
diff --git a/wear/src/main/java/com/android/developers/androidify/ui/theme/Theme.kt b/wear/src/main/java/com/android/developers/androidify/ui/theme/Theme.kt
new file mode 100644
index 00000000..b2bbf1e5
--- /dev/null
+++ b/wear/src/main/java/com/android/developers/androidify/ui/theme/Theme.kt
@@ -0,0 +1,112 @@
+/*
+ * 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.ui.theme
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.wear.compose.material3.ColorScheme
+import androidx.wear.compose.material3.MaterialTheme
+
+val LimeGreen = Color(0xFFC6FF00)
+val Primary80 = Color(0xFF6DDD81)
+val Primary90 = Color(0xFF89FA9B)
+val Blue = Color(0xFF4285F4)
+
+// Primary colors
+val Primary = Color(0xFF34A853)
+val OnPrimary = Color(0xFFFFFFFF)
+val PrimaryContainer = Color(0xFF34A853)
+val OnPrimaryContainer = Color(0xFF202124)
+
+// Secondary colors
+val Secondary = Color(0xFFE6F4EA)
+val OnSecondary = Color(0xFF202124)
+val SecondaryContainer = Color(0xFFE6F4EA)
+val OnSecondaryContainer = Color(0xFF202124)
+
+// Tertiary colors
+val Tertiary = Color(0xFF202124)
+val OnTertiary = Color(0xFFFFFFFF)
+val TertiaryContainer = Color(0xFF202124)
+val OnTertiaryContainer = Color(0xFFFFFFFF)
+
+// Error colors
+val Error = Color(0xFFBA1A1A) // Red
+val OnError = Color(0xFFFFFFFF) // White
+val ErrorContainer = Color(0xFFFFDAD6) // Light Red
+val OnErrorContainer = Color(0xFF93000A) // Dark Red
+
+// Surface colors
+val Surface = Color(0xFFF1F3F4) // White
+val SurfaceBright = Color(0xFFE6F4EA) // Light Green
+val InverseSurface = Color(0xFF313030) // Dark Gray
+val InverseOnSurface = Color(0xFFF3F0EF) // Light gray
+val SurfaceContainerLowest = Color(0xFFFFFFFF) // Off White
+val SurfaceContainerLow = Color(0xFFEEF0F2) // Light gray
+val SurfaceContainer = Color(0xFFE8EBED) // Gray
+val SurfaceContainerHigh = Color(0xFFE5E9EB) // Dark Gray
+val SurfaceContainerHighest = Color(0xFFE5E9EB) // Very dark Gray
+
+// Others colors
+val OnSurface = Color(0xFF202124) // Black
+val OnSurfaceVariant = Color(0xFF434846) // Dark Gray
+val Outline = Color(0xFF202124) // Dark Gray
+val OutlineVariant = Color(0xFF313030) // Light Dark Gray
+val Scrim = Color(0xFF000000) // Black
+val Shadow = Color(0xFF000000) // Dark gray
+
+private val wearColorScheme = ColorScheme(
+ primary = Primary,
+ onPrimary = OnPrimary,
+ primaryContainer = PrimaryContainer,
+ onPrimaryContainer = OnPrimaryContainer,
+
+ secondary = Secondary,
+ secondaryContainer = SecondaryContainer,
+ onSecondaryContainer = OnSecondaryContainer,
+ onSecondary = OnSecondary,
+
+ tertiary = Tertiary,
+ tertiaryContainer = TertiaryContainer,
+ onTertiaryContainer = OnTertiaryContainer,
+ onTertiary = OnTertiary,
+
+ error = Error,
+ onError = OnError,
+ onErrorContainer = OnErrorContainer,
+ errorContainer = ErrorContainer,
+ surfaceContainerLow = SurfaceContainerLow,
+ surfaceContainer = SurfaceContainer,
+ surfaceContainerHigh = SurfaceContainerHigh,
+ onSurface = OnSurface,
+ onSurfaceVariant = OnSurfaceVariant,
+ outline = Outline,
+ outlineVariant = OutlineVariant,
+)
+
+@Composable
+fun AndroidifyWearTheme(
+ content: @Composable () -> Unit,
+) {
+ /**
+ * Empty theme to customize for your app.
+ * See: https://developer.android.com/jetpack/compose/designsystems/custom
+ */
+ MaterialTheme(
+ colorScheme = wearColorScheme,
+ content = content,
+ )
+}
diff --git a/wear/src/main/java/com/android/developers/androidify/watchfacepush/WatchFaceOnboardingRepository.kt b/wear/src/main/java/com/android/developers/androidify/watchfacepush/WatchFaceOnboardingRepository.kt
new file mode 100644
index 00000000..b59a2a90
--- /dev/null
+++ b/wear/src/main/java/com/android/developers/androidify/watchfacepush/WatchFaceOnboardingRepository.kt
@@ -0,0 +1,166 @@
+/*
+ * 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.
+ */
+@file:OptIn(ExperimentalSerializationApi::class)
+
+package com.android.developers.androidify.watchfacepush
+
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.ParcelFileDescriptor
+import android.util.Log
+import androidx.core.content.ContextCompat
+import androidx.wear.watchfacepush.WatchFacePushManager
+import androidx.wear.watchfacepush.WatchFacePushManagerFactory
+import com.android.developers.androidify.data.WatchFacePushStateManager
+import com.android.developers.androidify.wear.common.WatchFaceActivationStrategy
+import com.android.developers.androidify.wear.common.WatchFaceInstallError
+import com.android.developers.androidify.wear.common.WatchFaceInstallationStatus
+import com.android.developers.androidify.wear.common.WearableConstants
+import com.google.android.gms.wearable.Wearable
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.tasks.await
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.encodeToByteArray
+import kotlinx.serialization.protobuf.ProtoBuf
+
+const val LAUNCHED_FROM_WATCH_FACE_TRANSFER = "launchedFromWatchFaceTransfer"
+
+class WatchFaceOnboardingRepository(
+ val context: Context,
+ val watchFacePushStateManager: WatchFacePushStateManager = WatchFacePushStateManager(context),
+) {
+ private val messageClient by lazy { Wearable.getMessageClient(context) }
+
+ /**
+ * Determines the activation strategy of the watch face. This means what action the user and/or
+ * system will have to take in order to set the watch face as active once it has been installed.
+ *
+ * For example, the app may already own the active watch face, in which case, no action is
+ * required. At the other end of the spectrum, another app may own the active watch face, and
+ * this app may have exhausted the API to request the active watch face. In this case, the
+ * strategy would be manually setting the watch face.
+ *
+ * Full range of options shown in [WatchFaceActivationStrategy].
+ */
+ suspend fun getWatchFaceActivationStrategy(): WatchFaceActivationStrategy {
+ val apiUsed = watchFacePushStateManager.activeWatchFaceApiUsed.first()
+ val hasActiveWatchFace = hasActiveWatchFace()
+ val hasPermission = hasSetWatchFacePermission()
+ val canRequestPermission = !watchFacePushStateManager.watchFacePermissionDenied.first()
+
+ return WatchFaceActivationStrategy.fromWatchFaceState(
+ hasActiveWatchFace = hasActiveWatchFace,
+ hasGrantedSetActivePermission = hasPermission,
+ canRequestSetActivePermission = canRequestPermission,
+ hasUsedSetActiveApi = apiUsed,
+ )
+ }
+ val watchFaceTransferState = watchFacePushStateManager.watchFaceInstallationStatus
+
+ suspend fun setWatchFaceTransferState(state: WatchFaceInstallationStatus) {
+ watchFacePushStateManager.setWatchFaceInstallationStatus(state)
+ }
+
+ suspend fun resetWatchFaceTransferState() {
+ watchFacePushStateManager.setWatchFaceInstallationStatus(WatchFaceInstallationStatus.NotStarted)
+ }
+
+ suspend fun resetWatchFaceTransferStateIfComplete() {
+ val currentStatus = watchFacePushStateManager.watchFaceInstallationStatus.first()
+ if (currentStatus is WatchFaceInstallationStatus.Complete) {
+ watchFacePushStateManager.setWatchFaceInstallationStatus(WatchFaceInstallationStatus.NotStarted)
+ }
+ }
+
+ suspend fun updateOrInstallWatchFace(apkFd: ParcelFileDescriptor, token: String): WatchFaceInstallError {
+ val wfpManager = WatchFacePushManagerFactory.createWatchFacePushManager(context)
+ val response = wfpManager.listWatchFaces()
+
+ try {
+ if (response.remainingSlotCount > 0) {
+ wfpManager.addWatchFace(apkFd, token)
+ } else {
+ val slotId = response.installedWatchFaceDetails.first().slotId
+ wfpManager.updateWatchFace(slotId, apkFd, token)
+ }
+ } catch (a: WatchFacePushManager.AddWatchFaceException) {
+ return WatchFaceInstallError.WATCH_FACE_INSTALL_ERROR
+ } catch (u: WatchFacePushManager.UpdateWatchFaceException) {
+ return WatchFaceInstallError.WATCH_FACE_INSTALL_ERROR
+ }
+ return WatchFaceInstallError.NO_ERROR
+ }
+
+ suspend fun setActiveWatchFace() {
+ val wfpManager = WatchFacePushManagerFactory.createWatchFacePushManager(context)
+ val watchFacePushStateManager = WatchFacePushStateManager(context)
+ val response = wfpManager.listWatchFaces()
+
+ if (response.installedWatchFaceDetails.isEmpty()) {
+ Log.w(TAG, "No watch face to set as active.")
+ return
+ }
+
+ val slotId = response.installedWatchFaceDetails.first().slotId
+ wfpManager.setWatchFaceAsActive(slotId)
+ // Record that the one-shot API has been used
+ watchFacePushStateManager.setActiveWatchFaceApiUsedKey(true)
+ }
+
+ /**
+ * If permission has been denied to the SET_PUSHED_WATCH_FACE_AS_ACTIVE permission, then this is
+ * stored, as this permission can only be requested and denied once. Keeping track of this helps
+ * determine what action the user needs to take to set the active watch face.
+ */
+ suspend fun updatePermissionStatus(granted: Boolean) {
+ val watchFacePushStateManager = WatchFacePushStateManager(context)
+ watchFacePushStateManager.setWatchFacePermissionDenied(!granted)
+ }
+
+ private suspend fun hasActiveWatchFace(): Boolean {
+ val wfpManager = WatchFacePushManagerFactory.createWatchFacePushManager(context)
+
+ val response = wfpManager.listWatchFaces()
+ return response.installedWatchFaceDetails.any {
+ wfpManager.isWatchFaceActive(it.packageName)
+ }
+ }
+
+ fun hasSetWatchFacePermission(): Boolean {
+ val permission = "com.google.wear.permission.SET_PUSHED_WATCH_FACE_AS_ACTIVE"
+ val permissionStatus = ContextCompat.checkSelfPermission(context, permission)
+ return permissionStatus == PackageManager.PERMISSION_GRANTED
+ }
+
+ /**
+ * Sends a status update to the phone. This is used where a further "completion" message needs
+ * to be sent to the phone after the installation has completed. For example, if once the watch
+ * face has completed install, the user needs to grant permission in order to set it as active,
+ * then that message is what is first sent to the phone.
+ *
+ * Once the user has then granted the permission, this method is used to send a further update
+ * to the phone with the updated status.
+ */
+ suspend fun sendInstallUpdate(state: WatchFaceInstallationStatus.Complete) {
+ val byteArray = ProtoBuf.encodeToByteArray(state)
+ val path = WearableConstants.ANDROIDIFY_FINALIZE_TRANSFER_TEMPLATE.format(state.transferId)
+ messageClient.sendMessage(state.validationToken, path, byteArray).await()
+ }
+
+ companion object {
+ private val TAG = WatchFaceOnboardingRepository::class.java.simpleName
+ }
+}
diff --git a/wear/src/main/res/drawable/ic_launcher_background.xml b/wear/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 00000000..b87185d3
--- /dev/null
+++ b/wear/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,139 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/wear/src/main/res/drawable/ic_launcher_monochrome.xml b/wear/src/main/res/drawable/ic_launcher_monochrome.xml
new file mode 100644
index 00000000..cf992330
--- /dev/null
+++ b/wear/src/main/res/drawable/ic_launcher_monochrome.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
diff --git a/wear/src/main/res/drawable/logo.webp b/wear/src/main/res/drawable/logo.webp
new file mode 100644
index 00000000..de11438d
Binary files /dev/null and b/wear/src/main/res/drawable/logo.webp differ
diff --git a/wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 00000000..4dde3ccd
--- /dev/null
+++ b/wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/wear/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/wear/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 00000000..4dde3ccd
--- /dev/null
+++ b/wear/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/wear/src/main/res/mipmap-hdpi/ic_launcher.webp b/wear/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 00000000..d94ff516
Binary files /dev/null and b/wear/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/wear/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/wear/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
new file mode 100644
index 00000000..1ffbbc79
Binary files /dev/null and b/wear/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ
diff --git a/wear/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/wear/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..d94ff516
Binary files /dev/null and b/wear/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/wear/src/main/res/mipmap-mdpi/ic_launcher.webp b/wear/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 00000000..96110fba
Binary files /dev/null and b/wear/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/wear/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/wear/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
new file mode 100644
index 00000000..c33d756b
Binary files /dev/null and b/wear/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ
diff --git a/wear/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/wear/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..96110fba
Binary files /dev/null and b/wear/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/wear/src/main/res/mipmap-xhdpi/ic_launcher.webp b/wear/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 00000000..6f30e11b
Binary files /dev/null and b/wear/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/wear/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/wear/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp
new file mode 100644
index 00000000..81275c6e
Binary files /dev/null and b/wear/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ
diff --git a/wear/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/wear/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..6f30e11b
Binary files /dev/null and b/wear/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/wear/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/wear/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 00000000..a54a54d2
Binary files /dev/null and b/wear/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/wear/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/wear/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp
new file mode 100644
index 00000000..79ac3123
Binary files /dev/null and b/wear/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ
diff --git a/wear/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/wear/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..a54a54d2
Binary files /dev/null and b/wear/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/wear/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/wear/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 00000000..de11438d
Binary files /dev/null and b/wear/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/wear/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/wear/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp
new file mode 100644
index 00000000..f53954b3
Binary files /dev/null and b/wear/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ
diff --git a/wear/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/wear/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..de11438d
Binary files /dev/null and b/wear/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/wear/src/main/res/values/strings.xml b/wear/src/main/res/values/strings.xml
new file mode 100644
index 00000000..01e1fb0f
--- /dev/null
+++ b/wear/src/main/res/values/strings.xml
@@ -0,0 +1,41 @@
+
+
+
+ Androidify
+ Set as active
+ Continue on phone
+ Androidify logo
+
+
+ To set the Androidify watch face, long-press on the current watch face.
+
+ Got it!
+
+ You must allow the app permission to change the active watch face, from the settings menu.
+
+ Open settings
+
+ You must grant the app permission to change the active watch face.
+
+ Launch
+ You\'re all set!
+ Great!
+ Receiving watch faceā¦
+ Welcome to Androidify!
+ Oh dear! An error occurred
+ OK
+
\ No newline at end of file
diff --git a/wear/src/main/res/values/wear.xml b/wear/src/main/res/values/wear.xml
new file mode 100644
index 00000000..22699643
--- /dev/null
+++ b/wear/src/main/res/values/wear.xml
@@ -0,0 +1,21 @@
+
+
+
+
+ - androidify
+
+
\ No newline at end of file
diff --git a/wear/watchface/.gitignore b/wear/watchface/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/wear/watchface/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/wear/watchface/build.gradle.kts b/wear/watchface/build.gradle.kts
new file mode 100644
index 00000000..99ecd753
--- /dev/null
+++ b/wear/watchface/build.gradle.kts
@@ -0,0 +1,44 @@
+/*
+ * 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.
+ */
+
+plugins {
+ alias(libs.plugins.android.application)
+}
+
+android {
+ namespace = "com.android.developers.androidify.watchfacepush.defaultwf"
+ compileSdk = libs.versions.compileSdk.get().toInt()
+
+ defaultConfig {
+ applicationId = "com.android.developers.androidify.watchfacepush.defaultwf"
+ minSdk = 36
+ targetSdk = 36
+ versionCode = 1
+ versionName = "1.0"
+ }
+
+ buildTypes {
+ release {
+ signingConfig = signingConfigs.getByName("debug")
+ isMinifyEnabled = true
+ isShrinkResources = false
+ }
+ debug {
+ isMinifyEnabled = true
+ }
+ }
+}
+
diff --git a/wear/watchface/src/main/AndroidManifest.xml b/wear/watchface/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..47bc9a6b
--- /dev/null
+++ b/wear/watchface/src/main/AndroidManifest.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/wear/watchface/src/main/res/drawable/logo.png b/wear/watchface/src/main/res/drawable/logo.png
new file mode 100644
index 00000000..506c9323
Binary files /dev/null and b/wear/watchface/src/main/res/drawable/logo.png differ
diff --git a/wear/watchface/src/main/res/drawable/preview.png b/wear/watchface/src/main/res/drawable/preview.png
new file mode 100644
index 00000000..95222da1
Binary files /dev/null and b/wear/watchface/src/main/res/drawable/preview.png differ
diff --git a/wear/watchface/src/main/res/raw/watchface.xml b/wear/watchface/src/main/res/raw/watchface.xml
new file mode 100644
index 00000000..16108a04
--- /dev/null
+++ b/wear/watchface/src/main/res/raw/watchface.xml
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ cta
+
+
+
+
+
+
\ No newline at end of file
diff --git a/wear/watchface/src/main/res/values/strings.xml b/wear/watchface/src/main/res/values/strings.xml
new file mode 100644
index 00000000..f42ab743
--- /dev/null
+++ b/wear/watchface/src/main/res/values/strings.xml
@@ -0,0 +1,20 @@
+
+
+
+ Androidify
+ Let\'s Go!
+
diff --git a/wear/watchface/src/main/res/xml/watch_face_info.xml b/wear/watchface/src/main/res/xml/watch_face_info.xml
new file mode 100644
index 00000000..d24f32e1
--- /dev/null
+++ b/wear/watchface/src/main/res/xml/watch_face_info.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+