From a348c1dee868605ffe6d52c404d284b1b9e95593 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 24 Sep 2026 21:33:15 +0200 Subject: [PATCH 1/2] feat: add a demo clock for subscription renewals --- app/src/main/java/to/bitkit/App.kt | 5 ++ .../main/java/to/bitkit/data/SettingsStore.kt | 8 ++ app/src/main/java/to/bitkit/di/EnvModule.kt | 11 +++ .../repositories/PaykitPaymentRequestRepo.kt | 44 ++++++----- ...PaykitSubscriptionNotificationScheduler.kt | 3 +- .../ui/screens/settings/DevSettingsScreen.kt | 46 +++++++++++ .../subscriptions/CreateSubscriptionScreen.kt | 6 +- .../subscriptions/SubscriptionsScreen.kt | 8 +- .../main/java/to/bitkit/utils/DemoClock.kt | 77 +++++++++++++++++++ .../to/bitkit/viewmodels/SettingsViewModel.kt | 10 +++ ...aykitPaymentRequestRepoSubscriptionTest.kt | 35 +++++++++ .../PaykitPaymentRequestRepoTest.kt | 1 + .../java/to/bitkit/utils/DemoClockTest.kt | 53 +++++++++++++ 13 files changed, 281 insertions(+), 26 deletions(-) create mode 100644 app/src/main/java/to/bitkit/utils/DemoClock.kt create mode 100644 app/src/test/java/to/bitkit/utils/DemoClockTest.kt diff --git a/app/src/main/java/to/bitkit/App.kt b/app/src/main/java/to/bitkit/App.kt index c127a13a68..816a0b8d03 100644 --- a/app/src/main/java/to/bitkit/App.kt +++ b/app/src/main/java/to/bitkit/App.kt @@ -15,6 +15,7 @@ import to.bitkit.appwidget.AppWidgetRefreshScheduler import to.bitkit.env.Env import to.bitkit.services.BluetoothInit import to.bitkit.services.PubkyAuthHandlerRegistrar +import to.bitkit.utils.DemoClockSync import to.bitkit.utils.Logger import javax.inject.Inject @@ -32,6 +33,9 @@ internal open class App : Application(), Configuration.Provider { @Inject lateinit var pubkyAuthHandlerRegistrar: PubkyAuthHandlerRegistrar + @Inject + lateinit var demoClockSync: DemoClockSync + override val workManagerConfiguration get() = Configuration.Builder() .setWorkerFactory(workerFactory) @@ -47,6 +51,7 @@ internal open class App : Application(), Configuration.Provider { // Initialize btleplug for Bluetooth support (required before any BLE usage) BluetoothInit.ensureInitialized() pubkyAuthHandlerRegistrar.start() + demoClockSync.start() } private fun installUncaughtExceptionLogger() { diff --git a/app/src/main/java/to/bitkit/data/SettingsStore.kt b/app/src/main/java/to/bitkit/data/SettingsStore.kt index 12220c64cc..5e92896be2 100644 --- a/app/src/main/java/to/bitkit/data/SettingsStore.kt +++ b/app/src/main/java/to/bitkit/data/SettingsStore.kt @@ -6,6 +6,7 @@ import androidx.datastore.dataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.preferencesDataStore import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow @@ -33,6 +34,7 @@ private val Context.settingsDataStore: DataStore by dataStore( private val Context.localSettingsDataStore: DataStore by preferencesDataStore("local_settings") @Singleton +@Suppress("TooManyFunctions") class SettingsStore @Inject constructor( @ApplicationContext private val context: Context, ) { @@ -44,6 +46,7 @@ class SettingsStore @Inject constructor( val isPubkyProfileSetupPending: Flow = localStore.data.map { it[PUBKY_PROFILE_SETUP_PENDING_KEY] ?: false } + val demoClockOffsetDays: Flow = localStore.data.map { it[DEMO_CLOCK_OFFSET_DAYS_KEY] ?: 0 } @Volatile var restoredMonitoredTypesFromBackup: Boolean = false @@ -107,6 +110,10 @@ class SettingsStore @Inject constructor( localStore.edit { it[PUBKY_PROFILE_SETUP_PENDING_KEY] = value } } + suspend fun setDemoClockOffsetDays(days: Int) { + localStore.edit { it[DEMO_CLOCK_OFFSET_DAYS_KEY] = days } + } + suspend fun addLastUsedTag(newTag: String) { store.updateData { currentSettings -> val combinedTags = (listOf(newTag) + currentSettings.lastUsedTags).distinct() @@ -140,6 +147,7 @@ class SettingsStore @Inject constructor( private const val MAX_LAST_USED_TAGS = 10 private val PAYKIT_ENABLED_KEY = booleanPreferencesKey("paykit_enabled") private val PUBKY_PROFILE_SETUP_PENDING_KEY = booleanPreferencesKey("pubky_profile_setup_pending") + private val DEMO_CLOCK_OFFSET_DAYS_KEY = intPreferencesKey("demo_clock_offset_days") } } diff --git a/app/src/main/java/to/bitkit/di/EnvModule.kt b/app/src/main/java/to/bitkit/di/EnvModule.kt index 24621dab02..6a7b536f32 100644 --- a/app/src/main/java/to/bitkit/di/EnvModule.kt +++ b/app/src/main/java/to/bitkit/di/EnvModule.kt @@ -8,7 +8,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import org.lightningdevkit.ldknode.Network import to.bitkit.env.Env +import to.bitkit.utils.DemoClock import java.util.Locale +import javax.inject.Qualifier import kotlin.time.Clock import kotlin.time.ExperimentalTime @@ -23,6 +25,15 @@ object EnvModule { @Provides fun provideClock(): Clock = Clock.System + @Provides + @SubscriptionClock + fun provideSubscriptionClock(clock: Clock): Clock = DemoClock.subscriptionClock(clock) + @Provides fun provideLocale(@ApplicationContext context: Context): Locale = context.resources.configuration.locales[0] } + +/** The [Clock] for Paykit subscription scheduling; [DemoClock] can move it ahead in debug builds. */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class SubscriptionClock diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 1101bccc1c..a644e252ea 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -38,6 +38,7 @@ import to.bitkit.async.appScope import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher +import to.bitkit.di.SubscriptionClock import to.bitkit.env.Env import to.bitkit.ext.runSuspendCatching import to.bitkit.flags.PaykitFeatureFlags @@ -255,6 +256,7 @@ class PaykitPaymentRequestRepo @Inject constructor( private val paymentProofRepo: PaykitPaymentProofRepo, private val subscriptionNotificationScheduler: PaykitSubscriptionNotificationScheduler, private val clock: Clock, + @SubscriptionClock private val subscriptionClock: Clock, ) { companion object { private const val TAG = "PaykitPaymentRequestRepo" @@ -321,7 +323,7 @@ class PaykitPaymentRequestRepo @Inject constructor( _pendingRequests.value.filterNot { it.id in presentedRequestIds } fun subscriptionProposals(): List = - _subscriptions.value.filter { it.isPayer && it.isProposalVisible(clock.now()) } + _subscriptions.value.filter { it.isPayer && it.isProposalVisible(subscriptionClock.now()) } fun automaticSubscriptionProposals(): List = subscriptionProposals().filterNot { it.id in presentedSubscriptionProposalIds } @@ -356,7 +358,7 @@ class PaykitPaymentRequestRepo @Inject constructor( ): Boolean = withContext(ioDispatcher) { operationMutex.withLock { val current = _subscriptions.value.firstOrNull { it.id == subscription.id } - ?.takeIf { it.isPayer && it.isProposalVisible(clock.now()) } + ?.takeIf { it.isPayer && it.isProposalVisible(subscriptionClock.now()) } ?: return@withLock false if (current.id in presentedSubscriptionProposalIds) return@withLock true val identity = activeIdentity ?: return@withLock false @@ -547,7 +549,7 @@ class PaykitPaymentRequestRepo @Inject constructor( ): PaykitSubscriptionCreation { val generation = stateGeneration.get() val expectedIdentity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable - val validationDate = clock.now() + val validationDate = subscriptionClock.now() val name = draft.name.trim() val description = draft.description.trim() validateSubscriptionDraft(draft, name, validationDate) @@ -563,7 +565,7 @@ class PaykitPaymentRequestRepo @Inject constructor( expectedIdentity = expectedIdentity, ) } - val proposalDate = clock.now() + val proposalDate = subscriptionClock.now() validateProposalExpiration(draft.expiresAt, proposalDate) val proposal = buildSubscriptionProposal(draft, name, description, iconUri, endpoints, proposalDate) PaykitSubscriptionProposal.validate(proposal) @@ -763,7 +765,7 @@ class PaykitPaymentRequestRepo @Inject constructor( runSuspendCatching { operationMutex.withLock { val identity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable - val validationDate = clock.now() + val validationDate = subscriptionClock.now() val current = _subscriptions.value.firstOrNull { it.id == subscription.id } ?.takeIf { it == subscription && it.isPayer && it.isProposalActionable(validationDate) } ?: throw PaykitPaymentRequestError.RequestUnavailable @@ -773,7 +775,7 @@ class PaykitPaymentRequestRepo @Inject constructor( current.paymentRequestId, ) processPendingMessages() - val acceptanceDate = clock.now() + val acceptanceDate = subscriptionClock.now() subscriptionAcceptedAt = subscriptionAcceptedAt + (current.id to acceptanceDate) persistSubscriptionState(identity) applySubscriptionRecordLocked(record, acceptanceDate) @@ -786,7 +788,7 @@ class PaykitPaymentRequestRepo @Inject constructor( } suspend fun cancel(subscription: PaykitSubscription): Result = updateSubscription(subscription) { - if (!it.canCancel(clock.now())) throw PaykitPaymentRequestError.RequestUnavailable + if (!it.canCancel(subscriptionClock.now())) throw PaykitPaymentRequestError.RequestUnavailable if (it.isPayer) { val identity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable val protectedRequestIds = paymentProofRepo.protectedRequestIdsForSubscriptionCancellation( @@ -832,6 +834,7 @@ class PaykitPaymentRequestRepo @Inject constructor( processPendingMessages() paykitSdkService.receivePrivateMessagesFromLinkedPeers().also(::logIntakeFailures) val now = clock.now() + val subscriptionNow = subscriptionClock.now() val records = paykitSdkService.paymentRequests() val locallyCompletedProofKinds = expectedIdentity ?.let(paymentProofStore::completedRequestProofKindsAwaitingSubmission) @@ -841,7 +844,7 @@ class PaykitPaymentRequestRepo @Inject constructor( ?.let(paymentProofStore::inFlightRequestIds) .orEmpty() val subscriptions = records.mapNotNull(PaymentRequestRecord::toPaykitSubscription) - .map { it.withExpiredLifecycle(now) } + .map { it.withExpiredLifecycle(subscriptionNow) } val restoredAcceptances = subscriptions .filter { it.isPayer && @@ -851,13 +854,13 @@ class PaykitPaymentRequestRepo @Inject constructor( .associate { subscription -> val acceptedAt = subscription.paidPeriods.minOfOrNull { it.startsAt } ?: subscription.createdAt - ?: now + ?: subscriptionNow subscription.id to acceptedAt } val updatedSubscriptionAcceptedAt = subscriptionAcceptedAt + restoredAcceptances val recurringRequestsBySubscription = subscriptions.filter { it.isPayer }.associateWith { subscription -> updatedSubscriptionAcceptedAt[subscription.id] - ?.let { subscription.requestsThrough(now, it) } + ?.let { subscription.requestsThrough(subscriptionNow, it) } .orEmpty() } val activeRecurringRequestIds = recurringRequestsBySubscription @@ -1069,7 +1072,7 @@ class PaykitPaymentRequestRepo @Inject constructor( val record = operation(current) processPendingMessages() val identity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable - applySubscriptionRecordLocked(record, clock.now()) + applySubscriptionRecordLocked(record, subscriptionClock.now()) synchronizeAfterSubscriptionAction(identity) } }.onFailure { Logger.warn("Failed to update Paykit subscription", it, context = TAG) } @@ -1172,9 +1175,10 @@ class PaykitPaymentRequestRepo @Inject constructor( private suspend fun discardExpiredRequestsLocked() { val now = clock.now() + val subscriptionNow = subscriptionClock.now() _pendingRequests.update { requests -> requests.filterNot { it.isExpired(now) } } _paymentRequestHistory.update { requests -> requests.withExpiredLifecycle(now) } - _subscriptions.update { subscriptions -> subscriptions.map { it.withExpiredLifecycle(now) } } + _subscriptions.update { subscriptions -> subscriptions.map { it.withExpiredLifecycle(subscriptionNow) } } prunePresentedRequestIds(_pendingRequests.value) prunePresentedSubscriptionProposalIds(_subscriptions.value) scheduleExpirationLocked() @@ -1192,7 +1196,7 @@ class PaykitPaymentRequestRepo @Inject constructor( private suspend fun prunePresentedSubscriptionProposalIds(subscriptions: List) { val proposalIds = subscriptions - .filter { it.isPayer && it.isProposalVisible(clock.now()) } + .filter { it.isPayer && it.isProposalVisible(subscriptionClock.now()) } .mapTo(mutableSetOf()) { it.id } val prunedIds = presentedSubscriptionProposalIds.intersect(proposalIds) if (prunedIds == presentedSubscriptionProposalIds) return @@ -1227,19 +1231,23 @@ class PaykitPaymentRequestRepo @Inject constructor( expirationJob?.cancel() expirationJob = null - val requestExpirations = (_pendingRequests.value + _paymentRequestHistory.value) + val now = clock.now() + val subscriptionNow = subscriptionClock.now() + val requestDelays = (_pendingRequests.value + _paymentRequestHistory.value) .filter { it.lifecycleState == PaymentRequestLifecycleState.PROPOSED } .mapNotNull { it.expiresAt } - val subscriptionExpirations = _subscriptions.value + .map { it - now } + // Subscription dates run on the subscription clock, which a demo offset can move ahead of real time. + val subscriptionDelays = _subscriptions.value .filter { it.lifecycleState == PaymentRequestLifecycleState.PROPOSED || it.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING } .flatMap { listOfNotNull(it.proposalExpiresAt, it.recurrence.endsAt) } - .filter { it > clock.now() } - val nextExpiration = (requestExpirations + subscriptionExpirations).minOrNull() + .filter { it > subscriptionNow } + .map { it - subscriptionNow } + val delayDuration = (requestDelays + subscriptionDelays).minOrNull()?.coerceAtLeast(Duration.ZERO) ?: return - val delayDuration = (nextExpiration - clock.now()).coerceAtLeast(Duration.ZERO) expirationJob = repoScope.launch { delay(delayDuration) operationMutex.withLock { diff --git a/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt b/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt index 63d371cae2..dde0eac534 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt @@ -17,6 +17,7 @@ import dagger.assisted.AssistedInject import dagger.hilt.android.qualifiers.ApplicationContext import to.bitkit.App import to.bitkit.R +import to.bitkit.di.SubscriptionClock import to.bitkit.ui.EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT import to.bitkit.ui.EXTRA_PAYKIT_COUNTERPARTY import to.bitkit.ui.EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH @@ -34,7 +35,7 @@ import kotlin.time.Instant @Singleton class PaykitSubscriptionNotificationScheduler @Inject constructor( @ApplicationContext private val context: Context, - private val clock: Clock, + @SubscriptionClock private val clock: Clock, private val workClient: PaykitSubscriptionWorkClient, ) { private companion object { diff --git a/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt index 86d8965344..f7966a5e7b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt @@ -1,10 +1,13 @@ package to.bitkit.ui.screens.settings +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -14,6 +17,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -29,6 +33,7 @@ import to.bitkit.ui.activityListViewModel import to.bitkit.ui.appViewModel import to.bitkit.ui.components.settings.SectionHeader import to.bitkit.ui.components.settings.SettingsButtonRow +import to.bitkit.ui.components.settings.SettingsButtonValue import to.bitkit.ui.components.settings.SettingsSwitchRow import to.bitkit.ui.components.settings.SettingsTextButtonRow import to.bitkit.ui.navigateTo @@ -37,6 +42,7 @@ import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.settingsViewModel import to.bitkit.ui.shared.util.shareZipFile +import to.bitkit.utils.DemoClock import to.bitkit.viewmodels.DevSettingsViewModel @Composable @@ -50,6 +56,7 @@ fun DevSettingsScreen( val context = LocalContext.current val isPaykitEnabled by settings.isPaykitEnabled.collectAsStateWithLifecycle() val isSavingsSwapEnabled by settings.isSavingsSwapEnabled.collectAsStateWithLifecycle() + val demoClockOffsetDays by settings.demoClockOffsetDays.collectAsStateWithLifecycle() var showPaykitWarning by remember { mutableStateOf(false) } ScreenColumn { @@ -100,6 +107,12 @@ fun DevSettingsScreen( }, switchTestTag = "PaykitUiToggle", ) + if (DemoClock.isAvailable) { + DemoClockOffsetRow( + offsetDays = demoClockOffsetDays, + onSelect = settings::setDemoClockOffsetDays, + ) + } } SectionHeader("HARDWARE WALLET") @@ -270,3 +283,36 @@ fun DevSettingsScreen( ) } } + +@Composable +private fun DemoClockOffsetRow( + offsetDays: Int, + onSelect: (Int) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + Box { + SettingsButtonRow( + title = "Demo clock offset (days)", + value = SettingsButtonValue.StringValue(demoClockOffsetLabel(offsetDays)), + onClick = { expanded = true }, + modifier = Modifier.testTag("DemoClockOffset") + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + DemoClock.OFFSET_DAYS_PRESETS.forEach { days -> + DropdownMenuItem( + text = { Text(demoClockOffsetLabel(days)) }, + onClick = { + expanded = false + onSelect(days) + }, + modifier = Modifier.testTag("DemoClockOffset-$days") + ) + } + } + } +} + +private fun demoClockOffsetLabel(days: Int) = if (days == 0) "Off" else "$days" diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt index 25487fac6e..b8feba691f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt @@ -88,10 +88,10 @@ import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent +import to.bitkit.utils.DemoClock import to.bitkit.utils.SubscriptionIcon import to.bitkit.viewmodels.AmountInputViewModel import to.bitkit.viewmodels.AppViewModel -import kotlin.time.Clock import kotlin.time.ExperimentalTime @Composable @@ -155,7 +155,7 @@ fun CreateSubscriptionSheet( initialDraft = PaykitPaymentRequestDraft( amountSats = amountSats, note = name, - expiresAt = Clock.System.now() + expiration.duration, + expiresAt = DemoClock.subscriptionNow() + expiration.duration, ), contact = null, onBack = { step = SubscriptionCreationStep.Details }, @@ -184,7 +184,7 @@ fun CreateSubscriptionSheet( name = name, description = description, frequency = frequency, - expiresAt = Clock.System.now() + expiration.duration, + expiresAt = DemoClock.subscriptionNow() + expiration.duration, iconBytes = iconBytes, ), target = target, diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt index c1b03d96d5..82871e8a82 100644 --- a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt @@ -102,11 +102,11 @@ import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.removeAccentTags import to.bitkit.ui.utils.withAccent +import to.bitkit.utils.DemoClock import to.bitkit.viewmodels.AppViewModel import java.math.BigDecimal import java.math.MathContext import java.math.RoundingMode -import kotlin.time.Clock import kotlin.time.ExperimentalTime import kotlin.time.Instant @@ -945,7 +945,7 @@ private fun SubscriptionCancel( SubscriptionProviderCard( subscription = subscription, contact = contact, - subtitle = subscription.rowSubtitle(Clock.System.now()), + subtitle = subscription.rowSubtitle(DemoClock.subscriptionNow()), onClick = onDetails, ) FillHeight() @@ -1117,11 +1117,11 @@ private fun PaykitSubscription.renewalText(now: Instant): String { @Composable private fun rememberSubscriptionNow(subscriptions: ImmutableList): Instant { - var now by remember(subscriptions) { mutableStateOf(Clock.System.now()) } + var now by remember(subscriptions) { mutableStateOf(DemoClock.subscriptionNow()) } LaunchedEffect(subscriptions, now) { val nextTransition = nextSubscriptionTransition(subscriptions, now) ?: return@LaunchedEffect delay(nextTransition - now) - now = Clock.System.now() + now = DemoClock.subscriptionNow() } return now } diff --git a/app/src/main/java/to/bitkit/utils/DemoClock.kt b/app/src/main/java/to/bitkit/utils/DemoClock.kt new file mode 100644 index 0000000000..9bc034135d --- /dev/null +++ b/app/src/main/java/to/bitkit/utils/DemoClock.kt @@ -0,0 +1,77 @@ +package to.bitkit.utils + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.launch +import to.bitkit.BuildConfig +import to.bitkit.async.appScope +import to.bitkit.data.SettingsStore +import to.bitkit.di.IoDispatcher +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +import kotlin.time.Instant + +/** + * Dev-only offset that moves Paykit subscription scheduling forward, so a recorded demo can show a renewal without + * waiting a whole billing period. It covers subscription proposals, acceptance, due periods, renewal dates and due + * notifications. One-time payment requests, invoices, payments and allowance checks keep real time. + */ +object DemoClock { + /** Offsets the Dev Settings accept, in whole days. */ + val OFFSET_DAYS_RANGE = 0..400 + + /** Offsets offered in Dev Settings, in whole days. */ + val OFFSET_DAYS_PRESETS = listOf(0, 1, 7, 30, 31, 62, 365) + + val isAvailable: Boolean get() = BuildConfig.DEBUG + + @Volatile + var offsetDays: Int = 0 + private set + + fun setOffsetDays(days: Int) = run { offsetDays = clampedOffsetDays(days) } + + fun clampedOffsetDays(days: Int): Int = days.coerceIn(OFFSET_DAYS_RANGE) + + fun subscriptionDate( + date: Instant, + offsetDays: Int = this.offsetDays, + isAvailable: Boolean = this.isAvailable, + ): Instant { + if (!isAvailable) return date + val days = clampedOffsetDays(offsetDays) + if (days == 0) return date + return date + days.days + } + + fun subscriptionNow(clock: Clock = Clock.System): Instant = subscriptionDate(clock.now()) + + /** [base] moved by the current offset, for code that schedules subscriptions through an injected [Clock]. */ + fun subscriptionClock(base: Clock): Clock = object : Clock { + override fun now(): Instant = subscriptionDate(base.now()) + } +} + +/** Keeps [DemoClock.offsetDays] in step with the offset stored by Dev Settings. */ +@Singleton +class DemoClockSync @Inject constructor( + private val settingsStore: SettingsStore, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) { + companion object { + private const val TAG = "DemoClockSync" + } + + private val scope = appScope(ioDispatcher, TAG) + + fun start() { + if (!DemoClock.isAvailable) return + scope.launch { + settingsStore.demoClockOffsetDays.collect { + DemoClock.setOffsetDays(it) + if (it != 0) Logger.info("Set the demo clock offset to '$it' days", context = TAG) + } + } + } +} diff --git a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt index 6527dd7f1c..da7a89b800 100644 --- a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt @@ -34,6 +34,7 @@ import to.bitkit.repositories.PublicPaykitError import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.repositories.WidgetsRepo import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.utils.DemoClock import to.bitkit.utils.Logger import javax.inject.Inject @@ -213,6 +214,15 @@ class SettingsViewModel @Inject constructor( val isPaykitStateLoaded = settingsStore.isPaykitEnabled.map { true } .asStateFlow(initialValue = false) + val demoClockOffsetDays = settingsStore.demoClockOffsetDays.map { DemoClock.clampedOffsetDays(it) } + .asStateFlow(initialValue = 0) + + fun setDemoClockOffsetDays(days: Int) { + viewModelScope.launch { + settingsStore.setDemoClockOffsetDays(DemoClock.clampedOffsetDays(days)) + } + } + val contactPaymentsEnabled = contactPaymentSettingsRepo.isEnabled .asStateFlow(initialValue = false) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index a6ff85e9f5..d1cc19faf2 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -47,6 +47,8 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime @@ -80,6 +82,10 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat (testDispatcher.scheduler.currentTime - schedulerOriginMillis).milliseconds, ) } + private var subscriptionOffset = Duration.ZERO + private val subscriptionClock = object : Clock { + override fun now(): Instant = clock.now() + subscriptionOffset + } private lateinit var sut: PaykitPaymentRequestRepo @Before @@ -108,6 +114,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat paymentProofRepo, notificationScheduler, clock, + subscriptionClock, ) sut.activate(LOCAL_IDENTITY) } @@ -447,6 +454,34 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat } } + @Test + fun `demo clock offset makes the next billing period due`() = test { + val proposal = paymentRequestRecord() + val active = paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(proposal), listOf(active)) + whenever( + paykitSdkService.acceptPaymentRequest( + COUNTERPARTY, + PaykitReceiverPaths.SERVER, + PAYMENT_REQUEST_ID, + ) + ).thenReturn(active) + sut.refresh().getOrThrow() + sut.accept(sut.subscriptions.value.single()).getOrThrow() + assertEquals( + listOf(Instant.parse("2027-01-01T08:00:00Z")), + sut.pendingRequests.value.mapNotNull { it.billingPeriod?.startsAt }, + ) + + subscriptionOffset = 31.days + sut.refresh().getOrThrow() + + assertEquals( + listOf(Instant.parse("2027-01-01T08:00:00Z"), Instant.parse("2027-02-01T08:00:00Z")), + sut.pendingRequests.value.mapNotNull { it.billingPeriod?.startsAt }.sorted(), + ) + } + @Test fun `accepting subscription rejects terms changed after review`() = test { val reviewedRecord = paymentRequestRecord() diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 42699007fd..12e2cdba93 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -110,6 +110,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentProofRepo, subscriptionNotificationScheduler, clock, + clock, ) sut.activate(LOCAL_IDENTITY) } diff --git a/app/src/test/java/to/bitkit/utils/DemoClockTest.kt b/app/src/test/java/to/bitkit/utils/DemoClockTest.kt new file mode 100644 index 0000000000..faaf1a451c --- /dev/null +++ b/app/src/test/java/to/bitkit/utils/DemoClockTest.kt @@ -0,0 +1,53 @@ +package to.bitkit.utils + +import org.junit.After +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +import kotlin.time.Instant + +class DemoClockTest { + private val date = Instant.parse("2027-01-15T08:00:00Z") + + @After + fun tearDown() { + DemoClock.setOffsetDays(0) + } + + @Test + fun `offset is off by default`() { + assertEquals(date, DemoClock.subscriptionDate(date, offsetDays = 0, isAvailable = true)) + } + + @Test + fun `offset moves the subscription date by whole days`() { + assertEquals(date + 31.days, DemoClock.subscriptionDate(date, offsetDays = 31, isAvailable = true)) + } + + @Test + fun `offset is ignored when unavailable`() { + assertEquals(date, DemoClock.subscriptionDate(date, offsetDays = 31, isAvailable = false)) + } + + @Test + fun `offset is clamped to the supported range`() { + DemoClock.setOffsetDays(-5) + assertEquals(0, DemoClock.offsetDays) + + DemoClock.setOffsetDays(10_000) + assertEquals(DemoClock.OFFSET_DAYS_RANGE.last, DemoClock.offsetDays) + } + + @Test + fun `subscription clock follows the base clock and the current offset`() { + val base = object : Clock { + override fun now(): Instant = date + } + val subscriptionClock = DemoClock.subscriptionClock(base) + + DemoClock.setOffsetDays(7) + + assertEquals(DemoClock.subscriptionDate(date, offsetDays = 7), subscriptionClock.now()) + } +} From 0960c3cbc32f0df3e2d9136977a33748faf04c08 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 25 Sep 2026 00:23:05 +0200 Subject: [PATCH 2/2] docs: add the second billing period journey --- .../subscriptions/second-billing-period.xml | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 journeys/subscriptions/second-billing-period.xml diff --git a/journeys/subscriptions/second-billing-period.xml b/journeys/subscriptions/second-billing-period.xml new file mode 100644 index 0000000000..cc11820bb2 --- /dev/null +++ b/journeys/subscriptions/second-billing-period.xml @@ -0,0 +1,23 @@ + + + The next period of a monthly subscription comes due without waiting a month. Dev Settings carries + a demo clock offset, in debug builds only, that moves subscription scheduling forward by a number + of days: proposal anchors and expiry, acceptance, due periods, renewal dates and due + notifications. One-time requests, invoices, payments and allowances keep real time, and the offset + is Off by default. Requires the active "Journey Sub" from review-and-subscribe.xml with its first + period paid on acceptance, and both instances on a debug build. + + + Launch the E2E Bitkit app on both instances with "Journey Sub" active and its first period paid, per review-and-subscribe.xml + On the creator instance, open the drawer menu, tap Settings (testTag "DrawerSettings"), then Dev Settings (testTag "DevSettings"), and scroll to the PAYKIT section + Verify the "Demo clock offset (days)" row (testTag "DemoClockOffset") reads "Off" + Tap the row and pick 31 (testTag "DemoClockOffset-31"); verify the row now reads "31" + Repeat the two previous steps on the payer instance + On the payer instance, return home and verify within about ten seconds that the next period arrives as a Payment Request for "Journey Sub" over 5,000 sats, opening the Swipe To Pay sheet or waiting in the bell (testTag "PaymentRequestsBell") + Pay it and verify Bitcoin Sent + On the creator instance, verify the payment arrives, as the Received Instant Bitcoin sheet or a new row in Activity + On the payer instance, open the drawer menu, tap Subscriptions and tap the "Journey Sub" row + Verify the PAYMENTS section lists two payments, today's and one dated a month later, and that RENEWS moved a further month ahead + On both instances, open Dev Settings again and set the demo clock offset back to Off (testTag "DemoClockOffset-0"); verify the row reads "Off" + +