Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
0326e58
feat: support pubky ring signup
ben-kaufman Sep 2, 2026
238d78c
fix: complete pubky ring signup
ben-kaufman Sep 2, 2026
305e75e
feat: support direct pubky signup
ben-kaufman Sep 2, 2026
800dfcf
fix: complete pubky signup handoff
ben-kaufman Sep 3, 2026
2a59eb5
fix: recover failed pubky signup
ben-kaufman Sep 3, 2026
f8103f7
fix: harden pubky signup recovery
ben-kaufman Sep 6, 2026
5a86731
fix: require consent and local auth for pubky signup
ben-kaufman Sep 7, 2026
d614969
test: format signup consent regression
ben-kaufman Sep 7, 2026
dc72c36
fix: preserve pubky homeserver on restore
ben-kaufman Sep 7, 2026
4b828cd
fix: route pubky ring signup links
ben-kaufman Sep 8, 2026
0ff1a01
Merge branch 'master' into codex/pubky-ring-signup
ben-kaufman Sep 8, 2026
6df1707
fix: separate pubky signup and authorization routes
ben-kaufman Sep 8, 2026
7e5d24b
fix: clear rejected contact payment context
ben-kaufman Sep 8, 2026
abd7983
fix: log superseded pubky approval failures
ben-kaufman Sep 8, 2026
f2bee10
test: cover superseded pubky approval failures
ben-kaufman Sep 8, 2026
3dac138
Merge branch 'master' into codex/pubky-ring-signup
ben-kaufman Sep 8, 2026
4acbbb9
chore: merge master into pubky signup
ben-kaufman Sep 9, 2026
15aca50
fix: allow completed pubky signup links to reopen
ben-kaufman Sep 9, 2026
d828fc9
fix: handle pubky approval and recovery lifecycle
ben-kaufman Sep 9, 2026
9ec2e37
Merge branch 'master' into codex/pubky-ring-signup
ben-kaufman Sep 10, 2026
e3670e9
fix: bound pubky ring relay approval
ben-kaufman Sep 10, 2026
1c43a45
fix: hide raw pubky authorization errors
ben-kaufman Sep 10, 2026
4060131
Merge branch 'master' into codex/pubky-ring-signup
ben-kaufman Sep 10, 2026
a9c5c5c
fix: restrict pubky auth links and restore error copy
ben-kaufman Sep 10, 2026
40b2182
fix: reject payment-wrapped pubky auth requests
ben-kaufman Sep 10, 2026
c935631
fix: retain pubky auth links during startup
ben-kaufman Sep 11, 2026
48c8b04
Merge branch 'master' into codex/pubky-ring-signup
ben-kaufman Sep 11, 2026
efef4e8
fix: retain pubky auth links until settings load
ben-kaufman Sep 11, 2026
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
28 changes: 27 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@
android:resource="@xml/shortcuts" />
</activity>

<!-- Enabled only while Bitkit can authorize pubkyauth requests locally. -->
<!-- Enabled only while Bitkit can authorize with a locally managed Pubky identity. -->
<activity-alias
android:name=".ui.MainActivityPubkyAuth"
android:targetActivity=".ui.MainActivity"
Expand All @@ -174,6 +174,32 @@
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="pubkyauth" />
<data android:host="signin_grant" />
<data android:host="signup_grant" />
</intent-filter>
</activity-alias>

<!-- Enabled only while Bitkit can create a Pubky identity. -->
<activity-alias
android:name=".ui.MainActivityPubkySignup"
android:targetActivity=".ui.MainActivity"
android:enabled="false"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="pubkyauth" />
<data android:host="signup" />
<data android:host="direct_signup" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="pubkyring"
android:host="signup" />
</intent-filter>
</activity-alias>

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 @@ -40,6 +40,9 @@ class SettingsStore @Inject constructor(

val data: Flow<SettingsData> = store.data
val isPaykitEnabled: Flow<Boolean> = localStore.data.map { it[PAYKIT_ENABLED_KEY] ?: false }
val isPubkyProfileSetupPending: Flow<Boolean> = localStore.data.map {
it[PUBKY_PROFILE_SETUP_PENDING_KEY] ?: false
}

@Volatile
var restoredMonitoredTypesFromBackup: Boolean = false
Expand Down Expand Up @@ -68,6 +71,10 @@ class SettingsStore @Inject constructor(
localStore.edit { it[PAYKIT_ENABLED_KEY] = value }
}

suspend fun setPubkyProfileSetupPending(value: Boolean) {
localStore.edit { it[PUBKY_PROFILE_SETUP_PENDING_KEY] = value }
}

suspend fun addLastUsedTag(newTag: String) {
store.updateData { currentSettings ->
val combinedTags = (listOf(newTag) + currentSettings.lastUsedTags).distinct()
Expand Down Expand Up @@ -100,6 +107,7 @@ class SettingsStore @Inject constructor(
private const val TAG = "SettingsStore"
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")
}
}

Expand Down
101 changes: 101 additions & 0 deletions app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
import to.bitkit.utils.AppError
import java.net.URI
import java.net.URLDecoder
import java.net.URLEncoder
import java.nio.charset.StandardCharsets

enum class PubkyAuthClaim(val wireValue: String) {
Expand Down Expand Up @@ -71,13 +72,23 @@ data class PubkyAuthRequest(
val permissions: List<PubkyAuthPermission>,
val serviceNames: List<String>,
val bitkitClaim: PubkyAuthClaim?,
val homeserverPublicKey: String? = null,
val signupToken: String? = null,
val authorizationUrl: String? = rawUrl,
) {
val isSignup: Boolean
get() = isSignupUrl(rawUrl)

companion object {
@Suppress("LongParameterList")
fun parse(
rawUrl: String,
clientId: String,
relay: String,
capabilities: String,
homeserverPublicKey: String? = null,
signupToken: String? = null,
authorizationUrl: String? = rawUrl,
): Result<PubkyAuthRequest> = parseBitkitClaim(rawUrl, capabilities).map { bitkitClaim ->
val permissions = parseCapabilities(capabilities)
PubkyAuthRequest(
Expand All @@ -88,9 +99,73 @@ data class PubkyAuthRequest(
permissions = permissions,
serviceNames = permissions.mapNotNull { extractServiceName(it.path) }.distinct(),
bitkitClaim = bitkitClaim,
homeserverPublicKey = homeserverPublicKey,
signupToken = signupToken,
authorizationUrl = authorizationUrl,
)
}

fun isProtocolUrl(rawUrl: String): Boolean = runCatching {
val uri = URI(rawUrl)
when (uri.scheme?.lowercase()) {
"pubkyauth" -> true
"pubkyring" -> uri.host.equals("signup", ignoreCase = true)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new pubkyring branch classifies pubkyring://signup as a supported protocol URL, and the ViewModel tests exercise it as a deeplink, but the exported MainActivityPubkyAuth alias in AndroidManifest.xml only registers the pubkyauth scheme. Android therefore cannot deliver this signup link to the new handler, so the advertised deeplink path works only when the payload is scanned. Could we register the pubkyring signup scheme on the conditionally enabled alias and cover actual intent resolution?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a separate pubkyring://signup intent filter to the existing conditionally enabled Pubky alias. Other Ring hosts remain excluded, and the pubkyauth filter keeps its existing matching behavior. PubkyAuthManifestTest.kt queries PackageManager against the merged manifest to cover disabled/enabled routing, the target activity, existing pubkyauth links, and rejection of unrelated Ring links. The new regression fails with the original manifest and passes with the filter. Compilation and all 2,347 unit tests pass.

else -> false
}
}.getOrDefault(false)

fun isSignupUrl(rawUrl: String): Boolean = runCatching { URI(rawUrl).isSignupRequest() }.getOrDefault(false)

fun parseSignup(rawUrl: String): Result<PubkyAuthRequest> = runCatching {
val uri = URI(rawUrl)
require(uri.isSignupRequest()) { "Unsupported Pubky signup URL" }
val query = parseQuery(uri)
val homeserver = query.requiredSingle("hs")
val authorizesApp = uri.authorizesApp(query)
val relay = if (authorizesApp) query.requiredSingle("relay") else ""
val secret = if (authorizesApp) query.requiredSingle("secret") else ""
val capabilities = if (authorizesApp) query.requiredSingle("caps") else ""
val authorizationUrl = if (authorizesApp) {
ringAuthorizationUrl(relay, secret, capabilities)
} else {
null
}

parse(
rawUrl = rawUrl,
clientId = "",
relay = relay,
capabilities = capabilities,
homeserverPublicKey = homeserver,
signupToken = query.optionalSingle("st"),
authorizationUrl = authorizationUrl,
).getOrThrow().also {
require(it.bitkitClaim == null) { "Pubky signup does not support Bitkit companion claims" }
}
}.fold(
onSuccess = { Result.success(it) },
onFailure = { Result.failure(PubkyAuthRequestError.InvalidUrl(it)) },
)

private fun URI.isSignupRequest(): Boolean = when (scheme?.lowercase()) {
"pubkyring" -> host.equals("signup", ignoreCase = true)
"pubkyauth" -> isDirectSignupRequest()
else -> false
}

private fun URI.isDirectSignupRequest(): Boolean =
scheme.equals("pubkyauth", ignoreCase = true) && (host ?: rawAuthority).let {
it.equals("direct_signup", ignoreCase = true) || it.equals("signup", ignoreCase = true)
}

private fun URI.authorizesApp(query: Map<String, List<String>>): Boolean =
scheme.equals("pubkyring", ignoreCase = true) ||
(
scheme.equals("pubkyauth", ignoreCase = true) &&
(host ?: rawAuthority).equals("signup", ignoreCase = true) &&
listOf("relay", "secret", "caps").any(query::containsKey)
)

fun parseBitkitClaim(rawUrl: String, capabilities: String): Result<PubkyAuthClaim?> =
parseBitkitClaimValues(rawUrl).fold(
onSuccess = { claimValues -> validateBitkitClaim(claimValues, capabilities) },
Expand Down Expand Up @@ -152,5 +227,31 @@ data class PubkyAuthRequest(
}

private fun decodeQueryComponent(value: String) = URLDecoder.decode(value, StandardCharsets.UTF_8.name())

private fun ringAuthorizationUrl(relay: String, secret: String, capabilities: String): String =
"pubkyauth:///?relay=${encodeQueryComponent(relay)}" +
"&secret=${encodeQueryComponent(secret)}&caps=${encodeQueryComponent(capabilities)}"

private fun encodeQueryComponent(value: String) =
URLEncoder.encode(value, StandardCharsets.UTF_8.name()).replace("+", "%20")

private fun parseQuery(uri: URI): Map<String, List<String>> = uri.rawQuery.orEmpty()
.split("&")
.filter { it.isNotEmpty() }
.map { it.split("=", limit = 2) }
.groupBy(
keySelector = { decodeQueryComponent(it.first()) },
valueTransform = { decodeQueryComponent(it.getOrElse(1) { "" }) },
)

private fun Map<String, List<String>>.requiredSingle(name: String): String =
optionalSingle(name)?.takeIf { it.isNotBlank() }
?: throw IllegalArgumentException("Missing Pubky signup parameter: $name")

private fun Map<String, List<String>>.optionalSingle(name: String): String? {
val values = this[name].orEmpty()
require(values.size <= 1) { "Duplicate Pubky signup parameter: $name" }
return values.singleOrNull()?.takeIf { it.isNotBlank() }
}
}
}
Loading
Loading