feat: push notifications with everything around

This commit is contained in:
infi 2026-05-22 18:52:45 +02:00
parent 1e2ef35df0
commit 2b84cbb342
7 changed files with 146 additions and 90 deletions

View File

@ -66,6 +66,10 @@
</intent-filter>
</service>
<receiver
android:name=".c2dm.ReplyReceiver"
android:exported="false" />
<activity
android:name=".activities.MainActivity"
android:exported="true"

View File

@ -75,6 +75,7 @@ import chat.stoat.R
import chat.stoat.StoatApplication
import chat.stoat.api.HitRateLimitException
import chat.stoat.api.StoatAPI
import chat.stoat.c2dm.NotificationDeepLink
import chat.stoat.api.StoatHttp
import chat.stoat.api.api
import chat.stoat.api.routes.microservices.geo.queryGeo
@ -354,6 +355,8 @@ class MainActivity : AppCompatActivity() {
StoatAPI.hydrateFromPersistentCache()
intent.getStringExtra("channelId")?.let { NotificationDeepLink.pendingChannelId.value = it }
setContent {
val windowSizeClass = calculateWindowSizeClass(this)
AppEntrypoint(

View File

@ -4,7 +4,6 @@ import android.app.PendingIntent
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
@ -17,22 +16,19 @@ import androidx.core.graphics.drawable.IconCompat
import chat.stoat.BuildConfig
import chat.stoat.R
import chat.stoat.activities.MainActivity
import chat.stoat.api.StoatJson
import chat.stoat.api.internals.ULID
import chat.stoat.api.routes.channel.fetchSingleChannel
import chat.stoat.api.routes.push.subscribePush
import chat.stoat.c2dm.ChannelRegistrator.Companion.CHANNEL_ID_GROUP_CONVERSATIONS_MESSAGES
import chat.stoat.core.model.data.STOAT_BASE
import chat.stoat.core.model.schemas.Message
import chat.stoat.core.model.schemas.User
import chat.stoat.core.model.schemas.ChannelType
import chat.stoat.persistence.Database
import chat.stoat.persistence.SqlStorage
import com.bumptech.glide.Glide
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import logcat.LogPriority
import logcat.logcat
object NotificationID {
const val NEW_MESSAGE = 0
@ -47,96 +43,82 @@ class HandlerService : FirebaseMessagingService() {
}
override fun onMessageReceived(fcmMessage: RemoteMessage) {
/// TEMPORARY CODE, SCHEMA TO BE REPLACED
val payloadString = fcmMessage.data["payload"]
if (payloadString == null) {
Log.e("HandlerService", "No payload in message, abort")
val data = fcmMessage.data
val type = data["type"]
if (type != "push.message") {
logcat(LogPriority.ERROR) { "Unknown message type: $type, abort" }
return
}
Log.d("HandlerService", payloadString)
val payload = StoatJson.parseToJsonElement(payloadString).jsonObject
val keys = payload.keys.toList().toString()
Log.d("HandlerService", "following keys: $keys")
var authorIcon = payload["icon"]?.jsonPrimitive?.contentOrNull
val message = payload["message"]?.jsonObject?.let {
StoatJson.decodeFromJsonElement(
Message.serializer(),
it
)
} ?: run {
Log.e("HandlerService", "No message in payload, abort")
val authorId = data["author"] ?: run {
logcat(LogPriority.ERROR) { "No author in message, abort" }
return
}
val user = payload["message"]?.jsonObject?.get("user")?.jsonObject?.let {
StoatJson.decodeFromJsonElement(
User.serializer(),
it
)
} ?: run {
Log.e("HandlerService", "No message->user in payload, abort")
val body = data["body"] ?: run {
logcat(LogPriority.ERROR) { "No body in message, abort" }
return
}
if (authorIcon == null) {
authorIcon =
"$STOAT_BASE/users/${message.author?.ifBlank { "0".repeat(26) }}/default_avatar"
val image = data["image"] ?: run {
logcat(LogPriority.WARN) { "No image in message, abort" }
return
}
val authorName = data["author_name"] ?: run {
logcat(LogPriority.ERROR) { "No author name in message, abort" }
return
}
val channelId = data["channel"] ?: run {
logcat(LogPriority.ERROR) { "No channel in message, abort" }
return
}
val messageId = data["message"] ?: run {
logcat(LogPriority.ERROR) { "No message ID in message, abort" }
return
}
val messageTimestamp = ULID.asTimestamp(messageId)
val db = Database(SqlStorage.driver)
val channelName = message.channel?.let {
db.channelQueries.findById(it).executeAsOneOrNull()
}?.let {
val channelName = db.channelQueries.findById(channelId).executeAsOneOrNull()?.let {
when (it.channelType) {
"DirectMessage" -> {
user.displayName ?: user.username
}
"TextChannel" -> {
"#${it.name}"
}
else -> {
it.name ?: getString(R.string.unknown)
}
"DirectMessage" -> authorName
"TextChannel" -> "#${it.name}"
else -> it.name ?: authorName
}
} ?: getString(
R.string.unknown
)
val messageTimestamp = message.id?.let { ULID.asTimestamp(it) } ?: run {
Log.e("HandlerService", "No message id in message, abort")
return
} ?: runBlocking {
runCatching { fetchSingleChannel(channelId) }.getOrNull()?.let {
when (it.channelType) {
ChannelType.DirectMessage -> authorName
ChannelType.TextChannel -> "#${it.name}"
else -> it.name ?: authorName
}
} ?: authorName
}
val bitmap = Glide.with(this)
.asBitmap()
.load(authorIcon)
.load(image)
.circleCrop()
.submit()
.get()
val author =
Person.Builder()
.setBot(user.bot != null)
.setKey(message.author)
.setIcon(IconCompat.createWithBitmap(bitmap))
.setName(user.displayName ?: user.username)
.build()
val author = Person.Builder()
.setBot(false)
.setKey(authorId)
.setIcon(IconCompat.createWithBitmap(bitmap))
.setName(authorName)
.build()
if (message.channel == null) {
Log.e("HandlerService", "No channel in message, abort")
return
}
val shortcutId = "${BuildConfig.APPLICATION_ID}.channel.${message.channel}"
val shortcutId = "${BuildConfig.APPLICATION_ID}.channel.$channelId"
val conversationIntent = Intent(this, MainActivity::class.java).apply {
action = Intent.ACTION_VIEW
putExtra("channelId", message.channel)
putExtra("channelId", channelId)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
@ -156,14 +138,18 @@ class HandlerService : FirebaseMessagingService() {
build()
}
val replyIntent = Intent(this, ReplyReceiver::class.java).apply {
putExtra("channelId", channelId)
}
val action: NotificationCompat.Action =
NotificationCompat.Action.Builder(
R.drawable.ic_reply_24dp,
getString(R.string.message_context_sheet_actions_reply),
PendingIntent.getActivity(
PendingIntent.getBroadcast(
this,
0,
conversationIntent,
channelId.hashCode(),
replyIntent,
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
)
@ -172,25 +158,21 @@ class HandlerService : FirebaseMessagingService() {
val contentIntent = PendingIntent.getActivity(
this,
message.channel.hashCode(),
channelId.hashCode(),
conversationIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val builder = NotificationCompat.Builder(this, CHANNEL_ID_GROUP_CONVERSATIONS_MESSAGES)
.setSmallIcon(R.drawable.ic_chat_24dp)
.setContentTitle(user.displayName ?: user.username)
.setContentText(message.content)
.setSmallIcon(R.drawable.ic_stoat_24dp)
.setContentTitle(authorName)
.setContentText(body)
.setContentIntent(contentIntent)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setStyle(
NotificationCompat.MessagingStyle(author)
.setConversationTitle(channelName)
.addMessage(
message.content ?: getString(R.string.reply_message_empty_has_attachments),
messageTimestamp,
author
)
.addMessage(body, messageTimestamp, author)
)
.addAction(action)
.setPriority(NotificationCompat.PRIORITY_HIGH)
@ -203,7 +185,7 @@ class HandlerService : FirebaseMessagingService() {
val bubbleIntent = PendingIntent.getActivity(
this,
message.channel.hashCode(),
channelId.hashCode(),
conversationIntent,
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
@ -228,8 +210,7 @@ class HandlerService : FirebaseMessagingService() {
) {
return
}
notify(message.channel, NotificationID.NEW_MESSAGE, builder.build())
notify(channelId, NotificationID.NEW_MESSAGE, builder.build())
}
/// END TEMPORARY CODE
}
}

View File

@ -0,0 +1,7 @@
package chat.stoat.c2dm
import kotlinx.coroutines.flow.MutableStateFlow
object NotificationDeepLink {
val pendingChannelId = MutableStateFlow<String?>(null)
}

View File

@ -0,0 +1,44 @@
package chat.stoat.c2dm
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.RemoteInput
import chat.stoat.api.StoatAPI
import chat.stoat.api.routes.channel.sendMessage
import chat.stoat.persistence.KVStorage
import kotlinx.coroutines.runBlocking
import logcat.LogPriority
import logcat.asLog
import logcat.logcat
class ReplyReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val channelId = intent.getStringExtra("channelId") ?: run {
logcat(LogPriority.ERROR) { "No channel ID, aborting" }
return
}
val content = RemoteInput.getResultsFromIntent(intent)
?.getCharSequence("content")
?.toString()
?.takeIf { it.isNotBlank() }
?: run {
logcat(LogPriority.ERROR) { "No content, aborting" }
return
}
runBlocking {
val token = KVStorage(context).get("sessionToken") ?: run {
logcat(LogPriority.ERROR) { "No session token, aborting" }
return@runBlocking
}
StoatAPI.setSessionHeader(token)
runCatching { sendMessage(channelId, content) }
.onFailure { logcat(LogPriority.ERROR) { "Send failed: ${it.asLog()}" } }
}
NotificationManagerCompat.from(context).cancel(channelId, NotificationID.NEW_MESSAGE)
}
}

View File

@ -80,6 +80,7 @@ import chat.stoat.composables.chat.DisconnectedNotice
import chat.stoat.composables.screens.chat.drawer.ChannelSideDrawer
import chat.stoat.core.model.schemas.ReleaseNotesSettings
import chat.stoat.dialogs.NotificationRationaleDialog
import chat.stoat.c2dm.NotificationDeepLink
import chat.stoat.internals.extensions.zero
import chat.stoat.persistence.KVStorage
import chat.stoat.screens.chat.dialogs.safety.ReportMessageDialog
@ -161,8 +162,14 @@ class ChatRouterViewModel(
init {
viewModelScope.launch {
val current = kvStorage.get("currentDestination")
setSaveDestination(ChatRouterDestination.fromString(current ?: ""))
val pendingChannel = NotificationDeepLink.pendingChannelId.value
if (pendingChannel != null) {
NotificationDeepLink.pendingChannelId.value = null
setSaveDestination(ChatRouterDestination.Channel(pendingChannel))
} else {
val current = kvStorage.get("currentDestination")
setSaveDestination(ChatRouterDestination.fromString(current ?: ""))
}
val seenEarlyAccess = kvStorage.getBoolean("spark/earlyAccess/dismissed")
val seenSwipeToReply = kvStorage.getBoolean("spark/swipeToReply/dismissed")

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M3.822,20.779C1.471,18.587 0,15.464 0,12C0,5.377 5.377,0 12,0C18.623,0 24,5.377 24,12C24,17.512 20.276,22.161 15.209,23.566C14.084,21.913 13.065,20.408 13.827,17.931C12.897,18.405 12.313,19.339 12.136,20.353C11.79,19.81 11.406,19.285 10.961,18.818C9.828,17.628 8.284,16.797 7.569,15.252C7.243,14.523 7.019,13.48 7.278,12.725C7.464,14.19 8.177,15.538 9.454,16.319C10.427,16.913 11.517,17.012 12.609,17.242C13.747,17.482 15.554,18.022 16.663,17.827C17.584,17.665 19.518,16.647 19.466,15.543C19.448,15.163 18.985,14.523 18.813,14.156C18.585,13.668 18.72,13.563 18.746,13.073C18.82,11.688 18.454,11.003 17.781,9.862C16.965,8.478 16.069,7.642 14.517,7.08C13.1,6.567 10.918,6.858 10.901,6.881C11.188,7.195 11.451,7.563 11.488,7.998C10.907,7.461 10.345,6.889 9.694,6.434C8.255,5.426 7.353,5.521 6.781,7.274C6.483,8.187 6.257,9.428 6.534,10.362C6.701,10.928 7.049,11.388 7.446,11.813C6.67,11.707 6.2,11.033 6.029,10.303C4.107,14.461 3.679,16.472 3.822,20.779ZM18.082,16.797L18.081,16.797C17.783,16.806 17.508,16.647 17.282,16.466C17.156,16.358 16.969,16.219 16.943,16.058C16.892,15.737 17.147,15.507 17.3,15.412C17.43,15.331 17.589,15.285 17.739,15.251C18.081,15.172 18.435,15.099 18.78,15.165C18.883,15.185 18.985,15.219 19.07,15.281C19.154,15.342 19.22,15.435 19.232,15.54C19.24,15.617 19.219,15.694 19.193,15.768C19.134,15.936 19.052,16.096 18.949,16.243C18.847,16.389 18.726,16.523 18.579,16.624C18.433,16.725 18.26,16.792 18.082,16.797ZM12.982,12.083C14.162,11.923 14.306,13.751 13.167,13.827C12.029,13.904 11.901,12.229 12.982,12.083ZM9.006,8.454C9.115,8.714 9.339,8.887 9.533,9.078C9.093,9.211 8.648,9.084 8.262,8.862C8.285,9.271 8.587,9.631 8.813,9.918C8.44,10.003 8.11,9.875 7.854,9.606C7.85,9.985 7.98,10.316 8.166,10.637C7.438,10.999 7.124,10.238 7.086,9.642C7.046,8.987 7.311,7.334 7.781,6.858C8.307,6.325 9.123,6.946 9.568,7.303C9.961,7.619 10.34,8.001 10.696,8.356C10.228,8.665 9.532,8.652 9.006,8.454ZM18.153,13.226C17.896,12.779 17.817,12.649 17.668,12.325C17.566,12.104 17.514,11.842 17.883,11.834C18.553,11.82 18.785,12.881 18.264,13.251C18.251,13.26 18.235,13.265 18.22,13.265C18.192,13.265 18.167,13.25 18.153,13.226ZM17.713,8.956C17.903,6.344 17.881,4.654 15.459,7.025C16.373,7.45 17.142,8.129 17.713,8.956Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
</vector>