From f196b8109890f47a8845deb0e6e216060f1f9cfb Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 17 Oct 2026 10:04:26 +0200 Subject: [PATCH 01/67] fix: keep playback retries active after socket errors --- .../services/PlaybackNetworkRecoveryGate.kt | 20 +++++++++++++--- .../PlaybackNetworkRecoveryGateTest.kt | 24 +++++++++++++++---- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/dev/typetype/android/services/PlaybackNetworkRecoveryGate.kt b/app/src/main/java/dev/typetype/android/services/PlaybackNetworkRecoveryGate.kt index a683c784..de76d1a2 100644 --- a/app/src/main/java/dev/typetype/android/services/PlaybackNetworkRecoveryGate.kt +++ b/app/src/main/java/dev/typetype/android/services/PlaybackNetworkRecoveryGate.kt @@ -6,6 +6,11 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.HttpDataSource import dev.typetype.android.core.error.CodedFailure import dev.typetype.android.data.network.PlaybackNetworkState +import java.net.ConnectException +import java.net.NoRouteToHostException +import java.net.SocketException +import java.net.SocketTimeoutException +import java.net.UnknownHostException internal sealed interface PlaybackNetworkRecoveryAction { data object Wait : PlaybackNetworkRecoveryAction @@ -41,9 +46,9 @@ internal class PlaybackNetworkRecoveryGate { attempt = 0 } if (!network.isAvailable) return PlaybackNetworkRecoveryAction.Wait - val delayMs = RETRY_DELAYS_MS.getOrNull(attempt) - ?: return PlaybackNetworkRecoveryAction.Wait - attempt++ + val retryIndex = attempt.coerceAtMost(RETRY_DELAYS_MS.lastIndex) + val delayMs = RETRY_DELAYS_MS[retryIndex] + attempt = (retryIndex + 1).coerceAtMost(RETRY_DELAYS_MS.lastIndex) return PlaybackNetworkRecoveryAction.RetryAfter(delayMs) } @@ -115,6 +120,15 @@ private fun Throwable.hasHttpTransportFailure(): Boolean { ) { return true } + if ( + current is ConnectException || + current is NoRouteToHostException || + current is SocketException || + current is SocketTimeoutException || + current is UnknownHostException + ) { + return true + } current = current?.cause?.takeUnless { it === current } } return false diff --git a/app/src/test/java/dev/typetype/android/services/PlaybackNetworkRecoveryGateTest.kt b/app/src/test/java/dev/typetype/android/services/PlaybackNetworkRecoveryGateTest.kt index 95230895..2ec6e333 100644 --- a/app/src/test/java/dev/typetype/android/services/PlaybackNetworkRecoveryGateTest.kt +++ b/app/src/test/java/dev/typetype/android/services/PlaybackNetworkRecoveryGateTest.kt @@ -4,6 +4,7 @@ import androidx.media3.common.PlaybackException import dev.typetype.android.core.error.CodedFailure import dev.typetype.android.data.network.PlaybackNetworkState import java.io.IOException +import java.net.ConnectException import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -36,17 +37,20 @@ class PlaybackNetworkRecoveryGateTest { } @Test - fun `same route recovery uses a bounded progressive schedule`() { + fun `same route recovery keeps retrying at a capped interval`() { val gate = PlaybackNetworkRecoveryGate() val network = PlaybackNetworkState(true, 3L) val expectedDelays = listOf(1_000L, 3_000L, 10_000L, 30_000L) + - List(4) { 60_000L } + List(8) { 60_000L } - val actions = expectedDelays.map { gate.failed("video", network) } - val exhausted = gate.failed("video", network) + val actions = List(expectedDelays.size) { gate.failed("video", network) } + val cappedRetries = List(3) { gate.failed("video", network) } assertEquals(expectedDelays.map(PlaybackNetworkRecoveryAction::RetryAfter), actions) - assertEquals(PlaybackNetworkRecoveryAction.Wait, exhausted) + assertEquals( + List(3) { PlaybackNetworkRecoveryAction.RetryAfter(60_000L) }, + cappedRetries, + ) } @Test @@ -119,4 +123,14 @@ class PlaybackNetworkRecoveryGateTest { assertTrue(isNetworkFailure) } + + @Test + fun `raw connection refusal with unspecified code is classified as network interruption`() { + val isNetworkFailure = isNetworkPlaybackFailure( + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + ConnectException("Connection refused"), + ) + + assertTrue(isNetworkFailure) + } } From cb4544278026d29e993efdeaaa941a65033e3735 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 18 Oct 2026 22:40:48 +0200 Subject: [PATCH 02/67] feat: keep video quality local with mobile cap --- .../PlayerSettingsPlaybackControlsTest.kt | 11 ++++ .../data/network/PlaybackNetworkMonitor.kt | 3 + .../data/network/PlaybackNetworkObserver.kt | 7 ++- .../network/PlaybackNetworkStateTracker.kt | 4 ++ .../DataStorePreferencesRepository.kt | 19 ++++++ .../domain/preferences/AppPreferences.kt | 2 + .../domain/preferences/PlaybackQuality.kt | 48 +++++++++++++++ .../preferences/PreferencesRepository.kt | 2 + .../player/PlayerPreferenceCoordinator.kt | 20 +++++-- .../android/feature/player/PlayerViewModel.kt | 1 + .../settings/player/PlayerSettingsAction.kt | 1 + .../settings/player/PlayerSettingsScreen.kt | 22 ++++++- .../settings/player/PlayerSettingsState.kt | 1 + .../player/PlayerSettingsViewModel.kt | 8 ++- .../services/PlaybackQueueItemResolver.kt | 14 ++++- app/src/main/res/values/strings.xml | 2 + .../PlaybackNetworkStateTrackerTest.kt | 11 ++++ .../DataStorePreferencesRepositoryTest.kt | 4 ++ .../domain/preferences/PlaybackQualityTest.kt | 59 +++++++++++++++++++ 19 files changed, 227 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/dev/typetype/android/domain/preferences/PlaybackQuality.kt create mode 100644 app/src/test/java/dev/typetype/android/domain/preferences/PlaybackQualityTest.kt diff --git a/app/src/androidTest/java/dev/typetype/android/feature/settings/player/PlayerSettingsPlaybackControlsTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/settings/player/PlayerSettingsPlaybackControlsTest.kt index 99204b88..e45b4e2c 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/settings/player/PlayerSettingsPlaybackControlsTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/settings/player/PlayerSettingsPlaybackControlsTest.kt @@ -27,6 +27,17 @@ class PlayerSettingsPlaybackControlsTest { assertEquals(PlayerSettingsAction.SetPreferredCodec("av1"), action.get()) } + @Test + fun mobileDataQualityCanBeLimited() { + val action = AtomicReference() + showSettings(action) + + composeRule.onNodeWithText("Mobile data quality").performClick() + composeRule.onNodeWithText("720p").performClick() + + assertEquals(PlayerSettingsAction.SetMobileDataQuality("720p"), action.get()) + } + @Test fun doubleTapSeekTimeOffersFiveSeconds() { val action = AtomicReference() diff --git a/app/src/main/java/dev/typetype/android/data/network/PlaybackNetworkMonitor.kt b/app/src/main/java/dev/typetype/android/data/network/PlaybackNetworkMonitor.kt index f3a4cdbd..d73652a6 100644 --- a/app/src/main/java/dev/typetype/android/data/network/PlaybackNetworkMonitor.kt +++ b/app/src/main/java/dev/typetype/android/data/network/PlaybackNetworkMonitor.kt @@ -184,5 +184,8 @@ class PlaybackNetworkMonitor @Inject constructor( } else { null }, + isMetered = activeCapabilities?.let { + !it.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) + } ?: connectivityManager.isActiveNetworkMetered, ) } diff --git a/app/src/main/java/dev/typetype/android/data/network/PlaybackNetworkObserver.kt b/app/src/main/java/dev/typetype/android/data/network/PlaybackNetworkObserver.kt index 5f71f311..2a9b2760 100644 --- a/app/src/main/java/dev/typetype/android/data/network/PlaybackNetworkObserver.kt +++ b/app/src/main/java/dev/typetype/android/data/network/PlaybackNetworkObserver.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.StateFlow data class PlaybackNetworkState( val isAvailable: Boolean, val generation: Long, + val isMetered: Boolean = false, ) interface PlaybackNetworkObserver { @@ -21,7 +22,11 @@ interface NetworkAvailabilityObserver { } internal object AlwaysAvailablePlaybackNetworkObserver : PlaybackNetworkObserver { - private val state = PlaybackNetworkState(isAvailable = true, generation = 0L) + private val state = PlaybackNetworkState( + isAvailable = true, + generation = 0L, + isMetered = false, + ) override fun snapshot(): PlaybackNetworkState = state diff --git a/app/src/main/java/dev/typetype/android/data/network/PlaybackNetworkStateTracker.kt b/app/src/main/java/dev/typetype/android/data/network/PlaybackNetworkStateTracker.kt index 66efbdd2..d668f90f 100644 --- a/app/src/main/java/dev/typetype/android/data/network/PlaybackNetworkStateTracker.kt +++ b/app/src/main/java/dev/typetype/android/data/network/PlaybackNetworkStateTracker.kt @@ -5,6 +5,7 @@ internal data class PlaybackNetworkRoute( val isBlocked: Boolean, val isValidated: Boolean?, val isSuspended: Boolean?, + val isMetered: Boolean = false, ) { val isAvailable: Boolean get() = identity != null && !isBlocked && isSuspended != true @@ -18,6 +19,7 @@ internal class PlaybackNetworkStateTracker( var state = PlaybackNetworkState( isAvailable = initialRoute.isAvailable, generation = 0L, + isMetered = initialRoute.isMetered, ) private set @@ -38,11 +40,13 @@ internal class PlaybackNetworkStateTracker( previousRoute.identity != nextRoute.identity || previousRoute.isBlocked != nextRoute.isBlocked || previousRoute.isAvailable != nextRoute.isAvailable || + previousRoute.isMetered != nextRoute.isMetered || recovered if (!materiallyChanged) return null return PlaybackNetworkState( isAvailable = nextRoute.isAvailable, generation = state.generation + 1L, + isMetered = nextRoute.isMetered, ).also { state = it } } } diff --git a/app/src/main/java/dev/typetype/android/data/preferences/DataStorePreferencesRepository.kt b/app/src/main/java/dev/typetype/android/data/preferences/DataStorePreferencesRepository.kt index 089f291a..e78d2b12 100644 --- a/app/src/main/java/dev/typetype/android/data/preferences/DataStorePreferencesRepository.kt +++ b/app/src/main/java/dev/typetype/android/data/preferences/DataStorePreferencesRepository.kt @@ -17,6 +17,7 @@ import dev.typetype.android.domain.preferences.AppPreferences import dev.typetype.android.domain.preferences.MangaHeadlineMarker import dev.typetype.android.domain.preferences.MangaPaper import dev.typetype.android.domain.preferences.PreferencesRepository +import dev.typetype.android.domain.preferences.normalizePlaybackQuality import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.flow.Flow @@ -50,6 +51,12 @@ class DataStorePreferencesRepository @Inject constructor( .takeIf(ALLOWED_DOUBLE_TAP_SEEK_SECONDS::contains) ?: 10, playerPreferredCodec = prefs[KEY_PLAYER_PREFERRED_CODEC] ?.takeIf(ALLOWED_PLAYER_CODECS::contains) ?: "recommended", + playerDefaultQuality = normalizePlaybackQuality( + prefs[KEY_PLAYER_DEFAULT_QUALITY] ?: "1080p", + ), + playerMobileDataQuality = normalizePlaybackQuality( + prefs[KEY_PLAYER_MOBILE_DATA_QUALITY] ?: "1080p", + ), playerSwipeSeekEnabled = prefs[KEY_PLAYER_SWIPE_SEEK] ?: true, playerSwipeBrightnessVolumeEnabled = prefs[KEY_PLAYER_SWIPE_BRIGHT_VOL] ?: true, playerPlaybackBrightnessPercent = prefs[KEY_PLAYER_PLAYBACK_BRIGHTNESS] @@ -114,6 +121,16 @@ class DataStorePreferencesRepository @Inject constructor( dataStore.edit { it[KEY_PLAYER_PREFERRED_CODEC] = value } } + override suspend fun setPlayerDefaultQuality(quality: String) { + dataStore.edit { it[KEY_PLAYER_DEFAULT_QUALITY] = normalizePlaybackQuality(quality) } + } + + override suspend fun setPlayerMobileDataQuality(quality: String) { + dataStore.edit { + it[KEY_PLAYER_MOBILE_DATA_QUALITY] = normalizePlaybackQuality(quality) + } + } + override suspend fun setPlayerSwipeSeekEnabled(enabled: Boolean) { dataStore.edit { it[KEY_PLAYER_SWIPE_SEEK] = enabled } } @@ -193,6 +210,8 @@ class DataStorePreferencesRepository @Inject constructor( val KEY_PLAYER_DOUBLE_TAP_SEEK = booleanPreferencesKey("player_double_tap_seek") val KEY_PLAYER_DOUBLE_TAP_SEEK_SECONDS = intPreferencesKey("player_double_tap_seek_seconds") val KEY_PLAYER_PREFERRED_CODEC = stringPreferencesKey("player_preferred_codec") + val KEY_PLAYER_DEFAULT_QUALITY = stringPreferencesKey("player_default_quality") + val KEY_PLAYER_MOBILE_DATA_QUALITY = stringPreferencesKey("player_mobile_data_quality") val KEY_PLAYER_SWIPE_SEEK = booleanPreferencesKey("player_swipe_seek") val KEY_PLAYER_SWIPE_BRIGHT_VOL = booleanPreferencesKey("player_swipe_bright_vol") val KEY_PLAYER_PLAYBACK_BRIGHTNESS = intPreferencesKey("player_playback_brightness") diff --git a/app/src/main/java/dev/typetype/android/domain/preferences/AppPreferences.kt b/app/src/main/java/dev/typetype/android/domain/preferences/AppPreferences.kt index bd4257d2..6949806a 100644 --- a/app/src/main/java/dev/typetype/android/domain/preferences/AppPreferences.kt +++ b/app/src/main/java/dev/typetype/android/domain/preferences/AppPreferences.kt @@ -18,6 +18,8 @@ data class AppPreferences( val playerDoubleTapSeekEnabled: Boolean = true, val playerDoubleTapSeekSeconds: Int = 10, val playerPreferredCodec: String = "recommended", + val playerDefaultQuality: String = "1080p", + val playerMobileDataQuality: String = "1080p", val playerSwipeSeekEnabled: Boolean = true, val playerSwipeBrightnessVolumeEnabled: Boolean = true, val playerPlaybackBrightnessPercent: Int? = null, diff --git a/app/src/main/java/dev/typetype/android/domain/preferences/PlaybackQuality.kt b/app/src/main/java/dev/typetype/android/domain/preferences/PlaybackQuality.kt new file mode 100644 index 00000000..f561bbb6 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/domain/preferences/PlaybackQuality.kt @@ -0,0 +1,48 @@ +package dev.typetype.android.domain.preferences + +private val QUALITY_HEIGHT = Regex("""(\d{3,4})p""", RegexOption.IGNORE_CASE) + +val PLAYBACK_QUALITY_OPTIONS = listOf( + "auto", + "144p", + "240p", + "360p", + "480p", + "720p", + "1080p", + "1440p", + "2160p", +) + +val PLAYBACK_MOBILE_QUALITY_OPTIONS = listOf( + "480p", + "720p", + "1080p", + "1440p", + "2160p", +) + +fun normalizePlaybackQuality(value: String): String { + val cleaned = value.trim().lowercase() + if (cleaned == "auto" || cleaned == "best") return "auto" + val height = QUALITY_HEIGHT.find(cleaned)?.groupValues?.get(1)?.toIntOrNull() + ?: return "1080p" + return "${height}p" +} + +fun effectivePlaybackQualityCap( + defaultQuality: String, + mobileDataQuality: String, + isMetered: Boolean, +): String { + val normalizedDefault = normalizePlaybackQuality(defaultQuality) + if (!isMetered) return normalizedDefault.capHeightLabel() + val normalizedMobile = normalizePlaybackQuality(mobileDataQuality) + return "${minOf(normalizedDefault.capHeight(), normalizedMobile.capHeight())}p" +} + +private fun String.capHeight(): Int = + if (this == "auto") 2160 else substringBefore('p').toIntOrNull() ?: 1080 + +private fun String.capHeightLabel(): String = + if (this == "auto") "2160p" else this diff --git a/app/src/main/java/dev/typetype/android/domain/preferences/PreferencesRepository.kt b/app/src/main/java/dev/typetype/android/domain/preferences/PreferencesRepository.kt index f5504d2d..04f03546 100644 --- a/app/src/main/java/dev/typetype/android/domain/preferences/PreferencesRepository.kt +++ b/app/src/main/java/dev/typetype/android/domain/preferences/PreferencesRepository.kt @@ -21,6 +21,8 @@ interface PreferencesRepository { suspend fun setPlayerDoubleTapSeekEnabled(enabled: Boolean) suspend fun setPlayerDoubleTapSeekSeconds(seconds: Int) suspend fun setPlayerPreferredCodec(codec: String) + suspend fun setPlayerDefaultQuality(quality: String) + suspend fun setPlayerMobileDataQuality(quality: String) suspend fun setPlayerSwipeSeekEnabled(enabled: Boolean) suspend fun setPlayerSwipeBrightnessVolumeEnabled(enabled: Boolean) suspend fun setPlayerPlaybackBrightnessPercent(percent: Int) diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerPreferenceCoordinator.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerPreferenceCoordinator.kt index 7ba9be2c..5e56d471 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlayerPreferenceCoordinator.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerPreferenceCoordinator.kt @@ -1,6 +1,8 @@ package dev.typetype.android.feature.player +import dev.typetype.android.data.network.PlaybackNetworkState import dev.typetype.android.domain.preferences.PreferencesRepository +import dev.typetype.android.domain.preferences.effectivePlaybackQualityCap import dev.typetype.android.domain.usersettings.UserSettings import dev.typetype.android.domain.usersettings.UserSettingsRepository import dev.typetype.android.feature.player.components.PlayerGestureConfig @@ -8,18 +10,26 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch internal class PlayerPreferenceCoordinator( private val local: PreferencesRepository, private val server: UserSettingsRepository, + private val networkStates: StateFlow, private val scope: CoroutineScope, ) { - val states: Flow = combine(local.observe(), server.observe()) { - preferences, - userSettings, - -> + val states: Flow = combine( + local.observe(), + server.observe(), + networkStates, + ) { preferences, userSettings, network -> + val automaticQualityCap = effectivePlaybackQualityCap( + defaultQuality = preferences.playerDefaultQuality, + mobileDataQuality = preferences.playerMobileDataQuality, + isMetered = network.isMetered, + ) PlayerPreferenceState( gestureConfig = PlayerGestureConfig( doubleTapSeekEnabled = preferences.playerDoubleTapSeekEnabled, @@ -33,7 +43,7 @@ internal class PlayerPreferenceCoordinator( autoplayCountdownSeconds = preferences.playerAutoplayCountdownSeconds, audioOnlyPlaybackDefault = preferences.playerAudioOnlyPlayback, preferredCodec = preferences.playerPreferredCodec, - userSettings = userSettings, + userSettings = userSettings.copy(defaultQuality = automaticQualityCap), ) } diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerViewModel.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerViewModel.kt index b4175c22..6576788d 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlayerViewModel.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerViewModel.kt @@ -67,6 +67,7 @@ class PlayerViewModel @Inject constructor( private val playerPreferences = PlayerPreferenceCoordinator( preferencesRepository, userSettingsRepository, + networkMonitor.states, viewModelScope, ) private val navigationController = PlayerNavigationController(playerHostController, playbackQueueCoordinator) diff --git a/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsAction.kt b/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsAction.kt index 5afda711..ef63e0e5 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsAction.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsAction.kt @@ -20,6 +20,7 @@ sealed interface PlayerSettingsAction { data class SetDanmakuSpeed(val speed: Float) : PlayerSettingsAction data class SetDanmakuSize(val size: Float) : PlayerSettingsAction data class SetDefaultQuality(val quality: String) : PlayerSettingsAction + data class SetMobileDataQuality(val quality: String) : PlayerSettingsAction data class SetDefaultPlaybackSpeed(val speed: Double) : PlayerSettingsAction data class SetDefaultService(val service: Int) : PlayerSettingsAction data class SetSubtitlesEnabled(val enabled: Boolean) : PlayerSettingsAction diff --git a/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsScreen.kt b/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsScreen.kt index 11927495..0ab9fb6f 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsScreen.kt @@ -40,13 +40,14 @@ import dev.typetype.android.core.ui.components.DropdownRow import dev.typetype.android.core.ui.components.ServiceRow import dev.typetype.android.core.ui.components.SettingsSectionHeader import dev.typetype.android.core.ui.components.SwitchRow +import dev.typetype.android.domain.preferences.PLAYBACK_MOBILE_QUALITY_OPTIONS +import dev.typetype.android.domain.preferences.PLAYBACK_QUALITY_OPTIONS import dev.typetype.android.feature.player.AV1_CODEC_KEY import dev.typetype.android.feature.player.H264_CODEC_KEY import dev.typetype.android.feature.player.RECOMMENDED_CODEC_KEY import dev.typetype.android.feature.player.VP9_CODEC_KEY import dev.typetype.android.feature.settings.SettingsDetailTopBar -private val QUALITY_OPTIONS = listOf("144p", "240p", "360p", "480p", "720p", "1080p", "1440p", "2160p") private val AUTOPLAY_COUNTDOWN_OPTIONS = listOf(0, 5, 10, 15, 30, 60) private val PLAYBACK_SPEED_OPTIONS = listOf(0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 4.0) @@ -194,11 +195,28 @@ fun PlayerSettingsScreen( DropdownRow( title = stringResource(R.string.settings_player_default_quality), subtitle = stringResource(R.string.settings_player_default_quality_subtitle), - options = QUALITY_OPTIONS.map { it to it }, + options = PLAYBACK_QUALITY_OPTIONS.map { quality -> + quality to if (quality == "auto") { + stringResource(R.string.playback_options_auto) + } else { + quality + } + }, selectedKey = state.defaultQuality, onSelect = { onAction(PlayerSettingsAction.SetDefaultQuality(it)) }, ) } + item { + DropdownRow( + title = stringResource(R.string.settings_player_mobile_quality), + subtitle = stringResource(R.string.settings_player_mobile_quality_subtitle), + options = PLAYBACK_MOBILE_QUALITY_OPTIONS.map { it to it }, + selectedKey = state.mobileDataQuality, + onSelect = { + onAction(PlayerSettingsAction.SetMobileDataQuality(it)) + }, + ) + } item { DropdownRow( title = stringResource(R.string.settings_player_default_speed), diff --git a/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsState.kt b/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsState.kt index 4f120a34..20334587 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsState.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsState.kt @@ -21,6 +21,7 @@ data class PlayerSettingsState( val danmakuSpeed: Float = 1f, val danmakuSize: Float = 1f, val defaultQuality: String = "1080p", + val mobileDataQuality: String = "1080p", val defaultPlaybackSpeed: Double = 1.0, val defaultService: Int = 0, val subtitlesEnabled: Boolean = false, diff --git a/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsViewModel.kt b/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsViewModel.kt index 3b64a1ca..5abda51a 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsViewModel.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsViewModel.kt @@ -7,7 +7,6 @@ import dev.typetype.android.domain.preferences.PreferencesRepository import dev.typetype.android.domain.usersettings.DEFAULT_SPONSOR_BLOCK_CATEGORY_ACTIONS import dev.typetype.android.domain.usersettings.SponsorBlockMode import dev.typetype.android.domain.usersettings.UserSettings -import dev.typetype.android.domain.usersettings.normalizeQualityName import dev.typetype.android.domain.usersettings.UserSettingsRepository import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow @@ -47,7 +46,8 @@ class PlayerSettingsViewModel @Inject constructor( danmakuEnabled = prefs.danmakuEnabled, danmakuSpeed = prefs.danmakuSpeed, danmakuSize = prefs.danmakuSize, - defaultQuality = normalizeQualityName(server.defaultQuality), + defaultQuality = prefs.playerDefaultQuality, + mobileDataQuality = prefs.playerMobileDataQuality, defaultPlaybackSpeed = server.defaultPlaybackSpeed, defaultService = server.defaultService, subtitlesEnabled = server.subtitlesEnabled, @@ -105,7 +105,9 @@ class PlayerSettingsViewModel @Inject constructor( is PlayerSettingsAction.SetAudioOnlyPlayback -> preferencesRepository.setPlayerAudioOnlyPlayback(action.enabled) is PlayerSettingsAction.SetDefaultQuality -> - updateServer { it.copy(defaultQuality = action.quality) } + preferencesRepository.setPlayerDefaultQuality(action.quality) + is PlayerSettingsAction.SetMobileDataQuality -> + preferencesRepository.setPlayerMobileDataQuality(action.quality) is PlayerSettingsAction.SetDefaultPlaybackSpeed -> updateServer { it.copy(defaultPlaybackSpeed = action.speed.coerceIn(0.25, 4.0)) } is PlayerSettingsAction.SetDefaultService -> diff --git a/app/src/main/java/dev/typetype/android/services/PlaybackQueueItemResolver.kt b/app/src/main/java/dev/typetype/android/services/PlaybackQueueItemResolver.kt index 7a73ac36..2ce42af8 100644 --- a/app/src/main/java/dev/typetype/android/services/PlaybackQueueItemResolver.kt +++ b/app/src/main/java/dev/typetype/android/services/PlaybackQueueItemResolver.kt @@ -3,6 +3,9 @@ package dev.typetype.android.services import android.content.Context import androidx.media3.common.MediaItem import dagger.hilt.android.qualifiers.ApplicationContext +import dev.typetype.android.data.network.PlaybackNetworkMonitor +import dev.typetype.android.domain.preferences.PreferencesRepository +import dev.typetype.android.domain.preferences.effectivePlaybackQualityCap import dev.typetype.android.domain.stream.SabrPlaybackRepository import dev.typetype.android.domain.stream.StreamRepository import dev.typetype.android.domain.stream.sabrPlaybackTarget @@ -15,18 +18,27 @@ import dev.typetype.android.feature.player.pickPlayableSource import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.first @Singleton class PlaybackQueueItemResolver @Inject constructor( @ApplicationContext context: Context, private val streamRepository: StreamRepository, private val userSettingsRepository: UserSettingsRepository, + private val preferencesRepository: PreferencesRepository, + private val networkMonitor: PlaybackNetworkMonitor, private val sabrPlaybackRepository: SabrPlaybackRepository, ) { private val codecSupport = DevicePlaybackCodecSupport(context) suspend fun resolve(videoUrl: String): Result = captureQueueResult { val settings = userSettingsRepository.current().getOrThrow() + val preferences = preferencesRepository.observe().first() + val automaticQualityCap = effectivePlaybackQualityCap( + defaultQuality = preferences.playerDefaultQuality, + mobileDataQuality = preferences.playerMobileDataQuality, + isMetered = networkMonitor.snapshot().isMetered, + ) val stream = streamRepository.loadPlaybackStream(videoUrl).getOrThrow() val selectedAudioKey = stream.initialAudioKey( settings.defaultAudioLanguage, @@ -38,7 +50,7 @@ class PlaybackQueueItemResolver @Inject constructor( selectedQuality = stream.initialQuality(), selectedAudioKey = selectedAudioKey, defaultAudioLanguage = settings.defaultAudioLanguage, - automaticQualityCap = settings.defaultQuality, + automaticQualityCap = automaticQualityCap, preferOriginalLanguage = settings.preferOriginalLanguage, codecSupport = codecSupport, prepareSabrPlayback = { loaded, selection, _ -> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1333383a..782c59bb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -302,6 +302,8 @@ Default codec Smart prefers efficient hardware decoding Preferred video resolution + Mobile data quality + Maximum quality on mobile data Default playback speed Applied when a video starts Subtitles diff --git a/app/src/test/java/dev/typetype/android/data/network/PlaybackNetworkStateTrackerTest.kt b/app/src/test/java/dev/typetype/android/data/network/PlaybackNetworkStateTrackerTest.kt index b03d6da0..1e98ef78 100644 --- a/app/src/test/java/dev/typetype/android/data/network/PlaybackNetworkStateTrackerTest.kt +++ b/app/src/test/java/dev/typetype/android/data/network/PlaybackNetworkStateTrackerTest.kt @@ -87,6 +87,15 @@ class PlaybackNetworkStateTrackerTest { assertEquals(PlaybackNetworkState(true, 1L), changed) } + @Test + fun `metered change advances generation`() { + val tracker = tracker(route(metered = false)) + + val changed = tracker.update(route(metered = true)) + + assertEquals(PlaybackNetworkState(true, 1L, isMetered = true), changed) + } + private fun tracker(route: PlaybackNetworkRoute) = PlaybackNetworkStateTracker(route) private fun route( @@ -94,10 +103,12 @@ class PlaybackNetworkStateTrackerTest { blocked: Boolean = false, validated: Boolean? = true, suspended: Boolean? = false, + metered: Boolean = false, ) = PlaybackNetworkRoute( identity = identity, isBlocked = blocked, isValidated = validated, isSuspended = suspended, + isMetered = metered, ) } diff --git a/app/src/test/java/dev/typetype/android/data/preferences/DataStorePreferencesRepositoryTest.kt b/app/src/test/java/dev/typetype/android/data/preferences/DataStorePreferencesRepositoryTest.kt index 31528267..0d03e5e8 100644 --- a/app/src/test/java/dev/typetype/android/data/preferences/DataStorePreferencesRepositoryTest.kt +++ b/app/src/test/java/dev/typetype/android/data/preferences/DataStorePreferencesRepositoryTest.kt @@ -70,6 +70,8 @@ class DataStorePreferencesRepositoryTest { ) first.setPlayerDoubleTapSeekSeconds(5) first.setPlayerPreferredCodec("vp9") + first.setPlayerDefaultQuality("2160p") + first.setPlayerMobileDataQuality("720p") first.setPlayerAccessibleControlsEnabled(true) firstJob.cancelAndJoin() @@ -82,6 +84,8 @@ class DataStorePreferencesRepositoryTest { assertEquals(5, preferences.playerDoubleTapSeekSeconds) assertEquals("vp9", preferences.playerPreferredCodec) + assertEquals("2160p", preferences.playerDefaultQuality) + assertEquals("720p", preferences.playerMobileDataQuality) assertEquals(true, preferences.playerAccessibleControlsEnabled) secondJob.cancelAndJoin() directory.deleteRecursively() diff --git a/app/src/test/java/dev/typetype/android/domain/preferences/PlaybackQualityTest.kt b/app/src/test/java/dev/typetype/android/domain/preferences/PlaybackQualityTest.kt new file mode 100644 index 00000000..3c78a61e --- /dev/null +++ b/app/src/test/java/dev/typetype/android/domain/preferences/PlaybackQualityTest.kt @@ -0,0 +1,59 @@ +package dev.typetype.android.domain.preferences + +import org.junit.Assert.assertEquals +import org.junit.Test + +class PlaybackQualityTest { + @Test + fun `auto keeps the highest quality on unmetered networks`() { + assertEquals( + "2160p", + effectivePlaybackQualityCap( + defaultQuality = "auto", + mobileDataQuality = "1080p", + isMetered = false, + ), + ) + } + + @Test + fun `auto uses the mobile cap on metered networks`() { + assertEquals( + "1080p", + effectivePlaybackQualityCap( + defaultQuality = "auto", + mobileDataQuality = "1080p", + isMetered = true, + ), + ) + } + + @Test + fun `a lower default quality stays below the mobile cap`() { + assertEquals( + "720p", + effectivePlaybackQualityCap( + defaultQuality = "720p", + mobileDataQuality = "1080p", + isMetered = true, + ), + ) + } + + @Test + fun `a higher default quality is capped on mobile`() { + assertEquals( + "1080p", + effectivePlaybackQualityCap( + defaultQuality = "2160p", + mobileDataQuality = "1080p", + isMetered = true, + ), + ) + } + + @Test + fun `quality names accept hdr suffixes`() { + assertEquals("1440p", normalizePlaybackQuality("1440p HDR")) + } +} From 46e9102a2c455bb444a5417f8406fd899746ec4d Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 21 Oct 2026 15:09:33 +0200 Subject: [PATCH 03/67] fix: retry failed push registration automatically --- .../android/domain/push/PushModels.kt | 2 + .../notifications/PushNotificationsScreen.kt | 1 + .../services/push/PushRegistrationManager.kt | 83 +++++++++++++++---- app/src/main/res/values/strings.xml | 11 ++- .../push/PushRegistrationFailureCodeTest.kt | 15 ++++ 5 files changed, 93 insertions(+), 19 deletions(-) create mode 100644 app/src/test/java/dev/typetype/android/services/push/PushRegistrationFailureCodeTest.kt diff --git a/app/src/main/java/dev/typetype/android/domain/push/PushModels.kt b/app/src/main/java/dev/typetype/android/domain/push/PushModels.kt index a2791d45..38c2e172 100644 --- a/app/src/main/java/dev/typetype/android/domain/push/PushModels.kt +++ b/app/src/main/java/dev/typetype/android/domain/push/PushModels.kt @@ -26,5 +26,7 @@ sealed interface PushRegistrationStatus { data object Registered : PushRegistrationStatus + data object ActionRequired : PushRegistrationStatus + data object Failed : PushRegistrationStatus } diff --git a/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsScreen.kt b/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsScreen.kt index 883af2bc..a351bb7c 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsScreen.kt @@ -214,6 +214,7 @@ private fun statusLabel(status: PushRegistrationStatus): Int = when (status) { PushRegistrationStatus.Registering -> R.string.push_status_connecting PushRegistrationStatus.MissingDistributor -> R.string.push_status_missing_distributor PushRegistrationStatus.Unavailable -> R.string.push_status_unavailable + PushRegistrationStatus.ActionRequired -> R.string.push_status_action_required PushRegistrationStatus.Failed -> R.string.push_status_failed PushRegistrationStatus.Disabled -> R.string.push_status_disabled } diff --git a/app/src/main/java/dev/typetype/android/services/push/PushRegistrationManager.kt b/app/src/main/java/dev/typetype/android/services/push/PushRegistrationManager.kt index 9eacb490..f0b776c1 100644 --- a/app/src/main/java/dev/typetype/android/services/push/PushRegistrationManager.kt +++ b/app/src/main/java/dev/typetype/android/services/push/PushRegistrationManager.kt @@ -5,6 +5,8 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dev.typetype.android.R import dev.typetype.android.data.account.ActiveAccountScope import dev.typetype.android.data.account.AccountScope +import dev.typetype.android.data.diagnostics.PushDiagnosticEvent +import dev.typetype.android.data.diagnostics.PushDiagnosticRecorder import dev.typetype.android.data.push.PushRegistrationStore import dev.typetype.android.data.push.pushInstanceName import dev.typetype.android.data.push.scopeFromInstanceName @@ -12,12 +14,13 @@ import dev.typetype.android.domain.push.PushRegistrationStatus import dev.typetype.android.domain.push.PushRepository import javax.inject.Inject import javax.inject.Singleton -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.unifiedpush.android.connector.UnifiedPush @@ -27,6 +30,7 @@ class PushRegistrationManager @Inject constructor( private val repository: PushRepository, private val registrationStore: PushRegistrationStore, private val activeAccountScope: ActiveAccountScope, + private val pushDiagnostics: PushDiagnosticRecorder, ) { private val _status = MutableStateFlow(PushRegistrationStatus.Disabled) val status: StateFlow = _status.asStateFlow() @@ -36,6 +40,7 @@ class PushRegistrationManager @Inject constructor( suspend fun enable(): PushRegistrationStatus { val scope = activeAccountScope.require() if (!repository.currentCapability().enabled) { + pushDiagnostics.record(scope, PushDiagnosticEvent.RegistrationFailed, "server_push_unavailable") return update(PushRegistrationStatus.Unavailable) } return startRegistration(scope) @@ -46,6 +51,7 @@ class PushRegistrationManager @Inject constructor( val registration = registrationStore.registrationOnce(scope) ?: return update(PushRegistrationStatus.Disabled) if (!repository.currentCapability().enabled) { + pushDiagnostics.record(scope, PushDiagnosticEvent.RegistrationFailed, "server_push_unavailable") return update(PushRegistrationStatus.Unavailable) } val endpoint = registration.endpoint ?: return startRegistration(scope) @@ -53,22 +59,37 @@ class PushRegistrationManager @Inject constructor( val auth = registration.auth if (p256dh.isNullOrBlank() || auth.isNullOrBlank()) return startRegistration(scope) return repository.registerDevice(registration.deviceId, endpoint, p256dh, auth).fold( - onSuccess = { update(PushRegistrationStatus.Registered) }, - onFailure = { update(PushRegistrationStatus.Failed) }, + onSuccess = { + pushDiagnostics.record(scope, PushDiagnosticEvent.RegistrationActive) + update(PushRegistrationStatus.Registered) + }, + onFailure = { + pushDiagnostics.record(scope, PushDiagnosticEvent.RegistrationFailed, "server_registration_failed") + update(PushRegistrationStatus.Failed) + }, ) } private suspend fun startRegistration(scope: AccountScope): PushRegistrationStatus { val instance = pushInstanceName(scope) if (UnifiedPush.getDistributors(context).isEmpty()) { + pushDiagnostics.record(scope, PushDiagnosticEvent.RegistrationFailed, "missing_distributor") return update(PushRegistrationStatus.MissingDistributor) } - if (UnifiedPush.getAckDistributor(context) == null) { - UnifiedPush.saveDistributor(context, UnifiedPush.getDistributors(context).first()) + return try { + if (UnifiedPush.getAckDistributor(context) == null) { + UnifiedPush.saveDistributor(context, UnifiedPush.getDistributors(context).first()) + } + registrationStore.ensureDeviceId(scope) + pushDiagnostics.record(scope, PushDiagnosticEvent.RegistrationRequested) + UnifiedPush.register(context, instance, context.getString(R.string.app_name)) + update(PushRegistrationStatus.Registering) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + pushDiagnostics.record(scope, PushDiagnosticEvent.RegistrationFailed, "unifiedpush_request_failed") + update(PushRegistrationStatus.Failed) } - registrationStore.ensureDeviceId(scope) - UnifiedPush.register(context, instance, context.getString(R.string.app_name)) - return update(PushRegistrationStatus.Registering) } suspend fun disable(): PushRegistrationStatus { @@ -78,6 +99,7 @@ class PushRegistrationManager @Inject constructor( } UnifiedPush.unregister(context, pushInstanceName(scope)) registrationStore.clear(scope) + pushDiagnostics.record(scope, PushDiagnosticEvent.RegistrationDisabled) return update(PushRegistrationStatus.Disabled) } @@ -99,13 +121,30 @@ class PushRegistrationManager @Inject constructor( fun onEndpointAvailable(instance: String, endpoint: String, p256dh: String, auth: String) { managerScope.launch { val accountScope = scopeFromInstanceName(instance) ?: return@launch - val deviceId = registrationStore.ensureDeviceId(accountScope) + val deviceId = try { + registrationStore.ensureDeviceId(accountScope).also { + registrationStore.setSubscription(accountScope, endpoint, p256dh, auth) + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + pushDiagnostics.record( + accountScope, + PushDiagnosticEvent.RegistrationFailed, + "registration_storage_failed", + ) + update(PushRegistrationStatus.Failed) + return@launch + } repository.registerDevice(deviceId, endpoint, p256dh, auth).fold( onSuccess = { - registrationStore.setSubscription(accountScope, endpoint, p256dh, auth) + pushDiagnostics.record(accountScope, PushDiagnosticEvent.RegistrationActive) update(PushRegistrationStatus.Registered) }, - onFailure = { update(PushRegistrationStatus.Failed) }, + onFailure = { + pushDiagnostics.record(accountScope, PushDiagnosticEvent.RegistrationFailed, "server_registration_failed") + update(PushRegistrationStatus.Failed) + }, ) } } @@ -117,13 +156,25 @@ class PushRegistrationManager @Inject constructor( repository.unregisterDevice(registration.deviceId) } registrationStore.clear(accountScope) + pushDiagnostics.record(accountScope, PushDiagnosticEvent.RegistrationDisabled) update(PushRegistrationStatus.Disabled) } } - fun onRegistrationFailed(instance: String) { - if (scopeFromInstanceName(instance) != null) { - update(PushRegistrationStatus.Failed) + fun onRegistrationFailed( + instance: String, + failureCode: String = "unifiedpush_registration_failed", + ) { + val scope = scopeFromInstanceName(instance) + if (scope != null) { + pushDiagnostics.record(scope, PushDiagnosticEvent.RegistrationFailed, failureCode) + update( + if (failureCode == "unifiedpush_action_required") { + PushRegistrationStatus.ActionRequired + } else { + PushRegistrationStatus.Failed + }, + ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 782c59bb..5a6f316b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -358,7 +358,7 @@ Control history tracking and stored activity Diagnostics Review redacted app and network activity - Stored only on this device, separately for the active instance and account. The journal contains redacted network metadata, app exit reasons and predefined SABR failure categories. A crash report keeps exception class names, method names and line numbers, but never exception messages. Session IDs are replaced with local fingerprints. URLs, search terms, video IDs, headers, cookies, tokens, request bodies and raw server messages are never recorded. + Stored only on this device, separately for the active instance and account. The journal contains redacted network metadata, app exit reasons, predefined SABR failure categories and opaque push trace IDs. A crash report keeps exception class names, method names and line numbers, but never exception messages. Session IDs are replaced with local fingerprints. URLs, search terms, video IDs, headers, cookies, tokens, request bodies and raw server messages are never recorded. Refresh Clear Share @@ -369,9 +369,13 @@ SABR %1$s Request %1$s Code %1$s + Trace %1$s + Local event Copy request ID - TypeType request ID + TypeType diagnostic ID Request ID copied + Trace ID copied + Copy trace ID Request %1$s Code %1$s App stopped unexpectedly @@ -1209,11 +1213,12 @@ New videos Push notifications New video alerts via UnifiedPush - Active + Registered Connecting… Off No push distributor installed Not offered by this instance + Open your push distributor and complete its pending action Registration failed %1$d of %2$d devices registered Install a UnifiedPush distributor such as ntfy to receive notifications. diff --git a/app/src/test/java/dev/typetype/android/services/push/PushRegistrationFailureCodeTest.kt b/app/src/test/java/dev/typetype/android/services/push/PushRegistrationFailureCodeTest.kt new file mode 100644 index 00000000..3f82038c --- /dev/null +++ b/app/src/test/java/dev/typetype/android/services/push/PushRegistrationFailureCodeTest.kt @@ -0,0 +1,15 @@ +package dev.typetype.android.services.push + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.unifiedpush.android.connector.FailedReason + +class PushRegistrationFailureCodeTest { + @Test + fun `maps distributor registration failures to stable diagnostic codes`() { + assertEquals("unifiedpush_network", FailedReason.NETWORK.diagnosticFailureCode()) + assertEquals("unifiedpush_internal_error", FailedReason.INTERNAL_ERROR.diagnosticFailureCode()) + assertEquals("unifiedpush_action_required", FailedReason.ACTION_REQUIRED.diagnosticFailureCode()) + assertEquals("unifiedpush_vapid_required", FailedReason.VAPID_REQUIRED.diagnosticFailureCode()) + } +} From 74585151875a3c5a0e7ece12edbbcd35bb7f7169 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 21 Oct 2026 15:09:38 +0200 Subject: [PATCH 04/67] fix: retry undelivered local notifications --- .../LocalNotificationDecision.kt | 5 +- .../notifications/LocalNotificationPoller.kt | 56 ++++++++++++++++++- .../notifications/LocalNotificationWorker.kt | 28 +++++++++- .../LocalNotificationDecisionTest.kt | 16 ++++++ 4 files changed, 100 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationDecision.kt b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationDecision.kt index 9cbb81a1..00b50d9a 100644 --- a/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationDecision.kt +++ b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationDecision.kt @@ -33,5 +33,8 @@ internal fun decideLocalNotifications( ) } -private fun NotificationItem.localNotificationKey(): String = +internal fun NotificationItem.localNotificationKey(): String = video.id.takeIf { it.isNotBlank() } ?: video.url + +internal fun LocalNotificationDecision.keysToPersist(unpostedKeys: Set): List = + keysToRecord.filterNot(unpostedKeys::contains) diff --git a/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationPoller.kt b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationPoller.kt index 5c8c548b..870c5318 100644 --- a/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationPoller.kt +++ b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationPoller.kt @@ -3,8 +3,11 @@ package dev.typetype.android.data.notifications import dev.typetype.android.data.account.AccountDao import dev.typetype.android.data.account.AccountScope import dev.typetype.android.data.account.ActiveAccountScope +import dev.typetype.android.data.diagnostics.PushDiagnosticEvent +import dev.typetype.android.data.diagnostics.PushDiagnosticRecorder import dev.typetype.android.domain.notifications.NotificationsRepository import dev.typetype.android.domain.push.PushRepository +import dev.typetype.android.services.push.NotificationPostResult import dev.typetype.android.services.push.PushNotifier import javax.inject.Inject import javax.inject.Singleton @@ -18,6 +21,7 @@ class LocalNotificationPoller @Inject constructor( private val pushRepository: PushRepository, private val seenStore: LocalNotificationSeenStore, private val notifier: PushNotifier, + private val pushDiagnostics: PushDiagnosticRecorder, ) { suspend fun poll(): Result = try { Result.success(pollOnce()) @@ -31,6 +35,7 @@ class LocalNotificationPoller @Inject constructor( val scope = eligibleScope() val enabledChannels = pushRepository.channelPreferences().getOrThrow() val page = notifications.page(0).getOrThrow() + pushDiagnostics.record(scope, PushDiagnosticEvent.LocalPollChecked) val state = seenStore.state(scope) val decision = decideLocalNotifications( items = page.items, @@ -39,9 +44,54 @@ class LocalNotificationPoller @Inject constructor( enabledChannels = enabledChannels, ) if (decision.keysToRecord.isEmpty() && decision.notified.isEmpty()) return 0 - seenStore.record(scope, decision.keysToRecord, seeded = true) - decision.notified.forEach(notifier::notifySubscription) - return decision.notified.size + val unpostedKeys = mutableSetOf() + var postedCount = 0 + decision.notified.forEach { item -> + when (notifier.notifySubscription(item)) { + NotificationPostResult.Posted -> { + postedCount += 1 + pushDiagnostics.record(scope, PushDiagnosticEvent.LocalNotificationPosted) + } + NotificationPostResult.NotificationsDisabled -> { + unpostedKeys += item.localNotificationKey() + pushDiagnostics.record( + scope, + PushDiagnosticEvent.LocalNotificationSuppressed, + failureCode = "notifications_disabled", + ) + } + NotificationPostResult.ChannelBlocked -> { + unpostedKeys += item.localNotificationKey() + pushDiagnostics.record( + scope, + PushDiagnosticEvent.LocalNotificationSuppressed, + failureCode = "channel_blocked", + ) + } + NotificationPostResult.PermissionDenied -> { + unpostedKeys += item.localNotificationKey() + pushDiagnostics.record( + scope, + PushDiagnosticEvent.LocalNotificationSuppressed, + failureCode = "permission_denied", + ) + } + NotificationPostResult.Failed -> { + unpostedKeys += item.localNotificationKey() + pushDiagnostics.record( + scope, + PushDiagnosticEvent.LocalNotificationFailed, + failureCode = "notification_manager_unavailable", + ) + } + } + } + seenStore.record( + scope, + decision.keysToPersist(unpostedKeys), + seeded = true, + ) + return postedCount } private suspend fun eligibleScope(): AccountScope { diff --git a/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationWorker.kt b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationWorker.kt index 895f7afb..8ffa4479 100644 --- a/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationWorker.kt +++ b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationWorker.kt @@ -6,7 +6,12 @@ import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import dev.typetype.android.data.account.ActiveAccountScope +import dev.typetype.android.data.diagnostics.PushDiagnosticEvent +import dev.typetype.android.data.diagnostics.PushDiagnosticRecorder +import dev.typetype.android.services.push.NotificationPostResult import dev.typetype.android.services.push.PushNotifier +import kotlinx.coroutines.flow.first @HiltWorker class LocalNotificationWorker @AssistedInject constructor( @@ -14,9 +19,22 @@ class LocalNotificationWorker @AssistedInject constructor( @Assisted parameters: WorkerParameters, private val poller: LocalNotificationPoller, private val notifier: PushNotifier, + private val activeAccountScope: ActiveAccountScope, + private val pushDiagnostics: PushDiagnosticRecorder, ) : CoroutineWorker(context, parameters) { override suspend fun doWork(): Result { - if (!notifier.canNotify()) return Result.success() + val blockedReason = notifier.blockedReason() + if (blockedReason != null) { + val scope = activeAccountScope.observe().first() + scope?.let { + pushDiagnostics.record( + it, + PushDiagnosticEvent.LocalNotificationSuppressed, + failureCode = blockedReason.failureCode(), + ) + } + return Result.success() + } return poller.poll().fold( onSuccess = { Result.success() }, onFailure = { failure -> @@ -24,4 +42,12 @@ class LocalNotificationWorker @AssistedInject constructor( }, ) } + + private fun NotificationPostResult.failureCode(): String = when (this) { + NotificationPostResult.NotificationsDisabled -> "notifications_disabled" + NotificationPostResult.ChannelBlocked -> "channel_blocked" + NotificationPostResult.PermissionDenied -> "permission_denied" + NotificationPostResult.Failed -> "notification_manager_unavailable" + NotificationPostResult.Posted -> "notification_available" + } } diff --git a/app/src/test/java/dev/typetype/android/data/notifications/LocalNotificationDecisionTest.kt b/app/src/test/java/dev/typetype/android/data/notifications/LocalNotificationDecisionTest.kt index fea55273..f378eb31 100644 --- a/app/src/test/java/dev/typetype/android/data/notifications/LocalNotificationDecisionTest.kt +++ b/app/src/test/java/dev/typetype/android/data/notifications/LocalNotificationDecisionTest.kt @@ -97,6 +97,22 @@ class LocalNotificationDecisionTest { assertEquals(listOf("older", "newest"), decision.notified.map { it.video.id }) } + @Test + fun `failed notifications stay eligible for the next poll`() { + val decision = decideLocalNotifications( + items = listOf( + item(videoId = "posted", channelUrl = CHANNEL), + item(videoId = "failed", channelUrl = CHANNEL), + item(videoId = "silent", channelUrl = OTHER_CHANNEL), + ), + knownKeys = emptySet(), + seeded = true, + enabledChannels = listOf(ChannelNotificationsPreference(CHANNEL, enabled = true)), + ) + + assertEquals(listOf("posted", "silent"), decision.keysToPersist(setOf("failed"))) + } + private fun item(videoId: String, channelUrl: String) = NotificationItem( type = "subscription_new_video", title = "Video $videoId", From 2fe3c8861173d57df9115479d2bd7d177547f033 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 21 Oct 2026 15:09:54 +0200 Subject: [PATCH 05/67] feat: classify incoming push messages --- .../push/PushMessageClassification.kt | 32 +++++++ .../push/PushMessageClassificationTest.kt | 94 +++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 app/src/main/java/dev/typetype/android/services/push/PushMessageClassification.kt create mode 100644 app/src/test/java/dev/typetype/android/services/push/PushMessageClassificationTest.kt diff --git a/app/src/main/java/dev/typetype/android/services/push/PushMessageClassification.kt b/app/src/main/java/dev/typetype/android/services/push/PushMessageClassification.kt new file mode 100644 index 00000000..13396b8d --- /dev/null +++ b/app/src/main/java/dev/typetype/android/services/push/PushMessageClassification.kt @@ -0,0 +1,32 @@ +package dev.typetype.android.services.push + +import dev.typetype.android.data.account.AccountScope +import dev.typetype.android.data.push.scopeFromInstanceName +import dev.typetype.android.domain.push.PushPayload +import dev.typetype.android.domain.push.parsePushPayload + +internal sealed interface PushMessageClassification { + data class Accepted(val scope: AccountScope, val payload: PushPayload) : PushMessageClassification + + data class Rejected(val scope: AccountScope?, val failureCode: String) : PushMessageClassification +} + +internal fun classifyPushMessage(instance: String, content: ByteArray): PushMessageClassification { + val scope = scopeFromInstanceName(instance) + ?: return PushMessageClassification.Rejected(null, "unknown_instance") + val payload = parsePushPayload(content) + ?: return PushMessageClassification.Rejected(scope, "invalid_payload") + if (payload.version != SUPPORTED_PAYLOAD_VERSION) { + return PushMessageClassification.Rejected(scope, "unsupported_payload_version") + } + if (payload.eventType != NEW_VIDEO_EVENT) { + return PushMessageClassification.Rejected(scope, "unsupported_event_type") + } + if (payload.accountId != scope.accountId) { + return PushMessageClassification.Rejected(scope, "account_mismatch") + } + return PushMessageClassification.Accepted(scope, payload) +} + +private const val SUPPORTED_PAYLOAD_VERSION = 1 +private const val NEW_VIDEO_EVENT = "subscription_new_video" diff --git a/app/src/test/java/dev/typetype/android/services/push/PushMessageClassificationTest.kt b/app/src/test/java/dev/typetype/android/services/push/PushMessageClassificationTest.kt new file mode 100644 index 00000000..5c3ba812 --- /dev/null +++ b/app/src/test/java/dev/typetype/android/services/push/PushMessageClassificationTest.kt @@ -0,0 +1,94 @@ +package dev.typetype.android.services.push + +import dev.typetype.android.data.account.AccountScope +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class PushMessageClassificationTest { + @Test + fun `accepts only supported event for registered instance scope`() { + val result = classifyPushMessage("server-id:account-id", payload(EVENT_TYPE).toByteArray()) + + assertTrue(result is PushMessageClassification.Accepted) + val accepted = result as PushMessageClassification.Accepted + assertEquals(AccountScope("server-id", "account-id"), accepted.scope) + assertEquals(EVENT_TYPE, accepted.payload.eventType) + assertEquals(EVENT_ID, accepted.payload.eventId) + } + + @Test + fun `rejects malformed payload without losing instance scope`() { + val result = classifyPushMessage("server-id:account-id", "not-json".toByteArray()) + + assertTrue(result is PushMessageClassification.Rejected) + val rejected = result as PushMessageClassification.Rejected + assertEquals(AccountScope("server-id", "account-id"), rejected.scope) + assertEquals("invalid_payload", rejected.failureCode) + } + + @Test + fun `rejects unsupported event types`() { + val result = classifyPushMessage("server-id:account-id", payload("unknown_event").toByteArray()) + + assertTrue(result is PushMessageClassification.Rejected) + assertEquals("unsupported_event_type", (result as PushMessageClassification.Rejected).failureCode) + } + + @Test + fun `rejects payload from a different account`() { + val result = classifyPushMessage( + "server-id:account-id", + payload(EVENT_TYPE, accountId = "other-account").toByteArray(), + ) + + assertTrue(result is PushMessageClassification.Rejected) + assertEquals("account_mismatch", (result as PushMessageClassification.Rejected).failureCode) + } + + @Test + fun `rejects unsupported payload versions`() { + val result = classifyPushMessage( + "server-id:account-id", + payload(EVENT_TYPE, version = 2).toByteArray(), + ) + + assertTrue(result is PushMessageClassification.Rejected) + assertEquals("unsupported_payload_version", (result as PushMessageClassification.Rejected).failureCode) + } + + @Test + fun `rejects unknown instance without assigning another account`() { + val result = classifyPushMessage("malformed-instance", payload(EVENT_TYPE).toByteArray()) + + assertTrue(result is PushMessageClassification.Rejected) + val rejected = result as PushMessageClassification.Rejected + assertNull(rejected.scope) + assertEquals("unknown_instance", rejected.failureCode) + } + + private fun payload(eventType: String, accountId: String = "account-id", version: Int = 1) = """ + { + "version": $version, + "eventType": "$eventType", + "serviceId": 0, + "serviceName": "YouTube", + "eventId": "$EVENT_ID", + "videoId": "video-id", + "videoUrl": "https://www.youtube.com/watch?v=video-id", + "channelId": "channel-id", + "channelName": "Channel", + "channelAvatarUrl": "", + "instanceId": "server-instance", + "accountId": "$accountId", + "publishedAt": 1760000000000, + "title": "New video" + } + """.trimIndent() + + private companion object { + const val EVENT_TYPE = "subscription_new_video" + const val EVENT_ID = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + } +} From 4f31d5e7f83525443977159cf130ddc636f4eb0d Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 21 Oct 2026 15:09:59 +0200 Subject: [PATCH 06/67] feat: trace push delivery outcomes --- .../services/push/PushNotifierAndroidTest.kt | 3 +- .../diagnostics/LocalDiagnosticsRepository.kt | 51 ++++++++- .../diagnostics/PushDiagnosticRecorder.kt | 69 +++++++++++ .../domain/diagnostics/DiagnosticEntry.kt | 1 + .../android/services/push/PushNotifier.kt | 83 +++++++++----- .../services/push/TypeTypePushService.kt | 107 +++++++++++++++++- 6 files changed, 276 insertions(+), 38 deletions(-) create mode 100644 app/src/main/java/dev/typetype/android/data/diagnostics/PushDiagnosticRecorder.kt diff --git a/app/src/androidTest/java/dev/typetype/android/services/push/PushNotifierAndroidTest.kt b/app/src/androidTest/java/dev/typetype/android/services/push/PushNotifierAndroidTest.kt index 6e9e9a12..883e48c1 100644 --- a/app/src/androidTest/java/dev/typetype/android/services/push/PushNotifierAndroidTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/services/push/PushNotifierAndroidTest.kt @@ -39,11 +39,12 @@ class PushNotifierAndroidTest { val notificationId = subscriptionNotificationId(payload.videoId, payload.videoUrl, payload.eventId) try { - PushNotifier(context).notify(payload) + val result = PushNotifier(context).notify(payload) val notification = manager.activeNotifications .firstOrNull { record -> record.id == notificationId } ?.notification + assertEquals(NotificationPostResult.Posted, result) assertNotNull(notification) assertEquals( payload.channelName, diff --git a/app/src/main/java/dev/typetype/android/data/diagnostics/LocalDiagnosticsRepository.kt b/app/src/main/java/dev/typetype/android/data/diagnostics/LocalDiagnosticsRepository.kt index 850037e9..3c026b0b 100644 --- a/app/src/main/java/dev/typetype/android/data/diagnostics/LocalDiagnosticsRepository.kt +++ b/app/src/main/java/dev/typetype/android/data/diagnostics/LocalDiagnosticsRepository.kt @@ -101,9 +101,32 @@ class LocalDiagnosticsRepository @Inject constructor( statusCode: Int? = null, requestId: String? = null, failureCode: String? = null, + traceId: String? = null, ) { - if (route !in LOCAL_EVENT_ROUTES) return val scope = currentScope()?.copy(route = route) ?: return + recordLocalEvent( + serverId = scope.serverId, + accountId = scope.accountId, + route = route, + timestampEpochMillis = timestampEpochMillis, + statusCode = statusCode, + requestId = requestId, + failureCode = failureCode, + traceId = traceId, + ) + } + + internal fun recordLocalEvent( + serverId: String, + accountId: String, + route: String, + timestampEpochMillis: Long = System.currentTimeMillis(), + statusCode: Int? = null, + requestId: String? = null, + failureCode: String? = null, + traceId: String? = null, + ) { + if (serverId.isBlank() || accountId.isBlank() || route !in LOCAL_EVENT_ROUTES) return val entry = DiagnosticEntry( timestampEpochMillis = timestampEpochMillis, method = LOCAL_METHOD, @@ -112,10 +135,11 @@ class LocalDiagnosticsRepository @Inject constructor( durationMillis = 0, requestId = requestId?.takeIf(REQUEST_ID_PATTERN::matches), failureCode = failureCode?.takeIf(FAILURE_CODE_PATTERN::matches), + traceId = traceId?.takeIf(TRACE_ID_PATTERN::matches), ) synchronized(lock) { directory.mkdirs() - val file = fileFor(scope) + val file = fileFor(DiagnosticScope(serverId, accountId, route)) file.appendText(encode(entry) + "\n", Charsets.UTF_8) if (file.length() > MAX_FILE_BYTES) trim(file) } @@ -168,11 +192,12 @@ class LocalDiagnosticsRepository @Inject constructor( entry.requestId.orEmpty(), entry.sabr?.let(SabrDiagnosticDetailCodec::encode).orEmpty(), entry.failureCode.orEmpty(), + entry.traceId.orEmpty(), ).joinToString("\t") private fun decode(line: String): DiagnosticEntry? { - val fields = line.split('\t', limit = 8) - if (fields.size !in 6..8) return null + val fields = line.split('\t', limit = 9) + if (fields.size !in 6..9) return null val route = fields[2].takeIf { it.startsWith('/') && it.length <= 40 } ?: return null return DiagnosticEntry( timestampEpochMillis = fields[0].toLongOrNull() ?: return null, @@ -189,6 +214,7 @@ class LocalDiagnosticsRepository @Inject constructor( ?.takeIf(String::isNotEmpty) ?.let(SabrDiagnosticDetailCodec::decode), failureCode = fields.getOrNull(7)?.takeIf(FAILURE_CODE_PATTERN::matches), + traceId = fields.getOrNull(8)?.takeIf(TRACE_ID_PATTERN::matches), ) } @@ -216,6 +242,7 @@ class LocalDiagnosticsRepository @Inject constructor( val ALLOWED_METHODS = setOf("DELETE", "GET", "PATCH", "POST", "PUT") val REQUEST_ID_PATTERN = Regex("[A-Za-z0-9_-]{8,64}") val FAILURE_CODE_PATTERN = Regex("[a-z0-9_]{3,64}") + val TRACE_ID_PATTERN = Regex("[A-Fa-f0-9]{8,64}") const val LOCAL_METHOD = "LOCAL" const val LEGACY_APPLICATION_METHOD = "APP" val LOCAL_EVENT_ROUTES = setOf( @@ -234,6 +261,22 @@ class LocalDiagnosticsRepository @Inject constructor( "/subscriptions/feed/persistence", "/subscriptions/feed/ready", "/subscriptions/feed/server", + "/push/instance/invalid", + "/push/registration/requested", + "/push/registration/endpoint", + "/push/registration/registered", + "/push/registration/failed", + "/push/registration/disabled", + "/push/message/received", + "/push/message/invalid", + "/push/notification/posted", + "/push/notification/suppressed", + "/push/notification/failed", + "/push/distributor/unavailable", + "/notifications/local/checked", + "/notifications/local/posted", + "/notifications/local/suppressed", + "/notifications/local/failed", ) } } diff --git a/app/src/main/java/dev/typetype/android/data/diagnostics/PushDiagnosticRecorder.kt b/app/src/main/java/dev/typetype/android/data/diagnostics/PushDiagnosticRecorder.kt new file mode 100644 index 00000000..2f9288c0 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/data/diagnostics/PushDiagnosticRecorder.kt @@ -0,0 +1,69 @@ +package dev.typetype.android.data.diagnostics + +import dev.typetype.android.data.account.AccountScope +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PushDiagnosticRecorder @Inject constructor( + private val diagnostics: LocalDiagnosticsRepository, +) { + fun record( + scope: AccountScope, + event: PushDiagnosticEvent, + failureCode: String? = null, + traceId: String? = null, + ) { + try { + diagnostics.recordLocalEvent( + serverId = scope.serverId, + accountId = scope.accountId, + route = event.route, + failureCode = failureCode, + traceId = traceId, + ) + } catch (_: Exception) { + return + } + } + + fun recordCurrent( + event: PushDiagnosticEvent, + failureCode: String? = null, + ) { + try { + diagnostics.recordLocalEvent( + route = event.route, + failureCode = failureCode, + ) + } catch (_: Exception) { + return + } + } + + fun traceId(eventId: String): String? = + eventId.takeIf(EVENT_ID_PATTERN::matches)?.take(16) + + private companion object { + val EVENT_ID_PATTERN = Regex("[a-fA-F0-9]{64}") + } +} + +enum class PushDiagnosticEvent(val route: String) { + InstanceInvalid("/push/instance/invalid"), + RegistrationRequested("/push/registration/requested"), + EndpointReceived("/push/registration/endpoint"), + RegistrationActive("/push/registration/registered"), + RegistrationFailed("/push/registration/failed"), + RegistrationDisabled("/push/registration/disabled"), + MessageReceived("/push/message/received"), + MessageInvalid("/push/message/invalid"), + NotificationPosted("/push/notification/posted"), + NotificationSuppressed("/push/notification/suppressed"), + NotificationFailed("/push/notification/failed"), + DistributorUnavailable("/push/distributor/unavailable"), + LocalPollChecked("/notifications/local/checked"), + LocalNotificationPosted("/notifications/local/posted"), + LocalNotificationSuppressed("/notifications/local/suppressed"), + LocalNotificationFailed("/notifications/local/failed"), +} diff --git a/app/src/main/java/dev/typetype/android/domain/diagnostics/DiagnosticEntry.kt b/app/src/main/java/dev/typetype/android/domain/diagnostics/DiagnosticEntry.kt index ff85cf22..1abe47de 100644 --- a/app/src/main/java/dev/typetype/android/domain/diagnostics/DiagnosticEntry.kt +++ b/app/src/main/java/dev/typetype/android/domain/diagnostics/DiagnosticEntry.kt @@ -9,4 +9,5 @@ data class DiagnosticEntry( val requestId: String?, val sabr: SabrDiagnosticDetail? = null, val failureCode: String? = null, + val traceId: String? = null, ) diff --git a/app/src/main/java/dev/typetype/android/services/push/PushNotifier.kt b/app/src/main/java/dev/typetype/android/services/push/PushNotifier.kt index ef8bf960..2a9f18eb 100644 --- a/app/src/main/java/dev/typetype/android/services/push/PushNotifier.kt +++ b/app/src/main/java/dev/typetype/android/services/push/PushNotifier.kt @@ -16,7 +16,6 @@ import dev.typetype.android.R import dev.typetype.android.domain.navigation.toPublicWatchParameter import dev.typetype.android.domain.notifications.NotificationItem import dev.typetype.android.domain.push.PushPayload -import dev.typetype.android.domain.push.parsePushPayload import javax.inject.Inject import javax.inject.Singleton @@ -24,41 +23,61 @@ import javax.inject.Singleton class PushNotifier @Inject constructor( @ApplicationContext private val context: Context, ) { - fun canNotify(): Boolean = NotificationManagerCompat.from(context).areNotificationsEnabled() + fun canNotify(): Boolean = blockedReason() == null - fun notify(payload: PushPayload) { - show( - title = payload.channelName.ifBlank { payload.serviceName }, - text = payload.title, - videoUrl = payload.videoUrl, - notificationId = subscriptionNotificationId( - videoId = payload.videoId, - videoUrl = payload.videoUrl, - fallback = payload.eventId, - ), - ) + fun blockedReason(): NotificationPostResult? = when { + !NotificationManagerCompat.from(context).areNotificationsEnabled() -> + NotificationPostResult.NotificationsDisabled + isChannelBlocked() -> NotificationPostResult.ChannelBlocked + else -> null } - fun notifySubscription(item: NotificationItem) { - show( - title = item.channelName.ifBlank { item.video.uploaderName }, - text = item.title, + fun notify(payload: PushPayload): NotificationPostResult = show( + title = payload.channelName.ifBlank { payload.serviceName }, + text = payload.title, + videoUrl = payload.videoUrl, + notificationId = subscriptionNotificationId( + videoId = payload.videoId, + videoUrl = payload.videoUrl, + fallback = payload.eventId, + ), + ) + + fun notifySubscription(item: NotificationItem): NotificationPostResult = show( + title = item.channelName.ifBlank { item.video.uploaderName }, + text = item.title, + videoUrl = item.video.url, + notificationId = subscriptionNotificationId( + videoId = item.video.id, videoUrl = item.video.url, - notificationId = subscriptionNotificationId( - videoId = item.video.id, - videoUrl = item.video.url, - fallback = item.video.url, - ), - ) - } + fallback = item.video.url, + ), + ) - private fun show(title: String, text: String, videoUrl: String, notificationId: Int) { - val manager = context.getSystemService(NotificationManager::class.java) ?: return - if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) return + private fun show( + title: String, + text: String, + videoUrl: String, + notificationId: Int, + ): NotificationPostResult { + val manager = context.getSystemService(NotificationManager::class.java) + ?: return NotificationPostResult.Failed if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { manager.createNotificationChannel(notificationChannel()) } - manager.notify(notificationId, buildNotification(title, text, videoUrl, notificationId)) + blockedReason()?.let { return it } + return try { + manager.notify(notificationId, buildNotification(title, text, videoUrl, notificationId)) + NotificationPostResult.Posted + } catch (_: SecurityException) { + NotificationPostResult.PermissionDenied + } + } + + private fun isChannelBlocked(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false + val manager = context.getSystemService(NotificationManager::class.java) ?: return false + return manager.getNotificationChannel(CHANNEL_ID)?.importance == NotificationManager.IMPORTANCE_NONE } private fun buildNotification( @@ -100,5 +119,13 @@ class PushNotifier @Inject constructor( } } +enum class NotificationPostResult { + Posted, + NotificationsDisabled, + ChannelBlocked, + PermissionDenied, + Failed, +} + internal fun subscriptionNotificationId(videoId: String, videoUrl: String, fallback: String): Int = videoId.trim().ifBlank { videoUrl.trim() }.ifBlank { fallback.trim() }.hashCode() diff --git a/app/src/main/java/dev/typetype/android/services/push/TypeTypePushService.kt b/app/src/main/java/dev/typetype/android/services/push/TypeTypePushService.kt index d7191236..a14db10b 100644 --- a/app/src/main/java/dev/typetype/android/services/push/TypeTypePushService.kt +++ b/app/src/main/java/dev/typetype/android/services/push/TypeTypePushService.kt @@ -1,7 +1,11 @@ package dev.typetype.android.services.push import dagger.hilt.android.AndroidEntryPoint -import dev.typetype.android.domain.push.parsePushPayload +import dev.typetype.android.data.account.AccountScope +import dev.typetype.android.data.diagnostics.PushDiagnosticEvent +import dev.typetype.android.data.diagnostics.PushDiagnosticRecorder +import dev.typetype.android.data.push.scopeFromInstanceName +import dev.typetype.android.domain.push.PushPayload import javax.inject.Inject import org.unifiedpush.android.connector.FailedReason import org.unifiedpush.android.connector.PushService @@ -14,25 +18,118 @@ class TypeTypePushService : PushService() { @Inject lateinit var notifier: PushNotifier + @Inject lateinit var pushDiagnostics: PushDiagnosticRecorder + override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) { + val scope = scopeFromInstanceName(instance) + if (scope == null) { + pushDiagnostics.recordCurrent(PushDiagnosticEvent.InstanceInvalid, "unknown_instance") + registrationManager.onRegistrationFailed(instance) + return + } val keySet = endpoint.pubKeySet - if (keySet == null) { + if (keySet == null || endpoint.url.isBlank() || keySet.pubKey.isBlank() || keySet.auth.isBlank()) { + pushDiagnostics.record( + scope, + PushDiagnosticEvent.RegistrationFailed, + failureCode = "endpoint_missing_keys", + ) registrationManager.onRegistrationFailed(instance) return } + pushDiagnostics.record(scope, PushDiagnosticEvent.EndpointReceived) registrationManager.onEndpointAvailable(instance, endpoint.url, keySet.pubKey, keySet.auth) } override fun onMessage(message: PushMessage, instance: String) { - val payload = parsePushPayload(message.content) ?: return - notifier.notify(payload) + when (val classification = classifyPushMessage(instance, message.content)) { + is PushMessageClassification.Rejected -> { + val scope = classification.scope + if (scope == null) { + pushDiagnostics.recordCurrent(PushDiagnosticEvent.InstanceInvalid, classification.failureCode) + } else { + pushDiagnostics.record(scope, PushDiagnosticEvent.MessageReceived) + pushDiagnostics.record( + scope, + PushDiagnosticEvent.MessageInvalid, + failureCode = classification.failureCode, + ) + } + } + is PushMessageClassification.Accepted -> showNotification(classification.scope, classification.payload) + } + } + + private fun showNotification( + scope: AccountScope, + payload: PushPayload, + ) { + pushDiagnostics.record(scope, PushDiagnosticEvent.MessageReceived) + val traceId = pushDiagnostics.traceId(payload.eventId) + val result = try { + notifier.notify(payload) + } catch (_: Exception) { + NotificationPostResult.Failed + } + when (result) { + NotificationPostResult.Posted -> pushDiagnostics.record( + scope, + PushDiagnosticEvent.NotificationPosted, + traceId = traceId, + ) + NotificationPostResult.NotificationsDisabled -> pushDiagnostics.record( + scope, + PushDiagnosticEvent.NotificationSuppressed, + failureCode = "notifications_disabled", + traceId = traceId, + ) + NotificationPostResult.ChannelBlocked -> pushDiagnostics.record( + scope, + PushDiagnosticEvent.NotificationSuppressed, + failureCode = "channel_blocked", + traceId = traceId, + ) + NotificationPostResult.PermissionDenied -> pushDiagnostics.record( + scope, + PushDiagnosticEvent.NotificationSuppressed, + failureCode = "permission_denied", + traceId = traceId, + ) + NotificationPostResult.Failed -> pushDiagnostics.record( + scope, + PushDiagnosticEvent.NotificationFailed, + failureCode = "notification_manager_unavailable", + traceId = traceId, + ) + } } override fun onRegistrationFailed(reason: FailedReason, instance: String) { - registrationManager.onRegistrationFailed(instance) + registrationManager.onRegistrationFailed(instance, reason.diagnosticFailureCode()) + } + + override fun onTempUnavailable(instance: String) { + val scope = scopeFromInstanceName(instance) + if (scope == null) { + pushDiagnostics.recordCurrent(PushDiagnosticEvent.InstanceInvalid, "unknown_instance") + return + } + pushDiagnostics.record( + scope, + PushDiagnosticEvent.DistributorUnavailable, + failureCode = "distributor_temporarily_unavailable", + ) } override fun onUnregistered(instance: String) { registrationManager.onUnregistered(instance) } } + +internal fun FailedReason.diagnosticFailureCode(): String = when (name) { + "NETWORK" -> "unifiedpush_network" + "INTERNAL_ERROR" -> "unifiedpush_internal_error" + "ACTION_REQUIRED" -> "unifiedpush_action_required" + "VAPID_REQUIRED" -> "unifiedpush_vapid_required" + else -> "unifiedpush_registration_failed" +} From ad58c889a169659670a05cc11e0e71b68f3222f0 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 21 Oct 2026 15:10:03 +0200 Subject: [PATCH 07/67] feat: show push traces in diagnostics --- .../settings/diagnostics/DiagnosticReport.kt | 1 + .../settings/diagnostics/DiagnosticsScreen.kt | 51 ++++++++++++++++--- .../diagnostics/DiagnosticReportTest.kt | 19 +++++++ 3 files changed, 64 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/dev/typetype/android/feature/settings/diagnostics/DiagnosticReport.kt b/app/src/main/java/dev/typetype/android/feature/settings/diagnostics/DiagnosticReport.kt index 7cb00328..9e0c5f5f 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/diagnostics/DiagnosticReport.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/diagnostics/DiagnosticReport.kt @@ -25,6 +25,7 @@ fun buildDiagnosticReport(entries: List, crashReport: CrashRepo } entry.failureCode?.let { append(" code $it") } entry.requestId?.let { append(" request $it") } + entry.traceId?.let { append(" trace $it") } appendLine() entry.sabr?.let { appendLine(" SABR ${it.redactedSummary()}") diff --git a/app/src/main/java/dev/typetype/android/feature/settings/diagnostics/DiagnosticsScreen.kt b/app/src/main/java/dev/typetype/android/feature/settings/diagnostics/DiagnosticsScreen.kt index 371184c3..256a9549 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/diagnostics/DiagnosticsScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/diagnostics/DiagnosticsScreen.kt @@ -196,8 +196,11 @@ private fun DiagnosticRow(entry: DiagnosticEntry) { ApplicationDiagnosticRow(entry, formatter) return } - val outcome = entry.statusCode?.let { stringResource(R.string.diagnostics_http_status, it) } - ?: stringResource(R.string.diagnostics_network_error) + val outcome = when (entry.method) { + "LOCAL", "APP" -> stringResource(R.string.diagnostics_local_event) + else -> entry.statusCode?.let { stringResource(R.string.diagnostics_http_status, it) } + ?: stringResource(R.string.diagnostics_network_error) + } Column( modifier = Modifier .fillMaxWidth() @@ -211,11 +214,19 @@ private fun DiagnosticRow(entry: DiagnosticEntry) { fontWeight = FontWeight.SemiBold, fontFamily = FontFamily.Monospace, ) - Text( - text = stringResource(R.string.diagnostics_result, outcome, entry.durationMillis), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + if (entry.method == "LOCAL" || entry.method == "APP") { + Text( + text = outcome, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Text( + text = stringResource(R.string.diagnostics_result, outcome, entry.durationMillis), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } Text( text = formatter.format(Date(entry.timestampEpochMillis)), style = MaterialTheme.typography.labelSmall, @@ -263,6 +274,32 @@ private fun DiagnosticRow(entry: DiagnosticEntry) { } } } + entry.traceId?.let { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.diagnostics_trace_id, it), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace, + modifier = Modifier.weight(1f), + ) + IconButton( + onClick = { + copyPlainText( + context = context, + value = it, + labelRes = R.string.diagnostics_clipboard_label, + confirmationRes = R.string.diagnostics_trace_id_copied, + ) + }, + ) { + Icon( + imageVector = Icons.Filled.ContentCopy, + contentDescription = stringResource(R.string.diagnostics_copy_trace_id), + ) + } + } + } } } diff --git a/app/src/test/java/dev/typetype/android/feature/settings/diagnostics/DiagnosticReportTest.kt b/app/src/test/java/dev/typetype/android/feature/settings/diagnostics/DiagnosticReportTest.kt index 287514f6..cf0e08b0 100644 --- a/app/src/test/java/dev/typetype/android/feature/settings/diagnostics/DiagnosticReportTest.kt +++ b/app/src/test/java/dev/typetype/android/feature/settings/diagnostics/DiagnosticReportTest.kt @@ -25,6 +25,25 @@ class DiagnosticReportTest { assertTrue(report.contains("request safe-request-id")) } + @Test + fun reportIncludesRedactedPushTraceId() { + val entry = DiagnosticEntry( + timestampEpochMillis = 1_700_000_000_000, + method = "LOCAL", + route = "/push/notification/posted", + statusCode = null, + durationMillis = 0, + requestId = null, + traceId = "abcdef0123456789", + ) + + val report = buildDiagnosticReport(listOf(entry)) + + assertTrue(report.contains("LOCAL /push/notification/posted")) + assertTrue(report.contains("trace abcdef0123456789")) + assertFalse(report.contains("notifications disabled")) + } + @Test fun reportContainsOnlyRedactedSabrMetadata() { val entry = DiagnosticEntry( From 9f9f0c616f7548d6847a70d662e36d9ff389c9a6 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 22 Oct 2026 19:20:50 +0200 Subject: [PATCH 08/67] fix: hide unknown server counts --- .../components/DescriptionSectionCountTest.kt | 51 +++++++++++++++++++ .../android/core/ui/util/KnownCount.kt | 3 ++ .../android/feature/channel/ChannelHeader.kt | 15 +++--- .../player/components/DescriptionSection.kt | 23 +++++---- .../feature/search/SearchResultCards.kt | 12 ++--- .../search/SearchPlaylistPresentationTest.kt | 10 +++- 6 files changed, 90 insertions(+), 24 deletions(-) create mode 100644 app/src/androidTest/java/dev/typetype/android/feature/player/components/DescriptionSectionCountTest.kt create mode 100644 app/src/main/java/dev/typetype/android/core/ui/util/KnownCount.kt diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/components/DescriptionSectionCountTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/components/DescriptionSectionCountTest.kt new file mode 100644 index 00000000..7ffd6f7b --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/components/DescriptionSectionCountTest.kt @@ -0,0 +1,51 @@ +package dev.typetype.android.feature.player.components + +import androidx.activity.ComponentActivity +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import dev.typetype.android.R +import org.junit.Rule +import org.junit.Test + +class DescriptionSectionCountTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun unknownViewCountIsHidden() { + setDescription(viewCount = -1) + + composeRule.onNodeWithText("-1").assertDoesNotExist() + composeRule.onNodeWithContentDescription(viewsLabel("-1")).assertDoesNotExist() + } + + @Test + fun zeroViewCountRemainsVisible() { + setDescription(viewCount = 0) + + composeRule.onNodeWithText("0").assertIsDisplayed() + composeRule.onNodeWithContentDescription(viewsLabel("0")).assertIsDisplayed() + } + + private fun setDescription(viewCount: Long) { + composeRule.setContent { + MaterialTheme { + DescriptionSection( + title = "Test video", + viewCount = viewCount, + likeCount = 0, + releaseDateMillis = 0, + description = "", + videoUrl = "https://www.youtube.com/watch?v=test", + onTimestampClick = {}, + ) + } + } + } + + private fun viewsLabel(count: String): String = + composeRule.activity.getString(R.string.player_views_count, count) +} diff --git a/app/src/main/java/dev/typetype/android/core/ui/util/KnownCount.kt b/app/src/main/java/dev/typetype/android/core/ui/util/KnownCount.kt new file mode 100644 index 00000000..a8b30314 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/core/ui/util/KnownCount.kt @@ -0,0 +1,3 @@ +package dev.typetype.android.core.ui.util + +internal fun Long.knownCountOrNull(): Long? = takeIf { it >= 0L } diff --git a/app/src/main/java/dev/typetype/android/feature/channel/ChannelHeader.kt b/app/src/main/java/dev/typetype/android/feature/channel/ChannelHeader.kt index da9f519b..eda2f364 100644 --- a/app/src/main/java/dev/typetype/android/feature/channel/ChannelHeader.kt +++ b/app/src/main/java/dev/typetype/android/feature/channel/ChannelHeader.kt @@ -49,6 +49,7 @@ import coil3.compose.AsyncImage import dev.typetype.android.R import dev.typetype.android.core.ui.share.LocalServerBaseUrl import dev.typetype.android.core.ui.share.buildImageUrl +import dev.typetype.android.core.ui.util.knownCountOrNull import dev.typetype.android.domain.channel.Channel private val BannerAspectRatio = 16f / 6f @@ -150,12 +151,14 @@ private fun ChannelHeaderContent( ) Column(modifier = Modifier.weight(1f)) { ChannelName(channel, expanded) - Spacer(Modifier.height(2.dp)) - Text( - text = formatSubscribers(channel.subscriberCount), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + channel.subscriberCount.knownCountOrNull()?.let { subscriberCount -> + Spacer(Modifier.height(2.dp)) + Text( + text = formatSubscribers(subscriberCount), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } if (notificationsAvailable) { IconButton( diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/DescriptionSection.kt b/app/src/main/java/dev/typetype/android/feature/player/components/DescriptionSection.kt index d362859f..84d773f2 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/DescriptionSection.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/DescriptionSection.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.compose.ui.unit.sp +import dev.typetype.android.core.ui.util.knownCountOrNull import dev.typetype.android.domain.navigation.sameVideoTimestampMillis import java.text.DateFormat import java.util.Date @@ -86,17 +87,20 @@ fun DescriptionSection( Spacer(Modifier.height(6.dp)) Row( verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), ) { - VideoStat( - value = formatCompact(viewCount), - icon = Icons.Outlined.Visibility, - contentDescription = stringResource( - dev.typetype.android.R.string.player_views_count, - formatCompact(viewCount), - ), - ) + viewCount.knownCountOrNull()?.let { knownViews -> + val formattedViews = formatCompact(knownViews) + VideoStat( + value = formattedViews, + icon = Icons.Outlined.Visibility, + contentDescription = stringResource( + dev.typetype.android.R.string.player_views_count, + formattedViews, + ), + ) + } if (likeCount > 0) { - Spacer(Modifier.width(16.dp)) VideoStat( value = formatCompact(likeCount), icon = Icons.Outlined.ThumbUp, @@ -107,7 +111,6 @@ fun DescriptionSection( ) } releaseDate?.let { - Spacer(Modifier.width(16.dp)) VideoStat( value = it, icon = Icons.Outlined.Event, diff --git a/app/src/main/java/dev/typetype/android/feature/search/SearchResultCards.kt b/app/src/main/java/dev/typetype/android/feature/search/SearchResultCards.kt index 5f560242..b7248e33 100644 --- a/app/src/main/java/dev/typetype/android/feature/search/SearchResultCards.kt +++ b/app/src/main/java/dev/typetype/android/feature/search/SearchResultCards.kt @@ -34,6 +34,7 @@ import coil3.compose.AsyncImage import dev.typetype.android.R import dev.typetype.android.core.ui.share.LocalServerBaseUrl import dev.typetype.android.core.ui.share.buildImageUrl +import dev.typetype.android.core.ui.util.knownCountOrNull import dev.typetype.android.domain.search.SearchChannel import dev.typetype.android.domain.search.SearchPlaylist import java.text.NumberFormat @@ -80,16 +81,17 @@ fun SearchChannelCard( } Text( text = when { - channel.subscriberCount >= 0 && channel.streamCount >= 0 -> stringResource( + channel.subscriberCount.knownCountOrNull() != null && + channel.streamCount.knownCountOrNull() != null -> stringResource( R.string.search_channel_metadata, formatCount(channel.subscriberCount), formatCount(channel.streamCount), ) - channel.subscriberCount >= 0 -> stringResource( + channel.subscriberCount.knownCountOrNull() != null -> stringResource( R.string.search_channel_metadata_subscribers_only, formatCount(channel.subscriberCount), ) - channel.streamCount >= 0 -> stringResource( + channel.streamCount.knownCountOrNull() != null -> stringResource( R.string.search_channel_videos_count, formatCount(channel.streamCount), ) @@ -118,7 +120,7 @@ fun SearchPlaylistCard( modifier: Modifier = Modifier, ) { val serverBaseUrl = LocalServerBaseUrl.current - val countLabel = displayablePlaylistStreamCount(playlist.streamCount)?.let { + val countLabel = playlist.streamCount.knownCountOrNull()?.let { stringResource(R.string.search_playlist_video_count, formatCount(it)) } ?: stringResource(R.string.search_playlist_type) Column( @@ -178,5 +180,3 @@ fun SearchPlaylistCard( } private fun formatCount(value: Long): String = NumberFormat.getIntegerInstance().format(value) - -internal fun displayablePlaylistStreamCount(value: Long): Long? = value.takeIf { it >= 0L } diff --git a/app/src/test/java/dev/typetype/android/feature/search/SearchPlaylistPresentationTest.kt b/app/src/test/java/dev/typetype/android/feature/search/SearchPlaylistPresentationTest.kt index 6f28cdad..17fc5992 100644 --- a/app/src/test/java/dev/typetype/android/feature/search/SearchPlaylistPresentationTest.kt +++ b/app/src/test/java/dev/typetype/android/feature/search/SearchPlaylistPresentationTest.kt @@ -1,5 +1,6 @@ package dev.typetype.android.feature.search +import dev.typetype.android.core.ui.util.knownCountOrNull import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test @@ -7,11 +8,16 @@ import org.junit.Test class SearchPlaylistPresentationTest { @Test fun `negative upstream sentinel is not presented as a video count`() { - assertNull(displayablePlaylistStreamCount(-2)) + assertNull((-2L).knownCountOrNull()) } @Test fun `known playlist count remains visible`() { - assertEquals(12L, displayablePlaylistStreamCount(12)) + assertEquals(12L, 12L.knownCountOrNull()) + } + + @Test + fun `zero remains a known count`() { + assertEquals(0L, 0L.knownCountOrNull()) } } From 9fab084d43330cce77f670befbc5de78a89cf190 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 22 Oct 2026 19:30:19 +0200 Subject: [PATCH 09/67] fix: retry empty related video metadata --- .../feature/player/PlayerStreamLoader.kt | 14 ++++++ .../feature/player/PlayerStreamLoaderTest.kt | 43 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerStreamLoader.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerStreamLoader.kt index b278f7ad..13fc2333 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlayerStreamLoader.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerStreamLoader.kt @@ -10,6 +10,7 @@ import dev.typetype.android.domain.stream.Stream import dev.typetype.android.domain.stream.StreamRepository import javax.inject.Inject import kotlinx.coroutines.async +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.launch @@ -96,6 +97,7 @@ internal fun loadProgressiveStream( loadChannelMetadata: suspend (Stream) -> Result? = { null }, loadProgress: suspend () -> Long, prefetchedMetadata: Stream? = null, + retryDelay: suspend (Long) -> Unit = { delay(it) }, ): Flow = channelFlow { val savedProgress = async { loadProgress() } val metadata = if (prefetchedMetadata == null) async { loadMetadata() } else null @@ -122,6 +124,17 @@ internal fun loadProgressiveStream( enriched = stream.withMetadataFrom(metadataStream) send(PlayerStreamUpdate.MetadataEnriched(enriched)) } + if (details != null && enriched.relatedStreams.isEmpty()) { + for (delayMillis in RELATED_METADATA_RETRY_DELAYS) { + retryDelay(delayMillis) + val retry = loadMetadata() ?: break + retry.onSuccess { metadataStream -> + enriched = enriched.withMetadataFrom(metadataStream) + send(PlayerStreamUpdate.MetadataEnriched(enriched)) + } + if (enriched.relatedStreams.isNotEmpty()) break + } + } if (enriched.needsChannelMetadata()) { loadChannelMetadata(enriched)?.onSuccess { channelStream -> send( @@ -156,3 +169,4 @@ private fun computeResumeMillis( private const val RESUME_MIN_MILLIS = 5_000L private const val RESUME_MAX_FRACTION = 0.95 +private val RELATED_METADATA_RETRY_DELAYS = longArrayOf(350L, 1_000L) diff --git a/app/src/test/java/dev/typetype/android/feature/player/PlayerStreamLoaderTest.kt b/app/src/test/java/dev/typetype/android/feature/player/PlayerStreamLoaderTest.kt index 89cab8ff..d51ae116 100644 --- a/app/src/test/java/dev/typetype/android/feature/player/PlayerStreamLoaderTest.kt +++ b/app/src/test/java/dev/typetype/android/feature/player/PlayerStreamLoaderTest.kt @@ -1,5 +1,6 @@ package dev.typetype.android.feature.player +import dev.typetype.android.domain.feed.Video import dev.typetype.android.domain.stream.Stream import dev.typetype.android.domain.stream.StreamPlaybackContract import dev.typetype.android.domain.stream.StreamRequestScope @@ -157,11 +158,35 @@ class PlayerStreamLoaderTest { assertEquals(1, metadataRequests.get()) } + @Test + fun `empty bootstrap related videos trigger metadata retries`() = runBlocking { + val metadataRequests = AtomicInteger() + val updates = loadProgressiveStream( + loadPlayback = { Result.success(stream("Bootstrap")) }, + loadMetadata = { + val request = metadataRequests.incrementAndGet() + val related = if (request == 1) { + emptyList() + } else { + listOf(relatedVideo()) + } + Result.success(stream("Detailed", related = related)) + }, + loadProgress = { 0L }, + retryDelay = {}, + ).take(3).toList() + + val retried = updates[2] as PlayerStreamUpdate.MetadataEnriched + assertEquals(2, metadataRequests.get()) + assertEquals(listOf("related"), retried.stream.relatedStreams.map(Video::id)) + } + private fun stream( title: String, accountId: String = "account", uploaderUrl: String = "", subscriberCount: Long = -1L, + related: List