Private
Public Access
1
0

Implements notifications

This commit is contained in:
2024-03-28 23:40:18 -07:00
parent 8d63c5f1f5
commit c2786d268f
6 changed files with 180 additions and 11 deletions

View File

@@ -2,6 +2,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application <application
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
@@ -32,6 +35,8 @@
android:name="android.app.lib_name" android:name="android.app.lib_name"
android:value="" /> android:value="" />
</activity> </activity>
<service android:name=".UpdateMonitorService" />
</application> </application>
</manifest> </manifest>

View File

@@ -1,16 +1,31 @@
package net.buzzert.kordophonedroid package net.buzzert.kordophonedroid
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
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.core.app.ActivityCompat
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import net.buzzert.kordophonedroid.ui.KordophoneApp import net.buzzert.kordophonedroid.ui.KordophoneApp
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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 { setContent {
KordophoneApp() KordophoneApp()
} }

View File

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

View File

@@ -56,11 +56,6 @@ class ConversationListViewModel @Inject constructor(
} }
} }
} }
// Start watching for updates
viewModelScope.launch {
chatRepository.beginWatchingForUpdates(this)
}
} }
suspend fun refresh() { suspend fun refresh() {

View File

@@ -28,6 +28,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment 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.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.app.NotificationManagerCompat
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.SubcomposeAsyncImage import coil.compose.SubcomposeAsyncImage
@@ -84,8 +87,14 @@ fun MessageListScreen(
viewModel.conversationGUID = conversationGUID viewModel.conversationGUID = conversationGUID
// Synchronize on launch // Synchronize on launch
val context = LocalContext.current
LaunchedEffect(Unit) { 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.markAsRead()
viewModel.synchronize() viewModel.synchronize()
} }

View File

@@ -10,6 +10,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@@ -53,16 +54,20 @@ class ChatRepository(
// Channel that's signaled when an outgoing message is delivered. // Channel that's signaled when an outgoing message is delivered.
val messageDeliveredChannel: SharedFlow<MessageDeliveredEvent> val messageDeliveredChannel: SharedFlow<MessageDeliveredEvent>
get() = _messageDeliveredChannel get() = _messageDeliveredChannel.asSharedFlow()
// Changes Flow // Changes Flow
val conversationChanges: Flow<List<Conversation>> val conversationChanges: Flow<List<Conversation>>
get() = database.conversationChanges get() = database.conversationChanges
.onEach { Log.d(REPO_LOG, "Got database conversations changed") } .onEach { Log.d(REPO_LOG, "Got database conversations changed") }
// New Messages
val newMessages: SharedFlow<Message>
get() = _newMessageChannel.asSharedFlow()
// Errors channel // Errors channel
val errorEncounteredChannel: SharedFlow<Error> val errorEncounteredChannel: SharedFlow<Error>
get() = _errorEncounteredChannel get() = _errorEncounteredChannel.asSharedFlow()
fun messagesChanged(conversation: Conversation): Flow<List<Message>> = fun messagesChanged(conversation: Conversation): Flow<List<Message>> =
database.messagesChanged(conversation) database.messagesChanged(conversation)
@@ -91,6 +96,7 @@ class ChatRepository(
private var outgoingMessageThread: Thread? = null private var outgoingMessageThread: Thread? = null
private val _messageDeliveredChannel = MutableSharedFlow<MessageDeliveredEvent>() private val _messageDeliveredChannel = MutableSharedFlow<MessageDeliveredEvent>()
private val _errorEncounteredChannel = MutableSharedFlow<Error>() private val _errorEncounteredChannel = MutableSharedFlow<Error>()
private val _newMessageChannel = MutableSharedFlow<Message>()
private var updateMonitor = UpdateMonitor(apiClient) private var updateMonitor = UpdateMonitor(apiClient)
private var updateWatchJob: Job? = null private var updateWatchJob: Job? = null
@@ -218,17 +224,18 @@ class ChatRepository(
.onEach { it.conversation = conversation } .onEach { it.conversation = conversation }
} }
private fun handleConversationChangedUpdate(conversation: Conversation) { private suspend fun handleConversationChangedUpdate(conversation: Conversation) {
Log.d(REPO_LOG, "Handling conversation changed update") Log.d(REPO_LOG, "Handling conversation changed update")
database.writeConversations(listOf(conversation)) database.writeConversations(listOf(conversation))
} }
private fun handleMessageAddedUpdate(message: Message) { private suspend fun handleMessageAddedUpdate(message: Message) {
Log.d(REPO_LOG, "Handling messages added update") Log.d(REPO_LOG, "Handling messages added update")
database.writeMessages(listOf(message), message.conversation) 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") Log.d(REPO_LOG, "Handling successful delivery event")
// Unfortunate protocol reality: the server doesn't tell us about new messages that are from us, // Unfortunate protocol reality: the server doesn't tell us about new messages that are from us,