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")
}
}