Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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" }
Expand All @@ -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" }
Expand Down
1 change: 1 addition & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ dependencyResolutionManagement {
rootProject.name = "MyCasts"
include(":app")
include(":core")
include(":wear")
82 changes: 82 additions & 0 deletions wear/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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)
}
37 changes: 37 additions & 0 deletions wear/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-feature android:name="android.hardware.type.watch" android:required="true" />

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<application
android:name=".MyCastsWearApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@android:style/Theme.DeviceDefault"
android:supportsRtl="true">

<!-- Required for a Wear OS app that runs without a connected phone (issue #276: the
target watch has its own LTE connection and streams independently), rather than only
being a companion surface for a phone-side app. -->
<meta-data
android:name="com.google.android.wearable.standalone"
android:value="true" />

<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
35 changes: 35 additions & 0 deletions wear/src/main/java/com/bugzapperlabs/mycasts/wear/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -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") }
}
}
}
Original file line number Diff line number Diff line change
@@ -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()
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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<SyncQueueItem>) {
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<List<SyncQueueItem>> = 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<PositionUpdate> = 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<SyncQueueItem> =
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),
)
30 changes: 30 additions & 0 deletions wear/src/main/res/drawable/ic_launcher_foreground.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The same RSS-style signal-wave arcs as before, radiating outward symmetrically
(180-degree rotational symmetry) into the top-right and bottom-left quadrants only,
stay entirely within the 108dp viewport's ~66dp adaptive-icon "safe zone"; see
Marketing/ApplicationIcon.png in myfeeds-winphone for the original brand mark this
reinterprets. The "m" monogram is now superimposed on a play triangle (issue #3): the
triangle is filled solid and the m's four strokes are punched out of it as negative
space (evenOdd fill), so the background orange shows through in the shape of the
letter. Both the triangle and the (reduced-scale) m stay under radius ~12.5dp from
center, clear of the arcs, which begin at radius 13dp. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFFFF"
android:pathData="M73,54 A19,19 0 0,0 54,35 L54,41 A13,13 0 0,1 67,54 Z
M83,54 A29,29 0 0,0 54,25 L54,31 A23,23 0 0,1 77,54 Z
M35,54 A19,19 0 0,0 54,73 L54,67 A13,13 0 0,1 41,54 Z
M25,54 A29,29 0 0,0 54,83 L54,77 A23,23 0 0,1 31,54 Z" />
<path
android:fillColor="#FFFFFF"
android:fillType="evenOdd"
android:pathData="M47.5,43.6 L66.5,54 L47.5,64.4 Z
M49,50.5 H50.75 V57.5 H49 Z
M53.125,50.5 H54.875 V57.5 H53.125 Z
M57.25,50.5 H59 V57.5 H57.25 Z
M49,50.5 H59 V51.9 H49 Z" />
</vector>
9 changes: 9 additions & 0 deletions wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Redesigned to be inherently mask-safe: a centered "m" monogram with RSS signal arcs
radiating outward symmetrically into the top-right and bottom-left quadrants only, so
nothing meaningful sits near the edges where a circle/squircle/rounded-square mask would
clip it. See ic_launcher_foreground.xml. -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
6 changes: 6 additions & 0 deletions wear/src/main/res/values/colors.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Matches :app's launcher background (see app/src/main/res/values/colors.xml) so the
watch app reads as the same product. -->
<color name="ic_launcher_background">#FF7D00</color>
</resources>
Loading
Loading