From a5c3f31af5a8ef924476fa5ba8f073584f8f3921 Mon Sep 17 00:00:00 2001 From: Mark Pitman Date: Tue, 1 Sep 2026 23:09:14 -0700 Subject: [PATCH] Scaffold the :wear application module (issue #276) Step 3 of Wear OS phase 1: a new standalone :wear Gradle module with its own Hilt Application/MainActivity, manifest (watch feature requirement, standalone meta-data so it runs without a paired phone, matching the target LTE watch), and launcher icon. Depends on :core, so it picks up the existing Room database and settings DataStore Hilt modules for free -- no wear-specific database module needed, since the watch's own installed copy of AppDatabase never touches the phone's file regardless of matching filenames (separate device, separate app). Adds PlayServicesWearSyncClient, the real DataClient-backed implementation of :core's WearSyncClient interface (added in the previous PR), bound via Hilt in WearSyncModule. Queue snapshots and position updates are wire-encoded into DataMap key/value pairs and round-tripped through the Data Layer API's putDataItem/DataClient listener callbacks. Installed and launched the debug build on a newly created Wear OS 6.0 emulator (mycasts_wear_test) to confirm it actually boots -- confirmed MainActivity reaches state=RESUMED with no crash. Still not wired up to the phone side (step 4: the sync bridge and PhoneSyncListenerService) or backed by real playback (step 5) or a real UI (step 6). --- gradle/libs.versions.toml | 7 ++ settings.gradle.kts | 1 + wear/build.gradle.kts | 82 ++++++++++++ wear/src/main/AndroidManifest.xml | 37 ++++++ .../mycasts/wear/MainActivity.kt | 35 ++++++ .../mycasts/wear/MyCastsWearApp.kt | 12 ++ .../mycasts/wear/di/WearSyncModule.kt | 29 +++++ .../wear/sync/PlayServicesWearSyncClient.kt | 118 ++++++++++++++++++ .../res/drawable/ic_launcher_foreground.xml | 30 +++++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 9 ++ wear/src/main/res/values/colors.xml | 6 + wear/src/main/res/values/strings.xml | 4 + 12 files changed, 370 insertions(+) create mode 100644 wear/build.gradle.kts create mode 100644 wear/src/main/AndroidManifest.xml create mode 100644 wear/src/main/java/com/bugzapperlabs/mycasts/wear/MainActivity.kt create mode 100644 wear/src/main/java/com/bugzapperlabs/mycasts/wear/MyCastsWearApp.kt create mode 100644 wear/src/main/java/com/bugzapperlabs/mycasts/wear/di/WearSyncModule.kt create mode 100644 wear/src/main/java/com/bugzapperlabs/mycasts/wear/sync/PlayServicesWearSyncClient.kt create mode 100644 wear/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 wear/src/main/res/values/colors.xml create mode 100644 wear/src/main/res/values/strings.xml diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2661b56c..6cf93317 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,8 @@ workManager = "2.10.0" hiltWork = "1.2.0" glance = "1.1.1" coreSplashscreen = "1.2.0" +wearCompose = "1.4.1" +playServicesWearable = "18.2.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -45,6 +47,10 @@ androidx-material3-adaptive-layout = { group = "androidx.compose.material3.adapt androidx-material3-adaptive-navigation = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation", version.ref = "material3Adaptive" } androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } +androidx-wear-compose-material = { group = "androidx.wear.compose", name = "compose-material", version.ref = "wearCompose" } +androidx-wear-compose-foundation = { group = "androidx.wear.compose", name = "compose-foundation", version.ref = "wearCompose" } +androidx-wear-compose-navigation = { group = "androidx.wear.compose", name = "compose-navigation", version.ref = "wearCompose" } +play-services-wearable = { group = "com.google.android.gms", name = "play-services-wearable", version.ref = "playServicesWearable" } hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } @@ -58,6 +64,7 @@ androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "a androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "coroutines" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 3718e8d3..25715f45 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -17,3 +17,4 @@ dependencyResolutionManagement { rootProject.name = "MyCasts" include(":app") include(":core") +include(":wear") diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts new file mode 100644 index 00000000..f3e447fe --- /dev/null +++ b/wear/build.gradle.kts @@ -0,0 +1,82 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt.android) +} + +android { + namespace = "com.bugzapperlabs.mycasts.wear" + compileSdk = 36 + + defaultConfig { + applicationId = "com.bugzapperlabs.mycasts.wear" + minSdk = 31 + targetSdk = 36 + versionCode = 1 + versionName = "0.1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + debug { + // Mirrors :app's convention (issue #258) so a debug watch build installs alongside a + // release-signed one instead of colliding on install. + applicationIdSuffix = ".debug" + versionNameSuffix = "-debug" + } + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +dependencies { + implementation(project(":core")) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.wear.compose.material) + implementation(libs.androidx.wear.compose.foundation) + implementation(libs.androidx.wear.compose.navigation) + implementation(libs.play.services.wearable) + implementation(libs.hilt.android) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.session) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.coroutines.play.services) + ksp(libs.hilt.android.compiler) + + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.test.core) + + debugImplementation(libs.androidx.ui.tooling) +} diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml new file mode 100644 index 00000000..5381c3ec --- /dev/null +++ b/wear/src/main/AndroidManifest.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/wear/src/main/java/com/bugzapperlabs/mycasts/wear/MainActivity.kt b/wear/src/main/java/com/bugzapperlabs/mycasts/wear/MainActivity.kt new file mode 100644 index 00000000..c01deaef --- /dev/null +++ b/wear/src/main/java/com/bugzapperlabs/mycasts/wear/MainActivity.kt @@ -0,0 +1,35 @@ +package com.bugzapperlabs.mycasts.wear + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material.Text +import dagger.hilt.android.AndroidEntryPoint + +/** + * Placeholder entry point (issue #276 step 3) -- real queue/now-playing screens land in step 6, + * once the sync bridge (step 4) and watch playback service (step 5) exist to back them. + */ +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + WearPlaceholderScreen() + } + } +} + +@Composable +private fun WearPlaceholderScreen() { + MaterialTheme { + ScalingLazyColumn(modifier = Modifier.fillMaxSize()) { + item { Text("MyCasts") } + } + } +} diff --git a/wear/src/main/java/com/bugzapperlabs/mycasts/wear/MyCastsWearApp.kt b/wear/src/main/java/com/bugzapperlabs/mycasts/wear/MyCastsWearApp.kt new file mode 100644 index 00000000..7d72db55 --- /dev/null +++ b/wear/src/main/java/com/bugzapperlabs/mycasts/wear/MyCastsWearApp.kt @@ -0,0 +1,12 @@ +package com.bugzapperlabs.mycasts.wear + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +/** + * The watch app's Hilt entry point, separate from `:app`'s `MyCastsApp` since they're independent + * processes on independent devices (issue #276) -- notification channels and WorkManager wiring + * are added here once the watch actually needs them (downloads/foreground workers are phase 2+). + */ +@HiltAndroidApp +class MyCastsWearApp : Application() diff --git a/wear/src/main/java/com/bugzapperlabs/mycasts/wear/di/WearSyncModule.kt b/wear/src/main/java/com/bugzapperlabs/mycasts/wear/di/WearSyncModule.kt new file mode 100644 index 00000000..d7c7e5a9 --- /dev/null +++ b/wear/src/main/java/com/bugzapperlabs/mycasts/wear/di/WearSyncModule.kt @@ -0,0 +1,29 @@ +package com.bugzapperlabs.mycasts.wear.di + +import android.content.Context +import com.bugzapperlabs.mycasts.sync.WearSyncClient +import com.bugzapperlabs.mycasts.wear.sync.PlayServicesWearSyncClient +import com.google.android.gms.wearable.DataClient +import com.google.android.gms.wearable.Wearable +import dagger.Binds +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) +object WearDataClientModule { + @Provides + @Singleton + fun provideDataClient(@ApplicationContext context: Context): DataClient = Wearable.getDataClient(context) +} + +@Module +@InstallIn(SingletonComponent::class) +abstract class WearSyncBindingModule { + @Binds + abstract fun bindWearSyncClient(impl: PlayServicesWearSyncClient): WearSyncClient +} diff --git a/wear/src/main/java/com/bugzapperlabs/mycasts/wear/sync/PlayServicesWearSyncClient.kt b/wear/src/main/java/com/bugzapperlabs/mycasts/wear/sync/PlayServicesWearSyncClient.kt new file mode 100644 index 00000000..908cf3f7 --- /dev/null +++ b/wear/src/main/java/com/bugzapperlabs/mycasts/wear/sync/PlayServicesWearSyncClient.kt @@ -0,0 +1,118 @@ +package com.bugzapperlabs.mycasts.wear.sync + +import com.bugzapperlabs.mycasts.sync.PositionUpdate +import com.bugzapperlabs.mycasts.sync.SyncQueueItem +import com.bugzapperlabs.mycasts.sync.WearSyncClient +import com.google.android.gms.wearable.DataClient +import com.google.android.gms.wearable.DataEvent +import com.google.android.gms.wearable.DataMap +import com.google.android.gms.wearable.DataMapItem +import com.google.android.gms.wearable.PutDataMapRequest +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.tasks.await +import javax.inject.Inject + +private const val QUEUE_PATH = "/queue" +private const val POSITION_PATH_PREFIX = "/position/" +private const val KEY_ITEMS = "items" +private const val KEY_ITEM_ID = "itemId" +private const val KEY_FEED_ID = "feedId" +private const val KEY_TITLE = "title" +private const val KEY_FEED_TITLE = "feedTitle" +private const val KEY_ENCLOSURE_URL = "enclosureUrl" +private const val KEY_ARTWORK_URL = "artworkUrl" +private const val KEY_DURATION_MS = "durationMs" +private const val KEY_POSITION_MS = "positionMs" +private const val KEY_ORDER_INDEX = "orderIndex" +private const val KEY_UPDATED_AT = "updatedAt" + +/** + * [WearSyncClient] over the real Wear OS Data Layer API (issue #276). `putDataItem`'s "last known + * value" persistence is what lets a snapshot survive the paired device being briefly unreachable + * -- see [WearSyncClient]'s own doc for why `DataClient` rather than `MessageClient` was chosen. + */ +class PlayServicesWearSyncClient @Inject constructor( + private val dataClient: DataClient, +) : WearSyncClient { + + override suspend fun putQueueSnapshot(queue: List) { + val request = PutDataMapRequest.create(QUEUE_PATH).apply { + dataMap.putDataMapArrayList(KEY_ITEMS, ArrayList(queue.map { it.toDataMap() })) + }.asPutDataRequest().setUrgent() + dataClient.putDataItem(request).await() + } + + override suspend fun putPosition(itemId: String, positionMs: Long, updatedAt: Long) { + val request = PutDataMapRequest.create(POSITION_PATH_PREFIX + itemId).apply { + dataMap.putString(KEY_ITEM_ID, itemId) + dataMap.putLong(KEY_POSITION_MS, positionMs) + dataMap.putLong(KEY_UPDATED_AT, updatedAt) + }.asPutDataRequest().setUrgent() + dataClient.putDataItem(request).await() + } + + override fun observeQueueSnapshots(): Flow> = callbackFlow { + val existing = dataClient.dataItems.await() + existing.firstOrNull { it.uri.path == QUEUE_PATH } + ?.let { trySend(DataMapItem.fromDataItem(it).dataMap.toSyncQueueItems()) } + existing.release() + + val listener = DataClient.OnDataChangedListener { events -> + for (event in events) { + if (event.type == DataEvent.TYPE_CHANGED && event.dataItem.uri.path == QUEUE_PATH) { + trySend(DataMapItem.fromDataItem(event.dataItem).dataMap.toSyncQueueItems()) + } + } + } + dataClient.addListener(listener) + awaitClose { dataClient.removeListener(listener) } + } + + override fun observePositionUpdates(): Flow = callbackFlow { + val listener = DataClient.OnDataChangedListener { events -> + for (event in events) { + val path = event.dataItem.uri.path + if (event.type == DataEvent.TYPE_CHANGED && path != null && path.startsWith(POSITION_PATH_PREFIX)) { + trySend(DataMapItem.fromDataItem(event.dataItem).dataMap.toPositionUpdate()) + } + } + } + dataClient.addListener(listener) + awaitClose { dataClient.removeListener(listener) } + } +} + +private fun SyncQueueItem.toDataMap(): DataMap = DataMap().apply { + putString(KEY_ITEM_ID, itemId) + putLong(KEY_FEED_ID, feedId) + title?.let { putString(KEY_TITLE, it) } + feedTitle?.let { putString(KEY_FEED_TITLE, it) } + enclosureUrl?.let { putString(KEY_ENCLOSURE_URL, it) } + artworkUrl?.let { putString(KEY_ARTWORK_URL, it) } + durationMs?.let { putLong(KEY_DURATION_MS, it) } + positionMs?.let { putLong(KEY_POSITION_MS, it) } + putInt(KEY_ORDER_INDEX, orderIndex) +} + +private fun DataMap.toSyncQueueItem(): SyncQueueItem = SyncQueueItem( + itemId = getString(KEY_ITEM_ID).orEmpty(), + feedId = getLong(KEY_FEED_ID), + title = getString(KEY_TITLE), + feedTitle = getString(KEY_FEED_TITLE), + enclosureUrl = getString(KEY_ENCLOSURE_URL), + artworkUrl = getString(KEY_ARTWORK_URL), + durationMs = if (containsKey(KEY_DURATION_MS)) getLong(KEY_DURATION_MS) else null, + positionMs = if (containsKey(KEY_POSITION_MS)) getLong(KEY_POSITION_MS) else null, + orderIndex = getInt(KEY_ORDER_INDEX), +) + +private fun DataMap.toSyncQueueItems(): List = + getDataMapArrayList(KEY_ITEMS)?.map { it.toSyncQueueItem() }.orEmpty() + +private fun DataMap.toPositionUpdate(): PositionUpdate = PositionUpdate( + itemId = getString(KEY_ITEM_ID).orEmpty(), + positionMs = getLong(KEY_POSITION_MS), + updatedAt = getLong(KEY_UPDATED_AT), +) diff --git a/wear/src/main/res/drawable/ic_launcher_foreground.xml b/wear/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..5b8d449a --- /dev/null +++ b/wear/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + 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..25597472 --- /dev/null +++ b/wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/wear/src/main/res/values/colors.xml b/wear/src/main/res/values/colors.xml new file mode 100644 index 00000000..80db4e32 --- /dev/null +++ b/wear/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + + #FF7D00 + diff --git a/wear/src/main/res/values/strings.xml b/wear/src/main/res/values/strings.xml new file mode 100644 index 00000000..0b1cbdc1 --- /dev/null +++ b/wear/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + MyCasts +