Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/src/main/java/to/bitkit/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand All @@ -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() {
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/java/to/bitkit/data/SettingsStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -33,6 +34,7 @@ private val Context.settingsDataStore: DataStore<SettingsData> by dataStore(
private val Context.localSettingsDataStore: DataStore<Preferences> by preferencesDataStore("local_settings")

@Singleton
@Suppress("TooManyFunctions")
class SettingsStore @Inject constructor(
@ApplicationContext private val context: Context,
) {
Expand All @@ -44,6 +46,7 @@ class SettingsStore @Inject constructor(
val isPubkyProfileSetupPending: Flow<Boolean> = localStore.data.map {
it[PUBKY_PROFILE_SETUP_PENDING_KEY] ?: false
}
val demoClockOffsetDays: Flow<Int> = localStore.data.map { it[DEMO_CLOCK_OFFSET_DAYS_KEY] ?: 0 }

@Volatile
var restoredMonitoredTypesFromBackup: Boolean = false
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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")
}
}

Expand Down
11 changes: 11 additions & 0 deletions app/src/main/java/to/bitkit/di/EnvModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -321,7 +323,7 @@ class PaykitPaymentRequestRepo @Inject constructor(
_pendingRequests.value.filterNot { it.id in presentedRequestIds }

fun subscriptionProposals(): List<PaykitSubscription> =
_subscriptions.value.filter { it.isPayer && it.isProposalVisible(clock.now()) }
_subscriptions.value.filter { it.isPayer && it.isProposalVisible(subscriptionClock.now()) }

fun automaticSubscriptionProposals(): List<PaykitSubscription> =
subscriptionProposals().filterNot { it.id in presentedSubscriptionProposalIds }
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -786,7 +788,7 @@ class PaykitPaymentRequestRepo @Inject constructor(
}

suspend fun cancel(subscription: PaykitSubscription): Result<Unit> = 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(
Expand Down Expand Up @@ -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)
Expand All @@ -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 &&
Expand All @@ -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
Expand Down Expand Up @@ -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) }
Expand Down Expand Up @@ -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()
Expand All @@ -1192,7 +1196,7 @@ class PaykitPaymentRequestRepo @Inject constructor(

private suspend fun prunePresentedSubscriptionProposalIds(subscriptions: List<PaykitSubscription>) {
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
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -100,6 +107,12 @@ fun DevSettingsScreen(
},
switchTestTag = "PaykitUiToggle",
)
if (DemoClock.isAvailable) {
DemoClockOffsetRow(
offsetDays = demoClockOffsetDays,
onSelect = settings::setDemoClockOffsetDays,
)
}
}

SectionHeader("HARDWARE WALLET")
Expand Down Expand Up @@ -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"
Loading
Loading