diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/helpers/migration/FixInAppCurrencyIfAbleTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/helpers/migration/FixInAppCurrencyIfAbleTest.kt new file mode 100644 index 0000000000..ea471c92ad --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/helpers/migration/FixInAppCurrencyIfAbleTest.kt @@ -0,0 +1,148 @@ +/* + * 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.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.signal.core.util.readToSingleObject +import org.signal.core.util.requireNonNullString +import org.signal.core.util.select +import org.signal.core.util.update +import org.signal.donations.InAppPaymentType +import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toDecimalValue +import org.thoughtcrime.securesms.database.InAppPaymentSubscriberTable +import org.thoughtcrime.securesms.database.InAppPaymentTable +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord +import org.thoughtcrime.securesms.database.model.databaseprotos.FiatValue +import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData +import org.thoughtcrime.securesms.testing.SignalDatabaseRule +import org.thoughtcrime.securesms.testing.assertIs +import org.whispersystems.signalservice.api.subscriptions.SubscriberId +import java.math.BigDecimal +import java.util.Currency + +@RunWith(AndroidJUnit4::class) +class FixInAppCurrencyIfAbleTest { + + @get:Rule + val harness = SignalDatabaseRule(deleteAllThreadsOnEachRun = false) + + @Test + fun givenNoSubscribers_whenIMigrate_thenIDoNothing() { + migrate() + } + + @Test + fun givenASubscriberButNoPayment_whenIMigrate_thenIDoNothing() { + val subscriber = insertSubscriber("USD") + clearCurrencyCode(subscriber) + migrate() + + getCurrencyCode(subscriber) assertIs "" + } + + @Test + fun givenASubscriberAndMismatchedPayment_whenIMigrate_thenIDoNothing() { + val subscriber = insertSubscriber("USD") + val otherSubscriber = insertSubscriber("EUR") + insertPayment(otherSubscriber) + clearCurrencyCode(subscriber) + migrate() + + getCurrencyCode(subscriber) assertIs "" + } + + @Test + fun givenASubscriberAndPaymentWithNoSubscriber_whenIMigrate_thenDoNothing() { + val subscriber = insertSubscriber("USD") + insertPayment(null) + clearCurrencyCode(subscriber) + migrate() + + getCurrencyCode(subscriber) assertIs "" + } + + @Test + fun givenASubscriberAndMatchingPayment_whenIMigrate_thenUpdateCurrencyCode() { + val subscriber = insertSubscriber("USD") + insertPayment(subscriber) + clearCurrencyCode(subscriber) + migrate() + + getCurrencyCode(subscriber) assertIs "USD" + } + + @Test + fun givenASupercededSubscriber_whenIMigrate_thenIDoNothing() { + val oldSubscriber = insertSubscriber("USD") + insertPayment(oldSubscriber) + clearCurrencyCode(oldSubscriber) + insertSubscriber("USD") + migrate() + } + + private fun migrate() { + V236_FixInAppSubscriberCurrencyIfAble.migrate( + context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application, + db = SignalDatabase.rawDatabase, + oldVersion = 0, + newVersion = 0 + ) + } + + private fun insertSubscriber(currencyCode: String): InAppPaymentSubscriberRecord { + val record = InAppPaymentSubscriberRecord( + subscriberId = SubscriberId.generate(), + currency = Currency.getInstance(currencyCode), + type = InAppPaymentSubscriberRecord.Type.DONATION, + requiresCancel = false, + paymentMethodType = InAppPaymentData.PaymentMethodType.PAYPAL + ) + + SignalDatabase.inAppPaymentSubscribers.insertOrReplace(record) + + return record + } + + private fun clearCurrencyCode(inAppPaymentSubscriberRecord: InAppPaymentSubscriberRecord) { + SignalDatabase.rawDatabase.update(InAppPaymentSubscriberTable.TABLE_NAME) + .values(InAppPaymentSubscriberTable.CURRENCY_CODE to "") + .where("${InAppPaymentSubscriberTable.SUBSCRIBER_ID} = ?", inAppPaymentSubscriberRecord.subscriberId.serialize()) + .run() + } + + private fun getCurrencyCode(inAppPaymentSubscriberRecord: InAppPaymentSubscriberRecord): String { + return SignalDatabase.rawDatabase.select(InAppPaymentSubscriberTable.CURRENCY_CODE) + .from(InAppPaymentSubscriberTable.TABLE_NAME) + .where("${InAppPaymentSubscriberTable.SUBSCRIBER_ID} = ?", inAppPaymentSubscriberRecord.subscriberId.serialize()) + .run() + .readToSingleObject { it.requireNonNullString(InAppPaymentSubscriberTable.CURRENCY_CODE) }!! + } + + private fun insertPayment(inAppPaymentSubscriberRecord: InAppPaymentSubscriberRecord?): InAppPaymentTable.InAppPayment { + val id = SignalDatabase.inAppPayments.insert( + type = InAppPaymentType.RECURRING_DONATION, + state = InAppPaymentTable.State.END, + subscriberId = inAppPaymentSubscriberRecord?.subscriberId, + endOfPeriod = null, + inAppPaymentData = InAppPaymentData( + amount = FiatValue( + currencyCode = inAppPaymentSubscriberRecord?.currency?.currencyCode ?: "USD", + amount = BigDecimal.ONE.toDecimalValue() + ), + level = 200, + paymentMethodType = inAppPaymentSubscriberRecord?.paymentMethodType ?: InAppPaymentData.PaymentMethodType.UNKNOWN + ) + ) + + return SignalDatabase.inAppPayments.getById(id)!! + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/InAppPaymentSubscriberTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/InAppPaymentSubscriberTable.kt index 75840495f2..ce71188da0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/InAppPaymentSubscriberTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/InAppPaymentSubscriberTable.kt @@ -45,10 +45,12 @@ class InAppPaymentSubscriberTable( private const val ID = "_id" /** The serialized subscriber id */ - private const val SUBSCRIBER_ID = "subscriber_id" + @VisibleForTesting + const val SUBSCRIBER_ID = "subscriber_id" /** The currency code for this subscriber id */ - private const val CURRENCY_CODE = "currency_code" + @VisibleForTesting + const val CURRENCY_CODE = "currency_code" /** The type of subscription used by this subscriber id */ private const val TYPE = "type" 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 8a8f34dd81..e66751ceb9 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 @@ -93,6 +93,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V232_CreateInAppPay import org.thoughtcrime.securesms.database.helpers.migration.V233_FixInAppPaymentTableDefaultNotifiedValue import org.thoughtcrime.securesms.database.helpers.migration.V234_ThumbnailRestoreStateColumn import org.thoughtcrime.securesms.database.helpers.migration.V235_AttachmentUuidColumn +import org.thoughtcrime.securesms.database.helpers.migration.V236_FixInAppSubscriberCurrencyIfAble /** * Contains all of the database migrations for [SignalDatabase]. Broken into a separate file for cleanliness. @@ -188,10 +189,11 @@ object SignalDatabaseMigrations { 232 to V232_CreateInAppPaymentTable, 233 to V233_FixInAppPaymentTableDefaultNotifiedValue, 234 to V234_ThumbnailRestoreStateColumn, - 235 to V235_AttachmentUuidColumn + 235 to V235_AttachmentUuidColumn, + 236 to V236_FixInAppSubscriberCurrencyIfAble ) - const val DATABASE_VERSION = 235 + const val DATABASE_VERSION = 236 @JvmStatic fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V236_FixInAppSubscriberCurrencyIfAble.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V236_FixInAppSubscriberCurrencyIfAble.kt new file mode 100644 index 0000000000..3f33e9b865 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V236_FixInAppSubscriberCurrencyIfAble.kt @@ -0,0 +1,84 @@ +/* + * 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 net.zetetic.database.sqlcipher.SQLiteDatabase +import org.signal.core.util.logging.Log +import org.signal.core.util.update +import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData + +/** + * Fixes a bug in storage sync where we could end up overwriting a subscriber's currency code with + * an empty string. This can't happen anymore, as we now require a Currency on the InAppPaymentSubscriberRecord + * instead of just a currency code string. + * + * If a subscriber has a null or empty currency code, we try to load the code from the + * in app payments table. We utilize CONFLICT_IGNORE because if there's already a new subscriber id + * created, we don't want to impact it. + * + * Because the data column is a protobuf encoded blob, we cannot do a raw query here. + */ +@Suppress("ClassName") +object V236_FixInAppSubscriberCurrencyIfAble : SignalDatabaseMigration { + + private val TAG = Log.tag(V236_FixInAppSubscriberCurrencyIfAble::class) + + override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + val subscriberIds: List = db.query( + "in_app_payment_subscriber", + arrayOf("subscriber_id"), + "currency_code = ?", + arrayOf(""), + null, + null, + "_id DESC" + ).use { cursor -> + val ids = mutableListOf() + val columnIndex = cursor.getColumnIndexOrThrow("subscriber_id") + while (cursor.moveToNext()) { + ids.add(cursor.getString(columnIndex)) + } + + ids + } + + for (id in subscriberIds) { + val currencyCode: String? = db.query( + "in_app_payment", + arrayOf("data"), + "subscriber_id = ?", + arrayOf(id), + null, + null, + "inserted_at DESC", + "1" + ).use { cursor -> + if (cursor.moveToFirst()) { + val columnIndex = cursor.getColumnIndexOrThrow("data") + val rawData = cursor.getBlob(columnIndex) + val data = InAppPaymentData.ADAPTER.decode(rawData) + + data.amount?.currencyCode + } else { + null + } + } + + if (currencyCode != null) { + Log.d(TAG, "Found and attempting to heal subscriber of currency $currencyCode") + db.update( + "in_app_payment_subscriber", + SQLiteDatabase.CONFLICT_IGNORE, + contentValuesOf("currency_code" to currencyCode), + "subscriber_id = ?", + arrayOf(id) + ) + } + } + } +}