Skip to content

feat: add profile fetching from pubky#824

Open
ben-kaufman wants to merge 17 commits intomasterfrom
feat/pubky-profile
Open

feat: add profile fetching from pubky#824
ben-kaufman wants to merge 17 commits intomasterfrom
feat/pubky-profile

Conversation

@ben-kaufman
Copy link
Contributor

Summary

Integrates Pubky decentralized identity into Bitkit, allowing users to connect their Pubky profile via Pubky Ring authentication. Once connected, the user's profile name and avatar are displayed on the home screen header, and a full profile page shows their bio, links, and a shareable QR code.

What's included

  • Authentication flow via Pubky Ring deep links (pubkyauth://) with relay-based session exchange, session persistence in Keychain, and automatic restoration on app launch
  • Profile page displaying name, bio, links, and a QR code with the profile picture overlaid
  • Home screen integration showing the authenticated user's name and avatar in the header
  • PubkyService — service layer wrapping paykit (profile/session management) and bitkit-core (auth relay, file fetching)
  • PubkyRepo — manages auth state, session lifecycle, and profile data with all Keychain/disk operations offloaded to background threads
  • PubkyImage component for loading pubky:// URIs with two-tier (memory + disk) caching
  • Suggestion card auto-dismiss when user is already authenticated

@claude

This comment has been minimized.

Copy link

@claude claude bot left a comment

Choose a reason for hiding this comment

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

Code review

Found 2 CLAUDE.md compliance issues.

@jvsena42
Copy link
Member

jvsena42 commented Mar 9, 2026

Starting review

@ovitrif ovitrif added this to the 2.2.0 milestone Mar 9, 2026
name = ffiProfile.name,
bio = ffiProfile.bio ?: "",
imageUrl = ffiProfile.image,
links = ffiProfile.links?.map { PubkyProfileLink(label = it.title, url = it.url) } ?: emptyList(),
Copy link
Member

Choose a reason for hiding this comment

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

could use mapNotNull

@ovitrif ovitrif changed the title Feat: add profile fetching from pubky feat: add profile fetching from pubky Mar 10, 2026
Copy link
Member

@jvsena42 jvsena42 left a comment

Choose a reason for hiding this comment

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

Could add some unit tests at Repo level too

}

private val scope = CoroutineScope(bgDispatcher + SupervisorJob())
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
Copy link
Member

Choose a reason for hiding this comment

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

could use DataStore instead


fun copyPublicKey() {
val pk = pubkyRepo.publicKey.value ?: return
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
Copy link
Member

Choose a reason for hiding this comment

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

Use the existing context extension method

This comment was marked as resolved.

Comment on lines +43 to +53
fun PubkyImage(
uri: String,
size: Dp,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val repo = remember {
EntryPointAccessors.fromApplication(context, PubkyImageEntryPoint::class.java).pubkyRepo()
}
var bitmap by remember(uri) { mutableStateOf(repo.cachedImage(uri)) }
var hasFailed by remember(uri) { mutableStateOf(false) }
Copy link
Member

Choose a reason for hiding this comment

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

could replace with a ViewModel injection

Copy link
Member

Choose a reason for hiding this comment

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

could be converted to Webp and imported in multiple densities using https://www.appicon.co/#image-sets

Copy link
Member

Choose a reason for hiding this comment

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

Android studio can convert it

Copy link
Collaborator

@ovitrif ovitrif left a comment

Choose a reason for hiding this comment

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

Reviewed up to and including PubkyImage.kt, will resume later.

Test Recording:
https://github.com/user-attachments/assets/fc3f6997-981e-422f-92e4-60769232bd3d

Manually tested and found 2 critical, pls see video above:

  1. Crash when trying to go to Profile via Drawer.
  2. Dialog should use AppAlertDialog which comes with theming for our design lang.

Plus a couple nits, strongest views on:

  • using viewModel and no business logic in UI layer
  • using DataStore instead of SharedPreferences.

Aside from these small things, it works great 🎉

_authState.update { PubkyAuthState.Authenticated }
Logger.info("Pubky auth completed for $pk", context = TAG)
loadProfile()
}.map {}
Copy link
Collaborator

@ovitrif ovitrif Mar 10, 2026

Choose a reason for hiding this comment

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

nit: could return Result<Boolean> to signal if successful

Edit: fixed to show what I meant using the inline code block.

Comment on lines +186 to +192
runCatching { pubkyService.signOut() }
.onFailure {
Logger.warn("Server sign out failed, forcing local sign out", it, context = TAG)
runCatching { pubkyService.forceSignOut() }
}
runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) }
runCatching { imageCache.clear() }
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: it's better to refactor this to use .recoverCatching{} and .mapCatching {} chaining.

Copy link
Collaborator

@ovitrif ovitrif left a comment

Choose a reason for hiding this comment

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

Completed review ✔️

Lots of small nits, but normally Claude Code or Cursor Agent should be able to just go over all comments and address them if prompted to do so. Might need GitHub MCP or gh (GitHub CLI) installed locally.


fun copyPublicKey() {
val pk = pubkyRepo.publicKey.value ?: return
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager

This comment was marked as resolved.

Comment on lines +433 to +451
<string name="profile__auth_error_title">Authorization Failed</string>
<string name="profile__empty_state">Unable to load your profile.</string>
<string name="profile__intro_description">Set up your portable pubky profile, so your contacts can reach you or pay you anytime, anywhere in the ecosystem.</string>
<string name="profile__intro_title">Portable\n&lt;accent&gt;pubky profile&lt;/accent&gt;</string>
<string name="profile__nav_title">Profile</string>
<string name="profile__public_key">Public Key</string>
<string name="profile__qr_scan_label">Scan to add {name}</string>
<string name="profile__retry_load">Try Again</string>
<string name="profile__ring_auth_description">Please authorize Bitkit with Pubky Ring, your mobile keychain for the next web.</string>
<string name="profile__ring_auth_title">Join the\n&lt;accent&gt;pubky web&lt;/accent&gt;</string>
<string name="profile__ring_authorize">Authorize</string>
<string name="profile__ring_download">Download</string>
<string name="profile__ring_not_installed_description">Pubky Ring is required to authorize your profile. Would you like to download it?</string>
<string name="profile__ring_not_installed_title">Pubky Ring Not Installed</string>
<string name="profile__ring_waiting">Waiting for authorization from Pubky Ring…</string>
<string name="profile__sign_out">Disconnect</string>
<string name="profile__sign_out_description">This will disconnect your Pubky profile from Bitkit. You can reconnect at any time.</string>
<string name="profile__sign_out_title">Disconnect Profile</string>
<string name="profile__your_name">Your Name</string>
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: pls check if some slashtags__ prefixed strings are still used or can be removed.

Comment on lines +14 to +21
override suspend fun readFrom(input: InputStream): PubkyStoreData {
return try {
json.decodeFromString(input.readBytes().decodeToString())
} catch (e: SerializationException) {
Logger.error("Failed to deserialize PubkyStoreData: $e")
defaultValue
}
}
Copy link

Choose a reason for hiding this comment

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

Three issues in this catch block per CLAUDE.md:

  1. Use Result API instead of try-catch: "ALWAYS use the Result API instead of try-catch"
  2. Exception manually appended to message: "NEVER manually append the Throwable's message or any other props to the string passed as the 1st param of Logger.* calls" — pass it as the e argument instead
  3. Missing context = TAG: "ALWAYS pass the TAG as context to Logger calls" — no TAG constant exists in this object

Suggested fix:

Suggested change
override suspend fun readFrom(input: InputStream): PubkyStoreData {
return try {
json.decodeFromString(input.readBytes().decodeToString())
} catch (e: SerializationException) {
Logger.error("Failed to deserialize PubkyStoreData: $e")
defaultValue
}
}
override suspend fun readFrom(input: InputStream): PubkyStoreData {
return runCatching {
json.decodeFromString(input.readBytes().decodeToString())
}.getOrElse {
Logger.error("Failed to deserialize PubkyStoreData", it, context = TAG)
defaultValue
}
}

Also add a companion TAG constant to this object:

private val TAG = PubkyStoreSerializer::class.simpleName

Copy link

@claude claude bot Mar 11, 2026

Choose a reason for hiding this comment

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

Claude finished @ovitrif's task in 1m 0s —— View job


  • Read PubkyStoreSerializer.kt for context
  • Analyze Kotlin semantics for this::class.simpleName in object
  • Post answer

Answer: this::class.simpleName in a Kotlin object

Technically it works, but private const val TAG = "..." is preferable. Here's why:

What works

In a Kotlin object, this refers to the singleton instance and is accessible in property initializers, so this compiles:

object PubkyStoreSerializer : Serializer<PubkyStoreData> {
    private val TAG = this::class.simpleName  // compiles ✓
}

The catch: nullable String?

KClass.simpleName returns String? (nullable) — it's null for anonymous objects and local classes. So TAG would be typed as String?, which means passing it to Logger.error(..., context = TAG) passes a nullable where a String is expected — this would likely cause a compile error or require a !!/?: "" workaround.

Why const val is better here

private const val TAG = "PubkyStoreSerializer"  // String, non-null, compile-time constant ✓
  • Non-nullable — no workaround needed
  • Compile-time constant — inlined at call sites (no runtime overhead)
  • Established pattern — matches every other serializer/class in the codebase

Summary

this::class.simpleName is fine for logging in regular classes (where you can store it as String? and pass ?: javaClass.simpleName), but for object declarations the const val approach is strictly better. The file already has the correct implementation.

pubkyStore.reset()
_publicKey.update { null }
_profile.update { null }
_authState.update { PubkyAuthState.Idle }
Copy link

Choose a reason for hiding this comment

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

Race condition with loadProfile()

signOut() does not acquire loadProfileMutex, creating a race condition with loadProfile():

  1. loadProfile() acquires the mutex and suspends inside pubkyService.getProfile(pk) (network I/O)
  2. signOut() runs concurrently, clears _publicKey, _profile, and sets auth state to Idle
  3. loadProfile() resumes, writes the fetched profile back to _profile (line 180), and calls cacheMetadata() — restoring stale data after sign-out

The result: after sign-out, _authState is Idle but _profile and pubkyStore contain the previous user's data, causing the UI to display a stale profile for an unauthenticated user. On next cold start, the cached name/image will be shown again.

suspend fun signOut(): Result<Unit> = runCatching {
withContext(ioDispatcher) {
runCatching { pubkyService.signOut() }
.recoverCatching {
Logger.warn("Server sign out failed, forcing local sign out", it, context = TAG)
pubkyService.forceSignOut()
}
runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) }
runCatching { imageCache.clear() }
}
pubkyStore.reset()
_publicKey.update { null }
_profile.update { null }
_authState.update { PubkyAuthState.Idle }
}

Fix: either have signOut() acquire loadProfileMutex before clearing state, or re-check _publicKey.value inside loadProfile() after the network call completes (while still holding the mutex) and skip writing if it is null.

@claude

This comment has been minimized.

@ben-kaufman ben-kaufman requested review from jvsena42 and ovitrif March 11, 2026 11:01
Copy link
Collaborator

@ovitrif ovitrif left a comment

Choose a reason for hiding this comment

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

Submitted couple more pending comments and resolved the ones where code changes applied. The goal is to reach a state where agent can be asked to scan through non-resolved review comments and address those in a new pass.

import javax.inject.Singleton

private val Context.pubkyDataStore: DataStore<PubkyStoreData> by dataStore(
fileName = "pubky_profile_cache.json",
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: may use more general name like pubky.json or an extension thereof which doesn't force this to profile only. ultimately these are jsons they can support even lists => name can be more generalistic

Comment on lines +165 to +181
try {
runCatching {
withContext(bgDispatcher) {
val ffiProfile = pubkyService.getProfile(pk)
Logger.debug("Profile loaded — name: ${ffiProfile.name}, image: ${ffiProfile.image}", context = TAG)
PubkyProfile.fromFfi(pk, ffiProfile)
}
}.onSuccess { loadedProfile ->
_profile.update { loadedProfile }
cacheMetadata(loadedProfile)
}.onFailure {
Logger.error("Failed to load profile", it, context = TAG)
}
} finally {
_isLoadingProfile.update { false }
loadProfileMutex.unlock()
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

yes, normally onSuccess and onFailure are meant for side effects, for chaining it's, recommended to use mapCatching

@claude
Copy link

claude bot commented Mar 11, 2026

  • Analyze this::class.simpleName for TAG in different Kotlin contexts
  • Check codebase patterns

Answer: private val TAG = this::class.simpleName — works in some contexts, breaks silently in others

Short answer: it's not safe to standardize on, specifically because of how companion objects work.


Where it works ✅

Context this::class.simpleName returns
Regular class body "MyClassName"
object declaration (e.g. PubkyStoreSerializer) "PubkyStoreSerializer"

Where it silently breaks ❌

companion object — which is the dominant TAG pattern in this codebase:

class MyViewModel : ViewModel() {
    companion object {
        private val TAG = this::class.simpleName  // returns "Companion", not "MyViewModel" ❌
    }
}

Inside a companion object, this refers to the companion object singleton itself, so .simpleName returns "Companion" — your logs would show Companion as the tag for every class.

Nullable return type ⚠️

KClass.simpleName returns String? (nullable). It's null for anonymous classes. You'd need ?: "Fallback" everywhere, or use !! which is banned by CLAUDE.md.

Not a compile-time constant ⚠️

val (not const val) means it's evaluated at runtime and can't be used in annotations.


Codebase verdict

Looking at the codebase, every TAG is a private const val TAG = "ClassName" string literal — in companion objects for ViewModels and as top-level private const val for files/objects. Given that the companion object pattern dominates, this::class.simpleName would produce wrong values for the majority of usages.

If you wanted to avoid the string literal, the only safe cross-context alternative would be:

// In companion object — reference the outer class explicitly:
companion object {
    private val TAG = MyViewModel::class.simpleName ?: "MyViewModel"
}

But this still has the nullable issue and isn't a const. The const val string literal is the cleanest option here and matches all existing patterns in the codebase.

View job run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants