From 1f0d6aea8a0e551c6f752abcb7332def1efefb3f Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 28 Sep 2026 13:00:29 +0000 Subject: [PATCH 01/23] fix: send UnifiedPush encryption keys --- .../android/data/network/dto/PushDto.kt | 2 ++ .../data/push/PushRegistrationStore.kt | 28 +++++++++++++++---- .../android/data/push/RemotePushRepository.kt | 9 ++++-- .../android/domain/push/PushRepository.kt | 2 +- .../services/push/PushRegistrationManager.kt | 21 +++++++------- .../services/push/TypeTypePushService.kt | 7 ++++- .../PushDeviceRegistrationRequestDtoTest.kt | 26 +++++++++++++++++ 7 files changed, 74 insertions(+), 21 deletions(-) create mode 100644 app/src/test/java/dev/typetype/android/data/network/dto/PushDeviceRegistrationRequestDtoTest.kt diff --git a/app/src/main/java/dev/typetype/android/data/network/dto/PushDto.kt b/app/src/main/java/dev/typetype/android/data/network/dto/PushDto.kt index 2824192f..77e70599 100644 --- a/app/src/main/java/dev/typetype/android/data/network/dto/PushDto.kt +++ b/app/src/main/java/dev/typetype/android/data/network/dto/PushDto.kt @@ -9,6 +9,8 @@ data class PushDeviceRegistrationRequestDto( val deviceId: String, val platform: String = "android", val endpoint: String, + val p256dh: String, + val auth: String, val expiresAt: Long? = null, ) diff --git a/app/src/main/java/dev/typetype/android/data/push/PushRegistrationStore.kt b/app/src/main/java/dev/typetype/android/data/push/PushRegistrationStore.kt index 46159fc0..fae37d70 100644 --- a/app/src/main/java/dev/typetype/android/data/push/PushRegistrationStore.kt +++ b/app/src/main/java/dev/typetype/android/data/push/PushRegistrationStore.kt @@ -19,6 +19,8 @@ class PushRegistrationStore @Inject constructor( data class Registration( val deviceId: String, val endpoint: String?, + val p256dh: String?, + val auth: String?, ) fun registration(scope: AccountScope): Flow = dataStore.data.map { prefs -> @@ -39,10 +41,11 @@ class PushRegistrationStore @Inject constructor( return dataStore.data.first()[deviceIdKey(scope)] ?: error("The stored push device id disappeared") } - suspend fun setEndpoint(scope: AccountScope, endpoint: String?) { + suspend fun setSubscription(scope: AccountScope, endpoint: String, p256dh: String, auth: String) { dataStore.edit { prefs -> - val key = endpointKey(scope) - if (endpoint == null) prefs.remove(key) else prefs[key] = endpoint + prefs[endpointKey(scope)] = endpoint + prefs[p256dhKey(scope)] = p256dh + prefs[authKey(scope)] = auth } } @@ -50,19 +53,32 @@ class PushRegistrationStore @Inject constructor( dataStore.edit { prefs -> prefs.remove(deviceIdKey(scope)) prefs.remove(endpointKey(scope)) + prefs.remove(p256dhKey(scope)) + prefs.remove(authKey(scope)) } } private fun Preferences.registration(scope: AccountScope): Registration? { val deviceId = this[deviceIdKey(scope)]?.takeIf(String::isNotBlank) ?: return null - return Registration(deviceId, this[endpointKey(scope)]) + return Registration( + deviceId = deviceId, + endpoint = this[endpointKey(scope)], + p256dh = this[p256dhKey(scope)], + auth = this[authKey(scope)], + ) } private fun deviceIdKey(scope: AccountScope) = - stringPreferencesKey("push_device_id_${scopeKey(scope)}") + stringPreferencesKey("push_device_id_" + scopeKey(scope)) private fun endpointKey(scope: AccountScope) = - stringPreferencesKey("push_endpoint_${scopeKey(scope)}") + stringPreferencesKey("push_endpoint_" + scopeKey(scope)) + + private fun p256dhKey(scope: AccountScope) = + stringPreferencesKey("push_p256dh_" + scopeKey(scope)) + + private fun authKey(scope: AccountScope) = + stringPreferencesKey("push_auth_" + scopeKey(scope)) private fun scopeKey(scope: AccountScope) = "${scope.serverId}_${scope.accountId}" } diff --git a/app/src/main/java/dev/typetype/android/data/push/RemotePushRepository.kt b/app/src/main/java/dev/typetype/android/data/push/RemotePushRepository.kt index 7c5fe569..19fea4c0 100644 --- a/app/src/main/java/dev/typetype/android/data/push/RemotePushRepository.kt +++ b/app/src/main/java/dev/typetype/android/data/push/RemotePushRepository.kt @@ -36,11 +36,16 @@ class RemotePushRepository @Inject constructor( ?: PushCapability() } - override suspend fun registerDevice(deviceId: String, endpoint: String): Result = guarded { + override suspend fun registerDevice( + deviceId: String, + endpoint: String, + p256dh: String, + auth: String, + ): Result = guarded { val api = apiHolder.require(requireEligibleScope()) val response = withContext(Dispatchers.IO) { api.registerPushDevice( - PushDeviceRegistrationRequestDto(deviceId = deviceId, endpoint = endpoint), + PushDeviceRegistrationRequestDto(deviceId = deviceId, endpoint = endpoint, p256dh = p256dh, auth = auth), ) } response.requireSuccessfulResponse() diff --git a/app/src/main/java/dev/typetype/android/domain/push/PushRepository.kt b/app/src/main/java/dev/typetype/android/domain/push/PushRepository.kt index 7579a30a..d8fcb3f5 100644 --- a/app/src/main/java/dev/typetype/android/domain/push/PushRepository.kt +++ b/app/src/main/java/dev/typetype/android/domain/push/PushRepository.kt @@ -5,7 +5,7 @@ import dev.typetype.android.domain.server.PushCapability interface PushRepository { suspend fun currentCapability(): PushCapability - suspend fun registerDevice(deviceId: String, endpoint: String): Result + suspend fun registerDevice(deviceId: String, endpoint: String, p256dh: String, auth: String): Result suspend fun unregisterDevice(deviceId: String): Result diff --git a/app/src/main/java/dev/typetype/android/services/push/PushRegistrationManager.kt b/app/src/main/java/dev/typetype/android/services/push/PushRegistrationManager.kt index dfaf7a77..9eacb490 100644 --- a/app/src/main/java/dev/typetype/android/services/push/PushRegistrationManager.kt +++ b/app/src/main/java/dev/typetype/android/services/push/PushRegistrationManager.kt @@ -49,13 +49,10 @@ class PushRegistrationManager @Inject constructor( return update(PushRegistrationStatus.Unavailable) } val endpoint = registration.endpoint ?: return startRegistration(scope) - val devices = repository.devices().getOrElse { - return update(PushRegistrationStatus.Failed) - } - if (devices.any { device -> device.deviceId == registration.deviceId }) { - return update(PushRegistrationStatus.Registered) - } - return repository.registerDevice(registration.deviceId, endpoint).fold( + val p256dh = registration.p256dh + val auth = registration.auth + if (p256dh.isNullOrBlank() || auth.isNullOrBlank()) return startRegistration(scope) + return repository.registerDevice(registration.deviceId, endpoint, p256dh, auth).fold( onSuccess = { update(PushRegistrationStatus.Registered) }, onFailure = { update(PushRegistrationStatus.Failed) }, ) @@ -92,18 +89,20 @@ class PushRegistrationManager @Inject constructor( val registration = registrationStore.registrationOnce(scope) return when { registration == null -> update(PushRegistrationStatus.Disabled) - registration.endpoint != null -> update(PushRegistrationStatus.Registered) + !registration.endpoint.isNullOrBlank() && + !registration.p256dh.isNullOrBlank() && + !registration.auth.isNullOrBlank() -> update(PushRegistrationStatus.Registered) else -> update(PushRegistrationStatus.Registering) } } - fun onEndpointAvailable(instance: String, endpoint: String) { + fun onEndpointAvailable(instance: String, endpoint: String, p256dh: String, auth: String) { managerScope.launch { val accountScope = scopeFromInstanceName(instance) ?: return@launch val deviceId = registrationStore.ensureDeviceId(accountScope) - repository.registerDevice(deviceId, endpoint).fold( + repository.registerDevice(deviceId, endpoint, p256dh, auth).fold( onSuccess = { - registrationStore.setEndpoint(accountScope, endpoint) + registrationStore.setSubscription(accountScope, endpoint, p256dh, auth) update(PushRegistrationStatus.Registered) }, onFailure = { update(PushRegistrationStatus.Failed) }, diff --git a/app/src/main/java/dev/typetype/android/services/push/TypeTypePushService.kt b/app/src/main/java/dev/typetype/android/services/push/TypeTypePushService.kt index af093df2..d7191236 100644 --- a/app/src/main/java/dev/typetype/android/services/push/TypeTypePushService.kt +++ b/app/src/main/java/dev/typetype/android/services/push/TypeTypePushService.kt @@ -15,7 +15,12 @@ class TypeTypePushService : PushService() { @Inject lateinit var notifier: PushNotifier override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) { - registrationManager.onEndpointAvailable(instance, endpoint.url) + val keySet = endpoint.pubKeySet + if (keySet == null) { + registrationManager.onRegistrationFailed(instance) + return + } + registrationManager.onEndpointAvailable(instance, endpoint.url, keySet.pubKey, keySet.auth) } override fun onMessage(message: PushMessage, instance: String) { diff --git a/app/src/test/java/dev/typetype/android/data/network/dto/PushDeviceRegistrationRequestDtoTest.kt b/app/src/test/java/dev/typetype/android/data/network/dto/PushDeviceRegistrationRequestDtoTest.kt new file mode 100644 index 00000000..92f516f0 --- /dev/null +++ b/app/src/test/java/dev/typetype/android/data/network/dto/PushDeviceRegistrationRequestDtoTest.kt @@ -0,0 +1,26 @@ +package dev.typetype.android.data.network.dto + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Test + +class PushDeviceRegistrationRequestDtoTest { + @Test + fun registrationJsonIncludesWebPushSubscriptionKeys() { + val request = PushDeviceRegistrationRequestDto( + deviceId = "device-1", + endpoint = "https://push.example/subscription", + p256dh = "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4", + auth = "BTBZMqHH6r4Tts7J_aSIgg", + ) + + val json = Json.parseToJsonElement(Json.encodeToString(request)).jsonObject + + assertEquals(request.endpoint, json["endpoint"]?.jsonPrimitive?.content) + assertEquals(request.p256dh, json["p256dh"]?.jsonPrimitive?.content) + assertEquals(request.auth, json["auth"]?.jsonPrimitive?.content) + } +} From fa561c2520b24ce10c77eedf8e590c8cb29e0275 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 3 Oct 2026 17:28:04 +0200 Subject: [PATCH 02/23] fix: prepare progressive SABR windows after seeks --- .../services/PublicVodSeekMeasurementTest.kt | 81 +++++++++++++++++++ .../data/stream/SabrPlaybackContract.kt | 8 +- .../stream/SabrPlaybackSessionProtocol.kt | 29 ++++++- .../stream/SabrPlaybackRateContractTest.kt | 53 ++++++++++-- 4 files changed, 160 insertions(+), 11 deletions(-) create mode 100644 app/src/androidTest/java/dev/typetype/android/services/PublicVodSeekMeasurementTest.kt diff --git a/app/src/androidTest/java/dev/typetype/android/services/PublicVodSeekMeasurementTest.kt b/app/src/androidTest/java/dev/typetype/android/services/PublicVodSeekMeasurementTest.kt new file mode 100644 index 00000000..2984c094 --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/services/PublicVodSeekMeasurementTest.kt @@ -0,0 +1,81 @@ +package dev.typetype.android.services + +import android.content.ComponentName +import android.content.Intent +import android.net.Uri +import android.os.SystemClock +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.MediaController +import androidx.media3.session.SessionToken +import androidx.test.platform.app.InstrumentationRegistry +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import kotlin.math.abs +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test + +@UnstableApi +class PublicVodSeekMeasurementTest { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + + @Test + fun measurePublicVodSeekReadinessWithPinnedTracks() { + assumeTrue(InstrumentationRegistry.getArguments().getString("publicVodSeekMeasurement") == "true") + val context = instrumentation.targetContext + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse("https://www.youtube.com/watch?v=tXH5EjM_96c")) + .setPackage(context.packageName) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + val controller = MediaController.Builder( + context, + SessionToken(context, ComponentName(context, PlaybackService::class.java)), + ).buildAsync().get(10, TimeUnit.SECONDS) + try { + val deadline = SystemClock.elapsedRealtime() + 40_000L + while (SystemClock.elapsedRealtime() < deadline && read { controller.currentTracks.groups.isEmpty() }) { + Thread.sleep(50L) + } + val tracks = read { + controller.currentTracks.groups.flatMap { group -> + (0 until group.length).filter(group::isTrackSelected).map { index -> + val format = group.getTrackFormat(index) + "${format.id}:${format.sampleMimeType}" + } + } + } + println("Selected public VOD tracks: $tracks") + assertTrue("Expected video itag 299, got $tracks", tracks.any { it.startsWith("299:") }) + assertTrue("Expected audio itag 140, got $tracks", tracks.any { it.startsWith("140:") }) + for (target in listOf(999_108L, 225_191L)) { + val started = SystemClock.elapsedRealtime() + instrumentation.runOnMainSync { + controller.play() + controller.seekTo(target) + } + var ready = false + while (SystemClock.elapsedRealtime() - started < 45_000L) { + ready = read { + controller.playerError == null && controller.isPlaying && + controller.playbackState == Player.STATE_READY && + abs(controller.currentPosition - target) < 4_000L + } + if (ready) break + Thread.sleep(25L) + } + println("Android controller ready: targetMs=$target delayMs=${SystemClock.elapsedRealtime() - started}") + assertTrue("Seek did not recover: $target", ready) + } + } finally { + instrumentation.runOnMainSync { controller.release() } + } + } + + private fun read(block: () -> T): T { + val result = AtomicReference>() + instrumentation.runOnMainSync { result.set(runCatching(block)) } + return requireNotNull(result.get()).getOrThrow() + } +} diff --git a/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackContract.kt b/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackContract.kt index 16df1470..72537464 100644 --- a/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackContract.kt +++ b/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackContract.kt @@ -182,13 +182,17 @@ private fun SabrPlaybackWindowTrackDto.requireTrack( sabrContractMismatch("SABR returned an empty $expectedKind window") } var previousEndMs = -1L - val resolvedSegments = segments.map { segment -> + val resolvedSegments = segments.mapIndexed { index, segment -> if ( segment.startMs < 0L || segment.durationMs <= 0L || segment.startMs < previousEndMs && previousEndMs - segment.startMs > TIMELINE_ROUNDING_TOLERANCE_MS ) { - sabrContractMismatch("SABR returned an invalid $expectedKind timeline") + sabrContractMismatch( + "SABR returned an invalid $expectedKind timeline: index=$index " + + "startMs=${segment.startMs} durationMs=${segment.durationMs} " + + "previousEndMs=$previousEndMs", + ) } val resolvedUrl = requireMediaUrl( baseUrl, diff --git a/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackSessionProtocol.kt b/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackSessionProtocol.kt index 9ca84b64..c6548e27 100644 --- a/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackSessionProtocol.kt +++ b/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackSessionProtocol.kt @@ -49,7 +49,10 @@ internal fun SabrPlaybackResponse.windowRequest( audioItag = audioItag, audioTrackId = audioTrackId, playbackRate = playbackRate.sanitizedPlaybackRate(), - bufferGoalMs = playbackRate.rateAwareBufferGoalMs(isLive), + bufferGoalMs = playbackRate.rateAwareBufferGoalMs( + isLive, + hasBufferedPlayback(ranges, playerTimeMs, audioOnly), + ), bufferedRanges = ranges.map { SabrPlaybackBufferedRangeDto(it.itag, it.startMs, it.endMs) }, @@ -122,7 +125,8 @@ internal val RECOVERABLE_CONTROL_STATUS_CODES = setOf(404, 409, 410) private const val DEFAULT_RETRY_MS = 500L private const val MIN_RETRY_MS = 250L private const val MAX_RETRY_MS = 2_000L -private const val VOD_BUFFER_GOAL_MS = 30_000L +private const val VOD_INITIAL_BUFFER_GOAL_MS = 2_500L +private const val VOD_BUFFER_GOAL_MS = 10_000L private const val LIVE_BUFFER_GOAL_MS = 8_000L private const val MAX_BUFFER_GOAL_MS = 60_000L private const val MIN_PLAYBACK_RATE = 0.25f @@ -131,8 +135,25 @@ private const val MAX_PLAYBACK_RATE = 4.0f private fun Float.sanitizedPlaybackRate(): Float = takeIf { isFinite() && this in MIN_PLAYBACK_RATE..MAX_PLAYBACK_RATE } ?: 1.0f -private fun Float.rateAwareBufferGoalMs(isLive: Boolean): Long = - ((if (isLive) LIVE_BUFFER_GOAL_MS else VOD_BUFFER_GOAL_MS) * +private fun Float.rateAwareBufferGoalMs(isLive: Boolean, hasBufferedPlayback: Boolean): Long = + ((when { + isLive -> LIVE_BUFFER_GOAL_MS + hasBufferedPlayback -> VOD_BUFFER_GOAL_MS + else -> VOD_INITIAL_BUFFER_GOAL_MS + }) * maxOf(1.0f, sanitizedPlaybackRate())) .toLong() .coerceAtMost(MAX_BUFFER_GOAL_MS) + +private fun SabrPlaybackResponse.hasBufferedPlayback( + ranges: List, + playerTimeMs: Long, + audioOnly: Boolean, +): Boolean { + val requiredItags = listOfNotNull(videoItag.takeUnless { audioOnly }, audioItag) + return requiredItags.isNotEmpty() && requiredItags.all { itag -> + ranges.any { range -> + range.itag == itag && range.startMs <= playerTimeMs && range.endMs > playerTimeMs + } + } +} diff --git a/app/src/test/java/dev/typetype/android/data/stream/SabrPlaybackRateContractTest.kt b/app/src/test/java/dev/typetype/android/data/stream/SabrPlaybackRateContractTest.kt index 8115b1d4..6bbb0e94 100644 --- a/app/src/test/java/dev/typetype/android/data/stream/SabrPlaybackRateContractTest.kt +++ b/app/src/test/java/dev/typetype/android/data/stream/SabrPlaybackRateContractTest.kt @@ -1,24 +1,25 @@ package dev.typetype.android.data.stream import dev.typetype.android.data.network.dto.SabrPlaybackResponse +import dev.typetype.android.domain.stream.SabrPlaybackBufferedRange import org.junit.Assert.assertEquals import org.junit.Test class SabrPlaybackRateContractTest { @Test fun fasterPlaybackExpandsTheRequestedBuffer() { - val request = response().windowRequest(emptyList(), playbackRate = 2.0f) + val request = response().windowRequest(bufferedTracks(), playbackRate = 2.0f) assertEquals(2.0f, request.playbackRate) - assertEquals(60_000L, request.bufferGoalMs) + assertEquals(20_000L, request.bufferGoalMs) } @Test fun slowerPlaybackKeepsTheBaselineBuffer() { - val request = response().windowRequest(emptyList(), playbackRate = 0.5f) + val request = response().windowRequest(bufferedTracks(), playbackRate = 0.5f) assertEquals(0.5f, request.playbackRate) - assertEquals(30_000L, request.bufferGoalMs) + assertEquals(10_000L, request.bufferGoalMs) } @Test @@ -26,7 +27,7 @@ class SabrPlaybackRateContractTest { val request = response().windowRequest(emptyList(), playbackRate = Float.NaN) assertEquals(1.0f, request.playbackRate) - assertEquals(30_000L, request.bufferGoalMs) + assertEquals(2_500L, request.bufferGoalMs) } @Test @@ -41,6 +42,48 @@ class SabrPlaybackRateContractTest { assertEquals(16_000L, request.bufferGoalMs) } + @Test + fun emptyBufferStartsWithAShortWindow() { + assertEquals(2_500L, response().windowRequest(emptyList()).bufferGoalMs) + } + + @Test + fun seekOutsideBufferedTracksStartsWithAShortWindow() { + val request = response().windowRequest(bufferedTracks(), playerTimeMs = 999_108L) + + assertEquals(2_500L, request.bufferGoalMs) + } + + @Test + fun bothSelectedTracksMustCoverThePositionBeforeExpanding() { + assertEquals(2_500L, response().windowRequest(bufferedTracks().take(1)).bufferGoalMs) + assertEquals(10_000L, response().windowRequest(bufferedTracks()).bufferGoalMs) + } + + @Test + fun unrelatedTracksDoNotExpandTheWindow() { + val ranges = listOf(SabrPlaybackBufferedRange(303, 0L, 10_000L)) + + assertEquals(2_500L, response().windowRequest(ranges).bufferGoalMs) + } + + @Test + fun audioOnlyDoesNotRequireVideoCoverage() { + val request = response().windowRequest(bufferedTracks().takeLast(1), audioOnly = true) + + assertEquals(10_000L, request.bufferGoalMs) + } + + @Test + fun initialWindowScalesWithPlaybackSpeed() { + assertEquals(5_000L, response().windowRequest(emptyList(), playbackRate = 2.0f).bufferGoalMs) + } + + private fun bufferedTracks() = listOf( + SabrPlaybackBufferedRange(137, 0L, 10_000L), + SabrPlaybackBufferedRange(140, 0L, 10_000L), + ) + private fun response() = SabrPlaybackResponse( sessionId = "session", videoId = "video", From 6a62f6197ea5edc5e952cdcb1ae130dfcf6f1fef Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 3 Oct 2026 17:28:25 +0200 Subject: [PATCH 03/23] fix: retry visible avatars and thumbnails with backoff --- .../core/ui/components/RetryingImageTest.kt | 56 +++++++++++++++++++ .../core/ui/components/ChannelAvatar.kt | 17 ++---- .../core/ui/components/RetryingImage.kt | 49 ++++++++++++++++ .../core/ui/components/SkeletonImage.kt | 10 ++-- .../core/ui/components/ImageRetryDelayTest.kt | 15 +++++ 5 files changed, 129 insertions(+), 18 deletions(-) create mode 100644 app/src/androidTest/java/dev/typetype/android/core/ui/components/RetryingImageTest.kt create mode 100644 app/src/main/java/dev/typetype/android/core/ui/components/RetryingImage.kt create mode 100644 app/src/test/java/dev/typetype/android/core/ui/components/ImageRetryDelayTest.kt diff --git a/app/src/androidTest/java/dev/typetype/android/core/ui/components/RetryingImageTest.kt b/app/src/androidTest/java/dev/typetype/android/core/ui/components/RetryingImageTest.kt new file mode 100644 index 00000000..a0bd2325 --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/core/ui/components/RetryingImageTest.kt @@ -0,0 +1,56 @@ +package dev.typetype.android.core.ui.components + +import android.graphics.Bitmap +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.unit.dp +import coil3.request.ImageRequest +import java.io.File +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class RetryingImageTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun failedImageRecoversWithoutChangingItsUrlOrLeavingTheScreen() { + val file = File.createTempFile("retry-image", ".png", rule.activity.cacheDir) + file.delete() + val errors = AtomicInteger() + val loaded = AtomicBoolean() + val request = ImageRequest.Builder(rule.activity).data(file).build() + try { + rule.setContent { + RetryingImage( + request = request, + contentDescription = "Test image", + contentScale = ContentScale.Crop, + modifier = Modifier.size(48.dp), + onLoaded = { success -> + if (success) { + loaded.set(true) + } else { + errors.incrementAndGet() + val bitmap = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888) + file.outputStream().use { + bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) + } + bitmap.recycle() + } + }, + ) + } + rule.waitUntil(10_000) { loaded.get() } + assertEquals(1, errors.get()) + } finally { + file.delete() + } + } +} diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/ChannelAvatar.kt b/app/src/main/java/dev/typetype/android/core/ui/components/ChannelAvatar.kt index ef0efa8d..b89aad21 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/components/ChannelAvatar.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/components/ChannelAvatar.kt @@ -10,7 +10,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -23,7 +22,6 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage import coil3.compose.LocalPlatformContext import coil3.request.ImageRequest import dev.typetype.android.core.ui.share.LocalServerBaseUrl @@ -40,8 +38,7 @@ fun ChannelAvatar( ) { val serverBaseUrl = LocalServerBaseUrl.current val context = LocalPlatformContext.current - var attempt by remember(avatarUrl) { mutableIntStateOf(0) } - var loaded by remember(avatarUrl, attempt) { mutableStateOf(false) } + var loaded by remember(serverBaseUrl, avatarUrl) { mutableStateOf(false) } val hasSource = avatarUrl.isNotBlank() var missingExpired by remember(avatarUrl) { mutableStateOf(false) } @@ -73,16 +70,13 @@ fun ChannelAvatar( ) } if (hasSource) { - AsyncImage( - model = ImageRequest.Builder(context) - .data(avatarRetryUrl(buildImageUrl(serverBaseUrl, avatarUrl), attempt)) + RetryingImage( + request = ImageRequest.Builder(context) + .data(buildImageUrl(serverBaseUrl, avatarUrl)) .build(), contentDescription = null, contentScale = ContentScale.Crop, - onSuccess = { loaded = true }, - onError = { - if (attempt < MAX_AVATAR_RETRIES) attempt += 1 - }, + onLoaded = { loaded = it }, modifier = Modifier.fillMaxSize(), ) } @@ -102,5 +96,4 @@ internal fun avatarInitial(name: String): String = name.trim().firstOrNull()?.uppercase() ?: "?" private const val AVATAR_RETRY_PARAM = "_tt_avatar_retry" -private const val MAX_AVATAR_RETRIES = 2 private const val MISSING_AVATAR_GRACE_MILLIS = 1_500L diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/RetryingImage.kt b/app/src/main/java/dev/typetype/android/core/ui/components/RetryingImage.kt new file mode 100644 index 00000000..fd2dae2e --- /dev/null +++ b/app/src/main/java/dev/typetype/android/core/ui/components/RetryingImage.kt @@ -0,0 +1,49 @@ +package dev.typetype.android.core.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import kotlinx.coroutines.delay + +@Composable +internal fun RetryingImage( + request: ImageRequest, + contentDescription: String?, + contentScale: ContentScale, + modifier: Modifier, + onLoaded: (Boolean) -> Unit, +) { + var attempt by remember(request.data) { mutableIntStateOf(0) } + var failed by remember(request.data, attempt) { mutableStateOf(false) } + LaunchedEffect(request.data, attempt, failed) { + if (failed) { + delay(imageRetryDelayMillis(attempt)) + attempt += 1 + } + } + key(request.data, attempt) { + AsyncImage( + model = request, + contentDescription = contentDescription, + contentScale = contentScale, + onSuccess = { onLoaded(true) }, + onError = { + onLoaded(false) + failed = true + }, + modifier = modifier, + ) + } +} + +internal fun imageRetryDelayMillis(attempt: Int): Long = + 1_000L shl attempt.coerceIn(0, 5) diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/SkeletonImage.kt b/app/src/main/java/dev/typetype/android/core/ui/components/SkeletonImage.kt index c3b84609..c48434be 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/components/SkeletonImage.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/components/SkeletonImage.kt @@ -10,7 +10,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Shape import androidx.compose.ui.layout.ContentScale -import coil3.compose.AsyncImage import coil3.compose.LocalPlatformContext import coil3.request.ImageRequest import coil3.request.crossfade @@ -28,22 +27,21 @@ fun SkeletonImage( ) { val serverBaseUrl = LocalServerBaseUrl.current val context = LocalPlatformContext.current - var loaded by remember(imageUrl) { mutableStateOf(false) } + var loaded by remember(serverBaseUrl, imageUrl) { mutableStateOf(false) } val hasSource = imageUrl.isNotBlank() Box(modifier = modifier.clip(shape)) { if (hasSource && !loaded) { TypeTypeSkeleton(modifier = Modifier.matchParentSize(), shape = shape) } if (hasSource) { - AsyncImage( - model = ImageRequest.Builder(context) + RetryingImage( + request = ImageRequest.Builder(context) .data(buildImageUrl(serverBaseUrl, imageUrl)) .apply { if (crossfadeMillis > 0) crossfade(crossfadeMillis) } .build(), contentDescription = contentDescription, contentScale = contentScale, - onSuccess = { loaded = true }, - onError = { loaded = false }, + onLoaded = { loaded = it }, modifier = Modifier.matchParentSize(), ) } diff --git a/app/src/test/java/dev/typetype/android/core/ui/components/ImageRetryDelayTest.kt b/app/src/test/java/dev/typetype/android/core/ui/components/ImageRetryDelayTest.kt new file mode 100644 index 00000000..af90ed6a --- /dev/null +++ b/app/src/test/java/dev/typetype/android/core/ui/components/ImageRetryDelayTest.kt @@ -0,0 +1,15 @@ +package dev.typetype.android.core.ui.components + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ImageRetryDelayTest { + @Test + fun retriesBackOffWithoutGrowingBeyondThirtyTwoSeconds() { + assertEquals(1_000L, imageRetryDelayMillis(0)) + assertEquals(2_000L, imageRetryDelayMillis(1)) + assertEquals(32_000L, imageRetryDelayMillis(5)) + assertEquals(32_000L, imageRetryDelayMillis(100)) + assertEquals(32_000L, imageRetryDelayMillis(Int.MAX_VALUE)) + } +} From 4242be0cb258203d5002e0e93e4bf7fc516eae08 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 3 Oct 2026 17:28:41 +0200 Subject: [PATCH 04/23] fix: hide error artwork in the collapsed player --- .../player/PlayerPlaceholderLayerTest.kt | 46 +++++++++++++++++++ .../feature/player/PlayerPlaceholderLayer.kt | 28 +++++++++++ .../android/feature/player/PlayerScreen.kt | 18 +++++--- 3 files changed, 85 insertions(+), 7 deletions(-) create mode 100644 app/src/androidTest/java/dev/typetype/android/feature/player/PlayerPlaceholderLayerTest.kt create mode 100644 app/src/main/java/dev/typetype/android/feature/player/PlayerPlaceholderLayer.kt diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerPlaceholderLayerTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerPlaceholderLayerTest.kt new file mode 100644 index 00000000..76b2e867 --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerPlaceholderLayerTest.kt @@ -0,0 +1,46 @@ +package dev.typetype.android.feature.player + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class PlayerPlaceholderLayerTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun errorArtworkDisappearsWhenCollapsedAndReturnsWhenExpanded() { + val progress = mutableFloatStateOf(0f) + rule.setContent { + Box(Modifier.size(120.dp).background(Color.Blue).testTag("viewport")) { + PlayerPlaceholderLayer({ progress.floatValue }) { + Box(Modifier.size(120.dp).background(Color.Red)) + } + } + } + assertCenter(Color.Red) + rule.runOnIdle { progress.floatValue = 1f } + assertCenter(Color.Blue) + rule.runOnIdle { progress.floatValue = 0f } + assertCenter(Color.Red) + } + + private fun assertCenter(expected: Color) { + rule.waitForIdle() + val pixels = rule.onNodeWithTag("viewport").captureToImage().toPixelMap() + assertEquals(expected, pixels[pixels.width / 2, pixels.height / 2]) + } +} diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerPlaceholderLayer.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerPlaceholderLayer.kt new file mode 100644 index 00000000..48762816 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerPlaceholderLayer.kt @@ -0,0 +1,28 @@ +package dev.typetype.android.feature.player + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.semantics.clearAndSetSemantics + +@Composable +internal fun PlayerPlaceholderLayer( + hostTransitionProgress: () -> Float, + content: @Composable () -> Unit, +) { + val hidden by remember(hostTransitionProgress) { + derivedStateOf { hostTransitionProgress() >= 0.99f } + } + Box( + Modifier.fillMaxSize() + .graphicsLayer { alpha = (1f - hostTransitionProgress()).coerceIn(0f, 1f) } + .then(if (hidden) Modifier.clearAndSetSemantics {} else Modifier), + ) { + content() + } +} diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerScreen.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerScreen.kt index 4f2b2d7a..853b4ec6 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlayerScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerScreen.kt @@ -152,13 +152,17 @@ fun PlayerScreen( color = MaterialTheme.colorScheme.background, ) { when { - state.isLoading -> LoadingState() - state.error != null -> ErrorState( - classification = state.error, - onNavigateBack = onNavigateBack, - onRetry = { onAction(PlayerAction.OnRetry) }, - onOpenAccounts = onOpenAccounts, - ) + state.isLoading -> PlayerPlaceholderLayer(hostTransitionProgress) { + LoadingState() + } + state.error != null -> PlayerPlaceholderLayer(hostTransitionProgress) { + ErrorState( + classification = state.error, + onNavigateBack = onNavigateBack, + onRetry = { onAction(PlayerAction.OnRetry) }, + onOpenAccounts = onOpenAccounts, + ) + } state.stream != null -> LoadedPlayer( stream = state.stream, videoUrl = state.videoUrl, From 90ec89e48c54c774ea89224cdd662688bab6a228 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 3 Oct 2026 17:28:55 +0200 Subject: [PATCH 05/23] fix: recover failed stream loading when connectivity returns --- .../data/stream/TransientPlaybackRequest.kt | 2 +- .../player/PlayerPreferenceStateMapping.kt | 10 +++++ .../player/PlayerStreamNetworkRecovery.kt | 38 +++++++++++++++++++ .../android/feature/player/PlayerViewModel.kt | 16 +++----- .../player/PlayerStreamNetworkRecoveryTest.kt | 35 +++++++++++++++++ 5 files changed, 90 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/dev/typetype/android/feature/player/PlayerPreferenceStateMapping.kt create mode 100644 app/src/main/java/dev/typetype/android/feature/player/PlayerStreamNetworkRecovery.kt create mode 100644 app/src/test/java/dev/typetype/android/feature/player/PlayerStreamNetworkRecoveryTest.kt diff --git a/app/src/main/java/dev/typetype/android/data/stream/TransientPlaybackRequest.kt b/app/src/main/java/dev/typetype/android/data/stream/TransientPlaybackRequest.kt index b3c135f2..e422d98e 100644 --- a/app/src/main/java/dev/typetype/android/data/stream/TransientPlaybackRequest.kt +++ b/app/src/main/java/dev/typetype/android/data/stream/TransientPlaybackRequest.kt @@ -58,4 +58,4 @@ internal suspend fun transientPlaybackRequest( private const val MAX_SERVER_RESPONSE_RETRIES = 24 private const val MAX_CONNECTED_TRANSPORT_RETRIES = 8 -private const val MAX_OFFLINE_WAIT_MS = 30 * 60 * 1_000L +private const val MAX_OFFLINE_WAIT_MS = 10_000L diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerPreferenceStateMapping.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerPreferenceStateMapping.kt new file mode 100644 index 00000000..6ab6ffcb --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerPreferenceStateMapping.kt @@ -0,0 +1,10 @@ +package dev.typetype.android.feature.player + +internal fun PlayerState.applyPreferences(prefs: PlayerPreferenceState): PlayerState = copy( + gestureConfig = prefs.gestureConfig, + playbackBrightnessPercent = prefs.brightnessPercent, + autoplayCountdownSeconds = prefs.autoplayCountdownSeconds, + audioOnlyPlaybackDefault = prefs.audioOnlyPlaybackDefault, + preferredCodec = prefs.preferredCodec, + userSettings = prefs.userSettings, +) diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerStreamNetworkRecovery.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerStreamNetworkRecovery.kt new file mode 100644 index 00000000..e4335781 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerStreamNetworkRecovery.kt @@ -0,0 +1,38 @@ +package dev.typetype.android.feature.player + +import dev.typetype.android.data.network.PlaybackNetworkState +import dev.typetype.android.feature.player.error.StreamErrorKind +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch + +internal fun observePlayerStreamNetworkRecovery( + scope: CoroutineScope, + playerStates: StateFlow, + networkStates: StateFlow, + retryStream: (String) -> Unit, +) { + val gate = PlayerStreamNetworkRetryGate() + scope.launch { + combine(playerStates, networkStates, gate::retryUrl) + .collect { url -> url?.let(retryStream) } + } +} + +internal class PlayerStreamNetworkRetryGate { + private var lastAttempt: Attempt? = null + + fun retryUrl(player: PlayerState, network: PlaybackNetworkState): String? { + if (!network.isAvailable || player.isLoading || player.stream != null || + player.error?.kind != StreamErrorKind.NetworkUnavailable + ) return null + val url = player.videoUrl.takeIf(String::isNotBlank) ?: return null + val attempt = Attempt(network.generation, url) + if (attempt == lastAttempt) return null + lastAttempt = attempt + return url + } + + private data class Attempt(val networkGeneration: Long, val videoUrl: String) +} diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerViewModel.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerViewModel.kt index a34c9201..b4175c22 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlayerViewModel.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerViewModel.kt @@ -3,6 +3,7 @@ package dev.typetype.android.feature.player import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import dev.typetype.android.data.network.PlaybackNetworkMonitor import dev.typetype.android.domain.comments.CommentsRepository import dev.typetype.android.domain.download.DownloadProgress import dev.typetype.android.domain.download.DownloadRepository @@ -42,6 +43,7 @@ class PlayerViewModel @Inject constructor( private val playbackQueueCoordinator: PlaybackQueueCoordinator, val commentsRepository: CommentsRepository, val subtitleCueLoader: PlayerSubtitleCueLoader, + networkMonitor: PlaybackNetworkMonitor, ) : ViewModel() { private val videoUrlFlow = combine( playerHostController.state.map { it.videoUrl }, @@ -76,6 +78,9 @@ class PlayerViewModel @Inject constructor( onWatchLaterChanged = { value -> _state.update { it.copy(isInWatchLater = value) } }, ) init { + observePlayerStreamNetworkRecovery( + viewModelScope, state, networkMonitor.states, ::loadStream, + ) viewModelScope.launch { videoUrlFlow.collect { url -> val hostState = playerHostController.state.value @@ -123,16 +128,7 @@ class PlayerViewModel @Inject constructor( private fun observePreferences() { viewModelScope.launch { playerPreferences.states.collect { prefs -> - _state.update { - it.copy( - gestureConfig = prefs.gestureConfig, - playbackBrightnessPercent = prefs.brightnessPercent, - autoplayCountdownSeconds = prefs.autoplayCountdownSeconds, - audioOnlyPlaybackDefault = prefs.audioOnlyPlaybackDefault, - preferredCodec = prefs.preferredCodec, - userSettings = prefs.userSettings, - ) - } + _state.update { it.applyPreferences(prefs) } } } viewModelScope.launch { playerPreferences.refresh() } diff --git a/app/src/test/java/dev/typetype/android/feature/player/PlayerStreamNetworkRecoveryTest.kt b/app/src/test/java/dev/typetype/android/feature/player/PlayerStreamNetworkRecoveryTest.kt new file mode 100644 index 00000000..3119d93e --- /dev/null +++ b/app/src/test/java/dev/typetype/android/feature/player/PlayerStreamNetworkRecoveryTest.kt @@ -0,0 +1,35 @@ +package dev.typetype.android.feature.player + +import dev.typetype.android.data.network.PlaybackNetworkState +import dev.typetype.android.feature.player.error.StreamErrorClass +import dev.typetype.android.feature.player.error.StreamErrorKind +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PlayerStreamNetworkRecoveryTest { + private val failure = StreamErrorClass(StreamErrorKind.NetworkUnavailable, rawMessage = null) + private val state = PlayerState(videoUrl = "https://video.example/public", isLoading = false, error = failure) + + @Test + fun recoversOncePerNetworkGeneration() { + val gate = PlayerStreamNetworkRetryGate() + assertNull(gate.retryUrl(state, PlaybackNetworkState(false, 1L))) + assertEquals(state.videoUrl, gate.retryUrl(state, PlaybackNetworkState(true, 2L))) + assertNull(gate.retryUrl(state, PlaybackNetworkState(true, 2L))) + assertEquals(state.videoUrl, gate.retryUrl(state, PlaybackNetworkState(true, 3L))) + } + + @Test + fun loadingAndOtherErrorsDoNotRetry() { + val gate = PlayerStreamNetworkRetryGate() + val online = PlaybackNetworkState(true, 2L) + assertNull(gate.retryUrl(state.copy(isLoading = true), online)) + assertNull(gate.retryUrl(state.copy(error = null), online)) + assertNull(gate.retryUrl(state.copy(videoUrl = ""), online)) + assertNull(gate.retryUrl(state.copy(error = StreamErrorClass( + StreamErrorKind.SabrUnavailable, rawMessage = null, + )), online)) + assertEquals(state.videoUrl, gate.retryUrl(state, online)) + } +} From afbfa421490ea5f91c90918910cefe737cf8544d Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 3 Oct 2026 17:29:43 +0200 Subject: [PATCH 06/23] build: prepare mobile beta 1.9.1-beta.1 --- app/build.gradle.kts | 4 ++-- release-notes/v1.9.1-beta.1.md | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 release-notes/v1.9.1-beta.1.md diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 72c76786..157de064 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 = 10900 - versionName = "1.9.0" + versionCode = 10901 + versionName = "1.9.1-beta.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" resValue("string", "app_name", "TypeType") } diff --git a/release-notes/v1.9.1-beta.1.md b/release-notes/v1.9.1-beta.1.md new file mode 100644 index 00000000..a381cf75 --- /dev/null +++ b/release-notes/v1.9.1-beta.1.md @@ -0,0 +1,10 @@ +Beta fixes for playback recovery and image loading. + +- Prepare smaller SABR windows after startup and seeks, then fill the buffer progressively. +- Keep retrying visible channel avatars and thumbnails with bounded backoff. +- Prevent loading and error artwork from covering mini-player metadata. +- Retry failed stream loading after the network returns. + +Validation: 832 unit tests, no failures; lint and debug/release builds passed. Two emulator runs recovered both forward and backward seeks with H.264/AAC. This does not establish that all playback freezes or audio recovery issues are resolved. + +Thanks to everyone testing TypeType and reporting problems. From c6f1819b310d863efdffee17615d5905398fecd9 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 15 Oct 2026 20:52:45 +0200 Subject: [PATCH 07/23] fix: allow trusted HTTP self-hosted servers --- .../setup/addserver/AddServerScreenTest.kt | 21 ++++++++++++ .../data/setup/LocalNetworkTargetResolver.kt | 6 ++++ .../android/data/setup/SetupRepositoryImpl.kt | 12 +++++-- .../android/domain/setup/ServerAddress.kt | 6 ++-- .../android/domain/setup/SetupRepository.kt | 4 ++- .../setup/addserver/AddServerAction.kt | 1 + .../setup/addserver/AddServerScreen.kt | 10 ++++++ .../feature/setup/addserver/AddServerState.kt | 1 + .../setup/addserver/AddServerViewModel.kt | 14 +++++--- app/src/main/res/values/strings.xml | 2 ++ .../setup/LocalNetworkTargetResolverTest.kt | 9 +++++ .../android/domain/setup/ServerAddressTest.kt | 34 ++++++++++++++++--- 12 files changed, 105 insertions(+), 15 deletions(-) diff --git a/app/src/androidTest/java/dev/typetype/android/feature/setup/addserver/AddServerScreenTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/setup/addserver/AddServerScreenTest.kt index 9ef3cbe2..41dffedf 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/setup/addserver/AddServerScreenTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/setup/addserver/AddServerScreenTest.kt @@ -6,7 +6,9 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import dev.typetype.android.core.ui.theme.TypeTypeTheme import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference import org.junit.Assert.assertTrue +import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test @@ -36,4 +38,23 @@ class AddServerScreenTest { assertTrue(settingsOpened.get()) } + + @Test + fun remoteHttpRequiresExplicitUnencryptedTransportOptIn() { + val action = AtomicReference() + composeRule.setContent { + TypeTypeTheme { + AddServerScreen( + state = AddServerState(url = "http://video.example"), + onAction = action::set, + onConnect = {}, + onOpenAppSettings = {}, + ) + } + } + + composeRule.onNodeWithText("Allow unencrypted HTTP").assertIsDisplayed().performClick() + + assertEquals(AddServerAction.OnAllowCleartextHttpChange(true), action.get()) + } } diff --git a/app/src/main/java/dev/typetype/android/data/setup/LocalNetworkTargetResolver.kt b/app/src/main/java/dev/typetype/android/data/setup/LocalNetworkTargetResolver.kt index cb5bbdd8..f5b1807f 100644 --- a/app/src/main/java/dev/typetype/android/data/setup/LocalNetworkTargetResolver.kt +++ b/app/src/main/java/dev/typetype/android/data/setup/LocalNetworkTargetResolver.kt @@ -34,4 +34,10 @@ private fun InetAddress.isLocalNetworkAddress(): Boolean = isLoopbackAddress || isLinkLocalAddress || isSiteLocalAddress || + isCarrierGradeNatAddress() || this is Inet6Address && address.firstOrNull()?.toInt()?.and(0xfe) == 0xfc + +private fun InetAddress.isCarrierGradeNatAddress(): Boolean = + address.size == 4 && + (address[0].toInt() and 0xff) == 100 && + (address[1].toInt() and 0xc0) == 0x40 diff --git a/app/src/main/java/dev/typetype/android/data/setup/SetupRepositoryImpl.kt b/app/src/main/java/dev/typetype/android/data/setup/SetupRepositoryImpl.kt index 6dcf6fb1..eb41e304 100644 --- a/app/src/main/java/dev/typetype/android/data/setup/SetupRepositoryImpl.kt +++ b/app/src/main/java/dev/typetype/android/data/setup/SetupRepositoryImpl.kt @@ -4,6 +4,7 @@ import dev.typetype.android.data.network.RetrofitFactory import dev.typetype.android.domain.server.Server import dev.typetype.android.domain.server.ServerRepository import dev.typetype.android.domain.server.PushCapability +import dev.typetype.android.domain.server.RecommenderCapability import dev.typetype.android.domain.server.RssCapability import dev.typetype.android.domain.setup.ProbeResult import dev.typetype.android.domain.setup.ServerAddress @@ -21,9 +22,9 @@ class SetupRepositoryImpl @Inject constructor( override suspend fun probeServer( rawUrl: String, - allowLocalCleartext: Boolean, + allowCleartextHttp: Boolean, ): Result = runCatching { - val candidates = ServerAddress.candidateBaseUrls(rawUrl, allowLocalCleartext) + val candidates = ServerAddress.candidateBaseUrls(rawUrl, allowCleartextHttp) val attempts = candidates.map { tryCandidate(it) } val resolved = attempts.filterIsInstance().firstOrNull()?.value ?: throw probeFailure(attempts) @@ -70,6 +71,13 @@ class SetupRepositoryImpl @Inject constructor( maxDevicesPerAccount = it.maxDevicesPerAccount, ) } ?: PushCapability(), + recommender = resolved.instance.recommender?.let { + RecommenderCapability( + installed = it.installed, + enabled = it.enabled, + experimental = it.experimental, + ) + } ?: RecommenderCapability(), ) } diff --git a/app/src/main/java/dev/typetype/android/domain/setup/ServerAddress.kt b/app/src/main/java/dev/typetype/android/domain/setup/ServerAddress.kt index c84ab1f3..275e2e9c 100644 --- a/app/src/main/java/dev/typetype/android/domain/setup/ServerAddress.kt +++ b/app/src/main/java/dev/typetype/android/domain/setup/ServerAddress.kt @@ -5,7 +5,7 @@ import java.net.URI object ServerAddress { fun candidateBaseUrls( rawValue: String, - allowLocalCleartext: Boolean = false, + allowCleartextHttp: Boolean = false, ): List { val value = rawValue.trim().trimEnd('/') require(value.isNotEmpty()) { "Enter an instance address" } @@ -19,8 +19,8 @@ object ServerAddress { } val host = parsed.host?.trim('[', ']')?.lowercase() require(!host.isNullOrBlank()) { "Enter a valid instance address" } - require(scheme != "http" || isLocalHost(host) || allowLocalCleartext) { - "Plain HTTP is allowed only for an instance on your local network" + require(scheme != "http" || allowCleartextHttp) { + "Plain HTTP requires explicit user opt-in because it is unencrypted" } val path = parsed.rawPath.orEmpty().trimEnd('/') val normalized = URI(scheme, null, host, parsed.port, path.ifEmpty { "/" }, null, null) diff --git a/app/src/main/java/dev/typetype/android/domain/setup/SetupRepository.kt b/app/src/main/java/dev/typetype/android/domain/setup/SetupRepository.kt index f820da13..83486a4a 100644 --- a/app/src/main/java/dev/typetype/android/domain/setup/SetupRepository.kt +++ b/app/src/main/java/dev/typetype/android/domain/setup/SetupRepository.kt @@ -2,12 +2,13 @@ package dev.typetype.android.domain.setup import dev.typetype.android.domain.server.Server import dev.typetype.android.domain.server.PushCapability +import dev.typetype.android.domain.server.RecommenderCapability import dev.typetype.android.domain.server.RssCapability interface SetupRepository { suspend fun probeServer( rawUrl: String, - allowLocalCleartext: Boolean = false, + allowCleartextHttp: Boolean = false, ): Result suspend fun persistServer(server: Server, makeCurrent: Boolean = true) } @@ -35,4 +36,5 @@ data class ProbeResult( val youtubeRemoteLoginUnavailableReason: String?, val rss: RssCapability, val push: PushCapability, + val recommender: RecommenderCapability = RecommenderCapability(), ) diff --git a/app/src/main/java/dev/typetype/android/feature/setup/addserver/AddServerAction.kt b/app/src/main/java/dev/typetype/android/feature/setup/addserver/AddServerAction.kt index f50f8108..ea5a67e1 100644 --- a/app/src/main/java/dev/typetype/android/feature/setup/addserver/AddServerAction.kt +++ b/app/src/main/java/dev/typetype/android/feature/setup/addserver/AddServerAction.kt @@ -2,6 +2,7 @@ package dev.typetype.android.feature.setup.addserver sealed interface AddServerAction { data class OnUrlChange(val url: String) : AddServerAction + data class OnAllowCleartextHttpChange(val allowed: Boolean) : AddServerAction data class OnConnectRequested(val permissionRequired: Boolean) : AddServerAction data object OnLocalNetworkPermissionGranted : AddServerAction data class OnLocalNetworkPermissionDenied(val permanently: Boolean) : AddServerAction diff --git a/app/src/main/java/dev/typetype/android/feature/setup/addserver/AddServerScreen.kt b/app/src/main/java/dev/typetype/android/feature/setup/addserver/AddServerScreen.kt index f4ee6f67..9c1a46ac 100644 --- a/app/src/main/java/dev/typetype/android/feature/setup/addserver/AddServerScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/setup/addserver/AddServerScreen.kt @@ -64,6 +64,7 @@ import dev.typetype.android.core.ui.components.SectionHeader import dev.typetype.android.core.ui.components.TypeTypePrimaryButton import dev.typetype.android.core.ui.components.TypeTypeTextField import dev.typetype.android.core.ui.components.RequestIdRow +import dev.typetype.android.core.ui.components.SwitchRow import dev.typetype.android.domain.setup.ServerAddress import kotlinx.coroutines.flow.collectLatest @@ -227,6 +228,15 @@ fun AddServerScreen( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, ) + SwitchRow( + title = stringResource(R.string.setup_allow_cleartext_title), + subtitle = stringResource(R.string.setup_allow_cleartext_subtitle), + checked = state.allowCleartextHttp, + onCheckedChange = { + onAction(AddServerAction.OnAllowCleartextHttpChange(it)) + }, + enabled = !state.isConnecting, + ) } if (state.resolvedName != null) { Spacer(Modifier.height(20.dp)) diff --git a/app/src/main/java/dev/typetype/android/feature/setup/addserver/AddServerState.kt b/app/src/main/java/dev/typetype/android/feature/setup/addserver/AddServerState.kt index ffc2be56..5f0bc6a8 100644 --- a/app/src/main/java/dev/typetype/android/feature/setup/addserver/AddServerState.kt +++ b/app/src/main/java/dev/typetype/android/feature/setup/addserver/AddServerState.kt @@ -2,6 +2,7 @@ package dev.typetype.android.feature.setup.addserver data class AddServerState( val url: String = "", + val allowCleartextHttp: Boolean = false, val isConnecting: Boolean = false, val errorMessage: String? = null, val errorRequestId: String? = null, diff --git a/app/src/main/java/dev/typetype/android/feature/setup/addserver/AddServerViewModel.kt b/app/src/main/java/dev/typetype/android/feature/setup/addserver/AddServerViewModel.kt index ac856aef..d1912d40 100644 --- a/app/src/main/java/dev/typetype/android/feature/setup/addserver/AddServerViewModel.kt +++ b/app/src/main/java/dev/typetype/android/feature/setup/addserver/AddServerViewModel.kt @@ -38,6 +38,7 @@ class AddServerViewModel @Inject constructor( is AddServerAction.OnUrlChange -> _state.update { it.copy( url = action.url, + allowCleartextHttp = false, errorMessage = null, errorRequestId = null, localNetworkPermissionDenied = false, @@ -47,8 +48,12 @@ class AddServerViewModel @Inject constructor( resolvedVersion = null, ) } + is AddServerAction.OnAllowCleartextHttpChange -> _state.update { + it.copy(allowCleartextHttp = action.allowed) + } is AddServerAction.OnConnectRequested -> checkLocalNetworkAccess(action.permissionRequired) - AddServerAction.OnLocalNetworkPermissionGranted -> connect(allowLocalCleartext = true) + AddServerAction.OnLocalNetworkPermissionGranted -> + checkLocalNetworkAccess(permissionRequired = false) is AddServerAction.OnLocalNetworkPermissionDenied -> _state.update { it.copy( isConnecting = false, @@ -87,12 +92,12 @@ class AddServerViewModel @Inject constructor( eventsChannel.send(AddServerEvent.RequestLocalNetworkPermission) } } else { - connect(allowLocalCleartext = localTarget) + connect(allowCleartextHttp = _state.value.allowCleartextHttp) } } } - private fun connect(allowLocalCleartext: Boolean) { + private fun connect(allowCleartextHttp: Boolean) { val typedUrl = _state.value.url if (typedUrl.isBlank()) { _state.update { @@ -113,7 +118,7 @@ class AddServerViewModel @Inject constructor( ) } viewModelScope.launch { - setupRepository.probeServer(typedUrl, allowLocalCleartext).fold( + setupRepository.probeServer(typedUrl, allowCleartextHttp).fold( onSuccess = { probe -> val server = Server( id = UUID.randomUUID().toString(), @@ -140,6 +145,7 @@ class AddServerViewModel @Inject constructor( youtubeRemoteLoginUnavailableReason = probe.youtubeRemoteLoginUnavailableReason, rss = probe.rss, push = probe.push, + recommender = probe.recommender, ) setupRepository.persistServer(server) _state.update { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 29ef1893..96f2d89c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -819,6 +819,8 @@ Allow local network access to connect to this self-hosted instance. Open app settings HTTP does not encrypt your password or account data. Use it only on a trusted local network. + Allow unencrypted HTTP + Enable only for a trusted private network or VPN. Your account data will not be encrypted by HTTPS. v%1$s Sign in Sign in again diff --git a/app/src/test/java/dev/typetype/android/data/setup/LocalNetworkTargetResolverTest.kt b/app/src/test/java/dev/typetype/android/data/setup/LocalNetworkTargetResolverTest.kt index c74f373e..1373af62 100644 --- a/app/src/test/java/dev/typetype/android/data/setup/LocalNetworkTargetResolverTest.kt +++ b/app/src/test/java/dev/typetype/android/data/setup/LocalNetworkTargetResolverTest.kt @@ -25,6 +25,15 @@ class LocalNetworkTargetResolverTest { ) } + @Test + fun tailscaleCgnatDomainRequestsLocalNetworkPermission() = runBlocking { + assertTrue( + localNetworkTarget("http://youtubetv.home") { + listOf(address(100, 96, 2, 1)) + }, + ) + } + @Test fun mixedPublicAndPrivateAnswersRequestPermission() = runBlocking { assertTrue( diff --git a/app/src/test/java/dev/typetype/android/domain/setup/ServerAddressTest.kt b/app/src/test/java/dev/typetype/android/domain/setup/ServerAddressTest.kt index 646b9649..9da5dcc8 100644 --- a/app/src/test/java/dev/typetype/android/domain/setup/ServerAddressTest.kt +++ b/app/src/test/java/dev/typetype/android/domain/setup/ServerAddressTest.kt @@ -23,18 +23,24 @@ class ServerAddressTest { } @Test - fun allowsExplicitHttpForLocalInstance() { + fun allowsPrivateLanHttpAfterExplicitOptIn() { assertEquals( listOf("http://192.168.1.10:8080/", "http://192.168.1.10:8080/api/"), - ServerAddress.candidateBaseUrls("http://192.168.1.10:8080"), + ServerAddress.candidateBaseUrls( + "http://192.168.1.10:8080", + allowCleartextHttp = true, + ), ) } @Test - fun keepsBracketedIpv6AndPort() { + fun allowsBracketedLocalIpv6AfterExplicitOptIn() { assertEquals( listOf("http://[fd12:3456::1]:8080/", "http://[fd12:3456::1]:8080/api/"), - ServerAddress.candidateBaseUrls("http://[fd12:3456::1]:8080"), + ServerAddress.candidateBaseUrls( + "http://[fd12:3456::1]:8080", + allowCleartextHttp = true, + ), ) } @@ -44,16 +50,34 @@ class ServerAddressTest { listOf("http://video.home.arpa:8080/", "http://video.home.arpa:8080/api/"), ServerAddress.candidateBaseUrls( "http://video.home.arpa:8080", - allowLocalCleartext = true, + allowCleartextHttp = true, ), ) } + @Test + fun allowsRemoteHttpOnlyAfterExplicitOptIn() { + assertEquals( + listOf("http://video.example/", "http://video.example/api/"), + ServerAddress.candidateBaseUrls("http://video.example", allowCleartextHttp = true), + ) + } + @Test(expected = IllegalArgumentException::class) fun rejectsPublicCleartextInstance() { ServerAddress.candidateBaseUrls("http://video.example") } + @Test(expected = IllegalArgumentException::class) + fun rejectsPrivateLanCleartextWithoutExplicitOptIn() { + ServerAddress.candidateBaseUrls("http://192.168.1.10:8080") + } + + @Test(expected = IllegalArgumentException::class) + fun rejectsTailscaleCleartextWithoutExplicitOptIn() { + ServerAddress.candidateBaseUrls("http://100.96.2.1:8080") + } + @Test fun `detects local hostnames`() { assertTrue(ServerAddress.requiresLocalNetworkAccess("typetype.local:8080")) From afd94a34b2074b5e5a20378524941d0a226798ca Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 15 Oct 2026 20:52:54 +0200 Subject: [PATCH 08/23] fix: keep fullscreen gestures inside safe areas --- .../player/PlayerContentLayoutComposeTest.kt | 35 ++++++ ...ullscreenGestureDeadzoneIntegrationTest.kt | 100 ++++++++++++++++++ .../feature/player/PlayerContentLayout.kt | 31 +++++- .../components/FullscreenGestureDeadzone.kt | 11 ++ .../components/PlayerFullscreenExitGesture.kt | 7 ++ .../components/PlayerGestureDragHandler.kt | 59 +++++++++++ .../player/components/PlayerGestureLayer.kt | 90 ++++++---------- .../FullscreenGestureDeadzoneTest.kt | 21 ++++ 8 files changed, 293 insertions(+), 61 deletions(-) create mode 100644 app/src/androidTest/java/dev/typetype/android/feature/player/components/FullscreenGestureDeadzoneIntegrationTest.kt create mode 100644 app/src/main/java/dev/typetype/android/feature/player/components/FullscreenGestureDeadzone.kt create mode 100644 app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureDragHandler.kt create mode 100644 app/src/test/java/dev/typetype/android/feature/player/components/FullscreenGestureDeadzoneTest.kt diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerContentLayoutComposeTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerContentLayoutComposeTest.kt index bf6d75e4..23a86d42 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerContentLayoutComposeTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerContentLayoutComposeTest.kt @@ -53,6 +53,16 @@ class PlayerContentLayoutComposeTest { assertNodeCount(PLAYER_TWO_PANE_LAYOUT_TAG, 0) } + @Test + fun detailsTrackTheVideoBottomDuringPullDownTransition() { + assertDetailsTrackVideoBottom(width = 400.dp, height = 600.dp) + } + + @Test + fun tabletDetailsTrackTheVideoBottomDuringPullDownTransition() { + assertDetailsTrackVideoBottom(width = 1280.dp, height = 800.dp) + } + @Test fun portraitTabletKeepsDetailsBelowThePlayer() { setLayout(width = 1067.dp, height = 1440.dp) @@ -153,6 +163,31 @@ class PlayerContentLayoutComposeTest { } } + private fun assertDetailsTrackVideoBottom(width: Dp, height: Dp) { + var progress by mutableStateOf(0f) + composeRule.setContent { + MaterialTheme { + Box(Modifier.requiredWidth(width).requiredHeight(height)) { + PlayerContentLayout( + isFullscreen = false, + hostTransitionProgress = { progress }, + modifier = Modifier.fillMaxSize(), + viewport = { Box(it.testTag(VIEWPORT_TAG)) }, + details = { Box(it.height(300.dp).testTag(DETAILS_TAG)) }, + recommendations = { Box(it.testTag(RECOMMENDATIONS_TAG)) }, + ) + } + } + } + + composeRule.runOnIdle { progress = 0.5f } + composeRule.waitForIdle() + + val viewport = bounds(VIEWPORT_TAG) + val details = bounds(DETAILS_TAG) + assertEquals("viewport=$viewport details=$details", viewport.bottom, details.top, 2f) + } + private fun bounds(tag: String) = composeRule .onNodeWithTag(tag) .fetchSemanticsNode() diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/components/FullscreenGestureDeadzoneIntegrationTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/components/FullscreenGestureDeadzoneIntegrationTest.kt new file mode 100644 index 00000000..19affedc --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/components/FullscreenGestureDeadzoneIntegrationTest.kt @@ -0,0 +1,100 @@ +package dev.typetype.android.feature.player.components + +import android.os.Looper +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.click +import androidx.compose.ui.test.doubleClick +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipe +import androidx.compose.ui.unit.dp +import androidx.media3.common.Player +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dev.typetype.android.feature.player.state.PlayerGestureState +import java.util.concurrent.atomic.AtomicInteger +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class FullscreenGestureDeadzoneIntegrationTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun edgeTouchesPassThroughAndCenterGesturesRemainActive() { + val player = GestureTestPlayer(Looper.getMainLooper()) + val tapCount = AtomicInteger() + composeRule.setContent { + PlayerGestureLayer( + player = player, + state = remember { PlayerGestureState() }, + onSingleTap = { tapCount.incrementAndGet() }, + onAdjustBrightness = {}, + onAdjustVolume = {}, + isFullscreen = true, + config = PlayerGestureConfig(swipeSeekEnabled = true), + modifier = Modifier.size(300.dp, 180.dp).testTag(GESTURE_TAG), + ) + } + + composeRule.onNodeWithTag(GESTURE_TAG).performTouchInput { + click(Offset(center.x, 8f)) + doubleClick(Offset(center.x, height - 8f)) + swipe(Offset(40f, 8f), Offset(width - 40f, 8f), 300L) + swipe(Offset(40f, height - 8f), Offset(width - 40f, height - 8f), 300L) + } + composeRule.runOnIdle { + assertEquals(0, tapCount.get()) + assertEquals(20_000L, player.currentPosition) + } + + composeRule.onNodeWithTag(GESTURE_TAG).performTouchInput { + click(center) + swipe(Offset(40f, center.y), Offset(width - 40f, center.y), 300L) + } + composeRule.runOnIdle { + assertEquals(1, tapCount.get()) + assertTrue(player.currentPosition > 20_000L) + player.release() + } + } + + @Test + fun fullscreenExitIgnoresTopAndBottomEdgeSwipes() { + val exitCount = AtomicInteger() + composeRule.setContent { + PlayerFullscreenExitGestureBox( + enabled = true, + onGestureFeedback = {}, + onExitFullscreen = { exitCount.incrementAndGet() }, + modifier = Modifier.size(300.dp, 180.dp).testTag(GESTURE_TAG), + ) { Box(Modifier.fillMaxSize()) } + } + + composeRule.onNodeWithTag(GESTURE_TAG).performTouchInput { + swipe(Offset(center.x, 8f), Offset(center.x, height - 8f), 300L) + swipe(Offset(center.x, height - 8f), Offset(center.x, 8f), 300L) + } + composeRule.runOnIdle { assertEquals(0, exitCount.get()) } + + composeRule.onNodeWithTag(GESTURE_TAG).performTouchInput { + swipe(center, Offset(center.x, height - 8f), 300L) + } + composeRule.runOnIdle { assertEquals(1, exitCount.get()) } + } + + private companion object { + const val GESTURE_TAG = "fullscreen_gesture_deadzone" + } +} diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerContentLayout.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerContentLayout.kt index 77042a7a..10eb0de4 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlayerContentLayout.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerContentLayout.kt @@ -76,6 +76,13 @@ internal fun PlayerContentLayout( derivedStateOf { hostTransitionProgress() >= 0.99f } } BoxWithConstraints(modifier = modifier.fillMaxSize()) { + val availableWidthPx = with(density) { maxWidth.toPx() } + val singleColumnDetailsTravelPx = miniTopPx + miniHeightPx - + availableWidthPx / VIDEO_ASPECT_RATIO + val twoPaneVideoWidthPx = availableWidthPx * PLAYER_PANE_WEIGHT / + (PLAYER_PANE_WEIGHT + DETAILS_PANE_WEIGHT) + val twoPaneDetailsTravelPx = miniTopPx + miniHeightPx - + twoPaneVideoWidthPx / VIDEO_ASPECT_RATIO when (playerContentLayoutMode(maxWidth.value, maxHeight.value, isFullscreen, isInPip)) { PlayerContentLayoutMode.Fullscreen -> retainedViewport( Modifier @@ -111,12 +118,18 @@ internal fun PlayerContentLayout( details( Modifier .fillMaxWidth() - .playerDetailsTransition(hostTransitionProgress) + .playerDetailsTransition( + progress = hostTransitionProgress, + verticalTravelPx = singleColumnDetailsTravelPx, + ) .then(if (detailsHidden) Modifier.clearAndSetSemantics { } else Modifier), ) recommendations( Modifier.fillMaxWidth() - .playerDetailsTransition(hostTransitionProgress) + .playerDetailsTransition( + progress = hostTransitionProgress, + verticalTravelPx = singleColumnDetailsTravelPx, + ) .then(if (detailsHidden) Modifier.clearAndSetSemantics { } else Modifier), ) } @@ -148,7 +161,10 @@ internal fun PlayerContentLayout( ) details( Modifier.fillMaxWidth() - .playerDetailsTransition(hostTransitionProgress) + .playerDetailsTransition( + progress = hostTransitionProgress, + verticalTravelPx = twoPaneDetailsTravelPx, + ) .then(if (detailsHidden) Modifier.clearAndSetSemantics { } else Modifier), ) } @@ -185,8 +201,13 @@ private fun Modifier.playerViewportTransition( clip = fraction > 0f } -private fun Modifier.playerDetailsTransition(progress: () -> Float): Modifier = graphicsLayer { - alpha = 1f - progress().coerceIn(0f, 1f) +private fun Modifier.playerDetailsTransition( + progress: () -> Float, + verticalTravelPx: Float = 0f, +): Modifier = graphicsLayer { + val fraction = progress().coerceIn(0f, 1f) + translationY = verticalTravelPx * fraction + alpha = 1f - fraction } private const val VIDEO_ASPECT_RATIO = 16f / 9f diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/FullscreenGestureDeadzone.kt b/app/src/main/java/dev/typetype/android/feature/player/components/FullscreenGestureDeadzone.kt new file mode 100644 index 00000000..12d25b56 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/components/FullscreenGestureDeadzone.kt @@ -0,0 +1,11 @@ +package dev.typetype.android.feature.player.components + +internal fun isWithinFullscreenGestureDeadzone( + yPx: Float, + heightPx: Float, + deadzonePx: Float, +): Boolean { + if (heightPx <= 0f || deadzonePx <= 0f) return false + val boundedDeadzone = deadzonePx.coerceAtMost(heightPx / 2f) + return yPx <= boundedDeadzone || yPx >= heightPx - boundedDeadzone +} diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerFullscreenExitGesture.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerFullscreenExitGesture.kt index def36143..07a110d3 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerFullscreenExitGesture.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerFullscreenExitGesture.kt @@ -86,11 +86,18 @@ internal fun Modifier.playerFullscreenExitGesture( pointerInput(state, enabled) { val directionThresholdPx = 18.dp.toPx() val completionThresholdPx = 64.dp.toPx() + val edgeDeadzonePx = 48.dp.toPx() awaitEachGesture { val down = awaitFirstDown( requireUnconsumed = false, pass = PointerEventPass.Initial, ) + if (isWithinFullscreenGestureDeadzone( + yPx = down.position.y, + heightPx = size.height.toFloat(), + deadzonePx = edgeDeadzonePx, + ) + ) return@awaitEachGesture val startsInMiddle = down.position.x in (size.width * 0.35f)..(size.width * 0.65f) var lastPosition = down.position var totalDrag = Offset.Zero diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureDragHandler.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureDragHandler.kt new file mode 100644 index 00000000..67cf364a --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureDragHandler.kt @@ -0,0 +1,59 @@ +package dev.typetype.android.feature.player.components + +import androidx.compose.ui.geometry.Offset +import dev.typetype.android.feature.player.state.DragMode +import dev.typetype.android.feature.player.state.PlayerGestureState +import kotlin.math.abs + +private const val LEVEL_DRAG_VIEW_FRACTION = 0.75f + +internal fun handleDragMode( + state: PlayerGestureState, + mode: DragMode, + delta: Offset, + levelDragRangePx: Float, + onAdjustBrightness: (Float) -> Unit, + onAdjustVolume: (Float) -> Unit, +) { + when (mode) { + DragMode.Brightness -> { + val next = adjustLevelFraction( + state.brightnessFraction.floatValue, + delta.y, + levelDragRangePx, + ) + state.brightnessFraction.floatValue = next + onAdjustBrightness(next) + } + DragMode.Volume -> { + val next = adjustLevelFraction( + state.volumeFraction.floatValue, + delta.y, + levelDragRangePx, + ) + state.volumeFraction.floatValue = next + onAdjustVolume(next) + } + DragMode.Seek, + DragMode.FullscreenEnter, + DragMode.FullscreenExit, + DragMode.None, + -> Unit + } +} + +internal fun adjustLevelFraction(current: Float, deltaY: Float, dragRangePx: Float): Float = + (current - deltaY / dragRangePx.coerceAtLeast(1f)).coerceIn(0f, 1f) + +internal fun levelDragRangePx(width: Float, height: Float): Float = + minOf(width, height) * LEVEL_DRAG_VIEW_FRACTION + +internal fun pickDragMode(dragAmount: Offset, startX: Float, width: Float): DragMode = when { + abs(dragAmount.x) > abs(dragAmount.y) * 1.5f -> DragMode.Seek + abs(dragAmount.x) > abs(dragAmount.y) -> DragMode.None + startX in (width * 0.35f)..(width * 0.65f) && dragAmount.y < 0f -> DragMode.FullscreenEnter + startX in (width * 0.35f)..(width * 0.65f) && dragAmount.y > 0f -> DragMode.FullscreenExit + startX < width / 2f -> DragMode.Brightness + startX > width * 2f / 3f -> DragMode.Volume + else -> DragMode.None +} diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureLayer.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureLayer.kt index d60da04d..ffeb3fc4 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureLayer.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureLayer.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.media3.common.Player @@ -29,8 +30,8 @@ import dev.typetype.android.feature.player.state.ResizeMode import kotlin.math.abs private const val DIRECTION_LOCK_THRESHOLD_PX = 18f -private const val LEVEL_DRAG_VIEW_FRACTION = 0.75f private const val LONG_PRESS_SPEED_FACTOR = 2f +private val FULLSCREEN_GESTURE_DEADZONE = 48.dp @Composable fun PlayerGestureLayer( @@ -50,6 +51,9 @@ fun PlayerGestureLayer( onVolumeGestureStart: () -> Float = { state.volumeFraction.floatValue }, ) { val currentOnSingleTap by rememberUpdatedState(onSingleTap) + val fullscreenGestureDeadzonePx = with(LocalDensity.current) { + FULLSCREEN_GESTURE_DEADZONE.toPx() + } var savedSpeed by remember { mutableFloatStateOf(1f) } var holdSpeed by remember { mutableFloatStateOf(2f) } fun restoreSpeed() { @@ -62,13 +66,19 @@ fun PlayerGestureLayer( Box( modifier = modifier .fillMaxSize() - .pointerInput(player, config, isFullscreen) { + .pointerInput(player, config, isFullscreen, fullscreenGestureDeadzonePx) { awaitEachGesture { val down = awaitFirstDown( requireUnconsumed = false, pass = PointerEventPass.Initial, ) val startX = down.position.x + if (isFullscreen && isWithinFullscreenGestureDeadzone( + yPx = down.position.y, + heightPx = size.height.toFloat(), + deadzonePx = fullscreenGestureDeadzonePx, + ) + ) return@awaitEachGesture var lastPosition = down.position var totalDrag = Offset.Zero var mode = DragMode.None @@ -182,10 +192,23 @@ fun PlayerGestureLayer( } } } - .pointerInput(player, config) { + .pointerInput(player, config, isFullscreen, fullscreenGestureDeadzonePx) { detectTapGestures( - onTap = { currentOnSingleTap() }, + onTap = { offset -> + if (!isFullscreen || !isWithinFullscreenGestureDeadzone( + offset.y, + size.height.toFloat(), + fullscreenGestureDeadzonePx, + ) + ) currentOnSingleTap() + }, onDoubleTap = { offset -> + if (isFullscreen && isWithinFullscreenGestureDeadzone( + offset.y, + size.height.toFloat(), + fullscreenGestureDeadzonePx, + ) + ) return@detectTapGestures val action = doubleTapAction(offset.x, size.width.toFloat()) if (!action.isEnabled(config.doubleTapSeekEnabled)) { return@detectTapGestures @@ -209,7 +232,13 @@ fun PlayerGestureLayer( } } }, - onLongPress = { + onLongPress = { offset -> + if (isFullscreen && isWithinFullscreenGestureDeadzone( + offset.y, + size.height.toFloat(), + fullscreenGestureDeadzonePx, + ) + ) return@detectTapGestures if (!config.longPressSpeedEnabled) return@detectTapGestures savedSpeed = player.playbackParameters.speed holdSpeed = LONG_PRESS_SPEED_FACTOR @@ -256,57 +285,6 @@ fun PlayerGestureLayer( private val PlayerGestureConfig.seekIncrementMs: Long get() = doubleTapSeekSeconds.coerceIn(5, 30) * 1_000L -private fun handleDragMode( - state: PlayerGestureState, - mode: DragMode, - delta: Offset, - levelDragRangePx: Float, - onAdjustBrightness: (Float) -> Unit, - onAdjustVolume: (Float) -> Unit, -) { - when (mode) { - DragMode.Brightness -> { - val next = adjustLevelFraction( - state.brightnessFraction.floatValue, - delta.y, - levelDragRangePx, - ) - state.brightnessFraction.floatValue = next - onAdjustBrightness(next) - } - DragMode.Volume -> { - val next = adjustLevelFraction( - state.volumeFraction.floatValue, - delta.y, - levelDragRangePx, - ) - state.volumeFraction.floatValue = next - onAdjustVolume(next) - } - DragMode.Seek, - DragMode.FullscreenEnter, - DragMode.FullscreenExit, - DragMode.None, - -> Unit - } -} - -internal fun adjustLevelFraction(current: Float, deltaY: Float, dragRangePx: Float): Float = - (current - deltaY / dragRangePx.coerceAtLeast(1f)).coerceIn(0f, 1f) - -internal fun levelDragRangePx(width: Float, height: Float): Float = - minOf(width, height) * LEVEL_DRAG_VIEW_FRACTION - -internal fun pickDragMode(dragAmount: Offset, startX: Float, width: Float): DragMode = when { - abs(dragAmount.x) > abs(dragAmount.y) * 1.5f -> DragMode.Seek - abs(dragAmount.x) > abs(dragAmount.y) -> DragMode.None - startX in (width * 0.35f)..(width * 0.65f) && dragAmount.y < 0f -> DragMode.FullscreenEnter - startX in (width * 0.35f)..(width * 0.65f) && dragAmount.y > 0f -> DragMode.FullscreenExit - startX < width / 2f -> DragMode.Brightness - startX > width * 2f / 3f -> DragMode.Volume - else -> DragMode.None -} - private fun resetDragState(state: PlayerGestureState) { state.dragMode.value = DragMode.None state.brightnessOverlayActive.value = false diff --git a/app/src/test/java/dev/typetype/android/feature/player/components/FullscreenGestureDeadzoneTest.kt b/app/src/test/java/dev/typetype/android/feature/player/components/FullscreenGestureDeadzoneTest.kt new file mode 100644 index 00000000..7fead9d0 --- /dev/null +++ b/app/src/test/java/dev/typetype/android/feature/player/components/FullscreenGestureDeadzoneTest.kt @@ -0,0 +1,21 @@ +package dev.typetype.android.feature.player.components + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class FullscreenGestureDeadzoneTest { + @Test + fun identifiesTopAndBottomDeadZones() { + assertTrue(isWithinFullscreenGestureDeadzone(8f, 600f, 48f)) + assertTrue(isWithinFullscreenGestureDeadzone(592f, 600f, 48f)) + assertFalse(isWithinFullscreenGestureDeadzone(120f, 600f, 48f)) + } + + @Test + fun deadzoneCannotConsumeTheEntireShortViewport() { + assertTrue(isWithinFullscreenGestureDeadzone(20f, 60f, 48f)) + assertFalse(isWithinFullscreenGestureDeadzone(30f, 60f, 0f)) + assertFalse(isWithinFullscreenGestureDeadzone(30f, 0f, 48f)) + } +} From 9c35d8ca4cf2767487229ad2a73e0c2116bb1f0d Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 15 Oct 2026 21:07:23 +0200 Subject: [PATCH 09/23] feat: model recommender instance capability --- .../android/data/network/dto/InstanceDto.kt | 8 ++++++++ .../server/RemoteServerCapabilitiesRepository.kt | 9 +++++++++ .../domain/server/RecommenderCapability.kt | 10 ++++++++++ .../dev/typetype/android/domain/server/Server.kt | 1 + .../data/server/ServerCapabilitiesTest.kt | 16 ++++++++++++++++ 5 files changed, 44 insertions(+) create mode 100644 app/src/main/java/dev/typetype/android/domain/server/RecommenderCapability.kt diff --git a/app/src/main/java/dev/typetype/android/data/network/dto/InstanceDto.kt b/app/src/main/java/dev/typetype/android/data/network/dto/InstanceDto.kt index 217f8000..bbbe2604 100644 --- a/app/src/main/java/dev/typetype/android/data/network/dto/InstanceDto.kt +++ b/app/src/main/java/dev/typetype/android/data/network/dto/InstanceDto.kt @@ -31,6 +31,7 @@ data class InstanceResponse( val youtubeRemoteLoginUnavailableReason: String? = null, val rss: RssInstanceCapabilityDto? = null, val pushNotifications: PushCapabilityDto? = null, + val recommender: RecommenderCapabilityDto? = null, ) @Serializable @@ -50,6 +51,13 @@ data class PushCapabilityDto( val maxDevicesPerAccount: Int = 0, ) +@Serializable +data class RecommenderCapabilityDto( + val installed: Boolean = false, + val enabled: Boolean = false, + val experimental: Boolean = false, +) + @Serializable data class MinClientVersion( val android: String? = null, diff --git a/app/src/main/java/dev/typetype/android/data/server/RemoteServerCapabilitiesRepository.kt b/app/src/main/java/dev/typetype/android/data/server/RemoteServerCapabilitiesRepository.kt index 177862d9..eefeb294 100644 --- a/app/src/main/java/dev/typetype/android/data/server/RemoteServerCapabilitiesRepository.kt +++ b/app/src/main/java/dev/typetype/android/data/server/RemoteServerCapabilitiesRepository.kt @@ -6,6 +6,7 @@ import dev.typetype.android.data.network.dto.PushCapabilityDto import dev.typetype.android.data.network.requireSuccessfulResponse import dev.typetype.android.domain.server.RssCapability import dev.typetype.android.domain.server.PushCapability +import dev.typetype.android.domain.server.RecommenderCapability import dev.typetype.android.domain.server.Server import dev.typetype.android.domain.server.ServerCapabilitiesRepository import dev.typetype.android.domain.server.ServerRepository @@ -65,6 +66,7 @@ internal fun Server.withCapabilities(instance: InstanceResponse): Server = copy( ) } ?: RssCapability(), push = instance.pushNotifications.toCapability(), + recommender = instance.recommender.toCapability(), ) private fun PushCapabilityDto?.toCapability(): PushCapability = PushCapability( @@ -74,6 +76,13 @@ private fun PushCapabilityDto?.toCapability(): PushCapability = PushCapability( maxDevicesPerAccount = this?.maxDevicesPerAccount ?: 0, ) +private fun dev.typetype.android.data.network.dto.RecommenderCapabilityDto?.toCapability(): RecommenderCapability = + RecommenderCapability( + installed = this?.installed == true, + enabled = this?.enabled == true, + experimental = this?.experimental == true, + ) + private suspend fun captureResult(block: suspend () -> T): Result = try { Result.success(block()) } catch (cancelled: CancellationException) { diff --git a/app/src/main/java/dev/typetype/android/domain/server/RecommenderCapability.kt b/app/src/main/java/dev/typetype/android/domain/server/RecommenderCapability.kt new file mode 100644 index 00000000..ae69f9ff --- /dev/null +++ b/app/src/main/java/dev/typetype/android/domain/server/RecommenderCapability.kt @@ -0,0 +1,10 @@ +package dev.typetype.android.domain.server + +data class RecommenderCapability( + val installed: Boolean = false, + val enabled: Boolean = false, + val experimental: Boolean = false, +) { + val available: Boolean + get() = installed && enabled +} diff --git a/app/src/main/java/dev/typetype/android/domain/server/Server.kt b/app/src/main/java/dev/typetype/android/domain/server/Server.kt index d15674af..7904e60d 100644 --- a/app/src/main/java/dev/typetype/android/domain/server/Server.kt +++ b/app/src/main/java/dev/typetype/android/domain/server/Server.kt @@ -25,4 +25,5 @@ data class Server( val youtubeRemoteLoginUnavailableReason: String? = null, val rss: RssCapability = RssCapability(), val push: PushCapability = PushCapability(), + val recommender: RecommenderCapability = RecommenderCapability(), ) diff --git a/app/src/test/java/dev/typetype/android/data/server/ServerCapabilitiesTest.kt b/app/src/test/java/dev/typetype/android/data/server/ServerCapabilitiesTest.kt index cbc0b9c1..a5c55b5e 100644 --- a/app/src/test/java/dev/typetype/android/data/server/ServerCapabilitiesTest.kt +++ b/app/src/test/java/dev/typetype/android/data/server/ServerCapabilitiesTest.kt @@ -98,6 +98,22 @@ class ServerCapabilitiesTest { assertTrue(refreshed.push.maxDevicesPerAccount == 3) } + @Test + fun enabledRecommenderCapabilityIsPreserved() { + val refreshed = server.withCapabilities( + decode( + BASE_INSTANCE.dropLast(1) + + ",\"recommender\":{\"installed\":true,\"enabled\":true," + + "\"experimental\":true}}", + ), + ) + + assertTrue(refreshed.recommender.installed) + assertTrue(refreshed.recommender.enabled) + assertTrue(refreshed.recommender.experimental) + assertTrue(refreshed.recommender.available) + } + private fun decode(value: String): InstanceResponse = json.decodeFromString(value) private companion object { From cec2c0a9d04028120e91576552ea98b6e7b9b42b Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 15 Oct 2026 21:07:56 +0200 Subject: [PATCH 10/23] feat: manage server recommender settings --- .../core/di/RecommenderRepositoryModule.kt | 19 ++++ .../android/data/network/TypeTypeApi.kt | 3 +- .../data/network/TypeTypeRecommenderApi.kt | 15 +++ .../RecommenderSettingsJson.kt | 12 ++ .../RemoteRecommenderSettingsRepository.kt | 53 +++++++++ .../RecommenderSettingsRepository.kt | 6 + .../network/TypeTypeServerContractTest.kt | 22 +--- ...peTypeServerRecommendationsContractTest.kt | 107 ++++++++++++++++++ .../RecommenderSettingsJsonTest.kt | 31 +++++ 9 files changed, 251 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/dev/typetype/android/core/di/RecommenderRepositoryModule.kt create mode 100644 app/src/main/java/dev/typetype/android/data/network/TypeTypeRecommenderApi.kt create mode 100644 app/src/main/java/dev/typetype/android/data/recommendations/RecommenderSettingsJson.kt create mode 100644 app/src/main/java/dev/typetype/android/data/recommendations/RemoteRecommenderSettingsRepository.kt create mode 100644 app/src/main/java/dev/typetype/android/domain/recommendations/RecommenderSettingsRepository.kt create mode 100644 app/src/test/java/dev/typetype/android/data/network/TypeTypeServerRecommendationsContractTest.kt create mode 100644 app/src/test/java/dev/typetype/android/data/recommendations/RecommenderSettingsJsonTest.kt diff --git a/app/src/main/java/dev/typetype/android/core/di/RecommenderRepositoryModule.kt b/app/src/main/java/dev/typetype/android/core/di/RecommenderRepositoryModule.kt new file mode 100644 index 00000000..6da500f8 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/core/di/RecommenderRepositoryModule.kt @@ -0,0 +1,19 @@ +package dev.typetype.android.core.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dev.typetype.android.data.recommendations.RemoteRecommenderSettingsRepository +import dev.typetype.android.domain.recommendations.RecommenderSettingsRepository +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class RecommenderRepositoryModule { + @Binds + @Singleton + abstract fun bindRecommenderSettingsRepository( + implementation: RemoteRecommenderSettingsRepository, + ): RecommenderSettingsRepository +} diff --git a/app/src/main/java/dev/typetype/android/data/network/TypeTypeApi.kt b/app/src/main/java/dev/typetype/android/data/network/TypeTypeApi.kt index d6174f89..e91a8d17 100644 --- a/app/src/main/java/dev/typetype/android/data/network/TypeTypeApi.kt +++ b/app/src/main/java/dev/typetype/android/data/network/TypeTypeApi.kt @@ -14,4 +14,5 @@ interface TypeTypeApi : TypeTypeRssApi, TypeTypeYoutubeSessionApi, TypeTypeLibraryApi, - TypeTypeMediaApi + TypeTypeMediaApi, + TypeTypeRecommenderApi diff --git a/app/src/main/java/dev/typetype/android/data/network/TypeTypeRecommenderApi.kt b/app/src/main/java/dev/typetype/android/data/network/TypeTypeRecommenderApi.kt new file mode 100644 index 00000000..d895387a --- /dev/null +++ b/app/src/main/java/dev/typetype/android/data/network/TypeTypeRecommenderApi.kt @@ -0,0 +1,15 @@ +package dev.typetype.android.data.network + +import kotlinx.serialization.json.JsonObject +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.PUT + +interface TypeTypeRecommenderApi { + @GET("recommendations/settings") + suspend fun recommenderSettings(): Response + + @PUT("recommendations/settings") + suspend fun updateRecommenderSettings(@Body settings: JsonObject): Response +} diff --git a/app/src/main/java/dev/typetype/android/data/recommendations/RecommenderSettingsJson.kt b/app/src/main/java/dev/typetype/android/data/recommendations/RecommenderSettingsJson.kt new file mode 100644 index 00000000..ef811724 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/data/recommendations/RecommenderSettingsJson.kt @@ -0,0 +1,12 @@ +package dev.typetype.android.data.recommendations + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.jsonPrimitive + +internal fun JsonObject.withRecommenderEnabled(enabled: Boolean): JsonObject = + JsonObject(toMutableMap().apply { put("enabled", JsonPrimitive(enabled)) }) + +internal fun JsonObject.recommenderEnabled(): Boolean = + this["enabled"]?.jsonPrimitive?.booleanOrNull == true diff --git a/app/src/main/java/dev/typetype/android/data/recommendations/RemoteRecommenderSettingsRepository.kt b/app/src/main/java/dev/typetype/android/data/recommendations/RemoteRecommenderSettingsRepository.kt new file mode 100644 index 00000000..5a6f8f1e --- /dev/null +++ b/app/src/main/java/dev/typetype/android/data/recommendations/RemoteRecommenderSettingsRepository.kt @@ -0,0 +1,53 @@ +package dev.typetype.android.data.recommendations + +import dev.typetype.android.data.account.ActiveAccountScope +import dev.typetype.android.data.network.TypeTypeApiHolder +import dev.typetype.android.data.network.requireSuccessfulResponse +import dev.typetype.android.domain.recommendations.RecommenderSettingsRepository +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Singleton +class RemoteRecommenderSettingsRepository @Inject constructor( + private val apiHolder: TypeTypeApiHolder, + private val activeAccountScope: ActiveAccountScope, +) : RecommenderSettingsRepository { + override suspend fun enabled(): Result = captureResult { + val scope = activeAccountScope.require() + val response = withContext(Dispatchers.IO) { + apiHolder.require(scope).recommenderSettings() + } + response.requireSuccessfulResponse() + val settings = requireNotNull(response.body()) { "Empty recommender settings" } + activeAccountScope.verify(scope) + settings.recommenderEnabled() + } + + override suspend fun setEnabled(enabled: Boolean): Result = captureResult { + val scope = activeAccountScope.require() + val api = apiHolder.require(scope) + val currentResponse = withContext(Dispatchers.IO) { api.recommenderSettings() } + currentResponse.requireSuccessfulResponse() + val current = requireNotNull(currentResponse.body()) { "Empty recommender settings" } + activeAccountScope.verify(scope) + val update = current.withRecommenderEnabled(enabled) + val response = withContext(Dispatchers.IO) { + api.updateRecommenderSettings(update) + } + response.requireSuccessfulResponse() + val saved = requireNotNull(response.body()) { "Empty saved recommender settings" } + activeAccountScope.verify(scope) + saved.recommenderEnabled() + } +} + +private suspend fun captureResult(block: suspend () -> T): Result = try { + Result.success(block()) +} catch (cancelled: CancellationException) { + throw cancelled +} catch (failure: Throwable) { + Result.failure(failure) +} diff --git a/app/src/main/java/dev/typetype/android/domain/recommendations/RecommenderSettingsRepository.kt b/app/src/main/java/dev/typetype/android/domain/recommendations/RecommenderSettingsRepository.kt new file mode 100644 index 00000000..a1632024 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/domain/recommendations/RecommenderSettingsRepository.kt @@ -0,0 +1,6 @@ +package dev.typetype.android.domain.recommendations + +interface RecommenderSettingsRepository { + suspend fun enabled(): Result + suspend fun setEnabled(enabled: Boolean): Result +} diff --git a/app/src/test/java/dev/typetype/android/data/network/TypeTypeServerContractTest.kt b/app/src/test/java/dev/typetype/android/data/network/TypeTypeServerContractTest.kt index d66deefa..680935fc 100644 --- a/app/src/test/java/dev/typetype/android/data/network/TypeTypeServerContractTest.kt +++ b/app/src/test/java/dev/typetype/android/data/network/TypeTypeServerContractTest.kt @@ -208,14 +208,10 @@ class TypeTypeServerContractTest { } @Test - fun contractRevisionIsPinnedToInspectedDevelopmentSource() { - assertEquals(40, SERVER_CONTRACT_REVISION.length) - assertEquals("100b430", SERVER_CONTRACT_REVISION.take(7)) - } - - @Test - fun subscriptionFeedContractIsPinnedToTheDeployedBetaSource() { - assertEquals("0c38bae", SUBSCRIPTION_FEED_CONTRACT_REVISION.take(7)) + fun contractRevisionIsPinnedToTheStableServerRelease() { + assertEquals("1.10.0", STABLE_SERVER_VERSION) + assertEquals(40, STABLE_SERVER_REVISION.length) + assertEquals("36e1e7d", STABLE_SERVER_REVISION.take(7)) } @Test @@ -258,11 +254,6 @@ class TypeTypeServerContractTest { assertEquals("history-id", history?.id) } - @Test - fun collectionContractIsPinnedToFreshServerMain() { - assertEquals("e885c20", COLLECTION_CONTRACT_REVISION.take(7)) - } - @Test fun playlistSummaryAndDetailUseSeparateServerRoutes() = runBlocking { server.enqueue( @@ -327,9 +318,8 @@ class TypeTypeServerContractTest { URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20") private companion object { - const val SERVER_CONTRACT_REVISION = "100b4303a9c63a7def99d6cf5ba68af67233a41f" - const val SUBSCRIPTION_FEED_CONTRACT_REVISION = "0c38baee8fa37c313d676fc3df6c5d812081df32" - const val COLLECTION_CONTRACT_REVISION = "e885c209279684b56edd8be06c6c8fdb6857a1a6" + const val STABLE_SERVER_VERSION = "1.10.0" + const val STABLE_SERVER_REVISION = "36e1e7d174df9161900c56e118e3543375e60c45" const val VIDEO_URL = "https://www.youtube.com/watch?v=video" const val NICONICO_URL = "https://www.nicovideo.jp/watch/sm9" const val BILIBILI_URL = "https://www.bilibili.com/video/BV1xx" diff --git a/app/src/test/java/dev/typetype/android/data/network/TypeTypeServerRecommendationsContractTest.kt b/app/src/test/java/dev/typetype/android/data/network/TypeTypeServerRecommendationsContractTest.kt new file mode 100644 index 00000000..bb582419 --- /dev/null +++ b/app/src/test/java/dev/typetype/android/data/network/TypeTypeServerRecommendationsContractTest.kt @@ -0,0 +1,107 @@ +package dev.typetype.android.data.network + +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class TypeTypeServerRecommendationsContractTest { + private lateinit var server: MockWebServer + private lateinit var api: TypeTypeApi + + @Before + fun setUp() { + server = MockWebServer() + server.start() + api = RetrofitFactory( + sessionClient = OkHttpClient(), + json = Json { ignoreUnknownKeys = true }, + ).create(server.url("/").toString()) + } + + @After + fun tearDown() { + server.shutdown() + } + + @Test + fun homeRecommendationsUseTheStableServerRouteAndQuery() = runBlocking { + server.enqueue(response("""{"items":[],"hasMore":false}""")) + + val result = api.homeRecommendations( + service = 0, + limit = 12, + intent = "auto", + cursor = "opaque", + ) + + assertTrue(result.isSuccessful) + assertEquals( + "/recommendations/home?service=0&limit=12&intent=auto&cursor=opaque", + server.takeRequest().path, + ) + } + + @Test + fun shortsRecommendationsUseTheRuntimeServerRouteAndQuery() = runBlocking { + server.enqueue(response("""{"items":[],"hasMore":false}""")) + + val result = api.shortsRecommendations( + service = 4, + limit = 5, + intent = "quick", + cursor = "opaque", + ) + + assertTrue(result.isSuccessful) + assertEquals( + "/recommendations/shorts?service=4&limit=5&intent=quick&cursor=opaque", + server.takeRequest().path, + ) + } + + @Test + fun recommenderSettingsUseStableGetAndReplaceRoutes() = runBlocking { + server.enqueue( + response("""{"enabled":false,"algorithmId":"signals","customInterests":["retro computers"]}"""), + ) + server.enqueue( + response("""{"enabled":true,"algorithmId":"signals","customInterests":["retro computers"]}"""), + ) + + val existing = requireNotNull(api.recommenderSettings().body()) + val saved = api.updateRecommenderSettings( + buildJsonObject { + put("enabled", true) + put("algorithmId", "signals") + put("customInterests", existing.getValue("customInterests")) + }, + ) + + assertTrue(saved.isSuccessful) + assertEquals( + "/recommendations/settings", + server.takeRequest().path, + ) + val updateRequest = server.takeRequest() + assertEquals("PUT", updateRequest.method) + assertEquals("/recommendations/settings", updateRequest.path) + assertEquals( + """{"enabled":true,"algorithmId":"signals","customInterests":["retro computers"]}""", + updateRequest.body.readUtf8(), + ) + } + + private fun response(body: String): MockResponse = MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(body) +} diff --git a/app/src/test/java/dev/typetype/android/data/recommendations/RecommenderSettingsJsonTest.kt b/app/src/test/java/dev/typetype/android/data/recommendations/RecommenderSettingsJsonTest.kt new file mode 100644 index 00000000..88618cd6 --- /dev/null +++ b/app/src/test/java/dev/typetype/android/data/recommendations/RecommenderSettingsJsonTest.kt @@ -0,0 +1,31 @@ +package dev.typetype.android.data.recommendations + +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class RecommenderSettingsJsonTest { + @Test + fun togglingPersonalizationPreservesEveryOtherServerSetting() { + val settings = buildJsonObject { + put("enabled", false) + put("algorithmId", "signals") + put("customInterests", buildJsonArray { add("astronomy") }) + put("futureServerField", "keep me") + } + + val updated = settings.withRecommenderEnabled(true) + + assertTrue(updated.recommenderEnabled()) + assertEquals("signals", updated["algorithmId"]?.jsonPrimitive?.content) + assertEquals("astronomy", updated["customInterests"]?.jsonArray?.first()?.jsonPrimitive?.content) + assertEquals("keep me", updated["futureServerField"]?.jsonPrimitive?.content) + assertFalse(settings.recommenderEnabled()) + } +} From 523a3f3c2c129b84e5021cd1e998a864ab77ad12 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 15 Oct 2026 21:08:05 +0200 Subject: [PATCH 11/23] feat: gate recommender controls by server capability --- .../content/ContentSettingsScreenTest.kt | 27 +++ .../settings/content/ContentSettingsAction.kt | 3 + .../settings/content/ContentSettingsScreen.kt | 36 ++++ .../settings/content/ContentSettingsState.kt | 7 + .../content/ContentSettingsViewModel.kt | 158 ++++++++++++++++++ app/src/main/res/values/strings.xml | 4 + 6 files changed, 235 insertions(+) diff --git a/app/src/androidTest/java/dev/typetype/android/feature/settings/content/ContentSettingsScreenTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/settings/content/ContentSettingsScreenTest.kt index 9eefa667..ee96f1f0 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/settings/content/ContentSettingsScreenTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/settings/content/ContentSettingsScreenTest.kt @@ -93,6 +93,33 @@ class ContentSettingsScreenTest { composeRule.onNodeWithText("Hide subscription live streams").assertIsDisplayed() } + @Test + fun recommenderControlAppearsOnlyWhenTheInstanceAdvertisesIt() { + val action = AtomicReference() + show( + ContentSettingsState( + isLoading = false, + recommenderAvailable = true, + recommenderExperimental = true, + ), + ) { action.set(it) } + + scrollTo("Use this instance’s recommender") + composeRule.onNodeWithText("Use this instance’s recommender").assertIsDisplayed() + composeRule.onNodeWithText("Experimental service; enabling it may change your home feed") + .assertIsDisplayed() + composeRule.onNodeWithText("Use this instance’s recommender").performClick() + + assertEquals(ContentSettingsAction.SetRecommenderEnabled(true), action.get()) + } + + @Test + fun recommenderControlIsHiddenWhenTheInstanceDoesNotAdvertiseIt() { + show(ContentSettingsState(isLoading = false)) + + composeRule.onNodeWithText("Use this instance’s recommender").assertDoesNotExist() + } + private fun show( state: ContentSettingsState, onAction: (ContentSettingsAction) -> Unit = {}, diff --git a/app/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsAction.kt b/app/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsAction.kt index 5abb6298..fec65bb9 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsAction.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsAction.kt @@ -5,6 +5,9 @@ import dev.typetype.android.domain.usersettings.UserSettings sealed interface ContentSettingsAction { data object Retry : ContentSettingsAction data object DismissFailure : ContentSettingsAction + data object RetryRecommender : ContentSettingsAction + data object DismissRecommenderFailure : ContentSettingsAction + data class SetRecommenderEnabled(val enabled: Boolean) : ContentSettingsAction sealed interface Update : ContentSettingsAction diff --git a/app/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsScreen.kt b/app/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsScreen.kt index a08f8570..c01d4547 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsScreen.kt @@ -170,6 +170,42 @@ fun ContentSettingsScreen( ) } } + if (state.recommenderAvailable) { + item { + SettingsSectionHeader( + stringResource(R.string.settings_content_recommender), + ) + } + item { + SwitchRow( + title = stringResource(R.string.settings_content_recommender_enabled), + subtitle = stringResource( + if (state.recommenderExperimental) { + R.string.settings_content_recommender_experimental + } else { + R.string.settings_content_recommender_subtitle + }, + ), + checked = state.recommenderEnabled, + onCheckedChange = { + onAction(ContentSettingsAction.SetRecommenderEnabled(it)) + }, + enabled = !state.isRecommenderLoading && !state.isRecommenderUpdating, + ) + } + state.recommenderErrorMessage?.let { message -> + item { + ContentSettingsFailureCard( + message = message, + requestId = state.recommenderErrorRequestId, + onRetry = { onAction(ContentSettingsAction.RetryRecommender) }, + onDismiss = { + onAction(ContentSettingsAction.DismissRecommenderFailure) + }, + ) + } + } + } } } } diff --git a/app/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsState.kt b/app/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsState.kt index 9e0fe6ca..754fb1e0 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsState.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsState.kt @@ -11,6 +11,13 @@ data class ContentSettingsState( val hideShorts: Boolean = false, val hideSubscriptionLiveStreams: Boolean = false, val supportsHideSubscriptionLiveStreams: Boolean = false, + val recommenderAvailable: Boolean = false, + val recommenderEnabled: Boolean = false, + val recommenderExperimental: Boolean = false, + val isRecommenderLoading: Boolean = false, + val isRecommenderUpdating: Boolean = false, + val recommenderErrorMessage: String? = null, + val recommenderErrorRequestId: String? = null, val deArrowEnabled: Boolean = false, val deArrowTitleMode: String = "dearrow", val deArrowThumbnailMode: String = "dearrow_or_random", diff --git a/app/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsViewModel.kt b/app/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsViewModel.kt index 26da36f0..8309c9a2 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsViewModel.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsViewModel.kt @@ -5,15 +5,24 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dev.typetype.android.R import dev.typetype.android.core.ui.error.UserErrorMapper +import dev.typetype.android.data.account.AccountScope import dev.typetype.android.data.account.ActiveAccountScope import dev.typetype.android.data.preferences.StartupLandingStore +import dev.typetype.android.domain.account.AccountRepository +import dev.typetype.android.domain.recommendations.RecommenderSettingsRepository +import dev.typetype.android.domain.server.ServerCapabilitiesRepository +import dev.typetype.android.domain.server.ServerRepository import dev.typetype.android.domain.usersettings.UserSettings import dev.typetype.android.domain.usersettings.UserSettingsRepository import javax.inject.Inject +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -24,6 +33,10 @@ class ContentSettingsViewModel @Inject constructor( private val errorMapper: UserErrorMapper, private val activeAccountScope: ActiveAccountScope, private val startupLandingStore: StartupLandingStore, + private val accountRepository: AccountRepository, + private val serverRepository: ServerRepository, + private val capabilitiesRepository: ServerCapabilitiesRepository, + private val recommenderSettingsRepository: RecommenderSettingsRepository, ) : ViewModel() { private val feedback = MutableStateFlow(ContentSettingsFeedback()) @@ -39,18 +52,139 @@ class ContentSettingsViewModel @Inject constructor( init { refresh() + observeRecommender() } fun onAction(action: ContentSettingsAction) { when (action) { ContentSettingsAction.Retry -> refresh() + ContentSettingsAction.RetryRecommender -> refreshRecommender() ContentSettingsAction.DismissFailure -> feedback.update { it.copy(errorMessage = null, errorRequestId = null) } + ContentSettingsAction.DismissRecommenderFailure -> feedback.update { + it.copy(recommenderErrorMessage = null, recommenderErrorRequestId = null) + } + is ContentSettingsAction.SetRecommenderEnabled -> updateRecommender(action.enabled) is ContentSettingsAction.Update -> update(action) } } + private fun observeRecommender() { + viewModelScope.launch { + activeAccountScope.observe().distinctUntilChanged().collectLatest { scope -> + feedback.update { it.withoutRecommender(scope != null) } + loadRecommender(scope) + } + } + } + + private fun refreshRecommender() { + viewModelScope.launch { + val scope = activeAccountScope.observe().first() + feedback.update { it.withoutRecommender(scope != null) } + loadRecommender(scope) + } + } + + private suspend fun loadRecommender(scope: AccountScope?) { + if (scope == null) return + try { + val account = accountRepository.observeAccounts().first().firstOrNull { + it.serverId == scope.serverId && it.id == scope.accountId + } + if (account == null || account.isGuest) { + clearRecommender(scope) + return + } + val cachedServer = serverRepository.getServer(scope.serverId) + if (cachedServer == null) { + clearRecommender(scope) + return + } + val server = capabilitiesRepository.refresh(scope.serverId).getOrNull() ?: cachedServer + if (!scopeIsCurrent(scope)) return + if (!server.recommender.available) { + feedback.update { it.withoutRecommender() } + return + } + feedback.update { + it.copy( + recommenderAvailable = true, + recommenderExperimental = server.recommender.experimental, + ) + } + val result = recommenderSettingsRepository.enabled() + if (!scopeIsCurrent(scope)) return + result.fold( + onSuccess = { enabled -> + feedback.update { + it.copy( + recommenderEnabled = enabled, + isRecommenderLoading = false, + recommenderErrorMessage = null, + recommenderErrorRequestId = null, + ) + } + }, + onFailure = ::showRecommenderFailure, + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + if (scopeIsCurrent(scope)) showRecommenderFailure(failure) + } + } + + private suspend fun clearRecommender(scope: AccountScope) { + if (scopeIsCurrent(scope)) feedback.update { it.withoutRecommender() } + } + + private suspend fun scopeIsCurrent(expected: AccountScope): Boolean = + activeAccountScope.observe().first() == expected + + private fun updateRecommender(enabled: Boolean) { + val previousEnabled = feedback.value.recommenderEnabled + if (!feedback.value.recommenderAvailable || feedback.value.isRecommenderLoading || + feedback.value.isRecommenderUpdating || previousEnabled == enabled + ) return + viewModelScope.launch { + val scope = activeAccountScope.observe().first() ?: return@launch + if (!feedback.value.recommenderAvailable) return@launch + feedback.update { + it.copy( + recommenderEnabled = enabled, + isRecommenderUpdating = true, + recommenderErrorMessage = null, + recommenderErrorRequestId = null, + ) + } + val result = recommenderSettingsRepository.setEnabled(enabled) + if (!scopeIsCurrent(scope)) return@launch + result.fold( + onSuccess = { saved -> + feedback.update { + it.copy(recommenderEnabled = saved, isRecommenderUpdating = false) + } + }, + onFailure = { failure -> showRecommenderFailure(failure, previousEnabled) }, + ) + } + } + + private fun showRecommenderFailure(failure: Throwable, enabled: Boolean? = null) { + val details = errorMapper.details(failure, R.string.settings_content_update_failed) + feedback.update { + it.copy( + recommenderEnabled = enabled ?: it.recommenderEnabled, + isRecommenderLoading = false, + isRecommenderUpdating = false, + recommenderErrorMessage = details.message, + recommenderErrorRequestId = details.requestId, + ) + } + } + private fun refresh() { viewModelScope.launch { feedback.update { it.copy(isLoading = true, errorMessage = null, errorRequestId = null) } @@ -98,10 +232,27 @@ class ContentSettingsViewModel @Inject constructor( private data class ContentSettingsFeedback( val isLoading: Boolean = true, val isUpdating: Boolean = false, + val recommenderAvailable: Boolean = false, + val recommenderEnabled: Boolean = false, + val recommenderExperimental: Boolean = false, + val isRecommenderLoading: Boolean = false, + val isRecommenderUpdating: Boolean = false, + val recommenderErrorMessage: String? = null, + val recommenderErrorRequestId: String? = null, val errorMessage: String? = null, val errorRequestId: String? = null, ) +private fun ContentSettingsFeedback.withoutRecommender(loading: Boolean = false) = copy( + recommenderAvailable = false, + recommenderEnabled = false, + recommenderExperimental = false, + isRecommenderLoading = loading, + isRecommenderUpdating = false, + recommenderErrorMessage = null, + recommenderErrorRequestId = null, +) + private fun UserSettings.toContentSettingsState(feedback: ContentSettingsFeedback) = ContentSettingsState( isLoading = feedback.isLoading, @@ -114,6 +265,13 @@ private fun UserSettings.toContentSettingsState(feedback: ContentSettingsFeedbac hideShorts = hideShorts, hideSubscriptionLiveStreams = hideSubscriptionLiveStreams, supportsHideSubscriptionLiveStreams = supportsHideSubscriptionLiveStreams, + recommenderAvailable = feedback.recommenderAvailable, + recommenderEnabled = feedback.recommenderEnabled, + recommenderExperimental = feedback.recommenderExperimental, + isRecommenderLoading = feedback.isRecommenderLoading, + isRecommenderUpdating = feedback.isRecommenderUpdating, + recommenderErrorMessage = feedback.recommenderErrorMessage, + recommenderErrorRequestId = feedback.recommenderErrorRequestId, deArrowEnabled = deArrowEnabled, deArrowTitleMode = deArrowTitleMode, deArrowThumbnailMode = deArrowThumbnailMode, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 96f2d89c..1333383a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -231,6 +231,10 @@ Remove the Shorts surface Hide subscription live streams Remove live and upcoming streams from subscriptions + Recommendations + Use this instance’s recommender + Choose whether its recommendation service can shape your home feed + Experimental service; enabling it may change your home feed Content settings Couldn\'t update content settings Red From f144ae59f6bf97cb64e28ec3a1e92d3545618737 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 15 Oct 2026 21:08:12 +0200 Subject: [PATCH 12/23] build: prepare Android 1.9.1 release --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 157de064..bf2d4287 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 = 10901 - versionName = "1.9.1-beta.1" + versionCode = 10902 + versionName = "1.9.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" resValue("string", "app_name", "TypeType") } From 32cea77e08cb36f5a4d762eaa48356934f2164ee Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 15 Oct 2026 21:10:13 +0200 Subject: [PATCH 13/23] test: preserve recommender settings as JSON values --- .../data/recommendations/RecommenderSettingsJsonTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/dev/typetype/android/data/recommendations/RecommenderSettingsJsonTest.kt b/app/src/test/java/dev/typetype/android/data/recommendations/RecommenderSettingsJsonTest.kt index 88618cd6..8be0057c 100644 --- a/app/src/test/java/dev/typetype/android/data/recommendations/RecommenderSettingsJsonTest.kt +++ b/app/src/test/java/dev/typetype/android/data/recommendations/RecommenderSettingsJsonTest.kt @@ -1,5 +1,6 @@ package dev.typetype.android.data.recommendations +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonArray @@ -16,7 +17,7 @@ class RecommenderSettingsJsonTest { val settings = buildJsonObject { put("enabled", false) put("algorithmId", "signals") - put("customInterests", buildJsonArray { add("astronomy") }) + put("customInterests", buildJsonArray { add(JsonPrimitive("astronomy")) }) put("futureServerField", "keep me") } From 47b28f24680713fcdb6975ecf3b8aaa4354433b4 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 15 Oct 2026 22:36:27 +0200 Subject: [PATCH 14/23] test: verify displayed push notifications --- .../services/push/PushNotifierAndroidTest.kt | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 app/src/androidTest/java/dev/typetype/android/services/push/PushNotifierAndroidTest.kt diff --git a/app/src/androidTest/java/dev/typetype/android/services/push/PushNotifierAndroidTest.kt b/app/src/androidTest/java/dev/typetype/android/services/push/PushNotifierAndroidTest.kt new file mode 100644 index 00000000..6e9e9a12 --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/services/push/PushNotifierAndroidTest.kt @@ -0,0 +1,67 @@ +package dev.typetype.android.services.push + +import android.Manifest +import android.app.Notification +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import dev.typetype.android.domain.push.PushPayload +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PushNotifierAndroidTest { + @Test + fun subscriptionPayloadBecomesVisibleNotification() { + val context = ApplicationProvider.getApplicationContext() + grantNotificationPermission(context) + val manager = requireNotNull(context.getSystemService(NotificationManager::class.java)) + val payload = PushPayload( + eventType = "subscription_new_video", + serviceId = 0, + serviceName = "YouTube", + eventId = "push-notifier-test", + videoId = "push-notifier-video", + videoUrl = "https://www.youtube.com/watch?v=push-notifier-video", + channelId = "channel-test", + channelName = "Notification test channel", + channelAvatarUrl = "", + instanceId = "instance-test", + accountId = "account-test", + publishedAt = 1_760_000_000_000, + title = "Notification delivery smoke test", + ) + val notificationId = subscriptionNotificationId(payload.videoId, payload.videoUrl, payload.eventId) + + try { + PushNotifier(context).notify(payload) + val notification = manager.activeNotifications + .firstOrNull { record -> record.id == notificationId } + ?.notification + + assertNotNull(notification) + assertEquals( + payload.channelName, + notification?.extras?.getString(Notification.EXTRA_TITLE), + ) + assertEquals( + payload.title, + notification?.extras?.getCharSequence(Notification.EXTRA_TEXT)?.toString(), + ) + } finally { + manager.cancel(notificationId) + } + } + + private fun grantNotificationPermission(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + InstrumentationRegistry.getInstrumentation().uiAutomation + .grantRuntimePermission(context.packageName, Manifest.permission.POST_NOTIFICATIONS) + } + } +} From 21a5fb0fc704e9d38aa180fd1d8e3b2e839d3a01 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 15 Oct 2026 22:42:59 +0200 Subject: [PATCH 15/23] test: cover the channel notification bell --- .../feature/channel/ChannelScreenStateTest.kt | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/app/src/androidTest/java/dev/typetype/android/feature/channel/ChannelScreenStateTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/channel/ChannelScreenStateTest.kt index 30dc22af..1c7892b5 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/channel/ChannelScreenStateTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/channel/ChannelScreenStateTest.kt @@ -1,5 +1,6 @@ package dev.typetype.android.feature.channel +import android.content.Context import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -14,6 +15,8 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performImeAction import androidx.compose.ui.test.performTextInput +import androidx.test.core.app.ApplicationProvider +import dev.typetype.android.R import dev.typetype.android.core.ui.components.LocalAnimatedStatePlayback import dev.typetype.android.core.ui.theme.TypeTypeTheme import dev.typetype.android.domain.channel.Channel @@ -68,6 +71,23 @@ class ChannelScreenStateTest { composeRule.onNodeWithText("Channel video").assertIsDisplayed() } + @Test + fun subscribedChannelShowsNotificationBellWhenPushIsAvailable() { + showContent( + ChannelState( + isLoading = false, + channel = channel(), + isSubscribed = true, + notificationsAvailable = true, + ), + ) + + composeRule.onNodeWithContentDescription( + ApplicationProvider.getApplicationContext() + .getString(R.string.channel_notifications_enable), + ).assertIsDisplayed() + } + @Test fun discoveryUsesCompactSectionAndSortMenus() { val actions = mutableListOf() From a1d8ebe1145c547a9e29876e0a18e593349348e7 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 16 Oct 2026 06:30:35 +0200 Subject: [PATCH 16/23] feat: persist server recommender capability --- .../24.json | 2291 +++++++++++++++++ .../database/TypeTypeDatabaseMigrationTest.kt | 32 +- .../android/data/database/TypeTypeDatabase.kt | 2 +- .../database/TypeTypeDatabaseMigrations.kt | 18 + .../android/data/server/ServerEntity.kt | 12 + .../data/server/ServerEntityMappingTest.kt | 25 +- 6 files changed, 2377 insertions(+), 3 deletions(-) create mode 100644 app/schemas/dev.typetype.android.data.database.TypeTypeDatabase/24.json diff --git a/app/schemas/dev.typetype.android.data.database.TypeTypeDatabase/24.json b/app/schemas/dev.typetype.android.data.database.TypeTypeDatabase/24.json new file mode 100644 index 00000000..790ddbae --- /dev/null +++ b/app/schemas/dev.typetype.android.data.database.TypeTypeDatabase/24.json @@ -0,0 +1,2291 @@ +{ + "formatVersion": 1, + "database": { + "version": 24, + "identityHash": "6b946f3c1c6c852d9b6276710462a356", + "entities": [ + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `displayName` TEXT NOT NULL, `addedAt` INTEGER NOT NULL, `tagline` TEXT, `version` TEXT NOT NULL DEFAULT '', `revision` TEXT NOT NULL DEFAULT '', `apiVersion` INTEGER NOT NULL DEFAULT 0, `logoUrl` TEXT, `bannerUrl` TEXT, `supportedServicesCsv` TEXT NOT NULL DEFAULT '', `minAndroidClientVersion` TEXT, `registrationAllowed` INTEGER NOT NULL DEFAULT 0, `guestAllowed` INTEGER NOT NULL DEFAULT 0, `localLoginEnabled` INTEGER NOT NULL DEFAULT 1, `oidcEnabled` INTEGER NOT NULL DEFAULT 0, `oidcProviderName` TEXT, `oidcAutoRedirect` INTEGER NOT NULL DEFAULT 0, `youtubeRemoteLoginSupported` INTEGER NOT NULL DEFAULT 0, `youtubeRemoteLoginEnabled` INTEGER NOT NULL DEFAULT 0, `youtubeRemoteLoginReady` INTEGER NOT NULL DEFAULT 0, `youtubeRemoteLoginUnavailableReason` TEXT, `rssEnabled` INTEGER NOT NULL DEFAULT 0, `rssMaxFeedsPerUser` INTEGER NOT NULL DEFAULT 0, `rssMaxItems` INTEGER NOT NULL DEFAULT 0, `rssMinimumPollMinutes` INTEGER NOT NULL DEFAULT 0, `rssRateLimitPerMinute` INTEGER NOT NULL DEFAULT 0, `pushEnabled` INTEGER NOT NULL DEFAULT 0, `pushProvider` TEXT NOT NULL DEFAULT 'unifiedpush', `pushEventTypesCsv` TEXT NOT NULL DEFAULT '', `pushMaxDevicesPerAccount` INTEGER NOT NULL DEFAULT 0, `recommenderInstalled` INTEGER NOT NULL DEFAULT 0, `recommenderEnabled` INTEGER NOT NULL DEFAULT 0, `recommenderExperimental` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "addedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tagline", + "columnName": "tagline", + "affinity": "TEXT" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "apiVersion", + "columnName": "apiVersion", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "logoUrl", + "columnName": "logoUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "bannerUrl", + "columnName": "bannerUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "supportedServicesCsv", + "columnName": "supportedServicesCsv", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "minAndroidClientVersion", + "columnName": "minAndroidClientVersion", + "affinity": "TEXT" + }, + { + "fieldPath": "registrationAllowed", + "columnName": "registrationAllowed", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "guestAllowed", + "columnName": "guestAllowed", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "localLoginEnabled", + "columnName": "localLoginEnabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "oidcEnabled", + "columnName": "oidcEnabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "oidcProviderName", + "columnName": "oidcProviderName", + "affinity": "TEXT" + }, + { + "fieldPath": "oidcAutoRedirect", + "columnName": "oidcAutoRedirect", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "youtubeRemoteLoginSupported", + "columnName": "youtubeRemoteLoginSupported", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "youtubeRemoteLoginEnabled", + "columnName": "youtubeRemoteLoginEnabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "youtubeRemoteLoginReady", + "columnName": "youtubeRemoteLoginReady", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "youtubeRemoteLoginUnavailableReason", + "columnName": "youtubeRemoteLoginUnavailableReason", + "affinity": "TEXT" + }, + { + "fieldPath": "rssEnabled", + "columnName": "rssEnabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rssMaxFeedsPerUser", + "columnName": "rssMaxFeedsPerUser", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rssMaxItems", + "columnName": "rssMaxItems", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rssMinimumPollMinutes", + "columnName": "rssMinimumPollMinutes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rssRateLimitPerMinute", + "columnName": "rssRateLimitPerMinute", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "pushEnabled", + "columnName": "pushEnabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "pushProvider", + "columnName": "pushProvider", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'unifiedpush'" + }, + { + "fieldPath": "pushEventTypesCsv", + "columnName": "pushEventTypesCsv", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "pushMaxDevicesPerAccount", + "columnName": "pushMaxDevicesPerAccount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "recommenderInstalled", + "columnName": "recommenderInstalled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "recommenderEnabled", + "columnName": "recommenderEnabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "recommenderExperimental", + "columnName": "recommenderExperimental", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "favorites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `videoUrl` TEXT NOT NULL, `favoritedAtMillis` INTEGER NOT NULL, `title` TEXT NOT NULL DEFAULT '', `thumbnailUrl` TEXT NOT NULL DEFAULT '', `durationSeconds` INTEGER NOT NULL DEFAULT 0, `channelName` TEXT NOT NULL DEFAULT '', `channelUrl` TEXT NOT NULL DEFAULT '', `channelAvatarUrl` TEXT NOT NULL DEFAULT '', `viewCount` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`serverId`, `accountId`, `videoUrl`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoUrl", + "columnName": "videoUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "favoritedAtMillis", + "columnName": "favoritedAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "channelName", + "columnName": "channelName", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "channelUrl", + "columnName": "channelUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "channelAvatarUrl", + "columnName": "channelAvatarUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "viewCount", + "columnName": "viewCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "accountId", + "videoUrl" + ] + }, + "indices": [ + { + "name": "index_favorites_serverId_accountId", + "unique": false, + "columnNames": [ + "serverId", + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_favorites_serverId_accountId` ON `${TABLE_NAME}` (`serverId`, `accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId", + "accountId" + ], + "referencedColumns": [ + "serverId", + "accountId" + ] + } + ] + }, + { + "tableName": "history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `id` TEXT NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `thumbnailUrl` TEXT NOT NULL, `channelName` TEXT NOT NULL, `channelUrl` TEXT NOT NULL DEFAULT '', `channelAvatarUrl` TEXT NOT NULL DEFAULT '', `durationSeconds` INTEGER NOT NULL, `progressSeconds` INTEGER NOT NULL, `watchedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `accountId`, `id`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "channelName", + "columnName": "channelName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "channelUrl", + "columnName": "channelUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "channelAvatarUrl", + "columnName": "channelAvatarUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progressSeconds", + "columnName": "progressSeconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "watchedAtMillis", + "columnName": "watchedAtMillis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "accountId", + "id" + ] + }, + "indices": [ + { + "name": "index_history_serverId_accountId", + "unique": false, + "columnNames": [ + "serverId", + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_history_serverId_accountId` ON `${TABLE_NAME}` (`serverId`, `accountId`)" + }, + { + "name": "index_history_serverId_accountId_url", + "unique": false, + "columnNames": [ + "serverId", + "accountId", + "url" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_history_serverId_accountId_url` ON `${TABLE_NAME}` (`serverId`, `accountId`, `url`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId", + "accountId" + ], + "referencedColumns": [ + "serverId", + "accountId" + ] + } + ] + }, + { + "tableName": "watch_later", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `thumbnailUrl` TEXT NOT NULL, `durationSeconds` INTEGER NOT NULL, `addedAtMillis` INTEGER NOT NULL, `channelName` TEXT NOT NULL DEFAULT '', `channelUrl` TEXT NOT NULL DEFAULT '', `channelAvatarUrl` TEXT NOT NULL DEFAULT '', `viewCount` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`serverId`, `accountId`, `url`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addedAtMillis", + "columnName": "addedAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "channelName", + "columnName": "channelName", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "channelUrl", + "columnName": "channelUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "channelAvatarUrl", + "columnName": "channelAvatarUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "viewCount", + "columnName": "viewCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "accountId", + "url" + ] + }, + "indices": [ + { + "name": "index_watch_later_serverId_accountId", + "unique": false, + "columnNames": [ + "serverId", + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_watch_later_serverId_accountId` ON `${TABLE_NAME}` (`serverId`, `accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId", + "accountId" + ], + "referencedColumns": [ + "serverId", + "accountId" + ] + } + ] + }, + { + "tableName": "playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cacheKey` TEXT NOT NULL, `serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `createdAtMillis` INTEGER NOT NULL, `videoCount` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`cacheKey`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "cacheKey", + "columnName": "cacheKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtMillis", + "columnName": "createdAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoCount", + "columnName": "videoCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "cacheKey" + ] + }, + "indices": [ + { + "name": "index_playlists_serverId_accountId", + "unique": false, + "columnNames": [ + "serverId", + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_serverId_accountId` ON `${TABLE_NAME}` (`serverId`, `accountId`)" + }, + { + "name": "index_playlists_serverId_accountId_id", + "unique": true, + "columnNames": [ + "serverId", + "accountId", + "id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlists_serverId_accountId_id` ON `${TABLE_NAME}` (`serverId`, `accountId`, `id`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId", + "accountId" + ], + "referencedColumns": [ + "serverId", + "accountId" + ] + } + ] + }, + { + "tableName": "playlist_videos", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlistCacheKey` TEXT NOT NULL, `playlistId` TEXT NOT NULL, `id` TEXT NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `thumbnailUrl` TEXT NOT NULL, `durationSeconds` INTEGER NOT NULL, `position` INTEGER NOT NULL, `channelName` TEXT NOT NULL DEFAULT '', `channelUrl` TEXT NOT NULL DEFAULT '', `channelAvatarUrl` TEXT NOT NULL DEFAULT '', `viewCount` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`playlistCacheKey`, `id`), FOREIGN KEY(`playlistCacheKey`) REFERENCES `playlists`(`cacheKey`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "playlistCacheKey", + "columnName": "playlistCacheKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "channelName", + "columnName": "channelName", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "channelUrl", + "columnName": "channelUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "channelAvatarUrl", + "columnName": "channelAvatarUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "viewCount", + "columnName": "viewCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "playlistCacheKey", + "id" + ] + }, + "indices": [ + { + "name": "index_playlist_videos_playlistCacheKey", + "unique": false, + "columnNames": [ + "playlistCacheKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_videos_playlistCacheKey` ON `${TABLE_NAME}` (`playlistCacheKey`)" + } + ], + "foreignKeys": [ + { + "table": "playlists", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistCacheKey" + ], + "referencedColumns": [ + "cacheKey" + ] + } + ] + }, + { + "tableName": "video_meta", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `videoUrl` TEXT NOT NULL, `channelName` TEXT NOT NULL, `channelUrl` TEXT NOT NULL, `channelAvatarUrl` TEXT NOT NULL, `viewCount` INTEGER NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `accountId`, `videoUrl`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoUrl", + "columnName": "videoUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "channelName", + "columnName": "channelName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "channelUrl", + "columnName": "channelUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "channelAvatarUrl", + "columnName": "channelAvatarUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "viewCount", + "columnName": "viewCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAtMillis", + "columnName": "updatedAtMillis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "accountId", + "videoUrl" + ] + }, + "indices": [ + { + "name": "index_video_meta_serverId_accountId", + "unique": false, + "columnNames": [ + "serverId", + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_video_meta_serverId_accountId` ON `${TABLE_NAME}` (`serverId`, `accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId", + "accountId" + ], + "referencedColumns": [ + "serverId", + "accountId" + ] + } + ] + }, + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `publicUsername` TEXT, `role` TEXT, `avatarUrl` TEXT, `avatarType` TEXT, `avatarCode` TEXT, `isGuest` INTEGER NOT NULL, `lastUsedAt` INTEGER NOT NULL, `sessionGeneration` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`serverId`, `accountId`), FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicUsername", + "columnName": "publicUsername", + "affinity": "TEXT" + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarType", + "columnName": "avatarType", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarCode", + "columnName": "avatarCode", + "affinity": "TEXT" + }, + { + "fieldPath": "isGuest", + "columnName": "isGuest", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUsedAt", + "columnName": "lastUsedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sessionGeneration", + "columnName": "sessionGeneration", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "accountId" + ] + }, + "indices": [ + { + "name": "index_accounts_serverId", + "unique": false, + "columnNames": [ + "serverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_serverId` ON `${TABLE_NAME}` (`serverId`)" + } + ], + "foreignKeys": [ + { + "table": "servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "downloads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sessionGeneration` INTEGER NOT NULL DEFAULT 0, `requestId` TEXT NOT NULL, `workId` TEXT NOT NULL, `videoUrl` TEXT NOT NULL, `title` TEXT NOT NULL, `quality` TEXT NOT NULL, `serverJobId` TEXT, `systemDownloadId` INTEGER, `fileName` TEXT, `status` TEXT NOT NULL, `progressPercent` INTEGER, `stage` TEXT, `errorMessage` TEXT, `cached` INTEGER NOT NULL, `createdAtMillis` INTEGER NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `accountId`, `requestId`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionGeneration", + "columnName": "sessionGeneration", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "requestId", + "columnName": "requestId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "workId", + "columnName": "workId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoUrl", + "columnName": "videoUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quality", + "columnName": "quality", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverJobId", + "columnName": "serverJobId", + "affinity": "TEXT" + }, + { + "fieldPath": "systemDownloadId", + "columnName": "systemDownloadId", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileName", + "columnName": "fileName", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "progressPercent", + "columnName": "progressPercent", + "affinity": "INTEGER" + }, + { + "fieldPath": "stage", + "columnName": "stage", + "affinity": "TEXT" + }, + { + "fieldPath": "errorMessage", + "columnName": "errorMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "cached", + "columnName": "cached", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtMillis", + "columnName": "createdAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAtMillis", + "columnName": "updatedAtMillis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "accountId", + "requestId" + ] + }, + "indices": [ + { + "name": "index_downloads_serverId_accountId", + "unique": false, + "columnNames": [ + "serverId", + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_downloads_serverId_accountId` ON `${TABLE_NAME}` (`serverId`, `accountId`)" + }, + { + "name": "index_downloads_workId", + "unique": true, + "columnNames": [ + "workId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_downloads_workId` ON `${TABLE_NAME}` (`workId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId", + "accountId" + ], + "referencedColumns": [ + "serverId", + "accountId" + ] + } + ] + }, + { + "tableName": "progress_outbox", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `videoUrl` TEXT NOT NULL, `positionMillis` INTEGER NOT NULL, `sessionGeneration` INTEGER NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `accountId`, `videoUrl`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoUrl", + "columnName": "videoUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "positionMillis", + "columnName": "positionMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sessionGeneration", + "columnName": "sessionGeneration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAtMillis", + "columnName": "updatedAtMillis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "accountId", + "videoUrl" + ] + }, + "indices": [ + { + "name": "index_progress_outbox_serverId_accountId_sessionGeneration", + "unique": false, + "columnNames": [ + "serverId", + "accountId", + "sessionGeneration" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_progress_outbox_serverId_accountId_sessionGeneration` ON `${TABLE_NAME}` (`serverId`, `accountId`, `sessionGeneration`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId", + "accountId" + ], + "referencedColumns": [ + "serverId", + "accountId" + ] + } + ] + }, + { + "tableName": "library_sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `collection` TEXT NOT NULL, `refreshGeneration` INTEGER NOT NULL, `lastAttemptAtMillis` INTEGER NOT NULL, `lastSuccessAtMillis` INTEGER, `lastFailureAtMillis` INTEGER, `failureCode` TEXT, `failureStatusCode` INTEGER, `requestId` TEXT, PRIMARY KEY(`serverId`, `accountId`, `collection`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "collection", + "columnName": "collection", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshGeneration", + "columnName": "refreshGeneration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAttemptAtMillis", + "columnName": "lastAttemptAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSuccessAtMillis", + "columnName": "lastSuccessAtMillis", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastFailureAtMillis", + "columnName": "lastFailureAtMillis", + "affinity": "INTEGER" + }, + { + "fieldPath": "failureCode", + "columnName": "failureCode", + "affinity": "TEXT" + }, + { + "fieldPath": "failureStatusCode", + "columnName": "failureStatusCode", + "affinity": "INTEGER" + }, + { + "fieldPath": "requestId", + "columnName": "requestId", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "accountId", + "collection" + ] + }, + "indices": [ + { + "name": "index_library_sync_state_serverId_accountId", + "unique": false, + "columnNames": [ + "serverId", + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_library_sync_state_serverId_accountId` ON `${TABLE_NAME}` (`serverId`, `accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId", + "accountId" + ], + "referencedColumns": [ + "serverId", + "accountId" + ] + } + ] + }, + { + "tableName": "library_mutation_outbox", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `mutationKey` TEXT NOT NULL, `collection` TEXT NOT NULL, `kind` TEXT NOT NULL, `targetId` TEXT NOT NULL, `parentId` TEXT, `desiredPresent` INTEGER NOT NULL, `title` TEXT NOT NULL, `thumbnailUrl` TEXT NOT NULL, `durationSeconds` INTEGER NOT NULL, `channelName` TEXT NOT NULL, `channelUrl` TEXT NOT NULL, `channelAvatarUrl` TEXT NOT NULL, `viewCount` INTEGER NOT NULL, `sessionGeneration` INTEGER NOT NULL, `mutationVersion` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAtMillis` INTEGER NOT NULL, `updatedAtMillis` INTEGER NOT NULL, `lastAttemptAtMillis` INTEGER, `attemptCount` INTEGER NOT NULL, `failureCode` TEXT, `failureStatusCode` INTEGER, `requestId` TEXT, PRIMARY KEY(`serverId`, `accountId`, `mutationKey`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mutationKey", + "columnName": "mutationKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "collection", + "columnName": "collection", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetId", + "columnName": "targetId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT" + }, + { + "fieldPath": "desiredPresent", + "columnName": "desiredPresent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "channelName", + "columnName": "channelName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "channelUrl", + "columnName": "channelUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "channelAvatarUrl", + "columnName": "channelAvatarUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "viewCount", + "columnName": "viewCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sessionGeneration", + "columnName": "sessionGeneration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mutationVersion", + "columnName": "mutationVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtMillis", + "columnName": "createdAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAtMillis", + "columnName": "updatedAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAttemptAtMillis", + "columnName": "lastAttemptAtMillis", + "affinity": "INTEGER" + }, + { + "fieldPath": "attemptCount", + "columnName": "attemptCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "failureCode", + "columnName": "failureCode", + "affinity": "TEXT" + }, + { + "fieldPath": "failureStatusCode", + "columnName": "failureStatusCode", + "affinity": "INTEGER" + }, + { + "fieldPath": "requestId", + "columnName": "requestId", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "accountId", + "mutationKey" + ] + }, + "indices": [ + { + "name": "index_library_mutation_outbox_serverId_accountId_sessionGeneration_state", + "unique": false, + "columnNames": [ + "serverId", + "accountId", + "sessionGeneration", + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_library_mutation_outbox_serverId_accountId_sessionGeneration_state` ON `${TABLE_NAME}` (`serverId`, `accountId`, `sessionGeneration`, `state`)" + }, + { + "name": "index_library_mutation_outbox_serverId_accountId_collection", + "unique": false, + "columnNames": [ + "serverId", + "accountId", + "collection" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_library_mutation_outbox_serverId_accountId_collection` ON `${TABLE_NAME}` (`serverId`, `accountId`, `collection`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId", + "accountId" + ], + "referencedColumns": [ + "serverId", + "accountId" + ] + } + ] + }, + { + "tableName": "subscriptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `channelUrl` TEXT NOT NULL, `name` TEXT NOT NULL, `avatarUrl` TEXT NOT NULL, `subscribedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `accountId`, `channelUrl`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "channelUrl", + "columnName": "channelUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subscribedAtMillis", + "columnName": "subscribedAtMillis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "accountId", + "channelUrl" + ] + }, + "indices": [ + { + "name": "index_subscriptions_serverId_accountId", + "unique": false, + "columnNames": [ + "serverId", + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_subscriptions_serverId_accountId` ON `${TABLE_NAME}` (`serverId`, `accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId", + "accountId" + ], + "referencedColumns": [ + "serverId", + "accountId" + ] + } + ] + }, + { + "tableName": "playback_resume", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `videoUrl` TEXT NOT NULL, `positionMillis` INTEGER NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `accountId`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoUrl", + "columnName": "videoUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "positionMillis", + "columnName": "positionMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAtMillis", + "columnName": "updatedAtMillis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "accountId" + ] + }, + "indices": [ + { + "name": "index_playback_resume_serverId_accountId", + "unique": false, + "columnNames": [ + "serverId", + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playback_resume_serverId_accountId` ON `${TABLE_NAME}` (`serverId`, `accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId", + "accountId" + ], + "referencedColumns": [ + "serverId", + "accountId" + ] + } + ] + }, + { + "tableName": "playback_queue", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `position` INTEGER NOT NULL, `currentIndex` INTEGER NOT NULL, `queueTitle` TEXT NOT NULL, `repeatMode` TEXT NOT NULL DEFAULT 'Off', `videoUrl` TEXT NOT NULL, `title` TEXT NOT NULL, `thumbnailUrl` TEXT NOT NULL, `durationSeconds` INTEGER NOT NULL, `channelName` TEXT NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `accountId`, `position`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentIndex", + "columnName": "currentIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "queueTitle", + "columnName": "queueTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repeatMode", + "columnName": "repeatMode", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'Off'" + }, + { + "fieldPath": "videoUrl", + "columnName": "videoUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "channelName", + "columnName": "channelName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAtMillis", + "columnName": "updatedAtMillis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "accountId", + "position" + ] + }, + "indices": [ + { + "name": "index_playback_queue_serverId_accountId", + "unique": false, + "columnNames": [ + "serverId", + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playback_queue_serverId_accountId` ON `${TABLE_NAME}` (`serverId`, `accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId", + "accountId" + ], + "referencedColumns": [ + "serverId", + "accountId" + ] + } + ] + }, + { + "tableName": "saved_public_playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `id` TEXT NOT NULL, `publicPlaylistId` TEXT NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `thumbnailUrl` TEXT NOT NULL, `uploaderName` TEXT NOT NULL, `streamCount` INTEGER NOT NULL, `playlistType` TEXT NOT NULL, `savedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `accountId`, `id`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicPlaylistId", + "columnName": "publicPlaylistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uploaderName", + "columnName": "uploaderName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "streamCount", + "columnName": "streamCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistType", + "columnName": "playlistType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "savedAtMillis", + "columnName": "savedAtMillis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "accountId", + "id" + ] + }, + "indices": [ + { + "name": "index_saved_public_playlists_serverId_accountId", + "unique": false, + "columnNames": [ + "serverId", + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_saved_public_playlists_serverId_accountId` ON `${TABLE_NAME}` (`serverId`, `accountId`)" + }, + { + "name": "index_saved_public_playlists_serverId_accountId_url", + "unique": true, + "columnNames": [ + "serverId", + "accountId", + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_saved_public_playlists_serverId_accountId_url` ON `${TABLE_NAME}` (`serverId`, `accountId`, `url`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId", + "accountId" + ], + "referencedColumns": [ + "serverId", + "accountId" + ] + } + ] + }, + { + "tableName": "feed_videos", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `feed` TEXT NOT NULL, `position` INTEGER NOT NULL, `videoUrl` TEXT NOT NULL, `videoId` TEXT NOT NULL, `title` TEXT NOT NULL, `thumbnailUrl` TEXT NOT NULL, `uploaderName` TEXT NOT NULL, `uploaderUrl` TEXT NOT NULL, `uploaderAvatarUrl` TEXT NOT NULL, `uploaderVerified` INTEGER NOT NULL, `durationSeconds` INTEGER NOT NULL, `isLive` INTEGER NOT NULL, `viewCount` INTEGER NOT NULL, `uploadedAtMillis` INTEGER NOT NULL, `isShortFormContent` INTEGER NOT NULL, `shortDescription` TEXT, `publishedAtMillis` INTEGER, `isPostLive` INTEGER NOT NULL, `isLiveContent` INTEGER NOT NULL, `requiresMembership` INTEGER NOT NULL, `savedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `accountId`, `feed`, `videoUrl`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "feed", + "columnName": "feed", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoUrl", + "columnName": "videoUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoId", + "columnName": "videoId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uploaderName", + "columnName": "uploaderName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uploaderUrl", + "columnName": "uploaderUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uploaderAvatarUrl", + "columnName": "uploaderAvatarUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uploaderVerified", + "columnName": "uploaderVerified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLive", + "columnName": "isLive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewCount", + "columnName": "viewCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uploadedAtMillis", + "columnName": "uploadedAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShortFormContent", + "columnName": "isShortFormContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shortDescription", + "columnName": "shortDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "publishedAtMillis", + "columnName": "publishedAtMillis", + "affinity": "INTEGER" + }, + { + "fieldPath": "isPostLive", + "columnName": "isPostLive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLiveContent", + "columnName": "isLiveContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiresMembership", + "columnName": "requiresMembership", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAtMillis", + "columnName": "savedAtMillis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "accountId", + "feed", + "videoUrl" + ] + }, + "indices": [ + { + "name": "index_feed_videos_serverId_accountId_feed", + "unique": false, + "columnNames": [ + "serverId", + "accountId", + "feed" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_videos_serverId_accountId_feed` ON `${TABLE_NAME}` (`serverId`, `accountId`, `feed`)" + }, + { + "name": "index_feed_videos_serverId_accountId_feed_position", + "unique": true, + "columnNames": [ + "serverId", + "accountId", + "feed", + "position" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_feed_videos_serverId_accountId_feed_position` ON `${TABLE_NAME}` (`serverId`, `accountId`, `feed`, `position`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId", + "accountId" + ], + "referencedColumns": [ + "serverId", + "accountId" + ] + } + ] + }, + { + "tableName": "youtube_takeout_imports", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sessionGeneration` INTEGER NOT NULL, `requestId` TEXT NOT NULL, `workId` TEXT NOT NULL, `documentUri` TEXT NOT NULL, `displayName` TEXT NOT NULL, `sizeBytes` INTEGER, `serverJobId` TEXT, `status` TEXT NOT NULL, `phase` TEXT, `progressPercent` INTEGER, `previewSubscriptions` INTEGER, `previewPlaylists` INTEGER, `previewPlaylistItems` INTEGER, `previewFavorites` INTEGER, `previewWatchLater` INTEGER, `previewHistory` INTEGER, `importedCount` INTEGER, `skippedCount` INTEGER, `failedCount` INTEGER, `warningCount` INTEGER NOT NULL, `errorCount` INTEGER NOT NULL, `failureCode` TEXT, `failureRequestId` TEXT, `collectionsRefreshed` INTEGER NOT NULL, `createdAtMillis` INTEGER NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `accountId`, `requestId`), FOREIGN KEY(`serverId`, `accountId`) REFERENCES `accounts`(`serverId`, `accountId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionGeneration", + "columnName": "sessionGeneration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requestId", + "columnName": "requestId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "workId", + "columnName": "workId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "documentUri", + "columnName": "documentUri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sizeBytes", + "columnName": "sizeBytes", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverJobId", + "columnName": "serverJobId", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phase", + "columnName": "phase", + "affinity": "TEXT" + }, + { + "fieldPath": "progressPercent", + "columnName": "progressPercent", + "affinity": "INTEGER" + }, + { + "fieldPath": "previewSubscriptions", + "columnName": "previewSubscriptions", + "affinity": "INTEGER" + }, + { + "fieldPath": "previewPlaylists", + "columnName": "previewPlaylists", + "affinity": "INTEGER" + }, + { + "fieldPath": "previewPlaylistItems", + "columnName": "previewPlaylistItems", + "affinity": "INTEGER" + }, + { + "fieldPath": "previewFavorites", + "columnName": "previewFavorites", + "affinity": "INTEGER" + }, + { + "fieldPath": "previewWatchLater", + "columnName": "previewWatchLater", + "affinity": "INTEGER" + }, + { + "fieldPath": "previewHistory", + "columnName": "previewHistory", + "affinity": "INTEGER" + }, + { + "fieldPath": "importedCount", + "columnName": "importedCount", + "affinity": "INTEGER" + }, + { + "fieldPath": "skippedCount", + "columnName": "skippedCount", + "affinity": "INTEGER" + }, + { + "fieldPath": "failedCount", + "columnName": "failedCount", + "affinity": "INTEGER" + }, + { + "fieldPath": "warningCount", + "columnName": "warningCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "errorCount", + "columnName": "errorCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "failureCode", + "columnName": "failureCode", + "affinity": "TEXT" + }, + { + "fieldPath": "failureRequestId", + "columnName": "failureRequestId", + "affinity": "TEXT" + }, + { + "fieldPath": "collectionsRefreshed", + "columnName": "collectionsRefreshed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtMillis", + "columnName": "createdAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAtMillis", + "columnName": "updatedAtMillis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "accountId", + "requestId" + ] + }, + "indices": [ + { + "name": "index_youtube_takeout_imports_serverId_accountId", + "unique": false, + "columnNames": [ + "serverId", + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_youtube_takeout_imports_serverId_accountId` ON `${TABLE_NAME}` (`serverId`, `accountId`)" + }, + { + "name": "index_youtube_takeout_imports_workId", + "unique": true, + "columnNames": [ + "workId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_youtube_takeout_imports_workId` ON `${TABLE_NAME}` (`workId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId", + "accountId" + ], + "referencedColumns": [ + "serverId", + "accountId" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6b946f3c1c6c852d9b6276710462a356')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/dev/typetype/android/data/database/TypeTypeDatabaseMigrationTest.kt b/app/src/androidTest/java/dev/typetype/android/data/database/TypeTypeDatabaseMigrationTest.kt index 467bed83..305e7427 100644 --- a/app/src/androidTest/java/dev/typetype/android/data/database/TypeTypeDatabaseMigrationTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/data/database/TypeTypeDatabaseMigrationTest.kt @@ -205,6 +205,36 @@ class TypeTypeDatabaseMigrationTest { } } + @Test + fun migratesVersion23WithRecommendationCapabilityDefaults() { + helper.createDatabase(DATABASE_NAME, 23).apply { + execSQL( + "INSERT INTO servers(id, baseUrl, displayName, addedAt) " + + "VALUES('instance-a', 'https://example.test/api', 'TypeType', 1)", + ) + close() + } + + val migrated = helper.runMigrationsAndValidate( + DATABASE_NAME, + 24, + true, + TypeTypeDatabaseMigrations.MIGRATION_23_24, + ) + + migrated.query( + "SELECT displayName, recommenderInstalled, recommenderEnabled, " + + "recommenderExperimental FROM servers WHERE id = 'instance-a'", + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals("TypeType", cursor.getString(0)) + assertEquals(0, cursor.getInt(1)) + assertEquals(0, cursor.getInt(2)) + assertEquals(0, cursor.getInt(3)) + } + migrated.close() + } + @Test fun migratesVersion13WithDurableScopedLibraryMutations() { helper.createDatabase(DATABASE_NAME, 13).apply { @@ -286,7 +316,7 @@ class TypeTypeDatabaseMigrationTest { private companion object { const val DATABASE_NAME = "migration-test.db" const val FIRST_RETAINED_VERSION = 1 - const val CURRENT_VERSION = 23 + const val CURRENT_VERSION = 24 const val DRAFT_VERSION_10_SERVERS = "CREATE TABLE `servers` (" + "`id` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `displayName` TEXT NOT NULL, " + diff --git a/app/src/main/java/dev/typetype/android/data/database/TypeTypeDatabase.kt b/app/src/main/java/dev/typetype/android/data/database/TypeTypeDatabase.kt index e11aadf9..3d1dee70 100644 --- a/app/src/main/java/dev/typetype/android/data/database/TypeTypeDatabase.kt +++ b/app/src/main/java/dev/typetype/android/data/database/TypeTypeDatabase.kt @@ -60,7 +60,7 @@ import dev.typetype.android.data.subscriptions.SubscriptionEntity FeedVideoEntity::class, YoutubeTakeoutImportEntity::class, ], - version = 23, + version = 24, exportSchema = true, autoMigrations = [ AutoMigration(from = 1, to = 2), diff --git a/app/src/main/java/dev/typetype/android/data/database/TypeTypeDatabaseMigrations.kt b/app/src/main/java/dev/typetype/android/data/database/TypeTypeDatabaseMigrations.kt index d34d7fc3..596c843f 100644 --- a/app/src/main/java/dev/typetype/android/data/database/TypeTypeDatabaseMigrations.kt +++ b/app/src/main/java/dev/typetype/android/data/database/TypeTypeDatabaseMigrations.kt @@ -174,6 +174,23 @@ object TypeTypeDatabaseMigrations { } } + val MIGRATION_23_24 = object : Migration(23, 24) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE `servers` ADD COLUMN `recommenderInstalled` " + + "INTEGER NOT NULL DEFAULT 0", + ) + db.execSQL( + "ALTER TABLE `servers` ADD COLUMN `recommenderEnabled` " + + "INTEGER NOT NULL DEFAULT 0", + ) + db.execSQL( + "ALTER TABLE `servers` ADD COLUMN `recommenderExperimental` " + + "INTEGER NOT NULL DEFAULT 0", + ) + } + } + val ALL = arrayOf( MIGRATION_7_8, MIGRATION_11_12, @@ -187,5 +204,6 @@ object TypeTypeDatabaseMigrations { MIGRATION_20_21, MIGRATION_21_22, MIGRATION_22_23, + MIGRATION_23_24, ) } diff --git a/app/src/main/java/dev/typetype/android/data/server/ServerEntity.kt b/app/src/main/java/dev/typetype/android/data/server/ServerEntity.kt index 4d6ad048..b2f738cf 100644 --- a/app/src/main/java/dev/typetype/android/data/server/ServerEntity.kt +++ b/app/src/main/java/dev/typetype/android/data/server/ServerEntity.kt @@ -3,6 +3,7 @@ package dev.typetype.android.data.server import androidx.room.Entity import androidx.room.ColumnInfo import androidx.room.PrimaryKey +import dev.typetype.android.domain.server.RecommenderCapability import dev.typetype.android.domain.server.Server @Entity(tableName = "servers") @@ -38,6 +39,9 @@ data class ServerEntity( @ColumnInfo(defaultValue = "unifiedpush") val pushProvider: String = "unifiedpush", @ColumnInfo(defaultValue = "") val pushEventTypesCsv: String = "", @ColumnInfo(defaultValue = "0") val pushMaxDevicesPerAccount: Int = 0, + @ColumnInfo(defaultValue = "0") val recommenderInstalled: Boolean = false, + @ColumnInfo(defaultValue = "0") val recommenderEnabled: Boolean = false, + @ColumnInfo(defaultValue = "0") val recommenderExperimental: Boolean = false, ) { fun toDomain(): Server = Server( id = id, @@ -75,6 +79,11 @@ data class ServerEntity( eventTypes = pushEventTypesCsv.split(',').mapNotNull { it.takeIf(String::isNotBlank) }, maxDevicesPerAccount = pushMaxDevicesPerAccount, ), + recommender = RecommenderCapability( + installed = recommenderInstalled, + enabled = recommenderEnabled, + experimental = recommenderExperimental, + ), ) companion object { @@ -110,6 +119,9 @@ data class ServerEntity( pushProvider = server.push.provider, pushEventTypesCsv = server.push.eventTypes.joinToString(","), pushMaxDevicesPerAccount = server.push.maxDevicesPerAccount, + recommenderInstalled = server.recommender.installed, + recommenderEnabled = server.recommender.enabled, + recommenderExperimental = server.recommender.experimental, ) } } diff --git a/app/src/test/java/dev/typetype/android/data/server/ServerEntityMappingTest.kt b/app/src/test/java/dev/typetype/android/data/server/ServerEntityMappingTest.kt index 1f32db53..2053bcf6 100644 --- a/app/src/test/java/dev/typetype/android/data/server/ServerEntityMappingTest.kt +++ b/app/src/test/java/dev/typetype/android/data/server/ServerEntityMappingTest.kt @@ -1,6 +1,7 @@ package dev.typetype.android.data.server import dev.typetype.android.domain.server.PushCapability +import dev.typetype.android.domain.server.RecommenderCapability import dev.typetype.android.domain.server.Server import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -32,7 +33,7 @@ class ServerEntityMappingTest { } @Test - fun oldRowsRemainPushUnavailableByDefault() { + fun oldRowsKeepOptionalCapabilitiesUnavailableByDefault() { val restored = ServerEntity( id = "server", baseUrl = "https://example.test/api/", @@ -43,5 +44,27 @@ class ServerEntityMappingTest { assertFalse(restored.push.enabled) assertEquals("unifiedpush", restored.push.provider) assertTrue(restored.push.eventTypes.isEmpty()) + assertFalse(restored.recommender.available) + } + + @Test + fun preservesRecommenderCapabilityAcrossRoomMapping() { + val server = Server( + id = "server", + baseUrl = "https://example.test/api/", + displayName = "Test", + addedAt = 1L, + recommender = RecommenderCapability( + installed = true, + enabled = true, + experimental = true, + ), + ) + + val restored = ServerEntity.fromDomain(server).toDomain() + + assertTrue(restored.recommender.installed) + assertTrue(restored.recommender.enabled) + assertTrue(restored.recommender.experimental) } } From 7ea2fadd799e68d7f8861a709838930b79ca4f8b Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 16 Oct 2026 06:30:40 +0200 Subject: [PATCH 17/23] fix: honor server recommender availability --- .../data/feed/HomeFeedRepositoryImpl.kt | 42 ++++++++++++++++++- .../data/feed/RecommendationAvailability.kt | 7 ++++ .../android/feature/home/HomeViewModel.kt | 9 +++- .../feed/RecommendationAvailabilityTest.kt | 35 ++++++++++++++++ .../home/HomeViewModelServerChangeTest.kt | 26 ++++++++++++ 5 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/dev/typetype/android/data/feed/RecommendationAvailability.kt create mode 100644 app/src/test/java/dev/typetype/android/data/feed/RecommendationAvailabilityTest.kt create mode 100644 app/src/test/java/dev/typetype/android/feature/home/HomeViewModelServerChangeTest.kt diff --git a/app/src/main/java/dev/typetype/android/data/feed/HomeFeedRepositoryImpl.kt b/app/src/main/java/dev/typetype/android/data/feed/HomeFeedRepositoryImpl.kt index 7871e13c..702b37bd 100644 --- a/app/src/main/java/dev/typetype/android/data/feed/HomeFeedRepositoryImpl.kt +++ b/app/src/main/java/dev/typetype/android/data/feed/HomeFeedRepositoryImpl.kt @@ -1,6 +1,7 @@ package dev.typetype.android.data.feed import dev.typetype.android.data.account.ActiveAccountScope +import dev.typetype.android.data.account.AccountScope import dev.typetype.android.data.network.TypeTypeApiHolder import dev.typetype.android.data.network.dto.toDomainVideo import dev.typetype.android.data.network.requireSuccessfulResponse @@ -10,6 +11,9 @@ import dev.typetype.android.domain.feed.SubscriptionsPage import dev.typetype.android.domain.feed.ShortsContinuation import dev.typetype.android.domain.feed.ShortsPage import dev.typetype.android.domain.feed.Video +import dev.typetype.android.domain.server.Server +import dev.typetype.android.domain.server.ServerCapabilitiesRepository +import dev.typetype.android.domain.server.ServerRepository import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.CancellationException @@ -20,6 +24,8 @@ import kotlinx.coroutines.withContext class HomeFeedRepositoryImpl @Inject constructor( private val apiHolder: TypeTypeApiHolder, private val activeAccountScope: ActiveAccountScope, + private val serverRepository: ServerRepository, + private val serverCapabilitiesRepository: ServerCapabilitiesRepository, private val feedVideoDao: FeedVideoDao, private val subscriptionFeedDiagnostics: SubscriptionFeedDiagnostics, ) : HomeFeedRepository { @@ -50,6 +56,15 @@ class HomeFeedRepositoryImpl @Inject constructor( limit: Int, ): Result = feedResult { val scope = activeAccountScope.require() + val server = if (scope.accountId.startsWith(GUEST_ACCOUNT_PREFIX)) { + null + } else { + serverForRecommendationRequest(scope, refresh = cursor == null) + } + activeAccountScope.verify(scope) + if (!scope.canUseRecommendations(server)) { + return@feedResult HomeRecommendationsPage(emptyList(), null) + } val api = apiHolder.require(scope) val response = withContext(Dispatchers.IO) { api.homeRecommendations(intent = "auto", cursor = cursor, limit = limit) @@ -101,8 +116,16 @@ class HomeFeedRepositoryImpl @Inject constructor( ): Result = feedResult { val scope = activeAccountScope.require() val api = apiHolder.require(scope) + val recommenderServer = if ( + scope.accountId.startsWith(GUEST_ACCOUNT_PREFIX) || + continuation is ShortsContinuation.Subscriptions + ) { + null + } else { + serverForRecommendationRequest(scope, refresh = continuation == null) + } val page = when { - scope.accountId.startsWith("guest:") -> loadDiscoveryShorts( + scope.accountId.startsWith(GUEST_ACCOUNT_PREFIX) -> loadDiscoveryShorts( api = api, nextPage = (continuation as? ShortsContinuation.Discovery)?.nextPage, service = service, @@ -113,6 +136,12 @@ class HomeFeedRepositoryImpl @Inject constructor( service = service, limit = limit, ).normalized(limit) + !scope.canUseRecommendations(recommenderServer) -> loadSubscriptionShorts( + api = api, + page = 0, + service = service, + limit = limit, + ).normalized(limit) else -> loadRecommendationShorts( api = api, cursor = (continuation as? ShortsContinuation.Recommendations)?.cursor, @@ -131,6 +160,16 @@ class HomeFeedRepositoryImpl @Inject constructor( page } + private suspend fun serverForRecommendationRequest(scope: AccountScope, refresh: Boolean): Server { + val cached = serverRepository.getServer(scope.serverId) + ?: error("Instance not found") + return if (refresh) { + serverCapabilitiesRepository.refresh(scope.serverId).getOrDefault(cached) + } else { + cached + } + } + private suspend fun loadCachedFeed(feed: String): List