Added a Storage Service Playground screen.

This commit is contained in:
Greyson Parrelli 2024-11-18 08:47:31 -05:00
parent 1b2c0db693
commit 59403e7da8
11 changed files with 467 additions and 5 deletions

View file

@ -157,7 +157,7 @@ object BackupRepository {
/**
* Checks whether or not we do not have enough storage space for our remaining attachments to be downloaded.
* Called from the attachment / thumbnail download jobs.
* Caller from the attachment / thumbnail download jobs.
*/
fun checkForOutOfStorageError(tag: String): Boolean {
val availableSpace = getFreeStorageSpace()

View file

@ -178,6 +178,14 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
)
clickPref(
title = DSLSettingsText.from("Storage Service Playground"),
summary = DSLSettingsText.from("Test and view storage service stuff."),
onClick = {
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToInternalStorageServicePlaygroundFragment())
}
)
switchPref(
title = DSLSettingsText.from("'Internal Details' button"),
summary = DSLSettingsText.from("Show a button in conversation settings that lets you see more information about a user."),

View file

@ -0,0 +1,358 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.internal.storage
import android.widget.Toast
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
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.Dividers
import org.signal.core.ui.Previews
import org.signal.core.ui.Rows
import org.signal.core.ui.Rows.TextAndLabel
import org.signal.core.ui.SignalPreview
import org.signal.core.util.Hex
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.internal.storage.InternalStorageServicePlaygroundViewModel.OneOffEvent
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.StorageForcePushJob
import org.thoughtcrime.securesms.jobs.StorageSyncJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.Util
import org.whispersystems.signalservice.api.storage.RecordIkm
import org.whispersystems.signalservice.api.storage.SignalStorageManifest
import org.whispersystems.signalservice.api.storage.SignalStorageRecord
import org.whispersystems.signalservice.api.storage.StorageId
import org.whispersystems.signalservice.api.storage.StorageKey
import org.whispersystems.signalservice.internal.storage.protos.ContactRecord
import org.whispersystems.signalservice.internal.storage.protos.StorageRecord
class InternalStorageServicePlaygroundFragment : ComposeFragment() {
val viewModel: InternalStorageServicePlaygroundViewModel by viewModels()
@Composable
override fun FragmentContent() {
val manifest by viewModel.manifest
val storageRecords by viewModel.storageRecords
val oneOffEvent by viewModel.oneOffEvents
var forceSsreToggled by remember { mutableStateOf(SignalStore.internal.forceSsre2Capability) }
Screen(
onBackPressed = { findNavController().popBackStack() },
manifest = manifest,
storageRecords = storageRecords,
oneOffEvent = oneOffEvent,
forceSsreCapability = forceSsreToggled,
onForceSsreToggled = { checked ->
SignalStore.internal.forceSsre2Capability = checked
forceSsreToggled = checked
},
onViewTabSelected = { viewModel.onViewTabSelected() }
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Screen(
manifest: SignalStorageManifest,
storageRecords: List<SignalStorageRecord>,
forceSsreCapability: Boolean,
oneOffEvent: OneOffEvent,
onForceSsreToggled: (Boolean) -> Unit = {},
onViewTabSelected: () -> Unit = {},
onBackPressed: () -> Unit = {}
) {
var tabIndex by remember { mutableIntStateOf(0) }
val tabs = listOf("Tools", "View")
Scaffold(
topBar = {
Column {
TopAppBar(
title = { Text("Storage Service Playground") },
navigationIcon = {
IconButton(onClick = onBackPressed) {
Icon(
painter = painterResource(R.drawable.symbol_arrow_left_24),
tint = MaterialTheme.colorScheme.onSurface,
contentDescription = null
)
}
}
)
TabRow(selectedTabIndex = tabIndex) {
tabs.forEachIndexed { index, tab ->
Tab(
text = { Text(tab) },
selected = index == tabIndex,
onClick = {
tabIndex = index
if (tabIndex == 1) {
onViewTabSelected()
}
}
)
}
}
}
}
) { contentPadding ->
Surface(modifier = Modifier.padding(contentPadding)) {
when (tabIndex) {
0 -> ToolScreen(
forceSsreCapability = forceSsreCapability,
onForceSsreToggled = onForceSsreToggled
)
1 -> ViewScreen(
manifest = manifest,
storageRecords = storageRecords,
oneOffEvent = oneOffEvent
)
}
}
}
}
@Composable
fun ToolScreen(
forceSsreCapability: Boolean,
onForceSsreToggled: (Boolean) -> Unit = {}
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
ActionRow("Enqueue StorageSyncJob", "Just a normal syncing operation.") {
AppDependencies.jobManager.add(StorageSyncJob())
}
ActionRow("Enqueue StorageForcePushJob", "Forces your local state over the remote.") {
AppDependencies.jobManager.add(StorageForcePushJob())
}
ActionRow("Reset local manifest", "Makes us think we're not at the latest version (and erases RecordIkm).") {
SignalStore.storageService.manifest = SignalStorageManifest.EMPTY
}
ActionRow("Set initial storage key", "Initializes it to something random. Will cause a decryption failure.") {
SignalStore.storageService.storageKeyForInitialDataRestore = StorageKey(Util.getSecretBytes(32))
}
ActionRow("Clear initial storage key", "Sets it to null.") {
SignalStore.storageService.storageKeyForInitialDataRestore = null
}
Rows.ToggleRow(
text = "Force SSRE2 Capability",
checked = forceSsreCapability,
onCheckChanged = onForceSsreToggled
)
}
}
@Composable
fun ViewScreen(
manifest: SignalStorageManifest,
storageRecords: List<SignalStorageRecord>,
oneOffEvent: OneOffEvent
) {
val context = LocalContext.current
LaunchedEffect(oneOffEvent) {
when (oneOffEvent) {
OneOffEvent.None -> Unit
OneOffEvent.ManifestDecryptionError -> {
Toast.makeText(context, "Failed to decrypt manifest!", Toast.LENGTH_SHORT).show()
}
OneOffEvent.StorageRecordDecryptionError -> {
Toast.makeText(context, "Failed to decrypt storage records!", Toast.LENGTH_SHORT).show()
}
}
}
LazyColumn(
modifier = Modifier.fillMaxHeight().padding(16.dp)
) {
item(key = "manifest") {
ManifestRow(manifest)
Dividers.Default()
}
storageRecords.forEach { record ->
item(key = Hex.toStringCondensed(record.id.raw)) {
StorageRecordRow(record)
Dividers.Default()
}
}
}
}
@Composable
private fun ManifestRow(manifest: SignalStorageManifest) {
Column {
ManifestItemRow("Version", manifest.versionString)
ManifestItemRow("RecordIkm", manifest.recordIkm?.value?.let { Hex.toStringCondensed(it) } ?: "null")
ManifestItemRow("Total ID count", manifest.storageIds.size.toString())
ManifestItemRow("Unknown ID count", manifest.storageIds.filter { it.isUnknown }.size.toString())
}
}
@Composable
private fun ManifestItemRow(title: String, value: String) {
Row(modifier = Modifier.fillMaxWidth()) {
Text(title + ":", fontWeight = FontWeight.Bold)
Spacer(Modifier.width(6.dp))
Text(value)
}
}
@Composable
private fun StorageRecordRow(record: SignalStorageRecord) {
Row(modifier = Modifier.fillMaxWidth()) {
when {
record.proto.account != null -> {
Column {
Text("Account", fontWeight = FontWeight.Bold)
ManifestItemRow("ID", Hex.toStringCondensed(record.id.raw))
}
}
record.proto.contact != null -> {
Column {
Text("Contact", fontWeight = FontWeight.Bold)
ManifestItemRow("ID", Hex.toStringCondensed(record.id.raw))
}
}
record.proto.groupV1 != null -> {
Column {
Text("GV1", fontWeight = FontWeight.Bold)
ManifestItemRow("ID", Hex.toStringCondensed(record.id.raw))
}
}
record.proto.groupV2 != null -> {
Column {
Text("GV2", fontWeight = FontWeight.Bold)
ManifestItemRow("ID", Hex.toStringCondensed(record.id.raw))
}
}
record.proto.callLink != null -> {
Column {
Text("Call Link", fontWeight = FontWeight.Bold)
ManifestItemRow("ID", Hex.toStringCondensed(record.id.raw))
}
}
record.proto.storyDistributionList != null -> {
Column {
Text("Distribution List", fontWeight = FontWeight.Bold)
ManifestItemRow("ID", Hex.toStringCondensed(record.id.raw))
}
}
else -> {
Column {
Text("Unknown!")
ManifestItemRow("ID", Hex.toStringCondensed(record.id.raw))
}
}
}
}
}
@Composable
private fun ActionRow(title: String, subtitle: String, onClick: () -> Unit) {
Row(
modifier = Modifier
.padding(Rows.defaultPadding())
.fillMaxWidth()
) {
TextAndLabel(text = title, label = subtitle)
Spacer(Modifier.width(8.dp))
RunButton { onClick() }
}
}
@Composable
private fun RunButton(onClick: () -> Unit) {
Buttons.LargeTonal(onClick = onClick) {
Text("Run")
}
}
@SignalPreview
@Composable
fun ScreenPreview() {
Previews.Preview {
Screen(
forceSsreCapability = true,
manifest = SignalStorageManifest.EMPTY,
storageRecords = emptyList(),
oneOffEvent = OneOffEvent.None
)
}
}
@SignalPreview
@Composable
fun ViewScreenPreview() {
val storageRecords = listOf(
SignalStorageRecord(
id = StorageId.forContact(byteArrayOf(1)),
proto = StorageRecord(
contact = ContactRecord()
)
),
SignalStorageRecord(
id = StorageId.forContact(byteArrayOf(2)),
proto = StorageRecord(
contact = ContactRecord()
)
)
)
Previews.Preview {
ViewScreen(
manifest = SignalStorageManifest(
version = 43,
sourceDeviceId = 2,
recordIkm = RecordIkm(ByteArray(32) { 1 }),
storageIds = storageRecords.map { it.id }
),
storageRecords = storageRecords,
oneOffEvent = OneOffEvent.None
)
}
}

View file

@ -0,0 +1,74 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.internal.storage
import androidx.compose.runtime.MutableState
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 kotlinx.coroutines.withContext
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.whispersystems.signalservice.api.storage.SignalStorageManifest
import org.whispersystems.signalservice.api.storage.SignalStorageRecord
import org.whispersystems.signalservice.api.storage.StorageServiceRepository
class InternalStorageServicePlaygroundViewModel : ViewModel() {
companion object {
private val TAG = Log.tag(InternalStorageServicePlaygroundViewModel::class)
}
private val _manifest: MutableState<SignalStorageManifest> = mutableStateOf(SignalStorageManifest.EMPTY)
val manifest: State<SignalStorageManifest>
get() = _manifest
private val _storageItems: MutableState<List<SignalStorageRecord>> = mutableStateOf(emptyList())
val storageRecords: State<List<SignalStorageRecord>>
get() = _storageItems
private val _oneOffEvents: MutableState<OneOffEvent> = mutableStateOf(OneOffEvent.None)
val oneOffEvents: State<OneOffEvent>
get() = _oneOffEvents
fun onViewTabSelected() {
viewModelScope.launch {
withContext(Dispatchers.IO) {
val repository = StorageServiceRepository(AppDependencies.storageServiceApi)
val storageKey = SignalStore.storageService.storageKeyForInitialDataRestore ?: SignalStore.storageService.storageKey
val manifest = when (val result = repository.getStorageManifest(storageKey)) {
is StorageServiceRepository.ManifestResult.Success -> result.manifest
else -> {
Log.w(TAG, "Failed to fetch manifest!")
_oneOffEvents.value = OneOffEvent.ManifestDecryptionError
return@withContext
}
}
_manifest.value = manifest
val records = when (val result = repository.readStorageRecords(storageKey, manifest.recordIkm, manifest.storageIds)) {
is StorageServiceRepository.StorageRecordResult.Success -> result.records
else -> {
Log.w(TAG, "Failed to fetch records!")
_oneOffEvents.value = OneOffEvent.StorageRecordDecryptionError
return@withContext
}
}
_storageItems.value = records
}
}
}
enum class OneOffEvent {
None, ManifestDecryptionError, StorageRecordDecryptionError
}
}

View file

@ -88,7 +88,7 @@ class StorageRotateManifestJob private constructor(parameters: Parameters) : Job
return when (val result = repository.writeUnchangedManifest(storageServiceKey, manifestWithNewVersion)) {
StorageServiceRepository.WriteStorageRecordsResult.Success -> {
Log.i(TAG, "Successfully rotated the manifest. Clearing restore key.")
Log.i(TAG, "Successfully rotated the manifest as version ${manifestWithNewVersion.version}.${manifestWithNewVersion.sourceDeviceId}. Clearing restore key.")
SignalStore.storageService.storageKeyForInitialDataRestore = null
Result.success()
}

View file

@ -151,6 +151,8 @@ class InternalValues internal constructor(store: KeyValueStore) : SignalStoreVal
var webSocketShadowingStats by nullableBlobValue(WEB_SOCKET_SHADOWING_STATS, null).defaultForExternalUsers()
var forceSsre2Capability by booleanValue("internal.force_ssre2_capability", false).defaultForExternalUsers()
private fun <T> SignalStoreValueDelegate<T>.defaultForExternalUsers(): SignalStoreValueDelegate<T> {
return this.withPrecondition { RemoteConfig.internalUser }
}

View file

@ -3,8 +3,10 @@ package org.thoughtcrime.securesms.migrations
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobs.MultiDeviceKeysUpdateJob
import org.thoughtcrime.securesms.jobs.StorageForcePushJob
import org.thoughtcrime.securesms.jobs.Svr2MirrorJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
/**
* Migration for when we introduce the Account Entropy Pool (AEP).
@ -23,7 +25,15 @@ internal class AepMigrationJob(
override fun isUiBlocking(): Boolean = false
override fun performMigration() {
if (!SignalStore.account.isRegistered) {
Log.w(TAG, "Not registered! Skipping.")
return
}
AppDependencies.jobManager.add(Svr2MirrorJob())
if (SignalStore.account.hasLinkedDevices) {
AppDependencies.jobManager.add(MultiDeviceKeysUpdateJob())
}
AppDependencies.jobManager.add(StorageForcePushJob())
}

View file

@ -322,7 +322,8 @@ class Recipient(
val versionedExpirationTimerCapability: Capability = capabilities.versionedExpirationTimer
/** The user's capability to handle the new storage record encryption scheme. */
val storageServiceEncryptionV2Capability: Capability = capabilities.storageServiceEncryptionV2
val storageServiceEncryptionV2Capability: Capability
get() = if (SignalStore.internal.forceSsre2Capability) Capability.SUPPORTED else capabilities.storageServiceEncryptionV2
/** The state around whether we can send sealed sender to this user. */
val sealedSenderAccessMode: SealedSenderAccessMode = if (pni.isPresent && pni == serviceId) {

View file

@ -748,6 +748,9 @@
<action
android:id="@+id/action_internalSettingsFragment_to_internalBackupPlaygroundFragment"
app:destination="@id/internalBackupPlaygroundFragment" />
<action
android:id="@+id/action_internalSettingsFragment_to_internalStorageServicePlaygroundFragment"
app:destination="@id/internalStorageServicePlaygroundFragment" />
</fragment>
<fragment
@ -800,6 +803,11 @@
android:name="org.thoughtcrime.securesms.components.settings.app.internal.backup.InternalBackupPlaygroundFragment"
android:label="internal_backup_playground_fragment" />
<fragment
android:id="@+id/internalStorageServicePlaygroundFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.internal.storage.InternalStorageServicePlaygroundFragment"
android:label="internal_storage_service_playground_fragment" />
<!-- endregion -->
<!-- App updates -->

View file

@ -8,7 +8,7 @@ import kotlin.reflect.full.memberProperties
/**
* Pairs a storage record with its id. Also contains some useful common methods.
*/
interface SignalRecord<E> {
sealed interface SignalRecord<E> {
val id: StorageId
val proto: E

View file

@ -189,7 +189,8 @@ class StorageServiceRepository(private val storageServiceApi: StorageServiceApi)
val manifestRecord = ManifestRecord(
sourceDevice = signalManifest.sourceDeviceId,
version = signalManifest.version,
identifiers = manifestIds
identifiers = manifestIds,
recordIkm = signalManifest.recordIkm?.value?.toByteString() ?: ByteString.EMPTY
)
val manifestKey = storageKey.deriveManifestKey(signalManifest.version)