Detect storage space issues during restore.

This commit is contained in:
Alex Hart 2024-11-14 13:26:30 -04:00 committed by Greyson Parrelli
parent b4472833b8
commit 7f1a866e79
14 changed files with 163 additions and 35 deletions

View file

@ -42,6 +42,7 @@ import org.signal.libsignal.protocol.logging.SignalProtocolLoggerProvider;
import org.signal.ringrtc.CallManager;
import org.thoughtcrime.securesms.apkupdate.ApkUpdateRefreshListener;
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage;
import org.thoughtcrime.securesms.backup.v2.BackupRepository;
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider;
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider;
import org.thoughtcrime.securesms.database.LogDatabase;
@ -253,6 +254,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
KeyCachingService.onAppForegrounded(this);
AppDependencies.getShakeToReport().enable();
checkBuildExpiration();
checkFreeDiskSpace();
MemoryTracker.start();
BackupSubscriptionCheckJob.enqueueIfAble();
@ -289,6 +291,13 @@ public class ApplicationContext extends Application implements AppForegroundObse
}
}
public void checkFreeDiskSpace() {
if (RemoteConfig.messageBackups()) {
long availableBytes = BackupRepository.INSTANCE.getFreeStorageSpace().getBytes();
SignalStore.backup().setSpaceAvailableOnDiskBytes(availableBytes);
}
}
/**
* Note: this is purposefully "started" twice -- once during application create, and once during foreground.
* This is so we can capture ANR's that happen on boot before the foreground event.

View file

@ -64,6 +64,7 @@ import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
import org.thoughtcrime.securesms.jobs.RestoreAttachmentJob
import org.thoughtcrime.securesms.keyvalue.KeyValueStore
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.SignalNetwork
@ -131,6 +132,9 @@ object BackupRepository {
}
}
/**
* Gets the free storage space in the device's data partition.
*/
fun getFreeStorageSpace(): ByteSize {
val statFs = StatFs(Environment.getDataDirectory().absolutePath)
val free = (statFs.availableBlocksLong) * statFs.blockSizeLong
@ -138,9 +142,33 @@ object BackupRepository {
return free.bytes
}
/**
* 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.
*/
fun checkForOutOfStorageError(tag: String): Boolean {
val availableSpace = getFreeStorageSpace()
val remainingAttachmentSize = SignalDatabase.attachments.getRemainingRestorableAttachmentSize().bytes
return if (availableSpace < remainingAttachmentSize) {
Log.w(tag, "Possibly out of space. ${availableSpace.toUnitString()} available.", true)
SignalStore.backup.spaceAvailableOnDiskBytes = availableSpace.bytes
true
} else {
false
}
}
/**
* Cancels any relevant jobs for media restore
*/
@JvmStatic
fun skipMediaRestore() {
// TODO [backups] -- Clear the error as necessary, cancel anything remaining in the restore
SignalStore.backup.userManuallySkippedMediaRestore = true
AppDependencies.jobManager.cancelAllInQueue(RestoreAttachmentJob.constructQueueString(RestoreAttachmentJob.RestoreOperation.RESTORE_OFFLOADED))
AppDependencies.jobManager.cancelAllInQueue(RestoreAttachmentJob.constructQueueString(RestoreAttachmentJob.RestoreOperation.INITIAL_RESTORE))
AppDependencies.jobManager.cancelAllInQueue(RestoreAttachmentJob.constructQueueString(RestoreAttachmentJob.RestoreOperation.MANUAL))
}
/**

View file

@ -172,7 +172,7 @@ class BackupAlertBottomSheet : UpgradeToPaidTierBottomSheet() {
private fun displaySkipRestoreDialog() {
MaterialAlertDialogBuilder(requireContext())
.setTitle((R.string.BackupAlertBottomSheet__skip_restore_question))
.setMessage(R.string.BackupAlertBottomSheet__if_you_skip_restore)
.setMessage(R.string.BackupAlertBottomSheet__if_you_skip_restore_the)
.setPositiveButton(R.string.BackupAlertBottomSheet__skip) { _, _ ->
BackupRepository.skipMediaRestore()
}

View file

@ -78,7 +78,7 @@ class MediaRestoreProgressBanner(private val listener: RestoreProgressBannerList
)
}
private fun getActiveRestoreFlow(): Flow<BackupStatusData.RestoringMedia> {
private fun getActiveRestoreFlow(): Flow<BackupStatusData> {
val flow: Flow<Unit> = callbackFlow {
val onChange = { trySend(Unit) }
@ -115,11 +115,16 @@ class MediaRestoreProgressBanner(private val listener: RestoreProgressBannerList
val totalRestoreSize = SignalStore.backup.totalRestorableAttachmentSize
val remainingAttachmentSize = SignalDatabase.attachments.getRemainingRestorableAttachmentSize()
val completedBytes = totalRestoreSize - remainingAttachmentSize
val availableBytes = SignalStore.backup.spaceAvailableOnDiskBytes
if (availableBytes > -1L && remainingAttachmentSize > availableBytes) {
BackupStatusData.NotEnoughFreeSpace(requiredSpace = remainingAttachmentSize.bytes)
} else {
BackupStatusData.RestoringMedia(completedBytes.bytes, totalRestoreSize.bytes)
}
}
}
}
.flowOn(Dispatchers.IO)
}

View file

@ -5,7 +5,10 @@
package org.thoughtcrime.securesms.jobs
import kotlinx.coroutines.runBlocking
import okio.IOException
import org.signal.core.util.billing.BillingPurchaseResult
import org.signal.core.util.billing.BillingPurchaseState
import org.signal.core.util.logging.Log
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
@ -19,6 +22,7 @@ import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.JobManager.Chain
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import kotlin.concurrent.withLock
import kotlin.time.Duration.Companion.days
/**
* Submits a purchase token to the server to link it with a subscriber id.
@ -26,7 +30,7 @@ import kotlin.concurrent.withLock
class InAppPaymentPurchaseTokenJob private constructor(
private val inAppPaymentId: InAppPaymentTable.InAppPaymentId,
parameters: Parameters
) : BaseJob(parameters) {
) : Job(parameters) {
companion object {
private val TAG = Log.tag(InAppPaymentPurchaseTokenJob::class)
@ -39,7 +43,7 @@ class InAppPaymentPurchaseTokenJob private constructor(
parameters = Parameters.Builder()
.addConstraint(NetworkConstraint.KEY)
.setQueue(InAppPaymentsRepository.resolveJobQueueKey(inAppPayment))
.setLifespan(InAppPaymentsRepository.resolveContextJobLifespan(inAppPayment).inWholeMilliseconds)
.setLifespan(3.days.inWholeMilliseconds)
.setMaxAttempts(Parameters.UNLIMITED)
.build()
)
@ -76,14 +80,34 @@ class InAppPaymentPurchaseTokenJob private constructor(
}
}
override fun onRun() {
InAppPaymentsRepository.resolveLock(inAppPaymentId).withLock {
doRun()
override fun run(): Result {
return InAppPaymentsRepository.resolveLock(inAppPaymentId).withLock {
runBlocking { linkPurchaseToken() }
}
}
private fun doRun() {
val inAppPayment = getAndValidateInAppPayment()
private suspend fun linkPurchaseToken(): Result {
if (!AppDependencies.billingApi.isApiAvailable()) {
warning("Billing API is not available on this device. Exiting.")
return Result.failure()
}
val purchaseState: BillingPurchaseState = when (val purchase = AppDependencies.billingApi.queryPurchases()) {
is BillingPurchaseResult.Success -> purchase.purchaseState
else -> BillingPurchaseState.UNSPECIFIED
}
if (purchaseState != BillingPurchaseState.PURCHASED) {
warning("Billing purchase not in the PURCHASED state. Retrying later.")
return Result.retry(defaultBackoff())
}
val inAppPayment = try {
getAndValidateInAppPayment()
} catch (e: IOException) {
warning("Failed to validate in-app payment.", e)
return Result.failure()
}
val response = AppDependencies.donationsService.linkGooglePlayBillingPurchaseTokenToSubscriberId(
inAppPayment.subscriberId!!,
@ -92,12 +116,13 @@ class InAppPaymentPurchaseTokenJob private constructor(
)
if (response.applicationError.isPresent) {
handleApplicationError(response.applicationError.get(), response.status)
return handleApplicationError(response.applicationError.get(), response.status)
} else if (response.result.isPresent) {
info("Successfully linked purchase token to subscriber id.")
return Result.success()
} else {
warning("Encountered a retryable exception.", response.executionError.get())
throw InAppPaymentRetryException(response.executionError.get())
return Result.retry(defaultBackoff())
}
}
@ -141,22 +166,21 @@ class InAppPaymentPurchaseTokenJob private constructor(
return inAppPayment
}
private fun handleApplicationError(applicationError: Throwable, status: Int) {
when (status) {
private fun handleApplicationError(applicationError: Throwable, status: Int): Result {
return when (status) {
402 -> {
warning("The purchaseToken payment is incomplete or invalid.", applicationError)
// TODO [backups] -- Is this a recoverable failure?
throw IOException("TODO -- recoverable?")
Result.retry(defaultBackoff())
}
403 -> {
warning("subscriberId authentication failure OR account authentication is present", applicationError)
throw IOException("subscriberId authentication failure OR account authentication is present")
Result.failure()
}
404 -> {
warning("No such subscriberId exists or subscriberId is malformed or the purchaseToken does not exist", applicationError)
throw IOException("No such subscriberId exists or subscriberId is malformed or the purchaseToken does not exist")
Result.failure()
}
409 -> {
@ -165,9 +189,6 @@ class InAppPaymentPurchaseTokenJob private constructor(
try {
info("Generating a new subscriber id.")
RecurringInAppPaymentRepository.ensureSubscriberId(InAppPaymentSubscriberRecord.Type.BACKUP, true).blockingAwait()
} catch (e: Exception) {
throw InAppPaymentRetryException(e)
}
info("Writing the new subscriber id to the InAppPayment.")
val latest = SignalDatabase.inAppPayments.getById(inAppPaymentId)!!
@ -176,18 +197,20 @@ class InAppPaymentPurchaseTokenJob private constructor(
)
info("Scheduling retry.")
throw InAppPaymentRetryException()
} catch (e: Exception) {
warning("Failed to generate and update subscriber id. Retrying later.", e)
}
Result.retry(defaultBackoff())
}
else -> {
warning("An unknown error occurred.", applicationError)
throw IOException(applicationError)
Result.failure()
}
}
}
override fun onShouldRetry(e: Exception): Boolean = e is InAppPaymentRetryException
private fun info(message: String, throwable: Throwable? = null) {
Log.i(TAG, "InAppPayment[$inAppPaymentId]: $message", throwable, true)
}

View file

@ -131,7 +131,15 @@ class RestoreAttachmentJob private constructor(
@Throws(Exception::class)
override fun onRun() {
try {
doWork()
} catch (e: IOException) {
if (BackupRepository.checkForOutOfStorageError(TAG)) {
throw RetryLaterException(e)
} else {
throw e
}
}
if (!SignalDatabase.messages.isStory(messageId)) {
AppDependencies.messageNotifier.updateNotification(context, forConversation(0))

View file

@ -79,6 +79,15 @@ class RestoreAttachmentThumbnailJob private constructor(
@Throws(Exception::class, IOException::class, InvalidAttachmentException::class, InvalidMessageException::class, MissingConfigurationException::class)
public override fun onRun() {
try {
doWork()
} catch (e: IOException) {
BackupRepository.checkForOutOfStorageError(TAG)
throw e
}
}
private fun doWork() {
Log.i(TAG, "onRun() messageId: $messageId attachmentId: $attachmentId")
val attachment = SignalDatabase.attachments.getAttachment(attachmentId)

View file

@ -41,7 +41,7 @@ class RestoreOptimizedMediaJob private constructor(parameters: Parameters) : Job
)
override fun run(): Result {
if (SignalStore.backup.optimizeStorage) {
if (SignalStore.backup.optimizeStorage && !SignalStore.backup.userManuallySkippedMediaRestore) {
return Result.success()
}

View file

@ -57,6 +57,9 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
private const val KEY_BACKUP_FAIL_ACKNOWLEDGED_SNOOZE_TIME = "backup.failed.acknowledged.snooze.time"
private const val KEY_BACKUP_FAIL_ACKNOWLEDGED_SNOOZE_COUNT = "backup.failed.acknowledged.snooze.count"
private const val KEY_BACKUP_FAIL_SHEET_SNOOZE_TIME = "backup.failed.sheet.snooze"
private const val KEY_BACKUP_FAIL_SPACE_REMAINING = "backup.failed.space.remaining"
private const val KEY_USER_MANUALLY_SKIPPED_MEDIA_RESTORE = "backup.user.manually.skipped.media.restore"
private const val KEY_MEDIA_ROOT_BACKUP_KEY = "backup.mediaRootBackupKey"
@ -89,6 +92,8 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
var lastMediaSyncTime: Long by longValue(KEY_LAST_BACKUP_MEDIA_SYNC_TIME, -1)
var backupFrequency: BackupFrequency by enumValue(KEY_BACKUP_FREQUENCY, BackupFrequency.MANUAL, BackupFrequency.Serializer)
var userManuallySkippedMediaRestore: Boolean by booleanValue(KEY_USER_MANUALLY_SKIPPED_MEDIA_RESTORE, false)
/**
* Key used to backup messages.
*/
@ -172,10 +177,19 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
/** True if we believe we have successfully uploaded a backup, otherwise false. */
var hasBackupBeenUploaded: Boolean by booleanValue(KEY_BACKUP_UPLOADED, false)
val hasBackupFailure: Boolean = getBoolean(KEY_BACKUP_FAIL, false)
val hasBackupFailure: Boolean get() = getBoolean(KEY_BACKUP_FAIL, false)
val nextBackupFailureSnoozeTime: Duration get() = getLong(KEY_BACKUP_FAIL_ACKNOWLEDGED_SNOOZE_TIME, 0L).milliseconds
val nextBackupFailureSheetSnoozeTime: Duration get() = getLong(KEY_BACKUP_FAIL_SHEET_SNOOZE_TIME, getNextBackupFailureSheetSnoozeTime(lastBackupTime.milliseconds).inWholeMilliseconds).milliseconds
/**
* Denotes how many bytes are still available on the disk for writing. Used to display
* the disk full error and sheet. Set when we believe there might be an "out of space"
* failure in BackupRestoreMediaJob and each time the application is brought into the
* foreground. We never clear this value, so it can't be used as an indicator that
* something bad happened, it can only be utilized as a reference for comparison.
*/
var spaceAvailableOnDiskBytes: Long by longValue(KEY_BACKUP_FAIL_SPACE_REMAINING, -1L)
/**
* Call when the user disables backups. Clears/resets all relevant fields.
*/

View file

@ -7473,7 +7473,7 @@
<!-- Dialog title for skipping media restore -->
<string name="BackupAlertBottomSheet__skip_restore_question">Skip restore?</string>
<!-- Dialog text for skipping media restore -->
<string name="BackupAlertBottomSheet__if_you_skip_restore">If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup.</string>
<string name="BackupAlertBottomSheet__if_you_skip_restore_the">If you skip restore the remaining media and attachments in your backup can be downloaded at a later time when storage space becomes available.</string>
<!-- BackupStatus -->
<!-- Status title when user does not have enough free space to download their media. Placeholder is required disk space. -->

View file

@ -16,6 +16,7 @@ import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.PendingPurchasesParams
import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.ProductDetailsResult
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.PurchasesUpdatedListener
import com.android.billingclient.api.QueryProductDetailsParams
import com.android.billingclient.api.QueryPurchasesParams
@ -41,6 +42,7 @@ import org.signal.core.util.billing.BillingDependencies
import org.signal.core.util.billing.BillingError
import org.signal.core.util.billing.BillingProduct
import org.signal.core.util.billing.BillingPurchaseResult
import org.signal.core.util.billing.BillingPurchaseState
import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import java.math.BigDecimal
@ -80,6 +82,7 @@ internal class BillingApiImpl(
} else {
Log.d(TAG, "purchasesUpdatedListener: successful purchase at ${newestPurchase.purchaseTime}")
BillingPurchaseResult.Success(
purchaseState = newestPurchase.purchaseState.toBillingPurchaseState(),
purchaseToken = newestPurchase.purchaseToken,
isAcknowledged = newestPurchase.isAcknowledged,
purchaseTime = newestPurchase.purchaseTime,
@ -202,6 +205,7 @@ internal class BillingApiImpl(
val purchase = result.purchasesList.maxByOrNull { it.purchaseTime } ?: return BillingPurchaseResult.None
return BillingPurchaseResult.Success(
purchaseState = purchase.purchaseState.toBillingPurchaseState(),
purchaseTime = purchase.purchaseTime,
purchaseToken = purchase.purchaseToken,
isAcknowledged = purchase.isAcknowledged,
@ -256,6 +260,14 @@ internal class BillingApiImpl(
}
}
private fun Int.toBillingPurchaseState(): BillingPurchaseState {
return when (this) {
Purchase.PurchaseState.PURCHASED -> BillingPurchaseState.PURCHASED
Purchase.PurchaseState.PENDING -> BillingPurchaseState.PENDING
else -> BillingPurchaseState.UNSPECIFIED
}
}
private suspend fun queryProductsInternal(): ProductDetailsResult {
val productList = listOf(
QueryProductDetailsParams.Product.newBuilder()

View file

@ -95,6 +95,10 @@ class ByteSize(val bytes: Long) {
return "${formatter.format(size)}${if (spaced) " " else ""}${unit.label}"
}
operator fun compareTo(other: ByteSize): Int {
return bytes.compareTo(other.bytes)
}
enum class Size(val label: String) {
BYTE("B"),
KIBIBYTE("KB"),

View file

@ -14,6 +14,7 @@ import kotlin.time.Duration.Companion.milliseconds
*/
sealed interface BillingPurchaseResult {
data class Success(
val purchaseState: BillingPurchaseState,
val purchaseToken: String,
val isAcknowledged: Boolean,
val purchaseTime: Long,

View file

@ -0,0 +1,15 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.util.billing
/**
* BillingPurchaseState which aligns with the Google Play Billing purchased state.
*/
enum class BillingPurchaseState {
UNSPECIFIED,
PURCHASED,
PENDING
}