Skip to content
Merged
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
4 changes: 4 additions & 0 deletions app/src/main/java/to/bitkit/models/Toast.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@ data class Toast(
val autoHide: Boolean,
val visibilityTime: Long = VISIBILITY_TIME_DEFAULT,
val testTag: String? = null,
val isLoading: Boolean = false,
) {
enum class ToastType { SUCCESS, INFO, LIGHTNING, WARNING, ERROR }

companion object {
const val VISIBILITY_TIME_SHORT = 1500L
const val VISIBILITY_TIME_DEFAULT = 3000L

/** Visibility for a toast with an action the user must read before it hides. */
const val VISIBILITY_TIME_LONG = 6000L
}
}
5 changes: 5 additions & 0 deletions app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1527,6 +1527,11 @@ class PubkyRepo @Inject constructor(
keychain.exists(Keychain.Key.PUBKY_SECRET_KEY.name)
}

/** Whether Bitkit stores its own key for the saved identity, read without restoring the session. */
suspend fun hasLocalSecretKey(): Boolean = withContext(ioDispatcher) {
keychain.exists(Keychain.Key.PUBKY_SECRET_KEY.name) && !keychain.exists(Keychain.Key.SHARED_PUBKY_SOURCE.name)
}

suspend fun hasIdentity(): Boolean = withContext(ioDispatcher) {
_publicKey.value != null ||
!keychain.loadString(Keychain.Key.PAYKIT_SESSION.name).isNullOrEmpty() ||
Expand Down
15 changes: 13 additions & 2 deletions app/src/main/java/to/bitkit/services/PubkyAuthHandlerRegistrar.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
import javax.inject.Singleton

/** Advertises Pubky signup and authorization handlers when their required identity state is available. */
/**
* Advertises Pubky signup and authorization handlers when their required identity state is available.
* While a saved session is not restored yet, a secret key Bitkit stores itself keeps the authorization handler on, so a
* sign-in link can wait for the restore instead of being dropped. Approval still checks the restored identity's key.
*/
@Singleton
internal class PubkyAuthHandlerRegistrar @Inject constructor(
@ApplicationContext private val context: Context,
Expand Down Expand Up @@ -59,7 +63,7 @@ internal class PubkyAuthHandlerRegistrar @Inject constructor(
.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 hasSecretKey = isPaykitUiEnabled && hasLocalSecretKey(publicKey)
val authEnabled = canHandlePubkyAuth(isPaykitUiEnabled, hasIdentity, hasSecretKey)
authEnabled to (isPaykitUiEnabled && !hasIdentity)
}
Expand All @@ -71,6 +75,13 @@ internal class PubkyAuthHandlerRegistrar @Inject constructor(
}
}

private suspend fun hasLocalSecretKey(publicKey: String?): Boolean {
if (publicKey != null) return pubkyRepo.hasSecretKey()
return runSuspendCatching { pubkyRepo.hasLocalSecretKey() }
.onFailure { Logger.warn("Failed to read saved Pubky secret key", it, context = TAG) }
.getOrDefault(false)
}

private fun setAliasEnabled(component: ComponentName, enabled: Boolean) {
val state =
if (enabled) {
Expand Down
30 changes: 26 additions & 4 deletions app/src/main/java/to/bitkit/ui/components/ToastView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
Expand Down Expand Up @@ -177,10 +178,21 @@ fun ToastView(
.fillMaxWidth()
.padding(16.dp)
) {
BodyMSB(
text = toast.title,
color = tintColor,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (toast.isLoading) {
GradientCircularProgressIndicator(
strokeWidth = 2.dp,
modifier = Modifier.size(16.dp)
)
}
BodyMSB(
text = toast.title,
color = tintColor,
)
}
toast.description?.let { description ->
Caption(
text = description,
Expand Down Expand Up @@ -312,6 +324,16 @@ private fun ToastViewPreview() {
),
onDismiss = {},
)
ToastView(
toast = Toast(
type = Toast.ToastType.INFO,
title = "Loading Your Pubky Profile",
description = "Your sign-in request opens when your profile is ready.",
autoHide = false,
isLoading = true,
),
onDismiss = {},
)
ToastView(
toast = Toast(
type = Toast.ToastType.ERROR,
Expand Down
21 changes: 13 additions & 8 deletions app/src/main/java/to/bitkit/ui/shared/toast/ToastQueueManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,14 @@ class ToastQueueManager(private val scope: CoroutineScope) {
private var isPaused = false

/**
* Add toast to queue. If queue is full, drops oldest.
* Add toast to queue. If queue is full, drops oldest. A shown or queued loading toast returns after the new one.
*/
fun enqueue(toast: Toast) {
val interruptedLoading = _currentToast.value?.takeIf { it.isLoading }
_queue.update { current ->
val newQueue = if (current.size >= MAX_QUEUE_SIZE) {
// Drop oldest (first item) when queue full
current.drop(1) + toast
} else {
current + toast
}
newQueue
val (loading, others) = current.partition { it.isLoading }
// Drop oldest when queue full
(others + toast + loading + listOfNotNull(interruptedLoading)).takeLast(MAX_QUEUE_SIZE)
}
dismissCurrentToast()
Comment thread
ovitrif marked this conversation as resolved.
}
Expand All @@ -63,6 +60,14 @@ class ToastQueueManager(private val scope: CoroutineScope) {
showNextToastIfAvailable()
}

/**
* Hide the given toast, if shown or still queued, and leave any other toast in place.
*/
fun dismiss(toast: Toast) {
if (_currentToast.value === toast) return dismissCurrentToast()
_queue.update { queue -> queue.filterNot { it === toast } }
}

/**
* Pause current toast timer (called on drag start).
*/
Expand Down
67 changes: 60 additions & 7 deletions app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ import to.bitkit.repositories.PendingPaymentResolution
import to.bitkit.repositories.PreActivityMetadataRepo
import to.bitkit.repositories.PrivatePaykitPaymentContext
import to.bitkit.repositories.PrivatePaykitRepo
import to.bitkit.repositories.PubkyIdentityReadiness
import to.bitkit.repositories.PubkyRepo
import to.bitkit.repositories.PubkySignIn
import to.bitkit.repositories.PublicPaykitPaymentResult
Expand Down Expand Up @@ -336,6 +337,7 @@ class AppViewModel @Inject constructor(

@Volatile
private var scheduledScan: ScheduledScan? = null
private var pubkyAuthWaitToast: Toast? = null

private val deferredScanLock = Any()
private var deferredScan: DeferredScan? = null
Expand Down Expand Up @@ -3064,11 +3066,15 @@ class AppViewModel @Inject constructor(
val nextJob = viewModelScope.launch(start = CoroutineStart.LAZY) {
clearPaymentRequestPreparation(previousPreparation)
scanMutex.withLock {
if (!awaitPubkyDeeplinkInitialization(source, data, allowPubkyAuth)) return@withLock
if (deferLockedScan(deferrable)) return@withLock
prepareContactPaymentContextForScan(normalized, allowPubkyAuth, contactPaymentContext)
if (startDelay > Duration.ZERO) delay(startDelay)
handleScan(data, routePubkyKeys, contactPaymentContext, allowPubkyAuth, suppressQuickPay)
try {
if (!awaitPubkyDeeplinkInitialization(source, data, allowPubkyAuth)) return@withLock
if (deferLockedScan(deferrable)) return@withLock
prepareContactPaymentContextForScan(normalized, allowPubkyAuth, contactPaymentContext)
if (startDelay > Duration.ZERO) delay(startDelay)
handleScan(data, routePubkyKeys, contactPaymentContext, allowPubkyAuth, suppressQuickPay)
} finally {
hidePubkyAuthWaitToast()
}
}
}
val nextScheduledScan = ScheduledScan(
Expand Down Expand Up @@ -3115,13 +3121,20 @@ class AppViewModel @Inject constructor(
}

if (!PubkyAuthRequest.isSignupUrl(data) || PubkyAuthRequest.isGrantSignupUrl(data)) {
val isInitializationReady = withTimeoutOrNull(PubkyService.AUTHORIZATION_TIMEOUT) {
if (!isContactLink) showPubkyAuthWaitToastIfRestoring()
val timeout = if (isContactLink) PubkyService.AUTHORIZATION_TIMEOUT else PUBKY_AUTH_DEEPLINK_TIMEOUT
val isInitializationReady = withTimeoutOrNull(timeout) {
pubkyRepo.awaitInitialization()
if (!isContactLink) pubkyRepo.awaitIdentityReady()
if (!isContactLink) awaitPubkyAuthIdentity()
awaitContactDataForDeeplink(isContactLink)
} ?: false
if (!isInitializationReady) {
Logger.warn("Failed to initialize Pubky deeplink", context = TAG)
if (!isContactLink) {
hidePubkyAuthWaitToast()
showPubkyIdentityUnavailableToast()
return false
}
ToastEventBus.send(
type = Toast.ToastType.ERROR,
title = context.getString(
Expand All @@ -3141,6 +3154,36 @@ class AppViewModel @Inject constructor(
return isPaykitUiEnabledFromSettings() && walletRepo.walletExists()
}

private suspend fun awaitPubkyAuthIdentity() {
while (pubkyRepo.awaitIdentityReady() == PubkyIdentityReadiness.Unavailable) {
delay(PUBKY_AUTH_DEEPLINK_RETRY_INTERVAL)
isOnline.first { it == ConnectivityState.CONNECTED }
Logger.debug("Retrying Pubky session restore for a sign-in link", context = TAG)
}
}

private suspend fun showPubkyAuthWaitToastIfRestoring() {
if (pubkyRepo.publicKey.value != null) return
if (!isPaykitUiEnabledFromSettings() || !walletRepo.walletExists()) return
if (!runSuspendCatching { pubkyRepo.hasIdentity() }.getOrDefault(false)) return
val waitToast = Toast(
type = Toast.ToastType.INFO,
title = context.getString(R.string.pubky_auth__loading_identity),
description = context.getString(R.string.pubky_auth__loading_identity_desc),
autoHide = false,
testTag = PUBKY_AUTH_WAIT_TOAST_TAG,
isLoading = true,
)
pubkyAuthWaitToast = waitToast
toastManager.enqueue(waitToast)
}

private fun hidePubkyAuthWaitToast() {
val waitToast = pubkyAuthWaitToast ?: return
pubkyAuthWaitToast = null
toastManager.dismiss(waitToast)
}

private suspend fun awaitContactDataForDeeplink(isContactLink: Boolean): Boolean {
if (!isContactLink || pubkyRepo.publicKey.value == null) return true
return pubkyRepo.refreshContacts()
Expand Down Expand Up @@ -7629,6 +7672,7 @@ class AppViewModel @Inject constructor(
type = Toast.ToastType.ERROR,
title = context.getString(R.string.pubky_auth__identity_unavailable),
description = context.getString(R.string.pubky_auth__identity_unavailable_desc),
visibilityTime = Toast.VISIBILITY_TIME_LONG,
)
return
}
Expand Down Expand Up @@ -7725,6 +7769,15 @@ class AppViewModel @Inject constructor(
private val PAYKIT_PAYMENT_REQUEST_PRESENTATION_RETRY_INTERVAL = 120.seconds
private val PUBLIC_PAYKIT_SYNC_DEBOUNCE = 1.seconds
private val PUBLIC_PAYKIT_BOLT11_REFRESH_WINDOW = 30.minutes
/** Longest wait for the saved Pubky session before a sign-in link fails. */
internal val PUBKY_AUTH_DEEPLINK_TIMEOUT = 60.seconds

/** Pause between Pubky session restore attempts while a sign-in link waits. */
private val PUBKY_AUTH_DEEPLINK_RETRY_INTERVAL = 3.seconds

/** Test tag of the toast shown while a sign-in link waits for the Pubky session. */
internal const val PUBKY_AUTH_WAIT_TOAST_TAG = "PubkyAuthWaitToast"

private const val BITKIT_SCHEME = "bitkit"
private const val RECOVERY_MODE_DEEPLINK = "recovery-mode"

Expand Down
2 changes: 2 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,8 @@
<string name="pubky_auth__homeserver">Homeserver</string>
<string name="pubky_auth__identity_unavailable">Couldn\'t Load Your Pubky Profile</string>
<string name="pubky_auth__identity_unavailable_desc">Check your connection and try again.</string>
<string name="pubky_auth__loading_identity">Loading Your Pubky Profile</string>
<string name="pubky_auth__loading_identity_desc">Your sign-in request opens when your profile is ready.</string>
<string name="pubky_auth__no_identity">Pubky Identity Required</string>
<string name="pubky_auth__no_identity_desc">Create a Pubky identity in your profile to approve auth requests.</string>
<string name="pubky_auth__signup_description">Create a new Pubky identity on this homeserver. Only continue if you trust it.</string>
Expand Down
13 changes: 13 additions & 0 deletions app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1772,6 +1772,19 @@ class PubkyRepoTest : BaseUnitTest() {
assertNull(result.getOrNull())
}

@Test
fun `hasLocalSecretKey is true only for a stored key without a Ring source`() = test {
whenever(keychain.exists(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(true)
assertTrue(sut.hasLocalSecretKey())

whenever(keychain.exists(Keychain.Key.SHARED_PUBKY_SOURCE.name)).thenReturn(true)
assertFalse(sut.hasLocalSecretKey())

whenever(keychain.exists(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(false)
whenever(keychain.exists(Keychain.Key.SHARED_PUBKY_SOURCE.name)).thenReturn(false)
assertFalse(sut.hasLocalSecretKey())
}

@Test
fun `adoptRingIdentity should reject a mismatching credential without changing the source`() = test {
val ringPubky = VALID_SELF_KEY.removePrefix("pubky")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() {
whenever(pubkyRepo.backupStateVersion).thenReturn(backupStateVersion)
whenever(pubkyRepo.identityRefreshVersion).thenReturn(identityRefreshVersion)
whenever(pubkyRepo.hasIdentity()).thenAnswer { publicKey.value != null }
whenever(pubkyRepo.hasLocalSecretKey()).thenReturn(false)
}

@Test
Expand Down Expand Up @@ -150,6 +151,26 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() {
verifyComponentStates(authEnabled = false, signupEnabled = true)
}

@Test
fun `failed restore keeps the handler on for a saved locally managed identity`() = test {
isPaykitEnabled.value = true
whenever(pubkyRepo.hasIdentity()).thenReturn(true)
whenever(pubkyRepo.hasLocalSecretKey()).thenReturn(true)

createSut().start(backgroundScope)
runCurrent()

verifyComponentStates(authEnabled = true, signupEnabled = false)
verify(pubkyRepo, never()).hasSecretKey()

clearInvocations(packageManager)
whenever(pubkyRepo.hasSecretKey()).thenReturn(true)
publicKey.value = "pubkylocal"
runCurrent()

verify(packageManager, never()).setComponentEnabledSetting(any(), any(), any())
}

@Test
fun `saved identity suppresses signup during restoration failure until explicitly removed`() = test {
isPaykitEnabled.value = true
Expand Down
Loading
Loading