4 Commits

Author SHA1 Message Date
f7d2866517 move msg relay sheet to home screen 2026-06-10 15:26:20 +07:00
a759ad48e4 chore: bump version 2026-06-09 14:55:47 +07:00
0d6b92b0c7 feat: Add support for NIP-55 (#18)
Reviewed-on: #18
2026-06-09 07:53:48 +00:00
6a69d3a5b2 chore: update nostr sdk 2026-06-08 16:40:56 +07:00
20 changed files with 569 additions and 215 deletions

View File

@@ -24,7 +24,7 @@ kotlin {
implementation(libs.jetbrains.navigation3.ui)
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
implementation(libs.androidx.core.splashscreen)
implementation("su.reya:nostr-sdk-kmp:0.2.3")
implementation("su.reya:nostr-sdk-kmp:0.2.6")
implementation("io.coil-kt.coil3:coil-compose:3.4.0")
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0")
@@ -69,7 +69,7 @@ android {
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1
versionName = "0.1.7"
versionName = "0.1.8"
}
packaging {
resources {

View File

@@ -11,6 +11,14 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<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
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"

View File

@@ -0,0 +1,147 @@
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.PublicKey
import rust.nostr.sdk.UnsignedEvent
class AndroidExternalSigner(
private val context: Context,
private val launcher: ExternalSignerLauncher,
) : ExternalSignerHandler {
private var cachedPackageName: String? = null
private data class ContentResolverResult(
val result: String,
val event: String? = null,
)
private fun queryContentResolver(
type: String,
payload: String,
pubkey: PublicKey? = null,
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,
projection.toTypedArray(),
null, null, null,
) ?: return null
return cursor.use {
if (it.getColumnIndex("rejected") > -1) return null
if (it.moveToFirst()) {
val resultIndex = it.getColumnIndex("result")
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
ContentResolverResult(result = result!!, event = event)
} else null
}
}
private suspend fun request(
type: String,
payload: String,
pubkey: PublicKey? = null,
currentUser: PublicKey? = null,
resultKey: String = "result",
extras: Map<String, String> = emptyMap(),
): String? {
// Try Content Resolver first
queryContentResolver(type, payload, pubkey, currentUser)?.let {
return it.result
}
// Fall back to Intent
val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:$payload".toUri()).apply {
`package` = cachedPackageName
putExtra("type", type)
if (pubkey != null) putExtra("pubkey", pubkey.toHex())
if (currentUser != null) putExtra("current_user", currentUser.toHex())
extras.forEach { (k, v) -> putExtra(k, v) }
}
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
return data.getStringExtra(resultKey)
}
override fun isAvailable(): Boolean {
val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:".toUri())
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")
if (permissions != null) putExtra("permissions", permissions)
}
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 pubkey = data.getStringExtra("result") ?: return null
val packageName = data.getStringExtra("package") ?: return null
cachedPackageName = packageName
return ExternalSignerResult(PublicKey.parse(pubkey), packageName)
}
override suspend fun signEvent(event: UnsignedEvent, currentUser: PublicKey): String? {
val extras = event.id()?.let { mapOf("id" to it.toHex()) } ?: emptyMap()
return request(
type = "sign_event",
payload = event.asJson(),
currentUser = currentUser,
resultKey = "event",
extras = extras,
)
}
override suspend fun nip04Encrypt(plaintext: String, pubkey: PublicKey): String? {
return request("nip04_encrypt", plaintext, pubkey)
}
override suspend fun nip04Decrypt(ciphertext: String, pubkey: PublicKey): String? {
return request("nip04_decrypt", ciphertext, pubkey)
}
override suspend fun nip44Encrypt(
plaintext: String,
pubkey: PublicKey,
currentUser: PublicKey
): String? {
return request("nip44_encrypt", plaintext, pubkey, currentUser)
}
override suspend fun nip44Decrypt(
ciphertext: String,
pubkey: PublicKey,
currentUser: PublicKey
): String? {
return request("nip44_decrypt", ciphertext, pubkey, currentUser)
}
}

View File

@@ -6,23 +6,11 @@ import android.os.Build
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.MotionScheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
@@ -38,13 +26,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.util.Consumer
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
@@ -54,7 +36,6 @@ import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import kotlinx.coroutines.launch
import su.reya.coop.screens.ChatScreen
import su.reya.coop.screens.HomeScreen
import su.reya.coop.screens.ImportScreen
@@ -95,7 +76,6 @@ fun App(viewModel: NostrViewModel) {
val qrScanResult = remember { QrScanResult() }
val signerRequired by viewModel.signerRequired.collectAsStateWithLifecycle()
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsStateWithLifecycle()
// Snackbar
val snackbarHostState = remember { SnackbarHostState() }
@@ -219,61 +199,6 @@ fun App(viewModel: NostrViewModel) {
}
}
)
// Show the relay setup dialog if the msg relay list is empty
if (isRelayListEmpty) {
ModalBottomSheet(
onDismissRequest = { viewModel.dismissRelayWarning() },
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surfaceContainer,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.5f)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Messaging Relays are required",
style = MaterialTheme.typography.headlineSmallEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = "Coop cannot found your messaging relays. To send and receive messages on Coop, you need to set up at least one messaging relay.",
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = "Please click the button below to continue with the default set of relays. You can always change them later in the settings.",
style = MaterialTheme.typography.bodyLarge.copy(
fontStyle = FontStyle.Italic,
),
)
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = {
scope.launch {
viewModel.useDefaultMsgRelayList()
sheetState.hide()
}
},
modifier = Modifier
.fillMaxWidth()
.height(ButtonDefaults.MediumContainerHeight),
) {
Text(
text = "Continue",
style = MaterialTheme.typography.titleMediumEmphasized,
)
}
}
}
}
}
}
}

View File

@@ -0,0 +1,28 @@
package su.reya.coop
import android.content.Intent
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
import kotlinx.coroutines.CompletableDeferred
class ExternalSignerLauncher {
private var launcher: ActivityResultLauncher<Intent>? = null
private var pendingResult: CompletableDeferred<ActivityResult>? = null
fun register(launcher: ActivityResultLauncher<Intent>) {
this.launcher = launcher
}
suspend fun launch(intent: Intent): ActivityResult {
val deferred = CompletableDeferred<ActivityResult>()
pendingResult = deferred
launcher?.launch(intent)
?: throw IllegalStateException("ExternalSignerLauncher not registered")
return deferred.await()
}
fun onResult(result: ActivityResult) {
pendingResult?.complete(result)
pendingResult = null
}
}

View File

@@ -2,9 +2,12 @@ package su.reya.coop
import android.content.Intent
import android.os.Bundle
import android.os.Process
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.ProcessLifecycleOwner
@@ -14,11 +17,16 @@ import su.reya.coop.coop.storage.SecretStore
import kotlin.system.exitProcess
class MainActivity : ComponentActivity() {
companion object {
val externalSignerLauncher = ExternalSignerLauncher()
}
private val viewModel: NostrViewModel by viewModels {
object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val secretStore = SecretStore(this@MainActivity)
return NostrViewModel(NostrManager.instance, secretStore) as T
val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
return NostrViewModel(NostrManager.instance, secretStore, androidSigner) as T
}
}
}
@@ -26,10 +34,9 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
throwable.printStackTrace()
android.util.Log.e(
"CoopCrash",
"Uncaught exception in thread ${thread.name}",
throwable
Log.e(
"CoopCrash", "Uncaught exception in thread ${thread.name}", throwable
)
// Start the Crash Activity
@@ -40,10 +47,17 @@ class MainActivity : ComponentActivity() {
startActivity(intent)
// Exit
android.os.Process.killProcess(android.os.Process.myPid())
Process.killProcess(Process.myPid())
exitProcess(1)
}
val resultLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
externalSignerLauncher.onResult(result)
}
externalSignerLauncher.register(resultLauncher)
val splashScreen = installSplashScreen()
enableEdgeToEdge()

View File

@@ -58,7 +58,11 @@ class NostrForegroundService : Service() {
dbDir.mkdirs()
// Initialize Nostr client
nostr.init(dbDir.absolutePath)
try {
nostr.init(dbDir.absolutePath)
} catch (e: Exception) {
throw IllegalStateException("Failed to initialize Nostr Client", e)
}
// Connect to bootstrap relays
nostr.connectBootstrapRelays()
// Handle notifications

View File

@@ -55,7 +55,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_send
import kotlinx.coroutines.flow.first
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.LocalNavigator
@@ -67,7 +66,6 @@ import su.reya.coop.roomId
import su.reya.coop.shared.Avatar
import su.reya.coop.shared.displayNameFlow
import su.reya.coop.shared.pictureFlow
import su.reya.coop.short
@Composable
fun ChatScreen(id: Long) {
@@ -77,9 +75,7 @@ fun ChatScreen(id: Long) {
// Get chat room by ID
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
val room by remember(id) {
derivedStateOf { chatRooms.firstOrNull { it.id == id } }
}
val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } }
// Show empty screen
if (room == null) {
@@ -88,7 +84,7 @@ fun ChatScreen(id: Long) {
contentAlignment = Alignment.Center
) {
Text(
text = "Chat room not found",
text = "Something went wrong.",
style = MaterialTheme.typography.titleMediumEmphasized,
color = MaterialTheme.colorScheme.onSurface
)
@@ -114,23 +110,14 @@ fun ChatScreen(id: Long) {
// Start loading spinner
loading = true
// Get msg relays for each member
viewModel.chatRoomConnect(id)
// Get messages
val initialMessages = viewModel.getChatRoomMessages(id)
messages.clear()
messages.addAll(initialMessages)
// Get msg relays for each member
val results = viewModel.chatRoomConnect(id)
results.forEach { (member, relays) ->
if (relays.isNotEmpty()) {
val metadata = viewModel.getMetadata(member).first { it != null }
val profile = metadata?.asRecord()
val name = profile?.displayName ?: profile?.name ?: member.short()
snackbarHostState.showSnackbar("Connected to messaging relays for $name")
}
}
// Stop loading spinner
loading = false

View File

@@ -13,8 +13,10 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
@@ -72,7 +74,9 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.compose.LifecycleResumeEffect
@@ -111,6 +115,7 @@ fun HomeScreen() {
val userProfile by currentUserProfile.collectAsStateWithLifecycle()
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsStateWithLifecycle()
val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsState(initial = false)
val isBannerDismissed by viewModel.isNotificationBannerDismissed.collectAsState()
@@ -448,6 +453,77 @@ fun HomeScreen() {
}
},
)
// Show the relay setup dialog if the msg relay list is empty
if (isRelayListEmpty) {
ModalBottomSheet(
onDismissRequest = { viewModel.dismissRelayWarning() },
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surfaceContainer,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.5f)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Messaging Relays are required",
style = MaterialTheme.typography.headlineSmallEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = "Coop cannot found your messaging relays. To send and receive messages on Coop, you need to set up at least one messaging relay.",
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = "Please click the button below to continue with the default set of relays. You can always change them later in the settings.",
style = MaterialTheme.typography.bodyLarge.copy(
fontStyle = FontStyle.Italic,
),
)
Spacer(modifier = Modifier.weight(1f))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextButton(
onClick = { },
modifier = Modifier
.weight(1f)
.height(ButtonDefaults.MediumContainerHeight),
) {
Text(
text = "Retry",
style = MaterialTheme.typography.titleMediumEmphasized,
)
}
Button(
onClick = {
scope.launch {
viewModel.useDefaultMsgRelayList()
sheetState.hide()
}
},
modifier = Modifier
.weight(1f)
.height(ButtonDefaults.MediumContainerHeight),
) {
Text(
text = "Use Default",
style = MaterialTheme.typography.titleMediumEmphasized,
)
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)

View File

@@ -78,7 +78,7 @@ fun ImportScreen() {
val scope = rememberCoroutineScope()
val isLoggedIn by viewModel.isLoggedIn.collectAsStateWithLifecycle(false)
val isBusy by viewModel.isBusy.collectAsStateWithLifecycle(false)
var secret by remember { mutableStateOf("") }
var pubkey by remember { mutableStateOf<PublicKey?>(null) }
@@ -205,7 +205,7 @@ fun ImportScreen() {
BasicTextField(
value = secret,
onValueChange = { secret = it },
enabled = !isLoggedIn,
enabled = !isBusy,
modifier = Modifier.fillMaxWidth(),
maxLines = 4,
keyboardOptions = KeyboardOptions(
@@ -258,9 +258,9 @@ fun ImportScreen() {
modifier = Modifier
.fillMaxWidth()
.height(ButtonDefaults.MediumContainerHeight),
enabled = secret.isNotBlank() && !isLoggedIn,
enabled = secret.isNotBlank() && !isBusy,
) {
if (isLoggedIn) {
if (isBusy) {
LoadingIndicator()
} else {
Text(

View File

@@ -15,12 +15,12 @@ fun NewIdentityScreen() {
val viewModel = LocalNostrViewModel.current
val navigator = LocalNavigator.current
val scope = rememberCoroutineScope()
val isLoggedIn by viewModel.isLoggedIn.collectAsStateWithLifecycle(false)
val isBusy by viewModel.isBusy.collectAsStateWithLifecycle(false)
ProfileEditor(
title = "Create a new identity",
buttonLabel = "Continue",
isBusy = isLoggedIn,
isBusy = isBusy,
onBack = { navigator.goBack() },
onConfirm = { name, bio, bytes, type ->
scope.launch {

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,11 +54,15 @@ 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()
val annotatedText = buildAnnotatedString {
append("By using Coop, you agree to accept\nour ")
// Push "Terms of Use" link
@@ -142,7 +155,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()

View File

@@ -18,7 +18,7 @@ fun UpdateProfileScreen() {
val currentUser = viewModel.currentUser() ?: return
val metadata by viewModel.getMetadata(currentUser).collectAsState(initial = null)
val isBusy by viewModel.isLoggedIn.collectAsStateWithLifecycle(false)
val isBusy by viewModel.isBusy.collectAsStateWithLifecycle(false)
val profile = metadata?.asRecord()

View File

@@ -33,7 +33,7 @@ kotlin {
implementation(libs.androidx.lifecycle.runtimeCompose)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
implementation("su.reya:nostr-sdk-kmp:0.2.3")
implementation("su.reya:nostr-sdk-kmp:0.2.6")
implementation("com.squareup.okio:okio:3.16.2")
}
androidMain.dependencies {

View File

@@ -0,0 +1,44 @@
package su.reya.coop
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.UnsignedEvent
/**
* Platform interface for NIP-55 external signer communication.
* Implemented on Android; no-op/null on other platforms.
*/
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?
suspend fun nip04Decrypt(ciphertext: String, pubkey: PublicKey): String?
suspend fun nip44Encrypt(plaintext: String, pubkey: PublicKey, currentUser: PublicKey): String?
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: PublicKey,
val packageName: String,
)

View File

@@ -0,0 +1,40 @@
package su.reya.coop
import rust.nostr.sdk.AsyncNostrSigner
import rust.nostr.sdk.Event
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.UnsignedEvent
class ExternalSignerProxy(
private val handler: ExternalSignerHandler,
private val currentUser: PublicKey,
) : AsyncNostrSigner {
override suspend fun getPublicKeyAsync(): PublicKey {
return currentUser
}
override suspend fun signEventAsync(unsignedEvent: UnsignedEvent): Event? {
val signedJson = handler.signEvent(unsignedEvent, currentUser) ?: return null
return Event.fromJson(signedJson)
}
override suspend fun nip04EncryptAsync(publicKey: PublicKey, content: String): String {
return handler.nip04Encrypt(content, publicKey)
?: throw Exception("NIP-04 encrypt rejected")
}
override suspend fun nip04DecryptAsync(publicKey: PublicKey, encryptedContent: String): String {
return handler.nip04Decrypt(encryptedContent, publicKey)
?: throw Exception("NIP-04 decrypt rejected")
}
override suspend fun nip44EncryptAsync(publicKey: PublicKey, content: String): String {
return handler.nip44Encrypt(content, publicKey, currentUser)
?: throw Exception("NIP-44 encrypt rejected")
}
override suspend fun nip44DecryptAsync(publicKey: PublicKey, payload: String): String {
return handler.nip44Decrypt(payload, publicKey, currentUser)
?: throw Exception("NIP-44 decrypt rejected")
}
}

View File

@@ -43,18 +43,18 @@ import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.ReqExitPolicy
import rust.nostr.sdk.ReqTarget
import rust.nostr.sdk.SendEventTarget
import rust.nostr.sdk.SignerAuthenticator
import rust.nostr.sdk.SingleLetterTag
import rust.nostr.sdk.SleepWhenIdle
import rust.nostr.sdk.SubscribeAutoCloseOptions
import rust.nostr.sdk.Tag
import rust.nostr.sdk.TagKind
import rust.nostr.sdk.Timestamp
import rust.nostr.sdk.UnsignedEvent
import rust.nostr.sdk.UnwrappedGift
import rust.nostr.sdk.extractRelayList
import rust.nostr.sdk.giftWrapAsync
import rust.nostr.sdk.initLogger
import rust.nostr.sdk.nip17ExtractRelayList
import rust.nostr.sdk.nip59MakeGiftWrapAsync
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
@@ -120,27 +120,23 @@ class Nostr {
// Initialize the logger for nostr client
initLogger(logLevel)
// Initialize the database and gossip instance
// Initialize configurations for nostr client
val lmdb = NostrDatabase.lmdb(dbPath)
val gossip = NostrGossip.inMemory()
// Set the idle timeout for relays
val authenticator = SignerAuthenticator(signer)
val idleTimeout = Duration.parse("5m")
client =
ClientBuilder()
.signer(signer)
.authenticator(authenticator)
.database(lmdb)
.gossip(gossip)
.gossipConfig(
GossipConfig()
.noBackgroundRefresh()
.fetchTimeout(Duration.parse("2s"))
.syncIdleTimeout(Duration.parse("100ms"))
.syncInitialTimeout(Duration.parse("100ms"))
)
.verifySubscriptions(false)
.automaticAuthentication(true)
.sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout))
.build()
@@ -391,16 +387,15 @@ class Nostr {
val currentUser =
signer.currentUser ?: throw IllegalStateException("User not signed in")
// Ensure the rumor ID is set
val rumor = rumor.ensureId()
// Construct the room id
val roomId = rumor.roomId()
// Construct reference tags
val tags = listOf(
Tag.identifier(giftId.toHex()),
Tag.event(rumor.id()!!),
Tag.reference(roomId.toString()),
Tag.custom(TagKind.Unknown("k"), listOf("14"))
Tag.custom("a", listOf(roomId.toString())),
Tag.custom("k", listOf("14"))
)
// Set event kind
@@ -408,8 +403,8 @@ class Nostr {
val event = EventBuilder(kind, rumor.asJson())
.tags(tags)
.build(currentUser)
.signWithKeys(Keys.generate())
.finalizeUnsigned(currentUser)
.signAsync(Keys.generate())
client?.database()?.saveEvent(event)
} catch (e: Exception) {
@@ -458,9 +453,10 @@ class Nostr {
client?.addRelay(
url = relay,
capabilities =
if (metadata == RelayMetadata.READ) RelayCapabilities.read()
else if (metadata == RelayMetadata.WRITE) RelayCapabilities.write()
else RelayCapabilities.none()
when (metadata) {
RelayMetadata.READ -> RelayCapabilities.read()
RelayMetadata.WRITE -> RelayCapabilities.write()
}
)
client?.connectRelay(relay)
}
@@ -471,7 +467,7 @@ class Nostr {
suspend fun getDefaultMsgRelayList(): List<RelayUrl> {
// Construct a list of messaging relays
val msgRelayList = listOf(
RelayUrl.parse("wss://relay.0xchat.com"),
RelayUrl.parse("wss://auth.nostr1.com"),
RelayUrl.parse("wss://nip17.com"),
)
@@ -487,7 +483,7 @@ class Nostr {
suspend fun createIdentity(keys: Keys, name: String, bio: String?, picture: String?) {
// Send relay list event
val relayList = getDefaultRelayList()
val relayListEvent = EventBuilder.relayList(relayList).signWithKeys(keys);
val relayListEvent = EventBuilder.relayList(relayList).finalizeAsync(keys);
client?.sendEvent(
event = relayListEvent,
@@ -498,7 +494,7 @@ class Nostr {
// Send messaging relay list event
val msgRelayList = getDefaultMsgRelayList()
val msgRelayListEvent = EventBuilder.nip17RelayList(msgRelayList).signWithKeys(keys)
val msgRelayListEvent = EventBuilder.nip17RelayList(msgRelayList).finalizeAsync(keys)
client?.sendEvent(
event = msgRelayListEvent,
@@ -509,7 +505,7 @@ class Nostr {
// Send metadata event
val metadata =
Metadata.fromRecord(MetadataRecord(displayName = name, about = bio, picture = picture))
val metadataEvent = EventBuilder.metadata(metadata).signWithKeys(keys)
val metadataEvent = EventBuilder.metadata(metadata).finalizeAsync(keys)
client?.sendEvent(
event = metadataEvent,
@@ -519,8 +515,8 @@ class Nostr {
// Send contact list event
val defaultContact =
listOf(Contact(publicKey = PublicKey.parse("npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x")))
val contactListEvent = EventBuilder.contactList(defaultContact).signWithKeys(keys)
Contact(PublicKey.parse("npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x"))
val contactListEvent = EventBuilder.contactList(listOf(defaultContact)).finalizeAsync(keys)
client?.sendEvent(
event = contactListEvent,
@@ -546,7 +542,7 @@ class Nostr {
picture = picture ?: record.picture
)
val newMetadata = Metadata.fromRecord(newRecord)
val event = EventBuilder.metadata(newMetadata).signAsync(signer)
val event = EventBuilder.metadata(newMetadata).finalizeAsync(signer)
client?.sendEvent(
event = event,
@@ -623,7 +619,7 @@ class Nostr {
suspend fun setMsgRelays(urls: List<RelayUrl>) {
try {
val event = EventBuilder.nip17RelayList(urls).signAsync(signer)
val event = EventBuilder.nip17RelayList(urls).finalizeAsync(signer)
client?.sendEvent(
event = event,
@@ -726,33 +722,25 @@ class Nostr {
}
}
suspend fun chatRoomConnect(members: List<PublicKey>): Map<PublicKey, List<RelayUrl>> {
suspend fun chatRoomConnect(members: List<PublicKey>) {
try {
val results = mutableMapOf<PublicKey, MutableList<RelayUrl>>()
members.forEach { member ->
results[member] = mutableListOf<RelayUrl>()
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
val filter = Filter().kind(kind).author(member).limit(1u)
val stream = client?.streamEvents(
target = ReqTarget.auto(listOf(filter)),
id = "room-${member.toBech32().substring(0, 10)}",
id = null,
timeout = Duration.parse("3s"),
policy = ReqExitPolicy.ExitOnEose
)
stream?.next()?.let { res ->
if (res.event != null) {
// Connect to the msg relays
connectMsgRelays(res.event!!)
// Mark the member as connected
results[member]?.add(res.relayUrl)
}
}
}
return results
} catch (e: Exception) {
throw IllegalStateException("Failed to fetch relays: ${e.message}", e)
}
@@ -762,10 +750,8 @@ class Nostr {
try {
val urls = nip17ExtractRelayList(event);
for (url in urls) {
if (client?.relay(url) == null) {
client?.addRelay(url)
client?.connectRelay(url)
}
client?.addRelay(url, RelayCapabilities.gossip())
client?.connectRelay(url)
}
} catch (e: Exception) {
throw IllegalStateException("Failed to connect to relays: ${e.message}", e)
@@ -787,7 +773,7 @@ class Nostr {
// Add a subject tag if provided
if (subject != null) {
tags.add(Tag.custom(TagKind.Subject, listOf(subject)))
tags.add(Tag.custom("subject", listOf(subject)))
}
// Add event tags for replies
@@ -805,13 +791,9 @@ class Nostr {
for (receiver in setOf(currentUser) + to) {
// Construct the rumor event
// NEVER SIGN this event with the current user signer
val rumor = EventBuilder
.privateMsgRumor(receiver = receiver, message = content)
val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), content)
.tags(tags)
.allowSelfTagging()
.build(currentUser)
// Ensure the event ID is set
.ensureId()
.finalizeUnsigned(currentUser)
// Emit the rumor to the chat screen
if (receiver == currentUser) {
@@ -819,12 +801,12 @@ class Nostr {
}
// Construct the gift wrap event
val gift = giftWrapAsync(
val gift = nip59MakeGiftWrapAsync(
signer = signer,
receiverPubkey = receiver,
rumor = rumor,
extraTags = listOf(
Tag.custom(TagKind.Unknown("k"), listOf("14"))
Tag.custom("k", listOf("14"))
)
)

View File

@@ -26,6 +26,8 @@ import rust.nostr.sdk.AsyncNostrSigner
import rust.nostr.sdk.EventBuilder
import rust.nostr.sdk.EventId
import rust.nostr.sdk.Keys
import rust.nostr.sdk.Kind
import rust.nostr.sdk.KindStandard
import rust.nostr.sdk.Metadata
import rust.nostr.sdk.NostrConnect
import rust.nostr.sdk.NostrConnectUri
@@ -42,7 +44,8 @@ import kotlin.time.Duration.Companion.seconds
class NostrViewModel(
private val nostr: Nostr,
private val secretStore: SecretStorage
private val secretStore: SecretStorage,
private val externalSignerHandler: ExternalSignerHandler? = null,
) : ViewModel() {
private val _isNotificationBannerDismissed = MutableStateFlow(false)
val isNotificationBannerDismissed = _isNotificationBannerDismissed.asStateFlow()
@@ -50,8 +53,8 @@ class NostrViewModel(
private val _signerRequired = MutableStateFlow<Boolean?>(null)
val signerRequired = _signerRequired.asStateFlow()
private val _isLoggedIn = MutableStateFlow(false)
val isLoggedIn = _isLoggedIn.asStateFlow()
private val _isBusy = MutableStateFlow(false)
val isBusy = _isBusy.asStateFlow()
private val _isPartialProcessedGiftWrap = MutableStateFlow(false)
val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow()
@@ -369,20 +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)
}
else -> throw IllegalArgumentException("Invalid secret format")
}
}
private suspend fun blossomUpload(file: ByteArray, contentType: String): String? {
try {
// Upload picture to Blossom
@@ -418,16 +407,16 @@ class NostrViewModel(
picture: ByteArray? = null,
contentType: String? = null
) {
_isLoggedIn.value = true
_isBusy.value = true
try {
val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") }
val newMetadata = nostr.updateProfile(name, bio, avatarUrl)
// Update the metadata state after successfully published
updateMetadata(nostr.signer.currentUser!!, newMetadata)
// Update local state
_isBusy.value = false
} catch (e: Exception) {
showError("Error: ${e.message}")
} finally {
_isLoggedIn.value = false
}
}
@@ -437,24 +426,50 @@ class NostrViewModel(
picture: ByteArray?,
contentType: String? = null
) {
_isLoggedIn.value = true
try {
val keys = Keys.generate()
val secret = keys.secretKey().toBech32()
val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") }
_isBusy.value = true
val keys = Keys.generate()
val secret = keys.secretKey().toBech32()
try {
val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") }
// Create identity
nostr.createIdentity(keys = keys, name = name, bio, picture = avatarUrl)
// Save secret to the secret storage
// Persist the secret in the secret storage
secretStore.set("user_signer", secret)
// Set an empty secret state
// Update local states
_isBusy.value = false
_signerRequired.value = false
} catch (e: Exception) {
showError("Error: ${e.message}")
} finally {
_isLoggedIn.value = false
}
}
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")
}
}
@@ -472,19 +487,59 @@ class NostrViewModel(
}
suspend fun importIdentity(secret: String) {
_isLoggedIn.value = true
_isBusy.value = true
try {
val signer = createSigner(secret)
// Update signer
nostr.setSigner(signer)
// Persist the secret in the secret storage
secretStore.set("user_signer", secret)
// Update local states
_signerRequired.value = false
_isBusy.value = false
} catch (e: Exception) {
showError("Error: ${e.message}")
} finally {
_signerRequired.value = false
_isLoggedIn.value = false
}
}
suspend fun connectExternalSigner() {
val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available")
_isBusy.value = true
try {
val permissions = SignerPermissions.toJson(
listOf(
SignerPermissions.signEvent(0),
SignerPermissions.signEvent(3),
SignerPermissions.signEvent(10000),
SignerPermissions.signEvent(10050),
SignerPermissions.signEvent(10063),
SignerPermissions.signEvent(22242),
SignerPermissions.signEvent(30030),
SignerPermissions.signEvent(30315),
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()}")
// Update local states
_signerRequired.value = false
_isBusy.value = false
} catch (e: Exception) {
throw Exception("Notice: ${e.message}")
}
}
fun isExternalSignerAvailable(): Boolean {
return externalSignerHandler?.isAvailable() == true
}
suspend fun useDefaultMsgRelayList() {
try {
val defaultRelays = nostr.getDefaultMsgRelayList()
@@ -520,10 +575,9 @@ class NostrViewModel(
val currentUser = nostr.signer.currentUser!!
// Construct the rumor event
val rumor = EventBuilder
.privateMsgRumor(to.first(), "")
val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), "")
.tags(to.map { Tag.publicKey(it) })
.build(currentUser)
.finalizeUnsigned(currentUser)
// Check if the room already exists
val id = rumor.roomId()
@@ -590,20 +644,16 @@ class NostrViewModel(
return emptyList()
}
suspend fun chatRoomConnect(roomId: Long): Map<PublicKey, List<RelayUrl>> {
try {
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
val members = room.members
fun chatRoomConnect(roomId: Long) {
viewModelScope.launch {
try {
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
val members = room.members
return runCatching {
nostr.chatRoomConnect(members.toList())
}.getOrElse { e ->
} catch (e: Exception) {
showError("Error: ${e.message}")
members.associateWith { emptyList() }
}
} catch (e: Exception) {
showError("Error: ${e.message}")
return emptyMap()
}
}

View File

@@ -6,7 +6,6 @@ import kotlinx.datetime.minus
import kotlinx.datetime.number
import kotlinx.datetime.toLocalDateTime
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.TagKind
import rust.nostr.sdk.Timestamp
import rust.nostr.sdk.UnsignedEvent
import kotlin.time.Clock
@@ -37,7 +36,7 @@ data class Room(
fun new(rumor: UnsignedEvent, userPubkey: PublicKey): Room {
val id = rumor.roomId()
val createdAt = rumor.createdAt()
val subject = rumor.tags().find(TagKind.Subject)?.content()
val subject = rumor.tags().toVec().find { it.kind() == "subject" }?.content()
// Collect the author's public key and all public keys from tags
val pubkeys: MutableSet<PublicKey> = mutableSetOf()

View File

@@ -75,7 +75,7 @@ class BlossomClient(
signer: AsyncNostrSigner,
authz: BlossomAuthorization
): HeaderValue {
val authEvent = EventBuilder.blossomAuth(authz).signAsync(signer)
val authEvent = EventBuilder.blossomAuth(authz).finalizeAsync(signer)
val encodedAuth = Base64.encode(authEvent.asJson().toByteArray())
val value = "Nostr $encodedAuth"
return HeaderValue(value)