diff --git a/.github/workflows/android-beta.yml b/.github/workflows/android-beta.yml index a60d2088..04c0b819 100644 --- a/.github/workflows/android-beta.yml +++ b/.github/workflows/android-beta.yml @@ -147,6 +147,7 @@ jobs: - name: Verify and prepare beta APK run: | + export PATH="$JAVA_HOME/bin:$PATH" mkdir -p artifacts apk="$(find app/build/outputs/apk/release -name '*.apk' | head -n 1)" test -n "$apk" diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 5d343366..4036e3c0 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -207,6 +207,7 @@ jobs: - name: Prepare APK artifact run: | + export PATH="$JAVA_HOME/bin:$PATH" mkdir -p artifacts apk="$(find app/build/outputs/apk/release -name '*.apk' | head -n 1)" test -n "$apk" diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 91643eae..ad3b3ab4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,8 +26,8 @@ android { applicationId = "dev.typetype.android" minSdk = 23 targetSdk = 37 - versionCode = 10822 - versionName = "1.8.1-beta.1" + versionCode = 10823 + versionName = "1.8.1-beta.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" resValue("string", "app_name", "TypeType") } diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerInteractionRowComposeTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerInteractionRowComposeTest.kt index a4ebd3dc..2f301c60 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerInteractionRowComposeTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerInteractionRowComposeTest.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import dev.typetype.android.R import org.junit.Assert.assertEquals +import org.junit.Assume.assumeTrue import org.junit.Rule import org.junit.Test @@ -24,6 +25,10 @@ class PlayerInteractionRowComposeTest { @Test fun tabletShowsLabelsAndLargerTouchTargets() { + assumeTrue( + "tablet interaction row needs a 600dp wide device", + composeRule.activity.resources.configuration.smallestScreenWidthDp >= 600, + ) var commentClicks = 0 setActions(720.dp) { commentClicks += 1 } diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/components/CommentBodyTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/components/CommentBodyTest.kt index d1958adc..20074a45 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/player/components/CommentBodyTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/components/CommentBodyTest.kt @@ -21,7 +21,7 @@ class CommentBodyTest { @Test fun longCommentCollapsesAndExpandsWithReadMore() { - val suffix = " more comment detail".repeat(60) + val suffix = " more comment detail".repeat(30) val comment = Comment( id = "comment", text = "Long comment starts here$suffix", @@ -47,7 +47,9 @@ class CommentBodyTest { } composeRule.onNodeWithText("Read more").assertExists().performClick() + composeRule.waitForIdle() composeRule.onNodeWithText("Show less").assertExists().performClick() + composeRule.waitForIdle() composeRule.onNodeWithText("Read more").assertExists() } } diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlsLayoutTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlsLayoutTest.kt index 82d253ae..0b6584d5 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlsLayoutTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlsLayoutTest.kt @@ -46,6 +46,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import java.lang.reflect.Proxy import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -164,6 +165,10 @@ class PlayerControlsLayoutTest { @Test fun tabletControlsHaveLargerTargetsWithoutOverlapping() { + assumeTrue( + "tablet player controls need a 600dp wide device", + composeRule.activity.resources.configuration.smallestScreenWidthDp >= 600, + ) setControls(720.dp, 405.dp, 720) assertControlsDoNotOverlap() composeRule.onNodeWithTag(PLAYER_CENTER_CONTROLS_TAG) diff --git a/app/src/androidTest/java/dev/typetype/android/feature/settings/appearance/AppearanceScreenTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/settings/appearance/AppearanceScreenTest.kt index 3da84b1a..de2b9a6d 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/settings/appearance/AppearanceScreenTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/settings/appearance/AppearanceScreenTest.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollToIndex +import androidx.compose.ui.test.performScrollToNode import dev.typetype.android.core.ui.theme.TypeTypeTheme import dev.typetype.android.domain.preferences.AccentColor import dev.typetype.android.domain.preferences.AppPreferences @@ -109,8 +110,8 @@ class AppearanceScreenTest { } } - composeRule.onAllNodes(hasScrollAction())[0].performScrollToIndex(11) - composeRule.onNode(isSelectable() and hasText("Red")) - .assertIsEnabled() + val accentCell = isSelectable() and hasText("Red") + composeRule.onAllNodes(hasScrollAction())[0].performScrollToNode(accentCell) + composeRule.onNode(accentCell).assertIsEnabled() } } diff --git a/app/src/androidTest/java/dev/typetype/android/feature/settings/imports/ImportDataScreenTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/settings/imports/ImportDataScreenTest.kt index fa0784ff..eb67587d 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/settings/imports/ImportDataScreenTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/settings/imports/ImportDataScreenTest.kt @@ -22,6 +22,8 @@ class ImportDataScreenTest { fun restoreRequiresASelectedArchive() { setScreen(ImportDataState()) + composeRule.onNode(hasScrollAction()) + .performScrollToNode(hasText("Restore backup")) composeRule.onNodeWithText("Restore backup").assertIsNotEnabled() } diff --git a/app/src/androidTest/java/dev/typetype/android/feature/settings/player/PlayerSettingsCaptionStyleTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/settings/player/PlayerSettingsCaptionStyleTest.kt index e9179b4c..3cd6ee55 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/settings/player/PlayerSettingsCaptionStyleTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/settings/player/PlayerSettingsCaptionStyleTest.kt @@ -30,6 +30,9 @@ class PlayerSettingsCaptionStyleTest { } } + composeRule.onNode(hasScrollAction()) + .performScrollToNode(hasText("Show advanced player settings")) + composeRule.onNodeWithText("Show advanced player settings").performClick() composeRule.onNode(hasScrollAction()) .performScrollToNode(hasText("Text size")) composeRule.onNodeWithText("Text size").performClick() diff --git a/app/src/androidTest/java/dev/typetype/android/feature/settings/player/PlayerSettingsDanmakuTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/settings/player/PlayerSettingsDanmakuTest.kt index f30fa374..45052702 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/settings/player/PlayerSettingsDanmakuTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/settings/player/PlayerSettingsDanmakuTest.kt @@ -30,6 +30,9 @@ class PlayerSettingsDanmakuTest { } } + composeRule.onNode(hasScrollAction()) + .performScrollToNode(hasText("Show advanced player settings")) + composeRule.onNodeWithText("Show advanced player settings").performClick() composeRule.onNode(hasScrollAction()) .performScrollToNode(hasText("Show bullet comments")) composeRule.onNodeWithText("Show bullet comments") diff --git a/app/src/androidTest/java/dev/typetype/android/services/PlaybackBackgroundTransitionTest.kt b/app/src/androidTest/java/dev/typetype/android/services/PlaybackBackgroundTransitionTest.kt index 77045353..8749203b 100644 --- a/app/src/androidTest/java/dev/typetype/android/services/PlaybackBackgroundTransitionTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/services/PlaybackBackgroundTransitionTest.kt @@ -82,7 +82,7 @@ class PlaybackBackgroundTransitionTest { } private fun waitForMainThread(condition: () -> Boolean): Boolean { - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30) while (System.nanoTime() < deadline) { if (readOnMainThread(condition)) return true Thread.sleep(50) diff --git a/app/src/main/java/dev/typetype/android/AppNavHost.kt b/app/src/main/java/dev/typetype/android/AppNavHost.kt index b43f93d3..aac890b3 100644 --- a/app/src/main/java/dev/typetype/android/AppNavHost.kt +++ b/app/src/main/java/dev/typetype/android/AppNavHost.kt @@ -27,6 +27,8 @@ import dev.typetype.android.core.ui.navigation.PrivacySettingsRoute import dev.typetype.android.core.ui.navigation.PublicPlaylistRoute import dev.typetype.android.core.ui.navigation.ProfileSettingsRoute import dev.typetype.android.core.ui.share.LocalServerBaseUrl +import dev.typetype.android.feature.player.LocalPlaybackPrewarm +import dev.typetype.android.feature.player.PlaybackPrewarm import dev.typetype.android.core.ui.navigation.AppearanceRoute import dev.typetype.android.core.ui.navigation.ContentSettingsRoute import dev.typetype.android.core.ui.navigation.HomeRoute @@ -61,7 +63,11 @@ import dev.typetype.android.feature.subscriptions.SubscriptionsRoute as Subscrip import kotlinx.coroutines.flow.collectLatest @Composable -fun AppNavHost(startRoute: Any, mainViewModel: MainViewModel) { +fun AppNavHost( + startRoute: Any, + mainViewModel: MainViewModel, + playbackPrewarm: PlaybackPrewarm? = null, +) { val navController: NavHostController = rememberNavController() val playerHostController = remember { mainViewModel.playerHostController } val currentServer by mainViewModel.currentServer.collectAsStateWithLifecycle() @@ -133,6 +139,7 @@ fun AppNavHost(startRoute: Any, mainViewModel: MainViewModel) { CompositionLocalProvider( LocalServerBaseUrl provides currentServer?.baseUrl, LocalDeArrowBranding provides deArrowEnvironment, + LocalPlaybackPrewarm provides playbackPrewarm, ) { AppShell( navController = navController, diff --git a/app/src/main/java/dev/typetype/android/MainActivity.kt b/app/src/main/java/dev/typetype/android/MainActivity.kt index 82093334..30b2839f 100644 --- a/app/src/main/java/dev/typetype/android/MainActivity.kt +++ b/app/src/main/java/dev/typetype/android/MainActivity.kt @@ -36,6 +36,7 @@ import dev.typetype.android.domain.navigation.resolveIncomingVideoUrl import dev.typetype.android.domain.navigation.resolveSharedVideoUrl import dev.typetype.android.domain.session.ActiveSessionRepository import dev.typetype.android.feature.player.components.PIP_ACTION_AUDIO_ONLY +import dev.typetype.android.feature.player.PlaybackPrewarm import dev.typetype.android.feature.player.components.PIP_ACTION_PLAY_PAUSE import dev.typetype.android.feature.player.components.PictureInPictureActionStateOwner import dev.typetype.android.feature.player.components.updatePictureInPicturePlaybackAction @@ -56,6 +57,9 @@ class MainActivity : ComponentActivity(), PictureInPictureActionStateOwner { @Inject lateinit var oidcCallbackRelay: OidcCallbackRelay + @Inject + lateinit var playbackPrewarm: PlaybackPrewarm + private val viewModel: MainViewModel by viewModels() private var activityReportingJob: Job? = null private var pipAudioOnlyAvailable = false @@ -105,7 +109,11 @@ class MainActivity : ComponentActivity(), PictureInPictureActionStateOwner { report = pendingCrashReport, onContinue = viewModel::continueAfterCrash, ) - startRoute != null -> AppNavHost(startRoute = startRoute, mainViewModel = viewModel) + startRoute != null -> AppNavHost( + startRoute = startRoute, + mainViewModel = viewModel, + playbackPrewarm = playbackPrewarm, + ) else -> FullScreenLoader() } } diff --git a/app/src/main/java/dev/typetype/android/MainViewModel.kt b/app/src/main/java/dev/typetype/android/MainViewModel.kt index bfaeaaf7..6ffc47c9 100644 --- a/app/src/main/java/dev/typetype/android/MainViewModel.kt +++ b/app/src/main/java/dev/typetype/android/MainViewModel.kt @@ -27,6 +27,7 @@ import dev.typetype.android.domain.subscriptions.SubscriptionsRepository import dev.typetype.android.domain.usersettings.UserSettings import dev.typetype.android.domain.usersettings.UserSettingsRepository import dev.typetype.android.feature.player.host.PlayerHostController +import dev.typetype.android.data.notifications.LocalNotificationScheduler import dev.typetype.android.services.push.PushRegistrationManager import javax.inject.Inject import kotlinx.coroutines.channels.Channel @@ -65,6 +66,7 @@ class MainViewModel @Inject constructor( private val libraryRepository: LibraryRepository, private val activeAccountScope: ActiveAccountScope, private val pushRegistrationManager: PushRegistrationManager, + private val localNotificationScheduler: LocalNotificationScheduler, private val startupLandingStore: StartupLandingStore, private val playbackResumeRepository: PlaybackResumeRepository, private val playbackQueueRepository: PlaybackQueueRepository, @@ -184,10 +186,15 @@ class MainViewModel @Inject constructor( launch { profileRepository.refresh() } launch { subscriptionsRepository.refresh() } launch { libraryRepository.resumePendingWrites() } - launch { pushRegistrationManager.reconcileRegistration() } + launch { reconcileNotificationDelivery() } } } + private suspend fun reconcileNotificationDelivery() { + pushRegistrationManager.reconcileRegistration() + localNotificationScheduler.sync() + } + private companion object { const val STARTUP_TIMEOUT_MS = 4_000L const val SESSION_VALIDATION_TIMEOUT_MS = 6_000L @@ -224,7 +231,7 @@ class MainViewModel @Inject constructor( launch { profileRepository.refresh() } launch { subscriptionsRepository.refresh() } launch { libraryRepository.resumePendingWrites() } - launch { pushRegistrationManager.reconcileRegistration() } + launch { reconcileNotificationDelivery() } launch { restorePlaybackUnlessExternalRequestArrives() } diff --git a/app/src/main/java/dev/typetype/android/core/di/RepositoryModule.kt b/app/src/main/java/dev/typetype/android/core/di/RepositoryModule.kt index 6675b24a..68100017 100644 --- a/app/src/main/java/dev/typetype/android/core/di/RepositoryModule.kt +++ b/app/src/main/java/dev/typetype/android/core/di/RepositoryModule.kt @@ -23,6 +23,8 @@ import dev.typetype.android.data.imports.RemotePortabilityRepository import dev.typetype.android.data.imports.RoomYoutubeTakeoutImportRepository import dev.typetype.android.data.library.RoomVideoMetaRepository import dev.typetype.android.data.notifications.RemoteNotificationsRepository +import dev.typetype.android.feature.player.PlaybackPrewarm +import dev.typetype.android.feature.player.PlaybackPrewarmLauncher import dev.typetype.android.data.network.NetworkAvailabilityObserver import dev.typetype.android.data.network.PlaybackNetworkMonitor import dev.typetype.android.data.network.PlaybackNetworkObserver @@ -179,6 +181,10 @@ abstract class RepositoryModule { @Singleton abstract fun bindPushRepository(impl: RemotePushRepository): PushRepository + @Binds + @Singleton + internal abstract fun bindPlaybackPrewarm(impl: PlaybackPrewarmLauncher): PlaybackPrewarm + @Binds @Singleton internal abstract fun bindStreamRepository(impl: StreamRepositoryImpl): StreamRepository diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/RelatedVideoCard.kt b/app/src/main/java/dev/typetype/android/core/ui/components/RelatedVideoCard.kt index 3d2bacf2..88faaba4 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/components/RelatedVideoCard.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/components/RelatedVideoCard.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -41,6 +42,9 @@ import dev.typetype.android.domain.feed.Video import dev.typetype.android.domain.feed.VideoAvailability import dev.typetype.android.domain.feed.availabilityAt import dev.typetype.android.domain.feed.releaseTimeMillis +import dev.typetype.android.feature.player.CARD_PREWARM_DELAY_MILLIS +import dev.typetype.android.feature.player.LocalPlaybackPrewarm +import kotlinx.coroutines.delay @OptIn(ExperimentalFoundationApi::class) @Composable @@ -55,6 +59,14 @@ fun RelatedVideoCard( var menuVisible by remember { mutableStateOf(false) } var availabilityVisible by remember { mutableStateOf(false) } val serverBaseUrl = LocalServerBaseUrl.current + val playbackPrewarm = LocalPlaybackPrewarm.current + + LaunchedEffect(video.url, playbackPrewarm) { + if (playbackPrewarm == null) return@LaunchedEffect + delay(CARD_PREWARM_DELAY_MILLIS) + playbackPrewarm.prewarm(video.url, video.isLive) + } + val availability = video.availabilityAt(System.currentTimeMillis()) val branding = rememberVideoBranding( sourceUrl = video.url, diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/VideoCard.kt b/app/src/main/java/dev/typetype/android/core/ui/components/VideoCard.kt index d0e300fd..3bf674d7 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/components/VideoCard.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/components/VideoCard.kt @@ -22,6 +22,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -42,6 +43,7 @@ import androidx.compose.ui.platform.LocalContext import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.request.crossfade +import kotlinx.coroutines.delay import dev.typetype.android.R import dev.typetype.android.core.ui.branding.rememberVideoBranding import dev.typetype.android.core.ui.share.LocalServerBaseUrl @@ -50,6 +52,8 @@ import dev.typetype.android.domain.feed.Video import dev.typetype.android.domain.feed.VideoAvailability import dev.typetype.android.domain.feed.availabilityAt import dev.typetype.android.domain.feed.releaseTimeMillis +import dev.typetype.android.feature.player.CARD_PREWARM_DELAY_MILLIS +import dev.typetype.android.feature.player.LocalPlaybackPrewarm @OptIn(ExperimentalFoundationApi::class) @Composable @@ -73,6 +77,13 @@ fun VideoCard( durationSeconds = video.durationSeconds, ) val openDescription = stringResource(R.string.video_open_accessibility, branding.title) + val playbackPrewarm = LocalPlaybackPrewarm.current + + LaunchedEffect(video.url, playbackPrewarm) { + if (playbackPrewarm == null) return@LaunchedEffect + delay(CARD_PREWARM_DELAY_MILLIS) + playbackPrewarm.prewarm(video.url, video.isLive) + } Column( modifier = modifier diff --git a/app/src/main/java/dev/typetype/android/core/ui/share/ShareUrls.kt b/app/src/main/java/dev/typetype/android/core/ui/share/ShareUrls.kt index 9fbb1c6f..f7aeddb1 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/share/ShareUrls.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/share/ShareUrls.kt @@ -1,11 +1,10 @@ package dev.typetype.android.core.ui.share import androidx.compose.runtime.compositionLocalOf +import dev.typetype.android.core.url.percentEncode import dev.typetype.android.domain.navigation.resolveIncomingVideoUrl import dev.typetype.android.domain.navigation.toPublicWatchParameter import java.net.URI -import java.net.URLEncoder -import java.nio.charset.StandardCharsets val LocalServerBaseUrl = compositionLocalOf { null } @@ -16,7 +15,7 @@ fun buildShareUrl(serverBaseUrl: String?, videoUrl: String): String { .removeSuffix("/api") .trimEnd('/') if (origin.isBlank()) return videoUrl - val encoded = URLEncoder.encode(toPublicWatchParameter(videoUrl), StandardCharsets.UTF_8.toString()) + val encoded = percentEncode(toPublicWatchParameter(videoUrl)) return "$origin/watch?v=$encoded" } @@ -77,7 +76,7 @@ fun buildImageUrl(serverBaseUrl: String?, imageUrl: String): String { if (!source.startsWith("http://") && !source.startsWith("https://")) return source if (!needsImageProxy(source)) return source - val encoded = URLEncoder.encode(source, StandardCharsets.UTF_8.toString()) + val encoded = percentEncode(source) return "$base/proxy?url=$encoded" } diff --git a/app/src/main/java/dev/typetype/android/core/url/PercentEncoding.kt b/app/src/main/java/dev/typetype/android/core/url/PercentEncoding.kt new file mode 100644 index 00000000..0829225e --- /dev/null +++ b/app/src/main/java/dev/typetype/android/core/url/PercentEncoding.kt @@ -0,0 +1,22 @@ +package dev.typetype.android.core.url + +private const val HEX = "0123456789ABCDEF" + +internal fun percentEncode(value: String): String { + val bytes = value.toByteArray(Charsets.UTF_8) + val encoded = StringBuilder(bytes.size) + for (byte in bytes) { + val code = byte.toInt() and 0xff + val character = code.toChar() + if (character in 'A'..'Z' || character in 'a'..'z' || character in '0'..'9' || + character == '-' || character == '_' || character == '.' || character == '~' + ) { + encoded.append(character) + } else { + encoded.append('%') + .append(HEX[(code shr 4) and 0x0f]) + .append(HEX[code and 0x0f]) + } + } + return encoded.toString() +} diff --git a/app/src/main/java/dev/typetype/android/data/network/TypeTypeMediaApi.kt b/app/src/main/java/dev/typetype/android/data/network/TypeTypeMediaApi.kt index b24a71fa..005a315d 100644 --- a/app/src/main/java/dev/typetype/android/data/network/TypeTypeMediaApi.kt +++ b/app/src/main/java/dev/typetype/android/data/network/TypeTypeMediaApi.kt @@ -48,6 +48,7 @@ interface TypeTypeMediaApi { suspend fun createSabrPlayback( @Path("videoId") videoId: String, @Body body: SabrPlaybackRequest, + @Query("prewarm") prewarm: Boolean? = null, ): Response @POST("sabr/playback/{sessionId}/seek") 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 new file mode 100644 index 00000000..9cbb81a1 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationDecision.kt @@ -0,0 +1,37 @@ +package dev.typetype.android.data.notifications + +import dev.typetype.android.domain.notifications.NotificationItem +import dev.typetype.android.domain.push.ChannelNotificationsPreference +import dev.typetype.android.domain.push.matchesChannel + +internal data class LocalNotificationDecision( + val keysToRecord: List, + val notified: List, +) + +internal fun decideLocalNotifications( + items: List, + knownKeys: Set, + seeded: Boolean, + enabledChannels: List, +): LocalNotificationDecision { + val fresh = items.filter { item -> item.localNotificationKey() !in knownKeys } + if (!seeded) { + return LocalNotificationDecision( + keysToRecord = items.map { item -> item.localNotificationKey() }, + notified = emptyList(), + ) + } + if (fresh.isEmpty()) return LocalNotificationDecision(emptyList(), emptyList()) + val deliverableChannels = enabledChannels.filter { it.enabled } + val deliverable = fresh + .filter { item -> deliverableChannels.any { it.matchesChannel(item.channelUrl) } } + .asReversed() + return LocalNotificationDecision( + keysToRecord = fresh.map { item -> item.localNotificationKey() }, + notified = deliverable, + ) +} + +private fun NotificationItem.localNotificationKey(): String = + video.id.takeIf { it.isNotBlank() } ?: video.url 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 new file mode 100644 index 00000000..5c8c548b --- /dev/null +++ b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationPoller.kt @@ -0,0 +1,55 @@ +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.domain.notifications.NotificationsRepository +import dev.typetype.android.domain.push.PushRepository +import dev.typetype.android.services.push.PushNotifier +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CancellationException + +@Singleton +class LocalNotificationPoller @Inject constructor( + private val activeAccountScope: ActiveAccountScope, + private val accountDao: AccountDao, + private val notifications: NotificationsRepository, + private val pushRepository: PushRepository, + private val seenStore: LocalNotificationSeenStore, + private val notifier: PushNotifier, +) { + suspend fun poll(): Result = try { + Result.success(pollOnce()) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Throwable) { + Result.failure(failure) + } + + private suspend fun pollOnce(): Int { + val scope = eligibleScope() + val enabledChannels = pushRepository.channelPreferences().getOrThrow() + val page = notifications.page(0).getOrThrow() + val state = seenStore.state(scope) + val decision = decideLocalNotifications( + items = page.items, + knownKeys = state.keys.toHashSet(), + seeded = state.seeded, + 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 + } + + private suspend fun eligibleScope(): AccountScope { + val scope = activeAccountScope.require() + val account = accountDao.get(scope.serverId, scope.accountId) + check(account != null && !account.isGuest) { + "Local notifications are unavailable for guest accounts" + } + return scope + } +} diff --git a/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationScheduler.kt b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationScheduler.kt new file mode 100644 index 00000000..c8586d4d --- /dev/null +++ b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationScheduler.kt @@ -0,0 +1,37 @@ +package dev.typetype.android.data.notifications + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class LocalNotificationScheduler @Inject constructor( + @ApplicationContext context: Context, +) { + private val workManager = WorkManager.getInstance(context) + + fun sync() { + workManager.enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.UPDATE, + PeriodicWorkRequestBuilder(INTERVAL_MINUTES, TimeUnit.MINUTES) + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, MIN_BACKOFF_MINUTES, TimeUnit.MINUTES) + .build(), + ) + } + + private companion object { + const val WORK_NAME = "typetype-local-notifications" + const val INTERVAL_MINUTES = 15L + const val MIN_BACKOFF_MINUTES = 5L + } +} diff --git a/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationSeenStore.kt b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationSeenStore.kt new file mode 100644 index 00000000..8dedb537 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationSeenStore.kt @@ -0,0 +1,58 @@ +package dev.typetype.android.data.notifications + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import dev.typetype.android.data.account.AccountScope +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.first + +@Singleton +class LocalNotificationSeenStore @Inject constructor( + private val dataStore: DataStore, +) { + data class State( + val seeded: Boolean, + val keys: List, + ) + + suspend fun state(scope: AccountScope): State = dataStore.data.first().toState(scope) + + suspend fun record(scope: AccountScope, keys: List, seeded: Boolean) { + if (keys.isEmpty() && !seeded) return + dataStore.edit { preferences -> + val merged = (preferences.toState(scope).keys + keys).distinct().takeLast(MAX_KEYS) + preferences[keysKey(scope)] = merged.joinToString(SEPARATOR) + preferences[seededKey(scope)] = seeded + } + } + + suspend fun clear(scope: AccountScope) { + dataStore.edit { preferences -> + preferences.remove(keysKey(scope)) + preferences.remove(seededKey(scope)) + } + } + + private fun Preferences.toState(scope: AccountScope): State = State( + seeded = this[seededKey(scope)] ?: false, + keys = this[keysKey(scope)] + ?.split(SEPARATOR) + ?.filter { it.isNotBlank() } + .orEmpty(), + ) + + private fun keysKey(scope: AccountScope) = + stringPreferencesKey("local_notifications_${scope.serverId}_${scope.accountId}_seen") + + private fun seededKey(scope: AccountScope) = + booleanPreferencesKey("local_notifications_${scope.serverId}_${scope.accountId}_seeded") + + private companion object { + const val SEPARATOR = "\n" + const val MAX_KEYS = 300 + } +} 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 new file mode 100644 index 00000000..895f7afb --- /dev/null +++ b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationWorker.kt @@ -0,0 +1,27 @@ +package dev.typetype.android.data.notifications + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import dev.typetype.android.services.push.PushNotifier + +@HiltWorker +class LocalNotificationWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted parameters: WorkerParameters, + private val poller: LocalNotificationPoller, + private val notifier: PushNotifier, +) : CoroutineWorker(context, parameters) { + override suspend fun doWork(): Result { + if (!notifier.canNotify()) return Result.success() + return poller.poll().fold( + onSuccess = { Result.success() }, + onFailure = { failure -> + if (failure is IllegalStateException) Result.success() else Result.retry() + }, + ) + } +} diff --git a/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackRepositoryImpl.kt b/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackRepositoryImpl.kt index 0e663e06..21de4b17 100644 --- a/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackRepositoryImpl.kt +++ b/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackRepositoryImpl.kt @@ -41,6 +41,13 @@ class SabrPlaybackRepositoryImpl @Inject constructor( preparer.prepareOnce(api, target.requestScope.baseUrl, target, startTimeMs) } + override suspend fun prewarm( + target: SabrPlaybackTarget, + startTimeMs: Long, + ): Result = execute(target) { api -> + preparer.prepare(api, target.requestScope.baseUrl, target, startTimeMs, prewarm = true) + } + override suspend fun seek( target: SabrPlaybackTarget, binding: SabrPlaybackBinding, diff --git a/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparer.kt b/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparer.kt index c7db219d..cb0323a1 100644 --- a/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparer.kt +++ b/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparer.kt @@ -25,7 +25,8 @@ internal class SabrPlaybackSessionPreparer( baseUrl: String, target: SabrPlaybackTarget, startTimeMs: Long = 0L, - ): SabrPlaybackSession = createSessionWithRecovery(api, baseUrl, target, startTimeMs) + prewarm: Boolean = false, + ): SabrPlaybackSession = createSessionWithRecovery(api, baseUrl, target, startTimeMs, prewarm) suspend fun prepareOnce( api: TypeTypeMediaApi, @@ -39,11 +40,13 @@ internal class SabrPlaybackSessionPreparer( baseUrl: String, target: SabrPlaybackTarget, startTimeMs: Long, + prewarm: Boolean = false, ): SabrPlaybackSession { val response = transientPlaybackRequest(pause, network) { api.createSabrPlayback( target.videoId, target.controlRequest(startTimeMs), + prewarm.takeIf { it }, ) } response.requireControlEndpoint( @@ -139,8 +142,9 @@ internal class SabrPlaybackSessionPreparer( baseUrl: String, target: SabrPlaybackTarget, startTimeMs: Long, + prewarm: Boolean = false, ): SabrPlaybackSession = try { - createSession(api, baseUrl, target, startTimeMs) + createSession(api, baseUrl, target, startTimeMs, prewarm) } catch (failure: SabrPlaybackRecoveryException) { recoverSession(api, baseUrl, target, startTimeMs, failure) } diff --git a/app/src/main/java/dev/typetype/android/data/stream/ServerRelativeUrl.kt b/app/src/main/java/dev/typetype/android/data/stream/ServerRelativeUrl.kt index ab9d568c..10f052a4 100644 --- a/app/src/main/java/dev/typetype/android/data/stream/ServerRelativeUrl.kt +++ b/app/src/main/java/dev/typetype/android/data/stream/ServerRelativeUrl.kt @@ -14,6 +14,11 @@ internal fun resolveServerUrl(baseUrl: String, value: String?): String? { return resolved.takeIf { it.hasSameOrigin(server) }?.toString() } +internal fun resolvePlaybackUrl(baseUrl: String, value: String?): String? { + val source = value?.takeIf { it.isNotBlank() } ?: return null + return source.toHttpUrlOrNull()?.toString() ?: resolveServerUrl(baseUrl, source) +} + internal fun resolveSabrPlaybackManifestUrl( baseUrl: String, value: String?, diff --git a/app/src/main/java/dev/typetype/android/data/stream/StreamEndpointLoader.kt b/app/src/main/java/dev/typetype/android/data/stream/StreamEndpointLoader.kt index dc0e55a4..b23c02b1 100644 --- a/app/src/main/java/dev/typetype/android/data/stream/StreamEndpointLoader.kt +++ b/app/src/main/java/dev/typetype/android/data/stream/StreamEndpointLoader.kt @@ -26,9 +26,18 @@ internal suspend fun TypeTypeMediaApi.loadYouTubeSabrBootstrapResponse( ): Response { val response = youtubeSabrBootstrap(videoUrl) response.rejectSabrRedirect("SABR stream bootstrap attempted to redirect") - return response + if (response.isSuccessful) return response + if (!response.canFallBackToSabrStreams()) return response + val fallback = youtubeSabrStreams(videoUrl) + fallback.rejectSabrRedirect("SABR stream discovery attempted to redirect") + return fallback } +private fun Response.canFallBackToSabrStreams(): Boolean = + code() in BOOTSTRAP_FALLBACK_STATUSES + +private val BOOTSTRAP_FALLBACK_STATUSES = setOf(404, 405, 422, 500, 501, 502, 503, 504) + private suspend fun TypeTypeMediaApi.fallbackToGeneric( response: Response, videoUrl: String, @@ -40,16 +49,19 @@ private fun Response.canFallbackToGeneric(): Boolean = internal fun StreamResponse.hasPlayableSabrContract(baseUrl: String? = null): Boolean { val playableVideoItags = (videoStreams + videoOnlyStreams).filter { it.deliveryMethod == SABR_DELIVERY_METHOD && it.itag > 0 && - it.manifestUrl.isAllowedSabrManifest(baseUrl) && isServerSabrVideoFormat(it.codec) + it.manifestUrl.isAllowedServerManifest(baseUrl) && isServerSabrVideoFormat(it.codec) }.mapTo(mutableSetOf()) { it.itag } return playableVideoItags.isNotEmpty() && audioStreams.any { it.deliveryMethod == SABR_DELIVERY_METHOD && it.itag > 0 && - it.itag !in playableVideoItags && it.manifestUrl.isAllowedSabrManifest(baseUrl) && + it.itag !in playableVideoItags && it.manifestUrl.isAllowedServerManifest(baseUrl) && isServerSabrAudioFormat(it.mimeType, it.codec) } } -private fun String?.isAllowedSabrManifest(baseUrl: String?): Boolean = +internal fun StreamResponse.hasPlayableLiveContract(baseUrl: String? = null): Boolean = + (isLive || hasLiveManifest) && hlsUrl.isAllowedServerManifest(baseUrl) + +private fun String?.isAllowedServerManifest(baseUrl: String?): Boolean = !isNullOrBlank() && (baseUrl == null || resolveServerUrl(baseUrl, this) != null) internal fun String.streamProvider(): StreamProvider { diff --git a/app/src/main/java/dev/typetype/android/data/stream/StreamRepositoryImpl.kt b/app/src/main/java/dev/typetype/android/data/stream/StreamRepositoryImpl.kt index 7d81fb1c..3ee789cf 100644 --- a/app/src/main/java/dev/typetype/android/data/stream/StreamRepositoryImpl.kt +++ b/app/src/main/java/dev/typetype/android/data/stream/StreamRepositoryImpl.kt @@ -2,6 +2,7 @@ package dev.typetype.android.data.stream import dev.typetype.android.data.account.AccountScopeProvider import dev.typetype.android.core.error.CodedFailure +import dev.typetype.android.core.url.percentEncode import dev.typetype.android.data.network.PlaybackNetworkObserver import dev.typetype.android.data.network.dto.AudioStreamItem import dev.typetype.android.data.network.dto.PreviewFrameItem @@ -26,8 +27,6 @@ import dev.typetype.android.domain.stream.StreamVideoSource import dev.typetype.android.domain.stream.isServerSabrAudioFormat import dev.typetype.android.domain.stream.isServerSabrVideoFormat import dev.typetype.android.domain.server.ServerRepository -import java.net.URLEncoder -import java.nio.charset.StandardCharsets import javax.inject.Inject import javax.inject.Singleton import kotlin.math.roundToLong @@ -103,7 +102,10 @@ internal class StreamRepositoryImpl @Inject constructor( } val body = response.body() ?: error("Empty stream body") activeAccountScope.verify(scope) - if (provider == StreamProvider.YouTube && !body.hasPlayableSabrContract(server.baseUrl)) { + if (provider == StreamProvider.YouTube && + !body.hasPlayableSabrContract(server.baseUrl) && + !body.hasPlayableLiveContract(server.baseUrl) + ) { throw SabrContractException() } body.toDomain(videoUrl, server.baseUrl, scope, provider) @@ -115,7 +117,9 @@ internal class StreamRepositoryImpl @Inject constructor( scope: dev.typetype.android.data.account.AccountScope, provider: StreamProvider, ): Stream { - val serverSabr = provider == StreamProvider.YouTube + val serverYoutube = provider == StreamProvider.YouTube + val serverSabr = serverYoutube && hasPlayableSabrContract(baseUrl) + val resolvedHlsUrl = resolvePlaybackUrl(baseUrl, hlsUrl).orEmpty() return Stream( playbackContract = if (serverSabr) { StreamPlaybackContract.ServerSabr @@ -136,12 +140,12 @@ internal class StreamRepositoryImpl @Inject constructor( likeCount = likeCount, dislikeCount = dislikeCount, uploadedAtMillis = uploaded, - hlsUrl = hlsUrl.takeIf { !serverSabr && it.isNotBlank() }, + hlsUrl = resolvedHlsUrl.takeIf { !serverSabr && it.isNotBlank() }, dashMpdUrl = dashMpdUrl.takeIf { !serverSabr && it.isNotBlank() }, - progressiveUrl = pickBestProgressiveStream(videoStreams).takeUnless { serverSabr }, + progressiveUrl = pickBestProgressiveStream(baseUrl, videoStreams).takeUnless { serverSabr }, serverDashManifestUrl = serverManifestUrl(baseUrl, "streams/manifest", videoUrl) - .takeUnless { serverSabr }, - serverHlsManifestUrl = hlsUrl.takeIf { !serverSabr && it.isNotBlank() } + .takeUnless { serverSabr || serverYoutube }, + serverHlsManifestUrl = hlsUrl.takeIf { !serverSabr && !serverYoutube && it.isNotBlank() } ?.let { serverManifestUrl(baseUrl, "streams/hls-manifest", videoUrl) }, serverSabrManifestUrl = resolveServerUrl(baseUrl, firstSabrManifestUrl()), sabrVideoStreams = (videoOnlyStreams + videoStreams) @@ -190,6 +194,7 @@ internal class StreamRepositoryImpl @Inject constructor( isLive = isLive, isPostLive = isPostLive, isLiveContent = isLiveContent, + requiresMembership = requiresMembership, category = category, ) } @@ -201,11 +206,14 @@ internal class StreamRepositoryImpl @Inject constructor( item.manifestUrl.takeIf { item.deliveryMethod == SABR_DELIVERY_METHOD } } - private fun pickBestProgressiveStream(videoStreams: List): String? = + private fun pickBestProgressiveStream( + baseUrl: String, + videoStreams: List, + ): String? = videoStreams .filter { !it.isVideoOnly && it.url.isNotBlank() } .maxByOrNull { it.height } - ?.url + ?.let { resolvePlaybackUrl(baseUrl, it.url) } private fun selectStoryboard(storyboards: List): StreamStoryboard? = storyboards.filter { it.frameWidth >= TARGET_STORYBOARD_FRAME_WIDTH } @@ -225,21 +233,26 @@ internal class StreamRepositoryImpl @Inject constructor( private fun serverManifestUrl(baseUrl: String, path: String, videoUrl: String): String { val normalizedBaseUrl = baseUrl.trimEnd('/') - val encoded = URLEncoder.encode(videoUrl, StandardCharsets.UTF_8) + val encoded = percentEncode(videoUrl) return "$normalizedBaseUrl/$path?url=$encoded" } private fun VideoStreamItem.toDomainVideoSource(baseUrl: String): StreamVideoSource = StreamVideoSource( - url = if (deliveryMethod == SABR_DELIVERY_METHOD) resolveServerUrl(baseUrl, manifestUrl).orEmpty() else url, + url = if (deliveryMethod == SABR_DELIVERY_METHOD) { + resolveServerUrl(baseUrl, manifestUrl).orEmpty() + } else { + resolvePlaybackUrl(baseUrl, url).orEmpty() + }, mimeType = mimeType, + playbackMimeType = HLS_MIME_TYPE.takeIf { deliveryMethod == HLS_DELIVERY_METHOD }, codec = codec, resolution = buildString { - append("${height.coerceAtLeast(0)}p") + append("${resolvedHeight()}p") fps.takeIf { it > 0 }?.let { append(it) } if (resolution.contains("HDR", ignoreCase = true)) append(" HDR") }, - width = width, - height = height, + width = width.coerceAtLeast(0), + height = resolvedHeight(), fps = fps, bitrate = bitrate, isVideoOnly = isVideoOnly, @@ -247,7 +260,11 @@ internal class StreamRepositoryImpl @Inject constructor( ) private fun AudioStreamItem.toDomainAudioSource(baseUrl: String): StreamAudioSource = StreamAudioSource( - url = if (deliveryMethod == SABR_DELIVERY_METHOD) resolveServerUrl(baseUrl, manifestUrl).orEmpty() else url, + url = if (deliveryMethod == SABR_DELIVERY_METHOD) { + resolveServerUrl(baseUrl, manifestUrl).orEmpty() + } else { + resolvePlaybackUrl(baseUrl, url).orEmpty() + }, mimeType = mimeType, codec = codec, bitrate = bitrate, @@ -294,8 +311,15 @@ internal suspend fun cancellableStreamResult( } private const val SABR_DELIVERY_METHOD = "sabr" +private const val HLS_DELIVERY_METHOD = "hls" +private const val HLS_MIME_TYPE = "application/vnd.apple.mpegurl" private const val TARGET_STORYBOARD_FRAME_WIDTH = 160 +private fun VideoStreamItem.resolvedHeight(): Int = + height.takeIf { it > 0 } ?: RESOLUTION_HEIGHT_PATTERN.find(resolution)?.groupValues?.get(1)?.toIntOrNull() ?: 0 + +private val RESOLUTION_HEIGHT_PATTERN = Regex("(\\d{3,4})") + private class SabrContractException : IllegalStateException("The server returned no playable SABR contract"), CodedFailure { 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 08befe8e..a2791d45 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 @@ -10,6 +10,11 @@ data class ChannelNotificationsPreference( val enabled: Boolean, ) +fun ChannelNotificationsPreference.matchesChannel(channelUrl: String): Boolean = + this.channelUrl == channelUrl || + channelUrl.startsWith("${this.channelUrl}/") || + this.channelUrl.startsWith("$channelUrl/") + sealed interface PushRegistrationStatus { data object Disabled : PushRegistrationStatus diff --git a/app/src/main/java/dev/typetype/android/domain/stream/SabrPlaybackRepository.kt b/app/src/main/java/dev/typetype/android/domain/stream/SabrPlaybackRepository.kt index 18a9a52b..26725d7f 100644 --- a/app/src/main/java/dev/typetype/android/domain/stream/SabrPlaybackRepository.kt +++ b/app/src/main/java/dev/typetype/android/domain/stream/SabrPlaybackRepository.kt @@ -49,6 +49,9 @@ interface SabrPlaybackRepository { onFailure = Result.Companion::failure, ) + suspend fun prewarm(target: SabrPlaybackTarget, startTimeMs: Long = 0L): Result = + prepare(target, startTimeMs) + suspend fun recoverOnce( target: SabrPlaybackTarget, startTimeMs: Long, diff --git a/app/src/main/java/dev/typetype/android/domain/stream/Stream.kt b/app/src/main/java/dev/typetype/android/domain/stream/Stream.kt index fa142f66..35acd661 100644 --- a/app/src/main/java/dev/typetype/android/domain/stream/Stream.kt +++ b/app/src/main/java/dev/typetype/android/domain/stream/Stream.kt @@ -41,6 +41,7 @@ data class Stream( val isLive: Boolean = false, val isPostLive: Boolean = false, val isLiveContent: Boolean = false, + val requiresMembership: Boolean = false, val category: String? = null, ) @@ -96,6 +97,7 @@ enum class StreamPlaybackContract { data class StreamVideoSource( val url: String, val mimeType: String, + val playbackMimeType: String? = null, val codec: String?, val resolution: String, val width: Int, diff --git a/app/src/main/java/dev/typetype/android/feature/channel/ChannelNotificationsController.kt b/app/src/main/java/dev/typetype/android/feature/channel/ChannelNotificationsController.kt index e1157d96..a9b84ab9 100644 --- a/app/src/main/java/dev/typetype/android/feature/channel/ChannelNotificationsController.kt +++ b/app/src/main/java/dev/typetype/android/feature/channel/ChannelNotificationsController.kt @@ -1,8 +1,8 @@ package dev.typetype.android.feature.channel import dev.typetype.android.data.account.ActiveAccountScope -import dev.typetype.android.domain.push.ChannelNotificationsPreference import dev.typetype.android.domain.push.PushRepository +import dev.typetype.android.domain.push.matchesChannel import dev.typetype.android.domain.server.ServerRepository import javax.inject.Inject @@ -21,16 +21,11 @@ class ChannelNotificationsController @Inject constructor( suspend fun currentEnabled(channelUrl: String): Boolean? = pushRepository.channelPreferences().getOrNull() - ?.firstOrNull { it.matches(channelUrl) } + ?.firstOrNull { it.matchesChannel(channelUrl) } ?.enabled suspend fun setEnabled(channelUrl: String, enabled: Boolean): Boolean? { pushRepository.setChannelPreference(channelUrl, enabled).getOrNull() ?: return null return enabled } - - private fun ChannelNotificationsPreference.matches(channelUrl: String): Boolean = - this.channelUrl == channelUrl || - channelUrl.endsWith(this.channelUrl) || - this.channelUrl.endsWith(channelUrl) } diff --git a/app/src/main/java/dev/typetype/android/feature/player/DanmakuSupport.kt b/app/src/main/java/dev/typetype/android/feature/player/DanmakuSupport.kt index 2d3dcb3c..e07f93b9 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/DanmakuSupport.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/DanmakuSupport.kt @@ -1,8 +1,10 @@ package dev.typetype.android.feature.player -import java.net.URI +import dev.typetype.android.data.stream.StreamProvider +import dev.typetype.android.data.stream.streamProvider -internal fun supportsServerBulletComments(videoUrl: String): Boolean { - val host = runCatching { URI(videoUrl).host?.lowercase() }.getOrNull() ?: return false - return host == "nicovideo.jp" || host.endsWith(".nicovideo.jp") || host == "nico.ms" -} +internal fun supportsServerBulletComments(videoUrl: String): Boolean = + when (videoUrl.streamProvider()) { + StreamProvider.NicoNico, StreamProvider.BiliBili -> true + StreamProvider.YouTube, StreamProvider.Generic -> false + } diff --git a/app/src/main/java/dev/typetype/android/feature/player/LocalPlaybackPrewarm.kt b/app/src/main/java/dev/typetype/android/feature/player/LocalPlaybackPrewarm.kt new file mode 100644 index 00000000..9d648da9 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/LocalPlaybackPrewarm.kt @@ -0,0 +1,11 @@ +package dev.typetype.android.feature.player + +import androidx.compose.runtime.staticCompositionLocalOf + +fun interface PlaybackPrewarm { + fun prewarm(videoUrl: String, knownLive: Boolean) +} + +val LocalPlaybackPrewarm = staticCompositionLocalOf { null } + +const val CARD_PREWARM_DELAY_MILLIS = 200L diff --git a/app/src/main/java/dev/typetype/android/feature/shorts/ShortsPlaybackPreheater.kt b/app/src/main/java/dev/typetype/android/feature/player/PlaybackPreheater.kt similarity index 57% rename from app/src/main/java/dev/typetype/android/feature/shorts/ShortsPlaybackPreheater.kt rename to app/src/main/java/dev/typetype/android/feature/player/PlaybackPreheater.kt index df0f745a..d980fa3f 100644 --- a/app/src/main/java/dev/typetype/android/feature/shorts/ShortsPlaybackPreheater.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlaybackPreheater.kt @@ -1,32 +1,41 @@ -package dev.typetype.android.feature.shorts +package dev.typetype.android.feature.player +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext import dev.typetype.android.domain.stream.SabrPlaybackRepository +import dev.typetype.android.domain.stream.Stream import dev.typetype.android.domain.stream.StreamRepository import dev.typetype.android.domain.stream.sabrPlaybackTarget import dev.typetype.android.domain.usersettings.UserSettings -import dev.typetype.android.feature.player.PlaybackCodecSupport -import dev.typetype.android.feature.player.RECOMMENDED_CODEC_KEY -import dev.typetype.android.feature.player.effectiveQuality -import dev.typetype.android.feature.player.initialAudioKey -import dev.typetype.android.feature.player.initialQuality -import dev.typetype.android.feature.player.SabrPlaybackPreloadStore -import dev.typetype.android.feature.player.sabrSelection +import dev.typetype.android.domain.usersettings.UserSettingsRepository import javax.inject.Inject +import javax.inject.Singleton -class ShortsPlaybackPreheater @Inject constructor( +@Singleton +class PlaybackPreheater @Inject constructor( + @ApplicationContext context: Context, private val streams: StreamRepository, private val sabr: SabrPlaybackRepository, private val preloads: SabrPlaybackPreloadStore, + private val userSettingsRepository: UserSettingsRepository, ) { + private val codecSupport: PlaybackCodecSupport = DevicePlaybackCodecSupport(context) + + suspend fun preheat(videoUrl: String, knownLive: Boolean = false) { + if (knownLive) return + val settings = userSettingsRepository.current().getOrNull() ?: return + preheat(videoUrl, settings, codecSupport, { true }) + } + internal suspend fun preheat( videoUrl: String, settings: UserSettings, codecSupport: PlaybackCodecSupport, - prepareSession: () -> Boolean, + canPrepare: () -> Boolean, ) { val stream = streams.prefetchPlaybackStream(videoUrl).getOrNull() ?: return - if (!prepareSession()) return - if (stream.isLive || stream.isLiveContent) return + if (!stream.isPreheatable()) return + if (!canPrepare()) return val selection = stream.sabrSelection( selectedQuality = stream.initialQuality().effectiveQuality(settings.defaultQuality), selectedAudioKey = stream.initialAudioKey( @@ -40,6 +49,9 @@ class ShortsPlaybackPreheater @Inject constructor( ) ?: return val target = stream.sabrPlaybackTarget(selection) val reservation = preloads.reserve(target) - if (reservation.owner) reservation.result.complete(sabr.prepare(target, 0L)) + if (reservation.owner) reservation.result.complete(sabr.prewarm(target, 0L)) } } + +private fun Stream.isPreheatable(): Boolean = + !isLive && !isPostLive && !isLiveContent && !requiresMembership diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlaybackPrewarmLauncher.kt b/app/src/main/java/dev/typetype/android/feature/player/PlaybackPrewarmLauncher.kt new file mode 100644 index 00000000..ce7be716 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/PlaybackPrewarmLauncher.kt @@ -0,0 +1,31 @@ +package dev.typetype.android.feature.player + +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +@Singleton +internal class PlaybackPrewarmLauncher @Inject constructor( + private val preheater: PlaybackPreheater, +) : PlaybackPrewarm { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private var inFlight = false + + override fun prewarm(videoUrl: String, knownLive: Boolean) { + if (videoUrl.isBlank()) return + synchronized(this) { + if (inFlight) return + inFlight = true + } + scope.launch { + try { + preheater.preheat(videoUrl, knownLive) + } finally { + synchronized(this@PlaybackPrewarmLauncher) { inFlight = false } + } + } + } +} diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerErrorState.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerErrorState.kt index 37998601..95088f13 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlayerErrorState.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerErrorState.kt @@ -33,6 +33,7 @@ fun ErrorState( -> R.raw.member_only StreamErrorKind.Generic, StreamErrorKind.AuthenticationExpired, + StreamErrorKind.ContentUnavailable, StreamErrorKind.LiveUnsupported, StreamErrorKind.NetworkUnavailable, StreamErrorKind.SabrInvalidIndex, @@ -45,6 +46,7 @@ fun ErrorState( -> R.raw.error_cat } val displayMessage = when (classification.kind) { + StreamErrorKind.ContentUnavailable -> stringResource(R.string.error_content_unavailable) StreamErrorKind.MemberOnly -> stringResource(R.string.state_member_only_message) StreamErrorKind.PaidContent -> stringResource(R.string.video_paid_message) StreamErrorKind.ScheduledPremiere -> stringResource(R.string.video_scheduled_message) diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerMediaBinder.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerMediaBinder.kt index 0f7a2eac..b97be1fd 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlayerMediaBinder.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerMediaBinder.kt @@ -10,6 +10,7 @@ import dev.typetype.android.domain.stream.Stream import dev.typetype.android.domain.stream.StreamAudioSource import dev.typetype.android.domain.stream.StreamPlaybackContract import dev.typetype.android.domain.stream.StreamSubtitleSource +import dev.typetype.android.domain.stream.StreamVideoSource import dev.typetype.android.services.MergedStreamMediaKeys import dev.typetype.android.services.sabrPlaybackBinding import dev.typetype.android.services.sabrPlaybackTarget @@ -216,7 +217,6 @@ private fun pickMergedSource( codecSupport: PlaybackCodecSupport, selectedCodec: String, ): PlayableSource? { - if (selectedAudioKey == null) return null val video = stream.videoOnlyStreams.pickVideo( selectedQuality, codecSupport, @@ -232,7 +232,7 @@ private fun pickMergedSource( ) ?: return null return PlayableSource( url = video.url, - mimeType = video.mimeType.normalizedMimeType(), + mimeType = video.playbackContainerMimeType(), audioUrl = audio.url, audioMimeType = audio.mimeType.normalizedMimeType(), ) @@ -247,7 +247,7 @@ private fun pickMuxedSource( stream.muxedVideoStreams.pickVideo(defaultQuality, codecSupport, selectedCodec)?.let { source -> PlayableSource( url = source.url, - mimeType = source.mimeType.normalizedMimeType() ?: MimeTypes.VIDEO_MP4, + mimeType = source.playbackContainerMimeType() ?: MimeTypes.VIDEO_MP4, ) } @@ -279,6 +279,9 @@ private fun applyTrackSelectionDefaults( private fun String.normalizedMimeType(): String? = substringBefore(";").trim().takeIf { it.isNotBlank() } +internal fun StreamVideoSource.playbackContainerMimeType(): String? = + (playbackMimeType ?: mimeType).normalizedMimeType() + internal val StreamAudioSource.key: String get() = audioTrackId?.takeIf { it.isNotBlank() } ?: audioLocale?.takeIf { it.isNotBlank() } diff --git a/app/src/main/java/dev/typetype/android/feature/player/ProviderPlaybackSource.kt b/app/src/main/java/dev/typetype/android/feature/player/ProviderPlaybackSource.kt index 5e452b4f..5fab22af 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/ProviderPlaybackSource.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/ProviderPlaybackSource.kt @@ -17,7 +17,7 @@ internal fun Stream.pickExplicitProviderSource( if (!video.isVideoOnly) { return PlayableSource( url = video.url, - mimeType = video.mimeType.normalizedProviderMimeType() ?: MimeTypes.VIDEO_MP4, + mimeType = video.playbackContainerMimeType() ?: MimeTypes.VIDEO_MP4, sourceKey = video.videoSelectionKey(), ) } @@ -31,7 +31,7 @@ internal fun Stream.pickExplicitProviderSource( ) ?: return null return PlayableSource( url = video.url, - mimeType = video.mimeType.normalizedProviderMimeType(), + mimeType = video.playbackContainerMimeType(), audioUrl = audio.url, audioMimeType = audio.mimeType.normalizedProviderMimeType(), sourceKey = video.videoSelectionKey(), diff --git a/app/src/main/java/dev/typetype/android/feature/player/error/StreamErrorClassifier.kt b/app/src/main/java/dev/typetype/android/feature/player/error/StreamErrorClassifier.kt index 6c8fbbaa..51fe6828 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/error/StreamErrorClassifier.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/error/StreamErrorClassifier.kt @@ -20,6 +20,7 @@ private val SCHEDULED_PREMIERE_NEEDLES = listOf( "premieres in", "premiere has not started", "premiere scheduled", + "live event will begin", ) private val PAID_CONTENT_NEEDLES = listOf( @@ -107,6 +108,7 @@ fun parseGeoRestriction(message: String?): String? { enum class StreamErrorKind { Generic, AuthenticationExpired, + ContentUnavailable, MemberOnly, PaidContent, ScheduledPremiere, @@ -168,10 +170,14 @@ fun classifyStreamError(failure: Throwable): StreamErrorClass { if (code == "members_only") { return StreamErrorClass(StreamErrorKind.MemberOnly, rawMessage = null, requestId = requestId) } + + if (code in CONTENT_UNAVAILABLE_CODES) { + return StreamErrorClass(StreamErrorKind.ContentUnavailable, rawMessage = null, requestId = requestId) + } if (code == "paid_content") { return StreamErrorClass(StreamErrorKind.PaidContent, rawMessage = null, requestId = requestId) } - if (code == "scheduled_premiere") { + if (code == "scheduled_premiere" || code == "live_not_started") { return StreamErrorClass(StreamErrorKind.ScheduledPremiere, rawMessage = null, requestId = requestId) } if (code == "youtube_session_needs_reconnect" || code == "youtube_session_unavailable") { @@ -268,6 +274,11 @@ private val SABR_UNAVAILABLE_CODES = setOf( "youtube_sabr_preparation_failed", ) +private val CONTENT_UNAVAILABLE_CODES = setOf( + "content_unavailable", + "provider_access_blocked", +) + private val SABR_CONTRACT_CODES = setOf( "youtube_sabr_contract_mismatch", "youtube_android_playback_incompatible", 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 323ad25a..883af2bc 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 @@ -92,6 +92,12 @@ internal fun PushNotificationsScreen( if (state.status == PushRegistrationStatus.MissingDistributor) { DistributorRow() } + Text( + text = stringResource(R.string.push_local_fallback), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(20.dp, 4.dp), + ) if (state.capabilityEnabled && state.deviceCount > 0) { StatusRow( label = stringResource(R.string.push_devices_count, state.deviceCount, state.maxDevices), diff --git a/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsViewModel.kt b/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsViewModel.kt index abd2f67a..fd691660 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsViewModel.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsViewModel.kt @@ -3,6 +3,7 @@ package dev.typetype.android.feature.settings.notifications import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import dev.typetype.android.data.notifications.LocalNotificationScheduler import dev.typetype.android.domain.push.PushRegistrationStatus import dev.typetype.android.domain.push.PushRepository import dev.typetype.android.services.push.PushRegistrationManager @@ -32,6 +33,7 @@ data class PushSettingsState( class PushNotificationsViewModel @Inject constructor( private val pushRepository: PushRepository, private val registrationManager: PushRegistrationManager, + private val localNotificationScheduler: LocalNotificationScheduler, ) : ViewModel() { private val _state = MutableStateFlow(PushSettingsState()) val state = _state.asStateFlow() @@ -44,6 +46,7 @@ class PushNotificationsViewModel @Inject constructor( viewModelScope.launch { registrationManager.status.collect { status -> _state.update { it.copy(status = status) } + localNotificationScheduler.sync() } } } @@ -92,4 +95,5 @@ class PushNotificationsViewModel @Inject constructor( } } } + } diff --git a/app/src/main/java/dev/typetype/android/feature/shorts/ShortsViewModel.kt b/app/src/main/java/dev/typetype/android/feature/shorts/ShortsViewModel.kt index f1f17c70..a79ec3b5 100644 --- a/app/src/main/java/dev/typetype/android/feature/shorts/ShortsViewModel.kt +++ b/app/src/main/java/dev/typetype/android/feature/shorts/ShortsViewModel.kt @@ -14,6 +14,7 @@ import dev.typetype.android.domain.library.cacheVideos import dev.typetype.android.domain.usersettings.UserSettingsRepository import dev.typetype.android.domain.usersettings.UserSettings import dev.typetype.android.feature.player.PlaybackCodecSupport +import dev.typetype.android.feature.player.PlaybackPreheater import javax.inject.Inject import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job @@ -32,7 +33,7 @@ class ShortsViewModel @Inject constructor( private val videoMetaRepository: VideoMetaRepository, private val userSettingsRepository: UserSettingsRepository, private val errorMapper: UserErrorMapper, - private val playbackPreheater: ShortsPlaybackPreheater, + private val playbackPreheater: PlaybackPreheater, ) : ViewModel() { private val _state = MutableStateFlow(ShortsState()) val state = _state.asStateFlow() 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 d78f6dd9..7a73ac36 100644 --- a/app/src/main/java/dev/typetype/android/services/PlaybackQueueItemResolver.kt +++ b/app/src/main/java/dev/typetype/android/services/PlaybackQueueItemResolver.kt @@ -42,7 +42,7 @@ class PlaybackQueueItemResolver @Inject constructor( preferOriginalLanguage = settings.preferOriginalLanguage, codecSupport = codecSupport, prepareSabrPlayback = { loaded, selection, _ -> - sabrPlaybackRepository.prepare(loaded.sabrPlaybackTarget(selection)).getOrThrow() + sabrPlaybackRepository.prewarm(loaded.sabrPlaybackTarget(selection)).getOrThrow() }, ), ) { "No playable source for queued video" } 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 7fef2ea0..ef8bf960 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 @@ -14,6 +14,7 @@ import androidx.core.app.NotificationManagerCompat import dagger.hilt.android.qualifiers.ApplicationContext 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 @@ -23,34 +24,67 @@ import javax.inject.Singleton class PushNotifier @Inject constructor( @ApplicationContext private val context: Context, ) { + fun canNotify(): Boolean = NotificationManagerCompat.from(context).areNotificationsEnabled() + 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 notifySubscription(item: NotificationItem) { + 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, + 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 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { manager.createNotificationChannel(notificationChannel()) } - manager.notify(payload.notificationId(), buildNotification(payload)) + manager.notify(notificationId, buildNotification(title, text, videoUrl, notificationId)) } - private fun buildNotification(payload: PushPayload): Notification { + private fun buildNotification( + title: String, + text: String, + videoUrl: String, + notificationId: Int, + ): Notification { val deepLink = Intent( Intent.ACTION_VIEW, - Uri.parse("typetype://watch?v=${toPublicWatchParameter(payload.videoUrl)}"), + Uri.parse("typetype://watch?v=${toPublicWatchParameter(videoUrl)}"), ) val contentIntent = PendingIntent.getActivity( context, - payload.notificationId(), + notificationId, deepLink, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) - val fallbackTitle = payload.channelName.ifBlank { payload.serviceName } return NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(R.drawable.ic_launcher_monochrome) - .setContentTitle(fallbackTitle) - .setContentText(payload.title) - .setStyle(NotificationCompat.BigTextStyle().bigText(payload.title)) + .setContentTitle(title) + .setContentText(text) + .setStyle(NotificationCompat.BigTextStyle().bigText(text)) .setContentIntent(contentIntent) .setAutoCancel(true) + .setOnlyAlertOnce(true) .build() } @@ -61,9 +95,10 @@ class PushNotifier @Inject constructor( NotificationManager.IMPORTANCE_DEFAULT, ) - private fun PushPayload.notificationId(): Int = eventId.hashCode() - private companion object { const val CHANNEL_ID = "subscription_push" } } + +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/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8296c3b8..29ef1893 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1211,6 +1211,7 @@ Install a UnifiedPush distributor such as ntfy to receive notifications. Choose distributor Couldn\'t update push notifications + The app also checks for new videos every 15 minutes Turn on new video notifications Turn off new video notifications Disable battery optimization for reliable notification delivery. diff --git a/app/src/test/java/dev/typetype/android/core/url/PercentEncodingTest.kt b/app/src/test/java/dev/typetype/android/core/url/PercentEncodingTest.kt new file mode 100644 index 00000000..39535ac5 --- /dev/null +++ b/app/src/test/java/dev/typetype/android/core/url/PercentEncodingTest.kt @@ -0,0 +1,25 @@ +package dev.typetype.android.core.url + +import org.junit.Assert.assertEquals +import org.junit.Test + +class PercentEncodingTest { + @Test + fun `keeps unreserved characters`() { + assertEquals("aZ0-_.~", percentEncode("aZ0-_.~")) + } + + @Test + fun `escapes reserved query characters`() { + assertEquals( + "https%3A%2F%2Fexample.test%2Fwatch%3Fv%3Da%26b%3Dc", + percentEncode("https://example.test/watch?v=a&b=c"), + ) + } + + @Test + fun `escapes spaces and multibyte characters as utf8`() { + assertEquals("a%20b", percentEncode("a b")) + assertEquals("%C3%A9t%C3%A9", percentEncode("été")) + } +} 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 new file mode 100644 index 00000000..fea55273 --- /dev/null +++ b/app/src/test/java/dev/typetype/android/data/notifications/LocalNotificationDecisionTest.kt @@ -0,0 +1,130 @@ +package dev.typetype.android.data.notifications + +import dev.typetype.android.domain.feed.Video +import dev.typetype.android.domain.notifications.NotificationItem +import dev.typetype.android.domain.push.ChannelNotificationsPreference +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class LocalNotificationDecisionTest { + @Test + fun `first poll records the backlog without notifying`() { + val decision = decideLocalNotifications( + items = listOf(item(videoId = "a", channelUrl = CHANNEL), item(videoId = "b", channelUrl = CHANNEL)), + knownKeys = emptySet(), + seeded = false, + enabledChannels = listOf(ChannelNotificationsPreference(CHANNEL, enabled = true)), + ) + + assertEquals(listOf("a", "b"), decision.keysToRecord) + assertTrue(decision.notified.isEmpty()) + } + + @Test + fun `alerts only new videos from enabled channels`() { + val decision = decideLocalNotifications( + items = listOf( + item(videoId = "new-enabled", channelUrl = CHANNEL), + item(videoId = "new-silent", channelUrl = OTHER_CHANNEL), + item(videoId = "seen", channelUrl = CHANNEL), + ), + knownKeys = setOf("seen"), + seeded = true, + enabledChannels = listOf(ChannelNotificationsPreference(CHANNEL, enabled = true)), + ) + + assertEquals(listOf("new-enabled", "new-silent"), decision.keysToRecord) + assertEquals(listOf("new-enabled"), decision.notified.map { it.video.id }) + } + + @Test + fun `disabled channels are recorded but never notified`() { + val decision = decideLocalNotifications( + items = listOf(item(videoId = "a", channelUrl = CHANNEL)), + knownKeys = emptySet(), + seeded = true, + enabledChannels = listOf(ChannelNotificationsPreference(CHANNEL, enabled = false)), + ) + + assertEquals(listOf("a"), decision.keysToRecord) + assertTrue(decision.notified.isEmpty()) + } + + @Test + fun `preference matches a channel url with an extra path segment on either side`() { + val trailingOnItem = decideLocalNotifications( + items = listOf(item(videoId = "a", channelUrl = "$CHANNEL/videos")), + knownKeys = emptySet(), + seeded = true, + enabledChannels = listOf(ChannelNotificationsPreference(CHANNEL, enabled = true)), + ) + val trailingOnPreference = decideLocalNotifications( + items = listOf(item(videoId = "a", channelUrl = CHANNEL)), + knownKeys = emptySet(), + seeded = true, + enabledChannels = listOf(ChannelNotificationsPreference("$CHANNEL/videos", enabled = true)), + ) + + assertEquals(listOf("a"), trailingOnItem.notified.map { it.video.id }) + assertEquals(listOf("a"), trailingOnPreference.notified.map { it.video.id }) + } + + @Test + fun `a longer channel identifier is not treated as the same channel`() { + val decision = decideLocalNotifications( + items = listOf(item(videoId = "a", channelUrl = "${CHANNEL}extra")), + knownKeys = emptySet(), + seeded = true, + enabledChannels = listOf(ChannelNotificationsPreference(CHANNEL, enabled = true)), + ) + + assertTrue(decision.notified.isEmpty()) + } + + @Test + fun `newest first input is notified oldest first`() { + val decision = decideLocalNotifications( + items = listOf( + item(videoId = "newest", channelUrl = CHANNEL), + item(videoId = "older", channelUrl = CHANNEL), + ), + knownKeys = emptySet(), + seeded = true, + enabledChannels = listOf(ChannelNotificationsPreference(CHANNEL, enabled = true)), + ) + + assertEquals(listOf("older", "newest"), decision.notified.map { it.video.id }) + } + + private fun item(videoId: String, channelUrl: String) = NotificationItem( + type = "subscription_new_video", + title = "Video $videoId", + createdAtMillis = 1L, + publishedAtMillis = 1L, + channelUrl = channelUrl, + channelName = "Channel", + channelAvatarUrl = "", + video = Video( + id = videoId, + url = "https://example.test/watch?v=$videoId", + title = "Video $videoId", + thumbnailUrl = "", + uploaderName = "Channel", + uploaderUrl = channelUrl, + uploaderAvatarUrl = "", + uploaderVerified = false, + durationSeconds = 0L, + isLive = false, + viewCount = 0L, + uploadedAtMillis = 1L, + isShortFormContent = false, + shortDescription = null, + ), + ) + + private companion object { + const val CHANNEL = "https://www.youtube.com/channel/UCaaaaaaaaaaaaaaaaaaaaaa" + const val OTHER_CHANNEL = "https://www.youtube.com/channel/UCbbbbbbbbbbbbbbbbbbbbbb" + } +} diff --git a/app/src/test/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparerTest.kt b/app/src/test/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparerTest.kt index 0af6a50c..24c62e96 100644 --- a/app/src/test/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparerTest.kt +++ b/app/src/test/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparerTest.kt @@ -81,6 +81,30 @@ class SabrPlaybackSessionPreparerTest { assertEquals("140", body.getValue("audioItag").jsonPrimitive.content) } + @Test + fun prewarmCreationMarksTheSessionForReuse() = runBlocking { + enqueueCreate(startTimeMs = 0, ready = true) + enqueuePosition(0) + enqueuePrefetch(ready = true) + enqueueWindow(startTimeMs = 0, endOfStream = false) + + preparer().prepare(api, baseUrl, target(), 0L, prewarm = true) + + assertEquals("/api/sabr/playback/video?prewarm=true", server.takeRequest().path) + } + + @Test + fun playbackCreationOmitsThePrewarmFlag() = runBlocking { + enqueueCreate(startTimeMs = 0, ready = true) + enqueuePosition(0) + enqueuePrefetch(ready = true) + enqueueWindow(startTimeMs = 0, endOfStream = false) + + preparer().prepare(api, baseUrl, target(), 0L) + + assertEquals("/api/sabr/playback/video", server.takeRequest().path) + } + @Test fun refreshReportsBufferedRangesForBothSelectedTracks() = runBlocking { enqueuePosition(55_000) diff --git a/app/src/test/java/dev/typetype/android/data/stream/StreamEndpointLoaderTest.kt b/app/src/test/java/dev/typetype/android/data/stream/StreamEndpointLoaderTest.kt index ee6e3e7d..2724714e 100644 --- a/app/src/test/java/dev/typetype/android/data/stream/StreamEndpointLoaderTest.kt +++ b/app/src/test/java/dev/typetype/android/data/stream/StreamEndpointLoaderTest.kt @@ -73,6 +73,73 @@ class StreamEndpointLoaderTest { assertEquals(1, server.requestCount) } + @Test + fun youtubeLiveHlsResponseIsAcceptedAsLiveContract() = runBlocking { + server.enqueue( + jsonResponse( + sabr = false, + live = true, + hlsUrl = "/streams/hls-manifest?token=signed", + ), + ) + + val body = api.loadStreamResponse(YOUTUBE_URL).body() + + assertFalse(body?.hasPlayableSabrContract(server.url("/").toString()) == true) + assertTrue(body?.hasPlayableLiveContract(server.url("/").toString()) == true) + assertEquals(1, server.requestCount) + } + + @Test + fun youtubeLiveHlsResponseOnAnotherOriginIsRejected() = runBlocking { + server.enqueue( + jsonResponse( + sabr = false, + live = true, + hlsUrl = "https://media.example/live.m3u8", + ), + ) + + val body = api.loadStreamResponse(YOUTUBE_URL).body() + + assertFalse(body?.hasPlayableLiveContract(server.url("/").toString()) == true) + } + + @Test + fun youtubePlaybackFallsBackWhenTheBootstrapRouteCannotServeTheVideo() = runBlocking { + server.enqueue( + MockResponse() + .setResponseCode(503) + .setHeader("Content-Type", "application/json") + .setBody("{\"error\":\"The video provider is blocking requests\",\"code\":\"provider_access_blocked\"}"), + ) + server.enqueue( + jsonResponse( + sabr = false, + live = true, + hlsUrl = "/streams/hls-manifest?token=signed", + ), + ) + + val response = api.loadYouTubeSabrBootstrapResponse(YOUTUBE_URL) + val body = response.body() + + assertTrue(response.isSuccessful) + assertTrue(body?.hasPlayableLiveContract(server.url("/").toString()) == true) + assertEquals("/streams/youtube/sabr/bootstrap?url=${encode(YOUTUBE_URL)}", server.takeRequest().path) + assertEquals("/streams/youtube/sabr?url=${encode(YOUTUBE_URL)}", server.takeRequest().path) + } + + @Test + fun youtubePlaybackKeepsABootstrapFailureThatTheSabrRouteCannotFix() = runBlocking { + server.enqueue(errorResponse(401)) + + val response = api.loadYouTubeSabrBootstrapResponse(YOUTUBE_URL) + + assertEquals(401, response.code()) + assertEquals(1, server.requestCount) + } + @Test fun youtubeSabrResponseWithoutValidItagsIsRejected() = runBlocking { server.enqueue(jsonResponse(sabr = true, videoItag = 0)) @@ -162,6 +229,8 @@ class StreamEndpointLoaderTest { private fun jsonResponse( sabr: Boolean, hls: Boolean = false, + live: Boolean = false, + hlsUrl: String? = null, videoItag: Int = 137, manifestUrl: String = "/sabr/manifest/video", videoCodec: String = "avc1.640028", @@ -170,7 +239,18 @@ class StreamEndpointLoaderTest { ): MockResponse = MockResponse() .setResponseCode(200) .setHeader("Content-Type", "application/json") - .setBody(streamJson(sabr, hls, videoItag, manifestUrl, videoCodec, audioMimeType, audioCodec)) + .setBody( + streamJson( + sabr = sabr, + live = live, + hlsUrlValue = hlsUrl ?: if (hls) "https://media.example/live.m3u8" else "", + videoItag = videoItag, + manifestUrl = manifestUrl, + videoCodec = videoCodec, + audioMimeType = audioMimeType, + audioCodec = audioCodec, + ), + ) private fun errorResponse(code: Int): MockResponse = MockResponse() .setResponseCode(code) @@ -179,7 +259,8 @@ class StreamEndpointLoaderTest { private fun streamJson( sabr: Boolean, - hls: Boolean, + live: Boolean, + hlsUrlValue: String, videoItag: Int, manifestUrl: String, videoCodec: String, @@ -189,7 +270,6 @@ class StreamEndpointLoaderTest { val delivery = if (sabr) "sabr" else "progressive" val manifest = if (sabr) "\"$manifestUrl\"" else "null" val mediaUrl = if (sabr) "" else "https://media.example/video.mp4" - val hlsUrl = if (hls) "https://media.example/live.m3u8" else "" return """ { "id":"video","title":"Video","uploaderName":"Channel","uploaderUrl":"/channel", @@ -197,7 +277,8 @@ class StreamEndpointLoaderTest { "viewCount":1,"likeCount":0,"dislikeCount":0,"uploadDate":"","uploaded":-1, "uploaderSubscriberCount":0,"uploaderVerified":false,"category":"","license":"", "visibility":"public","streamType":"VIDEO_STREAM","isShortFormContent":false, - "requiresMembership":false,"startPosition":0,"hlsUrl":"$hlsUrl","dashMpdUrl":"", + "requiresMembership":false,"startPosition":0,"hlsUrl":"$hlsUrlValue","dashMpdUrl":"", + "isLive":$live,"hasLiveManifest":$live, "videoStreams":[], "videoOnlyStreams":[{"url":"$mediaUrl","mimeType":"video/mp4","format":"MPEG_4", "resolution":"1080p","codec":"$videoCodec","isVideoOnly":true,"itag":$videoItag, diff --git a/app/src/test/java/dev/typetype/android/data/stream/StreamRepositoryLiveTest.kt b/app/src/test/java/dev/typetype/android/data/stream/StreamRepositoryLiveTest.kt new file mode 100644 index 00000000..83d8be37 --- /dev/null +++ b/app/src/test/java/dev/typetype/android/data/stream/StreamRepositoryLiveTest.kt @@ -0,0 +1,201 @@ +package dev.typetype.android.data.stream + +import dev.typetype.android.core.error.CodedFailure +import dev.typetype.android.data.account.AccountScope +import dev.typetype.android.data.account.AccountScopeProvider +import dev.typetype.android.data.network.AlwaysAvailablePlaybackNetworkObserver +import dev.typetype.android.data.network.dto.AudioStreamItem +import dev.typetype.android.data.network.dto.StreamResponse +import dev.typetype.android.data.network.dto.VideoStreamItem +import dev.typetype.android.domain.server.Server +import dev.typetype.android.domain.server.ServerRepository +import dev.typetype.android.domain.stream.StreamPlaybackContract +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import retrofit2.Response + +class StreamRepositoryLiveTest { + @Test + fun `youtube live hls response resolves the signed server manifest`() = runBlocking { + val repository = repository( + response( + isLive = true, + hasLiveManifest = true, + hlsUrl = LIVE_HLS_URL, + ), + ) + + val stream = repository.loadPlaybackStream(VIDEO_URL).getOrThrow() + + assertEquals(StreamPlaybackContract.ProviderMedia, stream.playbackContract) + assertEquals("$BASE_URL$LIVE_HLS_PATH", stream.hlsUrl) + assertNull(stream.serverSabrManifestUrl) + assertNull(stream.serverHlsManifestUrl) + assertNull(stream.serverDashManifestUrl) + assertTrue(stream.isLive) + assertTrue(stream.sabrVideoStreams.isEmpty()) + } + + @Test + fun `youtube live response without a server manifest is rejected`() = runBlocking { + val repository = repository( + response( + isLive = true, + hasLiveManifest = true, + hlsUrl = "https://media.example/live.m3u8", + ), + ) + + val failure = repository.loadPlaybackStream(VIDEO_URL).exceptionOrNull() + + assertEquals("youtube_sabr_unavailable", (failure as CodedFailure).failureCode) + } + + @Test + fun `youtube video without sabr or live manifest is rejected`() = runBlocking { + val repository = repository(response(isLive = false, hasLiveManifest = false, hlsUrl = "")) + + val failure = repository.loadPlaybackStream(VIDEO_URL).exceptionOrNull() + + assertEquals("youtube_sabr_unavailable", (failure as CodedFailure).failureCode) + } + + @Test + fun `youtube vod keeps the sabr contract and drops provider manifests`() = runBlocking { + val repository = repository( + response( + isLive = false, + hasLiveManifest = false, + hlsUrl = "", + sabrFormats = true, + ), + ) + + val stream = repository.loadPlaybackStream(VIDEO_URL).getOrThrow() + + assertEquals(StreamPlaybackContract.ServerSabr, stream.playbackContract) + assertNull(stream.hlsUrl) + assertEquals("$BASE_URL${SABR_MANIFEST_PATH.substringAfter('/')}", stream.serverSabrManifestUrl) + } + + private fun repository(response: StreamResponse) = StreamRepositoryImpl( + remoteSource = FixedStreamRemoteSource(response), + activeAccountScope = FixedAccountScope, + serverRepository = FixedServerRepository, + networkMonitor = AlwaysAvailablePlaybackNetworkObserver, + ) + + private class FixedStreamRemoteSource( + private val response: StreamResponse, + ) : StreamRemoteSource { + override suspend fun load( + scope: AccountScope, + videoUrl: String, + provider: StreamProvider, + playbackBootstrap: Boolean, + ): Response = Response.success(response) + } + + private object FixedAccountScope : AccountScopeProvider { + private val scope = AccountScope(SERVER_ID, ACCOUNT_ID) + + override fun observe(): Flow = flowOf(scope) + + override suspend fun require(): AccountScope = scope + + override suspend fun verify(expected: AccountScope) { + check(expected == scope) + } + } + + private object FixedServerRepository : ServerRepository { + private val server = Server(SERVER_ID, BASE_URL, "Instance", 0L) + + override fun observeServers(): Flow> = flowOf(listOf(server)) + override fun observeCurrentServer(): Flow = flowOf(server) + override suspend fun getServer(id: String): Server? = server.takeIf { id == SERVER_ID } + override suspend fun addServer(server: Server) = Unit + override suspend fun deleteServer(id: String) = Unit + override suspend fun setCurrentServer(id: String) = Unit + override suspend fun clearCurrentServer() = Unit + } + + private fun response( + isLive: Boolean, + hasLiveManifest: Boolean, + hlsUrl: String, + sabrFormats: Boolean = false, + ) = StreamResponse( + id = "video", + title = "Video", + uploaderName = "Channel", + uploaderUrl = "/channel", + uploaderAvatarUrl = "", + thumbnailUrl = "", + description = "", + duration = if (isLive) 0L else 60L, + viewCount = 1L, + likeCount = 0L, + dislikeCount = 0L, + uploadDate = "", + uploaded = 0L, + uploaderSubscriberCount = 1L, + uploaderVerified = false, + category = "", + license = "", + visibility = "public", + streamType = if (isLive) "LIVE_STREAM" else "VIDEO_STREAM", + isShortFormContent = false, + requiresMembership = false, + isLive = isLive, + hasLiveManifest = hasLiveManifest, + startPosition = 0L, + hlsUrl = hlsUrl, + dashMpdUrl = "", + videoOnlyStreams = if (sabrFormats) listOf(videoStream()) else emptyList(), + audioStreams = if (sabrFormats) listOf(audioStream()) else emptyList(), + ) + + private fun videoStream() = VideoStreamItem( + url = "", + mimeType = "video/mp4", + format = "MPEG_4", + resolution = "720p", + codec = "avc1.64001f", + isVideoOnly = true, + itag = 136, + width = 1280, + height = 720, + fps = 30, + contentLength = 1L, + deliveryMethod = "sabr", + manifestUrl = SABR_MANIFEST_PATH, + ) + + private fun audioStream() = AudioStreamItem( + url = "", + mimeType = "audio/mp4", + format = "MPEG_4", + codec = "mp4a.40.2", + itag = 140, + contentLength = 1L, + isOriginal = true, + deliveryMethod = "sabr", + manifestUrl = SABR_MANIFEST_PATH, + ) + + private companion object { + const val SERVER_ID = "server" + const val ACCOUNT_ID = "account" + const val BASE_URL = "https://instance.example/api/" + const val VIDEO_URL = "https://www.youtube.com/watch?v=video" + const val SABR_MANIFEST_PATH = "/sabr/manifest/video" + const val LIVE_HLS_PATH = "streams/hls-manifest?token=signed" + const val LIVE_HLS_URL = "/$LIVE_HLS_PATH" + } +} diff --git a/app/src/test/java/dev/typetype/android/data/stream/StreamRepositoryProviderMediaTest.kt b/app/src/test/java/dev/typetype/android/data/stream/StreamRepositoryProviderMediaTest.kt new file mode 100644 index 00000000..4b281bbd --- /dev/null +++ b/app/src/test/java/dev/typetype/android/data/stream/StreamRepositoryProviderMediaTest.kt @@ -0,0 +1,203 @@ +package dev.typetype.android.data.stream + +import dev.typetype.android.data.account.AccountScope +import dev.typetype.android.data.account.AccountScopeProvider +import dev.typetype.android.data.network.AlwaysAvailablePlaybackNetworkObserver +import dev.typetype.android.data.network.dto.AudioStreamItem +import dev.typetype.android.data.network.dto.StreamResponse +import dev.typetype.android.data.network.dto.VideoStreamItem +import dev.typetype.android.domain.server.Server +import dev.typetype.android.domain.server.ServerRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import retrofit2.Response + +class StreamRepositoryProviderMediaTest { + @Test + fun `niconico media handles resolve and keep hls playback container`() = runBlocking { + val repository = repository( + NICO_URL, + response( + hlsVideoHandles = true, + videoHandles = listOf(HANDLE_A, HANDLE_B), + audioHandles = listOf(HANDLE_AUDIO), + ), + ) + + val stream = repository.loadPlaybackStream(NICO_URL).getOrThrow() + val video = stream.videoOnlyStreams.first() + + assertEquals("$BASE_URL${HANDLE_A.substringAfter('/')}", video.url) + assertEquals("application/vnd.apple.mpegurl", video.playbackMimeType) + assertEquals(360, video.height) + assertEquals("$BASE_URL${HANDLE_AUDIO.substringAfter('/')}", stream.audioStreams.first().url) + assertNull(stream.hlsUrl) + } + + @Test + fun `bilibili progressive handles resolve without a playback container`() = runBlocking { + val repository = repository( + BILI_URL, + response( + hlsVideoHandles = false, + videoHandles = listOf(HANDLE_A), + audioHandles = listOf(HANDLE_AUDIO), + height = 384, + codec = "avc1.64001E", + ), + ) + + val stream = repository.loadPlaybackStream(BILI_URL).getOrThrow() + + assertEquals("$BASE_URL${HANDLE_A.substringAfter('/')}", stream.videoOnlyStreams.first().url) + assertNull(stream.videoOnlyStreams.first().playbackMimeType) + assertEquals(384, stream.videoOnlyStreams.first().height) + } + + @Test + fun `absolute provider media url is preserved`() = runBlocking { + val repository = repository( + NICO_URL, + response( + hlsVideoHandles = false, + videoHandles = listOf(), + audioHandles = listOf(), + absoluteUrl = "https://cdn.example/media.m3u8", + ), + ) + + val stream = repository.loadPlaybackStream(NICO_URL).getOrThrow() + + assertEquals("https://cdn.example/media.m3u8", stream.videoOnlyStreams.first().url) + } + + private fun repository(videoUrl: String, response: StreamResponse) = StreamRepositoryImpl( + remoteSource = FixedStreamRemoteSource(videoUrl, response), + activeAccountScope = FixedAccountScope, + serverRepository = FixedServerRepository, + networkMonitor = AlwaysAvailablePlaybackNetworkObserver, + ) + + private class FixedStreamRemoteSource( + private val expectedUrl: String, + private val response: StreamResponse, + ) : StreamRemoteSource { + override suspend fun load( + scope: AccountScope, + videoUrl: String, + provider: StreamProvider, + playbackBootstrap: Boolean, + ): Response { + check(videoUrl == expectedUrl) + return Response.success(response) + } + } + + private object FixedAccountScope : AccountScopeProvider { + private val scope = AccountScope(SERVER_ID, ACCOUNT_ID) + + override fun observe(): Flow = flowOf(scope) + + override suspend fun require(): AccountScope = scope + + override suspend fun verify(expected: AccountScope) { + check(expected == scope) + } + } + + private object FixedServerRepository : ServerRepository { + private val server = Server(SERVER_ID, BASE_URL, "Instance", 0L) + + override fun observeServers(): Flow> = flowOf(listOf(server)) + override fun observeCurrentServer(): Flow = flowOf(server) + override suspend fun getServer(id: String): Server? = server.takeIf { id == SERVER_ID } + override suspend fun addServer(server: Server) = Unit + override suspend fun deleteServer(id: String) = Unit + override suspend fun setCurrentServer(id: String) = Unit + override suspend fun clearCurrentServer() = Unit + } + + private fun response( + hlsVideoHandles: Boolean, + videoHandles: List, + audioHandles: List, + height: Int = 0, + codec: String? = null, + absoluteUrl: String? = null, + ) = StreamResponse( + id = "video", + title = "Video", + uploaderName = "Channel", + uploaderUrl = "/channel", + uploaderAvatarUrl = "", + thumbnailUrl = "", + description = "", + duration = 320L, + viewCount = 1L, + likeCount = 0L, + dislikeCount = 0L, + uploadDate = "", + uploaded = 0L, + uploaderSubscriberCount = 1L, + uploaderVerified = false, + category = "", + license = "", + visibility = "public", + streamType = "VIDEO_STREAM", + isShortFormContent = false, + requiresMembership = false, + startPosition = 0L, + hlsUrl = "", + dashMpdUrl = "", + videoOnlyStreams = (videoHandles + listOfNotNull(absoluteUrl)).map { handle -> + videoStream(handle, hlsVideoHandles, height, codec) + }, + audioStreams = audioHandles.map { handle -> audioStream(handle) }, + ) + + private fun videoStream( + url: String, + hls: Boolean, + height: Int, + codec: String?, + ) = VideoStreamItem( + url = url, + mimeType = "video/mp4", + format = "MPEG-4", + resolution = "360p", + codec = codec, + isVideoOnly = true, + itag = -1, + width = 0, + height = height, + fps = 0, + contentLength = 0L, + deliveryMethod = if (hls) "hls" else "progressive", + ) + + private fun audioStream(url: String) = AudioStreamItem( + url = url, + mimeType = "audio/mp4", + format = "m4a", + codec = null, + itag = -1, + contentLength = 0L, + isOriginal = false, + deliveryMethod = "progressive", + ) + + private companion object { + const val SERVER_ID = "server" + const val ACCOUNT_ID = "account" + const val BASE_URL = "https://instance.example/api/" + const val NICO_URL = "https://www.nicovideo.jp/watch/sm9" + const val BILI_URL = "https://www.bilibili.com/video/BV1xx411c7mD" + const val HANDLE_A = "/media/m1_WcTj0wQ0RcWaByyoMvP8E0ak" + const val HANDLE_B = "/media/m1_vyvt1J_-qiWsTYEyHrEP_q-D" + const val HANDLE_AUDIO = "/media/m1_X9jjkzoAVheGT2QYaOYSR08A" + } +} diff --git a/app/src/test/java/dev/typetype/android/feature/player/DanmakuSupportTest.kt b/app/src/test/java/dev/typetype/android/feature/player/DanmakuSupportTest.kt index de755238..ea1b6510 100644 --- a/app/src/test/java/dev/typetype/android/feature/player/DanmakuSupportTest.kt +++ b/app/src/test/java/dev/typetype/android/feature/player/DanmakuSupportTest.kt @@ -12,9 +12,17 @@ class DanmakuSupportTest { assertTrue(supportsServerBulletComments("https://nico.ms/sm9")) } + @Test + fun acceptsSupportedBiliBiliHosts() { + assertTrue(supportsServerBulletComments("https://www.bilibili.com/video/BV14whX66Eka?p=1")) + assertTrue(supportsServerBulletComments("https://bilibili.com/video/BV14whX66Eka")) + assertTrue(supportsServerBulletComments("https://b23.tv/abc123")) + } + @Test fun rejectsLookalikeAndUnrelatedHosts() { assertFalse(supportsServerBulletComments("https://nicovideo.jp.example.com/watch/sm9")) + assertFalse(supportsServerBulletComments("https://bilibili.com.example.com/video/BV1")) assertFalse(supportsServerBulletComments("https://youtube.com/watch?v=sm9")) assertFalse(supportsServerBulletComments("not a url")) } diff --git a/app/src/test/java/dev/typetype/android/feature/player/PlaybackSourceSelectorTest.kt b/app/src/test/java/dev/typetype/android/feature/player/PlaybackSourceSelectorTest.kt index 0a1981ca..2b6b11c2 100644 --- a/app/src/test/java/dev/typetype/android/feature/player/PlaybackSourceSelectorTest.kt +++ b/app/src/test/java/dev/typetype/android/feature/player/PlaybackSourceSelectorTest.kt @@ -4,6 +4,7 @@ import dev.typetype.android.domain.stream.StreamAudioSource import dev.typetype.android.domain.stream.Stream import dev.typetype.android.domain.stream.StreamPlaybackContract import dev.typetype.android.domain.stream.StreamVideoSource +import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Test @@ -257,6 +258,34 @@ class PlaybackSourceSelectorTest { assertEquals(french, selected) } + @Test + fun `provider video with separate audio merges without an explicit audio key`() = runBlocking { + val video = video("video", height = 360, codec = "avc1.64001E") + .copy(playbackMimeType = "application/vnd.apple.mpegurl") + val audio = audio("audio", locale = "ja") + val support = FakeCodecSupport( + video = mapOf(video.url to DecoderSupport.Hardware), + audio = mapOf(audio.url to DecoderSupport.Hardware), + ) + + val source = pickPlayableSource( + stream = providerStream(listOf(video), listOf(audio)), + selectedQuality = RECOMMENDED_QUALITY_KEY, + selectedAudioKey = null, + defaultAudioLanguage = "en", + automaticQualityCap = RECOMMENDED_QUALITY_KEY, + preferOriginalLanguage = false, + codecSupport = support, + prepareSabrPlayback = { _, _, _ -> null }, + ) + + requireNotNull(source) + assertEquals("video", source.url) + assertEquals("audio", source.audioUrl) + assertEquals("application/vnd.apple.mpegurl", source.mimeType) + assertEquals("audio/mp4", source.audioMimeType) + } + private fun video(url: String, height: Int, codec: String, itag: Int = 0) = StreamVideoSource( url = url, mimeType = "video/mp4", diff --git a/app/src/test/java/dev/typetype/android/feature/player/error/StreamErrorClassifierTest.kt b/app/src/test/java/dev/typetype/android/feature/player/error/StreamErrorClassifierTest.kt index bf3c4989..1255c29f 100644 --- a/app/src/test/java/dev/typetype/android/feature/player/error/StreamErrorClassifierTest.kt +++ b/app/src/test/java/dev/typetype/android/feature/player/error/StreamErrorClassifierTest.kt @@ -116,6 +116,36 @@ class StreamErrorClassifierTest { assertEquals(StreamErrorKind.MemberOnly, classifyStreamError(members).kind) } + @Test + fun `classifies scheduled lives the server reports as not started`() { + val scheduledLive = ServerResponseException( + ServerError("This live event will begin in 55 minutes.", "live_not_started", 409, "request-9"), + ) + + val classified = classifyStreamError(scheduledLive) + + assertEquals(StreamErrorKind.ScheduledPremiere, classified.kind) + assertEquals("request-9", classified.requestId) + assertEquals( + StreamErrorKind.ScheduledPremiere, + classifyStreamError("This live event will begin in 55 minutes.").kind, + ) + } + + @Test + fun `maps typed provider failures to content unavailable`() { + val missing = ServerResponseException( + ServerError("This live stream recording is not available.", "content_unavailable", 404, "request-7"), + ) + val blocked = ServerResponseException( + ServerError("Provider blocked this instance", "provider_access_blocked", 403, "request-8"), + ) + + assertEquals(StreamErrorKind.ContentUnavailable, classifyStreamError(missing).kind) + assertEquals("request-7", classifyStreamError(missing).requestId) + assertEquals(StreamErrorKind.ContentUnavailable, classifyStreamError(blocked).kind) + } + @Test fun `finds network failures wrapped by another layer`() { val failure = IllegalStateException("Request failed", IOException("socket closed")) diff --git a/app/src/test/java/dev/typetype/android/services/push/SubscriptionNotificationIdTest.kt b/app/src/test/java/dev/typetype/android/services/push/SubscriptionNotificationIdTest.kt new file mode 100644 index 00000000..a0a5c91b --- /dev/null +++ b/app/src/test/java/dev/typetype/android/services/push/SubscriptionNotificationIdTest.kt @@ -0,0 +1,34 @@ +package dev.typetype.android.services.push + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class SubscriptionNotificationIdTest { + @Test + fun `id follows the video id`() { + assertEquals( + "TIcVuMOK78I".hashCode(), + subscriptionNotificationId("TIcVuMOK78I", "https://www.youtube.com/watch?v=TIcVuMOK78I", "event"), + ) + } + + @Test + fun `id falls back to the video url when the id is blank`() { + val url = "https://www.youtube.com/watch?v=TIcVuMOK78I" + assertEquals(url.hashCode(), subscriptionNotificationId(" ", url, "event")) + } + + @Test + fun `id falls back to the event key when both video fields are blank`() { + assertEquals("event".hashCode(), subscriptionNotificationId("", " ", "event")) + } + + @Test + fun `push and local notifications share the id of the same video`() { + val pushId = subscriptionNotificationId("abc123", "https://example.test/watch?v=abc123", "event") + val localId = subscriptionNotificationId("abc123", "https://example.test/watch?v=abc123", "abc123") + assertEquals(pushId, localId) + assertNotEquals(pushId, subscriptionNotificationId("other", "https://example.test/watch?v=other", "event")) + } +}