Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
/*
* Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
*
* Licensed under the Stream License;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://github.com/GetStream/stream-video-android/blob/main/LICENSE
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.getstream.video.android.ui.lobby

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.getstream.video.android.compose.theme.VideoTheme
import io.getstream.video.android.compose.ui.components.base.StreamDialogPositiveNegative
import io.getstream.video.android.compose.ui.components.base.StreamTextField
import io.getstream.video.android.compose.ui.components.base.styling.ButtonStyles
import io.getstream.video.android.compose.ui.components.base.styling.StreamDialogStyles
import io.getstream.video.android.core.Call
import kotlinx.coroutines.launch
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec

/**
* Salt, iteration count and key length are fixed across the web, iOS and Android demos so the same
* passphrase produces the same key on every platform — otherwise a cross-platform test call just
* silently fails to decrypt. Real integrations distribute key material out of band instead of
* deriving it from a secret typed into a UI, which is why this lives in the demo and not the SDK.
*/
private const val KDF_SALT = "stream-e2ee"
private const val KDF_ITERATIONS = 100_000
private const val KDF_KEY_BITS = 128

/** Derives the AES-128 key that the demo shares between participants. */
internal fun deriveE2EEKey(passphrase: String): ByteArray {
val spec = PBEKeySpec(
passphrase.toCharArray(),
KDF_SALT.toByteArray(Charsets.UTF_8),
KDF_ITERATIONS,
KDF_KEY_BITS,
)
return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).encoded
}

/**
* Lobby toggle for end-to-end encryption. It belongs here rather than in the in-call menu because
* keys have to be set before [Call.join] — the publisher and subscriber capture the encryption
* manager when the session is created.
*/
@Composable
internal fun E2EELobbyButton(
call: Call,
onEnable: suspend (String) -> Result<Unit>,
onDisable: () -> Result<Unit>,
modifier: Modifier = Modifier,
) {
val encrypted by call.state.e2eeEnabled.collectAsStateWithLifecycle()
var showDialog by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()

IconButton(
modifier = modifier.testTag("Stream_LobbyE2EEButton"),
onClick = {
if (encrypted) {
error = onDisable().exceptionOrNull()?.message
} else {
showDialog = true
}
},
) {
Icon(
imageVector = if (encrypted) Icons.Default.Lock else Icons.Default.LockOpen,
contentDescription = if (encrypted) "Disable encryption" else "Enable encryption",
tint = if (encrypted) {
VideoTheme.colors.brandPrimary
} else {
VideoTheme.colors.basePrimary
},
)
}

if (showDialog) {
E2EEPassphraseDialog(
error = error,
onDismiss = {
showDialog = false
error = null
},
onConfirm = { passphrase ->
// Surfaced rather than swallowed: joining a call the user believes is encrypted
// when it is not would be worse than refusing to enable it.
scope.launch {
val failure = onEnable(passphrase).exceptionOrNull()
error = failure?.message ?: failure?.javaClass?.simpleName
showDialog = failure != null
}
},
)
}
}

@Composable
private fun E2EEPassphraseDialog(
error: String?,
onDismiss: () -> Unit,
onConfirm: (String) -> Unit,
) {
var passphrase by remember { mutableStateOf(TextFieldValue("")) }
var validationError by remember { mutableStateOf<String?>(null) }
val fieldError = validationError ?: error

StreamDialogPositiveNegative(
style = StreamDialogStyles.defaultDialogStyle(),
onDismiss = onDismiss,
icon = Icons.Default.Lock,
title = "Encrypt this call",
contentText = "Everyone on the call has to use the same passphrase. Recording, " +
"transcription, closed captions and HLS are unavailable on encrypted calls.",
content = {
Column(modifier = Modifier.fillMaxWidth()) {
StreamTextField(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.testTag("Stream_E2EEPassphraseField"),
value = passphrase,
onValueChange = {
passphrase = it
validationError = null
},
placeholder = "Passphrase",
error = fieldError != null,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Password,
),
)
if (fieldError != null) {
Text(
modifier = Modifier.padding(horizontal = 16.dp),
text = fieldError,
style = VideoTheme.typography.bodyS,
color = VideoTheme.colors.alertWarning,
)
}
}
},
positiveButton = Triple("Enable", ButtonStyles.secondaryButtonStyle()) {
if (passphrase.text.isBlank()) {
validationError = "Passphrase cannot be empty."
} else {
onConfirm(passphrase.text)
}
},
negativeButton = Triple("Cancel", ButtonStyles.tertiaryButtonStyle()) {
onDismiss()
},
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,13 @@ private fun CallLobbyHeader(
callLobbyViewModel = callLobbyViewModel,
)

CallLobbyHeaderContent(user, onBack)
CallLobbyHeaderContent(
user = user,
call = callLobbyViewModel.call,
onEnableE2EE = callLobbyViewModel::enableE2EE,
onDisableE2EE = callLobbyViewModel::disableE2EE,
onBack = onBack,
)

LaunchedEffect(key1 = isLoggedOut) {
if (isLoggedOut) {
Expand All @@ -176,6 +182,9 @@ private fun CallLobbyHeader(
@Composable
private fun CallLobbyHeaderContent(
user: State<User?>,
call: Call,
onEnableE2EE: suspend (String) -> Result<Unit>,
onDisableE2EE: () -> Result<Unit>,
onBack: () -> Unit,
) {
Row(
Expand Down Expand Up @@ -207,6 +216,13 @@ private fun CallLobbyHeaderContent(
maxLines = 1,
fontSize = 16.sp,
)
E2EELobbyButton(
call = call,
onEnable = onEnableE2EE,
onDisable = onDisableE2EE,
modifier = Modifier.padding(8.dp),
)

IconButton(
modifier = Modifier
.padding(8.dp)
Expand Down Expand Up @@ -542,6 +558,9 @@ private fun CallLobbyHeaderPreview() {
user = remember {
mutableStateOf(previewUsers[0])
},
call = previewCall,
onEnableE2EE = { Result.success(Unit) },
onDisableE2EE = { Result.success(Unit) },
) {
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,12 @@ import io.getstream.chat.android.client.ChatClient
import io.getstream.video.android.core.Call
import io.getstream.video.android.core.DeviceStatus
import io.getstream.video.android.core.StreamVideo
import io.getstream.video.android.core.e2ee.E2EEEventType
import io.getstream.video.android.core.e2ee.StreamEncryptionManager
import io.getstream.video.android.datastore.delegate.StreamUserDataStore
import io.getstream.video.android.model.StreamCallId
import io.getstream.video.android.model.User
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
Expand All @@ -44,6 +47,7 @@ import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import stream.video.sfu.models.AudioBitrateProfile
import javax.inject.Inject

Expand All @@ -54,6 +58,12 @@ class CallLobbyViewModel @Inject constructor(
private val googleSignInClient: GoogleSignInClient,
) : ViewModel() {

private companion object {
const val E2EE_KEY_INDEX = 0
}

private var e2eeManager: StreamEncryptionManager? = null

private val cid: String = checkNotNull(savedStateHandle["cid"])
val callId: StreamCallId = StreamCallId.fromCallCid(cid)

Expand All @@ -66,6 +76,11 @@ class CallLobbyViewModel @Inject constructor(
// this way the lobby screen can already display the right mic/camera settings
// This also starts listening to the call events to get the participant count
val callGetOrCreateResult = call.create()
Log.i(
"CallLobbyViewModel",
"Call ${call.cid} encryption mode=" +
"${call.state.settings.value?.encryption?.mode}",
)
if (callGetOrCreateResult.isFailure) {
// in demo we can ignore this. The lobby screen will just display default camera/video,
// but we will show an error
Expand Down Expand Up @@ -192,6 +207,54 @@ class CallLobbyViewModel @Inject constructor(
}
}

suspend fun enableE2EE(passphrase: String): Result<Unit> {
val key = runCatching {
withContext(Dispatchers.Default) { deriveE2EEKey(passphrase) }
}.getOrElse { return Result.failure(it) }
val created = StreamEncryptionManager.create(call.user.id)
.getOrElse { return Result.failure(it) }
created.setEventListener { event ->
val message = "Native E2EE event: $event"
when (event.type) {
E2EEEventType.DECRYPTION_RESUMED -> Log.i("CallLobbyViewModel", message)
E2EEEventType.KEY_STATE,
E2EEEventType.PERF_REPORT,
-> Log.d("CallLobbyViewModel", message)
E2EEEventType.DECRYPTION_FAILED,
E2EEEventType.DECRYPTION_STALLED,
E2EEEventType.ENCRYPTION_FAILED,
E2EEEventType.MISSING_KEY,
E2EEEventType.UNENCRYPTED_FRAME,
E2EEEventType.UNSUPPORTED_VERSION,
E2EEEventType.UNKNOWN,
-> Log.w("CallLobbyViewModel", message)
}
}
created.enablePerformanceReporting(true)
created.setSharedKey(E2EE_KEY_INDEX, key)
val attached = call.setE2EEManager(created)
if (attached.isFailure) {
created.dispose()
return attached
}
val previous = e2eeManager
e2eeManager = created
previous?.dispose()
return Result.success(Unit)
}

fun disableE2EE(): Result<Unit> {
val current = e2eeManager
val detached = call.setE2EEManager(null)
if (detached.isFailure) return detached
try {
current?.dispose()
} finally {
e2eeManager = null
}
return Result.success(Unit)
}

fun signOut() {
viewModelScope.launch {
googleSignInClient.signOut()
Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ tink = "1.9.0"
turbine = "0.13.0"
itu = "1.7.3"

streamWebRTC = "145.6.0"
streamWebRTC = "145.17.0"
streamNoiseCancellation = "3.0.0"
streamResult = "1.3.0"
streamChat = "6.10.0"
Expand Down
Loading
Loading