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 ->