idk
This commit is contained in:
@@ -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
|
||||
|
||||
+11
-9
@@ -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 -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user