diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts
index d3c35bb..dc58cbf 100644
--- a/composeApp/build.gradle.kts
+++ b/composeApp/build.gradle.kts
@@ -27,6 +27,7 @@ kotlin {
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
implementation("su.reya:nostr-sdk-kmp:0.2.3")
implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0")
+ implementation("androidx.lifecycle:lifecycle-process:2.8.0")
}
commonMain.dependencies {
implementation(libs.compose.runtime)
diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml
index c58475d..3fed69d 100644
--- a/composeApp/src/androidMain/AndroidManifest.xml
+++ b/composeApp/src/androidMain/AndroidManifest.xml
@@ -7,6 +7,9 @@
android:required="false" />
+
+
+
+
\ No newline at end of file
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt
index 3e826e8..04016c2 100644
--- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt
@@ -45,7 +45,7 @@ val LocalNavController = staticCompositionLocalOf {
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
-fun App(dbPath: String) {
+fun App() {
val context = LocalContext.current
val navController = rememberNavController()
val darkMode = isSystemInDarkTheme()
@@ -53,11 +53,10 @@ fun App(dbPath: String) {
// Snackbar
val snackbarHostState = remember { SnackbarHostState() }
- // Initialize Nostr and SecretStore
- val nostr = remember { Nostr() }
+ // Initialize Nostr View Model and Secret Store
val secretStore = remember { SecretStore(context) }
- val viewModel: NostrViewModel = viewModel { NostrViewModel(nostr, secretStore) }
-
+ val viewModel: NostrViewModel = viewModel { NostrViewModel(NostrManager.instance, secretStore) }
+
// Enabled the dynamic color scheme
val colorScheme = when {
android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> {
@@ -69,7 +68,7 @@ fun App(dbPath: String) {
}
LaunchedEffect(Unit) {
- viewModel.initAndConnect(dbPath)
+ viewModel.login()
viewModel.startNotificationHandler()
viewModel.getChatRooms()
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt
index 4d00430..f4514e1 100644
--- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt
@@ -1,22 +1,27 @@
package su.reya.coop
+import android.content.Intent
+import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
-import java.io.File
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
- // Get database directory
- val dbDir = File(filesDir, "nostr")
- dbDir.mkdirs()
+ val intent = Intent(this, NostrForegroundService::class.java)
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ startForegroundService(intent)
+ } else {
+ startService(intent)
+ }
setContent {
- App(dbDir.absolutePath)
+ App()
}
}
}
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt
new file mode 100644
index 0000000..c85197e
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt
@@ -0,0 +1,93 @@
+package su.reya.coop
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.Service
+import android.content.Intent
+import android.os.Build
+import android.os.IBinder
+import androidx.annotation.RequiresApi
+import androidx.core.app.NotificationCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.ProcessLifecycleOwner
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import java.io.File
+
+class NostrForegroundService : Service() {
+ private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ private val nostr = NostrManager.instance
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ private fun isUserInApp(): Boolean {
+ return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ createNotificationChannel()
+ val notification = createNotification("Connecting to Nostr...")
+ startForeground(1, notification)
+
+ serviceScope.launch {
+ try {
+ val dbDir = File(filesDir, "nostr")
+ dbDir.mkdirs()
+
+ // Initialize Nostr client
+ nostr.init(dbDir.absolutePath)
+
+ // Handle notifications
+ nostr.handleLiteNotifications { event ->
+ if (!isUserInApp()) {
+ showNewMessageNotification(event.content())
+ }
+ }
+ } catch (e: Exception) {
+ println("Failed to start Nostr in background: ${e.message}")
+ }
+ }
+
+ return START_STICKY
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ private fun createNotificationChannel() {
+ val channel = NotificationChannel(
+ "nostr_service",
+ "Nostr Background Service",
+ NotificationManager.IMPORTANCE_HIGH
+ )
+ val manager = getSystemService(NotificationManager::class.java)
+ manager?.createNotificationChannel(channel)
+ }
+
+ private fun createNotification(content: String): Notification {
+ return NotificationCompat.Builder(this, "nostr_service")
+ .setContentTitle("Coop")
+ .setContentText(content)
+ .setSmallIcon(android.R.drawable.ic_menu_send)
+ .setOngoing(true)
+ .build()
+ }
+
+ private fun showNewMessageNotification(message: String) {
+ val notification = NotificationCompat.Builder(this, "nostr_service")
+ .setContentTitle("New Message")
+ .setContentText(message)
+ .setAutoCancel(true)
+ .build()
+ val manager = getSystemService(NotificationManager::class.java)
+ manager?.notify(System.currentTimeMillis().toInt(), notification)
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ serviceScope.cancel()
+ }
+}
diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt
index 48863a9..22aad08 100644
--- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt
+++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt
@@ -52,7 +52,12 @@ import rust.nostr.sdk.initLogger
import rust.nostr.sdk.nip17ExtractRelayList
import kotlin.time.Duration
+object NostrManager {
+ val instance = Nostr()
+}
+
class Nostr {
+ private var isInitialized = false
var client: Client? = null
private set
var signer: UniversalSigner = UniversalSigner(Keys.generate())
@@ -64,6 +69,8 @@ class Nostr {
suspend fun init(dbPath: String) {
try {
+ if (isInitialized) return
+
// Initialize the logger for nostr client
initLogger(LogLevel.DEBUG)
@@ -105,6 +112,8 @@ class Nostr {
// Connect to all bootstrap relays and wait for all connections to be established
client?.connect(Duration.parse("3s"))
+
+ isInitialized = true
} catch (e: Exception) {
throw IllegalStateException("Failed to initialize Nostr client: ${e.message}", e)
}
@@ -119,9 +128,9 @@ class Nostr {
deviceSigner = null
}
- suspend fun setSigner(keys: AsyncNostrSigner) {
+ suspend fun setSigner(new: AsyncNostrSigner) {
try {
- signer.switch(keys)
+ signer.switch(new)
// Fetch metadata for current user
getUserMetadata()
} catch (e: Exception) {
@@ -184,18 +193,69 @@ class Nostr {
client?.subscribe(
target = ReqTarget.manual(target),
- id = "messages"
+ id = "all-gift-wraps"
)
} catch (e: Exception) {
throw IllegalStateException("Failed to fetch user messages: ${e.message}", e)
}
}
+ suspend fun handleLiteNotifications(
+ onNewMessage: (UnsignedEvent) -> Unit,
+ ) {
+ val now = Timestamp.now()
+ val processedEvent = mutableSetOf()
+ val notifications = client?.notifications() ?: return
+
+ while (true) {
+ val notification = notifications.next() ?: continue
+
+ when (notification) {
+ is ClientNotification.Message -> {
+ val relayUrl = notification.relayUrl
+
+ when (val message = notification.message.asEnum()) {
+ is RelayMessageEnum.EventMsg -> {
+ val event = message.event
+ val subscriptionId = message.subscriptionId
+
+ // Ignore events not from the newest gift wraps subscription
+ if (subscriptionId != "newest-gift-wraps") continue
+
+ // Prevent processing duplicate events
+ if (processedEvent.contains(event.id())) continue
+ processedEvent.add(event.id())
+
+ if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) {
+ try {
+ val rumor = extractRumor(event)
+
+ // Handle new message
+ rumor?.createdAt()?.asSecs()?.let {
+ if (it >= now.asSecs()) {
+ onNewMessage(rumor)
+ }
+ }
+ } catch (e: Exception) {
+ println("Failed to extract rumor: $e")
+ }
+ }
+ }
+
+ else -> {}
+ }
+ }
+
+ else -> {}
+ }
+ }
+ }
+
suspend fun handleNotifications(
onMetadataUpdate: (PublicKey, Metadata) -> Unit,
onContactListUpdate: (List) -> Unit,
onNewMessage: (UnsignedEvent) -> Unit,
- onEose: () -> Unit,
+ onSubscriptionClose: () -> Unit,
) = coroutineScope {
val now = Timestamp.now()
val processedEvent = mutableSetOf()
@@ -251,7 +311,7 @@ class Nostr {
// Start a new tracker
eoseTrackerJob = launch {
delay(10000) // Wait for 10 seconds
- onEose()
+ onSubscriptionClose()
}
// Handle new message
@@ -270,7 +330,7 @@ class Nostr {
val subscriptionId = message.subscriptionId
if (subscriptionId == "messages") {
- onEose()
+ onSubscriptionClose()
}
}
@@ -612,7 +672,9 @@ class Nostr {
signer = signer,
receiverPubkey = receiver,
rumor = rumor,
- extraTags = tags
+ extraTags = listOf(
+ Tag.custom(TagKind.Unknown("k"), listOf("14"))
+ )
)
// Send the event to receiver's NIP-17 relays
diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt
index 23d38bd..840e09c 100644
--- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt
+++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt
@@ -131,14 +131,11 @@ class NostrViewModel(
_metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }.value = metadata
}
- suspend fun initAndConnect(dbPath: String) {
+ suspend fun login() {
try {
- // Initialize nostr client
- nostr.init(dbPath)
- // Get user's secret
getUserSecret()
} catch (e: Exception) {
- showError("Failed to initialize Nostr: ${e.message}")
+ showError("Failed to login: ${e.message}")
}
}
@@ -151,7 +148,7 @@ class NostrViewModel(
onContactListUpdate = { contactList ->
_contactList.value = contactList.toSet()
},
- onEose = {
+ onSubscriptionClose = {
getChatRooms()
},
onNewMessage = { event ->