+ 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"
+ }
+}