feat: notification tweaks features etc i really want to just ship it

This commit is contained in:
infi 2026-05-25 01:12:51 +02:00
parent f93c22ee14
commit 4f12e86fe8
6 changed files with 169 additions and 9 deletions

View File

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

View File

@ -271,6 +271,9 @@ class MainActivityViewModel(
viewModelScope.launch { viewModelScope.launch {
kvStorage.remove("sessionToken") kvStorage.remove("sessionToken")
kvStorage.remove("sessionId") kvStorage.remove("sessionId")
kvStorage.remove("selfId")
kvStorage.remove("selfName")
kvStorage.remove("selfAvatarUrl")
startWithDestination("login/greeting") startWithDestination("login/greeting")
} }
} }

View File

@ -3,6 +3,12 @@ package chat.stoat.c2dm
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Rect
import android.graphics.Typeface
import android.os.Build import android.os.Build
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
@ -12,6 +18,7 @@ import androidx.core.app.RemoteInput
import androidx.core.content.LocusIdCompat import androidx.core.content.LocusIdCompat
import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.IconCompat import androidx.core.graphics.drawable.IconCompat
import chat.stoat.BuildConfig import chat.stoat.BuildConfig
import chat.stoat.R import chat.stoat.R
@ -20,8 +27,9 @@ import chat.stoat.api.internals.ULID
import chat.stoat.api.routes.channel.fetchSingleChannel 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.schemas.ChannelType import chat.stoat.core.model.data.STOAT_FILES
import chat.stoat.persistence.Database import chat.stoat.persistence.Database
import chat.stoat.persistence.KVStorage
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
@ -29,6 +37,40 @@ import com.google.firebase.messaging.RemoteMessage
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import logcat.LogPriority import logcat.LogPriority
import logcat.logcat import logcat.logcat
import kotlin.math.abs
private val LETTER_ICON_COLORS = intArrayOf(
0xFF1565C0.toInt(),
0xFF2E7D32.toInt(),
0xFF6A1B9A.toInt(),
0xFFC62828.toInt(),
0xFF00838F.toInt(),
0xFFE65100.toInt(),
0xFF4527A0.toInt(),
0xFF283593.toInt(),
)
private fun generateLetterBitmap(name: String, sizePx: Int = 256): Bitmap {
val letter = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
val color = LETTER_ICON_COLORS[abs(name.hashCode()) % LETTER_ICON_COLORS.size]
val bitmap = createBitmap(sizePx, sizePx)
val canvas = Canvas(bitmap)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
paint.color = color
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f, paint)
paint.color = Color.WHITE
paint.textSize = sizePx * 0.45f
paint.textAlign = Paint.Align.CENTER
paint.typeface = Typeface.DEFAULT_BOLD
val bounds = Rect()
paint.getTextBounds(letter, 0, 1, bounds)
canvas.drawText(letter, sizePx / 2f, sizePx / 2f + bounds.height() / 2f - bounds.bottom, paint)
return bitmap
}
object NotificationID { object NotificationID {
const val NEW_MESSAGE = 0 const val NEW_MESSAGE = 0
@ -112,17 +154,57 @@ class HandlerService : FirebaseMessagingService() {
} ?: authorName } ?: authorName
} }
val bitmap = Glide.with(this) fun loadBitmap(url: String) = Glide.with(this)
.asBitmap() .asBitmap()
.load(image) .load(url)
.circleCrop() .circleCrop()
.submit() .submit()
.get() .get()
val kv = KVStorage(this)
val selfId = runBlocking { kv.get("selfId") }.orEmpty()
val selfName = runBlocking { kv.get("selfName") }.orEmpty()
val selfAvatarUrl = runBlocking { kv.get("selfAvatarUrl") }
val selfBitmap: Bitmap = if (!selfAvatarUrl.isNullOrEmpty()) {
runCatching { loadBitmap(selfAvatarUrl) }.getOrNull()
?: generateLetterBitmap(selfName.ifEmpty { "?" })
} else {
generateLetterBitmap(selfName.ifEmpty { "?" })
}
val self = Person.Builder()
.setBot(false)
.setKey(selfId.ifEmpty { "self" })
.setIcon(IconCompat.createWithBitmap(selfBitmap))
.setName(selfName.ifEmpty { "Me" })
.build()
val dbChannel = db.channelQueries.findById(channelId).executeAsOneOrNull()
val authorBitmap = loadBitmap(image)
val conversationBitmap: Bitmap = when (dbChannel?.channelType) {
"TextChannel", "VoiceChannel" -> {
val server =
dbChannel.server?.let { db.serverQueries.findById(it).executeAsOneOrNull() }
val iconUrl = server?.iconId?.let { "$STOAT_FILES/icons/$it" }
iconUrl?.let { runCatching { loadBitmap(it) }.getOrNull() }
?: generateLetterBitmap(server?.name ?: channelName)
}
"Group" -> {
val iconUrl = dbChannel.iconId?.let { "$STOAT_FILES/icons/$it" }
iconUrl?.let { runCatching { loadBitmap(it) }.getOrNull() }
?: generateLetterBitmap(dbChannel.name ?: channelName)
}
else -> authorBitmap
}
val author = Person.Builder() val author = Person.Builder()
.setBot(false) .setBot(false)
.setKey(authorId) .setKey(authorId)
.setIcon(IconCompat.createWithBitmap(bitmap)) .setIcon(IconCompat.createWithBitmap(authorBitmap))
.setName(authorName) .setName(authorName)
.build() .build()
@ -137,7 +219,7 @@ class HandlerService : FirebaseMessagingService() {
val shortcut = ShortcutInfoCompat.Builder(this, shortcutId) val shortcut = ShortcutInfoCompat.Builder(this, shortcutId)
.setShortLabel(channelName) .setShortLabel(channelName)
.setLongLabel(channelName) .setLongLabel(channelName)
.setIcon(IconCompat.createWithBitmap(bitmap)) .setIcon(IconCompat.createWithBitmap(conversationBitmap))
.setIntent(conversationIntent) .setIntent(conversationIntent)
.setLongLived(true) .setLongLived(true)
.setPerson(author) .setPerson(author)
@ -154,7 +236,7 @@ class HandlerService : FirebaseMessagingService() {
putExtra("channelId", channelId) putExtra("channelId", channelId)
} }
val action: NotificationCompat.Action = val replyAction: 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),
@ -168,6 +250,24 @@ class HandlerService : FirebaseMessagingService() {
.addRemoteInput(remoteInput) .addRemoteInput(remoteInput)
.build() .build()
val markAsReadIntent = Intent(this, MarkAsReadReceiver::class.java).apply {
putExtra("channelId", channelId)
putExtra("messageId", messageId)
}
val markAsReadAction: NotificationCompat.Action =
NotificationCompat.Action.Builder(
R.drawable.ic_mark_chat_read_24dp,
getString(R.string.channel_context_sheet_actions_mark_read),
PendingIntent.getBroadcast(
this,
channelId.hashCode() xor 1,
markAsReadIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
)
.build()
val contentIntent = PendingIntent.getActivity( val contentIntent = PendingIntent.getActivity(
this, this,
channelId.hashCode(), channelId.hashCode(),
@ -182,11 +282,13 @@ class HandlerService : FirebaseMessagingService() {
.setContentIntent(contentIntent) .setContentIntent(contentIntent)
.setCategory(NotificationCompat.CATEGORY_MESSAGE) .setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setStyle( .setStyle(
NotificationCompat.MessagingStyle(author) NotificationCompat.MessagingStyle(self)
.setGroupConversation(dbChannel?.channelType != "DirectMessage")
.setConversationTitle(channelName) .setConversationTitle(channelName)
.addMessage(body, messageTimestamp, author) .addMessage(body, messageTimestamp, author)
) )
.addAction(action) .addAction(replyAction)
.addAction(markAsReadAction)
.setPriority(NotificationCompat.PRIORITY_HIGH) .setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true) .setAutoCancel(true)
@ -204,7 +306,7 @@ class HandlerService : FirebaseMessagingService() {
val bubbleMetadata = NotificationCompat.BubbleMetadata.Builder( val bubbleMetadata = NotificationCompat.BubbleMetadata.Builder(
bubbleIntent, bubbleIntent,
IconCompat.createWithBitmap(bitmap) IconCompat.createWithBitmap(conversationBitmap)
) )
.setDesiredHeight(600) .setDesiredHeight(600)
.setAutoExpandBubble(false) .setAutoExpandBubble(false)

View File

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

View File

@ -73,6 +73,9 @@ import chat.stoat.api.realtime.DisconnectionState
import chat.stoat.api.realtime.RealtimeSocket import chat.stoat.api.realtime.RealtimeSocket
import chat.stoat.api.routes.microservices.gazette.getLatestChangelog import chat.stoat.api.routes.microservices.gazette.getLatestChangelog
import chat.stoat.api.routes.push.subscribePush import chat.stoat.api.routes.push.subscribePush
import chat.stoat.api.routes.user.fetchSelf
import chat.stoat.core.model.data.STOAT_FILES
import chat.stoat.core.model.schemas.User
import chat.stoat.api.settings.SyncedSettings import chat.stoat.api.settings.SyncedSettings
import chat.stoat.callbacks.Action import chat.stoat.callbacks.Action
import chat.stoat.callbacks.ActionChannel import chat.stoat.callbacks.ActionChannel
@ -162,6 +165,12 @@ class ChatRouterViewModel(
init { init {
viewModelScope.launch { viewModelScope.launch {
runCatching { fetchSelf() }.getOrNull()?.let { user ->
kvStorage.set("selfId", user.id ?: "")
kvStorage.set("selfName", User.resolveDefaultName(user))
kvStorage.set("selfAvatarUrl", user.avatar?.id?.let { "$STOAT_FILES/avatars/$it" } ?: "")
}
val pendingChannel = NotificationDeepLink.pendingChannelId.value val pendingChannel = NotificationDeepLink.pendingChannelId.value
if (pendingChannel != null) { if (pendingChannel != null) {
NotificationDeepLink.pendingChannelId.value = null NotificationDeepLink.pendingChannelId.value = null

View File

@ -48,6 +48,9 @@ class SettingsScreenViewModel(
fun logout() { fun logout() {
runBlocking { runBlocking {
kvStorage.remove("sessionToken") kvStorage.remove("sessionToken")
kvStorage.remove("selfId")
kvStorage.remove("selfName")
kvStorage.remove("selfAvatarUrl")
LoadedSettings.reset() LoadedSettings.reset()
StoatAPI.logout() StoatAPI.logout()
} }