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> </intent-filter>
</service> </service>
<receiver
android:name=".c2dm.ReplyReceiver"
android:exported="false" />
<activity <activity
android:name=".activities.MainActivity" android:name=".activities.MainActivity"
android:exported="true" android:exported="true"

View File

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

View File

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