add connect with amber button

This commit is contained in:
2026-06-09 10:21:02 +07:00
parent a46063d8c4
commit 1638a00381
7 changed files with 153 additions and 202 deletions

View File

@@ -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? {
@@ -148,4 +158,4 @@ class AndroidExternalSigner(
): String? {
return request("nip44_decrypt", ciphertext, pubkey, currentUser)
}
}
}

View File

@@ -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
)
}
}

View File

@@ -81,4 +81,9 @@ class MainActivity : ComponentActivity() {
super.onNewIntent(intent)
setIntent(intent)
}
override fun onDestroy() {
externalSignerLauncher.unregister()
super.onDestroy()
}
}

View File

@@ -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()