diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml
index c4d8237..b396cd3 100644
--- a/composeApp/src/androidMain/AndroidManifest.xml
+++ b/composeApp/src/androidMain/AndroidManifest.xml
@@ -11,6 +11,14 @@
+
+
+
+
+
+
+
+
().apply {
+ add(payload)
+ add(pubkey?.toHex() ?: "")
+ add(currentUser?.toHex() ?: "")
+ }
+
+ val cursor = context.contentResolver.query(
+ uri,
+ projection.toTypedArray(),
+ null, null, null,
+ ) ?: return null
+
+ return cursor.use {
+ if (it.getColumnIndex("rejected") > -1) return null
+ if (it.moveToFirst()) {
+ val resultIndex = it.getColumnIndex("result")
+ val result = if (resultIndex > -1) it.getString(resultIndex) else null
+
+ val eventIndex = it.getColumnIndex("event")
+ val event = if (eventIndex > -1) it.getString(eventIndex) else null
+
+ ContentResolverResult(result = result!!, event = event)
+ } else null
+ }
+ }
+
+ private suspend fun request(
+ type: String,
+ payload: String,
+ pubkey: PublicKey? = null,
+ currentUser: PublicKey? = null,
+ resultKey: String = "result",
+ extras: Map = emptyMap(),
+ ): String? {
+ // Try Content Resolver first
+ queryContentResolver(type, payload, pubkey, currentUser)?.let {
+ return it.result
+ }
+
+ // Fall back to Intent
+ val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:$payload".toUri()).apply {
+ `package` = cachedPackageName
+ putExtra("type", type)
+ if (pubkey != null) putExtra("pubkey", pubkey.toHex())
+ if (currentUser != null) putExtra("current_user", currentUser.toHex())
+ extras.forEach { (k, v) -> putExtra(k, v) }
+ }
+
+ val result = launcher.launch(intent)
+ if (result.resultCode != Activity.RESULT_OK) return null
+
+ val data = result.data ?: return null
+ if (data.getBooleanExtra("rejected", false)) return null
+
+ return data.getStringExtra(resultKey)
+ }
+
+ override fun isAvailable(): Boolean {
+ val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:".toUri())
+ return context.packageManager.queryIntentActivities(intent, 0).isNotEmpty()
+ }
+
+ override fun setPackageName(packageName: String) {
+ cachedPackageName = packageName
+ }
+
+ override suspend fun getPublicKey(permissions: String?): ExternalSignerResult? {
+ val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:".toUri()).apply {
+ putExtra("type", "get_public_key")
+ if (permissions != null) putExtra("permissions", permissions)
+ }
+
+ val result = launcher.launch(intent)
+ if (result.resultCode != Activity.RESULT_OK) return null
+
+ val data = result.data ?: return null
+ if (data.getBooleanExtra("rejected", false)) return null
+
+ val pubkey = data.getStringExtra("result") ?: return null
+ val packageName = data.getStringExtra("package") ?: return null
+ cachedPackageName = packageName
+
+ return ExternalSignerResult(PublicKey.parse(pubkey), packageName)
+ }
+
+ override suspend fun signEvent(event: UnsignedEvent, currentUser: PublicKey): String? {
+ val extras = event.id()?.let { mapOf("id" to it.toHex()) } ?: emptyMap()
+ return request(
+ type = "sign_event",
+ payload = event.asJson(),
+ currentUser = currentUser,
+ resultKey = "event",
+ extras = extras,
+ )
+ }
+
+ override suspend fun nip04Encrypt(plaintext: String, pubkey: PublicKey): String? {
+ return request("nip04_encrypt", plaintext, pubkey)
+ }
+
+ override suspend fun nip04Decrypt(ciphertext: String, pubkey: PublicKey): String? {
+ return request("nip04_decrypt", ciphertext, pubkey)
+ }
+
+ override suspend fun nip44Encrypt(
+ plaintext: String,
+ pubkey: PublicKey,
+ currentUser: PublicKey
+ ): String? {
+ return request("nip44_encrypt", plaintext, pubkey, currentUser)
+ }
+
+ override suspend fun nip44Decrypt(
+ ciphertext: String,
+ pubkey: PublicKey,
+ currentUser: PublicKey
+ ): String? {
+ return request("nip44_decrypt", ciphertext, pubkey, currentUser)
+ }
+}
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/ExternalSignerLauncher.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/ExternalSignerLauncher.kt
new file mode 100644
index 0000000..cbfb686
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/ExternalSignerLauncher.kt
@@ -0,0 +1,28 @@
+package su.reya.coop
+
+import android.content.Intent
+import androidx.activity.result.ActivityResult
+import androidx.activity.result.ActivityResultLauncher
+import kotlinx.coroutines.CompletableDeferred
+
+class ExternalSignerLauncher {
+ private var launcher: ActivityResultLauncher? = null
+ private var pendingResult: CompletableDeferred? = null
+
+ fun register(launcher: ActivityResultLauncher) {
+ this.launcher = launcher
+ }
+
+ suspend fun launch(intent: Intent): ActivityResult {
+ val deferred = CompletableDeferred()
+ pendingResult = deferred
+ launcher?.launch(intent)
+ ?: throw IllegalStateException("ExternalSignerLauncher not registered")
+ return deferred.await()
+ }
+
+ fun onResult(result: ActivityResult) {
+ pendingResult?.complete(result)
+ pendingResult = null
+ }
+}
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt
index 0e51ee7..0fb8442 100644
--- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt
@@ -2,9 +2,12 @@ package su.reya.coop
import android.content.Intent
import android.os.Bundle
+import android.os.Process
+import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.ProcessLifecycleOwner
@@ -14,11 +17,16 @@ import su.reya.coop.coop.storage.SecretStore
import kotlin.system.exitProcess
class MainActivity : ComponentActivity() {
+ companion object {
+ val externalSignerLauncher = ExternalSignerLauncher()
+ }
+
private val viewModel: NostrViewModel by viewModels {
object : ViewModelProvider.Factory {
override fun create(modelClass: Class): T {
val secretStore = SecretStore(this@MainActivity)
- return NostrViewModel(NostrManager.instance, secretStore) as T
+ val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
+ return NostrViewModel(NostrManager.instance, secretStore, androidSigner) as T
}
}
}
@@ -26,10 +34,9 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
throwable.printStackTrace()
- android.util.Log.e(
- "CoopCrash",
- "Uncaught exception in thread ${thread.name}",
- throwable
+
+ Log.e(
+ "CoopCrash", "Uncaught exception in thread ${thread.name}", throwable
)
// Start the Crash Activity
@@ -40,10 +47,17 @@ class MainActivity : ComponentActivity() {
startActivity(intent)
// Exit
- android.os.Process.killProcess(android.os.Process.myPid())
+ Process.killProcess(Process.myPid())
exitProcess(1)
}
+ val resultLauncher = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result ->
+ externalSignerLauncher.onResult(result)
+ }
+ externalSignerLauncher.register(resultLauncher)
+
val splashScreen = installSplashScreen()
enableEdgeToEdge()
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt
index 39ad654..336f727 100644
--- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt
@@ -78,7 +78,7 @@ fun ImportScreen() {
val scope = rememberCoroutineScope()
- val isLoggedIn by viewModel.isLoggedIn.collectAsStateWithLifecycle(false)
+ val isBusy by viewModel.isBusy.collectAsStateWithLifecycle(false)
var secret by remember { mutableStateOf("") }
var pubkey by remember { mutableStateOf(null) }
@@ -90,7 +90,7 @@ fun ImportScreen() {
val profile = metadata?.asRecord()
val displayName = profile?.displayName ?: profile?.name ?: pubkey?.short() ?: "Unknown"
val picture = profile?.picture
-
+
LaunchedEffect(qrScanResult.content) {
qrScanResult.content?.let { result ->
runCatching {
@@ -205,7 +205,7 @@ fun ImportScreen() {
BasicTextField(
value = secret,
onValueChange = { secret = it },
- enabled = !isLoggedIn,
+ enabled = !isBusy,
modifier = Modifier.fillMaxWidth(),
maxLines = 4,
keyboardOptions = KeyboardOptions(
@@ -258,9 +258,9 @@ fun ImportScreen() {
modifier = Modifier
.fillMaxWidth()
.height(ButtonDefaults.MediumContainerHeight),
- enabled = secret.isNotBlank() && !isLoggedIn,
+ enabled = secret.isNotBlank() && !isBusy,
) {
- if (isLoggedIn) {
+ if (isBusy) {
LoadingIndicator()
} else {
Text(
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt
index f6cd375..216e402 100644
--- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt
@@ -15,12 +15,12 @@ fun NewIdentityScreen() {
val viewModel = LocalNostrViewModel.current
val navigator = LocalNavigator.current
val scope = rememberCoroutineScope()
- val isLoggedIn by viewModel.isLoggedIn.collectAsStateWithLifecycle(false)
+ val isBusy by viewModel.isBusy.collectAsStateWithLifecycle(false)
ProfileEditor(
title = "Create a new identity",
buttonLabel = "Continue",
- isBusy = isLoggedIn,
+ isBusy = isBusy,
onBack = { navigator.goBack() },
onConfirm = { name, bio, bytes, type ->
scope.launch {
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt
index 0c53b94..cb0ca6e 100644
--- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt
@@ -1,5 +1,6 @@
package su.reya.coop.screens
+import android.content.Intent
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -13,13 +14,17 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
@@ -27,6 +32,7 @@ import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
@@ -34,10 +40,13 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
+import androidx.core.net.toUri
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.coop
+import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalNavigator
+import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen
import su.reya.coop.shared.getExpressiveFontFamily
@@ -45,11 +54,15 @@ import su.reya.coop.shared.getExpressiveFontFamily
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun OnboardingScreen() {
+ val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current
+ val viewModel = LocalNostrViewModel.current
+ val scope = rememberCoroutineScope()
val logoPainter = painterResource(Res.drawable.coop)
val expressiveFont = getExpressiveFontFamily()
+
val annotatedText = buildAnnotatedString {
append("By using Coop, you agree to accept\nour ")
// Push "Terms of Use" link
@@ -142,7 +155,44 @@ fun OnboardingScreen() {
)
}
Spacer(modifier = Modifier.size(8.dp))
- OutlinedButton(
+ FilledTonalButton(
+ onClick = {
+ scope.launch {
+ if (viewModel.isExternalSignerAvailable()) {
+ try {
+ viewModel.connectExternalSigner()
+ navigator.navigate(Screen.Home)
+ } catch (e: Exception) {
+ e.message?.let { snackbarHostState.showSnackbar(it) }
+ }
+ } else {
+ val result = snackbarHostState.showSnackbar(
+ message = "External signer not installed. Please install Amber or alternatives.",
+ actionLabel = "Install",
+ withDismissAction = true,
+ duration = SnackbarDuration.Long
+ )
+ if (result == SnackbarResult.ActionPerformed) {
+ val intent = Intent(
+ Intent.ACTION_VIEW,
+ "https://zapstore.dev/apps/com.greenart7c3.nostrsigner".toUri()
+ )
+ context.startActivity(intent)
+ }
+ }
+ }
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(ButtonDefaults.MediumContainerHeight),
+ ) {
+ Text(
+ text = "Connect with Amber",
+ style = MaterialTheme.typography.titleMedium,
+ )
+ }
+ Spacer(modifier = Modifier.size(8.dp))
+ TextButton(
onClick = { navigator.navigate(Screen.Import) },
modifier = Modifier
.fillMaxWidth()
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt
index 5bf03ae..276fb4c 100644
--- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt
@@ -18,7 +18,7 @@ fun UpdateProfileScreen() {
val currentUser = viewModel.currentUser() ?: return
val metadata by viewModel.getMetadata(currentUser).collectAsState(initial = null)
- val isBusy by viewModel.isLoggedIn.collectAsStateWithLifecycle(false)
+ val isBusy by viewModel.isBusy.collectAsStateWithLifecycle(false)
val profile = metadata?.asRecord()
diff --git a/shared/src/commonMain/kotlin/su/reya/coop/ExternalSignerHandler.kt b/shared/src/commonMain/kotlin/su/reya/coop/ExternalSignerHandler.kt
new file mode 100644
index 0000000..920fe64
--- /dev/null
+++ b/shared/src/commonMain/kotlin/su/reya/coop/ExternalSignerHandler.kt
@@ -0,0 +1,44 @@
+package su.reya.coop
+
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import rust.nostr.sdk.PublicKey
+import rust.nostr.sdk.UnsignedEvent
+
+/**
+ * Platform interface for NIP-55 external signer communication.
+ * Implemented on Android; no-op/null on other platforms.
+ */
+interface ExternalSignerHandler {
+ fun isAvailable(): Boolean
+ fun setPackageName(packageName: String)
+ suspend fun getPublicKey(permissions: String? = null): ExternalSignerResult?
+ suspend fun signEvent(event: UnsignedEvent, currentUser: PublicKey): String?
+ suspend fun nip04Encrypt(plaintext: String, pubkey: PublicKey): String?
+ suspend fun nip04Decrypt(ciphertext: String, pubkey: PublicKey): String?
+ suspend fun nip44Encrypt(plaintext: String, pubkey: PublicKey, currentUser: PublicKey): String?
+ suspend fun nip44Decrypt(ciphertext: String, pubkey: PublicKey, currentUser: PublicKey): String?
+}
+
+@Serializable
+data class SignerPermission(
+ val type: String,
+ val kind: Int? = null,
+)
+
+object SignerPermissions {
+ fun signEvent(kind: Int? = null) = SignerPermission(type = "sign_event", kind = kind)
+ fun nip04Encrypt() = SignerPermission(type = "nip04_encrypt")
+ fun nip04Decrypt() = SignerPermission(type = "nip04_decrypt")
+ fun nip44Encrypt() = SignerPermission(type = "nip44_encrypt")
+ fun nip44Decrypt() = SignerPermission(type = "nip44_decrypt")
+
+ fun toJson(permissions: List): String {
+ return Json.encodeToString(permissions)
+ }
+}
+
+data class ExternalSignerResult(
+ val pubkey: PublicKey,
+ val packageName: String,
+)
\ No newline at end of file
diff --git a/shared/src/commonMain/kotlin/su/reya/coop/ExternalSignerProxy.kt b/shared/src/commonMain/kotlin/su/reya/coop/ExternalSignerProxy.kt
new file mode 100644
index 0000000..deee922
--- /dev/null
+++ b/shared/src/commonMain/kotlin/su/reya/coop/ExternalSignerProxy.kt
@@ -0,0 +1,40 @@
+package su.reya.coop
+
+import rust.nostr.sdk.AsyncNostrSigner
+import rust.nostr.sdk.Event
+import rust.nostr.sdk.PublicKey
+import rust.nostr.sdk.UnsignedEvent
+
+class ExternalSignerProxy(
+ private val handler: ExternalSignerHandler,
+ private val currentUser: PublicKey,
+) : AsyncNostrSigner {
+ override suspend fun getPublicKeyAsync(): PublicKey {
+ return currentUser
+ }
+
+ override suspend fun signEventAsync(unsignedEvent: UnsignedEvent): Event? {
+ val signedJson = handler.signEvent(unsignedEvent, currentUser) ?: return null
+ return Event.fromJson(signedJson)
+ }
+
+ override suspend fun nip04EncryptAsync(publicKey: PublicKey, content: String): String {
+ return handler.nip04Encrypt(content, publicKey)
+ ?: throw Exception("NIP-04 encrypt rejected")
+ }
+
+ override suspend fun nip04DecryptAsync(publicKey: PublicKey, encryptedContent: String): String {
+ return handler.nip04Decrypt(encryptedContent, publicKey)
+ ?: throw Exception("NIP-04 decrypt rejected")
+ }
+
+ override suspend fun nip44EncryptAsync(publicKey: PublicKey, content: String): String {
+ return handler.nip44Encrypt(content, publicKey, currentUser)
+ ?: throw Exception("NIP-44 encrypt rejected")
+ }
+
+ override suspend fun nip44DecryptAsync(publicKey: PublicKey, payload: String): String {
+ return handler.nip44Decrypt(payload, publicKey, currentUser)
+ ?: throw Exception("NIP-44 decrypt rejected")
+ }
+}
\ No newline at end of file
diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt
index 2de4218..0088e66 100644
--- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt
+++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt
@@ -44,7 +44,8 @@ import kotlin.time.Duration.Companion.seconds
class NostrViewModel(
private val nostr: Nostr,
- private val secretStore: SecretStorage
+ private val secretStore: SecretStorage,
+ private val externalSignerHandler: ExternalSignerHandler? = null,
) : ViewModel() {
private val _isNotificationBannerDismissed = MutableStateFlow(false)
val isNotificationBannerDismissed = _isNotificationBannerDismissed.asStateFlow()
@@ -52,8 +53,8 @@ class NostrViewModel(
private val _signerRequired = MutableStateFlow(null)
val signerRequired = _signerRequired.asStateFlow()
- private val _isLoggedIn = MutableStateFlow(false)
- val isLoggedIn = _isLoggedIn.asStateFlow()
+ private val _isBusy = MutableStateFlow(false)
+ val isBusy = _isBusy.asStateFlow()
private val _isPartialProcessedGiftWrap = MutableStateFlow(false)
val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow()
@@ -371,20 +372,6 @@ class NostrViewModel(
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 // or Duration.parse("50s")
- NostrConnect(uri = bunker, appKeys, timeout, null)
- }
-
- else -> throw IllegalArgumentException("Invalid secret format")
- }
- }
-
private suspend fun blossomUpload(file: ByteArray, contentType: String): String? {
try {
// Upload picture to Blossom
@@ -420,16 +407,16 @@ class NostrViewModel(
picture: ByteArray? = null,
contentType: String? = null
) {
- _isLoggedIn.value = true
+ _isBusy.value = true
try {
val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") }
val newMetadata = nostr.updateProfile(name, bio, avatarUrl)
// Update the metadata state after successfully published
updateMetadata(nostr.signer.currentUser!!, newMetadata)
+ // Update local state
+ _isBusy.value = false
} catch (e: Exception) {
showError("Error: ${e.message}")
- } finally {
- _isLoggedIn.value = false
}
}
@@ -439,7 +426,7 @@ class NostrViewModel(
picture: ByteArray?,
contentType: String? = null
) {
- _isLoggedIn.value = true
+ _isBusy.value = true
val keys = Keys.generate()
val secret = keys.secretKey().toBech32()
@@ -448,12 +435,41 @@ class NostrViewModel(
val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") }
// Create identity
nostr.createIdentity(keys = keys, name = name, bio, picture = avatarUrl)
+ // Persist the secret in the secret storage
+ secretStore.set("user_signer", secret)
+ // Update local states
+ _isBusy.value = false
+ _signerRequired.value = false
} catch (e: Exception) {
showError("Error: ${e.message}")
- } finally {
- secretStore.set("user_signer", secret)
- _isLoggedIn.value = false
- _signerRequired.value = false
+ }
+ }
+
+ 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")
}
}
@@ -471,19 +487,59 @@ class NostrViewModel(
}
suspend fun importIdentity(secret: String) {
- _isLoggedIn.value = true
+ _isBusy.value = true
try {
val signer = createSigner(secret)
+ // Update signer
nostr.setSigner(signer)
+ // Persist the secret in the secret storage
+ secretStore.set("user_signer", secret)
+ // Update local states
+ _signerRequired.value = false
+ _isBusy.value = false
} catch (e: Exception) {
showError("Error: ${e.message}")
- } finally {
- secretStore.set("user_signer", secret)
- _signerRequired.value = false
- _isLoggedIn.value = false
}
}
+ suspend fun connectExternalSigner() {
+ val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available")
+ _isBusy.value = 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("user_signer", "nip55://${result.packageName}/${result.pubkey.toHex()}")
+ // Update local states
+ _signerRequired.value = false
+ _isBusy.value = false
+ } catch (e: Exception) {
+ throw Exception("Notice: ${e.message}")
+ }
+ }
+
+ fun isExternalSignerAvailable(): Boolean {
+ return externalSignerHandler?.isAvailable() == true
+ }
+
suspend fun useDefaultMsgRelayList() {
try {
val defaultRelays = nostr.getDefaultMsgRelayList()