From 5554421762ee09abc909931145aab1324e0cce2e Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 8 Jun 2026 10:16:38 +0700 Subject: [PATCH] add external signer --- .../src/androidMain/AndroidManifest.xml | 8 ++ .../kotlin/su/reya/coop/ExternalSigner.kt | 46 +++++++++++ .../kotlin/su/reya/coop/MainActivity.kt | 78 +++++++++++-------- .../kotlin/su/reya/coop/ExternalSigner.kt | 56 +++++++++++++ .../kotlin/su/reya/coop/NostrViewModel.kt | 7 ++ 5 files changed, 164 insertions(+), 31 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/ExternalSigner.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/ExternalSigner.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 @@ + + + + + + + + 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") } }