diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index e1be422508..a8fcd21042 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -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. diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt index 432d6ca389..6433a5261b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt @@ -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)) } /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertBottomSheet.kt index 9d727ad10f..ba6c4a2bcf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertBottomSheet.kt @@ -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() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/banner/banners/MediaRestoreProgressBanner.kt b/app/src/main/java/org/thoughtcrime/securesms/banner/banners/MediaRestoreProgressBanner.kt index e5c0f1ce12..0bfbbe0414 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/banner/banners/MediaRestoreProgressBanner.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/banner/banners/MediaRestoreProgressBanner.kt @@ -78,7 +78,7 @@ class MediaRestoreProgressBanner(private val listener: RestoreProgressBannerList ) } - private fun getActiveRestoreFlow(): Flow { + private fun getActiveRestoreFlow(): Flow { val flow: Flow = callbackFlow { val onChange = { trySend(Unit) } @@ -115,8 +115,13 @@ 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 - BackupStatusData.RestoringMedia(completedBytes.bytes, totalRestoreSize.bytes) + if (availableBytes > -1L && remainingAttachmentSize > availableBytes) { + BackupStatusData.NotEnoughFreeSpace(requiredSpace = remainingAttachmentSize.bytes) + } else { + BackupStatusData.RestoringMedia(completedBytes.bytes, totalRestoreSize.bytes) + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentPurchaseTokenJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentPurchaseTokenJob.kt index 65b879228f..6c84e85579 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentPurchaseTokenJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentPurchaseTokenJob.kt @@ -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,29 +189,28 @@ class InAppPaymentPurchaseTokenJob private constructor( try { info("Generating a new subscriber id.") RecurringInAppPaymentRepository.ensureSubscriberId(InAppPaymentSubscriberRecord.Type.BACKUP, true).blockingAwait() + + info("Writing the new subscriber id to the InAppPayment.") + val latest = SignalDatabase.inAppPayments.getById(inAppPaymentId)!! + SignalDatabase.inAppPayments.update( + latest.copy(subscriberId = InAppPaymentsRepository.requireSubscriber(InAppPaymentSubscriberRecord.Type.BACKUP).subscriberId) + ) + + info("Scheduling retry.") } catch (e: Exception) { - throw InAppPaymentRetryException(e) + warning("Failed to generate and update subscriber id. Retrying later.", e) } - info("Writing the new subscriber id to the InAppPayment.") - val latest = SignalDatabase.inAppPayments.getById(inAppPaymentId)!! - SignalDatabase.inAppPayments.update( - latest.copy(subscriberId = InAppPaymentsRepository.requireSubscriber(InAppPaymentSubscriberRecord.Type.BACKUP).subscriberId) - ) - - info("Scheduling retry.") - throw InAppPaymentRetryException() + 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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentJob.kt index 1ed217f59e..12b9c79335 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentJob.kt @@ -131,7 +131,15 @@ class RestoreAttachmentJob private constructor( @Throws(Exception::class) override fun onRun() { - doWork() + 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)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentThumbnailJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentThumbnailJob.kt index 89b5d7424a..92b6157b62 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentThumbnailJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentThumbnailJob.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreOptimizedMediaJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreOptimizedMediaJob.kt index 5591ae4ff8..3a98128fdc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreOptimizedMediaJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreOptimizedMediaJob.kt @@ -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() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt index c42edcb9d3..178969e301 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt @@ -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. */ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d164be15d8..d131d7b31f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7473,7 +7473,7 @@ 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. + If you skip restore the remaining media and attachments in your backup can be downloaded at a later time when storage space becomes available. diff --git a/billing/src/main/java/org/signal/billing/BillingApiImpl.kt b/billing/src/main/java/org/signal/billing/BillingApiImpl.kt index c58b928872..2d01a35c53 100644 --- a/billing/src/main/java/org/signal/billing/BillingApiImpl.kt +++ b/billing/src/main/java/org/signal/billing/BillingApiImpl.kt @@ -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() diff --git a/core-util-jvm/src/main/java/org/signal/core/util/ByteExtensions.kt b/core-util-jvm/src/main/java/org/signal/core/util/ByteExtensions.kt index 6d372cd126..90d50fb17a 100644 --- a/core-util-jvm/src/main/java/org/signal/core/util/ByteExtensions.kt +++ b/core-util-jvm/src/main/java/org/signal/core/util/ByteExtensions.kt @@ -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"), diff --git a/core-util/src/main/java/org/signal/core/util/billing/BillingPurchaseResult.kt b/core-util/src/main/java/org/signal/core/util/billing/BillingPurchaseResult.kt index 389c96b335..61e21096ff 100644 --- a/core-util/src/main/java/org/signal/core/util/billing/BillingPurchaseResult.kt +++ b/core-util/src/main/java/org/signal/core/util/billing/BillingPurchaseResult.kt @@ -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, diff --git a/core-util/src/main/java/org/signal/core/util/billing/BillingPurchaseState.kt b/core-util/src/main/java/org/signal/core/util/billing/BillingPurchaseState.kt new file mode 100644 index 0000000000..1f97b9acd4 --- /dev/null +++ b/core-util/src/main/java/org/signal/core/util/billing/BillingPurchaseState.kt @@ -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 +}