mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-21 17:29:32 +01:00
Rewrite in-app-payment flows to prepare for backups support.
This commit is contained in:
committed by
Cody Henthorne
parent
b36b00a11c
commit
d719edf104
@@ -48,6 +48,7 @@ public class DatabaseObserver {
|
||||
|
||||
private static final String KEY_CALL_UPDATES = "CallUpdates";
|
||||
private static final String KEY_CALL_LINK_UPDATES = "CallLinkUpdates";
|
||||
private static final String KEY_IN_APP_PAYMENTS = "InAppPayments";
|
||||
|
||||
private final Application application;
|
||||
private final Executor executor;
|
||||
@@ -69,6 +70,7 @@ public class DatabaseObserver {
|
||||
private final Map<RecipientId, Set<Observer>> storyObservers;
|
||||
private final Set<Observer> callUpdateObservers;
|
||||
private final Map<CallLinkRoomId, Set<Observer>> callLinkObservers;
|
||||
private final Set<InAppPaymentObserver> inAppPaymentObservers;
|
||||
|
||||
public DatabaseObserver(Application application) {
|
||||
this.application = application;
|
||||
@@ -90,6 +92,7 @@ public class DatabaseObserver {
|
||||
this.scheduledMessageObservers = new HashMap<>();
|
||||
this.callUpdateObservers = new HashSet<>();
|
||||
this.callLinkObservers = new HashMap<>();
|
||||
this.inAppPaymentObservers = new HashSet<>();
|
||||
}
|
||||
|
||||
public void registerConversationListObserver(@NonNull Observer listener) {
|
||||
@@ -195,6 +198,10 @@ public class DatabaseObserver {
|
||||
});
|
||||
}
|
||||
|
||||
public void registerInAppPaymentObserver(@NonNull InAppPaymentObserver observer) {
|
||||
executor.execute(() -> inAppPaymentObservers.add(observer));
|
||||
}
|
||||
|
||||
public void unregisterObserver(@NonNull Observer listener) {
|
||||
executor.execute(() -> {
|
||||
conversationListObservers.remove(listener);
|
||||
@@ -221,6 +228,12 @@ public class DatabaseObserver {
|
||||
});
|
||||
}
|
||||
|
||||
public void unregisterObserver(@NonNull InAppPaymentObserver listener) {
|
||||
executor.execute(() -> {
|
||||
inAppPaymentObservers.remove(listener);
|
||||
});
|
||||
}
|
||||
|
||||
public void notifyConversationListeners(Set<Long> threadIds) {
|
||||
for (long threadId : threadIds) {
|
||||
notifyConversationListeners(threadId);
|
||||
@@ -356,6 +369,12 @@ public class DatabaseObserver {
|
||||
runPostSuccessfulTransaction(KEY_CALL_LINK_UPDATES, () -> notifyMapped(callLinkObservers, callLinkRoomId));
|
||||
}
|
||||
|
||||
public void notifyInAppPaymentsObservers(@NonNull InAppPaymentTable.InAppPayment inAppPayment) {
|
||||
runPostSuccessfulTransaction(KEY_IN_APP_PAYMENTS, () -> {
|
||||
inAppPaymentObservers.forEach(item -> item.onInAppPaymentChanged(inAppPayment));
|
||||
});
|
||||
}
|
||||
|
||||
private void runPostSuccessfulTransaction(@NonNull String dedupeKey, @NonNull Runnable runnable) {
|
||||
SignalDatabase.runPostSuccessfulTransaction(dedupeKey, () -> {
|
||||
executor.execute(runnable);
|
||||
@@ -421,4 +440,8 @@ public class DatabaseObserver {
|
||||
public interface MessageObserver {
|
||||
void onMessageChanged(@NonNull MessageId messageId);
|
||||
}
|
||||
|
||||
public interface InAppPaymentObserver {
|
||||
void onInAppPaymentChanged(@NonNull InAppPaymentTable.InAppPayment inAppPayment);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.core.content.contentValuesOf
|
||||
import org.signal.core.util.DatabaseSerializer
|
||||
import org.signal.core.util.Serializer
|
||||
import org.signal.core.util.insertInto
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.readToSingleObject
|
||||
import org.signal.core.util.requireBoolean
|
||||
import org.signal.core.util.requireInt
|
||||
import org.signal.core.util.requireNonNullString
|
||||
import org.signal.core.util.select
|
||||
import org.signal.core.util.update
|
||||
import org.signal.core.util.withinTransaction
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
|
||||
|
||||
/**
|
||||
* A table matching up SubscriptionIds to currency codes and type
|
||||
*/
|
||||
class InAppPaymentSubscriberTable(
|
||||
context: Context,
|
||||
databaseHelper: SignalDatabase
|
||||
) : DatabaseTable(context, databaseHelper) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(InAppPaymentSubscriberRecord::class.java)
|
||||
|
||||
@VisibleForTesting
|
||||
const val TABLE_NAME = "in_app_payment_subscriber"
|
||||
|
||||
/** Row ID */
|
||||
private const val ID = "_id"
|
||||
|
||||
/** The serialized subscriber id */
|
||||
private const val SUBSCRIBER_ID = "subscriber_id"
|
||||
|
||||
/** The currency code for this subscriber id */
|
||||
private const val CURRENCY_CODE = "currency_code"
|
||||
|
||||
/** The type of subscription used by this subscriber id */
|
||||
private const val TYPE = "type"
|
||||
|
||||
/** Specifies whether we should try to cancel any current subscription before starting a new one with this ID */
|
||||
private const val REQUIRES_CANCEL = "requires_cancel"
|
||||
|
||||
/** Specifies which payment method was utilized for the latest transaction with this id */
|
||||
private const val PAYMENT_METHOD_TYPE = "payment_method_type"
|
||||
|
||||
const val CREATE_TABLE = """
|
||||
CREATE TABLE $TABLE_NAME (
|
||||
$ID INTEGER PRIMARY KEY,
|
||||
$SUBSCRIBER_ID TEXT NOT NULL UNIQUE,
|
||||
$CURRENCY_CODE TEXT NOT NULL,
|
||||
$TYPE INTEGER NOT NULL,
|
||||
$REQUIRES_CANCEL INTEGER DEFAULT 0,
|
||||
$PAYMENT_METHOD_TYPE INTEGER DEFAULT 0,
|
||||
UNIQUE($CURRENCY_CODE, $TYPE)
|
||||
)
|
||||
"""
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts this subscriber, replacing any that it conflicts with.
|
||||
*
|
||||
* This is a destructive, mutating operation. For setting specific values, prefer the alternative setters available on this table class.
|
||||
*/
|
||||
fun insertOrReplace(inAppPaymentSubscriberRecord: InAppPaymentSubscriberRecord) {
|
||||
Log.i(TAG, "Setting subscriber for currency ${inAppPaymentSubscriberRecord.currencyCode}", Exception(), true)
|
||||
|
||||
writableDatabase.withinTransaction { db ->
|
||||
db.insertInto(TABLE_NAME)
|
||||
.values(InAppPaymentSubscriberSerializer.serialize(inAppPaymentSubscriberRecord))
|
||||
.run(conflictStrategy = SQLiteDatabase.CONFLICT_REPLACE)
|
||||
|
||||
SignalStore.donationsValues().setSubscriberCurrency(
|
||||
inAppPaymentSubscriberRecord.currencyCode,
|
||||
inAppPaymentSubscriberRecord.type
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether the subscriber in question requires a cancellation before a new subscription can be created.
|
||||
*/
|
||||
fun setRequiresCancel(subscriberId: SubscriberId, requiresCancel: Boolean) {
|
||||
writableDatabase.update(TABLE_NAME)
|
||||
.values(REQUIRES_CANCEL to requiresCancel)
|
||||
.where("$SUBSCRIBER_ID = ?", subscriberId.serialize())
|
||||
.run()
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the payment method on a subscriber.
|
||||
*/
|
||||
fun setPaymentMethod(subscriberId: SubscriberId, paymentMethodType: InAppPaymentData.PaymentMethodType) {
|
||||
writableDatabase.update(TABLE_NAME)
|
||||
.values(PAYMENT_METHOD_TYPE to paymentMethodType.value)
|
||||
.where("$SUBSCRIBER_ID = ?", subscriberId.serialize())
|
||||
.run()
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a subscriber for the given type by the currency code.
|
||||
*/
|
||||
fun getByCurrencyCode(currencyCode: String, type: InAppPaymentSubscriberRecord.Type): InAppPaymentSubscriberRecord? {
|
||||
return readableDatabase.select()
|
||||
.from(TABLE_NAME)
|
||||
.where("$TYPE = ? AND $CURRENCY_CODE = ?", TypeSerializer.serialize(type), currencyCode.uppercase())
|
||||
.run()
|
||||
.readToSingleObject(InAppPaymentSubscriberSerializer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a subscriber by SubscriberId
|
||||
*/
|
||||
fun getBySubscriberId(subscriberId: SubscriberId): InAppPaymentSubscriberRecord? {
|
||||
return readableDatabase.select()
|
||||
.from(TABLE_NAME)
|
||||
.where("$SUBSCRIBER_ID = ?", subscriberId.serialize())
|
||||
.run()
|
||||
.readToSingleObject(InAppPaymentSubscriberSerializer)
|
||||
}
|
||||
|
||||
object InAppPaymentSubscriberSerializer : DatabaseSerializer<InAppPaymentSubscriberRecord> {
|
||||
override fun serialize(data: InAppPaymentSubscriberRecord): ContentValues {
|
||||
return contentValuesOf(
|
||||
SUBSCRIBER_ID to data.subscriberId.serialize(),
|
||||
CURRENCY_CODE to data.currencyCode.uppercase(),
|
||||
TYPE to TypeSerializer.serialize(data.type),
|
||||
REQUIRES_CANCEL to data.requiresCancel,
|
||||
PAYMENT_METHOD_TYPE to data.paymentMethodType.value
|
||||
)
|
||||
}
|
||||
|
||||
override fun deserialize(input: Cursor): InAppPaymentSubscriberRecord {
|
||||
return InAppPaymentSubscriberRecord(
|
||||
subscriberId = SubscriberId.deserialize(input.requireNonNullString(SUBSCRIBER_ID)),
|
||||
currencyCode = input.requireNonNullString(CURRENCY_CODE),
|
||||
type = TypeSerializer.deserialize(input.requireInt(TYPE)),
|
||||
requiresCancel = input.requireBoolean(REQUIRES_CANCEL),
|
||||
paymentMethodType = InAppPaymentData.PaymentMethodType.fromValue(input.requireInt(PAYMENT_METHOD_TYPE)) ?: InAppPaymentData.PaymentMethodType.UNKNOWN
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
object TypeSerializer : Serializer<InAppPaymentSubscriberRecord.Type, Int> {
|
||||
override fun serialize(data: InAppPaymentSubscriberRecord.Type): Int = data.code
|
||||
override fun deserialize(input: Int): InAppPaymentSubscriberRecord.Type = InAppPaymentSubscriberRecord.Type.values().first { it.code == input }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.os.Parcelable
|
||||
import androidx.core.content.contentValuesOf
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.parcelize.TypeParceler
|
||||
import org.signal.core.util.DatabaseId
|
||||
import org.signal.core.util.DatabaseSerializer
|
||||
import org.signal.core.util.Serializer
|
||||
import org.signal.core.util.delete
|
||||
import org.signal.core.util.exists
|
||||
import org.signal.core.util.insertInto
|
||||
import org.signal.core.util.readToList
|
||||
import org.signal.core.util.readToSingleObject
|
||||
import org.signal.core.util.requireBoolean
|
||||
import org.signal.core.util.requireInt
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.requireNonNullBlob
|
||||
import org.signal.core.util.requireString
|
||||
import org.signal.core.util.select
|
||||
import org.signal.core.util.update
|
||||
import org.signal.core.util.updateAll
|
||||
import org.signal.core.util.withinTransaction
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.util.parcelers.MillisecondDurationParceler
|
||||
import org.thoughtcrime.securesms.util.parcelers.NullableSubscriberIdParceler
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Centralizes state information for donations and backups payments and redemption.
|
||||
*
|
||||
* Each entry in this database has a 1:1 relationship with a redeemable token, which can be for one of the following:
|
||||
* * A Gift Badge
|
||||
* * A Boost Badge
|
||||
* * A Subscription Badge
|
||||
* * A Backup Subscription
|
||||
*/
|
||||
class InAppPaymentTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTable(context, databaseHelper) {
|
||||
|
||||
companion object {
|
||||
private const val TABLE_NAME = "in_app_payment"
|
||||
|
||||
/**
|
||||
* Row ID
|
||||
*/
|
||||
private const val ID = "_id"
|
||||
|
||||
/**
|
||||
* What kind of payment this row represents
|
||||
*/
|
||||
private const val TYPE = "type"
|
||||
|
||||
/**
|
||||
* The current state of the given payment
|
||||
*/
|
||||
private const val STATE = "state"
|
||||
|
||||
/**
|
||||
* When the payment was first inserted into the database
|
||||
*/
|
||||
private const val INSERTED_AT = "inserted_at"
|
||||
|
||||
/**
|
||||
* The last time the payment was updated
|
||||
*/
|
||||
private const val UPDATED_AT = "updated_at"
|
||||
|
||||
/**
|
||||
* Whether the user has been notified of the payment's terminal state.
|
||||
*/
|
||||
private const val NOTIFIED = "notified"
|
||||
|
||||
/**
|
||||
* The subscriber id associated with the payment.
|
||||
*/
|
||||
private const val SUBSCRIBER_ID = "subscriber_id"
|
||||
|
||||
/**
|
||||
* The end of period related to the subscription, if this column represents a recurring payment.
|
||||
* A zero here indicates that we do not have an end of period yet for this recurring payment, OR
|
||||
* that this row does not represent a recurring payment.
|
||||
*/
|
||||
private const val END_OF_PERIOD = "end_of_period"
|
||||
|
||||
/**
|
||||
* Extraneous data that may or may not be common among payments
|
||||
*/
|
||||
private const val DATA = "data"
|
||||
|
||||
val CREATE_TABLE = """
|
||||
CREATE TABLE $TABLE_NAME (
|
||||
$ID INTEGER PRIMARY KEY,
|
||||
$TYPE INTEGER NOT NULL,
|
||||
$STATE INTEGER NOT NULL,
|
||||
$INSERTED_AT INTEGER NOT NULL,
|
||||
$UPDATED_AT INTEGER NOT NULL,
|
||||
$NOTIFIED INTEGER DEFAULT 1,
|
||||
$SUBSCRIBER_ID TEXT,
|
||||
$END_OF_PERIOD INTEGER DEFAULT 0,
|
||||
$DATA BLOB NOT NULL
|
||||
)
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when we create a new InAppPayment while in the checkout screen. At this point in
|
||||
* the flow, we know that there should not be any other InAppPayment objects currently in this
|
||||
* state.
|
||||
*/
|
||||
fun clearCreated() {
|
||||
writableDatabase.delete(TABLE_NAME)
|
||||
.where("$STATE = ?", State.serialize(State.CREATED))
|
||||
.run()
|
||||
}
|
||||
|
||||
fun insert(
|
||||
type: Type,
|
||||
state: State,
|
||||
subscriberId: SubscriberId?,
|
||||
endOfPeriod: Duration?,
|
||||
inAppPaymentData: InAppPaymentData
|
||||
): InAppPaymentId {
|
||||
val now = System.currentTimeMillis()
|
||||
return writableDatabase.insertInto(TABLE_NAME)
|
||||
.values(
|
||||
TYPE to type.code,
|
||||
STATE to state.code,
|
||||
INSERTED_AT to now,
|
||||
UPDATED_AT to now,
|
||||
SUBSCRIBER_ID to subscriberId?.serialize(),
|
||||
END_OF_PERIOD to (endOfPeriod?.inWholeSeconds ?: 0L),
|
||||
DATA to InAppPaymentData.ADAPTER.encode(inAppPaymentData),
|
||||
NOTIFIED to 1
|
||||
)
|
||||
.run()
|
||||
.let { InAppPaymentId(it) }
|
||||
}
|
||||
|
||||
fun update(
|
||||
inAppPayment: InAppPayment
|
||||
) {
|
||||
val updated = inAppPayment.copy(updatedAt = System.currentTimeMillis().milliseconds)
|
||||
writableDatabase.update(TABLE_NAME)
|
||||
.values(InAppPayment.serialize(updated))
|
||||
.where(ID_WHERE, inAppPayment.id)
|
||||
.run()
|
||||
|
||||
ApplicationDependencies.getDatabaseObserver().notifyInAppPaymentsObservers(inAppPayment)
|
||||
}
|
||||
|
||||
fun getAllWaitingForAuth(): List<InAppPayment> {
|
||||
return readableDatabase.select()
|
||||
.from(TABLE_NAME)
|
||||
.where("$STATE = ?", State.serialize(State.WAITING_FOR_AUTHORIZATION))
|
||||
.run()
|
||||
.readToList { InAppPayment.deserialize(it) }
|
||||
}
|
||||
|
||||
fun consumeInAppPaymentsToNotifyUser(): List<InAppPayment> {
|
||||
return writableDatabase.withinTransaction { db ->
|
||||
val payments = db.select()
|
||||
.from(TABLE_NAME)
|
||||
.where("$NOTIFIED = ?", 0)
|
||||
.run()
|
||||
.readToList(mapper = { InAppPayment.deserialize(it) })
|
||||
|
||||
db.updateAll(TABLE_NAME).values(NOTIFIED to 1).run()
|
||||
|
||||
payments
|
||||
}
|
||||
}
|
||||
|
||||
fun getById(id: InAppPaymentId): InAppPayment? {
|
||||
return readableDatabase.select()
|
||||
.from(TABLE_NAME)
|
||||
.where(ID_WHERE, id)
|
||||
.run()
|
||||
.readToSingleObject(InAppPayment.Companion)
|
||||
}
|
||||
|
||||
fun getByEndOfPeriod(type: Type, endOfPeriod: Duration): InAppPayment? {
|
||||
return readableDatabase.select()
|
||||
.from(TABLE_NAME)
|
||||
.where("$TYPE = ? AND $END_OF_PERIOD = ?", Type.serialize(type), endOfPeriod.inWholeSeconds)
|
||||
.run()
|
||||
.readToSingleObject(InAppPayment.Companion)
|
||||
}
|
||||
|
||||
fun getByLatestEndOfPeriod(type: Type): InAppPayment? {
|
||||
return readableDatabase.select()
|
||||
.from(TABLE_NAME)
|
||||
.where("$TYPE = ? AND $END_OF_PERIOD > 0", Type.serialize(type))
|
||||
.orderBy("$END_OF_PERIOD DESC")
|
||||
.limit(1)
|
||||
.run()
|
||||
.readToSingleObject(InAppPayment.Companion)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the latest entry in the table for the given subscriber id.
|
||||
*/
|
||||
fun getLatestBySubscriberId(subscriberId: SubscriberId): InAppPayment? {
|
||||
return readableDatabase.select()
|
||||
.from(TABLE_NAME)
|
||||
.where("$SUBSCRIBER_ID = ?", subscriberId.serialize())
|
||||
.orderBy("$END_OF_PERIOD DESC")
|
||||
.limit(1)
|
||||
.run()
|
||||
.readToSingleObject(InAppPayment.Companion)
|
||||
}
|
||||
|
||||
fun markSubscriptionManuallyCanceled(subscriberId: SubscriberId) {
|
||||
writableDatabase.withinTransaction {
|
||||
val inAppPayment = getLatestBySubscriberId(subscriberId) ?: return@withinTransaction
|
||||
|
||||
update(
|
||||
inAppPayment.copy(
|
||||
data = inAppPayment.data.copy(
|
||||
cancellation = InAppPaymentData.Cancellation(
|
||||
reason = InAppPaymentData.Cancellation.Reason.MANUAL
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether there are any pending donations in the database.
|
||||
*/
|
||||
fun hasPendingDonation(): Boolean {
|
||||
return readableDatabase.exists(TABLE_NAME)
|
||||
.where(
|
||||
"$STATE = ? AND ($TYPE = ? OR $TYPE = ? OR $TYPE = ?)",
|
||||
State.serialize(State.PENDING),
|
||||
Type.serialize(Type.RECURRING_DONATION),
|
||||
Type.serialize(Type.ONE_TIME_DONATION),
|
||||
Type.serialize(Type.ONE_TIME_GIFT)
|
||||
)
|
||||
.run()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether there are any pending donations in the database.
|
||||
*/
|
||||
fun hasPending(type: Type): Boolean {
|
||||
return readableDatabase.exists(TABLE_NAME)
|
||||
.where(
|
||||
"$STATE = ? AND $TYPE = ?",
|
||||
State.serialize(State.PENDING),
|
||||
Type.serialize(type)
|
||||
)
|
||||
.run()
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves from the database the latest payment of the given type that is either in the PENDING or WAITING_FOR_AUTHORIZATION state.
|
||||
*/
|
||||
fun getLatestInAppPaymentByType(type: Type): InAppPayment? {
|
||||
return readableDatabase.select()
|
||||
.from(TABLE_NAME)
|
||||
.where(
|
||||
"($STATE = ? OR $STATE = ? OR $STATE = ?) AND $TYPE = ?",
|
||||
State.serialize(State.PENDING),
|
||||
State.serialize(State.WAITING_FOR_AUTHORIZATION),
|
||||
State.serialize(State.END),
|
||||
Type.serialize(type)
|
||||
)
|
||||
.orderBy("$INSERTED_AT DESC")
|
||||
.limit(1)
|
||||
.run()
|
||||
.readToSingleObject(InAppPayment.Companion)
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a database row. Nicer than returning a raw value.
|
||||
*/
|
||||
@Parcelize
|
||||
data class InAppPaymentId(
|
||||
val rowId: Long
|
||||
) : DatabaseId, Parcelable {
|
||||
init {
|
||||
check(rowId > 0)
|
||||
}
|
||||
|
||||
override fun serialize(): String = rowId.toString()
|
||||
|
||||
override fun toString(): String = serialize()
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a single token payment.
|
||||
*/
|
||||
@Parcelize
|
||||
@TypeParceler<Duration, MillisecondDurationParceler>
|
||||
@TypeParceler<SubscriberId?, NullableSubscriberIdParceler>
|
||||
data class InAppPayment(
|
||||
val id: InAppPaymentId,
|
||||
val type: Type,
|
||||
val state: State,
|
||||
val insertedAt: Duration,
|
||||
val updatedAt: Duration,
|
||||
val notified: Boolean,
|
||||
val subscriberId: SubscriberId?,
|
||||
val endOfPeriod: Duration,
|
||||
val data: InAppPaymentData
|
||||
) : Parcelable {
|
||||
|
||||
@IgnoredOnParcel
|
||||
val endOfPeriodSeconds: Long = endOfPeriod.inWholeSeconds
|
||||
|
||||
companion object : DatabaseSerializer<InAppPayment> {
|
||||
|
||||
override fun serialize(data: InAppPayment): ContentValues {
|
||||
return contentValuesOf(
|
||||
ID to data.id.serialize(),
|
||||
TYPE to data.type.apply { check(this != Type.UNKNOWN) }.code,
|
||||
STATE to data.state.code,
|
||||
INSERTED_AT to data.insertedAt.inWholeSeconds,
|
||||
UPDATED_AT to data.updatedAt.inWholeSeconds,
|
||||
NOTIFIED to data.notified,
|
||||
SUBSCRIBER_ID to data.subscriberId?.serialize(),
|
||||
END_OF_PERIOD to data.endOfPeriod.inWholeSeconds,
|
||||
DATA to data.data.encode()
|
||||
)
|
||||
}
|
||||
|
||||
override fun deserialize(input: Cursor): InAppPayment {
|
||||
return InAppPayment(
|
||||
id = InAppPaymentId(input.requireLong(ID)),
|
||||
type = Type.deserialize(input.requireInt(TYPE)),
|
||||
state = State.deserialize(input.requireInt(STATE)),
|
||||
insertedAt = input.requireLong(INSERTED_AT).seconds,
|
||||
updatedAt = input.requireLong(UPDATED_AT).seconds,
|
||||
notified = input.requireBoolean(NOTIFIED),
|
||||
subscriberId = input.requireString(SUBSCRIBER_ID)?.let { SubscriberId.deserialize(it) },
|
||||
endOfPeriod = input.requireLong(END_OF_PERIOD).seconds,
|
||||
data = InAppPaymentData.ADAPTER.decode(input.requireNonNullBlob(DATA))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class Type(val code: Int, val recurring: Boolean) {
|
||||
/**
|
||||
* Used explicitly for mapping DonationErrorSource. Writing this value
|
||||
* into an InAppPayment is an error.
|
||||
*/
|
||||
UNKNOWN(-1, false),
|
||||
|
||||
/**
|
||||
* This payment is for a gift badge
|
||||
*/
|
||||
ONE_TIME_GIFT(0, false),
|
||||
|
||||
/**
|
||||
* This payment is for a one-time donation
|
||||
*/
|
||||
ONE_TIME_DONATION(1, false),
|
||||
|
||||
/**
|
||||
* This payment is for a recurring donation
|
||||
*/
|
||||
RECURRING_DONATION(2, true),
|
||||
|
||||
/**
|
||||
* This payment is for a recurring backup payment
|
||||
*/
|
||||
RECURRING_BACKUP(3, true);
|
||||
|
||||
companion object : Serializer<Type, Int> {
|
||||
override fun serialize(data: Type): Int = data.code
|
||||
override fun deserialize(input: Int): Type = values().first { it.code == input }
|
||||
}
|
||||
|
||||
fun toErrorSource(): DonationErrorSource {
|
||||
return when (this) {
|
||||
UNKNOWN -> DonationErrorSource.UNKNOWN
|
||||
ONE_TIME_GIFT -> DonationErrorSource.GIFT
|
||||
ONE_TIME_DONATION -> DonationErrorSource.ONE_TIME
|
||||
RECURRING_DONATION -> DonationErrorSource.MONTHLY
|
||||
RECURRING_BACKUP -> DonationErrorSource.UNKNOWN // TODO [message-backups] error handling
|
||||
}
|
||||
}
|
||||
|
||||
fun toSubscriberType(): InAppPaymentSubscriberRecord.Type? {
|
||||
return when (this) {
|
||||
RECURRING_BACKUP -> InAppPaymentSubscriberRecord.Type.BACKUP
|
||||
RECURRING_DONATION -> InAppPaymentSubscriberRecord.Type.DONATION
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun requireSubscriberType(): InAppPaymentSubscriberRecord.Type {
|
||||
return requireNotNull(toSubscriberType())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the payment pipeline state for a given in-app payment
|
||||
*
|
||||
* ```mermaid
|
||||
* flowchart TD
|
||||
* CREATED -- Auth required --> WAITING_FOR_AUTHORIZATION
|
||||
* CREATED -- Auth not required --> PENDING
|
||||
* WAITING_FOR_AUTHORIZATION -- User completes auth --> PENDING
|
||||
* WAITING_FOR_AUTHORIZATION -- User does not complete auth --> END
|
||||
* PENDING --> END
|
||||
* PENDING --> RETRY
|
||||
* PENDING --> END
|
||||
* RETRY --> PENDING
|
||||
* RETRY --> END
|
||||
* ```
|
||||
*/
|
||||
enum class State(val code: Int) {
|
||||
/**
|
||||
* This payment has been created, but not submitted for processing yet.
|
||||
*/
|
||||
CREATED(0),
|
||||
|
||||
/**
|
||||
* This payment is awaiting the user to return from an external authorization2
|
||||
* such as a 3DS flow or IDEAL confirmation.
|
||||
*/
|
||||
WAITING_FOR_AUTHORIZATION(1),
|
||||
|
||||
/**
|
||||
* This payment is authorized and is waiting to be processed.
|
||||
*/
|
||||
PENDING(2),
|
||||
|
||||
/**
|
||||
* This payment pipeline has been completed. Check the data to see the state.
|
||||
*/
|
||||
END(3);
|
||||
|
||||
companion object : Serializer<State, Int> {
|
||||
override fun serialize(data: State): Int = data.code
|
||||
override fun deserialize(input: Int): State = State.values().first { it.code == input }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,8 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
|
||||
val kyberPreKeyTable: KyberPreKeyTable = KyberPreKeyTable(context, this)
|
||||
val callLinkTable: CallLinkTable = CallLinkTable(context, this)
|
||||
val nameCollisionTables: NameCollisionTables = NameCollisionTables(context, this)
|
||||
val inAppPaymentTable: InAppPaymentTable = InAppPaymentTable(context, this)
|
||||
val inAppPaymentSubscriberTable: InAppPaymentSubscriberTable = InAppPaymentSubscriberTable(context, this)
|
||||
|
||||
override fun onOpen(db: net.zetetic.database.sqlcipher.SQLiteDatabase) {
|
||||
db.setForeignKeyConstraintsEnabled(true)
|
||||
@@ -111,6 +113,8 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
|
||||
db.execSQL(CallTable.CREATE_TABLE)
|
||||
db.execSQL(KyberPreKeyTable.CREATE_TABLE)
|
||||
NameCollisionTables.createTables(db)
|
||||
db.execSQL(InAppPaymentTable.CREATE_TABLE)
|
||||
db.execSQL(InAppPaymentSubscriberTable.CREATE_TABLE)
|
||||
executeStatements(db, SearchTable.CREATE_TABLE)
|
||||
executeStatements(db, RemappedRecordTables.CREATE_TABLE)
|
||||
executeStatements(db, MessageSendLogTables.CREATE_TABLE)
|
||||
@@ -535,5 +539,15 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
|
||||
@get:JvmName("nameCollisions")
|
||||
val nameCollisions: NameCollisionTables
|
||||
get() = instance!!.nameCollisionTables
|
||||
|
||||
@get:JvmStatic
|
||||
@get:JvmName("inAppPayments")
|
||||
val inAppPayments: InAppPaymentTable
|
||||
get() = instance!!.inAppPaymentTable
|
||||
|
||||
@get:JvmStatic
|
||||
@get:JvmName("inAppPaymentSubscribers")
|
||||
val inAppPaymentSubscribers: InAppPaymentSubscriberTable
|
||||
get() = instance!!.inAppPaymentSubscriberTable
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V228_AddNameCollisi
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V229_MarkMissedCallEventsNotified
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V230_UnreadCountIndices
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V231_ArchiveThumbnailColumns
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V232_CreateInAppPaymentTable
|
||||
|
||||
/**
|
||||
* Contains all of the database migrations for [SignalDatabase]. Broken into a separate file for cleanliness.
|
||||
@@ -180,10 +181,11 @@ object SignalDatabaseMigrations {
|
||||
228 to V228_AddNameCollisionTables,
|
||||
229 to V229_MarkMissedCallEventsNotified,
|
||||
230 to V230_UnreadCountIndices,
|
||||
231 to V231_ArchiveThumbnailColumns
|
||||
231 to V231_ArchiveThumbnailColumns,
|
||||
232 to V232_CreateInAppPaymentTable
|
||||
)
|
||||
|
||||
const val DATABASE_VERSION = 231
|
||||
const val DATABASE_VERSION = 232
|
||||
|
||||
@JvmStatic
|
||||
fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.database.helpers.migration
|
||||
|
||||
import android.app.Application
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase
|
||||
|
||||
/**
|
||||
* Create the table and migrate necessary data.
|
||||
*/
|
||||
@Suppress("ClassName")
|
||||
object V232_CreateInAppPaymentTable : SignalDatabaseMigration {
|
||||
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE in_app_payment (
|
||||
_id INTEGER PRIMARY KEY,
|
||||
type INTEGER NOT NULL,
|
||||
state INTEGER NOT NULL,
|
||||
inserted_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
notified INTEGER DEFAULT 0,
|
||||
subscriber_id TEXT,
|
||||
end_of_period INTEGER DEFAULT 0,
|
||||
data BLOB NOT NULL
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE in_app_payment_subscriber (
|
||||
_id INTEGER PRIMARY KEY,
|
||||
subscriber_id TEXT NOT NULL UNIQUE,
|
||||
currency_code TEXT NOT NULL,
|
||||
type INTEGER NOT NULL,
|
||||
requires_cancel INTEGER DEFAULT 0,
|
||||
payment_method_type INTEGER DEFAULT 0,
|
||||
UNIQUE(currency_code, type)
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.database.model
|
||||
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
|
||||
|
||||
/**
|
||||
* Represents a SubscriberId and metadata that can be used for a recurring
|
||||
* subscription of the given type. Stored in InAppPaymentSubscriberTable
|
||||
*/
|
||||
data class InAppPaymentSubscriberRecord(
|
||||
val subscriberId: SubscriberId,
|
||||
val currencyCode: String,
|
||||
val type: Type,
|
||||
val requiresCancel: Boolean,
|
||||
val paymentMethodType: InAppPaymentData.PaymentMethodType
|
||||
) {
|
||||
/**
|
||||
* Serves as the mutex by which to perform mutations to subscriptions.
|
||||
*/
|
||||
enum class Type(val code: Int, val jobQueue: String, val inAppPaymentType: InAppPaymentTable.Type) {
|
||||
/**
|
||||
* A recurring donation
|
||||
*/
|
||||
DONATION(0, "recurring-donations", InAppPaymentTable.Type.RECURRING_DONATION),
|
||||
|
||||
/**
|
||||
* A recurring backups subscription
|
||||
*/
|
||||
BACKUP(1, "recurring-backups", InAppPaymentTable.Type.RECURRING_BACKUP)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user