chore: merge the develop branch into master #1
@@ -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)
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
android:required="false" />
|
||||
|
||||
<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
|
||||
android:allowBackup="true"
|
||||
@@ -23,6 +26,11 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service
|
||||
android:name=".NostrForegroundService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -45,7 +45,7 @@ val LocalNavController = staticCompositionLocalOf<NavController> {
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun App(dbPath: String) {
|
||||
fun App() {
|
||||
val context = LocalContext.current
|
||||
val navController = rememberNavController()
|
||||
val darkMode = isSystemInDarkTheme()
|
||||
@@ -53,10 +53,9 @@ 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 {
|
||||
@@ -69,7 +68,7 @@ fun App(dbPath: String) {
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.initAndConnect(dbPath)
|
||||
viewModel.login()
|
||||
viewModel.startNotificationHandler()
|
||||
viewModel.getChatRooms()
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<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(
|
||||
onMetadataUpdate: (PublicKey, Metadata) -> Unit,
|
||||
onContactListUpdate: (List<PublicKey>) -> Unit,
|
||||
onNewMessage: (UnsignedEvent) -> Unit,
|
||||
onEose: () -> Unit,
|
||||
onSubscriptionClose: () -> Unit,
|
||||
) = coroutineScope {
|
||||
val now = Timestamp.now()
|
||||
val processedEvent = mutableSetOf<EventId>()
|
||||
@@ -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
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
Reference in New Issue
Block a user