Skip to content

Commit 504ae04

Browse files
committed
fix: complete Pubky signup handoff
1 parent 8f81189 commit 504ae04

5 files changed

Lines changed: 87 additions & 38 deletions

File tree

app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,14 +117,14 @@ data class PubkyAuthRequest(
117117
fun isSignupUrl(rawUrl: String): Boolean = runCatching { URI(rawUrl).isSignupRequest() }.getOrDefault(false)
118118

119119
fun isDirectSignupUrl(rawUrl: String): Boolean =
120-
runCatching { URI(rawUrl).isDirectSignupRequest() }.getOrDefault(false)
120+
parseSignup(rawUrl).getOrNull()?.let { it.authorizationUrl == null } ?: false
121121

122122
fun parseSignup(rawUrl: String): Result<PubkyAuthRequest> = runCatching {
123123
val uri = URI(rawUrl)
124124
require(uri.isSignupRequest()) { "Unsupported Pubky signup URL" }
125125
val query = parseQuery(uri)
126126
val homeserver = query.requiredSingle("hs")
127-
val authorizesApp = uri.scheme.equals("pubkyring", ignoreCase = true)
127+
val authorizesApp = uri.authorizesApp(query)
128128
val relay = if (authorizesApp) query.requiredSingle("relay") else ""
129129
val secret = if (authorizesApp) query.requiredSingle("secret") else ""
130130
val capabilities = if (authorizesApp) query.requiredSingle("caps") else ""
@@ -161,6 +161,14 @@ data class PubkyAuthRequest(
161161
it.equals("direct_signup", ignoreCase = true) || it.equals("signup", ignoreCase = true)
162162
}
163163

164+
private fun URI.authorizesApp(query: Map<String, List<String>>): Boolean =
165+
scheme.equals("pubkyring", ignoreCase = true) ||
166+
(
167+
scheme.equals("pubkyauth", ignoreCase = true) &&
168+
(host ?: rawAuthority).equals("signup", ignoreCase = true) &&
169+
listOf("relay", "secret", "caps").any(query::containsKey)
170+
)
171+
164172
fun parseBitkitClaim(rawUrl: String, capabilities: String): Result<PubkyAuthClaim?> =
165173
parseBitkitClaimValues(rawUrl).fold(
166174
onSuccess = { claimValues -> validateBitkitClaim(claimValues, capabilities) },

app/src/main/java/to/bitkit/ui/ContentView.kt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@ import android.content.Intent
77
import android.os.Build
88
import androidx.activity.compose.rememberLauncherForActivityResult
99
import androidx.activity.result.contract.ActivityResultContracts
10+
import androidx.compose.foundation.background
1011
import androidx.compose.foundation.layout.Box
12+
import androidx.compose.foundation.layout.Row
1113
import androidx.compose.foundation.layout.fillMaxSize
14+
import androidx.compose.foundation.layout.size
1215
import androidx.compose.material3.DrawerState
1316
import androidx.compose.material3.DrawerValue
1417
import androidx.compose.material3.rememberDrawerState
@@ -27,6 +30,7 @@ import androidx.compose.ui.Alignment
2730
import androidx.compose.ui.Modifier
2831
import androidx.compose.ui.platform.LocalContext
2932
import androidx.compose.ui.res.stringResource
33+
import androidx.compose.ui.unit.dp
3034
import androidx.core.net.toUri
3135
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
3236
import androidx.lifecycle.Lifecycle
@@ -63,8 +67,11 @@ import to.bitkit.models.Toast
6367
import to.bitkit.repositories.ConnectivityState
6468
import to.bitkit.ui.Routes.ExternalConnection
6569
import to.bitkit.ui.components.AuthCheckScreen
70+
import to.bitkit.ui.components.BodyM
6671
import to.bitkit.ui.components.DefaultSheetContainerColor
6772
import to.bitkit.ui.components.DrawerMenu
73+
import to.bitkit.ui.components.GradientCircularProgressIndicator
74+
import to.bitkit.ui.components.HorizontalSpacer
6875
import to.bitkit.ui.components.Sheet
6976
import to.bitkit.ui.components.SheetHandlePlacement
7077
import to.bitkit.ui.components.SheetHost
@@ -456,6 +463,7 @@ fun ContentView(
456463
val showWidgets by settingsViewModel.showWidgets.collectAsStateWithLifecycle()
457464
val currentSheet by appViewModel.currentSheet.collectAsStateWithLifecycle()
458465
val isCreatingPaymentRequest by appViewModel.isCreatingPaymentRequest.collectAsStateWithLifecycle()
466+
val isCompletingPubkySignup by appViewModel.isCompletingPubkySignup.collectAsStateWithLifecycle()
459467
val hwSendViewModel = hiltViewModel<HwSendViewModel>()
460468
val hwSendUiState by hwSendViewModel.uiState.collectAsStateWithLifecycle()
461469
val canDismissSheet = currentSheet !is Sheet.Send ||
@@ -716,6 +724,21 @@ fun ContentView(
716724
onOpenWidgetsSheet = { appViewModel.showSheet(Sheet.Widgets()) },
717725
modifier = Modifier.align(Alignment.TopEnd)
718726
)
727+
728+
if (isCompletingPubkySignup) {
729+
Box(
730+
modifier = Modifier
731+
.fillMaxSize()
732+
.background(Colors.Black),
733+
contentAlignment = Alignment.Center,
734+
) {
735+
Row(verticalAlignment = Alignment.CenterVertically) {
736+
GradientCircularProgressIndicator(modifier = Modifier.size(20.dp))
737+
HorizontalSpacer(12.dp)
738+
BodyM(text = stringResource(R.string.profile__deriving_keys), color = Colors.White64)
739+
}
740+
}
741+
}
719742
}
720743
}
721744
}

app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,8 @@ class AppViewModel @Inject constructor(
325325

326326
private val _currentSheet: MutableStateFlow<Sheet?> = MutableStateFlow(null)
327327
val currentSheet = _currentSheet.asStateFlow()
328+
private val _isCompletingPubkySignup = MutableStateFlow(false)
329+
val isCompletingPubkySignup = _isCompletingPubkySignup.asStateFlow()
328330
val pendingPaymentRequests = paykitPaymentRequestRepo.pendingRequests
329331
val paymentRequestHistory = paykitPaymentRequestRepo.paymentRequestHistory
330332
val eligiblePaymentRequestTargets = paykitPaymentRequestRepo.eligibleTargets
@@ -4719,23 +4721,32 @@ class AppViewModel @Inject constructor(
47194721

47204722
private suspend fun handleDirectPubkySignup(authUrl: String) {
47214723
hideSheet()
4722-
val request = pubkyRepo.parseAuthUrl(authUrl).getOrElse {
4723-
ToastEventBus.send(
4724-
type = Toast.ToastType.ERROR,
4725-
title = context.getString(R.string.profile__auth_error_title),
4726-
description = it.localizedPubkyAuthMessage(context),
4727-
)
4728-
return
4729-
}
4730-
pubkyRepo.approveSignupAuth(request).onFailure {
4731-
val alreadySignedIn = it is PubkyAlreadySignedInError
4732-
ToastEventBus.send(
4733-
type = if (alreadySignedIn) Toast.ToastType.INFO else Toast.ToastType.ERROR,
4734-
title = context.getString(
4735-
if (alreadySignedIn) R.string.pubky_auth__already_signed_in else R.string.profile__auth_error_title,
4736-
),
4737-
description = if (alreadySignedIn) null else it.localizedPubkyAuthMessage(context),
4738-
)
4724+
_isCompletingPubkySignup.value = true
4725+
try {
4726+
val request = pubkyRepo.parseAuthUrl(authUrl).getOrElse {
4727+
ToastEventBus.send(
4728+
type = Toast.ToastType.ERROR,
4729+
title = context.getString(R.string.profile__auth_error_title),
4730+
description = it.localizedPubkyAuthMessage(context),
4731+
)
4732+
return
4733+
}
4734+
pubkyRepo.approveSignupAuth(request).onFailure {
4735+
val alreadySignedIn = it is PubkyAlreadySignedInError
4736+
ToastEventBus.send(
4737+
type = if (alreadySignedIn) Toast.ToastType.INFO else Toast.ToastType.ERROR,
4738+
title = context.getString(
4739+
if (alreadySignedIn) {
4740+
R.string.pubky_auth__already_signed_in
4741+
} else {
4742+
R.string.profile__auth_error_title
4743+
},
4744+
),
4745+
description = if (alreadySignedIn) null else it.localizedPubkyAuthMessage(context),
4746+
)
4747+
}
4748+
} finally {
4749+
_isCompletingPubkySignup.value = false
47394750
}
47404751
}
47414752

app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,22 @@ import kotlin.test.assertTrue
1111
class PubkyAuthRequestTest {
1212

1313
@Test
14-
fun `parse Ring signup preserves registration and authorization details`() {
15-
val request = PubkyAuthRequest.parseSignup(ringSignupUrl("invite code")).getOrThrow()
16-
17-
assertTrue(request.isSignup)
18-
assertEquals("homeserver", request.homeserverPublicKey)
19-
assertEquals("invite code", request.signupToken)
20-
assertEquals("https://relay.example/inbox/", request.relay)
21-
assertEquals("/pub/example.app/:rw", request.capabilities)
22-
assertEquals(
23-
"pubkyauth:///?relay=https%3A%2F%2Frelay.example%2Finbox%2F" +
24-
"&secret=secret&caps=%2Fpub%2Fexample.app%2F%3Arw",
25-
request.authorizationUrl,
26-
)
14+
fun `parse authorized signup preserves registration and authorization details`() {
15+
listOf("pubkyring", "pubkyauth").forEach { scheme ->
16+
val request = PubkyAuthRequest.parseSignup(ringSignupUrl("invite code", scheme)).getOrThrow()
17+
18+
assertTrue(request.isSignup)
19+
assertEquals("homeserver", request.homeserverPublicKey)
20+
assertEquals("invite code", request.signupToken)
21+
assertEquals("https://relay.example/inbox/", request.relay)
22+
assertEquals("/pub/example.app/:rw", request.capabilities)
23+
assertEquals(
24+
"pubkyauth:///?relay=https%3A%2F%2Frelay.example%2Finbox%2F" +
25+
"&secret=secret&caps=%2Fpub%2Fexample.app%2F%3Arw",
26+
request.authorizationUrl,
27+
)
28+
assertFalse(PubkyAuthRequest.isDirectSignupUrl(request.rawUrl))
29+
}
2730
}
2831

2932
@Test
@@ -45,6 +48,7 @@ class PubkyAuthRequestTest {
4548
val invalidUrls = listOf(
4649
ringSignupUrl().replace("&secret=secret", ""),
4750
"${ringSignupUrl()}&hs=other",
51+
directSignupUrl("signup") + "&relay=https%3A%2F%2Frelay.example",
4852
)
4953

5054
invalidUrls.forEach { url ->
@@ -95,6 +99,7 @@ class PubkyAuthRequestTest {
9599
).getOrThrow()
96100

97101
assertFalse(request.isSignup)
102+
assertFalse(PubkyAuthRequest.isDirectSignupUrl(request.rawUrl))
98103
assertEquals("paykit.test", request.clientId)
99104
assertNull(request.bitkitClaim)
100105
}
@@ -307,8 +312,8 @@ class PubkyAuthRequestTest {
307312
return "pubkyauth://signin?caps=$capabilities&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F$claims"
308313
}
309314

310-
private fun ringSignupUrl(signupToken: String? = null): String =
311-
"pubkyring://signup?hs=homeserver" +
315+
private fun ringSignupUrl(signupToken: String? = null, scheme: String = "pubkyring"): String =
316+
"$scheme://signup?hs=homeserver" +
312317
"&relay=https%3A%2F%2Frelay.example%2Finbox%2F" +
313318
"&secret=secret&caps=%2Fpub%2Fexample.app%2F%3Arw" +
314319
signupToken?.let { "&st=${URLEncoder.encode(it, Charsets.UTF_8.name())}" }.orEmpty()

app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
223223
private val testPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
224224
private val signupAuthUrl =
225225
"pubkyring://signup?hs=homeserver&relay=https://relay&secret=request&caps=/pub/example/:rw"
226+
private val legacyAuthorizedSignupAuthUrl = signupAuthUrl.replace("pubkyring://", "pubkyauth://")
226227
private val directSignupAuthUrl = "pubkyauth://direct_signup?hs=homeserver&st=invite"
227228
private val legacyDirectSignupAuthUrl = "pubkyauth://signup?hs=homeserver&st=invite"
228229

@@ -1926,12 +1927,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
19261927
}
19271928

19281929
@Test
1929-
fun `global scanner accepts Ring signup without an existing identity`() = test {
1930+
fun `global scanner accepts authorized signup without an existing identity`() = test {
19301931
enablePaykitUi()
19311932

1932-
scanSignup(signupAuthUrl)
1933-
1934-
assertEquals(Sheet.PubkyAuth(signupAuthUrl), sut.currentSheet.value)
1933+
listOf(signupAuthUrl, legacyAuthorizedSignupAuthUrl).forEach { authUrl ->
1934+
scanSignup(authUrl)
1935+
assertEquals(Sheet.PubkyAuth(authUrl), sut.currentSheet.value)
1936+
}
19351937
verify(pubkyRepo, never()).hasSecretKey()
19361938
}
19371939

0 commit comments

Comments
 (0)