From 25009144982bd7d70d03ab7d61189980e78240f3 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 11 May 2026 14:22:32 -0400 Subject: [PATCH 1/2] fix(flutter): persistent release signing key via CI secret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update installs were failing at the system scanner step because every release APK was signed with a different signature: build.gradle.kts fell back to signingConfigs.getByName("debug"), and the debug keystore is generated per-machine — so every CI runner had a fresh random key. Android refuses to upgrade an installed app when the signature doesn't match, hence the "App not installed" failure after the scan. Two-part fix: 1. build.gradle.kts now defines a real "release" signing config when ANDROID_KEYSTORE_PATH is exported (with the matching password + alias env vars). Without those, falls through to the debug keystore so local `flutter run --release` still works. 2. flutter.yml decodes ANDROID_KEYSTORE_B64 (CI secret, base64 of the real release keystore) into a tmp path before the release-APK build step, then exports ANDROID_KEYSTORE_PATH + the three password/alias secrets as env vars. CI now hard-errors if the keystore secret is missing on a tagged build — we don't want another silent debug-keystore release. OPERATOR ACTION REQUIRED before the next tag: 1. Generate a release keystore (one-time): keytool -genkey -v -keystore minstrel-release.keystore \ -alias minstrel -keyalg RSA -keysize 2048 -validity 10000 2. Base64 it: base64 -w0 minstrel-release.keystore > keystore.b64 3. In Forgejo, add four repo secrets: ANDROID_KEYSTORE_B64 = contents of keystore.b64 ANDROID_KEY_ALIAS = minstrel ANDROID_STORE_PASSWORD = the password you set ANDROID_KEY_PASSWORD = same password (or different alias pwd) 4. Keep minstrel-release.keystore offline and backed up — losing it means you can never sign an upgrade for any existing install. 5. Existing installs from prior debug-keyed releases must be uninstalled before installing the first key-signed release. This is a one-time transition; future tagged builds will upgrade cleanly. --- .forgejo/workflows/flutter.yml | 27 +++++++++++++++++ flutter_client/android/app/build.gradle.kts | 33 +++++++++++++++++++-- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/.forgejo/workflows/flutter.yml b/.forgejo/workflows/flutter.yml index 0ff1066f..b677f62b 100644 --- a/.forgejo/workflows/flutter.yml +++ b/.forgejo/workflows/flutter.yml @@ -72,6 +72,25 @@ jobs: if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/') run: flutter build apk --debug + - name: Decode signing keystore + if: startsWith(github.ref, 'refs/tags/v') + # Reconstructs the release keystore from a base64-encoded + # CI secret so every tagged build is signed with the same + # key. Without this, each CI runner would generate its own + # debug keystore and Android would refuse to upgrade an + # existing install (signature mismatch). + shell: bash + env: + ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }} + run: | + if [ -z "${ANDROID_KEYSTORE_B64}" ]; then + echo "::error::ANDROID_KEYSTORE_B64 secret is missing — release builds need a consistent signing key" + exit 1 + fi + KEYSTORE_PATH="${RUNNER_TEMP}/minstrel-release.keystore" + echo "${ANDROID_KEYSTORE_B64}" | base64 -d > "${KEYSTORE_PATH}" + echo "ANDROID_KEYSTORE_PATH=${KEYSTORE_PATH}" >> "${GITHUB_ENV}" + - name: Build release APK if: startsWith(github.ref, 'refs/tags/v') # Inject the tag (sans leading 'v') as the build's --build-name @@ -79,7 +98,15 @@ jobs: # reports — otherwise the in-app update banner would always # think the user is behind because pubspec.yaml's static # version drifts from the actual release tag. + # + # ANDROID_KEYSTORE_PATH is set by the previous step; + # build.gradle.kts picks it up and signs the release APK with + # the operator's persistent keystore. shell: bash + env: + ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} run: | TAG="${GITHUB_REF#refs/tags/v}" flutter build apk --release --build-name="${TAG}" diff --git a/flutter_client/android/app/build.gradle.kts b/flutter_client/android/app/build.gradle.kts index afad756c..54f8d478 100644 --- a/flutter_client/android/app/build.gradle.kts +++ b/flutter_client/android/app/build.gradle.kts @@ -30,11 +30,38 @@ android { versionName = flutter.versionName } + // Real release signing config — populated only when CI exports + // ANDROID_KEYSTORE_PATH (decoded from a base64 secret) plus the + // matching password/alias env vars. Without these (local + // `flutter build apk --release` runs), the release build falls + // through to the debug keystore so local builds still work. + // + // Why this matters: Flutter's default `flutter run --release` and + // CI's earlier setup both signed with the per-machine debug + // keystore (~/.android/debug.keystore). Every CI runner generated + // its own debug key, so consecutive release APKs had different + // signatures and Android refused to upgrade an existing install. + val keystorePath: String? = System.getenv("ANDROID_KEYSTORE_PATH") + if (keystorePath != null && keystorePath.isNotEmpty()) { + signingConfigs { + create("release") { + storeFile = file(keystorePath) + storePassword = System.getenv("ANDROID_STORE_PASSWORD") + keyAlias = System.getenv("ANDROID_KEY_ALIAS") + keyPassword = System.getenv("ANDROID_KEY_PASSWORD") + } + } + } + buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + signingConfig = if (keystorePath != null && keystorePath.isNotEmpty()) { + signingConfigs.getByName("release") + } else { + // Local-only fallback so `flutter run --release` works + // without the keystore. CI must export ANDROID_KEYSTORE_PATH. + signingConfigs.getByName("debug") + } } } } From 8c00ee21c7e78da6eca860576de94c4981de4be4 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 11 May 2026 15:41:01 -0400 Subject: [PATCH 2/2] feat(flutter): infinite scroll on library Artists + Albums tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both providers were FutureProvider> returning a single page of 50, so once the user scrolled past 50 items the list just ended. Replace with AsyncNotifier> that exposes loadMore(): fetches the next page using items.length as the offset and appends to the existing list. Idempotent guard via _loadingMore flag — concurrent scroll events near the bottom collapse to a single fetch. Both tabs now wrap the GridView in NotificationListener that fires loadMore() when within 800px of maxScrollExtent. The lookahead is enough that the next page lands before the user hits the visible bottom on a typical phone scroll. Pull-to-refresh updated to invalidate + re-await the provider so a manual refresh always starts from the first page. --- .../lib/library/library_screen.dart | 193 ++++++++++++++---- 1 file changed, 151 insertions(+), 42 deletions(-) diff --git a/flutter_client/lib/library/library_screen.dart b/flutter_client/lib/library/library_screen.dart index c198fb7a..79f47e6e 100644 --- a/flutter_client/lib/library/library_screen.dart +++ b/flutter_client/lib/library/library_screen.dart @@ -37,13 +37,89 @@ final _meApiProvider = FutureProvider((ref) async { return MeApi(await ref.watch(dioProvider.future)); }); -final _libraryArtistsProvider = FutureProvider>((ref) async { - return (await ref.watch(_libraryListsApiProvider.future)).listArtists(); -}); +/// Paginated artist list. AsyncNotifier so the screen can call +/// `loadMore()` when the scroll approaches the bottom; subsequent +/// pages append to the existing items rather than replacing them. +class _LibraryArtistsNotifier extends AsyncNotifier> { + static const _pageSize = 50; + bool _loadingMore = false; -final _libraryAlbumsProvider = FutureProvider>((ref) async { - return (await ref.watch(_libraryListsApiProvider.future)).listAlbums(); -}); + @override + Future> build() async { + final api = await ref.watch(_libraryListsApiProvider.future); + return api.listArtists(limit: _pageSize, offset: 0); + } + + Future loadMore() async { + if (_loadingMore) return; + final cur = state.value; + if (cur == null) return; + if (cur.items.length >= cur.total) return; + _loadingMore = true; + try { + final api = await ref.read(_libraryListsApiProvider.future); + final next = await api.listArtists( + limit: _pageSize, + offset: cur.items.length, + ); + state = AsyncData(wire.Paged( + items: [...cur.items, ...next.items], + total: next.total, + limit: next.limit, + offset: 0, + )); + } catch (_) { + // Swallow — the next near-bottom event will retry. The current + // partial list stays visible. + } finally { + _loadingMore = false; + } + } +} + +final _libraryArtistsProvider = AsyncNotifierProvider< + _LibraryArtistsNotifier, + wire.Paged>(_LibraryArtistsNotifier.new); + +class _LibraryAlbumsNotifier extends AsyncNotifier> { + static const _pageSize = 50; + bool _loadingMore = false; + + @override + Future> build() async { + final api = await ref.watch(_libraryListsApiProvider.future); + return api.listAlbums(limit: _pageSize, offset: 0); + } + + Future loadMore() async { + if (_loadingMore) return; + final cur = state.value; + if (cur == null) return; + if (cur.items.length >= cur.total) return; + _loadingMore = true; + try { + final api = await ref.read(_libraryListsApiProvider.future); + final next = await api.listAlbums( + limit: _pageSize, + offset: cur.items.length, + ); + state = AsyncData(wire.Paged( + items: [...cur.items, ...next.items], + total: next.total, + limit: next.limit, + offset: 0, + )); + } catch (_) { + // Swallow — next scroll event will retry. + } finally { + _loadingMore = false; + } + } +} + +final _libraryAlbumsProvider = AsyncNotifierProvider< + _LibraryAlbumsNotifier, + wire.Paged>(_LibraryAlbumsNotifier.new); final _historyProvider = FutureProvider((ref) async { return (await ref.watch(_meApiProvider.future)).history(); @@ -134,24 +210,42 @@ class _ArtistsTab extends ConsumerWidget { data: (page) => page.items.isEmpty ? Center(child: Text("No artists yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center)) : RefreshIndicator( - onRefresh: () async => ref.refresh(_libraryArtistsProvider.future), - child: GridView.builder( - padding: const EdgeInsets.all(8), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - mainAxisSpacing: 8, - crossAxisSpacing: 8, - childAspectRatio: 0.78, - ), - itemCount: page.items.length, - itemBuilder: (ctx, i) { - final artist = page.items[i]; - return ArtistCard( - artist: artist, - onTap: () => ctx.push('/artists/${artist.id}', - extra: artist), - ); + onRefresh: () async { + ref.invalidate(_libraryArtistsProvider); + await ref.read(_libraryArtistsProvider.future); + }, + // Listen for scroll-near-bottom and ask the notifier + // to fetch the next page. 800px lookahead so the next + // page lands before the user reaches the visible end. + child: NotificationListener( + onNotification: (n) { + if (n.metrics.pixels >= + n.metrics.maxScrollExtent - 800) { + ref + .read(_libraryArtistsProvider.notifier) + .loadMore(); + } + return false; }, + child: GridView.builder( + padding: const EdgeInsets.all(8), + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 0.78, + ), + itemCount: page.items.length, + itemBuilder: (ctx, i) { + final artist = page.items[i]; + return ArtistCard( + artist: artist, + onTap: () => ctx.push('/artists/${artist.id}', + extra: artist), + ); + }, + ), ), ), ); @@ -170,7 +264,10 @@ class _AlbumsTab extends ConsumerWidget { data: (page) => page.items.isEmpty ? Center(child: Text("No albums yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center)) : RefreshIndicator( - onRefresh: () async => ref.refresh(_libraryAlbumsProvider.future), + onRefresh: () async { + ref.invalidate(_libraryAlbumsProvider); + await ref.read(_libraryAlbumsProvider.future); + }, // Same responsive 3-up grid as the artist detail // album list — LayoutBuilder + AlbumCard sized to the // cell, mainAxisExtent matched to actual card height. @@ -185,25 +282,37 @@ class _AlbumsTab extends ConsumerWidget { // cover (cellW - 16) + gap (8) + 2-line title (~36) // + artist line (~16) + small fudge. final cellH = (cellW - 16) + 8 + 36 + 16 + 4; - return GridView.builder( - padding: const EdgeInsets.all(sidePad), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: cols, - mainAxisExtent: cellH, - mainAxisSpacing: gap, - crossAxisSpacing: gap, - ), - itemCount: page.items.length, - itemBuilder: (ctx, i) { - final album = page.items[i]; - return AlbumCard( - album: album, - width: cellW, - titleMaxLines: 2, - onTap: () => ctx.push('/albums/${album.id}', - extra: album), - ); + return NotificationListener( + onNotification: (n) { + if (n.metrics.pixels >= + n.metrics.maxScrollExtent - 800) { + ref + .read(_libraryAlbumsProvider.notifier) + .loadMore(); + } + return false; }, + child: GridView.builder( + padding: const EdgeInsets.all(sidePad), + gridDelegate: + SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: cols, + mainAxisExtent: cellH, + mainAxisSpacing: gap, + crossAxisSpacing: gap, + ), + itemCount: page.items.length, + itemBuilder: (ctx, i) { + final album = page.items[i]; + return AlbumCard( + album: album, + width: cellW, + titleMaxLines: 2, + onTap: () => ctx.push('/albums/${album.id}', + extra: album), + ); + }, + ), ); }), ),