Implements message loading and display
This commit is contained in:
@@ -36,10 +36,10 @@ fun KordophoneApp(
|
||||
}
|
||||
|
||||
composable(Destination.MessageList.route) {
|
||||
val conversationID = it.arguments?.getString("id")
|
||||
MessageListScreen(messages = listOf()) {
|
||||
val conversationID = it.arguments?.getString("id")!!
|
||||
MessageListScreen(conversationGUID = conversationID, backAction = {
|
||||
navController.popBackStack()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,17 @@
|
||||
package net.buzzert.kordophonedroid.ui.messagelist
|
||||
|
||||
import android.util.Log
|
||||
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.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
@@ -34,56 +30,92 @@ import androidx.compose.material.TopAppBar
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.draw.alpha
|
||||
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.TextFieldValue
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
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
|
||||
data class Message(
|
||||
val content: String,
|
||||
private val IncomingChatBubbleShape = RoundedCornerShape(4.dp, 20.dp, 20.dp, 20.dp)
|
||||
private val OutgoingChatBubbleShape = RoundedCornerShape(20.dp, 4.dp, 20.dp, 20.dp)
|
||||
|
||||
data class MessageListViewItem(
|
||||
val text: String,
|
||||
val fromMe: Boolean,
|
||||
val delivered: Boolean = true,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun MessageListScreen(
|
||||
messages: List<Message>,
|
||||
backAction: () -> Unit
|
||||
conversationGUID: GUID,
|
||||
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()
|
||||
var textState by rememberSaveable(stateSaver = TextFieldValue.Saver) {
|
||||
mutableStateOf(TextFieldValue())
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Messages") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = backAction) {
|
||||
Icon(Icons.Filled.ArrowBack, null)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
|
||||
})
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
@@ -94,14 +126,19 @@ fun MessageListScreen(
|
||||
MessageEntry(
|
||||
onTextChanged = { textState = it },
|
||||
textFieldValue = textState,
|
||||
onSend = {
|
||||
onSendMessage(textState.text)
|
||||
|
||||
// Clear text state
|
||||
textState = TextFieldValue()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Messages(
|
||||
messages: List<Message>,
|
||||
messages: List<MessageListViewItem>,
|
||||
modifier: Modifier = Modifier,
|
||||
scrollState: LazyListState
|
||||
) {
|
||||
@@ -117,97 +154,59 @@ fun Messages(
|
||||
for (index in messages.indices) {
|
||||
val content = messages[index]
|
||||
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
|
||||
fun MessageBubble(message: Message) {
|
||||
val backgroundBubbleColor = MaterialTheme.colors.primary
|
||||
fun MessageBubble(
|
||||
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() {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = if (mine) Arrangement.End else Arrangement.Start,
|
||||
) {
|
||||
Surface(
|
||||
color = backgroundBubbleColor,
|
||||
shape = ChatBubbleShape
|
||||
shape = if (mine) OutgoingChatBubbleShape else IncomingChatBubbleShape,
|
||||
) {
|
||||
Text(
|
||||
text = message.content,
|
||||
text = text,
|
||||
style = MaterialTheme.typography.body1,
|
||||
modifier = Modifier
|
||||
.padding(16.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
|
||||
@Composable
|
||||
private fun MessageListScreenPreview() {
|
||||
val messages = listOf<Message>(
|
||||
Message(content = "Hello"),
|
||||
Message(content = "Hi there"),
|
||||
)
|
||||
val messages = listOf<MessageListViewItem>(
|
||||
MessageListViewItem(text = "Hello", fromMe = false),
|
||||
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 = {})
|
||||
}
|
||||
}
|
||||
@@ -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
BIN
backend/chat-cache-test
Normal file
Binary file not shown.
BIN
backend/chat-cache-test.lock
Normal file
BIN
backend/chat-cache-test.lock
Normal file
Binary file not shown.
@@ -57,7 +57,7 @@ class CachedChatDatabase (private val realmConfig: RealmConfiguration) {
|
||||
.first()
|
||||
.messages
|
||||
.asFlow()
|
||||
.map { it.list.map { it.toMessage(conversation) } }
|
||||
.map { it.set.map { it.toMessage(conversation) } }
|
||||
}
|
||||
|
||||
fun updateConversations(incomingConversations: List<ModelConversation>) = realm.writeBlocking {
|
||||
@@ -111,15 +111,24 @@ class CachedChatDatabase (private val realmConfig: RealmConfiguration) {
|
||||
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)
|
||||
realm.writeBlocking {
|
||||
val dbMessages = messages
|
||||
.map { it.toDatabaseMessage() }
|
||||
.map { it.toDatabaseMessage(outgoing = outgoing) }
|
||||
.map { copyToRealm(it, updatePolicy = UpdatePolicy.ALL) }
|
||||
|
||||
val obj = findLatest(dbConversation)
|
||||
obj!!.messages.addAll(dbMessages)
|
||||
findLatest(dbConversation)?.messages?.addAll(dbMessages)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearOutgoingMessages() {
|
||||
realm.query(Message::class, "isOutgoing == true").find().forEach { message ->
|
||||
realm.writeBlocking {
|
||||
findLatest(message)?.let {
|
||||
delete(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,12 @@ package net.buzzert.kordophone.backend.db.model
|
||||
|
||||
import io.realm.kotlin.Realm
|
||||
import io.realm.kotlin.ext.realmListOf
|
||||
import io.realm.kotlin.ext.realmSetOf
|
||||
import io.realm.kotlin.ext.toRealmList
|
||||
import io.realm.kotlin.types.RealmInstant
|
||||
import io.realm.kotlin.types.RealmList
|
||||
import io.realm.kotlin.types.RealmObject
|
||||
import io.realm.kotlin.types.RealmSet
|
||||
import io.realm.kotlin.types.annotations.PrimaryKey
|
||||
import net.buzzert.kordophone.backend.model.GUID
|
||||
import org.mongodb.kbson.ObjectId
|
||||
@@ -23,7 +25,7 @@ open class Conversation(
|
||||
var unreadCount: Int,
|
||||
|
||||
var lastMessagePreview: String?,
|
||||
var messages: RealmList<Message>,
|
||||
var messages: RealmSet<Message>,
|
||||
): RealmObject
|
||||
{
|
||||
constructor() : this(
|
||||
@@ -35,7 +37,7 @@ open class Conversation(
|
||||
unreadCount = 0,
|
||||
lastMessagePreview = null,
|
||||
|
||||
messages = realmListOf<Message>()
|
||||
messages = realmSetOf<Message>()
|
||||
)
|
||||
|
||||
fun toConversation(): ModelConversation {
|
||||
|
||||
@@ -22,6 +22,7 @@ open class Message(
|
||||
var date: RealmInstant,
|
||||
|
||||
var conversationGUID: GUID,
|
||||
var isOutgoing: Boolean,
|
||||
): RealmObject
|
||||
{
|
||||
constructor() : this(
|
||||
@@ -30,6 +31,7 @@ open class Message(
|
||||
sender = null,
|
||||
date = RealmInstant.now(),
|
||||
conversationGUID = ObjectId().toString(),
|
||||
isOutgoing = false,
|
||||
)
|
||||
|
||||
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
|
||||
return Message().apply {
|
||||
text = from.text
|
||||
@@ -51,5 +53,6 @@ fun ModelMessage.toDatabaseMessage(): Message {
|
||||
sender = from.sender
|
||||
date = from.date.toInstant().toRealmInstant()
|
||||
conversationGUID = from.conversation.guid
|
||||
isOutgoing = outgoing
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package net.buzzert.kordophone.backend.model
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
|
||||
typealias GUID = String
|
||||
|
||||
@@ -27,6 +28,19 @@ data class Conversation(
|
||||
@SerializedName("lastMessage")
|
||||
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 {
|
||||
return displayName ?: participants.joinToString(", ")
|
||||
|
||||
@@ -2,6 +2,7 @@ package net.buzzert.kordophone.backend.model
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
|
||||
data class Message(
|
||||
@SerializedName("guid")
|
||||
@@ -19,6 +20,18 @@ data class Message(
|
||||
@Transient
|
||||
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 {
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,12 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.consumeEach
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@@ -34,7 +38,8 @@ class ChatRepository(
|
||||
get() = database.fetchConversations()
|
||||
|
||||
// Channel that's signaled when an outgoing message is delivered.
|
||||
val messageDeliveredChannel = Channel<MessageDeliveredEvent>()
|
||||
val messageDeliveredChannel: SharedFlow<MessageDeliveredEvent>
|
||||
get() = _messageDeliveredChannel
|
||||
|
||||
// Changes Flow
|
||||
val conversationChanges: Flow<List<Conversation>>
|
||||
@@ -66,6 +71,7 @@ class ChatRepository(
|
||||
private val apiInterface = apiClient.getAPIInterface()
|
||||
private val outgoingMessageQueue: ArrayBlockingQueue<OutgoingMessageInfo> = ArrayBlockingQueue(16)
|
||||
private var outgoingMessageThread: Thread? = null
|
||||
private val _messageDeliveredChannel = MutableSharedFlow<MessageDeliveredEvent>()
|
||||
|
||||
private val updateMonitor = UpdateMonitor(apiClient)
|
||||
private var updateWatchJob: Job? = null
|
||||
@@ -83,6 +89,9 @@ class ChatRepository(
|
||||
launch {
|
||||
updateMonitor.messageAdded.collect { handleMessageAddedUpdate(it) }
|
||||
}
|
||||
launch {
|
||||
messageDeliveredChannel.collectLatest { handleMessageDelivered(it) }
|
||||
}
|
||||
}
|
||||
|
||||
updateMonitor.beginMonitoringUpdates()
|
||||
@@ -124,14 +133,26 @@ class ChatRepository(
|
||||
val serverConversations = fetchConversations()
|
||||
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
|
||||
Log.d(REPO_LOG, "Synchronizing messages")
|
||||
val sortedConversations = conversations.sortedBy { it.date }.reversed()
|
||||
for (conversation in sortedConversations.take(CONVERSATION_MESSAGE_SYNC_COUNT)) {
|
||||
synchronizeConversation(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() {
|
||||
database.close()
|
||||
@@ -164,6 +185,14 @@ class ChatRepository(
|
||||
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() {
|
||||
Log.d(REPO_LOG, "Outgoing Message Queue Main")
|
||||
while (true) {
|
||||
@@ -185,7 +214,7 @@ class ChatRepository(
|
||||
|
||||
if (result.isSuccessful) {
|
||||
Log.d(REPO_LOG, "Successfully sent message.")
|
||||
messageDeliveredChannel.send(MessageDeliveredEvent(guid, message, conversation))
|
||||
_messageDeliveredChannel.emit(MessageDeliveredEvent(guid, message, conversation))
|
||||
} else {
|
||||
Log.e(REPO_LOG, "Error sending message. Enqueuing for retry.")
|
||||
outgoingMessageQueue.add(it)
|
||||
|
||||
@@ -16,7 +16,7 @@ import okio.ByteString
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.lang.reflect.Type
|
||||
|
||||
const val UPMON_LOG: String = "ChatRepository"
|
||||
const val UPMON_LOG: String = "UpdateMonitor"
|
||||
|
||||
class UpdateMonitor(private val client: APIClient) : WebSocketListener() {
|
||||
// Flow for getting conversation changed notifications
|
||||
|
||||
@@ -3,6 +3,7 @@ package net.buzzert.kordophone.backend
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -87,7 +88,7 @@ class BackendTests {
|
||||
|
||||
val guid = repository.enqueueOutgoingMessage(outgoingMessage, conversation)
|
||||
|
||||
val event = repository.messageDeliveredChannel.receive()
|
||||
val event = repository.messageDeliveredChannel.first()
|
||||
assertEquals(event.guid, guid)
|
||||
assertEquals(event.message.text, outgoingMessage.text)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user