This commit is contained in:
2026-06-03 19:12:23 +01:00
parent 2f34976f3e
commit 7e001d8769
27 changed files with 727 additions and 188 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
xmlns:tools="http://tools.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
@@ -1,9 +1,13 @@
package dev.zxq5.chatapp.android
import android.Manifest
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@@ -62,9 +66,22 @@ class MainActivity : ComponentActivity() {
val currentScreen by chatViewModel.currentScreen.collectAsState()
val selectedChannelId by chatViewModel.channelId.collectAsState()
// Permission request launcher
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult = { isGranted ->
if (isGranted && authState == AuthState.Authenticated) {
MessageStreamService.start(this@MainActivity)
}
}
)
LaunchedEffect(authState) {
when (authState) {
AuthState.Authenticated -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
MessageStreamService.start(this@MainActivity)
chatViewModel.loadAccessibleChannels()
}
@@ -1,7 +1,9 @@
@file:OptIn(ExperimentalUuidApi::class)
package dev.zxq5.chatapp.android.api
import dev.zxq5.chatapp.android.BuildConfig.BASE_URL
import dev.zxq5.chatapp.android.api.model.Message
import dev.zxq5.chatapp.android.api.model.ChatEvent
import dev.zxq5.chatapp.android.api.model.SendMessage
import dev.zxq5.chatapp.android.api.model.SpaceDto
import io.ktor.client.HttpClient
@@ -25,6 +27,8 @@ import kotlinx.coroutines.flow.flow
import kotlinx.serialization.json.Json
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
class ChatClient(private val token: String) {
@@ -45,18 +49,18 @@ class ChatClient(private val token: String) {
suspend fun sendMessage(channelId: Long, userId: Int, text: String) {
http.post("${BASE_URL}/api/chat/$channelId") {
contentType(ContentType.Application.Json)
setBody(SendMessage(user_id = userId, text = text, timestamp = Clock.System.now()))
setBody(SendMessage(id = Uuid.random(), user_id = userId, text = text, timestamp = Clock.System.now()))
}
}
fun messageStream(channelId: Long): Flow<Message> = flow {
fun eventStream(channelId: Long): Flow<ChatEvent> = flow {
http.prepareGet("${BASE_URL}/api/events/$channelId").execute { response ->
val channel = response.bodyAsChannel()
while (!channel.isClosedForRead) {
val line = channel.readLine() ?: break
if (line.startsWith("data:")) {
val json = line.removePrefix("data:").trim()
runCatching { Json.decodeFromString<Message>(json) }
runCatching { Json.decodeFromString<ChatEvent>(json) }
.onSuccess { emit(it) }
}
}
@@ -0,0 +1,60 @@
@file:OptIn(ExperimentalUuidApi::class, ExperimentalTime::class)
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.json.JsonClassDiscriminator
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
import kotlinx.serialization.Serializable
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@OptIn(ExperimentalSerializationApi::class)
@Serializable
@JsonClassDiscriminator("type")
sealed class ChatEvent {
@Serializable
@SerialName("SendMessage")
data class SendMessage(
val data: Message
) : ChatEvent()
@Serializable
@SerialName("EditMessage")
data class EditMessage(
val data: EditMessageContent
) : ChatEvent()
@Serializable
@SerialName("MessageAppendContent")
data class MessageAppendContent(
val data: AppendContent
) : ChatEvent()
}
// tuple variants like (i64, ChatMsg) and (i64, String)
// need wrapper classes since kotlinx can't deserialise
// bare JSON arrays into data classes directly
@Serializable
data class EditMessageContent(
val id: Uuid,
val message: Message
)
@Serializable
data class AppendContent (
val id: Uuid,
val content: String
)
@Serializable
data class Message (
val id: Uuid,
val user_id: Int,
val display_name: String,
val text: String,
val timestamp: Instant
)
@@ -1,13 +0,0 @@
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
@Serializable
data class Message @OptIn(ExperimentalTime::class) constructor(
val user_id: Int,
val display_name: String,
val text: String,
val timestamp: Instant
)
@@ -3,9 +3,12 @@ package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@Serializable
data class SendMessage @OptIn(ExperimentalTime::class) constructor(
data class SendMessage @OptIn(ExperimentalTime::class, ExperimentalUuidApi::class) constructor(
val id: Uuid,
val user_id: Int,
val text: String,
val timestamp: Instant
@@ -6,6 +6,7 @@ import android.content.Intent
import android.os.IBinder
import android.util.Log
import dev.zxq5.chatapp.android.ChatApplication
import dev.zxq5.chatapp.android.api.model.ChatEvent
import dev.zxq5.chatapp.android.data.repository.ChatRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -37,8 +38,6 @@ class MessageStreamService : Service() {
fun start(context: Context) {
val intent = Intent(context, MessageStreamService::class.java)
// Use startService to avoid the requirement for a persistent notification.
// This also prevents ForegroundServiceDidNotStartInTimeException.
context.startService(intent)
}
@@ -64,17 +63,20 @@ class MessageStreamService : Service() {
if (channelId == null) return
currentStreamJob = serviceScope.launch {
chatRepository.messageStream(channelId)
chatRepository.eventStream(channelId)
.catch { e -> Log.e("Service", "Stream error", e) }
.collect { message ->
.collect { event ->
// Only show notification when an event (new message) is received
// and the app is not in the foreground on this channel.
if (!ChatApplication.AppState.isInForeground || activeChannelId != channelId) {
notificationService.showMessageNotification(
conversationId = channelId.toString(),
senderName = message.display_name,
messagePreview = message.text
)
when (event) {
is ChatEvent.SendMessage -> notificationService.showMessageNotification(
conversationId = channelId.toString(),
senderName = event.data.display_name,
messagePreview = event.data.text
)
else -> {}
}
}
}
}
@@ -1,6 +1,7 @@
package dev.zxq5.chatapp.android.data.repository
import dev.zxq5.chatapp.android.api.ChatClient
import dev.zxq5.chatapp.android.api.model.ChatEvent
import dev.zxq5.chatapp.android.core.data.TokenStore
import dev.zxq5.chatapp.android.api.model.Message
import dev.zxq5.chatapp.android.api.model.SpaceDto
@@ -43,8 +44,8 @@ class ChatRepository(private val tokenStore: TokenStore) {
getChatClient()?.sendMessage(channelId, userId, text)
}
fun messageStream(channelId: Long): Flow<Message> {
fun eventStream(channelId: Long): Flow<ChatEvent> {
_lastActiveChannel = channelId
return getChatClient()?.messageStream(channelId) ?: emptyFlow()
return getChatClient()?.eventStream(channelId) ?: emptyFlow()
}
}
@@ -1,12 +1,13 @@
@file:OptIn(ExperimentalUuidApi::class)
package dev.zxq5.chatapp.android.feature.chat
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.zxq5.chatapp.android.api.model.Channel
import dev.zxq5.chatapp.android.api.model.ChatEvent
import dev.zxq5.chatapp.android.data.repository.ChatRepository
import dev.zxq5.chatapp.android.api.model.Message
import dev.zxq5.chatapp.android.api.model.Space
import dev.zxq5.chatapp.android.api.model.SpaceDto
import dev.zxq5.chatapp.android.core.service.MessageStreamService
import io.ktor.client.plugins.ResponseException
@@ -17,6 +18,8 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.time.ExperimentalTime
import kotlin.uuid.ExperimentalUuidApi
class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
@@ -70,6 +73,7 @@ class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
}
}
@OptIn(ExperimentalTime::class)
private fun observeChannel() {
viewModelScope.launch {
_channelId.collect { id ->
@@ -78,7 +82,7 @@ class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
_channelError.value = null
if (id != null) {
streamJob = launch {
chatRepository.messageStream(id)
chatRepository.eventStream(id)
.catch { e ->
Log.e("Chat", "Stream error", e)
if (e is ResponseException && e.response.status == HttpStatusCode.Unauthorized) {
@@ -87,8 +91,31 @@ class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
_channelError.value = "Connection lost: ${e.message}"
}
}
.collect { message ->
_messages.update { it + message }
.collect { event ->
when (event) {
is ChatEvent.SendMessage -> {
_messages.update { it + event.data }
}
is ChatEvent.EditMessage -> {
_messages.update { messages ->
messages.map {
if (it.id == event.data.id) event.data.message
else it
}
}
}
is ChatEvent.MessageAppendContent -> {
_messages.update { messages ->
messages.map { msg ->
if (msg.id == event.data.id) {
msg.copy(text = msg.text + event.data.content)
} else {
msg
}
}
}
}
}
}
}
}
@@ -1,3 +1,5 @@
@file:OptIn(ExperimentalUuidApi::class)
package dev.zxq5.chatapp.android.feature.chat
import androidx.compose.foundation.BorderStroke
@@ -65,6 +67,7 @@ import dev.zxq5.chatapp.android.api.model.Message
import java.text.DateFormat
import java.util.Date
import kotlin.time.ExperimentalTime
import kotlin.uuid.ExperimentalUuidApi
@Composable
fun ChatScreen(
@@ -277,7 +280,7 @@ fun MessageScreen(channelId: Long, viewModel: ChatViewModel, onBack: () -> Unit)
modifier = Modifier.weight(1f).padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
items(messages) { message ->
items(messages, key = { it.id }) { message ->
MessageBubble(message, currentUserId)
}
item { Spacer(Modifier.height(10.dp)) }
@@ -378,7 +381,7 @@ fun MessageBubble(message: Message, currentUserId: Int?) {
horizontalAlignment = if (isMe) Alignment.End else Alignment.Start
) {
Surface(
color = if (isMe) MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.2f),
color = if (isMe) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.2f),
shape = RoundedCornerShape(
topStart = 14.dp,
topEnd = 14.dp,
@@ -388,14 +391,7 @@ fun MessageBubble(message: Message, currentUserId: Int?) {
border = border(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
) {
Column(modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp)) {
if (!isMe) {
Text(
message.display_name?.lowercase() ?: "unknown",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f),
modifier = Modifier.padding(bottom = 2.dp)
)
}
Text(
text = message.text,
style = MaterialTheme.typography.bodyLarge,
@@ -403,10 +399,11 @@ fun MessageBubble(message: Message, currentUserId: Int?) {
)
}
}
Text(
text = time,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f),
text = if (!isMe) message.display_name.lowercase() + " . " + time else time,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
)
}
@@ -2,7 +2,7 @@ package dev.zxq5.chatapp.android.ui.theme
import androidx.compose.ui.graphics.Color
val Black = Color(0xFF0A0A0A)
val Black = Color(0xFF000000)
val DarkGrey = Color(0xFF0D0D0D)
val Grey = Color(0xFF141414)
val LightGrey = Color(0xFF1E1E1E)