Commit Graph
9 Commits
Author SHA1 Message Date
bvandeusen 2e1a8a62d8 refactor(android): move cleartext opt-out into a networkSecurityConfig — #2439
android / Build + lint + test (push) Successful in 3m46s
`android:usesCleartextTraffic="true"` sat on <application> as a bare opt-out of
the platform's network-security default, with nothing recorded about why. It's
now a res/xml/network_security_config.xml carrying the same permission and the
reasoning behind it.

Behaviour is unchanged. networkSecurityConfig supersedes the attribute on API
24+ and our minSdk is 26, so the attribute is removed rather than kept
alongside.

Cleartext stays permitted because two independent things need it, and neither
can be narrowed to a domain list:

- The Minstrel server's host is user-entered at runtime, and plenty of
  self-hosters run plain HTTP on a LAN.
- UPnP/DLNA/Sonos — device-description and SOAP control URLs arrive in SSDP
  responses at runtime and are plain HTTP essentially always. This one wasn't in
  the original ticket, which only considered the server; it independently rules
  out the "tighten it later to RFC1918" idea, since <domain-config> matches
  literal hostnames, not CIDR ranges, and renderer IPs are unknowable ahead of
  time.

Trust anchors deliberately left at the platform default. Adding
<certificates src="user" /> would let self-hosters use HTTPS with a private CA —
which Mihon does, and which suits this product — but it also trusts every CA on
the device including a corporate MITM proxy. Raised separately rather than
assumed as a default.

tools:ignore="InsecureBaseConfiguration" mirrors Mihon's config and keeps
lintVitalRelease quiet about a choice that is deliberate and now documented.
2026-08-05 08:41:05 -04:00
bvandeusen a4b6f22d86 feat(update): silent self-update via PackageInstaller session — #2438
android / Build + lint + test (push) Successful in 3m54s
Replaces the ACTION_VIEW + application/vnd.android.package-archive handoff
with a PackageInstaller session, and declares
UPDATE_PACKAGES_WITHOUT_USER_ACTION so the update can land with no confirm
dialog at all.

The platform grants the silent path when the installer opts in via
setRequireUserAction(USER_ACTION_NOT_REQUIRED), the installed app targets
API 29+, the installer holds that permission, and the target is the
installer itself. Minstrel updating Minstrel satisfies all four. Where it
can't be granted — anything pre-S — the platform returns
STATUS_PENDING_USER_ACTION and we show its dialog instead, so this degrades
rather than failing.

Prior art: Mihon, which is out-of-store and self-updating and whose updates
are quiet for exactly this reason. It also confirmed REQUEST_INSTALL_PACKAGES
is not what draws install warnings — Mihon declares it too.

No setRequestUpdateOwnership(true), despite it reading like the obvious
declaration for a self-updater. Ownership can only be claimed on initial
installation (a no-op on update) and additionally wants the privileged
ENFORCE_UPDATE_OWNERSHIP permission. It's an API for app stores claiming the
apps they install.

Also: the install now has an outcome. The old path fired an intent and
assumed, so a failure and a user declining were indistinguishable. Sessions
report back, so InstallOutcome distinguishes Installed / Cancelled / Failed,
and cancelling returns to IDLE rather than showing an error — the user chose
it. DOWNLOADING and INSTALLING became separate stages because the install
half now genuinely waits, and "Downloading…" through a confirm dialog is a
lie.

The FileProvider and res/xml/file_paths.xml are gone. They existed only to
expose the cached APK as a content:// URI for the old intent; a session
takes a stream. Nothing else used that authority.

Two judgement calls worth naming:

- The pending-user-action intent is only launched if it resolves to a system
  component. Below API 34 a dynamically registered receiver can't declare
  itself unexported, so another app can broadcast at us, and an unchecked
  startActivity on an attacker-supplied extra would be an escalation
  primitive. The real confirm activity is a system app, so the check costs
  the legitimate path nothing.
- Cancellation unregisters the receiver but deliberately does NOT abandon the
  session. By then it's committed, and killing an install because the user
  navigated away from the banner misreads their intent.

Untestable here: no androidTest source set and no Robolectric, so the
gesture-level behaviour is operator on-device verification.
2026-08-04 16:19:24 -04:00
bvandeusenandClaude Opus 4.7 5f3905f2c7 feat(android): UPnP picker foundation (UPnP slice 3/6)
android / Build + lint + test (push) Successful in 4m11s
UpnpRoute - narrow domain model for a discovered UPnP / DLNA
renderer. Carries the AVTransport + RenderingControl control URLs
the SOAP client uses.

CastApi - Retrofit interface for the new POST /api/cast/stream-token
endpoint (UPnP slice 2/6). Returns {token, exp, url} for the
selection path.

OutputRoute.fromUpnpRoute - companion factory that tags the route
with Protocol.UPNP. Subtitle is 'Manufacturer Model' or falls back
to 'Network speaker' when description fields are blank.

CHANGE_WIFI_MULTICAST_STATE manifest permission - install-time on
all API levels, no runtime prompt. Required for SSDP multicast
discovery.

Discovery + SOAP + integration land in follow-up commits.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 11:51:11 -04:00
bvandeusen 0662c9d5cc feat(android): output picker foundation - mediarouter + OutputRoute
android / Build + lint + test (push) Has been cancelled
Bluetooth slice (1/5). Adds the androidx.mediarouter 1.7.0 dep,
declares BLUETOOTH_CONNECT (needed on Android 12+ to enumerate
paired BT devices by name), and lays down the OutputRoute domain
model.

OutputRoute decouples the picker UI from MediaRouter.RouteInfo
(framework class, can't be constructed in JVM tests - same
constraint we hit with LikeMediaCallback). The Protocol enum
includes UPNP/CAST/SONOS placeholders so the next slice slots in
without a data-model rename - see
docs/superpowers/specs/2026-06-03-android-output-picker-upnp-scope.md
for the deferred work.

Controller + ViewModel + Compose UI land in follow-up commits.
2026-06-03 10:27:02 -04:00
bvandeusen b9186937b3 chore(android): lock MainActivity to portrait
android / Build + lint + test (push) Successful in 3m39s
Operator feedback: landscape just stretches the phone-portrait
Compose layout awkwardly — every screen was sized for one column
of cards, so rotation produces wide rows of unrelated content with
big dead bands top and bottom. Until a dedicated tablet/landscape
layout exists, lock the activity to portrait via screenOrientation.

Revisit when a sw600dp resource set + multi-pane layouts land.
2026-06-02 08:12:58 -04:00
bvandeusen f080afc38c fix(android): remove WorkManager auto-initializer (lintVitalRelease)
android / Build signed release APK (push) Has been skipped
android / Build + lint + test (push) Successful in 4m26s
The android.yml release build (run 110, job 282) failed at
lintVitalRelease with:

  AndroidManifest.xml:13: Error: Remove androidx.work.WorkManagerInitializer
  from your AndroidManifest.xml when using on-demand initialization.
  [RemoveWorkManagerInitializer from androidx.work]

MinstrelApplication implements androidx.work.Configuration.Provider
and supplies the HiltWorkerFactory, so WorkManager initializes
on-demand at first WorkManager.getInstance(...) call. The androidx-
startup InitializationProvider that ships with the work-runtime
library still auto-registers WorkManagerInitializer, racing the
on-demand path. Lint catches this as a release blocker.

Fix: merge the InitializationProvider entry and remove the nested
WorkManagerInitializer meta-data with tools:node='remove'. This is
the AOSP-recommended fix when an Application is its own
Configuration.Provider.
2026-06-01 16:55:29 -04:00
bvandeusenandClaude Opus 4.7 8f5635c489 feat(android): in-app APK update install (audit v3 #25)
Wires the "Install vX.Y.Z" action the About card's update-check
teed up.

* ApkInstaller @Singleton — downloads the server APK via the shared
  OkHttpClient (inherits auth cookie + BaseUrlInterceptor host
  rewrite; apkUrl is server-relative) into cacheDir, then launches
  the system installer via a FileProvider content:// URI. canInstall()
  gates on PackageManager.canRequestPackageInstalls() on O+, and
  requestInstallPermission() opens the "install unknown apps" settings
  page when not yet granted.
* Manifest: REQUEST_INSTALL_PACKAGES permission + FileProvider
  (${applicationId}.fileprovider) + res/xml/file_paths.xml exposing
  the cache dir.
* AboutCardViewModel.install(info): permission check → download →
  launch, with isInstalling + installMessage state. Errors routed
  through ErrorCopy.
* About card shows an "Install vX.Y.Z" button under the check button
  when an update is available, "Downloading…" while in flight, and
  the install message line. Extracted UpdateControls / InstallButton /
  ButtonSpinner helpers to keep AboutCard under the length cap;
  added @file:Suppress(TooManyFunctions) for the settings-card density.

Closes audit v3 #25 — the last open parity item from the v3 sweep
aside from the offline-pool Home cards (#28).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 01:18:49 -04:00
bvandeusenandClaude Opus 4.7 85b8452b78 feat(android): MinstrelPlayerService — Media3 MediaSessionService (M8 phase 6.2)
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>
2026-05-23 12:26:47 -04:00
bvandeusenandClaude Opus 4.7 34e54f29e9 feat(android): :app module skeleton — Compose Activity + manifest
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>
2026-05-21 21:17:40 -04:00