Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 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
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Show loading state on Spending tab when node is not running #875

### Added
- Pubky profile onboarding with contact sync, import, and editing #824
- Lightning Connections empty state with onboarding screen #857
- Unified PIN management screen (enable/disable/change in one place) #857
- Support entry in drawer menu #857
Expand Down
6 changes: 6 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ val bcp47Locales = listOf(
"en", "ar", "es-419", "ca", "cs", "de", "el", "es", "es-ES", "fr", "it", "nl", "pl", "pt", "pt-BR", "ru"
)
val e2eBackendEnv = System.getenv("E2E_BACKEND") ?: "local"
val e2eHomegateUrlEnv = System.getenv("E2E_HOMEGATE_URL") ?: "http://127.0.0.1:6288"

android {
namespace = "to.bitkit"
Expand All @@ -63,6 +64,7 @@ android {
}
buildConfigField("boolean", "E2E", System.getenv("E2E")?.toBoolean()?.toString() ?: "false")
buildConfigField("String", "E2E_BACKEND", "\"$e2eBackendEnv\"")
buildConfigField("String", "E2E_HOMEGATE_URL", "\"$e2eHomegateUrlEnv\"")
buildConfigField("boolean", "GEO", System.getenv("GEO")?.toBoolean()?.toString() ?: "true")
buildConfigField("String", "LOCALES", "\"${bcp47Locales.joinToString(",")}\"")
}
Expand Down Expand Up @@ -237,6 +239,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 Expand Up @@ -267,6 +270,9 @@ dependencies {
implementation(libs.charts)
implementation(libs.haze)
implementation(libs.haze.materials)
// Image Loading
implementation(platform(libs.coil.bom))
implementation(libs.coil.compose)
// Compose Navigation
implementation(libs.navigation.compose)
androidTestImplementation(libs.navigation.testing)
Expand Down
6 changes: 6 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@
xmlns:tools="http://schemas.android.com/tools">

<queries>
<package android:name="to.pubky.ring" />
<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 Expand Up @@ -100,6 +105,7 @@
<data android:scheme="lnurlw" />
<data android:scheme="lnurlc" />
<data android:scheme="lnurlp" />
<data android:scheme="pubkyauth" />
</intent-filter>

<!-- NFC -->
Expand Down
6 changes: 6 additions & 0 deletions app/src/main/java/to/bitkit/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import android.app.Application.ActivityLifecycleCallbacks
import android.os.Bundle
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import coil3.ImageLoader
import coil3.SingletonImageLoader
import dagger.hilt.android.HiltAndroidApp
import to.bitkit.env.Env
import javax.inject.Inject
Expand All @@ -16,13 +18,17 @@ internal open class App : Application(), Configuration.Provider {
@Inject
lateinit var workerFactory: HiltWorkerFactory

@Inject
lateinit var imageLoader: ImageLoader

override val workManagerConfiguration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()

override fun onCreate() {
super.onCreate()
SingletonImageLoader.setSafe { imageLoader }
currentActivity = CurrentActivity().also { registerActivityLifecycleCallbacks(it) }
Env.initAppStoragePath(filesDir.absolutePath)
}
Expand Down
50 changes: 50 additions & 0 deletions app/src/main/java/to/bitkit/data/PubkyImageFetcher.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package to.bitkit.data

import coil3.ImageLoader
import coil3.Uri
import coil3.decode.DataSource
import coil3.decode.ImageSource
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import okio.Buffer
import org.json.JSONObject
import to.bitkit.services.PubkyService
import to.bitkit.utils.Logger

private const val TAG = "PubkyImageFetcher"
private const val PUBKY_SCHEME = "pubky://"

class PubkyImageFetcher(
private val uri: String,
private val options: Options,
private val pubkyService: PubkyService,
) : Fetcher {

override suspend fun fetch(): FetchResult {
val data = pubkyService.fetchFile(uri)
val blobData = resolveImageData(data)
val source = ImageSource(Buffer().apply { write(blobData) }, options.fileSystem)
return SourceFetchResult(source, null, dataSource = DataSource.NETWORK)
}

private suspend fun resolveImageData(data: ByteArray): ByteArray = runCatching {
val json = JSONObject(String(data))
val src = json.optString("src", "")
if (src.isNotEmpty() && src.startsWith(PUBKY_SCHEME)) {
Logger.debug("Found file descriptor, fetching blob from '$src'", context = TAG)
pubkyService.fetchFile(src)
} else {
data
}
}.getOrDefault(data)

class Factory(private val pubkyService: PubkyService) : Fetcher.Factory<Uri> {
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? {
val uri = data.toString()
if (!uri.startsWith(PUBKY_SCHEME)) return null
return PubkyImageFetcher(uri, options, pubkyService)
}
}
}
39 changes: 39 additions & 0 deletions app/src/main/java/to/bitkit/data/PubkyStore.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package to.bitkit.data

import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.dataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.serialization.Serializable
import to.bitkit.data.serializers.PubkyStoreSerializer
import javax.inject.Inject
import javax.inject.Singleton

private val Context.pubkyDataStore: DataStore<PubkyStoreData> by dataStore(
fileName = "pubky.json",
serializer = PubkyStoreSerializer,
)

@Singleton
class PubkyStore @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val store = context.pubkyDataStore

val data: Flow<PubkyStoreData> = store.data

suspend fun update(transform: (PubkyStoreData) -> PubkyStoreData) {
store.updateData(transform)
}

suspend fun reset() {
store.updateData { PubkyStoreData() }
}
}

@Serializable
data class PubkyStoreData(
val cachedName: String? = null,
val cachedImageUri: String? = null,
)
1 change: 1 addition & 0 deletions app/src/main/java/to/bitkit/data/SettingsStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ data class SettingsData(
val hasSeenSavingsIntro: Boolean = false,
val hasSeenShopIntro: Boolean = false,
val hasSeenProfileIntro: Boolean = false,
val hasSeenContactsIntro: Boolean = false,
val quickPayIntroSeen: Boolean = false,
val bgPaymentsIntroSeen: Boolean = false,
val isQuickPayEnabled: Boolean = false,
Expand Down
2 changes: 2 additions & 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,8 @@ class Keychain @Inject constructor(
BIP39_PASSPHRASE,
PIN,
PIN_ATTEMPTS_REMAINING,
PAYKIT_SESSION,
PUBKY_SECRET_KEY,
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package to.bitkit.data.serializers

import androidx.datastore.core.Serializer
import to.bitkit.data.PubkyStoreData
import to.bitkit.di.json
import to.bitkit.utils.Logger
import java.io.InputStream
import java.io.OutputStream

object PubkyStoreSerializer : Serializer<PubkyStoreData> {
private const val TAG = "PubkyStoreSerializer"

override val defaultValue: PubkyStoreData = PubkyStoreData()

override suspend fun readFrom(input: InputStream): PubkyStoreData {
return runCatching {
json.decodeFromString<PubkyStoreData>(input.readBytes().decodeToString())
}.getOrElse {
Logger.error("Failed to deserialize PubkyStoreData", it, context = TAG)
defaultValue
}
}

override suspend fun writeTo(t: PubkyStoreData, output: OutputStream) {
output.write(json.encodeToString(t).encodeToByteArray())
}
}
41 changes: 41 additions & 0 deletions app/src/main/java/to/bitkit/di/ImageModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package to.bitkit.di

import android.content.Context
import coil3.ImageLoader
import coil3.disk.DiskCache
import coil3.disk.directory
import coil3.memory.MemoryCache
import coil3.request.crossfade
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import to.bitkit.data.PubkyImageFetcher
import to.bitkit.services.PubkyService
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object ImageModule {

@Provides
@Singleton
fun provideImageLoader(
@ApplicationContext context: Context,
pubkyService: PubkyService,
): ImageLoader = ImageLoader.Builder(context)
.crossfade(true)
.components { add(PubkyImageFetcher.Factory(pubkyService)) }
.memoryCache {
MemoryCache.Builder()
.maxSizePercent(context, percent = 0.15)
.build()
}
.diskCache {
DiskCache.Builder()
.directory(context.cacheDir.resolve("pubky-images"))
.build()
}
.build()
}
43 changes: 41 additions & 2 deletions app/src/main/java/to/bitkit/env/Env.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ internal object Env {
const val isE2eTest = BuildConfig.E2E
const val isGeoblockingEnabled = BuildConfig.GEO
val e2eBackend = BuildConfig.E2E_BACKEND.lowercase()
val isLocalE2eBackend = isE2eTest && e2eBackend == "local"
const val e2eHomegateUrl = BuildConfig.E2E_HOMEGATE_URL
val network = Network.valueOf(BuildConfig.NETWORK)
val locales = BuildConfig.LOCALES.split(",")
const val walletSyncIntervalSecs = 10_uL
Expand Down Expand Up @@ -53,10 +55,11 @@ internal object Env {

val electrumServerUrl: String
get() {
val isE2eLocal = isE2eTest && e2eBackend == "local"
return when (network) {
Network.BITCOIN -> ElectrumServers.MAINNET.ESPLORA
Network.REGTEST -> if (isE2eLocal) ElectrumServers.REGTEST.LOCAL else ElectrumServers.REGTEST.STAG
Network.REGTEST -> {
if (isLocalE2eBackend) ElectrumServers.REGTEST.LOCAL else ElectrumServers.REGTEST.STAG
}
Network.TESTNET -> ElectrumServers.TESTNET
else -> TODO("${network.name} network not implemented")
}
Expand Down Expand Up @@ -154,6 +157,42 @@ internal object Env {
const val BITREFILL_APP = "Bitkit"
const val BITREFILL_REF = "AL6dyZYt"

private val pubkyDomain: String
get() = when (network) {
Network.BITCOIN -> "bitkit.to"
else -> "staging.bitkit.to"
}

val pubkyCapabilities: String
get() {
val prefix = when (network) {
Network.BITCOIN -> ""
else -> "staging."
}
return "/pub/$pubkyDomain/:rw,/pub/${prefix}pubky.app/:r,/pub/${prefix}paykit/v0/:rw"
}

val homegateUrl: String
get() {
if (isLocalE2eBackend) {
return e2eHomegateUrl
}

return when (network) {
Network.BITCOIN -> "https://homegate.pubky.app"
else -> "https://homegate.staging.pubky.app"
}
}

val profilePath: String
get() = "/pub/$pubkyDomain/profile.json"

val contactsBasePath: String
get() = "/pub/$pubkyDomain/contacts/"

val blobsBasePath: String
get() = "/pub/$pubkyDomain/blobs/"

val rnBackupServerHost: String
get() = when (network) {
Network.BITCOIN -> "https://blocktank.synonym.to/backups-ldk"
Expand Down
44 changes: 44 additions & 0 deletions app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package to.bitkit.models

import androidx.compose.runtime.Immutable

@Immutable
data class PubkyAuthPermission(
val path: String,
val accessLevel: String,
) {
val displayAccess: String
get() = accessLevel.map { char ->
when (char) {
'r' -> "READ"
'w' -> "WRITE"
else -> ""
}
}.filter { it.isNotEmpty() }.joinToString(", ")
}

data class PubkyAuthRequest(
val rawUrl: String,
val relay: String,
val permissions: List<PubkyAuthPermission>,
val serviceNames: List<String>,
) {
companion object {
fun parseCapabilities(caps: String): List<PubkyAuthPermission> =
caps.split(",")
.filter { it.isNotBlank() }
.mapNotNull { segment ->
val lastColon = segment.lastIndexOf(':')
if (lastColon <= 0) return@mapNotNull null
val path = segment.substring(0, lastColon)
val access = segment.substring(lastColon + 1)
PubkyAuthPermission(path = path, accessLevel = access)
}

fun extractServiceName(path: String): String? {
val parts = path.trimStart('/').split("/")
val pubIndex = parts.indexOf("pub")
return if (pubIndex >= 0 && pubIndex + 1 < parts.size) parts[pubIndex + 1] else null
}
}
}
Loading
Loading