unify error handler
This commit is contained in:
287
shared/src/commonMain/kotlin/su/reya/coop/AuthViewModel.kt
Normal file
287
shared/src/commonMain/kotlin/su/reya/coop/AuthViewModel.kt
Normal file
@@ -0,0 +1,287 @@
|
||||
package su.reya.coop
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
import rust.nostr.sdk.AsyncNostrSigner
|
||||
import rust.nostr.sdk.Keys
|
||||
import rust.nostr.sdk.NostrConnect
|
||||
import rust.nostr.sdk.NostrConnectUri
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import su.reya.coop.blossom.BlossomClient
|
||||
import su.reya.coop.nostr.ExternalSignerHandler
|
||||
import su.reya.coop.nostr.ExternalSignerProxy
|
||||
import su.reya.coop.nostr.Nostr
|
||||
import su.reya.coop.nostr.SignerPermissions
|
||||
import su.reya.coop.storage.SecretStorage
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
data class AuthState(
|
||||
val isBusy: Boolean = false,
|
||||
val signerRequired: Boolean? = null,
|
||||
val isNotificationBannerDismissed: Boolean = false,
|
||||
)
|
||||
|
||||
class AuthViewModel(
|
||||
private val nostr: Nostr,
|
||||
private val secretStore: SecretStorage,
|
||||
private val externalSignerHandler: ExternalSignerHandler? = null,
|
||||
) : BaseViewModel() {
|
||||
companion object {
|
||||
private const val KEY_USER_SIGNER = "user_signer"
|
||||
private const val KEY_APP_KEYS = "app_keys"
|
||||
private const val KEY_BANNER_DISMISSED = "notification_banner_dismissed"
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(AuthState())
|
||||
val state = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
// Check if the notification banner has been dismissed
|
||||
checkNotificationBannerDismissedStatus()
|
||||
|
||||
// Check local stored secret (secret key or bunker)
|
||||
login()
|
||||
}
|
||||
|
||||
private fun checkNotificationBannerDismissedStatus() {
|
||||
viewModelScope.launch {
|
||||
val dismissed = secretStore.get(KEY_BANNER_DISMISSED) == "true"
|
||||
_state.update { it.copy(isNotificationBannerDismissed = dismissed) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun login() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val secret = withTimeoutOrNull(3.seconds) {
|
||||
secretStore.get(KEY_USER_SIGNER)
|
||||
}
|
||||
|
||||
if (secret == null) {
|
||||
_state.update { it.copy(signerRequired = true) }
|
||||
return@launch
|
||||
}
|
||||
|
||||
runCatching {
|
||||
val signer = createSigner(secret)
|
||||
nostr.setSigner(signer)
|
||||
}.onSuccess {
|
||||
_state.update { it.copy(signerRequired = false) }
|
||||
}.onFailure { e ->
|
||||
showError("Login failed: ${e.message}")
|
||||
_state.update { it.copy(signerRequired = true) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
showError("Login failed: ${e.message}")
|
||||
_state.update { it.copy(signerRequired = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun logout(onLogout: () -> Unit = {}) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_state.update { it.copy(isBusy = true) }
|
||||
|
||||
// Reset the nostr signer and prune the database
|
||||
nostr.signer.switch(Keys.generate())
|
||||
nostr.prune()
|
||||
} catch (e: Exception) {
|
||||
showError("Logout encountered an error: ${e.message}")
|
||||
} finally {
|
||||
// Clear credentials from persistent storage
|
||||
secretStore.clear(KEY_USER_SIGNER)
|
||||
secretStore.clear(KEY_BANNER_DISMISSED)
|
||||
|
||||
// Call cleanup callback (e.g. to reset other ViewModels)
|
||||
onLogout()
|
||||
|
||||
_state.update { it.copy(isBusy = false, signerRequired = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissNotificationBanner() {
|
||||
viewModelScope.launch {
|
||||
secretStore.set(KEY_BANNER_DISMISSED, "true")
|
||||
_state.update { it.copy(isNotificationBannerDismissed = true) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getOrInitAppKeys(): Keys {
|
||||
val secret = secretStore.get(KEY_APP_KEYS)
|
||||
|
||||
// If app keys are already stored, use them
|
||||
if (secret != null) {
|
||||
return Keys.parse(secret)
|
||||
}
|
||||
|
||||
// Generate new app keys and save to the secret storage
|
||||
val keys = Keys.generate()
|
||||
secretStore.set(KEY_APP_KEYS, keys.secretKey().toBech32())
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
private suspend fun createSigner(secret: String): AsyncNostrSigner {
|
||||
return when {
|
||||
secret.startsWith("nsec1") -> Keys.parse(secret)
|
||||
|
||||
secret.startsWith("bunker://") -> {
|
||||
val appKeys = getOrInitAppKeys()
|
||||
val bunker = NostrConnectUri.parse(secret)
|
||||
val timeout = 50.seconds
|
||||
NostrConnect(uri = bunker, appKeys, timeout, null)
|
||||
}
|
||||
|
||||
secret.startsWith("nip55://") -> {
|
||||
val handler = externalSignerHandler
|
||||
?: throw IllegalStateException("External signer not available on this platform")
|
||||
|
||||
// Format: nip55://packageName/hexPubkey
|
||||
val parts = secret.removePrefix("nip55://").split("/", limit = 2)
|
||||
val packageName = parts[0]
|
||||
val pubkey = PublicKey.parse(parts[1])
|
||||
|
||||
handler.setPackageName(packageName)
|
||||
ExternalSignerProxy(handler, pubkey)
|
||||
}
|
||||
|
||||
else -> throw IllegalArgumentException("Invalid secret format")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun verifyIdentity(secret: String): PublicKey? {
|
||||
try {
|
||||
val signer = createSigner(secret)
|
||||
if (secret.startsWith("bunker://")) {
|
||||
showError("Please approve the connection.")
|
||||
}
|
||||
return signer.getPublicKeyAsync()
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun importIdentity(secret: String) {
|
||||
_state.update { it.copy(isBusy = true) }
|
||||
try {
|
||||
val signer = createSigner(secret)
|
||||
// Update signer
|
||||
nostr.setSigner(signer)
|
||||
// Persist the secret in the secret storage
|
||||
secretStore.set(KEY_USER_SIGNER, secret)
|
||||
// Update local states
|
||||
_state.update { it.copy(signerRequired = false, isBusy = false) }
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
_state.update { it.copy(isBusy = false) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun connectExternalSigner() {
|
||||
val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available")
|
||||
_state.update { it.copy(isBusy = true) }
|
||||
try {
|
||||
val permissions = SignerPermissions.toJson(
|
||||
listOf(
|
||||
SignerPermissions.signEvent(0),
|
||||
SignerPermissions.signEvent(3),
|
||||
SignerPermissions.signEvent(10000),
|
||||
SignerPermissions.signEvent(10050),
|
||||
SignerPermissions.signEvent(10063),
|
||||
SignerPermissions.signEvent(22242),
|
||||
SignerPermissions.signEvent(30030),
|
||||
SignerPermissions.signEvent(30315),
|
||||
SignerPermissions.nip44Encrypt(),
|
||||
SignerPermissions.nip44Decrypt(),
|
||||
)
|
||||
)
|
||||
|
||||
val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected")
|
||||
val signer = ExternalSignerProxy(handler, result.pubkey)
|
||||
|
||||
// Update signer
|
||||
nostr.setSigner(signer)
|
||||
// Store the signer in the secret storage
|
||||
secretStore.set(
|
||||
KEY_USER_SIGNER,
|
||||
"nip55://${result.packageName}/${result.pubkey.toHex()}"
|
||||
)
|
||||
// Update local states
|
||||
_state.update { it.copy(signerRequired = false, isBusy = false) }
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(isBusy = false) }
|
||||
showError("Notice: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun isExternalSignerAvailable(): Boolean {
|
||||
return externalSignerHandler?.isAvailable() == true
|
||||
}
|
||||
|
||||
suspend fun createIdentity(
|
||||
name: String,
|
||||
bio: String?,
|
||||
picture: ByteArray?,
|
||||
contentType: String? = null
|
||||
) {
|
||||
_state.update { it.copy(isBusy = true) }
|
||||
|
||||
val keys = Keys.generate()
|
||||
val secret = keys.secretKey().toBech32()
|
||||
|
||||
try {
|
||||
val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") }
|
||||
// Create identity
|
||||
nostr.profiles.createIdentity(keys = keys, name = name, bio = bio, picture = avatarUrl)
|
||||
// Persist the secret in the secret storage
|
||||
secretStore.set(KEY_USER_SIGNER, secret)
|
||||
// Update local states
|
||||
_state.update { it.copy(isBusy = false, signerRequired = false) }
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
_state.update { it.copy(isBusy = false) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun blossomUpload(
|
||||
file: ByteArray,
|
||||
contentType: String? = "image/jpeg"
|
||||
): String? {
|
||||
try {
|
||||
val blossom = BlossomClient(
|
||||
url = "https://blossom.band",
|
||||
client = HttpClient {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
ignoreUnknownKeys = true
|
||||
prettyPrint = true
|
||||
isLenient = true
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
val descriptor = blossom.upload(
|
||||
file = file,
|
||||
contentType = contentType,
|
||||
signer = nostr.signer.get()
|
||||
)
|
||||
|
||||
return descriptor?.url
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
46
shared/src/commonMain/kotlin/su/reya/coop/BaseViewModel.kt
Normal file
46
shared/src/commonMain/kotlin/su/reya/coop/BaseViewModel.kt
Normal file
@@ -0,0 +1,46 @@
|
||||
package su.reya.coop
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.serialization.json.Json
|
||||
import rust.nostr.sdk.AsyncNostrSigner
|
||||
import su.reya.coop.blossom.BlossomClient
|
||||
|
||||
abstract class BaseViewModel : ViewModel() {
|
||||
protected val httpClient by lazy {
|
||||
HttpClient {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
ignoreUnknownKeys = true
|
||||
prettyPrint = true
|
||||
isLenient = true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected fun showError(message: String) {
|
||||
ErrorManager.showError(message)
|
||||
}
|
||||
|
||||
protected suspend fun blossomUpload(
|
||||
signer: AsyncNostrSigner,
|
||||
file: ByteArray,
|
||||
contentType: String? = "image/jpeg"
|
||||
): String? {
|
||||
return try {
|
||||
val blossom = BlossomClient(url = "https://blossom.band", client = httpClient)
|
||||
val descriptor = blossom.upload(
|
||||
file = file,
|
||||
contentType = contentType,
|
||||
signer = signer,
|
||||
)
|
||||
descriptor?.url
|
||||
} catch (e: Exception) {
|
||||
showError("Upload failed: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
13
shared/src/commonMain/kotlin/su/reya/coop/ErrorManager.kt
Normal file
13
shared/src/commonMain/kotlin/su/reya/coop/ErrorManager.kt
Normal file
@@ -0,0 +1,13 @@
|
||||
package su.reya.coop
|
||||
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
|
||||
object ErrorManager {
|
||||
private val _errors = Channel<String>(Channel.BUFFERED)
|
||||
val errors = _errors.receiveAsFlow()
|
||||
|
||||
fun showError(message: String) {
|
||||
_errors.trySend(message)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
package su.reya.coop
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
@@ -22,7 +18,6 @@ import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -30,27 +25,17 @@ import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
import rust.nostr.sdk.AsyncNostrSigner
|
||||
import rust.nostr.sdk.EventBuilder
|
||||
import rust.nostr.sdk.EventId
|
||||
import rust.nostr.sdk.Keys
|
||||
import rust.nostr.sdk.Kind
|
||||
import rust.nostr.sdk.KindStandard
|
||||
import rust.nostr.sdk.NostrConnect
|
||||
import rust.nostr.sdk.NostrConnectUri
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.RelayMetadata
|
||||
import rust.nostr.sdk.RelayUrl
|
||||
import rust.nostr.sdk.Tag
|
||||
import rust.nostr.sdk.Timestamp
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import su.reya.coop.blossom.BlossomClient
|
||||
import su.reya.coop.nostr.ExternalSignerHandler
|
||||
import su.reya.coop.nostr.ExternalSignerProxy
|
||||
import su.reya.coop.nostr.Nostr
|
||||
import su.reya.coop.nostr.SignerPermissions
|
||||
import su.reya.coop.storage.SecretStorage
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
@@ -60,21 +45,9 @@ data class NostrAppState(
|
||||
val isRelayListEmpty: Boolean = false,
|
||||
val isSyncing: Boolean = false,
|
||||
val isPartialProcessedGiftWrap: Boolean = false,
|
||||
val isNotificationBannerDismissed: Boolean = false,
|
||||
val signerRequired: Boolean? = null,
|
||||
)
|
||||
|
||||
class NostrViewModel(
|
||||
private val nostr: Nostr,
|
||||
private val secretStore: SecretStorage,
|
||||
private val externalSignerHandler: ExternalSignerHandler? = null,
|
||||
) : ViewModel() {
|
||||
companion object {
|
||||
private const val KEY_USER_SIGNER = "user_signer"
|
||||
private const val KEY_APP_KEYS = "app_keys"
|
||||
private const val KEY_BANNER_DISMISSED = "notification_banner_dismissed"
|
||||
}
|
||||
|
||||
class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
|
||||
private val backgroundWorkTrigger = flow {
|
||||
coroutineScope {
|
||||
val observerJob = launch { runObserver() }
|
||||
@@ -114,18 +87,11 @@ class NostrViewModel(
|
||||
private val _sentReports = MutableSharedFlow<Map<EventId, List<RelayUrl>>>()
|
||||
val sentReport = _sentReports.asSharedFlow()
|
||||
|
||||
private val _errorEvents = Channel<String>(Channel.BUFFERED)
|
||||
val errorEvents = _errorEvents.receiveAsFlow()
|
||||
|
||||
private val profilesMutex = Mutex()
|
||||
private val profiles = mutableMapOf<PublicKey, MutableStateFlow<Profile?>>()
|
||||
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
|
||||
private val seenPublicKeys = mutableSetOf<PublicKey>()
|
||||
|
||||
val isNotificationBannerDismissed = appState.map { it.isNotificationBannerDismissed }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
val signerRequired = appState.map { it.signerRequired }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
|
||||
val isBusy = appState.map { it.isBusy }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
val isPartialProcessedGiftWrap = appState.map { it.isPartialProcessedGiftWrap }
|
||||
@@ -143,12 +109,6 @@ class NostrViewModel(
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
|
||||
|
||||
init {
|
||||
// Check if the notification banner has been dismissed
|
||||
checkNotificationBannerDismissedStatus()
|
||||
|
||||
// Check local stored secret (secret key or bunker)
|
||||
login()
|
||||
|
||||
// Automatically reconnect bootstrap relays
|
||||
reconnect()
|
||||
|
||||
@@ -170,19 +130,6 @@ class NostrViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun showError(message: String) {
|
||||
viewModelScope.launch {
|
||||
_errorEvents.send(message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkNotificationBannerDismissedStatus() {
|
||||
viewModelScope.launch {
|
||||
val dismissed = secretStore.get(KEY_BANNER_DISMISSED) == "true"
|
||||
_appState.update { it.copy(isNotificationBannerDismissed = dismissed) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun reconnect() {
|
||||
viewModelScope.launch {
|
||||
nostr.waitUntilInitialized()
|
||||
@@ -292,34 +239,6 @@ class NostrViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun login() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val secret = withTimeoutOrNull(3.seconds) {
|
||||
secretStore.get(KEY_USER_SIGNER)
|
||||
}
|
||||
|
||||
if (secret == null) {
|
||||
_appState.update { it.copy(signerRequired = true) }
|
||||
return@launch
|
||||
}
|
||||
|
||||
runCatching {
|
||||
val signer = createSigner(secret)
|
||||
nostr.setSigner(signer)
|
||||
}.onSuccess {
|
||||
_appState.update { it.copy(signerRequired = false) }
|
||||
}.onFailure { e ->
|
||||
showError("Login failed: ${e.message}")
|
||||
_appState.update { it.copy(signerRequired = true) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
showError("Login failed: ${e.message}")
|
||||
_appState.update { it.copy(signerRequired = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeSignerAndCheckRelays() {
|
||||
viewModelScope.launch {
|
||||
while (true) {
|
||||
@@ -374,95 +293,21 @@ class NostrViewModel(
|
||||
return flow.asStateFlow()
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_appState.update { it.copy(isBusy = true) }
|
||||
// Reset the nostr signer and prune the database
|
||||
nostr.signer.switch(Keys.generate())
|
||||
nostr.prune()
|
||||
} catch (e: Exception) {
|
||||
showError("Logout encountered an error: ${e.message}")
|
||||
} finally {
|
||||
// Clear credentials from persistent storage
|
||||
secretStore.clear(KEY_USER_SIGNER)
|
||||
secretStore.clear(KEY_BANNER_DISMISSED)
|
||||
|
||||
// Reset all UI states
|
||||
resetInternalState()
|
||||
|
||||
_appState.update { it.copy(isBusy = false, signerRequired = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetInternalState() {
|
||||
fun resetInternalState() {
|
||||
_chatRooms.value = emptySet()
|
||||
_contactList.value = emptySet()
|
||||
_appState.update {
|
||||
it.copy(
|
||||
isPartialProcessedGiftWrap = false,
|
||||
isRelayListEmpty = false,
|
||||
isNotificationBannerDismissed = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissNotificationBanner() {
|
||||
viewModelScope.launch {
|
||||
secretStore.set(KEY_BANNER_DISMISSED, "true")
|
||||
_appState.update { it.copy(isNotificationBannerDismissed = true) }
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissRelayWarning() {
|
||||
_appState.update { it.copy(isRelayListEmpty = false) }
|
||||
}
|
||||
|
||||
private suspend fun getOrInitAppKeys(): Keys {
|
||||
val secret = secretStore.get(KEY_APP_KEYS)
|
||||
|
||||
// If app keys are already stored, use them
|
||||
if (secret != null) {
|
||||
return Keys.parse(secret)
|
||||
}
|
||||
|
||||
// Generate new app keys and save to the secret storage
|
||||
val keys = Keys.generate()
|
||||
secretStore.set(KEY_APP_KEYS, keys.secretKey().toBech32())
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
suspend fun blossomUpload(file: ByteArray, contentType: String? = "image/jpeg"): String? {
|
||||
try {
|
||||
// Upload picture to Blossom
|
||||
val blossom = BlossomClient(
|
||||
url = "https://blossom.band",
|
||||
client = HttpClient {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
ignoreUnknownKeys = true
|
||||
prettyPrint = true
|
||||
isLenient = true
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
val descriptor = blossom.upload(
|
||||
file = file,
|
||||
contentType = contentType,
|
||||
signer = nostr.signer.get()
|
||||
)
|
||||
|
||||
return descriptor?.url
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateProfile(
|
||||
name: String? = null,
|
||||
bio: String? = null,
|
||||
@@ -471,11 +316,14 @@ class NostrViewModel(
|
||||
) {
|
||||
_appState.update { it.copy(isBusy = true) }
|
||||
try {
|
||||
val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") }
|
||||
val avatarUrl =
|
||||
picture?.let { blossomUpload(nostr.signer.get(), it, contentType ?: "image/jpeg") }
|
||||
val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl)
|
||||
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
||||
|
||||
// Update the metadata state after successfully published
|
||||
updateMetadata(currentUser, Profile(currentUser, newMetadata))
|
||||
|
||||
// Update local state
|
||||
_appState.update { it.copy(isBusy = false) }
|
||||
} catch (e: Exception) {
|
||||
@@ -483,126 +331,6 @@ class NostrViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createIdentity(
|
||||
name: String,
|
||||
bio: String?,
|
||||
picture: ByteArray?,
|
||||
contentType: String? = null
|
||||
) {
|
||||
_appState.update { it.copy(isBusy = true) }
|
||||
|
||||
val keys = Keys.generate()
|
||||
val secret = keys.secretKey().toBech32()
|
||||
|
||||
try {
|
||||
val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") }
|
||||
// Create identity
|
||||
nostr.profiles.createIdentity(keys = keys, name = name, bio, picture = avatarUrl)
|
||||
// Persist the secret in the secret storage
|
||||
secretStore.set(KEY_USER_SIGNER, secret)
|
||||
// Update local states
|
||||
_appState.update { it.copy(isBusy = false, signerRequired = false) }
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun createSigner(secret: String): AsyncNostrSigner {
|
||||
return when {
|
||||
secret.startsWith("nsec1") -> Keys.parse(secret)
|
||||
|
||||
secret.startsWith("bunker://") -> {
|
||||
val appKeys = getOrInitAppKeys()
|
||||
val bunker = NostrConnectUri.parse(secret)
|
||||
val timeout = 50.seconds // or Duration.parse("50s")
|
||||
NostrConnect(uri = bunker, appKeys, timeout, null)
|
||||
}
|
||||
|
||||
secret.startsWith("nip55://") -> {
|
||||
val handler = externalSignerHandler
|
||||
?: throw IllegalStateException("External signer not available on this platform")
|
||||
|
||||
// Format: nip55://packageName/hexPubkey
|
||||
val parts = secret.removePrefix("nip55://").split("/", limit = 2)
|
||||
val packageName = parts[0]
|
||||
val pubkey = PublicKey.parse(parts[1])
|
||||
|
||||
handler.setPackageName(packageName)
|
||||
ExternalSignerProxy(handler, pubkey)
|
||||
}
|
||||
|
||||
else -> throw IllegalArgumentException("Invalid secret format")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun verifyIdentity(secret: String): PublicKey? {
|
||||
try {
|
||||
val signer = createSigner(secret)
|
||||
if (secret.startsWith("bunker://")) {
|
||||
showError("Please approve the connection.")
|
||||
}
|
||||
return signer.getPublicKeyAsync()
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun importIdentity(secret: String) {
|
||||
_appState.update { it.copy(isBusy = true) }
|
||||
try {
|
||||
val signer = createSigner(secret)
|
||||
// Update signer
|
||||
nostr.setSigner(signer)
|
||||
// Persist the secret in the secret storage
|
||||
secretStore.set(KEY_USER_SIGNER, secret)
|
||||
// Update local states
|
||||
_appState.update { it.copy(signerRequired = false, isBusy = false) }
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun connectExternalSigner() {
|
||||
val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available")
|
||||
_appState.update { it.copy(isBusy = true) }
|
||||
try {
|
||||
val permissions = SignerPermissions.toJson(
|
||||
listOf(
|
||||
SignerPermissions.signEvent(0),
|
||||
SignerPermissions.signEvent(3),
|
||||
SignerPermissions.signEvent(10000),
|
||||
SignerPermissions.signEvent(10050),
|
||||
SignerPermissions.signEvent(10063),
|
||||
SignerPermissions.signEvent(22242),
|
||||
SignerPermissions.signEvent(30030),
|
||||
SignerPermissions.signEvent(30315),
|
||||
SignerPermissions.nip44Encrypt(),
|
||||
SignerPermissions.nip44Decrypt(),
|
||||
)
|
||||
)
|
||||
|
||||
val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected")
|
||||
val signer = ExternalSignerProxy(handler, result.pubkey)
|
||||
|
||||
// Update signer
|
||||
nostr.setSigner(signer)
|
||||
// Store the signer in the secret storage
|
||||
secretStore.set(
|
||||
KEY_USER_SIGNER,
|
||||
"nip55://${result.packageName}/${result.pubkey.toHex()}"
|
||||
)
|
||||
// Update local states
|
||||
_appState.update { it.copy(signerRequired = false, isBusy = false) }
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Notice: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun isExternalSignerAvailable(): Boolean {
|
||||
return externalSignerHandler?.isAvailable() == true
|
||||
}
|
||||
|
||||
suspend fun refetchMsgRelays() {
|
||||
val currentUser = nostr.signer.getPublicKeyAsync() ?: return
|
||||
val relays = nostr.relays.fetchMsgRelays(currentUser)
|
||||
@@ -872,10 +600,8 @@ class NostrViewModel(
|
||||
if (file == null) return
|
||||
|
||||
try {
|
||||
val uri = blossomUpload(file, contentType)
|
||||
?: throw IllegalArgumentException("Failed to upload file")
|
||||
|
||||
sendMessage(roomId, uri, replies)
|
||||
val uri = blossomUpload(nostr.signer.get(), file, contentType)
|
||||
if (uri != null) sendMessage(roomId, uri, replies)
|
||||
} catch (e: Exception) {
|
||||
throw IllegalArgumentException("Error: ${e.message}")
|
||||
}
|
||||
@@ -952,4 +678,3 @@ class NostrViewModel(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user