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 @@
+
+
+
+
+
+
+
+
Unit)? = null
+
+ private val launcher: ActivityResultLauncher =
+ activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ val intent = result.data
+ val result = intent?.getStringExtra("signature")
+ ?: intent?.getStringExtra("public_key")
+ ?: intent?.getStringExtra("content")
+ ?: intent?.dataString
+
+ callback?.invoke(result)
+ callback = null
+ }
+
+ override suspend fun launch(
+ content: String,
+ type: String,
+ pubkey: String?,
+ id: String?
+ ): String? =
+ suspendCancellableCoroutine { continuation ->
+ callback = { continuation.resume(it) }
+
+ val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:$content".toUri())
+ intent.putExtra("type", type)
+ pubkey?.let { intent.putExtra("pubkey", it) }
+ id?.let { intent.putExtra("id", it) }
+
+ try {
+ launcher.launch(intent)
+ } catch (e: Exception) {
+ callback?.invoke(null)
+ }
+ }
+}
\ 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 0e51ee7..27bba86 100644
--- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt
@@ -2,6 +2,8 @@ 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
@@ -14,50 +16,28 @@ import su.reya.coop.coop.storage.SecretStore
import kotlin.system.exitProcess
class MainActivity : ComponentActivity() {
- 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
- }
- }
- }
+ private lateinit var externalSignerLauncher: AndroidExternalSignerLauncher
override fun onCreate(savedInstanceState: Bundle?) {
- Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
- throwable.printStackTrace()
- android.util.Log.e(
- "CoopCrash",
- "Uncaught exception in thread ${thread.name}",
- throwable
- )
-
- // Start the Crash Activity
- val intent = Intent(this, CrashActivity::class.java).apply {
- putExtra("error", throwable.stackTraceToString())
- addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
- }
- startActivity(intent)
-
- // Exit
- android.os.Process.killProcess(android.os.Process.myPid())
- exitProcess(1)
- }
-
val splashScreen = installSplashScreen()
+ setupCrashHandler()
enableEdgeToEdge()
super.onCreate(savedInstanceState)
- val serviceIntent = Intent(this, NostrForegroundService::class.java)
- startForegroundService(serviceIntent)
+ // Initialize the nostr service and external signer
+ setupExternalSigner()
+ startNostrService()
+
+ // Initialize the ViewModel
+ val viewModel: NostrViewModel by viewModels { NostrViewModelFactory(this) }
// Keep the splash screen visible until the signer check is complete
splashScreen.setKeepOnScreenCondition {
viewModel.signerRequired.value == null
}
- // Bind the lifecycle of the ViewModel to the Activity's lifecycle'
+ // Bind the lifecycle of the ViewModel to the Activity's lifecycle
viewModel.bindLifecycle(ProcessLifecycleOwner.get().lifecycle)
setContent {
@@ -65,8 +45,44 @@ class MainActivity : ComponentActivity() {
}
}
+ private fun setupExternalSigner() {
+ val launcher = AndroidExternalSignerLauncher(this)
+ ExternalSignerLauncherProvider.launcher = launcher
+ }
+
+ private fun startNostrService() {
+ val intent = Intent(this, NostrForegroundService::class.java)
+ startForegroundService(intent)
+ }
+
+ private fun setupCrashHandler() {
+ Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
+ throwable.printStackTrace()
+
+ Log.e("CoopCrash", "Uncaught exception in thread ${thread.name}", throwable)
+
+ val intent = Intent(this, CrashActivity::class.java).apply {
+ putExtra("error", throwable.stackTraceToString())
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
+ }
+ startActivity(intent)
+
+ Process.killProcess(Process.myPid())
+ exitProcess(1)
+ }
+ }
+
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
}
}
+
+class NostrViewModelFactory(
+ private val activity: ComponentActivity
+) : ViewModelProvider.Factory {
+ override fun create(modelClass: Class): T {
+ val secretStore = SecretStore(activity)
+ return NostrViewModel(NostrManager.instance, secretStore) as T
+ }
+}
diff --git a/shared/src/commonMain/kotlin/su/reya/coop/ExternalSigner.kt b/shared/src/commonMain/kotlin/su/reya/coop/ExternalSigner.kt
new file mode 100644
index 0000000..f561043
--- /dev/null
+++ b/shared/src/commonMain/kotlin/su/reya/coop/ExternalSigner.kt
@@ -0,0 +1,56 @@
+package su.reya.coop
+
+import rust.nostr.sdk.AsyncNostrSigner
+import rust.nostr.sdk.Event
+import rust.nostr.sdk.PublicKey
+import rust.nostr.sdk.UnsignedEvent
+
+interface ExternalSignerLauncher {
+ suspend fun launch(
+ content: String,
+ type: String,
+ pubkey: String? = null,
+ id: String? = null
+ ): String?
+}
+
+object ExternalSignerLauncherProvider {
+ var launcher: ExternalSignerLauncher? = null
+}
+
+/**
+ * A cross-platform implementation of AsyncNostrSigner that delegates
+ * to a platform-specific launcher (NIP-55 on Android).
+ */
+class ExternalSigner(private val launcher: ExternalSignerLauncher) : AsyncNostrSigner {
+ override suspend fun getPublicKeyAsync(): PublicKey? {
+ val result = launcher.launch("", "get_public_key")
+ return result?.let { PublicKey.parse(it) }
+ }
+
+ override suspend fun signEventAsync(unsignedEvent: UnsignedEvent): Event? {
+ val result =
+ launcher.launch(unsignedEvent.asJson(), "sign_event", id = unsignedEvent.id()?.toHex())
+ return result?.let { Event.fromJson(it) }
+ }
+
+ override suspend fun nip04EncryptAsync(publicKey: PublicKey, content: String): String {
+ return launcher.launch(content, "nip04_encrypt", publicKey.toHex())
+ ?: throw Exception("Encryption failed")
+ }
+
+ override suspend fun nip04DecryptAsync(publicKey: PublicKey, encryptedContent: String): String {
+ return launcher.launch(encryptedContent, "nip04_decrypt", publicKey.toHex())
+ ?: throw Exception("Decryption failed")
+ }
+
+ override suspend fun nip44EncryptAsync(publicKey: PublicKey, content: String): String {
+ return launcher.launch(content, "nip44_encrypt", publicKey.toHex())
+ ?: throw Exception("Encryption failed")
+ }
+
+ override suspend fun nip44DecryptAsync(publicKey: PublicKey, payload: String): String {
+ return launcher.launch(payload, "nip44_decrypt", publicKey.toHex())
+ ?: throw Exception("Decryption failed")
+ }
+}
\ 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 edee2a8..c132ec1 100644
--- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt
+++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt
@@ -372,6 +372,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)
@@ -379,6 +380,12 @@ class NostrViewModel(
NostrConnect(uri = bunker, appKeys, timeout, null)
}
+ secret == "external" -> {
+ val launcher = ExternalSignerLauncherProvider.launcher
+ ?: throw IllegalStateException("External signer not supported on this platform")
+ ExternalSigner(launcher)
+ }
+
else -> throw IllegalArgumentException("Invalid secret format")
}
}