add external signer

This commit is contained in:
2026-06-08 10:16:38 +07:00
parent 50b7f7a3f3
commit 5554421762
5 changed files with 164 additions and 31 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

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