The actual replacement for everything audio_service plugin wrapped.
Media3 owns the foreground-service lifecycle, MediaSession token,
notification card, lock-screen surface, Bluetooth/AVRCP routing,
Pixel Watch tile, and Android Auto adapter natively — no plugin
layer between us and the platform.
Service shape (~25 LOC):
- @AndroidEntryPoint MediaSessionService
- @Inject PlayerFactory builds ExoPlayer in onCreate
- onGetSession returns the live MediaSession to any binding
controller (system UI, Wear OS companion, MediaController3 clients)
- onTaskRemoved keeps playing while audio is active (standard
media-app behavior); otherwise stopSelf so notification clears
- onDestroy releases session + player
Compare with flutter_client/lib/player/audio_handler.dart's 1000+ LOC
across MinstrelAudioHandler + the soft-teardown / stall-watchdog /
recovery machinery. Media3 owns most of that natively; we'll get to
the small portions we still need (queue management, position
reporting facade) in 6.3.
Manifest registration: foregroundServiceType="mediaPlayback" +
MediaSessionService intent-filter. MediaButtonReceiver is registered
by the Media3 library; no manual receiver class needed (Flutter's
manifest had to declare audio_service's receiver explicitly).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
detekt's PackageNaming rule rejects underscores in package names
(Kotlin/Java convention is lowercase, no separator). Renamed
com.fabledsword.minstrel.cache.audio_cache -> .audiocache.
Pattern for future multi-word subpackages: smush rather than _
separator (e.g. mutationqueue, synccontroller, when those land).
The on-disk audio_cache/ dir path inside the app cache (PlayerFactory.kt:41)
is unaffected — that's a filename string, not a package.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First Media3 wiring. PlayerFactory builds the process-singleton
ExoPlayer with our shared OkHttp + SimpleCache chain; the
MinstrelPlayerService (Phase 6.2) calls build() in onCreate.
Chain shape:
ExoPlayer
.setMediaSourceFactory(DefaultMediaSourceFactory + CacheDataSource)
.setAudioAttributes(USAGE_MEDIA + CONTENT_TYPE_MUSIC, focus=true)
.setHandleAudioBecomingNoisy(true)
CacheDataSource
.setCache(SimpleCache(audio_cache dir, LRU evictor, Room standalone DB))
.setUpstreamDataSourceFactory(OkHttpDataSource over shared OkHttp)
.setCacheWriteDataSinkFactory(CacheDataSink full-fragment)
Built-in audio focus + becoming-noisy handling — Media3 owns these so
no audio_session-equivalent code path is needed (the Flutter app had
~40 LOC for the same; here it's three lines of config).
simpleCache exposed as a PlayerFactory val so the AudioCacheEviction
Worker (Phase 12.3) can call removeSpan() during 2-bucket eviction.
CacheConfig (defaults 200MiB liked + 150MiB rolling) — Phase 11
Settings will let users override.
Bumped Media3 1.4.1 -> 1.10.1 (current stable, AGP 9 + Kotlin 2.3
friendly, MediaSessionService now extends LifecycleService which
makes 6.2's lifecycle-aware patterns cleaner). Breaking changes
between 1.4 and 1.10 don't affect our usage (DRM, FrameExtractor,
ChannelMixingMatrix — we use none).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Last task of Phase 5. The app now has the full bottom-bar shell with
NavHost wiring.
- nav/Routes.kt — @Serializable destinations: top-level tabs
(Home/Library/Search/Settings), detail screens with id args
(AlbumDetail, ArtistDetail, PlaylistDetail), overlays
(NowPlaying, Queue, Login).
- nav/MinstrelNavGraph.kt — NavHost with composable<RouteType>()
destinations. Library wires to the real LibraryScreen; everything
else uses the shared EmptyState as a "Coming soon" placeholder.
- MainActivity — Scaffold with NavigationBar bottom bar.
Selected-tab tracking via currentBackStackEntryAsState +
NavDestination.hasRoute(KClass) (type-safe routes API in
nav-compose 2.8+).
- Library cards' onArtistClick / onAlbumClick now navigate to
ArtistDetail(id) / AlbumDetail(id) — stubs for now, lit up when
those detail screens land.
Bottom-bar icons (Lucide CMP):
- House, LibraryBig, Search, Settings
startDestination = Library so the new UI is the cold-start landing
spot until the Home screen lands in Phase 6.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First user-visible UI. LibraryScreen renders the LibraryViewModel
UiState into horizontally-scrolling Artist / Album rows; Loading
shows a centered spinner, Empty / Error fall back to shared widgets.
Files:
- library/ui/LibraryScreen.kt — top-level screen, hiltViewModel
+ collectAsStateWithLifecycle, exhaustive when(state)
- library/widgets/ArtistCard.kt — circular cover + name beneath
- library/widgets/AlbumCard.kt — 144dp square cover + title +
artist beneath, matches Flutter spec (~176dp tile width)
- shared/widgets/EmptyState.kt — generic empty-state widget
(Lucide Inbox by default), used by Library + reusable for
Quarantine / search etc.
- shared/widgets/ErrorRetry.kt — error message + retry button
(uses LocalActionColors.primary = Moss per design system rule)
Audit-deferred items now triggered:
- MinstrelApplication implements SingletonImageLoader.Factory and
wires OkHttpNetworkFetcherFactory(callFactory = { okHttpClient })
so Coil cover-art requests reuse the shared auth-bearing OkHttp
- Lucide icons via com.composables:icons-lucide-android:2.2.1 for
placeholder / decorative iconography
MainActivity now renders LibraryScreen inside a Scaffold (not the
"phase 1" text placeholder). Nav-graph wiring deferred to Phase 5.5
— onArtistClick / onAlbumClick are no-op for now.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The three failing assertions tested an implementation detail. Under
UnconfinedTestDispatcher (MainDispatcherExtension's default), stateIn's
upstream Flow runs synchronously when the first subscriber attaches,
so the `Loading` initialValue gets replaced by the upstream emission
before Turbine's .test{} sees it. The observable behavior we care
about is the resolved state — Empty/Success/Error — not the
intermediate Loading.
Tests now collect the resolved state as the first awaitItem(), which
is what users actually see. The Loading state still exists in
production (StateFlow initialValue is preserved across the brief
window before stateIn collects the first upstream value when the
real dispatcher isn't unconfined).
Also cleared two compile warnings the run surfaced:
- AuthCookieInterceptorTest: added @OptIn(ExperimentalCoroutinesApi)
for UnconfinedTestDispatcher
- LibraryRepositoryTest: hoisted the Json instance into a companion
object (detekt warned about per-call creation)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LibraryRepository @Inject-constructs with CachedArtistDao, CachedAlbumDao,
CachedTrackDao. Hilt errored at hiltJavaCompileDebug with "MissingBinding"
for all three — Phase 4 only added the @Provides bridge for
AuthSessionDao in slice 10 (its single consumer).
Same one-line bridge per DAO: `db.<dao>()` from the AppDatabase
accessor. Future DAOs land in DatabaseModule when their first
@Inject-constructed consumer appears.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First Hilt-injected ViewModel + sealed UiState pattern.
LibraryUiState (sealed interface): Loading / Empty / Success / Error.
The cases are exhaustive so Compose `when` blocks the compiler checks.
LibraryViewModel:
- combine(observeArtists, observeAlbums) → Success/Empty decision
- .catch translates upstream Flow exceptions to UiState.Error
- .stateIn(viewModelScope, WhileSubscribed(5_000), Loading) — the
standard Compose-friendly pattern; subscriptions tear down 5s after
the last collector to ride out config changes without hanging the
DAO Flow forever.
MainDispatcherExtension — JUnit 5 equivalent of the JUnit 4
MainDispatcherRule pattern (audit-deferred item; trigger met). Swaps
Dispatchers.Main for UnconfinedTestDispatcher in beforeEach +
resetMain in afterEach. Apply with `@ExtendWith`.
LibraryViewModelTest covers all four UiState cases — initial Loading,
empty cache (Empty), populated cache (Success), and an upstream Flow
exception (Error). MockK for the repo, Turbine for the Flow assertions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cache-first reads of artists/albums/tracks. The Room DAOs are the source
of truth ViewModels observe; refreshArtistDetail / refreshAlbumDetail
pull from the server and upsert into Room — Flow emissions propagate
automatically.
- models/TrackRef.kt, models/ArtistRef.kt, models/AlbumRef.kt — domain
types mirroring flutter_client/lib/models/. `Ref` suffix matches
Flutter convention (lightweight reference, not full per-row metadata).
- library/data/LibraryMappers.kt — wire->entity (for sync writes),
entity->domain (for cache reads in ViewModels), wire->domain (for
fresh server responses bypassing cache), detail-wire->entity (drops
embedded array, repository upserts those separately).
- library/data/LibraryRepository.kt — Hilt-injected, observe* Flow
methods + suspend refresh* methods that upsert through the relevant
DAOs. Constructs its own LibraryApi via `retrofit.create()` per the
"repos own their interfaces" pattern adopted in NetworkModule.
- LibraryRepositoryTest.kt — MockK + Turbine + MockWebServer.
Verifies the Flow mapping, the wire->entity upsert split, and the
null-on-miss case for getArtist.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Kotlin (unlike Java) supports nested block comments. The doc-comment
on LibraryApi contained the string `/api/*` and `/api/home`-style
paths, which the lexer parsed as opening nested comments:
/**
* Retrofit interface for the server's native /api/* library surface. ← lexer: nested /* opens
...
*/ ← closes the nested one
// outer comment now unclosed; "Unclosed comment" reported at EOF
This compile error is what caused all the "ModuleProcessingStep was
unable to process NetworkModule because LibraryApi could not be
resolved" failures over the last four commits — KSP runs before
compileDebugKotlin and reports the downstream symptom (unresolvable
symbol) before the actual source-level error gets to print.
Rewrote the doc-comment to use `/api/...` and to wrap concrete paths
in backticks; no `/*` substring remains.
The "repos construct their Retrofit interface from shared Retrofit"
pattern from the previous commit stays; it's a sound pattern arrived
at via the wrong reasoning, but defensible on its own merits (fewer
Hilt bindings, locality of reference, easier test override).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "ModuleProcessingStep was unable to process NetworkModule because
LibraryApi could not be resolved" failure under KSP2 + Hilt 2.59.2
turns out to be specific to @Provides returning a hand-written Kotlin
interface that carries no KSP-processed annotations. Hilt's
ModuleProcessingStep resolves the return type through KSP2's API and
gets an ERROR type for source-only interfaces in some configurations
(google/dagger#4303 cluster).
Two source-of-truth interfaces I tested side-by-side:
- AuthSessionDao (@Dao, Room-processed) — @Provides works
- LibraryApi (only @GET Retrofit annotations, no KSP processor) — fails
Workaround that's actually a better pattern: feature repositories
construct their Retrofit interface from the Hilt-injected shared
Retrofit instance. Fewer bindings in the Hilt graph; one Retrofit
interface lives next to its sole consumer.
LibraryApi.kt + wire types remain; LibraryRepository (Phase 5.2) will
hold the `retrofit.create<LibraryApi>()` call.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hypothesis for the KSP2 "LibraryApi could not be resolved" failure:
ArtistWire.kt and AlbumWire.kt each declared TWO @Serializable
classes (the Ref and the Detail variant). LibraryApi imports the
Detail variants but the file names match the Ref variants. KSP2's
symbol indexing may key on `className.kt` and fail to surface the
second declaration in a multi-class file.
Splitting per the MatchingDeclarationName convention:
- ArtistDetailWire.kt (new)
- AlbumDetailWire.kt (new)
- ArtistWire.kt / AlbumWire.kt now contain only their namesake type
If this fixes it, the LibraryApi resolution will work without
changing the @Provides signature.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI hit a KSP/Hilt resolution error on the prior commit:
ModuleProcessingStep was unable to process 'NetworkModule' because
'LibraryApi' could not be resolved.
Switching from `retrofit.create(LibraryApi::class.java)` to the Kotlin
extension `retrofit.create()` (with explicit `LibraryApi` return type
annotation). The extension is reified and may sidestep whatever
type-resolution path the previous form tripped under KSP2 + Hilt.
If this also fails, the next step is to split AlbumWire.kt and
ArtistWire.kt so each file has a single top-level declaration —
investigating cross-file symbol-resolution order in KSP2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors flutter_client/lib/api/endpoints/library.dart 1:1.
Wire types (snake_case @SerialName per server JSON):
- TrackWire — id/title/album/artist/duration/streamUrl + nullable
track/disc numbers (fields verified against TrackRef.fromJson in
flutter_client/lib/models/track.dart)
- ArtistWire / ArtistDetailWire — the detail shape embeds "albums"
- AlbumWire / AlbumDetailWire — the detail shape embeds "tracks"
The Detail variants are explicit data classes (rather than a generic
envelope) because the server returns ArtistRef fields PLUS the
embedded array in the same object, which kotlinx.serialization can't
deserialize through a polymorphic envelope.
LibraryApi endpoints:
- getTrack(id)
- getArtistDetail(id) — ArtistDetailWire
- getArtistTracks(id) — bare List<TrackWire> (server emits a bare
array, NOT enveloped; Retrofit handles it via List return type)
- getAlbumDetail(id) — AlbumDetailWire
- shuffleLibrary(limit) — bare List<TrackWire>
Home endpoints (/api/home and /api/home/index) deferred to a future
HomeApi file because they have their own (larger) wire types that
only the Home screen consumes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Last slice. Promotes the Phase 3.1 in-memory AuthStore placeholder to a
Room-backed single-row auth_session table so session cookie + base URL
survive process death.
Design — hybrid storage:
- MutableStateFlow is the primary read source so interceptor-thread
reads stay synchronous (no awaiting a DAO call from inside an
OkHttp interceptor)
- Writes update the in-memory state synchronously AND launch a
write-through coroutine that persists to the DAO
- init() collects dao.observe() to keep in-memory in sync with
persisted state on app start + any external DB writes
AuthSessionDao gets partial-update queries (`setSessionCookie` /
`setBaseUrl`) so we don't have to round-trip the full row on every
mutation. First write does an upsert to seed the row.
DatabaseModule grows a @Provides for AuthSessionDao — Hilt can't inject
AppDatabase's abstract DAO accessors directly; each consumer-needed DAO
gets a thin bridge.
AuthCookieInterceptorTest updated: AuthStore now takes (dao, scope)
constructor args. Test uses mockk for the DAO and TestScope with
UnconfinedTestDispatcher so the in-memory state mutations the test
asserts on aren't affected by the asynchronous DAO writes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors flutter_client/lib/cache/db.dart's CachedHomeIndex — per-item
rows that drive the Home screen sections (Recently Added Albums,
Rediscover Albums/Artists, Most Played Tracks, Last Played Artists).
Composite PK (section, position) — exactly one row per slot per
section; sync replaces in-place via upsert. entityType ("album" /
"artist" / "track") dispatches per-tile hydration to the right
per-entity endpoint when the Home screen renders.
DAO surface fits the sync flow:
- observeBySection (Flow) for the Home composables
- getBySection (suspend) for one-shot sync reads
- upsertAll for sync writes
- deleteBySection for replace-all on a section sync
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related fixes from re-reading the Drift source for slice 8:
1. The plan called this slice "last_played" but the Drift table is
`cached_resume_state` — kept Drift's name for cross-reference
during the port. Single-row JSON-blob pattern (queue, currentIndex,
positionMs, source) — ResumeController (Phase 6.5) handles the
Kotlin-side encode/decode so the schema stays stable across
resume-shape evolution.
2. CacheSource enum was incomplete: Task 4.1 ported only 3 of the 5
Drift variants. Added AUTO_LIKED + AUTO_PLAYLIST (used by the
auto-cache prefetcher to tag cached files by reason — drives the
bucket eviction priority order INCIDENTAL > AUTO_PREFETCH >
AUTO_PLAYLIST > AUTO_LIKED > MANUAL).
No data migration needed — schema version is still 1 and we have no
real users yet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors flutter_client/lib/cache/db.dart's CachedMutations — the
offline-write queue that MutationQueue.enqueue() inserts into when a
server-write fails with IOException and MutationReplayer.drain() pops
from when connectivity returns (Phase 12.2).
`kind` is a string registered in `MutationKind` (Phase 12.2) so the
replayer can map to the right handler. `payload` is JSON-serialized
args. Unknown kinds get dropped at drain time rather than wedging.
DAO surface tailored to the replayer:
- observePendingCount: Flow<Int> for the offline-indicator badge
- getAll: FIFO list for drain (id ASC = oldest first)
- insert: returns the autoGenerate'd id
- recordAttempt(id, instant): atomic increment + lastAttemptAt set
- delete(id) / clear
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors flutter_client/lib/cache/db.dart's AudioCacheIndex — one row
per fully-downloaded audio file. Drives the 2-bucket LRU eviction
policy that Phase 12's AudioCacheEvictionWorker will execute.
DAO surface tailored to the eviction worker:
- totalBytes / bytesBySource — sum-of-sizeBytes for cap checks
- evictionCandidates(source) — oldest lastPlayedAt within a source
bucket, NULL lastPlayedAt sorted first (never-played candidates
evict before played ones)
- touchLastPlayed(trackId, instant) — single-column update from
the player on every "ready+playing" transition
- bulk delete by track-id list for batch evictions
CacheSource enum (MANUAL / INCIDENTAL / AUTO_PREFETCH) ported in
Task 4.1's TypeConverters.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors flutter_client/lib/cache/db.dart's CachedQuarantineMine —
the user's flagged-as-hidden tracks. Server returns the full
denormalized snapshot on /api/me/quarantine; the cache mirrors that
shape so the Quarantine screen renders without a join.
`createdAt` is a server ISO-8601 string (canonical timestamp);
`fetchedAt` is our local sync marker.
DAO covers the three known consumers:
- observeAll for the Quarantine screen (newest first)
- observeFlaggedTrackIds for feed-level filtering
- observeIsHidden(trackId) scalar Flow for per-tile UI
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related entities mirroring flutter_client/lib/cache/db.dart:
- CachedPlaylists — one row per playlist; `systemVariant` is null for
user playlists and "for_you" / "songs_like_artist" / etc. for
system-generated mixes (used by the add-to-playlist sheet filter)
- CachedPlaylistTracks — composite-PK join table, `position` carries
ordering inside a playlist
DAO surfaces split user vs system playlists at the query layer so
ViewModels don't have to filter — observeUserPlaylists/observeSystemPlaylists.
PlaylistTrackDao gets a deleteByPlaylist for the replace-all pattern
after a sync delta lands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors flutter_client/lib/cache/db.dart's CachedLikes Drift table —
composite PK (userId, entityType, entityId) so the same user can
like a track and its album and its artist independently. entityType
is a plain string ("track" | "album" | "artist") for parity with the
Drift schema and the server wire format.
DAO surface tailored to consumers we know are coming:
- observeLikedTrackIds(userId): Flow<List<String>> — audio-cache
eviction reads this set to identify "liked" bucket members
- observeLikedIdsOfType(userId, entityType): generalized variant
- observeIsLiked(...): scalar Flow for LikeButton composables
- upsertAll (sync writes)
- delete (mutation queue → toggle off)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors flutter_client/lib/cache/db.dart's CachedArtists / CachedAlbums /
CachedTracks Drift tables. Library cache foundation — LibraryRepository
(Phase 5.2) reads cache-first through these DAOs and refreshes from
server via the sync controller (Phase 12.4).
Column names follow Kotlin idiom (camelCase) instead of Drift's
snake_case; the schema is internal to the native client and the wire
JSON conversion happens in feature-level mappers.
Each DAO carries:
- observe* (Flow) for cache-first reads in ViewModels
- getById/getByIds (suspend) for one-shot lookups
- upsertAll (suspend, REPLACE) for sync writes
- deleteByIds (suspend) for sync-driven deletes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
M8 phase 3.1. Wires the single shared OkHttp + Retrofit instance the
whole app uses (per-endpoint Retrofit interfaces land in feature
modules). Audio HTTP via ExoPlayer's OkHttpDataSource.Factory will
reuse this same client — single auth/connection-pool surface.
- AuthStore: in-memory MutableStateFlow placeholder. Task 4.2
promotes it to a Room-backed single-row table for process-death
persistence; public API stays identical.
- AuthCookieInterceptor: attaches Cookie on outbound, captures
Set-Cookie on successful responses (login flow), clears the store
on 401 (logout signal).
- ServerBaseUrl: value class to type-safely DI the base URL.
- NetworkModule: Hilt-provided OkHttp + Retrofit + HttpLogging.
First task with unit tests — AuthCookieInterceptorTest uses MockWebServer
to verify all three interceptor branches plus a no-Set-Cookie-no-overwrite
case. Will tell us if the JUnit 5 + okhttp-mockwebserver test stack is
plumbed correctly through Gradle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Seven findings from the first real detekt run:
- LocalActionColors.kt / Tokens.kt: MatchingDeclarationName flagged
that the file names don't match the single top-level declaration.
Renamed to ActionColors.kt and FabledSwordTokens.kt (`git mv`).
- Typography.kt: three Font(...) calls exceeded the default 120-char
line length. Wrapped each named-arg list onto its own line.
- MainActivity.kt + MinstrelTheme.kt: FunctionNaming flagged App() /
MinstrelTheme() for not starting lowercase — these are
@Composable functions and PascalCase is the Compose convention.
Added a config override to the detekt YAML:
naming:
FunctionNaming:
ignoreAnnotated: ['Composable']
Matches every mainstream Compose codebase.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
M8 phase 1.4. Mirrors flutter_client/lib/theme/. Source of truth for hex
values is flutter_client/shared/fabledsword.tokens.json (manual sync until
cross-language codegen lands; ports the dark-surface + flat cohort).
Material 3 ColorScheme takes accent as primary; action colors
(Moss/Bronze/Oxblood) live in LocalActionColors as semantic roles per the
project_design_system rule "NEVER use accent for action buttons".
Typography uses androidx.compose.ui.text.googlefonts to fetch Fraunces /
Inter / JetBrains Mono at runtime via Play Services Fonts — matches the
Flutter client's `google_fonts` package (no bundled .ttf files in either
tree). Weights restricted to 400/500. Fraunces is reserved for ≥18sp
display/headline slots per the design-system rule.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
M8 phase 1.3. Plants the Hilt entrypoint so the rest of the modules
(NetworkModule, DatabaseModule, PlayerModule) can land in subsequent
phases. WorkerFactory wired so HiltWorker can be used directly later.
Restores @AndroidEntryPoint on MainActivity (deferred from 1.2 since
Hilt KSP errors without an annotated Application class).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
M8 phase 1.2. AndroidManifest declares FGS mediaPlayback +
POST_NOTIFICATIONS permissions ahead of the player phase. Activity
hosts a single Compose Scaffold for now; nav graph lands in phase 5.
Launcher icons reused from flutter_client/ (same applicationId means
same brand at cutover). MinstrelApplication referenced in manifest
but the class itself lands in Task 1.3 — manifest class names are
resolved at install time, not build time, so the intermediate commit
still builds.
@AndroidEntryPoint deferred to Task 1.3 alongside @HiltAndroidApp on
MinstrelApplication (Hilt KSP errors without an annotated Application).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>