diff --git a/.github/nightly-notes/2026-10-21-fix-datastore-statusbar-eq-playlists-botwall.md b/.github/nightly-notes/2026-10-21-fix-datastore-statusbar-eq-playlists-botwall.md new file mode 100644 index 0000000000..01c153a202 --- /dev/null +++ b/.github/nightly-notes/2026-10-21-fix-datastore-statusbar-eq-playlists-botwall.md @@ -0,0 +1,12 @@ +## New +- Playlists say when YouTube hides unavailable videos (#1358) +- The "not a bot" error can reset the YouTube session in one tap (#1354) + +## Fixed +- Flow no longer crashes on first launch while it creates its settings (#1355) (#1362) +- The crash screen shows a report once instead of on every launch (#1355) +- Status bar icons stay readable over the open video player in light theme (#1375) +- The system equalizer now changes how Flow sounds (#1373) +- Playlists made only of Shorts load again (#1358) +- A playlist that stops loading partway no longer shortens your saved copy (#1358) +- When every YouTube client asks to confirm you're not a bot, Flow retries once with a fresh session (#1354) diff --git a/AGENTS.md b/AGENTS.md index e5d34a48ea..5da916beb5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,7 +93,7 @@ Before writing any component, effect, animation, formatter, parser, scheduler, o | Paging | Paging 3 (`androidx.paging`, already used by search and channel) | New hand-rolled page cursors | | Playback | Media3 1.11.0 — exoplayer, hls, dash, session, datasource-okhttp | Custom player wiring, manifest handling, or media notifications | | Background work | WorkManager 2.11.2 | Custom threads, timers, alarms, or wakelocks | -| Persistence | Room 2.8.4, DataStore Preferences 1.1.1 | Hand-rolled file or SharedPreferences layers | +| Persistence | Room 2.8.4, DataStore Preferences 1.1.7 | Hand-rolled file or SharedPreferences layers | | JSON | `kotlinx.serialization` (103 files) | Gson for new code — it is kept only for legacy DTOs and carries an R8 reflection hazard | | HTTP | OkHttp 5.4.0 / Ktor 3.5.2 | Raw sockets or `HttpURLConnection` | | Dates and times | `java.time` — **core library desugaring is enabled, minSdk 26**, so all of it is available | `SimpleDateFormat`, `Calendar`, or manual millisecond arithmetic | 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 41bac3b6ca..62f3b93014 100644 --- a/app/src/main/java/io/github/aedev/flow/MainActivity.kt +++ b/app/src/main/java/io/github/aedev/flow/MainActivity.kt @@ -231,9 +231,14 @@ class MainActivity : ComponentActivity() { SideEffect { cachedAppUiRoot = appUiRoot } // Check for a crash that happened last session. - // If found, show the CrashReportScreen instead of the normal UI. + // If found, show the CrashReportScreen instead of the normal UI. It is cleared as soon + // as it is read, so leaving the screen any way shows it once, not on every launch. var pendingCrashLog by remember { - mutableStateOf(FlowCrashHandler.getLastCrash(applicationContext)) + mutableStateOf( + FlowCrashHandler.getLastCrash(applicationContext)?.also { + FlowCrashHandler.clearLastCrash(applicationContext) + }, + ) } if (pendingCrashLog != null) { @@ -241,10 +246,7 @@ class MainActivity : ComponentActivity() { FlowTheme(theme.copy(fontFamily = FlowFontFamily)) { CrashReportScreen( report = pendingCrashLog!!, - onContinue = { - FlowCrashHandler.clearLastCrash(applicationContext) - pendingCrashLog = null - }, + onContinue = { pendingCrashLog = null }, ) } return@setContent diff --git a/app/src/main/java/io/github/aedev/flow/data/local/SafeDataStore.kt b/app/src/main/java/io/github/aedev/flow/data/local/SafeDataStore.kt index b91312dac0..68dd470972 100644 --- a/app/src/main/java/io/github/aedev/flow/data/local/SafeDataStore.kt +++ b/app/src/main/java/io/github/aedev/flow/data/local/SafeDataStore.kt @@ -7,15 +7,58 @@ import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.retryWhen +import java.io.IOException import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KProperty private const val SAFE_DATA_STORE_TAG = "SafeDataStore" +private const val READ_RETRY_ATTEMPTS = 3 +private const val READ_RETRY_DELAY_MS = 100L -fun safePreferencesDataStore(name: String): ReadOnlyProperty> = - preferencesDataStore( - name = name, - corruptionHandler = ReplaceFileCorruptionHandler { corruption -> - Log.w(SAFE_DATA_STORE_TAG, "Resetting corrupted preferences DataStore: $name", corruption) - emptyPreferences() - } - ) +fun safePreferencesDataStore(name: String): ReadOnlyProperty> { + val delegate = + preferencesDataStore( + name = name, + corruptionHandler = + ReplaceFileCorruptionHandler { corruption -> + Log.w(SAFE_DATA_STORE_TAG, "Resetting corrupted preferences DataStore: $name", corruption) + emptyPreferences() + }, + ) + var wrapped: DataStore? = null + return object : ReadOnlyProperty> { + override fun getValue( + thisRef: Context, + property: KProperty<*>, + ): DataStore = + wrapped ?: synchronized(this) { + wrapped ?: ReadSafeDataStore(name, delegate.getValue(thisRef, property)).also { wrapped = it } + } + } +} + +internal class ReadSafeDataStore( + private val name: String, + private val delegate: DataStore, + private val retryDelayMs: Long = READ_RETRY_DELAY_MS, +) : DataStore { + // A failed read must not crash the collector. Retrying first keeps observers subscribed to + // later writes; only a read that keeps failing falls back to defaults and ends the stream. + override val data: Flow = + delegate.data + .retryWhen { cause, attempt -> + val retry = cause is IOException && attempt < READ_RETRY_ATTEMPTS + if (retry) delay(retryDelayMs * (attempt + 1)) + retry + }.catch { cause -> + if (cause !is IOException) throw cause + Log.w(SAFE_DATA_STORE_TAG, "Reading preferences DataStore failed, using defaults: $name", cause) + emit(emptyPreferences()) + } + + override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences = delegate.updateData(transform) +} diff --git a/app/src/main/java/io/github/aedev/flow/data/music/artwork/AnimatedArtworkCache.kt b/app/src/main/java/io/github/aedev/flow/data/music/artwork/AnimatedArtworkCache.kt index 6d70eaf29a..f0d74a50ea 100644 --- a/app/src/main/java/io/github/aedev/flow/data/music/artwork/AnimatedArtworkCache.kt +++ b/app/src/main/java/io/github/aedev/flow/data/music/artwork/AnimatedArtworkCache.kt @@ -3,7 +3,7 @@ package io.github.aedev.flow.data.music.artwork import android.content.Context import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore +import io.github.aedev.flow.data.local.safePreferencesDataStore import kotlinx.coroutines.flow.first /** A lookup answered before: the loop, or null when the album has none, and when it was asked. */ @@ -23,7 +23,7 @@ internal interface AnimatedArtworkCache { suspend fun remove(key: String) } -private val Context.animatedArtworkStore by preferencesDataStore(name = "animated_artwork") +private val Context.animatedArtworkStore by safePreferencesDataStore(name = "animated_artwork") /** Answers kept across launches, so the service is asked about an album once in [MAX_ENTRIES]. */ internal class DataStoreAnimatedArtworkCache( diff --git a/app/src/main/java/io/github/aedev/flow/data/repository/PlaylistWalk.kt b/app/src/main/java/io/github/aedev/flow/data/repository/PlaylistWalk.kt new file mode 100644 index 0000000000..588418f049 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/data/repository/PlaylistWalk.kt @@ -0,0 +1,38 @@ +package io.github.aedev.flow.data.repository + +import io.github.aedev.flow.data.model.Video + +/** The videos a walk collected, and whether it reached the playlist's last page. */ +internal data class PlaylistWalk( + val videos: List