fix(android): stop the webview undoing the network security config
MainActivity set mixedContentMode = MIXED_CONTENT_ALWAYS_ALLOW together with allowFileAccess/allowContentAccess = true, which is a blanket cleartext opt-in reached by hand — the exact thing network_security_config.xml exists to prevent and its own comment warns against. Nothing needed any of the three: - file:// is never loaded. Cached thumbnails go through convertFileSrc, which on Android resolves to http://asset.localhost/... and is answered by wry's request interceptor rather than the filesystem; downloaded media goes over the loopback HTTP server (DR-137), which exists precisely because the asset/file route cannot stream a large file. - content:// is never loaded. The manifest's FileProvider is for outbound share intents, not webview navigation. - Mixed content never arises. Tauri serves the UI from http://tauri.localhost (use_https_scheme defaults false and is not set), and both 127.0.0.1 and asset.localhost are loopback/.localhost origins Chromium treats as potentially trustworthy. A plain-HTTP remote server would be mixed content, but the network security config already rejects it first — so ALWAYS_ALLOW bought nothing. COMPATIBILITY_MODE rather than NEVER_ALLOW is a deliberate hedge: the platform default at targetSdk 21+ is NEVER_ALLOW, so this is still one step looser, and it keeps passive content working if the analysis missed a path. The two files now cross-reference each other so the pair cannot drift apart again. Also records why POST_NOTIFICATIONS is declared but never requested. An audit read the missing runtime request as a threat to the lockscreen controls; it is not. A foreground-service notification is explicitly NOT exempt, but a media-session one is, and the platform predicate (Notification.isMediaNotification) requires MediaStyle AND a non-null session token. Confirmed on device: appops POST_NOTIFICATION: ignore with the transport notification live. So no permission prompt is added and startForeground stays ungated — a guard there would trade a cosmetic problem for the "did not then call Service.startForeground()" kill. What is added is the guard matching the real precondition: both builders bind the token once and log an error if it is ever null, since SystemUI's media carousel is gated on the same predicate and a token-less notification loses the lockscreen controls entirely, silently. TRACES: UR-006, UR-071 | DR-198, DR-199
This commit is contained in:
@@ -25,6 +25,35 @@
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<!--
|
||||
Declared, and deliberately NEVER requested at runtime. That is not an
|
||||
oversight, and an audit has flagged it once already — please read before
|
||||
"fixing" it in either direction.
|
||||
|
||||
Nothing the app posts today needs it. The only notification it produces is
|
||||
the playback service's, which is a MediaStyle notification carrying a valid
|
||||
MediaSession token, and "Notifications related to media sessions are exempt
|
||||
from this behavior change". Verified on device (HONOR ROD2-W09, Android 16
|
||||
/ SDK 36): appops `POST_NOTIFICATION: ignore`, granted=false, and the
|
||||
transport notification simultaneously live with all three actions and
|
||||
working lockscreen controls. So there is no permission dialog, because a
|
||||
prompt the app does not need is a prompt that can be permanently denied for
|
||||
nothing. Media3 does not require the declaration either — media3-session's
|
||||
own manifest declares no permissions, and the MediaSessionService guide
|
||||
asks only for the two FOREGROUND_SERVICE permissions above.
|
||||
|
||||
It stays declared because the exemption is narrow: it is a property of the
|
||||
NOTIFICATION (MediaStyle *and* a non-null session token), not of the
|
||||
foreground service, and it covers media and self-managed-call notifications
|
||||
only. A download-completion notice (UR-011) would be an ordinary
|
||||
notification and would be silently dropped. Adding one means requesting
|
||||
this permission at runtime — AndroidX ActivityResultContracts.
|
||||
RequestPermission from MainActivity, at the point the feature is used — and
|
||||
handling refusal; keeping the declaration is what makes that a one-file
|
||||
change. See JellyTauPlaybackService.warnIfNotificationWillBeDropped.
|
||||
|
||||
TRACES: UR-006 | DR-198
|
||||
-->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- AndroidTV support -->
|
||||
|
||||
@@ -502,9 +502,52 @@ class MainActivity : TauriActivity() {
|
||||
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
allowFileAccess = true
|
||||
allowContentAccess = true
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||
|
||||
// The three settings below used to read
|
||||
// allowFileAccess = true
|
||||
// allowContentAccess = true
|
||||
// mixedContentMode = MIXED_CONTENT_ALWAYS_ALLOW
|
||||
// which handed the webview a blanket cleartext opt-in and undid
|
||||
// res/xml/network_security_config.xml, whose whole point is that only
|
||||
// 127.0.0.1 is exempt from the cleartext ban and that this "must not
|
||||
// become a blanket cleartext opt-in" (DR-138). Nothing needed any of it:
|
||||
//
|
||||
// - `file://` is never loaded. Cached thumbnails go through
|
||||
// `convertFileSrc` (imageCache.ts), which on Android resolves to
|
||||
// `http://asset.localhost/...` — a Tauri custom protocol answered by
|
||||
// wry's request interceptor, not the filesystem. Downloaded media goes
|
||||
// through `media_local_url` → the loopback HTTP server on 127.0.0.1
|
||||
// (media_server.rs, DR-137), which exists precisely *because* the
|
||||
// asset/file route cannot stream a large file.
|
||||
// - `content://` is never loaded either. The manifest's FileProvider is
|
||||
// for outbound share intents, not for webview navigation.
|
||||
// - Mixed content never arises. Tauri serves the UI from
|
||||
// `http://tauri.localhost` (`use_https_scheme` is false by default and
|
||||
// is not set in tauri.conf.json), and both the loopback media server
|
||||
// and `asset.localhost` are loopback/`.localhost` origins, which
|
||||
// Chromium treats as potentially trustworthy — so they are not mixed
|
||||
// content in the first place. A plain-HTTP *remote* Jellyfin server
|
||||
// would be, but the network security config already rejects it before
|
||||
// the mixed-content check is ever reached, so ALWAYS_ALLOW bought
|
||||
// nothing and only widened the hole.
|
||||
//
|
||||
// COMPATIBILITY_MODE rather than NEVER_ALLOW is a deliberate hedge, not
|
||||
// the default: the platform default at targetSdk 21+ is NEVER_ALLOW, so
|
||||
// this is still one step looser than "stop overriding". It keeps passive
|
||||
// content (images) working if some path the analysis above missed turns
|
||||
// out to need it, which matters because this change cannot be verified
|
||||
// anywhere but a device. Tighten to NEVER_ALLOW once offline video and
|
||||
// cached artwork are confirmed on real hardware.
|
||||
//
|
||||
// `allowFileAccess = false` is the targetSdk-30+ platform default being
|
||||
// restored; `allowContentAccess = false` is a genuine tightening (its
|
||||
// default is true) and is the one to look at first if anything that used
|
||||
// to render stops.
|
||||
//
|
||||
// TRACES: UR-071 | DR-199
|
||||
allowFileAccess = false
|
||||
allowContentAccess = false
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
|
||||
|
||||
android.util.Log.d("MainActivity", "WebView fully configured for media playback")
|
||||
}
|
||||
|
||||
+125
-2
@@ -245,9 +245,103 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this process could post an *ordinary* notification and have the
|
||||
* user see it.
|
||||
*
|
||||
* Deliberately **not** a gate on anything this service posts today — see
|
||||
* [warnIfNotificationWillBeDropped]. `POST_NOTIFICATIONS` is declared in the
|
||||
* manifest but never requested, so on Android 13+ this is normally `false`,
|
||||
* and that is the intended state. It is read only to decide whether a
|
||||
* token-less notification would be dropped.
|
||||
*/
|
||||
private fun hasPostNotificationsPermission(): Boolean =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
|
||||
/**
|
||||
* The media-session token is what makes this service's notifications legal
|
||||
* without `POST_NOTIFICATIONS` — do not drop it.
|
||||
*
|
||||
* Android 13 (API 33) gates notifications behind the `POST_NOTIFICATIONS`
|
||||
* runtime permission, and a foreground-service notification is explicitly
|
||||
* **not** exempt: "Android 13 (API level 33) and higher supports a runtime
|
||||
* permission for sending non-exempt (including Foreground Services (FGS))
|
||||
* notifications from an app: POST_NOTIFICATIONS", and with it denied the
|
||||
* user "still see[s] notices related to foreground services in the Task
|
||||
* Manager but [doesn't] see them in the notification drawer".
|
||||
*
|
||||
* A *media-session* notification is exempt, however: "Notifications related
|
||||
* to media sessions are exempt from this behavior change." That exemption is
|
||||
* a property of the notification, not of the service — the platform decides
|
||||
* it from the posted `Notification` itself, which must carry `MediaStyle`
|
||||
* **and** a valid `MediaSession` token. Every notification this service
|
||||
* builds does (`MediaStyle().setMediaSession(mediaSessionCompat.sessionToken)`,
|
||||
* with `mediaSessionCompat` created in `onCreate`, i.e. before any post), so
|
||||
* the shade entry and the lockscreen transport controls behind UR-006 appear
|
||||
* whether or not the permission was ever granted. That is why this app asks
|
||||
* for nothing at runtime and shows the user no permission dialog.
|
||||
*
|
||||
* The trap it leaves is a silent one, and it is worse than a missing shade
|
||||
* entry — which is what this exists to make loud. The platform predicate is
|
||||
* `Notification.isMediaNotification()`, requiring MediaStyle **and** a
|
||||
* non-null `EXTRA_MEDIA_SESSION`; `NotificationManagerService` uses it to
|
||||
* decide whether to drop the post, and SystemUI's media carousel
|
||||
* (`MediaDataProcessor.onNotificationAdded`) is gated on *the same*
|
||||
* predicate. So a token-less notification is blocked before it reaches the
|
||||
* notification listener, and the lockscreen/Quick Settings transport
|
||||
* controls — the whole of UR-006 — never appear at all, with no error and no
|
||||
* log anywhere. `mediaSessionCompat?.sessionToken` is a null-safe call, so
|
||||
* that failure is one stray initialisation-order change away.
|
||||
*
|
||||
* The exemption also covers only media and self-managed-call notifications,
|
||||
* so a genuinely non-media notification — a download-completion notice
|
||||
* (UR-011), say — gets none of it. Adding one means requesting
|
||||
* `POST_NOTIFICATIONS` at runtime first (AndroidX
|
||||
* `ActivityResultContracts.RequestPermission`, launched from `MainActivity`
|
||||
* at the point the feature is used, handling refusal), not merely calling
|
||||
* `notify`; the manifest keeps the declaration so that stays a one-file
|
||||
* change. Verified unchanged across API 33–36.
|
||||
*
|
||||
* TRACES: UR-006 | DR-198
|
||||
*/
|
||||
private fun warnIfNotificationWillBeDropped(token: MediaSessionCompat.Token?) {
|
||||
if (token != null) return
|
||||
if (hasPostNotificationsPermission()) return
|
||||
android.util.Log.e(
|
||||
"JellyTauPlaybackService",
|
||||
"Posting a notification with NO MediaSession token while POST_NOTIFICATIONS " +
|
||||
"is denied: it is not exempt and Android will drop it silently. " +
|
||||
"Lockscreen/shade transport controls (UR-006) will be missing."
|
||||
)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
// Start as foreground service immediately to avoid crash
|
||||
// Media3 will replace this with its own notification
|
||||
//
|
||||
// startForeground() is deliberately NOT gated on POST_NOTIFICATIONS, and
|
||||
// an audit asking for such a guard has been answered once already — do
|
||||
// not re-raise it. Two independent reasons:
|
||||
//
|
||||
// 1. The notification does not need the permission. It is exempt because
|
||||
// it is a media-session notification (see
|
||||
// warnIfNotificationWillBeDropped). Device evidence, HONOR ROD2-W09 on
|
||||
// Android 16 / SDK 36: appops reports `POST_NOTIFICATION: ignore` and
|
||||
// `granted=false`, while the same dumpsys shows this service
|
||||
// isForeground=true with `foregroundNoti=Notification(category=
|
||||
// transport actions=3 vis=PUBLIC)` live and the lockscreen transport
|
||||
// controls working.
|
||||
// 2. Skipping this call after startForegroundService() is a hard contract
|
||||
// violation — the system kills the process with "did not then call
|
||||
// Service.startForeground()". So a guard here would convert a cosmetic
|
||||
// problem into a crash.
|
||||
//
|
||||
// A denied permission must degrade to a missing *notification*, never to
|
||||
// a missing startForeground.
|
||||
//
|
||||
// TRACES: UR-006 | DR-198
|
||||
val notification = createBasicNotification()
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
@@ -263,6 +357,11 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
|
||||
// onCreate builds mediaSessionCompat, and onStartCommand cannot run
|
||||
// before onCreate, so this is expected to be non-null here.
|
||||
val sessionToken = mediaSessionCompat?.sessionToken
|
||||
warnIfNotificationWillBeDropped(sessionToken)
|
||||
|
||||
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
.setContentTitle("JellyTau")
|
||||
.setContentText("Playing")
|
||||
@@ -270,7 +369,7 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
.setContentIntent(pendingIntent)
|
||||
.setStyle(
|
||||
androidx.media.app.NotificationCompat.MediaStyle()
|
||||
.setMediaSession(mediaSessionCompat?.sessionToken)
|
||||
.setMediaSession(sessionToken)
|
||||
.setShowActionsInCompactView(0, 1, 2) // Show all 3 buttons in compact view
|
||||
)
|
||||
.addAction(
|
||||
@@ -446,6 +545,24 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
/**
|
||||
* Update the notification with current media metadata and playback state.
|
||||
* This should be called whenever metadata or playback state changes.
|
||||
*
|
||||
* This `notify()` reuses [NOTIFICATION_ID], so while the service is
|
||||
* foreground it updates the foreground notification in place. It is **not**
|
||||
* guarded on the service being foreground, and does not need to be, because
|
||||
* the exemption that keeps it postable is a property of the notification
|
||||
* (MediaStyle + session token) rather than of the foreground state — see
|
||||
* [warnIfNotificationWillBeDropped].
|
||||
*
|
||||
* That distinction is load-bearing, because this *is* reachable with the
|
||||
* service alive but not foreground. Every caller arrives over JNI from Rust
|
||||
* on a non-main thread against [getInstance], which is non-null from
|
||||
* `onCreate` to `onDestroy`: it can therefore interleave between `onCreate`
|
||||
* and `onStartCommand`, and a media3 `MediaSessionService` is also created
|
||||
* by a plain *bind* from a MediaController with no `startForeground` at all.
|
||||
* Were the exemption a foreground-service one, those windows would silently
|
||||
* drop the update; being a media-session one, they do not.
|
||||
*
|
||||
* TRACES: UR-006 | DR-198
|
||||
*/
|
||||
private fun updateNotification(title: String, artist: String, isPlaying: Boolean) {
|
||||
val intent = packageManager.getLaunchIntentForPackage(packageName)
|
||||
@@ -456,6 +573,12 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
|
||||
// The token is what exempts this from POST_NOTIFICATIONS; losing it here
|
||||
// would make every metadata update vanish from the shade and lockscreen
|
||||
// while the service kept running. See warnIfNotificationWillBeDropped.
|
||||
val sessionToken = mediaSessionCompat?.sessionToken
|
||||
warnIfNotificationWillBeDropped(sessionToken)
|
||||
|
||||
val notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
.setContentTitle(title)
|
||||
.setContentText(artist)
|
||||
@@ -463,7 +586,7 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
.setContentIntent(pendingIntent)
|
||||
.setStyle(
|
||||
androidx.media.app.NotificationCompat.MediaStyle()
|
||||
.setMediaSession(mediaSessionCompat?.sessionToken)
|
||||
.setMediaSession(sessionToken)
|
||||
.setShowActionsInCompactView(0, 1, 2) // Show all 3 buttons in compact view
|
||||
)
|
||||
.addAction(
|
||||
|
||||
@@ -12,7 +12,12 @@
|
||||
remote server still has to be HTTPS — this must not become a blanket
|
||||
cleartext opt-in.
|
||||
|
||||
TRACES: UR-071 | DR-138
|
||||
This file is only half the policy. MainActivity.configureWebViewSettings sets
|
||||
the webview's mixedContentMode and its file/content access flags; setting
|
||||
MIXED_CONTENT_ALWAYS_ALLOW there re-opened by hand what this config closes,
|
||||
which is DR-199. Change the two together, or not at all.
|
||||
|
||||
TRACES: UR-071 | DR-138, DR-199
|
||||
-->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false" />
|
||||
|
||||
Reference in New Issue
Block a user