Basic WearOS app (#8396)

### Description

An initial minimal implementation for a Wear OS app. So far, the wear
app can only display episodes and start them on the phone. No standalone
features available yet.

Contributes to #4264

### Checklist
<!-- 
To help us keep the issue tracker clean and work as efficient as
possible,
  please make sure that you have done all of the following.
You can tick the boxes below by placing an x inside the brackets like
this: [x]
-->
- [x] I have read the contribution guidelines:
https://github.com/AntennaPod/AntennaPod/blob/develop/CONTRIBUTING.md#submit-a-pull-request
- [x] I have performed a self-review of my code, going through my
changes line by line and carefully considering why this line change is
necessary
- [x] I have run the automated code checks using `./gradlew checkstyle
lint spotbugsPlayDebug spotbugsDebug`
- [x] My code follows the style guidelines of the AntennaPod project:
https://antennapod.org/contribute/develop/app/code-style
- [x] I have mentioned the corresponding issue and the relevant keyword
(e.g., "Closes: #xy") in the description (see
https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue)
- [x] If it is a core feature, I have added automated tests
This commit is contained in:
Hans-Peter Lehmann
2026-05-14 14:17:54 +02:00
committed by GitHub
parent c39cfc9719
commit 5fe70196eb
47 changed files with 1759 additions and 5 deletions

4
app-wearos/README.md Normal file
View File

@ -0,0 +1,4 @@
# :app-wearos
A companion Wear OS app for AntennaPod. Can browse episodes from the phone and remote-control the phone to start playback.
Communicates with the phone via the WearOS DataLayer API using paths and serialization defined in `:net:sync:wear-interface`.

83
app-wearos/build.gradle Normal file
View File

@ -0,0 +1,83 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jlleitschuh.gradle.ktlint")
}
apply from: "../common.gradle"
apply from: "../playFlavor.gradle"
android {
namespace "de.danoeh.antennapod.wearos"
defaultConfig {
applicationId "de.danoeh.antennapod"
minSdk 25
targetSdk 36
versionCode 1
versionName "1.0"
}
signingConfigs {
releaseConfig {
enableV1Signing true
enableV2Signing true
storeFile file(project.getProperties().getOrDefault("releaseStoreFile", "keystore"))
storePassword project.getProperties().getOrDefault("releaseStorePassword", "password")
keyAlias project.getProperties().getOrDefault("releaseKeyAlias", "alias")
keyPassword project.getProperties().getOrDefault("releaseKeyPassword", "password")
}
}
buildTypes {
debug {
applicationIdSuffix ".debug"
}
release {
minifyEnabled true
shrinkResources true
signingConfig signingConfigs.releaseConfig
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard.cfg"
}
}
buildFeatures {
compose true
}
kotlinOptions {
jvmTarget = "21"
}
lint {
disable "UnusedResources"
}
}
afterEvaluate {
tasks.named('lint').configure {
dependsOn ktlintCheck
}
}
dependencies {
implementation project(":model")
implementation project(":ui:common")
implementation project(":net:sync:wear-interface")
// NOTE: DO NOT INCLUDE a dependency on androidx.compose.material:material, the watch version is a replacement
implementation "androidx.wear:wear:$wearVersion"
implementation "com.google.android.gms:play-services-wearable:$wearableVersion"
implementation "androidx.wear.compose:compose-foundation:$wearComposeVersion"
implementation "androidx.wear.compose:compose-material3:$wearComposeVersion"
implementation "androidx.compose.ui:ui:$composeVersion"
implementation "androidx.compose.runtime:runtime:$composeVersion"
implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycleRuntimeComposeVersion"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleRuntimeComposeVersion"
implementation "androidx.core:core-ktx:$coreKtxVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0"
implementation "androidx.activity:activity-compose:$activityComposeVersion"
implementation "androidx.appcompat:appcompat:$appcompatVersion"
testImplementation "junit:junit:$junitVersion"
}

11
app-wearos/proguard.cfg Normal file
View File

@ -0,0 +1,11 @@
# Add project specific ProGuard rules here.
# Keep Kotlin metadata
-keep class kotlin.metadata.** { *; }
# Keep Compose runtime
-keep class androidx.compose.runtime.** { *; }
# Keep Activity
-keep class androidx.activity.** { *; }
-keep class androidx.activity.compose.** { *; }

View File

@ -0,0 +1,57 @@
<?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" />
<application
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@android:style/Theme.DeviceDefault">
<meta-data
android:name="com.google.android.wearable.standalone"
android:value="false" />
<meta-data
android:name="com.google.android.gms.version"
android:value="@integer/google_play_services_version" />
<activity
android:name="de.danoeh.antennapod.wearos.MainListActivity"
android:exported="true"
android:taskAffinity=".wearos">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name="de.danoeh.antennapod.wearos.FeedListActivity"
android:exported="false"
android:taskAffinity=".wearos" />
<activity
android:name="de.danoeh.antennapod.wearos.EpisodeListActivity"
android:exported="false"
android:taskAffinity=".wearos" />
<activity
android:name="de.danoeh.antennapod.wearos.EpisodeDetailActivity"
android:exported="false"
android:taskAffinity=".wearos" />
<service
android:name="de.danoeh.antennapod.wearos.sync.WearDataListenerService"
android:exported="true">
<intent-filter>
<action android:name="com.google.android.gms.wearable.MESSAGE_RECEIVED" />
<data android:scheme="wear" android:host="*" />
</intent-filter>
</service>
</application>
</manifest>

View File

@ -0,0 +1,178 @@
package de.danoeh.antennapod.wearos
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.text.style.Hyphens
import androidx.compose.ui.text.style.LineBreak
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.IntentCompat
import androidx.lifecycle.ViewModelProvider
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
import androidx.wear.compose.material3.LinearProgressIndicator
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.ScrollIndicator
import androidx.wear.compose.material3.Text
import de.danoeh.antennapod.model.feed.FeedItem
import de.danoeh.antennapod.ui.common.Converter
import de.danoeh.antennapod.ui.common.DateFormatter
import de.danoeh.antennapod.ui.common.R as CommonR
import de.danoeh.antennapod.wearos.composable.ListItem
class EpisodeDetailActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val episode = IntentCompat.getSerializableExtra(intent, EXTRA_EPISODE, FeedItem::class.java) ?: run {
finish()
return
}
val viewModel = ViewModelProvider(this, EpisodeDetailViewModel.factory(episode))
.get(EpisodeDetailViewModel::class.java)
setContent {
val uiState by viewModel.uiState.collectAsState()
EpisodeDetailScreen(
uiState = uiState,
onPlay = { viewModel.play() },
onPause = { viewModel.pause() },
onOpenOnPhone = { viewModel.openOnPhone() }
)
}
}
companion object {
const val EXTRA_EPISODE = "episode"
}
}
@Composable
fun EpisodeDetailScreen(
uiState: EpisodeDetailUiState,
onPlay: () -> Unit,
onPause: () -> Unit,
onOpenOnPhone: () -> Unit
) {
val item = uiState.item
val scrollState = rememberScalingLazyListState()
var titleExpanded by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxSize()) {
ScalingLazyColumn(
modifier = Modifier.fillMaxWidth(),
state = scrollState,
horizontalAlignment = Alignment.CenterHorizontally,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 32.dp),
autoCentering = null
) {
item {
val dateStr = DateFormatter.formatAbbrev(LocalContext.current, item.getPubDate())
Text(
text = dateStr,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
textAlign = TextAlign.Center
)
}
item {
Text(
text = item.title,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
.clickable { titleExpanded = !titleExpanded },
style = MaterialTheme.typography.titleMedium.copy(
hyphens = Hyphens.Auto,
lineBreak = LineBreak.Paragraph
),
textAlign = TextAlign.Center,
maxLines = if (titleExpanded) Int.MAX_VALUE else 3,
overflow = if (titleExpanded) TextOverflow.Clip else TextOverflow.Ellipsis
)
}
if (uiState.duration > 0) {
item {
LinearProgressIndicator(
progress = { uiState.position.toFloat() / uiState.duration.toFloat() },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 2.dp)
)
}
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 8.dp, top = 2.dp, end = 8.dp, bottom = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = Converter.getDurationStringLong(uiState.position),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
Text(
text = Converter.getDurationStringLong(uiState.duration),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
}
}
}
item {
if (uiState.isCurrentlyPlaying) {
ListItem(
text = stringResource(CommonR.string.pause_label),
iconRes = CommonR.drawable.ic_pause_black,
onClick = onPause
)
} else {
ListItem(
text = stringResource(CommonR.string.wearos_play_on_phone),
iconRes = CommonR.drawable.ic_play_48dp_black,
onClick = onPlay
)
}
}
item {
ListItem(
text = stringResource(CommonR.string.wearos_open_on_phone),
iconRes = CommonR.drawable.ic_phone_black,
onClick = onOpenOnPhone
)
}
item {
Spacer(modifier = Modifier.height(32.dp))
}
}
ScrollIndicator(state = scrollState, modifier = Modifier.align(Alignment.CenterEnd))
}
}

View File

@ -0,0 +1,85 @@
package de.danoeh.antennapod.wearos
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import de.danoeh.antennapod.model.feed.FeedItem
import de.danoeh.antennapod.net.sync.wearinterface.WearDataPaths
import de.danoeh.antennapod.wearos.sync.WearDataRepository
import de.danoeh.antennapod.wearos.sync.WearMessageSender
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class EpisodeDetailUiState(
val item: FeedItem,
val position: Int = 0,
val duration: Int = 0,
val isCurrentlyPlaying: Boolean = false
)
class EpisodeDetailViewModel(application: Application, private val episode: FeedItem) : AndroidViewModel(application) {
private val _uiState = MutableStateFlow(
EpisodeDetailUiState(
item = episode,
position = episode.media?.position ?: 0,
duration = episode.media?.duration ?: 0
)
)
val uiState: StateFlow<EpisodeDetailUiState> = _uiState
init {
viewModelScope.launch {
while (true) {
WearMessageSender.send(getApplication(), WearDataPaths.NOW_PLAYING)
delay(1.seconds)
}
}
viewModelScope.launch {
WearDataRepository.nowPlaying.collect { nowPlaying ->
val liveData = nowPlaying?.takeIf { it.item.id == episode.id }
val position = liveData?.item?.media?.position ?: episode.media?.position ?: 0
val duration = liveData?.item?.media?.duration?.takeIf { it > 0 } ?: episode.media?.duration ?: 0
val isCurrentlyPlaying = liveData?.isPlaying == true
_uiState.update {
it.copy(position = position, duration = duration, isCurrentlyPlaying = isCurrentlyPlaying)
}
}
}
}
fun play() {
viewModelScope.launch(Dispatchers.IO) {
WearMessageSender.send(getApplication(), WearDataPaths.playPath(episode.id))
}
}
fun pause() {
viewModelScope.launch(Dispatchers.IO) { WearMessageSender.send(getApplication(), WearDataPaths.PAUSE) }
}
fun openOnPhone() {
viewModelScope.launch(Dispatchers.IO) {
WearMessageSender.send(getApplication(), WearDataPaths.openOnPhonePath(episode.id))
}
}
companion object {
fun factory(episode: FeedItem): ViewModelProvider.Factory = viewModelFactory {
initializer {
EpisodeDetailViewModel(
checkNotNull(get(ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY)),
episode
)
}
}
}
}

View File

@ -0,0 +1,65 @@
package de.danoeh.antennapod.wearos
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.lifecycle.ViewModelProvider
import androidx.wear.compose.foundation.lazy.items
import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
import de.danoeh.antennapod.model.feed.FeedItem
import de.danoeh.antennapod.wearos.composable.ListItem
import de.danoeh.antennapod.wearos.composable.ListScaffold
class EpisodeListActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val path = intent.getStringExtra(EXTRA_PATH) ?: run {
finish()
return
}
val viewModel = ViewModelProvider(this, EpisodeListViewModel.factory(path))[EpisodeListViewModel::class.java]
setContent {
val uiState by viewModel.uiState.collectAsState()
EpisodeListScreen(
uiState = uiState,
onOpenEpisodeDetail = { episode ->
val intent = Intent(this, EpisodeDetailActivity::class.java).apply {
putExtra(EpisodeDetailActivity.EXTRA_EPISODE, episode)
}
startActivity(intent)
}
)
}
}
companion object {
const val EXTRA_PATH = "path"
}
}
@Composable
fun EpisodeListScreen(uiState: EpisodeListUiState, onOpenEpisodeDetail: (FeedItem) -> Unit) {
val scrollState = rememberScalingLazyListState()
val episodes: List<FeedItem>? = uiState.episodes
ListScaffold(
titleRes = uiState.titleRes,
scrollState = scrollState,
isPhoneSupported = uiState.isPhoneSupported,
isTimedOut = uiState.isTimedOut,
isLoading = episodes == null,
isEmpty = episodes?.isEmpty() == true
) {
items(episodes!!) { episode ->
ListItem(
text = episode.title ?: "",
onClick = { onOpenEpisodeDetail(episode) }
)
}
}
}

View File

@ -0,0 +1,66 @@
package de.danoeh.antennapod.wearos
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import de.danoeh.antennapod.model.feed.FeedItem
import de.danoeh.antennapod.net.sync.wearinterface.WearConnectionUtils
import de.danoeh.antennapod.wearos.sync.WearDataRepository
import de.danoeh.antennapod.wearos.sync.WearMessageSender
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
data class EpisodeListUiState(
val episodes: List<FeedItem>? = null,
val isPhoneSupported: Boolean = true,
val isTimedOut: Boolean = false,
val titleRes: Int = 0
)
class EpisodeListViewModel(application: Application, private val path: String) : AndroidViewModel(application) {
private val _uiState = MutableStateFlow(EpisodeListUiState(titleRes = NavigationNames.getTitleResForPath(path)))
val uiState: StateFlow<EpisodeListUiState> = _uiState
init {
viewModelScope.launch {
WearDataRepository.episodesByPath.collect { map ->
_uiState.update { it.copy(episodes = map[path]) }
}
}
viewModelScope.launch {
val isSupported = withContext(Dispatchers.IO) {
WearConnectionUtils.isPhoneSupported(getApplication())
}
if (!isSupported) {
_uiState.update { it.copy(isPhoneSupported = false) }
return@launch
}
WearMessageSender.send(getApplication(), path)
val received = withTimeoutOrNull(10.seconds) {
WearDataRepository.episodesByPath.first { it.containsKey(path) }
}
if (received == null) {
_uiState.update { it.copy(isTimedOut = true) }
}
}
}
companion object {
fun factory(path: String): ViewModelProvider.Factory = viewModelFactory {
initializer {
EpisodeListViewModel(checkNotNull(get(ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY)), path)
}
}
}
}

View File

@ -0,0 +1,55 @@
package de.danoeh.antennapod.wearos
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.lifecycle.ViewModelProvider
import androidx.wear.compose.foundation.lazy.items
import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
import de.danoeh.antennapod.model.feed.Feed
import de.danoeh.antennapod.net.sync.wearinterface.WearDataPaths
import de.danoeh.antennapod.wearos.composable.ListItem
import de.danoeh.antennapod.wearos.composable.ListScaffold
class FeedListActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val viewModel = ViewModelProvider(this)[FeedListViewModel::class.java]
setContent {
val uiState by viewModel.uiState.collectAsState()
FeedListScreen(uiState = uiState, onOpenFeedEpisodes = { feedId ->
val intent = Intent(this, EpisodeListActivity::class.java).apply {
putExtra(EpisodeListActivity.EXTRA_PATH, WearDataPaths.feedEpisodesPath(feedId))
}
startActivity(intent)
})
}
}
}
@Composable
fun FeedListScreen(uiState: FeedListUiState, onOpenFeedEpisodes: (Long) -> Unit) {
val scrollState = rememberScalingLazyListState()
val feeds: List<Feed>? = uiState.feeds
ListScaffold(
titleRes = NavigationNames.getTitleResForPath(WearDataPaths.SUBSCRIPTIONS),
scrollState = scrollState,
isPhoneSupported = uiState.isPhoneSupported,
isTimedOut = uiState.isTimedOut,
isLoading = feeds == null,
isEmpty = feeds?.isEmpty() == true
) {
items(feeds!!) { feed ->
ListItem(
text = feed.title ?: "",
onClick = { onOpenFeedEpisodes(feed.id) }
)
}
}
}

View File

@ -0,0 +1,55 @@
package de.danoeh.antennapod.wearos
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import de.danoeh.antennapod.model.feed.Feed
import de.danoeh.antennapod.net.sync.wearinterface.WearConnectionUtils
import de.danoeh.antennapod.net.sync.wearinterface.WearDataPaths
import de.danoeh.antennapod.wearos.sync.WearDataRepository
import de.danoeh.antennapod.wearos.sync.WearMessageSender
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
data class FeedListUiState(
val feeds: List<Feed>? = null,
val isPhoneSupported: Boolean = true,
val isTimedOut: Boolean = false
)
class FeedListViewModel(application: Application) : AndroidViewModel(application) {
private val _uiState = MutableStateFlow(FeedListUiState())
val uiState: StateFlow<FeedListUiState> = _uiState
init {
viewModelScope.launch {
WearDataRepository.feedsByPath.collect { map ->
_uiState.update { it.copy(feeds = map[WearDataPaths.SUBSCRIPTIONS]) }
}
}
viewModelScope.launch {
val isSupported = withContext(Dispatchers.IO) {
WearConnectionUtils.isPhoneSupported(getApplication())
}
if (!isSupported) {
_uiState.update { it.copy(isPhoneSupported = false) }
return@launch
}
WearMessageSender.send(getApplication(), WearDataPaths.SUBSCRIPTIONS)
val received = withTimeoutOrNull(10.seconds) {
WearDataRepository.feedsByPath.first { it.containsKey(WearDataPaths.SUBSCRIPTIONS) }
}
if (received == null) {
_uiState.update { it.copy(isTimedOut = true) }
}
}
}
}

View File

@ -0,0 +1,150 @@
package de.danoeh.antennapod.wearos
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
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.unit.dp
import androidx.lifecycle.ViewModelProvider
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
import androidx.wear.compose.foundation.lazy.items
import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
import androidx.wear.compose.material3.ListHeader
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.ScrollIndicator
import androidx.wear.compose.material3.Text
import de.danoeh.antennapod.model.feed.FeedItem
import de.danoeh.antennapod.net.sync.wearinterface.WearDataPaths
import de.danoeh.antennapod.ui.common.R as CommonR
import de.danoeh.antennapod.wearos.composable.ListItem
class MainListActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val viewModel = ViewModelProvider(this)[MainViewModel::class.java]
setContent {
val uiState by viewModel.uiState.collectAsState()
MainListScreen(
uiState = uiState,
onOpenEpisodeDetail = { episode ->
val intent = Intent(this, EpisodeDetailActivity::class.java).apply {
putExtra(EpisodeDetailActivity.EXTRA_EPISODE, episode)
}
startActivity(intent)
},
onOpenFeedList = {
startActivity(Intent(this, FeedListActivity::class.java))
},
onOpenEpisodeList = { path ->
val intent = Intent(this, EpisodeListActivity::class.java).apply {
putExtra(EpisodeListActivity.EXTRA_PATH, path)
}
startActivity(intent)
}
)
}
}
}
@Composable
fun MainListScreen(
uiState: MainUiState,
onOpenEpisodeDetail: (FeedItem) -> Unit,
onOpenFeedList: () -> Unit,
onOpenEpisodeList: (String) -> Unit
) {
val scrollState = rememberScalingLazyListState()
val versionName = BuildConfig.VERSION_NAME
val menuItems = remember {
listOf(
WearDataPaths.QUEUE,
WearDataPaths.DOWNLOADS,
WearDataPaths.EPISODES,
WearDataPaths.SUBSCRIPTIONS
)
}
Box(modifier = Modifier.fillMaxSize()) {
ScalingLazyColumn(
modifier = Modifier.fillMaxWidth(),
state = scrollState,
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
ListHeader(modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.app_name))
}
}
item {
if (uiState.nowPlaying != null) {
ListItem(
text = uiState.nowPlaying.item.title ?: "",
onClick = { onOpenEpisodeDetail(uiState.nowPlaying.item) }
)
} else {
ListItem(
text = stringResource(CommonR.string.no_media_playing_label),
onClick = {}
)
}
}
item {
Text(
text = stringResource(CommonR.string.wearos_browse_phone),
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
}
items(menuItems) { path ->
ListItem(
text = stringResource(NavigationNames.getTitleResForPath(path)),
iconRes = NavigationNames.getIconResForPath(path),
onClick = {
if (path == WearDataPaths.SUBSCRIPTIONS) {
onOpenFeedList()
} else {
onOpenEpisodeList(path)
}
}
)
}
item {
val connectionText = if (uiState.connectedPhone.isNotEmpty()) {
stringResource(CommonR.string.wearos_connected_to, uiState.connectedPhone) + "\n"
} else {
""
}
Text(
text = connectionText + stringResource(CommonR.string.wearos_version, versionName),
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
}
}
ScrollIndicator(state = scrollState, modifier = Modifier.align(Alignment.CenterEnd))
}
}

View File

@ -0,0 +1,44 @@
package de.danoeh.antennapod.wearos
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import de.danoeh.antennapod.net.sync.wearinterface.WearConnectionUtils
import de.danoeh.antennapod.net.sync.wearinterface.WearDataPaths
import de.danoeh.antennapod.net.sync.wearinterface.WearNowPlaying
import de.danoeh.antennapod.wearos.sync.WearDataRepository
import de.danoeh.antennapod.wearos.sync.WearMessageSender
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class MainUiState(val connectedPhone: String = "", val nowPlaying: WearNowPlaying? = null)
class MainViewModel(application: Application) : AndroidViewModel(application) {
private val _uiState = MutableStateFlow(MainUiState())
val uiState: StateFlow<MainUiState> = _uiState
init {
viewModelScope.launch(Dispatchers.IO) {
val name = WearConnectionUtils.getConnectedNodeName(getApplication())
_uiState.update { it.copy(connectedPhone = name) }
}
viewModelScope.launch {
while (true) {
WearMessageSender.send(getApplication(), WearDataPaths.NOW_PLAYING)
delay(5.seconds)
}
}
viewModelScope.launch {
WearDataRepository.nowPlaying.collect { nowPlaying ->
_uiState.update { it.copy(nowPlaying = nowPlaying) }
}
}
}
}

View File

@ -0,0 +1,30 @@
package de.danoeh.antennapod.wearos
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import de.danoeh.antennapod.net.sync.wearinterface.WearDataPaths
import de.danoeh.antennapod.ui.common.R as CommonR
object NavigationNames {
@StringRes
fun getTitleResForPath(path: String): Int = when (path) {
WearDataPaths.QUEUE -> CommonR.string.queue_label
WearDataPaths.DOWNLOADS -> CommonR.string.downloads_label
WearDataPaths.EPISODES -> CommonR.string.episodes_label
WearDataPaths.SUBSCRIPTIONS -> CommonR.string.subscriptions_label
else -> if (path.startsWith(WearDataPaths.FEED_EPISODES_PREFIX)) {
CommonR.string.episodes_label
} else {
CommonR.string.app_name
}
}
@DrawableRes
fun getIconResForPath(path: String): Int = when (path) {
WearDataPaths.QUEUE -> CommonR.drawable.ic_playlist_play_black
WearDataPaths.DOWNLOADS -> CommonR.drawable.ic_download_black
WearDataPaths.EPISODES -> CommonR.drawable.ic_feed_black
WearDataPaths.SUBSCRIPTIONS -> CommonR.drawable.ic_subscriptions_black
else -> CommonR.mipmap.ic_launcher
}
}

View File

@ -0,0 +1,40 @@
package de.danoeh.antennapod.wearos.composable
import androidx.annotation.DrawableRes
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.wear.compose.material3.FilledTonalButton
import androidx.wear.compose.material3.Icon
import androidx.wear.compose.material3.Text
@Composable
fun ListItem(text: String, @DrawableRes iconRes: Int? = null, onClick: () -> Unit) {
FilledTonalButton(
onClick = onClick,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
if (iconRes != null) {
Icon(
painter = painterResource(id = iconRes),
contentDescription = null,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(text = text, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
}

View File

@ -0,0 +1,74 @@
package de.danoeh.antennapod.wearos.composable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.unit.dp
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
import androidx.wear.compose.foundation.lazy.ScalingLazyListScope
import androidx.wear.compose.foundation.lazy.ScalingLazyListState
import androidx.wear.compose.material3.CircularProgressIndicator
import androidx.wear.compose.material3.ListHeader
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.ScrollIndicator
import androidx.wear.compose.material3.Text
import de.danoeh.antennapod.ui.common.R as CommonR
@Composable
fun ListScaffold(
titleRes: Int,
scrollState: ScalingLazyListState,
isPhoneSupported: Boolean,
isTimedOut: Boolean,
isLoading: Boolean,
isEmpty: Boolean,
content: ScalingLazyListScope.() -> Unit
) {
Box(modifier = Modifier.fillMaxSize()) {
ScalingLazyColumn(
modifier = Modifier.fillMaxWidth(),
state = scrollState
) {
item {
ListHeader(modifier = Modifier.fillMaxWidth()) {
Text(stringResource(titleRes))
}
}
when {
!isPhoneSupported -> item {
Text(
text = stringResource(CommonR.string.wearos_phone_not_supported),
modifier = Modifier.padding(8.dp),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error
)
}
!isLoading && !isEmpty -> content()
isTimedOut -> item {
Text(
text = stringResource(CommonR.string.wearos_phone_not_reachable),
modifier = Modifier.padding(8.dp),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error
)
}
isLoading -> item { CircularProgressIndicator() }
else -> item { Text(stringResource(CommonR.string.wearos_no_items)) }
}
}
ScrollIndicator(state = scrollState, modifier = Modifier.align(Alignment.CenterEnd))
}
}

View File

@ -0,0 +1,36 @@
package de.danoeh.antennapod.wearos.sync
import android.util.Log
import com.google.android.gms.wearable.MessageEvent
import com.google.android.gms.wearable.WearableListenerService
import de.danoeh.antennapod.net.sync.wearinterface.WearDataPaths
import de.danoeh.antennapod.net.sync.wearinterface.WearSerializer
class WearDataListenerService : WearableListenerService() {
override fun onMessageReceived(event: MessageEvent) {
val path = event.path
when (path) {
WearDataPaths.SUBSCRIPTIONS -> {
val feeds = WearSerializer.feedsFromBytes(event.data)
Log.d(TAG, "Received $path: ${feeds.size} feeds")
WearDataRepository.updateFeeds(path, feeds)
}
WearDataPaths.NOW_PLAYING ->
WearDataRepository.updateNowPlaying(WearSerializer.nowPlayingFromBytes(event.data))
WearDataPaths.QUEUE, WearDataPaths.DOWNLOADS, WearDataPaths.EPISODES -> {
val items = WearSerializer.episodesFromBytes(event.data)
Log.d(TAG, "Received $path: ${items.size} episodes")
WearDataRepository.updateEpisodes(path, items)
}
else -> if (path.startsWith(WearDataPaths.FEED_EPISODES_PREFIX)) {
val items = WearSerializer.episodesFromBytes(event.data)
Log.d(TAG, "Received $path: ${items.size} episodes")
WearDataRepository.updateEpisodes(path, items)
}
}
}
companion object {
private const val TAG = "WearDataListenerService"
}
}

View File

@ -0,0 +1,36 @@
package de.danoeh.antennapod.wearos.sync
import de.danoeh.antennapod.model.feed.Feed
import de.danoeh.antennapod.model.feed.FeedItem
import de.danoeh.antennapod.net.sync.wearinterface.WearNowPlaying
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
/**
* Singleton holding the last data received from the phone for each data path.
* Written by WearDataListenerService (even when no activity is running) and
* read by the screen composables.
*/
object WearDataRepository {
private val _episodesByPath = MutableStateFlow<Map<String, List<FeedItem>>>(emptyMap())
val episodesByPath: StateFlow<Map<String, List<FeedItem>>> = _episodesByPath
private val _feedsByPath = MutableStateFlow<Map<String, List<Feed>>>(emptyMap())
val feedsByPath: StateFlow<Map<String, List<Feed>>> = _feedsByPath
private val _nowPlaying = MutableStateFlow<WearNowPlaying?>(null)
val nowPlaying: StateFlow<WearNowPlaying?> = _nowPlaying
fun updateNowPlaying(nowPlaying: WearNowPlaying?) {
_nowPlaying.value = nowPlaying
}
fun updateEpisodes(path: String, items: List<FeedItem>) {
_episodesByPath.update { it + (path to items) }
}
fun updateFeeds(path: String, feeds: List<Feed>) {
_feedsByPath.update { it + (path to feeds) }
}
}

View File

@ -0,0 +1,23 @@
package de.danoeh.antennapod.wearos.sync
import android.content.Context
import android.util.Log
import com.google.android.gms.wearable.Wearable
import kotlinx.coroutines.tasks.await
object WearMessageSender {
private const val TAG = "WearMessageSender"
suspend fun send(context: Context, path: String) {
try {
val nodes = Wearable.getNodeClient(context).connectedNodes.await()
val node = nodes.firstOrNull() ?: run {
Log.w(TAG, "No connected nodes")
return
}
Wearable.getMessageClient(context).sendMessage(node.id, path, null).await()
} catch (e: Exception) {
Log.e(TAG, "Failed to send message to phone", e)
}
}
}

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@array/android_wear_capabilities">
<string-array name="android_wear_capabilities">
<item tools:ignore="Typos">antennapod_watch_v1</item>
</string-array>
</resources>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<exclude domain="root" path="." />
</full-backup-content>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" />
</cloud-backup>
<device-transfer>
<exclude domain="root" />
</device-transfer>
</data-extraction-rules>