diff --git a/.github/workflows/cache-core.yml b/.github/workflows/cache-core.yml new file mode 100644 index 000000000..2e00c8d50 --- /dev/null +++ b/.github/workflows/cache-core.yml @@ -0,0 +1,50 @@ +name: Core Cache + +on: + push: + branches: [main] + paths: + - 'jni/deltachat-core-rust' + - 'jni/Android.mk' + - 'jni/Application.mk' + - 'jni/dc_wrapper.c' + - 'scripts/ndk-make.sh' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + submodules: recursive + + - uses: android-actions/setup-android@v3 + - uses: nttld/setup-ndk@v1 + id: setup-ndk + with: + ndk-version: r27 + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: jni/deltachat-core-rust + + - name: Get deltachat-core-rust submodule hash + id: core-hash + run: echo "hash=$(git rev-parse HEAD:jni/deltachat-core-rust)" >> $GITHUB_OUTPUT + + - name: Cache compiled core + id: cache-core + uses: actions/cache@v4 + with: + path: | + jni/arm64-v8a + libs/arm64-v8a + key: core-arm64-v8a-${{ steps.core-hash.outputs.hash }}-${{ hashFiles('jni/Android.mk', 'jni/Application.mk', 'jni/dc_wrapper.c', 'scripts/ndk-make.sh') }}-ndk-r27 + + - name: Compile core + if: steps.cache-core.outputs.cache-hit != 'true' + env: + ANDROID_NDK_ROOT: ${{ steps.setup-ndk.outputs.ndk-path }} + run: | + export PATH="${PATH}:${ANDROID_NDK_ROOT}/toolchains/llvm/prebuilt/linux-x86_64/bin/" + scripts/install-toolchains.sh && scripts/ndk-make.sh arm64-v8a diff --git a/.github/workflows/preview-apk.yml b/.github/workflows/preview-apk.yml index a8be09d53..f893cf25b 100644 --- a/.github/workflows/preview-apk.yml +++ b/.github/workflows/preview-apk.yml @@ -43,15 +43,15 @@ jobs: - uses: Swatinem/rust-cache@v2 with: - working-directory: jni/deltachat-core-rust + workspaces: jni/deltachat-core-rust - name: Get deltachat-core-rust submodule hash id: core-hash run: echo "hash=$(git rev-parse HEAD:jni/deltachat-core-rust)" >> $GITHUB_OUTPUT - - name: Cache compiled core - id: cache-core - uses: actions/cache@v4 + - name: Restore compiled core + id: core-cache + uses: actions/cache/restore@v4 with: path: | jni/arm64-v8a @@ -59,6 +59,7 @@ jobs: key: core-arm64-v8a-${{ steps.core-hash.outputs.hash }}-${{ hashFiles('jni/Android.mk', 'jni/Application.mk', 'jni/dc_wrapper.c', 'scripts/ndk-make.sh') }}-ndk-r27 - name: Compile core + if: steps.core-cache.outputs.cache-hit != 'true' env: ANDROID_NDK_ROOT: ${{ steps.setup-ndk.outputs.ndk-path }} run: | diff --git a/src/main/java/chat/delta/rpc/BaseTransport.java b/src/main/java/chat/delta/rpc/BaseRpcTransport.java similarity index 98% rename from src/main/java/chat/delta/rpc/BaseTransport.java rename to src/main/java/chat/delta/rpc/BaseRpcTransport.java index d0f98e799..0ba22172b 100644 --- a/src/main/java/chat/delta/rpc/BaseTransport.java +++ b/src/main/java/chat/delta/rpc/BaseRpcTransport.java @@ -14,7 +14,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; /* Basic RPC Transport implementation */ -public abstract class BaseTransport implements Rpc.Transport { +public abstract class BaseRpcTransport implements Rpc.RpcTransport { private final Map> requestFutures = new ConcurrentHashMap<>(); private int requestId = 0; private final ObjectMapper mapper = new ObjectMapper(); diff --git a/src/main/java/chat/delta/rpc/Rpc.java b/src/main/java/chat/delta/rpc/Rpc.java index 142631e00..e6444ab29 100644 --- a/src/main/java/chat/delta/rpc/Rpc.java +++ b/src/main/java/chat/delta/rpc/Rpc.java @@ -9,16 +9,16 @@ import chat.delta.rpc.types.*; public class Rpc { - public interface Transport { + public interface RpcTransport { void call(String method, JsonNode... params) throws RpcException; T callForResult(TypeReference resultType, String method, JsonNode... params) throws RpcException; ObjectMapper getObjectMapper(); } - public final Transport transport; + public final RpcTransport transport; private final ObjectMapper mapper; - public Rpc(Transport transport) { + public Rpc(RpcTransport transport) { this.transport = transport; this.mapper = transport.getObjectMapper(); } diff --git a/src/main/java/com/b44t/messenger/FFITransport.java b/src/main/java/com/b44t/messenger/FFITransport.java index 7891a8809..a4196bea0 100644 --- a/src/main/java/com/b44t/messenger/FFITransport.java +++ b/src/main/java/com/b44t/messenger/FFITransport.java @@ -1,9 +1,9 @@ package com.b44t.messenger; -import chat.delta.rpc.BaseTransport; +import chat.delta.rpc.BaseRpcTransport; /* RPC transport over C FFI */ -public class FFITransport extends BaseTransport { +public class FFITransport extends BaseRpcTransport { private final DcJsonrpcInstance dcJsonrpcInstance; public FFITransport(DcJsonrpcInstance dcJsonrpcInstance) { diff --git a/src/main/java/org/thoughtcrime/securesms/calls/CallActivity.java b/src/main/java/org/thoughtcrime/securesms/calls/CallActivity.java index 9b3de3e34..fcfc3c1ba 100644 --- a/src/main/java/org/thoughtcrime/securesms/calls/CallActivity.java +++ b/src/main/java/org/thoughtcrime/securesms/calls/CallActivity.java @@ -403,7 +403,11 @@ public class CallActivity extends AppCompatActivity { .observe( this, name -> { - displayNameText.setText(name != null ? name : "Unknown"); + if (name != null) { + displayNameText.setText(name); + } else { + displayNameText.setText(R.string.unknown); + } }); viewModel @@ -532,7 +536,7 @@ public class CallActivity extends AppCompatActivity { switch (state) { case INITIALIZING: - statusText.setText("Initializing..."); + statusText.setText(R.string.call_initializing); incomingCallPrompt.setVisibility(View.GONE); bottomLayoutContainer.setVisibility(View.GONE); callerIconContainer.setVisibility(View.GONE); @@ -541,7 +545,7 @@ public class CallActivity extends AppCompatActivity { break; case PROMPTING_USER_ACCEPT: - statusText.setText("Incoming call"); + statusText.setText(R.string.call_incoming); incomingCallPrompt.setVisibility(View.VISIBLE); bottomLayoutContainer.setVisibility(View.GONE); callerIconContainer.setVisibility(View.VISIBLE); @@ -551,7 +555,7 @@ public class CallActivity extends AppCompatActivity { break; case RINGING: - statusText.setText("Ringing..."); + statusText.setText(R.string.call_ringing); incomingCallPrompt.setVisibility(View.GONE); bottomLayoutContainer.setVisibility(View.VISIBLE); callerIconContainer.setVisibility(View.GONE); @@ -560,7 +564,7 @@ public class CallActivity extends AppCompatActivity { break; case CONNECTING: - statusText.setText("Connecting..."); + statusText.setText(R.string.connectivity_connecting); incomingCallPrompt.setVisibility(View.GONE); bottomLayoutContainer.setVisibility(View.VISIBLE); callerIconContainer.setVisibility(View.GONE); @@ -569,7 +573,7 @@ public class CallActivity extends AppCompatActivity { break; case CONNECTED: - statusText.setText("Connected"); + statusText.setText(R.string.connectivity_connected); incomingCallPrompt.setVisibility(View.GONE); bottomLayoutContainer.setVisibility(View.VISIBLE); callerIconContainer.setVisibility(View.GONE); @@ -578,17 +582,17 @@ public class CallActivity extends AppCompatActivity { break; case RECONNECTING: - statusText.setText("Reconnecting..."); + statusText.setText(R.string.call_reconnecting); remoteAvatarView.setVisibility(View.VISIBLE); break; case ENDED: - statusText.setText("Call ended"); + statusText.setText(R.string.call_ended); finish(); break; case ERROR: - statusText.setText("Call failed"); + statusText.setText(R.string.call_failed); incomingCallPrompt.setVisibility(View.GONE); bottomLayoutContainer.setVisibility(View.GONE); callerIconContainer.setVisibility(View.GONE); @@ -698,6 +702,7 @@ public class CallActivity extends AppCompatActivity { private void layoutVideos() { if (isFinishing() || isDestroyed()) return; if (viewModel == null) return; + if (isInPictureInPictureMode()) return; CallViewModel.CallState state = viewModel.getCallState().getValue(); if (state == CallViewModel.CallState.ENDED || state == CallViewModel.CallState.ERROR) return; diff --git a/src/main/java/org/thoughtcrime/securesms/calls/CallCoordinator.java b/src/main/java/org/thoughtcrime/securesms/calls/CallCoordinator.java index 7bd7057f6..dc1e8d744 100644 --- a/src/main/java/org/thoughtcrime/securesms/calls/CallCoordinator.java +++ b/src/main/java/org/thoughtcrime/securesms/calls/CallCoordinator.java @@ -42,6 +42,8 @@ import com.b44t.messenger.DcChat; import com.b44t.messenger.DcContext; import com.b44t.messenger.DcEvent; import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import kotlin.Unit; import kotlin.coroutines.Continuation; import kotlin.coroutines.CoroutineContext; @@ -77,6 +79,7 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate { private final Rpc rpc; private CoroutineScope audioFlowScope; private final Handler mainHandler = new Handler(Looper.getMainLooper()); + private final ExecutorService eventExecutor = Executors.newSingleThreadExecutor(); // LiveData for Observable State private final MutableLiveData connectionState = @@ -91,6 +94,7 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate { private final MutableLiveData errorMessage = new MutableLiveData<>(); private final MutableLiveData displayName = new MutableLiveData<>(); private final MutableLiveData displayIcon = new MutableLiveData<>(); + private final MutableLiveData outgoingCallPlaced = new MutableLiveData<>(false); // Audio Routing Support private final MediatorLiveData currentAudioEndpoint = @@ -121,7 +125,7 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate { private final Runnable outgoingRingtoneRunnable = () -> { synchronized (CallCoordinator.this) { - if (callService != null && activeCallId != null) { + if (callService != null && hasActiveCall()) { callService.startOutgoingRingtone(); } } @@ -309,6 +313,10 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate { return displayIcon; } + public LiveData getOutgoingCallPlaced() { + return outgoingCallPlaced; + } + public LiveData getCurrentAudioEndpoint() { return currentAudioEndpoint; } @@ -823,43 +831,42 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate { // It seems this observer can fire on either main or background thread // Always move to background - new Thread( - () -> { - boolean hasVideo; + eventExecutor.execute( + () -> { + boolean hasVideo; - switch (eventId) { - case DcContext.DC_EVENT_INCOMING_CALL: - try { - hasVideo = this.rpc.callInfo(accId, callId).hasVideo; - } catch (RpcException e) { - Log.e(TAG, "Rpc.callInfo() failed", e); - hasVideo = false; - } - onIncomingCall(accId, callId, event.getData2Str(), hasVideo); - break; - case DcContext.DC_EVENT_INCOMING_CALL_ACCEPTED: - onIncomingCallAccepted(callId); - break; - case DcContext.DC_EVENT_OUTGOING_CALL_ACCEPTED: - String answerSDP = event.getData2Str(); - onOutgoingCallAccepted(callId, answerSDP); - break; - case DcContext.DC_EVENT_CALL_ENDED: - // This event is problematic because it can trigger in both directions, - // in addition to multiple other scenarios which cannot easily be distinguished - // May cause problems in edge cases - onCallEnded(accId, callId); - break; + switch (eventId) { + case DcContext.DC_EVENT_INCOMING_CALL: + try { + hasVideo = this.rpc.callInfo(accId, callId).hasVideo; + } catch (RpcException e) { + Log.e(TAG, "Rpc.callInfo() failed", e); + hasVideo = false; } - }) - .start(); + onIncomingCall(accId, callId, event.getData2Str(), hasVideo); + break; + case DcContext.DC_EVENT_INCOMING_CALL_ACCEPTED: + onIncomingCallAccepted(callId); + break; + case DcContext.DC_EVENT_OUTGOING_CALL_ACCEPTED: + String answerSDP = event.getData2Str(); + onOutgoingCallAccepted(callId, answerSDP); + break; + case DcContext.DC_EVENT_CALL_ENDED: + // This event is problematic because it can trigger in both directions, + // in addition to multiple other scenarios which cannot easily be distinguished + // May cause problems in edge cases + onCallEnded(accId, callId); + break; + } + }); } private synchronized void onIncomingCall( int accId, int callId, String offerSdp, boolean startsWithVideo) { Log.d(TAG, "onIncomingCall: accId=" + accId + ", callId=" + callId); - if (activeCallId != null) { + if (hasActiveCall()) { Log.w(TAG, "Already have an active call, ignoring incoming call"); return; } @@ -955,6 +962,27 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate { private synchronized void onCallEnded(int accId, int callId) { Log.d(TAG, "onCallEnded: accId=" + accId + ", callId=" + callId); + if (!hasActiveCall()) { + Log.w(TAG, "No active call, ignoring"); + return; + } + + if (!activeAccId.equals(accId) || !activeCallId.equals(callId)) { + Log.w( + TAG, + "Event IDs don't match active call " + + "(active: accId=" + + activeAccId + + " callId=" + + activeCallId + + ", event: accId=" + + accId + + " callId=" + + callId + + "), ignoring"); + return; + } + if (callService != null) { callService.stopRingtone(); } @@ -1002,7 +1030,7 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate { notifyBackendCallEnded(); // Cleanup - if (activeAccId != null && activeCallId != null) { + if (hasActiveCall()) { cleanupCall(activeAccId, activeCallId); } } @@ -1011,10 +1039,14 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate { public synchronized void cleanupCall(int accId, int callId) { Log.d(TAG, "cleanupCall: accId=" + accId + ", callId=" + callId); - if (activeCallId != null && !activeCallId.equals(callId) - || activeAccId != null && !activeAccId.equals(accId)) { - Log.w(TAG, "Cleanup accountId or callId doesn't match active call"); - // Clean up anyway. Otherwise, no new calls can happen. + if (!hasActiveCall()) { + Log.d(TAG, "No active call to clean up"); + return; + } + + if (!activeAccId.equals(accId) || !activeCallId.equals(callId)) { + Log.w(TAG, "Cleanup IDs don't match active call, aborting"); + return; } // Clear state @@ -1105,6 +1137,7 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate { remoteVideoEnabled.postValue(true); isRelayUsed.postValue(false); errorMessage.postValue(null); + outgoingCallPlaced.postValue(false); currentAudioEndpoint.postValue(null); availableAudioEndpoints.postValue(null); } @@ -1112,7 +1145,7 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate { public synchronized void initiateOutgoingCall(int accId, int chatId, boolean startsWithVideo) { Log.d(TAG, "Initiating outgoing call:accId=" + accId + ", chatId=" + chatId); - if (activeCallId != null) { + if (hasActiveCall()) { Log.w(TAG, "Already have an active call, cannot start new one"); return; } @@ -1176,6 +1209,7 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate { } this.activeCallId = callId; + outgoingCallPlaced.postValue(true); // Get callee info String calleeName = displayName.getValue(); @@ -1230,10 +1264,7 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate { Log.d(TAG, "CallControlScope initialized"); activeCallControlScope = scope; - mainHandler.post( - () -> { - setupAudioEndpointCollection(scope); - }); + mainHandler.post(() -> setupAudioEndpointCollection(scope)); return Unit.INSTANCE; }, @@ -1472,7 +1503,7 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate { } public synchronized boolean hasActiveCall() { - return activeCallId != null; + return activeAccId != null && activeCallId != null; } public synchronized boolean hasOngoingCall() { diff --git a/src/main/java/org/thoughtcrime/securesms/calls/CallService.java b/src/main/java/org/thoughtcrime/securesms/calls/CallService.java index 1250e2412..fa18aabad 100644 --- a/src/main/java/org/thoughtcrime/securesms/calls/CallService.java +++ b/src/main/java/org/thoughtcrime/securesms/calls/CallService.java @@ -25,8 +25,10 @@ import org.webrtc.PeerConnection; import org.webrtc.VideoTrack; /** - * Foreground service for VoIP calls Required to post CallStyle notifications on Android 12+ Owns - * WebRTC resources and keeps call alive + * Foreground service for VoIP calls + * + *

Required to post CallStyle notifications on Android 12+. Owns WebRTC resources and keeps call + * alive. */ @RequiresApi(api = Build.VERSION_CODES.O) public class CallService extends Service implements WebRTCClient.Callbacks { @@ -100,6 +102,10 @@ public class CallService extends Service implements WebRTCClient.Callbacks { private void fetchIceServersAndSetup() { new Thread( () -> { + if (!callCoordinator.hasActiveCall()) { + Log.d(TAG, "Call ended before ICE fetch, aborting"); + return; + } try { String iceServersJson = callCoordinator.fetchIceServers(); Log.d(TAG, "ICE servers fetched: " + iceServersJson); @@ -117,8 +123,10 @@ public class CallService extends Service implements WebRTCClient.Callbacks { } /** - * Start camera/microphone capture Must be called when app is in foreground Called by coordinator - * when ViewModel/Activity is ready + * Start camera/microphone capture + * + *

Must be called when app is in foreground. Called by coordinator when ViewModel/Activity is + * ready. */ public void startMediaCapture() { Log.d(TAG, "startMediaCapture (Camera/Microphone)"); diff --git a/src/main/java/org/thoughtcrime/securesms/calls/CallViewModel.java b/src/main/java/org/thoughtcrime/securesms/calls/CallViewModel.java index 3522c6366..74e25f5de 100644 --- a/src/main/java/org/thoughtcrime/securesms/calls/CallViewModel.java +++ b/src/main/java/org/thoughtcrime/securesms/calls/CallViewModel.java @@ -35,6 +35,7 @@ public class CallViewModel extends AndroidViewModel { private final LiveData errorMessage; private final LiveData displayName; private final LiveData displayIcon; + private final LiveData outgoingCallPlaced; private final LiveData currentAudioEndpoint; private final LiveData> availableAudioEndpoints; @@ -75,6 +76,7 @@ public class CallViewModel extends AndroidViewModel { this.errorMessage = callCoordinator.getErrorMessage(); this.displayName = callCoordinator.getDisplayName(); this.displayIcon = callCoordinator.getDisplayIcon(); + this.outgoingCallPlaced = callCoordinator.getOutgoingCallPlaced(); this.currentAudioEndpoint = callCoordinator.getCurrentAudioEndpoint(); this.availableAudioEndpoints = callCoordinator.getAvailableAudioEndpoints(); @@ -93,7 +95,7 @@ public class CallViewModel extends AndroidViewModel { if (callCoordinator.isIncomingCall()) { callState.setValue(CallState.PROMPTING_USER_ACCEPT); } else { - callState.setValue(CallState.RINGING); + callState.setValue(CallState.CONNECTING); } Log.d(TAG, "CallViewModel initialized"); @@ -116,6 +118,16 @@ public class CallViewModel extends AndroidViewModel { } } }); + + callState.addSource( + outgoingCallPlaced, + placed -> { + if (Boolean.TRUE.equals(placed) && !callCoordinator.isIncomingCall()) { + if (callState.getValue() == CallState.CONNECTING) { + callState.setValue(CallState.RINGING); + } + } + }); } private CallState translateConnectionState(PeerConnection.PeerConnectionState state) { @@ -131,7 +143,9 @@ public class CallViewModel extends AndroidViewModel { if (callCoordinator.isIncomingCall()) { return CallState.CONNECTING; } else { - return CallState.RINGING; // Mirror TypeScript + return Boolean.TRUE.equals(outgoingCallPlaced.getValue()) + ? CallState.RINGING + : CallState.CONNECTING; } case CONNECTED: diff --git a/src/main/java/org/thoughtcrime/securesms/calls/MediaStreamManager.java b/src/main/java/org/thoughtcrime/securesms/calls/MediaStreamManager.java index 4ec94252d..44a436782 100644 --- a/src/main/java/org/thoughtcrime/securesms/calls/MediaStreamManager.java +++ b/src/main/java/org/thoughtcrime/securesms/calls/MediaStreamManager.java @@ -19,7 +19,7 @@ import org.webrtc.VideoTrack; public class MediaStreamManager { - private static final String TAG = CallUtil.class.getSimpleName(); + private static final String TAG = MediaStreamManager.class.getSimpleName(); private static final String STREAM_ID = "local_stream"; private static final String AUDIO_TRACK_ID = "audio_track"; private static final String VIDEO_TRACK_ID = "video_track"; diff --git a/src/main/java/org/thoughtcrime/securesms/webrtc/WebRTCClient.java b/src/main/java/org/thoughtcrime/securesms/webrtc/WebRTCClient.java index 5c9f21a84..f64fd2f59 100644 --- a/src/main/java/org/thoughtcrime/securesms/webrtc/WebRTCClient.java +++ b/src/main/java/org/thoughtcrime/securesms/webrtc/WebRTCClient.java @@ -156,8 +156,14 @@ public class WebRTCClient { } /** - * Expected JSON format: [ {"urls": "stun:stun.example.com:3478"}, {"urls": - * "turn:turn.example.com", "username": "user", "credential": "pass"} ] + * Expected JSON format: + * + *

{@code
+   * [
+   *   {"urls": "stun:stun.example.com:3478"},
+   *   {"urls": "turn:turn.example.com", "username": "user", "credential": "pass"}
+   * ]
+   * }
*/ public void configure(String iceServersJson) { this.iceServers = parseIceServers(iceServersJson); @@ -514,7 +520,7 @@ public class WebRTCClient { } } - /** Wait for enough ICE before sending offer/answer Mirrors TypeScript gatheredEnoughIce() */ + /** Wait for enough ICE before sending offer/answer. Mirrors TypeScript gatheredEnoughIce() */ private boolean waitForEnoughIce() { boolean hasTurnServer = false; boolean hasStunServer = false; @@ -637,24 +643,32 @@ public class WebRTCClient { () -> { boolean gotIce = waitForEnoughIce(); - if (!gotIce) { - Log.w(TAG, "Proceeding without optimal ICE candidates"); - } + synchronized (WebRTCClient.this) { + if (isEnded || peerConnection == null) { + Log.d(TAG, "Call ended during ICE gathering, aborting"); + return; + } - // Get final SDP - SessionDescription localDesc = peerConnection.getLocalDescription(); - if (localDesc != null) { - String finalSdp = localDesc.description; + if (!gotIce) { + Log.w(TAG, "Proceeding without optimal ICE candidates"); + } - // Enable trickling for additional candidates - enableIceTrickling = true; + // Get final SDP + SessionDescription localDesc = peerConnection.getLocalDescription(); + if (localDesc != null) { + String finalSdp = localDesc.description; - // Notify ViewModel - mainHandler.post(() -> callbacks.onOfferReady(finalSdp)); - } else { - mainHandler.post( - () -> - callbacks.onError("Local description is null after ICE gathering")); + // Enable trickling for additional candidates + enableIceTrickling = true; + + // Notify ViewModel + mainHandler.post(() -> callbacks.onOfferReady(finalSdp)); + } else { + mainHandler.post( + () -> + callbacks.onError( + "Local description is null after ICE gathering")); + } } }) .start(); @@ -788,24 +802,32 @@ public class WebRTCClient { () -> { boolean gotIce = waitForEnoughIce(); - if (!gotIce) { - Log.w(TAG, "Proceeding without optimal ICE candidates"); - } + synchronized (WebRTCClient.this) { + if (isEnded || peerConnection == null) { + Log.d(TAG, "Call ended during ICE gathering, aborting"); + return; + } - // Get final SDP - SessionDescription localDesc = peerConnection.getLocalDescription(); - if (localDesc != null) { - String finalSdp = localDesc.description; + if (!gotIce) { + Log.w(TAG, "Proceeding without optimal ICE candidates"); + } - // Enable trickling for additional candidates - enableIceTrickling = true; + // Get final SDP + SessionDescription localDesc = peerConnection.getLocalDescription(); + if (localDesc != null) { + String finalSdp = localDesc.description; - // Notify ViewModel - mainHandler.post(() -> callbacks.onAnswerReady(finalSdp)); - } else { - mainHandler.post( - () -> - callbacks.onError("Local description is null after ICE gathering")); + // Enable trickling for additional candidates + enableIceTrickling = true; + + // Notify ViewModel + mainHandler.post(() -> callbacks.onAnswerReady(finalSdp)); + } else { + mainHandler.post( + () -> + callbacks.onError( + "Local description is null after ICE gathering")); + } } }) .start(); @@ -916,7 +938,7 @@ public class WebRTCClient { // Cleanup - public void endCall() { + public synchronized void endCall() { if (isEnded) { Log.d(TAG, "endCall() already called, skipping"); return; @@ -925,6 +947,11 @@ public class WebRTCClient { isEnded = true; Log.d(TAG, "Ending call"); + // Unblock any thread waiting in waitForEnoughIce() + if (iceGatheringLatch != null) iceGatheringLatch.countDown(); + if (relayCandidateLatch != null) relayCandidateLatch.countDown(); + if (srflxCandidateLatch != null) srflxCandidateLatch.countDown(); + if (peerConnection != null) { peerConnection.close(); peerConnection.dispose(); diff --git a/src/main/res/layout/activity_call.xml b/src/main/res/layout/activity_call.xml index beff4db6f..200a1b193 100644 --- a/src/main/res/layout/activity_call.xml +++ b/src/main/res/layout/activity_call.xml @@ -222,6 +222,8 @@ android:paddingBottom="24dp" android:paddingStart="16dp" android:paddingEnd="16dp" + android:clipChildren="false" + android:clipToPadding="false" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" diff --git a/src/main/res/layout/item_audio_device.xml b/src/main/res/layout/item_audio_device.xml index 6deb65458..807ebea35 100644 --- a/src/main/res/layout/item_audio_device.xml +++ b/src/main/res/layout/item_audio_device.xml @@ -3,6 +3,7 @@ xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content" + xmlns:tools="http://schemas.android.com/tools" android:orientation="horizontal" android:padding="16dp" android:gravity="center_vertical" @@ -15,7 +16,8 @@ android:layout_width="24dp" android:layout_height="24dp" android:layout_marginEnd="16dp" - android:contentDescription="Device type icon" /> + android:importantForAccessibility="no" + tools:src="@drawable/ic_volume_up" /> + tools:text="Speaker" /> + android:contentDescription="@string/selected" /> diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 3c5f73932..bc20a607c 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -182,6 +182,7 @@ %d Selected Selected: + Selected Me Draft Image @@ -441,6 +442,12 @@ Audio call Video call + Initializing… + Incoming call + Ringing… + Reconnecting… + Call ended + Call failed Outgoing audio call