diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Activity.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Activity.kt index 5b551f6bad..86c908b6d5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Activity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Activity.kt @@ -13,14 +13,22 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.viewinterop.AndroidView import androidx.fragment.compose.AndroidFragment +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.signal.mediasend.HudCommand import org.signal.mediasend.MediaSendActivityContract import org.signal.mediasend.MediaSendScreen import org.signal.mediasend.edit.LocalAddAMessageRowTextField import org.thoughtcrime.securesms.PassphraseRequiredActivity import org.thoughtcrime.securesms.components.emoji.EmojiTextView +import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity import org.thoughtcrime.securesms.mediasend.v2.review.AddMessageDialogFragment +import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.registration.olddevice.QuickTransferOldDeviceActivity +import org.thoughtcrime.securesms.util.CommunicationActions /** * Encapsulates the media send flow for v3. @@ -64,8 +72,28 @@ class MediaSendV3Activity : PassphraseRequiredActivity() { ) } - // TODO - QR HudCommands (GoToConversation, GoToLinkedDevices, GoToQuickTransfer) not yet wired up. - else -> Unit + is HudCommand.GoToConversation -> { + lifecycleScope.launch(Dispatchers.Default) { + val recipient = Recipient.resolved(RecipientId.from(it.recipientId.id)) + withContext(Dispatchers.Main) { + CommunicationActions.startConversation( + this@MediaSendV3Activity, + recipient, + null + ) + } + } + } + + HudCommand.GoToLinkedDevices -> { + startActivity(AppSettingsActivity.linkedDevices(this)) + finish() + } + + is HudCommand.GoToQuickTransfer -> { + startActivity(QuickTransferOldDeviceActivity.intent(this, it.qrData)) + finish() + } } } ) diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/DialogController.kt b/core/ui/src/main/java/org/signal/core/ui/compose/DialogController.kt new file mode 100644 index 0000000000..362e5901e7 --- /dev/null +++ b/core/ui/src/main/java/org/signal/core/ui/compose/DialogController.kt @@ -0,0 +1,117 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.ui.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first + +/** + * Drives a Compose dialog imperatively from a coroutine: [show] suspends until the user resolves the + * dialog and returns the [DialogResult] they chose. + * + * Hold an instance where a coroutine scope is available (typically a `ViewModel`), render [Content] + * once in your composition with the dialog UI, and call [show] from a coroutine to present it. + * [Content] draws nothing until [show] is called and hides itself again once a result is produced. + * + * [show] is single-shot: drive one dialog at a time per instance. Concurrent callers share the same + * visible state and all resolve to the same result. + * + * @param S the type of state passed to the dialog for rendering (e.g. a username to display). Use [Unit] + * when the dialog needs no state. + * + * Example: + * ``` + * // 1. Hold an instance (e.g. on a ViewModel). + * val usernameScannedDialog = DialogController() + * + * // 2. Render its Content once, supplying the dialog UI. + * usernameScannedDialog.Content { username, onDismissRequest, onConfirm, _, onDeny -> + * Dialogs.SimpleAlertDialog( + * title = stringResource(R.string.found_user, username), + * body = stringResource(R.string.start_chat_with, username), + * confirm = stringResource(R.string.go_to_chat), + * onConfirm = onConfirm, + * onDeny = onDeny, + * onDismissRequest = onDismissRequest + * ) + * } + * + * // 3. Show it from a coroutine and react to the result. + * when (usernameScannedDialog.show(username)) { + * DialogResult.POSITIVE -> goToChat() + * else -> Unit + * } + * ``` + */ +@Stable +class DialogController { + + private var dialogState: S? by mutableStateOf(null) + private var dialogResult: DialogResult? by mutableStateOf(null) + private var visible: Boolean by mutableStateOf(false) + + /** Presents the dialog with the given [state] and suspends until the user resolves it. */ + suspend fun show(state: S): DialogResult { + dialogState = state + dialogResult = null + visible = true + return awaitDialogResult() + } + + /** + * Renders [dialog] while one is being shown via [show]; renders nothing otherwise. Call once in + * composition. + * + * Invoke the callback matching the user's choice to resolve [show]: `onConfirm` → [DialogResult.POSITIVE], + * `onNeutral` → [DialogResult.NEUTRAL], `onDeny`/`onDismissRequest` → [DialogResult.NEGATIVE]. + */ + @Composable + fun Content(dialog: DialogContent) { + val state = dialogState + if (visible && state != null) { + dialog( + state, + { dialogResult = DialogResult.NEGATIVE }, + { dialogResult = DialogResult.POSITIVE }, + { dialogResult = DialogResult.NEUTRAL }, + { dialogResult = DialogResult.NEGATIVE } + ) + } + } + + private suspend fun awaitDialogResult(): DialogResult { + return try { + snapshotFlow { dialogResult } + .filterNotNull() + .first() + } finally { + // Hide the dialog on both normal resolution and cancellation, so a cancelled show() + // can't leave it stuck visible with nobody left to resolve it. + visible = false + } + } +} + +/** The dialog UI rendered by [DialogController.Content]. */ +typealias DialogContent = @Composable ( + state: S, + onDismissRequest: () -> Unit, + onConfirm: () -> Unit, + onNeutral: () -> Unit, + onDeny: () -> Unit +) -> Unit + +enum class DialogResult { + POSITIVE, + NEUTRAL, + NEGATIVE +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/DialogEvent.kt b/feature/media-send/src/main/java/org/signal/mediasend/DialogEvent.kt deleted file mode 100644 index 1327bba425..0000000000 --- a/feature/media-send/src/main/java/org/signal/mediasend/DialogEvent.kt +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright 2026 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.signal.mediasend - -internal sealed interface DialogEvent { - data class UsernameScanned(val qrCheckResult: MediaSendQrRepository.QrCheckResult.Username) : DialogEvent - data object LinkedDeviceScanned : DialogEvent -} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendEvent.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendEvent.kt index 616442b5f1..44e4639dc2 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendEvent.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendEvent.kt @@ -35,7 +35,7 @@ sealed interface HudCommand { val isViewOnceAvailable: Boolean ) : HudCommand - data class GoToConversation(val recipientId: MediaRecipientId, val username: String) : HudCommand + data class GoToConversation(val recipientId: MediaRecipientId) : HudCommand data object GoToLinkedDevices : HudCommand data class GoToQuickTransfer(val qrData: String) : HudCommand } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt index bacea93442..b73eee7ac2 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt @@ -11,9 +11,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigationevent.NavigationEventDispatcherOwner import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner +import org.signal.core.ui.compose.Dialogs import org.signal.core.ui.compose.theme.SignalTheme @Composable @@ -35,6 +37,27 @@ fun MediaSendScreen( SignalTheme { CompositionLocalProvider(LocalNavigationEventDispatcherOwner provides LocalActivity.current as NavigationEventDispatcherOwner) { Surface { + viewModel.usernameScannedDialog.Content { username, onDismissRequest, onConfirm, _, onDeny -> + Dialogs.SimpleAlertDialog( + title = stringResource(R.string.UsernameScannedDialog__username_dialog_title, username), + body = stringResource(R.string.UsernameScannedDialog__username_dialog_body, username), + confirm = stringResource(R.string.UsernameScannedDialog__username_dialog_go_to_chat_button), + onConfirm = onConfirm, + onDeny = onDeny, + onDismissRequest = onDismissRequest + ) + } + viewModel.linkedDeviceScannedDialog.Content { _, onDismissRequest, onConfirm, _, onDeny -> + Dialogs.SimpleAlertDialog( + title = stringResource(R.string.LinkedDeviceScannedDialog__device_link_dialog_title), + body = stringResource(R.string.LinkedDeviceScannedDialog__it_looks_like_youre_trying), + confirm = stringResource(R.string.LinkedDeviceScannedDialog__device_link_dialog_continue), + onConfirm = onConfirm, + onDeny = onDeny, + onDismissRequest = onDismissRequest + ) + } + MediaSendNavDisplay( stateFlow = viewModel.state, snackbarEvents = viewModel.snackbarEvents, diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt index badbe3dc07..a099454194 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt @@ -24,6 +24,7 @@ import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.target.Target import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -32,7 +33,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update @@ -40,9 +40,10 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.signal.core.models.media.Media import org.signal.core.models.media.MediaFolder +import org.signal.core.ui.compose.DialogController +import org.signal.core.ui.compose.DialogResult import org.signal.core.util.ContentTypeUtil import org.signal.core.util.StringUtil -import org.signal.core.util.throttleLatest import org.signal.imageeditor.core.model.EditorElement import org.signal.imageeditor.core.model.EditorModel import org.signal.imageeditor.core.renderers.UriGlideRenderer @@ -101,10 +102,10 @@ class MediaSendViewModel( private val internalSnackbarEvents: Channel = Channel(Channel.BUFFERED) internal val snackbarEvents: Flow = internalSnackbarEvents.receiveAsFlow() - private val internalDialogEvents: Channel = Channel(Channel.BUFFERED) - internal val dialogEvents: Flow = internalDialogEvents.receiveAsFlow() + internal val usernameScannedDialog = DialogController() + internal val linkedDeviceScannedDialog = DialogController() - private val qrCheckRequest: MutableStateFlow = MutableStateFlow("") + private val qrCheckRequest: Channel = Channel(Channel.RENDEZVOUS) /** * Main UI state. Backed by [SavedStateHandle] for automatic process death survival. @@ -142,14 +143,35 @@ class MediaSendViewModel( } } - viewModelScope.launch(Dispatchers.IO) { - qrCheckRequest.throttleLatest(5.seconds).filter { it.isNotEmpty() }.collect { - when (MediaSendDependencies.qrRepository.checkQrData(it)) { - MediaSendQrRepository.QrCheckResult.LinkDevice -> Unit // TODO() // dialog -> linked devices activity -> finish - MediaSendQrRepository.QrCheckResult.None -> Unit - is MediaSendQrRepository.QrCheckResult.ReRegistration -> Unit // TODO() // quick transfer activity -> finish - is MediaSendQrRepository.QrCheckResult.Username -> Unit // TODO() // dialog -> start conversation -> finish + viewModelScope.launch(Dispatchers.Default) { + for (qrData in qrCheckRequest) { + if (qrData.isEmpty()) { + continue } + + val result = MediaSendDependencies.qrRepository.checkQrData(qrData) + if (result == MediaSendQrRepository.QrCheckResult.None) { + continue + } + + when (result) { + MediaSendQrRepository.QrCheckResult.LinkDevice -> { + when (linkedDeviceScannedDialog.show(Unit)) { + DialogResult.POSITIVE -> sendHudCommand(HudCommand.GoToLinkedDevices) + else -> Unit + } + } + MediaSendQrRepository.QrCheckResult.None -> Unit + is MediaSendQrRepository.QrCheckResult.ReRegistration -> sendHudCommand(HudCommand.GoToQuickTransfer(qrData)) + is MediaSendQrRepository.QrCheckResult.Username -> { + when (usernameScannedDialog.show(result.username)) { + DialogResult.POSITIVE -> sendHudCommand(HudCommand.GoToConversation(result.recipientId)) + else -> Unit + } + } + } + + delay(5.seconds) } } @@ -215,7 +237,7 @@ class MediaSendViewModel( CameraXScreenEvent.GalleryClicked -> backStack.goToFolders() is CameraXScreenEvent.ImageCaptured -> handleImageCaptured(event) is CameraXScreenEvent.VideoCaptured -> handleVideoCaptured(event) - is CameraXScreenEvent.QrCodeFound -> qrCheckRequest.update { event.data } + is CameraXScreenEvent.QrCodeFound -> qrCheckRequest.trySend(event.data) CameraXScreenEvent.VideoCaptureError -> { internalSnackbarEvents.trySend(SnackbarEvent(message = R.string.MediaSendViewModel__error_recording_video)) } @@ -898,14 +920,6 @@ class MediaSendViewModel( //endregion - //region HUD Commands - - fun sendCommand(command: HudCommand) { - hudCommandChannel.trySend(command) - } - - //endregion - //region Query Methods fun hasSelectedMedia(): Boolean = internalState.value.selectedMedia.isNotEmpty() diff --git a/feature/media-send/src/main/res/values/strings.xml b/feature/media-send/src/main/res/values/strings.xml index a9c287396f..24693dc49b 100644 --- a/feature/media-send/src/main/res/values/strings.xml +++ b/feature/media-send/src/main/res/values/strings.xml @@ -90,4 +90,18 @@ Error recording video Error taking photo + + + Found %1$s + + Start a chat with \"%1$s\" + + Go to chat + + + Link device? + + It looks like you\'re trying to link a Signal device. Tap continue and then tap \"Link a New Device\" and scan the QR code again. + + Continue