8 Commits

Author SHA1 Message Date
bvandeusen 84f16c25f6 feat(ui): Lucide migration unit 1 — dependency + heart (#60)
Design system mandates Lucide, not Material. Foundation before the
mechanical Icons.* sweep:

- pubspec: add flutter_lucide ^1.11.0
- shared/widgets/lucide_heart.dart: LucideHeart renders the verified
  lucide-icons/lucide heart path as outline (stroke) or filled, via
  flutter_svg, tinted by color — Lucide ships no filled heart, so the
  liked state fills the same Lucide silhouette (user-chosen approach)
- like_button: use LucideHeart instead of Icons.favorite/_border
- notification drawables re-derived from the verbatim Lucide heart
  path (border = stroke, filled = fill); separators spaced for
  Android pathData

Unit 2 (mechanical Icons.* -> LucideIcons.* sweep) follows once this
is CI-green. pubspec.lock regenerated by CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:18:22 -04:00
bvandeusen 0d80a113fa feat(player): notification favorite via MediaControl.custom (6c)
Adds a heart action to the media notification implemented as a custom
control + customAction handler — NOT setRating, which is broken
upstream (audio_service #376: onSetRating never fires from a
notification tap) and previously blanked the Pixel Watch.

- res/drawable/ic_stat_favorite{,_border}.xml: white 24dp vector hearts
- audio_handler: favorite MediaControl.custom in _broadcastState
  (icon/label toggle by LikeBridge state; kept out of
  androidCompactActionIndices so compact/lock + Wear transport are
  unchanged); customAction override (Future<dynamic>, matches base)
  toggles the like then re-broadcasts; refreshFavoriteControl()
- player_provider: cascade refreshFavoriteControl into the likedIds
  listener so liking from TrackRow/kebab/SSE flips the notification heart

Reliable on phone notification + lock screen; Wear/Auto display of a
non-transport custom action is platform-dependent (not a bug).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:37:20 -04:00
bvandeusen 2500914498 fix(flutter): persistent release signing key via CI secret
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.
2026-05-11 14:22:32 -04:00
bvandeusen 02d9f39845 feat(flutter): in-app update banner + Android install intent (#397 phase 2)
Closes the operator-facing half of #397. Soft banner mounts at the
top of the shell when the server has a newer APK bundled at
/api/client/version; tap Install → APK downloads to cache → Android
PackageInstaller intent fires → user taps Install in the system
dialog → app restarts on the new version.

### Dart side

- lib/update/update_info.dart — wire shape for /api/client/version
- lib/update/client_update_provider.dart — Riverpod AsyncNotifier
  polling on app start + every 24h. Compares semver via pub_semver
  with non-semver string-equality fallback. Server 404 = "no update
  channel" = silent. Companion shouldShowUpdateBannerProvider gates
  on a per-version dismissed-set (in-memory; resets on restart).
- lib/update/installer.dart — UpdateInstaller wraps dio.download +
  the MethodChannel call to MainActivity.kt.
- lib/update/update_banner.dart — Material banner with idle /
  downloading (with progress) / error stages. Mounted in
  _ShellWithPlayerBar above the route content; SizedBox.shrink()
  when no update available so non-shell routes (login, server-url)
  see no banner anyway.
- isVersionNewer() extracted as a pure function and tested across
  semver, prerelease ordering, leading-v normalization, and
  non-semver fallback (date-tag style).

### Android side

- AndroidManifest.xml — REQUEST_INSTALL_PACKAGES permission;
  FileProvider with authorities ${applicationId}.fileprovider.
- res/xml/file_paths.xml — exposes the cache directory so the
  downloaded APK can be served via content:// URI (file:// is
  blocked since Android 7).
- MainActivity.kt — MethodChannel handler builds the FileProvider
  URI and fires Intent.ACTION_VIEW with FLAG_GRANT_READ_URI_PERMISSION
  so the system installer can read across the process boundary.

Phase 3 (CI sequencing — bake APK + version file into /app/client/
during release.yml's tag flow) is the remaining piece. Without it
the server returns 404 from /api/client/version and the banner
silently does nothing — graceful degradation, but no actual updates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:42:04 -04:00
bvandeusen 28b07c7ef4 fix(flutter/android): permit cleartext to 127.0.0.1 for just_audio loopback proxy
Audio playback failed against the prod HTTPS server with
"Cleartext HTTP traffic to 127.0.0.1 not permitted". Diagnostic
logging confirmed _baseUrl was correctly set to the prod URL — the
127.0.0.1 wasn't coming from the server or our URL construction.

Root cause: just_audio + audio_service spin up a local HTTP proxy on
loopback to inject the Authorization header, because Android's
MediaSession API can't pass headers down to ExoPlayer directly.
ExoPlayer connects to http://127.0.0.1:<random-port>; the proxy adds
the Bearer header and forwards to the real upstream HTTPS URL.
Loopback traffic doesn't leave the device.

This is the documented just_audio happy path — see
https://pub.dev/packages/just_audio#a-note-on-android-cleartext-traffic
— not a workaround for a plugin bug.

Adds network_security_config.xml that scopes cleartext permission to
127.0.0.1 ONLY. The base config keeps cleartextTrafficPermitted=false
so any actual remote endpoint must still be HTTPS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:24:47 -04:00
bvandeusen 751c2878f7 fix(flutter/android): production manifest + AudioServiceActivity
Two related Android setup gaps caught when running on a clean Pixel 6
emulator:

1. MainActivity extended FlutterActivity, but the audio_service
   plugin requires AudioServiceActivity. AudioService.init() threw
   PlatformException("The Activity class declared in your
   AndroidManifest.xml is wrong"), and the partial-engine-reinit that
   followed cascaded into a flutter_cache_manager SQLite EXCLUSIVE
   lock crash. Both errors clear with the correct base class.

2. The main manifest was missing two production permissions:
   - POST_NOTIFICATIONS — Android 13+ runtime requirement for the
     media notification (now-playing tile in the system tray).
     audio_service auto-prompts at init() time once declared.
   - ACCESS_NETWORK_STATE — used by ConnectionErrorBanner +
     flutter_cache_manager for connectivity awareness.

Note for future contributors: src/main/AndroidManifest.xml IS the
production manifest. The src/debug and src/profile variants only add
dev-only entries (e.g. INTERNET re-declared in debug for hot-reload).
There is no separate "release" manifest in Flutter's convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:33:30 -04:00
bvandeusen 80f98b3243 feat(flutter/player): MinstrelAudioHandler + just_audio + audio_service
audio_service runs the handler in a background isolate; UI watches
playbackState/mediaItem/queue streams through Riverpod. AndroidManifest
gets the foreground-service media-playback permission; Info.plist gets
UIBackgroundModes=audio. Bearer token attached to stream URLs via
just_audio's per-source headers.
2026-05-02 17:37:14 -04:00
bvandeusen e46224acc9 feat(flutter): project scaffold with pubspec and smoke test
iOS + Android targets only; desktop disabled (per spec, Tauri-wrapped
SvelteKit handles desktop in v1.1). Riverpod + dio + just_audio +
audio_service + go_router pinned in pubspec. Smoke test confirms the
ProviderScope wiring boots a Material app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 15:25:05 -04:00