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()