feat: Add support for NIP-55 #18
@@ -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<String?>().apply {
|
||||
add(payload)
|
||||
add(pubkey?.toHex() ?: "")
|
||||
add(currentUser?.toHex() ?: "")
|
||||
}
|
||||
|
||||
val cursor = context.contentResolver.query(
|
||||
uri,
|
||||
listOf(payload, pubkey, currentUser) as Array<out String?>?,
|
||||
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? {
|
||||
|
||||
@@ -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<String>()
|
||||
projection.add(payload)
|
||||
if (pubkey != null) projection.add(pubkey.toHex())
|
||||
projection.add(currentUser.toHex())
|
||||
|
||||
val cursor = context.contentResolver.query(
|
||||
uri,
|
||||
projection.toList() as Array<out String?>?,
|
||||
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<String, String> = 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<String, String> = 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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -81,4 +81,9 @@ class MainActivity : ComponentActivity() {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
externalSignerLauncher.unregister()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<SignerPermission>): String {
|
||||
return Json.encodeToString(permissions)
|
||||
}
|
||||
}
|
||||
|
||||
data class ExternalSignerResult(
|
||||
val pubkey: String,
|
||||
val pubkey: PublicKey,
|
||||
val packageName: String,
|
||||
)
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user