From 63759007103c2be99414a310be3a13834159253e Mon Sep 17 00:00:00 2001 From: James Magahern Date: Thu, 24 Aug 2023 00:45:18 -0700 Subject: [PATCH] Implements message loading and display --- .../kordophonedroid/ui/KordophoneApp.kt | 6 +- .../ui/messagelist/MessageEntryView.kt | 76 ++++++ .../ui/messagelist/MessageListScreen.kt | 229 +++++++++--------- .../ui/messagelist/MessageListViewModel.kt | 84 +++++++ backend/chat-cache-test | Bin 0 -> 32768 bytes backend/chat-cache-test.lock | Bin 0 -> 1728 bytes .../backend/db/CachedChatDatabase.kt | 19 +- .../backend/db/model/Conversation.kt | 6 +- .../kordophone/backend/db/model/Message.kt | 5 +- .../kordophone/backend/model/Conversation.kt | 14 ++ .../kordophone/backend/model/Message.kt | 22 ++ .../backend/server/ChatRepository.kt | 37 ++- .../backend/server/UpdateMonitor.kt | 2 +- .../kordophone/backend/BackendTests.kt | 3 +- 14 files changed, 371 insertions(+), 132 deletions(-) create mode 100644 app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageEntryView.kt create mode 100644 app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListViewModel.kt create mode 100644 backend/chat-cache-test create mode 100644 backend/chat-cache-test.lock diff --git a/app/src/main/java/net/buzzert/kordophonedroid/ui/KordophoneApp.kt b/app/src/main/java/net/buzzert/kordophonedroid/ui/KordophoneApp.kt index 369073c..368ba41 100644 --- a/app/src/main/java/net/buzzert/kordophonedroid/ui/KordophoneApp.kt +++ b/app/src/main/java/net/buzzert/kordophonedroid/ui/KordophoneApp.kt @@ -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() - } + }) } } } diff --git a/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageEntryView.kt b/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageEntryView.kt new file mode 100644 index 0000000..1c15edf --- /dev/null +++ b/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageEntryView.kt @@ -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") + } + } + } + } +} diff --git a/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListScreen.kt b/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListScreen.kt index f0dadfc..e120326 100644 --- a/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListScreen.kt +++ b/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListScreen.kt @@ -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,74 +30,115 @@ 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, - 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, + 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 = { + Column( + Modifier + .fillMaxSize() + .padding(paddingValues)) { - }) - } - ) { paddingValues -> - Column( - Modifier - .fillMaxSize() - .padding(paddingValues)) { + Messages(messages = messages, modifier = Modifier.weight(1f), scrollState = scrollState) - Messages(messages = messages, modifier = Modifier.weight(1f), scrollState = scrollState) + MessageEntry( + onTextChanged = { textState = it }, + textFieldValue = textState, + onSend = { + onSendMessage(textState.text) - MessageEntry( - onTextChanged = { textState = it }, - textFieldValue = textState, - ) - } + // Clear text state + textState = TextFieldValue() + }, + ) } } @Composable fun Messages( - messages: List, + messages: List, 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)) { - Surface( - color = backgroundBubbleColor, - shape = ChatBubbleShape + Column() { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = if (mine) Arrangement.End else Arrangement.Start, ) { - Text( - text = message.content, - style = MaterialTheme.typography.body1, - modifier = Modifier - .padding(16.dp) - ) + Surface( + color = backgroundBubbleColor, + shape = if (mine) OutgoingChatBubbleShape else IncomingChatBubbleShape, + ) { + Text( + 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(content = "Hello"), - Message(content = "Hi there"), - ) + val messages = listOf( + 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 = {}) + } } \ No newline at end of file diff --git a/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListViewModel.kt b/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListViewModel.kt new file mode 100644 index 0000000..125a5cd --- /dev/null +++ b/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListViewModel.kt @@ -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> = 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> + 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!!) + } +} \ No newline at end of file diff --git a/backend/chat-cache-test b/backend/chat-cache-test new file mode 100644 index 0000000000000000000000000000000000000000..473f39989f6fa02c0b14a243f693428a13054867 GIT binary patch literal 32768 zcmeHPPjFkudEbZs1PBVWNDCAt5l*5Astx<$|AW%XkY&nr8>j48N^uJxfCp2ZNal!i z!t`P^O*=f|(3!9^J>bY5I>TESHwWH=nO=C*A=6=c=y0Z!86J76`un}zB|!)>Nr~gw zowxR4-}k=VZ@>M%Z@>L^-%>&xPxmwLzsEOskj8}YKXO*=roCbxuXPArYW&zg(k-?p#k>J5ClbR5v# zbEzEC4eu+MeF#9-Hy*p>=#W!-}?D~9m1IJ!ISKG#aSI{U@j3Mrk9<^ zP3MV%o-d|Ovg1adC;8yPhwl%NxIn&PTTo@fMG=Pl0sGo~0sC`HzwrHna6m*{7`Et^ z{c@*09{3}HO%N1f8^2QTgZ89hU81tVe%Xp8?a~ZdK@IDAcpo-IBA5)Og6Uv2I3GN; zt3x|na$6nJ39TxawEF6YXj{~wvN773o7q3d518q5&NsG|;EU~%?Pzxg?%3Hvrkd(s zv$3WgwEbu^>#FzLx}qkaO4yxibGn^Iv)@@*bHr%-573tIxDYi%MBxQROvE8v!|+9g zG4XfpwQgYI=k2xaz{KCTyY0ZlFWPPV{)7AeAKRPlP*nI>p&dbmwQd9zx_Cx~b_Dc5 zi}W3kY{NpB2%}qL9zyGfH>orv;!zkZ?+Fo00$4&@BPGN>X`o#K88qCyOB|k=-?{(r z5!*qe#&y6i~~F~u|9_8qfPzl;J=#+HpGv!QHKiB#rI77D7~(G%R6T8<>k8I_3`zoG&|m8M z3Jt*r+I-gS_{>Cp!4K?qGty_be*!y&&AOHnuGZIm74E;7}J_F{=R zl+4;LVIOXL_P+-_6X~ITQ1)+)tYX^qzn6S$3hf72A2JK(?;h0~h@AjmS>H%HFo4{q{D@ZgxO}N!!7o-B6xPuWmPc z(LTHRHtgoO)qKYow42AxhjxnNp}v-4k`Ka==ZMtTR(K(@8L>w8My%0~?MI)=`ov4U zEr=xIm>z1X-HY^~%=MM@0J%i3i$52iihmNn7XK;yM%wtu zcw%r|T5*0!gzA3_0g*&&QAm@ z4Eue4Oub-P8MByc78|*2zSV39yIina zxmF?LG_$!(!K~XEJCC1Mxlk{ft!%|EWrdy3ne}|Jf=-&KR4O(ywqxfq&1|vIs#um; z$rgoKFXoE5LNim&X2Dao*~-|BN+nY+S*BUB?MkT*N|h|g=dDc1$`{dHDW97E8eAa^Sv@FEuiCCtuFk*+$+hmMeC_afFQ_)Ej0w)6C@{EUQt+ z=o;WD?^uOG-L6~Brf6i#CQ2t$u@L8hr)I{^+1X6F)u(jlJRuhitonf$2#NEk8)m8+PPxgG+S1_S%wY@PRY#W?Oda1+l8`i zLzHGY+aMhW^M7kR2%mldzTA(sc^vUVTrh@I{e<=yB&ekOp}!v7Cv;Uk%xyPCUXvw# zDU*KRRS(;$oRsLp-;?(VOQR{!CV#Ngj}Y)9-f}~JhFOUD8D=5qcf%|cF+*Yvi8UnF zU=Y@h7%`v3`XtsTu|A3Q#rzWMmsr2V`X$yM3rK80VgnKzkk~*hD6v6_4N7cKVuP`e z#D*j`B(Wih4aLF|8%cP+hDlz>6s*{FxF|boW2Wa?txUaa)iZXbUdqCERf|~F^`|yXKnv#HLdBP zzOOymaa*uH-Wx16e^mveUSwk;GMSuQn(R()PHs(ZPqH598xTrC({n&?cTxs2#G_q! z|JcNo?S{o^JylP(2F2G&6&=XJjTiYi7%vAEUKd}s&#J0;g?!QPbzawg?})EKf3&Xn z(ESYhBOS+gRfTO;9ve28JJ}y4U*AMulpE~rEljXc`W$x$XviJlVI)uIg98iK`E&!u z!%r67^FjyRSeK+ENC&J;9?iG;@K!j99r8kCRmJwJ(VeI@nvQqJQ?FH%a$VN#j^^K@ zSP)fr4q7VauZcxt6X#^u1NgSXa(!hx=x#FKTK?>Ze|`UxpQ>|UzSr|I4c-hWp^b72 zc^~-#*rmvph(pP&=Ovc{w&%F2JTOr(>c4*4aaos=Q;auAhwV-~u86T0V%)%X7}x7Z z4O1|B=eJ!JCEan0d*a9o`C>KN7?-%x*J+IbtRdZyXRmEmvlPR7a=n;Fw- z)hl+xE?1g`FY`C)DQjwRO3F{q=TrI{^8Oab4Zg{`n+VUs-*l$BQ`?FzU}Fc*OvDfK zH`O1E-_&^2_w7%K%Nj;$u$|_ws$kTMB&SxV)~0r+5|>k#)0fT5tVe&7x6NbzX8v-J zb<>i9^?Ml342eLS&$=C-naD5r!MV=b z<-vK)Xu+M=J~^*_dWX)i0rT1o8@d74L`cVh+^WgMw5HoXFKX^v+22|93wj3Wlh38; z_H-At6l`qt(WTtkHPuYdi@{>?4$M*Llke;{GluY3LgImOSi58lFF znYnL2JbIeFFzYCBYkB+jub5Z)j{LpE_`*}7cjf7otvC0sEMA>^Tj~coF!aF%=|Ydj zw%g00T$En7rlnqYuCm@Rz3!@ZFVZV*n&}nw8XK-x$uIQEV<$JM@%WdnZNAaDCh^d# z#6y6@L$5KG(Cg?~^{U#%URIyIYly6oNC)h^U|jN?W7xRnW+LBN1O>K--olqe_uA&Q z`EPGuvu0Ljq@Em%YvcN`ecPmN7wWoPNSWz&pa;KpaCP^(qz|m2`tfhw=-t>;{RV}& zeA;z-Vjl$0K-u^=#v*CgQ$7!d(AuB&GoR|Izl z=0qp3Bj#bNxX+xXY6d_Oq8ofPKP8`uXS(|>Y&3q!_-eef^6^O~>vyr$W- z8>L*UlyT|}>?{jK9DbE?IOSCG9VCwNW&WItcUPI@tr6pPqR?-}cW zA_o2af=H>8uQ}|rpBhQL+jaMo90BRW|KHU9|F^&W?Jxgj%dd! zz-SB6<=Ah)_ak%LM$vchj=Evi>)cuQ5wmE%uk_4*!I%Gwb#jh04hS2X><4(P&;5Z>?U-f44@E*jM%U7P~ zu-!2Ec5c>6TQ?8&7oHZ2>D6?#xR!3;?A?_5gP;G67e9P035AZlBT6Mqt6{inL+5_jj=to6pBXfB3_{{m&o9$j3dk@1KJNjJ6P6 zlo1o(9+0UcMB`R&$#Flg7>OVC;9;xO=(8-F;9Z z9`+EvDk8aL?&+VVb6Yu)m-=)t?(IK2@=H@fd!}jk76pA1+haZZy%%47bK@&qog@dbdX#?3Pkkc}i+|nYth4gRabbSOUuJ%${#V{N z=R7#Sc*Uc1x(M-9A=~*?Qulv+cs<5p}9J?P&C&zt=qClJc>9}uH#(kqQ z?i-bH->8iHM*HGEAMSauor8E}z9PFN{1WFo{`Ja6rK8SQWcjGqrgT$+>`&|1;4}AI3w+`;G55mxe>`%eK`>XF&zu&$k@ecZJ zzo+fuD$a7AUwKJqT=+uXy8Hip-L=cjg44pyVO-@cWw16^GFG;k&ETeRvutJSm3-00 zU%$~FFt^9fy6%0`>T{foqfhI13(ccD!Fs=q`4&p&-5D=`RJ=RPpC(kjpss#fK4^cc zC-R^_)%zOV>|}ph%O*jaYkuR~l2)f(c;>%P@V5p|-Lw z*v0q!{!qvt4u(U)a4>)(JnH^8GCC58j>KZ4u~;+~Ph3ch#mC|oqA~o41i%XkA>8}a zlr#1N?tO(S)?9-p6II+*Ko)m|cmoB-Db`#Z@ki$t!_EnMO0Uqln+CsIkRH>k+{1z2 zxbQOZz3dsk2+28ajO&EBLhid~v9F#H`|l=h&HY8{*3zxcEoqD^QF?ftL9U#9RqdjURk z6@Njh{bo(<2Ww(iB#pMwF{}#tf&YB}?Edq$@z)agA3k&(j{^T=9=qp~+K+bNpXoW_)_+BQ5(?3*Tyg6 zh+rI9{T(jtQrl4tHb6V7!a#PB+jH*q3fGR-UEFj2|4nZn>FpzJQ7km^uXEt-FGw`o;I;ddxMRsi$DZ=S(;Ya8p-R*jA-p#}yjh{>9(v^?AaWJWm*t z=Luu-JYh_pCye!-Ct!VY_ZR5uqd(?4pUPIVJ8G@(-Rj&%Y*Q6?6L(Xyy?c?rlJ^lg zAIaw*bG_%@yKOK|oX5XAf?KG75+dQ-Fy?#*Zy)^~Fy#8ttF8MJ7bmdt<9A%(N8sPz z;P~~rzYE*px}W|_{Cg(ItMj;s30=};_Fdf3y07iVD?JH#67VG8Nx+kUCjn0ao&-Dz zcoOg=;7P!ffF}V@0-gjs33w9lB;ZNFlYl1yPXe9|O{0Z#&+1Uw0N h67VG8Nx+kUCjn0ao&-DzcoOg=;7P!fz}Ha%{|C}Hq*?#~ literal 0 HcmV?d00001 diff --git a/backend/chat-cache-test.lock b/backend/chat-cache-test.lock new file mode 100644 index 0000000000000000000000000000000000000000..24d6e22a951cde11a3054e5f7a8a1e2215eb61ab GIT binary patch literal 1728 zcmeH_Jr2S!429kDR}iWYMWSPwVC)gXf;w^(M#dZ=Bgfz?pm{d6>XGRQ=g7)%l62_5xsD@xZRz*_<)WI$zIXM$sQr|mT400$ z$BN`WC&j4i#bPs;&%_A1vDnO+g%}|>5t}(!iV<>Cu?tEr>Ic*J^VeZ6$z@|$FADyeEXSuoQcC)x?a0`$c7H|w*zk&P!K8r literal 0 HcmV?d00001 diff --git a/backend/src/main/java/net/buzzert/kordophone/backend/db/CachedChatDatabase.kt b/backend/src/main/java/net/buzzert/kordophone/backend/db/CachedChatDatabase.kt index 4114469..4d06c13 100644 --- a/backend/src/main/java/net/buzzert/kordophone/backend/db/CachedChatDatabase.kt +++ b/backend/src/main/java/net/buzzert/kordophone/backend/db/CachedChatDatabase.kt @@ -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) = realm.writeBlocking { @@ -111,15 +111,24 @@ class CachedChatDatabase (private val realmConfig: RealmConfiguration) { return items.map { it.toConversation() } } - fun writeMessages(messages: List, conversation: ModelConversation) { + fun writeMessages(messages: List, 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) + } + } } } diff --git a/backend/src/main/java/net/buzzert/kordophone/backend/db/model/Conversation.kt b/backend/src/main/java/net/buzzert/kordophone/backend/db/model/Conversation.kt index 36439bb..927d60a 100644 --- a/backend/src/main/java/net/buzzert/kordophone/backend/db/model/Conversation.kt +++ b/backend/src/main/java/net/buzzert/kordophone/backend/db/model/Conversation.kt @@ -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, + var messages: RealmSet, ): RealmObject { constructor() : this( @@ -35,7 +37,7 @@ open class Conversation( unreadCount = 0, lastMessagePreview = null, - messages = realmListOf() + messages = realmSetOf() ) fun toConversation(): ModelConversation { diff --git a/backend/src/main/java/net/buzzert/kordophone/backend/db/model/Message.kt b/backend/src/main/java/net/buzzert/kordophone/backend/db/model/Message.kt index 24beca9..c27f151 100644 --- a/backend/src/main/java/net/buzzert/kordophone/backend/db/model/Message.kt +++ b/backend/src/main/java/net/buzzert/kordophone/backend/db/model/Message.kt @@ -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 } } diff --git a/backend/src/main/java/net/buzzert/kordophone/backend/model/Conversation.kt b/backend/src/main/java/net/buzzert/kordophone/backend/model/Conversation.kt index 2370449..6f4dfda 100644 --- a/backend/src/main/java/net/buzzert/kordophone/backend/model/Conversation.kt +++ b/backend/src/main/java/net/buzzert/kordophone/backend/model/Conversation.kt @@ -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(", ") diff --git a/backend/src/main/java/net/buzzert/kordophone/backend/model/Message.kt b/backend/src/main/java/net/buzzert/kordophone/backend/model/Message.kt index 92acdc2..1318cba 100644 --- a/backend/src/main/java/net/buzzert/kordophone/backend/model/Message.kt +++ b/backend/src/main/java/net/buzzert/kordophone/backend/model/Message.kt @@ -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 + } } \ No newline at end of file diff --git a/backend/src/main/java/net/buzzert/kordophone/backend/server/ChatRepository.kt b/backend/src/main/java/net/buzzert/kordophone/backend/server/ChatRepository.kt index 8b55955..393813c 100644 --- a/backend/src/main/java/net/buzzert/kordophone/backend/server/ChatRepository.kt +++ b/backend/src/main/java/net/buzzert/kordophone/backend/server/ChatRepository.kt @@ -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() + val messageDeliveredChannel: SharedFlow + get() = _messageDeliveredChannel // Changes Flow val conversationChanges: Flow> @@ -66,6 +71,7 @@ class ChatRepository( private val apiInterface = apiClient.getAPIInterface() private val outgoingMessageQueue: ArrayBlockingQueue = ArrayBlockingQueue(16) private var outgoingMessageThread: Thread? = null + private val _messageDeliveredChannel = MutableSharedFlow() 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,15 +133,27 @@ 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)) { - val messages = fetchMessages(conversation, limit = 15) - database.writeMessages(messages, conversation) + 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) diff --git a/backend/src/main/java/net/buzzert/kordophone/backend/server/UpdateMonitor.kt b/backend/src/main/java/net/buzzert/kordophone/backend/server/UpdateMonitor.kt index c1c5aab..7ad2519 100644 --- a/backend/src/main/java/net/buzzert/kordophone/backend/server/UpdateMonitor.kt +++ b/backend/src/main/java/net/buzzert/kordophone/backend/server/UpdateMonitor.kt @@ -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 diff --git a/backend/src/test/java/net/buzzert/kordophone/backend/BackendTests.kt b/backend/src/test/java/net/buzzert/kordophone/backend/BackendTests.kt index 38b770c..4081ebc 100644 --- a/backend/src/test/java/net/buzzert/kordophone/backend/BackendTests.kt +++ b/backend/src/test/java/net/buzzert/kordophone/backend/BackendTests.kt @@ -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)