Fix InAppPayments database inconsistency.

This commit is contained in:
Alex Hart
2025-03-13 15:35:14 -03:00
committed by Cody Henthorne
parent 8d53c1b384
commit 14f99bba24
4 changed files with 89 additions and 13 deletions

View File

@@ -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(

View File

@@ -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.
*/

View File

@@ -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

View File

@@ -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())
)
}
}