diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 980c221f07..bf97ade258 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -238,6 +238,7 @@ android { buildConfigField("String", "STRIPE_PUBLISHABLE_KEY", "\"pk_live_6cmGZopuTsV8novGgJJW9JpC00vLIgtQ1D\"") buildConfigField("boolean", "TRACING_ENABLED", "false") buildConfigField("boolean", "MESSAGE_BACKUP_RESTORE_ENABLED", "false") + buildConfigField("boolean", "LINK_DEVICE_UX_ENABLED", "false") ndk { abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64") @@ -317,6 +318,7 @@ android { isMinifyEnabled = false matchingFallbacks += "debug" buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Spinner\"") + buildConfigField("boolean", "LINK_DEVICE_UX_ENABLED", "true") } create("perf") { diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/link/RegisterLinkDeviceQrFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/link/RegisterLinkDeviceQrFragment.kt new file mode 100644 index 0000000000..196b13be60 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/link/RegisterLinkDeviceQrFragment.kt @@ -0,0 +1,318 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.link + +import android.os.Bundle +import android.view.View +import android.view.WindowManager +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.findNavController +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.signal.core.ui.compose.Buttons +import org.signal.core.ui.compose.Dialogs +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.SignalPreview +import org.signal.core.ui.compose.horizontalGutters +import org.signal.core.ui.compose.theme.SignalTheme +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCode +import org.thoughtcrime.securesms.compose.ComposeFragment +import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel +import org.thoughtcrime.securesms.registrationv3.ui.shared.RegistrationScreen +import java.lang.IllegalStateException + +/** + * Crude show QR code on link device to allow linking from primary device. + */ +class RegisterLinkDeviceQrFragment : ComposeFragment() { + + private val sharedViewModel by activityViewModels() + private val viewModel: RegisterLinkDeviceQrViewModel by viewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onResume(owner: LifecycleOwner) { + requireActivity().window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + + override fun onPause(owner: LifecycleOwner) { + requireActivity().window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + }) + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { + viewModel + .state + .mapNotNull { it.provisionMessage } + .distinctUntilChanged() + .collect { message -> + withContext(Dispatchers.IO) { + val result = sharedViewModel.registerAsLinkedDevice(requireContext().applicationContext, message) + + when (result) { + RegisterLinkDeviceResult.Success -> Unit + else -> viewModel.setRegisterAsLinkedDeviceError(result) + } + } + } + } + } + } + + @Composable + override fun FragmentContent() { + val state by viewModel.state.collectAsState() + + RegisterLinkDeviceQrScreen( + state = state, + onRetryQrCode = viewModel::restartProvisioningSocket, + onErrorDismiss = viewModel::clearErrors, + onCancel = { findNavController().popBackStack() } + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun RegisterLinkDeviceQrScreen( + state: RegisterLinkDeviceQrViewModel.RegisterLinkDeviceState, + onRetryQrCode: () -> Unit = {}, + onErrorDismiss: () -> Unit = {}, + onCancel: () -> Unit = {} +) { + // TODO [link-device] use actual design + RegistrationScreen( + title = "Scan this code with your phone", + subtitle = null, + bottomContent = { + TextButton( + onClick = onCancel, + modifier = Modifier.align(Alignment.Center) + ) { + Text(text = stringResource(android.R.string.cancel)) + } + } + ) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(space = 48.dp, alignment = Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(space = 48.dp), + modifier = Modifier + .fillMaxWidth() + .horizontalGutters() + ) { + Box( + modifier = Modifier + .widthIn(160.dp, 320.dp) + .aspectRatio(1f) + .clip(RoundedCornerShape(24.dp)) + .background(SignalTheme.colors.colorSurface5) + .padding(40.dp) + ) { + SignalTheme(isDarkMode = false) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surface) + .fillMaxWidth() + .fillMaxHeight() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + AnimatedContent( + targetState = state.qrState, + contentKey = { it::class }, + contentAlignment = Alignment.Center, + label = "qr-code-progress", + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + ) { qrState -> + when (qrState) { + is RegisterLinkDeviceQrViewModel.QrState.Loaded -> { + QrCode( + data = qrState.qrData, + foregroundColor = Color(0xFF2449C0), + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + ) + } + + RegisterLinkDeviceQrViewModel.QrState.Loading -> { + Box(contentAlignment = Alignment.Center) { + CircularProgressIndicator(modifier = Modifier.size(48.dp)) + } + } + + is RegisterLinkDeviceQrViewModel.QrState.Scanned, + RegisterLinkDeviceQrViewModel.QrState.Failed -> { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + val text = if (state.qrState is RegisterLinkDeviceQrViewModel.QrState.Scanned) { + "Scanned on device" + } else { + stringResource(R.string.RestoreViaQr_qr_code_error) + } + + Text( + text = text, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Buttons.Small( + onClick = onRetryQrCode + ) { + Text(text = stringResource(R.string.RestoreViaQr_retry)) + } + } + } + } + } + } + } + } + + // TODO [link-device] use actual copy + Column( + modifier = Modifier + .align(alignment = Alignment.CenterVertically) + .widthIn(160.dp, 320.dp) + ) { + InstructionRow( + icon = painterResource(R.drawable.symbol_settings_android_24), + instruction = "Open Signal Settings on your device" + ) + + InstructionRow( + icon = painterResource(R.drawable.symbol_link_24), + instruction = "Tap \"Linked devices\"" + ) + + InstructionRow( + icon = painterResource(R.drawable.symbol_qrcode_24), + instruction = "Tap \"Link a new device\" and scan this code" + ) + } + } + + if (state.isRegistering) { + Dialogs.IndeterminateProgressDialog() + } else if (state.showProvisioningError) { + Dialogs.SimpleMessageDialog( + message = "failed provision", + onDismiss = onErrorDismiss, + dismiss = stringResource(android.R.string.ok) + ) + } else if (state.registrationErrorResult != null) { + val message = when (state.registrationErrorResult) { + RegisterLinkDeviceResult.IncorrectVerification -> "incorrect verification" + RegisterLinkDeviceResult.InvalidRequest -> "invalid request" + RegisterLinkDeviceResult.MaxLinkedDevices -> "max linked devices reached" + RegisterLinkDeviceResult.MissingCapability -> "missing capability, must update" + is RegisterLinkDeviceResult.NetworkException -> "network exception ${state.registrationErrorResult.t.message}" + is RegisterLinkDeviceResult.RateLimited -> "rate limited ${state.registrationErrorResult.retryAfter}" + is RegisterLinkDeviceResult.UnexpectedException -> "unexpected exception ${state.registrationErrorResult.t.message}" + RegisterLinkDeviceResult.Success -> throw IllegalStateException() + } + + Dialogs.SimpleMessageDialog( + message = message, + onDismiss = onErrorDismiss, + dismiss = stringResource(android.R.string.ok) + ) + } + } +} + +@Composable +private fun InstructionRow( + icon: Painter, + instruction: String +) { + Row( + modifier = Modifier + .padding(vertical = 12.dp) + ) { + Icon( + painter = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Text( + text = instruction, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@SignalPreview +@Composable +private fun InstructionRowPreview() { + Previews.Preview { + InstructionRow( + icon = painterResource(R.drawable.symbol_phone_24), + instruction = "Instruction!" + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/link/RegisterLinkDeviceQrViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/link/RegisterLinkDeviceQrViewModel.kt new file mode 100644 index 0000000000..f8bb3f29fe --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/link/RegisterLinkDeviceQrViewModel.kt @@ -0,0 +1,178 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.link + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData +import org.thoughtcrime.securesms.crypto.IdentityKeyUtil +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.whispersystems.signalservice.api.provisioning.ProvisioningSocket +import org.whispersystems.signalservice.internal.crypto.SecondaryProvisioningCipher +import org.whispersystems.signalservice.internal.push.ProvisionMessage +import java.io.Closeable + +/** + * Handles creating and maintaining a provisioning websocket in the pursuit + * of adding this device as a linked device. + */ +class RegisterLinkDeviceQrViewModel : ViewModel() { + + companion object { + private val TAG = Log.tag(RegisterLinkDeviceQrViewModel::class) + } + + private val store: MutableStateFlow = MutableStateFlow(RegisterLinkDeviceState()) + + val state: StateFlow = store + + private var socketHandles: MutableList = mutableListOf() + private var startNewSocketJob: Job? = null + + init { + restartProvisioningSocket() + } + + override fun onCleared() { + shutdown() + } + + fun restartProvisioningSocket() { + shutdown() + + startNewSocket() + + startNewSocketJob = viewModelScope.launch(Dispatchers.IO) { + var count = 0 + while (count < 5 && isActive) { + delay(ProvisioningSocket.LIFESPAN / 2) + if (isActive) { + startNewSocket() + count++ + Log.d(TAG, "Started next websocket count: $count") + } + } + } + } + + private fun startNewSocket() { + synchronized(socketHandles) { + socketHandles += start() + + if (socketHandles.size > 2) { + socketHandles.removeAt(0).close() + } + } + } + + private fun start(): Closeable { + store.update { + if (it.qrState !is QrState.Loaded) { + it.copy(qrState = QrState.Loading) + } else { + it + } + } + + return ProvisioningSocket.start( + mode = ProvisioningSocket.Mode.LINK, + identityKeyPair = IdentityKeyUtil.generateIdentityKeyPair(), + configuration = AppDependencies.signalServiceNetworkAccess.getConfiguration(), + handler = { id, t -> + store.update { + if (it.currentSocketId == null || it.currentSocketId == id) { + Log.w(TAG, "Current socket [$id] has failed, stopping automatic connects", t) + shutdown() + it.copy(currentSocketId = null, qrState = QrState.Failed) + } else { + Log.i(TAG, "Old socket [$id] failed, ignoring") + it + } + } + } + ) { socket -> + val url = socket.getProvisioningUrl() + store.update { + Log.d(TAG, "Updating QR code with data from [${socket.id}]") + + it.copy( + currentSocketId = socket.id, + qrState = QrState.Loaded( + qrData = QrCodeData.forData( + data = url, + supportIconOverlay = false + ) + ) + ) + } + + val result = socket.getProvisioningMessageDecryptResult() + + if (result is SecondaryProvisioningCipher.ProvisioningDecryptResult.Success) { + store.update { it.copy(isRegistering = true, provisionMessage = result.message, qrState = QrState.Scanned) } + shutdown() + } else { + store.update { + if (it.currentSocketId == socket.id) { + it.copy(qrState = QrState.Scanned, showProvisioningError = true) + } else { + it + } + } + } + } + } + + private fun shutdown() { + startNewSocketJob?.cancel() + synchronized(socketHandles) { + socketHandles.forEach { it.close() } + socketHandles.clear() + } + } + + fun clearErrors() { + store.update { + it.copy( + showProvisioningError = false, + registrationErrorResult = null + ) + } + + restartProvisioningSocket() + } + + fun setRegisterAsLinkedDeviceError(result: RegisterLinkDeviceResult) { + store.update { + it.copy(registrationErrorResult = result) + } + } + + data class RegisterLinkDeviceState( + val isRegistering: Boolean = false, + val qrState: QrState = QrState.Loading, + val provisionMessage: ProvisionMessage? = null, + val showProvisioningError: Boolean = false, + val registrationErrorResult: RegisterLinkDeviceResult? = null, + val currentSocketId: Int? = null + ) + + sealed interface QrState { + data object Loading : QrState + data class Loaded(val qrData: QrCodeData) : QrState + data object Failed : QrState + data object Scanned : QrState + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/welcome/WelcomeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/welcome/WelcomeFragment.kt index ad80756eb1..c49763df5a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/welcome/WelcomeFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/welcome/WelcomeFragment.kt @@ -11,8 +11,10 @@ import android.view.View import androidx.core.content.ContextCompat import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController +import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.signal.core.util.getSerializableCompat import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.BuildConfig import org.thoughtcrime.securesms.LoggingFragment import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.ViewBinderDelegate @@ -52,6 +54,17 @@ class WelcomeFragment : LoggingFragment(R.layout.fragment_registration_welcome_v binding.welcomeTransferOrRestore.setOnClickListener { onRestoreOrTransferClicked() } binding.welcomeTransferOrRestore.visible = !sharedViewModel.isReregister + if (BuildConfig.LINK_DEVICE_UX_ENABLED) { + binding.image.setOnLongClickListener { + MaterialAlertDialogBuilder(requireContext()) + .setMessage("Link device?") + .setPositiveButton("Link", { _, _ -> onLinkDeviceClicked() }) + .setNegativeButton(android.R.string.cancel, null) + .show() + true + } + } + childFragmentManager.setFragmentResultListener(RestoreWelcomeBottomSheet.REQUEST_KEY, viewLifecycleOwner) { requestKey, bundle -> if (requestKey == RestoreWelcomeBottomSheet.REQUEST_KEY) { when (val userSelection = bundle.getSerializableCompat(RestoreWelcomeBottomSheet.REQUEST_KEY, WelcomeUserSelection::class.java)) { @@ -69,6 +82,7 @@ class WelcomeFragment : LoggingFragment(R.layout.fragment_registration_welcome_v WelcomeUserSelection.RESTORE_WITH_OLD_PHONE, WelcomeUserSelection.RESTORE_WITH_NO_PHONE -> navigateToNextScreenViaRestore(userSelection) WelcomeUserSelection.CONTINUE -> navigateToNextScreenViaContinue() + WelcomeUserSelection.LINK -> navigateToLinkDevice() null -> Unit } } @@ -76,6 +90,18 @@ class WelcomeFragment : LoggingFragment(R.layout.fragment_registration_welcome_v } } + private fun onLinkDeviceClicked() { + if (Permissions.isRuntimePermissionsRequired() && !hasAllPermissions()) { + findNavController().safeNavigate(WelcomeFragmentDirections.actionWelcomeFragmentToGrantPermissionsFragment(WelcomeUserSelection.LINK)) + } else { + navigateToLinkDevice() + } + } + + private fun navigateToLinkDevice() { + findNavController().safeNavigate(WelcomeFragmentDirections.goToLinkViaQr()) + } + override fun onResume() { super.onResume() sharedViewModel.resetRestoreDecision() @@ -115,6 +141,7 @@ class WelcomeFragment : LoggingFragment(R.layout.fragment_registration_welcome_v sharedViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.PERMISSIONS_GRANTED) when (userSelection) { + WelcomeUserSelection.LINK, WelcomeUserSelection.CONTINUE -> throw IllegalArgumentException() WelcomeUserSelection.RESTORE_WITH_OLD_PHONE -> { sharedViewModel.intendToRestore(hasOldDevice = true, fromRemote = true) diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/welcome/WelcomeUserSelection.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/welcome/WelcomeUserSelection.kt index b20e310cc7..fcddd4bbac 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/welcome/WelcomeUserSelection.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/welcome/WelcomeUserSelection.kt @@ -9,5 +9,5 @@ package org.thoughtcrime.securesms.registrationv3.ui.welcome * User options available to start registration flow. */ enum class WelcomeUserSelection { - CONTINUE, RESTORE_WITH_OLD_PHONE, RESTORE_WITH_NO_PHONE + CONTINUE, RESTORE_WITH_OLD_PHONE, RESTORE_WITH_NO_PHONE, LINK } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt index 3a2b1ea303..20b4a82344 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt @@ -1049,7 +1049,7 @@ object RemoteConfig { hotSwappable = false, active = false ) { value -> - BuildConfig.MESSAGE_BACKUP_RESTORE_ENABLED || value.asBoolean(false) + BuildConfig.MESSAGE_BACKUP_RESTORE_ENABLED || BuildConfig.LINK_DEVICE_UX_ENABLED || value.asBoolean(false) } @JvmStatic diff --git a/app/src/main/res/navigation/registration_v3.xml b/app/src/main/res/navigation/registration_v3.xml index 9185dacd91..e1c35d940e 100644 --- a/app/src/main/res/navigation/registration_v3.xml +++ b/app/src/main/res/navigation/registration_v3.xml @@ -43,6 +43,14 @@ app:popEnterAnim="@anim/nav_default_pop_enter_anim" app:popExitAnim="@anim/nav_default_pop_exit_anim" /> + + + + +