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/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt
index 0c53b94..652cfca 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,7 @@
package su.reya.coop.screens
+import android.content.Context
+import android.content.Intent
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -13,13 +15,16 @@ 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.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,38 @@ fun OnboardingScreen() {
)
}
Spacer(modifier = Modifier.size(8.dp))
- OutlinedButton(
+ FilledTonalButton(
+ onClick = {
+ scope.launch {
+ if (isExternalSignerInstalled(context)) {
+ viewModel.importIdentity("external")
+ } else {
+ val result = snackbarHostState.showSnackbar(
+ message = "External signer not installed. Please install Amber or alternatives.",
+ actionLabel = "Install"
+ )
+
+ if (result == SnackbarResult.ActionPerformed) {
+ val intent = Intent(
+ Intent.ACTION_VIEW,
+ "https://zapstore.dev/apps/com.greenart7c3.nostrsigner".toUri()
+ )
+ context.startActivity(intent)
+ }
+ }
+ }
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .size(ButtonDefaults.MediumContainerHeight),
+ ) {
+ Text(
+ text = "Connect via Amber",
+ style = MaterialTheme.typography.titleMedium,
+ )
+ }
+ Spacer(modifier = Modifier.size(8.dp))
+ TextButton(
onClick = { navigator.navigate(Screen.Import) },
modifier = Modifier
.fillMaxWidth()
@@ -216,3 +259,8 @@ fun LogoRepeatingBackground(
}
}
}
+
+fun isExternalSignerInstalled(context: Context): Boolean {
+ val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:".toUri())
+ return context.packageManager.queryIntentActivities(intent, 0).isNotEmpty()
+}
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..961bd6e 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")
}
}
@@ -476,10 +483,10 @@ class NostrViewModel(
try {
val signer = createSigner(secret)
nostr.setSigner(signer)
- secretStore.set("user_signer", secret)
} catch (e: Exception) {
showError("Error: ${e.message}")
} finally {
+ secretStore.set("user_signer", secret)
_signerRequired.value = false
_isLoggedIn.value = false
}