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.signal.ringrtc.CallManager;
import org.thoughtcrime.securesms.apkupdate.ApkUpdateRefreshListener; import org.thoughtcrime.securesms.apkupdate.ApkUpdateRefreshListener;
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage; import org.thoughtcrime.securesms.avatar.AvatarPickerStorage;
import org.thoughtcrime.securesms.backup.v2.BackupRepository;
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider; import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider;
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider; import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider;
import org.thoughtcrime.securesms.database.LogDatabase; import org.thoughtcrime.securesms.database.LogDatabase;
@ -253,6 +254,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
KeyCachingService.onAppForegrounded(this); KeyCachingService.onAppForegrounded(this);
AppDependencies.getShakeToReport().enable(); AppDependencies.getShakeToReport().enable();
checkBuildExpiration(); checkBuildExpiration();
checkFreeDiskSpace();
MemoryTracker.start(); MemoryTracker.start();
BackupSubscriptionCheckJob.enqueueIfAble(); 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. * 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. * 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.dependencies.AppDependencies
import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
import org.thoughtcrime.securesms.jobs.RestoreAttachmentJob
import org.thoughtcrime.securesms.keyvalue.KeyValueStore import org.thoughtcrime.securesms.keyvalue.KeyValueStore
import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.SignalNetwork 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 { fun getFreeStorageSpace(): ByteSize {
val statFs = StatFs(Environment.getDataDirectory().absolutePath) val statFs = StatFs(Environment.getDataDirectory().absolutePath)
val free = (statFs.availableBlocksLong) * statFs.blockSizeLong val free = (statFs.availableBlocksLong) * statFs.blockSizeLong
@ -138,9 +142,33 @@ object BackupRepository {
return free.bytes 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 @JvmStatic
fun skipMediaRestore() { 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() { private fun displaySkipRestoreDialog() {
MaterialAlertDialogBuilder(requireContext()) MaterialAlertDialogBuilder(requireContext())
.setTitle((R.string.BackupAlertBottomSheet__skip_restore_question)) .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) { _, _ -> .setPositiveButton(R.string.BackupAlertBottomSheet__skip) { _, _ ->
BackupRepository.skipMediaRestore() 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 flow: Flow<Unit> = callbackFlow {
val onChange = { trySend(Unit) } val onChange = { trySend(Unit) }
@ -115,11 +115,16 @@ class MediaRestoreProgressBanner(private val listener: RestoreProgressBannerList
val totalRestoreSize = SignalStore.backup.totalRestorableAttachmentSize val totalRestoreSize = SignalStore.backup.totalRestorableAttachmentSize
val remainingAttachmentSize = SignalDatabase.attachments.getRemainingRestorableAttachmentSize() val remainingAttachmentSize = SignalDatabase.attachments.getRemainingRestorableAttachmentSize()
val completedBytes = totalRestoreSize - remainingAttachmentSize 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) BackupStatusData.RestoringMedia(completedBytes.bytes, totalRestoreSize.bytes)
} }
} }
} }
}
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
} }

View file

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

View file

@ -131,7 +131,15 @@ class RestoreAttachmentJob private constructor(
@Throws(Exception::class) @Throws(Exception::class)
override fun onRun() { override fun onRun() {
try {
doWork() doWork()
} catch (e: IOException) {
if (BackupRepository.checkForOutOfStorageError(TAG)) {
throw RetryLaterException(e)
} else {
throw e
}
}
if (!SignalDatabase.messages.isStory(messageId)) { if (!SignalDatabase.messages.isStory(messageId)) {
AppDependencies.messageNotifier.updateNotification(context, forConversation(0)) 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) @Throws(Exception::class, IOException::class, InvalidAttachmentException::class, InvalidMessageException::class, MissingConfigurationException::class)
public override fun onRun() { 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") Log.i(TAG, "onRun() messageId: $messageId attachmentId: $attachmentId")
val attachment = SignalDatabase.attachments.getAttachment(attachmentId) val attachment = SignalDatabase.attachments.getAttachment(attachmentId)

View file

@ -41,7 +41,7 @@ class RestoreOptimizedMediaJob private constructor(parameters: Parameters) : Job
) )
override fun run(): Result { override fun run(): Result {
if (SignalStore.backup.optimizeStorage) { if (SignalStore.backup.optimizeStorage && !SignalStore.backup.userManuallySkippedMediaRestore) {
return Result.success() 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_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_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_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" 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 lastMediaSyncTime: Long by longValue(KEY_LAST_BACKUP_MEDIA_SYNC_TIME, -1)
var backupFrequency: BackupFrequency by enumValue(KEY_BACKUP_FREQUENCY, BackupFrequency.MANUAL, BackupFrequency.Serializer) 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. * 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. */ /** True if we believe we have successfully uploaded a backup, otherwise false. */
var hasBackupBeenUploaded: Boolean by booleanValue(KEY_BACKUP_UPLOADED, 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 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 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. * Call when the user disables backups. Clears/resets all relevant fields.
*/ */

View file

@ -7473,7 +7473,7 @@
<!-- Dialog title for skipping media restore --> <!-- Dialog title for skipping media restore -->
<string name="BackupAlertBottomSheet__skip_restore_question">Skip restore?</string> <string name="BackupAlertBottomSheet__skip_restore_question">Skip restore?</string>
<!-- Dialog text for skipping media restore --> <!-- 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 --> <!-- BackupStatus -->
<!-- Status title when user does not have enough free space to download their media. Placeholder is required disk space. --> <!-- 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.PendingPurchasesParams
import com.android.billingclient.api.ProductDetails import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.ProductDetailsResult import com.android.billingclient.api.ProductDetailsResult
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.PurchasesUpdatedListener import com.android.billingclient.api.PurchasesUpdatedListener
import com.android.billingclient.api.QueryProductDetailsParams import com.android.billingclient.api.QueryProductDetailsParams
import com.android.billingclient.api.QueryPurchasesParams 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.BillingError
import org.signal.core.util.billing.BillingProduct import org.signal.core.util.billing.BillingProduct
import org.signal.core.util.billing.BillingPurchaseResult 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.logging.Log
import org.signal.core.util.money.FiatMoney import org.signal.core.util.money.FiatMoney
import java.math.BigDecimal import java.math.BigDecimal
@ -80,6 +82,7 @@ internal class BillingApiImpl(
} else { } else {
Log.d(TAG, "purchasesUpdatedListener: successful purchase at ${newestPurchase.purchaseTime}") Log.d(TAG, "purchasesUpdatedListener: successful purchase at ${newestPurchase.purchaseTime}")
BillingPurchaseResult.Success( BillingPurchaseResult.Success(
purchaseState = newestPurchase.purchaseState.toBillingPurchaseState(),
purchaseToken = newestPurchase.purchaseToken, purchaseToken = newestPurchase.purchaseToken,
isAcknowledged = newestPurchase.isAcknowledged, isAcknowledged = newestPurchase.isAcknowledged,
purchaseTime = newestPurchase.purchaseTime, purchaseTime = newestPurchase.purchaseTime,
@ -202,6 +205,7 @@ internal class BillingApiImpl(
val purchase = result.purchasesList.maxByOrNull { it.purchaseTime } ?: return BillingPurchaseResult.None val purchase = result.purchasesList.maxByOrNull { it.purchaseTime } ?: return BillingPurchaseResult.None
return BillingPurchaseResult.Success( return BillingPurchaseResult.Success(
purchaseState = purchase.purchaseState.toBillingPurchaseState(),
purchaseTime = purchase.purchaseTime, purchaseTime = purchase.purchaseTime,
purchaseToken = purchase.purchaseToken, purchaseToken = purchase.purchaseToken,
isAcknowledged = purchase.isAcknowledged, 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 { private suspend fun queryProductsInternal(): ProductDetailsResult {
val productList = listOf( val productList = listOf(
QueryProductDetailsParams.Product.newBuilder() QueryProductDetailsParams.Product.newBuilder()

View file

@ -95,6 +95,10 @@ class ByteSize(val bytes: Long) {
return "${formatter.format(size)}${if (spaced) " " else ""}${unit.label}" 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) { enum class Size(val label: String) {
BYTE("B"), BYTE("B"),
KIBIBYTE("KB"), KIBIBYTE("KB"),

View file

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