Update linked devices screen.

This commit is contained in:
Michelle Tang
2024-06-05 17:17:03 -07:00
committed by Alex Hart
parent 5c181e774f
commit ac52b5b992
21 changed files with 911 additions and 11 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<Device>? {
val accountManager = AppDependencies.signalServiceAccountManager
return try {
val devices: List<Device> = 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
}
}

View File

@@ -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<Device> = emptyList(),
val deviceToRemove: Device? = null,
@StringRes val progressDialogMessage: Int = -1,
val toastDialog: String = ""
)

View File

@@ -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<LinkDeviceSettingsState> = _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
)
}
}
}
}

View File

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

View File

@@ -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<String> NOT_REMOTE_CAPABLE = SetUtil.newHashSet(MESSAGE_BACKUPS, REGISTRATION_V2, RESTORE_POST_REGISTRATION);
static final Set<String> 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<String, Object> getMemoryValues() {
return new TreeMap<>(REMOTE_VALUES);