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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 @@ + + + + + + +