From ac52b5b992b2c473286ee6e038bc744966614548 Mon Sep 17 00:00:00 2001 From: Michelle Tang Date: Wed, 5 Jun 2024 17:17:03 -0700 Subject: [PATCH] Update linked devices screen. --- .../settings/app/AppSettingsFragment.kt | 6 +- .../securesms/linkdevice/Device.kt | 6 + .../linkdevice/LinkDeviceFragment.kt | 242 ++++++++++++++++++ .../LinkDeviceLearnMoreBottomSheetFragment.kt | 122 +++++++++ .../linkdevice/LinkDeviceRepository.kt | 74 ++++++ .../linkdevice/LinkDeviceSettingsState.kt | 13 + .../linkdevice/LinkDeviceViewModel.kt | 64 +++++ .../thoughtcrime/securesms/util/DateUtils.kt | 8 +- .../securesms/util/FeatureFlags.java | 8 +- .../res/drawable-night/ic_all_devices.xml | 45 ++++ .../main/res/drawable-night/ic_devices.xml | 34 +++ .../res/drawable-night/ic_devices_intro.xml | 66 +++++ app/src/main/res/drawable/ic_all_devices.xml | 45 ++++ app/src/main/res/drawable/ic_devices.xml | 34 +++ .../main/res/drawable/ic_devices_intro.xml | 66 +++++ app/src/main/res/navigation/app_settings.xml | 15 +- .../app_settings_with_change_number_v2.xml | 15 +- .../main/res/raw-night/linking_device.json | 1 + app/src/main/res/raw/linking_device.json | 1 + app/src/main/res/values/strings.xml | 23 ++ .../main/java/org/signal/core/ui/Dialogs.kt | 34 +++ 21 files changed, 911 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/linkdevice/Device.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceLearnMoreBottomSheetFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepository.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceSettingsState.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceViewModel.kt create mode 100644 app/src/main/res/drawable-night/ic_all_devices.xml create mode 100644 app/src/main/res/drawable-night/ic_devices.xml create mode 100644 app/src/main/res/drawable-night/ic_devices_intro.xml create mode 100644 app/src/main/res/drawable/ic_all_devices.xml create mode 100644 app/src/main/res/drawable/ic_devices.xml create mode 100644 app/src/main/res/drawable/ic_devices_intro.xml create mode 100644 app/src/main/res/raw-night/linking_device.json create mode 100644 app/src/main/res/raw/linking_device.json diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt index 51240c5427..c88486fc2f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt @@ -160,7 +160,11 @@ class AppSettingsFragment : DSLSettingsFragment( title = DSLSettingsText.from(R.string.preferences__linked_devices), icon = DSLSettingsIcon.from(R.drawable.symbol_devices_24), onClick = { - findNavController().safeNavigate(R.id.action_appSettingsFragment_to_deviceActivity) + if (FeatureFlags.linkedDevicesV2()) { + findNavController().safeNavigate(R.id.action_appSettingsFragment_to_linkDeviceFragment) + } else { + findNavController().safeNavigate(R.id.action_appSettingsFragment_to_deviceActivity) + } }, isEnabled = state.isRegisteredAndUpToDate() ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/Device.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/Device.kt new file mode 100644 index 0000000000..a5ed5da636 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/Device.kt @@ -0,0 +1,6 @@ +package org.thoughtcrime.securesms.linkdevice + +/** + * Class that represents a linked device + */ +data class Device(val id: Long, val name: String, val createdMillis: Long, val lastSeenMillis: Long) diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceFragment.kt new file mode 100644 index 0000000000..61e3a0f56c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceFragment.kt @@ -0,0 +1,242 @@ +package org.thoughtcrime.securesms.linkdevice + +import android.widget.Toast +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.fragment.app.viewModels +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.Previews +import org.signal.core.ui.Scaffolds +import org.signal.core.ui.SignalPreview +import org.thoughtcrime.securesms.DeviceActivity +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.compose.ComposeFragment +import org.thoughtcrime.securesms.util.BottomSheetUtil +import org.thoughtcrime.securesms.util.DateUtils +import java.util.Locale + +/** + * Fragment that shows current linked devices + */ +class LinkDeviceFragment : ComposeFragment() { + + private val viewModel: LinkDeviceViewModel by viewModels() + + @Composable + override fun FragmentContent() { + val state by viewModel.state + + LaunchedEffect(state.toastDialog) { + if (state.toastDialog.isNotEmpty()) { + Toast.makeText(requireContext(), state.toastDialog, Toast.LENGTH_LONG).show() + } + } + + Scaffolds.Settings( + title = stringResource(id = R.string.preferences__linked_devices), + onNavigationClick = { findNavController().popBackStack() }, + navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24), + navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close) + ) { contentPadding: PaddingValues -> + DeviceDescriptionScreen( + state = state, + modifier = Modifier.padding(contentPadding), + onLearnMore = this::openLearnMore, + onLinkDevice = this::openLinkNewDevice, + setDeviceToRemove = this::setDeviceToRemove, + onRemoveDevice = this::onRemoveDevice + ) + } + } + + override fun onResume() { + super.onResume() + viewModel.loadDevices(requireContext()) + } + + private fun openLearnMore() { + LinkDeviceLearnMoreBottomSheetFragment().show(childFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + } + + private fun openLinkNewDevice() { + // TODO(Michelle): Use linkDeviceAddFragment + startActivity(DeviceActivity.getIntentForScanner(requireContext())) + // findNavController().safeNavigate(R.id.action_linkDeviceFragment_to_linkDeviceAddFragment) + } + + private fun setDeviceToRemove(device: Device?) { + viewModel.setDeviceToRemove(device) + } + + private fun onRemoveDevice(device: Device) { + viewModel.removeDevice(requireContext(), device) + } +} + +@Composable +fun DeviceDescriptionScreen( + state: LinkDeviceSettingsState, + modifier: Modifier = Modifier, + onLearnMore: () -> Unit = {}, + onLinkDevice: () -> Unit = {}, + setDeviceToRemove: (Device?) -> Unit = {}, + onRemoveDevice: (Device) -> Unit = {} +) { + if (state.progressDialogMessage != -1) { + Dialogs.IndeterminateProgressDialog(stringResource(id = state.progressDialogMessage)) + } + if (state.deviceToRemove != null) { + val device: Device = state.deviceToRemove + Dialogs.SimpleAlertDialog( + title = stringResource(id = R.string.DeviceListActivity_unlink_s, device.name), + body = stringResource(id = R.string.DeviceListActivity_by_unlinking_this_device_it_will_no_longer_be_able_to_send_or_receive), + confirm = stringResource(R.string.LinkDeviceFragment__unlink), + dismiss = stringResource(android.R.string.cancel), + onConfirm = { onRemoveDevice(device) }, + onDismiss = { setDeviceToRemove(null) } + ) + } + + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier.verticalScroll(rememberScrollState())) { + Icon( + painter = painterResource(R.drawable.ic_devices_intro), + contentDescription = stringResource(R.string.preferences__linked_devices), + tint = Color.Unspecified + ) + Text( + text = stringResource(id = R.string.LinkDeviceFragment__use_signal_on_desktop_ipad), + textAlign = TextAlign.Center, + modifier = Modifier.padding(start = 20.dp, end = 20.dp, top = 12.dp, bottom = 4.dp) + ) + ClickableText( + text = AnnotatedString(stringResource(id = R.string.LearnMoreTextView_learn_more)), + style = TextStyle(color = MaterialTheme.colorScheme.primary) + ) { + onLearnMore() + } + + Spacer(modifier = Modifier.size(20.dp)) + + Buttons.LargeTonal( + onClick = onLinkDevice, + modifier = Modifier.width(300.dp) + ) { + Text(stringResource(id = R.string.LinkDeviceFragment__link_a_new_device)) + } + + if (state.devices.isNotEmpty()) { + Dividers.Default() + + Column { + Text( + text = stringResource(R.string.LinkDeviceFragment__my_linked_devices), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 24.dp, top = 12.dp, bottom = 24.dp) + ) + state.devices.forEach { device -> + DeviceRow(device, setDeviceToRemove) + } + } + } + + Row( + modifier = Modifier.padding(horizontal = 40.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Spacer(modifier = Modifier.size(12.dp)) + Icon( + painter = painterResource(R.drawable.symbol_lock_24), + contentDescription = null + ) + Text( + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + text = stringResource(id = R.string.LinkDeviceFragment__messages_and_chat_info_are_protected) + ) + } + } +} + +@Composable +fun DeviceRow(device: Device, setDeviceToRemove: (Device) -> Unit) { + val titleString = device.name.ifEmpty { stringResource(R.string.DeviceListItem_unnamed_device) } + val linkedDate = DateUtils.getDayPrecisionTimeSpanString(LocalContext.current, Locale.getDefault(), device.createdMillis) + val lastActive = DateUtils.getDayPrecisionTimeSpanString(LocalContext.current, Locale.getDefault(), device.lastSeenMillis) + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { setDeviceToRemove(device) }, + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(id = R.drawable.symbol_devices_24), + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), + contentScale = ContentScale.Inside, + modifier = Modifier + .padding(start = 24.dp) + .size(40.dp) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = CircleShape + ) + ) + Spacer(modifier = Modifier.size(20.dp)) + Column { + Text(text = titleString, style = MaterialTheme.typography.bodyLarge) + Spacer(modifier = Modifier.size(4.dp)) + Text(stringResource(R.string.DeviceListItem_linked_s, linkedDate), style = MaterialTheme.typography.bodyMedium) + Text(stringResource(R.string.DeviceListItem_last_active_s, lastActive), style = MaterialTheme.typography.bodyMedium) + } + } + Spacer(modifier = Modifier.size(16.dp)) +} + +@SignalPreview +@Composable +private fun DeviceScreenPreview() { + val previewDevices = listOf( + Device(1, "Sam's Macbook Pro", 1715793982000, 1716053182000), + Device(1, "Sam's iPad", 1715793182000, 1716053122000) + ) + val previewState = LinkDeviceSettingsState(devices = previewDevices) + + Previews.Preview { + DeviceDescriptionScreen(previewState) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceLearnMoreBottomSheetFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceLearnMoreBottomSheetFragment.kt new file mode 100644 index 0000000000..bec3e8db2d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceLearnMoreBottomSheetFragment.kt @@ -0,0 +1,122 @@ +package org.thoughtcrime.securesms.linkdevice + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.signal.core.ui.BottomSheets +import org.signal.core.ui.Previews +import org.signal.core.ui.SignalPreview +import org.signal.core.ui.Texts +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment +import org.thoughtcrime.securesms.util.CommunicationActions +import org.thoughtcrime.securesms.util.SpanUtil + +/** + * Bottom sheet dialog displayed when users click 'Learn more' when linking a device + */ +class LinkDeviceLearnMoreBottomSheetFragment : ComposeBottomSheetDialogFragment() { + + override val peekHeightPercentage: Float = 0.8f + + companion object { + const val SIGNAL_DOWNLOAD_URL = "https://signal.org/download" + } + + @Composable + override fun SheetContent() { + LearnMoreSheet() + } +} + +@Composable +fun LearnMoreSheet() { + val context = LocalContext.current + val downloadUrl = stringResource(id = R.string.LinkDeviceFragment__signal_download_url) + val fullString = stringResource(id = R.string.LinkDeviceFragment__on_other_device_visit_signal, downloadUrl) + val spanned = SpanUtil.urlSubsequence(fullString, downloadUrl, LinkDeviceLearnMoreBottomSheetFragment.SIGNAL_DOWNLOAD_URL) + + return Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .wrapContentSize(Alignment.Center) + .padding(bottom = 48.dp) + ) { + BottomSheets.Handle() + Icon( + painter = painterResource(R.drawable.ic_all_devices), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.size(110.dp) + ) + Text( + style = MaterialTheme.typography.titleLarge, + text = stringResource(R.string.LinkDeviceFragment__signal_on_desktop_ipad), + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp) + ) + LinkedDeviceInformationRow( + painterResource(R.drawable.symbol_lock_24), + stringResource(R.string.LinkDeviceFragment__all_messaging_is_private) + ) + LinkedDeviceInformationRow( + painterResource(R.drawable.ic_replies_outline_20), + stringResource(R.string.LinkDeviceFragment__signal_messages_are_synchronized) + ) + Row(modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp, start = 40.dp, end = 32.dp)) { + Icon( + painter = painterResource(R.drawable.symbol_save_android_24), + contentDescription = stringResource(R.string.preferences__linked_devices), + modifier = Modifier.size(24.dp).padding(top = 4.dp) + ) + Texts.LinkifiedText( + textWithUrlSpans = spanned, + onUrlClick = { CommunicationActions.openBrowserLink(context, it) }, + style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), + modifier = Modifier.padding(start = 20.dp) + ) + } + } +} + +@Composable +private fun LinkedDeviceInformationRow( + iconPainter: Painter, + text: String +) { + Row(modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp, start = 40.dp, end = 32.dp)) { + Icon( + painter = iconPainter, + contentDescription = null, + modifier = Modifier.size(24.dp).padding(top = 4.dp) + ) + Text( + style = MaterialTheme.typography.bodyLarge, + text = text, + modifier = Modifier.padding(start = 20.dp) + ) + } +} + +@SignalPreview +@Composable +fun LearnMorePreview() { + Previews.BottomSheetPreview { + LearnMoreSheet() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepository.kt new file mode 100644 index 0000000000..79457819eb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepository.kt @@ -0,0 +1,74 @@ +package org.thoughtcrime.securesms.linkdevice + +import org.signal.core.util.Base64.decode +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.devicelist.protos.DeviceName +import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.registration.secondary.DeviceNameCipher +import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo +import org.whispersystems.signalservice.api.push.SignalServiceAddress +import java.io.IOException + +/** + * Repository for linked devices and its various actions (linking, unlinking, listing). + */ +object LinkDeviceRepository { + + private val TAG = Log.tag(LinkDeviceRepository::class) + + fun removeDevice(deviceId: Long): Boolean { + return try { + val accountManager = AppDependencies.signalServiceAccountManager + accountManager.removeDevice(deviceId) + LinkedDeviceInactiveCheckJob.enqueue() + true + } catch (e: IOException) { + Log.w(TAG, e) + false + } + } + + fun loadDevices(): List? { + val accountManager = AppDependencies.signalServiceAccountManager + return try { + val devices: List = accountManager.getDevices() + .filter { d: DeviceInfo -> d.getId() != SignalServiceAddress.DEFAULT_DEVICE_ID } + .map { deviceInfo: DeviceInfo -> deviceInfo.toDevice() } + .sortedBy { it.createdMillis } + .toList() + devices + } catch (e: IOException) { + Log.w(TAG, e) + null + } + } + + private fun DeviceInfo.toDevice(): Device { + val defaultDevice = Device(getId().toLong(), getName(), getCreated(), getLastSeen()) + try { + if (getName().isNullOrEmpty() || getName().length < 4) { + Log.w(TAG, "Invalid DeviceInfo name.") + return defaultDevice + } + + val deviceName = DeviceName.ADAPTER.decode(decode(getName())) + if (deviceName.ciphertext == null || deviceName.ephemeralPublic == null || deviceName.syntheticIv == null) { + Log.w(TAG, "Got a DeviceName that wasn't properly populated.") + return defaultDevice + } + + val plaintext = DeviceNameCipher.decryptDeviceName(deviceName, SignalStore.account().aciIdentityKey) + if (plaintext == null) { + Log.w(TAG, "Failed to decrypt device name.") + return defaultDevice + } + + return Device(getId().toLong(), String(plaintext), getCreated(), getLastSeen()) + } catch (e: Exception) { + Log.w(TAG, "Failed while reading the protobuf.", e) + } + return defaultDevice + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceSettingsState.kt new file mode 100644 index 0000000000..6c87813ac3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceSettingsState.kt @@ -0,0 +1,13 @@ +package org.thoughtcrime.securesms.linkdevice + +import androidx.annotation.StringRes + +/** + * Information about linked devices. Used in [LinkDeviceViewModel]. + */ +data class LinkDeviceSettingsState( + val devices: List = emptyList(), + val deviceToRemove: Device? = null, + @StringRes val progressDialogMessage: Int = -1, + val toastDialog: String = "" +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceViewModel.kt new file mode 100644 index 0000000000..e7cc716887 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceViewModel.kt @@ -0,0 +1,64 @@ +package org.thoughtcrime.securesms.linkdevice + +import android.content.Context +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.thoughtcrime.securesms.R + +/** + * Maintains the state of the [LinkDeviceFragment] + */ +class LinkDeviceViewModel : ViewModel() { + + private val _state = mutableStateOf(LinkDeviceSettingsState()) + val state: State = _state + + fun onResume() { + _state.value = _state.value.copy() + } + + fun setDeviceToRemove(device: Device?) { + _state.value = _state.value.copy(deviceToRemove = device) + } + + fun removeDevice(context: Context, device: Device) { + viewModelScope.launch(Dispatchers.IO) { + _state.value = _state.value.copy( + progressDialogMessage = R.string.DeviceListActivity_unlinking_device + ) + val success = LinkDeviceRepository.removeDevice(device.id) + if (success) { + loadDevices(context) + _state.value = _state.value.copy( + toastDialog = context.getString(R.string.LinkDeviceFragment__s_unlinked, device.name), + progressDialogMessage = -1 + ) + } else { + _state.value = _state.value.copy( + progressDialogMessage = -1 + ) + } + } + } + + fun loadDevices(context: Context) { + viewModelScope.launch(Dispatchers.IO) { + val devices = LinkDeviceRepository.loadDevices() + if (devices == null) { + _state.value = _state.value.copy( + toastDialog = context.getString(R.string.DeviceListActivity_network_failed), + progressDialogMessage = -1 + ) + } else { + _state.value = _state.value.copy( + devices = devices, + progressDialogMessage = -1 + ) + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt index 9a560e367e..0ace03c23d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt @@ -210,13 +210,7 @@ object DateUtils : android.text.format.DateUtils() { return if (isSameDay(System.currentTimeMillis(), timestamp)) { context.getString(R.string.DeviceListItem_today) } else { - val format: String = when { - timestamp.isWithin(6.days) -> "EEE " - timestamp.isWithin(365.days) -> "MMM d" - else -> "MMM d, yyy" - } - - timestamp.toDateString(format, locale) + timestamp.toDateString("dd/MM/yy", locale) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index 1d9a649383..ec73a1a37d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -129,6 +129,7 @@ public final class FeatureFlags { private static final String RESTORE_POST_REGISTRATION = "android.registration.restorePostRegistration"; private static final String LIBSIGNAL_WEB_SOCKET_SHADOW_PCT = "android.libsignalWebSocketShadowingPercentage"; private static final String DELETE_SYNC_SEND_RECEIVE = "android.deleteSyncSendReceive"; + private static final String LINKED_DEVICES_V2 = "android.linkedDevices.v2"; /** * We will only store remote values for flags in this set. If you want a flag to be controllable @@ -211,7 +212,7 @@ public final class FeatureFlags { ); @VisibleForTesting - static final Set NOT_REMOTE_CAPABLE = SetUtil.newHashSet(MESSAGE_BACKUPS, REGISTRATION_V2, RESTORE_POST_REGISTRATION); + static final Set NOT_REMOTE_CAPABLE = SetUtil.newHashSet(MESSAGE_BACKUPS, REGISTRATION_V2, RESTORE_POST_REGISTRATION, LINKED_DEVICES_V2); /** * Values in this map will take precedence over any value. This should only be used for local @@ -750,6 +751,11 @@ public final class FeatureFlags { return getBoolean(DELETE_SYNC_SEND_RECEIVE, false); } + /** Whether or not to use V2 of linked devices. */ + public static boolean linkedDevicesV2() { + return getBoolean(LINKED_DEVICES_V2, false); + } + /** Only for rendering debug info. */ public static synchronized @NonNull Map getMemoryValues() { return new TreeMap<>(REMOTE_VALUES); diff --git a/app/src/main/res/drawable-night/ic_all_devices.xml b/app/src/main/res/drawable-night/ic_all_devices.xml new file mode 100644 index 0000000000..0be4ae9f27 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_all_devices.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable-night/ic_devices.xml b/app/src/main/res/drawable-night/ic_devices.xml new file mode 100644 index 0000000000..2f3402be1e --- /dev/null +++ b/app/src/main/res/drawable-night/ic_devices.xml @@ -0,0 +1,34 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable-night/ic_devices_intro.xml b/app/src/main/res/drawable-night/ic_devices_intro.xml new file mode 100644 index 0000000000..ab5c17b35a --- /dev/null +++ b/app/src/main/res/drawable-night/ic_devices_intro.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_all_devices.xml b/app/src/main/res/drawable/ic_all_devices.xml new file mode 100644 index 0000000000..728d4f03c8 --- /dev/null +++ b/app/src/main/res/drawable/ic_all_devices.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_devices.xml b/app/src/main/res/drawable/ic_devices.xml new file mode 100644 index 0000000000..d97ba9b284 --- /dev/null +++ b/app/src/main/res/drawable/ic_devices.xml @@ -0,0 +1,34 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_devices_intro.xml b/app/src/main/res/drawable/ic_devices_intro.xml new file mode 100644 index 0000000000..71cdfb12c5 --- /dev/null +++ b/app/src/main/res/drawable/ic_devices_intro.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/navigation/app_settings.xml b/app/src/main/res/navigation/app_settings.xml index cf0df6b26e..d95b682200 100644 --- a/app/src/main/res/navigation/app_settings.xml +++ b/app/src/main/res/navigation/app_settings.xml @@ -43,6 +43,13 @@ app:exitAnim="@anim/fragment_open_exit" app:popEnterAnim="@anim/fragment_close_enter" app:popExitAnim="@anim/fragment_close_exit" /> + - + + + + + - + + + + Chat session refreshed Signal uses end-to-end encryption and it may need to refresh your chat session sometimes. This doesn\'t affect your chat\'s security, but you may have missed a message from this contact, and you can ask them to resend it. + + + Use Signal on desktop or iPad. Your messages will sync to your linked devices. + + Link a new device + + Messages and chat info are protected by end-to-end encryption on all devices + + Signal on Desktop or iPad + + All messaging on linked devices is private + + Signal messages are synchronized with Signal on your mobile phone after it is linked. Your previous message history will not appear. + + On your other device, visit %s to install Signal + signal.org/download + + My linked devices + + Unlink + + %s unlinked + Unlink \'%s\'? By unlinking this device, it will no longer be able to send or receive messages. diff --git a/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt b/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt index f3332c888b..b0050b5b39 100644 --- a/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt +++ b/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt @@ -6,6 +6,7 @@ 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.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -128,6 +129,33 @@ object Dialogs { ) } + /** + * Customizable progress spinner that shows [message] below the spinner to let users know + * an action is completing + */ + @Composable + fun IndeterminateProgressDialog(message: String) { + androidx.compose.material3.AlertDialog( + onDismissRequest = {}, + confirmButton = {}, + dismissButton = {}, + text = { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth().fillMaxHeight() + ) { + Spacer(modifier = Modifier.size(24.dp)) + CircularProgressIndicator() + Spacer(modifier = Modifier.size(20.dp)) + Text(message) + } + }, + modifier = Modifier + .size(200.dp) + ) + } + @OptIn(ExperimentalLayoutApi::class) @Composable fun PermissionRationaleDialog( @@ -237,3 +265,9 @@ private fun MessageDialogPreview() { private fun IndeterminateProgressDialogPreview() { Dialogs.IndeterminateProgressDialog() } + +@Preview +@Composable +private fun IndeterminateProgressDialogMessagePreview() { + Dialogs.IndeterminateProgressDialog("Completing...") +}