Add internal backup stats tab.

This commit is contained in:
Cody Henthorne
2025-06-04 11:54:13 -04:00
parent be4af1d560
commit 26b6019b28
5 changed files with 283 additions and 22 deletions

View File

@@ -35,6 +35,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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
@@ -52,6 +53,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -133,6 +135,11 @@ class InternalBackupPlaygroundFragment : ComposeFragment() {
override fun FragmentContent() {
val context = LocalContext.current
val state by viewModel.state
val statsState by viewModel.statsState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.loadStats()
}
Tabs(
onBack = { findNavController().popBackStack() },
@@ -225,6 +232,14 @@ class InternalBackupPlaygroundFragment : ComposeFragment() {
.show()
}
)
},
statsContent = {
InternalBackupStatsTab(
statsState,
object : StatsCallbacks {
override fun loadRemoteState() = viewModel.loadRemoteStats()
}
)
}
)
}
@@ -234,9 +249,10 @@ class InternalBackupPlaygroundFragment : ComposeFragment() {
@Composable
fun Tabs(
onBack: () -> Unit,
mainContent: @Composable () -> Unit
mainContent: @Composable () -> Unit,
statsContent: @Composable () -> Unit
) {
val tabs = listOf("Main")
val tabs = listOf("Main", "Stats")
var tabIndex by remember { mutableIntStateOf(0) }
val snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }
@@ -274,6 +290,7 @@ fun Tabs(
Surface(modifier = Modifier.padding(it)) {
when (tabIndex) {
0 -> mainContent()
1 -> statsContent()
}
}
}

View File

@@ -11,6 +11,7 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
@@ -18,6 +19,9 @@ import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.util.Hex
import org.signal.core.util.bytes
@@ -32,8 +36,8 @@ import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.backup.ArchiveUploadProgress
import org.thoughtcrime.securesms.backup.v2.ArchiveValidator
import org.thoughtcrime.securesms.backup.v2.BackupMetadata
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.DebugBackupMetadata
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.backup.v2.local.ArchiveFileSystem
import org.thoughtcrime.securesms.backup.v2.local.ArchiveResult
@@ -42,6 +46,8 @@ import org.thoughtcrime.securesms.backup.v2.local.LocalArchiver.FailureCause
import org.thoughtcrime.securesms.backup.v2.local.SnapshotFileSystem
import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupReader.Companion.MAC_SIZE
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.AttachmentTable.DebugAttachmentStats
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.BackupMessagesJob
import org.thoughtcrime.securesms.jobs.BackupRestoreJob
@@ -84,6 +90,8 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
)
val state: State<ScreenState> = _state
val statsState: MutableStateFlow<StatsState> = MutableStateFlow(StatsState())
enum class DialogState {
None,
ImportCredentials
@@ -256,7 +264,7 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
disposables += Single
.fromCallable {
BackupRepository.restoreBackupTier(SignalStore.account.requireAci())
BackupRepository.getRemoteBackupState()
BackupRepository.debugGetRemoteBackupState()
}
.subscribeOn(Schedulers.io())
.subscribe { result ->
@@ -348,6 +356,29 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
}
}
fun loadStats() {
viewModelScope.launch(Dispatchers.IO) {
launch {
var stats = SignalDatabase.attachments.debugGetAttachmentStats()
statsState.update { it.copy(attachmentStats = stats) }
}
}
}
fun loadRemoteStats() {
viewModelScope.launch(Dispatchers.IO) {
launch {
statsState.update { it.copy(loadingRemoteStats = true) }
val (remoteState: DebugBackupMetadata?, errorMsg: String?) = when (val result = BackupRepository.debugGetRemoteBackupState()) {
is NetworkResult.Success -> result.result to null
else -> null to result.toString()
}
statsState.update { it.copy(remoteState = remoteState, remoteFailureMsg = errorMsg, loadingRemoteStats = false) }
}
}
}
suspend fun deleteRemoteBackupData(): Boolean = withContext(Dispatchers.IO) {
when (val result = BackupRepository.debugDeleteAllArchivedMedia()) {
is NetworkResult.Success -> Log.i(TAG, "Remote data deleted")
@@ -386,7 +417,7 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
sealed class RemoteBackupState {
data object Unknown : RemoteBackupState()
data object NotFound : RemoteBackupState()
data class Available(val response: BackupMetadata) : RemoteBackupState()
data class Available(val response: DebugBackupMetadata) : RemoteBackupState()
}
data class MediaState(
@@ -451,4 +482,13 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
val messageBackupKey: MessageBackupKey,
val aci: ACI
)
data class StatsState(
val attachmentStats: DebugAttachmentStats? = null,
val loadingRemoteStats: Boolean = false,
val remoteState: DebugBackupMetadata? = null,
val remoteFailureMsg: String? = null
) {
val valid = attachmentStats != null
}
}

View File

@@ -0,0 +1,116 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.internal.backup
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.Dividers
import org.signal.core.ui.compose.Texts
import org.signal.core.util.bytes
@Composable
fun InternalBackupStatsTab(stats: InternalBackupPlaygroundViewModel.StatsState, callbacks: StatsCallbacks) {
val scrollState = rememberScrollState()
Column(modifier = Modifier.verticalScroll(scrollState)) {
Texts.SectionHeader(text = "Local Attachment State")
if (stats.attachmentStats != null) {
Text(text = "Attachment Count: ${stats.attachmentStats.attachmentCount}")
Text(text = "Transit Download State:")
stats.attachmentStats.transferStateCounts.forEach { (state, count) ->
if (count > 0) {
Text(text = "$state: $count")
}
}
Text(text = "Valid for archive Transit Download State:")
stats.attachmentStats.validForArchiveTransferStateCounts.forEach { (state, count) ->
if (count > 0) {
Text(text = "$state: $count")
}
}
Spacer(modifier = Modifier.size(4.dp))
Text(text = "Archive State:")
stats.attachmentStats.archiveStateCounts.forEach { (state, count) ->
if (count > 0) {
Text(text = "$state: $count")
}
}
Spacer(modifier = Modifier.size(16.dp))
Text(text = "Unique/archived data files: ${stats.attachmentStats.attachmentFileCount}/${stats.attachmentStats.finishedAttachmentFileCount}")
Text(text = "Unique/archived verified digest count: ${stats.attachmentStats.attachmentDigestCount}/${stats.attachmentStats.finishedAttachmentDigestCount}")
Text(text = "Unique/expected thumbnail files: ${stats.attachmentStats.thumbnailFileCount}/${stats.attachmentStats.estimatedThumbnailCount}")
Text(text = "Local Total: ${stats.attachmentStats.attachmentFileCount + stats.attachmentStats.thumbnailFileCount}")
Text(text = "Expected remote total: ${stats.attachmentStats.estimatedThumbnailCount + stats.attachmentStats.finishedAttachmentDigestCount}")
Spacer(modifier = Modifier.size(16.dp))
Text(text = "Pending upload: ${stats.attachmentStats.pendingUploadBytes} (~${stats.attachmentStats.pendingUploadBytes.bytes.toUnitString()})")
Text(text = "Est uploaded attachments: ${stats.attachmentStats.uploadedAttachmentBytes} (~${stats.attachmentStats.uploadedAttachmentBytes.bytes.toUnitString()})")
Text(text = "Est uploaded thumbnails: ${stats.attachmentStats.thumbnailBytes} (~${stats.attachmentStats.thumbnailBytes.bytes.toUnitString()})")
val total = stats.attachmentStats.thumbnailBytes + stats.attachmentStats.uploadedAttachmentBytes
Text(text = "Est total: $total (~${total.bytes.toUnitString()})")
} else {
CircularProgressIndicator()
}
Dividers.Default()
Texts.SectionHeader(text = "Remote State")
if (!stats.loadingRemoteStats && stats.remoteState == null && stats.remoteFailureMsg == null) {
Button(onClick = callbacks::loadRemoteState) {
Text(text = "Load remote stats (expensive and long)")
}
} else {
if (stats.loadingRemoteStats) {
CircularProgressIndicator()
} else if (stats.remoteState != null) {
Text(text = "Media item count: ${stats.remoteState.mediaCount}")
Text(text = "Media items sum size: ${stats.remoteState.mediaSize} (~${stats.remoteState.mediaSize.bytes.toUnitString()})")
Text(text = "Server estimated used size: ${stats.remoteState.usedSpace} (~${stats.remoteState.usedSpace.bytes.toUnitString()})")
} else if (stats.remoteFailureMsg != null) {
Text(text = stats.remoteFailureMsg)
}
Dividers.Default()
Texts.SectionHeader(text = "Expected vs Actual")
if (stats.attachmentStats != null && stats.remoteState != null) {
val finished = stats.attachmentStats.finishedAttachmentFileCount
val thumbnails = stats.attachmentStats.thumbnailFileCount
Text(text = "Expected Count/Actual Remote Count: ${finished + thumbnails} / ${stats.remoteState.mediaCount}")
} else {
CircularProgressIndicator()
}
}
}
}
interface StatsCallbacks {
fun loadRemoteState()
companion object {
val EMPTY = object : StatsCallbacks {
override fun loadRemoteState() = Unit
}
}
}