Allow renaming of linked device.

This commit is contained in:
Michelle Tang
2024-11-25 13:45:20 -08:00
committed by Greyson Parrelli
parent ce69c5f7da
commit 3e699a132b
16 changed files with 500 additions and 12 deletions

View File

@@ -0,0 +1,77 @@
package org.thoughtcrime.securesms.jobs
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.jobs.protos.DeviceNameChangeJobData
import org.thoughtcrime.securesms.recipients.Recipient
import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException
import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage
import org.whispersystems.signalservice.internal.push.SyncMessage
import java.io.IOException
import java.util.concurrent.TimeUnit
/**
* Sends a sync message that a linked device has changed its name
*/
class DeviceNameChangeJob private constructor(
private val data: DeviceNameChangeJobData,
parameters: Parameters
) : Job(parameters) {
companion object {
const val KEY: String = "DeviceNameChangeJob"
private val TAG = Log.tag(DeviceNameChangeJob::class.java)
}
constructor(
deviceId: Int
) : this(
DeviceNameChangeJobData(deviceId),
Parameters.Builder()
.addConstraint(NetworkConstraint.KEY)
.setQueue("DeviceNameChangeJob")
.setLifespan(TimeUnit.DAYS.toMillis(1))
.setMaxAttempts(Parameters.UNLIMITED)
.build()
)
override fun serialize(): ByteArray {
return data.encode()
}
override fun getFactoryKey(): String = KEY
override fun run(): Result {
if (!Recipient.self().isRegistered) {
Log.w(TAG, "Not registered")
return Result.failure()
}
return try {
val result = AppDependencies.signalServiceMessageSender.sendSyncMessage(
SignalServiceSyncMessage.forDeviceNameChange(SyncMessage.DeviceNameChange(data.deviceId))
)
if (result.isSuccess) {
Result.success()
} else {
Log.w(TAG, "Unable to send device name sync - trying later")
Result.retry(defaultBackoff())
}
} catch (e: IOException) {
Log.w(TAG, "Unable to send device name sync - trying later", e)
Result.retry(defaultBackoff())
} catch (e: UntrustedIdentityException) {
Log.w(TAG, "Unable to send device name sync", e)
Result.failure()
}
}
override fun onFailure() = Unit
class Factory : Job.Factory<DeviceNameChangeJob?> {
override fun create(parameters: Parameters, serializedData: ByteArray?): DeviceNameChangeJob {
return DeviceNameChangeJob(DeviceNameChangeJobData.ADAPTER.decode(serializedData!!), parameters)
}
}
}

View File

@@ -142,6 +142,7 @@ public final class JobManagerFactories {
put(CopyAttachmentToArchiveJob.KEY, new CopyAttachmentToArchiveJob.Factory());
put(CreateReleaseChannelJob.KEY, new CreateReleaseChannelJob.Factory());
put(DeleteAbandonedAttachmentsJob.KEY, new DeleteAbandonedAttachmentsJob.Factory());
put(DeviceNameChangeJob.KEY, new DeviceNameChangeJob.Factory());
put(DirectoryRefreshJob.KEY, new DirectoryRefreshJob.Factory());
put(DownloadLatestEmojiDataJob.KEY, new DownloadLatestEmojiDataJob.Factory());
put(EmojiSearchIndexDownloadJob.KEY, new EmojiSearchIndexDownloadJob.Factory());

View File

@@ -0,0 +1,153 @@
package org.thoughtcrime.securesms.linkdevice
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import com.google.android.material.snackbar.Snackbar
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.SignalPreview
import org.signal.core.util.isNotNullOrBlank
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeFragment
/**
* Fragment for changing the name of a linked device
*/
class EditDeviceNameFragment : ComposeFragment() {
companion object {
private val TAG = Log.tag(EditDeviceNameFragment::class)
const val MAX_LENGTH = 50
}
private val viewModel: LinkDeviceViewModel by activityViewModels()
@Composable
override fun FragmentContent() {
val state by viewModel.state.collectAsStateWithLifecycle()
val navController: NavController by remember { mutableStateOf(findNavController()) }
val context = LocalContext.current
LaunchedEffect(state.oneTimeEvent) {
when (state.oneTimeEvent) {
LinkDeviceSettingsState.OneTimeEvent.SnackbarNameChangeSuccess -> {
Snackbar.make(requireView(), context.getString(R.string.EditDeviceNameFragment__device_name_updated), Snackbar.LENGTH_LONG).show()
navController.popBackStack()
}
LinkDeviceSettingsState.OneTimeEvent.SnackbarNameChangeFailure -> {
Snackbar.make(requireView(), context.getString(R.string.EditDeviceNameFragment__unable_to_change), Snackbar.LENGTH_LONG).show()
}
LinkDeviceSettingsState.OneTimeEvent.HideFinishedSheet -> Unit
LinkDeviceSettingsState.OneTimeEvent.LaunchQrCodeScanner -> Unit
LinkDeviceSettingsState.OneTimeEvent.None -> Unit
LinkDeviceSettingsState.OneTimeEvent.ShowFinishedSheet -> Unit
is LinkDeviceSettingsState.OneTimeEvent.ToastLinked -> Unit
LinkDeviceSettingsState.OneTimeEvent.ToastNetworkFailed -> Unit
is LinkDeviceSettingsState.OneTimeEvent.ToastUnlinked -> Unit
}
}
Scaffolds.Settings(
title = stringResource(id = R.string.EditDeviceNameFragment__edit),
onNavigationClick = { navController.popBackStack() },
navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24),
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
) { contentPadding: PaddingValues ->
EditNameScreen(
state = state,
modifier = Modifier.padding(contentPadding),
onSave = { viewModel.saveName(it) }
)
}
}
}
@Composable
private fun EditNameScreen(
state: LinkDeviceSettingsState,
modifier: Modifier = Modifier,
onSave: (String) -> Unit = {}
) {
val focusRequester = remember { FocusRequester() }
val name = state.deviceToEdit!!.name ?: ""
var deviceName by remember { mutableStateOf(TextFieldValue(name, TextRange(name.length))) }
Box(
modifier = modifier.fillMaxHeight()
) {
TextField(
value = deviceName,
label = { Text(text = stringResource(id = R.string.EditDeviceNameFragment__device_name)) },
onValueChange = {
deviceName = it.copy(
text = it.text.substring(0, minOf(it.text.length, EditDeviceNameFragment.MAX_LENGTH))
)
},
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant
),
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.padding(top = 16.dp, bottom = 12.dp, start = 20.dp, end = 28.dp)
)
Buttons.MediumTonal(
enabled = deviceName.text.isNotNullOrBlank() && (deviceName.text != name),
onClick = { onSave(deviceName.text) },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 24.dp, bottom = 16.dp)
) {
Text(text = stringResource(R.string.EditDeviceNameFragment__save))
}
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
@SignalPreview
@Composable
private fun DeviceListScreenLinkingPreview() {
Previews.Preview {
EditNameScreen(
state = LinkDeviceSettingsState(
deviceToEdit = Device(1, "Laptop", 0, 0)
)
)
}
}

View File

@@ -9,6 +9,7 @@ import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@@ -59,6 +60,7 @@ import androidx.navigation.fragment.findNavController
import org.signal.core.ui.Buttons
import org.signal.core.ui.Dialogs
import org.signal.core.ui.Dividers
import org.signal.core.ui.DropdownMenus
import org.signal.core.ui.Previews
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.SignalPreview
@@ -146,6 +148,8 @@ class LinkDeviceFragment : ComposeFragment() {
navController.popBackStack()
}
}
LinkDeviceSettingsState.OneTimeEvent.SnackbarNameChangeFailure -> Unit
LinkDeviceSettingsState.OneTimeEvent.SnackbarNameChangeSuccess -> Unit
}
if (state.oneTimeEvent != LinkDeviceSettingsState.OneTimeEvent.None) {
@@ -176,7 +180,11 @@ class LinkDeviceFragment : ComposeFragment() {
onDeviceSelectedForRemoval = { device -> viewModel.setDeviceToRemove(device) },
onDeviceRemovalConfirmed = { device -> viewModel.removeDevice(device) },
onSyncFailureRetryRequested = { deviceId -> viewModel.onSyncErrorRetryRequested(deviceId) },
onSyncFailureIgnored = { viewModel.onSyncErrorIgnored() }
onSyncFailureIgnored = { viewModel.onSyncErrorIgnored() },
onEditDevice = { device ->
viewModel.setDeviceToEdit(device)
navController.safeNavigate(R.id.action_linkDeviceFragment_to_editDeviceNameFragment)
}
)
}
}
@@ -221,7 +229,8 @@ fun DeviceListScreen(
onDeviceSelectedForRemoval: (Device?) -> Unit = {},
onDeviceRemovalConfirmed: (Device) -> Unit = {},
onSyncFailureRetryRequested: (Int?) -> Unit = {},
onSyncFailureIgnored: () -> Unit = {}
onSyncFailureIgnored: () -> Unit = {},
onEditDevice: (Device) -> Unit = {}
) {
// If a bottom sheet is showing, we don't want the spinner underneath
if (!state.bottomSheetVisible) {
@@ -328,7 +337,7 @@ fun DeviceListScreen(
)
} else {
state.devices.forEach { device ->
DeviceRow(device, onDeviceSelectedForRemoval)
DeviceRow(device, onDeviceSelectedForRemoval, onEditDevice)
}
}
}
@@ -372,16 +381,14 @@ fun DeviceListScreen(
}
@Composable
fun DeviceRow(device: Device, setDeviceToRemove: (Device) -> Unit) {
fun DeviceRow(device: Device, setDeviceToRemove: (Device) -> Unit, onEditDevice: (Device) -> Unit) {
val titleString = if (device.name.isNullOrEmpty()) stringResource(R.string.DeviceListItem_unnamed_device) else device.name
val linkedDate = DateUtils.getDayPrecisionTimeSpanString(LocalContext.current, Locale.getDefault(), device.createdMillis)
val lastActive = DateUtils.getDayPrecisionTimeSpanString(LocalContext.current, Locale.getDefault(), device.lastSeenMillis)
val menuController = remember { DropdownMenus.MenuController() }
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { setDeviceToRemove(device) },
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(id = R.drawable.symbol_devices_24),
@@ -395,13 +402,76 @@ fun DeviceRow(device: Device, setDeviceToRemove: (Device) -> Unit) {
color = MaterialTheme.colorScheme.surfaceVariant,
shape = CircleShape
)
.align(Alignment.CenterVertically)
)
Spacer(modifier = Modifier.size(16.dp))
Column {
Column(
modifier = Modifier.align(Alignment.CenterVertically).padding(start = 16.dp).weight(1f)
) {
Text(text = titleString, style = MaterialTheme.typography.bodyLarge)
Text(stringResource(R.string.DeviceListItem_linked_s, linkedDate), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(stringResource(R.string.DeviceListItem_last_active_s, lastActive), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Box {
Icon(
painterResource(id = R.drawable.symbol_more_vertical),
contentDescription = null,
modifier = Modifier.padding(top = 16.dp, end = 16.dp).clickable { menuController.show() }
)
DropdownMenus.Menu(controller = menuController, offsetX = 16.dp, offsetY = 4.dp) { controller ->
DropdownMenus.Item(
contentPadding = PaddingValues(0.dp),
text = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Icon(
painter = painterResource(id = R.drawable.symbol_link_slash_16),
contentDescription = null,
modifier = Modifier.size(24.dp)
)
Text(
text = stringResource(R.string.LinkDeviceFragment__unlink),
modifier = Modifier.padding(horizontal = 16.dp),
style = MaterialTheme.typography.bodyLarge
)
}
},
onClick = {
setDeviceToRemove(device)
controller.hide()
}
)
DropdownMenus.Item(
contentPadding = PaddingValues(0.dp),
text = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Icon(
painter = painterResource(id = R.drawable.symbol_edit_24),
contentDescription = null,
modifier = Modifier.size(24.dp)
)
Text(
text = stringResource(R.string.LinkDeviceFragment__edit_name),
modifier = Modifier.padding(horizontal = 16.dp),
style = MaterialTheme.typography.bodyLarge
)
}
},
onClick = {
onEditDevice(device)
controller.hide()
}
)
}
}
}
}

View File

@@ -5,6 +5,7 @@ import org.signal.core.util.Base64
import org.signal.core.util.Stopwatch
import org.signal.core.util.isNotNullOrBlank
import org.signal.core.util.logging.Log
import org.signal.core.util.logging.logI
import org.signal.core.util.logging.logW
import org.signal.libsignal.protocol.InvalidKeyException
import org.signal.libsignal.protocol.ecc.Curve
@@ -13,6 +14,7 @@ import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.devicelist.protos.DeviceName
import org.thoughtcrime.securesms.jobs.DeviceNameChangeJob
import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.SignalNetwork
@@ -29,6 +31,7 @@ import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.nio.charset.StandardCharsets
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
@@ -334,6 +337,28 @@ object LinkDeviceRepository {
return NetworkResult.NetworkError(IOException("Hit max retries!"))
}
/**
* Changes the name of a linked device and sends a sync message if successful
*/
fun changeDeviceName(deviceName: String, deviceId: Int): DeviceNameChangeResult {
val encryptedDeviceName = Base64.encodeWithoutPadding(DeviceNameCipher.encryptDeviceName(deviceName.toByteArray(StandardCharsets.UTF_8), SignalStore.account.aciIdentityKey))
return when (val result = SignalNetwork.linkDevice.setDeviceName(encryptedDeviceName, deviceId)) {
is NetworkResult.Success -> {
AppDependencies.jobManager.add(DeviceNameChangeJob(deviceId))
DeviceNameChangeResult.Success.logI(TAG, "Successfully changed device name")
}
is NetworkResult.NetworkError -> {
DeviceNameChangeResult.NetworkError(result.exception).logW(TAG, "Could not change name due to network error.", result.exception)
}
is NetworkResult.StatusCodeError -> {
DeviceNameChangeResult.NetworkError(result.exception).logW(TAG, "Could not change name due to status code error ${result.code}")
}
is NetworkResult.ApplicationError -> {
throw result.throwable.logW(TAG, "Could not change name due to application error.")
}
}
}
sealed interface LinkDeviceResult {
data object None : LinkDeviceResult
data class Success(val token: String) : LinkDeviceResult
@@ -350,4 +375,9 @@ object LinkDeviceRepository {
data class BadRequest(val exception: IOException) : LinkUploadArchiveResult
data class NetworkError(val exception: IOException) : LinkUploadArchiveResult
}
sealed interface DeviceNameChangeResult {
data object Success : DeviceNameChangeResult
data class NetworkError(val exception: IOException) : DeviceNameChangeResult
}
}

View File

@@ -18,7 +18,8 @@ data class LinkDeviceSettingsState(
val linkDeviceResult: LinkDeviceResult = LinkDeviceResult.None,
val seenIntroSheet: Boolean = false,
val seenEducationSheet: Boolean = false,
val bottomSheetVisible: Boolean = false
val bottomSheetVisible: Boolean = false,
val deviceToEdit: Device? = null
) {
sealed interface DialogState {
data object None : DialogState
@@ -34,6 +35,8 @@ data class LinkDeviceSettingsState(
data object ToastNetworkFailed : OneTimeEvent
data class ToastUnlinked(val name: String) : OneTimeEvent
data class ToastLinked(val name: String) : OneTimeEvent
data object SnackbarNameChangeSuccess : OneTimeEvent
data object SnackbarNameChangeFailure : OneTimeEvent
data object ShowFinishedSheet : OneTimeEvent
data object HideFinishedSheet : OneTimeEvent
data object LaunchQrCodeScanner : OneTimeEvent

View File

@@ -323,4 +323,29 @@ class LinkDeviceViewModel : ViewModel() {
)
}
}
fun setDeviceToEdit(device: Device) {
_state.update {
it.copy(
deviceToEdit = device
)
}
}
fun saveName(name: String) {
viewModelScope.launch(Dispatchers.IO) {
val device = _state.value.deviceToEdit!!
val result = LinkDeviceRepository.changeDeviceName(name, device.id)
val event = when (result) {
LinkDeviceRepository.DeviceNameChangeResult.Success -> OneTimeEvent.SnackbarNameChangeSuccess
is LinkDeviceRepository.DeviceNameChangeResult.NetworkError -> OneTimeEvent.SnackbarNameChangeFailure
}
_state.update {
it.copy(
oneTimeEvent = event
)
}
}
}
}