Compare commits

..

25 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
adbenitez 198268a4c3 Merge remote-tracking branch 'upstream/main' 2026-04-11 14:35:01 +02:00
adb 71158970ae Merge pull request #4357 from deltachat/wch423/external-cache-fix
Add fallback for getExternalCacheDir() in case primary storage is SD
2026-04-10 18:40:34 +02:00
wch423 1383b06e86 Add fallback for getExternalCacheDir() in case primary storage is SD card 2026-04-10 15:36:56 +02:00
B. Petersen 24165e311b deprecate sticker string, pointing to deeply nested profile folder 2026-04-09 17:34:14 +02:00
adb 5ec892db34 Merge pull request #4352 from deltachat/wch423/call-microphone-permission
Refine call permission checks and gates
2026-04-08 20:46:21 +02:00
wch423 caef7eda29 Add fixes for PiP caused bug and potential Android 16 problem with permission popups 2026-04-08 18:21:49 +02:00
wch423 cf53af4778 Refine permission checks and gates 2026-04-08 16:08:14 +02:00
adbenitez 9a22597473 delete zapstore 2026-04-03 17:18:08 +02:00
adb acb4eb2ae1 Merge pull request #4334 from deltachat/adb/issue-4316
remove proxy switch from EditRelayActivity
2026-04-01 00:14:06 +02:00
wchen342 20c0354938 Merge pull request #4336 from deltachat/wch423/call-multidevice-incoming
Fix wrong states when an incoming call is answered on a second device
2026-03-31 17:36:30 -04:00
adbenitez eac112d602 update changelog 2026-03-31 23:09:29 +02:00
wch423 9b4f659f67 Fix crash when an incoming call is answered on a second device 2026-03-31 17:17:11 +02:00
adbenitez 484cee21c6 apply Spotless 2026-03-31 15:24:03 +02:00
adbenitez 0e40318050 rmeove proxy switch from EditRelayActivity 2026-03-31 15:15:02 +02:00
wchen342 b2cc76ff2e Merge pull request #4333 from deltachat/wch423/call-mirror-self
Mirror self video during call
2026-03-30 13:24:01 -04:00
adb 96acaaf000 Merge pull request #4332 from deltachat/adb/remove-deprecated-stock-str-2026-03-30
remove deprecated stock strings 99 and 100
2026-03-30 18:44:31 +02:00
adbenitez 400e5ea671 Merge branch 'adb/remove-deprecated-stock-str-2026-03-30' of https://github.com/deltachat/deltachat-android into adb/remove-deprecated-stock-str-2026-03-30 2026-03-30 18:41:03 +02:00
adbenitez 4fac460926 remove unused strings 2026-03-30 18:40:44 +02:00
wch423 94a5631566 Fix camera iterating through multiple back cameras; Formatting code 2026-03-30 18:00:39 +02:00
wch423 ea91075107 Mirror self video during calls; Minor thread-safe fixes 2026-03-30 17:29:43 +02:00
adb d765d3ddeb Merge branch 'main' into adb/remove-deprecated-stock-str-2026-03-30 2026-03-30 16:01:06 +02:00
adbenitez 094fb1e2a4 remove deprecated stock strings 99 and 100 2026-03-30 15:47:35 +02:00
20 changed files with 943 additions and 185 deletions
-11
View File
@@ -72,14 +72,3 @@ jobs:
files: |
build/outputs/apk/foss/release/*.apk
build/outputs/mapping/fossRelease/mapping-*.txt
- name: Release on ZapStore
run: |
export CHECKSUM=6e2c7cf6da53c3f1a78b523a6aacd6316dce3d74ace6f859c2676729ee439990
curl -sL https://cdn.zapstore.dev/$CHECKSUM -o zapstore
if echo "$CHECKSUM zapstore" | sha256sum -c --status; then
chmod +x zapstore
SIGN_WITH=${{ secrets.NOSTR_KEY }} ./zapstore publish --indexer-mode
else
echo "ERROR: checksum doesn't match!"
fi
+3
View File
@@ -3,6 +3,9 @@
## Unreleased
* Fix file sharing to certain apps (e.g. Material Files, etc.)
* Fix problem with calls when microphone permission is not granted
* Fix taking pictures and videos in devices with SD cards
* Remove proxy toggle from profile editing to avoid confusion
## v2.48.0
2026-03
+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);
});
}
}
}
@@ -52,7 +52,8 @@ import org.webrtc.VideoTrack;
public class CallActivity extends AppCompatActivity {
private static final String TAG = CallActivity.class.getSimpleName();
private static final int PERMISSION_REQUEST_CODE = 1001;
private static final int MIC_PERMISSION_REQUEST_CODE = 1001;
private static final int CAMERA_PERMISSION_REQUEST_CODE = 1002;
public static final String ACTION_ANSWER_CALL = BuildConfig.APPLICATION_ID + ".ANSWER_CALL";
public static final String ACTION_DECLINE_CALL = BuildConfig.APPLICATION_ID + ".DECLINE_CALL";
@@ -94,10 +95,30 @@ public class CallActivity extends AppCompatActivity {
private PowerManager.WakeLock proximityWakeLock;
// States
private boolean awaitingPermissionResult = false;
private boolean pausedWhileAwaitingPermission = false;
private boolean intentHandled = false;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// Destructive actions need nothing
String action = getIntent() != null ? getIntent().getAction() : null;
if (ACTION_DECLINE_CALL.equals(action)) {
Log.d(TAG, "Handling DECLINE_CALL action from notification");
CallCoordinator.getInstance(getApplication()).declineCall();
finish();
return;
}
if (ACTION_HANGUP_CALL.equals(action)) {
Log.d(TAG, "Handling HANGUP_CALL action from notification");
CallCoordinator.getInstance(getApplication()).hangUp();
finish();
return;
}
setContentView(R.layout.activity_call);
setupWindowFlags();
@@ -108,11 +129,6 @@ public class CallActivity extends AppCompatActivity {
initializeProximityWakeLock();
if (!hasRequiredPermissions()) {
requestRequiredPermissions();
return;
}
// PiP listener
addOnPictureInPictureModeChangedListener(
pipModeInfo -> {
@@ -139,47 +155,74 @@ public class CallActivity extends AppCompatActivity {
initializeViewModel();
// Intent handling needs permissions
if (!hasMicrophonePermission()) {
awaitingPermissionResult = true;
ActivityCompat.requestPermissions(
this, new String[] {Manifest.permission.RECORD_AUDIO}, MIC_PERMISSION_REQUEST_CODE);
return;
}
if (shouldRequestCameraPermission()) {
awaitingPermissionResult = true;
ActivityCompat.requestPermissions(
this, new String[] {Manifest.permission.CAMERA}, CAMERA_PERMISSION_REQUEST_CODE);
return;
}
handleIntents(getIntent());
intentHandled = true;
}
private void handleIntents(Intent intent) {
if (intent == null || viewModel == null) {
if (intent == null) {
return;
}
CallCoordinator coordinator = CallCoordinator.getInstance(getApplication());
if (!coordinator.hasActiveCall()) {
Log.e(TAG, "No active call exists, cannot proceed");
Toast.makeText(this, "No active call", Toast.LENGTH_SHORT).show();
finish();
return;
}
String action = intent.getAction();
Log.d(TAG, "handleIntents: action=" + action);
// Handle notification actions
if (ACTION_ANSWER_CALL.equals(action)) {
Log.d(TAG, "Handling ANSWER_CALL action from notification");
viewModel.handleNotificationAnswer();
return;
}
// Destructive actions without ViewModel
if (ACTION_DECLINE_CALL.equals(action)) {
Log.d(TAG, "Handling DECLINE_CALL action from notification");
viewModel.handleNotificationDecline();
Log.d(TAG, "Handling DECLINE_CALL action");
if (viewModel != null) {
viewModel.handleNotificationDecline();
} else {
coordinator.declineCall();
}
finish();
return;
}
if (ACTION_HANGUP_CALL.equals(action)) {
Log.d(TAG, "Handling HANGUP_CALL action from notification");
viewModel.handleNotificationHangup();
Log.d(TAG, "Handling HANGUP_CALL action");
if (viewModel != null) {
viewModel.handleNotificationHangup();
} else {
coordinator.hangUp();
}
finish();
return;
}
if (viewModel == null) {
return;
}
if (!coordinator.hasActiveCall()) {
Log.e(TAG, "No active call exists, cannot proceed");
finish();
return;
}
if (ACTION_ANSWER_CALL.equals(action)) {
Log.d(TAG, "Handling ANSWER_CALL action from notification");
viewModel.handleNotificationAnswer();
return;
}
if (coordinator.hasOngoingCall()) {
Log.d(TAG, "Resuming existing call");
} else if (!coordinator.isIncomingCall()) {
@@ -373,6 +416,7 @@ public class CallActivity extends AppCompatActivity {
case INITIALIZING:
case PROMPTING_USER_ACCEPT:
case ENDED:
case ANSWERED_ELSEWHERE:
case ERROR:
default:
finish();
@@ -504,6 +548,8 @@ public class CallActivity extends AppCompatActivity {
viewModel.getVideoEnabled(), v -> videoConfigChanged.setValue(true));
videoConfigChanged.addSource(
viewModel.getRemoteVideoEnabled(), v -> videoConfigChanged.setValue(true));
videoConfigChanged.addSource(
viewModel.getIsFrontCamera(), v -> videoConfigChanged.setValue(true));
// Video layout
videoConfigChanged.observe(
@@ -578,6 +624,23 @@ public class CallActivity extends AppCompatActivity {
statusText.setText(R.string.call_reconnecting);
break;
case ANSWERED_ELSEWHERE:
statusText.setText(R.string.call_answered_elsewhere);
incomingCallPrompt.setVisibility(View.GONE);
bottomLayoutContainer.setVisibility(View.GONE);
callerIconContainer.setVisibility(View.GONE);
answerModeSelector.setVisibility(View.GONE);
new Handler(Looper.getMainLooper())
.postDelayed(
() -> {
if (!isFinishing()) {
finish();
}
},
1500);
break;
case ENDED:
statusText.setText(R.string.call_ended);
finish();
@@ -705,17 +768,21 @@ public class CallActivity extends AppCompatActivity {
VideoTrack localTrack = viewModel.getLocalVideoTrack().getValue();
VideoTrack remoteTrack = viewModel.getRemoteVideoTrack().getValue();
boolean isFront = Boolean.TRUE.equals(viewModel.getIsFrontCamera().getValue());
boolean showFullScreen = false;
if (state == CallViewModel.CallState.CONNECTED
&& remoteTrack != null
&& Boolean.TRUE.equals(remoteVideoEnabled)) {
remoteVideoView.setMirror(false);
remoteTrack.addSink(remoteVideoView);
showFullScreen = true;
} else if (!coordinator.isIncomingCall()
&& (state == CallViewModel.CallState.RINGING || state == CallViewModel.CallState.CONNECTING)
&& localTrack != null
&& Boolean.TRUE.equals(videoEnabled)) {
remoteVideoView.setMirror(isFront);
localTrack.addSink(remoteVideoView);
showFullScreen = true;
}
@@ -729,6 +796,7 @@ public class CallActivity extends AppCompatActivity {
&& !isInPictureInPictureMode();
if (showCorner) {
localVideoView.setMirror(isFront);
localTrack.addSink(localVideoView);
}
@@ -758,18 +826,46 @@ public class CallActivity extends AppCompatActivity {
// Permissions
private boolean hasRequiredPermissions() {
return ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
== PackageManager.PERMISSION_GRANTED
&& ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
== PackageManager.PERMISSION_GRANTED;
private boolean hasMicrophonePermission() {
return ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
== PackageManager.PERMISSION_GRANTED;
}
private void requestRequiredPermissions() {
ActivityCompat.requestPermissions(
this,
new String[] {Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO},
PERMISSION_REQUEST_CODE);
private boolean hasCameraPermission() {
return ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
== PackageManager.PERMISSION_GRANTED;
}
private boolean shouldRequestCameraPermission() {
CallCoordinator coordinator = CallCoordinator.getInstance(getApplication());
return coordinator.isStartsWithVideo() && !hasCameraPermission();
}
private void handleMicPermissionDenied() {
Toast.makeText(this, R.string.call_requires_mic_permission, Toast.LENGTH_LONG).show();
CallCoordinator coordinator = CallCoordinator.getInstance(getApplication());
if (coordinator.hasActiveCall()
&& coordinator.isIncomingCall()
&& !coordinator.hasOngoingCall()) {
coordinator.declineCall();
}
finish();
}
private void proceedAfterPermissions() {
if (intentHandled) return;
if (shouldRequestCameraPermission()) {
awaitingPermissionResult = true;
ActivityCompat.requestPermissions(
this, new String[] {Manifest.permission.CAMERA}, CAMERA_PERMISSION_REQUEST_CODE);
return;
}
handleIntents(getIntent());
intentHandled = true;
}
@Override
@@ -777,38 +873,34 @@ public class CallActivity extends AppCompatActivity {
int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == PERMISSION_REQUEST_CODE) {
boolean microphoneGranted = false;
boolean cameraGranted = false;
awaitingPermissionResult = false;
pausedWhileAwaitingPermission = false;
for (int i = 0; i < permissions.length; i++) {
if (permissions[i].equals(Manifest.permission.RECORD_AUDIO)) {
microphoneGranted = (grantResults[i] == PackageManager.PERMISSION_GRANTED);
} else if (permissions[i].equals(Manifest.permission.CAMERA)) {
cameraGranted = (grantResults[i] == PackageManager.PERMISSION_GRANTED);
}
}
CallCoordinator coordinator = CallCoordinator.getInstance(getApplication());
if (!microphoneGranted) {
Toast.makeText(this, "Microphone permission is required for calls", Toast.LENGTH_LONG)
.show();
finish();
if (requestCode == MIC_PERMISSION_REQUEST_CODE) {
boolean micGranted =
grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED;
if (!micGranted) {
handleMicPermissionDenied();
return;
}
CallCoordinator coordinator = CallCoordinator.getInstance(getApplication());
} else if (requestCode == CAMERA_PERMISSION_REQUEST_CODE) {
boolean cameraGranted =
grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED;
if (!cameraGranted && coordinator.isStartsWithVideo()) {
if (!cameraGranted) {
Log.w(TAG, "Camera permission denied, switching to audio-only");
Toast.makeText(
this, "Starting audio-only call (camera permission denied)", Toast.LENGTH_SHORT)
.show();
coordinator.setStartsWithVideo(false);
}
initializeViewModel();
handleIntents(getIntent());
}
proceedAfterPermissions();
}
// Picture-in-Picture
@@ -817,6 +909,11 @@ public class CallActivity extends AppCompatActivity {
public void onUserLeaveHint() {
super.onUserLeaveHint();
// Do not finish activity when a permission request is pending
if (awaitingPermissionResult) {
return;
}
// Enter PiP mode when user presses home button during active call
if (viewModel != null) {
CallViewModel.CallState state = viewModel.getCallState().getValue();
@@ -833,6 +930,7 @@ public class CallActivity extends AppCompatActivity {
case INITIALIZING:
case PROMPTING_USER_ACCEPT:
case ENDED:
case ANSWERED_ELSEWHERE:
case ERROR:
default:
finish();
@@ -873,17 +971,51 @@ public class CallActivity extends AppCompatActivity {
protected void onPause() {
super.onPause();
if (awaitingPermissionResult) {
pausedWhileAwaitingPermission = true;
}
if (proximityWakeLock != null && proximityWakeLock.isHeld()) {
proximityWakeLock.release();
Log.d(TAG, "Proximity wake lock released in onDestroy");
}
}
@Override
protected void onResume() {
super.onResume();
// Fallback for Android 16 bug: onRequestPermissionsResult not called
if (awaitingPermissionResult && pausedWhileAwaitingPermission) {
Log.w(TAG, "Permission result callback not received, handling in onResume");
awaitingPermissionResult = false;
pausedWhileAwaitingPermission = false;
if (!hasMicrophonePermission()) {
handleMicPermissionDenied();
return;
}
// Mic was granted without callback
if (shouldRequestCameraPermission()) {
awaitingPermissionResult = true;
ActivityCompat.requestPermissions(
this, new String[] {Manifest.permission.CAMERA}, CAMERA_PERMISSION_REQUEST_CODE);
return;
}
proceedAfterPermissions();
}
}
@Override
protected void onDestroy() {
super.onDestroy();
detachAllTracks();
if (viewModel != null) {
detachAllTracks();
}
// Release video renderers
if (localVideoView != null) {
@@ -95,6 +95,8 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
private final MutableLiveData<String> displayName = new MutableLiveData<>();
private final MutableLiveData<Icon> displayIcon = new MutableLiveData<>();
private final MutableLiveData<Boolean> outgoingCallPlaced = new MutableLiveData<>(false);
private final MutableLiveData<Boolean> answeredElsewhere = new MutableLiveData<>(false);
private final MutableLiveData<Boolean> isFrontCamera = new MutableLiveData<>(true);
// Audio Routing Support
private final MediatorLiveData<CallEndpointCompat> currentAudioEndpoint =
@@ -317,6 +319,10 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
return outgoingCallPlaced;
}
public LiveData<Boolean> getAnsweredElsewhere() {
return answeredElsewhere;
}
public LiveData<CallEndpointCompat> getCurrentAudioEndpoint() {
return currentAudioEndpoint;
}
@@ -325,6 +331,10 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
return availableAudioEndpoints;
}
public LiveData<Boolean> getIsFrontCamera() {
return isFrontCamera;
}
// State Update Methods (CallService)
public void updateConnectionState(PeerConnection.PeerConnectionState state) {
@@ -359,6 +369,11 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
isRelayUsed.postValue(isRelay);
}
public void updateFrontCamera(boolean front) {
Log.d(TAG, "updateFrontCamera: " + front);
isFrontCamera.postValue(front);
}
public void reportError(String error) {
Log.e(TAG, "reportError: " + error);
errorMessage.postValue(error);
@@ -366,7 +381,7 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
// Delayed Media Initialization Support
public void startMediaCapture() {
public synchronized void startMediaCapture() {
Log.d(TAG, "startMediaCapture");
if (callService != null) {
callService.startMediaCapture();
@@ -375,7 +390,7 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
}
}
public void handleCallControlScopeAnswer() {
public synchronized void handleCallControlScopeAnswer() {
Log.d(TAG, "handleCallControlScopeAnswer");
if (!isIncomingCall) {
@@ -512,22 +527,7 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
return;
}
// Check microphone and camera permissions
if (!hasMicrophonePermission()) {
Log.e(TAG, "Microphone permission not granted");
Intent intent = new Intent(appContext, CallActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP);
appContext.startActivity(intent);
notificationManager.cancel(NOTIFICATION_ID_CALL);
return;
}
if (startsWithVideo && !hasCameraPermission()) {
Log.w(TAG, "Camera permission not granted");
startsWithVideo = false;
}
// Launch CallActivity with answer action
// Launch CallActivity
Intent intent = new Intent(appContext, CallActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP);
appContext.startActivity(intent);
@@ -628,7 +628,7 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
cleanupCall(activeAccId, activeCallId);
}
public void setAudioEnabled(boolean enabled) {
public synchronized void setAudioEnabled(boolean enabled) {
Log.d(TAG, "setAudioEnabled: " + enabled);
localAudioEnabled.postValue(enabled);
@@ -640,7 +640,7 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
}
}
public void setVideoEnabled(boolean enabled) {
public synchronized void setVideoEnabled(boolean enabled) {
Log.d(TAG, "setVideoEnabled: " + enabled);
localVideoEnabled.postValue(enabled);
@@ -652,14 +652,14 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
}
}
public void switchCamera() {
public synchronized void switchCamera() {
Log.d(TAG, "switchCamera");
if (callService != null) {
callService.switchCamera();
}
}
public void startOutgoingCall() {
public synchronized void startOutgoingCall() {
Log.d(TAG, "startOutgoingCall");
if (callService != null) {
callService.startOutgoingCall();
@@ -846,7 +846,8 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
onIncomingCall(accId, callId, event.getData2Str(), hasVideo);
break;
case DcContext.DC_EVENT_INCOMING_CALL_ACCEPTED:
onIncomingCallAccepted(callId);
boolean fromThisDevice = event.getData2Int() != 0; // Data2 is from_this_device
onIncomingCallAccepted(callId, fromThisDevice);
break;
case DcContext.DC_EVENT_OUTGOING_CALL_ACCEPTED:
String answerSDP = event.getData2Str();
@@ -902,8 +903,13 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
startAndBindService();
}
private synchronized void onIncomingCallAccepted(int callId) {
Log.d(TAG, "onIncomingCallAccepted: callId=" + callId);
private synchronized void onIncomingCallAccepted(int callId, boolean fromThisDevice) {
Log.d(TAG, "onIncomingCallAccepted: callId=" + callId + ", fromThisDevice=" + fromThisDevice);
if (!fromThisDevice) {
onCallAnsweredOnOtherDevice();
return;
}
if (activeCallId == null || !activeCallId.equals(callId)) {
Log.w(TAG, "Accepted call ID doesn't match active call");
@@ -918,6 +924,52 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
showOrUpdateOngoingNotification("Call with " + callerName);
}
private synchronized void onCallAnsweredOnOtherDevice() {
Log.d(TAG, "Call was answered on another device");
if (!hasActiveCall()) {
Log.d(TAG, "No active call, ignoring");
return;
}
// Prevent notifyBackendCallEnded() from firing during WebRTC teardown.
// The call is still active on the other device.
hasNotifiedBackend = true;
if (callService != null) {
callService.stopRingtone();
}
notificationManager.cancel(NOTIFICATION_ID_CALL);
answeredElsewhere.postValue(true);
// Disconnect from Telecom CallControlScope
CallControlScope scope = activeCallControlScope;
if (scope != null) {
scope.disconnect(
new DisconnectCause(DisconnectCause.REMOTE),
new Continuation<CallControlResult>() {
@NonNull
@Override
public CoroutineContext getContext() {
return EmptyCoroutineContext.INSTANCE;
}
@Override
public void resumeWith(@NonNull Object result) {
Log.d(TAG, "Disconnect (answered elsewhere) completed");
}
});
}
if (callService != null) {
callService.endCall();
}
cleanupCall(activeAccId, activeCallId);
}
private void onOutgoingCallAccepted(int callId, String answerSdp) {
Log.d(TAG, "onOutgoingCallAccepted: callId=" + callId + ", got answer SDP");
@@ -1125,6 +1177,7 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
private void resetLiveDataForNewCall() {
connectionState.postValue(PeerConnection.PeerConnectionState.NEW);
answeredElsewhere.postValue(false); // clearLiveData() must not reset answeredElsewhere
clearLiveData();
}
@@ -1150,7 +1203,7 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
return;
}
// Check camera and microphone permissions
// Check microphone permission
if (!hasMicrophonePermission()) {
Log.e(TAG, "Microphone permission not granted");
@@ -1160,11 +1213,6 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
return;
}
if (startsWithVideo && !hasCameraPermission()) {
Log.w(TAG, "Camera permission not granted, will start audio-only");
startsWithVideo = false;
}
resetLiveDataForNewCall();
this.activeCallId = -1; // Placeholder call ID for Intent
@@ -142,17 +142,13 @@ public class CallService extends Service implements WebRTCClient.Callbacks {
return;
}
// Check permissions
boolean hasMicrophone = callCoordinator.hasMicrophonePermission();
boolean hasCamera = callCoordinator.hasCameraPermission();
if (!hasMicrophone) {
if (!callCoordinator.hasMicrophonePermission()) {
Log.e(TAG, "Microphone permission not granted, cannot start call");
callCoordinator.reportError("Microphone permission is required for calls");
return;
}
boolean startsWithVideo = callCoordinator.isStartsWithVideo() && hasCamera;
boolean startsWithVideo = callCoordinator.isStartsWithVideo();
Log.d(TAG, "Creating media stream with video: " + startsWithVideo);
@@ -164,14 +160,18 @@ public class CallService extends Service implements WebRTCClient.Callbacks {
webRTCClient.setLocalMediaStream(stream);
callCoordinator.updateFrontCamera(mediaStreamManager.isFrontCamera());
callCoordinator.setVideoEnabled(startsWithVideo);
if (!stream.videoTracks.isEmpty()) {
VideoTrack localTrack = stream.videoTracks.get(0);
callCoordinator.updateLocalVideoTrack(localTrack);
} else {
Log.w(TAG, "Camera unavailable, call will be audio-only");
callCoordinator.reportError("Camera unavailable, using audio only");
Log.w(TAG, "No video track in stream, call will be audio-only");
if (startsWithVideo) {
callCoordinator.reportError("Camera unavailable, using audio only");
}
callCoordinator.setVideoEnabled(false);
}
@@ -181,7 +181,9 @@ public class CallService extends Service implements WebRTCClient.Callbacks {
@Override
public void onError(String error) {
Log.e(TAG, "Failed to setup media: " + error);
callCoordinator.reportError("Camera/microphone error: " + error);
if (startsWithVideo) {
callCoordinator.reportError("Camera/microphone error: " + error);
}
callCoordinator.setVideoEnabled(false);
}
});
@@ -410,7 +412,18 @@ public class CallService extends Service implements WebRTCClient.Callbacks {
Log.d(TAG, "switchCamera");
if (mediaStreamManager != null) {
mediaStreamManager.switchCamera();
mediaStreamManager.switchCamera(
new MediaStreamManager.CameraSwitchCallback() {
@Override
public void onCameraSwitch(boolean isFrontCamera) {
callCoordinator.updateFrontCamera(isFrontCamera);
}
@Override
public void onError(String error) {
Log.e(TAG, "Camera switch failed: " + error);
}
});
}
}
@@ -13,6 +13,7 @@ import androidx.lifecycle.LiveData;
import androidx.lifecycle.MediatorLiveData;
import androidx.lifecycle.Observer;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import org.webrtc.PeerConnection;
import org.webrtc.VideoTrack;
@@ -36,8 +37,10 @@ public class CallViewModel extends AndroidViewModel {
private final LiveData<String> displayName;
private final LiveData<Icon> displayIcon;
private final LiveData<Boolean> outgoingCallPlaced;
private final LiveData<Boolean> answeredElsewhere;
private final LiveData<CallEndpointCompat> currentAudioEndpoint;
private final LiveData<List<CallEndpointCompat>> availableAudioEndpoints;
private final LiveData<Boolean> isFrontCamera;
// Translated from coordinator's connectionState
private final MediatorLiveData<CallState> callState;
@@ -46,7 +49,7 @@ public class CallViewModel extends AndroidViewModel {
private Observer<VideoTrack> answerCallObserver;
private Observer<VideoTrack> startOutgoingCallObserver;
private boolean hasCallEnded = false;
private final AtomicBoolean hasCallEnded = new AtomicBoolean(false);
// User-facing call states
public enum CallState {
@@ -56,6 +59,7 @@ public class CallViewModel extends AndroidViewModel {
CONNECTING,
CONNECTED,
RECONNECTING,
ANSWERED_ELSEWHERE,
ENDED,
ERROR
}
@@ -77,8 +81,10 @@ public class CallViewModel extends AndroidViewModel {
this.displayName = callCoordinator.getDisplayName();
this.displayIcon = callCoordinator.getDisplayIcon();
this.outgoingCallPlaced = callCoordinator.getOutgoingCallPlaced();
this.answeredElsewhere = callCoordinator.getAnsweredElsewhere();
this.currentAudioEndpoint = callCoordinator.getCurrentAudioEndpoint();
this.availableAudioEndpoints = callCoordinator.getAvailableAudioEndpoints();
this.isFrontCamera = callCoordinator.getIsFrontCamera();
this.callState = new MediatorLiveData<>(CallState.INITIALIZING);
@@ -105,6 +111,10 @@ public class CallViewModel extends AndroidViewModel {
callState.addSource(
callCoordinator.getConnectionState(),
state -> {
if (callState.getValue() == CallState.ANSWERED_ELSEWHERE) {
return;
}
CallState newState = translateConnectionState(state);
if (callState.getValue() != newState) {
@@ -113,9 +123,7 @@ public class CallViewModel extends AndroidViewModel {
if (state == PeerConnection.PeerConnectionState.FAILED
|| state == PeerConnection.PeerConnectionState.CLOSED) {
if (!hasCallEnded) {
hasCallEnded = true;
}
hasCallEnded.set(true);
}
});
@@ -128,6 +136,15 @@ public class CallViewModel extends AndroidViewModel {
}
}
});
callState.addSource(
answeredElsewhere,
value -> {
if (Boolean.TRUE.equals(value)) {
hasCallEnded.set(true);
callState.setValue(CallState.ANSWERED_ELSEWHERE);
}
});
}
private CallState translateConnectionState(PeerConnection.PeerConnectionState state) {
@@ -257,11 +274,10 @@ public class CallViewModel extends AndroidViewModel {
public void declineCall() {
Log.d(TAG, "declineCall");
if (hasCallEnded) {
if (!hasCallEnded.compareAndSet(false, true)) {
Log.w(TAG, "Call already ended");
return;
}
hasCallEnded = true;
callCoordinator.declineCall();
}
@@ -269,11 +285,10 @@ public class CallViewModel extends AndroidViewModel {
public void hangUp() {
Log.d(TAG, "hangUp");
if (hasCallEnded) {
if (!hasCallEnded.compareAndSet(false, true)) {
Log.w(TAG, "Call already ended");
return;
}
hasCallEnded = true;
callCoordinator.hangUp();
}
@@ -354,8 +369,7 @@ public class CallViewModel extends AndroidViewModel {
public void onCallDisconnected(DisconnectCause disconnectCause) {
Log.d(TAG, "onCallDisconnected callback from CallControlScope, cause: " + disconnectCause);
if (!hasCallEnded) {
hasCallEnded = true;
if (hasCallEnded.compareAndSet(false, true)) {
callState.postValue(CallState.ENDED);
}
}
@@ -414,6 +428,10 @@ public class CallViewModel extends AndroidViewModel {
return availableAudioEndpoints;
}
public LiveData<Boolean> getIsFrontCamera() {
return isFrontCamera;
}
// Notification Action Handlers
public void handleNotificationAnswer() {
@@ -1,9 +1,14 @@
package org.thoughtcrime.securesms.calls;
import android.Manifest;
import android.content.Context;
import android.content.pm.PackageManager;
import android.os.Build;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.core.content.ContextCompat;
import org.thoughtcrime.securesms.EglUtils;
import org.webrtc.AudioSource;
import org.webrtc.AudioTrack;
@@ -31,6 +36,7 @@ public class MediaStreamManager {
private VideoSource videoSource;
private AudioSource audioSource;
private SurfaceTextureHelper surfaceTextureHelper;
private volatile boolean isFrontCamera = true;
public interface Callback {
void onMediaStreamReady(MediaStream stream);
@@ -38,6 +44,12 @@ public class MediaStreamManager {
void onError(String error);
}
public interface CameraSwitchCallback {
void onCameraSwitch(boolean isFrontCamera);
void onError(String error);
}
public MediaStreamManager(@NonNull Context context, PeerConnectionFactory peerConnectionFactory) {
this.context = context.getApplicationContext();
@@ -45,6 +57,7 @@ public class MediaStreamManager {
}
/** Create media stream with audio and optionally video */
@RequiresApi(api = Build.VERSION_CODES.M)
public void createMediaStream(Callback callback) {
try {
MediaStream mediaStream = peerConnectionFactory.createLocalMediaStream(STREAM_ID);
@@ -83,6 +96,12 @@ public class MediaStreamManager {
@Nullable
private VideoCapturer createVideoCapturer() {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA)
!= PackageManager.PERMISSION_GRANTED) {
Log.w(TAG, "Camera permission not granted");
return null;
}
Camera2Enumerator enumerator = new Camera2Enumerator(context);
// Try front camera first
@@ -91,6 +110,7 @@ public class MediaStreamManager {
if (enumerator.isFrontFacing(deviceName)) {
VideoCapturer capturer = enumerator.createCapturer(deviceName, null);
if (capturer != null) {
isFrontCamera = true;
return capturer;
}
}
@@ -100,6 +120,7 @@ public class MediaStreamManager {
for (String deviceName : deviceNames) {
VideoCapturer capturer = enumerator.createCapturer(deviceName, null);
if (capturer != null) {
isFrontCamera = enumerator.isFrontFacing(deviceName);
return capturer;
}
}
@@ -107,12 +128,61 @@ public class MediaStreamManager {
return null;
}
public void switchCamera() {
if (videoCapturer instanceof CameraVideoCapturer) {
CameraVideoCapturer cameraVideoCapturer = (CameraVideoCapturer) videoCapturer;
cameraVideoCapturer.switchCamera(null);
Log.d(TAG, "Camera switched");
public void switchCamera(@Nullable CameraSwitchCallback callback) {
if (!(videoCapturer instanceof CameraVideoCapturer)) {
Log.e(TAG, "switchCamera called but videoCapturer is not a CameraVideoCapturer");
return;
}
CameraVideoCapturer cameraVideoCapturer = (CameraVideoCapturer) videoCapturer;
// Find the opposite-facing camera
Camera2Enumerator enumerator = new Camera2Enumerator(context);
String[] deviceNames = enumerator.getDeviceNames();
String targetCameraName = null;
for (String deviceName : deviceNames) {
boolean isTargetFront = !isFrontCamera;
boolean deviceIsFront = enumerator.isFrontFacing(deviceName);
if (deviceIsFront == isTargetFront) {
targetCameraName = deviceName;
break; // Take the first match
}
}
if (targetCameraName == null) {
Log.e(TAG, "No camera found with opposite facing direction");
if (callback != null) {
callback.onError("No opposite camera available");
}
return;
}
final String finalTargetCameraName = targetCameraName;
Log.d(TAG, "Switching to camera: " + finalTargetCameraName);
// Call with explicit camera name
cameraVideoCapturer.switchCamera(
new CameraVideoCapturer.CameraSwitchHandler() {
@Override
public void onCameraSwitchDone(boolean isFront) {
Log.d(TAG, "switchCamera SUCCESS, isFront=" + isFront);
isFrontCamera = isFront;
if (callback != null) callback.onCameraSwitch(isFront);
}
@Override
public void onCameraSwitchError(String errorDescription) {
Log.e(TAG, "switchCamera FAILED: " + errorDescription);
if (callback != null) callback.onError(errorDescription);
}
},
finalTargetCameraName);
}
public boolean isFrontCamera() {
return isFrontCamera;
}
/** Cleanup resources */
@@ -145,8 +145,6 @@ public class DcHelper {
dcContext.setStockTranslation(91, context.getString(R.string.devicemsg_self_deleted));
dcContext.setStockTranslation(97, context.getString(R.string.forwarded));
dcContext.setStockTranslation(98, context.getString(R.string.devicemsg_storage_exceeding));
dcContext.setStockTranslation(99, context.getString(R.string.n_bytes_message));
dcContext.setStockTranslation(100, context.getString(R.string.download_max_available_until));
dcContext.setStockTranslation(103, context.getString(R.string.incoming_messages));
dcContext.setStockTranslation(104, context.getString(R.string.outgoing_messages));
dcContext.setStockTranslation(107, context.getString(R.string.connectivity_connected));
@@ -208,7 +208,6 @@ public class PersistentBlobProvider {
return getFile(context, ContentUris.parseId(uri)).delete();
}
//noinspection SimplifiableIfStatement
if (isExternalBlobUri(context, uri)) {
return new File(uri.getPath()).delete();
}
@@ -287,6 +286,20 @@ public class PersistentBlobProvider {
private static @NonNull File getExternalDir(Context context) throws IOException {
File externalDir = context.getExternalCacheDir();
if (externalDir != null) {
try {
FileProviderUtil.getUriFor(context, new File(externalDir, "test"));
} catch (IllegalArgumentException e) {
Log.w(
TAG,
"External cache dir not resolvable by FileProvider, "
+ "falling back to internal cache",
e);
externalDir = null;
}
}
if (externalDir == null) {
externalDir = context.getCacheDir();
}
@@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.relay;
import static org.thoughtcrime.securesms.connect.DcHelper.CONFIG_PROXY_ENABLED;
import static org.thoughtcrime.securesms.connect.DcHelper.getContext;
import android.content.DialogInterface;
@@ -24,7 +23,6 @@ import androidx.annotation.IdRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.widget.SwitchCompat;
import androidx.constraintlayout.widget.Group;
import chat.delta.rpc.Rpc;
import chat.delta.rpc.RpcException;
@@ -44,7 +42,6 @@ import org.thoughtcrime.securesms.WelcomeActivity;
import org.thoughtcrime.securesms.connect.DcEventCenter;
import org.thoughtcrime.securesms.connect.DcHelper;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.proxy.ProxySettingsActivity;
import org.thoughtcrime.securesms.util.IntentUtils;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
@@ -79,8 +76,6 @@ public class EditRelayActivity extends BaseActionBarActivity
Spinner smtpSecurity;
Spinner certCheck;
private SwitchCompat proxySwitch;
Rpc rpc;
int accId;
@@ -116,13 +111,6 @@ public class EditRelayActivity extends BaseActionBarActivity
smtpSecurity = findViewById(R.id.smtp_security);
certCheck = findViewById(R.id.cert_check);
proxySwitch = findViewById(R.id.proxy_settings);
proxySwitch.setOnClickListener(
l -> {
proxySwitch.setChecked(!proxySwitch.isChecked()); // revert toggle
startActivity(new Intent(this, ProxySettingsActivity.class));
});
String addr = getIntent().getStringExtra(EXTRA_ADDR);
EnteredLoginParam config = null;
try {
@@ -185,10 +173,6 @@ public class EditRelayActivity extends BaseActionBarActivity
boolean expandAdvanced = false;
int intVal;
intVal = DcHelper.getInt(this, CONFIG_PROXY_ENABLED);
proxySwitch.setChecked(intVal == 1);
expandAdvanced = expandAdvanced || intVal == 1;
if (config != null) { // configured
emailInput.setText(config.addr);
if (!TextUtils.isEmpty(config.addr)) {
@@ -246,7 +230,6 @@ public class EditRelayActivity extends BaseActionBarActivity
@Override
public void onResume() {
super.onResume();
proxySwitch.setChecked(DcHelper.getInt(this, CONFIG_PROXY_ENABLED) == 1);
}
private void showLog() {
@@ -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);
}
}
}
@@ -15,6 +15,7 @@ import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
@@ -57,34 +58,34 @@ public class WebRTCClient {
private final Context context;
private final Handler mainHandler;
private PeerConnectionFactory peerConnectionFactory;
private PeerConnection peerConnection;
private List<PeerConnection.IceServer> iceServers;
private volatile PeerConnection peerConnection;
private volatile List<PeerConnection.IceServer> iceServers;
private DataChannel iceTricklingDataChannel;
private DataChannel mutedStateDataChannel;
// ICE candidate
private final List<IceCandidate> iceCandidateBuffer;
private boolean iceTricklingChannelOpen;
private boolean mutedStateChannelOpen;
private boolean enableIceTrickling;
private boolean isEnded = false;
private volatile boolean iceTricklingChannelOpen;
private volatile boolean mutedStateChannelOpen;
private volatile boolean enableIceTrickling;
private final AtomicBoolean isEnded = new AtomicBoolean(false);
// ICE gathering
private volatile boolean isIceGatheringComplete;
private volatile boolean hasRelayCandidate;
private volatile boolean hasSrflxCandidate;
private volatile boolean hasHostCandidate;
private CountDownLatch iceGatheringLatch;
private CountDownLatch relayCandidateLatch;
private CountDownLatch srflxCandidateLatch;
private volatile CountDownLatch iceGatheringLatch;
private volatile CountDownLatch relayCandidateLatch;
private volatile CountDownLatch srflxCandidateLatch;
// Media
private MediaStream localStream;
private VideoTrack localVideoTrack;
private AudioTrack localAudioTrack;
private VideoTrack remoteVideoTrack;
private AudioTrack remoteAudioTrack;
private volatile VideoTrack localVideoTrack;
private volatile AudioTrack localAudioTrack;
private volatile VideoTrack remoteVideoTrack;
private volatile AudioTrack remoteAudioTrack;
// Callbacks to ViewModel
private final Callbacks callbacks;
@@ -644,7 +645,7 @@ public class WebRTCClient {
boolean gotIce = waitForEnoughIce();
synchronized (WebRTCClient.this) {
if (isEnded || peerConnection == null) {
if (isEnded.get() || peerConnection == null) {
Log.d(TAG, "Call ended during ICE gathering, aborting");
return;
}
@@ -803,7 +804,7 @@ public class WebRTCClient {
boolean gotIce = waitForEnoughIce();
synchronized (WebRTCClient.this) {
if (isEnded || peerConnection == null) {
if (isEnded.get() || peerConnection == null) {
Log.d(TAG, "Call ended during ICE gathering, aborting");
return;
}
@@ -939,12 +940,10 @@ public class WebRTCClient {
// Cleanup
public synchronized void endCall() {
if (isEnded) {
if (!isEnded.compareAndSet(false, true)) {
Log.d(TAG, "endCall() already called, skipping");
return;
}
isEnded = true;
Log.d(TAG, "Ending call");
// Unblock any thread waiting in waitForEnoughIce()
+2 -13
View File
@@ -129,7 +129,7 @@
android:visibility="gone"
tools:visibility="visible"
app:constraint_referenced_ids="inbox, imap_login, imap_server, imap_port, imap_security_label, imap_security, outbox_view_spacer_top,
outbox, smtp_login, smtp_password, smtp_server, smtp_port, smtp_security_label, smtp_security, cert_check_label, cert_check, view_log_button, proxy_settings" />
outbox, smtp_login, smtp_password, smtp_server, smtp_port, smtp_security_label, smtp_security, cert_check_label, cert_check, view_log_button" />
<ImageView
android:id="@+id/advanced_icon"
@@ -157,17 +157,6 @@
app:layout_constraintStart_toEndOf="@id/advanced_icon"
app:layout_constraintTop_toBottomOf="@id/no_servers_hint" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/proxy_settings"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingTop="16dp"
android:paddingBottom="16dp"
android:text="@string/proxy_use_proxy"
app:layout_constraintEnd_toEndOf="@id/guideline_root_end"
app:layout_constraintStart_toStartOf="@id/guideline_root_start"
app:layout_constraintTop_toBottomOf="@id/advanced_text" />
<TextView
android:id="@+id/inbox"
android:layout_width="wrap_content"
@@ -176,7 +165,7 @@
android:layout_marginTop="8dp"
android:text="@string/login_inbox"
app:layout_constraintStart_toStartOf="@id/guideline_root_start"
app:layout_constraintTop_toBottomOf="@id/proxy_settings" />
app:layout_constraintTop_toBottomOf="@id/advanced_text" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/imap_login"
+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);
})();
+4 -4
View File
@@ -175,7 +175,9 @@
<!-- "Stickers" as known from other messengers; in some languages, the English "Sticker" is fine. -->
<string name="sticker">Sticker</string>
<string name="add_to_sticker_collection">Add to Sticker Collection</string>
<!-- deprecated, use sticker_picker_empty_hint instead -->
<string name="add_stickers_instructions">To add stickers, tap "Open Sticker Folder", create a subfolder for your sticker pack, and drag image and sticker files there</string>
<!-- deprecated -->
<string name="open_sticker_folder">Open Sticker Folder</string>
<string name="ask_add_sticker_to_collection">Add this sticker to your collection?</string>
<string name="ask_delete_sticker">Delete this sticker?</string>
@@ -416,6 +418,8 @@
<string name="canceled_call">Canceled call</string>
<string name="missed_call">Missed call</string>
<string name="already_in_call">Already in a call</string>
<string name="call_answered_elsewhere">Call answered on another device</string>
<string name="call_requires_mic_permission">Microphone permission is required for calls</string>
<!-- get confirmations -->
<!-- confirmation for leaving groups or channels. If a subject is needed, "Are you sure you want to leave the chat?" would work as well -->
@@ -814,10 +818,6 @@
<string name="up_to_x_most_worse_quality_images">Up to %1$s, most worse quality images</string>
<string name="up_to_x_most_balanced_quality_images">Up to %1$s, most balanced quality images</string>
<string name="download_failed">Download failed</string>
<!-- %1$s will be replaced by a human-readable number of bytes, eg. 32 KiB, 1 MiB. Resulting string eg. "1 MiB message" -->
<string name="n_bytes_message">%1$s message</string>
<!-- %1$s will be replaced by human-readable date and time -->
<string name="download_max_available_until">Download maximum available until %1$s</string>
<string name="profile_image_select">Select Profile Image</string>
<string name="select_your_new_profile_image">Select your new profile image</string>
<string name="profile_image_delete">Delete Profile Image</string>
+4
View File
@@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path
name="cache"
path="." />
<external-cache-path
name="external_cache"
path="." />
-7
View File
@@ -1,7 +0,0 @@
repository: https://github.com/ArcaneChat/android
assets:
- build/outputs/apk/foss/release/.*.apk
- .*.apk
remote_metadata:
- fdroid
- github