Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,13 @@ class PaykitSubscriptionWorkClient @Inject constructor(
class PaykitSubscriptionNotificationWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters,
private val clock: Clock,
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
val startsAt = inputData.getString(EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT)
?.let { runCatching { Instant.parse(it) }.getOrNull() }
?: return Result.failure()
if (startsAt > clock.now()) return Result.retry()
if (App.currentActivity?.value != null) return Result.success()
applicationContext.pushNotification(
title = applicationContext.getString(R.string.subscriptions__payment_due_title),
Expand Down
239 changes: 153 additions & 86 deletions app/src/main/java/to/bitkit/repositories/PubkyRepo.kt

Large diffs are not rendered by default.

24 changes: 21 additions & 3 deletions app/src/main/java/to/bitkit/services/PaykitSdkService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.lightningdevkit.ldknode.Network
import to.bitkit.async.BaseCoroutineScope
import to.bitkit.data.PubkyStore
import to.bitkit.data.keychain.Keychain
import to.bitkit.di.IoDispatcher
import to.bitkit.env.Env
Expand Down Expand Up @@ -169,6 +170,7 @@ internal object PaykitReceiverPaths {
class PaykitSdkService @Inject constructor(
@ApplicationContext private val context: Context,
private val keychain: Keychain,
private val pubkyStore: PubkyStore,
@IoDispatcher ioDispatcher: CoroutineDispatcher,
) : BaseCoroutineScope(ioDispatcher, TAG) {
private val stateStore = PaykitSdkStateBlobStore(keychain)
Expand All @@ -185,6 +187,7 @@ class PaykitSdkService @Inject constructor(
private val identityRepublishMutex = Mutex()
private var republishPublicKey: String? = null
private var nextIdentityRepublishAt = 0L
private var lastIdentityRepublishAt = 0L
private val handleMutex = Mutex()
private val operationMutex = Mutex()
private val setupMutex = Mutex()
Expand All @@ -207,10 +210,11 @@ class PaykitSdkService @Inject constructor(
internal constructor(
context: Context,
keychain: Keychain,
pubkyStore: PubkyStore,
bootstrapFactory: (() -> PubkySessionBootstrap)? = null,
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
sdkFactory: () -> PaykitSdk,
) : this(context, keychain, ioDispatcher) {
) : this(context, keychain, pubkyStore, ioDispatcher) {
this.sdkFactory = sdkFactory
if (bootstrapFactory != null) this.bootstrapFactory = bootstrapFactory
isSetup.complete(Unit)
Expand Down Expand Up @@ -270,9 +274,16 @@ class PaykitSdkService @Inject constructor(
if (!isSetup.isCompleted) PaykitAndroid.initializeOrThrow(context)
val key = publicKey ?: sessionProvider.loadLocalSecretKey()?.let(::pubkyPublicKeyFromSecret)
val identity = key?.let(PubkyPublicKeyFormat::normalized) ?: return@runSuspendCatching
if (identity == republishPublicKey && now < nextIdentityRepublishAt) return@runSuspendCatching
if (
identity == republishPublicKey &&
now >= lastIdentityRepublishAt &&
now < nextIdentityRepublishAt
) {
return@runSuspendCatching
}

republishPublicKey = identity
lastIdentityRepublishAt = now
nextIdentityRepublishAt = now + IDENTITY_REPUBLISH_RETRY_INTERVAL.inWholeMilliseconds
if (bootstrap().republishIdentity(identity)) {
nextIdentityRepublishAt = now + IDENTITY_REPUBLISH_INTERVAL.inWholeMilliseconds
Expand Down Expand Up @@ -980,7 +991,13 @@ class PaykitSdkService @Inject constructor(
}

private suspend fun currentSdkStatePublicKeyLocked(): String? {
return handle().identityStatus()?.publicKey
// Read the persisted owner without restoring the grant we are about to replace.
sessionProvider.suspendStoredSessionAccess()
return try {
handle().identityStatus()?.publicKey
} finally {
sessionProvider.resumeStoredSessionAccess()
}
}

private suspend fun persistSessionAccess(
Expand All @@ -1005,6 +1022,7 @@ class PaykitSdkService @Inject constructor(
persistSessionAccess(result.sessionAccess, shouldStoreLocalSecret)
sessionProvider.setLiveSessionAccess(result.sessionAccess)
if (!PubkyPublicKeyFormat.matches(previousPublicKey, result.publicKey)) {
if (previousPublicKey != null) pubkyStore.reset()
keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name)
}
resetRuntime()
Expand Down
33 changes: 17 additions & 16 deletions app/src/main/java/to/bitkit/services/PubkyAuthHandlerRegistrar.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import kotlinx.coroutines.launch
import to.bitkit.async.appScope
import to.bitkit.data.SettingsStore
import to.bitkit.di.IoDispatcher
import to.bitkit.ext.runSuspendCatching
import to.bitkit.flags.PaykitFeatureFlags
import to.bitkit.repositories.PubkyRepo
import to.bitkit.utils.Logger
Expand Down Expand Up @@ -48,24 +49,24 @@ internal class PubkyAuthHandlerRegistrar @Inject constructor(

collectionScope.launch {
pubkyRepo.awaitInitialization()
combine(settingsStore.isPaykitEnabled, pubkyRepo.publicKey) { localFlagEnabled, publicKey ->
localFlagEnabled to publicKey
combine(
settingsStore.isPaykitEnabled,
pubkyRepo.publicKey,
pubkyRepo.backupStateVersion,
pubkyRepo.identityRefreshVersion,
) { localFlagEnabled, publicKey, _, _ ->
val hasIdentity = runSuspendCatching { pubkyRepo.hasIdentity() }
.onFailure { Logger.warn("Failed to read saved Pubky identity", it, context = TAG) }
.getOrDefault(true)
val isPaykitUiEnabled = PaykitFeatureFlags.isUiEnabled(localFlagEnabled)
val hasSecretKey = isPaykitUiEnabled && publicKey != null && pubkyRepo.hasSecretKey()
val authEnabled = canHandlePubkyAuth(isPaykitUiEnabled, hasIdentity, hasSecretKey)
authEnabled to (isPaykitUiEnabled && !hasIdentity)
}
.distinctUntilChanged()
.collectLatest { (localFlagEnabled, publicKey) ->
val isPaykitUiEnabled = PaykitFeatureFlags.isUiEnabled(localFlagEnabled)
val hasIdentity = publicKey != null
val hasSecretKey = isPaykitUiEnabled && hasIdentity && pubkyRepo.hasSecretKey()

setAliasEnabled(
aliasComponent,
canHandlePubkyAuth(
isPaykitUiEnabled = isPaykitUiEnabled,
hasIdentity = hasIdentity,
hasSecretKey = hasSecretKey,
),
)
setAliasEnabled(signupAliasComponent, isPaykitUiEnabled && !hasIdentity)
.collectLatest { (authEnabled, signupEnabled) ->
setAliasEnabled(aliasComponent, authEnabled)
setAliasEnabled(signupAliasComponent, signupEnabled)
}
}
}
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/java/to/bitkit/ui/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,11 @@ class MainActivity : FragmentActivity() {
intent.launchKey()?.let { outState.putString(KEY_CONSUMED_LAUNCH_INTENT, it) }
}

override fun onResume() {
super.onResume()
appViewModel.onAppResumed()
}

override fun onStop() {
super.onStop()
if (!isChangingConfigurations) appViewModel.lockOnBackground()
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -779,6 +779,7 @@ class AppViewModel @Inject constructor(
.drop(1)
.filter { it == ConnectivityState.CONNECTED }
.collect {
pubkyRepo.restoreSessionIfNeeded()
if (paykitPaymentRequestPollingJob?.isActive == true) {
paykitPaymentRequestPollingJob?.cancel()
paykitPaymentRequestPollingJob = null
Expand Down Expand Up @@ -5763,6 +5764,12 @@ class AppViewModel @Inject constructor(

fun checkTimedSheets() = timedSheetManager.onHomeScreenEntered()

fun onAppResumed() {
viewModelScope.launch {
if (isOnline.value == ConnectivityState.CONNECTED) pubkyRepo.restoreSessionIfNeeded()
}
}

fun onHomeResumed() {
checkTimedSheets()
hwWalletRepo.onAppForegrounded()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@ package to.bitkit.repositories
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.work.ExistingWorkPolicy
import androidx.work.ListenableWorker
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import com.synonym.paykit.PaymentRequestLifecycleState
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
Expand All @@ -16,6 +20,7 @@ import org.mockito.kotlin.clearInvocations
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import to.bitkit.ui.EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT
Expand Down Expand Up @@ -88,6 +93,17 @@ class PaykitSubscriptionNotificationSchedulerTest {
)
}

@Test
fun `worker defers a reminder when clock is before the billing period`() = runTest {
val params = mock<WorkerParameters>()
whenever(params.inputData).thenReturn(
workDataOf(EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT to NEXT_PERIOD_START.toString()),
)
val worker = PaykitSubscriptionNotificationWorker(context, params, clock)

assertEquals(ListenableWorker.Result.retry(), worker.doWork())
}

@Test
fun `synchronize cancels work no longer required`() {
sut.synchronize(
Expand Down
28 changes: 28 additions & 0 deletions app/src/test/java/to/bitkit/repositories/PaykitSubscriptionTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,41 @@ package to.bitkit.repositories

import com.synonym.paykit.PaymentRequestLifecycleState
import org.junit.Test
import java.util.TimeZone
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.time.ExperimentalTime
import kotlin.time.Instant

class PaykitSubscriptionTest {
@Test
fun `travel and daylight saving changes preserve UTC billing boundaries`() {
val original = TimeZone.getDefault()
try {
for (zone in listOf("America/New_York", "Pacific/Kiritimati", "Pacific/Pago_Pago")) {
TimeZone.setDefault(TimeZone.getTimeZone(zone))
val recurrence = PaykitSubscriptionRecurrence(
every = 1,
unit = PaykitRecurrenceUnit.Day,
startsAt = Instant.parse("2027-03-13T08:00:00Z"),
anchor = Instant.parse("2027-03-13T08:00:00Z"),
endsAt = null,
)

val periods = recurrence.upcomingPeriodsAfter(Instant.parse("2027-03-13T09:00:00Z"), 2)

assertEquals(
listOf(Instant.parse("2027-03-14T08:00:00Z"), Instant.parse("2027-03-15T08:00:00Z")),
periods.map { it.startsAt },
zone,
)
}
} finally {
TimeZone.setDefault(original)
}
}

@Test
fun `monthly recurrence returns to anchor day after a short month`() {
val recurrence = PaykitSubscriptionRecurrence(
Expand Down
Loading
Loading