mirror of
https://github.com/ArcaneChat/android.git
synced 2026-07-03 14:05:24 +02:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f442377e7 | |||
| 6ecb276a49 | |||
| 69c888f168 | |||
| 198268a4c3 | |||
| 71158970ae | |||
| 1383b06e86 | |||
| 24165e311b | |||
| 5ec892db34 | |||
| caef7eda29 | |||
| cf53af4778 | |||
| 9a22597473 | |||
| acb4eb2ae1 | |||
| 20c0354938 | |||
| eac112d602 | |||
| 9b4f659f67 | |||
| 484cee21c6 | |||
| 0e40318050 | |||
| b2cc76ff2e | |||
| 96acaaf000 | |||
| 400e5ea671 | |||
| 4fac460926 | |||
| 94a5631566 | |||
| ea91075107 | |||
| d765d3ddeb | |||
| 094fb1e2a4 |
@@ -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,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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path
|
||||
name="cache"
|
||||
path="." />
|
||||
|
||||
<external-cache-path
|
||||
name="external_cache"
|
||||
path="." />
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
repository: https://github.com/ArcaneChat/android
|
||||
assets:
|
||||
- build/outputs/apk/foss/release/.*.apk
|
||||
- .*.apk
|
||||
remote_metadata:
|
||||
- fdroid
|
||||
- github
|
||||
Reference in New Issue
Block a user