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("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)
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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
|
||||||
|
|||||||
@@ -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 ->
|
||||||
|
|||||||
Reference in New Issue
Block a user