diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/DeviceNameChangeJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/DeviceNameChangeJob.kt new file mode 100644 index 0000000000..301473e815 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/DeviceNameChangeJob.kt @@ -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 { + override fun create(parameters: Parameters, serializedData: ByteArray?): DeviceNameChangeJob { + return DeviceNameChangeJob(DeviceNameChangeJobData.ADAPTER.decode(serializedData!!), parameters) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index b5c7eb616d..b6f615e84f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -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()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/EditDeviceNameFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/EditDeviceNameFragment.kt new file mode 100644 index 0000000000..7bae8b4053 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/EditDeviceNameFragment.kt @@ -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) + ) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceFragment.kt index 75dc61a952..6876518e13 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceFragment.kt @@ -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() + } + ) + } + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepository.kt index d2dee55fed..5aa62a84a0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepository.kt @@ -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 + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceSettingsState.kt index c97795cf38..d0a79c6a9b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceSettingsState.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceViewModel.kt index 6937d83afd..1b0905b790 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceViewModel.kt @@ -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 + ) + } + } + } } diff --git a/app/src/main/protowire/JobData.proto b/app/src/main/protowire/JobData.proto index bbd56caa5a..3ec13620c8 100644 --- a/app/src/main/protowire/JobData.proto +++ b/app/src/main/protowire/JobData.proto @@ -141,3 +141,7 @@ message UploadAttachmentToArchiveJobData { message BackupMediaSnapshotSyncJobData { uint64 syncTime = 1; } + +message DeviceNameChangeJobData { + uint32 deviceId = 1; +} diff --git a/app/src/main/res/navigation/app_settings_with_change_number.xml b/app/src/main/res/navigation/app_settings_with_change_number.xml index 5a4ebfbe74..53480bf079 100644 --- a/app/src/main/res/navigation/app_settings_with_change_number.xml +++ b/app/src/main/res/navigation/app_settings_with_change_number.xml @@ -244,6 +244,13 @@ app:exitAnim="@anim/fragment_open_exit" app:popEnterAnim="@anim/fragment_close_enter" app:popExitAnim="@anim/fragment_close_exit" /> + @@ -264,6 +271,10 @@ android:id="@+id/linkDeviceEducationSheet" android:name="org.thoughtcrime.securesms.linkdevice.LinkDeviceEducationSheet" /> + + Try linking again Continue without transferring + + Edit name + + + + Edit device name + + Device name + + Save + + Device name updated + + Unable to change device name. Try again later. diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java index bc04bc50be..12eed31f9b 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java @@ -757,6 +757,8 @@ public class SignalServiceMessageSender { content = createCallLinkUpdateContent(message.getCallLinkUpdate().get()); } else if (message.getCallLogEvent().isPresent()) { content = createCallLogEventContent(message.getCallLogEvent().get()); + } else if (message.getDeviceNameChange().isPresent()) { + content = createDeviceNameChangeContent(message.getDeviceNameChange().get()); } else { throw new IOException("Unsupported sync message!"); } @@ -1729,6 +1731,13 @@ public class SignalServiceMessageSender { 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() { byte[] padding = Util.getRandomLengthSecretBytes(512); diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/LinkDeviceApi.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/LinkDeviceApi.kt index 2460c3b3d3..ebb0e58278 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/LinkDeviceApi.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/LinkDeviceApi.kt @@ -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 { + return NetworkResult.fromFetch { + pushServiceSocket.setDeviceName(deviceId, SetDeviceNameRequest(encryptedDeviceName)) + } + } } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/SetDeviceNameRequest.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/SetDeviceNameRequest.kt new file mode 100644 index 0000000000..9723284d76 --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/SetDeviceNameRequest.kt @@ -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 +) diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/SignalServiceSyncMessage.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/SignalServiceSyncMessage.java index 4555b6a024..f54b3a13bc 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/SignalServiceSyncMessage.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/SignalServiceSyncMessage.java @@ -6,6 +6,7 @@ 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.CallLinkUpdate; import org.whispersystems.signalservice.internal.push.SyncMessage.CallLogEvent; @@ -35,6 +36,7 @@ public class SignalServiceSyncMessage { private final Optional callEvent; private final Optional callLinkUpdate; private final Optional callLogEvent; + private final Optional deviceNameChange; private SignalServiceSyncMessage(Optional sent, Optional contacts, @@ -52,7 +54,8 @@ public class SignalServiceSyncMessage { Optional> views, Optional callEvent, Optional callLinkUpdate, - Optional callLogEvent) + Optional callLogEvent, + Optional deviceNameChange) { this.sent = sent; this.contacts = contacts; @@ -71,6 +74,7 @@ public class SignalServiceSyncMessage { this.callEvent = callEvent; this.callLinkUpdate = callLinkUpdate; this.callLogEvent = callLogEvent; + this.deviceNameChange = deviceNameChange; } public static SignalServiceSyncMessage forSentTranscript(SentTranscriptMessage sent) { @@ -90,6 +94,7 @@ public class SignalServiceSyncMessage { 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()); } @@ -130,6 +136,7 @@ public class SignalServiceSyncMessage { 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()); } @@ -170,6 +178,7 @@ public class SignalServiceSyncMessage { Optional.of(views), 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()); } @@ -213,6 +223,7 @@ public class SignalServiceSyncMessage { 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()); } @@ -253,6 +265,7 @@ public class SignalServiceSyncMessage { 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()); } @@ -293,6 +307,7 @@ public class SignalServiceSyncMessage { 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()); } @@ -333,6 +349,7 @@ public class SignalServiceSyncMessage { 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()); } @@ -373,6 +391,7 @@ public class SignalServiceSyncMessage { Optional.empty(), Optional.empty(), Optional.empty(), + Optional.empty(), Optional.empty()); } @@ -393,6 +412,7 @@ public class SignalServiceSyncMessage { Optional.empty(), Optional.of(callEvent), Optional.empty(), + Optional.empty(), Optional.empty()); } @@ -413,6 +433,7 @@ public class SignalServiceSyncMessage { Optional.empty(), Optional.empty(), Optional.of(callLinkUpdate), + Optional.empty(), Optional.empty()); } @@ -433,7 +454,29 @@ public class SignalServiceSyncMessage { 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() { @@ -453,6 +496,7 @@ public class SignalServiceSyncMessage { Optional.empty(), Optional.empty(), Optional.empty(), + Optional.empty(), Optional.empty()); } @@ -524,6 +568,10 @@ public class SignalServiceSyncMessage { return callLogEvent; } + public Optional getDeviceNameChange() { + return deviceNameChange; + } + public enum FetchType { LOCAL_PROFILE, STORAGE_MANIFEST, diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java index 330deffa96..4bcddc21c8 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java @@ -66,6 +66,7 @@ import org.whispersystems.signalservice.api.crypto.SealedSenderAccess; import org.whispersystems.signalservice.api.groupsv2.CredentialResponse; import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString; 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.WaitForLinkedDeviceResponse; 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_FROM_LINK_PATH = "/v1/accounts/username_link/%s"; 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 IDENTIFIER_REGISTERED_PATH = "/v1/accounts/account/%s"; 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); } + 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 { makeServiceRequest(REQUEST_RATE_LIMIT_PUSH_CHALLENGE, "POST", ""); } diff --git a/libsignal-service/src/main/protowire/SignalService.proto b/libsignal-service/src/main/protowire/SignalService.proto index fcdfa96d88..dc3166c428 100644 --- a/libsignal-service/src/main/protowire/SignalService.proto +++ b/libsignal-service/src/main/protowire/SignalService.proto @@ -707,6 +707,11 @@ message SyncMessage { repeated AttachmentDelete attachmentDeletes = 4; } + message DeviceNameChange { + reserved /*name*/ 1; + optional uint32 deviceId = 2; + } + optional Sent sent = 1; optional Contacts contacts = 2; reserved /*groups*/ 3; @@ -729,6 +734,7 @@ message SyncMessage { optional CallLinkUpdate callLinkUpdate = 20; optional CallLogEvent callLogEvent = 21; optional DeleteForMe deleteForMe = 22; + optional DeviceNameChange deviceNameChange = 23; } message AttachmentPointer {