wip: add support for NIP-55 #17
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
56
shared/src/commonMain/kotlin/su/reya/coop/ExternalSigner.kt
Normal file
56
shared/src/commonMain/kotlin/su/reya/coop/ExternalSigner.kt
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user