Private
Public Access
1
0

Implements message loading and display

This commit is contained in:
2023-08-24 00:45:18 -07:00
parent 4add2b4674
commit 6375900710
14 changed files with 371 additions and 132 deletions

View File

@@ -36,10 +36,10 @@ fun KordophoneApp(
} }
composable(Destination.MessageList.route) { composable(Destination.MessageList.route) {
val conversationID = it.arguments?.getString("id") val conversationID = it.arguments?.getString("id")!!
MessageListScreen(messages = listOf()) { MessageListScreen(conversationGUID = conversationID, backAction = {
navController.popBackStack() navController.popBackStack()
} })
} }
} }
} }

View File

@@ -0,0 +1,76 @@
package net.buzzert.kordophonedroid.ui.messagelist
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.Button
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
@Composable
fun MessageEntry(
keyboardType: KeyboardType = KeyboardType.Text,
onTextChanged: (TextFieldValue) -> Unit,
textFieldValue: TextFieldValue,
onSend: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Surface(
shape = RoundedCornerShape(8.dp),
border = BorderStroke(1.dp, color = MaterialTheme.colors.onBackground.copy(0.4f))
) {
Row(modifier = Modifier
.padding(horizontal = 8.dp)
.weight(1f)
.align(Alignment.Bottom)
.imePadding()
.navigationBarsPadding()
) {
BasicTextField(
value = textFieldValue,
onValueChange = { onTextChanged(it) },
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.align(Alignment.CenterVertically)
.padding(horizontal = 8.dp),
cursorBrush = SolidColor(MaterialTheme.colors.onBackground),
textStyle = MaterialTheme.typography.body1.copy(MaterialTheme.colors.onBackground),
decorationBox = { textContent ->
if (textFieldValue.text.isEmpty()) {
Text(
text = "Message",
style = MaterialTheme.typography.body1.copy(
color = MaterialTheme.colors.onSurface.copy(alpha = 0.4f)
)
)
}
textContent()
}
)
Button(onClick = onSend) {
Text(text = "Send")
}
}
}
}
}

View File

@@ -1,20 +1,17 @@
package net.buzzert.kordophonedroid.ui.messagelist package net.buzzert.kordophonedroid.ui.messagelist
import android.util.Log
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -22,7 +19,6 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button import androidx.compose.material.Button
import androidx.compose.material.Icon import androidx.compose.material.Icon
import androidx.compose.material.IconButton import androidx.compose.material.IconButton
@@ -34,74 +30,115 @@ import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import net.buzzert.kordophone.backend.model.GUID
import net.buzzert.kordophone.backend.model.Message
@Immutable private val IncomingChatBubbleShape = RoundedCornerShape(4.dp, 20.dp, 20.dp, 20.dp)
data class Message( private val OutgoingChatBubbleShape = RoundedCornerShape(20.dp, 4.dp, 20.dp, 20.dp)
val content: String,
data class MessageListViewItem(
val text: String,
val fromMe: Boolean,
val delivered: Boolean = true,
) )
@Composable @Composable
fun MessageListScreen( fun MessageListScreen(
messages: List<Message>, conversationGUID: GUID,
backAction: () -> Unit backAction: () -> Unit,
viewModel: MessageListViewModel = hiltViewModel(),
) {
viewModel.conversationGUID = conversationGUID
LaunchedEffect(Unit) {
Log.d("MessageListScreen", "Launched effect")
viewModel.synchronize()
}
val messages by viewModel.messages.collectAsStateWithLifecycle(initialValue = listOf())
val messageItems = messages.map {
MessageListViewItem(
text = it.text,
fromMe = it.sender == null,
delivered = !viewModel.isPendingMessage(it)
)
}
Scaffold(topBar = { MessagesListTopAppBar(title = viewModel.title, backAction = backAction) }) { padding ->
MessageTranscript(
messages = messageItems,
paddingValues = padding,
onSendMessage = { text ->
viewModel.enqueueOutgoingMessage(text)
}
)
}
}
@Composable
fun MessagesListTopAppBar(title: String, backAction: () -> Unit) {
TopAppBar(
title = { Text(title) },
navigationIcon = {
IconButton(onClick = backAction) {
Icon(Icons.Filled.ArrowBack, null)
}
},
actions = {}
)
}
@Composable
fun MessageTranscript(
messages: List<MessageListViewItem>,
paddingValues: PaddingValues,
onSendMessage: (text: String) -> Unit,
) { ) {
val scrollState = rememberLazyListState() val scrollState = rememberLazyListState()
var textState by rememberSaveable(stateSaver = TextFieldValue.Saver) { var textState by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue()) mutableStateOf(TextFieldValue())
} }
Scaffold( Column(
topBar = { Modifier
TopAppBar( .fillMaxSize()
title = { Text("Messages") }, .padding(paddingValues)) {
navigationIcon = {
IconButton(onClick = backAction) {
Icon(Icons.Filled.ArrowBack, null)
}
},
actions = {
}) Messages(messages = messages, modifier = Modifier.weight(1f), scrollState = scrollState)
}
) { paddingValues ->
Column(
Modifier
.fillMaxSize()
.padding(paddingValues)) {
Messages(messages = messages, modifier = Modifier.weight(1f), scrollState = scrollState) MessageEntry(
onTextChanged = { textState = it },
textFieldValue = textState,
onSend = {
onSendMessage(textState.text)
MessageEntry( // Clear text state
onTextChanged = { textState = it }, textState = TextFieldValue()
textFieldValue = textState, },
) )
}
} }
} }
@Composable @Composable
fun Messages( fun Messages(
messages: List<Message>, messages: List<MessageListViewItem>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
scrollState: LazyListState scrollState: LazyListState
) { ) {
@@ -117,97 +154,59 @@ fun Messages(
for (index in messages.indices) { for (index in messages.indices) {
val content = messages[index] val content = messages[index]
item { item {
MessageBubble(content) MessageBubble(
text = content.text,
mine = content.fromMe,
modifier = Modifier.alpha(if (!content.delivered) 0.5F else 1.0f)
)
} }
} }
} }
} }
} }
private val ChatBubbleShape = RoundedCornerShape(4.dp, 20.dp, 20.dp, 20.dp)
@Composable @Composable
fun MessageBubble(message: Message) { fun MessageBubble(
val backgroundBubbleColor = MaterialTheme.colors.primary text: String,
mine: Boolean,
modifier: Modifier = Modifier,
) {
val backgroundBubbleColor = if (mine) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
Column(modifier = Modifier.padding(end = 16.dp)) { Column() {
Surface( Row(
color = backgroundBubbleColor, modifier = modifier.fillMaxWidth(),
shape = ChatBubbleShape horizontalArrangement = if (mine) Arrangement.End else Arrangement.Start,
) { ) {
Text( Surface(
text = message.content, color = backgroundBubbleColor,
style = MaterialTheme.typography.body1, shape = if (mine) OutgoingChatBubbleShape else IncomingChatBubbleShape,
modifier = Modifier ) {
.padding(16.dp) Text(
) text = text,
style = MaterialTheme.typography.body1,
modifier = Modifier
.padding(16.dp)
)
}
} }
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
} }
} }
@Composable
private fun MessageEntry(
keyboardType: KeyboardType = KeyboardType.Text,
onTextChanged: (TextFieldValue) -> Unit,
textFieldValue: TextFieldValue,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Surface(
shape = RoundedCornerShape(8.dp),
border = BorderStroke(1.dp, color = MaterialTheme.colors.onBackground.copy(0.4f))
) {
Row(modifier = Modifier
.padding(horizontal = 8.dp)
.weight(1f)
.align(Alignment.Bottom)
.imePadding()
.navigationBarsPadding()
) {
BasicTextField(
value = textFieldValue,
onValueChange = { onTextChanged(it) },
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.align(Alignment.CenterVertically)
.padding(horizontal = 8.dp),
cursorBrush = SolidColor(MaterialTheme.colors.onBackground),
textStyle = MaterialTheme.typography.body1.copy(MaterialTheme.colors.onBackground),
decorationBox = { textContent ->
if (textFieldValue.text.isEmpty()) {
Text(
text = "Message",
style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onSurface)
)
}
textContent()
}
)
Button(onClick = { /*TODO*/ }) {
Text(text = "Send")
}
}
}
}
}
// - // -
@Preview @Preview
@Composable @Composable
private fun MessageListScreenPreview() { private fun MessageListScreenPreview() {
val messages = listOf<Message>( val messages = listOf<MessageListViewItem>(
Message(content = "Hello"), MessageListViewItem(text = "Hello", fromMe = false),
Message(content = "Hi there"), MessageListViewItem(text = "Hey there", fromMe = true),
) MessageListViewItem(text = "How's it going", fromMe = true, delivered = false)
).reversed()
MessageListScreen(messages = messages) {} Scaffold() {
MessageTranscript(messages = messages, paddingValues = it, onSendMessage = {})
}
} }

View File

@@ -0,0 +1,84 @@
package net.buzzert.kordophonedroid.ui.messagelist
import android.util.Log
import androidx.compose.runtime.mutableStateListOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import net.buzzert.kordophone.backend.model.Conversation
import net.buzzert.kordophone.backend.model.GUID
import net.buzzert.kordophone.backend.model.Message
import net.buzzert.kordophone.backend.server.ChatRepository
import java.util.Date
import java.util.UUID
import javax.inject.Inject
@HiltViewModel
class MessageListViewModel @Inject constructor(
private val repository: ChatRepository,
) : ViewModel()
{
var conversationGUID: GUID? = null
set(value) {
field = value
value?.let {
conversation = repository.conversationForGuid(it)
}
}
private var conversation: Conversation? = null
private val pendingMessages: MutableStateFlow<List<Message>> = MutableStateFlow(listOf())
init {
viewModelScope.launch {
// Remove pending message after message is delivered.
// By now, the repository should've committed this to the store.
repository.messageDeliveredChannel.collectLatest { event ->
pendingMessages.value =
pendingMessages.value.filter { it.guid != event.message.guid }
}
}
}
val messages: Flow<List<Message>>
get() = repository.messagesChanged(conversation!!)
.combine(pendingMessages) { a, b -> a.union(b) }
.map { messages ->
messages
.sortedBy { it.date }
.reversed()
}
val title: String get() = conversation!!.formattedDisplayName()
fun enqueueOutgoingMessage(text: String) {
val outgoingMessage = Message(
guid = UUID.randomUUID().toString(),
text = text,
sender = null,
date = Date(),
conversation = conversation!!,
)
pendingMessages.value = pendingMessages.value + listOf(outgoingMessage)
repository.enqueueOutgoingMessage(outgoingMessage, conversation!!)
}
fun isPendingMessage(message: Message): Boolean {
return pendingMessages.value.contains(message)
}
fun synchronize() = viewModelScope.launch {
repository.synchronizeConversation(conversation!!)
}
}

BIN
backend/chat-cache-test Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -57,7 +57,7 @@ class CachedChatDatabase (private val realmConfig: RealmConfiguration) {
.first() .first()
.messages .messages
.asFlow() .asFlow()
.map { it.list.map { it.toMessage(conversation) } } .map { it.set.map { it.toMessage(conversation) } }
} }
fun updateConversations(incomingConversations: List<ModelConversation>) = realm.writeBlocking { fun updateConversations(incomingConversations: List<ModelConversation>) = realm.writeBlocking {
@@ -111,15 +111,24 @@ class CachedChatDatabase (private val realmConfig: RealmConfiguration) {
return items.map { it.toConversation() } return items.map { it.toConversation() }
} }
fun writeMessages(messages: List<ModelMessage>, conversation: ModelConversation) { fun writeMessages(messages: List<ModelMessage>, conversation: ModelConversation, outgoing: Boolean = false) {
val dbConversation = getManagedConversationByGuid(conversation.guid) val dbConversation = getManagedConversationByGuid(conversation.guid)
realm.writeBlocking { realm.writeBlocking {
val dbMessages = messages val dbMessages = messages
.map { it.toDatabaseMessage() } .map { it.toDatabaseMessage(outgoing = outgoing) }
.map { copyToRealm(it, updatePolicy = UpdatePolicy.ALL) } .map { copyToRealm(it, updatePolicy = UpdatePolicy.ALL) }
val obj = findLatest(dbConversation) findLatest(dbConversation)?.messages?.addAll(dbMessages)
obj!!.messages.addAll(dbMessages) }
}
fun clearOutgoingMessages() {
realm.query(Message::class, "isOutgoing == true").find().forEach { message ->
realm.writeBlocking {
findLatest(message)?.let {
delete(it)
}
}
} }
} }

View File

@@ -2,10 +2,12 @@ package net.buzzert.kordophone.backend.db.model
import io.realm.kotlin.Realm import io.realm.kotlin.Realm
import io.realm.kotlin.ext.realmListOf import io.realm.kotlin.ext.realmListOf
import io.realm.kotlin.ext.realmSetOf
import io.realm.kotlin.ext.toRealmList import io.realm.kotlin.ext.toRealmList
import io.realm.kotlin.types.RealmInstant import io.realm.kotlin.types.RealmInstant
import io.realm.kotlin.types.RealmList import io.realm.kotlin.types.RealmList
import io.realm.kotlin.types.RealmObject import io.realm.kotlin.types.RealmObject
import io.realm.kotlin.types.RealmSet
import io.realm.kotlin.types.annotations.PrimaryKey import io.realm.kotlin.types.annotations.PrimaryKey
import net.buzzert.kordophone.backend.model.GUID import net.buzzert.kordophone.backend.model.GUID
import org.mongodb.kbson.ObjectId import org.mongodb.kbson.ObjectId
@@ -23,7 +25,7 @@ open class Conversation(
var unreadCount: Int, var unreadCount: Int,
var lastMessagePreview: String?, var lastMessagePreview: String?,
var messages: RealmList<Message>, var messages: RealmSet<Message>,
): RealmObject ): RealmObject
{ {
constructor() : this( constructor() : this(
@@ -35,7 +37,7 @@ open class Conversation(
unreadCount = 0, unreadCount = 0,
lastMessagePreview = null, lastMessagePreview = null,
messages = realmListOf<Message>() messages = realmSetOf<Message>()
) )
fun toConversation(): ModelConversation { fun toConversation(): ModelConversation {

View File

@@ -22,6 +22,7 @@ open class Message(
var date: RealmInstant, var date: RealmInstant,
var conversationGUID: GUID, var conversationGUID: GUID,
var isOutgoing: Boolean,
): RealmObject ): RealmObject
{ {
constructor() : this( constructor() : this(
@@ -30,6 +31,7 @@ open class Message(
sender = null, sender = null,
date = RealmInstant.now(), date = RealmInstant.now(),
conversationGUID = ObjectId().toString(), conversationGUID = ObjectId().toString(),
isOutgoing = false,
) )
fun toMessage(parentConversation: ModelConversation): ModelMessage { fun toMessage(parentConversation: ModelConversation): ModelMessage {
@@ -43,7 +45,7 @@ open class Message(
} }
} }
fun ModelMessage.toDatabaseMessage(): Message { fun ModelMessage.toDatabaseMessage(outgoing: Boolean = false): Message {
val from = this val from = this
return Message().apply { return Message().apply {
text = from.text text = from.text
@@ -51,5 +53,6 @@ fun ModelMessage.toDatabaseMessage(): Message {
sender = from.sender sender = from.sender
date = from.date.toInstant().toRealmInstant() date = from.date.toInstant().toRealmInstant()
conversationGUID = from.conversation.guid conversationGUID = from.conversation.guid
isOutgoing = outgoing
} }
} }

View File

@@ -2,6 +2,7 @@ package net.buzzert.kordophone.backend.model
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import java.util.Date import java.util.Date
import java.util.UUID
typealias GUID = String typealias GUID = String
@@ -27,6 +28,19 @@ data class Conversation(
@SerializedName("lastMessage") @SerializedName("lastMessage")
var lastMessage: Message?, var lastMessage: Message?,
) { ) {
companion object {
fun generate(): Conversation {
return Conversation(
guid = UUID.randomUUID().toString(),
date = Date(),
participants = listOf("foo@foo.com"),
displayName = null,
unreadCount = 0,
lastMessagePreview = null,
lastMessage = null,
)
}
}
fun formattedDisplayName(): String { fun formattedDisplayName(): String {
return displayName ?: participants.joinToString(", ") return displayName ?: participants.joinToString(", ")

View File

@@ -2,6 +2,7 @@ package net.buzzert.kordophone.backend.model
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import java.util.Date import java.util.Date
import java.util.UUID
data class Message( data class Message(
@SerializedName("guid") @SerializedName("guid")
@@ -19,6 +20,18 @@ data class Message(
@Transient @Transient
var conversation: Conversation, var conversation: Conversation,
) { ) {
companion object {
fun generate(text: String, conversation: Conversation = Conversation.generate(), sender: String? = null): Message {
return Message(
guid = UUID.randomUUID().toString(),
text = text,
sender = sender,
date = Date(),
conversation = conversation,
)
}
}
override fun toString(): String { override fun toString(): String {
return "Message(guid=$guid, text=$text, sender=$sender, date=$date, parent=${conversation.guid})" return "Message(guid=$guid, text=$text, sender=$sender, date=$date, parent=${conversation.guid})"
} }
@@ -35,4 +48,13 @@ data class Message(
conversation.guid == o.conversation.guid conversation.guid == o.conversation.guid
) )
} }
override fun hashCode(): Int {
var result = guid.hashCode()
result = 31 * result + text.hashCode()
result = 31 * result + (sender?.hashCode() ?: 0)
result = 31 * result + date.hashCode()
result = 31 * result + conversation.guid.hashCode()
return result
}
} }

View File

@@ -5,8 +5,12 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@@ -34,7 +38,8 @@ class ChatRepository(
get() = database.fetchConversations() get() = database.fetchConversations()
// Channel that's signaled when an outgoing message is delivered. // Channel that's signaled when an outgoing message is delivered.
val messageDeliveredChannel = Channel<MessageDeliveredEvent>() val messageDeliveredChannel: SharedFlow<MessageDeliveredEvent>
get() = _messageDeliveredChannel
// Changes Flow // Changes Flow
val conversationChanges: Flow<List<Conversation>> val conversationChanges: Flow<List<Conversation>>
@@ -66,6 +71,7 @@ class ChatRepository(
private val apiInterface = apiClient.getAPIInterface() private val apiInterface = apiClient.getAPIInterface()
private val outgoingMessageQueue: ArrayBlockingQueue<OutgoingMessageInfo> = ArrayBlockingQueue(16) private val outgoingMessageQueue: ArrayBlockingQueue<OutgoingMessageInfo> = ArrayBlockingQueue(16)
private var outgoingMessageThread: Thread? = null private var outgoingMessageThread: Thread? = null
private val _messageDeliveredChannel = MutableSharedFlow<MessageDeliveredEvent>()
private val updateMonitor = UpdateMonitor(apiClient) private val updateMonitor = UpdateMonitor(apiClient)
private var updateWatchJob: Job? = null private var updateWatchJob: Job? = null
@@ -83,6 +89,9 @@ class ChatRepository(
launch { launch {
updateMonitor.messageAdded.collect { handleMessageAddedUpdate(it) } updateMonitor.messageAdded.collect { handleMessageAddedUpdate(it) }
} }
launch {
messageDeliveredChannel.collectLatest { handleMessageDelivered(it) }
}
} }
updateMonitor.beginMonitoringUpdates() updateMonitor.beginMonitoringUpdates()
@@ -124,15 +133,27 @@ class ChatRepository(
val serverConversations = fetchConversations() val serverConversations = fetchConversations()
database.updateConversations(serverConversations) database.updateConversations(serverConversations)
// Delete outgoing conversations
// This is an unfortunate limitation in that we don't know what outgoing GUIDs are going to
// be before we send them.
// TODO: Keep this in mind when syncing messages after a certain GUID. The outgoing GUIDs are fake.
database.clearOutgoingMessages()
// Sync top N number of conversations' message content // Sync top N number of conversations' message content
Log.d(REPO_LOG, "Synchronizing messages") Log.d(REPO_LOG, "Synchronizing messages")
val sortedConversations = conversations.sortedBy { it.date }.reversed() val sortedConversations = conversations.sortedBy { it.date }.reversed()
for (conversation in sortedConversations.take(CONVERSATION_MESSAGE_SYNC_COUNT)) { for (conversation in sortedConversations.take(CONVERSATION_MESSAGE_SYNC_COUNT)) {
val messages = fetchMessages(conversation, limit = 15) synchronizeConversation(conversation)
database.writeMessages(messages, conversation)
} }
} }
suspend fun synchronizeConversation(conversation: Conversation) {
// TODO: Should only fetch messages after the last GUID we know about.
// But keep in mind that outgoing message GUIDs are fake...
val messages = fetchMessages(conversation, limit = 15)
database.writeMessages(messages, conversation)
}
fun close() { fun close() {
database.close() database.close()
} }
@@ -164,6 +185,14 @@ class ChatRepository(
database.writeMessages(listOf(message), message.conversation) database.writeMessages(listOf(message), message.conversation)
} }
private fun handleMessageDelivered(event: MessageDeliveredEvent) {
Log.d(REPO_LOG, "Handling successful delivery event")
// Unfortunate protocol reality: the server doesn't tell us about new messages that are from us,
// so we have to explicitly handle this like a messageAddedUpdate.
database.writeMessages(listOf(event.message), event.conversation, outgoing = true)
}
private fun outgoingMessageQueueMain() { private fun outgoingMessageQueueMain() {
Log.d(REPO_LOG, "Outgoing Message Queue Main") Log.d(REPO_LOG, "Outgoing Message Queue Main")
while (true) { while (true) {
@@ -185,7 +214,7 @@ class ChatRepository(
if (result.isSuccessful) { if (result.isSuccessful) {
Log.d(REPO_LOG, "Successfully sent message.") Log.d(REPO_LOG, "Successfully sent message.")
messageDeliveredChannel.send(MessageDeliveredEvent(guid, message, conversation)) _messageDeliveredChannel.emit(MessageDeliveredEvent(guid, message, conversation))
} else { } else {
Log.e(REPO_LOG, "Error sending message. Enqueuing for retry.") Log.e(REPO_LOG, "Error sending message. Enqueuing for retry.")
outgoingMessageQueue.add(it) outgoingMessageQueue.add(it)

View File

@@ -16,7 +16,7 @@ import okio.ByteString
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
import java.lang.reflect.Type import java.lang.reflect.Type
const val UPMON_LOG: String = "ChatRepository" const val UPMON_LOG: String = "UpdateMonitor"
class UpdateMonitor(private val client: APIClient) : WebSocketListener() { class UpdateMonitor(private val client: APIClient) : WebSocketListener() {
// Flow for getting conversation changed notifications // Flow for getting conversation changed notifications

View File

@@ -3,6 +3,7 @@ package net.buzzert.kordophone.backend
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -87,7 +88,7 @@ class BackendTests {
val guid = repository.enqueueOutgoingMessage(outgoingMessage, conversation) val guid = repository.enqueueOutgoingMessage(outgoingMessage, conversation)
val event = repository.messageDeliveredChannel.receive() val event = repository.messageDeliveredChannel.first()
assertEquals(event.guid, guid) assertEquals(event.guid, guid)
assertEquals(event.message.text, outgoingMessage.text) assertEquals(event.message.text, outgoingMessage.text)