Private
Public Access
1
0

Better database syncing... maybe

This commit is contained in:
2023-08-19 01:22:56 -07:00
parent 19a6960ab2
commit 4add2b4674
10 changed files with 123 additions and 48 deletions

View File

@@ -17,6 +17,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
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.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -24,6 +25,22 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import net.buzzert.kordophone.backend.model.Conversation import net.buzzert.kordophone.backend.model.Conversation
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Date
fun formatDateTime(dateTime: LocalDateTime): String {
val formatter: DateTimeFormatter = if (LocalDate.now().isEqual(dateTime.toLocalDate())) {
DateTimeFormatter.ofPattern("HH:mm") // show just the time
} else {
DateTimeFormatter.ofPattern("M/d/yy") // show day/month/year
}
return dateTime.format(formatter)
}
@Composable @Composable
fun ConversationListScreen( fun ConversationListScreen(
@@ -58,6 +75,7 @@ fun ConversationListView(
id = conversation.guid, id = conversation.guid,
isUnread = conversation.unreadCount > 0, isUnread = conversation.unreadCount > 0,
lastMessagePreview = conversation.lastMessagePreview ?: "", lastMessagePreview = conversation.lastMessagePreview ?: "",
date = conversation.date,
onClick = { onConversationSelected(conversation.guid) } onClick = { onConversationSelected(conversation.guid) }
) )
} }
@@ -71,11 +89,12 @@ fun ConversationListItem(
id: String, id: String,
isUnread: Boolean, isUnread: Boolean,
lastMessagePreview: String, lastMessagePreview: String,
date: Date,
onClick: () -> Unit onClick: () -> Unit
) { ) {
val unreadSize = 12.dp val unreadSize = 12.dp
val horizontalPadding = 8.dp val horizontalPadding = 8.dp
val verticalPadding = 12.dp val verticalPadding = 14.dp
Row( Row(
Modifier Modifier
@@ -108,7 +127,12 @@ fun ConversationListItem(
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
Text("13:37", Text(
formatDateTime(
date.toInstant()
.atZone(ZoneId.systemDefault())
.toLocalDateTime()
),
modifier = Modifier.align(Alignment.CenterVertically), modifier = Modifier.align(Alignment.CenterVertically),
color = MaterialTheme.colors.onBackground.copy(alpha = 0.4f) color = MaterialTheme.colors.onBackground.copy(alpha = 0.4f)
) )
@@ -116,7 +140,7 @@ fun ConversationListItem(
Spacer(Modifier.width(horizontalPadding)) Spacer(Modifier.width(horizontalPadding))
} }
Text(lastMessagePreview) Text(lastMessagePreview, maxLines = 1, overflow = TextOverflow.Ellipsis)
Spacer(Modifier.height(verticalPadding)) Spacer(Modifier.height(verticalPadding))
Divider() Divider()
@@ -142,7 +166,7 @@ fun UnreadIndicator(size: Dp, modifier: Modifier = Modifier) {
@Composable @Composable
fun ConversationListItemPreview() { fun ConversationListItemPreview() {
Column(modifier = Modifier.background(MaterialTheme.colors.background)) { Column(modifier = Modifier.background(MaterialTheme.colors.background)) {
ConversationListItem(name = "James Magahern", id = "asdf", lastMessagePreview = "This is a test", isUnread = true) {} ConversationListItem(name = "James Magahern", id = "asdf", lastMessagePreview = "This is a test", date = Date(), isUnread = true) {}
} }
} }

View File

@@ -4,13 +4,16 @@ import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.buzzert.kordophone.backend.model.Conversation import net.buzzert.kordophone.backend.model.Conversation
import net.buzzert.kordophone.backend.server.ChatRepository import net.buzzert.kordophone.backend.server.ChatRepository
import javax.inject.Inject import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
@HiltViewModel @HiltViewModel
class ConversationListViewModel @Inject constructor( class ConversationListViewModel @Inject constructor(
@@ -28,7 +31,9 @@ class ConversationListViewModel @Inject constructor(
// TODO: Need error handling (exceptions thrown below) // TODO: Need error handling (exceptions thrown below)
viewModelScope.launch { viewModelScope.launch {
repository.synchronize() withContext(Dispatchers.IO) {
repository.synchronize()
}
} }
viewModelScope.launch { viewModelScope.launch {

View File

@@ -1,9 +1,11 @@
package net.buzzert.kordophone.backend.db package net.buzzert.kordophone.backend.db
import io.realm.kotlin.MutableRealm
import io.realm.kotlin.Realm import io.realm.kotlin.Realm
import io.realm.kotlin.RealmConfiguration import io.realm.kotlin.RealmConfiguration
import io.realm.kotlin.UpdatePolicy import io.realm.kotlin.UpdatePolicy
import io.realm.kotlin.ext.toRealmList import io.realm.kotlin.ext.toRealmList
import io.realm.kotlin.query.find
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@@ -44,7 +46,8 @@ class CachedChatDatabase (private val realmConfig: RealmConfiguration) {
// Flow for watching changes to the database // Flow for watching changes to the database
val conversationChanges: Flow<List<ModelConversation>> val conversationChanges: Flow<List<ModelConversation>>
get() = realm.query(Conversation::class).find().asFlow().map { get() = realm.query(Conversation::class).find().asFlow().map {
it.list.map { it.toConversation() } realm.copyFromRealm(it.list)
.map { it.toConversation() }
} }
// Flow for watching for message changes for a given conversation // Flow for watching for message changes for a given conversation
@@ -57,37 +60,45 @@ class CachedChatDatabase (private val realmConfig: RealmConfiguration) {
.map { it.list.map { it.toMessage(conversation) } } .map { it.list.map { it.toMessage(conversation) } }
} }
fun writeConversations(conversations: List<ModelConversation>) = realm.writeBlocking { fun updateConversations(incomingConversations: List<ModelConversation>) = realm.writeBlocking {
val dbConversations = conversations val incomingDatabaseConversations = incomingConversations.map { it.toDatabaseConversation() }
// Convert to database conversations
.map { it.toDatabaseConversation() }
// Look for existing conversations, if applicable var deletedConversations = realm.query(Conversation::class).find()
.map { .minus(incomingDatabaseConversations)
try {
val existingConvo = getConversationByGuid(it.guid)
// Update existing record deletedConversations.forEach { conversation ->
findLatest(existingConvo)?.apply { findLatest(conversation)?.let {
displayName = it.displayName delete(it)
participants = it.participants
date = it.date
unreadCount = it.unreadCount
} ?: existingConvo
} catch (e: NoSuchElementException) {
// This means object is unmanaged (i.e. it's new)
it
}
} }
}
dbConversations.forEach { writeManagedConversations(this, incomingDatabaseConversations)
copyToRealm(it, updatePolicy = UpdatePolicy.ALL) }
fun writeConversations(conversations: List<ModelConversation>) = realm.writeBlocking {
writeManagedConversations(this, conversations.map { it.toDatabaseConversation() })
}
private fun writeManagedConversations(mutableRealm: MutableRealm, conversations: List<Conversation>) {
conversations.forEach {conversation ->
try {
val managedConversation = getManagedConversationByGuid(conversation.guid)
mutableRealm.findLatest(managedConversation)?.apply {
displayName = conversation.displayName
participants = conversation.participants
date = conversation.date
unreadCount = conversation.unreadCount
}
} catch (e: NoSuchElementException) {
// Conversation does not exist. Copy it to the realm.
mutableRealm.copyToRealm(conversation, updatePolicy = UpdatePolicy.ALL)
}
} }
} }
fun deleteConversations(conversations: List<ModelConversation>) = realm.writeBlocking { fun deleteConversations(conversations: List<ModelConversation>) = realm.writeBlocking {
conversations.forEach { inConversation -> conversations.forEach { inConversation ->
val conversation = getConversationByGuid(inConversation.guid) val conversation = getManagedConversationByGuid(inConversation.guid)
findLatest(conversation)?.let { findLatest(conversation)?.let {
delete(it) delete(it)
} }
@@ -95,12 +106,13 @@ class CachedChatDatabase (private val realmConfig: RealmConfiguration) {
} }
fun fetchConversations(): List<ModelConversation> { fun fetchConversations(): List<ModelConversation> {
val items = realm.query(Conversation::class).find() val itemResults = realm.query(Conversation::class).find()
val items = realm.copyFromRealm(itemResults)
return items.map { it.toConversation() } return items.map { it.toConversation() }
} }
fun writeMessages(messages: List<ModelMessage>, conversation: ModelConversation) { fun writeMessages(messages: List<ModelMessage>, conversation: ModelConversation) {
val dbConversation = getConversationByGuid(conversation.guid) val dbConversation = getManagedConversationByGuid(conversation.guid)
realm.writeBlocking { realm.writeBlocking {
val dbMessages = messages val dbMessages = messages
.map { it.toDatabaseMessage() } .map { it.toDatabaseMessage() }
@@ -120,9 +132,13 @@ class CachedChatDatabase (private val realmConfig: RealmConfiguration) {
realm.close() realm.close()
} }
fun getConversationByGuid(guid: GUID): Conversation { private fun getManagedConversationByGuid(guid: GUID): Conversation {
return realm.query(Conversation::class, "guid == '$guid'") return realm.query(Conversation::class, "guid == '$guid'")
.find() .find()
.first() .first()
} }
fun getConversationByGuid(guid: GUID): Conversation {
return realm.copyFromRealm(getManagedConversationByGuid(guid))
}
} }

View File

@@ -22,6 +22,7 @@ open class Conversation(
var date: RealmInstant, var date: RealmInstant,
var unreadCount: Int, var unreadCount: Int,
var lastMessagePreview: String?,
var messages: RealmList<Message>, var messages: RealmList<Message>,
): RealmObject ): RealmObject
{ {
@@ -32,6 +33,7 @@ open class Conversation(
participants = realmListOf<String>(), participants = realmListOf<String>(),
date = RealmInstant.now(), date = RealmInstant.now(),
unreadCount = 0, unreadCount = 0,
lastMessagePreview = null,
messages = realmListOf<Message>() messages = realmListOf<Message>()
) )
@@ -43,17 +45,29 @@ open class Conversation(
date = Date.from(date.toInstant()), date = Date.from(date.toInstant()),
unreadCount = unreadCount, unreadCount = unreadCount,
guid = guid, guid = guid,
lastMessagePreview = lastMessagePreview,
lastMessage = null, lastMessage = null,
lastMessagePreview = null,
) )
if (messages.isNotEmpty()) { if (messages.isNotEmpty()) {
conversation.lastMessage = messages.last().toMessage(conversation) val lastMessage = sortedMessages().last()
conversation.lastMessagePreview = messages.last().text conversation.lastMessage = lastMessage.toMessage(conversation)
conversation.lastMessagePreview = lastMessage.text
} }
return conversation return conversation
} }
private fun sortedMessages(): List<Message> {
return messages.sortedBy { it.date }
}
override fun equals(other: Any?): Boolean {
if (other == null || javaClass != other.javaClass) return false
val o = other as Conversation
return guid == o.guid
}
} }
fun ModelConversation.toDatabaseConversation(): Conversation { fun ModelConversation.toDatabaseConversation(): Conversation {
@@ -63,6 +77,7 @@ fun ModelConversation.toDatabaseConversation(): Conversation {
participants = from.participants.toRealmList() participants = from.participants.toRealmList()
date = from.date.toInstant().toRealmInstant() date = from.date.toInstant().toRealmInstant()
unreadCount = from.unreadCount unreadCount = from.unreadCount
lastMessagePreview = from.lastMessagePreview
guid = from.guid guid = from.guid
} }
} }

View File

@@ -32,7 +32,7 @@ data class Message(
text == o.text && text == o.text &&
sender == o.sender && sender == o.sender &&
date == o.date && date == o.date &&
conversation == o.conversation conversation.guid == o.conversation.guid
) )
} }
} }

View File

@@ -2,6 +2,7 @@ package net.buzzert.kordophone.backend.server
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import net.buzzert.kordophone.backend.model.Conversation 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.model.Message
import okhttp3.ResponseBody import okhttp3.ResponseBody
import retrofit2.Call import retrofit2.Call
@@ -32,7 +33,12 @@ interface APIInterface {
suspend fun getConversations(): Response<List<Conversation>> suspend fun getConversations(): Response<List<Conversation>>
@GET("/messages") @GET("/messages")
suspend fun getMessages(@Query("guid") conversationGUID: String): Response<List<Message>> suspend fun getMessages(
@Query("guid") conversationGUID: String,
@Query("limit") limit: Int? = null,
@Query("beforeMessageGUID") beforeMessageGUID: GUID? = null,
@Query("afterMessageGUID") afterMessageGUID: GUID? = null,
): Response<List<Message>>
@POST("/sendMessage") @POST("/sendMessage")
suspend fun sendMessage(@Body request: SendMessageRequest): Response<Void> suspend fun sendMessage(@Body request: SendMessageRequest): Response<Void>

View File

@@ -122,16 +122,13 @@ class ChatRepository(
// Sync conversations // Sync conversations
val serverConversations = fetchConversations() val serverConversations = fetchConversations()
val deletedConversations = conversations.minus(serverConversations) database.updateConversations(serverConversations)
database.deleteConversations(deletedConversations)
database.writeConversations(serverConversations)
// 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) val messages = fetchMessages(conversation, limit = 15)
database.writeMessages(messages, conversation) database.writeMessages(messages, conversation)
} }
} }
@@ -146,15 +143,20 @@ class ChatRepository(
return apiInterface.getConversations().bodyOnSuccessOrThrow() return apiInterface.getConversations().bodyOnSuccessOrThrow()
} }
private suspend fun fetchMessages(conversation: Conversation): List<Message> { private suspend fun fetchMessages(
return apiInterface.getMessages(conversation.guid) conversation: Conversation,
limit: Int? = null,
before: Message? = null,
after: Message? = null,
): List<Message> {
return apiInterface.getMessages(conversation.guid, limit, before?.guid, after?.guid)
.bodyOnSuccessOrThrow() .bodyOnSuccessOrThrow()
.onEach { it.conversation = conversation } .onEach { it.conversation = conversation }
} }
private fun handleConversationChangedUpdate(conversation: Conversation) { private fun handleConversationChangedUpdate(conversation: Conversation) {
Log.d(REPO_LOG, "Handling conversation changed update") Log.d(REPO_LOG, "Handling conversation changed update")
database.writeConversations(conversations) database.writeConversations(listOf(conversation))
} }
private fun handleMessageAddedUpdate(message: Message) { private fun handleMessageAddedUpdate(message: Message) {

View File

@@ -105,13 +105,13 @@ class BackendTests {
repo.synchronize() repo.synchronize()
// Check our count. // Check our count.
assertEquals(repo.conversations.count(), 10) assertEquals(10, repo.conversations.count())
// Sync again: let's ensure we're de-duplicating conversations. // Sync again: let's ensure we're de-duplicating conversations.
repo.synchronize() repo.synchronize()
// Should be no change... // Should be no change...
assertEquals(repo.conversations.count(), 10) assertEquals(10, repo.conversations.count())
// Say unread count + lastMessage preview changes on server. // Say unread count + lastMessage preview changes on server.
val someConversation = conversations.first().apply { val someConversation = conversations.first().apply {

View File

@@ -41,7 +41,7 @@ class DatabaseTests {
val readMessages = db.fetchMessages(conversation) val readMessages = db.fetchMessages(conversation)
assertEquals(readMessages, messages) assertEquals(readMessages, messages)
assertEquals(readMessages[0].conversation, conversation) assertEquals(readMessages[0].conversation.guid, conversation.guid)
db.close() db.close()
} }
@@ -72,5 +72,7 @@ class DatabaseTests {
// Make sure our new name was written // Make sure our new name was written
assertEquals(nowConversations.first().displayName, "wow") assertEquals(nowConversations.first().displayName, "wow")
db.close()
} }
} }

View File

@@ -222,7 +222,12 @@ class MockServerInterface(private val server: MockServer): APIInterface {
return Response.success(server.conversations) return Response.success(server.conversations)
} }
override suspend fun getMessages(conversationGUID: String): Response<List<Message>> { override suspend fun getMessages(
conversationGUID: String,
limit: Int?,
beforeMessageGUID: GUID?,
afterMessageGUID: GUID?
): Response<List<Message>> {
val messages = server.getMessagesForConversationGUID(conversationGUID) val messages = server.getMessagesForConversationGUID(conversationGUID)
return if (messages != null) { return if (messages != null) {