From c2786d268f0b9747d59f3b60dcab0414eaf21f2f Mon Sep 17 00:00:00 2001 From: James Magahern Date: Thu, 28 Mar 2024 23:40:18 -0700 Subject: [PATCH] Implements notifications --- app/src/main/AndroidManifest.xml | 5 + .../buzzert/kordophonedroid/MainActivity.kt | 15 ++ .../kordophonedroid/UpdateMonitorService.kt | 138 ++++++++++++++++++ .../ConversationListViewModel.kt | 5 - .../ui/messagelist/MessageListScreen.kt | 11 +- .../backend/server/ChatRepository.kt | 17 ++- 6 files changed, 180 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/net/buzzert/kordophonedroid/UpdateMonitorService.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c571bfd..92213c5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/java/net/buzzert/kordophonedroid/MainActivity.kt b/app/src/main/java/net/buzzert/kordophonedroid/MainActivity.kt index f92b989..5473810 100644 --- a/app/src/main/java/net/buzzert/kordophonedroid/MainActivity.kt +++ b/app/src/main/java/net/buzzert/kordophonedroid/MainActivity.kt @@ -1,16 +1,31 @@ package net.buzzert.kordophonedroid +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.core.app.ActivityCompat import dagger.hilt.android.AndroidEntryPoint import net.buzzert.kordophonedroid.ui.KordophoneApp + @AndroidEntryPoint class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // Ask for notifications + val hasPermission = ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) + if (hasPermission != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1234) + } + + // Start update monitor service + val intent = Intent(this, UpdateMonitorService::class.java) + startService(intent) + setContent { KordophoneApp() } diff --git a/app/src/main/java/net/buzzert/kordophonedroid/UpdateMonitorService.kt b/app/src/main/java/net/buzzert/kordophonedroid/UpdateMonitorService.kt new file mode 100644 index 0000000..4d790db --- /dev/null +++ b/app/src/main/java/net/buzzert/kordophonedroid/UpdateMonitorService.kt @@ -0,0 +1,138 @@ +package net.buzzert.kordophonedroid + +import android.Manifest +import android.R +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationChannelGroup +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Color +import android.os.IBinder +import android.util.Log +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import net.buzzert.kordophone.backend.model.Message +import net.buzzert.kordophone.backend.server.ChatRepository +import javax.inject.Inject + +const val PUSH_CHANNEL_ID = "net.buzzert.kordophone.persistentNotification" +const val NEW_MESSAGE_CHANNEL_ID = "net.buzzert.kordophone.newMessage" + +const val UPDATER_LOG = "UpdateService" + +@AndroidEntryPoint +class UpdateMonitorService: Service() +{ + @Inject lateinit var chatRepository: ChatRepository + + private var newMessageID: Int = 0 + private val job = SupervisorJob() + private val scope = CoroutineScope(Dispatchers.IO + job) + + private fun createNotificationChannel(channelId: String, channelName: String) { + val chan = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_DEFAULT) + chan.lockscreenVisibility = Notification.VISIBILITY_PRIVATE + chan.lightColor = Color.BLUE + + val service = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + service.createNotificationChannel(chan) + } + + override fun onCreate() { + super.onCreate() + + createNotificationChannel(NEW_MESSAGE_CHANNEL_ID, "New Messages") + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + createNotificationChannel(PUSH_CHANNEL_ID, "Update Monitor Service") + + val notificationIntent = Intent(this, MainActivity::class.java) + val pendingIntent = PendingIntent.getActivity( + this, + 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE + ) + + val notification: Notification = NotificationCompat.Builder(this, PUSH_CHANNEL_ID) + .setContentTitle("Kordophone Connected") + .setContentText("Kordophone is listening for new messages.") + .setSmallIcon(R.drawable.sym_action_chat) + .setContentIntent(pendingIntent) + .setShowWhen(false) + .setSilent(true) + .setOngoing(true) + .build() + + startForeground(5738, notification) + + // Connect to monitor + chatRepository.beginWatchingForUpdates(scope) + + // Connect to new message flow for notifications + scope.launch { + chatRepository.newMessages.collectLatest(::onReceiveNewMessage) + } + + // Restart if we get killed + return START_STICKY + } + + private fun onReceiveNewMessage(message: Message) { + val hasPermission = ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) + if (hasPermission != PackageManager.PERMISSION_GRANTED) { + Log.e(UPDATER_LOG, "No permissions to post notifications.") + return + } + + if (message.conversation.unreadCount == 0) { + // Not unread. + return + } + + if (message.sender == null) { + // From me. + return + } + + if (message.conversation.isGroupChat) { + // For now, since these can be noisy and there's no UI for changing it, ignore group chats. + return + } + + val groupId = message.conversation.guid + val notification = NotificationCompat.Builder(this, NEW_MESSAGE_CHANNEL_ID) + .setContentTitle(message.sender) + .setContentText(message.text) + .setSmallIcon(R.drawable.stat_notify_chat) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setGroup(groupId) + .build() + + val manager = NotificationManagerCompat.from(this) + manager.notify(newMessageID++, notification) + } + + override fun onBind(intent: Intent?): IBinder? { + // no binding + return null + } + + override fun onDestroy() { + super.onDestroy() + + chatRepository.stopWatchingForUpdates() + job.cancel() + } +} \ No newline at end of file diff --git a/app/src/main/java/net/buzzert/kordophonedroid/ui/conversationlist/ConversationListViewModel.kt b/app/src/main/java/net/buzzert/kordophonedroid/ui/conversationlist/ConversationListViewModel.kt index 56b0afe..3c0f3e6 100644 --- a/app/src/main/java/net/buzzert/kordophonedroid/ui/conversationlist/ConversationListViewModel.kt +++ b/app/src/main/java/net/buzzert/kordophonedroid/ui/conversationlist/ConversationListViewModel.kt @@ -56,11 +56,6 @@ class ConversationListViewModel @Inject constructor( } } } - - // Start watching for updates - viewModelScope.launch { - chatRepository.beginWatchingForUpdates(this) - } } suspend fun refresh() { diff --git a/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListScreen.kt b/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListScreen.kt index 9107eb4..9ab435c 100644 --- a/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListScreen.kt +++ b/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListScreen.kt @@ -28,6 +28,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCompositionContext +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -40,6 +42,7 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.core.app.NotificationManagerCompat import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.compose.SubcomposeAsyncImage @@ -84,8 +87,14 @@ fun MessageListScreen( viewModel.conversationGUID = conversationGUID // Synchronize on launch + val context = LocalContext.current LaunchedEffect(Unit) { - Log.d("MessageListScreen", "Launched effect") + // Clear notifications for this conversation + with(NotificationManagerCompat.from(context)) { + // Not sure how to cancel individual notifications, or groups yet... + cancelAll() + } + viewModel.markAsRead() viewModel.synchronize() } diff --git a/backend/src/main/java/net/buzzert/kordophone/backend/server/ChatRepository.kt b/backend/src/main/java/net/buzzert/kordophone/backend/server/ChatRepository.kt index 35b609b..bcc0c7f 100644 --- a/backend/src/main/java/net/buzzert/kordophone/backend/server/ChatRepository.kt +++ b/backend/src/main/java/net/buzzert/kordophone/backend/server/ChatRepository.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.onEach @@ -53,16 +54,20 @@ class ChatRepository( // Channel that's signaled when an outgoing message is delivered. val messageDeliveredChannel: SharedFlow - get() = _messageDeliveredChannel + get() = _messageDeliveredChannel.asSharedFlow() // Changes Flow val conversationChanges: Flow> get() = database.conversationChanges .onEach { Log.d(REPO_LOG, "Got database conversations changed") } + // New Messages + val newMessages: SharedFlow + get() = _newMessageChannel.asSharedFlow() + // Errors channel val errorEncounteredChannel: SharedFlow - get() = _errorEncounteredChannel + get() = _errorEncounteredChannel.asSharedFlow() fun messagesChanged(conversation: Conversation): Flow> = database.messagesChanged(conversation) @@ -91,6 +96,7 @@ class ChatRepository( private var outgoingMessageThread: Thread? = null private val _messageDeliveredChannel = MutableSharedFlow() private val _errorEncounteredChannel = MutableSharedFlow() + private val _newMessageChannel = MutableSharedFlow() private var updateMonitor = UpdateMonitor(apiClient) private var updateWatchJob: Job? = null @@ -218,17 +224,18 @@ class ChatRepository( .onEach { it.conversation = conversation } } - private fun handleConversationChangedUpdate(conversation: Conversation) { + private suspend fun handleConversationChangedUpdate(conversation: Conversation) { Log.d(REPO_LOG, "Handling conversation changed update") database.writeConversations(listOf(conversation)) } - private fun handleMessageAddedUpdate(message: Message) { + private suspend fun handleMessageAddedUpdate(message: Message) { Log.d(REPO_LOG, "Handling messages added update") database.writeMessages(listOf(message), message.conversation) + _newMessageChannel.emit(message) } - private fun handleMessageDelivered(event: MessageDeliveredEvent) { + private suspend fun handleMessageDelivered(event: MessageDeliveredEvent) { Log.d(REPO_LOG, "Handling successful delivery event") // Unfortunate protocol reality: the server doesn't tell us about new messages that are from us,