From a46063d8c4a2251e81eb7863ef3b35b9867653bb Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 9 Jun 2026 08:59:16 +0700 Subject: [PATCH 1/5] wip: external signer --- .../src/androidMain/AndroidManifest.xml | 8 + .../su/reya/coop/AndroidExternalSigner.kt | 151 ++++++++++++++++ .../kotlin/su/reya/coop/ExternalSigner.kt | 166 ++++++++++++++++++ .../su/reya/coop/ExternalSignerLauncher.kt | 34 ++++ .../kotlin/su/reya/coop/MainActivity.kt | 24 ++- .../su/reya/coop/ExternalSignerHandler.kt | 24 +++ .../su/reya/coop/ExternalSignerProxy.kt | 41 +++++ .../kotlin/su/reya/coop/NostrViewModel.kt | 16 +- 8 files changed, 457 insertions(+), 7 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/AndroidExternalSigner.kt create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/ExternalSigner.kt create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/ExternalSignerLauncher.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/ExternalSignerHandler.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/ExternalSignerProxy.kt 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 @@ + + + + + + + + ?, + 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, + ): 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()) + } + + 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("result") + } + + override fun isAvailable(): Boolean { + val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:".toUri()) + return context.packageManager.queryIntentActivities(intent, 0).isNotEmpty() + } + + 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 + + return ExternalSignerResult(pubkey, packageName) + } + + override suspend fun signEvent(event: UnsignedEvent, currentUser: PublicKey): String? { + // Try Content Resolver first + queryContentResolver( + "SIGN_EVENT", + event.asJson(), + null, + currentUser + )?.let { result -> + if (result.event != null) return result.event + } + + // Fall back to Intent + val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:${event.asJson()}".toUri()).apply { + `package` = cachedPackageName + putExtra("type", "sign_event") + putExtra("current_user", currentUser.toHex()) + if (event.id() != null) putExtra("id", event.id()!!.toHex()) + } + + 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("event") + } + + 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) + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/ExternalSigner.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/ExternalSigner.kt new file mode 100644 index 0000000..4c08304 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/ExternalSigner.kt @@ -0,0 +1,166 @@ +package su.reya.coop + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.core.net.toUri +import rust.nostr.sdk.AsyncNostrSigner +import rust.nostr.sdk.Event +import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.UnsignedEvent + +data class SignerResult( + val result: String, + val event: Event? = null, +) + +class ExternalSigner( + private val context: Context, + private val packageName: String, + private val currentUser: PublicKey, + private val launcher: ExternalSignerLauncher, +) : AsyncNostrSigner { + private fun queryContentResolver( + type: String, + payload: String, + pubkey: PublicKey? = null + ): SignerResult? { + val uri = "content://$packageName.${type.uppercase()}".toUri() + val projection = mutableListOf() + projection.add(payload) + if (pubkey != null) projection.add(pubkey.toHex()) + projection.add(currentUser.toHex()) + + val cursor = context.contentResolver.query( + uri, + projection.toList() as Array?, + null, null, null, + ) ?: return null + + return cursor.use { + if (it.getColumnIndex("rejected") > -1) return null + + if (it.moveToFirst()) { + val resultIndex = it.getColumnIndex(payload) + 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 + + SignerResult(result = result!!, event = Event.fromJson(event!!)) + } else { + null + } + } + } + + private suspend fun requestViaIntent( + type: String, + payload: String, + extras: Map = emptyMap() + ): String { + val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:$payload".toUri()).apply { + `package` = packageName + putExtra("type", type) + putExtra("current_user", currentUser.toHex()) + extras.forEach { (k, v) -> putExtra(k, v) } + } + + val result = launcher.launch(intent) + + if (result.resultCode != Activity.RESULT_OK) { + throw Exception("Signer returned error (resultCode=${result.resultCode})") + } + + val data = result.data ?: throw Exception("Signer returned no data") + + if (data.getBooleanExtra("rejected", false)) { + throw Exception("User rejected the request") + } + + return data.getStringExtra("result") ?: throw Exception("Signer returned no result") + } + + private suspend fun request( + type: String, + payload: String, + pubkey: PublicKey? = null, + currentUser: PublicKey? = null, + extras: Map = emptyMap() + ): String { + // Try silent Content Resolver first + queryContentResolver(type, payload, pubkey)?.let { return it.result } + + // Fall back to Intent + val allExtras = extras.toMutableMap().apply { + if (pubkey != null) put("pubkey", pubkey.toHex()) + if (currentUser != null) put("current_user", currentUser.toHex()) + } + + return requestViaIntent(type, payload, allExtras) + } + + override suspend fun getPublicKeyAsync(): PublicKey { + return currentUser + } + + override suspend fun signEventAsync(unsignedEvent: UnsignedEvent): Event? { + val eventJson = unsignedEvent.asJson() + + // Try Content Resolver first + val contentResult = queryContentResolver("sign_event", eventJson) + contentResult?.event?.let { return it } + + // Fall back to Intent + val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:$eventJson".toUri()).apply { + `package` = packageName + putExtra("type", "sign_event") + putExtra("current_user", currentUser.toHex()) + putExtra("id", unsignedEvent.id()?.toHex() ?: "") + } + + 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 signedEventJson = data.getStringExtra("event") + + return signedEventJson?.let { Event.fromJson(it) } + } + + override suspend fun nip04EncryptAsync(publicKey: PublicKey, content: String): String { + return request( + "nip04_encrypt", + payload = content, + pubkey = publicKey + ) + } + + override suspend fun nip04DecryptAsync(publicKey: PublicKey, encryptedContent: String): String { + return request( + "nip04_decrypt", + payload = encryptedContent, + pubkey = publicKey + ) + } + + override suspend fun nip44EncryptAsync(publicKey: PublicKey, content: String): String { + return request( + "nip44_encrypt", + payload = content, + pubkey = publicKey, + currentUser = currentUser + ) + } + + override suspend fun nip44DecryptAsync(publicKey: PublicKey, payload: String): String { + return request( + "nip44_decrypt", + payload = payload, + pubkey = publicKey, + currentUser = currentUser + ) + } +} \ No newline at end of file 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..c05f578 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/ExternalSignerLauncher.kt @@ -0,0 +1,34 @@ +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 + } + + fun unregister() { + launcher = null + pendingResult?.cancel() + pendingResult = null + } + + 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..ce3609f 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,14 @@ import su.reya.coop.coop.storage.SecretStore import kotlin.system.exitProcess class MainActivity : ComponentActivity() { + private 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 +32,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 +45,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/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..94391c0 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/ExternalSignerHandler.kt @@ -0,0 +1,24 @@ +package su.reya.coop + +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 + + 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? +} + +data class ExternalSignerResult( + val pubkey: String, + 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..4c0ea37 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/ExternalSignerProxy.kt @@ -0,0 +1,41 @@ +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 packageName: String, + 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..58592a5 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() @@ -374,6 +375,7 @@ class NostrViewModel( 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) @@ -381,6 +383,18 @@ class NostrViewModel( 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]) + + ExternalSignerProxy(handler, packageName, pubkey) + } + else -> throw IllegalArgumentException("Invalid secret format") } } -- 2.49.1 From 1638a003814c24abeb114d1537809f5d7190002f Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 9 Jun 2026 10:21:02 +0700 Subject: [PATCH 2/5] add connect with amber button --- .../su/reya/coop/AndroidExternalSigner.kt | 18 +- .../kotlin/su/reya/coop/ExternalSigner.kt | 166 ------------------ .../kotlin/su/reya/coop/MainActivity.kt | 5 + .../su/reya/coop/screens/OnboardingScreen.kt | 53 +++++- .../su/reya/coop/ExternalSignerHandler.kt | 24 ++- .../su/reya/coop/ExternalSignerProxy.kt | 1 - .../kotlin/su/reya/coop/NostrViewModel.kt | 88 +++++++--- 7 files changed, 153 insertions(+), 202 deletions(-) delete mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/ExternalSigner.kt diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/AndroidExternalSigner.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/AndroidExternalSigner.kt index 33d64fb..f7c0531 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/AndroidExternalSigner.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/AndroidExternalSigner.kt @@ -12,7 +12,6 @@ class AndroidExternalSigner( private val launcher: ExternalSignerLauncher, ) : ExternalSignerHandler { private var cachedPackageName: String? = null - private var cachedPublicKey: PublicKey? = null private data class ContentResolverResult( val result: String, @@ -26,9 +25,15 @@ class AndroidExternalSigner( currentUser: PublicKey? = null, ): ContentResolverResult? { val uri = "content://$cachedPackageName.${type.uppercase()}".toUri() + val projection = mutableListOf().apply { + add(payload) + add(pubkey?.toHex() ?: "") + add(currentUser?.toHex() ?: "") + } + val cursor = context.contentResolver.query( uri, - listOf(payload, pubkey, currentUser) as Array?, + projection.toTypedArray(), null, null, null, ) ?: return null @@ -79,6 +84,10 @@ class AndroidExternalSigner( 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") @@ -93,8 +102,9 @@ class AndroidExternalSigner( val pubkey = data.getStringExtra("result") ?: return null val packageName = data.getStringExtra("package") ?: return null + cachedPackageName = packageName - return ExternalSignerResult(pubkey, packageName) + return ExternalSignerResult(PublicKey.parse(pubkey), packageName) } override suspend fun signEvent(event: UnsignedEvent, currentUser: PublicKey): String? { @@ -148,4 +158,4 @@ class AndroidExternalSigner( ): String? { return request("nip44_decrypt", ciphertext, pubkey, currentUser) } -} \ No newline at end of file +} diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/ExternalSigner.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/ExternalSigner.kt deleted file mode 100644 index 4c08304..0000000 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/ExternalSigner.kt +++ /dev/null @@ -1,166 +0,0 @@ -package su.reya.coop - -import android.app.Activity -import android.content.Context -import android.content.Intent -import androidx.core.net.toUri -import rust.nostr.sdk.AsyncNostrSigner -import rust.nostr.sdk.Event -import rust.nostr.sdk.PublicKey -import rust.nostr.sdk.UnsignedEvent - -data class SignerResult( - val result: String, - val event: Event? = null, -) - -class ExternalSigner( - private val context: Context, - private val packageName: String, - private val currentUser: PublicKey, - private val launcher: ExternalSignerLauncher, -) : AsyncNostrSigner { - private fun queryContentResolver( - type: String, - payload: String, - pubkey: PublicKey? = null - ): SignerResult? { - val uri = "content://$packageName.${type.uppercase()}".toUri() - val projection = mutableListOf() - projection.add(payload) - if (pubkey != null) projection.add(pubkey.toHex()) - projection.add(currentUser.toHex()) - - val cursor = context.contentResolver.query( - uri, - projection.toList() as Array?, - null, null, null, - ) ?: return null - - return cursor.use { - if (it.getColumnIndex("rejected") > -1) return null - - if (it.moveToFirst()) { - val resultIndex = it.getColumnIndex(payload) - 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 - - SignerResult(result = result!!, event = Event.fromJson(event!!)) - } else { - null - } - } - } - - private suspend fun requestViaIntent( - type: String, - payload: String, - extras: Map = emptyMap() - ): String { - val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:$payload".toUri()).apply { - `package` = packageName - putExtra("type", type) - putExtra("current_user", currentUser.toHex()) - extras.forEach { (k, v) -> putExtra(k, v) } - } - - val result = launcher.launch(intent) - - if (result.resultCode != Activity.RESULT_OK) { - throw Exception("Signer returned error (resultCode=${result.resultCode})") - } - - val data = result.data ?: throw Exception("Signer returned no data") - - if (data.getBooleanExtra("rejected", false)) { - throw Exception("User rejected the request") - } - - return data.getStringExtra("result") ?: throw Exception("Signer returned no result") - } - - private suspend fun request( - type: String, - payload: String, - pubkey: PublicKey? = null, - currentUser: PublicKey? = null, - extras: Map = emptyMap() - ): String { - // Try silent Content Resolver first - queryContentResolver(type, payload, pubkey)?.let { return it.result } - - // Fall back to Intent - val allExtras = extras.toMutableMap().apply { - if (pubkey != null) put("pubkey", pubkey.toHex()) - if (currentUser != null) put("current_user", currentUser.toHex()) - } - - return requestViaIntent(type, payload, allExtras) - } - - override suspend fun getPublicKeyAsync(): PublicKey { - return currentUser - } - - override suspend fun signEventAsync(unsignedEvent: UnsignedEvent): Event? { - val eventJson = unsignedEvent.asJson() - - // Try Content Resolver first - val contentResult = queryContentResolver("sign_event", eventJson) - contentResult?.event?.let { return it } - - // Fall back to Intent - val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:$eventJson".toUri()).apply { - `package` = packageName - putExtra("type", "sign_event") - putExtra("current_user", currentUser.toHex()) - putExtra("id", unsignedEvent.id()?.toHex() ?: "") - } - - 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 signedEventJson = data.getStringExtra("event") - - return signedEventJson?.let { Event.fromJson(it) } - } - - override suspend fun nip04EncryptAsync(publicKey: PublicKey, content: String): String { - return request( - "nip04_encrypt", - payload = content, - pubkey = publicKey - ) - } - - override suspend fun nip04DecryptAsync(publicKey: PublicKey, encryptedContent: String): String { - return request( - "nip04_decrypt", - payload = encryptedContent, - pubkey = publicKey - ) - } - - override suspend fun nip44EncryptAsync(publicKey: PublicKey, content: String): String { - return request( - "nip44_encrypt", - payload = content, - pubkey = publicKey, - currentUser = currentUser - ) - } - - override suspend fun nip44DecryptAsync(publicKey: PublicKey, payload: String): String { - return request( - "nip44_decrypt", - payload = payload, - pubkey = publicKey, - currentUser = currentUser - ) - } -} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt index ce3609f..9e94a94 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -81,4 +81,9 @@ class MainActivity : ComponentActivity() { super.onNewIntent(intent) setIntent(intent) } + + override fun onDestroy() { + externalSignerLauncher.unregister() + super.onDestroy() + } } 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..17845be 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,8 +54,11 @@ 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() @@ -142,7 +154,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/shared/src/commonMain/kotlin/su/reya/coop/ExternalSignerHandler.kt b/shared/src/commonMain/kotlin/su/reya/coop/ExternalSignerHandler.kt index 94391c0..920fe64 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/ExternalSignerHandler.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/ExternalSignerHandler.kt @@ -1,5 +1,7 @@ package su.reya.coop +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json import rust.nostr.sdk.PublicKey import rust.nostr.sdk.UnsignedEvent @@ -9,7 +11,7 @@ import rust.nostr.sdk.UnsignedEvent */ 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? @@ -18,7 +20,25 @@ interface ExternalSignerHandler { 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: String, + 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 index 4c0ea37..deee922 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/ExternalSignerProxy.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/ExternalSignerProxy.kt @@ -7,7 +7,6 @@ import rust.nostr.sdk.UnsignedEvent class ExternalSignerProxy( private val handler: ExternalSignerHandler, - private val packageName: String, private val currentUser: PublicKey, ) : AsyncNostrSigner { override suspend fun getPublicKeyAsync(): PublicKey { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 58592a5..f55652f 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -372,33 +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) - } - - 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]) - - ExternalSignerProxy(handler, packageName, pubkey) - } - - else -> throw IllegalArgumentException("Invalid secret format") - } - } - private suspend fun blossomUpload(file: ByteArray, contentType: String): String? { try { // Upload picture to Blossom @@ -471,6 +444,34 @@ class NostrViewModel( } } + 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) @@ -498,6 +499,39 @@ class NostrViewModel( } } + suspend fun connectExternalSigner() { + val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available") + _isLoggedIn.value = true + try { + val permissions = SignerPermissions.toJson( + listOf( + SignerPermissions.signEvent(), + SignerPermissions.nip04Encrypt(), + SignerPermissions.nip04Decrypt(), + 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()}") + } catch (e: Exception) { + showError("Error: ${e.message}") + } finally { + _signerRequired.value = false + _isLoggedIn.value = false + } + } + + fun isExternalSignerAvailable(): Boolean { + return externalSignerHandler?.isAvailable() == true + } + suspend fun useDefaultMsgRelayList() { try { val defaultRelays = nostr.getDefaultMsgRelayList() -- 2.49.1 From 81ea442eac566ff967e43ad341e2cb18aa347b4a Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 9 Jun 2026 10:28:52 +0700 Subject: [PATCH 3/5] fix --- .../su/reya/coop/screens/ImportScreen.kt | 10 +++++----- .../su/reya/coop/screens/NewIdentityScreen.kt | 4 ++-- .../su/reya/coop/screens/OnboardingScreen.kt | 3 ++- .../reya/coop/screens/UpdateProfileScreen.kt | 2 +- .../kotlin/su/reya/coop/NostrViewModel.kt | 20 +++++++++---------- 5 files changed, 20 insertions(+), 19 deletions(-) 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 17845be..ca55709 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt @@ -160,9 +160,10 @@ fun OnboardingScreen() { if (viewModel.isExternalSignerAvailable()) { try { viewModel.connectExternalSigner() - navigator.navigate(Screen.Home) } catch (e: Exception) { e.message?.let { snackbarHostState.showSnackbar(it) } + } finally { + navigator.navigate(Screen.Home) } } else { val result = snackbarHostState.showSnackbar( 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/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index f55652f..0c40fe3 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -53,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() @@ -407,7 +407,7 @@ 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) @@ -416,7 +416,7 @@ class NostrViewModel( } catch (e: Exception) { showError("Error: ${e.message}") } finally { - _isLoggedIn.value = false + _isBusy.value = false } } @@ -426,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() @@ -439,7 +439,7 @@ class NostrViewModel( showError("Error: ${e.message}") } finally { secretStore.set("user_signer", secret) - _isLoggedIn.value = false + _isBusy.value = false _signerRequired.value = false } } @@ -486,7 +486,7 @@ class NostrViewModel( } suspend fun importIdentity(secret: String) { - _isLoggedIn.value = true + _isBusy.value = true try { val signer = createSigner(secret) nostr.setSigner(signer) @@ -495,13 +495,13 @@ class NostrViewModel( } finally { secretStore.set("user_signer", secret) _signerRequired.value = false - _isLoggedIn.value = false + _isBusy.value = false } } suspend fun connectExternalSigner() { val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available") - _isLoggedIn.value = true + _isBusy.value = true try { val permissions = SignerPermissions.toJson( listOf( @@ -524,7 +524,7 @@ class NostrViewModel( showError("Error: ${e.message}") } finally { _signerRequired.value = false - _isLoggedIn.value = false + _isBusy.value = false } } -- 2.49.1 From 483a84c616e51696a9812ada3d76171bc8508761 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 9 Jun 2026 14:27:21 +0700 Subject: [PATCH 4/5] fix --- .../su/reya/coop/ExternalSignerLauncher.kt | 6 ----- .../kotlin/su/reya/coop/MainActivity.kt | 9 +++---- .../su/reya/coop/screens/OnboardingScreen.kt | 3 +-- .../kotlin/su/reya/coop/NostrViewModel.kt | 27 ++++++++++--------- 4 files changed, 19 insertions(+), 26 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/ExternalSignerLauncher.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/ExternalSignerLauncher.kt index c05f578..cbfb686 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/ExternalSignerLauncher.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/ExternalSignerLauncher.kt @@ -13,12 +13,6 @@ class ExternalSignerLauncher { this.launcher = launcher } - fun unregister() { - launcher = null - pendingResult?.cancel() - pendingResult = null - } - suspend fun launch(intent: Intent): ActivityResult { val deferred = CompletableDeferred() pendingResult = deferred diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt index 9e94a94..0fb8442 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -17,7 +17,9 @@ import su.reya.coop.coop.storage.SecretStore import kotlin.system.exitProcess class MainActivity : ComponentActivity() { - private val externalSignerLauncher = ExternalSignerLauncher() + companion object { + val externalSignerLauncher = ExternalSignerLauncher() + } private val viewModel: NostrViewModel by viewModels { object : ViewModelProvider.Factory { @@ -81,9 +83,4 @@ class MainActivity : ComponentActivity() { super.onNewIntent(intent) setIntent(intent) } - - override fun onDestroy() { - externalSignerLauncher.unregister() - super.onDestroy() - } } 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 ca55709..17845be 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt @@ -160,10 +160,9 @@ fun OnboardingScreen() { if (viewModel.isExternalSignerAvailable()) { try { viewModel.connectExternalSigner() + navigator.navigate(Screen.Home) } catch (e: Exception) { e.message?.let { snackbarHostState.showSnackbar(it) } - } finally { - navigator.navigate(Screen.Home) } } else { val result = snackbarHostState.showSnackbar( diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 0c40fe3..c7ae978 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -413,10 +413,10 @@ class NostrViewModel( 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 { - _isBusy.value = false } } @@ -435,12 +435,13 @@ class NostrViewModel( val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") } // Create identity nostr.createIdentity(keys = keys, name = name, bio, picture = avatarUrl) - } catch (e: Exception) { - showError("Error: ${e.message}") - } finally { + // 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}") } } @@ -489,13 +490,15 @@ class NostrViewModel( _isBusy.value = true try { val signer = createSigner(secret) + // Update signer nostr.setSigner(signer) - } catch (e: Exception) { - showError("Error: ${e.message}") - } finally { + // 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}") } } @@ -512,19 +515,19 @@ class NostrViewModel( 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()}") - } catch (e: Exception) { - showError("Error: ${e.message}") - } finally { + // Update local states _signerRequired.value = false _isBusy.value = false + } catch (e: Exception) { + throw Exception("Notice: ${e.message}") } } -- 2.49.1 From eadf49b39d2e73dbaae419cdb28b0210b4dbe4d3 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 9 Jun 2026 14:53:19 +0700 Subject: [PATCH 5/5] improve --- .../su/reya/coop/AndroidExternalSigner.kt | 38 ++++++------------- .../su/reya/coop/screens/OnboardingScreen.kt | 1 + .../kotlin/su/reya/coop/NostrViewModel.kt | 11 ++++-- 3 files changed, 21 insertions(+), 29 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/AndroidExternalSigner.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/AndroidExternalSigner.kt index f7c0531..e7e3122 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/AndroidExternalSigner.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/AndroidExternalSigner.kt @@ -56,6 +56,8 @@ class AndroidExternalSigner( 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 { @@ -68,6 +70,7 @@ class AndroidExternalSigner( 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) @@ -76,7 +79,7 @@ class AndroidExternalSigner( val data = result.data ?: return null if (data.getBooleanExtra("rejected", false)) return null - return data.getStringExtra("result") + return data.getStringExtra(resultKey) } override fun isAvailable(): Boolean { @@ -108,31 +111,14 @@ class AndroidExternalSigner( } override suspend fun signEvent(event: UnsignedEvent, currentUser: PublicKey): String? { - // Try Content Resolver first - queryContentResolver( - "SIGN_EVENT", - event.asJson(), - null, - currentUser - )?.let { result -> - if (result.event != null) return result.event - } - - // Fall back to Intent - val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:${event.asJson()}".toUri()).apply { - `package` = cachedPackageName - putExtra("type", "sign_event") - putExtra("current_user", currentUser.toHex()) - if (event.id() != null) putExtra("id", event.id()!!.toHex()) - } - - 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("event") + 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? { 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 17845be..cb0ca6e 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt @@ -62,6 +62,7 @@ fun OnboardingScreen() { 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 diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index c7ae978..0088e66 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -508,9 +508,14 @@ class NostrViewModel( try { val permissions = SignerPermissions.toJson( listOf( - SignerPermissions.signEvent(), - SignerPermissions.nip04Encrypt(), - SignerPermissions.nip04Decrypt(), + 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(), ) -- 2.49.1