-
Notifications
You must be signed in to change notification settings - Fork 3
feat: add profile and contacts fetching from pubky #824
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 8 commits
5983a73
b5f211c
8d5bac7
1bdd233
1484f26
06a618d
7ff4b1e
13bd697
359eddc
ec754c1
9ede70f
ea2a78e
fcb20c9
5100c97
1985f9e
8656cb7
9b3a0e8
97b8e70
4be2306
6634eca
c3b548c
e0527c7
63cfa6e
78d16f7
1f0bba0
7c4fdad
e52430d
b0383b4
ae06f53
d16bbe1
6ae1a82
94b2b5a
9a07040
a3ac562
64e8017
0ce5b99
c1c9215
ad1e8f4
ca7fa32
f3481b5
752d21a
bc3f89f
f161dd2
efe1e5c
b9fd56f
03f3656
12b0516
f43c091
b24c552
529296a
2a1be0b
4e83dd9
e61d696
cb036a7
60b7ff4
a9489ca
2051774
1e38929
0b23ded
e3de573
3babc9d
348af4a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) { | ||
| 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) } | ||
|
ovitrif marked this conversation as resolved.
Outdated
|
||
| return File(diskDir, hash) | ||
| } | ||
| } | ||
| 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(), | ||
|
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)}" | ||
|
ovitrif marked this conversation as resolved.
Outdated
|
||
| } else { | ||
| publicKey | ||
| } | ||
| } | ||
| 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( | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
| @ApplicationContext private val context: Context, | ||
| @BgDispatcher private val bgDispatcher: CoroutineDispatcher, | ||
|
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) | ||
|
ben-kaufman marked this conversation as resolved.
Outdated
ovitrif marked this conversation as resolved.
Outdated
|
||
| private val loadProfileMutex = Mutex() | ||
|
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) | ||
|
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) | ||
|
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 {} | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: could return Edit: fixed to show what I meant using the inline code block.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 } | ||
|
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() | ||
| } | ||
|
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 } | ||
|
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() | ||
| } | ||
| } | ||
| 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() } | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. a bit 'dumb' but why not 🤷🏻 .
This comment was marked as resolved.
Sorry, something went wrong. |
||
|
|
||
| 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() } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.