From 26f781241c30c16ea73f19efdd036126e61a6cff Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 10:56:00 +0300 Subject: [PATCH 01/74] Add Discord presence domain models --- .../flow/discord/DiscordPresenceModels.kt | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceModels.kt diff --git a/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceModels.kt b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceModels.kt new file mode 100644 index 00000000..268a7dea --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceModels.kt @@ -0,0 +1,80 @@ +package io.github.aedev.flow.discord + +enum class PlaybackKind { + VIDEO, + SHORT, + LIVE, + MUSIC, +} + +enum class DiscordActivityType { + WATCHING, + LISTENING, +} + +data class PlaybackSnapshot( + val kind: PlaybackKind, + val mediaId: String, + val title: String, + val subtitle: String, + val artworkUrl: String, + val positionMs: Long, + val durationMs: Long, + val isPlaying: Boolean, + val isLive: Boolean, +) + +data class DiscordPresencePayload( + val type: DiscordActivityType, + val mediaId: String, + val details: String, + val state: String, + val largeImage: String, + val largeImageText: String, + val startTimestampSeconds: Long?, + val endTimestampSeconds: Long?, +) + +data class SentPresence( + val payload: DiscordPresencePayload, + val sentAtElapsedMs: Long, +) + +sealed interface DiscordPresenceDecision { + data class Send(val payload: DiscordPresencePayload) : DiscordPresenceDecision + data object Skip : DiscordPresenceDecision +} + +enum class DiscordConnectionState { + UNAVAILABLE, + DISCONNECTED, + LINKING, + CONNECTING, + CONNECTED, + ERROR, +} + +data class DiscordPresenceStatus( + val isAvailable: Boolean = false, + val isEnabled: Boolean = false, + val connectionState: DiscordConnectionState = DiscordConnectionState.UNAVAILABLE, + val linkedAccountLabel: String? = null, + val errorMessage: String? = null, +) + +data class DiscordAuthTokens( + val accessToken: String, + val refreshToken: String, + val expiresAtEpochSeconds: Long, +) + +sealed interface DiscordLinkResult { + data object Success : DiscordLinkResult + data class Failure(val message: String) : DiscordLinkResult +} + +sealed interface DiscordTransportResult { + data object Success : DiscordTransportResult + data object Unavailable : DiscordTransportResult + data class Failure(val message: String) : DiscordTransportResult +} From 76b6beef179e059fc723fd9a3698ec1d3158c2a5 Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 10:56:10 +0300 Subject: [PATCH 02/74] Map Flow playback to Discord presence --- .../flow/discord/DiscordPresenceMapper.kt | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceMapper.kt diff --git a/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceMapper.kt b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceMapper.kt new file mode 100644 index 00000000..bc50f285 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceMapper.kt @@ -0,0 +1,63 @@ +package io.github.aedev.flow.discord + +class DiscordPresenceMapper { + fun map( + snapshot: PlaybackSnapshot, + nowEpochSeconds: Long, + ): DiscordPresencePayload? { + if (!snapshot.isPlaying || snapshot.mediaId.isBlank()) return null + + val details = normalize(snapshot.title, fallback = "Playing in Flow") + val subtitle = normalize(snapshot.subtitle, fallback = "Flow") + val durationMs = snapshot.durationMs.coerceAtLeast(0L) + val positionMs = snapshot.positionMs.coerceAtLeast(0L).let { position -> + if (durationMs > 0L) position.coerceAtMost(durationMs) else position + } + val hasTimestamps = !snapshot.isLive && durationMs > 0L + + return DiscordPresencePayload( + type = when (snapshot.kind) { + PlaybackKind.MUSIC -> DiscordActivityType.LISTENING + PlaybackKind.VIDEO, + PlaybackKind.SHORT, + PlaybackKind.LIVE, + -> DiscordActivityType.WATCHING + }, + mediaId = snapshot.mediaId, + details = details, + state = if (snapshot.kind == PlaybackKind.MUSIC) { + subtitle + } else { + normalize("by $subtitle", fallback = "Flow") + }, + largeImage = snapshot.artworkUrl + .trim() + .takeIf { it.startsWith("https://") } + ?: FALLBACK_IMAGE_KEY, + largeImageText = details, + startTimestampSeconds = if (hasTimestamps) { + nowEpochSeconds - positionMs / 1_000L + } else { + null + }, + endTimestampSeconds = if (hasTimestamps) { + nowEpochSeconds + (durationMs - positionMs) / 1_000L + } else { + null + }, + ) + } + + private fun normalize(value: String, fallback: String): String = + value + .trim() + .ifBlank { fallback } + .take(MAX_TEXT_LENGTH) + .padEnd(MIN_TEXT_LENGTH, ' ') + + private companion object { + const val MIN_TEXT_LENGTH = 2 + const val MAX_TEXT_LENGTH = 128 + const val FALLBACK_IMAGE_KEY = "flow_logo" + } +} From e920f2f63aa2cae447e8971d261a923d26cde754 Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 10:56:18 +0300 Subject: [PATCH 03/74] Add Discord presence update policy --- .../flow/discord/DiscordPresencePolicy.kt | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 app/src/main/java/io/github/aedev/flow/discord/DiscordPresencePolicy.kt diff --git a/app/src/main/java/io/github/aedev/flow/discord/DiscordPresencePolicy.kt b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresencePolicy.kt new file mode 100644 index 00000000..25b0506b --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresencePolicy.kt @@ -0,0 +1,63 @@ +package io.github.aedev.flow.discord + +import kotlin.math.abs + +class DiscordPresencePolicy( + private val minimumUpdateIntervalMs: Long = 5_000L, + private val seekDriftThresholdMs: Long = 10_000L, +) { + fun decide( + previous: SentPresence?, + candidate: DiscordPresencePayload, + nowElapsedMs: Long, + ): DiscordPresenceDecision { + if (previous == null) return DiscordPresenceDecision.Send(candidate) + + if ( + previous.payload.mediaId != candidate.mediaId || + previous.payload.type != candidate.type + ) { + return DiscordPresenceDecision.Send(candidate) + } + + val previousWithoutTimestamps = previous.payload.withoutTimestamps() + val candidateWithoutTimestamps = candidate.withoutTimestamps() + val intervalElapsed = nowElapsedMs - previous.sentAtElapsedMs >= minimumUpdateIntervalMs + + if (previousWithoutTimestamps != candidateWithoutTimestamps) { + return if (intervalElapsed) { + DiscordPresenceDecision.Send(candidate) + } else { + DiscordPresenceDecision.Skip + } + } + + val meaningfulSeek = maxOf( + timestampDriftMs( + previous.payload.startTimestampSeconds, + candidate.startTimestampSeconds, + ), + timestampDriftMs( + previous.payload.endTimestampSeconds, + candidate.endTimestampSeconds, + ), + ) >= seekDriftThresholdMs + + return if (meaningfulSeek && intervalElapsed) { + DiscordPresenceDecision.Send(candidate) + } else { + DiscordPresenceDecision.Skip + } + } + + private fun DiscordPresencePayload.withoutTimestamps(): DiscordPresencePayload = copy( + startTimestampSeconds = null, + endTimestampSeconds = null, + ) + + private fun timestampDriftMs(left: Long?, right: Long?): Long = when { + left == null && right == null -> 0L + left == null || right == null -> Long.MAX_VALUE + else -> abs(left - right) * 1_000L + } +} From 24a2ccf40a527641a38c434764b45d7429f795ac Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 10:56:33 +0300 Subject: [PATCH 04/74] Test Discord presence mapping --- .../flow/discord/DiscordPresenceMapperTest.kt | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 app/src/test/java/io/github/aedev/flow/discord/DiscordPresenceMapperTest.kt diff --git a/app/src/test/java/io/github/aedev/flow/discord/DiscordPresenceMapperTest.kt b/app/src/test/java/io/github/aedev/flow/discord/DiscordPresenceMapperTest.kt new file mode 100644 index 00000000..edd8373b --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/discord/DiscordPresenceMapperTest.kt @@ -0,0 +1,126 @@ +package io.github.aedev.flow.discord + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class DiscordPresenceMapperTest { + private val mapper = DiscordPresenceMapper() + + @Test + fun musicMapsToListeningWithArtistAndCountdown() { + val result = mapper.map( + snapshot = PlaybackSnapshot( + kind = PlaybackKind.MUSIC, + mediaId = "song-1", + title = "Night Drive", + subtitle = "Example Artist", + artworkUrl = "https://i.ytimg.com/vi/song-1/maxresdefault.jpg", + positionMs = 30_000L, + durationMs = 180_000L, + isPlaying = true, + isLive = false, + ), + nowEpochSeconds = 1_000L, + ) + + assertThat(result).isEqualTo( + DiscordPresencePayload( + type = DiscordActivityType.LISTENING, + mediaId = "song-1", + details = "Night Drive", + state = "Example Artist", + largeImage = "https://i.ytimg.com/vi/song-1/maxresdefault.jpg", + largeImageText = "Night Drive", + startTimestampSeconds = 970L, + endTimestampSeconds = 1_150L, + ), + ) + } + + @Test + fun videoMapsToWatchingWithChannel() { + val result = mapper.map( + snapshot = PlaybackSnapshot( + kind = PlaybackKind.VIDEO, + mediaId = "video-1", + title = "Building Flow", + subtitle = "A-E Dev", + artworkUrl = "https://i.ytimg.com/vi/video-1/maxresdefault.jpg", + positionMs = 10_000L, + durationMs = 70_000L, + isPlaying = true, + isLive = false, + ), + nowEpochSeconds = 5_000L, + ) + + assertThat(result?.type).isEqualTo(DiscordActivityType.WATCHING) + assertThat(result?.details).isEqualTo("Building Flow") + assertThat(result?.state).isEqualTo("by A-E Dev") + assertThat(result?.startTimestampSeconds).isEqualTo(4_990L) + assertThat(result?.endTimestampSeconds).isEqualTo(5_060L) + } + + @Test + fun liveVideoOmitsTimestampsAndRejectsInsecureArtwork() { + val result = mapper.map( + snapshot = PlaybackSnapshot( + kind = PlaybackKind.LIVE, + mediaId = "live-1", + title = "Live Stream", + subtitle = "A-E Dev", + artworkUrl = "http://insecure.example/image.jpg", + positionMs = 60_000L, + durationMs = 0L, + isPlaying = true, + isLive = true, + ), + nowEpochSeconds = 9_000L, + ) + + assertThat(result?.startTimestampSeconds).isNull() + assertThat(result?.endTimestampSeconds).isNull() + assertThat(result?.largeImage).isEqualTo("flow_logo") + } + + @Test + fun pausedSnapshotDoesNotPublish() { + val result = mapper.map( + snapshot = PlaybackSnapshot( + kind = PlaybackKind.SHORT, + mediaId = "short-1", + title = "Short", + subtitle = "Creator", + artworkUrl = "", + positionMs = 0L, + durationMs = 20_000L, + isPlaying = false, + isLive = false, + ), + nowEpochSeconds = 10L, + ) + + assertThat(result).isNull() + } + + @Test + fun fieldsAreTrimmedAndLimitedToDiscordMaximum() { + val result = mapper.map( + snapshot = PlaybackSnapshot( + kind = PlaybackKind.MUSIC, + mediaId = "song-2", + title = "x".repeat(200), + subtitle = " Artist ", + artworkUrl = "", + positionMs = 0L, + durationMs = 1_000L, + isPlaying = true, + isLive = false, + ), + nowEpochSeconds = 10L, + ) + + assertThat(result?.details).hasLength(128) + assertThat(result?.state).isEqualTo("Artist") + } +} From 5bce0daf0ebd7779aaba08ca8d122ca828f991c1 Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 10:56:44 +0300 Subject: [PATCH 05/74] Test Discord presence update policy --- .../flow/discord/DiscordPresencePolicyTest.kt | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 app/src/test/java/io/github/aedev/flow/discord/DiscordPresencePolicyTest.kt diff --git a/app/src/test/java/io/github/aedev/flow/discord/DiscordPresencePolicyTest.kt b/app/src/test/java/io/github/aedev/flow/discord/DiscordPresencePolicyTest.kt new file mode 100644 index 00000000..3506d0d0 --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/discord/DiscordPresencePolicyTest.kt @@ -0,0 +1,84 @@ +package io.github.aedev.flow.discord + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class DiscordPresencePolicyTest { + private val policy = DiscordPresencePolicy( + minimumUpdateIntervalMs = 5_000L, + seekDriftThresholdMs = 10_000L, + ) + + private val base = DiscordPresencePayload( + type = DiscordActivityType.WATCHING, + mediaId = "video-1", + details = "Title", + state = "by Creator", + largeImage = "flow_logo", + largeImageText = "Title", + startTimestampSeconds = 100L, + endTimestampSeconds = 200L, + ) + + @Test + fun firstPayloadIsSent() { + assertThat(policy.decide(null, base, 1_000L)) + .isEqualTo(DiscordPresenceDecision.Send(base)) + } + + @Test + fun identicalPayloadIsSkipped() { + assertThat( + policy.decide( + previous = SentPresence(base, sentAtElapsedMs = 1_000L), + candidate = base, + nowElapsedMs = 8_000L, + ), + ).isEqualTo(DiscordPresenceDecision.Skip) + } + + @Test + fun changedMediaBypassesMinimumInterval() { + val changed = base.copy(mediaId = "video-2", details = "Next Video") + + assertThat( + policy.decide( + previous = SentPresence(base, sentAtElapsedMs = 1_000L), + candidate = changed, + nowElapsedMs = 2_000L, + ), + ).isEqualTo(DiscordPresenceDecision.Send(changed)) + } + + @Test + fun timestampDriftBelowThresholdIsSkipped() { + val drifted = base.copy( + startTimestampSeconds = 95L, + endTimestampSeconds = 195L, + ) + + assertThat( + policy.decide( + previous = SentPresence(base, sentAtElapsedMs = 1_000L), + candidate = drifted, + nowElapsedMs = 7_000L, + ), + ).isEqualTo(DiscordPresenceDecision.Skip) + } + + @Test + fun timestampDriftAtThresholdIsSentAfterMinimumInterval() { + val drifted = base.copy( + startTimestampSeconds = 90L, + endTimestampSeconds = 190L, + ) + + assertThat( + policy.decide( + previous = SentPresence(base, sentAtElapsedMs = 1_000L), + candidate = drifted, + nowElapsedMs = 7_000L, + ), + ).isEqualTo(DiscordPresenceDecision.Send(drifted)) + } +} From 4d2446947e947c3fdcebe1b0def4d51b3c801484 Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 10:57:08 +0300 Subject: [PATCH 06/74] Store Discord tokens with Android Keystore --- .../aedev/flow/discord/DiscordTokenStore.kt | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 app/src/main/java/io/github/aedev/flow/discord/DiscordTokenStore.kt diff --git a/app/src/main/java/io/github/aedev/flow/discord/DiscordTokenStore.kt b/app/src/main/java/io/github/aedev/flow/discord/DiscordTokenStore.kt new file mode 100644 index 00000000..bef7da28 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/discord/DiscordTokenStore.kt @@ -0,0 +1,125 @@ +package io.github.aedev.flow.discord + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.AtomicFile +import android.util.Base64 +import org.json.JSONObject +import java.io.File +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +class DiscordTokenStore(context: Context) { + private val storageFile = AtomicFile( + File(context.applicationContext.noBackupFilesDir, TOKEN_FILE_NAME), + ) + + @Synchronized + fun save(tokens: DiscordAuthTokens) { + val plaintext = JSONObject() + .put("access_token", tokens.accessToken) + .put("refresh_token", tokens.refreshToken) + .put("expires_at", tokens.expiresAtEpochSeconds) + .toString() + .toByteArray(Charsets.UTF_8) + + val cipher = Cipher.getInstance(TRANSFORMATION).apply { + init(Cipher.ENCRYPT_MODE, getOrCreateKey()) + } + val payload = JSONObject() + .put("iv", Base64.encodeToString(cipher.iv, Base64.NO_WRAP)) + .put( + "ciphertext", + Base64.encodeToString(cipher.doFinal(plaintext), Base64.NO_WRAP), + ) + .toString() + .toByteArray(Charsets.UTF_8) + + val output = storageFile.startWrite() + try { + output.use { it.write(payload) } + storageFile.finishWrite(output) + } catch (error: Throwable) { + storageFile.failWrite(output) + throw error + } + } + + @Synchronized + fun load(): DiscordAuthTokens? { + if (!storageFile.baseFile.exists()) return null + + return runCatching { + val payload = JSONObject( + storageFile.openRead().bufferedReader(Charsets.UTF_8).use { it.readText() }, + ) + val cipher = Cipher.getInstance(TRANSFORMATION).apply { + init( + Cipher.DECRYPT_MODE, + getOrCreateKey(), + GCMParameterSpec( + GCM_TAG_LENGTH_BITS, + Base64.decode(payload.getString("iv"), Base64.NO_WRAP), + ), + ) + } + val plaintext = cipher.doFinal( + Base64.decode(payload.getString("ciphertext"), Base64.NO_WRAP), + ) + val tokens = JSONObject(String(plaintext, Charsets.UTF_8)) + DiscordAuthTokens( + accessToken = tokens.getString("access_token"), + refreshToken = tokens.getString("refresh_token"), + expiresAtEpochSeconds = tokens.getLong("expires_at"), + ) + }.getOrElse { + clear() + null + } + } + + @Synchronized + fun clear() { + storageFile.delete() + runCatching { + KeyStore.getInstance(ANDROID_KEYSTORE).apply { + load(null) + deleteEntry(KEY_ALIAS) + } + } + } + + private fun getOrCreateKey(): SecretKey { + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + (keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it } + + return KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, + ANDROID_KEYSTORE, + ).run { + init( + KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setRandomizedEncryptionRequired(true) + .build(), + ) + generateKey() + } + } + + private companion object { + const val TOKEN_FILE_NAME = "discord_tokens.enc" + const val KEY_ALIAS = "flow_discord_tokens_v1" + const val ANDROID_KEYSTORE = "AndroidKeyStore" + const val TRANSFORMATION = "AES/GCM/NoPadding" + const val GCM_TAG_LENGTH_BITS = 128 + } +} From 0054300502c6474b73361af74f5ca7da8e835e15 Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 11:01:10 +0300 Subject: [PATCH 07/74] feat: add Discord presence transport seam --- .../flow/discord/DiscordPresenceTransport.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceTransport.kt diff --git a/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceTransport.kt b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceTransport.kt new file mode 100644 index 00000000..aa453d42 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceTransport.kt @@ -0,0 +1,18 @@ +package io.github.aedev.flow.discord + +import android.app.Activity +import kotlinx.coroutines.flow.StateFlow + +interface DiscordPresenceTransport { + val connectionState: StateFlow + val linkedAccountName: StateFlow + val lastError: StateFlow + + fun attachActivity(activity: Activity?) + suspend fun link(): DiscordLinkResult + suspend fun connect(tokens: DiscordAuthTokens): DiscordLinkResult + suspend fun update(payload: DiscordPresencePayload): Boolean + suspend fun clear(): Boolean + suspend fun unlink(): Boolean + fun close() +} From d3e82d486c2a1247a04bdd983ea2f8ee142d2761 Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 11:05:12 +0300 Subject: [PATCH 08/74] feat: add unavailable Discord transport --- .../aedev/flow/discord/NoOpDiscordPresenceTransport.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 app/src/main/java/io/github/aedev/flow/discord/NoOpDiscordPresenceTransport.kt diff --git a/app/src/main/java/io/github/aedev/flow/discord/NoOpDiscordPresenceTransport.kt b/app/src/main/java/io/github/aedev/flow/discord/NoOpDiscordPresenceTransport.kt new file mode 100644 index 00000000..243116bc --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/discord/NoOpDiscordPresenceTransport.kt @@ -0,0 +1,10 @@ +package io.github.aedev.flow.discord + +import android.app.Activity +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class NoOpDiscordPresenceTransport( + private val unavailableMessage: String = "Discord Rich Presence is unavailable in this build." +) : DiscordPresenceTransport { \ No newline at end of file From ba53eba90803ca8018222e44ab52134532471443 Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 11:12:54 +0300 Subject: [PATCH 09/74] test: define Discord presence coordination --- .../discord/DiscordPresenceCoordinatorTest.kt | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 app/src/test/java/io/github/aedev/flow/discord/DiscordPresenceCoordinatorTest.kt diff --git a/app/src/test/java/io/github/aedev/flow/discord/DiscordPresenceCoordinatorTest.kt b/app/src/test/java/io/github/aedev/flow/discord/DiscordPresenceCoordinatorTest.kt new file mode 100644 index 00000000..488c57b4 --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/discord/DiscordPresenceCoordinatorTest.kt @@ -0,0 +1,116 @@ +package io.github.aedev.flow.discord + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class DiscordPresenceCoordinatorTest { + + @Test + fun `disabled setting clears presence`() = runTest { + val enabled = MutableStateFlow(false) + val playback = MutableStateFlow(null) + val transport = RecordingTransport() + val coordinator = DiscordPresenceCoordinator( + enabled = enabled, + playback = playback, + transport = transport, + nowMs = { 1_000L }, + ) + + val job = launch { coordinator.run() } + runCurrent() + + assertThat(transport.clears).isEqualTo(1) + assertThat(transport.updates).isEmpty() + job.cancelAndJoin() + } + + @Test + fun `enabled playback publishes mapped presence`() = runTest { + val enabled = MutableStateFlow(true) + val playback = MutableStateFlow( + FlowPlaybackSnapshot( + mediaId = "video-1", + mediaKind = FlowMediaKind.VIDEO, + title = "A useful video", + creator = "Flow Creator", + artworkUrl = "https://example.com/art.jpg", + durationMs = 120_000L, + positionMs = 30_000L, + isPlaying = true, + ), + ) + val transport = RecordingTransport() + val coordinator = DiscordPresenceCoordinator( + enabled = enabled, + playback = playback, + transport = transport, + nowMs = { 100_000L }, + ) + + val job = launch { coordinator.run() } + runCurrent() + + assertThat(transport.updates).hasSize(1) + assertThat(transport.updates.single().details).isEqualTo("A useful video") + assertThat(transport.updates.single().state).isEqualTo("Flow Creator") + job.cancelAndJoin() + } + + @Test + fun `repeated snapshots are deduplicated`() = runTest { + var now = 100_000L + val snapshot = FlowPlaybackSnapshot( + mediaId = "song-1", + mediaKind = FlowMediaKind.MUSIC, + title = "A useful song", + creator = "Flow Artist", + durationMs = 180_000L, + positionMs = 20_000L, + isPlaying = true, + ) + val enabled = MutableStateFlow(true) + val playback = MutableStateFlow(snapshot) + val transport = RecordingTransport() + val coordinator = DiscordPresenceCoordinator( + enabled = enabled, + playback = playback, + transport = transport, + nowMs = { now }, + ) + + val job = launch { coordinator.run() } + runCurrent() + now += 1_000L + playback.value = snapshot.copy(positionMs = 21_000L) + runCurrent() + + assertThat(transport.updates).hasSize(1) + job.cancelAndJoin() + } + + private class RecordingTransport : DiscordPresenceTransport { + override val isAvailable: Boolean = true + val updates = mutableListOf() + var clears: Int = 0 + + override suspend fun connect(): DiscordConnectionState = DiscordConnectionState.CONNECTED + + override suspend fun update(activity: DiscordActivity) { + updates += activity + } + + override suspend fun clear() { + clears += 1 + } + + override suspend fun disconnect() = Unit + } +} From a56542de370e6855cf9ed90ade18c6fe14299319 Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 11:13:34 +0300 Subject: [PATCH 10/74] test: align Discord coordinator contract --- .../discord/DiscordPresenceCoordinatorTest.kt | 61 ++++++++++++------- 1 file changed, 39 insertions(+), 22 deletions(-) diff --git a/app/src/test/java/io/github/aedev/flow/discord/DiscordPresenceCoordinatorTest.kt b/app/src/test/java/io/github/aedev/flow/discord/DiscordPresenceCoordinatorTest.kt index 488c57b4..4e6a1fdd 100644 --- a/app/src/test/java/io/github/aedev/flow/discord/DiscordPresenceCoordinatorTest.kt +++ b/app/src/test/java/io/github/aedev/flow/discord/DiscordPresenceCoordinatorTest.kt @@ -1,5 +1,6 @@ package io.github.aedev.flow.discord +import android.app.Activity import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.cancelAndJoin @@ -15,13 +16,14 @@ class DiscordPresenceCoordinatorTest { @Test fun `disabled setting clears presence`() = runTest { val enabled = MutableStateFlow(false) - val playback = MutableStateFlow(null) + val playback = MutableStateFlow(null) val transport = RecordingTransport() val coordinator = DiscordPresenceCoordinator( enabled = enabled, playback = playback, transport = transport, - nowMs = { 1_000L }, + nowEpochSeconds = { 1_000L }, + nowElapsedMs = { 1_000L }, ) val job = launch { coordinator.run() } @@ -35,16 +37,17 @@ class DiscordPresenceCoordinatorTest { @Test fun `enabled playback publishes mapped presence`() = runTest { val enabled = MutableStateFlow(true) - val playback = MutableStateFlow( - FlowPlaybackSnapshot( + val playback = MutableStateFlow( + PlaybackSnapshot( + kind = PlaybackKind.VIDEO, mediaId = "video-1", - mediaKind = FlowMediaKind.VIDEO, title = "A useful video", - creator = "Flow Creator", + subtitle = "Flow Creator", artworkUrl = "https://example.com/art.jpg", durationMs = 120_000L, positionMs = 30_000L, isPlaying = true, + isLive = false, ), ) val transport = RecordingTransport() @@ -52,7 +55,8 @@ class DiscordPresenceCoordinatorTest { enabled = enabled, playback = playback, transport = transport, - nowMs = { 100_000L }, + nowEpochSeconds = { 100_000L }, + nowElapsedMs = { 1_000L }, ) val job = launch { coordinator.run() } @@ -60,35 +64,38 @@ class DiscordPresenceCoordinatorTest { assertThat(transport.updates).hasSize(1) assertThat(transport.updates.single().details).isEqualTo("A useful video") - assertThat(transport.updates.single().state).isEqualTo("Flow Creator") + assertThat(transport.updates.single().state).isEqualTo("by Flow Creator") job.cancelAndJoin() } @Test fun `repeated snapshots are deduplicated`() = runTest { - var now = 100_000L - val snapshot = FlowPlaybackSnapshot( + var elapsed = 1_000L + val snapshot = PlaybackSnapshot( + kind = PlaybackKind.MUSIC, mediaId = "song-1", - mediaKind = FlowMediaKind.MUSIC, title = "A useful song", - creator = "Flow Artist", + subtitle = "Flow Artist", + artworkUrl = "", durationMs = 180_000L, positionMs = 20_000L, isPlaying = true, + isLive = false, ) val enabled = MutableStateFlow(true) - val playback = MutableStateFlow(snapshot) + val playback = MutableStateFlow(snapshot) val transport = RecordingTransport() val coordinator = DiscordPresenceCoordinator( enabled = enabled, playback = playback, transport = transport, - nowMs = { now }, + nowEpochSeconds = { 100_000L }, + nowElapsedMs = { elapsed }, ) val job = launch { coordinator.run() } runCurrent() - now += 1_000L + elapsed += 1_000L playback.value = snapshot.copy(positionMs = 21_000L) runCurrent() @@ -97,20 +104,30 @@ class DiscordPresenceCoordinatorTest { } private class RecordingTransport : DiscordPresenceTransport { - override val isAvailable: Boolean = true - val updates = mutableListOf() + override val connectionState = MutableStateFlow(DiscordConnectionState.CONNECTED) + override val linkedAccountName = MutableStateFlow(null) + override val lastError = MutableStateFlow(null) + val updates = mutableListOf() var clears: Int = 0 - override suspend fun connect(): DiscordConnectionState = DiscordConnectionState.CONNECTED + override fun attachActivity(activity: Activity?) = Unit - override suspend fun update(activity: DiscordActivity) { - updates += activity + override suspend fun link(): DiscordLinkResult = DiscordLinkResult.Success + + override suspend fun connect(tokens: DiscordAuthTokens): DiscordLinkResult = DiscordLinkResult.Success + + override suspend fun update(payload: DiscordPresencePayload): Boolean { + updates += payload + return true } - override suspend fun clear() { + override suspend fun clear(): Boolean { clears += 1 + return true } - override suspend fun disconnect() = Unit + override suspend fun unlink(): Boolean = true + + override fun close() = Unit } } From d45881a91b5ed8fc245ffebdaaa5ad2dca903050 Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 11:13:41 +0300 Subject: [PATCH 11/74] feat: coordinate Discord presence updates --- .../discord/DiscordPresenceCoordinator.kt | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceCoordinator.kt diff --git a/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceCoordinator.kt b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceCoordinator.kt new file mode 100644 index 00000000..ef2167c9 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceCoordinator.kt @@ -0,0 +1,51 @@ +package io.github.aedev.flow.discord + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +class DiscordPresenceCoordinator( + private val enabled: Flow, + private val playback: Flow, + private val transport: DiscordPresenceTransport, + private val mapper: DiscordPresenceMapper = DiscordPresenceMapper(), + private val policy: DiscordPresencePolicy = DiscordPresencePolicy(), + private val nowEpochSeconds: () -> Long = { System.currentTimeMillis() / 1_000L }, + private val nowElapsedMs: () -> Long, +) { + private var lastSent: SentPresence? = null + + suspend fun run() { + combine(enabled, playback) { isEnabled, snapshot -> isEnabled to snapshot } + .collect { (isEnabled, snapshot) -> + if (!isEnabled || snapshot == null) { + clearPresence() + return@collect + } + + val candidate = mapper.map(snapshot, nowEpochSeconds()) + if (candidate == null) { + clearPresence() + return@collect + } + + when (val decision = policy.decide(lastSent, candidate, nowElapsedMs())) { + is DiscordPresenceDecision.Send -> { + if (transport.update(decision.payload)) { + lastSent = SentPresence( + payload = decision.payload, + sentAtElapsedMs = nowElapsedMs(), + ) + } + } + + DiscordPresenceDecision.Skip -> Unit + } + } + } + + private suspend fun clearPresence() { + if (transport.clear()) { + lastSent = null + } + } +} From b4e661688ea0009f4404aecc9cfd73469dafcd83 Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 11:14:06 +0300 Subject: [PATCH 12/74] test: define no-op Discord transport behavior --- .../NoOpDiscordPresenceTransportTest.kt | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 app/src/test/java/io/github/aedev/flow/discord/NoOpDiscordPresenceTransportTest.kt diff --git a/app/src/test/java/io/github/aedev/flow/discord/NoOpDiscordPresenceTransportTest.kt b/app/src/test/java/io/github/aedev/flow/discord/NoOpDiscordPresenceTransportTest.kt new file mode 100644 index 00000000..5753abf7 --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/discord/NoOpDiscordPresenceTransportTest.kt @@ -0,0 +1,43 @@ +package io.github.aedev.flow.discord + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class NoOpDiscordPresenceTransportTest { + + @Test + fun `no-op transport reports unavailable and rejects updates`() = runTest { + val transport = NoOpDiscordPresenceTransport("Not included") + + assertThat(transport.connectionState.value).isEqualTo(DiscordConnectionState.UNAVAILABLE) + assertThat(transport.lastError.value).isEqualTo("Not included") + assertThat(transport.update(samplePayload())).isFalse() + assertThat(transport.clear()).isFalse() + } + + @Test + fun `no-op transport cannot link or connect`() = runTest { + val transport = NoOpDiscordPresenceTransport() + val tokens = DiscordAuthTokens( + accessToken = "access", + refreshToken = "refresh", + expiresAtEpochSeconds = 2_000L, + ) + + assertThat(transport.link()).isEqualTo(DiscordLinkResult.Failure("Discord Rich Presence is unavailable in this build.")) + assertThat(transport.connect(tokens)).isEqualTo(DiscordLinkResult.Failure("Discord Rich Presence is unavailable in this build.")) + assertThat(transport.unlink()).isFalse() + } + + private fun samplePayload() = DiscordPresencePayload( + type = DiscordActivityType.WATCHING, + mediaId = "video-1", + details = "Video", + state = "by Creator", + largeImage = "flow_logo", + largeImageText = "Video", + startTimestampSeconds = null, + endTimestampSeconds = null, + ) +} From aa0eb227b2aff72a4b2247581fa3223f743c11bb Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 11:14:13 +0300 Subject: [PATCH 13/74] fix: complete no-op Discord transport --- .../discord/NoOpDiscordPresenceTransport.kt | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/github/aedev/flow/discord/NoOpDiscordPresenceTransport.kt b/app/src/main/java/io/github/aedev/flow/discord/NoOpDiscordPresenceTransport.kt index 243116bc..c13c2514 100644 --- a/app/src/main/java/io/github/aedev/flow/discord/NoOpDiscordPresenceTransport.kt +++ b/app/src/main/java/io/github/aedev/flow/discord/NoOpDiscordPresenceTransport.kt @@ -6,5 +6,30 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow class NoOpDiscordPresenceTransport( - private val unavailableMessage: String = "Discord Rich Presence is unavailable in this build." -) : DiscordPresenceTransport { \ No newline at end of file + private val unavailableMessage: String = "Discord Rich Presence is unavailable in this build.", +) : DiscordPresenceTransport { + private val _connectionState = MutableStateFlow(DiscordConnectionState.UNAVAILABLE) + override val connectionState: StateFlow = _connectionState.asStateFlow() + + private val _linkedAccountName = MutableStateFlow(null) + override val linkedAccountName: StateFlow = _linkedAccountName.asStateFlow() + + private val _lastError = MutableStateFlow(unavailableMessage) + override val lastError: StateFlow = _lastError.asStateFlow() + + override fun attachActivity(activity: Activity?) = Unit + + override suspend fun link(): DiscordLinkResult = + DiscordLinkResult.Failure(unavailableMessage) + + override suspend fun connect(tokens: DiscordAuthTokens): DiscordLinkResult = + DiscordLinkResult.Failure(unavailableMessage) + + override suspend fun update(payload: DiscordPresencePayload): Boolean = false + + override suspend fun clear(): Boolean = false + + override suspend fun unlink(): Boolean = false + + override fun close() = Unit +} From 3d48e676740b71b7984e3d0b1faecfabf3101767 Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 11:14:30 +0300 Subject: [PATCH 14/74] fix: import Flow collection operator --- .../io/github/aedev/flow/discord/DiscordPresenceCoordinator.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceCoordinator.kt b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceCoordinator.kt index ef2167c9..225ad808 100644 --- a/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceCoordinator.kt +++ b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceCoordinator.kt @@ -1,6 +1,7 @@ package io.github.aedev.flow.discord import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine class DiscordPresenceCoordinator( From ec7643f9cdb29ab5805b68896a0a207cb60e3199 Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 12:43:26 +0300 Subject: [PATCH 15/74] docs(discord): define integration design --- .../plans/2026-07-14-discord-rich-presence.md | 90 +++++++++++++++++++ ...2026-07-14-discord-rich-presence-design.md | 33 +++++++ 2 files changed, 123 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-14-discord-rich-presence.md create mode 100644 docs/superpowers/specs/2026-07-14-discord-rich-presence-design.md diff --git a/docs/superpowers/plans/2026-07-14-discord-rich-presence.md b/docs/superpowers/plans/2026-07-14-discord-rich-presence.md new file mode 100644 index 00000000..886ac80d --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-discord-rich-presence.md @@ -0,0 +1,90 @@ +# Discord Rich Presence Implementation Plan + +> **For agentic workers:** Execute inline in red-green-refactor order; do not dispatch subagents. + +**Goal:** Build the complete settings, lifecycle, playback, flavor, and official Discord Social SDK integration path. + +**Architecture:** Common Kotlin code owns preferences, state, playback selection, and orchestration behind a transport seam. Flavor source sets provide either a fail-closed adapter or a GitHub C++/JNI adapter linked to the owner-supplied official AAR. + +**Tech Stack:** Kotlin, Compose Material 3, DataStore Preferences, coroutines/Flow, Media3, C++20/JNI, Gradle product flavors, GitHub Actions. + +## Global Constraints + +- Default disabled and never misleading when unavailable. +- Never commit the Discord SDK AAR, tokens, secrets, keystores, or signing credentials. +- FOSS compiles without proprietary artifacts or Discord operations. +- Use only official Discord Social SDK API names verified from Discord's current documentation. +- Preserve existing player architecture and throttle position sampling to five seconds. + +--- + +### Task 1: Preferences and settings state + +**Files:** `DiscordPreferences.kt`, `DiscordSettingsState.kt`, focused JVM tests. + +- [ ] Add failing tests for disabled default, persistence, and all row subtitle states. +- [ ] Run the focused test and observe the expected red result. +- [ ] Implement the DataStore component and pure state derivation. +- [ ] Run focused and neighboring Discord tests green. +- [ ] Commit `feat(discord): persist opt-in settings state`. + +### Task 2: Playback snapshot selection + +**Files:** `DiscordPlaybackSource.kt`, `ShortsPlayerPool.kt`, `ShortsScreen.kt`, focused JVM tests. + +- [ ] Add failing tests for video, live, Shorts, music, overlap priority, pause, and media switching. +- [ ] Run and observe red. +- [ ] Implement pure candidates/selection and event-driven plus five-second sampling. +- [ ] Run focused tests green. +- [ ] Commit `feat(discord): select active playback snapshots`. + +### Task 3: Coordinator and service lifecycle + +**Files:** coordinator tests/code, `DiscordPresenceService.kt`, service tests. + +- [ ] Add failing tests for disable, unlink, lifecycle shutdown, retries, stale clears, and transport failure. +- [ ] Run and observe red. +- [ ] Implement minimal lifecycle and error behavior. +- [ ] Run Discord tests green. +- [ ] Commit `feat(discord): manage presence lifecycle`. + +### Task 4: Settings UI and navigation + +**Files:** `DiscordSettingsScreen.kt`, `SettingsScreen.kt`, `FlowNavigation.kt`, `strings.xml`. + +- [ ] Add testable state/copy assertions before UI wiring. +- [ ] Add the search-indexed Content & Playback row and dedicated Material 3 screen. +- [ ] Compile the GitHub and FOSS Kotlin variants. +- [ ] Commit `feat(settings): expose Discord Rich Presence`. + +### Task 5: Flavor factories and official adapter + +**Files:** flavor factories, GitHub adapter/bridge, CMake, Gradle, manifests, adapter tests. + +- [ ] Add failing fake-bridge adapter tests for link/connect/update/clear/unlink/error paths. +- [ ] Run and observe red. +- [ ] Implement the FOSS factory and GitHub native adapter using documented SDK APIs. +- [ ] Configure optional private AAR/Prefab and deep linking without leaking it into FOSS. +- [ ] Run adapter tests and flavor builds. +- [ ] Commit `feat(discord): add official Social SDK transport`. + +### Task 6: Application and Activity wiring + +**Files:** `FlowApplication.kt`, `MainActivity.kt`. + +- [ ] Add lifecycle behavior tests to the service first. +- [ ] Initialize once at application scope and weakly attach/detach Activity. +- [ ] Clear and close on final lifecycle shutdown without disrupting background playback semantics. +- [ ] Run lifecycle tests and builds. +- [ ] Commit `feat(discord): wire application lifecycle`. + +### Task 7: CI, documentation, and end-to-end verification + +**Files:** `.github/workflows/build.yml`, `docs/discord-rich-presence.md`, `.gitignore`. + +- [ ] Make CI decode optional owner-supplied SDK material, test before packaging, verify APK existence, calculate SHA-256, and upload `flow-discord-rpc-nightly-apk`. +- [ ] Document setup, privacy, build, test, limitations, and troubleshooting with official links. +- [ ] Run all requested unit, lint, and assemble task equivalents. +- [ ] Inspect APK contents, manifest, commit, artifact, and digest. +- [ ] Review the full `main...HEAD` diff for leaks, races, stale state, flavor leakage, and misleading UI. +- [ ] Commit `ci(discord): verify nightly integration` and update draft PR #1. diff --git a/docs/superpowers/specs/2026-07-14-discord-rich-presence-design.md b/docs/superpowers/specs/2026-07-14-discord-rich-presence-design.md new file mode 100644 index 00000000..c46b1431 --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-discord-rich-presence-design.md @@ -0,0 +1,33 @@ +# Discord Rich Presence Design + +## Goal + +Expose an opt-in Discord Rich Presence setting and publish current Flow playback through Discord's official Social SDK on supported GitHub builds, while keeping FOSS builds SDK-free and honest about unavailability. + +## Architecture + +- `DiscordPreferences` owns only the enabled flag and the last non-sensitive account label. +- `DiscordPlaybackSource` converts the existing video, Shorts, and music managers into immutable snapshots. Active playback priority is Shorts, then regular video/live, then music. +- `DiscordPresenceService` owns transport connection, token storage, coordinator lifetime, Activity attachment, enable/disable, link, retry, unlink, and shutdown. +- Common code depends on `DiscordPresenceTransport`. Each flavor supplies `DiscordPresenceTransportFactory`: FOSS always returns the fail-closed adapter; GitHub returns the official adapter only when both an application ID and the private Social SDK AAR were supplied at build time. +- The GitHub adapter calls a small C++20 JNI bridge linked through the official AAR Prefab package. The bridge uses only documented Social SDK APIs and runs `discordpp::RunCallbacks` on a bounded callback thread. + +## User experience + +The row is indexed in Settings search and appears in Content & Playback for both flavors. Its subtitle is derived from availability, enabled state, connection state, account label, and errors. The dedicated screen contains the opt-in switch, status, connect/retry or disconnect action, account label, and playback-data privacy copy. Enabling is rejected when the SDK is unavailable or no account is linked. + +## Playback and lifecycle + +Snapshots are sampled at five-second intervals while enabled and also react immediately to player and metadata changes. This retains the existing policy's deduplication and seek correction without high-frequency SDK calls. Pause, stop, media switches, disable, unlink, and shutdown clear presence. `MainActivity` is held weakly by the service and attached to the SDK only between `onStart` and `onStop`. + +## Official SDK requirements + +Android 7.0+ is supported. Mobile Rich Presence requires account linking. Linking uses SDK 1.5+ OAuth2 PKCE with `openid sdk.social_layer_presence`, a public-client Discord application, and the `discord-:/authorize/callback` deep link. The SDK is a private Developer Portal download named `discord_partner_sdk.aar`, not a public Maven dependency. The adapter uses `Authorize`, `GetToken`, `RefreshToken`, `UpdateToken`, `Connect`, `FetchCurrentUser`, `RevokeToken`, `UpdateRichPresence`, `ClearRichPresence`, `Disconnect`, and `RunCallbacks`. + +## Failure behavior + +Missing AAR or application ID produces `Unavailable in this build`; it never presents an enabled state. Transport failures preserve the opt-in preference only when retry is meaningful, expose a concise error, and clear stale presence. Invalid stored tokens are deleted and return the UI to Not connected. + +## Verification + +Focused JVM tests cover preferences, settings derivation, snapshot selection/mapping, policy, coordinator clearing/failures, FOSS behavior, and the SDK adapter through a fake bridge. GitHub Actions runs unit tests before lint and packaging, uploads the exact nightly APK with missing-file errors enabled, and records the APK digest. From 7cc5d184211ddd0bd57658866e9b9835e687e2aa Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 12:43:34 +0300 Subject: [PATCH 16/74] test(discord): define preference and settings state --- .../flow/discord/DiscordPreferencesTest.kt | 50 +++++++++++++ .../flow/discord/DiscordSettingsStateTest.kt | 75 +++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 app/src/test/java/io/github/aedev/flow/discord/DiscordPreferencesTest.kt create mode 100644 app/src/test/java/io/github/aedev/flow/discord/DiscordSettingsStateTest.kt diff --git a/app/src/test/java/io/github/aedev/flow/discord/DiscordPreferencesTest.kt b/app/src/test/java/io/github/aedev/flow/discord/DiscordPreferencesTest.kt new file mode 100644 index 00000000..dc08dfc7 --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/discord/DiscordPreferencesTest.kt @@ -0,0 +1,50 @@ +package io.github.aedev.flow.discord + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import com.google.common.truth.Truth.assertThat +import java.io.File +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class DiscordPreferencesTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `enabled defaults to false`() = runTest { + val preferences = createPreferences(backgroundScope, "default.preferences_pb") + + assertThat(preferences.enabled.first()).isFalse() + } + + @Test + fun `enabled and account label persist across instances`() = runTest { + val file = File(temporaryFolder.root, "persisted.preferences_pb") + val dataStore = PreferenceDataStoreFactory.create( + scope = backgroundScope, + produceFile = { file }, + ) + val first = DiscordPreferences(dataStore) + first.setEnabled(true) + first.setLinkedAccountLabel("pasta") + val second = DiscordPreferences(dataStore) + + assertThat(second.enabled.first()).isTrue() + assertThat(second.linkedAccountLabel.first()).isEqualTo("pasta") + } + + private fun createPreferences(scope: CoroutineScope, fileName: String): DiscordPreferences = + createPreferences(scope, File(temporaryFolder.root, fileName)) + + private fun createPreferences(scope: CoroutineScope, file: File): DiscordPreferences = + DiscordPreferences( + PreferenceDataStoreFactory.create( + scope = scope, + produceFile = { file }, + ), + ) +} diff --git a/app/src/test/java/io/github/aedev/flow/discord/DiscordSettingsStateTest.kt b/app/src/test/java/io/github/aedev/flow/discord/DiscordSettingsStateTest.kt new file mode 100644 index 00000000..ad115b61 --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/discord/DiscordSettingsStateTest.kt @@ -0,0 +1,75 @@ +package io.github.aedev.flow.discord + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class DiscordSettingsStateTest { + @Test + fun `unavailable transport cannot appear enabled`() { + val state = deriveDiscordSettingsState( + preferenceEnabled = true, + transportAvailable = false, + connectionState = DiscordConnectionState.UNAVAILABLE, + accountName = null, + errorMessage = "SDK missing", + ) + + assertThat(state.isEnabled).isFalse() + assertThat(state.canEnable).isFalse() + assertThat(state.summary).isEqualTo(DiscordSettingsSummary.UNAVAILABLE) + } + + @Test + fun `available disabled setting reports off`() { + val state = deriveDiscordSettingsState( + preferenceEnabled = false, + transportAvailable = true, + connectionState = DiscordConnectionState.DISCONNECTED, + accountName = null, + errorMessage = null, + ) + + assertThat(state.summary).isEqualTo(DiscordSettingsSummary.OFF) + } + + @Test + fun `enabled unlinked setting reports not connected`() { + val state = deriveDiscordSettingsState( + preferenceEnabled = true, + transportAvailable = true, + connectionState = DiscordConnectionState.DISCONNECTED, + accountName = null, + errorMessage = null, + ) + + assertThat(state.summary).isEqualTo(DiscordSettingsSummary.NOT_CONNECTED) + } + + @Test + fun `connected setting exposes account`() { + val state = deriveDiscordSettingsState( + preferenceEnabled = true, + transportAvailable = true, + connectionState = DiscordConnectionState.CONNECTED, + accountName = "pasta", + errorMessage = null, + ) + + assertThat(state.summary).isEqualTo(DiscordSettingsSummary.CONNECTED) + assertThat(state.accountName).isEqualTo("pasta") + } + + @Test + fun `transport error takes precedence while enabled`() { + val state = deriveDiscordSettingsState( + preferenceEnabled = true, + transportAvailable = true, + connectionState = DiscordConnectionState.ERROR, + accountName = null, + errorMessage = "Connection timed out", + ) + + assertThat(state.summary).isEqualTo(DiscordSettingsSummary.ERROR) + assertThat(state.errorMessage).isEqualTo("Connection timed out") + } +} From 34ba97eb24b8a01b0a81d673b7f72beaefe9526f Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 12:43:45 +0300 Subject: [PATCH 17/74] feat(discord): persist settings and select playback --- .../flow/discord/DiscordPlaybackSelector.kt | 11 ++ .../flow/discord/DiscordPlaybackSource.kt | 103 ++++++++++++++++++ .../aedev/flow/discord/DiscordPreferences.kt | 49 +++++++++ .../flow/discord/DiscordSettingsState.kt | 47 ++++++++ .../flow/player/shorts/ShortsPlayerPool.kt | 15 +++ .../flow/ui/screens/shorts/ShortsScreen.kt | 1 + .../discord/DiscordPlaybackSelectorTest.kt | 62 +++++++++++ .../flow/discord/DiscordPresenceMapperTest.kt | 22 ++++ 8 files changed, 310 insertions(+) create mode 100644 app/src/main/java/io/github/aedev/flow/discord/DiscordPlaybackSelector.kt create mode 100644 app/src/main/java/io/github/aedev/flow/discord/DiscordPlaybackSource.kt create mode 100644 app/src/main/java/io/github/aedev/flow/discord/DiscordPreferences.kt create mode 100644 app/src/main/java/io/github/aedev/flow/discord/DiscordSettingsState.kt create mode 100644 app/src/test/java/io/github/aedev/flow/discord/DiscordPlaybackSelectorTest.kt diff --git a/app/src/main/java/io/github/aedev/flow/discord/DiscordPlaybackSelector.kt b/app/src/main/java/io/github/aedev/flow/discord/DiscordPlaybackSelector.kt new file mode 100644 index 00000000..70ecf919 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/discord/DiscordPlaybackSelector.kt @@ -0,0 +1,11 @@ +package io.github.aedev.flow.discord + +class DiscordPlaybackSelector { + fun select( + short: PlaybackSnapshot?, + video: PlaybackSnapshot?, + music: PlaybackSnapshot?, + ): PlaybackSnapshot? = sequenceOf(short, video, music) + .filterNotNull() + .firstOrNull { snapshot -> snapshot.isPlaying && snapshot.mediaId.isNotBlank() } +} diff --git a/app/src/main/java/io/github/aedev/flow/discord/DiscordPlaybackSource.kt b/app/src/main/java/io/github/aedev/flow/discord/DiscordPlaybackSource.kt new file mode 100644 index 00000000..484ef6e0 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/discord/DiscordPlaybackSource.kt @@ -0,0 +1,103 @@ +package io.github.aedev.flow.discord + +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import io.github.aedev.flow.player.EnhancedMusicPlayerManager +import io.github.aedev.flow.player.EnhancedPlayerManager +import io.github.aedev.flow.player.GlobalPlayerState +import io.github.aedev.flow.player.shorts.ShortsPlayerPool +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.isActive + +@OptIn(UnstableApi::class) +class DiscordPlaybackSource( + private val videoManager: EnhancedPlayerManager = EnhancedPlayerManager.getInstance(), + private val shortsPool: ShortsPlayerPool = ShortsPlayerPool.getInstance(), + private val selector: DiscordPlaybackSelector = DiscordPlaybackSelector(), +) { + val playback: Flow = merge( + GlobalPlayerState.currentVideo.map { Unit }, + videoManager.playerState.map { Unit }, + EnhancedMusicPlayerManager.currentTrack.map { Unit }, + EnhancedMusicPlayerManager.playerState.map { Unit }, + shortsPool.currentVideo.map { Unit }, + ticker(), + ).map { + selector.select( + short = shortSnapshot(), + video = videoSnapshot(), + music = musicSnapshot(), + ) + }.distinctUntilChanged() + + private fun shortSnapshot(): PlaybackSnapshot? { + val short = shortsPool.currentVideo.value ?: return null + if (shortsPool.currentVideoId.value != short.id) return null + return PlaybackSnapshot( + kind = PlaybackKind.SHORT, + mediaId = short.id, + title = short.title, + subtitle = short.channelName, + artworkUrl = short.thumbnailUrl, + positionMs = shortsPool.playbackPosition(), + durationMs = shortsPool.playbackDuration(), + isPlaying = shortsPool.isPlaying(), + isLive = false, + ) + } + + private fun videoSnapshot(): PlaybackSnapshot? { + val video = GlobalPlayerState.currentVideo.value ?: return null + val state = videoManager.playerState.value + if (state.currentVideoId != video.id) return null + val isLive = state.isLive || video.isLive + return PlaybackSnapshot( + kind = when { + isLive -> PlaybackKind.LIVE + video.isShort -> PlaybackKind.SHORT + else -> PlaybackKind.VIDEO + }, + mediaId = video.id, + title = video.title, + subtitle = video.channelName, + artworkUrl = video.thumbnailUrl, + positionMs = videoManager.getCurrentPosition(), + durationMs = if (isLive) 0L else videoManager.getDuration().coerceAtLeast(0L), + isPlaying = state.isPlaying, + isLive = isLive, + ) + } + + private fun musicSnapshot(): PlaybackSnapshot? { + val track = EnhancedMusicPlayerManager.currentTrack.value ?: return null + val state = EnhancedMusicPlayerManager.playerState.value + return PlaybackSnapshot( + kind = PlaybackKind.MUSIC, + mediaId = track.videoId, + title = track.title, + subtitle = track.artist, + artworkUrl = track.highResThumbnailUrl, + positionMs = EnhancedMusicPlayerManager.getCurrentPosition(), + durationMs = EnhancedMusicPlayerManager.getDuration().coerceAtLeast(0L), + isPlaying = state.isPlaying, + isLive = false, + ) + } + + private fun ticker(): Flow = flow { + while (currentCoroutineContext().isActive) { + emit(Unit) + delay(POSITION_SAMPLE_INTERVAL_MS) + } + } + + private companion object { + const val POSITION_SAMPLE_INTERVAL_MS = 5_000L + } +} diff --git a/app/src/main/java/io/github/aedev/flow/discord/DiscordPreferences.kt b/app/src/main/java/io/github/aedev/flow/discord/DiscordPreferences.kt new file mode 100644 index 00000000..d22a9277 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/discord/DiscordPreferences.kt @@ -0,0 +1,49 @@ +package io.github.aedev.flow.discord + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import io.github.aedev.flow.data.local.safePreferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.discordDataStore: DataStore by safePreferencesDataStore( + name = "discord_presence", +) + +class DiscordPreferences( + private val dataStore: DataStore, +) { + constructor(context: Context) : this(context.applicationContext.discordDataStore) + + val enabled: Flow = dataStore.data.map { preferences -> + preferences[ENABLED] ?: false + } + + val linkedAccountLabel: Flow = dataStore.data.map { preferences -> + preferences[LINKED_ACCOUNT_LABEL]?.takeIf(String::isNotBlank) + } + + suspend fun setEnabled(enabled: Boolean) { + dataStore.edit { preferences -> preferences[ENABLED] = enabled } + } + + suspend fun setLinkedAccountLabel(label: String?) { + dataStore.edit { preferences -> + val normalized = label?.trim()?.takeIf(String::isNotBlank) + if (normalized == null) { + preferences.remove(LINKED_ACCOUNT_LABEL) + } else { + preferences[LINKED_ACCOUNT_LABEL] = normalized + } + } + } + + private companion object { + val ENABLED = booleanPreferencesKey("enabled") + val LINKED_ACCOUNT_LABEL = stringPreferencesKey("linked_account_label") + } +} diff --git a/app/src/main/java/io/github/aedev/flow/discord/DiscordSettingsState.kt b/app/src/main/java/io/github/aedev/flow/discord/DiscordSettingsState.kt new file mode 100644 index 00000000..f9581404 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/discord/DiscordSettingsState.kt @@ -0,0 +1,47 @@ +package io.github.aedev.flow.discord + +enum class DiscordSettingsSummary { + OFF, + NOT_CONNECTED, + CONNECTED, + UNAVAILABLE, + ERROR, +} + +data class DiscordSettingsState( + val isAvailable: Boolean, + val isEnabled: Boolean, + val canEnable: Boolean, + val connectionState: DiscordConnectionState, + val summary: DiscordSettingsSummary, + val accountName: String?, + val errorMessage: String?, +) + +fun deriveDiscordSettingsState( + preferenceEnabled: Boolean, + transportAvailable: Boolean, + connectionState: DiscordConnectionState, + accountName: String?, + errorMessage: String?, +): DiscordSettingsState { + val available = transportAvailable && connectionState != DiscordConnectionState.UNAVAILABLE + val enabled = preferenceEnabled && available + val summary = when { + !available -> DiscordSettingsSummary.UNAVAILABLE + enabled && connectionState == DiscordConnectionState.ERROR -> DiscordSettingsSummary.ERROR + !enabled -> DiscordSettingsSummary.OFF + connectionState == DiscordConnectionState.CONNECTED -> DiscordSettingsSummary.CONNECTED + else -> DiscordSettingsSummary.NOT_CONNECTED + } + + return DiscordSettingsState( + isAvailable = available, + isEnabled = enabled, + canEnable = available, + connectionState = connectionState, + summary = summary, + accountName = accountName?.takeIf(String::isNotBlank), + errorMessage = errorMessage?.takeIf(String::isNotBlank), + ) +} diff --git a/app/src/main/java/io/github/aedev/flow/player/shorts/ShortsPlayerPool.kt b/app/src/main/java/io/github/aedev/flow/player/shorts/ShortsPlayerPool.kt index 7cf46e5a..c9a2d9b4 100644 --- a/app/src/main/java/io/github/aedev/flow/player/shorts/ShortsPlayerPool.kt +++ b/app/src/main/java/io/github/aedev/flow/player/shorts/ShortsPlayerPool.kt @@ -20,6 +20,7 @@ import androidx.media3.exoplayer.trackselection.AdaptiveTrackSelection import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.upstream.DefaultAllocator import io.github.aedev.flow.data.local.PlayerPreferences +import io.github.aedev.flow.data.model.ShortVideo import io.github.aedev.flow.player.analytics.PlaybackAnalyticsLogger import io.github.aedev.flow.player.config.PlayerConfig import io.github.aedev.flow.player.datasource.YouTubeHttpDataSource @@ -95,6 +96,19 @@ class ShortsPlayerPool private constructor() { private val _currentVideoId = MutableStateFlow(null) val currentVideoId: StateFlow = _currentVideoId.asStateFlow() + private val _currentVideo = MutableStateFlow(null) + val currentVideo: StateFlow = _currentVideo.asStateFlow() + + fun setCurrentVideo(video: ShortVideo?) { + _currentVideo.value = video + } + + fun playbackPosition(): Long = findActivePlayer()?.currentPosition ?: 0L + + fun playbackDuration(): Long = findActivePlayer()?.duration?.coerceAtLeast(0L) ?: 0L + + fun isPlaying(): Boolean = findActivePlayer()?.isPlaying == true + // INITIALIZATION fun initialize(context: Context) { if (isInitialized) return @@ -492,6 +506,7 @@ class ShortsPlayerPool private constructor() { } isInitialized = false _currentVideoId.value = null + _currentVideo.value = null } fun isReady(): Boolean = isInitialized && players[0] != null diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/shorts/ShortsScreen.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/shorts/ShortsScreen.kt index f5e89385..7f7b6484 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/shorts/ShortsScreen.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/shorts/ShortsScreen.kt @@ -194,6 +194,7 @@ fun ShortsScreen( val settled = pagerState.settledPage val playerPool = ShortsPlayerPool.getInstance() playerPool.initialize(context) + playerPool.setCurrentVideo(uiState.shorts.getOrNull(settled)) suspend fun prepareShort(index: Int, short: ShortVideo, shouldPlay: Boolean) { try { diff --git a/app/src/test/java/io/github/aedev/flow/discord/DiscordPlaybackSelectorTest.kt b/app/src/test/java/io/github/aedev/flow/discord/DiscordPlaybackSelectorTest.kt new file mode 100644 index 00000000..f5e0ddbf --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/discord/DiscordPlaybackSelectorTest.kt @@ -0,0 +1,62 @@ +package io.github.aedev.flow.discord + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class DiscordPlaybackSelectorTest { + private val selector = DiscordPlaybackSelector() + + @Test + fun `shorts take priority over regular video and music`() { + val selected = selector.select( + short = snapshot(PlaybackKind.SHORT, "short"), + video = snapshot(PlaybackKind.VIDEO, "video"), + music = snapshot(PlaybackKind.MUSIC, "music"), + ) + + assertThat(selected?.mediaId).isEqualTo("short") + } + + @Test + fun `regular video takes priority over music`() { + val selected = selector.select( + short = null, + video = snapshot(PlaybackKind.LIVE, "live"), + music = snapshot(PlaybackKind.MUSIC, "music"), + ) + + assertThat(selected?.mediaId).isEqualTo("live") + } + + @Test + fun `paused higher priority player does not hide playing music`() { + val selected = selector.select( + short = snapshot(PlaybackKind.SHORT, "short", isPlaying = false), + video = snapshot(PlaybackKind.VIDEO, "video", isPlaying = false), + music = snapshot(PlaybackKind.MUSIC, "music"), + ) + + assertThat(selected?.mediaId).isEqualTo("music") + } + + @Test + fun `no active player clears selection`() { + assertThat(selector.select(null, null, null)).isNull() + } + + private fun snapshot( + kind: PlaybackKind, + id: String, + isPlaying: Boolean = true, + ) = PlaybackSnapshot( + kind = kind, + mediaId = id, + title = id, + subtitle = "creator", + artworkUrl = "", + positionMs = 0, + durationMs = 60_000, + isPlaying = isPlaying, + isLive = kind == PlaybackKind.LIVE, + ) +} diff --git a/app/src/test/java/io/github/aedev/flow/discord/DiscordPresenceMapperTest.kt b/app/src/test/java/io/github/aedev/flow/discord/DiscordPresenceMapperTest.kt index edd8373b..9047ca95 100644 --- a/app/src/test/java/io/github/aedev/flow/discord/DiscordPresenceMapperTest.kt +++ b/app/src/test/java/io/github/aedev/flow/discord/DiscordPresenceMapperTest.kt @@ -103,6 +103,28 @@ class DiscordPresenceMapperTest { assertThat(result).isNull() } + @Test + fun shortMapsToWatchingWithCreatorAndFiniteTiming() { + val result = mapper.map( + snapshot = PlaybackSnapshot( + kind = PlaybackKind.SHORT, + mediaId = "short-1", + title = "A Short", + subtitle = "Creator", + artworkUrl = "https://i.ytimg.com/vi/short-1/oar2.jpg", + positionMs = 5_000L, + durationMs = 20_000L, + isPlaying = true, + isLive = false, + ), + nowEpochSeconds = 100L, + ) + + assertThat(result?.type).isEqualTo(DiscordActivityType.WATCHING) + assertThat(result?.state).isEqualTo("by Creator") + assertThat(result?.endTimestampSeconds).isEqualTo(115L) + } + @Test fun fieldsAreTrimmedAndLimitedToDiscordMaximum() { val result = mapper.map( From e174ac9fd66a6afe40682bd736b310fc9aa8267e Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 12:44:02 +0300 Subject: [PATCH 18/74] feat(discord): add Kizzy-style Gateway transport --- app/build.gradle.kts | 1 + .../DiscordPlatformTransportFactory.kt | 12 + app/src/github/AndroidManifest.xml | 9 + .../flow/discord/DiscordLoginActivity.kt | 72 +++++ .../aedev/flow/discord/DiscordLoginBroker.kt | 25 ++ .../DiscordPlatformTransportFactory.kt | 18 ++ .../discord/KizzyDiscordPresenceTransport.kt | 274 ++++++++++++++++++ .../flow/discord/KizzyGatewayProtocol.kt | 79 +++++ .../io/github/aedev/flow/FlowApplication.kt | 4 + .../java/io/github/aedev/flow/MainActivity.kt | 3 + .../discord/DiscordPresenceCoordinator.kt | 5 +- .../flow/discord/DiscordPresenceRuntime.kt | 164 +++++++++++ .../flow/discord/DiscordPresenceTransport.kt | 1 + .../DiscordPresenceTransportFactory.kt | 12 + .../discord/NoOpDiscordPresenceTransport.kt | 2 + .../discord/DiscordPresenceCoordinatorTest.kt | 42 +++ .../NoOpDiscordPresenceTransportTest.kt | 1 + .../flow/discord/KizzyGatewayProtocolTest.kt | 49 ++++ 18 files changed, 770 insertions(+), 3 deletions(-) create mode 100644 app/src/foss/java/io/github/aedev/flow/discord/DiscordPlatformTransportFactory.kt create mode 100644 app/src/github/AndroidManifest.xml create mode 100644 app/src/github/java/io/github/aedev/flow/discord/DiscordLoginActivity.kt create mode 100644 app/src/github/java/io/github/aedev/flow/discord/DiscordLoginBroker.kt create mode 100644 app/src/github/java/io/github/aedev/flow/discord/DiscordPlatformTransportFactory.kt create mode 100644 app/src/github/java/io/github/aedev/flow/discord/KizzyDiscordPresenceTransport.kt create mode 100644 app/src/github/java/io/github/aedev/flow/discord/KizzyGatewayProtocol.kt create mode 100644 app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceRuntime.kt create mode 100644 app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceTransportFactory.kt create mode 100644 app/src/testGithub/java/io/github/aedev/flow/discord/KizzyGatewayProtocolTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 64ac8990..44a512a7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -47,6 +47,7 @@ android { dimension = "version" isDefault = true buildConfigField("Boolean", "UPDATER_ENABLED", "true") + buildConfigField("String", "DISCORD_APPLICATION_ID", "\"1526515771021328514\"") } create("foss") { dimension = "version" diff --git a/app/src/foss/java/io/github/aedev/flow/discord/DiscordPlatformTransportFactory.kt b/app/src/foss/java/io/github/aedev/flow/discord/DiscordPlatformTransportFactory.kt new file mode 100644 index 00000000..e11fab57 --- /dev/null +++ b/app/src/foss/java/io/github/aedev/flow/discord/DiscordPlatformTransportFactory.kt @@ -0,0 +1,12 @@ +package io.github.aedev.flow.discord + +import android.content.Context +import okhttp3.OkHttpClient + +class DiscordPlatformTransportFactory : DiscordPresenceTransportFactory { + override fun create( + context: Context, + okHttpClient: OkHttpClient, + tokenStore: DiscordTokenStore, + ): DiscordPresenceTransport = NoOpDiscordPresenceTransport() +} diff --git a/app/src/github/AndroidManifest.xml b/app/src/github/AndroidManifest.xml new file mode 100644 index 00000000..49c491c0 --- /dev/null +++ b/app/src/github/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/github/java/io/github/aedev/flow/discord/DiscordLoginActivity.kt b/app/src/github/java/io/github/aedev/flow/discord/DiscordLoginActivity.kt new file mode 100644 index 00000000..6fc8452f --- /dev/null +++ b/app/src/github/java/io/github/aedev/flow/discord/DiscordLoginActivity.kt @@ -0,0 +1,72 @@ +package io.github.aedev.flow.discord + +import android.annotation.SuppressLint +import android.graphics.Color +import android.os.Bundle +import android.view.WindowManager +import android.webkit.CookieManager +import android.webkit.WebStorage +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.ComponentActivity +import org.json.JSONArray + +class DiscordLoginActivity : ComponentActivity() { + private var completed = false + + @SuppressLint("SetJavaScriptEnabled") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + + val webView = WebView(this).apply { + setBackgroundColor(Color.TRANSPARENT) + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.userAgentString = settings.userAgentString.replace("; wv", "") + webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView, url: String) { + super.onPageFinished(view, url) + if (url.startsWith("https://discord.com/")) { + readToken(view) + } + } + } + loadUrl(DISCORD_LOGIN_URL) + } + setContentView(webView) + } + + override fun onDestroy() { + if (!completed && isFinishing) { + DiscordLoginBroker.fail("Discord connection was cancelled.") + } + super.onDestroy() + } + + private fun readToken(webView: WebView) { + webView.evaluateJavascript(TOKEN_SCRIPT) { encodedResult -> + val token = runCatching { + JSONArray("[$encodedResult]").getString(0) + }.getOrNull() + ?.trim() + ?.removeSurrounding("\"") + ?.takeIf { it.isNotEmpty() && it != "null" } + + if (token != null) { + completed = true + DiscordLoginBroker.complete(token) + webView.clearHistory() + webView.clearCache(true) + WebStorage.getInstance().deleteAllData() + CookieManager.getInstance().removeAllCookies(null) + finish() + } + } + } + + private companion object { + const val DISCORD_LOGIN_URL = "https://discord.com/login" + const val TOKEN_SCRIPT = "(function(){return window.localStorage.getItem('token');})()" + } +} diff --git a/app/src/github/java/io/github/aedev/flow/discord/DiscordLoginBroker.kt b/app/src/github/java/io/github/aedev/flow/discord/DiscordLoginBroker.kt new file mode 100644 index 00000000..b8398bf8 --- /dev/null +++ b/app/src/github/java/io/github/aedev/flow/discord/DiscordLoginBroker.kt @@ -0,0 +1,25 @@ +package io.github.aedev.flow.discord + +import kotlinx.coroutines.CompletableDeferred + +internal object DiscordLoginBroker { + private var pending: CompletableDeferred>? = null + + @Synchronized + fun begin(): CompletableDeferred>? { + if (pending?.isActive == true) return null + return CompletableDeferred>().also { pending = it } + } + + @Synchronized + fun complete(token: String) { + pending?.complete(Result.success(token)) + pending = null + } + + @Synchronized + fun fail(message: String) { + pending?.complete(Result.failure(IllegalStateException(message))) + pending = null + } +} diff --git a/app/src/github/java/io/github/aedev/flow/discord/DiscordPlatformTransportFactory.kt b/app/src/github/java/io/github/aedev/flow/discord/DiscordPlatformTransportFactory.kt new file mode 100644 index 00000000..1388dc65 --- /dev/null +++ b/app/src/github/java/io/github/aedev/flow/discord/DiscordPlatformTransportFactory.kt @@ -0,0 +1,18 @@ +package io.github.aedev.flow.discord + +import android.content.Context +import io.github.aedev.flow.BuildConfig +import okhttp3.OkHttpClient + +class DiscordPlatformTransportFactory : DiscordPresenceTransportFactory { + override fun create( + context: Context, + okHttpClient: OkHttpClient, + tokenStore: DiscordTokenStore, + ): DiscordPresenceTransport = KizzyDiscordPresenceTransport( + context = context.applicationContext, + client = okHttpClient, + tokenStore = tokenStore, + applicationId = BuildConfig.DISCORD_APPLICATION_ID, + ) +} diff --git a/app/src/github/java/io/github/aedev/flow/discord/KizzyDiscordPresenceTransport.kt b/app/src/github/java/io/github/aedev/flow/discord/KizzyDiscordPresenceTransport.kt new file mode 100644 index 00000000..953a28e9 --- /dev/null +++ b/app/src/github/java/io/github/aedev/flow/discord/KizzyDiscordPresenceTransport.kt @@ -0,0 +1,274 @@ +package io.github.aedev.flow.discord + +import android.app.Activity +import android.content.Context +import android.content.Intent +import java.lang.ref.WeakReference +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import org.json.JSONArray +import org.json.JSONObject + +/** + * GitHub-flavor Discord Gateway adapter inspired by Kizzy's Android RPC approach. + * This uses a Discord user session and is not an official Discord integration. + */ +class KizzyDiscordPresenceTransport( + private val context: Context, + private val client: OkHttpClient, + private val tokenStore: DiscordTokenStore, + private val applicationId: String, +) : DiscordPresenceTransport { + override val isAvailable: Boolean = applicationId.isNotBlank() + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val imageCache = ConcurrentHashMap() + private var activityReference = WeakReference(null) + private var socket: WebSocket? = null + private var heartbeatJob: Job? = null + private var readySignal: CompletableDeferred? = null + private var currentToken: String? = null + private var sequence: Int? = null + private var generation = 0 + + private val _connectionState = MutableStateFlow(DiscordConnectionState.DISCONNECTED) + override val connectionState: StateFlow = _connectionState.asStateFlow() + + private val _linkedAccountName = MutableStateFlow(null) + override val linkedAccountName: StateFlow = _linkedAccountName.asStateFlow() + + private val _lastError = MutableStateFlow(null) + override val lastError: StateFlow = _lastError.asStateFlow() + + override fun attachActivity(activity: Activity?) { + activityReference = WeakReference(activity) + } + + override suspend fun link(): DiscordLinkResult { + val activity = activityReference.get() + ?: return fail("Open Flow before connecting Discord.") + val result = DiscordLoginBroker.begin() + ?: return fail("A Discord connection is already in progress.") + + _connectionState.value = DiscordConnectionState.LINKING + _lastError.value = null + activity.startActivity(Intent(activity, DiscordLoginActivity::class.java)) + + val token = runCatching { + withTimeout(LOGIN_TIMEOUT_MS) { result.await().getOrThrow() } + }.getOrElse { error -> + return fail(error.message ?: "Discord connection did not complete.") + } + + return connect( + DiscordAuthTokens( + accessToken = token, + refreshToken = "", + expiresAtEpochSeconds = Long.MAX_VALUE, + ), + ) + } + + override suspend fun connect(tokens: DiscordAuthTokens): DiscordLinkResult { + if (tokens.accessToken.isBlank()) return fail("Discord returned an empty session token.") + if (applicationId.isBlank()) return fail("Discord application ID is missing from this build.") + if ( + _connectionState.value == DiscordConnectionState.CONNECTED && + currentToken == tokens.accessToken + ) { + return DiscordLinkResult.Success + } + + closeSocket() + val connectionGeneration = ++generation + currentToken = tokens.accessToken + sequence = null + _connectionState.value = DiscordConnectionState.CONNECTING + _lastError.value = null + val signal = CompletableDeferred() + readySignal = signal + + val request = Request.Builder().url(KizzyGatewayProtocol.GATEWAY_URL).build() + socket = client.newWebSocket(request, gatewayListener(connectionGeneration, tokens)) + + return runCatching { + withTimeout(CONNECTION_TIMEOUT_MS) { signal.await() } + }.getOrElse { + fail("Discord Gateway connection timed out.") + } + } + + override suspend fun update(payload: DiscordPresencePayload): Boolean { + if (!ensureConnected()) return false + val resolvedImage = resolveExternalImage(payload.largeImage) + return socket?.send( + KizzyGatewayProtocol.presence(payload, applicationId, resolvedImage), + ) == true + } + + override suspend fun clear(): Boolean { + if (_connectionState.value != DiscordConnectionState.CONNECTED) return true + return socket?.send(KizzyGatewayProtocol.presence(null, applicationId, null)) == true + } + + override suspend fun unlink(): Boolean { + clear() + closeSocket() + tokenStore.clear() + currentToken = null + _linkedAccountName.value = null + _lastError.value = null + _connectionState.value = DiscordConnectionState.DISCONNECTED + return true + } + + override fun close() { + if (_connectionState.value == DiscordConnectionState.CONNECTED) { + socket?.send(KizzyGatewayProtocol.presence(null, applicationId, null)) + } + closeSocket() + scope.cancel() + } + + private fun gatewayListener( + connectionGeneration: Int, + tokens: DiscordAuthTokens, + ) = object : WebSocketListener() { + override fun onMessage(webSocket: WebSocket, text: String) { + if (connectionGeneration != generation) return + val payload = runCatching { JSONObject(text) }.getOrNull() ?: return + if (payload.has("s") && !payload.isNull("s")) sequence = payload.optInt("s") + + when (payload.optInt("op", -1)) { + 0 -> if (payload.optString("t") == "READY") { + val user = payload.optJSONObject("d")?.optJSONObject("user") + val accountName = user?.optString("global_name") + ?.takeIf(String::isNotBlank) + ?: user?.optString("username")?.takeIf(String::isNotBlank) + ?: "Discord user" + _linkedAccountName.value = accountName + _connectionState.value = DiscordConnectionState.CONNECTED + _lastError.value = null + tokenStore.save(tokens) + readySignal?.complete(DiscordLinkResult.Success) + } + + 1 -> webSocket.send(KizzyGatewayProtocol.heartbeat(sequence)) + 7 -> failFromCallback("Discord requested a reconnect.") + 9 -> failFromCallback("Discord rejected the session. Reconnect your account.") + 10 -> { + val interval = payload.optJSONObject("d")?.optLong("heartbeat_interval") ?: 0L + startHeartbeat(webSocket, interval, connectionGeneration) + webSocket.send(KizzyGatewayProtocol.identify(tokens.accessToken)) + } + } + } + + override fun onFailure(webSocket: WebSocket, throwable: Throwable, response: Response?) { + if (connectionGeneration != generation) return + failFromCallback("Discord Gateway connection failed.") + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + if (connectionGeneration != generation) return + heartbeatJob?.cancel() + if (_connectionState.value == DiscordConnectionState.CONNECTED) { + _connectionState.value = DiscordConnectionState.DISCONNECTED + } + } + } + + private fun startHeartbeat(webSocket: WebSocket, intervalMs: Long, connectionGeneration: Int) { + heartbeatJob?.cancel() + if (intervalMs <= 0) return + heartbeatJob = scope.launch { + while (isActive && connectionGeneration == generation) { + delay(intervalMs) + webSocket.send(KizzyGatewayProtocol.heartbeat(sequence)) + } + } + } + + private suspend fun ensureConnected(): Boolean { + if (_connectionState.value == DiscordConnectionState.CONNECTED) return true + val saved = tokenStore.load() ?: return false + return connect(saved) == DiscordLinkResult.Success + } + + private suspend fun resolveExternalImage(imageUrl: String): String? { + if (!imageUrl.startsWith("https://")) return null + imageCache[imageUrl]?.let { return it } + val token = currentToken ?: return null + + return withContext(Dispatchers.IO) { + runCatching { + val body = JSONObject() + .put("urls", JSONArray().put(imageUrl)) + .toString() + .toRequestBody(JSON_MEDIA_TYPE) + val request = Request.Builder() + .url("https://discord.com/api/v9/applications/$applicationId/external-assets") + .header("Authorization", token) + .post(body) + .build() + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) return@use null + val path = JSONArray(response.body?.string().orEmpty()) + .optJSONObject(0) + ?.optString("external_asset_path") + ?.takeIf(String::isNotBlank) + path?.let { "mp:$it" } + } + }.getOrNull()?.also { imageCache[imageUrl] = it } + } + } + + private fun closeSocket() { + generation++ + heartbeatJob?.cancel() + heartbeatJob = null + readySignal?.cancel() + readySignal = null + socket?.close(1000, "Flow Discord presence closed") + socket = null + sequence = null + } + + private fun fail(message: String): DiscordLinkResult.Failure { + _connectionState.value = DiscordConnectionState.ERROR + _lastError.value = message + return DiscordLinkResult.Failure(message) + } + + private fun failFromCallback(message: String) { + val failure = fail(message) + readySignal?.complete(failure) + } + + private companion object { + val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType() + val LOGIN_TIMEOUT_MS = TimeUnit.MINUTES.toMillis(5) + val CONNECTION_TIMEOUT_MS = TimeUnit.SECONDS.toMillis(20) + } +} diff --git a/app/src/github/java/io/github/aedev/flow/discord/KizzyGatewayProtocol.kt b/app/src/github/java/io/github/aedev/flow/discord/KizzyGatewayProtocol.kt new file mode 100644 index 00000000..e7e3c302 --- /dev/null +++ b/app/src/github/java/io/github/aedev/flow/discord/KizzyGatewayProtocol.kt @@ -0,0 +1,79 @@ +package io.github.aedev.flow.discord + +import org.json.JSONArray +import org.json.JSONObject + +internal object KizzyGatewayProtocol { + const val GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json" + + fun identify(token: String): String = JSONObject() + .put("op", 2) + .put( + "d", + JSONObject() + .put("token", token) + .put("capabilities", 65) + .put("compress", false) + .put("large_threshold", 100) + .put( + "properties", + JSONObject() + .put("os", "Android") + .put("browser", "Discord Android") + .put("device", "Flow"), + ), + ) + .toString() + + fun heartbeat(sequence: Int?): String = JSONObject() + .put("op", 1) + .put("d", sequence ?: JSONObject.NULL) + .toString() + + fun presence( + payload: DiscordPresencePayload?, + applicationId: String, + resolvedImage: String?, + ): String { + val activities = JSONArray() + if (payload != null) { + val activity = JSONObject() + .put("name", "Flow") + .put("type", if (payload.type == DiscordActivityType.LISTENING) 2 else 3) + .put("details", payload.details) + .put("state", payload.state) + .put("application_id", applicationId) + + if (payload.startTimestampSeconds != null || payload.endTimestampSeconds != null) { + activity.put( + "timestamps", + JSONObject().apply { + payload.startTimestampSeconds?.let { put("start", it * 1_000L) } + payload.endTimestampSeconds?.let { put("end", it * 1_000L) } + }, + ) + } + if (!resolvedImage.isNullOrBlank()) { + activity.put( + "assets", + JSONObject() + .put("large_image", resolvedImage) + .put("large_text", payload.largeImageText), + ) + } + activities.put(activity) + } + + return JSONObject() + .put("op", 3) + .put( + "d", + JSONObject() + .put("since", 0) + .put("activities", activities) + .put("status", "online") + .put("afk", false), + ) + .toString() + } +} diff --git a/app/src/main/java/io/github/aedev/flow/FlowApplication.kt b/app/src/main/java/io/github/aedev/flow/FlowApplication.kt index f35f93be..7864ff6a 100644 --- a/app/src/main/java/io/github/aedev/flow/FlowApplication.kt +++ b/app/src/main/java/io/github/aedev/flow/FlowApplication.kt @@ -29,6 +29,7 @@ import io.github.aedev.flow.innertube.YouTube import io.github.aedev.flow.innertube.pages.NewPipeExtractor import io.github.aedev.flow.utils.AppLanguageManager import io.github.aedev.flow.utils.potoken.NewPipePoTokenProvider +import io.github.aedev.flow.discord.DiscordPresenceRuntime import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -66,6 +67,8 @@ class FlowApplication : Application(), ImageLoaderFactory { super.onCreate() appContext = applicationContext + DiscordPresenceRuntime.initialize(this, okHttpClient) + val playerPreferences = PlayerPreferences(this) // Injects modern TLS/SSL certificates so OkHttp and Ktor don't crash @@ -245,6 +248,7 @@ class FlowApplication : Application(), ImageLoaderFactory { } override fun onTerminate() { + DiscordPresenceRuntime.shutdown() super.onTerminate() // Clean up performance dispatcher resources PerformanceDispatcher.shutdown() diff --git a/app/src/main/java/io/github/aedev/flow/MainActivity.kt b/app/src/main/java/io/github/aedev/flow/MainActivity.kt index feb4d15e..420cd281 100644 --- a/app/src/main/java/io/github/aedev/flow/MainActivity.kt +++ b/app/src/main/java/io/github/aedev/flow/MainActivity.kt @@ -51,6 +51,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import io.github.aedev.flow.utils.AppLanguageManager import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import io.github.aedev.flow.discord.DiscordPresenceRuntime @AndroidEntryPoint class MainActivity : ComponentActivity() { @@ -114,6 +115,7 @@ class MainActivity : ComponentActivity() { installSplashScreen() super.onCreate(savedInstanceState) + DiscordPresenceRuntime.attachActivity(this) window.setSoftInputMode(android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING) @@ -395,6 +397,7 @@ class MainActivity : ComponentActivity() { override fun onDestroy() { videoLifecycleLog("onDestroy") + DiscordPresenceRuntime.detachActivity(this) val playerManager = io.github.aedev.flow.player.EnhancedPlayerManager.getInstance() val playerState = playerManager.playerState.value val shouldKeepBackgroundPlayback = diff --git a/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceCoordinator.kt b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceCoordinator.kt index 225ad808..25a63c36 100644 --- a/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceCoordinator.kt +++ b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceCoordinator.kt @@ -45,8 +45,7 @@ class DiscordPresenceCoordinator( } private suspend fun clearPresence() { - if (transport.clear()) { - lastSent = null - } + transport.clear() + lastSent = null } } diff --git a/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceRuntime.kt b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceRuntime.kt new file mode 100644 index 00000000..18e1e007 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceRuntime.kt @@ -0,0 +1,164 @@ +package io.github.aedev.flow.discord + +import android.app.Activity +import android.content.Context +import android.os.SystemClock +import java.lang.ref.WeakReference +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient + +object DiscordPresenceRuntime { + private var scope: CoroutineScope? = null + private var preferences: DiscordPreferences? = null + private var tokenStore: DiscordTokenStore? = null + private var transport: DiscordPresenceTransport? = null + private var attachedActivity = WeakReference(null) + + private val unavailableState = DiscordSettingsState( + isAvailable = false, + isEnabled = false, + canEnable = false, + connectionState = DiscordConnectionState.UNAVAILABLE, + summary = DiscordSettingsSummary.UNAVAILABLE, + accountName = null, + errorMessage = "Discord Rich Presence has not initialized.", + ) + private val _settingsState = MutableStateFlow(unavailableState) + val settingsState: StateFlow = _settingsState + + @Synchronized + fun initialize(context: Context, okHttpClient: OkHttpClient) { + if (scope != null) return + val applicationContext = context.applicationContext + val runtimeScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val runtimePreferences = DiscordPreferences(applicationContext) + val runtimeTokenStore = DiscordTokenStore(applicationContext) + val runtimeTransport = DiscordPlatformTransportFactory().create( + context = applicationContext, + okHttpClient = okHttpClient, + tokenStore = runtimeTokenStore, + ) + + scope = runtimeScope + preferences = runtimePreferences + tokenStore = runtimeTokenStore + transport = runtimeTransport + + val state = combine( + runtimePreferences.enabled, + runtimePreferences.linkedAccountLabel, + runtimeTransport.connectionState, + runtimeTransport.linkedAccountName, + runtimeTransport.lastError, + ) { enabled, savedAccount, connection, liveAccount, error -> + deriveDiscordSettingsState( + preferenceEnabled = enabled, + transportAvailable = runtimeTransport.isAvailable, + connectionState = connection, + accountName = liveAccount ?: savedAccount, + errorMessage = error, + ) + }.stateIn(runtimeScope, SharingStarted.Eagerly, unavailableState) + + runtimeScope.launch { state.collect { _settingsState.value = it } } + runtimeScope.launch { + runtimeTransport.linkedAccountName.collect { accountName -> + if (!accountName.isNullOrBlank()) { + runtimePreferences.setLinkedAccountLabel(accountName) + } + } + } + runtimeScope.launch { + runtimePreferences.enabled.collect { enabled -> + if (!enabled) { + runtimeTransport.clear() + } else { + runtimeTokenStore.load()?.let { runtimeTransport.connect(it) } + } + } + } + runtimeScope.launch { + DiscordPresenceCoordinator( + enabled = runtimePreferences.enabled, + playback = DiscordPlaybackSource().playback, + transport = runtimeTransport, + nowElapsedMs = SystemClock::elapsedRealtime, + ).run() + } + } + + fun attachActivity(activity: Activity?) { + attachedActivity = WeakReference(activity) + transport?.attachActivity(activity) + } + + fun detachActivity(activity: Activity) { + if (attachedActivity.get() === activity) { + attachedActivity.clear() + transport?.attachActivity(null) + } + } + + suspend fun setEnabled(enabled: Boolean) { + val currentTransport = transport ?: return + val currentPreferences = preferences ?: return + if (!currentTransport.isAvailable) { + currentPreferences.setEnabled(false) + return + } + + currentPreferences.setEnabled(enabled) + if (!enabled) { + currentTransport.clear() + } + } + + suspend fun connectAccount(): DiscordLinkResult { + val currentTransport = transport + ?: return DiscordLinkResult.Failure("Discord Rich Presence has not initialized.") + val result = currentTransport.link() + if (result == DiscordLinkResult.Success) { + currentTransport.linkedAccountName.value?.let { accountName -> + preferences?.setLinkedAccountLabel(accountName) + } + } + return result + } + + suspend fun retry(): DiscordLinkResult { + val currentTransport = transport + ?: return DiscordLinkResult.Failure("Discord Rich Presence has not initialized.") + val tokens = tokenStore?.load() + ?: return DiscordLinkResult.Failure("Connect your Discord account first.") + return currentTransport.connect(tokens) + } + + suspend fun unlink(): Boolean { + preferences?.setEnabled(false) + val result = transport?.unlink() ?: false + preferences?.setLinkedAccountLabel(null) + return result + } + + fun shutdown() { + val currentTransport = transport + scope?.launch { currentTransport?.clear() } + currentTransport?.close() + scope?.cancel() + scope = null + preferences = null + tokenStore = null + transport = null + attachedActivity.clear() + _settingsState.value = unavailableState + } +} diff --git a/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceTransport.kt b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceTransport.kt index aa453d42..c9f12c20 100644 --- a/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceTransport.kt +++ b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceTransport.kt @@ -4,6 +4,7 @@ import android.app.Activity import kotlinx.coroutines.flow.StateFlow interface DiscordPresenceTransport { + val isAvailable: Boolean val connectionState: StateFlow val linkedAccountName: StateFlow val lastError: StateFlow diff --git a/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceTransportFactory.kt b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceTransportFactory.kt new file mode 100644 index 00000000..ccb29571 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceTransportFactory.kt @@ -0,0 +1,12 @@ +package io.github.aedev.flow.discord + +import android.content.Context +import okhttp3.OkHttpClient + +interface DiscordPresenceTransportFactory { + fun create( + context: Context, + okHttpClient: OkHttpClient, + tokenStore: DiscordTokenStore, + ): DiscordPresenceTransport +} diff --git a/app/src/main/java/io/github/aedev/flow/discord/NoOpDiscordPresenceTransport.kt b/app/src/main/java/io/github/aedev/flow/discord/NoOpDiscordPresenceTransport.kt index c13c2514..a4e93e10 100644 --- a/app/src/main/java/io/github/aedev/flow/discord/NoOpDiscordPresenceTransport.kt +++ b/app/src/main/java/io/github/aedev/flow/discord/NoOpDiscordPresenceTransport.kt @@ -8,6 +8,8 @@ import kotlinx.coroutines.flow.asStateFlow class NoOpDiscordPresenceTransport( private val unavailableMessage: String = "Discord Rich Presence is unavailable in this build.", ) : DiscordPresenceTransport { + override val isAvailable: Boolean = false + private val _connectionState = MutableStateFlow(DiscordConnectionState.UNAVAILABLE) override val connectionState: StateFlow = _connectionState.asStateFlow() diff --git a/app/src/test/java/io/github/aedev/flow/discord/DiscordPresenceCoordinatorTest.kt b/app/src/test/java/io/github/aedev/flow/discord/DiscordPresenceCoordinatorTest.kt index 4e6a1fdd..8d58ce08 100644 --- a/app/src/test/java/io/github/aedev/flow/discord/DiscordPresenceCoordinatorTest.kt +++ b/app/src/test/java/io/github/aedev/flow/discord/DiscordPresenceCoordinatorTest.kt @@ -103,11 +103,48 @@ class DiscordPresenceCoordinatorTest { job.cancelAndJoin() } + @Test + fun `failed transport update is retried`() = runTest { + val snapshot = PlaybackSnapshot( + kind = PlaybackKind.VIDEO, + mediaId = "video-1", + title = "Video", + subtitle = "Creator", + artworkUrl = "", + durationMs = 60_000L, + positionMs = 1_000L, + isPlaying = true, + isLive = false, + ) + val enabled = MutableStateFlow(true) + val playback = MutableStateFlow(snapshot) + val transport = RecordingTransport().apply { failNextUpdate = true } + val coordinator = DiscordPresenceCoordinator( + enabled = enabled, + playback = playback, + transport = transport, + nowEpochSeconds = { 100L }, + nowElapsedMs = { 10_000L }, + ) + + val job = launch { coordinator.run() } + runCurrent() + playback.value = snapshot.copy(positionMs = 2_000L) + runCurrent() + + assertThat(transport.updateAttempts).isEqualTo(2) + assertThat(transport.updates).hasSize(1) + job.cancelAndJoin() + } + private class RecordingTransport : DiscordPresenceTransport { + override val isAvailable: Boolean = true override val connectionState = MutableStateFlow(DiscordConnectionState.CONNECTED) override val linkedAccountName = MutableStateFlow(null) override val lastError = MutableStateFlow(null) val updates = mutableListOf() + var updateAttempts = 0 + var failNextUpdate = false var clears: Int = 0 override fun attachActivity(activity: Activity?) = Unit @@ -117,6 +154,11 @@ class DiscordPresenceCoordinatorTest { override suspend fun connect(tokens: DiscordAuthTokens): DiscordLinkResult = DiscordLinkResult.Success override suspend fun update(payload: DiscordPresencePayload): Boolean { + updateAttempts += 1 + if (failNextUpdate) { + failNextUpdate = false + return false + } updates += payload return true } diff --git a/app/src/test/java/io/github/aedev/flow/discord/NoOpDiscordPresenceTransportTest.kt b/app/src/test/java/io/github/aedev/flow/discord/NoOpDiscordPresenceTransportTest.kt index 5753abf7..a9414fea 100644 --- a/app/src/test/java/io/github/aedev/flow/discord/NoOpDiscordPresenceTransportTest.kt +++ b/app/src/test/java/io/github/aedev/flow/discord/NoOpDiscordPresenceTransportTest.kt @@ -10,6 +10,7 @@ class NoOpDiscordPresenceTransportTest { fun `no-op transport reports unavailable and rejects updates`() = runTest { val transport = NoOpDiscordPresenceTransport("Not included") + assertThat(transport.isAvailable).isFalse() assertThat(transport.connectionState.value).isEqualTo(DiscordConnectionState.UNAVAILABLE) assertThat(transport.lastError.value).isEqualTo("Not included") assertThat(transport.update(samplePayload())).isFalse() diff --git a/app/src/testGithub/java/io/github/aedev/flow/discord/KizzyGatewayProtocolTest.kt b/app/src/testGithub/java/io/github/aedev/flow/discord/KizzyGatewayProtocolTest.kt new file mode 100644 index 00000000..bcda23cd --- /dev/null +++ b/app/src/testGithub/java/io/github/aedev/flow/discord/KizzyGatewayProtocolTest.kt @@ -0,0 +1,49 @@ +package io.github.aedev.flow.discord + +import com.google.common.truth.Truth.assertThat +import org.json.JSONObject +import org.junit.Test + +class KizzyGatewayProtocolTest { + @Test + fun `identify contains user token without logging or transforming it`() { + val payload = JSONObject(KizzyGatewayProtocol.identify("user-token")) + + assertThat(payload.getInt("op")).isEqualTo(2) + assertThat(payload.getJSONObject("d").getString("token")).isEqualTo("user-token") + } + + @Test + fun `watching activity uses supplied application and millisecond timestamps`() { + val payload = JSONObject( + KizzyGatewayProtocol.presence( + payload = DiscordPresencePayload( + type = DiscordActivityType.WATCHING, + mediaId = "video", + details = "Title", + state = "by Creator", + largeImage = "https://example.com/image.jpg", + largeImageText = "Title", + startTimestampSeconds = 10, + endTimestampSeconds = 20, + ), + applicationId = "123", + resolvedImage = "mp:external/asset", + ), + ) + val activity = payload.getJSONObject("d").getJSONArray("activities").getJSONObject(0) + + assertThat(activity.getInt("type")).isEqualTo(3) + assertThat(activity.getString("application_id")).isEqualTo("123") + assertThat(activity.getJSONObject("timestamps").getLong("start")).isEqualTo(10_000L) + assertThat(activity.getJSONObject("assets").getString("large_image")) + .isEqualTo("mp:external/asset") + } + + @Test + fun `clear presence sends an empty activity list`() { + val payload = JSONObject(KizzyGatewayProtocol.presence(null, "123", null)) + + assertThat(payload.getJSONObject("d").getJSONArray("activities").length()).isEqualTo(0) + } +} From 15a0be96f1b645e4f625799824ee8973fc135241 Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 12:44:13 +0300 Subject: [PATCH 19/74] feat(settings): expose Discord Rich Presence --- .../io/github/aedev/flow/ui/FlowNavigation.kt | 11 +- .../screens/settings/DiscordSettingsScreen.kt | 204 ++++++++++++++++++ .../ui/screens/settings/SettingsScreen.kt | 13 ++ app/src/main/res/values/strings.xml | 19 ++ 4 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/io/github/aedev/flow/ui/screens/settings/DiscordSettingsScreen.kt diff --git a/app/src/main/java/io/github/aedev/flow/ui/FlowNavigation.kt b/app/src/main/java/io/github/aedev/flow/ui/FlowNavigation.kt index 4b697a6c..c7166132 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/FlowNavigation.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/FlowNavigation.kt @@ -337,7 +337,16 @@ fun NavGraphBuilder.flowAppGraph( onNavigateToAutoBackup = { navController.navigate("settings/auto_backup") }, onNavigateToSyncDevices = { navController.navigate("settings/sync_devices") }, onNavigateToExport = { navController.navigate("settings/export") }, - onNavigateToSponsorBlockSettings = { navController.navigate("settings/sponsorblock") } + onNavigateToSponsorBlockSettings = { navController.navigate("settings/sponsorblock") }, + onNavigateToDiscordSettings = { navController.navigate("settings/discord") } + ) + } + + composable("settings/discord") { + currentRoute.value = "settings/discord" + showBottomNav.value = false + io.github.aedev.flow.ui.screens.settings.DiscordSettingsScreen( + onNavigateBack = { navController.popBackStack() } ) } diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/DiscordSettingsScreen.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/DiscordSettingsScreen.kt new file mode 100644 index 00000000..b15b6d22 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/DiscordSettingsScreen.kt @@ -0,0 +1,204 @@ +package io.github.aedev.flow.ui.screens.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.weight +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.outlined.Link +import androidx.compose.material.icons.outlined.LinkOff +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.Security +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.github.aedev.flow.R +import io.github.aedev.flow.discord.DiscordConnectionState +import io.github.aedev.flow.discord.DiscordPresenceRuntime +import io.github.aedev.flow.discord.DiscordSettingsState +import io.github.aedev.flow.discord.DiscordSettingsSummary +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DiscordSettingsScreen(onNavigateBack: () -> Unit) { + val state by DiscordPresenceRuntime.settingsState.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.discord_presence_title)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back)) + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f), + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + text = stringResource(R.string.discord_presence_enable), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = statusText(state), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = state.isEnabled, + enabled = state.canEnable, + onCheckedChange = { enabled -> + scope.launch { DiscordPresenceRuntime.setEnabled(enabled) } + }, + ) + } + } + + state.accountName?.let { accountName -> + Text( + text = stringResource(R.string.discord_presence_connected_account, accountName), + style = MaterialTheme.typography.bodyLarge, + ) + } + + state.errorMessage?.takeIf { + state.summary == DiscordSettingsSummary.ERROR || !state.isAvailable + }?.let { error -> + Text( + text = error, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } + + if (state.isAvailable) { + if (state.accountName == null) { + Button( + onClick = { scope.launch { DiscordPresenceRuntime.connectAccount() } }, + enabled = state.connectionState !in setOf( + DiscordConnectionState.LINKING, + DiscordConnectionState.CONNECTING, + ), + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Outlined.Link, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.discord_presence_connect)) + } + } else { + OutlinedButton( + onClick = { scope.launch { DiscordPresenceRuntime.unlink() } }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Outlined.LinkOff, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.discord_presence_disconnect)) + } + } + + if (state.summary == DiscordSettingsSummary.ERROR) { + OutlinedButton( + onClick = { scope.launch { DiscordPresenceRuntime.retry() } }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Outlined.Refresh, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.discord_presence_retry)) + } + } + } + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.45f), + ), + ) { + Column(Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Outlined.Security, contentDescription = null) + Text( + text = stringResource(R.string.discord_presence_privacy_title), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(start = 8.dp), + ) + } + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.discord_presence_privacy_body), + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.discord_presence_gateway_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } +} + +@Composable +fun discordSettingsSummaryText(state: DiscordSettingsState): String = when (state.summary) { + DiscordSettingsSummary.OFF -> stringResource(R.string.discord_presence_off) + DiscordSettingsSummary.NOT_CONNECTED -> stringResource(R.string.discord_presence_not_connected) + DiscordSettingsSummary.CONNECTED -> state.accountName?.let { account -> + stringResource(R.string.discord_presence_connected_as, account) + } ?: stringResource(R.string.discord_presence_connected) + DiscordSettingsSummary.UNAVAILABLE -> stringResource(R.string.discord_presence_unavailable) + DiscordSettingsSummary.ERROR -> stringResource(R.string.discord_presence_connection_error) +} + +@Composable +private fun statusText(state: DiscordSettingsState): String = when (state.connectionState) { + DiscordConnectionState.LINKING -> stringResource(R.string.discord_presence_linking) + DiscordConnectionState.CONNECTING -> stringResource(R.string.discord_presence_connecting) + else -> discordSettingsSummaryText(state) +} diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/SettingsScreen.kt index d20c58e5..8b9e7eb9 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/SettingsScreen.kt @@ -60,6 +60,8 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardActions import androidx.compose.ui.text.input.ImeAction +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.github.aedev.flow.discord.DiscordPresenceRuntime @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) @Composable @@ -90,6 +92,7 @@ fun SettingsScreen( onNavigateToSyncDevices: () -> Unit, onNavigateToExport: () -> Unit, onNavigateToSponsorBlockSettings: () -> Unit, + onNavigateToDiscordSettings: () -> Unit, modifier: Modifier = Modifier ) { val context = LocalContext.current @@ -116,6 +119,8 @@ fun SettingsScreen( // Player preferences states val currentRegion by playerPreferences.trendingRegion.collectAsState(initial = "US") val currentAppLanguage by playerPreferences.appLanguage.collectAsState(initial = AppLanguageManager.SYSTEM_DEFAULT) + val discordSettingsState by DiscordPresenceRuntime.settingsState.collectAsStateWithLifecycle() + val discordSettingsSummary = discordSettingsSummaryText(discordSettingsState) // Deep Flow state val deepFlowActive by playerPreferences.deepFlowActive.collectAsState(initial = false) @@ -235,6 +240,7 @@ fun SettingsScreen( SettingSearchEntry(Icons.Outlined.Schedule, androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_datetime), androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_datetime_subtitle), secAppearance, onNavigateToDateTimeSettings), SettingSearchEntry(Icons.Outlined.FilterAlt, androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_content_prefs), androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_content_prefs_subtitle), secContentPlayback, onNavigateToUserPreferences), SettingSearchEntry(Icons.Outlined.PlayCircle, androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_player), androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_player_subtitle), secContentPlayback, onNavigateToPlayerSettings), + SettingSearchEntry(Icons.Outlined.Share, androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.discord_presence_title), discordSettingsSummary, secContentPlayback, onNavigateToDiscordSettings), SettingSearchEntry(Icons.Outlined.Public, androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_proxy), androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_proxy_subtitle), secContentPlayback, onNavigateToProxySettings), SettingSearchEntry(io.github.aedev.flow.R.drawable.ic_block, androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.sb_settings_title), androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.sb_settings_subtitle), secContentPlayback, onNavigateToSponsorBlockSettings), SettingSearchEntry(Icons.Outlined.HighQuality, androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_quality), androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_quality_subtitle), secContentPlayback, onNavigateToVideoQuality), @@ -770,6 +776,13 @@ item { onClick = onNavigateToPlayerSettings ) HorizontalDivider(Modifier.padding(start = 56.dp), color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + SettingsItem( + icon = Icons.Outlined.Share, + title = androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.discord_presence_title), + subtitle = discordSettingsSummary, + onClick = onNavigateToDiscordSettings + ) + HorizontalDivider(Modifier.padding(start = 56.dp), color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) SettingsItem( icon = Icons.Outlined.Public, title = androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_proxy), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a6d99648..5195ba6f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1999,4 +1999,23 @@ Clear history? This removes all recognized songs. This cannot be undone. Close popup player + + + Discord Rich Presence + Share playback activity + Off + Not connected + Connected + Connected as %1$s + Discord account: %1$s + Unavailable in this build + Connection error + Waiting for Discord sign-in… + Connecting to Discord… + Connect Discord account + Disconnect and unlink + Retry connection + Privacy + When enabled, Flow shares the playing video, Short, live stream, or song title, creator or artist, artwork, and playback timing with Discord. The setting is off by default. Your Discord session token is encrypted with Android Keystore and never added to app logs. + This build uses the unofficial Kizzy-style Discord Gateway method and your Discord user session, not OAuth. Discord prohibits automated user accounts and may restrict or terminate accounts that use unsupported clients. Continue only if you accept that risk. From b91db137ccebe129b77755cbc3d476f0841783c8 Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 12:44:24 +0300 Subject: [PATCH 20/74] ci(discord): verify nightly integration --- .github/workflows/build.yml | 32 +++++++++--- docs/discord-rich-presence.md | 50 +++++++++++++++++++ .../plans/2026-07-14-discord-rich-presence.md | 24 ++++----- ...2026-07-14-discord-rich-presence-design.md | 20 ++++---- 4 files changed, 98 insertions(+), 28 deletions(-) create mode 100644 docs/discord-rich-presence.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4ca68a1e..15ea5194 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,7 +2,7 @@ name: Android CI/CD on: push: - branches: [ main, master, develop ] + branches: [ main, master, develop, agent/discord-rich-presence ] tags: - 'v*' pull_request: @@ -49,6 +49,16 @@ jobs: echo "Release keystore secret is not available. Skipping decoding." fi + - name: Run unit tests + run: > + ./gradlew :app:testGithubNightlyUnitTest :app:testFossReleaseUnitTest + --parallel --build-cache --no-daemon --stacktrace + + - name: Lint GitHub nightly + run: > + ./gradlew :app:lintGithubNightly + --build-cache --no-daemon --stacktrace + # Build all variants in a single Gradle invocation so tasks share the # configuration phase, classpath resolution and incremental build cache. # --parallel — lets Gradle run independent subproject tasks concurrently. @@ -63,18 +73,27 @@ jobs: KEY_ALIAS: ${{ secrets.KEY_ALIAS }} KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + - name: Verify Discord nightly APK + run: | + test -f app/build/outputs/apk/github/nightly/app-github-nightly.apk + sha256sum app/build/outputs/apk/github/nightly/app-github-nightly.apk | tee app/build/outputs/apk/github/nightly/app-github-nightly.apk.sha256 + - name: Upload Release APK uses: actions/upload-artifact@v4 with: name: flow-release-apk path: app/build/outputs/apk/github/release/app-github-release.apk + if-no-files-found: error retention-days: 14 - name: Upload Nightly APK uses: actions/upload-artifact@v4 with: - name: flow-nightly-apk - path: app/build/outputs/apk/github/nightly/app-github-nightly.apk + name: flow-discord-rpc-nightly-apk + path: | + app/build/outputs/apk/github/nightly/app-github-nightly.apk + app/build/outputs/apk/github/nightly/app-github-nightly.apk.sha256 + if-no-files-found: error retention-days: 14 - name: Upload FOSS Release APK @@ -82,6 +101,7 @@ jobs: with: name: flow-foss-release-apk path: app/build/outputs/apk/foss/release/app-foss-release.apk + if-no-files-found: error retention-days: 14 release: @@ -104,7 +124,7 @@ jobs: - name: Download Nightly APK uses: actions/download-artifact@v4 with: - name: flow-nightly-apk + name: flow-discord-rpc-nightly-apk path: dist/ - name: Download FOSS Release APK @@ -124,7 +144,7 @@ jobs: run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT - name: Create Release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.get_version.outputs.VERSION }} name: Flow ${{ steps.get_version.outputs.VERSION }} @@ -265,4 +285,4 @@ jobs: draft: false prerelease: false env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/docs/discord-rich-presence.md b/docs/discord-rich-presence.md new file mode 100644 index 00000000..16aac502 --- /dev/null +++ b/docs/discord-rich-presence.md @@ -0,0 +1,50 @@ +# Discord Rich Presence + +## Behavior and privacy + +Discord Rich Presence is an opt-in feature under **Settings → Content & Playback → Discord Rich Presence**. It is disabled by default. When enabled and connected, Flow shares the active media title, creator or artist, thumbnail, activity type, and playback timestamps. Live streams omit finite timestamps. Presence is cleared on pause, stop, disable, unlink, and shutdown. + +The GitHub flavor uses a Kizzy-style direct Discord Gateway connection. This is not Discord's official Social SDK or OAuth. Connecting signs in through a private in-app WebView and stores the resulting Discord user-session token in an AES-GCM file whose key is held by Android Keystore. Tokens are never placed in DataStore, BuildConfig, source control, or logs. + +Discord prohibits automating normal user accounts and warns that self-bot behavior may lead to account termination. Users must accept this risk before using the feature. See [Discord's automated user account policy](https://support.discord.com/hc/en-us/articles/115002192352-Automated-User-Accounts-Self-Bots) and the [Kizzy repository](https://github.com/dead8309/Kizzy). + +## Build flavors and configuration + +- `github`: includes the real Gateway transport and account connection screen. The public Discord application ID is `1526515771021328514`. +- `foss`: contains no Gateway transport and performs no Discord network operations. The settings row remains visible and reports **Unavailable in this build**. + +No Discord client secret, OAuth redirect, private AAR, Gradle property, or GitHub Actions secret is required for the Kizzy-style transport. Release signing still uses the repository's existing optional `STORE_PASSWORD`, `KEY_ALIAS`, `KEY_PASSWORD`, and `RELEASE_KEYSTORE_BASE64` secrets. + +## Build and test + +Use JDK 17 and run: + +```bash +./gradlew :app:testGithubNightlyUnitTest +./gradlew :app:lintGithubNightly +./gradlew :app:assembleGithubNightly +./gradlew :app:testFossReleaseUnitTest +./gradlew :app:assembleFossRelease +``` + +The nightly APK is generated at `app/build/outputs/apk/github/nightly/app-github-nightly.apk`. GitHub Actions uploads it as `flow-discord-rpc-nightly-apk` with a SHA-256 checksum file. + +## Manual verification + +1. Install the GitHub nightly APK on Android 8.0 or newer. +2. Open the Discord Rich Presence screen and confirm the switch is off. +3. Select **Connect Discord account**, complete sign-in, and confirm the account name appears. +4. Enable sharing and play a regular video, Short, live stream, and music track. +5. Test pause, seek, resume, media switching, stop, disable, and unlink. Confirm Discord does not retain stale media. +6. Install the FOSS build and confirm the row reports unavailable and cannot be enabled. + +## Troubleshooting and limitations + +- **Sign-in never completes:** Discord may block or change embedded WebView login. Update Android System WebView, cancel, and retry. +- **Connection error:** Retry from the settings screen. If Discord invalidated the session, unlink and connect again. +- **Artwork missing:** Discord's external-asset endpoint may reject or delay a YouTube thumbnail; text and timestamps still update. +- **Presence disappears in the background:** Android may stop the process or network connection. Flow reconnects from the encrypted saved session only while the feature remains enabled. +- **Account risk:** The Gateway user-session method is unsupported by Discord. The only policy-safe direct Android alternative is Discord's private official Social SDK distribution. +- Discord can change Gateway payloads, WebView login, or external assets without notice. + +Kizzy and Flow are licensed under GPL-3.0. The transport is a Flow-specific implementation inspired by Kizzy's Gateway design; Kizzy source files are not vendored. diff --git a/docs/superpowers/plans/2026-07-14-discord-rich-presence.md b/docs/superpowers/plans/2026-07-14-discord-rich-presence.md index 886ac80d..e64d755b 100644 --- a/docs/superpowers/plans/2026-07-14-discord-rich-presence.md +++ b/docs/superpowers/plans/2026-07-14-discord-rich-presence.md @@ -2,18 +2,18 @@ > **For agentic workers:** Execute inline in red-green-refactor order; do not dispatch subagents. -**Goal:** Build the complete settings, lifecycle, playback, flavor, and official Discord Social SDK integration path. +**Goal:** Build the complete settings, lifecycle, playback, flavor, and Kizzy-style Discord Gateway integration path. -**Architecture:** Common Kotlin code owns preferences, state, playback selection, and orchestration behind a transport seam. Flavor source sets provide either a fail-closed adapter or a GitHub C++/JNI adapter linked to the owner-supplied official AAR. +**Architecture:** Common Kotlin code owns preferences, state, playback selection, and orchestration behind a transport seam. Flavor source sets provide either a fail-closed adapter or a GitHub Gateway adapter using the existing OkHttp dependency. -**Tech Stack:** Kotlin, Compose Material 3, DataStore Preferences, coroutines/Flow, Media3, C++20/JNI, Gradle product flavors, GitHub Actions. +**Tech Stack:** Kotlin, Compose Material 3, DataStore Preferences, coroutines/Flow, Media3, OkHttp WebSocket, Gradle product flavors, GitHub Actions. ## Global Constraints - Default disabled and never misleading when unavailable. -- Never commit the Discord SDK AAR, tokens, secrets, keystores, or signing credentials. +- Never commit Discord session tokens, secrets, keystores, or signing credentials. - FOSS compiles without proprietary artifacts or Discord operations. -- Use only official Discord Social SDK API names verified from Discord's current documentation. +- Disclose that the selected Gateway user-session method is unofficial and carries account risk. - Preserve existing player architecture and throttle position sampling to five seconds. --- @@ -57,16 +57,16 @@ - [ ] Compile the GitHub and FOSS Kotlin variants. - [ ] Commit `feat(settings): expose Discord Rich Presence`. -### Task 5: Flavor factories and official adapter +### Task 5: Flavor factories and Gateway adapter -**Files:** flavor factories, GitHub adapter/bridge, CMake, Gradle, manifests, adapter tests. +**Files:** flavor factories, GitHub Gateway adapter, Gradle, manifests, adapter tests. - [ ] Add failing fake-bridge adapter tests for link/connect/update/clear/unlink/error paths. - [ ] Run and observe red. -- [ ] Implement the FOSS factory and GitHub native adapter using documented SDK APIs. -- [ ] Configure optional private AAR/Prefab and deep linking without leaking it into FOSS. +- [ ] Implement the FOSS factory and GitHub Kizzy-style Gateway adapter. +- [ ] Keep WebView linking and Gateway operations out of the FOSS source set. - [ ] Run adapter tests and flavor builds. -- [ ] Commit `feat(discord): add official Social SDK transport`. +- [ ] Commit `feat(discord): add Kizzy-style Gateway transport`. ### Task 6: Application and Activity wiring @@ -82,8 +82,8 @@ **Files:** `.github/workflows/build.yml`, `docs/discord-rich-presence.md`, `.gitignore`. -- [ ] Make CI decode optional owner-supplied SDK material, test before packaging, verify APK existence, calculate SHA-256, and upload `flow-discord-rpc-nightly-apk`. -- [ ] Document setup, privacy, build, test, limitations, and troubleshooting with official links. +- [ ] Make CI test before packaging, verify APK existence, calculate SHA-256, and upload `flow-discord-rpc-nightly-apk`. +- [ ] Document privacy, account risk, build, test, limitations, and troubleshooting. - [ ] Run all requested unit, lint, and assemble task equivalents. - [ ] Inspect APK contents, manifest, commit, artifact, and digest. - [ ] Review the full `main...HEAD` diff for leaks, races, stale state, flavor leakage, and misleading UI. diff --git a/docs/superpowers/specs/2026-07-14-discord-rich-presence-design.md b/docs/superpowers/specs/2026-07-14-discord-rich-presence-design.md index c46b1431..275ed6ec 100644 --- a/docs/superpowers/specs/2026-07-14-discord-rich-presence-design.md +++ b/docs/superpowers/specs/2026-07-14-discord-rich-presence-design.md @@ -2,32 +2,32 @@ ## Goal -Expose an opt-in Discord Rich Presence setting and publish current Flow playback through Discord's official Social SDK on supported GitHub builds, while keeping FOSS builds SDK-free and honest about unavailability. +Expose an opt-in Discord Rich Presence setting and publish current Flow playback through a Kizzy-style Discord Gateway transport in GitHub builds, while keeping FOSS builds network-free and honest about unavailability. ## Architecture - `DiscordPreferences` owns only the enabled flag and the last non-sensitive account label. - `DiscordPlaybackSource` converts the existing video, Shorts, and music managers into immutable snapshots. Active playback priority is Shorts, then regular video/live, then music. -- `DiscordPresenceService` owns transport connection, token storage, coordinator lifetime, Activity attachment, enable/disable, link, retry, unlink, and shutdown. -- Common code depends on `DiscordPresenceTransport`. Each flavor supplies `DiscordPresenceTransportFactory`: FOSS always returns the fail-closed adapter; GitHub returns the official adapter only when both an application ID and the private Social SDK AAR were supplied at build time. -- The GitHub adapter calls a small C++20 JNI bridge linked through the official AAR Prefab package. The bridge uses only documented Social SDK APIs and runs `discordpp::RunCallbacks` on a bounded callback thread. +- `DiscordPresenceRuntime` owns transport connection, token storage, coordinator lifetime, Activity attachment, enable/disable, link, retry, unlink, and shutdown. +- Common code depends on `DiscordPresenceTransport`. Each flavor supplies `DiscordPresenceTransportFactory`: FOSS returns the fail-closed adapter; GitHub returns the Gateway adapter. +- The GitHub adapter implements Gateway identify, heartbeat, presence update, clearing, account state, and external-image resolution behind the transport seam. It is inspired by Kizzy without vendoring Kizzy source files. ## User experience -The row is indexed in Settings search and appears in Content & Playback for both flavors. Its subtitle is derived from availability, enabled state, connection state, account label, and errors. The dedicated screen contains the opt-in switch, status, connect/retry or disconnect action, account label, and playback-data privacy copy. Enabling is rejected when the SDK is unavailable or no account is linked. +The row is indexed in Settings search and appears in Content & Playback for both flavors. Its subtitle is derived from availability, enabled state, connection state, account label, and errors. The dedicated screen contains the opt-in switch, status, connect/retry or disconnect action, account label, playback-data privacy copy, and an explicit unsupported-client warning. Enabling is rejected when the transport is unavailable. ## Playback and lifecycle -Snapshots are sampled at five-second intervals while enabled and also react immediately to player and metadata changes. This retains the existing policy's deduplication and seek correction without high-frequency SDK calls. Pause, stop, media switches, disable, unlink, and shutdown clear presence. `MainActivity` is held weakly by the service and attached to the SDK only between `onStart` and `onStop`. +Snapshots are sampled at five-second intervals and also react immediately to player and metadata changes. This retains the existing policy's deduplication and seek correction without high-frequency Gateway calls. Pause, stop, media switches, disable, unlink, and shutdown clear presence. `MainActivity` is held weakly by the runtime and detached on destruction. -## Official SDK requirements +## Gateway requirements and risk -Android 7.0+ is supported. Mobile Rich Presence requires account linking. Linking uses SDK 1.5+ OAuth2 PKCE with `openid sdk.social_layer_presence`, a public-client Discord application, and the `discord-:/authorize/callback` deep link. The SDK is a private Developer Portal download named `discord_partner_sdk.aar`, not a public Maven dependency. The adapter uses `Authorize`, `GetToken`, `RefreshToken`, `UpdateToken`, `Connect`, `FetchCurrentUser`, `RevokeToken`, `UpdateRichPresence`, `ClearRichPresence`, `Disconnect`, and `RunCallbacks`. +The public Discord application ID is compiled into the GitHub flavor; no client secret or private artifact is used. Linking uses an embedded Discord login and a Discord user-session token encrypted with Android Keystore. This is not OAuth or an official Discord-supported mobile integration. Discord prohibits self-bots and may restrict or terminate accounts, so the risk is disclosed in the UI. ## Failure behavior -Missing AAR or application ID produces `Unavailable in this build`; it never presents an enabled state. Transport failures preserve the opt-in preference only when retry is meaningful, expose a concise error, and clear stale presence. Invalid stored tokens are deleted and return the UI to Not connected. +The FOSS adapter produces `Unavailable in this build`; it never presents an enabled state. Transport failures preserve the opt-in preference when retry is meaningful, expose a concise error, and clear stale presence. Unlink deletes the encrypted session and non-sensitive account label. ## Verification -Focused JVM tests cover preferences, settings derivation, snapshot selection/mapping, policy, coordinator clearing/failures, FOSS behavior, and the SDK adapter through a fake bridge. GitHub Actions runs unit tests before lint and packaging, uploads the exact nightly APK with missing-file errors enabled, and records the APK digest. +Focused JVM tests cover preferences, settings derivation, snapshot selection/mapping, policy, coordinator clearing/failures, FOSS behavior, and Gateway payload behavior. GitHub Actions runs unit tests before lint and packaging, uploads the exact nightly APK with missing-file errors enabled, and records the APK digest. From 76c736b4f3ffec97d959b765caa566c68506ceab Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 12:52:58 +0300 Subject: [PATCH 21/74] fix(settings): compile Discord presence screen --- .../aedev/flow/ui/screens/settings/DiscordSettingsScreen.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/DiscordSettingsScreen.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/DiscordSettingsScreen.kt index b15b6d22..8772bb4b 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/DiscordSettingsScreen.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/DiscordSettingsScreen.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.weight import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack From 22fa9fb5f62fed589cfaad998ea970cb21b9f0cd Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 13:04:52 +0300 Subject: [PATCH 22/74] fix(discord): make gateway protocol JVM-testable --- .../flow/discord/KizzyGatewayProtocol.kt | 115 ++++++++++-------- .../flow/discord/KizzyGatewayProtocolTest.kt | 34 ++++-- 2 files changed, 83 insertions(+), 66 deletions(-) diff --git a/app/src/github/java/io/github/aedev/flow/discord/KizzyGatewayProtocol.kt b/app/src/github/java/io/github/aedev/flow/discord/KizzyGatewayProtocol.kt index e7e3c302..b5be3ac7 100644 --- a/app/src/github/java/io/github/aedev/flow/discord/KizzyGatewayProtocol.kt +++ b/app/src/github/java/io/github/aedev/flow/discord/KizzyGatewayProtocol.kt @@ -1,79 +1,88 @@ package io.github.aedev.flow.discord -import org.json.JSONArray -import org.json.JSONObject +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put internal object KizzyGatewayProtocol { const val GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json" - fun identify(token: String): String = JSONObject() - .put("op", 2) - .put( + fun identify(token: String): String = buildJsonObject { + put("op", 2) + put( "d", - JSONObject() - .put("token", token) - .put("capabilities", 65) - .put("compress", false) - .put("large_threshold", 100) - .put( + buildJsonObject { + put("token", token) + put("capabilities", 65) + put("compress", false) + put("large_threshold", 100) + put( "properties", - JSONObject() - .put("os", "Android") - .put("browser", "Discord Android") - .put("device", "Flow"), - ), + buildJsonObject { + put("os", "Android") + put("browser", "Discord Android") + put("device", "Flow") + }, + ) + }, ) - .toString() + }.toString() - fun heartbeat(sequence: Int?): String = JSONObject() - .put("op", 1) - .put("d", sequence ?: JSONObject.NULL) - .toString() + fun heartbeat(sequence: Int?): String = buildJsonObject { + put("op", 1) + if (sequence == null) put("d", JsonNull) else put("d", sequence) + }.toString() fun presence( payload: DiscordPresencePayload?, applicationId: String, resolvedImage: String?, ): String { - val activities = JSONArray() - if (payload != null) { - val activity = JSONObject() - .put("name", "Flow") - .put("type", if (payload.type == DiscordActivityType.LISTENING) 2 else 3) - .put("details", payload.details) - .put("state", payload.state) - .put("application_id", applicationId) + val activities = buildJsonArray { + if (payload != null) { + add( + buildJsonObject { + put("name", "Flow") + put("type", if (payload.type == DiscordActivityType.LISTENING) 2 else 3) + put("details", payload.details) + put("state", payload.state) + put("application_id", applicationId) - if (payload.startTimestampSeconds != null || payload.endTimestampSeconds != null) { - activity.put( - "timestamps", - JSONObject().apply { - payload.startTimestampSeconds?.let { put("start", it * 1_000L) } - payload.endTimestampSeconds?.let { put("end", it * 1_000L) } + if (payload.startTimestampSeconds != null || payload.endTimestampSeconds != null) { + put( + "timestamps", + buildJsonObject { + payload.startTimestampSeconds?.let { put("start", it * 1_000L) } + payload.endTimestampSeconds?.let { put("end", it * 1_000L) } + }, + ) + } + if (!resolvedImage.isNullOrBlank()) { + put( + "assets", + buildJsonObject { + put("large_image", resolvedImage) + put("large_text", payload.largeImageText) + }, + ) + } }, ) } - if (!resolvedImage.isNullOrBlank()) { - activity.put( - "assets", - JSONObject() - .put("large_image", resolvedImage) - .put("large_text", payload.largeImageText), - ) - } - activities.put(activity) } - return JSONObject() - .put("op", 3) - .put( + return buildJsonObject { + put("op", 3) + put( "d", - JSONObject() - .put("since", 0) - .put("activities", activities) - .put("status", "online") - .put("afk", false), + buildJsonObject { + put("since", 0) + put("activities", activities) + put("status", "online") + put("afk", false) + }, ) - .toString() + }.toString() } } diff --git a/app/src/testGithub/java/io/github/aedev/flow/discord/KizzyGatewayProtocolTest.kt b/app/src/testGithub/java/io/github/aedev/flow/discord/KizzyGatewayProtocolTest.kt index bcda23cd..0b265a9c 100644 --- a/app/src/testGithub/java/io/github/aedev/flow/discord/KizzyGatewayProtocolTest.kt +++ b/app/src/testGithub/java/io/github/aedev/flow/discord/KizzyGatewayProtocolTest.kt @@ -1,21 +1,25 @@ package io.github.aedev.flow.discord import com.google.common.truth.Truth.assertThat -import org.json.JSONObject +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import org.junit.Test class KizzyGatewayProtocolTest { @Test fun `identify contains user token without logging or transforming it`() { - val payload = JSONObject(KizzyGatewayProtocol.identify("user-token")) + val payload = Json.parseToJsonElement(KizzyGatewayProtocol.identify("user-token")).jsonObject - assertThat(payload.getInt("op")).isEqualTo(2) - assertThat(payload.getJSONObject("d").getString("token")).isEqualTo("user-token") + assertThat(payload.getValue("op").jsonPrimitive.content.toInt()).isEqualTo(2) + assertThat(payload.getValue("d").jsonObject.getValue("token").jsonPrimitive.content) + .isEqualTo("user-token") } @Test fun `watching activity uses supplied application and millisecond timestamps`() { - val payload = JSONObject( + val payload = Json.parseToJsonElement( KizzyGatewayProtocol.presence( payload = DiscordPresencePayload( type = DiscordActivityType.WATCHING, @@ -30,20 +34,24 @@ class KizzyGatewayProtocolTest { applicationId = "123", resolvedImage = "mp:external/asset", ), - ) - val activity = payload.getJSONObject("d").getJSONArray("activities").getJSONObject(0) + ).jsonObject + val activity = payload.getValue("d").jsonObject + .getValue("activities").jsonArray[0].jsonObject - assertThat(activity.getInt("type")).isEqualTo(3) - assertThat(activity.getString("application_id")).isEqualTo("123") - assertThat(activity.getJSONObject("timestamps").getLong("start")).isEqualTo(10_000L) - assertThat(activity.getJSONObject("assets").getString("large_image")) + assertThat(activity.getValue("type").jsonPrimitive.content.toInt()).isEqualTo(3) + assertThat(activity.getValue("application_id").jsonPrimitive.content).isEqualTo("123") + assertThat(activity.getValue("timestamps").jsonObject.getValue("start").jsonPrimitive.content.toLong()) + .isEqualTo(10_000L) + assertThat(activity.getValue("assets").jsonObject.getValue("large_image").jsonPrimitive.content) .isEqualTo("mp:external/asset") } @Test fun `clear presence sends an empty activity list`() { - val payload = JSONObject(KizzyGatewayProtocol.presence(null, "123", null)) + val payload = Json.parseToJsonElement( + KizzyGatewayProtocol.presence(null, "123", null), + ).jsonObject - assertThat(payload.getJSONObject("d").getJSONArray("activities").length()).isEqualTo(0) + assertThat(payload.getValue("d").jsonObject.getValue("activities").jsonArray).isEmpty() } } From a95cd1361f60788c0be7b41084a9bd3ccdb52f42 Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 13:10:11 +0300 Subject: [PATCH 23/74] fix(discord): harden connection and settings lifecycle --- .../DiscordPlatformTransportFactory.kt | 1 - .../discord/KizzyDiscordPresenceTransport.kt | 19 +++++++++++++++---- .../flow/discord/DiscordPresenceRuntime.kt | 1 - .../flow/discord/DiscordSettingsState.kt | 2 +- .../aedev/flow/discord/DiscordTokenStore.kt | 3 ++- .../screens/settings/DiscordSettingsScreen.kt | 5 ++++- .../flow/discord/DiscordSettingsStateTest.kt | 15 +++++++++++++++ 7 files changed, 37 insertions(+), 9 deletions(-) diff --git a/app/src/github/java/io/github/aedev/flow/discord/DiscordPlatformTransportFactory.kt b/app/src/github/java/io/github/aedev/flow/discord/DiscordPlatformTransportFactory.kt index 1388dc65..986263b2 100644 --- a/app/src/github/java/io/github/aedev/flow/discord/DiscordPlatformTransportFactory.kt +++ b/app/src/github/java/io/github/aedev/flow/discord/DiscordPlatformTransportFactory.kt @@ -10,7 +10,6 @@ class DiscordPlatformTransportFactory : DiscordPresenceTransportFactory { okHttpClient: OkHttpClient, tokenStore: DiscordTokenStore, ): DiscordPresenceTransport = KizzyDiscordPresenceTransport( - context = context.applicationContext, client = okHttpClient, tokenStore = tokenStore, applicationId = BuildConfig.DISCORD_APPLICATION_ID, diff --git a/app/src/github/java/io/github/aedev/flow/discord/KizzyDiscordPresenceTransport.kt b/app/src/github/java/io/github/aedev/flow/discord/KizzyDiscordPresenceTransport.kt index 953a28e9..7191d08f 100644 --- a/app/src/github/java/io/github/aedev/flow/discord/KizzyDiscordPresenceTransport.kt +++ b/app/src/github/java/io/github/aedev/flow/discord/KizzyDiscordPresenceTransport.kt @@ -1,7 +1,6 @@ package io.github.aedev.flow.discord import android.app.Activity -import android.content.Context import android.content.Intent import java.lang.ref.WeakReference import java.util.concurrent.ConcurrentHashMap @@ -18,6 +17,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import okhttp3.MediaType.Companion.toMediaType @@ -35,7 +35,6 @@ import org.json.JSONObject * This uses a Discord user session and is not an official Discord integration. */ class KizzyDiscordPresenceTransport( - private val context: Context, private val client: OkHttpClient, private val tokenStore: DiscordTokenStore, private val applicationId: String, @@ -43,14 +42,15 @@ class KizzyDiscordPresenceTransport( override val isAvailable: Boolean = applicationId.isNotBlank() private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val connectionMutex = Mutex() private val imageCache = ConcurrentHashMap() private var activityReference = WeakReference(null) private var socket: WebSocket? = null private var heartbeatJob: Job? = null private var readySignal: CompletableDeferred? = null private var currentToken: String? = null - private var sequence: Int? = null - private var generation = 0 + @Volatile private var sequence: Int? = null + @Volatile private var generation = 0 private val _connectionState = MutableStateFlow(DiscordConnectionState.DISCONNECTED) override val connectionState: StateFlow = _connectionState.asStateFlow() @@ -91,6 +91,15 @@ class KizzyDiscordPresenceTransport( } override suspend fun connect(tokens: DiscordAuthTokens): DiscordLinkResult { + connectionMutex.lock() + return try { + connectLocked(tokens) + } finally { + connectionMutex.unlock() + } + } + + private suspend fun connectLocked(tokens: DiscordAuthTokens): DiscordLinkResult { if (tokens.accessToken.isBlank()) return fail("Discord returned an empty session token.") if (applicationId.isBlank()) return fail("Discord application ID is missing from this build.") if ( @@ -115,6 +124,7 @@ class KizzyDiscordPresenceTransport( return runCatching { withTimeout(CONNECTION_TIMEOUT_MS) { signal.await() } }.getOrElse { + closeSocket() fail("Discord Gateway connection timed out.") } } @@ -264,6 +274,7 @@ class KizzyDiscordPresenceTransport( private fun failFromCallback(message: String) { val failure = fail(message) readySignal?.complete(failure) + closeSocket() } private companion object { diff --git a/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceRuntime.kt b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceRuntime.kt index 18e1e007..d737202f 100644 --- a/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceRuntime.kt +++ b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceRuntime.kt @@ -151,7 +151,6 @@ object DiscordPresenceRuntime { fun shutdown() { val currentTransport = transport - scope?.launch { currentTransport?.clear() } currentTransport?.close() scope?.cancel() scope = null diff --git a/app/src/main/java/io/github/aedev/flow/discord/DiscordSettingsState.kt b/app/src/main/java/io/github/aedev/flow/discord/DiscordSettingsState.kt index f9581404..b6824451 100644 --- a/app/src/main/java/io/github/aedev/flow/discord/DiscordSettingsState.kt +++ b/app/src/main/java/io/github/aedev/flow/discord/DiscordSettingsState.kt @@ -29,7 +29,7 @@ fun deriveDiscordSettingsState( val enabled = preferenceEnabled && available val summary = when { !available -> DiscordSettingsSummary.UNAVAILABLE - enabled && connectionState == DiscordConnectionState.ERROR -> DiscordSettingsSummary.ERROR + connectionState == DiscordConnectionState.ERROR -> DiscordSettingsSummary.ERROR !enabled -> DiscordSettingsSummary.OFF connectionState == DiscordConnectionState.CONNECTED -> DiscordSettingsSummary.CONNECTED else -> DiscordSettingsSummary.NOT_CONNECTED diff --git a/app/src/main/java/io/github/aedev/flow/discord/DiscordTokenStore.kt b/app/src/main/java/io/github/aedev/flow/discord/DiscordTokenStore.kt index bef7da28..3326dce3 100644 --- a/app/src/main/java/io/github/aedev/flow/discord/DiscordTokenStore.kt +++ b/app/src/main/java/io/github/aedev/flow/discord/DiscordTokenStore.kt @@ -41,7 +41,8 @@ class DiscordTokenStore(context: Context) { val output = storageFile.startWrite() try { - output.use { it.write(payload) } + output.write(payload) + output.flush() storageFile.finishWrite(output) } catch (error: Throwable) { storageFile.failWrite(output) diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/DiscordSettingsScreen.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/DiscordSettingsScreen.kt index 8772bb4b..55baa037 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/DiscordSettingsScreen.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/DiscordSettingsScreen.kt @@ -9,6 +9,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.outlined.Link @@ -64,7 +66,8 @@ fun DiscordSettingsScreen(onNavigateBack: () -> Unit) { modifier = Modifier .fillMaxSize() .padding(padding) - .padding(horizontal = 16.dp), + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(16.dp), ) { Card( diff --git a/app/src/test/java/io/github/aedev/flow/discord/DiscordSettingsStateTest.kt b/app/src/test/java/io/github/aedev/flow/discord/DiscordSettingsStateTest.kt index ad115b61..4c67ceb4 100644 --- a/app/src/test/java/io/github/aedev/flow/discord/DiscordSettingsStateTest.kt +++ b/app/src/test/java/io/github/aedev/flow/discord/DiscordSettingsStateTest.kt @@ -72,4 +72,19 @@ class DiscordSettingsStateTest { assertThat(state.summary).isEqualTo(DiscordSettingsSummary.ERROR) assertThat(state.errorMessage).isEqualTo("Connection timed out") } + + @Test + fun `connection error remains visible while preference is disabled`() { + val state = deriveDiscordSettingsState( + preferenceEnabled = false, + transportAvailable = true, + connectionState = DiscordConnectionState.ERROR, + accountName = null, + errorMessage = "Sign-in failed", + ) + + assertThat(state.isEnabled).isFalse() + assertThat(state.summary).isEqualTo(DiscordSettingsSummary.ERROR) + assertThat(state.errorMessage).isEqualTo("Sign-in failed") + } } From 6ea4db24ed4c5a09a524405276a3d628849a283e Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 13:16:55 +0300 Subject: [PATCH 24/74] test(discord): cover retry unlink and lifecycle clearing --- .../flow/discord/DiscordConnectionActions.kt | 19 +++++ .../flow/discord/DiscordPresenceRuntime.kt | 14 ++-- .../discord/DiscordConnectionActionsTest.kt | 78 +++++++++++++++++++ .../discord/DiscordPresenceCoordinatorTest.kt | 35 +++++++++ 4 files changed, 139 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/io/github/aedev/flow/discord/DiscordConnectionActions.kt create mode 100644 app/src/test/java/io/github/aedev/flow/discord/DiscordConnectionActionsTest.kt diff --git a/app/src/main/java/io/github/aedev/flow/discord/DiscordConnectionActions.kt b/app/src/main/java/io/github/aedev/flow/discord/DiscordConnectionActions.kt new file mode 100644 index 00000000..7ca32f64 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/discord/DiscordConnectionActions.kt @@ -0,0 +1,19 @@ +package io.github.aedev.flow.discord + +internal suspend fun retryDiscordConnection( + transport: DiscordPresenceTransport, + loadTokens: () -> DiscordAuthTokens?, +): DiscordLinkResult = loadTokens()?.let { tokens -> + transport.connect(tokens) +} ?: transport.link() + +internal suspend fun unlinkDiscordConnection( + transport: DiscordPresenceTransport, + disablePreference: suspend () -> Unit, + clearAccountLabel: suspend () -> Unit, +): Boolean { + disablePreference() + val result = transport.unlink() + clearAccountLabel() + return result +} diff --git a/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceRuntime.kt b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceRuntime.kt index d737202f..aeb7827a 100644 --- a/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceRuntime.kt +++ b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceRuntime.kt @@ -137,16 +137,16 @@ object DiscordPresenceRuntime { suspend fun retry(): DiscordLinkResult { val currentTransport = transport ?: return DiscordLinkResult.Failure("Discord Rich Presence has not initialized.") - val tokens = tokenStore?.load() - ?: return DiscordLinkResult.Failure("Connect your Discord account first.") - return currentTransport.connect(tokens) + return retryDiscordConnection(currentTransport) { tokenStore?.load() } } suspend fun unlink(): Boolean { - preferences?.setEnabled(false) - val result = transport?.unlink() ?: false - preferences?.setLinkedAccountLabel(null) - return result + val currentTransport = transport ?: return false + return unlinkDiscordConnection( + transport = currentTransport, + disablePreference = { preferences?.setEnabled(false) }, + clearAccountLabel = { preferences?.setLinkedAccountLabel(null) }, + ) } fun shutdown() { diff --git a/app/src/test/java/io/github/aedev/flow/discord/DiscordConnectionActionsTest.kt b/app/src/test/java/io/github/aedev/flow/discord/DiscordConnectionActionsTest.kt new file mode 100644 index 00000000..b8f78802 --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/discord/DiscordConnectionActionsTest.kt @@ -0,0 +1,78 @@ +package io.github.aedev.flow.discord + +import android.app.Activity +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DiscordConnectionActionsTest { + @Test + fun `retry reconnects with stored credentials`() = runTest { + val transport = RecordingTransport() + val tokens = DiscordAuthTokens("saved", "", Long.MAX_VALUE) + + retryDiscordConnection(transport) { tokens } + + assertThat(transport.connectedTokens).isEqualTo(tokens) + assertThat(transport.linkCalls).isEqualTo(0) + } + + @Test + fun `retry starts account linking when credentials are unavailable`() = runTest { + val transport = RecordingTransport() + + retryDiscordConnection(transport) { null } + + assertThat(transport.connectedTokens).isNull() + assertThat(transport.linkCalls).isEqualTo(1) + } + + @Test + fun `unlink disables sharing clears transport and removes account label`() = runTest { + val transport = RecordingTransport() + val events = mutableListOf() + + val result = unlinkDiscordConnection( + transport = transport, + disablePreference = { events += "disabled" }, + clearAccountLabel = { events += "label-cleared" }, + ) + + assertThat(result).isTrue() + assertThat(transport.unlinkCalls).isEqualTo(1) + assertThat(events).containsExactly("disabled", "label-cleared").inOrder() + } + + private class RecordingTransport : DiscordPresenceTransport { + override val isAvailable = true + override val connectionState = MutableStateFlow(DiscordConnectionState.DISCONNECTED) + override val linkedAccountName = MutableStateFlow(null) + override val lastError = MutableStateFlow(null) + var connectedTokens: DiscordAuthTokens? = null + var linkCalls = 0 + var unlinkCalls = 0 + + override fun attachActivity(activity: Activity?) = Unit + + override suspend fun link(): DiscordLinkResult { + linkCalls += 1 + return DiscordLinkResult.Success + } + + override suspend fun connect(tokens: DiscordAuthTokens): DiscordLinkResult { + connectedTokens = tokens + return DiscordLinkResult.Success + } + + override suspend fun update(payload: DiscordPresencePayload) = true + override suspend fun clear() = true + + override suspend fun unlink(): Boolean { + unlinkCalls += 1 + return true + } + + override fun close() = Unit + } +} diff --git a/app/src/test/java/io/github/aedev/flow/discord/DiscordPresenceCoordinatorTest.kt b/app/src/test/java/io/github/aedev/flow/discord/DiscordPresenceCoordinatorTest.kt index 8d58ce08..b68e5892 100644 --- a/app/src/test/java/io/github/aedev/flow/discord/DiscordPresenceCoordinatorTest.kt +++ b/app/src/test/java/io/github/aedev/flow/discord/DiscordPresenceCoordinatorTest.kt @@ -137,6 +137,41 @@ class DiscordPresenceCoordinatorTest { job.cancelAndJoin() } + @Test + fun `playback lifecycle stop clears previously published presence`() = runTest { + val enabled = MutableStateFlow(true) + val playback = MutableStateFlow( + PlaybackSnapshot( + kind = PlaybackKind.VIDEO, + mediaId = "video-1", + title = "Video", + subtitle = "Creator", + artworkUrl = "", + durationMs = 60_000L, + positionMs = 1_000L, + isPlaying = true, + isLive = false, + ), + ) + val transport = RecordingTransport() + val coordinator = DiscordPresenceCoordinator( + enabled = enabled, + playback = playback, + transport = transport, + nowEpochSeconds = { 100L }, + nowElapsedMs = { 10_000L }, + ) + + val job = launch { coordinator.run() } + runCurrent() + playback.value = null + runCurrent() + + assertThat(transport.updates).hasSize(1) + assertThat(transport.clears).isEqualTo(1) + job.cancelAndJoin() + } + private class RecordingTransport : DiscordPresenceTransport { override val isAvailable: Boolean = true override val connectionState = MutableStateFlow(DiscordConnectionState.CONNECTED) From d0f6c5cd9a650beb6c68a2b7e98e6759a24e51da Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 13:19:03 +0300 Subject: [PATCH 25/74] ci(discord): build exact branch head --- .github/workflows/build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 15ea5194..2d48d787 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,6 +19,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - name: Set up JDK 17 uses: actions/setup-java@v4 From 67f58b9621afa67152cb3681cb1d82f63c8b804d Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 13:34:45 +0300 Subject: [PATCH 26/74] fix(ci): upload signed or unsigned release APK --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2d48d787..e5228c33 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -84,7 +84,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: flow-release-apk - path: app/build/outputs/apk/github/release/app-github-release.apk + path: app/build/outputs/apk/github/release/*.apk if-no-files-found: error retention-days: 14 From 26d7241cce6f3e2c98b4577e3e5b06a743f8cf20 Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 14:05:08 +0300 Subject: [PATCH 27/74] fix(ci): upload signed or unsigned FOSS APK --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e5228c33..119b2e33 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -102,7 +102,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: flow-foss-release-apk - path: app/build/outputs/apk/foss/release/app-foss-release.apk + path: app/build/outputs/apk/foss/release/*.apk if-no-files-found: error retention-days: 14 From 0ef4e8abf93440f40e2ce157a24a10ac7592a886 Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 14:16:47 +0300 Subject: [PATCH 28/74] fix(discord): capture token after authenticated login --- .../flow/discord/DiscordLoginActivity.kt | 35 ++++++++++++------- .../discord/DiscordLoginTokenExtractor.kt | 20 +++++++++++ .../discord/DiscordLoginTokenExtractorTest.kt | 29 +++++++++++++++ 3 files changed, 72 insertions(+), 12 deletions(-) create mode 100644 app/src/github/java/io/github/aedev/flow/discord/DiscordLoginTokenExtractor.kt create mode 100644 app/src/testGithub/java/io/github/aedev/flow/discord/DiscordLoginTokenExtractorTest.kt diff --git a/app/src/github/java/io/github/aedev/flow/discord/DiscordLoginActivity.kt b/app/src/github/java/io/github/aedev/flow/discord/DiscordLoginActivity.kt index 6fc8452f..7db26314 100644 --- a/app/src/github/java/io/github/aedev/flow/discord/DiscordLoginActivity.kt +++ b/app/src/github/java/io/github/aedev/flow/discord/DiscordLoginActivity.kt @@ -9,7 +9,6 @@ import android.webkit.WebStorage import android.webkit.WebView import android.webkit.WebViewClient import androidx.activity.ComponentActivity -import org.json.JSONArray class DiscordLoginActivity : ComponentActivity() { private var completed = false @@ -25,11 +24,15 @@ class DiscordLoginActivity : ComponentActivity() { settings.domStorageEnabled = true settings.userAgentString = settings.userAgentString.replace("; wv", "") webViewClient = object : WebViewClient() { + @Deprecated("Deprecated in Java") + override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean { + requestTokenWhenAuthenticated(view, url) + return false + } + override fun onPageFinished(view: WebView, url: String) { super.onPageFinished(view, url) - if (url.startsWith("https://discord.com/")) { - readToken(view) - } + requestTokenWhenAuthenticated(view, url) } } loadUrl(DISCORD_LOGIN_URL) @@ -44,16 +47,17 @@ class DiscordLoginActivity : ComponentActivity() { super.onDestroy() } + private fun requestTokenWhenAuthenticated(webView: WebView, url: String) { + if (!completed && DiscordLoginTokenExtractor.isAuthenticatedAppUrl(url)) { + readToken(webView) + } + } + private fun readToken(webView: WebView) { webView.evaluateJavascript(TOKEN_SCRIPT) { encodedResult -> - val token = runCatching { - JSONArray("[$encodedResult]").getString(0) - }.getOrNull() - ?.trim() - ?.removeSurrounding("\"") - ?.takeIf { it.isNotEmpty() && it != "null" } + val token = DiscordLoginTokenExtractor.decodeJavascriptValue(encodedResult) - if (token != null) { + if (token != null && !completed) { completed = true DiscordLoginBroker.complete(token) webView.clearHistory() @@ -67,6 +71,13 @@ class DiscordLoginActivity : ComponentActivity() { private companion object { const val DISCORD_LOGIN_URL = "https://discord.com/login" - const val TOKEN_SCRIPT = "(function(){return window.localStorage.getItem('token');})()" + const val TOKEN_SCRIPT = "(function(){" + + "var frame=document.createElement('iframe');" + + "frame.style.display='none';" + + "document.body.appendChild(frame);" + + "var token=frame.contentWindow.localStorage.getItem('token');" + + "frame.remove();" + + "return token;" + + "})()" } } diff --git a/app/src/github/java/io/github/aedev/flow/discord/DiscordLoginTokenExtractor.kt b/app/src/github/java/io/github/aedev/flow/discord/DiscordLoginTokenExtractor.kt new file mode 100644 index 00000000..fc9ee414 --- /dev/null +++ b/app/src/github/java/io/github/aedev/flow/discord/DiscordLoginTokenExtractor.kt @@ -0,0 +1,20 @@ +package io.github.aedev.flow.discord + +import java.net.URI +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.jsonPrimitive + +internal object DiscordLoginTokenExtractor { + fun isAuthenticatedAppUrl(url: String): Boolean = runCatching { + val uri = URI(url) + uri.scheme.equals("https", ignoreCase = true) && + uri.host.equals("discord.com", ignoreCase = true) && + uri.path.trimEnd('/') == "/app" + }.getOrDefault(false) + + fun decodeJavascriptValue(value: String): String? = runCatching { + val element = Json.parseToJsonElement(value) + if (element is JsonNull) null else element.jsonPrimitive.content + }.getOrNull()?.trim()?.takeIf { it.isNotEmpty() && it != "null" } +} diff --git a/app/src/testGithub/java/io/github/aedev/flow/discord/DiscordLoginTokenExtractorTest.kt b/app/src/testGithub/java/io/github/aedev/flow/discord/DiscordLoginTokenExtractorTest.kt new file mode 100644 index 00000000..46b2f838 --- /dev/null +++ b/app/src/testGithub/java/io/github/aedev/flow/discord/DiscordLoginTokenExtractorTest.kt @@ -0,0 +1,29 @@ +package io.github.aedev.flow.discord + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class DiscordLoginTokenExtractorTest { + @Test + fun `authenticated Discord app route requests token extraction`() { + assertThat(DiscordLoginTokenExtractor.isAuthenticatedAppUrl("https://discord.com/app")) + .isTrue() + assertThat(DiscordLoginTokenExtractor.isAuthenticatedAppUrl("https://discord.com/app/")) + .isTrue() + } + + @Test + fun `login and non Discord routes do not request token extraction`() { + assertThat(DiscordLoginTokenExtractor.isAuthenticatedAppUrl("https://discord.com/login")) + .isFalse() + assertThat(DiscordLoginTokenExtractor.isAuthenticatedAppUrl("https://example.com/app")) + .isFalse() + } + + @Test + fun `JavaScript result decodes a token and rejects null`() { + assertThat(DiscordLoginTokenExtractor.decodeJavascriptValue("\"discord-token\"")) + .isEqualTo("discord-token") + assertThat(DiscordLoginTokenExtractor.decodeJavascriptValue("null")).isNull() + } +} From f78f1d7791bf517c9a9da7de4ddbe615abb1882e Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 14:18:49 +0300 Subject: [PATCH 29/74] fix(discord): normalize quoted WebView token --- .../github/aedev/flow/discord/DiscordLoginTokenExtractor.kt | 5 ++++- .../aedev/flow/discord/DiscordLoginTokenExtractorTest.kt | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/github/java/io/github/aedev/flow/discord/DiscordLoginTokenExtractor.kt b/app/src/github/java/io/github/aedev/flow/discord/DiscordLoginTokenExtractor.kt index fc9ee414..a508abcc 100644 --- a/app/src/github/java/io/github/aedev/flow/discord/DiscordLoginTokenExtractor.kt +++ b/app/src/github/java/io/github/aedev/flow/discord/DiscordLoginTokenExtractor.kt @@ -16,5 +16,8 @@ internal object DiscordLoginTokenExtractor { fun decodeJavascriptValue(value: String): String? = runCatching { val element = Json.parseToJsonElement(value) if (element is JsonNull) null else element.jsonPrimitive.content - }.getOrNull()?.trim()?.takeIf { it.isNotEmpty() && it != "null" } + }.getOrNull() + ?.trim() + ?.removeSurrounding("\"") + ?.takeIf { it.isNotEmpty() && it != "null" } } diff --git a/app/src/testGithub/java/io/github/aedev/flow/discord/DiscordLoginTokenExtractorTest.kt b/app/src/testGithub/java/io/github/aedev/flow/discord/DiscordLoginTokenExtractorTest.kt index 46b2f838..0b2cd8fc 100644 --- a/app/src/testGithub/java/io/github/aedev/flow/discord/DiscordLoginTokenExtractorTest.kt +++ b/app/src/testGithub/java/io/github/aedev/flow/discord/DiscordLoginTokenExtractorTest.kt @@ -24,6 +24,8 @@ class DiscordLoginTokenExtractorTest { fun `JavaScript result decodes a token and rejects null`() { assertThat(DiscordLoginTokenExtractor.decodeJavascriptValue("\"discord-token\"")) .isEqualTo("discord-token") + assertThat(DiscordLoginTokenExtractor.decodeJavascriptValue("\"\\\"quoted-token\\\"\"")) + .isEqualTo("quoted-token") assertThat(DiscordLoginTokenExtractor.decodeJavascriptValue("null")).isNull() } } From 12d25139ec3fdd856815d9e284eda61db2c0503f Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 14:57:41 +0300 Subject: [PATCH 30/74] fix(player): promote video service before playback setup --- .../aedev/flow/service/VideoPlayerService.kt | 24 ++++++++++++++++--- .../VideoServiceForegroundStartupPolicy.kt | 13 ++++++++++ ...VideoServiceForegroundStartupPolicyTest.kt | 22 +++++++++++++++++ 3 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/io/github/aedev/flow/service/VideoServiceForegroundStartupPolicy.kt create mode 100644 app/src/test/java/io/github/aedev/flow/service/VideoServiceForegroundStartupPolicyTest.kt diff --git a/app/src/main/java/io/github/aedev/flow/service/VideoPlayerService.kt b/app/src/main/java/io/github/aedev/flow/service/VideoPlayerService.kt index 52e016a1..15b63c98 100644 --- a/app/src/main/java/io/github/aedev/flow/service/VideoPlayerService.kt +++ b/app/src/main/java/io/github/aedev/flow/service/VideoPlayerService.kt @@ -84,6 +84,9 @@ class VideoPlayerService : MediaSessionService() { override fun onCreate() { super.onCreate() + // Android starts this service with startForegroundService when a Media3 controller connects. + // Promote before any player/session initialization so Android 16 cannot hit its FGS deadline. + promoteToForeground() FlowCrashHandler.recordPhase("video-service", "onCreate") val notificationProvider = DefaultMediaNotificationProvider.Builder(this) .setChannelId(MEDIA_CHANNEL_ID) @@ -135,9 +138,17 @@ class VideoPlayerService : MediaSessionService() { ACTION_HIDE_POPUP -> popupPlayerWindow?.dismiss() } - if (EnhancedPlayerManager.getInstance().getVideoMediaSession() == null) { + val startPlan = videoServiceForegroundStartupPlan( + hasMediaSession = EnhancedPlayerManager.getInstance().getVideoMediaSession() != null, + ) + if (startPlan.promoteImmediately && !promoteToForeground()) { + serviceLog("Foreground promotion failed — stopping before system deadline") + stopSelf(startId) + return START_NOT_STICKY + } + + if (startPlan.stopAfterPromotion) { serviceLog("No media session available — placeholder then stop") - promoteToForeground() ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) stopSelf(startId) return START_NOT_STICKY @@ -147,7 +158,7 @@ class VideoPlayerService : MediaSessionService() { return START_STICKY } - private fun promoteToForeground() { + private fun promoteToForeground(): Boolean { try { ensureFallbackNotificationChannel() val notification = NotificationCompat.Builder(this, FALLBACK_CHANNEL_ID) @@ -159,8 +170,15 @@ class VideoPlayerService : MediaSessionService() { this, FALLBACK_NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK ) + FlowCrashHandler.recordPhase("video-service", "foreground-started") + return true } catch (e: Exception) { Log.e(TAG, "Failed to promote service to foreground", e) + FlowCrashHandler.recordPhase( + "video-service", + "foreground-start-failed ${e.javaClass.simpleName}", + ) + return false } } diff --git a/app/src/main/java/io/github/aedev/flow/service/VideoServiceForegroundStartupPolicy.kt b/app/src/main/java/io/github/aedev/flow/service/VideoServiceForegroundStartupPolicy.kt new file mode 100644 index 00000000..5903c482 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/service/VideoServiceForegroundStartupPolicy.kt @@ -0,0 +1,13 @@ +package io.github.aedev.flow.service + +internal data class VideoServiceForegroundStartupPlan( + val promoteImmediately: Boolean, + val stopAfterPromotion: Boolean, +) + +internal fun videoServiceForegroundStartupPlan( + hasMediaSession: Boolean, +): VideoServiceForegroundStartupPlan = VideoServiceForegroundStartupPlan( + promoteImmediately = true, + stopAfterPromotion = !hasMediaSession, +) diff --git a/app/src/test/java/io/github/aedev/flow/service/VideoServiceForegroundStartupPolicyTest.kt b/app/src/test/java/io/github/aedev/flow/service/VideoServiceForegroundStartupPolicyTest.kt new file mode 100644 index 00000000..d63e6ece --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/service/VideoServiceForegroundStartupPolicyTest.kt @@ -0,0 +1,22 @@ +package io.github.aedev.flow.service + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class VideoServiceForegroundStartupPolicyTest { + @Test + fun `a service with a media session still promotes immediately`() { + val plan = videoServiceForegroundStartupPlan(hasMediaSession = true) + + assertThat(plan.promoteImmediately).isTrue() + assertThat(plan.stopAfterPromotion).isFalse() + } + + @Test + fun `a service without a media session promotes before stopping`() { + val plan = videoServiceForegroundStartupPlan(hasMediaSession = false) + + assertThat(plan.promoteImmediately).isTrue() + assertThat(plan.stopAfterPromotion).isTrue() + } +} From 1cad2d54c1726186ad609f3c55bb7ccd6f09b7cf Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 17:17:13 +0300 Subject: [PATCH 31/74] fix: read Discord playback snapshots on main thread --- .../io/github/aedev/flow/discord/DiscordPlaybackSource.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/io/github/aedev/flow/discord/DiscordPlaybackSource.kt b/app/src/main/java/io/github/aedev/flow/discord/DiscordPlaybackSource.kt index 484ef6e0..11a09bef 100644 --- a/app/src/main/java/io/github/aedev/flow/discord/DiscordPlaybackSource.kt +++ b/app/src/main/java/io/github/aedev/flow/discord/DiscordPlaybackSource.kt @@ -6,11 +6,13 @@ import io.github.aedev.flow.player.EnhancedMusicPlayerManager import io.github.aedev.flow.player.EnhancedPlayerManager import io.github.aedev.flow.player.GlobalPlayerState import io.github.aedev.flow.player.shorts.ShortsPlayerPool +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.isActive @@ -34,7 +36,11 @@ class DiscordPlaybackSource( video = videoSnapshot(), music = musicSnapshot(), ) - }.distinctUntilChanged() + } + // Media3 players are owned by the main application thread. The presence runtime + // collects on IO for its gateway calls, so keep every snapshot read upstream on main. + .flowOn(Dispatchers.Main.immediate) + .distinctUntilChanged() private fun shortSnapshot(): PlaybackSnapshot? { val short = shortsPool.currentVideo.value ?: return null From 93a6b0bdf1e85b85c544c183bdc0acba25662ef8 Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 17:17:29 +0300 Subject: [PATCH 32/74] test: cover Discord player-thread snapshot reads --- .../DiscordPlaybackSourceThreadingTest.kt | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 app/src/test/java/io/github/aedev/flow/discord/DiscordPlaybackSourceThreadingTest.kt diff --git a/app/src/test/java/io/github/aedev/flow/discord/DiscordPlaybackSourceThreadingTest.kt b/app/src/test/java/io/github/aedev/flow/discord/DiscordPlaybackSourceThreadingTest.kt new file mode 100644 index 00000000..aa0c9102 --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/discord/DiscordPlaybackSourceThreadingTest.kt @@ -0,0 +1,87 @@ +package io.github.aedev.flow.discord + +import com.google.common.truth.Truth.assertThat +import io.github.aedev.flow.data.model.Video +import io.github.aedev.flow.player.EnhancedPlayerManager +import io.github.aedev.flow.player.GlobalPlayerState +import io.github.aedev.flow.player.shorts.ShortsPlayerPool +import io.github.aedev.flow.player.state.EnhancedPlayerState +import io.mockk.every +import io.mockk.mockk +import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.junit.Test +import java.util.concurrent.Executors + +@OptIn(ExperimentalCoroutinesApi::class) +class DiscordPlaybackSourceThreadingTest { + + @Test + fun `video player state is read on the main player thread when presence collects on IO`() = runBlocking { + val playerExecutor = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "player-main") + } + val presenceExecutor = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "presence-io") + } + val playerDispatcher = playerExecutor.asCoroutineDispatcher() + val presenceDispatcher = presenceExecutor.asCoroutineDispatcher() + val readThread = AtomicReference() + val video = Video( + id = "video-id", + title = "Video title", + channelName = "Channel", + channelId = "channel-id", + thumbnailUrl = "https://example.com/thumb.jpg", + duration = 60, + viewCount = 0L, + uploadDate = "today", + ) + val videoManager = mockk() + val shortsPool = mockk() + + every { videoManager.playerState } returns MutableStateFlow( + EnhancedPlayerState(currentVideoId = video.id, isPlaying = true), + ) + every { videoManager.getCurrentPosition() } answers { + readThread.set(Thread.currentThread().name) + 5_000L + } + every { videoManager.getDuration() } returns 60_000L + every { shortsPool.currentVideo } returns MutableStateFlow(null) + every { shortsPool.currentVideoId } returns MutableStateFlow(null) + + Dispatchers.setMain(playerDispatcher) + GlobalPlayerState.setCurrentVideo(video) + try { + val source = DiscordPlaybackSource( + videoManager = videoManager, + shortsPool = shortsPool, + ) + + withContext(presenceDispatcher) { + withTimeout(5_000L) { + source.playback.first { it?.mediaId == video.id } + } + } + + assertThat(readThread.get()).isEqualTo("player-main") + } finally { + GlobalPlayerState.setCurrentVideo(null) + Dispatchers.resetMain() + playerDispatcher.close() + presenceDispatcher.close() + playerExecutor.shutdownNow() + presenceExecutor.shutdownNow() + } + } +} From 8f03d0fbcdb6a3ebabbea304af1b16bd6382ad46 Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 17:27:27 +0300 Subject: [PATCH 33/74] test: make Discord playback thread regression test JVM-safe --- .../DiscordPlaybackSourceThreadingTest.kt | 56 +++++++------------ 1 file changed, 19 insertions(+), 37 deletions(-) diff --git a/app/src/test/java/io/github/aedev/flow/discord/DiscordPlaybackSourceThreadingTest.kt b/app/src/test/java/io/github/aedev/flow/discord/DiscordPlaybackSourceThreadingTest.kt index aa0c9102..ef0e2026 100644 --- a/app/src/test/java/io/github/aedev/flow/discord/DiscordPlaybackSourceThreadingTest.kt +++ b/app/src/test/java/io/github/aedev/flow/discord/DiscordPlaybackSourceThreadingTest.kt @@ -1,18 +1,13 @@ package io.github.aedev.flow.discord import com.google.common.truth.Truth.assertThat -import io.github.aedev.flow.data.model.Video -import io.github.aedev.flow.player.EnhancedPlayerManager -import io.github.aedev.flow.player.GlobalPlayerState -import io.github.aedev.flow.player.shorts.ShortsPlayerPool -import io.github.aedev.flow.player.state.EnhancedPlayerState import io.mockk.every import io.mockk.mockk +import java.util.concurrent.Executors import java.util.concurrent.atomic.AtomicReference import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.resetMain @@ -20,13 +15,12 @@ import kotlinx.coroutines.test.setMain import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import org.junit.Test -import java.util.concurrent.Executors @OptIn(ExperimentalCoroutinesApi::class) class DiscordPlaybackSourceThreadingTest { @Test - fun `video player state is read on the main player thread when presence collects on IO`() = runBlocking { + fun `playback snapshots are built on the main player thread when presence collects on IO`() = runBlocking { val playerExecutor = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "player-main") } @@ -35,48 +29,36 @@ class DiscordPlaybackSourceThreadingTest { } val playerDispatcher = playerExecutor.asCoroutineDispatcher() val presenceDispatcher = presenceExecutor.asCoroutineDispatcher() - val readThread = AtomicReference() - val video = Video( - id = "video-id", + val snapshotThread = AtomicReference() + val selector = mockk() + val expectedSnapshot = PlaybackSnapshot( + kind = PlaybackKind.VIDEO, + mediaId = "video-id", title = "Video title", - channelName = "Channel", - channelId = "channel-id", - thumbnailUrl = "https://example.com/thumb.jpg", - duration = 60, - viewCount = 0L, - uploadDate = "today", - ) - val videoManager = mockk() - val shortsPool = mockk() - - every { videoManager.playerState } returns MutableStateFlow( - EnhancedPlayerState(currentVideoId = video.id, isPlaying = true), + subtitle = "Channel", + artworkUrl = "", + positionMs = 0L, + durationMs = 60_000L, + isPlaying = true, + isLive = false, ) - every { videoManager.getCurrentPosition() } answers { - readThread.set(Thread.currentThread().name) - 5_000L + every { selector.select(any(), any(), any()) } answers { + snapshotThread.set(Thread.currentThread().name) + expectedSnapshot } - every { videoManager.getDuration() } returns 60_000L - every { shortsPool.currentVideo } returns MutableStateFlow(null) - every { shortsPool.currentVideoId } returns MutableStateFlow(null) Dispatchers.setMain(playerDispatcher) - GlobalPlayerState.setCurrentVideo(video) try { - val source = DiscordPlaybackSource( - videoManager = videoManager, - shortsPool = shortsPool, - ) + val source = DiscordPlaybackSource(selector = selector) withContext(presenceDispatcher) { withTimeout(5_000L) { - source.playback.first { it?.mediaId == video.id } + source.playback.first() } } - assertThat(readThread.get()).isEqualTo("player-main") + assertThat(snapshotThread.get()).isEqualTo("player-main") } finally { - GlobalPlayerState.setCurrentVideo(null) Dispatchers.resetMain() playerDispatcher.close() presenceDispatcher.close() From 93e9d3944f3c7d8cb623a375f7e841ee22072242 Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 17:34:49 +0300 Subject: [PATCH 34/74] ci: run Android builds on pasta_vps --- .github/workflows/build.yml | 112 +----------------------------------- 1 file changed, 1 insertion(+), 111 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 119b2e33..cdaf118c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,7 +14,7 @@ permissions: jobs: build: name: Build APK - runs-on: ubuntu-latest + runs-on: [self-hosted, pasta_vps] steps: - name: Checkout code @@ -178,113 +178,3 @@ jobs: - Add new sorting options in playlists - Add new player seekbar width customization - Add remeber brightness toggle #474 - - Add new more lyrics providers (Based on Metrolist's provider implementation https://github.com/MetrolistGroup/Metrolist ) - - Add new lyrics engine (Based on Metrolist's ExperimentalLyrics.kt, LyricsLine.kt, LyricsCommon.kt, and FadingEdge.kt.) - - Add new Music Audio Quality selector #521 - - Add external music downloads #256 - - Add shorts history delete option #514 - - Add LireTube history export option #507 - - Add shorts playback speed selection #496 - - Add option to disable or change long-press playback speed #475 - - Add option to disable volume boost #491 - - Add date and time format customization #481 - - Add codec preference settings #560 - - Add Android Auto support - - Add live chat support - - IMPROVEMENTS - - Seekbar preview and slider optimization #360 #361 - - Improve media playback notification handling #347 - - Improve Monochrome app logo quality #336 - - Add paging to comment replies #369 - - Add master backup import to onboarding #365 - - Improve video watch progress handling #379 - - Add share button to channel screen #397 - - Add download pause/resume to download screen #407 - - Add to playlist sheet redesign and video addition to multiple playlists #391 - - Improve comments and description sheets design and interactions #397 - - Chapters sheet redesign - - Battery optimization #383 #559 - - Personality/control center redesign and optimization - - Improve dislike signals from video player - - Improve topics categories - - Improve dynamic video player sizing and handling - - Player settings sheet redesign and Introduce new sheet sidebar in player - - Add player sizing toggle - - Add paid videos filters #439 - - Improve video and shorts playback performance #442 #425 - - Prefer VP9/H264 over AV1 for playback #406 - - Improve Live streams handling and UI #435 #416 #390 - - Improved watched content filtering #451 - - Improve subscriptions feed UI, count, content and cache #435 #468 - - Redesign App Icon picker #400 - - Improve shorts UI and performance #446 - - Watch history and likes screens redesign - - Improve playlists and library screens UI design - - Improve subtitle handling #271 - - Improve upcoming videos handling and add a reminder #470 - - Improve mini video player animaton #464 - - Improve sponsor skip button placement #476 - - Improve music playback speed and simpmusic issues - - Improve lyrics formatting, seeking and better word level lyrics hightlight - - Improve Music feed and add more sections - - Music player and its components redesign - - New queue sheet design, improved queue handling and similar content fetching - - Improve AV1 handling #548 - - Increase subscription feed cache size - - Reduce player UI position update churn during playback to improve high-FPS fast-speed smoothness #478 - - Improve playlist and background playback STABILITY - - FIXES AND STABILITY - - Fix play mode switch pop up #378 - - Fix Go to channel on home and related videos #370 #351 - - Fix comment loading in queue #352 - - Fix queue dock position by @ItsMe-95fx #371 - - Fix channel content rendering #366 - - Fix empty media playback notification #347 - - Fix limited playlist content #385 - - Fix auto play when in background playback #388 - - Fix pip mode freeze #354 - - Fix low thumbnail quality #403 - - Fix channel avatar on subscription #387 - - Fix background playback #417 #434 - - Fix playback issues when playing from shared links #422 - - Fix playlist playback history #413 - - Fix album and subscriptions search crashes #443 #445 - - Fix video downloads and songs playback in wrong language #381 #353 - - Fix large playlist crashing #454 - - Fix video player supression #453 - - Fix content preference backup #452 - - Fix low resolution casting #299 - - Fix shorts player disable option #466 - - Fix music playlist delete path #471 - - Fix LazyColumn crash #479 - - Fix player jitter #478 - - Fix missing track duration - - Fix channel banner resolution #518 - - ENGINE - - Improve topic maturity detection for better recommendations #281 - - Fix watch progress tracking accuracy - - Optimize learning signal weighting - - Improve discovery query filtering - - Improve feed history tracking - - Fix recommendation engine concurrency issue - - Improve brain state persistence - - Connect YouTube watch history to recommendations #389 - - Add Interest Anchor Queries to improve recommendations - - CORE: - - Add native channel content extraction via InnerTube API - - Bump NewPipeExtractor version to v0.26.2 - - Add SABR stream decoders and native InnerTube API video stream extraction with NewPipeExtractor as fallback - - generate_release_notes: true - files: | - dist/flow.apk - dist/flow-nightly.apk - dist/flow-foss.apk - draft: false - prerelease: false - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From ad7b21a7c8c22c5885f2b95009ec7140e882c65e Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 17:46:36 +0300 Subject: [PATCH 35/74] Update build.yml --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4ca68a1e..3784833d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,7 +14,7 @@ permissions: jobs: build: name: Build APK - runs-on: ubuntu-latest + runs-on: self-hosted, Linux, X64 steps: - name: Checkout code @@ -265,4 +265,4 @@ jobs: draft: false prerelease: false env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 8ce5fcb641ff0c7697f4c1a7b14e8b591d093f38 Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 17:49:21 +0300 Subject: [PATCH 36/74] Update build.yml --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cdaf118c..16d2fc74 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,7 +14,7 @@ permissions: jobs: build: name: Build APK - runs-on: [self-hosted, pasta_vps] + runs-on: [self-hosted] steps: - name: Checkout code From 379fd1dabfb4143b4d1928fc1ccddef35d837498 Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 17:57:11 +0300 Subject: [PATCH 37/74] ci: match the Ubuntu self-hosted runner labels --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 16d2fc74..a99c2df7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,7 +14,7 @@ permissions: jobs: build: name: Build APK - runs-on: [self-hosted] + runs-on: [self-hosted, Linux, X64] steps: - name: Checkout code From c4d8fd5509c41a5a41ed94eea3a11cc4003d526d Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 17:58:29 +0300 Subject: [PATCH 38/74] ci: allow manual Android workflow runs --- .github/workflows/build.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a99c2df7..9f8d3bc1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,6 +1,7 @@ name: Android CI/CD on: + workflow_dispatch: push: branches: [ main, master, develop, agent/discord-rich-presence ] tags: @@ -178,3 +179,7 @@ jobs: - Add new sorting options in playlists - Add new player seekbar width customization - Add remeber brightness toggle #474 + - Add new more lyrics providers (Based on Metrolist's provider implementation https://github.com/MetrolistGroup/Metrolist ) + - Add new lyrics engine (Based on Metrolist's ExperimentalLyrics.kt, LyricsLine.kt, LyricsCommon.kt, and FadingEdge.kt.) + - Add new Music Audio Quality selector #521 + - Add external music downloads #256 From 6fa8152093e689701e731970487876aa3e46d22b Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 17:59:47 +0300 Subject: [PATCH 39/74] test: document Discord playback dispatcher boundary --- .../aedev/flow/discord/DiscordPlaybackSourceThreadingTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/test/java/io/github/aedev/flow/discord/DiscordPlaybackSourceThreadingTest.kt b/app/src/test/java/io/github/aedev/flow/discord/DiscordPlaybackSourceThreadingTest.kt index ef0e2026..609351e8 100644 --- a/app/src/test/java/io/github/aedev/flow/discord/DiscordPlaybackSourceThreadingTest.kt +++ b/app/src/test/java/io/github/aedev/flow/discord/DiscordPlaybackSourceThreadingTest.kt @@ -49,6 +49,7 @@ class DiscordPlaybackSourceThreadingTest { Dispatchers.setMain(playerDispatcher) try { + // DiscordPresenceRuntime collects this flow on IO, while Media3 owns the main thread. val source = DiscordPlaybackSource(selector = selector) withContext(presenceDispatcher) { From 34f705e68aff640e313fab87f298d3733fdd7fb7 Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 18:01:21 +0300 Subject: [PATCH 40/74] ci: trigger self-hosted Android verification From 7035cb0265c2d2899203c4d93fbb3d68191d852f Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 18:04:04 +0300 Subject: [PATCH 41/74] ci: install Android SDK on self-hosted runner --- .github/workflows/build.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9f8d3bc1..63b1787a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,6 +30,11 @@ jobs: distribution: 'temurin' cache: gradle + - name: Set up Android SDK + uses: android-actions/setup-android@v4 + with: + packages: 'platform-tools platforms;android-35 build-tools;35.0.0' + # Explicit Gradle cache on top of setup-java's built-in cache. # Caches the build-cache directory so incremental tasks are skipped. - name: Cache Gradle build cache @@ -183,3 +188,8 @@ jobs: - Add new lyrics engine (Based on Metrolist's ExperimentalLyrics.kt, LyricsLine.kt, LyricsCommon.kt, and FadingEdge.kt.) - Add new Music Audio Quality selector #521 - Add external music downloads #256 + - Add shorts history delete option #514 + - Add LireTube history export option #507 + - Add shorts playback speed selection #496 + - Add option to disable or change long-press playback speed #475 + - Add option to disable volume boost #491 From 468113d640d19aeada9f090fdb420b4eef0e7888 Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 18:29:21 +0300 Subject: [PATCH 42/74] test: isolate Discord playback dispatcher boundary --- .../flow/discord/DiscordPlaybackSource.kt | 48 ++++++++++++------- .../DiscordPlaybackSourceThreadingTest.kt | 30 +++++------- 2 files changed, 42 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/io/github/aedev/flow/discord/DiscordPlaybackSource.kt b/app/src/main/java/io/github/aedev/flow/discord/DiscordPlaybackSource.kt index 11a09bef..2c895306 100644 --- a/app/src/main/java/io/github/aedev/flow/discord/DiscordPlaybackSource.kt +++ b/app/src/main/java/io/github/aedev/flow/discord/DiscordPlaybackSource.kt @@ -6,6 +6,7 @@ import io.github.aedev.flow.player.EnhancedMusicPlayerManager import io.github.aedev.flow.player.EnhancedPlayerManager import io.github.aedev.flow.player.GlobalPlayerState import io.github.aedev.flow.player.shorts.ShortsPlayerPool +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay @@ -23,24 +24,24 @@ class DiscordPlaybackSource( private val shortsPool: ShortsPlayerPool = ShortsPlayerPool.getInstance(), private val selector: DiscordPlaybackSelector = DiscordPlaybackSelector(), ) { - val playback: Flow = merge( - GlobalPlayerState.currentVideo.map { Unit }, - videoManager.playerState.map { Unit }, - EnhancedMusicPlayerManager.currentTrack.map { Unit }, - EnhancedMusicPlayerManager.playerState.map { Unit }, - shortsPool.currentVideo.map { Unit }, - ticker(), - ).map { - selector.select( - short = shortSnapshot(), - video = videoSnapshot(), - music = musicSnapshot(), - ) - } - // Media3 players are owned by the main application thread. The presence runtime - // collects on IO for its gateway calls, so keep every snapshot read upstream on main. - .flowOn(Dispatchers.Main.immediate) - .distinctUntilChanged() + val playback: Flow = discordPlaybackSnapshotFlow( + signals = merge( + GlobalPlayerState.currentVideo.map { Unit }, + videoManager.playerState.map { Unit }, + EnhancedMusicPlayerManager.currentTrack.map { Unit }, + EnhancedMusicPlayerManager.playerState.map { Unit }, + shortsPool.currentVideo.map { Unit }, + ticker(), + ), + snapshotDispatcher = Dispatchers.Main.immediate, + readSnapshot = { + selector.select( + short = shortSnapshot(), + video = videoSnapshot(), + music = musicSnapshot(), + ) + }, + ) private fun shortSnapshot(): PlaybackSnapshot? { val short = shortsPool.currentVideo.value ?: return null @@ -107,3 +108,14 @@ class DiscordPlaybackSource( const val POSITION_SAMPLE_INTERVAL_MS = 5_000L } } + +internal fun discordPlaybackSnapshotFlow( + signals: Flow, + snapshotDispatcher: CoroutineDispatcher, + readSnapshot: () -> PlaybackSnapshot?, +): Flow = signals + .map { readSnapshot() } + // Media3 players are owned by the main application thread. The presence runtime + // collects on IO for its gateway calls, so keep every snapshot read upstream on main. + .flowOn(snapshotDispatcher) + .distinctUntilChanged() diff --git a/app/src/test/java/io/github/aedev/flow/discord/DiscordPlaybackSourceThreadingTest.kt b/app/src/test/java/io/github/aedev/flow/discord/DiscordPlaybackSourceThreadingTest.kt index 609351e8..f2d18ad3 100644 --- a/app/src/test/java/io/github/aedev/flow/discord/DiscordPlaybackSourceThreadingTest.kt +++ b/app/src/test/java/io/github/aedev/flow/discord/DiscordPlaybackSourceThreadingTest.kt @@ -1,22 +1,16 @@ package io.github.aedev.flow.discord import com.google.common.truth.Truth.assertThat -import io.mockk.every -import io.mockk.mockk import java.util.concurrent.Executors import java.util.concurrent.atomic.AtomicReference -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import org.junit.Test -@OptIn(ExperimentalCoroutinesApi::class) class DiscordPlaybackSourceThreadingTest { @Test @@ -30,7 +24,6 @@ class DiscordPlaybackSourceThreadingTest { val playerDispatcher = playerExecutor.asCoroutineDispatcher() val presenceDispatcher = presenceExecutor.asCoroutineDispatcher() val snapshotThread = AtomicReference() - val selector = mockk() val expectedSnapshot = PlaybackSnapshot( kind = PlaybackKind.VIDEO, mediaId = "video-id", @@ -42,25 +35,26 @@ class DiscordPlaybackSourceThreadingTest { isPlaying = true, isLive = false, ) - every { selector.select(any(), any(), any()) } answers { - snapshotThread.set(Thread.currentThread().name) - expectedSnapshot - } - Dispatchers.setMain(playerDispatcher) try { - // DiscordPresenceRuntime collects this flow on IO, while Media3 owns the main thread. - val source = DiscordPlaybackSource(selector = selector) + val snapshots = discordPlaybackSnapshotFlow( + signals = flowOf(Unit), + snapshotDispatcher = playerDispatcher, + readSnapshot = { + snapshotThread.set(Thread.currentThread().name) + expectedSnapshot + }, + ) - withContext(presenceDispatcher) { + val actualSnapshot = withContext(presenceDispatcher) { withTimeout(5_000L) { - source.playback.first() + snapshots.first() } } + assertThat(actualSnapshot).isEqualTo(expectedSnapshot) assertThat(snapshotThread.get()).isEqualTo("player-main") } finally { - Dispatchers.resetMain() playerDispatcher.close() presenceDispatcher.close() playerExecutor.shutdownNow() From 9bff8e37ccec96aa0459d0a3eed696136ac7269c Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 18:45:26 +0300 Subject: [PATCH 43/74] test: compare Discord playback thread identity --- .../flow/discord/DiscordPlaybackSourceThreadingTest.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/test/java/io/github/aedev/flow/discord/DiscordPlaybackSourceThreadingTest.kt b/app/src/test/java/io/github/aedev/flow/discord/DiscordPlaybackSourceThreadingTest.kt index f2d18ad3..d63b0e9d 100644 --- a/app/src/test/java/io/github/aedev/flow/discord/DiscordPlaybackSourceThreadingTest.kt +++ b/app/src/test/java/io/github/aedev/flow/discord/DiscordPlaybackSourceThreadingTest.kt @@ -15,15 +15,16 @@ class DiscordPlaybackSourceThreadingTest { @Test fun `playback snapshots are built on the main player thread when presence collects on IO`() = runBlocking { + val playerThread = AtomicReference() val playerExecutor = Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "player-main") + Thread(runnable, "player-main").also(playerThread::set) } val presenceExecutor = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "presence-io") } val playerDispatcher = playerExecutor.asCoroutineDispatcher() val presenceDispatcher = presenceExecutor.asCoroutineDispatcher() - val snapshotThread = AtomicReference() + val snapshotThread = AtomicReference() val expectedSnapshot = PlaybackSnapshot( kind = PlaybackKind.VIDEO, mediaId = "video-id", @@ -41,7 +42,7 @@ class DiscordPlaybackSourceThreadingTest { signals = flowOf(Unit), snapshotDispatcher = playerDispatcher, readSnapshot = { - snapshotThread.set(Thread.currentThread().name) + snapshotThread.set(Thread.currentThread()) expectedSnapshot }, ) @@ -53,7 +54,7 @@ class DiscordPlaybackSourceThreadingTest { } assertThat(actualSnapshot).isEqualTo(expectedSnapshot) - assertThat(snapshotThread.get()).isEqualTo("player-main") + assertThat(snapshotThread.get()).isSameInstanceAs(playerThread.get()) } finally { playerDispatcher.close() presenceDispatcher.close() From 856ec2aba9d43fdc4be84e8a172a26aa287a1ba7 Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 19:31:52 +0300 Subject: [PATCH 44/74] ci: return Android builds to GitHub-hosted runners --- .github/workflows/build.yml | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 63b1787a..bb1bdab2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,7 +1,6 @@ name: Android CI/CD on: - workflow_dispatch: push: branches: [ main, master, develop, agent/discord-rich-presence ] tags: @@ -15,7 +14,7 @@ permissions: jobs: build: name: Build APK - runs-on: [self-hosted, Linux, X64] + runs-on: ubuntu-latest steps: - name: Checkout code @@ -30,11 +29,6 @@ jobs: distribution: 'temurin' cache: gradle - - name: Set up Android SDK - uses: android-actions/setup-android@v4 - with: - packages: 'platform-tools platforms;android-35 build-tools;35.0.0' - # Explicit Gradle cache on top of setup-java's built-in cache. # Caches the build-cache directory so incremental tasks are skipped. - name: Cache Gradle build cache @@ -184,12 +178,3 @@ jobs: - Add new sorting options in playlists - Add new player seekbar width customization - Add remeber brightness toggle #474 - - Add new more lyrics providers (Based on Metrolist's provider implementation https://github.com/MetrolistGroup/Metrolist ) - - Add new lyrics engine (Based on Metrolist's ExperimentalLyrics.kt, LyricsLine.kt, LyricsCommon.kt, and FadingEdge.kt.) - - Add new Music Audio Quality selector #521 - - Add external music downloads #256 - - Add shorts history delete option #514 - - Add LireTube history export option #507 - - Add shorts playback speed selection #496 - - Add option to disable or change long-press playback speed #475 - - Add option to disable volume boost #491 From fa096736e2be4a88c7cdfbcd4503db3ac26eac7b Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 19:41:33 +0300 Subject: [PATCH 45/74] fix(ci): restore complete Android workflow --- .github/workflows/build.yml | 110 ++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bb1bdab2..119b2e33 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -178,3 +178,113 @@ jobs: - Add new sorting options in playlists - Add new player seekbar width customization - Add remeber brightness toggle #474 + - Add new more lyrics providers (Based on Metrolist's provider implementation https://github.com/MetrolistGroup/Metrolist ) + - Add new lyrics engine (Based on Metrolist's ExperimentalLyrics.kt, LyricsLine.kt, LyricsCommon.kt, and FadingEdge.kt.) + - Add new Music Audio Quality selector #521 + - Add external music downloads #256 + - Add shorts history delete option #514 + - Add LireTube history export option #507 + - Add shorts playback speed selection #496 + - Add option to disable or change long-press playback speed #475 + - Add option to disable volume boost #491 + - Add date and time format customization #481 + - Add codec preference settings #560 + - Add Android Auto support + - Add live chat support + + IMPROVEMENTS + - Seekbar preview and slider optimization #360 #361 + - Improve media playback notification handling #347 + - Improve Monochrome app logo quality #336 + - Add paging to comment replies #369 + - Add master backup import to onboarding #365 + - Improve video watch progress handling #379 + - Add share button to channel screen #397 + - Add download pause/resume to download screen #407 + - Add to playlist sheet redesign and video addition to multiple playlists #391 + - Improve comments and description sheets design and interactions #397 + - Chapters sheet redesign + - Battery optimization #383 #559 + - Personality/control center redesign and optimization + - Improve dislike signals from video player + - Improve topics categories + - Improve dynamic video player sizing and handling + - Player settings sheet redesign and Introduce new sheet sidebar in player + - Add player sizing toggle + - Add paid videos filters #439 + - Improve video and shorts playback performance #442 #425 + - Prefer VP9/H264 over AV1 for playback #406 + - Improve Live streams handling and UI #435 #416 #390 + - Improved watched content filtering #451 + - Improve subscriptions feed UI, count, content and cache #435 #468 + - Redesign App Icon picker #400 + - Improve shorts UI and performance #446 + - Watch history and likes screens redesign + - Improve playlists and library screens UI design + - Improve subtitle handling #271 + - Improve upcoming videos handling and add a reminder #470 + - Improve mini video player animaton #464 + - Improve sponsor skip button placement #476 + - Improve music playback speed and simpmusic issues + - Improve lyrics formatting, seeking and better word level lyrics hightlight + - Improve Music feed and add more sections + - Music player and its components redesign + - New queue sheet design, improved queue handling and similar content fetching + - Improve AV1 handling #548 + - Increase subscription feed cache size + - Reduce player UI position update churn during playback to improve high-FPS fast-speed smoothness #478 + - Improve playlist and background playback STABILITY + + FIXES AND STABILITY + - Fix play mode switch pop up #378 + - Fix Go to channel on home and related videos #370 #351 + - Fix comment loading in queue #352 + - Fix queue dock position by @ItsMe-95fx #371 + - Fix channel content rendering #366 + - Fix empty media playback notification #347 + - Fix limited playlist content #385 + - Fix auto play when in background playback #388 + - Fix pip mode freeze #354 + - Fix low thumbnail quality #403 + - Fix channel avatar on subscription #387 + - Fix background playback #417 #434 + - Fix playback issues when playing from shared links #422 + - Fix playlist playback history #413 + - Fix album and subscriptions search crashes #443 #445 + - Fix video downloads and songs playback in wrong language #381 #353 + - Fix large playlist crashing #454 + - Fix video player supression #453 + - Fix content preference backup #452 + - Fix low resolution casting #299 + - Fix shorts player disable option #466 + - Fix music playlist delete path #471 + - Fix LazyColumn crash #479 + - Fix player jitter #478 + - Fix missing track duration + - Fix channel banner resolution #518 + + ENGINE + - Improve topic maturity detection for better recommendations #281 + - Fix watch progress tracking accuracy + - Optimize learning signal weighting + - Improve discovery query filtering + - Improve feed history tracking + - Fix recommendation engine concurrency issue + - Improve brain state persistence + - Connect YouTube watch history to recommendations #389 + - Add Interest Anchor Queries to improve recommendations + + CORE: + - Add native channel content extraction via InnerTube API + - Bump NewPipeExtractor version to v0.26.2 + - Add SABR stream decoders and native InnerTube API video stream extraction with NewPipeExtractor as fallback + + generate_release_notes: true + files: | + dist/flow.apk + dist/flow-nightly.apk + dist/flow-foss.apk + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 8b3ce1896102d1f097f82fa359f9b869dbfd1b02 Mon Sep 17 00:00:00 2001 From: Pasta Date: Tue, 14 Jul 2026 20:15:05 +0300 Subject: [PATCH 46/74] Update build.yml --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bcfbc900..119b2e33 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,7 +14,7 @@ permissions: jobs: build: name: Build APK - runs-on: self-hosted, Linux, X64 + runs-on: ubuntu-latest steps: - name: Checkout code From 1328e53140580c16aeb9d80286c2db64849f13ce Mon Sep 17 00:00:00 2001 From: Pasta Date: Wed, 15 Jul 2026 09:02:17 +0300 Subject: [PATCH 47/74] file that is not needed --- .../plans/2026-07-14-discord-rich-presence.md | 90 ------------------- 1 file changed, 90 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-14-discord-rich-presence.md diff --git a/docs/superpowers/plans/2026-07-14-discord-rich-presence.md b/docs/superpowers/plans/2026-07-14-discord-rich-presence.md deleted file mode 100644 index e64d755b..00000000 --- a/docs/superpowers/plans/2026-07-14-discord-rich-presence.md +++ /dev/null @@ -1,90 +0,0 @@ -# Discord Rich Presence Implementation Plan - -> **For agentic workers:** Execute inline in red-green-refactor order; do not dispatch subagents. - -**Goal:** Build the complete settings, lifecycle, playback, flavor, and Kizzy-style Discord Gateway integration path. - -**Architecture:** Common Kotlin code owns preferences, state, playback selection, and orchestration behind a transport seam. Flavor source sets provide either a fail-closed adapter or a GitHub Gateway adapter using the existing OkHttp dependency. - -**Tech Stack:** Kotlin, Compose Material 3, DataStore Preferences, coroutines/Flow, Media3, OkHttp WebSocket, Gradle product flavors, GitHub Actions. - -## Global Constraints - -- Default disabled and never misleading when unavailable. -- Never commit Discord session tokens, secrets, keystores, or signing credentials. -- FOSS compiles without proprietary artifacts or Discord operations. -- Disclose that the selected Gateway user-session method is unofficial and carries account risk. -- Preserve existing player architecture and throttle position sampling to five seconds. - ---- - -### Task 1: Preferences and settings state - -**Files:** `DiscordPreferences.kt`, `DiscordSettingsState.kt`, focused JVM tests. - -- [ ] Add failing tests for disabled default, persistence, and all row subtitle states. -- [ ] Run the focused test and observe the expected red result. -- [ ] Implement the DataStore component and pure state derivation. -- [ ] Run focused and neighboring Discord tests green. -- [ ] Commit `feat(discord): persist opt-in settings state`. - -### Task 2: Playback snapshot selection - -**Files:** `DiscordPlaybackSource.kt`, `ShortsPlayerPool.kt`, `ShortsScreen.kt`, focused JVM tests. - -- [ ] Add failing tests for video, live, Shorts, music, overlap priority, pause, and media switching. -- [ ] Run and observe red. -- [ ] Implement pure candidates/selection and event-driven plus five-second sampling. -- [ ] Run focused tests green. -- [ ] Commit `feat(discord): select active playback snapshots`. - -### Task 3: Coordinator and service lifecycle - -**Files:** coordinator tests/code, `DiscordPresenceService.kt`, service tests. - -- [ ] Add failing tests for disable, unlink, lifecycle shutdown, retries, stale clears, and transport failure. -- [ ] Run and observe red. -- [ ] Implement minimal lifecycle and error behavior. -- [ ] Run Discord tests green. -- [ ] Commit `feat(discord): manage presence lifecycle`. - -### Task 4: Settings UI and navigation - -**Files:** `DiscordSettingsScreen.kt`, `SettingsScreen.kt`, `FlowNavigation.kt`, `strings.xml`. - -- [ ] Add testable state/copy assertions before UI wiring. -- [ ] Add the search-indexed Content & Playback row and dedicated Material 3 screen. -- [ ] Compile the GitHub and FOSS Kotlin variants. -- [ ] Commit `feat(settings): expose Discord Rich Presence`. - -### Task 5: Flavor factories and Gateway adapter - -**Files:** flavor factories, GitHub Gateway adapter, Gradle, manifests, adapter tests. - -- [ ] Add failing fake-bridge adapter tests for link/connect/update/clear/unlink/error paths. -- [ ] Run and observe red. -- [ ] Implement the FOSS factory and GitHub Kizzy-style Gateway adapter. -- [ ] Keep WebView linking and Gateway operations out of the FOSS source set. -- [ ] Run adapter tests and flavor builds. -- [ ] Commit `feat(discord): add Kizzy-style Gateway transport`. - -### Task 6: Application and Activity wiring - -**Files:** `FlowApplication.kt`, `MainActivity.kt`. - -- [ ] Add lifecycle behavior tests to the service first. -- [ ] Initialize once at application scope and weakly attach/detach Activity. -- [ ] Clear and close on final lifecycle shutdown without disrupting background playback semantics. -- [ ] Run lifecycle tests and builds. -- [ ] Commit `feat(discord): wire application lifecycle`. - -### Task 7: CI, documentation, and end-to-end verification - -**Files:** `.github/workflows/build.yml`, `docs/discord-rich-presence.md`, `.gitignore`. - -- [ ] Make CI test before packaging, verify APK existence, calculate SHA-256, and upload `flow-discord-rpc-nightly-apk`. -- [ ] Document privacy, account risk, build, test, limitations, and troubleshooting. -- [ ] Run all requested unit, lint, and assemble task equivalents. -- [ ] Inspect APK contents, manifest, commit, artifact, and digest. -- [ ] Review the full `main...HEAD` diff for leaks, races, stale state, flavor leakage, and misleading UI. -- [ ] Commit `ci(discord): verify nightly integration` and update draft PR #1. From 5f88319324b58d8b25b65f5f88ef715bee141b0e Mon Sep 17 00:00:00 2001 From: Pasta Date: Wed, 15 Jul 2026 09:03:33 +0300 Subject: [PATCH 48/74] file that is not needed --- ...2026-07-14-discord-rich-presence-design.md | 33 ------------------- 1 file changed, 33 deletions(-) delete mode 100644 docs/superpowers/specs/2026-07-14-discord-rich-presence-design.md diff --git a/docs/superpowers/specs/2026-07-14-discord-rich-presence-design.md b/docs/superpowers/specs/2026-07-14-discord-rich-presence-design.md deleted file mode 100644 index 275ed6ec..00000000 --- a/docs/superpowers/specs/2026-07-14-discord-rich-presence-design.md +++ /dev/null @@ -1,33 +0,0 @@ -# Discord Rich Presence Design - -## Goal - -Expose an opt-in Discord Rich Presence setting and publish current Flow playback through a Kizzy-style Discord Gateway transport in GitHub builds, while keeping FOSS builds network-free and honest about unavailability. - -## Architecture - -- `DiscordPreferences` owns only the enabled flag and the last non-sensitive account label. -- `DiscordPlaybackSource` converts the existing video, Shorts, and music managers into immutable snapshots. Active playback priority is Shorts, then regular video/live, then music. -- `DiscordPresenceRuntime` owns transport connection, token storage, coordinator lifetime, Activity attachment, enable/disable, link, retry, unlink, and shutdown. -- Common code depends on `DiscordPresenceTransport`. Each flavor supplies `DiscordPresenceTransportFactory`: FOSS returns the fail-closed adapter; GitHub returns the Gateway adapter. -- The GitHub adapter implements Gateway identify, heartbeat, presence update, clearing, account state, and external-image resolution behind the transport seam. It is inspired by Kizzy without vendoring Kizzy source files. - -## User experience - -The row is indexed in Settings search and appears in Content & Playback for both flavors. Its subtitle is derived from availability, enabled state, connection state, account label, and errors. The dedicated screen contains the opt-in switch, status, connect/retry or disconnect action, account label, playback-data privacy copy, and an explicit unsupported-client warning. Enabling is rejected when the transport is unavailable. - -## Playback and lifecycle - -Snapshots are sampled at five-second intervals and also react immediately to player and metadata changes. This retains the existing policy's deduplication and seek correction without high-frequency Gateway calls. Pause, stop, media switches, disable, unlink, and shutdown clear presence. `MainActivity` is held weakly by the runtime and detached on destruction. - -## Gateway requirements and risk - -The public Discord application ID is compiled into the GitHub flavor; no client secret or private artifact is used. Linking uses an embedded Discord login and a Discord user-session token encrypted with Android Keystore. This is not OAuth or an official Discord-supported mobile integration. Discord prohibits self-bots and may restrict or terminate accounts, so the risk is disclosed in the UI. - -## Failure behavior - -The FOSS adapter produces `Unavailable in this build`; it never presents an enabled state. Transport failures preserve the opt-in preference when retry is meaningful, expose a concise error, and clear stale presence. Unlink deletes the encrypted session and non-sensitive account label. - -## Verification - -Focused JVM tests cover preferences, settings derivation, snapshot selection/mapping, policy, coordinator clearing/failures, FOSS behavior, and Gateway payload behavior. GitHub Actions runs unit tests before lint and packaging, uploads the exact nightly APK with missing-file errors enabled, and records the APK digest. From db2ccd642ba64448d130a41789b94866556dfb23 Mon Sep 17 00:00:00 2001 From: Pasta Date: Thu, 16 Jul 2026 11:55:41 +0300 Subject: [PATCH 49/74] feat(tv): add native Android TV interface --- .github/workflows/apply-tv-integration.yml | 28 +++ .tv-bootstrap/integration.patch.gz.b64 | 1 + .../java/io/github/aedev/flow/TvActivity.kt | 18 ++ .../flow/data/local/AppUiModePreferences.kt | 31 +++ .../github/aedev/flow/platform/AppUiMode.kt | 25 +++ .../flow/platform/DeviceFormFactorDetector.kt | 22 ++ .../screens/settings/InterfaceModeDialog.kt | 71 +++++++ .../io/github/aedev/flow/ui/tv/FlowTvApp.kt | 116 +++++++++++ .../flow/ui/tv/components/TvFocusableCard.kt | 64 ++++++ .../flow/ui/tv/components/TvNavigationRail.kt | 78 +++++++ .../flow/ui/tv/components/TvScreenStates.kt | 51 +++++ .../flow/ui/tv/components/TvVideoCard.kt | 114 ++++++++++ .../flow/ui/tv/input/TvPlayerKeyMapper.kt | 26 +++ .../flow/ui/tv/navigation/TvDestination.kt | 31 +++ .../aedev/flow/ui/tv/screens/TvHomeScreen.kt | 141 +++++++++++++ .../flow/ui/tv/screens/TvLibraryScreen.kt | 140 +++++++++++++ .../flow/ui/tv/screens/TvPlayerScreen.kt | 195 ++++++++++++++++++ .../flow/ui/tv/screens/TvSearchScreen.kt | 121 +++++++++++ .../flow/ui/tv/screens/TvSettingsScreen.kt | 89 ++++++++ .../ui/tv/screens/TvSubscriptionsScreen.kt | 119 +++++++++++ app/src/main/res/drawable-xhdpi/tv_banner.png | Bin 0 -> 2430 bytes app/src/main/res/values/tv_strings.xml | 35 ++++ .../aedev/flow/platform/AppUiModeTest.kt | 28 +++ .../flow/ui/tv/input/TvPlayerKeyMapperTest.kt | 25 +++ .../ui/tv/navigation/TvDestinationTest.kt | 24 +++ 25 files changed, 1593 insertions(+) create mode 100644 .github/workflows/apply-tv-integration.yml create mode 100644 .tv-bootstrap/integration.patch.gz.b64 create mode 100644 app/src/main/java/io/github/aedev/flow/TvActivity.kt create mode 100644 app/src/main/java/io/github/aedev/flow/data/local/AppUiModePreferences.kt create mode 100644 app/src/main/java/io/github/aedev/flow/platform/AppUiMode.kt create mode 100644 app/src/main/java/io/github/aedev/flow/platform/DeviceFormFactorDetector.kt create mode 100644 app/src/main/java/io/github/aedev/flow/ui/screens/settings/InterfaceModeDialog.kt create mode 100644 app/src/main/java/io/github/aedev/flow/ui/tv/FlowTvApp.kt create mode 100644 app/src/main/java/io/github/aedev/flow/ui/tv/components/TvFocusableCard.kt create mode 100644 app/src/main/java/io/github/aedev/flow/ui/tv/components/TvNavigationRail.kt create mode 100644 app/src/main/java/io/github/aedev/flow/ui/tv/components/TvScreenStates.kt create mode 100644 app/src/main/java/io/github/aedev/flow/ui/tv/components/TvVideoCard.kt create mode 100644 app/src/main/java/io/github/aedev/flow/ui/tv/input/TvPlayerKeyMapper.kt create mode 100644 app/src/main/java/io/github/aedev/flow/ui/tv/navigation/TvDestination.kt create mode 100644 app/src/main/java/io/github/aedev/flow/ui/tv/screens/TvHomeScreen.kt create mode 100644 app/src/main/java/io/github/aedev/flow/ui/tv/screens/TvLibraryScreen.kt create mode 100644 app/src/main/java/io/github/aedev/flow/ui/tv/screens/TvPlayerScreen.kt create mode 100644 app/src/main/java/io/github/aedev/flow/ui/tv/screens/TvSearchScreen.kt create mode 100644 app/src/main/java/io/github/aedev/flow/ui/tv/screens/TvSettingsScreen.kt create mode 100644 app/src/main/java/io/github/aedev/flow/ui/tv/screens/TvSubscriptionsScreen.kt create mode 100644 app/src/main/res/drawable-xhdpi/tv_banner.png create mode 100644 app/src/main/res/values/tv_strings.xml create mode 100644 app/src/test/java/io/github/aedev/flow/platform/AppUiModeTest.kt create mode 100644 app/src/test/java/io/github/aedev/flow/ui/tv/input/TvPlayerKeyMapperTest.kt create mode 100644 app/src/test/java/io/github/aedev/flow/ui/tv/navigation/TvDestinationTest.kt diff --git a/.github/workflows/apply-tv-integration.yml b/.github/workflows/apply-tv-integration.yml new file mode 100644 index 00000000..d8573d8c --- /dev/null +++ b/.github/workflows/apply-tv-integration.yml @@ -0,0 +1,28 @@ +name: Apply TV integration patch + +on: + push: + branches: + - feat/android-tv-support + +permissions: + contents: write + +jobs: + apply: + if: ${{ github.actor != 'github-actions[bot]' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Apply integration patch + run: | + base64 --decode .tv-bootstrap/integration.patch.gz.b64 | gzip --decompress > /tmp/tv-integration.patch + git apply --check /tmp/tv-integration.patch + git apply /tmp/tv-integration.patch + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add app/src/main/AndroidManifest.xml app/src/main/java/io/github/aedev/flow/MainActivity.kt app/src/main/java/io/github/aedev/flow/ui/screens/settings/SettingsScreen.kt + git commit -m "feat(tv): integrate TV runtime and settings" + git push diff --git a/.tv-bootstrap/integration.patch.gz.b64 b/.tv-bootstrap/integration.patch.gz.b64 new file mode 100644 index 00000000..5a2d3838 --- /dev/null +++ b/.tv-bootstrap/integration.patch.gz.b64 @@ -0,0 +1 @@ +H4sICDGcWGoAA3R2LWludGVncmF0aW9uLnBhdGNoAMVbW3PiuBJ+z6/QUqe24AwRIfekJjMhhD1hJxcqMLNV54VSbAE6MbZXspmwU/nvp1u2wQbfyGxYP8wEu7slfX2V1TbFaER2d8fCI6zBXLehpNGYMmE3WrYpHWHeMVuMuPLoy9QiT4UkO8I2+QvhT8a+uXdMKT87Mdi+SZp7e8eHhzu7u7slxtn58OFDmbEuL8lus35MPjTrZ+Tycod8/IyznHGphGNfVJp0r0K4bTimsMcXFd8b7Z5WPn8CumkohgC9rc5ZIPyiMvE897zRUMaET5mi4X1qOFOYzXNDctUI71VAzM4HAtdHX3G1O+LM8yUn4eNzm035RSUSoJyR951JTi3O7CdmPFcWhJL/6QvJYfARsxSvkMan8nInTJparuf4xkQZknO7QPQOWQp3uZwKhWBlyF8S0O79oPN43xloKW+U0Wq3O/3+EKT88fD4ZdgftAadn5H3R+tLZ3j70P6ihaA5HO2jPRwd1U+0QWixYEeWMJgHHMEdvJKy6W+W8721pKusEzILSK5Adb57UfGkzyuBmuI0T8y2ubyoXJqSfWdPFm94s2FwM0WiMNBIL6fCnTK3IYyhxXwbLC+N1mJP3AJi5UmwZXSNIc48hVI6vm12s0QP9eOKxur0ELE6Pa03zxZgpcMD9k/HjjO2+MIlxlNFDSYpSwUtMSGuHF8aIOYSvK3BfM+ZOp6Y8SGuwuTKCExgCedHZsBz4c2Xt1JUNpi1QrJKOl2uNlI5+IvrSA8dZkXBSVUglHeOCZNQoAyLDxw3gzbwyAcpuO1pjC4qFjxTBnOzxHsQerjW9BxmPMBf2jpp32PSg/Eqn5KMH4XtgfjdkbA8LlceLuDMdKeAmwY09K7VvV+GoIQUUDIfO3KeLyeiored1v1Vq/1leNv6et++6Tyui/3YyJr6x0ZkAouIlTSMxNRWDOMO8sXSNFIpVxVtZqbB/7EZawinAY8mPuQkbvJZYwT6aMSHoc/easIqzRimTPPs7Mg4alK6z05OTo/3clJmedEpibQ8s06vJ/VTyK8n9eYexAgipohbBOMLJkbXUZxK3/YEGOq/dzJJRhh4tA9Qi80d36NXzssm5GAk1h176Yu/eDabLyh4phgJLnc+5BC5FvNGjpzSW8dgVtuxR2LsyzBHbMLn8RdvwSEcGsBKNawUYaWwCkYtJA+YruE3lDFsHJtiESOkpq8CQ05P8hGXUNNwlT8qTHTOJf2P5Twxq6d/QADxeD4XrDDMhIV03iIy6RilreXwEPLuh8PjerMZs5Z0CZ6wFP3qwiJ5hEZphq49cvKpbe59d+QzAteTzsu81BAhZj1hYMnVtcM/brjlFulqYRcLTW1C/+g4Xkn6az4TBv8N/vwNgqEjr7nH8f9CfWkjtiHaRiBeC2Y54/xhUc+zQMmzQqO48oVlBq607kNR5Kb9ufL49IqBOUKCS3M2KGcVrFeF9Szt6x/94EcblgHFO4QFYSsPirL4U22EJ6e6rDnUJSAx4Kki8dBGzkk7wiK6V62RH1CB6BzhSjEDeMiMSeIK91ooLDfbE248/+48nRP45zO5ILZvWSkM3MbNRguKnJ5wgUxX3kFii9MZDKoxc6F8IFz8Te8errq3ndXpjHybzITJHXRm3EJod76HlFdV+Nc56dre59o56esSESR+n3CbBA/18laS4AudclOwA7SMKeAZBAmqi/Jh9/q2Q3Y/kQr+EZSLzdMjvdc62683DzeFNn7h+tXE+R5ojjzNiYQAMn3ikvwgU9/DUk0v7mFUxfRcI68RGEsRFjGC4AsLjcdiavgS4qOXrDRC+mWQj3Etb2bz+iIWeIE3NuG00FyNVcXhvHARa2JZxIsgJMagU7gLmrEs8O6W0nBUhS08cNrIWJCRtr4OHu5ag267ti7eXIkVsYlXE2iAn6OweoQpqmytAMyKPNTUf1Qj3iRn1qpDq18gQHGbYM14dXXSK/L64AKd0QgGBOzXvWgpe81mGg2inZhAHCWMGBKNz5swj0yAi9vcJGCPHoFNqN5crnF3R0RXJXVtvcDKSRuFPHJdTMogBhGMS5yZxBlpEhtWAov+2qXaiw4OmvUjcKMDyJLHP+VGcUiFuuYcTM5+7k8wmoI5eROhLhMl3QpRtjQH0LjzlTCCkPDI//TxXUmq0HRawH43TXpYXFTTn+IVuqCuKUCfusrQtpnNomm+MSmY7UUs4c8cLsNXsBHV4/SYxT1PO3bK3RwZSueyWzGeBBPWrnyRertQyjWTz+tCEnfLy1iikf4gR5Jja8L2hNljnMcPYvOgxIN8kM2GlxebfsSUz6EMsB8a7KjBvnNp8TKXtTNVfIluNRquli/jNfvxazEmIXgxaGYhzqWQWSolZHt/bMIhq+GA74VOe91rYiC5kXsVoZTukhH7u6KVsoJqNPB7odZfCRNvcbmMCLQV71ud/1YcsZ+MZ2/HbDXebhGyxNDbRuztQSwzx2wjnKWvYhnY1spVvMSIVGPVZnyTNfiWWuRG12LLW82m0bMMK6pvuDPrmgDGyp16Pr9QQcF2sVrCFfA5dkQN+wvlw05Oq9IIfkTPoGh8zRGUgdor4bBnLUKnGJvsUi6XLbecKxiwdEmXK2ejsq6EpFKl3SZyisu7AuPJi525rHillnmFXCv+X0iPV4lyr1DOaz5Jnn/gVab0K4fYerzcGmblomX8+mnUypeEhXMpKAu3gWJ+efj+aJYsFYuhKCgXt4FlQdm4LTCLSsiSWGaWkduDMquc3DqSPxckC8vLfwDRrQfOf6yeTH/ld5Hx4E3VaYlIv1q+pr+jjK43bolKYJzJm4Nv9hZsU2xzNnOpuOa/VFnDNBu09Ec5Vp+1g1h99x5djQbZp2Rw0yH93m2rf0P67cdO555U/7vb1V0QA8fNUDru5ZanRrh50/1eZ3t45HzcPNAtgG96mR4bUB+w3YoRN+aGxW+dcbUCUc5z3EqMCGfyi1CL8+HFHzoF/Por+SX9BJkK1XNc39Wz0EdyCbhloH5uxtqF8ERj0YMYNNi4UxotA4/BaYDg8OGx27kftAbdh/th7+Fx8NjqDpLKwWmvHZisnzumbovfe26vSZ1rhIO5audS2Hc31q1r6CqpRyF5J/pKC6GBrMDZeo5j0TH3uniKbBu8WqMu8xVvWdZq2HnVlnbc1Mefx4d7bz1YdmZcSjAwfZTr2F8Vl7eczfiNgAyTWJPy0VzWSJaYFeoSX29IDrYXa3nEXbs+sLoBvVkwgOSGI83ehClerUQH9JU6qayMTFhwnH3xr3DM4GfHxpNas1LkPwlZcWqIBg+2NSeAF5ekJ3oETz1xcVoOhFiCCsTTbJgxgXkSVzpjyZXiZqCUowOtlJOzzZWywFJbBAxyTq7AKDhDy8Zz5/qSBiN1H6p9oFIPEIRtwc04eXDKr0lry/s/3qSwuLCIVbdW0G+dxz74EO1ffxl27wfkI0ncH7Yfrjt9+pAnKCc0CbeFLa7crOIpX23NyZA9DQckfEv/nC8aQYOHaqhQZCOSHTZ7bNJVV0pc2Gt3+nTGT/gTpcb+iJ2Zp2/qtSs34EYdeOVEhp1WYPfw72lRo1UYAjH/YwAo12sVtXctthUl6fkL5AGTm23HcmRBh1qsvS0MybHetp9qjNu0U6tE3xkQ38KuxgfsVhGMNUePlWPT3+GfHpMqRvLseFB76d4mB+TZXNFroVzmYUu20go9011LYIdBHYMpIqn5auCNJTpfYnThcH3cGMW6P9qJB1G+QxZ3VRPJZpc1RS26PqKNT6KtZbOmmVxR2H2QJjetUeYPUOIiCUVdM9+Y5fPc1pmoL6mL6WjEDG34QZdcfpOSjrO1qIjBaT/p7wSwMSS57iJzvlrwKeE5ch7HRAvfiRLnlYRQQoKWTt2dta+/fGjun9Sbx4UWZAqFqX/xHMXgGq+D+4CvQnQfw7ZeFafLBbuWPYY/nTI5x91W6oMBLLOaNq9a9GlAtANI0U+iaE15vnJSoTguQO+eFja1sqWFsn9J9CMoTNLeYiR9bPHyId1WAUf9jgalrTWErb4agM1e0HqoJ5BlmGHtkeAO7fk1ap0Hc8HwrwtAooIu4IWWwrwQbErQBtZCAE3SZHelBX6QIRtIzZZXYoCAMHuUvduatvj9A1377R+eBrVfrsFjo2j4rGN7sIlBVC2hPHDfZZkTkvQ5k8YE6eZV/JJG0QcfAzg3aU/NjQkkt/G8ntofLlHLugHzMfzspZrq848hlf41RC+XjjU0dCVc+5tE29yXzBriNxbA6w2nzB0q/8kTnsVhDMUNxLxjj2FldTC3ezYTY8B64PQgKzk2s6BWrtU3gid4J/43LSAKO0Ph8elQVxgw70pFzx3iOMwDt27JuS/v12IuUTz1wexdZi0inx1qp9/CGCs6juGRG0Zwx4NRZANtRxXRuywKPw6zwgFgJWFxE6vDbvGTuIwlxsh+ZoEgRr8zMHzv3daIXwG+j1lE0jMNYs1xcPmwL3zGGLQJTgPffh8jCFIFrmTh01sZpiRkQTmciDj6C4SmLuZPDg7rzQO9P4NBstp3g1TftgB2fCWcGsnSOTNelN44UvwFCYVZ1wLfo8hq9EUUdZmJXyfgNwH6HfbRMTVddC7crsHvOxhXgr8EXx3qu31D/618HTHC8xB45M6rzHInDBMyPRrVMt4ER0m3CwjktIugmYKk9ZCczaLVs3wH+Z4xO3sSkZlsYx4xm8ye0NKSimN9upQMRf5TVpW6yqRVZWGRYVWLrJXN+I46Taa1nf8DdV04R4BBAAA= \ No newline at end of file diff --git a/app/src/main/java/io/github/aedev/flow/TvActivity.kt b/app/src/main/java/io/github/aedev/flow/TvActivity.kt new file mode 100644 index 00000000..e2351b38 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/TvActivity.kt @@ -0,0 +1,18 @@ +package io.github.aedev.flow + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity + +/** Leanback launcher bridge that keeps [MainActivity] as Flow's single runtime host. */ +class TvActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val forwardedIntent = Intent(intent).apply { + setClass(this@TvActivity, MainActivity::class.java) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + } + startActivity(forwardedIntent) + finish() + } +} diff --git a/app/src/main/java/io/github/aedev/flow/data/local/AppUiModePreferences.kt b/app/src/main/java/io/github/aedev/flow/data/local/AppUiModePreferences.kt new file mode 100644 index 00000000..eaa6500f --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/data/local/AppUiModePreferences.kt @@ -0,0 +1,31 @@ +package io.github.aedev.flow.data.local + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import io.github.aedev.flow.platform.AppUiMode +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.appUiModeDataStore: DataStore by safePreferencesDataStore(name = "app_ui_mode") + +/** Stores the interface override independently from playback preferences. */ +class AppUiModePreferences(context: Context) { + private val appContext = context.applicationContext + + val mode: Flow = appContext.appUiModeDataStore.data.map { preferences -> + AppUiMode.fromStorage(preferences[MODE]) + } + + suspend fun setMode(mode: AppUiMode) { + appContext.appUiModeDataStore.edit { preferences -> + preferences[MODE] = mode.name + } + } + + private companion object { + val MODE = stringPreferencesKey("mode") + } +} diff --git a/app/src/main/java/io/github/aedev/flow/platform/AppUiMode.kt b/app/src/main/java/io/github/aedev/flow/platform/AppUiMode.kt new file mode 100644 index 00000000..0b0337d5 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/platform/AppUiMode.kt @@ -0,0 +1,25 @@ +package io.github.aedev.flow.platform + +/** User-selected interface mode for the shared Flow APK. */ +enum class AppUiMode { + AUTOMATIC, + MOBILE, + TV; + + fun resolve(deviceFormFactor: DeviceFormFactor): AppUiRoot = when (this) { + AUTOMATIC -> if (deviceFormFactor == DeviceFormFactor.TV) AppUiRoot.TV else AppUiRoot.MOBILE + MOBILE -> AppUiRoot.MOBILE + TV -> AppUiRoot.TV + } + + companion object { + fun fromStorage(value: String?): AppUiMode = + entries.firstOrNull { it.name == value } ?: AUTOMATIC + } +} + +/** The UI root rendered by [io.github.aedev.flow.MainActivity]. */ +enum class AppUiRoot { + MOBILE, + TV, +} diff --git a/app/src/main/java/io/github/aedev/flow/platform/DeviceFormFactorDetector.kt b/app/src/main/java/io/github/aedev/flow/platform/DeviceFormFactorDetector.kt new file mode 100644 index 00000000..9532a3b2 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/platform/DeviceFormFactorDetector.kt @@ -0,0 +1,22 @@ +package io.github.aedev.flow.platform + +import android.app.UiModeManager +import android.content.Context +import android.content.pm.PackageManager +import android.content.res.Configuration + +/** Runtime device categories relevant to Flow's interface selection. */ +enum class DeviceFormFactor { + MOBILE, + TV, +} + +/** Detects television devices without making touchscreen availability mandatory. */ +object DeviceFormFactorDetector { + fun detect(context: Context): DeviceFormFactor { + val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as? UiModeManager + val isTelevisionMode = uiModeManager?.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION + val hasLeanback = context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) + return if (isTelevisionMode || hasLeanback) DeviceFormFactor.TV else DeviceFormFactor.MOBILE + } +} diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/InterfaceModeDialog.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/InterfaceModeDialog.kt new file mode 100644 index 00000000..14cccc92 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/InterfaceModeDialog.kt @@ -0,0 +1,71 @@ +package io.github.aedev.flow.ui.screens.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.github.aedev.flow.R +import io.github.aedev.flow.platform.AppUiMode + +@Composable +fun InterfaceModeDialog( + selected: AppUiMode, + onSelected: (AppUiMode) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.interface_mode_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + AppUiMode.entries.forEach { mode -> + val title = when (mode) { + AppUiMode.AUTOMATIC -> stringResource(R.string.interface_mode_automatic) + AppUiMode.MOBILE -> stringResource(R.string.interface_mode_mobile) + AppUiMode.TV -> stringResource(R.string.interface_mode_tv) + } + val summary = when (mode) { + AppUiMode.AUTOMATIC -> stringResource(R.string.interface_mode_automatic_summary) + AppUiMode.MOBILE -> stringResource(R.string.interface_mode_mobile_summary) + AppUiMode.TV -> stringResource(R.string.interface_mode_tv_summary) + } + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onSelected(mode) } + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + RadioButton(selected = selected == mode, onClick = null) + Column { + Text(title, style = MaterialTheme.typography.titleMedium) + Text( + text = summary, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.close)) + } + }, + ) +} diff --git a/app/src/main/java/io/github/aedev/flow/ui/tv/FlowTvApp.kt b/app/src/main/java/io/github/aedev/flow/ui/tv/FlowTvApp.kt new file mode 100644 index 00000000..261dd718 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/ui/tv/FlowTvApp.kt @@ -0,0 +1,116 @@ +package io.github.aedev.flow.ui.tv + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.media3.common.util.UnstableApi +import io.github.aedev.flow.data.model.Video +import io.github.aedev.flow.player.GlobalPlayerState +import io.github.aedev.flow.ui.screens.home.HomeViewModel +import io.github.aedev.flow.ui.screens.player.VideoPlayerViewModel +import io.github.aedev.flow.ui.screens.search.SearchViewModel +import io.github.aedev.flow.ui.screens.subscriptions.SubscriptionsViewModel +import io.github.aedev.flow.ui.tv.components.TvNavigationRail +import io.github.aedev.flow.ui.tv.navigation.TvDestination +import io.github.aedev.flow.ui.tv.screens.TvHomeScreen +import io.github.aedev.flow.ui.tv.screens.TvLibraryScreen +import io.github.aedev.flow.ui.tv.screens.TvPlayerScreen +import io.github.aedev.flow.ui.tv.screens.TvSearchScreen +import io.github.aedev.flow.ui.tv.screens.TvSettingsScreen +import io.github.aedev.flow.ui.tv.screens.TvSubscriptionsScreen + +@androidx.annotation.OptIn(UnstableApi::class) +@Composable +fun FlowTvApp( + deeplinkVideoId: String? = null, + isShort: Boolean = false, + onDeeplinkConsumed: () -> Unit = {}, +) { + val context = LocalContext.current + val activity = context as ComponentActivity + val homeViewModel: HomeViewModel = hiltViewModel(activity) + val playerViewModel: VideoPlayerViewModel = hiltViewModel(activity) + val subscriptionsViewModel: SubscriptionsViewModel = viewModel(viewModelStoreOwner = activity) + val searchViewModel: SearchViewModel = viewModel(viewModelStoreOwner = activity) + var destination by remember { mutableStateOf(TvDestination.HOME) } + val activeVideo by GlobalPlayerState.currentVideo.collectAsStateWithLifecycle() + + fun play(video: Video) { + GlobalPlayerState.setCurrentVideo(video) + playerViewModel.playVideo(video) + } + + LaunchedEffect(deeplinkVideoId) { + val videoId = deeplinkVideoId ?: return@LaunchedEffect + play( + Video( + id = videoId, + title = "", + channelName = "", + channelId = "", + thumbnailUrl = "", + duration = 0, + viewCount = 0L, + uploadDate = "", + isShort = isShort, + ) + ) + onDeeplinkConsumed() + } + + Box(modifier = Modifier.fillMaxSize()) { + Row(modifier = Modifier.fillMaxSize()) { + TvNavigationRail( + selected = destination, + onSelected = { destination = it }, + ) + when (destination) { + TvDestination.HOME -> TvHomeScreen( + viewModel = homeViewModel, + onVideoClick = ::play, + modifier = Modifier.weight(1f), + ) + TvDestination.SUBSCRIPTIONS -> TvSubscriptionsScreen( + viewModel = subscriptionsViewModel, + onVideoClick = ::play, + modifier = Modifier.weight(1f), + ) + TvDestination.SEARCH -> TvSearchScreen( + viewModel = searchViewModel, + onVideoClick = ::play, + modifier = Modifier.weight(1f), + ) + TvDestination.LIBRARY -> TvLibraryScreen( + onVideoClick = ::play, + modifier = Modifier.weight(1f), + ) + TvDestination.SETTINGS -> TvSettingsScreen( + modifier = Modifier.weight(1f), + ) + } + } + + activeVideo?.let { video -> + TvPlayerScreen( + video = video, + viewModel = playerViewModel, + onClose = { + playerViewModel.clearVideo() + GlobalPlayerState.setCurrentVideo(null) + }, + ) + } + } +} diff --git a/app/src/main/java/io/github/aedev/flow/ui/tv/components/TvFocusableCard.kt b/app/src/main/java/io/github/aedev/flow/ui/tv/components/TvFocusableCard.kt new file mode 100644 index 00000000..dbe0e35d --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/ui/tv/components/TvFocusableCard.kt @@ -0,0 +1,64 @@ +package io.github.aedev.flow.ui.tv.components + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.focus.onFocusChanged +import androidx.compose.ui.unit.dp + +/** Material 3 card with a visible ten-foot focus state. */ +@Composable +fun TvFocusableCard( + onClick: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit, +) { + var focused by remember { mutableStateOf(false) } + val scale by animateFloatAsState( + targetValue = if (focused) 1.05f else 1f, + label = "tvCardScale", + ) + + Card( + onClick = onClick, + modifier = modifier + .graphicsLayer { + scaleX = scale + scaleY = scale + } + .onFocusChanged { focused = it.isFocused }, + colors = CardDefaults.cardColors( + containerColor = if (focused) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainer + }, + contentColor = if (focused) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + ), + border = if (focused) { + BorderStroke(2.dp, MaterialTheme.colorScheme.outline) + } else { + null + }, + elevation = CardDefaults.cardElevation( + defaultElevation = if (focused) 8.dp else 1.dp, + ), + ) { + Box(content = content) + } +} diff --git a/app/src/main/java/io/github/aedev/flow/ui/tv/components/TvNavigationRail.kt b/app/src/main/java/io/github/aedev/flow/ui/tv/components/TvNavigationRail.kt new file mode 100644 index 00000000..b84da6a3 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/ui/tv/components/TvNavigationRail.kt @@ -0,0 +1,78 @@ +package io.github.aedev.flow.ui.tv.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.github.aedev.flow.ui.tv.navigation.TvDestination + +@Composable +fun TvNavigationRail( + selected: TvDestination, + onSelected: (TvDestination) -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .width(236.dp) + .fillMaxHeight(), + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Column( + modifier = Modifier.padding(horizontal = 20.dp, vertical = 32.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(io.github.aedev.flow.R.string.app_name), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) + Spacer(modifier = Modifier.size(8.dp)) + TvDestination.primary.forEach { destination -> + TvFocusableCard( + onClick = { onSelected(destination) }, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = destination.icon, + contentDescription = null, + tint = if (destination == selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + Spacer(modifier = Modifier.width(14.dp)) + Text( + text = stringResource(destination.labelRes), + style = MaterialTheme.typography.titleMedium, + fontWeight = if (destination == selected) FontWeight.Bold else FontWeight.Medium, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/io/github/aedev/flow/ui/tv/components/TvScreenStates.kt b/app/src/main/java/io/github/aedev/flow/ui/tv/components/TvScreenStates.kt new file mode 100644 index 00000000..869cafad --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/ui/tv/components/TvScreenStates.kt @@ -0,0 +1,51 @@ +package io.github.aedev.flow.ui.tv.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun TvLoadingState(modifier: Modifier = Modifier) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } +} + +@Composable +fun TvMessageState( + title: String, + message: String? = null, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + modifier = Modifier.padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + ) + if (!message.isNullOrBlank()) { + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } + } +} diff --git a/app/src/main/java/io/github/aedev/flow/ui/tv/components/TvVideoCard.kt b/app/src/main/java/io/github/aedev/flow/ui/tv/components/TvVideoCard.kt new file mode 100644 index 00000000..27780641 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/ui/tv/components/TvVideoCard.kt @@ -0,0 +1,114 @@ +package io.github.aedev.flow.ui.tv.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import io.github.aedev.flow.data.model.Video + +@Composable +fun TvVideoCard( + video: Video, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TvFocusableCard( + onClick = onClick, + modifier = modifier.width(280.dp), + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + .background(MaterialTheme.colorScheme.surfaceVariant), + ) { + AsyncImage( + model = video.thumbnailUrl, + contentDescription = video.title, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + if (video.duration > 0) { + Text( + text = formatTvDuration(video.duration), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onInverseSurface, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(8.dp) + .background( + color = MaterialTheme.colorScheme.inverseSurface, + shape = MaterialTheme.shapes.extraSmall, + ) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = video.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = video.channelName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +fun TvVideoRow( + videos: List