Add developer only crude link device UX.

This commit is contained in:
Cody Henthorne
2025-08-01 14:49:11 -04:00
parent e29abdea91
commit f6ab408fc8
7 changed files with 540 additions and 2 deletions

View File

@@ -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") {

View File

@@ -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<RegistrationViewModel>()
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!"
)
}
}

View File

@@ -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<RegisterLinkDeviceState> = MutableStateFlow(RegisterLinkDeviceState())
val state: StateFlow<RegisterLinkDeviceState> = store
private var socketHandles: MutableList<Closeable> = 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<ProvisionMessage>(
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
}
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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

View File

@@ -43,6 +43,14 @@
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
<action
android:id="@+id/go_to_linkViaQr"
app:destination="@id/linkViaQr"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
</fragment>
<fragment
@@ -81,6 +89,11 @@
app:popUpToInclusive="true" />
</fragment>
<fragment
android:id="@+id/linkViaQr"
android:name="org.thoughtcrime.securesms.registrationv3.ui.link.RegisterLinkDeviceQrFragment">
</fragment>
<fragment
android:id="@+id/noBackupToRestore"
android:name="org.thoughtcrime.securesms.registrationv3.ui.restore.NoBackupToRestoreFragment">