diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/AndroidExternalSigner.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/AndroidExternalSigner.kt index 33d64fb..f7c0531 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/AndroidExternalSigner.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/AndroidExternalSigner.kt @@ -12,7 +12,6 @@ class AndroidExternalSigner( private val launcher: ExternalSignerLauncher, ) : ExternalSignerHandler { private var cachedPackageName: String? = null - private var cachedPublicKey: PublicKey? = null private data class ContentResolverResult( val result: String, @@ -26,9 +25,15 @@ class AndroidExternalSigner( currentUser: PublicKey? = null, ): ContentResolverResult? { val uri = "content://$cachedPackageName.${type.uppercase()}".toUri() + val projection = mutableListOf().apply { + add(payload) + add(pubkey?.toHex() ?: "") + add(currentUser?.toHex() ?: "") + } + val cursor = context.contentResolver.query( uri, - listOf(payload, pubkey, currentUser) as Array?, + projection.toTypedArray(), null, null, null, ) ?: return null @@ -79,6 +84,10 @@ class AndroidExternalSigner( return context.packageManager.queryIntentActivities(intent, 0).isNotEmpty() } + override fun setPackageName(packageName: String) { + cachedPackageName = packageName + } + override suspend fun getPublicKey(permissions: String?): ExternalSignerResult? { val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:".toUri()).apply { putExtra("type", "get_public_key") @@ -93,8 +102,9 @@ class AndroidExternalSigner( val pubkey = data.getStringExtra("result") ?: return null val packageName = data.getStringExtra("package") ?: return null + cachedPackageName = packageName - return ExternalSignerResult(pubkey, packageName) + return ExternalSignerResult(PublicKey.parse(pubkey), packageName) } override suspend fun signEvent(event: UnsignedEvent, currentUser: PublicKey): String? { @@ -148,4 +158,4 @@ class AndroidExternalSigner( ): 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 deleted file mode 100644 index 4c08304..0000000 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/ExternalSigner.kt +++ /dev/null @@ -1,166 +0,0 @@ -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/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt index ce3609f..9e94a94 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -81,4 +81,9 @@ class MainActivity : ComponentActivity() { super.onNewIntent(intent) setIntent(intent) } + + override fun onDestroy() { + externalSignerLauncher.unregister() + super.onDestroy() + } } 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..17845be 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,6 @@ package su.reya.coop.screens +import android.content.Intent import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -13,13 +14,17 @@ 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.SnackbarDuration 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,44 @@ fun OnboardingScreen() { ) } Spacer(modifier = Modifier.size(8.dp)) - OutlinedButton( + FilledTonalButton( + onClick = { + scope.launch { + if (viewModel.isExternalSignerAvailable()) { + try { + viewModel.connectExternalSigner() + navigator.navigate(Screen.Home) + } catch (e: Exception) { + e.message?.let { snackbarHostState.showSnackbar(it) } + } + } else { + val result = snackbarHostState.showSnackbar( + message = "External signer not installed. Please install Amber or alternatives.", + actionLabel = "Install", + withDismissAction = true, + duration = SnackbarDuration.Long + ) + if (result == SnackbarResult.ActionPerformed) { + val intent = Intent( + Intent.ACTION_VIEW, + "https://zapstore.dev/apps/com.greenart7c3.nostrsigner".toUri() + ) + context.startActivity(intent) + } + } + } + }, + modifier = Modifier + .fillMaxWidth() + .height(ButtonDefaults.MediumContainerHeight), + ) { + Text( + text = "Connect with Amber", + style = MaterialTheme.typography.titleMedium, + ) + } + Spacer(modifier = Modifier.size(8.dp)) + TextButton( onClick = { navigator.navigate(Screen.Import) }, modifier = Modifier .fillMaxWidth() diff --git a/shared/src/commonMain/kotlin/su/reya/coop/ExternalSignerHandler.kt b/shared/src/commonMain/kotlin/su/reya/coop/ExternalSignerHandler.kt index 94391c0..920fe64 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/ExternalSignerHandler.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/ExternalSignerHandler.kt @@ -1,5 +1,7 @@ package su.reya.coop +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json import rust.nostr.sdk.PublicKey import rust.nostr.sdk.UnsignedEvent @@ -9,7 +11,7 @@ import rust.nostr.sdk.UnsignedEvent */ interface ExternalSignerHandler { fun isAvailable(): Boolean - + fun setPackageName(packageName: String) suspend fun getPublicKey(permissions: String? = null): ExternalSignerResult? suspend fun signEvent(event: UnsignedEvent, currentUser: PublicKey): String? suspend fun nip04Encrypt(plaintext: String, pubkey: PublicKey): String? @@ -18,7 +20,25 @@ interface ExternalSignerHandler { suspend fun nip44Decrypt(ciphertext: String, pubkey: PublicKey, currentUser: PublicKey): String? } +@Serializable +data class SignerPermission( + val type: String, + val kind: Int? = null, +) + +object SignerPermissions { + fun signEvent(kind: Int? = null) = SignerPermission(type = "sign_event", kind = kind) + fun nip04Encrypt() = SignerPermission(type = "nip04_encrypt") + fun nip04Decrypt() = SignerPermission(type = "nip04_decrypt") + fun nip44Encrypt() = SignerPermission(type = "nip44_encrypt") + fun nip44Decrypt() = SignerPermission(type = "nip44_decrypt") + + fun toJson(permissions: List): String { + return Json.encodeToString(permissions) + } +} + data class ExternalSignerResult( - val pubkey: String, + val pubkey: PublicKey, 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 index 4c0ea37..deee922 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/ExternalSignerProxy.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/ExternalSignerProxy.kt @@ -7,7 +7,6 @@ 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 { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 58592a5..f55652f 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -372,33 +372,6 @@ class NostrViewModel( return keys } - 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) - val timeout = 50.seconds // or Duration.parse("50s") - 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") - } - } - private suspend fun blossomUpload(file: ByteArray, contentType: String): String? { try { // Upload picture to Blossom @@ -471,6 +444,34 @@ 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) + val timeout = 50.seconds // or Duration.parse("50s") + 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]) + + handler.setPackageName(packageName) + ExternalSignerProxy(handler, pubkey) + } + + else -> throw IllegalArgumentException("Invalid secret format") + } + } + suspend fun verifyIdentity(secret: String): PublicKey? { try { val signer = createSigner(secret) @@ -498,6 +499,39 @@ class NostrViewModel( } } + suspend fun connectExternalSigner() { + val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available") + _isLoggedIn.value = true + try { + val permissions = SignerPermissions.toJson( + listOf( + SignerPermissions.signEvent(), + SignerPermissions.nip04Encrypt(), + SignerPermissions.nip04Decrypt(), + SignerPermissions.nip44Encrypt(), + SignerPermissions.nip44Decrypt(), + ) + ) + val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected") + val signer = ExternalSignerProxy(handler, result.pubkey) + + // Update signer + nostr.setSigner(signer) + + // Store the signer in the secret storage + secretStore.set("user_signer", "nip55://${result.packageName}/${result.pubkey.toHex()}") + } catch (e: Exception) { + showError("Error: ${e.message}") + } finally { + _signerRequired.value = false + _isLoggedIn.value = false + } + } + + fun isExternalSignerAvailable(): Boolean { + return externalSignerHandler?.isAvailable() == true + } + suspend fun useDefaultMsgRelayList() { try { val defaultRelays = nostr.getDefaultMsgRelayList()