Detect storage space issues during restore.
This commit is contained in:
parent
b4472833b8
commit
7f1a866e79
14 changed files with 163 additions and 35 deletions
|
@ -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.
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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. -->
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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"),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
Loading…
Add table
Reference in a new issue