fix(android): register WebView JS bridges once; stop audio-focus fight

Locking the screen killed audio on video playback even with the
background-audio toggle armed.

configureWebViewForMedia() ran from onCreate's delayed post AND from
every onResume, re-calling addJavascriptInterface on each pass — five
times in a 45s session. WebView binds injected objects at page-load
time, so re-injecting over a live page leaves JS holding a stale proxy:
the object stays truthy (passing the `bridge()?.` optional chain) while
its methods vanish. Logcat showed 66 "WebView: Unknown object" errors
and, in JS, "TypeError: setEnabled is not a function".

So the toggle turned blue but never reached native. backgroundAudioEnabled
stayed false, onStop never dispatched 'jellytau-background', the handoff
never ran, and audio stopped the instant the screen locked. PiP and audio
focus broke identically.

- Register the bridges exactly once per WebView (identity-compared), and
  split the idempotent settings/chrome-client work into
  configureWebViewSettings() so it still runs on every resume.
- Forward WebView console output to logcat as "JellyTauWeb". The frontend
  was previously invisible to adb, which is what made this bug so hard to
  place; keep it for the next boundary-spanning diagnosis.
- setBackgroundAudioEnabled now reports whether native was actually
  reached instead of silently no-oping, so a dead bridge can never again
  masquerade as an armed toggle.

Removing the re-injection revived a latent conflict it had been masking:
the focus calls started working, and three AUDIOFOCUS_GAIN requesters
inside one uid began fighting — MainActivity, ExoPlayer, and Chromium's
own AudioFocusDelegate. The grant was followed ~45ms later by
AUDIOFOCUS_LOSS, whose handler paused playback, so arming background
audio (or just pressing play) paused the video in a loop.

WebView already manages focus for <video>. Drop the redundant
AndroidAudioFocus bridge, its listeners and its helpers entirely, and
leave focus to whichever engine is actually rendering — consistent with
the player-is-authoritative principle.

Also drops the dead AndroidBackgroundAudio.isSupported() probe, unused
since the button gate moved to platform().

TRACES: UR-040 | IR-025, DR-051 | UT-062
This commit is contained in:
2026-07-28 01:32:57 +02:00
parent 5759a97289
commit e5d3cc06f2
3 changed files with 162 additions and 103 deletions
@@ -1,10 +1,5 @@
package com.dtourolle.jellytau package com.dtourolle.jellytau
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
@@ -19,8 +14,6 @@ class MainActivity : TauriActivity() {
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
private var configAttempts = 0 private var configAttempts = 0
private val maxConfigAttempts = 10 private val maxConfigAttempts = 10
private var audioFocusRequest: AudioFocusRequest? = null
private val audioManager by lazy { getSystemService(Context.AUDIO_SERVICE) as AudioManager }
/** /**
* Coarse override for whether backgrounding the app should auto-enter PiP. * Coarse override for whether backgrounding the app should auto-enter PiP.
@@ -50,6 +43,15 @@ class MainActivity : TauriActivity() {
*/ */
private var mediaWebView: WebView? = null private var mediaWebView: WebView? = null
/**
* The WebView the @JavascriptInterface bridges have been injected into.
*
* addJavascriptInterface must run once per WebView instance: re-injecting
* over an already-loaded page hands JS a stale proxy whose methods are gone.
* Compared by identity so a genuinely new WebView still gets its bridges.
*/
private var bridgesInstalledOn: WebView? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -159,19 +161,36 @@ class MainActivity : TauriActivity() {
android.util.Log.d("MainActivity", "WebView found! Configuring settings...") android.util.Log.d("MainActivity", "WebView found! Configuring settings...")
mediaWebView = webView mediaWebView = webView
// Add JavaScript interface for audio focus control // Register the @JavascriptInterface bridges EXACTLY ONCE per WebView.
webView.addJavascriptInterface(object : Any() { //
@JavascriptInterface // configureWebViewForMedia() runs from onCreate's delayed post AND from
fun requestAudioFocus() { // every onResume (plus each WebView re-find), so this used to re-inject
handler.post { this@MainActivity.requestAudioFocus() } // all four bridges repeatedly - 5 times in a 45s session. WebView binds
} // injected objects at page-load time; re-injecting over a live page
// leaves JS holding a stale proxy. The object stays truthy while its
// methods vanish, which surfaced as a flood of
// "WebView: Unknown object" chromium errors and, in JS,
// "TypeError: setEnabled is not a function".
//
// The visible bug: the background-audio toggle turned blue but never
// reached native, so backgroundAudioEnabled stayed false, onStop never
// dispatched 'jellytau-background', and a locked screen killed audio
// instantly (UR-040). Audio focus and PiP broke the same way.
//
// The settings/WebChromeClient work below is idempotent and must keep
// running on resume; only the bridge injection is one-shot.
if (webView === bridgesInstalledOn) {
android.util.Log.d("MainActivity", "JS bridges already installed on this WebView - skipping re-injection")
configureWebViewSettings(webView)
return
}
bridgesInstalledOn = webView
@JavascriptInterface // NOTE: there is deliberately no "AndroidAudioFocus" bridge. Manual focus
fun abandonAudioFocus() { // requests from the WebView competed with Chromium's own
handler.post { this@MainActivity.abandonAudioFocus() } // AudioFocusDelegate and with ExoPlayer, and the resulting
} // AUDIOFOCUS_LOSS paused playback. See the comment on the video listeners
}, "AndroidAudioFocus") // in configureWebViewSettings().
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidAudioFocus' added")
// Add JavaScript interface for picture-in-picture control. // Add JavaScript interface for picture-in-picture control.
// enterPip/canEnterPip must run on the main thread; @JavascriptInterface // enterPip/canEnterPip must run on the main thread; @JavascriptInterface
@@ -212,10 +231,6 @@ class MainActivity : TauriActivity() {
backgroundAudioEnabled = enabled backgroundAudioEnabled = enabled
android.util.Log.d("MainActivity", "backgroundAudioEnabled = $enabled") android.util.Log.d("MainActivity", "backgroundAudioEnabled = $enabled")
} }
/** Whether background audio is available on this device (needs PiP-era APIs unnecessary; audio service always present on Android). */
@JavascriptInterface
fun isSupported(): Boolean = true
}, "AndroidBackgroundAudio") }, "AndroidBackgroundAudio")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidBackgroundAudio' added") android.util.Log.d("MainActivity", "JavaScript interface 'AndroidBackgroundAudio' added")
@@ -248,6 +263,21 @@ class MainActivity : TauriActivity() {
dispatchWebEvent("jellytau-network-changed") dispatchWebEvent("jellytau-network-changed")
} }
configureWebViewSettings(webView)
} catch (e: Exception) {
android.util.Log.e("MainActivity", "Failed to configure WebView for media", e)
}
}
/**
* WebView settings, chrome client and the video-unmute script.
*
* Split out from the bridge injection because this half is idempotent and
* must re-run on every resume, whereas addJavascriptInterface must not.
*/
private fun configureWebViewSettings(webView: WebView) {
try {
// Set WebChromeClient to handle video playback and audio focus // Set WebChromeClient to handle video playback and audio focus
webView.webChromeClient = object : WebChromeClient() { webView.webChromeClient = object : WebChromeClient() {
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) { override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
@@ -259,6 +289,21 @@ class MainActivity : TauriActivity() {
super.onHideCustomView() super.onHideCustomView()
android.util.Log.d("MainActivity", "Video exited fullscreen") android.util.Log.d("MainActivity", "Video exited fullscreen")
} }
/**
* Forward WebView console output to logcat under the "JellyTauWeb" tag.
*
* Without this the frontend is invisible to `adb logcat`, which makes
* diagnosing anything that spans the JS/native boundary (the
* background-audio handoff in particular) guesswork.
*/
override fun onConsoleMessage(msg: android.webkit.ConsoleMessage): Boolean {
android.util.Log.d(
"JellyTauWeb",
"${msg.message()} (${msg.sourceId()}:${msg.lineNumber()})"
)
return true
}
} }
android.util.Log.d("MainActivity", "WebChromeClient configured") android.util.Log.d("MainActivity", "WebChromeClient configured")
@@ -287,29 +332,18 @@ class MainActivity : TauriActivity() {
video.volume = 1.0; video.volume = 1.0;
console.log('[Android] Video unmuted, volume:', video.volume, 'muted:', video.muted); console.log('[Android] Video unmuted, volume:', video.volume, 'muted:', video.muted);
// Add event listeners to manage audio focus // NOTE: deliberately no audio-focus calls here.
video.addEventListener('play', function() { //
console.log('[Android] Video play event - requesting audio focus'); // WebView already manages audio focus for <video> through
if (typeof AndroidAudioFocus !== 'undefined') { // Chromium's own AudioFocusDelegate. Requesting AUDIOFOCUS_GAIN
AndroidAudioFocus.requestAudioFocus(); // again from MainActivity made two requesters compete inside one
} // uid: the grant was immediately followed by AUDIOFOCUS_LOSS
console.log('[Android] Video state - muted:', this.muted, 'volume:', this.volume); // (~45ms), whose handler paused playback - so arming background
}); // audio, or simply pressing play, paused the video in a loop.
//
video.addEventListener('pause', function() { // ExoPlayer is the third potential owner and stays authoritative
console.log('[Android] Video pause event - abandoning audio focus'); // for native playback (JellyTauPlayer manages its own focus).
if (typeof AndroidAudioFocus !== 'undefined') { // Leave focus to whichever engine is actually rendering.
AndroidAudioFocus.abandonAudioFocus();
}
});
video.addEventListener('ended', function() {
console.log('[Android] Video ended event - abandoning audio focus');
if (typeof AndroidAudioFocus !== 'undefined') {
AndroidAudioFocus.abandonAudioFocus();
}
});
video.addEventListener('volumechange', function() { video.addEventListener('volumechange', function() {
console.log('[Android] Video volume changed - volume:', this.volume, 'muted:', this.muted); console.log('[Android] Video volume changed - volume:', this.volume, 'muted:', this.muted);
}); });
@@ -356,48 +390,4 @@ class MainActivity : TauriActivity() {
return null return null
} }
private fun requestAudioFocus() {
android.util.Log.d("MainActivity", "Requesting audio focus for video playback")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE)
.build()
audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(audioAttributes)
.setAcceptsDelayedFocusGain(true)
.setOnAudioFocusChangeListener { focusChange ->
android.util.Log.d("MainActivity", "Audio focus changed: $focusChange")
}
.build()
val result = audioManager.requestAudioFocus(audioFocusRequest!!)
android.util.Log.d("MainActivity", "Audio focus request result: $result")
} else {
@Suppress("DEPRECATION")
val result = audioManager.requestAudioFocus(
{ focusChange ->
android.util.Log.d("MainActivity", "Audio focus changed: $focusChange")
},
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN
)
android.util.Log.d("MainActivity", "Audio focus request result (legacy): $result")
}
}
private fun abandonAudioFocus() {
android.util.Log.d("MainActivity", "Abandoning audio focus")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioFocusRequest?.let {
audioManager.abandonAudioFocusRequest(it)
}
} else {
@Suppress("DEPRECATION")
audioManager.abandonAudioFocus { }
}
}
} }
+68
View File
@@ -0,0 +1,68 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { setBackgroundAudioEnabled } from "./backgroundAudio";
/**
* Bridge-reporting contract for the background-audio toggle.
*
* TRACES: UR-040 | IR-025, DR-051 | UT-062
*
* Regression guard for the "screen lock kills video audio" bug: MainActivity
* re-ran configureWebViewForMedia() on every onResume, re-calling
* addJavascriptInterface over a live page. WebView then served a stale proxy —
* `window.AndroidBackgroundAudio` stayed truthy but its methods were gone, so
* `setEnabled` threw `TypeError: e.setEnabled is not a function`.
*
* The old implementation swallowed that with `bridge()?.setEnabled(...)` inside
* a try/catch returning void, so the UI showed "armed" while native never got
* the flag — and onStop's `if (backgroundAudioEnabled)` guard never dispatched
* `jellytau-background`. Audio died the instant the screen locked.
*
* setBackgroundAudioEnabled must therefore REPORT whether native was actually
* reached, so a dead bridge can never masquerade as an armed toggle.
*/
describe("setBackgroundAudioEnabled", () => {
beforeEach(() => {
delete (window as unknown as Record<string, unknown>).AndroidBackgroundAudio;
vi.restoreAllMocks();
});
it("reports success when the bridge is present and the call lands", () => {
const setEnabled = vi.fn();
window.AndroidBackgroundAudio = { setEnabled };
expect(setBackgroundAudioEnabled(true)).toBe(true);
expect(setEnabled).toHaveBeenCalledWith(true);
});
it("reports failure when the bridge object is absent entirely", () => {
expect(setBackgroundAudioEnabled(true)).toBe(false);
});
it("reports failure for a stale proxy whose methods are gone", () => {
// The exact shape of the bug: object present (so `?.` passes) but the
// method is missing after re-injection over a live page.
window.AndroidBackgroundAudio = {} as unknown as typeof window.AndroidBackgroundAudio;
expect(setBackgroundAudioEnabled(true)).toBe(false);
});
it("reports failure when the bridge method throws", () => {
window.AndroidBackgroundAudio = {
setEnabled: () => {
throw new TypeError("e.setEnabled is not a function");
},
};
expect(setBackgroundAudioEnabled(true)).toBe(false);
});
it("never throws out to the caller — the toggle must not break the player", () => {
window.AndroidBackgroundAudio = {
setEnabled: () => {
throw new Error("boom");
},
};
expect(() => setBackgroundAudioEnabled(false)).not.toThrow();
});
});
+14 -13
View File
@@ -20,7 +20,6 @@
interface AndroidBackgroundAudioBridge { interface AndroidBackgroundAudioBridge {
setEnabled(enabled: boolean): void; setEnabled(enabled: boolean): void;
isSupported(): boolean;
} }
declare global { declare global {
@@ -34,25 +33,27 @@ function bridge(): AndroidBackgroundAudioBridge | undefined {
return window.AndroidBackgroundAudio; return window.AndroidBackgroundAudio;
} }
/** Whether background audio is available — used to decide if the toggle renders. */
export function isBackgroundAudioSupported(): boolean {
try {
return bridge()?.isSupported() ?? false;
} catch (err) {
console.warn("[BgAudio] isSupported check failed:", err);
return false;
}
}
/** /**
* Arm/disarm background-audio mode for the current video. When armed, the native * Arm/disarm background-audio mode for the current video. When armed, the native
* side runs the audio handoff on background instead of entering PiP. * side runs the audio handoff on background instead of entering PiP.
*/ */
export function setBackgroundAudioEnabled(enabled: boolean): void { export function setBackgroundAudioEnabled(enabled: boolean): boolean {
const b = bridge();
if (!b) {
// The button is gated on platform(), not on this bridge, so it can render
// before/without the bridge existing. Silently no-oping here leaves the UI
// showing "armed" while native never learns — and the handoff then never
// fires on lock. Report it so callers can retry.
console.warn("[BgAudio] setEnabled: bridge missing, native NOT armed");
return false;
}
try { try {
bridge()?.setEnabled(enabled); b.setEnabled(enabled);
console.log("[BgAudio] setEnabled ->", enabled);
return true;
} catch (err) { } catch (err) {
console.warn("[BgAudio] Failed to set enabled:", err); console.warn("[BgAudio] Failed to set enabled:", err);
return false;
} }
} }