From 5fe70196eb6c2eccc07290b5cf899d16c4c02ab6 Mon Sep 17 00:00:00 2001 From: Hans-Peter Lehmann Date: Thu, 14 May 2026 14:17:54 +0200 Subject: [PATCH] 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 - [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 --- .editorconfig | 4 + .github/pull_request_template.md | 2 +- .github/workflows/checks.yml | 6 +- .gitignore | 3 + AGENTS.md | 2 +- app-wearos/README.md | 4 + app-wearos/build.gradle | 83 ++++++++ app-wearos/proguard.cfg | 11 ++ app-wearos/src/main/AndroidManifest.xml | 57 ++++++ .../wearos/EpisodeDetailActivity.kt | 178 ++++++++++++++++++ .../wearos/EpisodeDetailViewModel.kt | 85 +++++++++ .../antennapod/wearos/EpisodeListActivity.kt | 65 +++++++ .../antennapod/wearos/EpisodeListViewModel.kt | 66 +++++++ .../antennapod/wearos/FeedListActivity.kt | 55 ++++++ .../antennapod/wearos/FeedListViewModel.kt | 55 ++++++ .../antennapod/wearos/MainListActivity.kt | 150 +++++++++++++++ .../danoeh/antennapod/wearos/MainViewModel.kt | 44 +++++ .../antennapod/wearos/NavigationNames.kt | 30 +++ .../antennapod/wearos/composable/ListItem.kt | 40 ++++ .../wearos/composable/ListScaffold.kt | 74 ++++++++ .../wearos/sync/WearDataListenerService.kt | 36 ++++ .../wearos/sync/WearDataRepository.kt | 36 ++++ .../wearos/sync/WearMessageSender.kt | 23 +++ app-wearos/src/main/res/values/wear.xml | 7 + app-wearos/src/main/res/xml/backup_rules.xml | 4 + .../main/res/xml/data_extraction_rules.xml | 9 + app/build.gradle | 2 + .../antennapod/activity/MainActivity.java | 10 +- .../ui/screen/episode/ItemPagerFragment.java | 9 + app/src/play/AndroidManifest.xml | 9 + .../antennapod/WearListenerService.java | 131 +++++++++++++ app/src/play/res/values/wear.xml | 6 + build.gradle | 18 ++ common.gradle | 7 + net/sync/wear-interface/README.md | 4 + net/sync/wear-interface/build.gradle | 18 ++ .../wearinterface/WearConnectionUtils.java | 59 ++++++ .../net/sync/wearinterface/WearDataPaths.java | 28 +++ .../sync/wearinterface/WearNowPlaying.java | 13 ++ .../sync/wearinterface/WearSerializer.java | 143 ++++++++++++++ .../src/main/res/values/wear.xml | 4 + .../wearinterface/WearSerializerTest.java | 125 ++++++++++++ settings.gradle | 3 + .../appstartintent/MainActivityStarter.java | 6 + .../src/main/res/drawable/ic_pause_black.xml | 15 ++ .../src/main/res/drawable/ic_phone_black.xml | 15 ++ ui/i18n/src/main/res/values/strings.xml | 10 + 47 files changed, 1759 insertions(+), 5 deletions(-) create mode 100644 app-wearos/README.md create mode 100644 app-wearos/build.gradle create mode 100644 app-wearos/proguard.cfg create mode 100644 app-wearos/src/main/AndroidManifest.xml create mode 100644 app-wearos/src/main/java/de/danoeh/antennapod/wearos/EpisodeDetailActivity.kt create mode 100644 app-wearos/src/main/java/de/danoeh/antennapod/wearos/EpisodeDetailViewModel.kt create mode 100644 app-wearos/src/main/java/de/danoeh/antennapod/wearos/EpisodeListActivity.kt create mode 100644 app-wearos/src/main/java/de/danoeh/antennapod/wearos/EpisodeListViewModel.kt create mode 100644 app-wearos/src/main/java/de/danoeh/antennapod/wearos/FeedListActivity.kt create mode 100644 app-wearos/src/main/java/de/danoeh/antennapod/wearos/FeedListViewModel.kt create mode 100644 app-wearos/src/main/java/de/danoeh/antennapod/wearos/MainListActivity.kt create mode 100644 app-wearos/src/main/java/de/danoeh/antennapod/wearos/MainViewModel.kt create mode 100644 app-wearos/src/main/java/de/danoeh/antennapod/wearos/NavigationNames.kt create mode 100644 app-wearos/src/main/java/de/danoeh/antennapod/wearos/composable/ListItem.kt create mode 100644 app-wearos/src/main/java/de/danoeh/antennapod/wearos/composable/ListScaffold.kt create mode 100644 app-wearos/src/main/java/de/danoeh/antennapod/wearos/sync/WearDataListenerService.kt create mode 100644 app-wearos/src/main/java/de/danoeh/antennapod/wearos/sync/WearDataRepository.kt create mode 100644 app-wearos/src/main/java/de/danoeh/antennapod/wearos/sync/WearMessageSender.kt create mode 100644 app-wearos/src/main/res/values/wear.xml create mode 100644 app-wearos/src/main/res/xml/backup_rules.xml create mode 100644 app-wearos/src/main/res/xml/data_extraction_rules.xml create mode 100644 app/src/play/java/de/danoeh/antennapod/WearListenerService.java create mode 100644 app/src/play/res/values/wear.xml create mode 100644 net/sync/wear-interface/README.md create mode 100644 net/sync/wear-interface/build.gradle create mode 100644 net/sync/wear-interface/src/main/java/de/danoeh/antennapod/net/sync/wearinterface/WearConnectionUtils.java create mode 100644 net/sync/wear-interface/src/main/java/de/danoeh/antennapod/net/sync/wearinterface/WearDataPaths.java create mode 100644 net/sync/wear-interface/src/main/java/de/danoeh/antennapod/net/sync/wearinterface/WearNowPlaying.java create mode 100644 net/sync/wear-interface/src/main/java/de/danoeh/antennapod/net/sync/wearinterface/WearSerializer.java create mode 100644 net/sync/wear-interface/src/main/res/values/wear.xml create mode 100644 net/sync/wear-interface/src/test/java/de/danoeh/antennapod/net/sync/wearinterface/WearSerializerTest.java create mode 100644 ui/common/src/main/res/drawable/ic_pause_black.xml create mode 100644 ui/common/src/main/res/drawable/ic_phone_black.xml diff --git a/.editorconfig b/.editorconfig index ad6ac1f5c..c4ca02caf 100644 --- a/.editorconfig +++ b/.editorconfig @@ -5,3 +5,7 @@ root = true [*] charset = utf-8 max_line_length = 120 + +[*.kt] +ktlint_code_style = android_studio +ktlint_function_naming_ignore_when_annotated_with = Composable diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index e5fb01b05..adbca13f1 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -9,7 +9,7 @@ --> - [ ] I have read the contribution guidelines: https://github.com/AntennaPod/AntennaPod/blob/develop/CONTRIBUTING.md#submit-a-pull-request - [ ] 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 -- [ ] I have run the automated code checks using `./gradlew checkstyle lint spotbugsPlayDebug spotbugsDebug` +- [ ] I have run the automated code checks using `./gradlew checkstyle lint` - [ ] My code follows the style guidelines of the AntennaPod project: https://antennapod.org/contribute/develop/app/code-style - [ ] 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) - [ ] If it is a core feature, I have added automated tests diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 2542051ef..ad867a309 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -43,7 +43,7 @@ jobs: find . -wholename "*/res/layout/*.xml" | xargs java -jar android-xml-formatter.jar test $(git diff | wc -l) -eq 0 || (echo -e "\n\n===== Found XML code style violations! See output below how to fix them. =====\n\n" && git --no-pager diff --color=always && false) - name: Checkstyle, Lint, SpotBugs - run: ./gradlew checkstyle lint spotbugsPlayDebug spotbugsDebug + run: ./gradlew checkstyle lint - name: Generate readable error messages for GitHub if: failure() run: | @@ -88,7 +88,9 @@ jobs: - name: Configure parallel build run: echo "org.gradle.parallel=true" >> local.properties - name: Create temporary release keystore - run: keytool -noprompt -genkey -v -keystore "app/keystore" -alias alias -storepass password -keypass password -keyalg RSA -validity 10 -dname "CN=antennapod.org, OU=dummy, O=dummy, L=dummy, S=dummy, C=US" + run: | + keytool -noprompt -genkey -v -keystore "app/keystore" -alias alias -storepass password -keypass password -keyalg RSA -validity 10 -dname "CN=antennapod.org, OU=dummy, O=dummy, L=dummy, S=dummy, C=US" + cp app/keystore app-wearos/keystore - name: Build run: ./gradlew assemble${{ matrix.variant }} - name: Test diff --git a/.gitignore b/.gitignore index fae9511f3..1a662f766 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ gradle.properties .vscode .cursor *.hprof +.kotlin +.claude +.cursor diff --git a/AGENTS.md b/AGENTS.md index bd0ec6621..dadb2db65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,7 +79,7 @@ Then confirm with the user that the application is running correctly. If there is a crash, read the logs using `adb logcat -d | grep "de.danoeh.antennapod" | tail -20` and fix the issue. For running tests, use the command `./gradlew --console=plain` and use the task `:test` of the relevant module. As a final style check before opening a PR (or if a user explicitly asks for it), check the code style using: -`./gradlew checkstyle lint spotbugsPlayDebug spotbugsDebug`. +`./gradlew checkstyle lint`. If any command does not give any output, it is likely that it failed, so abort. # PR Conventions diff --git a/app-wearos/README.md b/app-wearos/README.md new file mode 100644 index 000000000..71468026d --- /dev/null +++ b/app-wearos/README.md @@ -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`. diff --git a/app-wearos/build.gradle b/app-wearos/build.gradle new file mode 100644 index 000000000..42a7feb57 --- /dev/null +++ b/app-wearos/build.gradle @@ -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" +} diff --git a/app-wearos/proguard.cfg b/app-wearos/proguard.cfg new file mode 100644 index 000000000..04a39a031 --- /dev/null +++ b/app-wearos/proguard.cfg @@ -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.** { *; } diff --git a/app-wearos/src/main/AndroidManifest.xml b/app-wearos/src/main/AndroidManifest.xml new file mode 100644 index 000000000..3135d2d60 --- /dev/null +++ b/app-wearos/src/main/AndroidManifest.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app-wearos/src/main/java/de/danoeh/antennapod/wearos/EpisodeDetailActivity.kt b/app-wearos/src/main/java/de/danoeh/antennapod/wearos/EpisodeDetailActivity.kt new file mode 100644 index 000000000..651c2ad63 --- /dev/null +++ b/app-wearos/src/main/java/de/danoeh/antennapod/wearos/EpisodeDetailActivity.kt @@ -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)) + } +} diff --git a/app-wearos/src/main/java/de/danoeh/antennapod/wearos/EpisodeDetailViewModel.kt b/app-wearos/src/main/java/de/danoeh/antennapod/wearos/EpisodeDetailViewModel.kt new file mode 100644 index 000000000..f54747702 --- /dev/null +++ b/app-wearos/src/main/java/de/danoeh/antennapod/wearos/EpisodeDetailViewModel.kt @@ -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 = _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 + ) + } + } + } +} diff --git a/app-wearos/src/main/java/de/danoeh/antennapod/wearos/EpisodeListActivity.kt b/app-wearos/src/main/java/de/danoeh/antennapod/wearos/EpisodeListActivity.kt new file mode 100644 index 000000000..255f474fa --- /dev/null +++ b/app-wearos/src/main/java/de/danoeh/antennapod/wearos/EpisodeListActivity.kt @@ -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? = 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) } + ) + } + } +} diff --git a/app-wearos/src/main/java/de/danoeh/antennapod/wearos/EpisodeListViewModel.kt b/app-wearos/src/main/java/de/danoeh/antennapod/wearos/EpisodeListViewModel.kt new file mode 100644 index 000000000..acd0ed976 --- /dev/null +++ b/app-wearos/src/main/java/de/danoeh/antennapod/wearos/EpisodeListViewModel.kt @@ -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? = 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 = _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) + } + } + } +} diff --git a/app-wearos/src/main/java/de/danoeh/antennapod/wearos/FeedListActivity.kt b/app-wearos/src/main/java/de/danoeh/antennapod/wearos/FeedListActivity.kt new file mode 100644 index 000000000..b2452cfe3 --- /dev/null +++ b/app-wearos/src/main/java/de/danoeh/antennapod/wearos/FeedListActivity.kt @@ -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? = 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) } + ) + } + } +} diff --git a/app-wearos/src/main/java/de/danoeh/antennapod/wearos/FeedListViewModel.kt b/app-wearos/src/main/java/de/danoeh/antennapod/wearos/FeedListViewModel.kt new file mode 100644 index 000000000..f0ba1c996 --- /dev/null +++ b/app-wearos/src/main/java/de/danoeh/antennapod/wearos/FeedListViewModel.kt @@ -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? = null, + val isPhoneSupported: Boolean = true, + val isTimedOut: Boolean = false +) + +class FeedListViewModel(application: Application) : AndroidViewModel(application) { + private val _uiState = MutableStateFlow(FeedListUiState()) + val uiState: StateFlow = _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) } + } + } + } +} diff --git a/app-wearos/src/main/java/de/danoeh/antennapod/wearos/MainListActivity.kt b/app-wearos/src/main/java/de/danoeh/antennapod/wearos/MainListActivity.kt new file mode 100644 index 000000000..0badff686 --- /dev/null +++ b/app-wearos/src/main/java/de/danoeh/antennapod/wearos/MainListActivity.kt @@ -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)) + } +} diff --git a/app-wearos/src/main/java/de/danoeh/antennapod/wearos/MainViewModel.kt b/app-wearos/src/main/java/de/danoeh/antennapod/wearos/MainViewModel.kt new file mode 100644 index 000000000..f7b7e18ce --- /dev/null +++ b/app-wearos/src/main/java/de/danoeh/antennapod/wearos/MainViewModel.kt @@ -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 = _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) } + } + } + } +} diff --git a/app-wearos/src/main/java/de/danoeh/antennapod/wearos/NavigationNames.kt b/app-wearos/src/main/java/de/danoeh/antennapod/wearos/NavigationNames.kt new file mode 100644 index 000000000..8ca40c29e --- /dev/null +++ b/app-wearos/src/main/java/de/danoeh/antennapod/wearos/NavigationNames.kt @@ -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 + } +} diff --git a/app-wearos/src/main/java/de/danoeh/antennapod/wearos/composable/ListItem.kt b/app-wearos/src/main/java/de/danoeh/antennapod/wearos/composable/ListItem.kt new file mode 100644 index 000000000..0bd096b43 --- /dev/null +++ b/app-wearos/src/main/java/de/danoeh/antennapod/wearos/composable/ListItem.kt @@ -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) + } + } +} diff --git a/app-wearos/src/main/java/de/danoeh/antennapod/wearos/composable/ListScaffold.kt b/app-wearos/src/main/java/de/danoeh/antennapod/wearos/composable/ListScaffold.kt new file mode 100644 index 000000000..c9ac40bf3 --- /dev/null +++ b/app-wearos/src/main/java/de/danoeh/antennapod/wearos/composable/ListScaffold.kt @@ -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)) + } +} diff --git a/app-wearos/src/main/java/de/danoeh/antennapod/wearos/sync/WearDataListenerService.kt b/app-wearos/src/main/java/de/danoeh/antennapod/wearos/sync/WearDataListenerService.kt new file mode 100644 index 000000000..41f616098 --- /dev/null +++ b/app-wearos/src/main/java/de/danoeh/antennapod/wearos/sync/WearDataListenerService.kt @@ -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" + } +} diff --git a/app-wearos/src/main/java/de/danoeh/antennapod/wearos/sync/WearDataRepository.kt b/app-wearos/src/main/java/de/danoeh/antennapod/wearos/sync/WearDataRepository.kt new file mode 100644 index 000000000..0e2086f8f --- /dev/null +++ b/app-wearos/src/main/java/de/danoeh/antennapod/wearos/sync/WearDataRepository.kt @@ -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>>(emptyMap()) + val episodesByPath: StateFlow>> = _episodesByPath + + private val _feedsByPath = MutableStateFlow>>(emptyMap()) + val feedsByPath: StateFlow>> = _feedsByPath + + private val _nowPlaying = MutableStateFlow(null) + val nowPlaying: StateFlow = _nowPlaying + + fun updateNowPlaying(nowPlaying: WearNowPlaying?) { + _nowPlaying.value = nowPlaying + } + + fun updateEpisodes(path: String, items: List) { + _episodesByPath.update { it + (path to items) } + } + + fun updateFeeds(path: String, feeds: List) { + _feedsByPath.update { it + (path to feeds) } + } +} diff --git a/app-wearos/src/main/java/de/danoeh/antennapod/wearos/sync/WearMessageSender.kt b/app-wearos/src/main/java/de/danoeh/antennapod/wearos/sync/WearMessageSender.kt new file mode 100644 index 000000000..aa971bbcf --- /dev/null +++ b/app-wearos/src/main/java/de/danoeh/antennapod/wearos/sync/WearMessageSender.kt @@ -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) + } + } +} diff --git a/app-wearos/src/main/res/values/wear.xml b/app-wearos/src/main/res/values/wear.xml new file mode 100644 index 000000000..53dfb5b2b --- /dev/null +++ b/app-wearos/src/main/res/values/wear.xml @@ -0,0 +1,7 @@ + + + + antennapod_watch_v1 + + diff --git a/app-wearos/src/main/res/xml/backup_rules.xml b/app-wearos/src/main/res/xml/backup_rules.xml new file mode 100644 index 000000000..24bad9354 --- /dev/null +++ b/app-wearos/src/main/res/xml/backup_rules.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app-wearos/src/main/res/xml/data_extraction_rules.xml b/app-wearos/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 000000000..798f19b88 --- /dev/null +++ b/app-wearos/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/build.gradle b/app/build.gradle index d716b7d9c..6e08e4aae 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -62,6 +62,7 @@ dependencies { implementation project(':net:download:service') implementation project(':net:ssl') implementation project(':net:sync:service') + playImplementation project(':net:sync:wear-interface') implementation project(':parser:feed') implementation project(':parser:transcript') implementation project(':playback:base') @@ -104,6 +105,7 @@ dependencies { implementation "androidx.media3:media3-common:$media3Version" implementation "androidx.media3:media3-session:$media3Version" implementation "androidx.media3:media3-ui:$media3Version" + playImplementation "com.google.android.gms:play-services-wearable:$wearableVersion" implementation "org.apache.commons:commons-lang3:$commonslangVersion" implementation "commons-io:commons-io:$commonsioVersion" diff --git a/app/src/main/java/de/danoeh/antennapod/activity/MainActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/MainActivity.java index 7f1ab5f86..539ca200a 100644 --- a/app/src/main/java/de/danoeh/antennapod/activity/MainActivity.java +++ b/app/src/main/java/de/danoeh/antennapod/activity/MainActivity.java @@ -75,6 +75,7 @@ import de.danoeh.antennapod.ui.screen.download.DownloadLogFragment; import de.danoeh.antennapod.ui.screen.drawer.BottomNavigation; import de.danoeh.antennapod.ui.screen.drawer.NavDrawerFragment; import de.danoeh.antennapod.ui.screen.drawer.NavigationNames; +import de.danoeh.antennapod.ui.screen.episode.ItemPagerFragment; import de.danoeh.antennapod.ui.screen.feed.FeedItemlistFragment; import de.danoeh.antennapod.ui.screen.home.HomeFragment; import de.danoeh.antennapod.ui.screen.playback.audio.AudioPlayerFragment; @@ -734,7 +735,14 @@ public class MainActivity extends CastEnabledActivity implements NavigationToolb private void handleNavIntent() { Log.d(TAG, "handleNavIntent()"); Intent intent = getIntent(); - if (intent.hasExtra(MainActivityStarter.EXTRA_FEED_ID)) { + if (intent.hasExtra(MainActivityStarter.EXTRA_EPISODE_ID)) { + long episodeId = intent.getLongExtra(MainActivityStarter.EXTRA_EPISODE_ID, 0); + if (episodeId <= 0) { + return; + } + loadChildFragment(ItemPagerFragment.newInstance(episodeId)); + sheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); + } else if (intent.hasExtra(MainActivityStarter.EXTRA_FEED_ID)) { long feedId = intent.getLongExtra(MainActivityStarter.EXTRA_FEED_ID, 0); Bundle args = intent.getBundleExtra(MainActivityStarter.EXTRA_FRAGMENT_ARGS); if (feedId > 0) { diff --git a/app/src/main/java/de/danoeh/antennapod/ui/screen/episode/ItemPagerFragment.java b/app/src/main/java/de/danoeh/antennapod/ui/screen/episode/ItemPagerFragment.java index 6ea94a2f2..44ad51922 100644 --- a/app/src/main/java/de/danoeh/antennapod/ui/screen/episode/ItemPagerFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/ui/screen/episode/ItemPagerFragment.java @@ -42,6 +42,15 @@ public class ItemPagerFragment extends Fragment implements MaterialToolbar.OnMen private static final String KEY_PAGER_ID = "pager_id"; private ViewPager2 pager; + public static ItemPagerFragment newInstance(long itemId) { + ItemPagerFragment fragment = new ItemPagerFragment(); + Bundle args = new Bundle(); + args.putLongArray(ARG_FEEDITEMS, new long[]{itemId}); + args.putInt(ARG_FEEDITEM_POS, 0); + fragment.setArguments(args); + return fragment; + } + /** * Creates a new instance of an ItemPagerFragment. * diff --git a/app/src/play/AndroidManifest.xml b/app/src/play/AndroidManifest.xml index a3e879081..56844b68d 100644 --- a/app/src/play/AndroidManifest.xml +++ b/app/src/play/AndroidManifest.xml @@ -5,6 +5,15 @@ + + + + + + + diff --git a/app/src/play/java/de/danoeh/antennapod/WearListenerService.java b/app/src/play/java/de/danoeh/antennapod/WearListenerService.java new file mode 100644 index 000000000..8ae65e96a --- /dev/null +++ b/app/src/play/java/de/danoeh/antennapod/WearListenerService.java @@ -0,0 +1,131 @@ +package de.danoeh.antennapod; + +import android.util.Log; +import com.google.android.gms.wearable.MessageEvent; +import com.google.android.gms.wearable.Wearable; +import de.danoeh.antennapod.playback.service.PlaybackService; +import de.danoeh.antennapod.storage.preferences.PlaybackPreferences; +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.schedulers.Schedulers; +import com.google.android.gms.wearable.WearableListenerService; +import de.danoeh.antennapod.model.feed.Feed; +import de.danoeh.antennapod.model.feed.FeedItem; +import de.danoeh.antennapod.model.feed.FeedItemFilter; +import de.danoeh.antennapod.model.feed.SortOrder; +import android.content.Intent; +import de.danoeh.antennapod.playback.service.PlaybackController; +import de.danoeh.antennapod.ui.appstartintent.MainActivityStarter; +import de.danoeh.antennapod.model.feed.FeedMedia; +import de.danoeh.antennapod.net.sync.wearinterface.WearDataPaths; +import de.danoeh.antennapod.net.sync.wearinterface.WearSerializer; +import de.danoeh.antennapod.playback.service.PlaybackServiceStarter; +import de.danoeh.antennapod.storage.database.DBReader; + +import java.util.List; + +public class WearListenerService extends WearableListenerService { + private static final String TAG = "WearListenerService"; + private static final int MAX_ITEMS = 100; + + @Override + public void onMessageReceived(MessageEvent event) { + String path = event.getPath(); + String sourceNodeId = event.getSourceNodeId(); + Log.d(TAG, "Message received: " + path + " from " + sourceNodeId); + Completable.fromAction(() -> handleMessage(path, sourceNodeId)) + .subscribeOn(Schedulers.computation()) + .subscribe( + () -> { }, + throwable -> Log.e(TAG, "Failed to handle wearable message: " + path, throwable)); + } + + private void handleMessage(String path, String sourceNodeId) { + switch (path) { + case WearDataPaths.NOW_PLAYING: + FeedMedia media = DBReader.getFeedMedia(PlaybackPreferences.getCurrentlyPlayingFeedMediaId()); + if (media == null) { + return; + } + if (!PlaybackService.isRunning) { + reply(sourceNodeId, WearDataPaths.NOW_PLAYING, + WearSerializer.nowPlayingToBytes(media.getItem(), false)); + return; + } + PlaybackController.bindToMedia3Service(this, controller -> { + media.setPosition((int) controller.getCurrentPosition()); + if (controller.getDuration() > 0) { + media.setDuration((int) controller.getDuration()); + } + reply(sourceNodeId, WearDataPaths.NOW_PLAYING, + WearSerializer.nowPlayingToBytes(media.getItem(), true)); + }); + break; + case WearDataPaths.PAUSE: + PlaybackController.bindToMedia3Service(this, controller -> controller.pause()); + break; + case WearDataPaths.QUEUE: + reply(sourceNodeId, path, WearSerializer.episodesToBytes(DBReader.getQueue())); + break; + case WearDataPaths.DOWNLOADS: + reply(sourceNodeId, path, WearSerializer.episodesToBytes(DBReader.getEpisodes(0, MAX_ITEMS, + new FeedItemFilter(FeedItemFilter.DOWNLOADED), SortOrder.DATE_NEW_OLD))); + break; + case WearDataPaths.EPISODES: + reply(sourceNodeId, path, WearSerializer.episodesToBytes(DBReader.getEpisodes(0, MAX_ITEMS, + FeedItemFilter.unfiltered(), SortOrder.DATE_NEW_OLD))); + break; + case WearDataPaths.SUBSCRIPTIONS: + reply(sourceNodeId, path, WearSerializer.feedsToBytes(DBReader.getFeedList())); + break; + default: + if (path.startsWith(WearDataPaths.PLAY_PREFIX)) { + try { + long itemId = Long.parseLong(path.substring(WearDataPaths.PLAY_PREFIX.length())); + playItem(itemId); + } catch (NumberFormatException e) { + Log.w(TAG, "Ignoring malformed play path: " + path, e); + } + } else if (path.startsWith(WearDataPaths.FEED_EPISODES_PREFIX)) { + try { + long feedId = Long.parseLong(path.substring(WearDataPaths.FEED_EPISODES_PREFIX.length())); + Feed feed = DBReader.getFeed(feedId, false, 0, MAX_ITEMS); + List feedItems = feed != null ? feed.getItems() : java.util.Collections.emptyList(); + reply(sourceNodeId, path, WearSerializer.episodesToBytes(feedItems)); + } catch (NumberFormatException e) { + Log.w(TAG, "Ignoring malformed feed episodes path: " + path, e); + } + } else if (path.startsWith(WearDataPaths.OPEN_ON_PHONE_PREFIX)) { + try { + long itemId = Long.parseLong(path.substring(WearDataPaths.OPEN_ON_PHONE_PREFIX.length())); + Intent intent = new MainActivityStarter(this).withOpenEpisode(itemId).getIntent(); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(intent); + } catch (NumberFormatException e) { + Log.w(TAG, "Ignoring malformed open on phone path: " + path, e); + } + } else { + Log.d(TAG, "Ignoring unknown path: " + path); + } + break; + } + } + + private void playItem(long itemId) { + FeedItem item = DBReader.getFeedItem(itemId); + if (item == null || item.getMedia() == null) { + Log.w(TAG, "Item or media not found for id " + itemId); + return; + } + Log.d(TAG, "Starting playback for: " + item.getTitle()); + new PlaybackServiceStarter(this, item.getMedia()) + .callEvenIfRunning(true) + .start(); + } + + private void reply(String nodeId, String path, byte[] payload) { + Log.d(TAG, "Sending reply to " + nodeId + " at " + path + " (" + payload.length + " bytes)"); + Wearable.getMessageClient(this).sendMessage(nodeId, path, payload) + .addOnSuccessListener(id -> Log.d(TAG, "Reply sent: " + path)) + .addOnFailureListener(e -> Log.e(TAG, "Failed to send reply to " + path, e)); + } +} diff --git a/app/src/play/res/values/wear.xml b/app/src/play/res/values/wear.xml new file mode 100644 index 000000000..f10a55d81 --- /dev/null +++ b/app/src/play/res/values/wear.xml @@ -0,0 +1,6 @@ + + + + @string/wear_capability_phone + + diff --git a/build.gradle b/build.gradle index 957a76513..c5ae0490f 100644 --- a/build.gradle +++ b/build.gradle @@ -1,11 +1,20 @@ buildscript { ext.agpVersion = "8.11.0" + ext.kotlin_version = '2.3.20' + dependencies { + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } + repositories { + mavenCentral() + } } plugins { id 'com.android.application' version "$agpVersion" apply false id 'com.android.library' version "$agpVersion" apply false id 'com.github.spotbugs' version '4.8.0' apply false id 'checkstyle' + id 'org.jlleitschuh.gradle.ktlint' version '12.3.0' apply false + id 'org.jetbrains.kotlin.plugin.compose' version "$kotlin_version" apply false } project.ext { @@ -23,6 +32,15 @@ project.ext { workManagerVersion = "2.10.3" googleMaterialVersion = "1.12.0" + // Wear OS + wearableVersion = "19.0.0" + wearVersion = "1.3.0" + wearComposeVersion = "1.6.0" + composeVersion = "1.7.5" + lifecycleRuntimeComposeVersion = "2.8.7" + coreKtxVersion = "1.18.0" + activityComposeVersion = "1.9.3" + // Third-party commonslangVersion = "3.18.0" commonsioVersion = "2.5" // Recent versions cause ClassNotFoundException on Android 6 diff --git a/common.gradle b/common.gradle index fd4c69cd0..fd2693893 100644 --- a/common.gradle +++ b/common.gradle @@ -137,6 +137,13 @@ checkstyle { showViolations = true } +afterEvaluate { + tasks.matching { it.name == 'lint' }.configureEach { + dependsOn tasks.matching { t -> t.name == 'spotbugsDebug' } + dependsOn tasks.matching { t -> t.name == 'spotbugsPlayDebug' } + } +} + tasks.register('checkstyle', Checkstyle) { group = "verification" diff --git a/net/sync/wear-interface/README.md b/net/sync/wear-interface/README.md new file mode 100644 index 000000000..f48b395e3 --- /dev/null +++ b/net/sync/wear-interface/README.md @@ -0,0 +1,4 @@ +# :net:sync:wear-interface + +This module contains the shared data paths and serialization logic for communication between the phone app and the Wear OS companion app. +`WearDataPaths` defines message path constants; `WearSerializer` handles JSON encoding. Both `:app` and `:app-wearos` depend on this module. diff --git a/net/sync/wear-interface/build.gradle b/net/sync/wear-interface/build.gradle new file mode 100644 index 000000000..0e32a19f3 --- /dev/null +++ b/net/sync/wear-interface/build.gradle @@ -0,0 +1,18 @@ +plugins { + id("com.android.library") +} +apply from: "../../../common.gradle" + +android { + namespace "de.danoeh.antennapod.net.sync.wearinterface" +} + +dependencies { + implementation project(':model') + + annotationProcessor "androidx.annotation:annotation:$annotationVersion" + implementation "com.google.android.gms:play-services-wearable:$wearableVersion" + + testImplementation "junit:junit:$junitVersion" + testImplementation "org.robolectric:robolectric:$robolectricVersion" +} diff --git a/net/sync/wear-interface/src/main/java/de/danoeh/antennapod/net/sync/wearinterface/WearConnectionUtils.java b/net/sync/wear-interface/src/main/java/de/danoeh/antennapod/net/sync/wearinterface/WearConnectionUtils.java new file mode 100644 index 000000000..95a8ecc8f --- /dev/null +++ b/net/sync/wear-interface/src/main/java/de/danoeh/antennapod/net/sync/wearinterface/WearConnectionUtils.java @@ -0,0 +1,59 @@ +package de.danoeh.antennapod.net.sync.wearinterface; + +import android.content.Context; +import android.util.Log; +import androidx.annotation.NonNull; +import com.google.android.gms.tasks.Tasks; +import com.google.android.gms.wearable.CapabilityClient; +import com.google.android.gms.wearable.Node; +import com.google.android.gms.wearable.Wearable; +import java.util.Set; +import java.util.concurrent.ExecutionException; + +public final class WearConnectionUtils { + private static final String TAG = "WearConnectionUtils"; + + private WearConnectionUtils() { + } + + /** + * Returns true if a reachable phone with the matching capability is connected. + * The capability is declared in the phone's wear.xml and versioned, so both sides + * can negotiate compatibility when updated independently. + */ + public static boolean isPhoneSupported(@NonNull Context context) { + try { + String capability = context.getString(R.string.wear_capability_phone); + Set nodes = Tasks.await( + Wearable.getCapabilityClient(context) + .getCapability(capability, CapabilityClient.FILTER_REACHABLE)) + .getNodes(); + return !nodes.isEmpty(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + Log.w(TAG, "Failed to check phone capability", e); + return false; + } catch (ExecutionException e) { + Log.w(TAG, "Failed to check phone capability", e); + return false; + } + } + + @NonNull + public static String getConnectedNodeName(@NonNull Context context) { + try { + var nodes = Tasks.await(Wearable.getNodeClient(context).getConnectedNodes()); + if (nodes.isEmpty()) { + return ""; + } + return nodes.get(0).getDisplayName(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + Log.w(TAG, "Failed to get connected node name", e); + return ""; + } catch (ExecutionException e) { + Log.w(TAG, "Failed to get connected node name", e); + return ""; + } + } +} diff --git a/net/sync/wear-interface/src/main/java/de/danoeh/antennapod/net/sync/wearinterface/WearDataPaths.java b/net/sync/wear-interface/src/main/java/de/danoeh/antennapod/net/sync/wearinterface/WearDataPaths.java new file mode 100644 index 000000000..f1db15720 --- /dev/null +++ b/net/sync/wear-interface/src/main/java/de/danoeh/antennapod/net/sync/wearinterface/WearDataPaths.java @@ -0,0 +1,28 @@ +package de.danoeh.antennapod.net.sync.wearinterface; + +public final class WearDataPaths { + public static final String QUEUE = "/queue"; + public static final String DOWNLOADS = "/downloads"; + public static final String EPISODES = "/episodes"; + public static final String SUBSCRIPTIONS = "/subscriptions"; + public static final String FEED_EPISODES_PREFIX = "/feed_episodes/"; + public static final String PLAY_PREFIX = "/play/"; + public static final String NOW_PLAYING = "/now_playing"; + public static final String PAUSE = "/pause"; + public static final String OPEN_ON_PHONE_PREFIX = "/open_on_phone/"; + + public static String playPath(long itemId) { + return PLAY_PREFIX + itemId; + } + + public static String openOnPhonePath(long itemId) { + return OPEN_ON_PHONE_PREFIX + itemId; + } + + public static String feedEpisodesPath(long feedId) { + return FEED_EPISODES_PREFIX + feedId; + } + + private WearDataPaths() { + } +} diff --git a/net/sync/wear-interface/src/main/java/de/danoeh/antennapod/net/sync/wearinterface/WearNowPlaying.java b/net/sync/wear-interface/src/main/java/de/danoeh/antennapod/net/sync/wearinterface/WearNowPlaying.java new file mode 100644 index 000000000..529d9460b --- /dev/null +++ b/net/sync/wear-interface/src/main/java/de/danoeh/antennapod/net/sync/wearinterface/WearNowPlaying.java @@ -0,0 +1,13 @@ +package de.danoeh.antennapod.net.sync.wearinterface; + +import de.danoeh.antennapod.model.feed.FeedItem; + +public class WearNowPlaying { + public final FeedItem item; + public final boolean isPlaying; + + public WearNowPlaying(FeedItem item, boolean isPlaying) { + this.item = item; + this.isPlaying = isPlaying; + } +} diff --git a/net/sync/wear-interface/src/main/java/de/danoeh/antennapod/net/sync/wearinterface/WearSerializer.java b/net/sync/wear-interface/src/main/java/de/danoeh/antennapod/net/sync/wearinterface/WearSerializer.java new file mode 100644 index 000000000..24e43021a --- /dev/null +++ b/net/sync/wear-interface/src/main/java/de/danoeh/antennapod/net/sync/wearinterface/WearSerializer.java @@ -0,0 +1,143 @@ +package de.danoeh.antennapod.net.sync.wearinterface; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import de.danoeh.antennapod.model.feed.Feed; +import de.danoeh.antennapod.model.feed.FeedItem; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import de.danoeh.antennapod.model.feed.FeedMedia; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +public final class WearSerializer { + private static final String KEY_EPISODE_ID = "episode_id"; + private static final String KEY_FEED_ID = "feed_id"; + private static final String KEY_TITLE = "title"; + private static final String KEY_PUB_DATE = "pub_date"; + private static final String KEY_DURATION = "duration"; + private static final String KEY_POSITION = "position"; + private static final String KEY_IS_PLAYING = "is_playing"; + + private WearSerializer() { + } + + @NonNull + private static JSONObject episodeToJson(@NonNull FeedItem item) throws JSONException { + JSONObject obj = new JSONObject(); + obj.put(KEY_EPISODE_ID, item.getId()); + obj.put(KEY_TITLE, item.getTitle() != null ? item.getTitle() : ""); + obj.put(KEY_PUB_DATE, item.getPubDate() != null ? item.getPubDate().getTime() : 0); + obj.put(KEY_DURATION, item.getMedia() != null ? item.getMedia().getDuration() : 0); + obj.put(KEY_POSITION, item.getMedia() != null ? item.getMedia().getPosition() : 0); + return obj; + } + + @NonNull + private static FeedItem episodeFromJson(@NonNull JSONObject obj) throws JSONException { + FeedItem item = new FeedItem(); + item.setId(obj.optLong(KEY_EPISODE_ID, -1)); + item.setTitle(obj.optString(KEY_TITLE, "")); + long pubDate = obj.optLong(KEY_PUB_DATE, 0); + if (pubDate != 0) { + item.setPubDate(new Date(pubDate)); + } + int duration = obj.optInt(KEY_DURATION, 0); + int position = obj.optInt(KEY_POSITION, 0); + FeedMedia media = new FeedMedia(0, item, duration, position, 0, null, null, null, 0, null, 0, 0L); + item.setMedia(media); + return item; + } + + @NonNull + public static byte[] episodesToBytes(@NonNull List items) { + JSONArray array = new JSONArray(); + for (FeedItem item : items) { + try { + array.put(episodeToJson(item)); + } catch (JSONException e) { + // skip malformed item + } + } + return array.toString().getBytes(StandardCharsets.UTF_8); + } + + @NonNull + public static List episodesFromBytes(@NonNull byte[] data) { + List items = new ArrayList<>(); + try { + JSONArray array = new JSONArray(new String(data, StandardCharsets.UTF_8)); + for (int i = 0; i < array.length(); i++) { + items.add(episodeFromJson(array.getJSONObject(i))); + } + } catch (JSONException e) { + // return whatever we managed to parse + } + return items; + } + + @NonNull + public static byte[] feedsToBytes(@NonNull List feeds) { + JSONArray array = new JSONArray(); + for (Feed feed : feeds) { + try { + JSONObject obj = new JSONObject(); + obj.put(KEY_FEED_ID, feed.getId()); + obj.put(KEY_TITLE, feed.getTitle() != null ? feed.getTitle() : ""); + array.put(obj); + } catch (JSONException e) { + // skip malformed feed + } + } + return array.toString().getBytes(StandardCharsets.UTF_8); + } + + @NonNull + public static List feedsFromBytes(@NonNull byte[] data) { + List feeds = new ArrayList<>(); + try { + JSONArray array = new JSONArray(new String(data, StandardCharsets.UTF_8)); + for (int i = 0; i < array.length(); i++) { + JSONObject obj = array.getJSONObject(i); + Feed feed = new Feed(null, null); + feed.setId(obj.optLong(KEY_FEED_ID, -1)); + feed.setTitle(obj.optString(KEY_TITLE, "")); + feeds.add(feed); + } + } catch (JSONException e) { + // return whatever we managed to parse + } + return feeds; + } + + @NonNull + public static byte[] nowPlayingToBytes(@NonNull FeedItem item, boolean isPlaying) { + try { + JSONObject obj = episodeToJson(item); + obj.put(KEY_IS_PLAYING, isPlaying); + return obj.toString().getBytes(StandardCharsets.UTF_8); + } catch (JSONException e) { + return new byte[0]; + } + } + + @Nullable + public static WearNowPlaying nowPlayingFromBytes(@NonNull byte[] data) { + if (data.length == 0) { + return null; + } + try { + JSONObject obj = new JSONObject(new String(data, StandardCharsets.UTF_8)); + FeedItem item = episodeFromJson(obj); + boolean isPlaying = obj.optBoolean(KEY_IS_PLAYING, false); + return new WearNowPlaying(item, isPlaying); + } catch (JSONException e) { + return null; + } + } + +} diff --git a/net/sync/wear-interface/src/main/res/values/wear.xml b/net/sync/wear-interface/src/main/res/values/wear.xml new file mode 100644 index 000000000..ab2066c76 --- /dev/null +++ b/net/sync/wear-interface/src/main/res/values/wear.xml @@ -0,0 +1,4 @@ + + + antennapod_phone_v1 + diff --git a/net/sync/wear-interface/src/test/java/de/danoeh/antennapod/net/sync/wearinterface/WearSerializerTest.java b/net/sync/wear-interface/src/test/java/de/danoeh/antennapod/net/sync/wearinterface/WearSerializerTest.java new file mode 100644 index 000000000..02463d580 --- /dev/null +++ b/net/sync/wear-interface/src/test/java/de/danoeh/antennapod/net/sync/wearinterface/WearSerializerTest.java @@ -0,0 +1,125 @@ +package de.danoeh.antennapod.net.sync.wearinterface; + +import de.danoeh.antennapod.model.feed.Feed; +import de.danoeh.antennapod.model.feed.FeedItem; +import de.danoeh.antennapod.model.feed.FeedMedia; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +@RunWith(RobolectricTestRunner.class) +public class WearSerializerTest { + @Test + public void testEpisodesRoundTrip() { + FeedItem item = new FeedItem(); + item.setId(42L); + item.setTitle("Test Episode"); + item.setPubDate(new Date(1000000L)); + FeedMedia media = new FeedMedia(0, item, 3600000, 90000, 0, null, null, null, 0, null, 0, 0L); + item.setMedia(media); + + byte[] bytes = WearSerializer.episodesToBytes(Collections.singletonList(item)); + List result = WearSerializer.episodesFromBytes(bytes); + + assertEquals(1, result.size()); + assertEquals(42L, result.get(0).getId()); + assertEquals("Test Episode", result.get(0).getTitle()); + assertEquals(1000000L, result.get(0).getPubDate().getTime()); + assertEquals(3600000, result.get(0).getMedia().getDuration()); + assertEquals(90000, result.get(0).getMedia().getPosition()); + } + + @Test + public void testEpisodesRoundTripEmpty() { + byte[] bytes = WearSerializer.episodesToBytes(Collections.emptyList()); + List result = WearSerializer.episodesFromBytes(bytes); + assertTrue(result.isEmpty()); + } + + @Test + public void testEpisodesRoundTripNullTitle() { + FeedItem item = new FeedItem(); + item.setId(1L); + item.setTitle(null); + + byte[] bytes = WearSerializer.episodesToBytes(Collections.singletonList(item)); + List result = WearSerializer.episodesFromBytes(bytes); + + assertEquals(1, result.size()); + assertEquals("", result.get(0).getTitle()); + } + + @Test + public void testFeedsRoundTrip() { + Feed feed = new Feed(null, null); + feed.setId(7L); + feed.setTitle("Test Feed"); + + byte[] bytes = WearSerializer.feedsToBytes(Collections.singletonList(feed)); + List result = WearSerializer.feedsFromBytes(bytes); + + assertEquals(1, result.size()); + assertEquals(7L, result.get(0).getId()); + assertEquals("Test Feed", result.get(0).getTitle()); + } + + @Test + public void testFeedsRoundTripEmpty() { + byte[] bytes = WearSerializer.feedsToBytes(Collections.emptyList()); + List result = WearSerializer.feedsFromBytes(bytes); + assertTrue(result.isEmpty()); + } + + @Test + public void testFeedsRoundTripMultiple() { + Feed feed1 = new Feed(null, null); + feed1.setId(1L); + feed1.setTitle("Feed One"); + Feed feed2 = new Feed(null, null); + feed2.setId(2L); + feed2.setTitle("Feed Two"); + + byte[] bytes = WearSerializer.feedsToBytes(Arrays.asList(feed1, feed2)); + List result = WearSerializer.feedsFromBytes(bytes); + + assertEquals(2, result.size()); + assertEquals(1L, result.get(0).getId()); + assertEquals("Feed One", result.get(0).getTitle()); + assertEquals(2L, result.get(1).getId()); + assertEquals("Feed Two", result.get(1).getTitle()); + } + + @Test + public void testNowPlayingRoundTrip() { + FeedItem item = new FeedItem(); + item.setId(99L); + item.setTitle("Now Playing"); + item.setPubDate(new Date(2000000L)); + FeedMedia media = new FeedMedia(0, item, 7200000, 120000, 0, null, null, null, 0, null, 0, 0L); + item.setMedia(media); + + byte[] bytes = WearSerializer.nowPlayingToBytes(item, true); + WearNowPlaying result = WearSerializer.nowPlayingFromBytes(bytes); + + assertTrue(result != null); + assertEquals(99L, result.item.getId()); + assertEquals("Now Playing", result.item.getTitle()); + assertEquals(7200000, result.item.getMedia().getDuration()); + assertEquals(120000, result.item.getMedia().getPosition()); + assertTrue(result.isPlaying); + } + + @Test + public void testNowPlayingFromBytesEmpty() { + WearNowPlaying result = WearSerializer.nowPlayingFromBytes(new byte[0]); + assertNull(result); + } +} diff --git a/settings.gradle b/settings.gradle index b28791ff8..ea74b86c6 100644 --- a/settings.gradle +++ b/settings.gradle @@ -17,6 +17,8 @@ dependencyResolutionManagement { } include ':app' +include ':app-wearos' + include ':event' include ':model' include ':system' @@ -29,6 +31,7 @@ include ':net:ssl' include ':net:sync:gpoddernet' include ':net:sync:service-interface' include ':net:sync:service' +include ':net:sync:wear-interface' include ':parser:feed' include ':parser:media' diff --git a/ui/app-start-intent/src/main/java/de/danoeh/antennapod/ui/appstartintent/MainActivityStarter.java b/ui/app-start-intent/src/main/java/de/danoeh/antennapod/ui/appstartintent/MainActivityStarter.java index 39fc8433b..ab367cda1 100644 --- a/ui/app-start-intent/src/main/java/de/danoeh/antennapod/ui/appstartintent/MainActivityStarter.java +++ b/ui/app-start-intent/src/main/java/de/danoeh/antennapod/ui/appstartintent/MainActivityStarter.java @@ -13,6 +13,7 @@ public class MainActivityStarter { public static final String INTENT = "de.danoeh.antennapod.intents.MAIN_ACTIVITY"; public static final String EXTRA_OPEN_PLAYER = "open_player"; public static final String EXTRA_FEED_ID = "fragment_feed_id"; + public static final String EXTRA_EPISODE_ID = "episode_id"; public static final String EXTRA_CLEAR_BACK_STACK = "clear_back_stack"; public static final String EXTRA_FRAGMENT_TAG = "fragment_tag"; public static final String EXTRA_OPEN_DRAWER = "open_drawer"; @@ -56,6 +57,11 @@ public class MainActivityStarter { return this; } + public MainActivityStarter withOpenEpisode(long episodeId) { + intent.putExtra(EXTRA_EPISODE_ID, episodeId); + return this; + } + public MainActivityStarter withClearBackStack() { intent.putExtra(EXTRA_CLEAR_BACK_STACK, true); return this; diff --git a/ui/common/src/main/res/drawable/ic_pause_black.xml b/ui/common/src/main/res/drawable/ic_pause_black.xml new file mode 100644 index 000000000..39442dac7 --- /dev/null +++ b/ui/common/src/main/res/drawable/ic_pause_black.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/ui/common/src/main/res/drawable/ic_phone_black.xml b/ui/common/src/main/res/drawable/ic_phone_black.xml new file mode 100644 index 000000000..f2349588c --- /dev/null +++ b/ui/common/src/main/res/drawable/ic_phone_black.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/ui/i18n/src/main/res/values/strings.xml b/ui/i18n/src/main/res/values/strings.xml index e3e9f474f..435bf0012 100644 --- a/ui/i18n/src/main/res/values/strings.xml +++ b/ui/i18n/src/main/res/values/strings.xml @@ -1029,4 +1029,14 @@ My favorite podcasts My year %d in podcasts. #AntennaPodEcho + + + Connected to %s + Version %s + Phone not compatible. Make sure you are running the most recent version of AntennaPod from Google Play on your phone. + No items + Could not reach the phone. Make sure your phone is nearby and AntennaPod is installed. + Play on phone + Browse phone + Open on phone