wip: add support for NIP-55 #17

Closed
reya wants to merge 2 commits from feat/nip55 into master
6 changed files with 215 additions and 34 deletions

View File

@@ -11,6 +11,14 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="nostrsigner" />
</intent>
</queries>
<application <application
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"

View File

@@ -0,0 +1,46 @@
package su.reya.coop
import android.content.Intent
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.net.toUri
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
class AndroidExternalSignerLauncher(activity: ComponentActivity) : ExternalSignerLauncher {
private var callback: ((String?) -> Unit)? = null
private val launcher: ActivityResultLauncher<Intent> =
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)
}
}
}

View File

@@ -2,6 +2,8 @@ package su.reya.coop
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.os.Process
import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
@@ -14,50 +16,28 @@ import su.reya.coop.coop.storage.SecretStore
import kotlin.system.exitProcess import kotlin.system.exitProcess
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val viewModel: NostrViewModel by viewModels { private lateinit var externalSignerLauncher: AndroidExternalSignerLauncher
object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val secretStore = SecretStore(this@MainActivity)
return NostrViewModel(NostrManager.instance, secretStore) as T
}
}
}
override fun onCreate(savedInstanceState: Bundle?) { 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() val splashScreen = installSplashScreen()
setupCrashHandler()
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val serviceIntent = Intent(this, NostrForegroundService::class.java) // Initialize the nostr service and external signer
startForegroundService(serviceIntent) setupExternalSigner()
startNostrService()
// Initialize the ViewModel
val viewModel: NostrViewModel by viewModels { NostrViewModelFactory(this) }
// Keep the splash screen visible until the signer check is complete // Keep the splash screen visible until the signer check is complete
splashScreen.setKeepOnScreenCondition { splashScreen.setKeepOnScreenCondition {
viewModel.signerRequired.value == null 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) viewModel.bindLifecycle(ProcessLifecycleOwner.get().lifecycle)
setContent { 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) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent) setIntent(intent)
} }
} }
class NostrViewModelFactory(
private val activity: ComponentActivity
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val secretStore = SecretStore(activity)
return NostrViewModel(NostrManager.instance, secretStore) as T
}
}

View File

@@ -1,5 +1,7 @@
package su.reya.coop.screens package su.reya.coop.screens
import android.content.Context
import android.content.Intent
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size 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.rotate
import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles 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.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.coop import coop.composeapp.generated.resources.coop
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.shared.getExpressiveFontFamily import su.reya.coop.shared.getExpressiveFontFamily
@@ -45,8 +54,11 @@ import su.reya.coop.shared.getExpressiveFontFamily
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun OnboardingScreen() { fun OnboardingScreen() {
val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val viewModel = LocalNostrViewModel.current
val scope = rememberCoroutineScope()
val logoPainter = painterResource(Res.drawable.coop) val logoPainter = painterResource(Res.drawable.coop)
val expressiveFont = getExpressiveFontFamily() val expressiveFont = getExpressiveFontFamily()
@@ -142,7 +154,38 @@ fun OnboardingScreen() {
) )
} }
Spacer(modifier = Modifier.size(8.dp)) 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) }, onClick = { navigator.navigate(Screen.Import) },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .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()
}

View File

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

View File

@@ -372,6 +372,7 @@ class NostrViewModel(
private suspend fun createSigner(secret: String): AsyncNostrSigner { private suspend fun createSigner(secret: String): AsyncNostrSigner {
return when { return when {
secret.startsWith("nsec1") -> Keys.parse(secret) secret.startsWith("nsec1") -> Keys.parse(secret)
secret.startsWith("bunker://") -> { secret.startsWith("bunker://") -> {
val appKeys = getOrInitAppKeys() val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret) val bunker = NostrConnectUri.parse(secret)
@@ -379,6 +380,12 @@ class NostrViewModel(
NostrConnect(uri = bunker, appKeys, timeout, null) 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") else -> throw IllegalArgumentException("Invalid secret format")
} }
} }
@@ -476,10 +483,10 @@ class NostrViewModel(
try { try {
val signer = createSigner(secret) val signer = createSigner(secret)
nostr.setSigner(signer) nostr.setSigner(signer)
secretStore.set("user_signer", secret)
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") showError("Error: ${e.message}")
} finally { } finally {
secretStore.set("user_signer", secret)
_signerRequired.value = false _signerRequired.value = false
_isLoggedIn.value = false _isLoggedIn.value = false
} }