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.navigation3.ui)
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3) implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
implementation(libs.androidx.core.splashscreen) 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-compose:3.4.0")
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0") implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0") implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0")
@@ -69,7 +69,7 @@ android {
minSdk = libs.versions.android.minSdk.get().toInt() minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt() targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1 versionCode = 1
versionName = "0.1.7" versionName = "0.1.8"
} }
packaging { packaging {
resources { resources {

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,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.ComponentActivity
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.isSystemInDarkTheme 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.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialExpressiveTheme import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.MotionScheme import androidx.compose.material3.MotionScheme
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicDarkColorScheme
@@ -38,13 +26,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf 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.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.core.util.Consumer
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
@@ -54,7 +36,6 @@ import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import kotlinx.coroutines.launch
import su.reya.coop.screens.ChatScreen import su.reya.coop.screens.ChatScreen
import su.reya.coop.screens.HomeScreen import su.reya.coop.screens.HomeScreen
import su.reya.coop.screens.ImportScreen import su.reya.coop.screens.ImportScreen
@@ -95,7 +76,6 @@ fun App(viewModel: NostrViewModel) {
val qrScanResult = remember { QrScanResult() } val qrScanResult = remember { QrScanResult() }
val signerRequired by viewModel.signerRequired.collectAsStateWithLifecycle() val signerRequired by viewModel.signerRequired.collectAsStateWithLifecycle()
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsStateWithLifecycle()
// Snackbar // Snackbar
val snackbarHostState = remember { SnackbarHostState() } 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.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
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner
@@ -14,11 +17,16 @@ import su.reya.coop.coop.storage.SecretStore
import kotlin.system.exitProcess import kotlin.system.exitProcess
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
companion object {
val externalSignerLauncher = ExternalSignerLauncher()
}
private val viewModel: NostrViewModel by viewModels { private val viewModel: NostrViewModel by viewModels {
object : ViewModelProvider.Factory { object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
val secretStore = SecretStore(this@MainActivity) 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?) { override fun onCreate(savedInstanceState: Bundle?) {
Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
throwable.printStackTrace() throwable.printStackTrace()
android.util.Log.e(
"CoopCrash", Log.e(
"Uncaught exception in thread ${thread.name}", "CoopCrash", "Uncaught exception in thread ${thread.name}", throwable
throwable
) )
// Start the Crash Activity // Start the Crash Activity
@@ -40,10 +47,17 @@ class MainActivity : ComponentActivity() {
startActivity(intent) startActivity(intent)
// Exit // Exit
android.os.Process.killProcess(android.os.Process.myPid()) Process.killProcess(Process.myPid())
exitProcess(1) exitProcess(1)
} }
val resultLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
externalSignerLauncher.onResult(result)
}
externalSignerLauncher.register(resultLauncher)
val splashScreen = installSplashScreen() val splashScreen = installSplashScreen()
enableEdgeToEdge() enableEdgeToEdge()

View File

@@ -58,7 +58,11 @@ class NostrForegroundService : Service() {
dbDir.mkdirs() dbDir.mkdirs()
// Initialize Nostr client // 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 // Connect to bootstrap relays
nostr.connectBootstrapRelays() nostr.connectBootstrapRelays()
// Handle notifications // Handle notifications

View File

@@ -55,7 +55,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_send import coop.composeapp.generated.resources.ic_send
import kotlinx.coroutines.flow.first
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.LocalNavigator 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.Avatar
import su.reya.coop.shared.displayNameFlow import su.reya.coop.shared.displayNameFlow
import su.reya.coop.shared.pictureFlow import su.reya.coop.shared.pictureFlow
import su.reya.coop.short
@Composable @Composable
fun ChatScreen(id: Long) { fun ChatScreen(id: Long) {
@@ -77,9 +75,7 @@ fun ChatScreen(id: Long) {
// Get chat room by ID // Get chat room by ID
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle() val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
val room by remember(id) { val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } }
derivedStateOf { chatRooms.firstOrNull { it.id == id } }
}
// Show empty screen // Show empty screen
if (room == null) { if (room == null) {
@@ -88,7 +84,7 @@ fun ChatScreen(id: Long) {
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = "Chat room not found", text = "Something went wrong.",
style = MaterialTheme.typography.titleMediumEmphasized, style = MaterialTheme.typography.titleMediumEmphasized,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )
@@ -114,23 +110,14 @@ fun ChatScreen(id: Long) {
// Start loading spinner // Start loading spinner
loading = true loading = true
// Get msg relays for each member
viewModel.chatRoomConnect(id)
// Get messages // Get messages
val initialMessages = viewModel.getChatRoomMessages(id) val initialMessages = viewModel.getChatRoomMessages(id)
messages.clear() messages.clear()
messages.addAll(initialMessages) 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 // Stop loading spinner
loading = false 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.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding 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.ClipEntry
import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext 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.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.LifecycleResumeEffect
@@ -111,6 +115,7 @@ fun HomeScreen() {
val userProfile by currentUserProfile.collectAsStateWithLifecycle() val userProfile by currentUserProfile.collectAsStateWithLifecycle()
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle() val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsStateWithLifecycle()
val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsState(initial = false) val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsState(initial = false)
val isBannerDismissed by viewModel.isNotificationBannerDismissed.collectAsState() 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) @OptIn(ExperimentalMaterial3ExpressiveApi::class)

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
package su.reya.coop.screens package su.reya.coop.screens
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 +14,17 @@ 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.SnackbarDuration
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,11 +54,15 @@ 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()
val annotatedText = buildAnnotatedString { val annotatedText = buildAnnotatedString {
append("By using Coop, you agree to accept\nour ") append("By using Coop, you agree to accept\nour ")
// Push "Terms of Use" link // Push "Terms of Use" link
@@ -142,7 +155,44 @@ fun OnboardingScreen() {
) )
} }
Spacer(modifier = Modifier.size(8.dp)) 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) }, onClick = { navigator.navigate(Screen.Import) },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()

View File

@@ -18,7 +18,7 @@ fun UpdateProfileScreen() {
val currentUser = viewModel.currentUser() ?: return val currentUser = viewModel.currentUser() ?: return
val metadata by viewModel.getMetadata(currentUser).collectAsState(initial = null) 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() val profile = metadata?.asRecord()

View File

@@ -33,7 +33,7 @@ kotlin {
implementation(libs.androidx.lifecycle.runtimeCompose) implementation(libs.androidx.lifecycle.runtimeCompose)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0") 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") implementation("com.squareup.okio:okio:3.16.2")
} }
androidMain.dependencies { 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.ReqExitPolicy
import rust.nostr.sdk.ReqTarget import rust.nostr.sdk.ReqTarget
import rust.nostr.sdk.SendEventTarget import rust.nostr.sdk.SendEventTarget
import rust.nostr.sdk.SignerAuthenticator
import rust.nostr.sdk.SingleLetterTag import rust.nostr.sdk.SingleLetterTag
import rust.nostr.sdk.SleepWhenIdle import rust.nostr.sdk.SleepWhenIdle
import rust.nostr.sdk.SubscribeAutoCloseOptions import rust.nostr.sdk.SubscribeAutoCloseOptions
import rust.nostr.sdk.Tag import rust.nostr.sdk.Tag
import rust.nostr.sdk.TagKind
import rust.nostr.sdk.Timestamp import rust.nostr.sdk.Timestamp
import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnsignedEvent
import rust.nostr.sdk.UnwrappedGift import rust.nostr.sdk.UnwrappedGift
import rust.nostr.sdk.extractRelayList import rust.nostr.sdk.extractRelayList
import rust.nostr.sdk.giftWrapAsync
import rust.nostr.sdk.initLogger import rust.nostr.sdk.initLogger
import rust.nostr.sdk.nip17ExtractRelayList import rust.nostr.sdk.nip17ExtractRelayList
import rust.nostr.sdk.nip59MakeGiftWrapAsync
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@@ -120,27 +120,23 @@ class Nostr {
// Initialize the logger for nostr client // Initialize the logger for nostr client
initLogger(logLevel) initLogger(logLevel)
// Initialize the database and gossip instance // Initialize configurations for nostr client
val lmdb = NostrDatabase.lmdb(dbPath) val lmdb = NostrDatabase.lmdb(dbPath)
val gossip = NostrGossip.inMemory() val gossip = NostrGossip.inMemory()
val authenticator = SignerAuthenticator(signer)
// Set the idle timeout for relays
val idleTimeout = Duration.parse("5m") val idleTimeout = Duration.parse("5m")
client = client =
ClientBuilder() ClientBuilder()
.signer(signer) .authenticator(authenticator)
.database(lmdb) .database(lmdb)
.gossip(gossip) .gossip(gossip)
.gossipConfig( .gossipConfig(
GossipConfig() GossipConfig()
.noBackgroundRefresh() .noBackgroundRefresh()
.fetchTimeout(Duration.parse("2s")) .fetchTimeout(Duration.parse("2s"))
.syncIdleTimeout(Duration.parse("100ms"))
.syncInitialTimeout(Duration.parse("100ms"))
) )
.verifySubscriptions(false) .verifySubscriptions(false)
.automaticAuthentication(true)
.sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout)) .sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout))
.build() .build()
@@ -391,16 +387,15 @@ class Nostr {
val currentUser = val currentUser =
signer.currentUser ?: throw IllegalStateException("User not signed in") signer.currentUser ?: throw IllegalStateException("User not signed in")
// Ensure the rumor ID is set // Construct the room id
val rumor = rumor.ensureId()
val roomId = rumor.roomId() val roomId = rumor.roomId()
// Construct reference tags // Construct reference tags
val tags = listOf( val tags = listOf(
Tag.identifier(giftId.toHex()), Tag.identifier(giftId.toHex()),
Tag.event(rumor.id()!!), Tag.event(rumor.id()!!),
Tag.reference(roomId.toString()), Tag.custom("a", listOf(roomId.toString())),
Tag.custom(TagKind.Unknown("k"), listOf("14")) Tag.custom("k", listOf("14"))
) )
// Set event kind // Set event kind
@@ -408,8 +403,8 @@ class Nostr {
val event = EventBuilder(kind, rumor.asJson()) val event = EventBuilder(kind, rumor.asJson())
.tags(tags) .tags(tags)
.build(currentUser) .finalizeUnsigned(currentUser)
.signWithKeys(Keys.generate()) .signAsync(Keys.generate())
client?.database()?.saveEvent(event) client?.database()?.saveEvent(event)
} catch (e: Exception) { } catch (e: Exception) {
@@ -458,9 +453,10 @@ class Nostr {
client?.addRelay( client?.addRelay(
url = relay, url = relay,
capabilities = capabilities =
if (metadata == RelayMetadata.READ) RelayCapabilities.read() when (metadata) {
else if (metadata == RelayMetadata.WRITE) RelayCapabilities.write() RelayMetadata.READ -> RelayCapabilities.read()
else RelayCapabilities.none() RelayMetadata.WRITE -> RelayCapabilities.write()
}
) )
client?.connectRelay(relay) client?.connectRelay(relay)
} }
@@ -471,7 +467,7 @@ class Nostr {
suspend fun getDefaultMsgRelayList(): List<RelayUrl> { suspend fun getDefaultMsgRelayList(): List<RelayUrl> {
// Construct a list of messaging relays // Construct a list of messaging relays
val msgRelayList = listOf( val msgRelayList = listOf(
RelayUrl.parse("wss://relay.0xchat.com"), RelayUrl.parse("wss://auth.nostr1.com"),
RelayUrl.parse("wss://nip17.com"), RelayUrl.parse("wss://nip17.com"),
) )
@@ -487,7 +483,7 @@ class Nostr {
suspend fun createIdentity(keys: Keys, name: String, bio: String?, picture: String?) { suspend fun createIdentity(keys: Keys, name: String, bio: String?, picture: String?) {
// Send relay list event // Send relay list event
val relayList = getDefaultRelayList() val relayList = getDefaultRelayList()
val relayListEvent = EventBuilder.relayList(relayList).signWithKeys(keys); val relayListEvent = EventBuilder.relayList(relayList).finalizeAsync(keys);
client?.sendEvent( client?.sendEvent(
event = relayListEvent, event = relayListEvent,
@@ -498,7 +494,7 @@ class Nostr {
// Send messaging relay list event // Send messaging relay list event
val msgRelayList = getDefaultMsgRelayList() val msgRelayList = getDefaultMsgRelayList()
val msgRelayListEvent = EventBuilder.nip17RelayList(msgRelayList).signWithKeys(keys) val msgRelayListEvent = EventBuilder.nip17RelayList(msgRelayList).finalizeAsync(keys)
client?.sendEvent( client?.sendEvent(
event = msgRelayListEvent, event = msgRelayListEvent,
@@ -509,7 +505,7 @@ class Nostr {
// Send metadata event // Send metadata event
val metadata = val metadata =
Metadata.fromRecord(MetadataRecord(displayName = name, about = bio, picture = picture)) 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( client?.sendEvent(
event = metadataEvent, event = metadataEvent,
@@ -519,8 +515,8 @@ class Nostr {
// Send contact list event // Send contact list event
val defaultContact = val defaultContact =
listOf(Contact(publicKey = PublicKey.parse("npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x"))) Contact(PublicKey.parse("npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x"))
val contactListEvent = EventBuilder.contactList(defaultContact).signWithKeys(keys) val contactListEvent = EventBuilder.contactList(listOf(defaultContact)).finalizeAsync(keys)
client?.sendEvent( client?.sendEvent(
event = contactListEvent, event = contactListEvent,
@@ -546,7 +542,7 @@ class Nostr {
picture = picture ?: record.picture picture = picture ?: record.picture
) )
val newMetadata = Metadata.fromRecord(newRecord) val newMetadata = Metadata.fromRecord(newRecord)
val event = EventBuilder.metadata(newMetadata).signAsync(signer) val event = EventBuilder.metadata(newMetadata).finalizeAsync(signer)
client?.sendEvent( client?.sendEvent(
event = event, event = event,
@@ -623,7 +619,7 @@ class Nostr {
suspend fun setMsgRelays(urls: List<RelayUrl>) { suspend fun setMsgRelays(urls: List<RelayUrl>) {
try { try {
val event = EventBuilder.nip17RelayList(urls).signAsync(signer) val event = EventBuilder.nip17RelayList(urls).finalizeAsync(signer)
client?.sendEvent( client?.sendEvent(
event = event, 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 { try {
val results = mutableMapOf<PublicKey, MutableList<RelayUrl>>()
members.forEach { member -> members.forEach { member ->
results[member] = mutableListOf<RelayUrl>()
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS) val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
val filter = Filter().kind(kind).author(member).limit(1u) val filter = Filter().kind(kind).author(member).limit(1u)
val stream = client?.streamEvents( val stream = client?.streamEvents(
target = ReqTarget.auto(listOf(filter)), target = ReqTarget.auto(listOf(filter)),
id = "room-${member.toBech32().substring(0, 10)}", id = null,
timeout = Duration.parse("3s"), timeout = Duration.parse("3s"),
policy = ReqExitPolicy.ExitOnEose policy = ReqExitPolicy.ExitOnEose
) )
stream?.next()?.let { res -> stream?.next()?.let { res ->
if (res.event != null) { if (res.event != null) {
// Connect to the msg relays
connectMsgRelays(res.event!!) connectMsgRelays(res.event!!)
// Mark the member as connected
results[member]?.add(res.relayUrl)
} }
} }
} }
return results
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to fetch relays: ${e.message}", e) throw IllegalStateException("Failed to fetch relays: ${e.message}", e)
} }
@@ -762,10 +750,8 @@ class Nostr {
try { try {
val urls = nip17ExtractRelayList(event); val urls = nip17ExtractRelayList(event);
for (url in urls) { for (url in urls) {
if (client?.relay(url) == null) { client?.addRelay(url, RelayCapabilities.gossip())
client?.addRelay(url) client?.connectRelay(url)
client?.connectRelay(url)
}
} }
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to connect to relays: ${e.message}", e) throw IllegalStateException("Failed to connect to relays: ${e.message}", e)
@@ -787,7 +773,7 @@ class Nostr {
// Add a subject tag if provided // Add a subject tag if provided
if (subject != null) { if (subject != null) {
tags.add(Tag.custom(TagKind.Subject, listOf(subject))) tags.add(Tag.custom("subject", listOf(subject)))
} }
// Add event tags for replies // Add event tags for replies
@@ -805,13 +791,9 @@ class Nostr {
for (receiver in setOf(currentUser) + to) { for (receiver in setOf(currentUser) + to) {
// Construct the rumor event // Construct the rumor event
// NEVER SIGN this event with the current user signer // NEVER SIGN this event with the current user signer
val rumor = EventBuilder val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), content)
.privateMsgRumor(receiver = receiver, message = content)
.tags(tags) .tags(tags)
.allowSelfTagging() .finalizeUnsigned(currentUser)
.build(currentUser)
// Ensure the event ID is set
.ensureId()
// Emit the rumor to the chat screen // Emit the rumor to the chat screen
if (receiver == currentUser) { if (receiver == currentUser) {
@@ -819,12 +801,12 @@ class Nostr {
} }
// Construct the gift wrap event // Construct the gift wrap event
val gift = giftWrapAsync( val gift = nip59MakeGiftWrapAsync(
signer = signer, signer = signer,
receiverPubkey = receiver, receiverPubkey = receiver,
rumor = rumor, rumor = rumor,
extraTags = listOf( 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.EventBuilder
import rust.nostr.sdk.EventId import rust.nostr.sdk.EventId
import rust.nostr.sdk.Keys import rust.nostr.sdk.Keys
import rust.nostr.sdk.Kind
import rust.nostr.sdk.KindStandard
import rust.nostr.sdk.Metadata import rust.nostr.sdk.Metadata
import rust.nostr.sdk.NostrConnect import rust.nostr.sdk.NostrConnect
import rust.nostr.sdk.NostrConnectUri import rust.nostr.sdk.NostrConnectUri
@@ -42,7 +44,8 @@ import kotlin.time.Duration.Companion.seconds
class NostrViewModel( class NostrViewModel(
private val nostr: Nostr, private val nostr: Nostr,
private val secretStore: SecretStorage private val secretStore: SecretStorage,
private val externalSignerHandler: ExternalSignerHandler? = null,
) : ViewModel() { ) : ViewModel() {
private val _isNotificationBannerDismissed = MutableStateFlow(false) private val _isNotificationBannerDismissed = MutableStateFlow(false)
val isNotificationBannerDismissed = _isNotificationBannerDismissed.asStateFlow() val isNotificationBannerDismissed = _isNotificationBannerDismissed.asStateFlow()
@@ -50,8 +53,8 @@ class NostrViewModel(
private val _signerRequired = MutableStateFlow<Boolean?>(null) private val _signerRequired = MutableStateFlow<Boolean?>(null)
val signerRequired = _signerRequired.asStateFlow() val signerRequired = _signerRequired.asStateFlow()
private val _isLoggedIn = MutableStateFlow(false) private val _isBusy = MutableStateFlow(false)
val isLoggedIn = _isLoggedIn.asStateFlow() val isBusy = _isBusy.asStateFlow()
private val _isPartialProcessedGiftWrap = MutableStateFlow(false) private val _isPartialProcessedGiftWrap = MutableStateFlow(false)
val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow() val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow()
@@ -369,20 +372,6 @@ class NostrViewModel(
return keys 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? { private suspend fun blossomUpload(file: ByteArray, contentType: String): String? {
try { try {
// Upload picture to Blossom // Upload picture to Blossom
@@ -418,16 +407,16 @@ class NostrViewModel(
picture: ByteArray? = null, picture: ByteArray? = null,
contentType: String? = null contentType: String? = null
) { ) {
_isLoggedIn.value = true _isBusy.value = true
try { try {
val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") } val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") }
val newMetadata = nostr.updateProfile(name, bio, avatarUrl) val newMetadata = nostr.updateProfile(name, bio, avatarUrl)
// Update the metadata state after successfully published // Update the metadata state after successfully published
updateMetadata(nostr.signer.currentUser!!, newMetadata) updateMetadata(nostr.signer.currentUser!!, newMetadata)
// Update local state
_isBusy.value = false
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") showError("Error: ${e.message}")
} finally {
_isLoggedIn.value = false
} }
} }
@@ -437,24 +426,50 @@ class NostrViewModel(
picture: ByteArray?, picture: ByteArray?,
contentType: String? = null contentType: String? = null
) { ) {
_isLoggedIn.value = true _isBusy.value = true
try {
val keys = Keys.generate()
val secret = keys.secretKey().toBech32()
val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") }
val keys = Keys.generate()
val secret = keys.secretKey().toBech32()
try {
val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") }
// Create identity // Create identity
nostr.createIdentity(keys = keys, name = name, bio, picture = avatarUrl) nostr.createIdentity(keys = keys, name = name, bio, picture = avatarUrl)
// Persist the secret in the secret storage
// Save secret to the secret storage
secretStore.set("user_signer", secret) secretStore.set("user_signer", secret)
// Update local states
// Set an empty secret state _isBusy.value = false
_signerRequired.value = false _signerRequired.value = false
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") 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) { suspend fun importIdentity(secret: String) {
_isLoggedIn.value = true _isBusy.value = true
try { try {
val signer = createSigner(secret) val signer = createSigner(secret)
// Update signer
nostr.setSigner(signer) nostr.setSigner(signer)
// Persist the secret in the secret storage
secretStore.set("user_signer", secret) secretStore.set("user_signer", secret)
// Update local states
_signerRequired.value = false
_isBusy.value = false
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") 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() { suspend fun useDefaultMsgRelayList() {
try { try {
val defaultRelays = nostr.getDefaultMsgRelayList() val defaultRelays = nostr.getDefaultMsgRelayList()
@@ -520,10 +575,9 @@ class NostrViewModel(
val currentUser = nostr.signer.currentUser!! val currentUser = nostr.signer.currentUser!!
// Construct the rumor event // Construct the rumor event
val rumor = EventBuilder val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), "")
.privateMsgRumor(to.first(), "")
.tags(to.map { Tag.publicKey(it) }) .tags(to.map { Tag.publicKey(it) })
.build(currentUser) .finalizeUnsigned(currentUser)
// Check if the room already exists // Check if the room already exists
val id = rumor.roomId() val id = rumor.roomId()
@@ -590,20 +644,16 @@ class NostrViewModel(
return emptyList() return emptyList()
} }
suspend fun chatRoomConnect(roomId: Long): Map<PublicKey, List<RelayUrl>> { fun chatRoomConnect(roomId: Long) {
try { viewModelScope.launch {
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found") try {
val members = room.members val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
val members = room.members
return runCatching {
nostr.chatRoomConnect(members.toList()) nostr.chatRoomConnect(members.toList())
}.getOrElse { e -> } catch (e: Exception) {
showError("Error: ${e.message}") 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.number
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.TagKind
import rust.nostr.sdk.Timestamp import rust.nostr.sdk.Timestamp
import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnsignedEvent
import kotlin.time.Clock import kotlin.time.Clock
@@ -37,7 +36,7 @@ data class Room(
fun new(rumor: UnsignedEvent, userPubkey: PublicKey): Room { fun new(rumor: UnsignedEvent, userPubkey: PublicKey): Room {
val id = rumor.roomId() val id = rumor.roomId()
val createdAt = rumor.createdAt() 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 // Collect the author's public key and all public keys from tags
val pubkeys: MutableSet<PublicKey> = mutableSetOf() val pubkeys: MutableSet<PublicKey> = mutableSetOf()

View File

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