diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 77cd5453c..438fcfb33 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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))
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ebcf34f04..cbf32fed5 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -6,6 +6,10 @@
+
+
+
+
()
+ 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) {
+ 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) }
+ return File(diskDir, hash)
+ }
+}
diff --git a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt
index 9b777174a..b6c35d935 100644
--- a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt
+++ b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt
@@ -127,6 +127,7 @@ class Keychain @Inject constructor(
BIP39_PASSPHRASE,
PIN,
PIN_ATTEMPTS_REMAINING,
+ PAYKIT_SESSION,
}
}
diff --git a/app/src/main/java/to/bitkit/models/PubkyProfile.kt b/app/src/main/java/to/bitkit/models/PubkyProfile.kt
new file mode 100644
index 000000000..abf87124c
--- /dev/null
+++ b/app/src/main/java/to/bitkit/models/PubkyProfile.kt
@@ -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,
+ 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(),
+ status = ffiProfile.status,
+ )
+ }
+ }
+
+ val truncatedPublicKey: String
+ get() = if (publicKey.length > 10) {
+ "${publicKey.take(4)}...${publicKey.takeLast(4)}"
+ } else {
+ publicKey
+ }
+}
diff --git a/app/src/main/java/to/bitkit/models/Suggestion.kt b/app/src/main/java/to/bitkit/models/Suggestion.kt
index 6c70d0b72..8ae3c576d 100644
--- a/app/src/main/java/to/bitkit/models/Suggestion.kt
+++ b/app/src/main/java/to/bitkit/models/Suggestion.kt
@@ -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,
diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
new file mode 100644
index 000000000..6b65c68f0
--- /dev/null
+++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
@@ -0,0 +1,238 @@
+package to.bitkit.repositories
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+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 org.json.JSONObject
+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(
+ @ApplicationContext private val context: Context,
+ @BgDispatcher private val bgDispatcher: CoroutineDispatcher,
+ 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 const val PUBKY_SCHEME = "pubky://"
+ }
+
+ private val scope = CoroutineScope(bgDispatcher + SupervisorJob())
+ private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ private val loadProfileMutex = Mutex()
+
+ private val _authState = MutableStateFlow(PubkyAuthState.Idle)
+
+ private val _profile = MutableStateFlow(null)
+ val profile: StateFlow = _profile.asStateFlow()
+
+ private val _publicKey = MutableStateFlow(null)
+ val publicKey: StateFlow = _publicKey.asStateFlow()
+
+ private val _isLoadingProfile = MutableStateFlow(false)
+ val isLoadingProfile: StateFlow = _isLoadingProfile.asStateFlow()
+
+ val isAuthenticated: StateFlow = _authState.map { it == PubkyAuthState.Authenticated }
+ .stateIn(scope, SharingStarted.Eagerly, false)
+
+ val displayName: StateFlow = _profile.map { it?.name ?: prefs.getString(KEY_CACHED_NAME, null) }
+ .stateIn(scope, SharingStarted.Eagerly, prefs.getString(KEY_CACHED_NAME, null))
+
+ val displayImageUri: StateFlow = _profile
+ .map { it?.imageUrl ?: prefs.getString(KEY_CACHED_IMAGE_URI, null) }
+ .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
+
+ 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)
+ loadProfile()
+ }
+ is InitResult.RestorationFailed -> {
+ runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) }
+ }
+ }
+ }
+
+ suspend fun startAuthentication(): Result {
+ _authState.update { PubkyAuthState.Authenticating }
+ return runCatching {
+ withContext(bgDispatcher) { pubkyService.startAuth() }
+ }.onFailure {
+ _authState.update { PubkyAuthState.Idle }
+ }
+ }
+
+ suspend fun completeAuthentication(): Result {
+ 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)
+
+ 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 {}
+ }
+
+ 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 }
+ 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()
+ }
+ }
+
+ suspend fun signOut(): Result = 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) }
+ runCatching { imageCache.clear() }
+ }
+ clearCachedMetadata()
+ _publicKey.update { null }
+ _profile.update { null }
+ _authState.update { PubkyAuthState.Idle }
+ }
+
+ fun cachedImage(uri: String): Bitmap? = imageCache.memoryImage(uri)
+
+ suspend fun fetchImage(uri: String): Result = runCatching {
+ withContext(bgDispatcher) {
+ imageCache.image(uri)?.let { return@withContext it }
+
+ val data = pubkyService.fetchFile(uri)
+ val blobData = resolveImageData(data)
+ val image = BitmapFactory.decodeByteArray(blobData, 0, blobData.size)
+ ?: error("Could not decode image blob (${blobData.size} bytes)")
+ imageCache.store(image, blobData, uri)
+ image
+ }
+ }
+
+ private suspend fun resolveImageData(data: ByteArray): ByteArray {
+ return runCatching {
+ val json = JSONObject(String(data))
+ val src = json.optString("src", "")
+ if (src.isNotEmpty() && src.startsWith(PUBKY_SCHEME)) {
+ Logger.debug("File descriptor found, fetching blob from: $src", context = TAG)
+ pubkyService.fetchFile(src)
+ } else {
+ data
+ }
+ }.getOrDefault(data)
+ }
+
+ private fun cacheMetadata(profile: PubkyProfile) {
+ prefs.edit()
+ .putString(KEY_CACHED_NAME, profile.name)
+ .putString(KEY_CACHED_IMAGE_URI, profile.imageUrl)
+ .apply()
+ }
+
+ private fun clearCachedMetadata() {
+ prefs.edit().clear().apply()
+ }
+}
diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt
new file mode 100644
index 000000000..d696fc0aa
--- /dev/null
+++ b/app/src/main/java/to/bitkit/services/PubkyService.kt
@@ -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() }
+
+ 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() }
+}
diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt
index 4c5ca717b..95135171b 100644
--- a/app/src/main/java/to/bitkit/ui/ContentView.kt
+++ b/app/src/main/java/to/bitkit/ui/ContentView.kt
@@ -60,8 +60,11 @@ import to.bitkit.ui.onboarding.WalletRestoreErrorView
import to.bitkit.ui.onboarding.WalletRestoreSuccessView
import to.bitkit.ui.screens.CriticalUpdateScreen
import to.bitkit.ui.screens.common.ComingSoonScreen
-import to.bitkit.ui.screens.profile.CreateProfileScreen
import to.bitkit.ui.screens.profile.ProfileIntroScreen
+import to.bitkit.ui.screens.profile.ProfileScreen
+import to.bitkit.ui.screens.profile.ProfileViewModel
+import to.bitkit.ui.screens.profile.PubkyRingAuthScreen
+import to.bitkit.ui.screens.profile.PubkyRingAuthViewModel
import to.bitkit.ui.screens.recovery.RecoveryMnemonicScreen
import to.bitkit.ui.screens.recovery.RecoveryModeScreen
import to.bitkit.ui.screens.scanner.QrScanningScreen
@@ -361,6 +364,8 @@ fun ContentView(
val hasSeenWidgetsIntro by settingsViewModel.hasSeenWidgetsIntro.collectAsStateWithLifecycle()
val hasSeenShopIntro by settingsViewModel.hasSeenShopIntro.collectAsStateWithLifecycle()
+ val hasSeenProfileIntro by settingsViewModel.hasSeenProfileIntro.collectAsStateWithLifecycle()
+ val isProfileAuthenticated by settingsViewModel.isPubkyAuthenticated.collectAsStateWithLifecycle()
val currentSheet by appViewModel.currentSheet.collectAsStateWithLifecycle()
Box(
@@ -487,6 +492,8 @@ fun ContentView(
rootNavController = navController,
hasSeenWidgetsIntro = hasSeenWidgetsIntro,
hasSeenShopIntro = hasSeenShopIntro,
+ hasSeenProfileIntro = hasSeenProfileIntro,
+ isProfileAuthenticated = isProfileAuthenticated,
modifier = Modifier.align(Alignment.TopEnd),
)
}
@@ -910,30 +917,38 @@ private fun NavGraphBuilder.comingSoon(
onBackClick = { navController.popBackStack() }
)
}
- composableWithDefaultTransitions {
- ComingSoonScreen(
- onWalletOverviewClick = { navController.navigateToHome() },
- onBackClick = { navController.popBackStack() }
- )
- }
}
private fun NavGraphBuilder.profile(
navController: NavHostController,
settingsViewModel: SettingsViewModel,
) {
+ composableWithDefaultTransitions {
+ val viewModel: ProfileViewModel = hiltViewModel()
+ ProfileScreen(
+ viewModel = viewModel,
+ onBackClick = { navController.popBackStack() },
+ )
+ }
composableWithDefaultTransitions {
ProfileIntroScreen(
onContinue = {
settingsViewModel.setHasSeenProfileIntro(true)
- navController.navigate(Routes.CreateProfile)
+ navController.navigate(Routes.PubkyRingAuth)
},
- onBackClick = { navController.popBackStack() }
+ onBackClick = { navController.popBackStack() },
)
}
- composableWithDefaultTransitions {
- CreateProfileScreen(
- onBack = { navController.popBackStack() },
+ composableWithDefaultTransitions {
+ val viewModel: PubkyRingAuthViewModel = hiltViewModel()
+ PubkyRingAuthScreen(
+ viewModel = viewModel,
+ onBackClick = { navController.popBackStack() },
+ onAuthenticated = {
+ navController.navigate(Routes.Profile) {
+ popUpTo(Routes.Home)
+ }
+ },
)
}
}
@@ -1674,6 +1689,14 @@ fun NavController.navigateToAboutSettings() = navigate(
@Stable
sealed interface Routes {
+ companion object {
+ fun profileRoute(isAuthenticated: Boolean, hasSeenIntro: Boolean): Routes = when {
+ isAuthenticated -> Profile
+ hasSeenIntro -> PubkyRingAuth
+ else -> ProfileIntro
+ }
+ }
+
@Serializable
data object Home : Routes
@@ -1914,7 +1937,7 @@ sealed interface Routes {
data object ProfileIntro : Routes
@Serializable
- data object CreateProfile : Routes
+ data object PubkyRingAuth : Routes
@Serializable
data object ShopIntro : Routes
diff --git a/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt b/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt
index 79b694d41..1a31be345 100644
--- a/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt
+++ b/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt
@@ -66,6 +66,8 @@ fun DrawerMenu(
hasSeenWidgetsIntro: Boolean,
hasSeenShopIntro: Boolean,
modifier: Modifier = Modifier,
+ hasSeenProfileIntro: Boolean = false,
+ isProfileAuthenticated: Boolean = false,
) {
val scope = rememberCoroutineScope()
@@ -113,6 +115,9 @@ fun DrawerMenu(
rootNavController.navigateIfNotCurrent(Routes.ShopDiscover)
}
},
+ onClickProfile = {
+ rootNavController.navigateIfNotCurrent(Routes.profileRoute(isProfileAuthenticated, hasSeenProfileIntro))
+ },
)
}
}
@@ -123,6 +128,7 @@ private fun Menu(
drawerState: DrawerState,
onClickAddWidget: () -> Unit,
onClickShop: () -> Unit,
+ onClickProfile: () -> Unit,
) {
val scope = rememberCoroutineScope()
@@ -170,7 +176,7 @@ private fun Menu(
label = stringResource(R.string.wallet__drawer__profile),
iconRes = R.drawable.ic_user_square,
onClick = {
- rootNavController.navigateIfNotCurrent(Routes.Profile)
+ onClickProfile()
scope.launch { drawerState.close() }
},
modifier = Modifier.testTag("DrawerProfile")
diff --git a/app/src/main/java/to/bitkit/ui/components/PubkyImage.kt b/app/src/main/java/to/bitkit/ui/components/PubkyImage.kt
new file mode 100644
index 000000000..ee6deb125
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/components/PubkyImage.kt
@@ -0,0 +1,108 @@
+package to.bitkit.ui.components
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.android.EntryPointAccessors
+import dagger.hilt.components.SingletonComponent
+import to.bitkit.R
+import to.bitkit.repositories.PubkyRepo
+import to.bitkit.ui.theme.Colors
+import to.bitkit.utils.Logger
+
+private const val TAG = "PubkyImage"
+
+@EntryPoint
+@InstallIn(SingletonComponent::class)
+interface PubkyImageEntryPoint {
+ fun pubkyRepo(): PubkyRepo
+}
+
+@Composable
+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) }
+
+ LaunchedEffect(uri) {
+ hasFailed = false
+
+ if (bitmap != null) return@LaunchedEffect
+
+ repo.fetchImage(uri)
+ .onSuccess { bitmap = it }
+ .onFailure {
+ Logger.error("Failed to load pubky image", it, context = TAG)
+ hasFailed = true
+ }
+ }
+
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = modifier
+ .size(size)
+ .clip(CircleShape)
+ ) {
+ val currentBitmap = bitmap
+ when {
+ currentBitmap != null -> {
+ Image(
+ bitmap = currentBitmap.asImageBitmap(),
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.matchParentSize()
+ )
+ }
+ hasFailed -> {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .matchParentSize()
+ .background(Colors.Gray5, CircleShape)
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_user_square),
+ contentDescription = null,
+ tint = Colors.White32,
+ modifier = Modifier.size(size / 2)
+ )
+ }
+ }
+ else -> {
+ CircularProgressIndicator(
+ strokeWidth = 2.dp,
+ color = Colors.White32,
+ modifier = Modifier.size(size / 3)
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt
deleted file mode 100644
index 8d8e11c83..000000000
--- a/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt
+++ /dev/null
@@ -1,52 +0,0 @@
-package to.bitkit.ui.screens.profile
-
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.padding
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import to.bitkit.R
-import to.bitkit.ui.components.Display
-import to.bitkit.ui.scaffold.AppTopBar
-import to.bitkit.ui.scaffold.DrawerNavIcon
-import to.bitkit.ui.scaffold.ScreenColumn
-import to.bitkit.ui.theme.AppThemeSurface
-import to.bitkit.ui.theme.Colors
-
-@Composable
-fun CreateProfileScreen(
- onBack: () -> Unit,
-) { // TODO IMPLEMENT
- ScreenColumn {
- AppTopBar(
- titleText = stringResource(R.string.slashtags__profile_create),
- onBackClick = onBack,
- actions = { DrawerNavIcon() },
- )
-
- Column(
- modifier = Modifier.padding(horizontal = 32.dp)
- ) {
- Spacer(Modifier.weight(1f))
-
- Display(
- text = stringResource(R.string.other__coming_soon),
- color = Colors.White
- )
- Spacer(Modifier.weight(1f))
- }
- }
-}
-
-@Preview(showBackground = true)
-@Composable
-private fun Preview() {
- AppThemeSurface {
- CreateProfileScreen(
- onBack = {},
- )
- }
-}
diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileIntroScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileIntroScreen.kt
index 27fe39a66..ade8291be 100644
--- a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileIntroScreen.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileIntroScreen.kt
@@ -2,9 +2,7 @@ package to.bitkit.ui.screens.profile
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@@ -16,6 +14,7 @@ import to.bitkit.R
import to.bitkit.ui.components.BodyM
import to.bitkit.ui.components.Display
import to.bitkit.ui.components.PrimaryButton
+import to.bitkit.ui.components.VerticalSpacer
import to.bitkit.ui.scaffold.AppTopBar
import to.bitkit.ui.scaffold.DrawerNavIcon
import to.bitkit.ui.scaffold.ScreenColumn
@@ -30,7 +29,7 @@ fun ProfileIntroScreen(
) {
ScreenColumn {
AppTopBar(
- titleText = stringResource(R.string.slashtags__profile),
+ titleText = stringResource(R.string.profile__nav_title),
onBackClick = onBackClick,
actions = { DrawerNavIcon() },
)
@@ -47,19 +46,17 @@ fun ProfileIntroScreen(
)
Display(
- text = stringResource(
- R.string.slashtags__onboarding_profile1_header
- ).withAccent(accentColor = Colors.Brand),
- color = Colors.White
+ text = stringResource(R.string.profile__intro_title).withAccent(accentColor = Colors.PubkyGreen),
+ color = Colors.White,
)
- Spacer(Modifier.height(8.dp))
- BodyM(text = stringResource(R.string.slashtags__onboarding_profile1_text), color = Colors.White64)
- Spacer(Modifier.height(32.dp))
+ VerticalSpacer(8.dp)
+ BodyM(text = stringResource(R.string.profile__intro_description), color = Colors.White64)
+ VerticalSpacer(32.dp)
PrimaryButton(
text = stringResource(R.string.common__continue),
- onClick = onContinue
+ onClick = onContinue,
)
- Spacer(Modifier.height(16.dp))
+ VerticalSpacer(16.dp)
}
}
}
diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt
new file mode 100644
index 000000000..536d69ab4
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt
@@ -0,0 +1,362 @@
+package to.bitkit.ui.screens.profile
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Logout
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import to.bitkit.R
+import to.bitkit.models.PubkyProfile
+import to.bitkit.models.PubkyProfileLink
+import to.bitkit.ui.components.BodyM
+import to.bitkit.ui.components.Headline
+import to.bitkit.ui.components.PubkyImage
+import to.bitkit.ui.components.QrCodeImage
+import to.bitkit.ui.components.SecondaryButton
+import to.bitkit.ui.components.VerticalSpacer
+import to.bitkit.ui.scaffold.AppTopBar
+import to.bitkit.ui.scaffold.ScreenColumn
+import to.bitkit.ui.theme.AppThemeSurface
+import to.bitkit.ui.theme.Colors
+
+@Composable
+fun ProfileScreen(
+ viewModel: ProfileViewModel,
+ onBackClick: () -> Unit,
+) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+
+ LaunchedEffect(Unit) {
+ viewModel.effects.collect {
+ when (it) {
+ ProfileEffect.SignedOut -> onBackClick()
+ }
+ }
+ }
+
+ Content(
+ uiState = uiState,
+ onBackClick = onBackClick,
+ onCopy = { viewModel.copyPublicKey() },
+ onShare = { viewModel.sharePublicKey() },
+ onSignOut = { viewModel.showSignOutConfirmation() },
+ onDismissSignOutDialog = { viewModel.dismissSignOutDialog() },
+ onConfirmSignOut = { viewModel.signOut() },
+ onRetry = { viewModel.loadProfile() },
+ )
+}
+
+@Composable
+private fun Content(
+ uiState: ProfileUiState,
+ onBackClick: () -> Unit,
+ onCopy: () -> Unit,
+ onShare: () -> Unit,
+ onSignOut: () -> Unit,
+ onDismissSignOutDialog: () -> Unit,
+ onConfirmSignOut: () -> Unit,
+ onRetry: () -> Unit,
+) {
+ val currentProfile = uiState.profile
+
+ ScreenColumn {
+ AppTopBar(
+ titleText = stringResource(R.string.profile__nav_title),
+ onBackClick = onBackClick,
+ )
+
+ when {
+ uiState.isLoading && currentProfile == null -> LoadingState()
+ currentProfile != null -> ProfileBody(
+ profile = currentProfile,
+ isSigningOut = uiState.isSigningOut,
+ onCopy = onCopy,
+ onShare = onShare,
+ onSignOut = onSignOut,
+ )
+ else -> EmptyState(onRetry = onRetry)
+ }
+ }
+
+ if (uiState.showSignOutDialog) {
+ AlertDialog(
+ onDismissRequest = onDismissSignOutDialog,
+ title = { Text(stringResource(R.string.profile__sign_out_title)) },
+ text = { Text(stringResource(R.string.profile__sign_out_description)) },
+ confirmButton = {
+ TextButton(onClick = onConfirmSignOut) {
+ Text(stringResource(R.string.profile__sign_out))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismissSignOutDialog) {
+ Text(stringResource(R.string.common__dialog_cancel))
+ }
+ },
+ )
+ }
+}
+
+@Composable
+private fun ProfileBody(
+ profile: PubkyProfile,
+ isSigningOut: Boolean,
+ onCopy: () -> Unit,
+ onShare: () -> Unit,
+ onSignOut: () -> Unit,
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 32.dp)
+ ) {
+ VerticalSpacer(24.dp)
+
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Headline(text = AnnotatedString(profile.name))
+ VerticalSpacer(8.dp)
+ Text(
+ text = profile.truncatedPublicKey,
+ color = Colors.White,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 15.sp,
+ )
+ }
+
+ if (profile.imageUrl != null) {
+ PubkyImage(uri = profile.imageUrl, size = 64.dp)
+ } else {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .size(64.dp)
+ .clip(CircleShape)
+ .background(Colors.PubkyGreen)
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_user_square),
+ contentDescription = null,
+ tint = Colors.White32,
+ modifier = Modifier.size(32.dp)
+ )
+ }
+ }
+ }
+
+ VerticalSpacer(16.dp)
+
+ if (profile.bio.isNotEmpty()) {
+ Text(
+ text = profile.bio,
+ color = Colors.White64,
+ fontSize = 18.sp,
+ lineHeight = 26.sp,
+ )
+ VerticalSpacer(8.dp)
+ }
+ HorizontalDivider()
+
+ VerticalSpacer(24.dp)
+
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ ActionButton(iconRes = R.drawable.ic_copy, onClick = onCopy)
+ ActionButton(iconRes = R.drawable.ic_share, onClick = onShare)
+ ActionButton(
+ imageVector = Icons.AutoMirrored.Filled.Logout,
+ onClick = onSignOut,
+ enabled = !isSigningOut,
+ )
+ }
+
+ VerticalSpacer(24.dp)
+
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ QrCodeImage(
+ content = profile.publicKey,
+ modifier = Modifier.fillMaxWidth()
+ )
+ if (profile.imageUrl != null) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .size(68.dp)
+ .background(Color.White, CircleShape)
+ ) {
+ PubkyImage(
+ uri = profile.imageUrl,
+ size = 50.dp,
+ )
+ }
+ }
+ }
+ VerticalSpacer(12.dp)
+ Text(
+ text = stringResource(R.string.profile__qr_scan_label).replace("{name}", profile.name),
+ color = Colors.White,
+ fontSize = 15.sp,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ VerticalSpacer(32.dp)
+
+ profile.links.forEach { ProfileLinkRow(label = it.label, value = it.url) }
+ }
+}
+
+@Composable
+private fun ActionButton(
+ iconRes: Int? = null,
+ imageVector: ImageVector? = null,
+ onClick: () -> Unit,
+ enabled: Boolean = true,
+) {
+ IconButton(
+ onClick = onClick,
+ enabled = enabled,
+ modifier = Modifier
+ .size(48.dp)
+ .clip(CircleShape)
+ .background(
+ Brush.verticalGradient(listOf(Colors.Gray5, Colors.Gray6)),
+ CircleShape,
+ )
+ .border(1.dp, Colors.White10, CircleShape)
+ ) {
+ val tint = if (enabled) Colors.White else Colors.White32
+ when {
+ iconRes != null -> Icon(
+ painter = painterResource(iconRes),
+ contentDescription = null,
+ tint = tint,
+ modifier = Modifier.size(24.dp)
+ )
+ imageVector != null -> Icon(
+ imageVector = imageVector,
+ contentDescription = null,
+ tint = tint,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+ }
+}
+
+@Composable
+private fun ProfileLinkRow(label: String, value: String) {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ VerticalSpacer(16.dp)
+ Text(
+ text = label.uppercase(),
+ color = Colors.White64,
+ fontWeight = FontWeight.Medium,
+ fontSize = 13.sp,
+ letterSpacing = 1.sp,
+ )
+ VerticalSpacer(8.dp)
+ Text(
+ text = value,
+ color = Colors.White,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 15.sp,
+ )
+ VerticalSpacer(16.dp)
+ HorizontalDivider()
+ }
+}
+
+@Composable
+private fun LoadingState() {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.fillMaxSize()
+ ) {
+ CircularProgressIndicator(color = Colors.White32)
+ }
+}
+
+@Composable
+private fun EmptyState(onRetry: () -> Unit) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 32.dp)
+ ) {
+ BodyM(text = stringResource(R.string.profile__empty_state), color = Colors.White64)
+ VerticalSpacer(16.dp)
+ SecondaryButton(
+ text = stringResource(R.string.profile__retry_load),
+ onClick = onRetry,
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun Preview() {
+ AppThemeSurface {
+ Content(
+ uiState = ProfileUiState(
+ profile = PubkyProfile(
+ publicKey = "pk8e3qm5...gxag",
+ name = "Satoshi",
+ bio = "Building a peer-to-peer electronic cash system.",
+ imageUrl = null,
+ links = listOf(PubkyProfileLink("Website", "https://bitcoin.org")),
+ status = null,
+ ),
+ ),
+ onBackClick = {},
+ onCopy = {},
+ onShare = {},
+ onSignOut = {},
+ onDismissSignOutDialog = {},
+ onConfirmSignOut = {},
+ onRetry = {},
+ )
+ }
+}
diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt
new file mode 100644
index 000000000..3fdd6c35d
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt
@@ -0,0 +1,127 @@
+package to.bitkit.ui.screens.profile
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.content.Intent
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import to.bitkit.R
+import to.bitkit.models.PubkyProfile
+import to.bitkit.models.Toast
+import to.bitkit.repositories.PubkyRepo
+import to.bitkit.ui.shared.toast.ToastEventBus
+import to.bitkit.utils.Logger
+import javax.inject.Inject
+
+@HiltViewModel
+class ProfileViewModel @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val pubkyRepo: PubkyRepo,
+) : ViewModel() {
+ companion object {
+ private const val TAG = "ProfileViewModel"
+ }
+
+ private val _showSignOutDialog = MutableStateFlow(false)
+ private val _isSigningOut = MutableStateFlow(false)
+
+ val uiState: StateFlow = combine(
+ pubkyRepo.profile,
+ pubkyRepo.publicKey,
+ pubkyRepo.isLoadingProfile,
+ _showSignOutDialog,
+ _isSigningOut,
+ ) { profile, publicKey, isLoading, showSignOutDialog, isSigningOut ->
+ ProfileUiState(
+ profile = profile,
+ publicKey = publicKey,
+ isLoading = isLoading,
+ showSignOutDialog = showSignOutDialog,
+ isSigningOut = isSigningOut,
+ )
+ }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ProfileUiState())
+
+ private val _effects = MutableSharedFlow(extraBufferCapacity = 1)
+ val effects = _effects.asSharedFlow()
+
+ init {
+ loadProfile()
+ }
+
+ fun loadProfile() {
+ viewModelScope.launch { pubkyRepo.loadProfile() }
+ }
+
+ fun showSignOutConfirmation() {
+ _showSignOutDialog.update { true }
+ }
+
+ fun dismissSignOutDialog() {
+ _showSignOutDialog.update { false }
+ }
+
+ fun signOut() {
+ viewModelScope.launch {
+ _isSigningOut.update { true }
+ _showSignOutDialog.update { false }
+ pubkyRepo.signOut()
+ .onSuccess {
+ _effects.emit(ProfileEffect.SignedOut)
+ }
+ .onFailure {
+ Logger.error("Sign out failed", it, context = TAG)
+ ToastEventBus.send(
+ type = Toast.ToastType.ERROR,
+ title = context.getString(R.string.profile__sign_out_title),
+ description = it.message,
+ )
+ }
+ _isSigningOut.update { false }
+ }
+ }
+
+ fun copyPublicKey() {
+ val pk = pubkyRepo.publicKey.value ?: return
+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ clipboard.setPrimaryClip(ClipData.newPlainText(context.getString(R.string.profile__public_key), pk))
+ viewModelScope.launch {
+ ToastEventBus.send(
+ type = Toast.ToastType.SUCCESS,
+ title = context.getString(R.string.common__copied),
+ )
+ }
+ }
+
+ fun sharePublicKey() {
+ val pk = pubkyRepo.publicKey.value ?: return
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_TEXT, pk)
+ }
+ context.startActivity(Intent.createChooser(intent, null).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
+ }
+}
+
+data class ProfileUiState(
+ val profile: PubkyProfile? = null,
+ val publicKey: String? = null,
+ val isLoading: Boolean = false,
+ val showSignOutDialog: Boolean = false,
+ val isSigningOut: Boolean = false,
+)
+
+sealed interface ProfileEffect {
+ data object SignedOut : ProfileEffect
+}
diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyRingAuthScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyRingAuthScreen.kt
new file mode 100644
index 000000000..4dd8a6e9b
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyRingAuthScreen.kt
@@ -0,0 +1,214 @@
+package to.bitkit.ui.screens.profile
+
+import android.content.Intent
+import android.net.Uri
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clipToBounds
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import to.bitkit.R
+import to.bitkit.ui.components.BodyM
+import to.bitkit.ui.components.Display
+import to.bitkit.ui.components.FillHeight
+import to.bitkit.ui.components.HorizontalSpacer
+import to.bitkit.ui.components.PrimaryButton
+import to.bitkit.ui.components.SecondaryButton
+import to.bitkit.ui.components.VerticalSpacer
+import to.bitkit.ui.scaffold.AppTopBar
+import to.bitkit.ui.theme.AppThemeSurface
+import to.bitkit.ui.theme.Colors
+import to.bitkit.ui.utils.withAccent
+
+private const val PUBKY_RING_PLAY_STORE_URL = "https://play.google.com/store/apps/details?id=to.pubky.ring"
+private const val BG_IMAGE_WIDTH_FRACTION = 0.83f
+private const val TAG_OFFSET_X = -0.179f
+private const val TAG_OFFSET_Y = -0.124f
+private const val KEYRING_OFFSET_X = 0.341f
+private const val KEYRING_OFFSET_Y = -0.195f
+private const val TAG_ALPHA = 0.6f
+private const val KEYRING_ALPHA = 0.9f
+
+@Composable
+fun PubkyRingAuthScreen(
+ viewModel: PubkyRingAuthViewModel,
+ onBackClick: () -> Unit,
+ onAuthenticated: () -> Unit,
+) {
+ val context = LocalContext.current
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+
+ LaunchedEffect(Unit) {
+ viewModel.effects.collect {
+ when (it) {
+ PubkyRingAuthEffect.Authenticated -> onAuthenticated()
+ }
+ }
+ }
+
+ LaunchedEffect(uiState.isWaitingForRing) {
+ if (uiState.isWaitingForRing) {
+ viewModel.waitForApproval()
+ }
+ }
+
+ Content(
+ uiState = uiState,
+ onBackClick = onBackClick,
+ onDownload = {
+ context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PUBKY_RING_PLAY_STORE_URL)))
+ },
+ onAuthorize = { viewModel.authenticate() },
+ onDismissDialog = { viewModel.dismissRingNotInstalledDialog() },
+ )
+}
+
+@Composable
+private fun Content(
+ uiState: PubkyRingAuthUiState,
+ onBackClick: () -> Unit,
+ onDownload: () -> Unit,
+ onAuthorize: () -> Unit,
+ onDismissDialog: () -> Unit,
+) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background)
+ .systemBarsPadding()
+ .clipToBounds()
+ ) {
+ BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
+ Image(
+ painter = painterResource(R.drawable.tag_pubky),
+ contentDescription = null,
+ contentScale = ContentScale.Fit,
+ modifier = Modifier
+ .fillMaxWidth(BG_IMAGE_WIDTH_FRACTION)
+ .align(Alignment.Center)
+ .offset(x = maxWidth * TAG_OFFSET_X, y = maxHeight * TAG_OFFSET_Y)
+ .alpha(TAG_ALPHA)
+ )
+
+ Image(
+ painter = painterResource(R.drawable.keyring),
+ contentDescription = null,
+ contentScale = ContentScale.Fit,
+ modifier = Modifier
+ .fillMaxWidth(BG_IMAGE_WIDTH_FRACTION)
+ .align(Alignment.Center)
+ .offset(x = maxWidth * KEYRING_OFFSET_X, y = maxHeight * KEYRING_OFFSET_Y)
+ .alpha(KEYRING_ALPHA)
+ )
+ }
+
+ Column(modifier = Modifier.fillMaxSize()) {
+ AppTopBar(
+ titleText = stringResource(R.string.profile__nav_title),
+ onBackClick = onBackClick,
+ )
+
+ FillHeight()
+
+ Column(modifier = Modifier.padding(horizontal = 32.dp)) {
+ Image(
+ painter = painterResource(R.drawable.pubky_ring_logo),
+ contentDescription = null,
+ modifier = Modifier.height(36.dp)
+ )
+ VerticalSpacer(24.dp)
+
+ Display(
+ text = stringResource(R.string.profile__ring_auth_title)
+ .withAccent(accentColor = Colors.PubkyGreen),
+ color = Colors.White,
+ )
+ VerticalSpacer(8.dp)
+
+ BodyM(
+ text = if (uiState.isWaitingForRing) {
+ stringResource(R.string.profile__ring_waiting)
+ } else {
+ stringResource(R.string.profile__ring_auth_description)
+ },
+ color = Colors.White64,
+ )
+ VerticalSpacer(24.dp)
+
+ Row {
+ SecondaryButton(
+ text = stringResource(R.string.profile__ring_download),
+ onClick = onDownload,
+ modifier = Modifier.weight(1f)
+ )
+ HorizontalSpacer(16.dp)
+ PrimaryButton(
+ text = stringResource(R.string.profile__ring_authorize),
+ isLoading = uiState.isAuthenticating,
+ onClick = onAuthorize,
+ modifier = Modifier.weight(1f)
+ )
+ }
+ VerticalSpacer(16.dp)
+ }
+ }
+ }
+
+ if (uiState.showRingNotInstalledDialog) {
+ AlertDialog(
+ onDismissRequest = onDismissDialog,
+ title = { Text(stringResource(R.string.profile__ring_not_installed_title)) },
+ text = { Text(stringResource(R.string.profile__ring_not_installed_description)) },
+ confirmButton = {
+ TextButton(onClick = {
+ onDismissDialog()
+ onDownload()
+ }) { Text(stringResource(R.string.profile__ring_download)) }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismissDialog) {
+ Text(stringResource(R.string.common__dialog_cancel))
+ }
+ },
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun Preview() {
+ AppThemeSurface {
+ Content(
+ uiState = PubkyRingAuthUiState(),
+ onBackClick = {},
+ onDownload = {},
+ onAuthorize = {},
+ onDismissDialog = {},
+ )
+ }
+}
diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyRingAuthViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyRingAuthViewModel.kt
new file mode 100644
index 000000000..6436a61b7
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyRingAuthViewModel.kt
@@ -0,0 +1,124 @@
+package to.bitkit.ui.screens.profile
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import to.bitkit.R
+import to.bitkit.models.Toast
+import to.bitkit.repositories.PubkyRepo
+import to.bitkit.ui.shared.toast.ToastEventBus
+import to.bitkit.utils.Logger
+import javax.inject.Inject
+
+@HiltViewModel
+class PubkyRingAuthViewModel @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val pubkyRepo: PubkyRepo,
+) : ViewModel() {
+ companion object {
+ private const val TAG = "PubkyRingAuthViewModel"
+ }
+
+ private val _uiState = MutableStateFlow(PubkyRingAuthUiState())
+ val uiState = _uiState.asStateFlow()
+
+ private val _effects = MutableSharedFlow(extraBufferCapacity = 1)
+ val effects = _effects.asSharedFlow()
+
+ private var approvalJob: Job? = null
+
+ override fun onCleared() {
+ super.onCleared()
+ if (_uiState.value.isWaitingForRing || _uiState.value.isAuthenticating) {
+ approvalJob?.cancel()
+ pubkyRepo.cancelAuthenticationSync()
+ }
+ }
+
+ fun authenticate() {
+ viewModelScope.launch {
+ if (_uiState.value.isWaitingForRing) {
+ approvalJob?.cancel()
+ approvalJob = null
+ _uiState.update { it.copy(isWaitingForRing = false) }
+ pubkyRepo.cancelAuthentication()
+ }
+
+ _uiState.update { it.copy(isAuthenticating = true) }
+
+ pubkyRepo.startAuthentication()
+ .onSuccess { authUrl ->
+ _uiState.update { it.copy(isAuthenticating = false) }
+
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(authUrl)).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ val canOpen = intent.resolveActivity(context.packageManager) != null
+ if (!canOpen) {
+ approvalJob?.cancel()
+ pubkyRepo.cancelAuthentication()
+ _uiState.update { it.copy(showRingNotInstalledDialog = true) }
+ return@launch
+ }
+
+ _uiState.update { it.copy(isWaitingForRing = true) }
+ context.startActivity(intent)
+ }
+ .onFailure {
+ Logger.error("Authentication failed", it, context = TAG)
+ _uiState.update { it.copy(isAuthenticating = false) }
+ ToastEventBus.send(
+ type = Toast.ToastType.ERROR,
+ title = context.getString(R.string.profile__auth_error_title),
+ description = it.message,
+ )
+ }
+ }
+ }
+
+ fun waitForApproval() {
+ if (approvalJob?.isActive == true) return
+
+ approvalJob = viewModelScope.launch {
+ pubkyRepo.completeAuthentication()
+ .onSuccess {
+ _uiState.update { it.copy(isWaitingForRing = false) }
+ _effects.emit(PubkyRingAuthEffect.Authenticated)
+ }
+ .onFailure {
+ Logger.error("Auth approval failed", it, context = TAG)
+ _uiState.update { it.copy(isWaitingForRing = false) }
+ ToastEventBus.send(
+ type = Toast.ToastType.ERROR,
+ title = context.getString(R.string.profile__auth_error_title),
+ description = it.message,
+ )
+ }
+ }
+ }
+
+ fun dismissRingNotInstalledDialog() {
+ _uiState.update { it.copy(showRingNotInstalledDialog = false) }
+ }
+}
+
+data class PubkyRingAuthUiState(
+ val isAuthenticating: Boolean = false,
+ val isWaitingForRing: Boolean = false,
+ val showRingNotInstalledDialog: Boolean = false,
+)
+
+sealed interface PubkyRingAuthEffect {
+ data object Authenticated : PubkyRingAuthEffect
+}
diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt
index 1543b821d..9618b9315 100644
--- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt
@@ -16,11 +16,13 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.DrawerState
import androidx.compose.material3.DrawerValue
@@ -41,6 +43,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
@@ -77,12 +80,14 @@ import to.bitkit.ui.components.AppStatus
import to.bitkit.ui.components.BalanceHeaderView
import to.bitkit.ui.components.EmptyStateView
import to.bitkit.ui.components.HorizontalSpacer
+import to.bitkit.ui.components.PubkyImage
import to.bitkit.ui.components.Sheet
import to.bitkit.ui.components.StatusBarSpacer
import to.bitkit.ui.components.SuggestionCard
import to.bitkit.ui.components.TabBar
import to.bitkit.ui.components.TertiaryButton
import to.bitkit.ui.components.Text13Up
+import to.bitkit.ui.components.Title
import to.bitkit.ui.components.TopBarSpacer
import to.bitkit.ui.components.VerticalSpacer
import to.bitkit.ui.components.WalletBalanceView
@@ -131,6 +136,9 @@ fun HomeScreen(
val hasSeenTransferIntro by settingsViewModel.hasSeenTransferIntro.collectAsStateWithLifecycle()
val hasSeenShopIntro by settingsViewModel.hasSeenShopIntro.collectAsStateWithLifecycle()
val hasSeenProfileIntro by settingsViewModel.hasSeenProfileIntro.collectAsStateWithLifecycle()
+ val isPubkyAuthenticated by settingsViewModel.isPubkyAuthenticated.collectAsStateWithLifecycle()
+ val profileDisplayName by homeViewModel.profileDisplayName.collectAsStateWithLifecycle()
+ val profileDisplayImageUri by homeViewModel.profileDisplayImageUri.collectAsStateWithLifecycle()
val hasSeenWidgetsIntro: Boolean by settingsViewModel.hasSeenWidgetsIntro.collectAsStateWithLifecycle()
val bgPaymentsIntroSeen: Boolean by settingsViewModel.bgPaymentsIntroSeen.collectAsStateWithLifecycle()
val quickPayIntroSeen by settingsViewModel.quickPayIntroSeen.collectAsStateWithLifecycle()
@@ -152,12 +160,19 @@ fun HomeScreen(
DeleteWidgetAlert(type, homeViewModel)
}
+ val navigateToProfile = {
+ rootNavController.navigate(Routes.profileRoute(isPubkyAuthenticated, hasSeenProfileIntro))
+ }
+
Content(
isRefreshing = isRefreshing,
homeUiState = homeUiState,
rootNavController = rootNavController,
walletNavController = walletNavController,
drawerState = drawerState,
+ profileDisplayName = profileDisplayName,
+ profileDisplayImageUri = profileDisplayImageUri,
+ onClickProfile = navigateToProfile,
latestActivities = latestActivities,
onRefresh = {
activityListViewModel.resync()
@@ -202,9 +217,7 @@ fun HomeScreen(
)
}
- Suggestion.PROFILE -> {
- rootNavController.navigate(Routes.Profile)
- }
+ Suggestion.PROFILE -> navigateToProfile()
Suggestion.SHOP -> {
if (!hasSeenShopIntro) {
@@ -270,6 +283,9 @@ private fun Content(
rootNavController: NavController,
walletNavController: NavController,
drawerState: DrawerState,
+ profileDisplayName: String? = null,
+ profileDisplayImageUri: String? = null,
+ onClickProfile: () -> Unit = {},
hazeState: HazeState = rememberHazeState(),
latestActivities: List?,
onRefresh: () -> Unit = {},
@@ -293,6 +309,9 @@ private fun Content(
rootNavController = rootNavController,
scope = scope,
drawerState = drawerState,
+ profileDisplayName = profileDisplayName,
+ profileDisplayImageUri = profileDisplayImageUri,
+ onClickProfile = onClickProfile,
)
val pullToRefreshState = rememberPullToRefreshState()
PullToRefreshBox(
@@ -611,6 +630,9 @@ private fun TopBar(
rootNavController: NavController,
scope: CoroutineScope,
drawerState: DrawerState,
+ profileDisplayName: String? = null,
+ profileDisplayImageUri: String? = null,
+ onClickProfile: () -> Unit = {},
) {
val topbarGradient = Brush.verticalGradient(
colorStops = arrayOf(
@@ -629,7 +651,13 @@ private fun TopBar(
.zIndex(1f)
) {
TopAppBar(
- title = {},
+ title = {
+ ProfileButton(
+ displayName = profileDisplayName,
+ displayImageUri = profileDisplayImageUri,
+ onClick = onClickProfile,
+ )
+ },
actions = {
AppStatus(onClick = { rootNavController.navigate(Routes.AppStatus) })
HorizontalSpacer(4.dp)
@@ -649,6 +677,48 @@ private fun TopBar(
}
}
+@Composable
+private fun ProfileButton(
+ displayName: String?,
+ displayImageUri: String?,
+ onClick: () -> Unit,
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier
+ .clickableAlpha(onClick = onClick)
+ .testTag("ProfileButton")
+ ) {
+ if (displayImageUri != null) {
+ PubkyImage(
+ uri = displayImageUri,
+ size = 32.dp,
+ )
+ } else {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .size(32.dp)
+ .clip(CircleShape)
+ .background(Colors.Gray4)
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_user_square),
+ contentDescription = null,
+ tint = Colors.White32,
+ modifier = Modifier.size(16.dp)
+ )
+ }
+ }
+
+ Title(
+ text = displayName ?: stringResource(R.string.profile__your_name),
+ maxLines = 1,
+ )
+ }
+}
+
@Composable
private fun DeleteWidgetAlert(
type: WidgetType,
diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt
index a2e6e563d..69f9ffa15 100644
--- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt
@@ -24,6 +24,7 @@ import to.bitkit.models.toSuggestionOrNull
import to.bitkit.models.widget.ArticleModel
import to.bitkit.models.widget.toArticleModel
import to.bitkit.models.widget.toBlockModel
+import to.bitkit.repositories.PubkyRepo
import to.bitkit.repositories.TransferRepo
import to.bitkit.repositories.WalletRepo
import to.bitkit.repositories.WidgetsRepo
@@ -38,11 +39,15 @@ class HomeViewModel @Inject constructor(
private val widgetsRepo: WidgetsRepo,
private val settingsStore: SettingsStore,
private val transferRepo: TransferRepo,
+ private val pubkyRepo: PubkyRepo,
) : ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow = _uiState.asStateFlow()
+ val profileDisplayName = pubkyRepo.displayName
+ val profileDisplayImageUri = pubkyRepo.displayImageUri
+
private val _currentArticle = MutableStateFlow(null)
private val _currentFact = MutableStateFlow(null)
@@ -248,7 +253,8 @@ class HomeViewModel @Inject constructor(
walletRepo.balanceState,
settingsStore.data,
transferRepo.activeTransfers,
- ) { balanceState, settings, transfers ->
+ pubkyRepo.isAuthenticated,
+ ) { balanceState, settings, transfers, profileAuthenticated ->
val baseSuggestions = when {
balanceState.totalLightningSats > 0uL -> { // With Lightning
listOfNotNull(
@@ -260,7 +266,7 @@ class HomeViewModel @Inject constructor(
Suggestion.QUICK_PAY,
Suggestion.NOTIFICATIONS.takeIf { !settings.notificationsGranted },
Suggestion.SHOP,
- Suggestion.PROFILE,
+ Suggestion.PROFILE.takeIf { !profileAuthenticated },
)
}
@@ -275,7 +281,7 @@ class HomeViewModel @Inject constructor(
Suggestion.SUPPORT,
Suggestion.INVITE,
Suggestion.SHOP,
- Suggestion.PROFILE,
+ Suggestion.PROFILE.takeIf { !profileAuthenticated },
)
}
@@ -289,7 +295,7 @@ class HomeViewModel @Inject constructor(
Suggestion.SECURE.takeIf { !settings.isPinEnabled },
Suggestion.SUPPORT,
Suggestion.INVITE,
- Suggestion.PROFILE,
+ Suggestion.PROFILE.takeIf { !profileAuthenticated },
)
}
}
diff --git a/app/src/main/java/to/bitkit/ui/theme/Colors.kt b/app/src/main/java/to/bitkit/ui/theme/Colors.kt
index cd5c38b47..c4dd6f7b7 100644
--- a/app/src/main/java/to/bitkit/ui/theme/Colors.kt
+++ b/app/src/main/java/to/bitkit/ui/theme/Colors.kt
@@ -10,6 +10,7 @@ object Colors {
val Purple = Color(0xFFB95CE8)
val Red = Color(0xFFE95164)
val Yellow = Color(0xFFFFD200)
+ val PubkyGreen = Color(0xFFBEFF00)
// Base
val Black = Color(0xFF000000)
@@ -55,4 +56,5 @@ object Colors {
val Red24 = Red.copy(alpha = 0.24f)
val Yellow16 = Yellow.copy(alpha = 0.16f)
val Yellow24 = Yellow.copy(alpha = 0.24f)
+ val PubkyGreen24 = PubkyGreen.copy(alpha = 0.24f)
}
diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
index 0da507768..c47386c23 100644
--- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
+++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
@@ -104,6 +104,7 @@ import to.bitkit.repositories.CurrencyRepo
import to.bitkit.repositories.HealthRepo
import to.bitkit.repositories.LightningRepo
import to.bitkit.repositories.PreActivityMetadataRepo
+import to.bitkit.repositories.PubkyRepo
import to.bitkit.repositories.TransferRepo
import to.bitkit.repositories.WalletRepo
import to.bitkit.services.AppUpdaterService
@@ -156,6 +157,7 @@ class AppViewModel @Inject constructor(
private val transferRepo: TransferRepo,
private val migrationService: MigrationService,
private val coreService: CoreService,
+ private val pubkyRepo: PubkyRepo,
private val appUpdateSheet: AppUpdateTimedSheet,
private val backupSheet: BackupTimedSheet,
private val notificationsSheet: NotificationsTimedSheet,
@@ -246,6 +248,7 @@ class AppViewModel @Inject constructor(
}
init {
+ viewModelScope.launch { pubkyRepo.initialize() }
viewModelScope.launch {
ToastEventBus.events.collect {
toast(it)
diff --git a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt
index ba3e42286..e9e52dc1d 100644
--- a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt
+++ b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt
@@ -11,12 +11,14 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import to.bitkit.data.SettingsStore
import to.bitkit.models.TransactionSpeed
+import to.bitkit.repositories.PubkyRepo
import javax.inject.Inject
@Suppress("TooManyFunctions")
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val settingsStore: SettingsStore,
+ private val pubkyRepo: PubkyRepo,
) : ViewModel() {
fun reset() = viewModelScope.launch { settingsStore.reset() }
@@ -91,6 +93,8 @@ class SettingsViewModel @Inject constructor(
}
}
+ val isPubkyAuthenticated = pubkyRepo.isAuthenticated
+
val quickPayIntroSeen = settingsStore.data.map { it.quickPayIntroSeen }
.asStateFlow(initialValue = false)
diff --git a/app/src/main/res/drawable-nodpi/pubky_ring_logo.png b/app/src/main/res/drawable-nodpi/pubky_ring_logo.png
new file mode 100644
index 000000000..86109b892
Binary files /dev/null and b/app/src/main/res/drawable-nodpi/pubky_ring_logo.png differ
diff --git a/app/src/main/res/drawable-nodpi/tag_pubky.png b/app/src/main/res/drawable-nodpi/tag_pubky.png
new file mode 100644
index 000000000..4003777bf
Binary files /dev/null and b/app/src/main/res/drawable-nodpi/tag_pubky.png differ
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 7115a659a..2daa51b5f 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -430,6 +430,25 @@
Update Available
Please update Bitkit to the latest version for new features and bug fixes!
Update\n<accent>Bitkit</accent>
+ Authorization Failed
+ Unable to load your profile.
+ Set up your portable pubky profile, so your contacts can reach you or pay you anytime, anywhere in the ecosystem.
+ Portable\n<accent>pubky profile</accent>
+ Profile
+ Public Key
+ Scan to add {name}
+ Try Again
+ Please authorize Bitkit with Pubky Ring, your mobile keychain for the next web.
+ Join the\n<accent>pubky web</accent>
+ Authorize
+ Download
+ Pubky Ring is required to authorize your profile. Would you like to download it?
+ Pubky Ring Not Installed
+ Waiting for authorization from Pubky Ring…
+ Disconnect
+ This will disconnect your Pubky profile from Bitkit. You can reconnect at any time.
+ Disconnect Profile
+ Your Name
Back Up
Now that you have some funds in your wallet, it is time to back up your money!
There are no funds in your wallet yet, but you can create a backup if you wish.
diff --git a/docs/pubky.md b/docs/pubky.md
new file mode 100644
index 000000000..8f72fc5a5
--- /dev/null
+++ b/docs/pubky.md
@@ -0,0 +1,125 @@
+# Pubky Profile Integration
+
+## Overview
+
+Bitkit integrates [Pubky](https://pubky.org) decentralized identity, allowing users to connect their Pubky profile via [Pubky Ring](https://play.google.com/store/apps/details?id=to.pubky.ring) authentication. Once connected, the user's profile name and avatar appear on the home screen header, and a full profile page shows their bio, links, and a shareable QR code.
+
+## Auth Flow
+
+```
+ProfileIntroScreen → PubkyRingAuthScreen → ProfileScreen
+```
+
+1. **ProfileIntroScreen** — presents the Pubky feature and a "Continue" button
+2. **PubkyRingAuthScreen** — initiates authentication via Pubky Ring deep link (`pubkyauth://`), waits for approval via relay, then completes session import
+3. **ProfileScreen** — displays the authenticated user's profile (name, bio, links, QR code)
+
+### Deep Link Flow
+
+The auth handshake uses a relay-based protocol:
+
+1. `PubkyService.startAuth()` generates a `pubkyauth://` URL with required capabilities
+2. The URL is opened via `ACTION_VIEW` intent, launching Pubky Ring
+3. Pubky Ring prompts the user to approve the requested capabilities
+4. `PubkyService.completeAuth()` blocks on the relay until Ring sends approval, returning a session secret
+5. `PubkyService.importSession()` activates the session, returning the user's public key
+6. The session secret is persisted in Keychain for restoration on next launch
+
+### Auth State Machine (`PubkyAuthState`)
+
+- **Idle** — no authentication in progress
+- **Authenticating** — `startAuth()` has been called, waiting for relay setup
+- **Authenticated** — session active, profile available
+
+## Service Layer (`PubkyService`)
+
+Wraps two FFI libraries:
+
+- **paykit-ffi** (`com.synonym:paykit-android`) — session management and profile fetching
+ - `paykitInitialize()`, `paykitImportSession()`, `paykitGetProfile()`, `paykitSignOut()`, `paykitForceSignOut()`
+- **bitkit-core** (`com.synonym:bitkit-core-android`) — auth relay and file fetching
+ - `startPubkyAuth()`, `completePubkyAuth()`, `cancelPubkyAuth()`, `fetchPubkyFile()`
+
+All calls are dispatched on `ServiceQueue.CORE` (single-thread executor) to ensure serial access to the underlying Rust state.
+
+## Repository Layer (`PubkyRepo`)
+
+Manages auth state, session lifecycle, and profile data. Singleton scoped.
+
+### Session Persistence
+
+- Session secret is stored in `Keychain` under `PAYKIT_SESSION` key
+- On app launch, `initialize()` attempts to restore the session via `importSession()`
+- If restoration fails, the stale keychain entry is deleted to allow a clean retry
+- Session secret is only persisted **after** `importSession()` succeeds to avoid stale entries on failure
+
+### Profile Loading
+
+- `loadProfile()` fetches the profile for the authenticated public key
+- Uses a `Mutex` with `tryLock()` to prevent concurrent loads
+- The mutex is released in a `finally` block to handle coroutine cancellation
+- Profile name and image URI are cached in `SharedPreferences` for instant display on launch before the full profile loads
+
+### Exposed State
+
+| StateFlow | Description |
+|---|---|
+| `profile` | Full `PubkyProfile` or null |
+| `publicKey` | Authenticated user's public key |
+| `isAuthenticated` | Derived from internal auth state |
+| `displayName` | Profile name with cached fallback |
+| `displayImageUri` | Profile image URI with cached fallback |
+| `isLoadingProfile` | Loading indicator |
+
+## PubkyImage Component
+
+Composable for loading and displaying images from `pubky://` URIs.
+
+### Caching Strategy (`PubkyImageCache`)
+
+Two-tier cache:
+
+1. **Memory** — `ConcurrentHashMap` for instant access
+2. **Disk** — files in `cacheDir/pubky-images/`, keyed by SHA-256 hash of the URI
+
+### Loading Flow
+
+1. Check memory cache → return if hit
+2. Check disk cache → decode, populate memory, return if hit
+3. Fetch via `PubkyService.fetchFile(uri)`
+4. If response is a JSON file descriptor with a `src` field, follow the indirection and fetch the blob
+5. Decode the blob into a `Bitmap`, store in both caches
+
+### Display States
+
+- **Loading** — `CircularProgressIndicator`
+- **Loaded** — circular-clipped `Image`
+- **Failed** — fallback user icon on gray background
+
+## Domain Model (`PubkyProfile`)
+
+- `publicKey`, `name`, `bio`, `imageUrl`, `links`, `status`
+- `truncatedPublicKey` — computed property showing first/last 4 chars
+- `PubkyProfileLink` — `label` + `url` pair
+- `fromFfi()` — maps from paykit's `FfiProfile` FFI type
+
+## Home Screen Integration
+
+- `HomeViewModel` observes `PubkyRepo.displayName` and `PubkyRepo.displayImageUri`
+- The home screen header shows the profile name and avatar when authenticated
+- The `PROFILE` suggestion card is auto-dismissed when the user is authenticated
+
+## Key Files
+
+| File | Purpose |
+|---|---|
+| `services/PubkyService.kt` | FFI wrapper |
+| `repositories/PubkyRepo.kt` | Auth state and session management |
+| `data/PubkyImageCache.kt` | Two-tier image cache |
+| `models/PubkyProfile.kt` | Domain model |
+| `ui/components/PubkyImage.kt` | Image composable |
+| `ui/screens/profile/ProfileIntroScreen.kt` | Intro screen |
+| `ui/screens/profile/PubkyRingAuthScreen.kt` | Auth screen |
+| `ui/screens/profile/PubkyRingAuthViewModel.kt` | Auth ViewModel |
+| `ui/screens/profile/ProfileScreen.kt` | Profile display |
+| `ui/screens/profile/ProfileViewModel.kt` | Profile ViewModel |
diff --git a/docs/screens-map.md b/docs/screens-map.md
index 727bd129b..9ba0fda3d 100644
--- a/docs/screens-map.md
+++ b/docs/screens-map.md
@@ -145,10 +145,10 @@ Legend: RN = React Native screen, Android = Compose screen
| - | - |
| Contacts.tsx | `todo` |
| Contact.tsx | `todo` |
-| Profile.tsx | CreateProfileScreen.kt / ProfileIntroScreen.kt |
-| ProfileEdit.tsx | CreateProfileScreen.kt |
-| ProfileOnboarding.tsx | ProfileIntroScreen.kt |
-| ProfileLink.tsx | CreateProfileScreen.kt |
+| Profile.tsx | ProfileScreen.kt |
+| ProfileEdit.tsx | `n/a` |
+| ProfileOnboarding.tsx | ProfileIntroScreen.kt / PubkyRingAuthScreen.kt |
+| ProfileLink.tsx | `n/a` |
## Widgets
| RN | Android |
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index f78fafcf0..8f4c70a43 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -19,7 +19,8 @@ activity-compose = { module = "androidx.activity:activity-compose", version = "1
appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" }
barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" }
biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" }
-bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.38" }
+bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.42" }
+paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc1" }
bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" }
camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" }
camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" }
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 9510f42e0..e3a1da778 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -61,6 +61,14 @@ dependencyResolutionManagement {
password = pass
}
}
+ maven {
+ url = uri("https://maven.pkg.github.com/pubky/paykit-rs")
+ credentials {
+ val (user, pass) = getGithubCredentials()
+ username = user
+ password = pass
+ }
+ }
}
}
rootProject.name = "bitkit-android"