diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedPlaylistTrackDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedPlaylistTrackDao.kt index 02ff83b0..072cc11b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedPlaylistTrackDao.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedPlaylistTrackDao.kt @@ -4,6 +4,7 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.Transaction import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity import kotlinx.coroutines.flow.Flow @@ -35,10 +36,33 @@ interface CachedPlaylistTrackDao { @Query("SELECT MAX(position) FROM cached_playlist_tracks WHERE playlistId = :playlistId") suspend fun maxPosition(playlistId: String): Int? - /** Replace-all pattern for a playlist; called after a sync delta lands. */ @Query("DELETE FROM cached_playlist_tracks WHERE playlistId = :playlistId") suspend fun deleteByPlaylist(playlistId: String) + /** + * Replaces a playlist's whole membership in ONE transaction; called + * after a refresh or a sync delta lands. + * + * Atomic on purpose. Room's InvalidationTracker only notifies observers + * after the transaction commits, so [observeByPlaylist] never sees the + * empty gap between the delete and the re-insert. Un-transacted, that + * gap is a real observed state — it's what made every Home row visibly + * collapse to empty and refill before issue #2327 fixed the equivalent + * write in `CachedHomeIndexDao`. + * + * Nothing observes [observeByPlaylist] live today, so this is + * pre-emptive: it means making playlist detail cache-first later can't + * silently reintroduce that flicker. + */ + @Transaction + suspend fun replacePlaylistTracks( + playlistId: String, + rows: List, + ) { + deleteByPlaylist(playlistId) + if (rows.isNotEmpty()) upsertAll(rows) + } + @Query( "DELETE FROM cached_playlist_tracks " + "WHERE playlistId = :playlistId AND trackId IN (:trackIds)", diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt index b68db292..d7b697ce 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt @@ -119,9 +119,9 @@ class PlaylistsRepository @Inject constructor( throw e } playlistDao.upsertAll(listOf(wire.toPlaylistEntity())) - playlistTrackDao.deleteByPlaylist(id) - playlistTrackDao.upsertAll( - wire.tracks.mapNotNull { row -> + playlistTrackDao.replacePlaylistTracks( + playlistId = id, + rows = wire.tracks.mapNotNull { row -> row.trackId?.let { trackId -> CachedPlaylistTrackEntity( playlistId = id, diff --git a/ci-requirements.md b/ci-requirements.md index 712ccda0..cbf3592d 100644 --- a/ci-requirements.md +++ b/ci-requirements.md @@ -9,11 +9,17 @@ Minstrel's four workflows consume two CI images: ``` git.fabledsword.com/bvandeusen/ci-go:1.26 -git.fabledsword.com/bvandeusen/ci-flutter:3.44 +git.fabledsword.com/bvandeusen/ci-android:36 ``` -- `ci-go:1.26` — Go server tests (`.gitea/workflows/test-go.yml`), web SPA tests (`.gitea/workflows/test-web.yml`), and the release container build (`.gitea/workflows/release.yml`). -- `ci-flutter:3.44` — Flutter client tests + debug/release APK builds (`.gitea/workflows/flutter.yml`). +- `ci-go:1.26` — Go server tests (`.gitea/workflows/test-go.yml`), web SPA tests (`.gitea/workflows/test-web.yml`), and the release container build (`release.yml`'s `image-release` job). +- `ci-android:36` — native Kotlin/Compose client: ktlint + detekt + unit tests + debug APK (`.gitea/workflows/android.yml`), and the signed release APK (`release.yml`'s `android-release` job). + +**`ci-flutter` is no longer consumed.** The M8 rewrite replaced the Flutter +client with the native Android app and `flutter.yml` was removed; `ci-android` +took its place. `flutter_client/` is still in the tree but nothing builds it. +CI-Runner still publishes `ci-flutter` and will retire it once that directory +goes — so if the Flutter client is ever revived, say so there first. ## Image deps used @@ -25,13 +31,20 @@ git.fabledsword.com/bvandeusen/ci-flutter:3.44 - **docker buildx** — release container build + push in `release.yml`. - **curl** — release-asset polling / upload in `release.yml`. -### From `ci-flutter:3.44` -- **Flutter** (3.44 stable channel) — `flutter pub get`, `flutter analyze --fatal-infos`, `flutter test`, `flutter build apk` (debug + signed release). -- **Dart** — `dart run tool/gen_tokens.dart`, `dart run build_runner build` (drift codegen). -- **Android SDK + NDK + cmdline-tools + build-tools** — APK assembly + signing. -- **Java 25** — Gradle / Android build. +### From `ci-android:36` +- **JDK 25** — Gradle launcher + Android build. Requires Gradle 9.1.0+ in + `android/gradle/wrapper`; older Gradle rejects JDK 25 with an opaque `"25.0.3"` + error. The workflows also set `JAVA_TOOL_OPTIONS=--enable-native-access=ALL-UNNAMED` + to silence Gradle's launcher-JVM restricted-method warning. +- **Android SDK + cmdline-tools + build-tools 36.0.0** — APK assembly + signing. + No NDK: the native client has no C/C++ sources (this is why it isn't on + `ci-flutter`). +- **ktlint + detekt** — `./gradlew ktlintCheck` and `./gradlew detekt` in + `android.yml`. Image pins track `android/gradle/libs.versions.toml` so local + and CI checks agree. - **git** — `actions/checkout@v4` baseline (and any shell git operations). -- **base64 + curl** — keystore decode + release-asset upload in the tag-build path. +- **base64 + curl** — keystore decode + release-asset upload in `release.yml`'s + `android-release` job. ## Per-job tool installs @@ -39,10 +52,10 @@ None. ## Notes -- **Label/image split.** Workflows keep `runs-on: go-ci` / `runs-on: flutter-ci` as the scheduling label per the [`ci-runners.md`](https://…/FabledRulebook/ci-runners.md) "label = scheduling handle, image = `container.image`" pattern. The labels are intentional handles, not toolchain assertions. +- **Label/image split.** Workflows keep `runs-on: go-ci` / `runs-on: flutter-ci` as the scheduling label per the [`ci-runners.md`](https://…/FabledRulebook/ci-runners.md) "label = scheduling handle, image = `container.image`" pattern. The labels are intentional handles, not toolchain assertions — which is why the Android jobs still schedule on `flutter-ci` while pulling `ci-android:36`. Switch them to `android-ci` if that runner label is ever registered; nothing breaks either way. - **Integration-job docker-socket dependency.** `test-go.yml`'s integration job uses the runner's shared docker socket (`/var/run/docker.sock`) to bridge-IP-discover the per-job Postgres service container by name + network intersection — the dev compose's `minstrel-postgres-*` containers are explicitly skipped as belt-and-suspenders. Depends on `act_runner.valid_volumes` whitelisting the socket; if that ever stops auto-mounting, integration tests fail at the `docker inspect` step. - **Go toolchain pin.** `go.mod` is on `go 1.25.0` because `golang.org/x/crypto v0.51.0` declares 1.25 as its minimum. `ci-go:1.26` satisfies this with headroom. Future `x/crypto` bumps that move the Go floor should be paired with an image-tag bump in this file + the workflows. -- **In-app update channel polling.** `release.yml` polls Gitea's release-asset API for up to 15 min on tag pushes to fetch the APK that `flutter.yml` is concurrently attaching to the same release. The asset eventually appears because `flutter.yml` and `release.yml` run in parallel on the same tag; if the polling times out, the server image ships without the bundled update channel (graceful degradation, not a build failure). +- **In-app update channel — `needs:`, not polling.** `release.yml`'s `image-release` job declares `needs: [android-release]`, so on tag pushes the signed APK is guaranteed present before the image build starts — no polling window, no race. (The old cross-workflow polling against `flutter.yml` is gone with that workflow.) On non-tag `main` pushes `android-release` is skipped and `image-release` instead pulls the most recent release's APK and reconstructs its exact `versionName`, so `:latest` never ships without an update channel. It degrades to an empty `client/` — never a wrong version — if no release, asset, or tag commit-count can be resolved. - **Cache server reachability.** `test-web.yml` does NOT use `cache: 'npm'` on `actions/setup-node` — the Gitea Actions cache server isn't reachable from this runner's container network and `setup-node` was burning ~4m41s on ETIMEDOUT before failing open. With the migration to `ci-go:1.26`, `setup-node` is removed entirely (Node is in the image). The cache concern reappears if a future change re-introduces a network-dependent action. - **Artifacts — use the mirrored actions, never `actions/{upload,download}-artifact`.** ```yaml