Files
arcanechat-android/src/main/java/org/thoughtcrime/securesms/WebViewActivity.java
T
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

371 lines
12 KiB
Java

package org.thoughtcrime.securesms;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.ImageView;
import android.widget.Toast;
import androidx.annotation.RequiresApi;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.widget.SearchView;
import androidx.webkit.ProxyConfig;
import androidx.webkit.ProxyController;
import androidx.webkit.WebSettingsCompat;
import androidx.webkit.WebViewFeature;
import chat.delta.rpc.types.SecurejoinSource;
import java.net.IDN;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.thoughtcrime.securesms.qr.QrCodeHandler;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.IntentUtils;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
public class WebViewActivity extends PassphraseRequiredActionBarActivity
implements SearchView.OnQueryTextListener, WebView.FindListener {
private static final String TAG = WebViewActivity.class.getSimpleName();
// Regex to extract the host from a URL for IDN conversion.
private static final Pattern URL_PATTERN =
Pattern.compile("^((?:[a-zA-Z0-9]+://)?)([^/?#]+)(.*)$");
protected WebView webView;
/**
* Return true the window content should display fullscreen/edge-to-edge ex. in the integrated
* maps app
*/
protected boolean immersiveMode() {
return false;
}
protected boolean shouldAskToOpenLink() {
return false;
}
protected void toggleFakeProxy(boolean enable) {
if (WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) {
if (enable) {
// Set proxy to non-routable address.
ProxyConfig proxyConfig =
new ProxyConfig.Builder().removeImplicitRules().addProxyRule("0.0.0.0").build();
ProxyController.getInstance()
.setProxyOverride(proxyConfig, Runnable::run, () -> Log.i(TAG, "Set WebView proxy."));
} else {
ProxyController.getInstance()
.clearProxyOverride(Runnable::run, () -> Log.i(TAG, "Cleared WebView proxy."));
}
} else {
Log.w(TAG, "Cannot " + (enable ? "set" : "clear") + " WebView proxy.");
}
}
@Override
protected void onCreate(Bundle state, boolean ready) {
setContentView(R.layout.web_view_activity);
ActionBar actionBar = getSupportActionBar();
if (actionBar != null) {
actionBar.setDisplayHomeAsUpEnabled(true);
}
webView = findViewById(R.id.webview);
if (immersiveMode()) {
// set a shadow in the status bar to make it more readable
findViewById(R.id.status_bar_background)
.setBackgroundResource(R.drawable.search_toolbar_shadow);
} else {
// add padding to avoid content hidden behind system bars
ViewUtil.applyWindowInsets(
findViewById(R.id.content_container), true, true, true, true, true, false);
}
webView.setWebViewClient(
new WebViewClient() {
// IMPORTANT: this is will likely not be called inside iframes unless target=_blank is
// used in the anchor/link tag.
// `shouldOverrideUrlLoading()` is called when the user clicks a URL,
// returning `true` causes the WebView to abort loading the URL,
// returning `false` causes the WebView to continue loading the URL as usual.
// the method is not called for POST request nor for on-page-links.
//
// nb: from API 24, `shouldOverrideUrlLoading(String)` is deprecated and
// `shouldOverrideUrlLoading(WebResourceRequest)` shall be used.
// the new one has the same functionality, and the old one still exist,
// so, to support all systems, for now, using the old one seems to be the simplest way.
@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
if (url != null) {
String schema = url.split(":")[0].toLowerCase();
switch (schema) {
case "http":
case "https":
case "gemini":
case "tel":
case "sms":
case "mailto":
case "openpgp4fpr":
case "geo":
case "dcaccount":
case "dclogin":
return openOnlineUrl(url);
}
}
return true; // returning `true` aborts loading
}
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, String url) {
WebResourceResponse res = interceptRequest(url);
if (res != null) {
return res;
}
return super.shouldInterceptRequest(view, url);
}
@Override
@RequiresApi(21)
public WebResourceResponse shouldInterceptRequest(
WebView view, WebResourceRequest request) {
WebResourceResponse res = interceptRequest(request.getUrl().toString());
if (res != null) {
return res;
}
return super.shouldInterceptRequest(view, request);
}
});
webView.setFindListener(this);
// disable "safe browsing" as this has privacy issues,
// eg. at least false positives are sent to the "Safe Browsing Lookup API".
// as all URLs opened in the WebView are local anyway,
// "safe browsing" will never be able to report issues, so it can be disabled.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
webView.getSettings().setSafeBrowsingEnabled(false);
}
}
protected void setForceDark() {
if (Build.VERSION.SDK_INT <= 32
&& WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) {
// needed for older API (tested on android7) that do not set `color-scheme` without the
// following hint
WebSettingsCompat.setForceDark(
webView.getSettings(),
DynamicTheme.isDarkTheme(this)
? WebSettingsCompat.FORCE_DARK_ON
: WebSettingsCompat.FORCE_DARK_OFF);
}
}
/**
* 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();
if (pauseWebViewOnPause()) {
webView.onPause();
}
}
@Override
public void onResume() {
super.onResume();
webView.onResume();
}
@Override
protected void onDestroy() {
super.onDestroy();
webView.destroy();
}
@Override
public boolean onPrepareOptionsMenu(Menu menu) {
MenuInflater inflater = this.getMenuInflater();
menu.clear();
inflater.inflate(R.menu.web_view, menu);
try {
MenuItem searchItem = menu.findItem(R.id.menu_search_web_view);
searchItem.setOnActionExpandListener(
new MenuItem.OnActionExpandListener() {
@Override
public boolean onMenuItemActionExpand(final MenuItem item) {
searchMenu = menu;
WebViewActivity.this.lastQuery = "";
WebViewActivity.this.makeSearchMenuVisible(menu, searchItem);
return true;
}
@Override
public boolean onMenuItemActionCollapse(final MenuItem item) {
WebViewActivity.this.invalidateOptionsMenu();
return true;
}
});
SearchView searchView = (SearchView) searchItem.getActionView();
searchView.setOnQueryTextListener(this);
searchView.setQueryHint(getString(R.string.search));
searchView.setIconifiedByDefault(true);
// hide the [X] beside the search field - this is too much noise, search can be aborted eg. by
// "back"
ImageView closeBtn = searchView.findViewById(R.id.search_close_btn);
if (closeBtn != null) {
closeBtn.setEnabled(false);
closeBtn.setImageDrawable(null);
}
} catch (Exception e) {
Log.e(TAG, "cannot set up web-view-search: ", e);
}
super.onPrepareOptionsMenu(menu);
return true;
}
// search
private Menu searchMenu = null;
private String lastQuery = "";
private Toast lastToast = null;
private void updateResultCounter(int curr, int total) {
if (searchMenu != null) {
MenuItem item = searchMenu.findItem(R.id.menu_search_counter);
if (curr != -1) {
item.setTitle(String.format("%d/%d", total == 0 ? 0 : curr + 1, total));
item.setVisible(true);
} else {
item.setVisible(false);
}
}
}
@Override
public boolean onQueryTextSubmit(String query) {
return true; // action handled by listener
}
@Override
public boolean onQueryTextChange(String query) {
String normQuery = query.trim();
lastQuery = normQuery;
if (lastToast != null) {
lastToast.cancel();
lastToast = null;
}
webView.findAllAsync(normQuery);
return true; // action handled by listener
}
@Override
public void onFindResultReceived(
int activeMatchOrdinal, int numberOfMatches, boolean isDoneCounting) {
if (isDoneCounting) {
if (numberOfMatches > 0) {
updateResultCounter(activeMatchOrdinal, numberOfMatches);
} else {
if (lastQuery.isEmpty()) {
updateResultCounter(-1, 0); // hide
} else {
String msg = getString(R.string.search_no_result_for_x, lastQuery);
if (lastToast != null) {
lastToast.cancel();
}
lastToast = Toast.makeText(this, msg, Toast.LENGTH_SHORT);
lastToast.show();
updateResultCounter(0, 0); // show as "0/0"
}
}
}
}
// other actions
@Override
public boolean onOptionsItemSelected(MenuItem item) {
super.onOptionsItemSelected(item);
int itemId = item.getItemId();
if (itemId == android.R.id.home) {
finish();
return true;
} else if (itemId == R.id.menu_search_up) {
if (lastQuery.isEmpty()) {
webView.scrollTo(0, 0);
} else {
webView.findNext(false);
}
return true;
} else if (itemId == R.id.menu_search_down) {
if (lastQuery.isEmpty()) {
webView.scrollTo(0, 1000000000);
} else {
webView.findNext(true);
}
return true;
}
return false;
}
// onBackPressed() can be overwritten by derived classes as needed.
// the default behavior (close the activity) is just fine eg. for Webxdc, Connectivity, HTML-mails
protected boolean openOnlineUrl(String url) {
// invite-links should be handled directly
String schema = url.split(":")[0].toLowerCase();
if (schema.equals("openpgp4fpr") || url.startsWith("https://" + Util.INVITE_DOMAIN + "/")) {
new QrCodeHandler(this).handleOnlySecureJoinQr(url, SecurejoinSource.InternalLink, null);
return true; // abort internal loading
}
if (shouldAskToOpenLink()) {
String displayUrl;
try {
Matcher m = URL_PATTERN.matcher(url);
displayUrl = m.find() ? m.group(1) + IDN.toASCII(m.group(2)) + m.group(3) : url;
} catch (IllegalArgumentException e) {
// IDN.toASCII() failed (e.g. string too long), fall back to raw url
displayUrl = url;
}
new AlertDialog.Builder(this)
.setTitle(R.string.open_url_confirmation)
.setMessage(displayUrl)
.setNeutralButton(R.string.cancel, null)
.setPositiveButton(R.string.open, (d, w) -> IntentUtils.showInBrowser(this, url))
.setNegativeButton(
R.string.global_menu_edit_copy_desktop,
(d, w) -> {
Util.writeTextToClipboard(this, url);
Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show();
})
.show();
} else {
IntentUtils.showInBrowser(this, url);
}
// returning `true` causes the WebView to abort loading
return true;
}
protected WebResourceResponse interceptRequest(String url) {
return null;
}
}