add nostr foreground service

This commit is contained in:
2026-05-18 12:34:46 +07:00
parent 955da2fea6
commit 1c85e26e7f
7 changed files with 189 additions and 24 deletions

View File

@@ -27,6 +27,7 @@ kotlin {
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0") implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
implementation("su.reya:nostr-sdk-kmp:0.2.3") implementation("su.reya:nostr-sdk-kmp:0.2.3")
implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0") implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0")
implementation("androidx.lifecycle:lifecycle-process:2.8.0")
} }
commonMain.dependencies { commonMain.dependencies {
implementation(libs.compose.runtime) implementation(libs.compose.runtime)

View File

@@ -7,6 +7,9 @@
android:required="false" /> android:required="false" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -23,6 +26,11 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<service
android:name=".NostrForegroundService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application> </application>
</manifest> </manifest>

View File

@@ -45,7 +45,7 @@ val LocalNavController = staticCompositionLocalOf<NavController> {
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun App(dbPath: String) { fun App() {
val context = LocalContext.current val context = LocalContext.current
val navController = rememberNavController() val navController = rememberNavController()
val darkMode = isSystemInDarkTheme() val darkMode = isSystemInDarkTheme()
@@ -53,11 +53,10 @@ fun App(dbPath: String) {
// Snackbar // Snackbar
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
// Initialize Nostr and SecretStore // Initialize Nostr View Model and Secret Store
val nostr = remember { Nostr() }
val secretStore = remember { SecretStore(context) } 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 // Enabled the dynamic color scheme
val colorScheme = when { val colorScheme = when {
android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> { android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> {
@@ -69,7 +68,7 @@ fun App(dbPath: String) {
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.initAndConnect(dbPath) viewModel.login()
viewModel.startNotificationHandler() viewModel.startNotificationHandler()
viewModel.getChatRooms() viewModel.getChatRooms()

View File

@@ -1,22 +1,27 @@
package su.reya.coop package su.reya.coop
import android.content.Intent
import android.os.Build
import android.os.Bundle import android.os.Bundle
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 java.io.File
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Get database directory val intent = Intent(this, NostrForegroundService::class.java)
val dbDir = File(filesDir, "nostr")
dbDir.mkdirs() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
setContent { setContent {
App(dbDir.absolutePath) App()
} }
} }
} }

View File

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

View File

@@ -52,7 +52,12 @@ import rust.nostr.sdk.initLogger
import rust.nostr.sdk.nip17ExtractRelayList import rust.nostr.sdk.nip17ExtractRelayList
import kotlin.time.Duration import kotlin.time.Duration
object NostrManager {
val instance = Nostr()
}
class Nostr { class Nostr {
private var isInitialized = false
var client: Client? = null var client: Client? = null
private set private set
var signer: UniversalSigner = UniversalSigner(Keys.generate()) var signer: UniversalSigner = UniversalSigner(Keys.generate())
@@ -64,6 +69,8 @@ class Nostr {
suspend fun init(dbPath: String) { suspend fun init(dbPath: String) {
try { try {
if (isInitialized) return
// Initialize the logger for nostr client // Initialize the logger for nostr client
initLogger(LogLevel.DEBUG) initLogger(LogLevel.DEBUG)
@@ -105,6 +112,8 @@ class Nostr {
// Connect to all bootstrap relays and wait for all connections to be established // Connect to all bootstrap relays and wait for all connections to be established
client?.connect(Duration.parse("3s")) client?.connect(Duration.parse("3s"))
isInitialized = true
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to initialize Nostr client: ${e.message}", e) throw IllegalStateException("Failed to initialize Nostr client: ${e.message}", e)
} }
@@ -119,9 +128,9 @@ class Nostr {
deviceSigner = null deviceSigner = null
} }
suspend fun setSigner(keys: AsyncNostrSigner) { suspend fun setSigner(new: AsyncNostrSigner) {
try { try {
signer.switch(keys) signer.switch(new)
// Fetch metadata for current user // Fetch metadata for current user
getUserMetadata() getUserMetadata()
} catch (e: Exception) { } catch (e: Exception) {
@@ -184,18 +193,69 @@ class Nostr {
client?.subscribe( client?.subscribe(
target = ReqTarget.manual(target), target = ReqTarget.manual(target),
id = "messages" id = "all-gift-wraps"
) )
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to fetch user messages: ${e.message}", e) throw IllegalStateException("Failed to fetch user messages: ${e.message}", e)
} }
} }
suspend fun handleLiteNotifications(
onNewMessage: (UnsignedEvent) -> Unit,
) {
val now = Timestamp.now()
val processedEvent = mutableSetOf<EventId>()
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( suspend fun handleNotifications(
onMetadataUpdate: (PublicKey, Metadata) -> Unit, onMetadataUpdate: (PublicKey, Metadata) -> Unit,
onContactListUpdate: (List<PublicKey>) -> Unit, onContactListUpdate: (List<PublicKey>) -> Unit,
onNewMessage: (UnsignedEvent) -> Unit, onNewMessage: (UnsignedEvent) -> Unit,
onEose: () -> Unit, onSubscriptionClose: () -> Unit,
) = coroutineScope { ) = coroutineScope {
val now = Timestamp.now() val now = Timestamp.now()
val processedEvent = mutableSetOf<EventId>() val processedEvent = mutableSetOf<EventId>()
@@ -251,7 +311,7 @@ class Nostr {
// Start a new tracker // Start a new tracker
eoseTrackerJob = launch { eoseTrackerJob = launch {
delay(10000) // Wait for 10 seconds delay(10000) // Wait for 10 seconds
onEose() onSubscriptionClose()
} }
// Handle new message // Handle new message
@@ -270,7 +330,7 @@ class Nostr {
val subscriptionId = message.subscriptionId val subscriptionId = message.subscriptionId
if (subscriptionId == "messages") { if (subscriptionId == "messages") {
onEose() onSubscriptionClose()
} }
} }
@@ -612,7 +672,9 @@ class Nostr {
signer = signer, signer = signer,
receiverPubkey = receiver, receiverPubkey = receiver,
rumor = rumor, rumor = rumor,
extraTags = tags extraTags = listOf(
Tag.custom(TagKind.Unknown("k"), listOf("14"))
)
) )
// Send the event to receiver's NIP-17 relays // Send the event to receiver's NIP-17 relays

View File

@@ -131,14 +131,11 @@ class NostrViewModel(
_metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }.value = metadata _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }.value = metadata
} }
suspend fun initAndConnect(dbPath: String) { suspend fun login() {
try { try {
// Initialize nostr client
nostr.init(dbPath)
// Get user's secret
getUserSecret() getUserSecret()
} catch (e: Exception) { } catch (e: Exception) {
showError("Failed to initialize Nostr: ${e.message}") showError("Failed to login: ${e.message}")
} }
} }
@@ -151,7 +148,7 @@ class NostrViewModel(
onContactListUpdate = { contactList -> onContactListUpdate = { contactList ->
_contactList.value = contactList.toSet() _contactList.value = contactList.toSet()
}, },
onEose = { onSubscriptionClose = {
getChatRooms() getChatRooms()
}, },
onNewMessage = { event -> onNewMessage = { event ->