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(CopyAttachmentToArchiveJob.KEY, new CopyAttachmentToArchiveJob.Factory());
put(CreateReleaseChannelJob.KEY, new CreateReleaseChannelJob.Factory()); put(CreateReleaseChannelJob.KEY, new CreateReleaseChannelJob.Factory());
put(DeleteAbandonedAttachmentsJob.KEY, new DeleteAbandonedAttachmentsJob.Factory()); put(DeleteAbandonedAttachmentsJob.KEY, new DeleteAbandonedAttachmentsJob.Factory());
put(DeviceNameChangeJob.KEY, new DeviceNameChangeJob.Factory());
put(DirectoryRefreshJob.KEY, new DirectoryRefreshJob.Factory()); put(DirectoryRefreshJob.KEY, new DirectoryRefreshJob.Factory());
put(DownloadLatestEmojiDataJob.KEY, new DownloadLatestEmojiDataJob.Factory()); put(DownloadLatestEmojiDataJob.KEY, new DownloadLatestEmojiDataJob.Factory());
put(EmojiSearchIndexDownloadJob.KEY, new EmojiSearchIndexDownloadJob.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.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row 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.Buttons
import org.signal.core.ui.Dialogs import org.signal.core.ui.Dialogs
import org.signal.core.ui.Dividers import org.signal.core.ui.Dividers
import org.signal.core.ui.DropdownMenus
import org.signal.core.ui.Previews import org.signal.core.ui.Previews
import org.signal.core.ui.Scaffolds import org.signal.core.ui.Scaffolds
import org.signal.core.ui.SignalPreview import org.signal.core.ui.SignalPreview
@ -146,6 +148,8 @@ class LinkDeviceFragment : ComposeFragment() {
navController.popBackStack() navController.popBackStack()
} }
} }
LinkDeviceSettingsState.OneTimeEvent.SnackbarNameChangeFailure -> Unit
LinkDeviceSettingsState.OneTimeEvent.SnackbarNameChangeSuccess -> Unit
} }
if (state.oneTimeEvent != LinkDeviceSettingsState.OneTimeEvent.None) { if (state.oneTimeEvent != LinkDeviceSettingsState.OneTimeEvent.None) {
@ -176,7 +180,11 @@ class LinkDeviceFragment : ComposeFragment() {
onDeviceSelectedForRemoval = { device -> viewModel.setDeviceToRemove(device) }, onDeviceSelectedForRemoval = { device -> viewModel.setDeviceToRemove(device) },
onDeviceRemovalConfirmed = { device -> viewModel.removeDevice(device) }, onDeviceRemovalConfirmed = { device -> viewModel.removeDevice(device) },
onSyncFailureRetryRequested = { deviceId -> viewModel.onSyncErrorRetryRequested(deviceId) }, 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 = {}, onDeviceSelectedForRemoval: (Device?) -> Unit = {},
onDeviceRemovalConfirmed: (Device) -> Unit = {}, onDeviceRemovalConfirmed: (Device) -> Unit = {},
onSyncFailureRetryRequested: (Int?) -> 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 a bottom sheet is showing, we don't want the spinner underneath
if (!state.bottomSheetVisible) { if (!state.bottomSheetVisible) {
@ -328,7 +337,7 @@ fun DeviceListScreen(
) )
} else { } else {
state.devices.forEach { device -> state.devices.forEach { device ->
DeviceRow(device, onDeviceSelectedForRemoval) DeviceRow(device, onDeviceSelectedForRemoval, onEditDevice)
} }
} }
} }
@ -372,16 +381,14 @@ fun DeviceListScreen(
} }
@Composable @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 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 linkedDate = DateUtils.getDayPrecisionTimeSpanString(LocalContext.current, Locale.getDefault(), device.createdMillis)
val lastActive = DateUtils.getDayPrecisionTimeSpanString(LocalContext.current, Locale.getDefault(), device.lastSeenMillis) val lastActive = DateUtils.getDayPrecisionTimeSpanString(LocalContext.current, Locale.getDefault(), device.lastSeenMillis)
val menuController = remember { DropdownMenus.MenuController() }
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { setDeviceToRemove(device) },
verticalAlignment = Alignment.CenterVertically
) { ) {
Image( Image(
painter = painterResource(id = R.drawable.symbol_devices_24), painter = painterResource(id = R.drawable.symbol_devices_24),
@ -395,13 +402,76 @@ fun DeviceRow(device: Device, setDeviceToRemove: (Device) -> Unit) {
color = MaterialTheme.colorScheme.surfaceVariant, color = MaterialTheme.colorScheme.surfaceVariant,
shape = CircleShape 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(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_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) 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.Stopwatch
import org.signal.core.util.isNotNullOrBlank import org.signal.core.util.isNotNullOrBlank
import org.signal.core.util.logging.Log import org.signal.core.util.logging.Log
import org.signal.core.util.logging.logI
import org.signal.core.util.logging.logW import org.signal.core.util.logging.logW
import org.signal.libsignal.protocol.InvalidKeyException import org.signal.libsignal.protocol.InvalidKeyException
import org.signal.libsignal.protocol.ecc.Curve 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.crypto.ProfileKeyUtil
import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.devicelist.protos.DeviceName import org.thoughtcrime.securesms.devicelist.protos.DeviceName
import org.thoughtcrime.securesms.jobs.DeviceNameChangeJob
import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob
import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.SignalNetwork import org.thoughtcrime.securesms.net.SignalNetwork
@ -29,6 +31,7 @@ import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.nio.charset.StandardCharsets
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@ -334,6 +337,28 @@ object LinkDeviceRepository {
return NetworkResult.NetworkError(IOException("Hit max retries!")) 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 { sealed interface LinkDeviceResult {
data object None : LinkDeviceResult data object None : LinkDeviceResult
data class Success(val token: String) : LinkDeviceResult data class Success(val token: String) : LinkDeviceResult
@ -350,4 +375,9 @@ object LinkDeviceRepository {
data class BadRequest(val exception: IOException) : LinkUploadArchiveResult data class BadRequest(val exception: IOException) : LinkUploadArchiveResult
data class NetworkError(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 linkDeviceResult: LinkDeviceResult = LinkDeviceResult.None,
val seenIntroSheet: Boolean = false, val seenIntroSheet: Boolean = false,
val seenEducationSheet: Boolean = false, val seenEducationSheet: Boolean = false,
val bottomSheetVisible: Boolean = false val bottomSheetVisible: Boolean = false,
val deviceToEdit: Device? = null
) { ) {
sealed interface DialogState { sealed interface DialogState {
data object None : DialogState data object None : DialogState
@ -34,6 +35,8 @@ data class LinkDeviceSettingsState(
data object ToastNetworkFailed : OneTimeEvent data object ToastNetworkFailed : OneTimeEvent
data class ToastUnlinked(val name: String) : OneTimeEvent data class ToastUnlinked(val name: String) : OneTimeEvent
data class ToastLinked(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 ShowFinishedSheet : OneTimeEvent
data object HideFinishedSheet : OneTimeEvent data object HideFinishedSheet : OneTimeEvent
data object LaunchQrCodeScanner : 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
)
}
}
}
} }

View file

@ -141,3 +141,7 @@ message UploadAttachmentToArchiveJobData {
message BackupMediaSnapshotSyncJobData { message BackupMediaSnapshotSyncJobData {
uint64 syncTime = 1; uint64 syncTime = 1;
} }
message DeviceNameChangeJobData {
uint32 deviceId = 1;
}

View file

@ -244,6 +244,13 @@
app:exitAnim="@anim/fragment_open_exit" app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter" app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" /> app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_linkDeviceFragment_to_editDeviceNameFragment"
app:destination="@id/editDeviceNameFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action <action
android:id="@+id/action_linkDeviceFragment_to_linkDeviceFinishedSheet" android:id="@+id/action_linkDeviceFragment_to_linkDeviceFinishedSheet"
app:destination="@id/linkDeviceFinishedSheet" /> app:destination="@id/linkDeviceFinishedSheet" />
@ -264,6 +271,10 @@
android:id="@+id/linkDeviceEducationSheet" android:id="@+id/linkDeviceEducationSheet"
android:name="org.thoughtcrime.securesms.linkdevice.LinkDeviceEducationSheet" /> android:name="org.thoughtcrime.securesms.linkdevice.LinkDeviceEducationSheet" />
<fragment
android:id="@+id/editDeviceNameFragment"
android:name="org.thoughtcrime.securesms.linkdevice.EditDeviceNameFragment" />
<fragment <fragment
android:id="@+id/addLinkDeviceFragment" android:id="@+id/addLinkDeviceFragment"
android:name="org.thoughtcrime.securesms.linkdevice.AddLinkDeviceFragment" android:name="org.thoughtcrime.securesms.linkdevice.AddLinkDeviceFragment"

View file

@ -1012,6 +1012,20 @@
<string name="LinkDeviceFragment__sync_failure_retry_button">Try linking again</string> <string name="LinkDeviceFragment__sync_failure_retry_button">Try linking again</string>
<!-- Text button of a button in a dialog that, when pressed, will ignore syncing errors and link a new device without syncing message content --> <!-- Text button of a button in a dialog that, when pressed, will ignore syncing errors and link a new device without syncing message content -->
<string name="LinkDeviceFragment__sync_failure_dismiss_button">Continue without transferring</string> <string name="LinkDeviceFragment__sync_failure_dismiss_button">Continue without transferring</string>
<!-- Option in context menu to edit the name of a linked device -->
<string name="LinkDeviceFragment__edit_name">Edit name</string>
<!-- EditDeviceNameFragment -->
<!-- App bar title when editing the name of a device -->
<string name="EditDeviceNameFragment__edit">Edit device name</string>
<!-- Text hint shown when entering in a new device name -->
<string name="EditDeviceNameFragment__device_name">Device name</string>
<!-- Button to save name change -->
<string name="EditDeviceNameFragment__save">Save</string>
<!-- Toast message shown when a device name was successfully changed -->
<string name="EditDeviceNameFragment__device_name_updated">Device name updated</string>
<!-- Toast message shown when a device name could not be changed and to try again later -->
<string name="EditDeviceNameFragment__unable_to_change">Unable to change device name. Try again later.</string>
<!-- AddLinkDeviceFragment --> <!-- AddLinkDeviceFragment -->
<!-- Description text shown on the QR code scanner when linking a device --> <!-- Description text shown on the QR code scanner when linking a device -->

View file

@ -757,6 +757,8 @@ public class SignalServiceMessageSender {
content = createCallLinkUpdateContent(message.getCallLinkUpdate().get()); content = createCallLinkUpdateContent(message.getCallLinkUpdate().get());
} else if (message.getCallLogEvent().isPresent()) { } else if (message.getCallLogEvent().isPresent()) {
content = createCallLogEventContent(message.getCallLogEvent().get()); content = createCallLogEventContent(message.getCallLogEvent().get());
} else if (message.getDeviceNameChange().isPresent()) {
content = createDeviceNameChangeContent(message.getDeviceNameChange().get());
} else { } else {
throw new IOException("Unsupported sync message!"); throw new IOException("Unsupported sync message!");
} }
@ -1729,6 +1731,13 @@ public class SignalServiceMessageSender {
return container.syncMessage(builder.build()).build(); return container.syncMessage(builder.build()).build();
} }
private Content createDeviceNameChangeContent(SyncMessage.DeviceNameChange proto) {
Content.Builder container = new Content.Builder();
SyncMessage.Builder builder = createSyncMessageBuilder().deviceNameChange(proto);
return container.syncMessage(builder.build()).build();
}
private SyncMessage.Builder createSyncMessageBuilder() { private SyncMessage.Builder createSyncMessageBuilder() {
byte[] padding = Util.getRandomLengthSecretBytes(512); byte[] padding = Util.getRandomLengthSecretBytes(512);

View file

@ -131,4 +131,19 @@ class LinkDeviceApi(private val pushServiceSocket: PushServiceSocket) {
) )
} }
} }
/**
* Sets the name for a linked device
*
* PUT /v1/accounts/name
*
* - 204: Success.
* - 403: Not authorized to change the name of the device with the given ID
* - 404: No device found with the given ID
*/
fun setDeviceName(encryptedDeviceName: String, deviceId: Int): NetworkResult<Unit> {
return NetworkResult.fromFetch {
pushServiceSocket.setDeviceName(deviceId, SetDeviceNameRequest(encryptedDeviceName))
}
}
} }

View file

@ -0,0 +1,15 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.link
import com.fasterxml.jackson.annotation.JsonProperty
/**
* Request body for setting the name of a linked device.
*/
data class SetDeviceNameRequest(
@JsonProperty val deviceName: String
)

View file

@ -6,6 +6,7 @@
package org.whispersystems.signalservice.api.messages.multidevice; package org.whispersystems.signalservice.api.messages.multidevice;
import org.whispersystems.signalservice.internal.push.SyncMessage.DeviceNameChange;
import org.whispersystems.signalservice.internal.push.SyncMessage.CallEvent; import org.whispersystems.signalservice.internal.push.SyncMessage.CallEvent;
import org.whispersystems.signalservice.internal.push.SyncMessage.CallLinkUpdate; import org.whispersystems.signalservice.internal.push.SyncMessage.CallLinkUpdate;
import org.whispersystems.signalservice.internal.push.SyncMessage.CallLogEvent; import org.whispersystems.signalservice.internal.push.SyncMessage.CallLogEvent;
@ -35,6 +36,7 @@ public class SignalServiceSyncMessage {
private final Optional<CallEvent> callEvent; private final Optional<CallEvent> callEvent;
private final Optional<CallLinkUpdate> callLinkUpdate; private final Optional<CallLinkUpdate> callLinkUpdate;
private final Optional<CallLogEvent> callLogEvent; private final Optional<CallLogEvent> callLogEvent;
private final Optional<DeviceNameChange> deviceNameChange;
private SignalServiceSyncMessage(Optional<SentTranscriptMessage> sent, private SignalServiceSyncMessage(Optional<SentTranscriptMessage> sent,
Optional<ContactsMessage> contacts, Optional<ContactsMessage> contacts,
@ -52,7 +54,8 @@ public class SignalServiceSyncMessage {
Optional<List<ViewedMessage>> views, Optional<List<ViewedMessage>> views,
Optional<CallEvent> callEvent, Optional<CallEvent> callEvent,
Optional<CallLinkUpdate> callLinkUpdate, Optional<CallLinkUpdate> callLinkUpdate,
Optional<CallLogEvent> callLogEvent) Optional<CallLogEvent> callLogEvent,
Optional<DeviceNameChange> deviceNameChange)
{ {
this.sent = sent; this.sent = sent;
this.contacts = contacts; this.contacts = contacts;
@ -71,6 +74,7 @@ public class SignalServiceSyncMessage {
this.callEvent = callEvent; this.callEvent = callEvent;
this.callLinkUpdate = callLinkUpdate; this.callLinkUpdate = callLinkUpdate;
this.callLogEvent = callLogEvent; this.callLogEvent = callLogEvent;
this.deviceNameChange = deviceNameChange;
} }
public static SignalServiceSyncMessage forSentTranscript(SentTranscriptMessage sent) { public static SignalServiceSyncMessage forSentTranscript(SentTranscriptMessage sent) {
@ -90,6 +94,7 @@ public class SignalServiceSyncMessage {
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.empty(),
Optional.empty()); Optional.empty());
} }
@ -110,6 +115,7 @@ public class SignalServiceSyncMessage {
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.empty(),
Optional.empty()); Optional.empty());
} }
@ -130,6 +136,7 @@ public class SignalServiceSyncMessage {
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.empty(),
Optional.empty()); Optional.empty());
} }
@ -150,6 +157,7 @@ public class SignalServiceSyncMessage {
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.empty(),
Optional.empty()); Optional.empty());
} }
@ -170,6 +178,7 @@ public class SignalServiceSyncMessage {
Optional.of(views), Optional.of(views),
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.empty(),
Optional.empty()); Optional.empty());
} }
@ -190,6 +199,7 @@ public class SignalServiceSyncMessage {
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.empty(),
Optional.empty()); Optional.empty());
} }
@ -213,6 +223,7 @@ public class SignalServiceSyncMessage {
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.empty(),
Optional.empty()); Optional.empty());
} }
@ -233,6 +244,7 @@ public class SignalServiceSyncMessage {
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.empty(),
Optional.empty()); Optional.empty());
} }
@ -253,6 +265,7 @@ public class SignalServiceSyncMessage {
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.empty(),
Optional.empty()); Optional.empty());
} }
@ -273,6 +286,7 @@ public class SignalServiceSyncMessage {
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.empty(),
Optional.empty()); Optional.empty());
} }
@ -293,6 +307,7 @@ public class SignalServiceSyncMessage {
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.empty(),
Optional.empty()); Optional.empty());
} }
@ -313,6 +328,7 @@ public class SignalServiceSyncMessage {
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.empty(),
Optional.empty()); Optional.empty());
} }
@ -333,6 +349,7 @@ public class SignalServiceSyncMessage {
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.empty(),
Optional.empty()); Optional.empty());
} }
@ -353,6 +370,7 @@ public class SignalServiceSyncMessage {
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.empty(),
Optional.empty()); Optional.empty());
} }
@ -373,6 +391,7 @@ public class SignalServiceSyncMessage {
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.empty(),
Optional.empty()); Optional.empty());
} }
@ -393,6 +412,7 @@ public class SignalServiceSyncMessage {
Optional.empty(), Optional.empty(),
Optional.of(callEvent), Optional.of(callEvent),
Optional.empty(), Optional.empty(),
Optional.empty(),
Optional.empty()); Optional.empty());
} }
@ -413,6 +433,7 @@ public class SignalServiceSyncMessage {
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.of(callLinkUpdate), Optional.of(callLinkUpdate),
Optional.empty(),
Optional.empty()); Optional.empty());
} }
@ -433,7 +454,29 @@ public class SignalServiceSyncMessage {
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.of(callLogEvent)); Optional.of(callLogEvent),
Optional.empty());
}
public static SignalServiceSyncMessage forDeviceNameChange(@Nonnull DeviceNameChange deviceNameChange) {
return new SignalServiceSyncMessage(Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.of(deviceNameChange));
} }
public static SignalServiceSyncMessage empty() { public static SignalServiceSyncMessage empty() {
@ -453,6 +496,7 @@ public class SignalServiceSyncMessage {
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.empty(),
Optional.empty()); Optional.empty());
} }
@ -524,6 +568,10 @@ public class SignalServiceSyncMessage {
return callLogEvent; return callLogEvent;
} }
public Optional<DeviceNameChange> getDeviceNameChange() {
return deviceNameChange;
}
public enum FetchType { public enum FetchType {
LOCAL_PROFILE, LOCAL_PROFILE,
STORAGE_MANIFEST, STORAGE_MANIFEST,

View file

@ -66,6 +66,7 @@ import org.whispersystems.signalservice.api.crypto.SealedSenderAccess;
import org.whispersystems.signalservice.api.groupsv2.CredentialResponse; import org.whispersystems.signalservice.api.groupsv2.CredentialResponse;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString; import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString;
import org.whispersystems.signalservice.api.link.LinkedDeviceVerificationCodeResponse; import org.whispersystems.signalservice.api.link.LinkedDeviceVerificationCodeResponse;
import org.whispersystems.signalservice.api.link.SetDeviceNameRequest;
import org.whispersystems.signalservice.api.link.SetLinkedDeviceTransferArchiveRequest; import org.whispersystems.signalservice.api.link.SetLinkedDeviceTransferArchiveRequest;
import org.whispersystems.signalservice.api.link.WaitForLinkedDeviceResponse; import org.whispersystems.signalservice.api.link.WaitForLinkedDeviceResponse;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener; import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener;
@ -239,6 +240,7 @@ public class PushServiceSocket {
private static final String USERNAME_LINK_PATH = "/v1/accounts/username_link"; private static final String USERNAME_LINK_PATH = "/v1/accounts/username_link";
private static final String USERNAME_FROM_LINK_PATH = "/v1/accounts/username_link/%s"; private static final String USERNAME_FROM_LINK_PATH = "/v1/accounts/username_link/%s";
private static final String DELETE_ACCOUNT_PATH = "/v1/accounts/me"; private static final String DELETE_ACCOUNT_PATH = "/v1/accounts/me";
private static final String SET_DEVICE_NAME_PATH = "/v1/accounts/name?deviceId=%s";
private static final String CHANGE_NUMBER_PATH = "/v2/accounts/number"; private static final String CHANGE_NUMBER_PATH = "/v2/accounts/number";
private static final String IDENTIFIER_REGISTERED_PATH = "/v1/accounts/account/%s"; private static final String IDENTIFIER_REGISTERED_PATH = "/v1/accounts/account/%s";
private static final String REQUEST_ACCOUNT_DATA_PATH = "/v2/accounts/data_report"; private static final String REQUEST_ACCOUNT_DATA_PATH = "/v2/accounts/data_report";
@ -1380,6 +1382,11 @@ public class PushServiceSocket {
makeServiceRequest(DELETE_ACCOUNT_PATH, "DELETE", null); makeServiceRequest(DELETE_ACCOUNT_PATH, "DELETE", null);
} }
public void setDeviceName(int deviceId, @Nonnull SetDeviceNameRequest request) throws IOException {
String body = JsonUtil.toJson(request);
makeServiceRequest(String.format(Locale.US, SET_DEVICE_NAME_PATH, deviceId), "PUT", body);
}
public void requestRateLimitPushChallenge() throws IOException { public void requestRateLimitPushChallenge() throws IOException {
makeServiceRequest(REQUEST_RATE_LIMIT_PUSH_CHALLENGE, "POST", ""); makeServiceRequest(REQUEST_RATE_LIMIT_PUSH_CHALLENGE, "POST", "");
} }

View file

@ -707,6 +707,11 @@ message SyncMessage {
repeated AttachmentDelete attachmentDeletes = 4; repeated AttachmentDelete attachmentDeletes = 4;
} }
message DeviceNameChange {
reserved /*name*/ 1;
optional uint32 deviceId = 2;
}
optional Sent sent = 1; optional Sent sent = 1;
optional Contacts contacts = 2; optional Contacts contacts = 2;
reserved /*groups*/ 3; reserved /*groups*/ 3;
@ -729,6 +734,7 @@ message SyncMessage {
optional CallLinkUpdate callLinkUpdate = 20; optional CallLinkUpdate callLinkUpdate = 20;
optional CallLogEvent callLogEvent = 21; optional CallLogEvent callLogEvent = 21;
optional DeleteForMe deleteForMe = 22; optional DeleteForMe deleteForMe = 22;
optional DeviceNameChange deviceNameChange = 23;
} }
message AttachmentPointer { message AttachmentPointer {