From 14f99bba249722b1d58cc3e46e458e3378c984d2 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Thu, 13 Mar 2025 15:35:14 -0300 Subject: [PATCH] Fix InAppPayments database inconsistency. --- .../MessageBackupsFlowViewModel.kt | 17 ++---- .../securesms/database/InAppPaymentTable.kt | 21 +++++++ .../helpers/SignalDatabaseMigrations.kt | 4 +- ...8_FixInAppPaymentsErrorStateConsistency.kt | 60 +++++++++++++++++++ 4 files changed, 89 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V268_FixInAppPaymentsErrorStateConsistency.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt index ad3a0b50af..90d7261e0d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt @@ -5,7 +5,6 @@ package org.thoughtcrime.securesms.backup.v2.ui.subscription -import androidx.annotation.WorkerThread import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.FlowPreview @@ -251,25 +250,19 @@ class MessageBackupsFlowViewModel( } } - /** - * Ensures we have a SubscriberId created and available for use. This is considered safe because - * the screen this is called in is assumed to only be accessible if the user does not currently have - * a subscription. - */ - @WorkerThread - private fun ensureSubscriberIdForBackups(purchaseToken: IAPSubscriptionId.GooglePlayBillingPurchaseToken) { - RecurringInAppPaymentRepository.ensureSubscriberId(InAppPaymentSubscriberRecord.Type.BACKUP, iapSubscriptionId = purchaseToken).blockingAwait() - } - /** * Handles a successful BillingPurchaseResult. Updates the in app payment, enqueues the appropriate job chain, * and handles any resulting error. Like donations, we will wait up to 10s for the completion of the job chain. + * + * This will always rotate the subscriber-id. */ @OptIn(FlowPreview::class) private suspend fun handleSuccess(result: BillingPurchaseResult.Success, inAppPaymentId: InAppPaymentTable.InAppPaymentId) { withContext(SignalDispatchers.IO) { Log.d(TAG, "Setting purchase token data on InAppPayment and InAppPaymentSubscriber.") - ensureSubscriberIdForBackups(IAPSubscriptionId.GooglePlayBillingPurchaseToken(result.purchaseToken)) + + val iapSubscriptionId = IAPSubscriptionId.GooglePlayBillingPurchaseToken(result.purchaseToken) + RecurringInAppPaymentRepository.ensureSubscriberId(InAppPaymentSubscriberRecord.Type.BACKUP, iapSubscriptionId = iapSubscriptionId, isRotation = true).blockingAwait() val inAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)!! SignalDatabase.inAppPayments.update( diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/InAppPaymentTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/InAppPaymentTable.kt index 8de3716196..cbd02e499a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/InAppPaymentTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/InAppPaymentTable.kt @@ -32,6 +32,7 @@ import org.signal.core.util.withinTransaction import org.signal.donations.InAppPaymentType import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.jobs.InAppPaymentKeepAliveJob import org.thoughtcrime.securesms.util.parcelers.MillisecondDurationParceler import org.thoughtcrime.securesms.util.parcelers.NullableSubscriberIdParceler import org.whispersystems.signalservice.api.subscriptions.SubscriberId @@ -134,6 +135,9 @@ class InAppPaymentTable(context: Context, databaseHelper: SignalDatabase) : Data inAppPaymentData: InAppPaymentData ): InAppPaymentId { val now = System.currentTimeMillis() + + validateInAppPayment(state, inAppPaymentData) + return writableDatabase.insertInto(TABLE_NAME) .values( TYPE to type.code, @@ -153,6 +157,9 @@ class InAppPaymentTable(context: Context, databaseHelper: SignalDatabase) : Data inAppPayment: InAppPayment ) { val updated = inAppPayment.copy(updatedAt = System.currentTimeMillis().milliseconds) + + validateInAppPayment(updated.state, updated.data) + writableDatabase.update(TABLE_NAME) .values(InAppPayment.serialize(updated)) .where(ID_WHERE, inAppPayment.id) @@ -305,6 +312,20 @@ class InAppPaymentTable(context: Context, databaseHelper: SignalDatabase) : Data .readToSingleObject(InAppPayment.Companion) } + /** + * Validates the given InAppPayment properties and throws an exception if they're invalid. + */ + private fun validateInAppPayment( + state: State, + inAppPaymentData: InAppPaymentData + ) { + if (inAppPaymentData.error?.data_ == InAppPaymentKeepAliveJob.KEEP_ALIVE) { + check(state == State.PENDING) { "Data has keep-alive error: Expected PENDING state but was $state." } + } else if (inAppPaymentData.error != null) { + check(state == State.END) { "Data has error: Expected END state but was $state" } + } + } + /** * Represents a database row. Nicer than returning a raw value. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt index 67dd9bccfc..5300a8d8b2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt @@ -122,6 +122,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V264_FixGroupAddMem import org.thoughtcrime.securesms.database.helpers.migration.V265_FixFtsTriggers import org.thoughtcrime.securesms.database.helpers.migration.V266_UniqueThreadPinOrder import org.thoughtcrime.securesms.database.helpers.migration.V267_FixGroupInvitationDeclinedUpdate +import org.thoughtcrime.securesms.database.helpers.migration.V268_FixInAppPaymentsErrorStateConsistency import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase /** @@ -249,7 +250,8 @@ object SignalDatabaseMigrations { 264 to V264_FixGroupAddMemberUpdate, 265 to V265_FixFtsTriggers, 266 to V266_UniqueThreadPinOrder, - 267 to V267_FixGroupInvitationDeclinedUpdate + 267 to V267_FixGroupInvitationDeclinedUpdate, + 268 to V268_FixInAppPaymentsErrorStateConsistency ) const val DATABASE_VERSION = 268 diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V268_FixInAppPaymentsErrorStateConsistency.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V268_FixInAppPaymentsErrorStateConsistency.kt new file mode 100644 index 0000000000..182879a74e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V268_FixInAppPaymentsErrorStateConsistency.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.database.helpers.migration + +import android.app.Application +import androidx.core.content.contentValuesOf +import org.signal.core.util.forEach +import org.signal.core.util.logging.Log +import org.signal.core.util.requireLong +import org.signal.core.util.requireNonNullBlob +import org.thoughtcrime.securesms.database.InAppPaymentTable +import org.thoughtcrime.securesms.database.SQLiteDatabase +import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData + +/** + * Ensure consistent [InAppPaymentTable.State] and [InAppPaymentData.Error] state across the database. + */ +@Suppress("ClassName") +object V268_FixInAppPaymentsErrorStateConsistency : SignalDatabaseMigration { + private const val KEEP_ALIVE = "keep-alive" + private const val STATE_PENDING = 2L + private const val STATE_END = 3L + + private val TAG = Log.tag(V268_FixInAppPaymentsErrorStateConsistency::class) + + override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + db.query("SELECT _id, state, data FROM in_app_payment").forEach { + val id = it.requireLong("_id") + val state = it.requireLong("state") + val data = InAppPaymentData.ADAPTER.decode(it.requireNonNullBlob("data")) + + if (data.error?.data_ == KEEP_ALIVE && state != STATE_PENDING) { + Log.d(TAG, "Detected a data inconsistency. Expected PENDING state but was State:$state") + val newData = data.newBuilder().error( + data.error.newBuilder().data_(null).build() + ).build() + + updateInAppPayment(db, id, newData) + } else if (data.error != null && state != STATE_END) { + Log.d(TAG, "Detected a data inconsistency. Expected END state but was State:$state") + updateInAppPayment(db, id, data) + } + } + } + + private fun updateInAppPayment(db: SQLiteDatabase, id: Long, data: InAppPaymentData) { + db.update( + "in_app_payment", + contentValuesOf( + "state" to STATE_END, + "data" to data.encode() + ), + "_id = ?", + arrayOf(id.toString()) + ) + } +}