Fix backup progress being stuck.

This commit is contained in:
Greyson Parrelli
2026-06-03 15:06:31 -03:00
committed by Alex Hart
parent be80619a3b
commit 4a68e0c469
3 changed files with 450 additions and 2 deletions
@@ -6,14 +6,18 @@
package org.thoughtcrime.securesms.backup
import androidx.annotation.WorkerThread
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.withContext
import org.signal.core.util.bytes
import org.signal.core.util.logging.Log
@@ -46,6 +50,8 @@ object ArchiveUploadProgress {
private val TAG = Log.tag(ArchiveUploadProgress::class)
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val _progress: MutableSharedFlow<Unit> = MutableSharedFlow(replay = 1)
private var uploadProgress: ArchiveUploadProgressState = SignalStore.backup.archiveUploadState ?: ArchiveUploadProgressState(
@@ -61,7 +67,7 @@ object ArchiveUploadProgress {
/**
* Observe this to get updates on the current upload progress.
*/
val progress: Flow<ArchiveUploadProgressState> = _progress
val progress: SharedFlow<ArchiveUploadProgressState> = _progress
.throttleLatest(500.milliseconds) {
uploadProgress.state == ArchiveUploadProgressState.State.None ||
(uploadProgress.state == ArchiveUploadProgressState.State.UploadBackupFile && uploadProgress.backupFileUploadedBytes == 0L) ||
@@ -114,6 +120,11 @@ object ArchiveUploadProgress {
}
.onStart { emit(uploadProgress) }
.flowOn(Dispatchers.IO)
.shareIn(scope, SharingStarted.Eagerly, replay = 1)
init {
_progress.tryEmit(Unit)
}
val inProgress
get() = uploadProgress.state != ArchiveUploadProgressState.State.None && uploadProgress.state != ArchiveUploadProgressState.State.UserCanceled
@@ -400,6 +400,8 @@ class UploadAttachmentToArchiveJob private constructor(
SignalDatabase.attachments.setArchiveTransferStateFailure(attachmentId, AttachmentTable.ArchiveTransferState.TEMPORARY_FAILURE)
}
}
ArchiveUploadProgress.onAttachmentFinished(attachmentId)
}
private fun setArchiveTransferStateWithDelayedNotification(attachmentId: AttachmentId, transferState: AttachmentTable.ArchiveTransferState) {
@@ -0,0 +1,435 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup
import android.app.Application
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isTrue
import io.mockk.Runs
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkObject
import io.mockk.unmockkStatic
import io.mockk.verify
import org.junit.After
import org.junit.AfterClass
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.backup.ArchiveUploadProgress.ArchiveBackupProgressListener
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.ArchiveCommitAttachmentDeletesJob
import org.thoughtcrime.securesms.jobs.ArchiveThumbnailUploadJob
import org.thoughtcrime.securesms.jobs.BackupMessagesJob
import org.thoughtcrime.securesms.jobs.UploadAttachmentToArchiveJob
import org.thoughtcrime.securesms.keyvalue.BackupValues
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.protos.ArchiveUploadProgressState
import org.thoughtcrime.securesms.keyvalue.protos.ArchiveUploadProgressState.BackupPhase
import org.thoughtcrime.securesms.keyvalue.protos.ArchiveUploadProgressState.State
import org.thoughtcrime.securesms.testutil.MockAppDependenciesRule
import org.thoughtcrime.securesms.util.SignalLocalMetrics
import org.whispersystems.signalservice.api.messages.AttachmentTransferProgress
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, application = Application::class)
class ArchiveUploadProgressTest {
@get:Rule
val appDependencies = MockAppDependenciesRule()
companion object {
private lateinit var backup: BackupValues
private lateinit var attachments: AttachmentTable
private var storedState: ArchiveUploadProgressState = ArchiveUploadProgressState(state = State.None)
private var backsUpMedia: Boolean = true
private var finishedInitialBackup: Boolean = false
private var pendingArchiveUploadBytes: Long = 0
@BeforeClass
@JvmStatic
fun setUpClass() {
backup = mockk(relaxUnitFun = true)
attachments = mockk(relaxed = true)
val signalDatabase = mockk<SignalDatabase>(relaxed = true)
mockkObject(SignalStore)
every { SignalStore.backup } returns backup
every { backup.archiveUploadState } answers { storedState }
every { backup.archiveUploadState = any() } answers { storedState = firstArg() }
every { backup.backsUpMedia } answers { backsUpMedia }
every { backup.finishedInitialBackup } answers { finishedInitialBackup }
mockkObject(SignalDatabase)
every { SignalDatabase.instance } returns signalDatabase
every { signalDatabase.attachmentTable } returns attachments
every { attachments.getPendingArchiveUploadBytes() } answers { pendingArchiveUploadBytes }
mockkObject(BackupMessagesJob.Companion)
every { BackupMessagesJob.enqueue() } just Runs
every { BackupMessagesJob.cancel() } just Runs
mockkStatic(SignalLocalMetrics.ArchiveAttachmentUpload::class)
every { SignalLocalMetrics.ArchiveAttachmentUpload.start(any()) } just Runs
every { SignalLocalMetrics.ArchiveAttachmentUpload.end(any()) } just Runs
}
@AfterClass
@JvmStatic
fun tearDownClass() {
unmockkObject(SignalStore)
unmockkObject(SignalDatabase)
unmockkObject(BackupMessagesJob.Companion)
unmockkStatic(SignalLocalMetrics.ArchiveAttachmentUpload::class)
}
}
@Before
fun setUp() {
clearMocks(backup, attachments, BackupMessagesJob.Companion, answers = false, recordedCalls = true)
storedState = ArchiveUploadProgressState(state = State.None)
backsUpMedia = true
finishedInitialBackup = false
pendingArchiveUploadBytes = 0
every { AppDependencies.jobManager.areQueuesEmpty(any()) } returns true
setUploadProgress(ArchiveUploadProgressState(state = State.None))
clearAttachmentProgress()
}
@After
fun tearDown() {
setUploadProgress(ArchiveUploadProgressState(state = State.None))
clearAttachmentProgress()
}
@Test
fun `begin - sets export state and shows banner when initial backup not finished`() {
ArchiveUploadProgress.begin()
assertThat(uploadProgress().state).isEqualTo(State.Export)
verify { backup.uploadBannerVisible = true }
}
@Test
fun `begin - does not show banner when initial backup already finished`() {
finishedInitialBackup = true
ArchiveUploadProgress.begin()
assertThat(uploadProgress().state).isEqualTo(State.Export)
verify(exactly = 0) { backup.uploadBannerVisible = any() }
}
@Test
fun `begin - overrides a prior user cancellation`() {
setUploadProgress(ArchiveUploadProgressState(state = State.UserCanceled))
ArchiveUploadProgress.begin()
assertThat(uploadProgress().state).isEqualTo(State.Export)
}
@Test
fun `cancel - sets user canceled state, hides banner, and cancels jobs`() {
setUploadProgress(ArchiveUploadProgressState(state = State.Export))
ArchiveUploadProgress.cancel()
assertThat(uploadProgress().state).isEqualTo(State.UserCanceled)
verify { backup.uploadBannerVisible = false }
verify { BackupMessagesJob.cancel() }
verify { AppDependencies.jobManager.cancelAllInQueue(ArchiveCommitAttachmentDeletesJob.ARCHIVE_ATTACHMENT_QUEUE) }
verify { AppDependencies.jobManager.cancelAllInQueues(UploadAttachmentToArchiveJob.QUEUES) }
verify { AppDependencies.jobManager.cancelAllInQueue(ArchiveThumbnailUploadJob.KEY) }
}
@Test
fun `cancel - is sticky and prevents subsequent state changes`() {
ArchiveUploadProgress.cancel()
assertThat(uploadProgress().state).isEqualTo(State.UserCanceled)
ArchiveUploadProgress.onMessageBackupCreated(backupFileSize = 1234)
assertThat(uploadProgress().state).isEqualTo(State.UserCanceled)
}
@Test
fun `onMessageBackupCreated - sets upload backup file state with totals`() {
ArchiveUploadProgress.onMessageBackupCreated(backupFileSize = 5000)
val state = uploadProgress()
assertThat(state.state).isEqualTo(State.UploadBackupFile)
assertThat(state.backupFileTotalBytes).isEqualTo(5000L)
assertThat(state.backupFileUploadedBytes).isEqualTo(0L)
}
@Test
fun `onMessageBackupUploadProgress - tracks transmitted and total bytes`() {
ArchiveUploadProgress.onMessageBackupUploadProgress(AttachmentTransferProgress(total = 8000L, transmitted = 3000L))
val state = uploadProgress()
assertThat(state.state).isEqualTo(State.UploadBackupFile)
assertThat(state.backupFileUploadedBytes).isEqualTo(3000L)
assertThat(state.backupFileTotalBytes).isEqualTo(8000L)
}
@Test
fun `onAttachmentSectionStarted - sets upload media state with total bytes`() {
pendingArchiveUploadBytes = 7000
ArchiveUploadProgress.onAttachmentSectionStarted(totalAttachmentBytes = 7000)
val state = uploadProgress()
assertThat(state.state).isEqualTo(State.UploadMedia)
assertThat(state.mediaUploadedBytes).isEqualTo(0L)
assertThat(state.mediaTotalBytes).isEqualTo(7000L)
}
@Test
fun `onMessageBackupFinishedEarly - without media goes to none and marks initial backup finished`() {
backsUpMedia = false
setUploadProgress(ArchiveUploadProgressState(state = State.UploadBackupFile, backupFileTotalBytes = 100))
ArchiveUploadProgress.onMessageBackupFinishedEarly()
val state = uploadProgress()
assertThat(state.state).isEqualTo(State.None)
assertThat(state.backupPhase).isEqualTo(BackupPhase.BackupPhaseNone)
verify { backup.finishedInitialBackup = true }
}
@Test
fun `onMessageBackupFinishedEarly - with pending media reverts to upload media`() {
backsUpMedia = true
every { AppDependencies.jobManager.areQueuesEmpty(any()) } returns false
pendingArchiveUploadBytes = 2000
setUploadProgress(ArchiveUploadProgressState(state = State.UploadBackupFile, backupFileTotalBytes = 100))
ArchiveUploadProgress.onMessageBackupFinishedEarly()
val state = uploadProgress()
assertThat(state.state).isEqualTo(State.UploadMedia)
assertThat(state.mediaTotalBytes).isEqualTo(2000L)
verify { backup.finishedInitialBackup = true }
}
@Test
fun `onMainBackupFileUploadFailure - without media goes to none`() {
backsUpMedia = false
setUploadProgress(ArchiveUploadProgressState(state = State.UploadBackupFile, backupFileTotalBytes = 100))
ArchiveUploadProgress.onMainBackupFileUploadFailure()
val state = uploadProgress()
assertThat(state.state).isEqualTo(State.None)
assertThat(state.backupPhase).isEqualTo(BackupPhase.BackupPhaseNone)
}
@Test
fun `onMainBackupFileUploadFailure - with pending media reverts to upload media`() {
backsUpMedia = true
every { AppDependencies.jobManager.areQueuesEmpty(any()) } returns false
pendingArchiveUploadBytes = 3000
setUploadProgress(ArchiveUploadProgressState(state = State.UploadBackupFile, backupFileTotalBytes = 100))
ArchiveUploadProgress.onMainBackupFileUploadFailure()
val state = uploadProgress()
assertThat(state.state).isEqualTo(State.UploadMedia)
assertThat(state.mediaTotalBytes).isEqualTo(3000L)
}
@Test
fun `listener - phase callbacks set export state with matching phase`() {
val cases = listOf<Pair<() -> Unit, BackupPhase>>(
({ ArchiveBackupProgressListener.onAccount() }) to BackupPhase.Account,
({ ArchiveBackupProgressListener.onRecipient() }) to BackupPhase.Recipient,
({ ArchiveBackupProgressListener.onThread() }) to BackupPhase.Thread,
({ ArchiveBackupProgressListener.onCall() }) to BackupPhase.Call,
({ ArchiveBackupProgressListener.onSticker() }) to BackupPhase.Sticker,
({ ArchiveBackupProgressListener.onNotificationProfile() }) to BackupPhase.NotificationProfile,
({ ArchiveBackupProgressListener.onChatFolder() }) to BackupPhase.ChatFolder
)
cases.forEach { (action, expectedPhase) ->
action()
val state = uploadProgress()
assertThat(state.state).isEqualTo(State.Export)
assertThat(state.backupPhase).isEqualTo(expectedPhase)
}
}
@Test
fun `listener - onMessage sets export state with frame counts`() {
ArchiveBackupProgressListener.onMessage(currentProgress = 25, approximateCount = 100)
val state = uploadProgress()
assertThat(state.state).isEqualTo(State.Export)
assertThat(state.backupPhase).isEqualTo(BackupPhase.Message)
assertThat(state.frameExportCount).isEqualTo(25L)
assertThat(state.frameTotalCount).isEqualTo(100L)
}
@Test
fun `listener - onAttachment resets phase to none`() {
ArchiveBackupProgressListener.onMessage(currentProgress = 25, approximateCount = 100)
ArchiveBackupProgressListener.onAttachment(currentProgress = 3, totalCount = 7)
val state = uploadProgress()
assertThat(state.state).isEqualTo(State.Export)
assertThat(state.backupPhase).isEqualTo(BackupPhase.BackupPhaseNone)
}
@Test
fun `inProgress - reflects active states`() {
setUploadProgress(ArchiveUploadProgressState(state = State.Export))
assertThat(ArchiveUploadProgress.inProgress).isTrue()
setUploadProgress(ArchiveUploadProgressState(state = State.None))
assertThat(ArchiveUploadProgress.inProgress).isFalse()
setUploadProgress(ArchiveUploadProgressState(state = State.UserCanceled))
assertThat(ArchiveUploadProgress.inProgress).isFalse()
}
@Test
fun `onAttachmentStarted - registers attachment and starts metric`() {
setUploadProgress(ArchiveUploadProgressState(state = State.UploadMedia, mediaTotalBytes = 1000, mediaUploadedBytes = 0))
pendingArchiveUploadBytes = 1000
val attachmentId = AttachmentId(1)
ArchiveUploadProgress.onAttachmentStarted(attachmentId, sizeBytes = 500)
verify { SignalLocalMetrics.ArchiveAttachmentUpload.start(attachmentId) }
assertThat(attachmentProgressMap().containsKey(attachmentId)).isTrue()
}
@Test
fun `onAttachmentProgress - computes media uploaded bytes via flow`() {
setUploadProgress(ArchiveUploadProgressState(state = State.UploadMedia, mediaTotalBytes = 1000, mediaUploadedBytes = 0))
pendingArchiveUploadBytes = 1000
val attachmentId = AttachmentId(1)
ArchiveUploadProgress.onAttachmentProgress(attachmentId, bytesUploaded = 300)
val state = awaitUploadProgress { it.state == State.UploadMedia && it.mediaUploadedBytes == 300L }
assertThat(state.mediaTotalBytes).isEqualTo(1000L)
assertThat(state.mediaUploadedBytes).isEqualTo(300L)
}
@Test
fun `onAttachmentFinished - removes attachment and ends metric`() {
setUploadProgress(ArchiveUploadProgressState(state = State.UploadMedia, mediaTotalBytes = 1000, mediaUploadedBytes = 0))
pendingArchiveUploadBytes = 1000
val attachmentId = AttachmentId(1)
ArchiveUploadProgress.onAttachmentStarted(attachmentId, sizeBytes = 500)
ArchiveUploadProgress.onAttachmentFinished(attachmentId)
verify { SignalLocalMetrics.ArchiveAttachmentUpload.end(attachmentId) }
assertThat(attachmentProgressMap().containsKey(attachmentId)).isFalse()
}
@Test
fun `progress flow - expands total when more media becomes pending than expected`() {
setUploadProgress(ArchiveUploadProgressState(state = State.UploadMedia, mediaTotalBytes = 1000, mediaUploadedBytes = 0))
backsUpMedia = true
pendingArchiveUploadBytes = 1500
ArchiveUploadProgress.triggerUpdate()
val state = awaitUploadProgress { it.state == State.UploadMedia && it.mediaTotalBytes == 1500L }
assertThat(state.mediaTotalBytes).isEqualTo(1500L)
assertThat(state.mediaUploadedBytes).isEqualTo(0L)
}
@Test
fun `progress flow - completes when no pending bytes remain`() {
setUploadProgress(ArchiveUploadProgressState(state = State.UploadMedia, mediaTotalBytes = 1000, mediaUploadedBytes = 0))
backsUpMedia = true
pendingArchiveUploadBytes = 0
ArchiveUploadProgress.triggerUpdate()
val state = awaitUploadProgress { it.state == State.None }
assertThat(state.mediaUploadedBytes).isEqualTo(1000L)
verify { backup.finishedInitialBackup = true }
verify { BackupMessagesJob.enqueue() }
}
@Test
fun `progress flow - completes immediately when media is not backed up`() {
setUploadProgress(ArchiveUploadProgressState(state = State.UploadMedia, mediaTotalBytes = 1000, mediaUploadedBytes = 0))
backsUpMedia = false
ArchiveUploadProgress.triggerUpdate()
val state = awaitUploadProgress { it.state == State.None }
assertThat(state.backupPhase).isEqualTo(BackupPhase.BackupPhaseNone)
verify { backup.finishedInitialBackup = true }
}
private fun uploadProgress(): ArchiveUploadProgressState {
val field = ArchiveUploadProgress::class.java.getDeclaredField("uploadProgress")
field.isAccessible = true
return field.get(ArchiveUploadProgress) as ArchiveUploadProgressState
}
private fun setUploadProgress(value: ArchiveUploadProgressState) {
val field = ArchiveUploadProgress::class.java.getDeclaredField("uploadProgress")
field.isAccessible = true
field.set(ArchiveUploadProgress, value)
storedState = value
}
@Suppress("UNCHECKED_CAST")
private fun attachmentProgressMap(): MutableMap<AttachmentId, Any> {
val field = ArchiveUploadProgress::class.java.getDeclaredField("attachmentProgress")
field.isAccessible = true
return field.get(ArchiveUploadProgress) as MutableMap<AttachmentId, Any>
}
private fun clearAttachmentProgress() {
attachmentProgressMap().clear()
}
private fun awaitUploadProgress(timeoutMs: Long = 5000, predicate: (ArchiveUploadProgressState) -> Boolean): ArchiveUploadProgressState {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
val current = uploadProgress()
if (predicate(current)) {
return current
}
ArchiveUploadProgress.triggerUpdate()
Thread.sleep(25)
}
val current = uploadProgress()
if (predicate(current)) {
return current
}
throw AssertionError("Timed out waiting for expected progress state. Last state: $current")
}
}