Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
5983a73
feat: add profile fetching from pubky
ben-kaufman Mar 5, 2026
b5f211c
Merge branch 'master' into feat/pubky-profile
ben-kaufman Mar 5, 2026
8d5bac7
fix build
ben-kaufman Mar 5, 2026
1bdd233
Merge branch 'feat/pubky-profile' of https://github.com/synonymdev/bi…
ben-kaufman Mar 5, 2026
1484f26
fix paykit version
ben-kaufman Mar 5, 2026
06a618d
fixes
ben-kaufman Mar 6, 2026
7ff4b1e
fixes
ben-kaufman Mar 6, 2026
13bd697
fixes
ben-kaufman Mar 6, 2026
359eddc
fixes
ben-kaufman Mar 6, 2026
ec754c1
detekt fix
ben-kaufman Mar 6, 2026
9ede70f
fixes
ben-kaufman Mar 6, 2026
ea2a78e
fix comment
ben-kaufman Mar 10, 2026
fcb20c9
Merge branch 'master' into feat/pubky-profile
ovitrif Mar 10, 2026
5100c97
fix feedback comments
ben-kaufman Mar 11, 2026
1985f9e
Merge branch 'master' into feat/pubky-profile
ben-kaufman Mar 11, 2026
8656cb7
claude fixes
ben-kaufman Mar 11, 2026
9b3a0e8
feat: migrate pubky profile fetching from paykit to bitkitcore
ben-kaufman Mar 11, 2026
97b8e70
feat: add Pubky contacts screen with intro flow and contact detail view
ben-kaufman Mar 11, 2026
4be2306
fixes
ben-kaufman Mar 11, 2026
6634eca
Merge branch 'master' into feat/pubky-profile
ben-kaufman Mar 12, 2026
c3b548c
Merge branch 'master' into feat/pubky-profile
ovitrif Mar 12, 2026
e0527c7
fixes
ben-kaufman Mar 15, 2026
63cfa6e
Merge remote-tracking branch 'origin/master' into feat/pubky-profile
ben-kaufman Mar 16, 2026
78d16f7
fixes
ben-kaufman Mar 16, 2026
1f0bba0
fixes
ben-kaufman Mar 16, 2026
7c4fdad
fix detekt
ben-kaufman Mar 16, 2026
e52430d
feat: add coil with pubky image fetcher
ovitrif Mar 13, 2026
b0383b4
refactor: migrate pubky images to coil
ovitrif Mar 13, 2026
ae06f53
feat: add crossfade and spring pop to pubky images
ovitrif Mar 13, 2026
d16bbe1
refactor: remove modifiers trailing comma
ovitrif Mar 13, 2026
6ae1a82
refactor: use AsyncImage vs. SubcomposeAsyncImage
ovitrif Mar 16, 2026
94b2b5a
fix: address PR review remarks
ovitrif Mar 16, 2026
9a07040
refactor: extract ActionButton and LinkRow into shared components, fi…
ben-kaufman Mar 17, 2026
a3ac562
Merge branch 'master' into feat/pubky-profile
ben-kaufman Mar 17, 2026
64e8017
Merge branch 'feat/pubky-profile' into feat/pubky-async-image
ben-kaufman Mar 17, 2026
0ce5b99
Merge pull request #846 from synonymdev/feat/pubky-async-image
ben-kaufman Mar 17, 2026
c1c9215
merge: resolve conflicts with master
ben-kaufman Apr 3, 2026
ad1e8f4
feat: add pubky profile and contacts
ben-kaufman Apr 5, 2026
ca7fa32
fix: harden pubky contact flows
ben-kaufman Apr 6, 2026
f3481b5
Merge branch 'master' into feat/pubky-profile
ovitrif Apr 6, 2026
752d21a
fix: require auth for pubky approval
ben-kaufman Apr 7, 2026
bc3f89f
Merge branch 'master' into feat/pubky-profile
ovitrif Apr 7, 2026
f161dd2
Merge branch 'master' into feat/pubky-profile
ovitrif Apr 8, 2026
efe1e5c
fix: align pubky ring flow
ben-kaufman Apr 9, 2026
b9fd56f
Merge branch 'master' into feat/pubky-profile
ben-kaufman Apr 9, 2026
03f3656
Merge remote-tracking branch 'origin/feat/pubky-profile' into feat/pu…
ben-kaufman Apr 9, 2026
12b0516
Merge remote-tracking branch 'origin/feat/pubky-profile' into feat/pu…
ben-kaufman Apr 9, 2026
f43c091
fix: split pubky homegate env
ben-kaufman Apr 9, 2026
b24c552
fix: address claude review notes
ben-kaufman Apr 9, 2026
529296a
fix: prefer bitkit contact profiles
ben-kaufman Apr 9, 2026
2a1be0b
chore: update changelog entry w. PR ID
ovitrif Apr 9, 2026
4e83dd9
fix: polish pubky profile flow
ben-kaufman Apr 9, 2026
e61d696
Merge remote-tracking branch 'origin/feat/pubky-profile' into feat/pu…
ben-kaufman Apr 9, 2026
cb036a7
fix: rename bio to notes
ben-kaufman Apr 9, 2026
60b7ff4
fix: polish pubky edit fields
ben-kaufman Apr 9, 2026
a9489ca
fix: address pr review and claude.md compliance
ben-kaufman Apr 10, 2026
2051774
fix: suppress large class detekt warning
ben-kaufman Apr 10, 2026
1e38929
fix: delete contacts on profile deletion
ben-kaufman Apr 10, 2026
0b23ded
fix: validate pubky before add
ben-kaufman Apr 10, 2026
e3de573
test: e2e ids for profile and contacts
piotr-iohk Apr 20, 2026
3babc9d
test: test ids adjustments
piotr-iohk Apr 20, 2026
348af4a
Merge branch 'master' into feat/pubky-profile
ben-kaufman Apr 21, 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
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ dependencies {
implementation(libs.bouncycastle.provider.jdk)
implementation(libs.ldk.node.android) { exclude(group = "net.java.dev.jna", module = "jna") }
implementation(libs.bitkit.core)
implementation(libs.paykit)
implementation(libs.vss.client)
// Firebase
implementation(platform(libs.firebase.bom))
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
<intent>
<action android:name="android.settings.APPLICATION_DETAILS_SETTINGS" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="pubkyauth" />
</intent>
</queries>

<uses-feature
Expand Down
52 changes: 52 additions & 0 deletions app/src/main/java/to/bitkit/data/PubkyImageCache.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package to.bitkit.data

import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import java.security.MessageDigest
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class PubkyImageCache @Inject constructor(
@ApplicationContext context: Context,
) {
private val memoryCache = ConcurrentHashMap<String, Bitmap>()
private val diskDir: File = File(context.cacheDir, "pubky-images").also { it.mkdirs() }

fun memoryImage(uri: String): Bitmap? = memoryCache[uri]

fun image(uri: String): Bitmap? {
memoryCache[uri]?.let { return it }

val file = diskPath(uri)
if (file.exists()) {
val bitmap = BitmapFactory.decodeFile(file.absolutePath) ?: return null
memoryCache[uri] = bitmap
return bitmap
}
return null
}

fun store(bitmap: Bitmap, data: ByteArray, uri: String) {
Comment thread
ovitrif marked this conversation as resolved.
Outdated
memoryCache[uri] = bitmap
runCatching { diskPath(uri).writeBytes(data) }
}

fun clear() {
memoryCache.clear()
runCatching {
diskDir.deleteRecursively()
diskDir.mkdirs()
}
}

private fun diskPath(uri: String): File {
val digest = MessageDigest.getInstance("SHA-256")
val hash = digest.digest(uri.toByteArray()).joinToString("") { "%02x".format(it) }
Comment thread
ovitrif marked this conversation as resolved.
Outdated
return File(diskDir, hash)
}
}
1 change: 1 addition & 0 deletions app/src/main/java/to/bitkit/data/keychain/Keychain.kt
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ class Keychain @Inject constructor(
BIP39_PASSPHRASE,
PIN,
PIN_ATTEMPTS_REMAINING,
PAYKIT_SESSION,
}
}

Expand Down
34 changes: 34 additions & 0 deletions app/src/main/java/to/bitkit/models/PubkyProfile.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package to.bitkit.models

import com.synonym.paykit.FfiProfile

data class PubkyProfileLink(val label: String, val url: String)

data class PubkyProfile(
val publicKey: String,
val name: String,
val bio: String,
val imageUrl: String?,
val links: List<PubkyProfileLink>,
val status: String?,
) {
companion object {
fun fromFfi(publicKey: String, ffiProfile: FfiProfile): PubkyProfile {
return PubkyProfile(
publicKey = publicKey,
name = ffiProfile.name,
bio = ffiProfile.bio ?: "",
imageUrl = ffiProfile.image,
links = ffiProfile.links?.map { PubkyProfileLink(label = it.title, url = it.url) } ?: emptyList(),
Comment thread
ben-kaufman marked this conversation as resolved.
Outdated
status = ffiProfile.status,
)
}
}

val truncatedPublicKey: String
get() = if (publicKey.length > 10) {
"${publicKey.take(4)}...${publicKey.takeLast(4)}"
Comment thread
ovitrif marked this conversation as resolved.
Outdated
} else {
publicKey
}
}
4 changes: 2 additions & 2 deletions app/src/main/java/to/bitkit/models/Suggestion.kt
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ enum class Suggestion(
PROFILE(
title = R.string.cards__slashtagsProfile__title,
description = R.string.cards__slashtagsProfile__description,
color = Colors.Brand24,
icon = R.drawable.crown
color = Colors.PubkyGreen24,
icon = R.drawable.crown,
),
SHOP(
title = R.string.cards__shop__title,
Expand Down
221 changes: 221 additions & 0 deletions app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
package to.bitkit.repositories

import android.content.Context
import android.content.SharedPreferences
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.withContext
import to.bitkit.data.PubkyImageCache
import to.bitkit.data.keychain.Keychain
import to.bitkit.di.BgDispatcher
import to.bitkit.models.PubkyProfile
import to.bitkit.services.PubkyService
import to.bitkit.utils.Logger
import javax.inject.Inject
import javax.inject.Singleton

enum class PubkyAuthState { Idle, Authenticating, Authenticated }

@Singleton
class PubkyRepo @Inject constructor(
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
@ApplicationContext private val context: Context,
@BgDispatcher private val bgDispatcher: CoroutineDispatcher,
Comment thread
ovitrif marked this conversation as resolved.
Outdated
private val pubkyService: PubkyService,
private val keychain: Keychain,
private val imageCache: PubkyImageCache,
) {
companion object {
private const val TAG = "PubkyRepo"
private const val PREFS_NAME = "pubky_profile_cache"
private const val KEY_CACHED_NAME = "cached_name"
private const val KEY_CACHED_IMAGE_URI = "cached_image_uri"
}

private val scope = CoroutineScope(bgDispatcher + SupervisorJob())
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
Comment thread
ben-kaufman marked this conversation as resolved.
Outdated
Comment thread
ovitrif marked this conversation as resolved.
Outdated
private val loadProfileMutex = Mutex()
Comment thread
jvsena42 marked this conversation as resolved.

private val _authState = MutableStateFlow(PubkyAuthState.Idle)
val authState: StateFlow<PubkyAuthState> = _authState.asStateFlow()

private val _profile = MutableStateFlow<PubkyProfile?>(null)
val profile: StateFlow<PubkyProfile?> = _profile.asStateFlow()

private val _publicKey = MutableStateFlow<String?>(null)
val publicKey: StateFlow<String?> = _publicKey.asStateFlow()

private val _isLoadingProfile = MutableStateFlow(false)
val isLoadingProfile: StateFlow<Boolean> = _isLoadingProfile.asStateFlow()

private val _isInitialized = MutableStateFlow(false)
val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow()

private val _cachedName = MutableStateFlow(prefs.getString(KEY_CACHED_NAME, null))
val cachedName: StateFlow<String?> = _cachedName.asStateFlow()

private val _cachedImageUri = MutableStateFlow(prefs.getString(KEY_CACHED_IMAGE_URI, null))
val cachedImageUri: StateFlow<String?> = _cachedImageUri.asStateFlow()

val isAuthenticated: StateFlow<Boolean> = _authState.map { it == PubkyAuthState.Authenticated }
.stateIn(scope, SharingStarted.Eagerly, false)

val displayName: StateFlow<String?> = _profile.map { it?.name }
.stateIn(scope, SharingStarted.Eagerly, prefs.getString(KEY_CACHED_NAME, null))

val displayImageUri: StateFlow<String?> = _profile.map { it?.imageUrl }
.stateIn(scope, SharingStarted.Eagerly, prefs.getString(KEY_CACHED_IMAGE_URI, null))

private sealed interface InitResult {
data object NoSession : InitResult
data class Restored(val publicKey: String) : InitResult
data object RestorationFailed : InitResult
}

suspend fun initialize() {
val result = runCatching {
withContext(bgDispatcher) {
pubkyService.initialize()

val savedSecret = runCatching {
keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)
}.getOrNull()

if (savedSecret.isNullOrEmpty()) {
return@withContext InitResult.NoSession
}

runCatching {
val pk = pubkyService.importSession(savedSecret)
InitResult.Restored(pk)
}.getOrElse {
Logger.warn("Failed to restore paykit session", it, context = TAG)
InitResult.RestorationFailed
}
}
}.onFailure {
Logger.error("Failed to initialize paykit", it, context = TAG)
}.getOrNull() ?: return

_isInitialized.update { true }

when (result) {
is InitResult.NoSession -> Logger.debug("No saved paykit session found", context = TAG)
is InitResult.Restored -> {
_publicKey.update { result.publicKey }
_authState.update { PubkyAuthState.Authenticated }
Logger.info("Paykit session restored for ${result.publicKey}", context = TAG)
Comment thread
ovitrif marked this conversation as resolved.
Outdated
loadProfile()
}
is InitResult.RestorationFailed -> {
runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) }
}
}
}

suspend fun startAuthentication(): Result<String> {
_authState.update { PubkyAuthState.Authenticating }
return runCatching {
withContext(bgDispatcher) { pubkyService.startAuth() }
}.onFailure {
_authState.update { PubkyAuthState.Idle }
}
}

suspend fun completeAuthentication(): Result<Unit> {
return runCatching {
withContext(bgDispatcher) {
val sessionSecret = pubkyService.completeAuth()
val pk = pubkyService.importSession(sessionSecret)

runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) }
keychain.saveString(Keychain.Key.PAYKIT_SESSION.name, sessionSecret)
Comment thread
ben-kaufman marked this conversation as resolved.

pk
}
}.onFailure {
_authState.update { PubkyAuthState.Idle }
}.onSuccess { pk ->
_publicKey.update { pk }
_authState.update { PubkyAuthState.Authenticated }
Logger.info("Pubky auth completed for $pk", context = TAG)
loadProfile()
}.map {}
Copy link
Copy Markdown
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.

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.

The caller only uses .onSuccess/.onFailure — it never reads the Boolean value. Result is sufficient.

}

suspend fun cancelAuthentication() {
runCatching {
withContext(bgDispatcher) { pubkyService.cancelAuth() }
}.onFailure { Logger.warn("Cancel auth failed", it, context = TAG) }
_authState.update { PubkyAuthState.Idle }
}

fun cancelAuthenticationSync() {
scope.launch { cancelAuthentication() }
}

suspend fun loadProfile() {
val pk = _publicKey.value ?: return
if (!loadProfileMutex.tryLock()) return

_isLoadingProfile.update { true }
Comment thread
ben-kaufman marked this conversation as resolved.
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()
}
Comment thread
ben-kaufman marked this conversation as resolved.
}

suspend fun signOut(): Result<Unit> = runCatching {
withContext(bgDispatcher) {
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) }
imageCache.clear()
}
clearCachedMetadata()
_publicKey.update { null }
_profile.update { null }
_authState.update { PubkyAuthState.Idle }
Comment thread
ben-kaufman marked this conversation as resolved.
Outdated
}

private fun cacheMetadata(profile: PubkyProfile) {
_cachedName.update { profile.name }
_cachedImageUri.update { profile.imageUrl }
prefs.edit()
.putString(KEY_CACHED_NAME, profile.name)
.putString(KEY_CACHED_IMAGE_URI, profile.imageUrl)
.apply()
}

private fun clearCachedMetadata() {
_cachedName.update { null }
_cachedImageUri.update { null }
prefs.edit().clear().apply()
}
}
51 changes: 51 additions & 0 deletions app/src/main/java/to/bitkit/services/PubkyService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package to.bitkit.services

import com.synonym.bitkitcore.cancelPubkyAuth
import com.synonym.bitkitcore.completePubkyAuth
import com.synonym.bitkitcore.fetchPubkyFile
import com.synonym.bitkitcore.startPubkyAuth
import com.synonym.paykit.FfiProfile
import com.synonym.paykit.paykitForceSignOut
import com.synonym.paykit.paykitGetProfile
import com.synonym.paykit.paykitImportSession
import com.synonym.paykit.paykitInitialize
import com.synonym.paykit.paykitSignOut
import to.bitkit.async.ServiceQueue
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class PubkyService @Inject constructor() {

companion object {
const val REQUIRED_CAPABILITIES =
"/pub/paykit.app/v0/:rw,/pub/pubky.app/profile.json:rw,/pub/pubky.app/follows/:rw"
}

suspend fun initialize() =
ServiceQueue.CORE.background { paykitInitialize() }
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

a bit 'dumb' but why not 🤷🏻 .
I mean if some code calls one of the methods in here before calling initialize I suspect there won't be a descriptive error message to help with debugging.
Basically the reason we did that isSetup in VssBackupClient ;)
although, this is not sdk code so it might've been not needed to implement those guards all along.

This comment was marked as resolved.


suspend fun importSession(secret: String): String =
ServiceQueue.CORE.background { paykitImportSession(secret) }

suspend fun startAuth(): String =
ServiceQueue.CORE.background { startPubkyAuth(REQUIRED_CAPABILITIES) }

suspend fun completeAuth(): String =
ServiceQueue.CORE.background { completePubkyAuth() }

suspend fun cancelAuth() =
ServiceQueue.CORE.background { cancelPubkyAuth() }

suspend fun fetchFile(uri: String): ByteArray =
ServiceQueue.CORE.background { fetchPubkyFile(uri) }

suspend fun getProfile(publicKey: String): FfiProfile =
ServiceQueue.CORE.background { paykitGetProfile(publicKey) }

suspend fun signOut() =
ServiceQueue.CORE.background { paykitSignOut() }

suspend fun forceSignOut() =
ServiceQueue.CORE.background { paykitForceSignOut() }
}
Loading
Loading