Compare commits

...

3 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] 3f442377e7 Address code review feedback: fix sentinel value, preserve audio title, simplify JS checks
Agent-Logs-Url: https://github.com/ArcaneChat/android/sessions/57af82f8-b482-4d5a-977f-fc74ffd48db3

Co-authored-by: adbenitez <24558636+adbenitez@users.noreply.github.com>
2026-04-12 20:02:52 +00:00
copilot-swe-agent[bot] 6ecb276a49 Integrate Webxdc WebView audio with Android media session
- Create WebxdcMediaSessionService: a MediaSessionService backed by a
  stub SimpleBasePlayer that posts the system media notification when
  webxdc apps play audio. Handles WEBXDC_AUDIO_STARTED/STOPPED/PAUSED/
  RESUMED custom SessionCommands from WebxdcActivity and relays
  notification play/pause button presses back to the WebView via
  local broadcasts.

- Modify WebxdcActivity:
  - Connect a MediaController to WebxdcMediaSessionService in onCreate
  - Add notifyAudioStarted/Stopped/Paused/Resumed JS interface methods
    in InternalJSApi (called from webxdc.js audio event listeners)
  - Register a BroadcastReceiver for notification pause/resume to call
    evaluateJavascript to pause/resume <audio>/<video> elements
  - Override pauseWebViewOnPause() to keep WebView running in background
    while audio is playing
  - Release controller and send AUDIO_STOPPED on destroy

- Modify WebViewActivity: add pauseWebViewOnPause() hook so subclasses
  can prevent webView.onPause() (e.g., during audio playback)

- Add audio/video event listener setup to webxdc.js that polls for
  audio/video elements and calls InternalJSApi.notifyAudio* when
  play/pause/ended events fire

- Register WebxdcMediaSessionService in AndroidManifest.xml with
  foregroundServiceType=mediaPlayback

Agent-Logs-Url: https://github.com/ArcaneChat/android/sessions/57af82f8-b482-4d5a-977f-fc74ffd48db3

Co-authored-by: adbenitez <24558636+adbenitez@users.noreply.github.com>
2026-04-12 19:59:25 +00:00
copilot-swe-agent[bot] 69c888f168 Initial plan 2026-04-12 19:47:37 +00:00
5 changed files with 507 additions and 1 deletions
+9
View File
@@ -484,6 +484,15 @@
</intent-filter>
</service>
<service
android:name=".service.WebxdcMediaSessionService"
android:foregroundServiceType="mediaPlayback"
android:exported="true">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService" />
</intent-filter>
</service>
<service
android:name=".calls.CallService"
android:enabled="true"
@@ -166,10 +166,21 @@ public class WebViewActivity extends PassphraseRequiredActionBarActivity
}
}
/**
* Returns true if the WebView should be paused when the activity is paused.
* Subclasses can override to keep the WebView running in the background (e.g., during audio
* playback).
*/
protected boolean pauseWebViewOnPause() {
return true;
}
@Override
protected void onPause() {
super.onPause();
webView.onPause();
if (pauseWebViewOnPause()) {
webView.onPause();
}
}
@Override
@@ -1,8 +1,11 @@
package org.thoughtcrime.securesms;
import android.app.Activity;
import android.content.BroadcastReceiver;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.ActivityInfo;
import android.content.res.Configuration;
import android.graphics.Bitmap;
@@ -26,12 +29,18 @@ import android.webkit.WebSettings;
import android.webkit.WebView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.appcompat.app.ActionBar;
import androidx.core.app.TaskStackBuilder;
import androidx.core.content.ContextCompat;
import androidx.core.content.pm.ShortcutInfoCompat;
import androidx.core.content.pm.ShortcutManagerCompat;
import androidx.core.graphics.drawable.IconCompat;
import androidx.media3.session.MediaController;
import androidx.media3.session.SessionCommand;
import androidx.media3.session.SessionToken;
import com.google.common.util.concurrent.ListenableFuture;
import chat.delta.rpc.Rpc;
import chat.delta.rpc.RpcException;
import com.b44t.messenger.DcChat;
@@ -53,6 +62,7 @@ import org.json.JSONObject;
import org.thoughtcrime.securesms.connect.AccountManager;
import org.thoughtcrime.securesms.connect.DcEventCenter;
import org.thoughtcrime.securesms.connect.DcHelper;
import org.thoughtcrime.securesms.service.WebxdcMediaSessionService;
import org.thoughtcrime.securesms.util.IntentUtils;
import org.thoughtcrime.securesms.util.JsonUtils;
import org.thoughtcrime.securesms.util.MediaUtil;
@@ -81,6 +91,12 @@ public class WebxdcActivity extends WebViewActivity implements DcEventCenter.DcE
private TextToSpeech tts;
private boolean isAudioPlaying = false;
private String currentAudioTitle = "";
private @Nullable MediaController webxdcMediaController;
private @Nullable ListenableFuture<MediaController> webxdcMediaControllerFuture;
private @Nullable BroadcastReceiver notificationControlReceiver;
public static void openMaps(Context context, int chatId) {
openMaps(context, chatId, "");
}
@@ -268,6 +284,8 @@ public class WebxdcActivity extends WebViewActivity implements DcEventCenter.DcE
webView.loadUrl(this.baseURL + "/webxdc_bootstrap324567869.html?i=1&href=" + encodedHref);
initializeWebxdcMediaController();
Util.runOnAnyBackgroundThread(
() -> {
final DcChat chat = dcContext.getChat(dcAppMsg.getChatId());
@@ -291,12 +309,72 @@ public class WebxdcActivity extends WebViewActivity implements DcEventCenter.DcE
DcHelper.getNotificationCenter(this).clearVisibleWebxdc();
}
@Override
protected boolean pauseWebViewOnPause() {
// Keep the WebView JS timers/audio running in the background when audio is playing,
// mirroring what browsers do when a tab has media playing.
return !isAudioPlaying;
}
private void initializeWebxdcMediaController() {
SessionToken sessionToken =
new SessionToken(this, new ComponentName(this, WebxdcMediaSessionService.class));
webxdcMediaControllerFuture =
new MediaController.Builder(this, sessionToken).buildAsync();
webxdcMediaControllerFuture.addListener(
() -> {
try {
webxdcMediaController = webxdcMediaControllerFuture.get();
} catch (Exception e) {
Log.e(TAG, "Error connecting to WebxdcMediaSessionService", e);
}
},
ContextCompat.getMainExecutor(this));
// Register receiver for play/pause commands from the system notification.
notificationControlReceiver =
new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
if (WebxdcMediaSessionService.ACTION_NOTIFICATION_PAUSE.equals(intent.getAction())) {
webView.evaluateJavascript(
"document.querySelectorAll('audio,video').forEach(function(el){el.pause();});",
null);
} else if (WebxdcMediaSessionService.ACTION_NOTIFICATION_RESUME.equals(
intent.getAction())) {
webView.evaluateJavascript(
"document.querySelectorAll('audio,video').forEach(function(el){el.play();});",
null);
}
}
};
IntentFilter filter = new IntentFilter();
filter.addAction(WebxdcMediaSessionService.ACTION_NOTIFICATION_PAUSE);
filter.addAction(WebxdcMediaSessionService.ACTION_NOTIFICATION_RESUME);
ContextCompat.registerReceiver(
this, notificationControlReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED);
}
@Override
protected void onDestroy() {
lastOpenTime = System.currentTimeMillis();
DcHelper.getEventCenter(this.getApplicationContext()).removeObservers(this);
leaveRealtimeChannel();
tts.shutdown();
if (isAudioPlaying && webxdcMediaController != null) {
webxdcMediaController.sendCustomCommand(
new SessionCommand(WebxdcMediaSessionService.COMMAND_AUDIO_STOPPED, new Bundle()),
Bundle.EMPTY);
}
if (notificationControlReceiver != null) {
unregisterReceiver(notificationControlReceiver);
notificationControlReceiver = null;
}
if (webxdcMediaControllerFuture != null) {
MediaController.releaseFuture(webxdcMediaControllerFuture);
webxdcMediaControllerFuture = null;
webxdcMediaController = null;
}
super.onDestroy();
}
@@ -737,5 +815,79 @@ public class WebxdcActivity extends WebViewActivity implements DcEventCenter.DcE
if (lang != null && !lang.isEmpty()) tts.setLanguage(Locale.forLanguageTag(lang));
tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, null);
}
/**
* @noinspection unused
*/
@JavascriptInterface
public void notifyAudioStarted(String title) {
Util.runOnMain(
() -> {
if (webxdcMediaController == null) return;
isAudioPlaying = true;
currentAudioTitle = title;
Bundle args = new Bundle();
args.putString("title", title);
args.putString(
"artist",
WebxdcActivity.this.dcAppMsg.getWebxdcInfo().optString("name", ""));
args.putInt("msg_id", WebxdcActivity.this.dcAppMsg.getId());
args.putInt("account_id", WebxdcActivity.this.dcContext.getAccountId());
webxdcMediaController.sendCustomCommand(
new SessionCommand(WebxdcMediaSessionService.COMMAND_AUDIO_STARTED, new Bundle()),
args);
});
}
/**
* @noinspection unused
*/
@JavascriptInterface
public void notifyAudioStopped() {
Util.runOnMain(
() -> {
if (webxdcMediaController == null) return;
isAudioPlaying = false;
currentAudioTitle = "";
webxdcMediaController.sendCustomCommand(
new SessionCommand(WebxdcMediaSessionService.COMMAND_AUDIO_STOPPED, new Bundle()),
Bundle.EMPTY);
});
}
/**
* @noinspection unused
*/
@JavascriptInterface
public void notifyAudioPaused() {
Util.runOnMain(
() -> {
if (webxdcMediaController == null) return;
isAudioPlaying = false;
webxdcMediaController.sendCustomCommand(
new SessionCommand(WebxdcMediaSessionService.COMMAND_AUDIO_PAUSED, new Bundle()),
Bundle.EMPTY);
});
}
/**
* @noinspection unused
*/
@JavascriptInterface
public void notifyAudioResumed() {
Util.runOnMain(
() -> {
if (webxdcMediaController == null) return;
isAudioPlaying = true;
Bundle args = new Bundle();
args.putString("title", currentAudioTitle);
args.putString(
"artist",
WebxdcActivity.this.dcAppMsg.getWebxdcInfo().optString("name", ""));
webxdcMediaController.sendCustomCommand(
new SessionCommand(WebxdcMediaSessionService.COMMAND_AUDIO_RESUMED, new Bundle()),
args);
});
}
}
}
@@ -0,0 +1,308 @@
package org.thoughtcrime.securesms.service;
import android.app.PendingIntent;
import android.content.Intent;
import android.os.Bundle;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.OptIn;
import androidx.media3.common.Player;
import androidx.media3.common.SimpleBasePlayer;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.session.MediaSession;
import androidx.media3.session.MediaSessionService;
import androidx.media3.session.SessionCommand;
import androidx.media3.session.SessionCommands;
import androidx.media3.session.SessionResult;
import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import org.thoughtcrime.securesms.WebxdcActivity;
/**
* A {@link MediaSessionService} for webxdc mini-apps playing audio in a WebView.
*
* <p>The actual audio is played by the WebView's internal audio engine. This service holds a
* {@link MediaSession} backed by a stub {@link SimpleBasePlayer} purely to post the system media
* notification and respond to hardware media keys / notification play-pause buttons.
*
* <p>Communication with {@link WebxdcActivity} uses custom {@link SessionCommand}s:
*
* <ul>
* <li>{@code WEBXDC_AUDIO_STARTED} audio began playing; args carry {@code title}, {@code
* artist}, {@code msg_id}, {@code account_id} so the notification tap reopens the correct
* webxdc instance.
* <li>{@code WEBXDC_AUDIO_STOPPED} audio fully stopped; service removes the notification.
* <li>{@code WEBXDC_AUDIO_PAUSED} audio paused by the app (not by the notification).
* <li>{@code WEBXDC_AUDIO_RESUMED} audio resumed by the app (not by the notification).
* </ul>
*
* When the user presses play/pause in the notification, the stub player's
* {@link SimpleBasePlayer#handleSetPlayWhenReady} relays the command back to {@link
* WebxdcActivity} via a broadcast so the WebView can pause/resume its audio/video elements.
*/
@OptIn(markerClass = UnstableApi.class)
public class WebxdcMediaSessionService extends MediaSessionService {
public static final String COMMAND_AUDIO_STARTED = "WEBXDC_AUDIO_STARTED";
public static final String COMMAND_AUDIO_STOPPED = "WEBXDC_AUDIO_STOPPED";
public static final String COMMAND_AUDIO_PAUSED = "WEBXDC_AUDIO_PAUSED";
public static final String COMMAND_AUDIO_RESUMED = "WEBXDC_AUDIO_RESUMED";
/** Broadcast action sent when the system notification requests audio pause. */
public static final String ACTION_NOTIFICATION_PAUSE =
"org.thoughtcrime.securesms.WEBXDC_NOTIFICATION_PAUSE";
/** Broadcast action sent when the system notification requests audio resume. */
public static final String ACTION_NOTIFICATION_RESUME =
"org.thoughtcrime.securesms.WEBXDC_NOTIFICATION_RESUME";
private static final String TAG = WebxdcMediaSessionService.class.getSimpleName();
private StubPlayer stubPlayer;
private MediaSession session;
// -------------------------------------------------------------------------
// Lifecycle
// -------------------------------------------------------------------------
@Override
public void onCreate() {
super.onCreate();
// Default session activity: open the conversation list. Updated by WEBXDC_AUDIO_STARTED.
Intent defaultIntent =
new Intent(this, org.thoughtcrime.securesms.ConversationListActivity.class);
defaultIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
PendingIntent defaultPendingIntent =
PendingIntent.getActivity(
this,
0,
defaultIntent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
stubPlayer = new StubPlayer();
session =
new MediaSession.Builder(this, stubPlayer)
.setSessionActivity(defaultPendingIntent)
.setCallback(new SessionCallbackImpl())
.build();
}
@Nullable
@Override
public MediaSession onGetSession(MediaSession.ControllerInfo controllerInfo) {
return session;
}
@Override
public void onDestroy() {
if (session != null) {
session.release();
session = null;
}
if (stubPlayer != null) {
stubPlayer.release();
stubPlayer = null;
}
super.onDestroy();
}
// -------------------------------------------------------------------------
// Stub player
// -------------------------------------------------------------------------
/**
* Minimal {@link SimpleBasePlayer} that reports playback state to the {@link MediaSession}
* without controlling any audio engine. Play/pause commands from the notification are relayed
* back to {@link WebxdcActivity} via a broadcast.
*/
@UnstableApi
private final class StubPlayer extends SimpleBasePlayer {
private boolean playWhenReady = false;
private int playbackState = Player.STATE_IDLE;
private ImmutableList<MediaItemData> playlist = ImmutableList.of();
StubPlayer() {
super(WebxdcMediaSessionService.this.getMainLooper());
}
@NonNull
@Override
protected State getState() {
State.Builder builder =
new State.Builder()
.setAvailableCommands(
new Player.Commands.Builder()
.addAll(Player.COMMAND_PLAY_PAUSE, Player.COMMAND_STOP)
.build())
.setPlayWhenReady(playWhenReady, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST)
.setPlaybackState(playbackState)
.setPlaylist(playlist);
if (!playlist.isEmpty()) {
builder.setCurrentMediaItemIndex(0);
}
return builder.build();
}
/** Called when the user presses play/pause in the system notification. */
@NonNull
@Override
protected ListenableFuture<?> handleSetPlayWhenReady(boolean play) {
playWhenReady = play;
Intent broadcast =
new Intent(play ? ACTION_NOTIFICATION_RESUME : ACTION_NOTIFICATION_PAUSE);
broadcast.setPackage(getPackageName());
sendBroadcast(broadcast);
invalidateState();
return Futures.immediateFuture(null);
}
@NonNull
@Override
protected ListenableFuture<?> handleStop() {
playWhenReady = false;
playbackState = Player.STATE_IDLE;
playlist = ImmutableList.of();
Intent broadcast = new Intent(ACTION_NOTIFICATION_PAUSE);
broadcast.setPackage(getPackageName());
sendBroadcast(broadcast);
invalidateState();
return Futures.immediateFuture(null);
}
void setPlaying(String title, String artist) {
playWhenReady = true;
playbackState = Player.STATE_READY;
playlist = buildPlaylist(title, artist);
invalidateState();
}
void setPaused() {
playWhenReady = false;
playbackState = Player.STATE_READY;
invalidateState();
}
void setStopped() {
playWhenReady = false;
playbackState = Player.STATE_IDLE;
playlist = ImmutableList.of();
invalidateState();
}
private ImmutableList<MediaItemData> buildPlaylist(String title, String artist) {
androidx.media3.common.MediaMetadata metadata =
new androidx.media3.common.MediaMetadata.Builder()
.setTitle(title)
.setArtist(artist)
.build();
androidx.media3.common.MediaItem mediaItem =
new androidx.media3.common.MediaItem.Builder()
.setMediaId("webxdc_audio")
.setMediaMetadata(metadata)
.build();
return ImmutableList.of(
new MediaItemData.Builder("webxdc_audio").setMediaItem(mediaItem).build());
}
}
// -------------------------------------------------------------------------
// Session callback
// -------------------------------------------------------------------------
private final class SessionCallbackImpl implements MediaSession.Callback {
@OptIn(markerClass = UnstableApi.class)
@NonNull
@Override
public MediaSession.ConnectionResult onConnect(
@NonNull MediaSession session, @NonNull MediaSession.ControllerInfo controller) {
SessionCommands sessionCommands =
MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS
.buildUpon()
.add(new SessionCommand(COMMAND_AUDIO_STARTED, new Bundle()))
.add(new SessionCommand(COMMAND_AUDIO_STOPPED, new Bundle()))
.add(new SessionCommand(COMMAND_AUDIO_PAUSED, new Bundle()))
.add(new SessionCommand(COMMAND_AUDIO_RESUMED, new Bundle()))
.build();
return new MediaSession.ConnectionResult.AcceptedResultBuilder(session)
.setAvailableSessionCommands(sessionCommands)
.build();
}
@NonNull
@Override
public ListenableFuture<SessionResult> onCustomCommand(
@NonNull MediaSession session,
@NonNull MediaSession.ControllerInfo controller,
@NonNull SessionCommand customCommand,
@NonNull Bundle args) {
switch (customCommand.customAction) {
case COMMAND_AUDIO_STARTED:
handleAudioStarted(args);
break;
case COMMAND_AUDIO_STOPPED:
if (stubPlayer != null) stubPlayer.setStopped();
break;
case COMMAND_AUDIO_PAUSED:
if (stubPlayer != null) stubPlayer.setPaused();
break;
case COMMAND_AUDIO_RESUMED:
if (stubPlayer != null)
stubPlayer.setPlaying(args.getString("title", ""), args.getString("artist", ""));
break;
default:
break;
}
return Futures.immediateFuture(new SessionResult(SessionResult.RESULT_SUCCESS));
}
}
// -------------------------------------------------------------------------
// Command handlers
// -------------------------------------------------------------------------
private void handleAudioStarted(Bundle args) {
String title = args.getString("title", "");
String artist = args.getString("artist", "");
if (args.containsKey("msg_id")) {
int msgId = args.getInt("msg_id");
int accountId = args.getInt("account_id", 0);
updateSessionActivity(accountId, msgId);
}
if (stubPlayer != null) {
stubPlayer.setPlaying(title, artist);
}
Log.i(TAG, "Audio started: title=" + title + " artist=" + artist);
}
@OptIn(markerClass = UnstableApi.class)
private void updateSessionActivity(int accountId, int msgId) {
try {
Intent intent = new Intent(this, WebxdcActivity.class);
intent.setAction(Intent.ACTION_VIEW);
intent.putExtra("accountId", accountId);
intent.putExtra("appMessageId", msgId);
intent.putExtra("hideActionBar", false);
intent.putExtra("href", "");
intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
PendingIntent pendingIntent =
PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
if (session != null) {
session.setSessionActivity(pendingIntent);
}
} catch (Exception e) {
Log.e(TAG, "Failed to update session activity", e);
}
}
}
+26
View File
@@ -181,3 +181,29 @@ window.webxdc = (() => {
},
};
})();
// Audio/video media session integration: notify Android when media plays/pauses/stops.
(function() {
function setupMediaListeners(doc) {
var elements = doc.querySelectorAll('audio, video');
for (var i = 0; i < elements.length; i++) {
(function(el) {
if (el._arcaneMediaListened) return;
el._arcaneMediaListened = true;
el.addEventListener('play', function() {
if (window.InternalJSApi) InternalJSApi.notifyAudioStarted(document.title || '');
});
el.addEventListener('pause', function() {
if (window.InternalJSApi) InternalJSApi.notifyAudioPaused();
});
el.addEventListener('ended', function() {
if (window.InternalJSApi) InternalJSApi.notifyAudioStopped();
});
})(elements[i]);
}
}
// Poll periodically so dynamically created audio/video elements are also detected.
setInterval(function() {
try { setupMediaListeners(document); } catch(e) {}
}, 2000);
})();