feat: implement basic notification (#6)
Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
@@ -18,15 +18,29 @@
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.App.Starting">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="chat"
|
||||
android:scheme="coop" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".NostrForegroundService"
|
||||
android:enabled="true"
|
||||
|
||||
@@ -39,14 +39,13 @@ import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navDeepLink
|
||||
import androidx.navigation.toRoute
|
||||
import kotlinx.coroutines.launch
|
||||
import su.reya.coop.coop.storage.SecretStore
|
||||
import su.reya.coop.screens.ChatScreen
|
||||
import su.reya.coop.screens.HomeScreen
|
||||
import su.reya.coop.screens.ImportScreen
|
||||
@@ -72,7 +71,7 @@ val LocalNavController = staticCompositionLocalOf<NavController> {
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun App() {
|
||||
fun App(viewModel: NostrViewModel) {
|
||||
val context = LocalContext.current
|
||||
val navController = rememberNavController()
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -81,17 +80,15 @@ fun App() {
|
||||
// Snackbar
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
// Initialize Nostr View Model and Secret Store
|
||||
val secretStore = remember { SecretStore(context) }
|
||||
val viewModel: NostrViewModel = viewModel { NostrViewModel(NostrManager.instance, secretStore) }
|
||||
|
||||
// Enabled the dynamic color scheme
|
||||
val colorScheme = when {
|
||||
// Enable the dynamic color scheme for Android 12+
|
||||
android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> {
|
||||
if (darkMode) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
|
||||
// When dark mode is enabled, use the dark color scheme
|
||||
darkMode -> darkColorScheme()
|
||||
// Fallback to the light color scheme
|
||||
else -> expressiveLightColorScheme()
|
||||
}
|
||||
|
||||
@@ -111,21 +108,107 @@ fun App() {
|
||||
LocalSnackbarHostState provides snackbarHostState,
|
||||
LocalNavController provides navController,
|
||||
) {
|
||||
val emptySecret by viewModel.emptySecret.collectAsState(initial = null)
|
||||
val signerRequired by viewModel.signerRequired.collectAsState(initial = null)
|
||||
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsState()
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
|
||||
LaunchedEffect(emptySecret) {
|
||||
LaunchedEffect(signerRequired) {
|
||||
// Navigate to the home screen if the secret is already set
|
||||
if (emptySecret == false) {
|
||||
if (signerRequired == false) {
|
||||
navController.navigate(Screen.Home) {
|
||||
popUpTo(Screen.Onboarding) { inclusive = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show loading screen while initializing
|
||||
if (emptySecret == null) return@CompositionLocalProvider
|
||||
// Keep the splash screen visible until the secret check is complete
|
||||
if (signerRequired == null) {
|
||||
return@CompositionLocalProvider
|
||||
}
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = if (signerRequired!!) Screen.Onboarding else Screen.Home
|
||||
) {
|
||||
composable<Screen.Onboarding> { backStackEntry ->
|
||||
OnboardingScreen(
|
||||
onOpenImport = { navController.navigate(Screen.Import) },
|
||||
onOpenNew = { navController.navigate(Screen.NewIdentity) }
|
||||
)
|
||||
}
|
||||
composable<Screen.Import> { backStackEntry ->
|
||||
val isCreating by viewModel.isCreating.collectAsState()
|
||||
|
||||
ImportScreen(
|
||||
isLoading = isCreating,
|
||||
onBack = { navController.popBackStack() },
|
||||
onSave = { secret ->
|
||||
viewModel.importIdentity(secret)
|
||||
}
|
||||
)
|
||||
}
|
||||
composable<Screen.NewIdentity> { backStackEntry ->
|
||||
val isCreating by viewModel.isCreating.collectAsState()
|
||||
|
||||
NewIdentityScreen(
|
||||
isLoading = isCreating,
|
||||
onBack = { navController.popBackStack() },
|
||||
onSave = { name, bio, uri ->
|
||||
val contentType = uri?.let { context.contentResolver.getType(it) }
|
||||
val picture = uri?.let {
|
||||
context.contentResolver.openInputStream(it)?.use { input ->
|
||||
input.readBytes()
|
||||
}
|
||||
}
|
||||
viewModel.createIdentity(name, bio, picture, contentType)
|
||||
}
|
||||
)
|
||||
}
|
||||
composable<Screen.Home> { backStackEntry ->
|
||||
HomeScreen(
|
||||
onOpenChat = { id -> navController.navigate(Screen.Chat(id)) },
|
||||
onNewChat = { navController.navigate(Screen.NewChat) }
|
||||
)
|
||||
}
|
||||
composable<Screen.Chat>(
|
||||
deepLinks = listOf(
|
||||
navDeepLink<Screen.Chat>(basePath = "coop://chat")
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val chat: Screen.Chat = backStackEntry.toRoute()
|
||||
ChatScreen(
|
||||
id = chat.id,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable<Screen.Profile> { backStackEntry ->
|
||||
val profile: Screen.Profile = backStackEntry.toRoute()
|
||||
ProfileScreen(
|
||||
pubkey = profile.pubkey,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable<Screen.NewChat> { backStackEntry ->
|
||||
NewChatScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable<Screen.Scan> { backStackEntry ->
|
||||
ScanScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable<Screen.MyQr> { backStackEntry ->
|
||||
MyQrScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable<Screen.Relay> { backStackEntry ->
|
||||
RelayScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Show the relay setup dialog if the msg relay list is empty
|
||||
if (isRelayListEmpty) {
|
||||
@@ -181,86 +264,6 @@ fun App() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = if (emptySecret == false) Screen.Home else Screen.Onboarding
|
||||
) {
|
||||
composable<Screen.Onboarding> { backStackEntry ->
|
||||
OnboardingScreen(
|
||||
onOpenImport = { navController.navigate(Screen.Import) },
|
||||
onOpenNew = { navController.navigate(Screen.NewIdentity) }
|
||||
)
|
||||
}
|
||||
composable<Screen.Import> { backStackEntry ->
|
||||
val isCreating by viewModel.isCreating.collectAsState()
|
||||
|
||||
ImportScreen(
|
||||
isLoading = isCreating,
|
||||
onBack = { navController.popBackStack() },
|
||||
onSave = { secret ->
|
||||
viewModel.importIdentity(secret)
|
||||
}
|
||||
)
|
||||
}
|
||||
composable<Screen.NewIdentity> { backStackEntry ->
|
||||
val isCreating by viewModel.isCreating.collectAsState()
|
||||
|
||||
NewIdentityScreen(
|
||||
isLoading = isCreating,
|
||||
onBack = { navController.popBackStack() },
|
||||
onSave = { name, bio, uri ->
|
||||
val contentType = uri?.let { context.contentResolver.getType(it) }
|
||||
val picture = uri?.let {
|
||||
context.contentResolver.openInputStream(it)?.use { input ->
|
||||
input.readBytes()
|
||||
}
|
||||
}
|
||||
viewModel.createIdentity(name, bio, picture, contentType)
|
||||
}
|
||||
)
|
||||
}
|
||||
composable<Screen.Home> { backStackEntry ->
|
||||
HomeScreen(
|
||||
onOpenChat = { id -> navController.navigate(Screen.Chat(id)) },
|
||||
onNewChat = { navController.navigate(Screen.NewChat) }
|
||||
)
|
||||
}
|
||||
composable<Screen.Chat> { backStackEntry ->
|
||||
val chat: Screen.Chat = backStackEntry.toRoute()
|
||||
ChatScreen(
|
||||
id = chat.id,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable<Screen.Profile> { backStackEntry ->
|
||||
val profile: Screen.Profile = backStackEntry.toRoute()
|
||||
ProfileScreen(
|
||||
pubkey = profile.pubkey,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable<Screen.NewChat> { backStackEntry ->
|
||||
NewChatScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable<Screen.Scan> { backStackEntry ->
|
||||
ScanScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable<Screen.MyQr> { backStackEntry ->
|
||||
MyQrScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable<Screen.Relay> { backStackEntry ->
|
||||
RelayScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,25 +6,48 @@ import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import su.reya.coop.coop.storage.SecretStore
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val viewModel: NostrViewModel by viewModels {
|
||||
object : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
val secretStore = SecretStore(this@MainActivity)
|
||||
return NostrViewModel(NostrManager.instance, secretStore) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen()
|
||||
val splashScreen = installSplashScreen()
|
||||
enableEdgeToEdge()
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val intent = Intent(this, NostrForegroundService::class.java)
|
||||
val serviceIntent = Intent(this, NostrForegroundService::class.java)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
startForegroundService(intent)
|
||||
startForegroundService(serviceIntent)
|
||||
} else {
|
||||
startService(intent)
|
||||
startService(serviceIntent)
|
||||
}
|
||||
|
||||
// Keep the splash screen visible until the signer check is complete
|
||||
splashScreen.setKeepOnScreenCondition {
|
||||
viewModel.signerRequired.value == null
|
||||
}
|
||||
|
||||
setContent {
|
||||
App()
|
||||
App(viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,14 @@ package su.reya.coop
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
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.core.net.toUri
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -31,7 +33,8 @@ class NostrForegroundService : Service() {
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
createNotificationChannel()
|
||||
val notification = createNotification("Connecting to Nostr...")
|
||||
|
||||
val notification = createNotification()
|
||||
startForeground(1, notification)
|
||||
|
||||
serviceScope.launch {
|
||||
@@ -43,11 +46,25 @@ class NostrForegroundService : Service() {
|
||||
// Connect to bootstrap relays
|
||||
nostr.connectBootstrapRelays()
|
||||
// Handle notifications
|
||||
nostr.handleLiteNotifications { event ->
|
||||
if (!isUserInApp()) {
|
||||
showNewMessageNotification(event.content())
|
||||
nostr.handleNotifications(
|
||||
onMetadataUpdate = { pubkey, metadata ->
|
||||
serviceScope.launch { nostr.emitMetadataUpdate(pubkey, metadata) }
|
||||
},
|
||||
onContactListUpdate = { contacts ->
|
||||
serviceScope.launch { nostr.emitContactListUpdate(contacts) }
|
||||
},
|
||||
onSubscriptionClose = {
|
||||
serviceScope.launch { nostr.emitSubscriptionClosed() }
|
||||
},
|
||||
onNewMessage = { event ->
|
||||
serviceScope.launch {
|
||||
if (!isUserInApp()) {
|
||||
showNewMessageNotification(event.roomId(), event.content())
|
||||
}
|
||||
nostr.emitNewEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
println("Failed to start Nostr in background: ${e.message}")
|
||||
}
|
||||
@@ -58,30 +75,68 @@ class NostrForegroundService : Service() {
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun createNotificationChannel() {
|
||||
val channel = NotificationChannel(
|
||||
"nostr_service",
|
||||
"Nostr Background Service",
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
|
||||
val serviceChannel = NotificationChannel(
|
||||
"nostr_service_silent",
|
||||
"Nostr Background Status",
|
||||
NotificationManager.IMPORTANCE_MIN
|
||||
).apply {
|
||||
setShowBadge(false)
|
||||
}
|
||||
manager?.createNotificationChannel(serviceChannel)
|
||||
|
||||
val messageChannel = NotificationChannel(
|
||||
"nostr_messages",
|
||||
"New Messages",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
)
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager?.createNotificationChannel(channel)
|
||||
manager?.createNotificationChannel(messageChannel)
|
||||
}
|
||||
|
||||
private fun createNotification(content: String): Notification {
|
||||
return NotificationCompat.Builder(this, "nostr_service")
|
||||
.setContentTitle("Coop")
|
||||
.setContentText(content)
|
||||
.setSmallIcon(android.R.drawable.ic_menu_send)
|
||||
private fun createNotification(content: String? = null): Notification {
|
||||
val builder = NotificationCompat.Builder(this, "nostr_service")
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.setCategory(Notification.CATEGORY_SERVICE)
|
||||
|
||||
if (content != null) {
|
||||
builder.setContentTitle("Coop")
|
||||
builder.setContentText(content)
|
||||
} else {
|
||||
builder.setContentTitle("Coop is active")
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun showNewMessageNotification(message: String) {
|
||||
val notification = NotificationCompat.Builder(this, "nostr_service")
|
||||
.setContentTitle("New Message")
|
||||
private fun showNewMessageNotification(roomId: Long, message: String) {
|
||||
val deepLinkUri = "coop://chat/$roomId".toUri()
|
||||
|
||||
val intent = Intent(
|
||||
Intent.ACTION_VIEW,
|
||||
deepLinkUri,
|
||||
this,
|
||||
MainActivity::class.java
|
||||
)
|
||||
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
roomId.toInt(),
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val notification = NotificationCompat.Builder(this, "nostr_messages")
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle("You received a new message")
|
||||
.setContentText(message)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setCategory(Notification.CATEGORY_MESSAGE)
|
||||
.build()
|
||||
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager?.notify(System.currentTimeMillis().toInt(), notification)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -37,6 +39,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -90,19 +93,16 @@ fun ChatScreen(
|
||||
|
||||
var text by remember { mutableStateOf("") }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var newOtherMessages by remember { mutableIntStateOf(0) }
|
||||
|
||||
val messages = remember { mutableStateListOf<UnsignedEvent>() }
|
||||
val groupedMessages = remember(messages.toList()) {
|
||||
messages.groupBy { it.createdAt().formatAsGroupHeader() }
|
||||
}
|
||||
|
||||
fun setLoading(value: Boolean) {
|
||||
loading = value
|
||||
}
|
||||
|
||||
LaunchedEffect(id) {
|
||||
// Start loading spinner
|
||||
setLoading(true)
|
||||
loading = true
|
||||
|
||||
// Get messages
|
||||
val initialMessages = viewModel.getChatRoomMessages(id)
|
||||
@@ -122,7 +122,7 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
// Stop loading spinner
|
||||
setLoading(false)
|
||||
loading = false
|
||||
|
||||
// Handle new messages
|
||||
viewModel.newEvents.collect { event ->
|
||||
@@ -130,6 +130,9 @@ fun ChatScreen(
|
||||
if (event.id() !in messages.map { it.id() }) {
|
||||
messages.add(0, event)
|
||||
}
|
||||
} else {
|
||||
// If the event is not in the current room, it's a new message from another user
|
||||
newOtherMessages++
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,11 +176,21 @@ fun ChatScreen(
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
BadgedBox(
|
||||
badge = {
|
||||
if (newOtherMessages > 0) {
|
||||
Badge {
|
||||
Text(newOtherMessages.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
|
||||
BIN
composeApp/src/androidMain/res/drawable-hdpi/ic_notification.png
Normal file
BIN
composeApp/src/androidMain/res/drawable-hdpi/ic_notification.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 551 B |
BIN
composeApp/src/androidMain/res/drawable-mdpi/ic_notification.png
Normal file
BIN
composeApp/src/androidMain/res/drawable-mdpi/ic_notification.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 391 B |
Binary file not shown.
|
After Width: | Height: | Size: 727 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
Reference in New Issue
Block a user