diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8884e456bb1..69fe41e6c15 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -44,6 +44,7 @@ + @@ -369,7 +370,7 @@ + android:foregroundServiceType="microphone|camera|mediaProjection" /> diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt index 2ad7065a292..b25e4cb6a37 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -14,6 +14,7 @@ import android.annotation.SuppressLint import android.app.ActivityManager import android.app.PendingIntent import android.app.RemoteAction +import android.content.ActivityNotFoundException import android.content.BroadcastReceiver import android.content.Context import android.content.DialogInterface @@ -29,11 +30,13 @@ import android.media.AudioFormat import android.media.AudioRecord import android.media.MediaPlayer import android.media.MediaRecorder +import android.media.projection.MediaProjectionManager import android.net.Uri import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper +import android.os.ResultReceiver import android.provider.Settings import android.text.TextUtils import android.text.format.DateUtils @@ -45,6 +48,7 @@ import android.view.View.OnTouchListener import android.widget.Toast import androidx.activity.SystemBarStyle import androidx.activity.enableEdgeToEdge +import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.DrawableRes import androidx.annotation.StringRes @@ -71,7 +75,9 @@ import com.nextcloud.talk.R import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication +import com.nextcloud.talk.call.AndroidScreenCapture import com.nextcloud.talk.call.CallParticipantList +import com.nextcloud.talk.call.LocalScreenShareController import com.nextcloud.talk.call.LocalStateBroadcaster import com.nextcloud.talk.call.LocalStateBroadcasterMcu import com.nextcloud.talk.call.LocalStateBroadcasterNoMcu @@ -83,6 +89,7 @@ import com.nextcloud.talk.call.e2ee.CallEncryption import com.nextcloud.talk.call.e2ee.FrameKeyRing import com.nextcloud.talk.call.MutableLocalCallParticipantModel import com.nextcloud.talk.call.ReactionAnimator +import com.nextcloud.talk.call.ScreenCapture import com.nextcloud.talk.call.components.ParticipantGrid import com.nextcloud.talk.call.components.SelfVideoView import com.nextcloud.talk.call.components.screenshare.ScreenShareComponent @@ -129,6 +136,7 @@ import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability import com.nextcloud.talk.utils.CapabilitiesUtil.isCallRecordingAvailable import com.nextcloud.talk.utils.ConversationUtils.checkIfVoiceRoom import com.nextcloud.talk.utils.NotificationUtils.getCallRingtoneUri +import com.nextcloud.talk.utils.ParticipantPermissions import com.nextcloud.talk.utils.ReceiverFlag import com.nextcloud.talk.utils.SpreedFeatures import com.nextcloud.talk.utils.VibrationUtils.vibrateShort @@ -288,6 +296,35 @@ class CallActivity : CallBaseActivity() { private val callParticipantMessageListeners: MutableMap = HashMap() private val selfPeerConnectionObserver: PeerConnectionObserver = CallActivitySelfPeerConnectionObserver() + private val localScreenShareController = LocalScreenShareController(ScreenShareHost()) { runOnUiThread(it) } + + private var conversationScreenSharePermission = false + + // The permission from signaling is more recent than the one from the conversation, so it takes precedence. + private var signalingScreenSharePermission: Boolean? = null + + @Volatile + var isAllowedToShareScreen = false + private set + + private val screenSharePermissionListener = object : SignalingMessageReceiver.ParticipantListMessageListener { + override fun onUsersInRoom(participants: MutableList) { + updateScreenSharePermission(participants) + } + + override fun onParticipantsUpdate(participants: MutableList) { + updateScreenSharePermission(participants) + } + + override fun onAllParticipantsUpdate(inCall: Long) { + // unused atm + } + } + + // Screen sharing needs the foreground service, which is not started without microphone and notification permission. + @Volatile + private var isCallForegroundServiceStarted = false + private val callParticipantListObserver: CallParticipantList.Observer = object : CallParticipantList.Observer { override fun onCallParticipantsChanged( joined: Collection, @@ -368,6 +405,12 @@ class CallActivity : CallBaseActivity() { private var moreCallActionsDialog: MoreCallActionsDialog? = null private var elapsedSeconds: Long = 0 + private val screenSharingPermissionLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result: ActivityResult -> + onScreenSharingPermissionResult(result) + } + private var requestPermissionLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { permissionMap: Map -> @@ -433,6 +476,15 @@ class CallActivity : CallBaseActivity() { private var othersInCall = false private var isOneToOneConversation = false private var currentConversation: ConversationDto? = null + set(value) { + field = value + conversationScreenSharePermission = value != null && + ParticipantPermissions( + conversationUser.capabilities?.spreedCapability, + ConversationModel.mapToConversationModel(value, conversationUser) + ).canPublishScreen() + applyScreenSharePermission() + } private lateinit var micInputAudioRecorder: AudioRecord private var micInputAudioRecordThread: Thread? = null @@ -563,6 +615,7 @@ class CallActivity : CallBaseActivity() { initRaiseHandViewModel() initCallRecordingViewModel(extras.getInt(KEY_RECORDING_STATE, 0)) + initScreenSharingIndicator() initClickListeners(isModerator, isOneToOneConversation) binding!!.microphoneButton.setOnTouchListener(MicrophoneButtonTouchListener()) @@ -687,6 +740,31 @@ class CallActivity : CallBaseActivity() { } } + private fun initScreenSharingIndicator() { + lifecycleScope.launch { + callViewModel.isLocalScreenSharing.collect { isSharing -> + binding?.callScreenSharingIndicator?.visibility = if (isSharing) View.VISIBLE else View.GONE + } + } + binding!!.callScreenSharingIndicator.setOnClickListener { showStopScreenSharingDialog() } + } + + private fun showStopScreenSharingDialog() { + val dialogBuilder = MaterialAlertDialogBuilder(this) + .setTitle(R.string.nc_stop_screen_sharing) + .setMessage(R.string.nc_stop_screen_sharing_confirm_message) + .setPositiveButton(R.string.nc_stop_screen_sharing) { _: DialogInterface?, _: Int -> + localScreenShareController.stop() + } + .setNegativeButton(R.string.nc_common_dismiss, null) + viewThemeUtils.dialog.colorMaterialAlertDialogBackground(this, dialogBuilder) + val dialog = dialogBuilder.show() + viewThemeUtils.platform.colorTextButtons( + dialog.getButton(AlertDialog.BUTTON_POSITIVE), + dialog.getButton(AlertDialog.BUTTON_NEGATIVE) + ) + } + private fun initRaiseHandViewModel() { raiseHandViewModel = ViewModelProvider(this, viewModelFactory).get(RaiseHandViewModel::class.java) raiseHandViewModel!!.setData(conversationUser, roomToken!!, isBreakoutRoom) @@ -865,6 +943,7 @@ class CallActivity : CallBaseActivity() { override fun onStart() { super.onStart() active = true + updateExcludeFromRecents(true) initFeaturesVisibility() try { cache!!.evictAll() @@ -873,6 +952,26 @@ class CallActivity : CallBaseActivity() { } } + /** + * The call task is excluded from recents, so it is not shown there next to the conversations while the call is on + * screen or in picture in picture. Once in background, for example, when sharing the screen or when picture in + * picture is not possible or was closed, the system removes background tasks excluded from recents as soon as + * another task is used, which destroys the activity and ends the call. Therefore the call is shown in recents while + * in background, so it keeps running until it is ended or removed from recents. + */ + private fun updateExcludeFromRecents(isExcluded: Boolean) { + if (isFinishing || isDestroyed) { + return + } + try { + getSystemService(ActivityManager::class.java).appTasks + .firstOrNull { it.taskInfo?.taskIdCompat == taskId } + ?.setExcludeFromRecents(isExcluded) + } catch (e: IllegalArgumentException) { + Log.w(TAG, "Failed to update whether the call is excluded from recents", e) + } + } + override fun onStop() { super.onStop() Log.d( @@ -881,6 +980,7 @@ class CallActivity : CallBaseActivity() { " isFinishing=$isFinishing isChangingConfigurations=$isChangingConfigurations" ) active = false + updateExcludeFromRecents(false) if (isMicInputAudioThreadRunning) { stopMicInputDetection() @@ -903,7 +1003,7 @@ class CallActivity : CallBaseActivity() { } private fun initFeaturesVisibility() { - if (isAllowedToStartOrStopRecording || isAllowedToRaiseHand) { + if (isAllowedToStartOrStopRecording || isAllowedToRaiseHand || isAllowedToShareScreen) { binding!!.moreCallActions.visibility = View.VISIBLE } else { binding!!.moreCallActions.visibility = View.GONE @@ -950,9 +1050,9 @@ class CallActivity : CallBaseActivity() { binding!!.callRecordingIndicator.setOnClickListener { if (isAllowedToStartOrStopRecording) { if (callRecordingViewModel!!.viewState.value is RecordingStartingState) { - if (moreCallActionsDialog == null) { - moreCallActionsDialog = MoreCallActionsDialog(this) - } + // A fresh instance is created every time, as the dialog's lifecycle is destroyed on dismiss and + // reusing the instance would leave its lifecycle-scoped observers inactive. + moreCallActionsDialog = MoreCallActionsDialog(this) moreCallActionsDialog!!.show() } else { callRecordingViewModel!!.clickRecordButton() @@ -1282,6 +1382,7 @@ class CallActivity : CallBaseActivity() { SEC_10 ).show() } else { + isCallForegroundServiceStarted = true CallForegroundService.start( applicationContext, conversationName, @@ -1658,6 +1759,7 @@ class CallActivity : CallBaseActivity() { if (currentCallStatus !== CallStatus.LEAVING) { hangup(true, false) } + isCallForegroundServiceStarted = false CallForegroundService.stop(applicationContext) powerManagerUtils!!.updatePhoneState(PowerManagerUtils.PhoneState.IDLE) @@ -1977,6 +2079,8 @@ class CallActivity : CallBaseActivity() { } callParticipantList = CallParticipantList(signalingMessageReceiver) callParticipantList!!.addObserver(callParticipantListObserver) + signalingMessageReceiver!!.removeListener(screenSharePermissionListener) + signalingMessageReceiver!!.addListener(screenSharePermissionListener) if (hasMCU) { localStateBroadcaster = LocalStateBroadcasterMcu(localCallParticipantModel, messageSender) @@ -2282,6 +2386,7 @@ class CallActivity : CallBaseActivity() { private fun hangup(shutDownView: Boolean, endCallForAll: Boolean) { Log.d(TAG, "hangup! shutDownView=$shutDownView, endCallForAll=$endCallForAll") + stopOrSuspendScreenSharing(shutDownView) joinRoomInitiated = false if (shutDownView) { setCallState(CallStatus.LEAVING) @@ -2319,6 +2424,7 @@ class CallActivity : CallBaseActivity() { if (shutDownView) { Log.d(TAG, "Stopping foreground service from hangup()") + isCallForegroundServiceStarted = false CallForegroundService.stop(applicationContext) } @@ -2386,6 +2492,7 @@ class CallActivity : CallBaseActivity() { callParticipantList!!.removeObserver(callParticipantListObserver) callParticipantList!!.destroy() } + signalingMessageReceiver?.removeListener(screenSharePermissionListener) val endCall: Boolean? = if (endCallForAll) true else null // Fire DELETE best-effort; do not block the UI waiting for the server response. @@ -2572,6 +2679,7 @@ class CallActivity : CallBaseActivity() { if (!isSelfInCall) { Log.d(TAG, "Self not in call, disconnecting from all other sessions") + stopOrSuspendScreenSharing(shutDownView = false) removeSessions(participantsInCall) return } @@ -2579,12 +2687,7 @@ class CallActivity : CallBaseActivity() { return } if (hasMCU) { - // Ensure that own publishing peer is set up. - getOrCreatePeerConnectionWrapperForSessionIdAndType( - webSocketClient!!.sessionId, - VIDEO_STREAM_TYPE_VIDEO, - true - ) + setUpOwnPublishers() } handleJoinedCallParticipantsChanged(selfParticipant, joined, currentSessionId) @@ -2594,11 +2697,21 @@ class CallActivity : CallBaseActivity() { removeSessions(left) } + /** + * Ensures that the own publishing peers are set up. The screen share publisher is closed while reconnecting, so it + * is set up again once back in the call. + */ + private fun setUpOwnPublishers() { + getOrCreatePeerConnectionWrapperForSessionIdAndType(webSocketClient!!.sessionId, VIDEO_STREAM_TYPE_VIDEO, true) + localScreenShareController.ensurePublisher() + } + private fun removeSessions(sessions: Collection) { for ((_, _, _, _, _, _, _, _, _, _, session) in sessions) { Log.d(TAG, " session that will be removed is: $session") endPeerConnection(session, "video") endPeerConnection(session, "screen") + localScreenShareController.onParticipantLeft(session) removeCallParticipant(session) } } @@ -2892,6 +3005,8 @@ class CallActivity : CallBaseActivity() { // and send the local state exactly when the data channel becomes ready. localStateBroadcaster!!.handleCallParticipantAdded(callViewModel.getParticipant(sessionId)!!.uiState) + localScreenShareController.onParticipantJoined(sessionId) + initPipMode() } @@ -3379,7 +3494,12 @@ class CallActivity : CallBaseActivity() { // Don't hang up if the activity is just backgrounded (e.g., task switching). // The ICE failure is likely transient due to the activity being stopped. // The connection will recover when the activity resumes. - if (!active && currentCallStatus === CallStatus.IN_CONVERSATION) { + // While sharing the screen the call is expected to be in background and may not be resumed for + // a long time, so it reconnects instead. + if (!active && + !localScreenShareController.isSharing && + currentCallStatus === CallStatus.IN_CONVERSATION + ) { Log.d(TAG, "ICE FAILED while backgrounded, skipping hangup (will recover on resume)") return@runOnUiThread } @@ -3638,6 +3758,191 @@ class CallActivity : CallBaseActivity() { eventBus.post(ConfigurationChangeEvent()) } + private fun updateScreenSharePermission(participants: List) { + val ownSessionId = getOwnSessionId() + val permissions = participants.firstOrNull { it?.sessionId == ownSessionId }?.participantPermissions ?: return + val isAllowed = ParticipantPermissions.canPublishScreen( + conversationUser.capabilities?.spreedCapability, + permissions + ) + + runOnUiThread { + signalingScreenSharePermission = isAllowed + applyScreenSharePermission() + } + } + + /** + * Must be called on the main thread. + */ + private fun applyScreenSharePermission() { + val isAllowed = signalingScreenSharePermission ?: conversationScreenSharePermission + if (isAllowed == isAllowedToShareScreen) { + return + } + isAllowedToShareScreen = isAllowed + callViewModel.setScreenSharingAllowed(isAllowed) + if (binding == null) { + return + } + initFeaturesVisibility() + if (!isAllowed && localScreenShareController.isSharing) { + localScreenShareController.stop() + Snackbar.make(binding!!.root, R.string.nc_screen_sharing_not_allowed, Snackbar.LENGTH_LONG).show() + } + } + + fun toggleScreenSharing() { + if (localScreenShareController.isSharing) { + localScreenShareController.stop() + return + } + if (localScreenShareController.isPending) { + return + } + if (!isAllowedToShareScreen) { + Snackbar.make(binding!!.root, R.string.nc_screen_sharing_not_allowed, Snackbar.LENGTH_LONG).show() + return + } + if (!isConnectionEstablished) { + Snackbar.make(binding!!.root, R.string.nc_screen_sharing_not_connected, Snackbar.LENGTH_LONG).show() + return + } + if (!isCallForegroundServiceStarted) { + Snackbar.make(binding!!.root, R.string.nc_screen_sharing_permissions_missing, Snackbar.LENGTH_LONG).show() + return + } + // The permission dialog moves the call to the background, which must not enter picture in picture. + if (!localScreenShareController.requestStart()) { + return + } + val mediaProjectionManager = getSystemService(MediaProjectionManager::class.java) + try { + screenSharingPermissionLauncher.launch(mediaProjectionManager.createScreenCaptureIntent()) + } catch (e: ActivityNotFoundException) { + Log.e(TAG, "Screen capture is not supported on this device", e) + localScreenShareController.cancelPending() + showScreenSharingError() + } + } + + private fun onScreenSharingPermissionResult(result: ActivityResult) { + val permissionResultData = result.data + if (result.resultCode != RESULT_OK || permissionResultData == null) { + Log.d(TAG, "Screen sharing permission was not granted") + localScreenShareController.cancelPending() + return + } + localScreenShareController.onPermissionGranted(permissionResultData) + } + + /** + * The own signaling session, which differs from the call session with external signaling. + */ + private fun getOwnSessionId(): String? = if (webSocketClient != null) webSocketClient!!.sessionId else callSession + + private fun isCallActiveForScreenSharing(): Boolean = + isConnectionEstablished && currentCallStatus !== CallStatus.LEAVING + + /** + * While reconnecting the screen keeps being captured, so it is shared again once back in the call. + */ + private fun stopOrSuspendScreenSharing(shutDownView: Boolean) { + val isReconnecting = currentCallStatus === CallStatus.RECONNECTING || + currentCallStatus === CallStatus.PUBLISHER_FAILED + if (shutDownView || !isReconnecting) { + localScreenShareController.stop() + } else { + localScreenShareController.suspend() + } + } + + /** + * Picture in picture would be shown on top of the shared screen, so the call is just moved to the background + * while sharing; the call notification shows that the screen is being shared. + */ + override fun isAutomaticPipModeSuppressed(): Boolean = + localScreenShareController.isSharing || localScreenShareController.isPending + + private fun showScreenSharingError() { + Snackbar.make(binding!!.root, R.string.nc_screen_sharing_failed, Snackbar.LENGTH_LONG).show() + } + + private inner class ScreenShareHost : LocalScreenShareController.Host { + override val hasMCU: Boolean + get() = this@CallActivity.hasMCU + + override val ownSessionId: String? + get() = getOwnSessionId() + + override val participantSessionIds: Collection + get() = getParticipantSessionKeys() + + override val isCallActive: Boolean + get() = isCallActiveForScreenSharing() + + override val isScreenSharingAllowed: Boolean + get() = isAllowedToShareScreen + + override fun startForegroundServiceScreenSharing(onResult: (isReady: Boolean) -> Unit) { + val resultReceiver = object : ResultReceiver(Handler(Looper.getMainLooper())) { + override fun onReceiveResult(resultCode: Int, resultData: Bundle?) { + onResult(resultCode == CallForegroundService.RESULT_SCREEN_SHARING_READY) + } + } + CallForegroundService.startScreenSharing(applicationContext, resultReceiver) + } + + override fun stopForegroundServiceScreenSharing() { + CallForegroundService.stopScreenSharing(applicationContext) + } + + override fun createScreenCapture(permissionResultData: Intent, onStop: () -> Unit): ScreenCapture? { + // Both are released when hanging up, which may happen in parallel from the signaling thread. + val eglBase = rootEglBase ?: return null + val factory = peerConnectionFactory ?: return null + return AndroidScreenCapture(applicationContext, eglBase, factory, permissionResultData, onStop) + } + + override fun createPeerConnection(sessionId: String, stream: MediaStream): PeerConnectionWrapper { + val (senderKeyRing, receiverKeyRing) = keyRingsFor(hasMCU, hasMCU, sessionId) + return PeerConnectionWrapper( + peerConnectionFactory, + iceServers, + sdpConstraintsForMCUPublisher, + sessionId, + ownSessionId, + stream, + hasMCU, + hasMCU, + VIDEO_STREAM_TYPE_SCREEN, + signalingMessageReceiver, + signalingMessageSender, + isCallEndToEndEncryptionEnabled, + senderKeyRing, + receiverKeyRing, + true + ) + } + + override fun send(message: NCSignalingMessageDto, sessionId: String) { + messageSender?.send(message, sessionId) + } + + override fun onStateChanged() { + callViewModel.setLocalScreenSharing(localScreenShareController.isSharing) + updatePipAutoEnter() + } + + override fun onScreenSharingFailed() { + showScreenSharingError() + } + + override fun onEncryptionFailed() { + failCallEncryption(R.string.nc_call_e2ee_setup_failed) + } + } + val isAllowedToStartOrStopRecording: Boolean get() { val spreedCap = conversationUser.capabilities?.spreedCapability @@ -3669,6 +3974,7 @@ class CallActivity : CallBaseActivity() { var active = false const val VIDEO_STREAM_TYPE_VIDEO = "video" + const val VIDEO_STREAM_TYPE_SCREEN = "screen" private val TAG = CallActivity::class.java.simpleName private val PERMISSIONS_CAMERA = arrayOf( Manifest.permission.CAMERA diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java b/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java index ec6ff7930f4..a1827e62fd5 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java +++ b/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java @@ -33,7 +33,7 @@ public abstract class CallBaseActivity extends BaseActivity { private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(true) { @Override public void handleOnBackPressed() { - if (isPipModePossible()) { + if (shouldEnterPipModeAutomatically()) { enterPipMode(); } else { moveTaskToBack(true); @@ -110,7 +110,7 @@ public void onTopResumedActivityChanged(boolean isTopResumedActivity) { Log.d(TAG, "onTopResumedActivityChanged: isTopResumedActivity=" + isTopResumedActivity + " isInPictureInPictureMode=" + isInPictureInPictureMode()); if (isTopResumedActivity || isInPictureInPictureMode() - || !isPipModePossible() + || !shouldEnterPipModeAutomatically() || isChangingConfigurations() || isFinishing()) { return; @@ -129,7 +129,7 @@ public void onPause() { // On API 29+, onTopResumedActivityChanged already handled this. if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && !isInPictureInPictureMode() - && isPipModePossible() + && shouldEnterPipModeAutomatically() && !isChangingConfigurations() && !isFinishing()) { enterPipMode(); @@ -152,7 +152,7 @@ protected void onUserLeaveHint() { // moveTaskToBack here: onUserLeaveHint also fires when a transient overlay like the // permission dialog appears at call start, which would throw the call to the background. if (!isInPipMode - && isPipModePossible() + && shouldEnterPipModeAutomatically() && Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { enterPipMode(); } @@ -173,6 +173,35 @@ void enterPipMode() { } } + /** + * Whether picture in picture should not be entered when the user leaves the activity (for example, with the back + * or home buttons). Picture in picture can still be entered explicitly. + *

+ * {@link #updatePipAutoEnter()} must be called whenever the returned value changes. + */ + protected boolean isAutomaticPipModeSuppressed() { + return false; + } + + private boolean shouldEnterPipModeAutomatically() { + return isPipModePossible() && !isAutomaticPipModeSuppressed(); + } + + /** + * Updates whether the system enters picture in picture automatically when leaving the activity. + */ + void updatePipAutoEnter() { + // It may be called from a task posted before the activity was destroyed. + if (isFinishing() || isDestroyed()) { + return; + } + // The builder is only created if picture in picture was possible when the activity was created. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && mPictureInPictureParamsBuilder != null) { + mPictureInPictureParamsBuilder.setAutoEnterEnabled(!isAutomaticPipModeSuppressed()); + setPictureInPictureParams(mPictureInPictureParamsBuilder.build()); + } + } + boolean isPipModePossible() { boolean deviceHasPipFeature = getPackageManager().hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE); diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallViewModel.kt b/app/src/main/java/com/nextcloud/talk/activities/CallViewModel.kt index ee4c0a2b2f0..ff0a6560115 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallViewModel.kt @@ -27,6 +27,12 @@ class CallViewModel @Inject constructor() : ViewModel() { private val _activeScreenShareSession = MutableStateFlow(null) val activeScreenShareSession: StateFlow = _activeScreenShareSession.asStateFlow() + private val _isLocalScreenSharing = MutableStateFlow(false) + val isLocalScreenSharing: StateFlow = _isLocalScreenSharing.asStateFlow() + + private val _isScreenSharingAllowed = MutableStateFlow(false) + val isScreenSharingAllowed: StateFlow = _isScreenSharingAllowed.asStateFlow() + fun getParticipant(sessionId: String?): ParticipantHandler? { if (sessionId == null) { Log.w(TAG, "Attempted to get participant with null sessionId.") @@ -88,6 +94,14 @@ class CallViewModel @Inject constructor() : ViewModel() { } } + fun setLocalScreenSharing(isSharing: Boolean) { + _isLocalScreenSharing.value = isSharing + } + + fun setScreenSharingAllowed(isAllowed: Boolean) { + _isScreenSharingAllowed.value = isAllowed + } + public override fun onCleared() { participantHandlers.values.forEach { it.destroy() } participantHandlers.clear() diff --git a/app/src/main/java/com/nextcloud/talk/call/LocalScreenShareController.kt b/app/src/main/java/com/nextcloud/talk/call/LocalScreenShareController.kt new file mode 100644 index 00000000000..3b3910296be --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/call/LocalScreenShareController.kt @@ -0,0 +1,430 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.call + +import android.content.Intent +import android.util.Log +import com.nextcloud.talk.models.json.signaling.NCSignalingMessageDto +import com.nextcloud.talk.webrtc.PeerConnectionWrapper +import com.nextcloud.talk.webrtc.PeerConnectionWrapper.PeerConnectionObserver +import org.webrtc.MediaStream +import org.webrtc.PeerConnection.IceConnectionState + +/** + * Shares the local screen in a call, from asking for the permission until the screen sharing is stopped. + * + * Without MCU the screen is sent through one connection per remote participant, with MCU through a single publisher + * connection, for which the MCU then sends offers to the remote participants on request. Failed connections are + * created again a few times, and while the call reconnects the screen keeps being captured, so it is shared again + * once back in the call. + * + * The state is changed both from the main thread and from the signaling thread, so the methods can be called from + * any thread unless stated otherwise. + */ +class LocalScreenShareController(private val host: Host, private val runOnMainThread: (Runnable) -> Unit) { + + /** + * Provides what the screen sharing needs from the call. + */ + interface Host { + val hasMCU: Boolean + + /** + * The own signaling session, which the publisher connection is created for with MCU. + */ + val ownSessionId: String? + + val participantSessionIds: Collection + + /** + * Whether the call is joined, so connections can be created. + */ + val isCallActive: Boolean + + val isScreenSharingAllowed: Boolean + + /** + * Adds the media projection type to the call foreground service, which is needed before the screen can be + * captured. + */ + fun startForegroundServiceScreenSharing(onResult: (isReady: Boolean) -> Unit) + + fun stopForegroundServiceScreenSharing() + + /** + * Returns null if the capture can not be created, for example, because the call is being hung up. + */ + fun createScreenCapture(permissionResultData: Intent, onStop: () -> Unit): ScreenCapture? + + /** + * @throws IllegalStateException if the connection has to be end-to-end encrypted but the keys are missing + */ + fun createPeerConnection(sessionId: String, stream: MediaStream): PeerConnectionWrapper + + fun send(message: NCSignalingMessageDto, sessionId: String) + + /** + * Called on the main thread when [isSharing] or [isPending] changed. + */ + fun onStateChanged() + + /** + * Called on the main thread when the screen sharing could not be started or failed. + */ + fun onScreenSharingFailed() + + /** + * Called when a connection could not be created because it could not be end-to-end encrypted. + */ + fun onEncryptionFailed() + } + + // Guards the whole state; host methods that may tear the screen sharing down are not called while holding it. + private val lock = Any() + + @Volatile + private var capture: ScreenCapture? = null + + // Set from asking for the permission until the screen sharing started, failed or was cancelled. + @Volatile + private var isPendingInternal = false + + private val connections: MutableList = ArrayList() + private val observers: MutableMap = HashMap() + + // Failed connections are created again a few times per remote session before giving up. + private val retryCounts: MutableMap = HashMap() + + private var isPublisherConnected = false + + val isSharing: Boolean + get() = capture != null + + val isPending: Boolean + get() = isPendingInternal + + /** + * Marks the screen sharing as being started, which has to be followed by [onPermissionGranted] or + * [cancelPending]. Returns false if the screen is already shared or being started. + */ + fun requestStart(): Boolean { + synchronized(lock) { + if (capture != null || isPendingInternal) { + return false + } + isPendingInternal = true + } + notifyStateChanged() + return true + } + + fun cancelPending() { + val wasPending = synchronized(lock) { + isPendingInternal.also { isPendingInternal = false } + } + if (wasPending) { + notifyStateChanged() + } + } + + /** + * Must be called on the main thread. + */ + fun onPermissionGranted(permissionResultData: Intent) { + if (!isPendingInternal || !host.isCallActive) { + Log.d(TAG, "Screen sharing was cancelled while asking for the permission") + cancelPending() + return + } + + // On Android 14+ the media projection can only be started once the foreground service has the media + // projection type, which in turn requires the permission to have been granted. + host.startForegroundServiceScreenSharing { isReady -> + if (isReady) { + start(permissionResultData) + } else { + cancelPending() + host.onScreenSharingFailed() + } + } + } + + private fun start(permissionResultData: Intent) { + val newCapture = startCapture(permissionResultData) ?: return + + val isCancelled = synchronized(lock) { + // stop() may have been called from the signaling thread while the capture was being started. + if (isPendingInternal) { + capture = newCapture + isPendingInternal = false + isPublisherConnected = false + } + capture !== newCapture + } + if (isCancelled) { + Log.d(TAG, "Screen sharing was cancelled while starting it") + newCapture.release() + host.stopForegroundServiceScreenSharing() + notifyStateChanged() + } else { + notifyStateChanged() + if (host.hasMCU) { + addConnection(host.ownSessionId) + } else { + host.participantSessionIds.forEach { addConnection(it) } + } + } + } + + /** + * Returns the started capture, or null if the screen sharing could not be started or was cancelled. + */ + private fun startCapture(permissionResultData: Intent): ScreenCapture? { + val canStart = capture == null && isPendingInternal && host.isScreenSharingAllowed && host.isCallActive + val newCapture = if (canStart) { + host.createScreenCapture(permissionResultData) { runOnMainThread { stop() } } + } else { + null + } + return when { + newCapture == null -> { + cancelPending() + host.stopForegroundServiceScreenSharing() + null + } + + !newCapture.start() -> { + newCapture.release() + cancelPending() + host.stopForegroundServiceScreenSharing() + host.onScreenSharingFailed() + null + } + + else -> newCapture + } + } + + /** + * Only the first call after starting the screen sharing tears it down. If the screen sharing is still being + * started it is cancelled instead. + */ + fun stop() { + val stoppedCapture: ScreenCapture? + var stoppedConnections: List = emptyList() + var wasPending = false + synchronized(lock) { + stoppedCapture = capture + if (stoppedCapture == null) { + wasPending = isPendingInternal + isPendingInternal = false + } else { + capture = null + isPublisherConnected = false + retryCounts.clear() + stoppedConnections = connections.toList() + connections.clear() + } + } + if (stoppedCapture == null) { + if (wasPending) { + notifyStateChanged() + } + return + } + + sendScreenMessage("unshareScreen", host.participantSessionIds) + + stoppedConnections.forEach { closeConnection(it) } + + stoppedCapture.release() + + host.stopForegroundServiceScreenSharing() + notifyStateChanged() + } + + /** + * Closes the connections while the call reconnects, but keeps capturing the screen, so it is shared again once + * back in the call without asking the user for the permission again. + */ + fun suspend() { + val suspendedConnections = synchronized(lock) { + isPublisherConnected = false + retryCounts.clear() + connections.toList().also { connections.clear() } + } + suspendedConnections.forEach { closeConnection(it) } + } + + /** + * Sets up the publisher connection with MCU if the screen is shared, for example, after reconnecting. + */ + fun ensurePublisher() { + if (host.hasMCU) { + addConnection(host.ownSessionId) + } + } + + fun onParticipantJoined(sessionId: String) { + if (capture == null) { + return + } + if (host.hasMCU) { + // Participants joining before the publisher is connected are requested once it is connected. + val isConnected = synchronized(lock) { isPublisherConnected } + if (isConnected) { + sendScreenMessage("sendoffer", listOf(sessionId)) + } + } else { + addConnection(sessionId) + } + } + + fun onParticipantLeft(sessionId: String?) { + val connection = synchronized(lock) { + // The connection is created again from scratch if the participant joins again. + retryCounts.remove(sessionId) + connections.firstOrNull { it.sessionId == sessionId }?.also { connections.remove(it) } + } ?: return + closeConnection(connection) + } + + private fun addConnection(sessionId: String?) { + val isEncryptionFailed = synchronized(lock) { + val stream = capture?.stream ?: return + if (sessionId == null || connections.any { it.sessionId == sessionId }) { + return + } + + val connection = try { + host.createPeerConnection(sessionId, stream) + } catch (e: IllegalStateException) { + Log.e(TAG, "Not creating a screen share connection to $sessionId", e) + null + } + if (connection != null) { + val observer = ConnectionObserver(connection) + observers[connection] = observer + connection.addObserver(observer) + connections.add(connection) + } + connection == null + } + // Ending the call also stops the screen sharing, so it is not done while holding the lock. + if (isEncryptionFailed) { + host.onEncryptionFailed() + } + } + + private fun closeConnection(connection: PeerConnectionWrapper) { + synchronized(lock) { + observers.remove(connection) + }?.let { connection.removeObserver(it) } + connection.removePeerConnection() + } + + /** + * With MCU "sendoffer" requests the MCU to send the offer for the screen publisher to the remote participant. + */ + private fun sendScreenMessage(type: String, sessionIds: Collection) { + sessionIds.forEach { sessionId -> + host.send(NCSignalingMessageDto(type = type, roomType = ROOM_TYPE_SCREEN), sessionId) + } + } + + private fun notifyStateChanged() { + runOnMainThread { host.onStateChanged() } + } + + private inner class ConnectionObserver(private val connection: PeerConnectionWrapper) : PeerConnectionObserver { + override fun onStreamAdded(mediaStream: MediaStream) { + // unused atm + } + + override fun onStreamRemoved(mediaStream: MediaStream) { + // unused atm + } + + override fun onIceConnectionStateChanged(iceConnectionState: IceConnectionState) { + runOnMainThread { + if (iceConnectionState == IceConnectionState.CONNECTED) { + onConnected() + } else if (iceConnectionState == IceConnectionState.FAILED) { + // Closed connections are ignored there. + onFailed() + } + } + } + + /** + * Must be called on the main thread. + */ + private fun onConnected() { + // The check and the update are done together, as the screen sharing may be stopped from the signaling + // thread in between. + val isFirstPublisherConnection = synchronized(lock) { + // The event may have been queued before the connection was closed, even if sharing started again. + if (!connections.contains(connection)) { + return + } + retryCounts.remove(connection.sessionId) + (host.hasMCU && !isPublisherConnected).also { + if (it) { + isPublisherConnected = true + } + } + } + if (isFirstPublisherConnection) { + sendScreenMessage("sendoffer", host.participantSessionIds) + } + } + + /** + * Must be called on the main thread. + */ + private fun onFailed() { + val sessionId = connection.sessionId + val shouldRetry = synchronized(lock) { + if (!connections.remove(connection)) { + return + } + if (host.hasMCU) { + isPublisherConnected = false + } + val retryCount = retryCounts[sessionId] ?: 0 + retryCounts[sessionId] = retryCount + 1 + retryCount < MAX_RETRIES + } + closeConnection(connection) + + if (!host.isCallActive) { + // The connections are set up again once reconnected. + return + } + if (shouldRetry) { + Log.w(TAG, "Screen share connection to $sessionId failed, creating it again") + // The remote participants close the connection receiving the screen, so the new offer is not handled + // by the failed connection. + sendScreenMessage("unshareScreen", if (host.hasMCU) host.participantSessionIds else listOf(sessionId)) + addConnection(sessionId) + } else if (host.hasMCU) { + Log.e(TAG, "Screen share publisher connection failed") + stop() + host.onScreenSharingFailed() + } else { + Log.e(TAG, "Screen share connection to $sessionId failed, not sharing the screen with it anymore") + } + } + } + + companion object { + private val TAG = LocalScreenShareController::class.java.simpleName + + private const val ROOM_TYPE_SCREEN = "screen" + private const val MAX_RETRIES = 3 + } +} diff --git a/app/src/main/java/com/nextcloud/talk/call/ScreenCapture.kt b/app/src/main/java/com/nextcloud/talk/call/ScreenCapture.kt new file mode 100644 index 00000000000..5fc582e5a2a --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/call/ScreenCapture.kt @@ -0,0 +1,186 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.call + +import android.content.Context +import android.content.Intent +import android.hardware.display.DisplayManager +import android.media.projection.MediaProjection +import android.os.Handler +import android.os.Looper +import android.util.DisplayMetrics +import android.util.Log +import android.view.Display +import org.webrtc.EglBase +import org.webrtc.MediaStream +import org.webrtc.PeerConnectionFactory +import org.webrtc.ScreenCapturerAndroid +import org.webrtc.SurfaceTextureHelper +import kotlin.math.max + +/** + * Capture of the local screen, provided as a stream with a single video track. + */ +interface ScreenCapture { + val stream: MediaStream + + /** + * Returns false if the capture could not be started. + */ + fun start(): Boolean + + /** + * Stops the capture and releases it, including the stream. Can be called from any thread. + */ + fun release() +} + +/** + * Captures the whole display through a media projection, independently of the size of the call window (for example, + * in picture in picture or split screen) and of whether the call is visible. + * + * @param permissionResultData the result of the media projection permission dialog + * @param onStop called when the media projection is stopped by the system or the user + */ +class AndroidScreenCapture( + private val context: Context, + eglBase: EglBase, + factory: PeerConnectionFactory, + permissionResultData: Intent, + onStop: () -> Unit +) : ScreenCapture { + + private val capturer = ScreenCapturerAndroid( + permissionResultData, + object : MediaProjection.Callback() { + override fun onStop() { + onStop() + } + } + ) + private val surfaceTextureHelper = SurfaceTextureHelper.create("ScreenCaptureThread", eglBase.eglBaseContext) + private val videoSource = factory.createVideoSource(true) + + override val stream: MediaStream = factory.createLocalMediaStream("NCSS").apply { + addTrack(factory.createVideoTrack("NCs0", videoSource)) + } + + private val displayManager = context.getSystemService(DisplayManager::class.java) + private val displayListener = object : DisplayManager.DisplayListener { + override fun onDisplayAdded(displayId: Int) { + // unused atm + } + + override fun onDisplayRemoved(displayId: Int) { + // unused atm + } + + override fun onDisplayChanged(displayId: Int) { + if (displayId == Display.DEFAULT_DISPLAY) { + updateCaptureSize() + } + } + } + + // Guards the capturer, as the size is changed from the main thread while it may be released from any thread. + private val lock = Any() + private var captureSize: Pair? = null + private var isCapturing = false + private var isReleased = false + + override fun start(): Boolean { + val size = getCaptureSize() + val isStarted = synchronized(lock) { + !isReleased && startCapture(size) + } + if (isStarted) { + displayManager.registerDisplayListener(displayListener, Handler(Looper.getMainLooper())) + } + return isStarted + } + + /** + * Must be called while holding the lock. + */ + private fun startCapture(size: Pair): Boolean { + captureSize = size + capturer.initialize(surfaceTextureHelper, context, videoSource.capturerObserver) + // The media projection may fail to start, for example, if the permission expired or was revoked. + return try { + capturer.startCapture(size.first, size.second, FRAME_RATE) + isCapturing = true + true + } catch (e: SecurityException) { + Log.e(TAG, "Failed to start screen capture", e) + false + } catch (e: IllegalStateException) { + Log.e(TAG, "Failed to start screen capture", e) + false + } + } + + override fun release() { + displayManager.unregisterDisplayListener(displayListener) + + synchronized(lock) { + if (isReleased) { + return + } + isReleased = true + // Only stop the capturer if it was actually started; stopping a capturer that failed to start (so its + // media projection was never set up) would throw. + if (isCapturing) { + try { + capturer.stopCapture() + } catch (e: InterruptedException) { + Log.e(TAG, "Failed to stop screen capture", e) + } + } + capturer.dispose() + } + videoSource.dispose() + surfaceTextureHelper.dispose() + stream.dispose() + } + + private fun updateCaptureSize() { + val size = getCaptureSize() + synchronized(lock) { + if (isReleased || captureSize == null || size == captureSize) { + return + } + captureSize = size + capturer.changeCaptureFormat(size.first, size.second, FRAME_RATE) + } + } + + private fun getCaptureSize(): Pair { + val displayMetrics = DisplayMetrics() + // The configuration of the activity is not updated while it is stopped, so the display is queried directly. + @Suppress("DEPRECATION") + displayManager.getDisplay(Display.DEFAULT_DISPLAY).getRealMetrics(displayMetrics) + return scaleCaptureSize(displayMetrics.widthPixels, displayMetrics.heightPixels) + } + + companion object { + private val TAG = AndroidScreenCapture::class.java.simpleName + + private const val FRAME_RATE = 15 + private const val MAX_DIMENSION = 1920 + + /** + * Returns the given display size in its current rotation scaled down to [MAX_DIMENSION]. + */ + fun scaleCaptureSize(displayWidth: Int, displayHeight: Int): Pair { + val scale = max(1f, max(displayWidth, displayHeight).toFloat() / MAX_DIMENSION) + // Video encoders expect even dimensions. + val width = (displayWidth / scale).toInt() and 1.inv() + val height = (displayHeight / scale).toInt() and 1.inv() + return Pair(width, height) + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/models/json/participants/ParticipantDto.kt b/app/src/main/java/com/nextcloud/talk/models/json/participants/ParticipantDto.kt index 6abd48e2836..da95bf77cd1 100644 --- a/app/src/main/java/com/nextcloud/talk/models/json/participants/ParticipantDto.kt +++ b/app/src/main/java/com/nextcloud/talk/models/json/participants/ParticipantDto.kt @@ -75,7 +75,10 @@ data class ParticipantDto( @JsonField(name = ["invitedActorId"]) var invitedActorId: String? = null, - var selected: Boolean = false + var selected: Boolean = false, + + // Only set from signaling messages + var participantPermissions: Int? = null ) : Parcelable { // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' constructor() : this( diff --git a/app/src/main/java/com/nextcloud/talk/models/json/signaling/NCSignalingMessageDto.kt b/app/src/main/java/com/nextcloud/talk/models/json/signaling/NCSignalingMessageDto.kt index c8a9b5e980f..f3139258e4a 100644 --- a/app/src/main/java/com/nextcloud/talk/models/json/signaling/NCSignalingMessageDto.kt +++ b/app/src/main/java/com/nextcloud/talk/models/json/signaling/NCSignalingMessageDto.kt @@ -28,8 +28,10 @@ data class NCSignalingMessageDto( @JsonField(name = ["sid"]) var sid: String? = null, @JsonField(name = ["prefix"]) - var prefix: String? = null + var prefix: String? = null, + @JsonField(name = ["broadcaster"]) + var broadcaster: String? = null ) : Parcelable { // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' - constructor() : this(null, null, null, null, null, null, null) + constructor() : this(null, null, null, null, null, null, null, null) } diff --git a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt index 35ec17783ea..816dc5196ea 100644 --- a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt +++ b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt @@ -25,11 +25,13 @@ import android.os.Bundle import android.os.Handler import android.os.IBinder import android.os.Looper +import android.os.ResultReceiver import android.util.Log import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE import androidx.core.content.ContextCompat +import androidx.core.content.IntentCompat import com.nextcloud.talk.R import com.nextcloud.talk.activities.CallActivity import com.nextcloud.talk.application.NextcloudTalkApplication @@ -71,12 +73,18 @@ class CallForegroundService : Service() { private var currentCredentials: String? = null private var currentIsOneToOne = false private var conversationAvatarBitmap: Bitmap? = null + private var isScreenSharing = false + private var isCallStarted = false override fun onBind(intent: Intent?): IBinder? = null @SuppressLint("ForegroundServiceType") override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { Log.d(TAG, "onStartCommand called") + + handleScreenSharingAction(intent, startId)?.let { return it } + + isCallStarted = true handler.removeCallbacksAndMessages(null) val conversationName = intent?.getStringExtra(EXTRA_CONVERSATION_NAME) @@ -104,6 +112,79 @@ class CallForegroundService : Service() { return START_STICKY } + /** + * Returns the value to return from onStartCommand if the intent was a screen sharing action, or null otherwise. + * + * Screen sharing actions are ignored if the service was started by them rather than by an ongoing call (for + * example, if the call ended in the meantime), as the service must not be kept running in that case. + */ + private fun handleScreenSharingAction(intent: Intent?, startId: Int): Int? { + val action = intent?.action + val resultReceiver = intent?.let { + IntentCompat.getParcelableExtra(it, EXTRA_RESULT_RECEIVER, ResultReceiver::class.java) + } + + return when { + action != ACTION_START_SCREEN_SHARING && action != ACTION_STOP_SCREEN_SHARING -> null + + !isCallStarted -> { + Log.w(TAG, "Ignoring screen sharing action without an ongoing call") + resultReceiver?.send(RESULT_SCREEN_SHARING_FAILED, null) + stopSelf(startId) + START_NOT_STICKY + } + + else -> { + if (action == ACTION_START_SCREEN_SHARING) { + setScreenSharing(true, resultReceiver) + } else if (isScreenSharing) { + setScreenSharing(false, null) + } + START_STICKY + } + } + } + + /** + * Updates the foreground service type to include or exclude media projection, which is required to capture the + * screen. The result receiver is notified once the type was updated, as only then the capture can be started. + */ + @SuppressLint("ForegroundServiceType") + private fun setScreenSharing(enabled: Boolean, resultReceiver: ResultReceiver?) { + isScreenSharing = enabled + + var resultCode = RESULT_SCREEN_SHARING_READY + try { + val notification = buildNotification(currentConversationName, currentCallExtras) + val foregroundServiceType = resolveForegroundServiceType(currentCallExtras) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + foregroundServiceType != FOREGROUND_SERVICE_TYPE_ZERO + ) { + startForeground(NOTIFICATION_ID, notification, foregroundServiceType) + } else { + startForeground(NOTIFICATION_ID, notification) + } + } catch (e: SecurityException) { + resultCode = handleScreenSharingTypeUpdateFailure(e) + } catch (e: IllegalStateException) { + // Reconfiguring the foreground service while in background may not be allowed, which throws a + // ForegroundServiceStartNotAllowedException (an IllegalStateException), not a SecurityException. + resultCode = handleScreenSharingTypeUpdateFailure(e) + } + + resultReceiver?.send(resultCode, null) + } + + private fun handleScreenSharingTypeUpdateFailure(e: Exception): Int { + Log.e(TAG, "Failed to update foreground service type for screen sharing", e) + isScreenSharing = false + // The type may not be changed while in background, but the notification must not keep stating that the + // screen is shared. + getSystemService(NotificationManager::class.java) + .notify(NOTIFICATION_ID, buildNotification(currentConversationName, currentCallExtras)) + return RESULT_SCREEN_SHARING_FAILED + } + override fun onDestroy() { Log.d(TAG, "onDestroy called") handler.removeCallbacksAndMessages(null) @@ -142,8 +223,8 @@ class CallForegroundService : Service() { return NotificationCompat.Builder(this, channelId) .setContentTitle(contentTitle) - .setContentText(getString(R.string.nc_call_ongoing_notification_content)) - .setSmallIcon(R.drawable.ic_call_white_24dp) + .setContentText(getScreenSharingText() ?: getString(R.string.nc_call_ongoing_notification_content)) + .setSmallIcon(getSmallIcon()) .setOngoing(true) .setCategory(NotificationCompat.CATEGORY_CALL) .setPriority(NotificationCompat.PRIORITY_LOW) @@ -180,7 +261,7 @@ class CallForegroundService : Service() { return Notification.Builder(this, channelId) .setStyle(callStyle) - .setSmallIcon(R.drawable.ic_call_white_24dp) + .setSmallIcon(getSmallIcon()) .setContentIntent(pendingIntent) .setOngoing(true) .setCategory(Notification.CATEGORY_CALL) @@ -192,10 +273,31 @@ class CallForegroundService : Service() { builder.setWhen(callStartTime) builder.setShowWhen(true) } + getScreenSharingText()?.let { builder.setContentText(it) } } .build() } + /** + * Returns the text shown while the screen is shared, or null to use the default text of the notification. + */ + private fun getScreenSharingText(): String? = + if (isScreenSharing) { + getString(R.string.nc_call_ongoing_notification_screen_sharing) + } else { + null + } + + /** + * The small icon is shown in the status bar, so it indicates when the screen is being shared. + */ + private fun getSmallIcon(): Int = + if (isScreenSharing) { + R.drawable.ic_screen_share_white_24dp + } else { + R.drawable.ic_call_white_24dp + } + /** * The CallStyle notification's Person icon defaults to a generic phone icon; this fetches the * conversation avatar in the background and re-posts the notification once it is available. @@ -316,6 +418,10 @@ class CallForegroundService : Service() { if (!isVoiceOnlyCall && canPublishVideo && isCameraPermissionGranted) { serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA } + + if (isScreenSharing) { + serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION + } } return serviceType } @@ -331,6 +437,12 @@ class CallForegroundService : Service() { private const val EXTRA_CREDENTIALS = "extra_credentials" private const val EXTRA_IS_ONE_TO_ONE = "extra_is_one_to_one" private const val CALL_DURATION_UPDATE_INTERVAL = 1000L + private const val ACTION_START_SCREEN_SHARING = "action_start_screen_sharing" + private const val ACTION_STOP_SCREEN_SHARING = "action_stop_screen_sharing" + private const val EXTRA_RESULT_RECEIVER = "extra_result_receiver" + + const val RESULT_SCREEN_SHARING_READY = 0 + const val RESULT_SCREEN_SHARING_FAILED = 1 fun start(context: Context, conversationName: String?, callIntentExtras: Bundle?, avatarInfo: AvatarInfo) { val serviceIntent = Intent(context, CallForegroundService::class.java).apply { @@ -344,6 +456,25 @@ class CallForegroundService : Service() { ContextCompat.startForegroundService(context, serviceIntent) } + /** + * Must be called after the user granted the media projection and before the screen capture is started. The + * result receiver is notified with [RESULT_SCREEN_SHARING_READY] or [RESULT_SCREEN_SHARING_FAILED]. + */ + fun startScreenSharing(context: Context, resultReceiver: ResultReceiver) { + val serviceIntent = Intent(context, CallForegroundService::class.java).apply { + action = ACTION_START_SCREEN_SHARING + putExtra(EXTRA_RESULT_RECEIVER, resultReceiver) + } + context.startService(serviceIntent) + } + + fun stopScreenSharing(context: Context) { + val serviceIntent = Intent(context, CallForegroundService::class.java).apply { + action = ACTION_STOP_SCREEN_SHARING + } + context.startService(serviceIntent) + } + fun stop(context: Context) { context.stopService(Intent(context, CallForegroundService::class.java)) } diff --git a/app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.kt b/app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.kt index 26b64ff6c0c..79857832072 100644 --- a/app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.kt +++ b/app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.kt @@ -79,12 +79,12 @@ abstract class SignalingMessageReceiver { * Only the following participant properties are set: * - inCall * - lastPing + * - participantPermissions (since Talk 13) * - sessionId * - userId (if the participant is not a guest) * - * "participantPermissions" is provided in the message (since Talk 13), but not currently set in the - * participant. "publishingPermissions" was provided instead in Talk 12, but it was not used anywhere, so it is - * ignored. + * "publishingPermissions" was provided instead of "participantPermissions" in Talk 12, but it was not used + * anywhere, so it is ignored. * * @param participants all the participants (users and guests) in the room */ @@ -104,6 +104,7 @@ abstract class SignalingMessageReceiver { * Only the following participant properties are set: * - inCall * - lastPing + * - participantPermissions (since Talk 13) * - sessionId * - type * - userId (if the participant is not a guest) @@ -111,9 +112,8 @@ abstract class SignalingMessageReceiver { * "nextcloudSessionId" is provided in the message (when the "inCall" property of any participant changed), but * not currently set in the participant. * - * "participantPermissions" is provided in the message (since Talk 13), but not currently set in the - * participant. "publishingPermissions" was provided instead in Talk 12, but it was not used anywhere, so it is - * ignored. + * "publishingPermissions" was provided instead of "participantPermissions" in Talk 12, but it was not used + * anywhere, so it is ignored. * * @param participants all the participants (users and guests) in the room */ @@ -629,6 +629,10 @@ abstract class SignalingMessageReceiver { participant.actorId = participantMap["actorId"].toString() } + if (participantMap["participantPermissions"] is Number) { + participant.participantPermissions = (participantMap["participantPermissions"] as Number).toInt() + } + // Only in external signaling messages if (participantMap["participantType"] != null) { val participantTypeInt = participantMap["participantType"].toString().toInt() @@ -883,6 +887,8 @@ abstract class SignalingMessageReceiver { // although extremely unlikely, that the WebRtcMessageListeners for the second offer are notified before the // WebRtcMessageListeners for the first offer. This should not be a problem, though, so for simplicity // the statements are not synchronized. + // Offers are always sent by the participant sharing its screen, so they never belong to a connection + // sharing the local screen. offerMessageNotifier.notifyOffer(sessionId, roomType, sdp, nick) webRtcMessageNotifier.notifyOffer(sessionId, roomType, sdp, nick) @@ -897,7 +903,7 @@ abstract class SignalingMessageReceiver { val sdp = payload.sdp val nick = payload.nick - webRtcMessageNotifier.notifyAnswer(sessionId, roomType, sdp, nick) + webRtcMessageNotifier.notifyAnswer(sessionId, getListenerRoomTypeForAnswer(sessionId, roomType), sdp, nick) return } @@ -952,7 +958,7 @@ abstract class SignalingMessageReceiver { webRtcMessageNotifier.notifyCandidate( sessionId, - roomType, + getListenerRoomType(sessionId, roomType, signalingMessage.broadcaster), ncIceCandidate.sdpMid, ncIceCandidate.sdpMLineIndex, ncIceCandidate.candidate @@ -962,9 +968,43 @@ abstract class SignalingMessageReceiver { } if ("endOfCandidates" == type) { - webRtcMessageNotifier.notifyEndOfCandidates(sessionId, roomType) + webRtcMessageNotifier.notifyEndOfCandidates( + sessionId, + getListenerRoomType(sessionId, roomType, signalingMessage.broadcaster) + ) return } } + + /** + * Without MCU both the connection sharing the local screen and the connection receiving the screen of a remote + * participant use the "screen" room type with the same remote session. Messages for the former are delivered to + * listeners added with [ROOM_TYPE_SCREEN_SHARING] instead. + * + * The participant sharing a screen sets itself as the "broadcaster" of its messages, so messages from a + * broadcaster belong to the receiving connection. Answers are only sent to the participant sharing a screen. + */ + private fun getListenerRoomType(sessionId: String?, roomType: String?, broadcaster: String?): String? { + if (ROOM_TYPE_SCREEN != roomType || (broadcaster != null && broadcaster == sessionId)) { + return roomType + } + return getListenerRoomTypeForAnswer(sessionId, roomType) + } + + private fun getListenerRoomTypeForAnswer(sessionId: String?, roomType: String?): String? = + if (ROOM_TYPE_SCREEN == roomType && webRtcMessageNotifier.hasListenerFor(sessionId, ROOM_TYPE_SCREEN_SHARING)) { + ROOM_TYPE_SCREEN_SHARING + } else { + roomType + } + + companion object { + private const val ROOM_TYPE_SCREEN = "screen" + + /** + * Room type to add a [WebRtcMessageListener] for a connection sharing the local screen. + */ + const val ROOM_TYPE_SCREEN_SHARING = "screen-sharing" + } } diff --git a/app/src/main/java/com/nextcloud/talk/signaling/WebRtcMessageNotifier.java b/app/src/main/java/com/nextcloud/talk/signaling/WebRtcMessageNotifier.java index 4bc6f3cb7e7..98a2f8819dd 100644 --- a/app/src/main/java/com/nextcloud/talk/signaling/WebRtcMessageNotifier.java +++ b/app/src/main/java/com/nextcloud/talk/signaling/WebRtcMessageNotifier.java @@ -68,6 +68,10 @@ public synchronized void removeListener(SignalingMessageReceiver.WebRtcMessageLi } } + public synchronized boolean hasListenerFor(String sessionId, String roomType) { + return !getListenersFor(sessionId, roomType).isEmpty(); + } + private List getListenersFor(String sessionId, String roomType) { List webRtcMessageListeners = new ArrayList<>(webRtcMessageListenersFrom.size()); diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/MoreCallActionsDialog.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/MoreCallActionsDialog.kt index 453212ffef2..5b0d719a953 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/MoreCallActionsDialog.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/MoreCallActionsDialog.kt @@ -13,6 +13,7 @@ import android.view.ViewGroup import android.widget.LinearLayout import androidx.core.content.ContextCompat import androidx.emoji2.widget.EmojiTextView +import androidx.lifecycle.lifecycleScope import autodagger.AutoInjector import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog @@ -28,6 +29,7 @@ import com.nextcloud.talk.ui.theme.hostViewThemeUtils import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.viewmodels.CallRecordingViewModel +import kotlinx.coroutines.launch import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -49,10 +51,8 @@ class MoreCallActionsDialog(private val callActivity: CallActivity) : BottomShee viewThemeUtils.platform.themeDialogDark(binding.root) - initItemsVisibility() initEmojiBar() initClickListeners() - initObservers() } override fun onStart() { @@ -60,6 +60,10 @@ class MoreCallActionsDialog(private val callActivity: CallActivity) : BottomShee val bottomSheet = findViewById(R.id.design_bottom_sheet) val behavior = BottomSheetBehavior.from(bottomSheet as View) behavior.state = BottomSheetBehavior.STATE_COLLAPSED + + // The dialog may be shown again after being dismissed, which destroys its lifecycle and thus the observers. + initItemsVisibility() + initObservers() } private fun initItemsVisibility() { @@ -98,6 +102,11 @@ class MoreCallActionsDialog(private val callActivity: CallActivity) : BottomShee binding.backgroundBlur.setOnClickListener { callActivity.toggleBackgroundBlur() } + + binding.shareScreen.setOnClickListener { + callActivity.toggleScreenSharing() + dismiss() + } } private fun initEmojiBar() { @@ -205,6 +214,28 @@ class MoreCallActionsDialog(private val callActivity: CallActivity) : BottomShee } } } + + lifecycleScope.launch { + callActivity.callViewModel.isScreenSharingAllowed.collect { isAllowed -> + binding.shareScreen.visibility = if (isAllowed) View.VISIBLE else View.GONE + } + } + + lifecycleScope.launch { + callActivity.callViewModel.isLocalScreenSharing.collect { isSharing -> + if (isSharing) { + binding.shareScreenText.text = context.getText(R.string.nc_stop_screen_sharing) + binding.shareScreenIcon.setImageDrawable( + ContextCompat.getDrawable(context, R.drawable.ic_stop_screen_share_24) + ) + } else { + binding.shareScreenText.text = context.getText(R.string.nc_start_screen_sharing) + binding.shareScreenIcon.setImageDrawable( + ContextCompat.getDrawable(context, R.drawable.ic_screen_share_24) + ) + } + } + } } companion object { diff --git a/app/src/main/java/com/nextcloud/talk/utils/ParticipantPermissions.kt b/app/src/main/java/com/nextcloud/talk/utils/ParticipantPermissions.kt index 2b376cf16f2..7140f6a00e1 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/ParticipantPermissions.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/ParticipantPermissions.kt @@ -28,11 +28,7 @@ class ParticipantPermissions( private val hasChatPermission = (conversation.permissions and CHAT) == CHAT private val hasReactPermission = (conversation.permissions and REACT) == REACT - private fun hasConversationPermissions(): Boolean = - CapabilitiesUtil.hasSpreedFeatureCapability( - spreedCapabilities, - SpreedFeatures.CONVERSATION_PERMISSION - ) + private fun hasConversationPermissions(): Boolean = hasConversationPermissions(spreedCapabilities) fun canIgnoreLobby(): Boolean { if (hasConversationPermissions()) { @@ -63,6 +59,8 @@ class ParticipantPermissions( true } + fun canPublishScreen(): Boolean = canPublishScreen(spreedCapabilities, conversation.permissions) + fun hasChatPermission(): Boolean { if (CapabilitiesUtil.hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.CHAT_PERMISSION)) { return hasChatPermission @@ -96,5 +94,21 @@ class ParticipantPermissions( const val DEFAULT_GROUP_PERMISSIONS = JOIN_CALL or PUBLISH_AUDIO or PUBLISH_VIDEO or PUBLISH_SCREEN or CHAT or REACT + + private fun hasConversationPermissions(spreedCapabilities: SpreedCapabilityDto?): Boolean = + CapabilitiesUtil.hasSpreedFeatureCapability( + spreedCapabilities, + SpreedFeatures.CONVERSATION_PERMISSION + ) + + /** + * @param permissions the permissions of the participant, either from the conversation or from signaling + */ + fun canPublishScreen(spreedCapabilities: SpreedCapabilityDto?, permissions: Int): Boolean = + if (hasConversationPermissions(spreedCapabilities)) { + (permissions and PUBLISH_SCREEN) == PUBLISH_SCREEN + } else { + true + } } } diff --git a/app/src/main/java/com/nextcloud/talk/webrtc/PeerConnectionWrapper.java b/app/src/main/java/com/nextcloud/talk/webrtc/PeerConnectionWrapper.java index 10a755c8b7b..830a48e9bf0 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/PeerConnectionWrapper.java +++ b/app/src/main/java/com/nextcloud/talk/webrtc/PeerConnectionWrapper.java @@ -66,6 +66,8 @@ public final class PeerConnectionWrapper { private final SdpObserver sdpObserver; private final boolean isMCUPublisher; + private final boolean isScreenSharer; + private final String localSession; private final String videoStreamType; // Keys of the remote participant when the call is end-to-end encrypted, attached to every receiver @@ -132,6 +134,27 @@ public PeerConnectionWrapper(PeerConnectionFactory peerConnectionFactory, boolean encryptionRequired, @Nullable FrameKeyRing senderKeyRing, @Nullable FrameKeyRing receiverKeyRing) { + this(peerConnectionFactory, iceServerList, mediaConstraints, sessionId, localSession, localStream, + isMCUPublisher, hasMCU, videoStreamType, signalingMessageReceiver, signalingMessageSender, + encryptionRequired, senderKeyRing, receiverKeyRing, false); + } + + /** + * @param isScreenSharer true if the connection sends the local screen, either directly to the remote participant + * or, with MCU, to the own publisher. The connection then always sends the offer and does + * not open data channels. + */ + public PeerConnectionWrapper(PeerConnectionFactory peerConnectionFactory, + List iceServerList, + MediaConstraints mediaConstraints, + String sessionId, String localSession, @Nullable MediaStream localStream, + boolean isMCUPublisher, boolean hasMCU, String videoStreamType, + SignalingMessageReceiver signalingMessageReceiver, + SignalingMessageSender signalingMessageSender, + boolean encryptionRequired, + @Nullable FrameKeyRing senderKeyRing, + @Nullable FrameKeyRing receiverKeyRing, + boolean isScreenSharer) { boolean missingSenderKeys = (isMCUPublisher || localStream != null) && senderKeyRing == null; boolean missingReceiverKeys = !isMCUPublisher && receiverKeyRing == null; if (encryptionRequired && (missingSenderKeys || missingReceiverKeys)) { @@ -140,6 +163,8 @@ public PeerConnectionWrapper(PeerConnectionFactory peerConnectionFactory, } this.videoStreamType = videoStreamType; + this.isScreenSharer = isScreenSharer; + this.localSession = localSession; this.receiverKeyRing = receiverKeyRing; this.preferredVideoCodec = senderKeyRing != null || receiverKeyRing != null ? "VP8" : "H264"; @@ -155,7 +180,10 @@ public PeerConnectionWrapper(PeerConnectionFactory peerConnectionFactory, peerConnection = peerConnectionFactory.createPeerConnection(configuration, new InitialPeerConnectionObserver()); this.signalingMessageReceiver = signalingMessageReceiver; - this.signalingMessageReceiver.addListener(webRtcMessageListener, sessionId, videoStreamType); + this.signalingMessageReceiver.addListener( + webRtcMessageListener, + sessionId, + isScreenSharer ? SignalingMessageReceiver.ROOM_TYPE_SCREEN_SHARING : videoStreamType); this.signalingMessageSender = signalingMessageSender; @@ -181,7 +209,9 @@ public PeerConnectionWrapper(PeerConnectionFactory peerConnectionFactory, } } - if (hasMCU || hasInitiated) { + if (isScreenSharer) { + peerConnection.createOffer(sdpObserver, mediaConstraints); + } else if (hasMCU || hasInitiated) { DataChannel.Init init = new DataChannel.Init(); init.negotiated = false; @@ -474,6 +504,9 @@ private NCSignalingMessageDto createBaseSignalingMessage(String type) { ncSignalingMessage.setTo(sessionId); ncSignalingMessage.setRoomType(videoStreamType); ncSignalingMessage.setType(type); + if (isScreenSharer) { + ncSignalingMessage.setBroadcaster(localSession); + } return ncSignalingMessage; } diff --git a/app/src/main/res/drawable/ic_screen_share_24.xml b/app/src/main/res/drawable/ic_screen_share_24.xml new file mode 100644 index 00000000000..0392f9ef320 --- /dev/null +++ b/app/src/main/res/drawable/ic_screen_share_24.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/ic_screen_share_white_24dp.xml b/app/src/main/res/drawable/ic_screen_share_white_24dp.xml new file mode 100644 index 00000000000..ba300b929cf --- /dev/null +++ b/app/src/main/res/drawable/ic_screen_share_white_24dp.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/ic_stop_screen_share_24.xml b/app/src/main/res/drawable/ic_stop_screen_share_24.xml new file mode 100644 index 00000000000..15daa8de532 --- /dev/null +++ b/app/src/main/res/drawable/ic_stop_screen_share_24.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/screen_share_indicator.xml b/app/src/main/res/drawable/screen_share_indicator.xml new file mode 100644 index 00000000000..85856de6ad6 --- /dev/null +++ b/app/src/main/res/drawable/screen_share_indicator.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/layout/call_activity.xml b/app/src/main/res/layout/call_activity.xml index d8ae0b660d3..f225b2a4c18 100644 --- a/app/src/main/res/layout/call_activity.xml +++ b/app/src/main/res/layout/call_activity.xml @@ -55,6 +55,7 @@ @@ -81,6 +82,20 @@ tools:visibility="visible"> + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2ef2c7618a5..eeaf305c6bf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -399,6 +399,7 @@ How to translate with transifex: You missed a call from %s Call in progress Tap to return to your call. + You are sharing your screen Return to call End call Open picture-in-picture mode @@ -1098,6 +1099,13 @@ How to translate with transifex: Nextcloud app suggestions in account chooser dialog Turn on background blur Turn off background blur + Share screen + Stop screen sharing + Screen sharing could not be started + You are not allowed to share your screen + You can share your screen once the call is connected + To share your screen, allow notifications and microphone access + Do you really want to stop sharing your screen? Account not found Password is secure diff --git a/app/src/test/java/com/nextcloud/talk/call/AndroidScreenCaptureTest.kt b/app/src/test/java/com/nextcloud/talk/call/AndroidScreenCaptureTest.kt new file mode 100644 index 00000000000..b8c01807e1a --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/call/AndroidScreenCaptureTest.kt @@ -0,0 +1,31 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.call + +import org.junit.Assert.assertEquals +import org.junit.Test + +class AndroidScreenCaptureTest { + + @Test + fun testSmallDisplayIsNotScaled() { + assertEquals(Pair(1080, 1920), AndroidScreenCapture.scaleCaptureSize(1080, 1920)) + assertEquals(Pair(720, 1280), AndroidScreenCapture.scaleCaptureSize(720, 1280)) + } + + @Test + fun testLargeDisplayIsScaledInBothRotations() { + assertEquals(Pair(1920, 864), AndroidScreenCapture.scaleCaptureSize(3840, 1728)) + assertEquals(Pair(864, 1920), AndroidScreenCapture.scaleCaptureSize(1728, 3840)) + } + + @Test + fun testDimensionsAreEven() { + assertEquals(Pair(1920, 886), AndroidScreenCapture.scaleCaptureSize(2400, 1108)) + assertEquals(Pair(720, 1280), AndroidScreenCapture.scaleCaptureSize(721, 1281)) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/call/LocalScreenShareControllerTest.kt b/app/src/test/java/com/nextcloud/talk/call/LocalScreenShareControllerTest.kt new file mode 100644 index 00000000000..611e304d54e --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/call/LocalScreenShareControllerTest.kt @@ -0,0 +1,423 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.call + +import android.content.Intent +import com.nextcloud.talk.models.json.signaling.NCSignalingMessageDto +import com.nextcloud.talk.webrtc.PeerConnectionWrapper +import com.nextcloud.talk.webrtc.PeerConnectionWrapper.PeerConnectionObserver +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mockito +import org.mockito.Mockito.verify +import org.webrtc.MediaStream +import org.webrtc.PeerConnection.IceConnectionState + +@Suppress("TooManyFunctions") +class LocalScreenShareControllerTest { + + private class FakeScreenCapture(private val canStart: Boolean = true) : ScreenCapture { + override val stream: MediaStream = Mockito.mock(MediaStream::class.java) + var isStarted = false + var isReleased = false + + override fun start(): Boolean { + isStarted = canStart + return canStart + } + + override fun release() { + isReleased = true + } + } + + private class FakeHost : LocalScreenShareController.Host { + override var hasMCU = false + override var ownSessionId: String? = OWN_SESSION_ID + override var participantSessionIds: Collection = listOf(SESSION_ID_1, SESSION_ID_2) + override var isCallActive = true + override var isScreenSharingAllowed = true + + var foregroundServiceResult: ((Boolean) -> Unit)? = null + var isForegroundServiceScreenSharing = false + var canCreateConnections = true + var capture: FakeScreenCapture? = FakeScreenCapture() + var onCaptureStop: (() -> Unit)? = null + val connections = ArrayList() + val sentMessages = ArrayList>() + var stateChangedCount = 0 + var failedCount = 0 + var encryptionFailedCount = 0 + + override fun startForegroundServiceScreenSharing(onResult: (isReady: Boolean) -> Unit) { + isForegroundServiceScreenSharing = true + foregroundServiceResult = onResult + } + + override fun stopForegroundServiceScreenSharing() { + isForegroundServiceScreenSharing = false + } + + override fun createScreenCapture(permissionResultData: Intent, onStop: () -> Unit): ScreenCapture? { + onCaptureStop = onStop + return capture + } + + override fun createPeerConnection(sessionId: String, stream: MediaStream): PeerConnectionWrapper { + check(canCreateConnections) { "No keys" } + val connection = Mockito.mock(PeerConnectionWrapper::class.java) + Mockito.`when`(connection.sessionId).thenReturn(sessionId) + connections.add(connection) + return connection + } + + override fun send(message: NCSignalingMessageDto, sessionId: String) { + assertEquals("screen", message.roomType) + sentMessages.add(Pair(message.type, sessionId)) + } + + override fun onStateChanged() { + stateChangedCount++ + } + + override fun onScreenSharingFailed() { + failedCount++ + } + + override fun onEncryptionFailed() { + encryptionFailedCount++ + } + + fun connectionTo(sessionId: String): PeerConnectionWrapper = connections.last { it.sessionId == sessionId } + + fun messages(type: String): List = sentMessages.filter { it.first == type }.map { it.second } + } + + private lateinit var host: FakeHost + private lateinit var controller: LocalScreenShareController + private val permissionResultData = Mockito.mock(Intent::class.java) + + @Before + fun setUp() { + host = FakeHost() + controller = LocalScreenShareController(host) { it.run() } + } + + private fun startSharing() { + assertTrue(controller.requestStart()) + controller.onPermissionGranted(permissionResultData) + host.foregroundServiceResult!!.invoke(true) + } + + private fun setIceConnectionState(connection: PeerConnectionWrapper, state: IceConnectionState) { + val captor = ArgumentCaptor.forClass(PeerConnectionObserver::class.java) + verify(connection).addObserver(captor.capture()) + captor.value.onIceConnectionStateChanged(state) + } + + @Test + fun testStartWithoutMcuConnectsToEachParticipant() { + startSharing() + + assertTrue(controller.isSharing) + assertFalse(controller.isPending) + assertTrue(host.capture!!.isStarted) + assertTrue(host.isForegroundServiceScreenSharing) + assertEquals(listOf(SESSION_ID_1, SESSION_ID_2), host.connections.map { it.sessionId }) + } + + @Test + fun testStartWithMcuConnectsToOwnPublisher() { + host.hasMCU = true + + startSharing() + + assertEquals(listOf(OWN_SESSION_ID), host.connections.map { it.sessionId }) + } + + @Test + fun testRequestStartIsRefusedWhilePendingOrSharing() { + assertTrue(controller.requestStart()) + assertFalse(controller.requestStart()) + + controller.onPermissionGranted(permissionResultData) + host.foregroundServiceResult!!.invoke(true) + + assertFalse(controller.requestStart()) + } + + @Test + fun testPermissionGrantedAfterStopIsIgnored() { + controller.requestStart() + controller.stop() + + controller.onPermissionGranted(permissionResultData) + + assertFalse(controller.isPending) + assertFalse(controller.isSharing) + assertFalse(host.isForegroundServiceScreenSharing) + } + + @Test + fun testForegroundServiceFailure() { + controller.requestStart() + controller.onPermissionGranted(permissionResultData) + + host.foregroundServiceResult!!.invoke(false) + + assertFalse(controller.isPending) + assertFalse(controller.isSharing) + assertEquals(1, host.failedCount) + } + + @Test + fun testCaptureFailure() { + host.capture = FakeScreenCapture(canStart = false) + + startSharing() + + assertFalse(controller.isPending) + assertFalse(controller.isSharing) + assertTrue(host.capture!!.isReleased) + assertFalse(host.isForegroundServiceScreenSharing) + assertEquals(1, host.failedCount) + assertTrue(host.connections.isEmpty()) + } + + @Test + fun testStopWhileWaitingForForegroundService() { + controller.requestStart() + controller.onPermissionGranted(permissionResultData) + controller.stop() + + host.foregroundServiceResult!!.invoke(true) + + assertFalse(controller.isSharing) + assertFalse(host.capture!!.isStarted) + assertFalse(host.isForegroundServiceScreenSharing) + } + + @Test + fun testStartIsAbortedWhenNotAllowedAnymore() { + controller.requestStart() + controller.onPermissionGranted(permissionResultData) + host.isScreenSharingAllowed = false + + host.foregroundServiceResult!!.invoke(true) + + assertFalse(controller.isSharing) + assertFalse(controller.isPending) + assertFalse(host.isForegroundServiceScreenSharing) + } + + @Test + fun testStop() { + startSharing() + val connections = host.connections.toList() + val stateChangedCount = host.stateChangedCount + + controller.stop() + controller.stop() + + assertFalse(controller.isSharing) + assertTrue(host.capture!!.isReleased) + assertFalse(host.isForegroundServiceScreenSharing) + assertEquals(listOf(SESSION_ID_1, SESSION_ID_2), host.messages("unshareScreen")) + connections.forEach { verify(it).removePeerConnection() } + assertEquals(stateChangedCount + 1, host.stateChangedCount) + } + + @Test + fun testStopWhenMediaProjectionStops() { + startSharing() + + host.onCaptureStop!!.invoke() + + assertFalse(controller.isSharing) + assertTrue(host.capture!!.isReleased) + } + + @Test + fun testOffersAreRequestedOnceThePublisherIsConnected() { + host.hasMCU = true + startSharing() + val publisher = host.connectionTo(OWN_SESSION_ID) + + controller.onParticipantJoined(SESSION_ID_3) + assertTrue(host.messages("sendoffer").isEmpty()) + + setIceConnectionState(publisher, IceConnectionState.CONNECTED) + setIceConnectionState(publisher, IceConnectionState.CONNECTED) + assertEquals(listOf(SESSION_ID_1, SESSION_ID_2), host.messages("sendoffer")) + + controller.onParticipantJoined(SESSION_ID_3) + assertEquals(listOf(SESSION_ID_1, SESSION_ID_2, SESSION_ID_3), host.messages("sendoffer")) + } + + @Test + fun testConnectedEventAfterStopDoesNotAffectNextScreenSharing() { + host.hasMCU = true + startSharing() + val oldPublisher = host.connectionTo(OWN_SESSION_ID) + controller.stop() + + setIceConnectionState(oldPublisher, IceConnectionState.CONNECTED) + assertTrue(host.messages("sendoffer").isEmpty()) + + host.capture = FakeScreenCapture() + startSharing() + setIceConnectionState(host.connectionTo(OWN_SESSION_ID), IceConnectionState.CONNECTED) + assertEquals(listOf(SESSION_ID_1, SESSION_ID_2), host.messages("sendoffer")) + } + + @Test + fun testJoinedAndLeftParticipantsWithoutMcu() { + startSharing() + + controller.onParticipantJoined(SESSION_ID_3) + val connection = host.connectionTo(SESSION_ID_3) + controller.onParticipantLeft(SESSION_ID_3) + + verify(connection).removePeerConnection() + assertEquals(3, host.connections.size) + } + + @Test + fun testParticipantsJoiningWhileNotSharingAreIgnored() { + controller.onParticipantJoined(SESSION_ID_3) + + assertTrue(host.connections.isEmpty()) + } + + @Test + fun testFailedConnectionWithoutMcuIsRetriedBeforeGivingUp() { + startSharing() + + repeat(MAX_RETRIES) { + val connection = host.connectionTo(SESSION_ID_1) + setIceConnectionState(connection, IceConnectionState.FAILED) + verify(connection).removePeerConnection() + } + assertEquals(List(MAX_RETRIES) { SESSION_ID_1 }, host.messages("unshareScreen")) + + setIceConnectionState(host.connectionTo(SESSION_ID_1), IceConnectionState.FAILED) + + // The other participants still get the screen. + assertTrue(controller.isSharing) + assertEquals(0, host.failedCount) + assertEquals(2 + MAX_RETRIES, host.connections.size) + } + + @Test + fun testRejoiningParticipantResetsRetries() { + startSharing() + repeat(MAX_RETRIES + 1) { + setIceConnectionState(host.connectionTo(SESSION_ID_1), IceConnectionState.FAILED) + } + + controller.onParticipantLeft(SESSION_ID_1) + controller.onParticipantJoined(SESSION_ID_1) + setIceConnectionState(host.connectionTo(SESSION_ID_1), IceConnectionState.FAILED) + + assertEquals(2 + MAX_RETRIES + 2, host.connections.size) + } + + @Test + fun testSuccessfulConnectionResetsRetries() { + startSharing() + + repeat(MAX_RETRIES) { + setIceConnectionState(host.connectionTo(SESSION_ID_1), IceConnectionState.FAILED) + } + setIceConnectionState(host.connectionTo(SESSION_ID_1), IceConnectionState.CONNECTED) + setIceConnectionState(host.connectionTo(SESSION_ID_1), IceConnectionState.FAILED) + + assertEquals(2 + MAX_RETRIES + 1, host.connections.size) + } + + @Test + fun testFailedPublisherStopsScreenSharingAfterRetries() { + host.hasMCU = true + startSharing() + + repeat(MAX_RETRIES + 1) { + setIceConnectionState(host.connectionTo(OWN_SESSION_ID), IceConnectionState.FAILED) + } + + assertFalse(controller.isSharing) + assertEquals(1, host.failedCount) + assertEquals(1 + MAX_RETRIES, host.connections.size) + } + + @Test + fun testFailedConnectionIsNotRetriedWhileReconnecting() { + startSharing() + host.isCallActive = false + + setIceConnectionState(host.connectionTo(SESSION_ID_1), IceConnectionState.FAILED) + + assertEquals(2, host.connections.size) + assertTrue(controller.isSharing) + } + + @Test + fun testSuspendKeepsCapturingAndSharesAgainWithMcu() { + host.hasMCU = true + startSharing() + val publisher = host.connectionTo(OWN_SESSION_ID) + + controller.suspend() + + verify(publisher).removePeerConnection() + assertTrue(controller.isSharing) + assertFalse(host.capture!!.isReleased) + assertTrue(host.isForegroundServiceScreenSharing) + + host.ownSessionId = NEW_OWN_SESSION_ID + controller.ensurePublisher() + + assertEquals(NEW_OWN_SESSION_ID, host.connections.last().sessionId) + } + + @Test + fun testSuspendSharesAgainWithJoinedParticipantsWithoutMcu() { + startSharing() + + controller.suspend() + controller.ensurePublisher() + assertEquals(2, host.connections.size) + + controller.onParticipantJoined(SESSION_ID_1) + assertEquals(SESSION_ID_1, host.connections.last().sessionId) + assertEquals(3, host.connections.size) + } + + @Test + fun testEncryptionFailure() { + host.canCreateConnections = false + + startSharing() + + assertEquals(2, host.encryptionFailedCount) + assertTrue(host.connections.isEmpty()) + // Ending the call is up to the host. + assertTrue(controller.isSharing) + } + + companion object { + private const val OWN_SESSION_ID = "own-session-id" + private const val NEW_OWN_SESSION_ID = "new-own-session-id" + private const val SESSION_ID_1 = "session-id-1" + private const val SESSION_ID_2 = "session-id-2" + private const val SESSION_ID_3 = "session-id-3" + private const val MAX_RETRIES = 3 + } +} diff --git a/app/src/test/java/com/nextcloud/talk/signaling/SignalingMessageReceiverParticipantListTest.java b/app/src/test/java/com/nextcloud/talk/signaling/SignalingMessageReceiverParticipantListTest.java index e1fdb3dc552..1037296e7e7 100644 --- a/app/src/test/java/com/nextcloud/talk/signaling/SignalingMessageReceiverParticipantListTest.java +++ b/app/src/test/java/com/nextcloud/talk/signaling/SignalingMessageReceiverParticipantListTest.java @@ -82,6 +82,7 @@ public void testInternalSignalingParticipantListMessageUsersInRoom() { expectedParticipant1.setUserId("theUserId"); expectedParticipant1.setActorType(ParticipantDto.ActorType.FEDERATED); expectedParticipant1.setActorId("theActorId"); + expectedParticipant1.setParticipantPermissions(42); expectedParticipantList.add(expectedParticipant1); ParticipantDto expectedParticipant2 = new ParticipantDto(); @@ -297,6 +298,7 @@ public void testExternalSignalingParticipantListMessageParticipantsUpdate() { expectedParticipant1.setUserId("theUserId"); expectedParticipant1.setActorType(ParticipantDto.ActorType.FEDERATED); expectedParticipant1.setActorId("theActorId"); + expectedParticipant1.setParticipantPermissions(42); expectedParticipantList.add(expectedParticipant1); ParticipantDto expectedParticipant2 = new ParticipantDto(); diff --git a/app/src/test/java/com/nextcloud/talk/signaling/SignalingMessageReceiverScreenSharingTest.kt b/app/src/test/java/com/nextcloud/talk/signaling/SignalingMessageReceiverScreenSharingTest.kt new file mode 100644 index 00000000000..3fee622a4bb --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/signaling/SignalingMessageReceiverScreenSharingTest.kt @@ -0,0 +1,159 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.signaling + +import com.nextcloud.talk.models.json.signaling.NCIceCandidateDto +import com.nextcloud.talk.models.json.signaling.NCMessagePayloadDto +import com.nextcloud.talk.models.json.signaling.NCSignalingMessageDto +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.only +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoInteractions + +class SignalingMessageReceiverScreenSharingTest { + + private lateinit var signalingMessageReceiver: SignalingMessageReceiver + private lateinit var receivingListener: SignalingMessageReceiver.WebRtcMessageListener + private lateinit var sharingListener: SignalingMessageReceiver.WebRtcMessageListener + + @Before + fun setUp() { + signalingMessageReceiver = object : SignalingMessageReceiver() {} + receivingListener = mock(SignalingMessageReceiver.WebRtcMessageListener::class.java) + sharingListener = mock(SignalingMessageReceiver.WebRtcMessageListener::class.java) + } + + private fun addBothListeners() { + signalingMessageReceiver.addListener(receivingListener, REMOTE_SESSION_ID, "screen") + signalingMessageReceiver.addListener( + sharingListener, + REMOTE_SESSION_ID, + SignalingMessageReceiver.ROOM_TYPE_SCREEN_SHARING + ) + } + + private fun createMessage(type: String, broadcaster: String? = null): NCSignalingMessageDto { + val payload = NCMessagePayloadDto() + payload.type = type + if (type == "candidate") { + val iceCandidate = NCIceCandidateDto() + iceCandidate.sdpMid = "theSdpMid" + iceCandidate.sdpMLineIndex = 42 + iceCandidate.candidate = "theCandidate" + payload.iceCandidate = iceCandidate + } else { + payload.sdp = "theSdp" + } + + val message = NCSignalingMessageDto() + message.from = REMOTE_SESSION_ID + message.type = type + message.roomType = "screen" + message.broadcaster = broadcaster + message.payload = payload + return message + } + + @Test + fun testOfferIsDeliveredToReceivingConnection() { + addBothListeners() + + signalingMessageReceiver.processSignalingMessage(createMessage("offer", REMOTE_SESSION_ID)) + + verify(receivingListener, only()).onOffer("theSdp", null) + verifyNoInteractions(sharingListener) + } + + @Test + fun testAnswerIsDeliveredToSharingConnection() { + addBothListeners() + + signalingMessageReceiver.processSignalingMessage(createMessage("answer")) + + verify(sharingListener, only()).onAnswer("theSdp", null) + verifyNoInteractions(receivingListener) + } + + @Test + fun testAnswerWithoutSharingConnectionIsDeliveredToScreenListener() { + signalingMessageReceiver.addListener(receivingListener, REMOTE_SESSION_ID, "screen") + + signalingMessageReceiver.processSignalingMessage(createMessage("answer")) + + verify(receivingListener, only()).onAnswer("theSdp", null) + } + + @Test + fun testCandidateFromBroadcasterIsDeliveredToReceivingConnection() { + addBothListeners() + + signalingMessageReceiver.processSignalingMessage(createMessage("candidate", REMOTE_SESSION_ID)) + + verify(receivingListener, only()).onCandidate("theSdpMid", 42, "theCandidate") + verifyNoInteractions(sharingListener) + } + + @Test + fun testCandidateWithoutBroadcasterIsDeliveredToSharingConnection() { + addBothListeners() + + signalingMessageReceiver.processSignalingMessage(createMessage("candidate")) + + verify(sharingListener, only()).onCandidate("theSdpMid", 42, "theCandidate") + verifyNoInteractions(receivingListener) + } + + @Test + fun testCandidateWithLocalBroadcasterIsDeliveredToSharingConnection() { + addBothListeners() + + signalingMessageReceiver.processSignalingMessage(createMessage("candidate", "theLocalSessionId")) + + verify(sharingListener, only()).onCandidate("theSdpMid", 42, "theCandidate") + verifyNoInteractions(receivingListener) + } + + @Test + fun testCandidateWithoutSharingConnectionIsDeliveredToReceivingConnection() { + signalingMessageReceiver.addListener(receivingListener, REMOTE_SESSION_ID, "screen") + + signalingMessageReceiver.processSignalingMessage(createMessage("candidate")) + + verify(receivingListener, only()).onCandidate("theSdpMid", 42, "theCandidate") + } + + @Test + fun testEndOfCandidatesFromBroadcasterIsDeliveredToReceivingConnection() { + addBothListeners() + + signalingMessageReceiver.processSignalingMessage(createMessage("endOfCandidates", REMOTE_SESSION_ID)) + + verify(receivingListener, only()).onEndOfCandidates() + verifyNoInteractions(sharingListener) + } + + @Test + fun testVideoCandidateIsNotDeliveredToSharingConnection() { + val videoListener = mock(SignalingMessageReceiver.WebRtcMessageListener::class.java) + signalingMessageReceiver.addListener(videoListener, REMOTE_SESSION_ID, "video") + addBothListeners() + + val message = createMessage("candidate") + message.roomType = "video" + signalingMessageReceiver.processSignalingMessage(message) + + verify(videoListener, only()).onCandidate("theSdpMid", 42, "theCandidate") + verifyNoInteractions(sharingListener) + verifyNoInteractions(receivingListener) + } + + companion object { + private const val REMOTE_SESSION_ID = "theRemoteSessionId" + } +} diff --git a/app/src/test/java/com/nextcloud/talk/utils/ParticipantPermissionsTest.kt b/app/src/test/java/com/nextcloud/talk/utils/ParticipantPermissionsTest.kt index 28cdf580c05..493b85fa2d2 100644 --- a/app/src/test/java/com/nextcloud/talk/utils/ParticipantPermissionsTest.kt +++ b/app/src/test/java/com/nextcloud/talk/utils/ParticipantPermissionsTest.kt @@ -18,6 +18,33 @@ import org.junit.Test class ParticipantPermissionsTest : TestCase() { + @Test + fun test_canPublishScreenWithConversationPermissionsCapability() { + val spreedCapability = SpreedCapabilityDto() + spreedCapability.features = listOf("conversation-permissions") + + assertTrue( + ParticipantPermissions.canPublishScreen( + spreedCapability, + ParticipantPermissions.CUSTOM or ParticipantPermissions.PUBLISH_SCREEN + ) + ) + assertFalse( + ParticipantPermissions.canPublishScreen( + spreedCapability, + ParticipantPermissions.CUSTOM or ParticipantPermissions.PUBLISH_VIDEO + ) + ) + } + + @Test + fun test_canPublishScreenWithoutConversationPermissionsCapability() { + val spreedCapability = SpreedCapabilityDto() + spreedCapability.features = emptyList() + + assertTrue(ParticipantPermissions.canPublishScreen(spreedCapability, ParticipantPermissions.CUSTOM)) + } + @Test fun test_areFlagsSet() { val spreedCapability = SpreedCapabilityDto() diff --git a/app/src/test/java/com/nextcloud/talk/webrtc/PeerConnectionWrapperScreenSharingTest.kt b/app/src/test/java/com/nextcloud/talk/webrtc/PeerConnectionWrapperScreenSharingTest.kt new file mode 100644 index 00000000000..faa66956995 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/webrtc/PeerConnectionWrapperScreenSharingTest.kt @@ -0,0 +1,226 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.webrtc + +import com.nextcloud.talk.call.e2ee.FrameKeyRing +import com.nextcloud.talk.models.json.signaling.NCSignalingMessageDto +import com.nextcloud.talk.signaling.SignalingMessageReceiver +import com.nextcloud.talk.signaling.SignalingMessageSender +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyString +import org.mockito.ArgumentMatchers.eq +import org.mockito.Mockito +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.webrtc.DataChannel +import org.webrtc.FrameDecryptor +import org.webrtc.FrameEncryptor +import org.webrtc.MediaConstraints +import org.webrtc.MediaStream +import org.webrtc.PeerConnection +import org.webrtc.PeerConnectionFactory +import org.webrtc.RtpReceiver +import org.webrtc.RtpSender +import org.webrtc.SdpObserver +import org.webrtc.SessionDescription +import org.webrtc.VideoTrack + +class PeerConnectionWrapperScreenSharingTest { + + private class FakeKeyRing : FrameKeyRing { + val encryptors = ArrayList() + + override fun setKey(key: ByteArray, keyIndex: Int): Boolean = true + + override fun attachEncryptor(sender: RtpSender): Boolean { + sender.setFrameEncryptor(FrameEncryptor { 0 }.also { encryptors.add(it) }) + return true + } + + override fun attachDecryptor(receiver: RtpReceiver): Boolean { + receiver.setFrameDecryptor(FrameDecryptor { 0 }) + return true + } + + override fun dispose() { + // unused atm + } + } + + private lateinit var mockedPeerConnection: PeerConnection + private lateinit var mockedPeerConnectionFactory: PeerConnectionFactory + private lateinit var mockedSignalingMessageReceiver: SignalingMessageReceiver + private lateinit var mockedSignalingMessageSender: SignalingMessageSender + + @Before + fun setUp() { + mockedPeerConnection = Mockito.mock(PeerConnection::class.java) + mockedPeerConnectionFactory = Mockito.mock(PeerConnectionFactory::class.java) + mockedSignalingMessageReceiver = Mockito.mock(SignalingMessageReceiver::class.java) + mockedSignalingMessageSender = Mockito.mock(SignalingMessageSender::class.java) + Mockito.`when`( + mockedPeerConnectionFactory.createPeerConnection( + any(PeerConnection.RTCConfiguration::class.java), + any(PeerConnection.Observer::class.java) + ) + ).thenReturn(mockedPeerConnection) + val mockedStatusDataChannel = Mockito.mock(DataChannel::class.java) + Mockito.`when`(mockedStatusDataChannel.label()).thenReturn("status") + Mockito.`when`(mockedPeerConnection.createDataChannel(eq("status"), any())).thenReturn(mockedStatusDataChannel) + } + + /** + * The local session is lower than the remote one, so the connection would not send the offer if it did not + * share the screen. + */ + private fun createWrapper( + isScreenSharer: Boolean, + hasMCU: Boolean = false, + localStream: MediaStream? = createScreenStream(), + videoStreamType: String = "screen", + encryptionRequired: Boolean = false, + senderKeyRing: FrameKeyRing? = null + ): PeerConnectionWrapper = + PeerConnectionWrapper( + mockedPeerConnectionFactory, + ArrayList(), + MediaConstraints(), + if (hasMCU) LOCAL_SESSION_ID else REMOTE_SESSION_ID, + LOCAL_SESSION_ID, + localStream, + hasMCU, + hasMCU, + videoStreamType, + mockedSignalingMessageReceiver, + mockedSignalingMessageSender, + encryptionRequired, + senderKeyRing, + null, + isScreenSharer + ) + + // The tracks are public fields initialized in the constructor, which a plain mock would skip. + private fun createScreenStream(): MediaStream { + val stream = Mockito.mock(MediaStream::class.java, Mockito.withSettings().useConstructor(0L)) + Mockito.`when`(stream.id).thenReturn("NCSS") + stream.videoTracks.add(Mockito.mock(VideoTrack::class.java)) + return stream + } + + private fun captureOfferObserver(): SdpObserver { + val captor = ArgumentCaptor.forClass(SdpObserver::class.java) + verify(mockedPeerConnection).createOffer(captor.capture(), any(MediaConstraints::class.java)) + return captor.value + } + + private fun captureSentMessage(): NCSignalingMessageDto { + val captor = ArgumentCaptor.forClass(NCSignalingMessageDto::class.java) + verify(mockedSignalingMessageSender).send(captor.capture()) + return captor.value + } + + @Test + fun testScreenSharerSendsOfferWithoutDataChannel() { + createWrapper(isScreenSharer = true) + + verify(mockedPeerConnection).addTrack(any(VideoTrack::class.java), eq(listOf("NCSS"))) + verify(mockedPeerConnection).createOffer(any(SdpObserver::class.java), any(MediaConstraints::class.java)) + verify(mockedPeerConnection, never()).createDataChannel(anyString(), any()) + } + + @Test + fun testNotOffererDoesNotSendOfferWithoutSharingScreen() { + createWrapper(isScreenSharer = false, localStream = null, videoStreamType = "video") + + verify(mockedPeerConnection, never()).createOffer( + any(SdpObserver::class.java), + any(MediaConstraints::class.java) + ) + } + + @Test + fun testScreenSharerListensWithScreenSharingRoomType() { + createWrapper(isScreenSharer = true) + createWrapper(isScreenSharer = false, localStream = null) + + verify(mockedSignalingMessageReceiver).addListener( + any(SignalingMessageReceiver.WebRtcMessageListener::class.java), + eq(REMOTE_SESSION_ID), + eq(SignalingMessageReceiver.ROOM_TYPE_SCREEN_SHARING) + ) + verify(mockedSignalingMessageReceiver).addListener( + any(SignalingMessageReceiver.WebRtcMessageListener::class.java), + eq(REMOTE_SESSION_ID), + eq("screen") + ) + } + + @Test + fun testScreenSharerSetsItselfAsBroadcaster() { + createWrapper(isScreenSharer = true) + + captureOfferObserver().onCreateSuccess(SessionDescription(SessionDescription.Type.OFFER, SDP)) + + val message = captureSentMessage() + assertEquals("offer", message.type) + assertEquals("screen", message.roomType) + assertEquals(REMOTE_SESSION_ID, message.to) + assertEquals(LOCAL_SESSION_ID, message.broadcaster) + } + + @Test + fun testOtherConnectionsDoNotSetBroadcaster() { + createWrapper(isScreenSharer = false, hasMCU = true, localStream = null, videoStreamType = "video") + + captureOfferObserver().onCreateSuccess(SessionDescription(SessionDescription.Type.OFFER, SDP)) + + assertNull(captureSentMessage().broadcaster) + } + + @Test + fun testEncryptedScreenShareIsEncryptedBeforeOffer() { + val sender = Mockito.mock(RtpSender::class.java) + Mockito.`when`(mockedPeerConnection.senders).thenReturn(listOf(sender)) + val keyRing = FakeKeyRing() + + createWrapper(isScreenSharer = true, hasMCU = true, encryptionRequired = true, senderKeyRing = keyRing) + + assertEquals(1, keyRing.encryptors.size) + val inOrder = Mockito.inOrder(sender, mockedPeerConnection) + inOrder.verify(sender).setFrameEncryptor(keyRing.encryptors[0]) + inOrder.verify(mockedPeerConnection).createOffer( + any(SdpObserver::class.java), + any(MediaConstraints::class.java) + ) + } + + @Test + fun testEncryptedScreenShareWithoutKeysIsRefused() { + for (hasMCU in listOf(true, false)) { + assertThrows(IllegalStateException::class.java) { + createWrapper(isScreenSharer = true, hasMCU = hasMCU, encryptionRequired = true) + } + } + + verify(mockedPeerConnectionFactory, never()).createPeerConnection( + any(PeerConnection.RTCConfiguration::class.java), + any(PeerConnection.Observer::class.java) + ) + } + + companion object { + private const val LOCAL_SESSION_ID = "a-local-session-id" + private const val REMOTE_SESSION_ID = "b-remote-session-id" + private const val SDP = "v=0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\na=rtpmap:96 H264/90000\r\n" + } +}