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 }