diff --git a/app/src/main/java/net/buzzert/kordophonedroid/ui/conversationlist/ConversationListScreen.kt b/app/src/main/java/net/buzzert/kordophonedroid/ui/conversationlist/ConversationListScreen.kt index 2766837..53d0ed4 100644 --- a/app/src/main/java/net/buzzert/kordophonedroid/ui/conversationlist/ConversationListScreen.kt +++ b/app/src/main/java/net/buzzert/kordophonedroid/ui/conversationlist/ConversationListScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle 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.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.hilt.navigation.compose.hiltViewModel 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 fun ConversationListScreen( @@ -58,6 +75,7 @@ fun ConversationListView( id = conversation.guid, isUnread = conversation.unreadCount > 0, lastMessagePreview = conversation.lastMessagePreview ?: "", + date = conversation.date, onClick = { onConversationSelected(conversation.guid) } ) } @@ -71,11 +89,12 @@ fun ConversationListItem( id: String, isUnread: Boolean, lastMessagePreview: String, + date: Date, onClick: () -> Unit ) { val unreadSize = 12.dp val horizontalPadding = 8.dp - val verticalPadding = 12.dp + val verticalPadding = 14.dp Row( Modifier @@ -108,7 +127,12 @@ fun ConversationListItem( Spacer(Modifier.weight(1f)) - Text("13:37", + Text( + formatDateTime( + date.toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDateTime() + ), modifier = Modifier.align(Alignment.CenterVertically), color = MaterialTheme.colors.onBackground.copy(alpha = 0.4f) ) @@ -116,7 +140,7 @@ fun ConversationListItem( Spacer(Modifier.width(horizontalPadding)) } - Text(lastMessagePreview) + Text(lastMessagePreview, maxLines = 1, overflow = TextOverflow.Ellipsis) Spacer(Modifier.height(verticalPadding)) Divider() @@ -142,7 +166,7 @@ fun UnreadIndicator(size: Dp, modifier: Modifier = Modifier) { @Composable fun ConversationListItemPreview() { 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) {} } } diff --git a/app/src/main/java/net/buzzert/kordophonedroid/ui/conversationlist/ConversationListViewModel.kt b/app/src/main/java/net/buzzert/kordophonedroid/ui/conversationlist/ConversationListViewModel.kt index 87d3990..e8e0abf 100644 --- a/app/src/main/java/net/buzzert/kordophonedroid/ui/conversationlist/ConversationListViewModel.kt +++ b/app/src/main/java/net/buzzert/kordophonedroid/ui/conversationlist/ConversationListViewModel.kt @@ -4,13 +4,16 @@ import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import net.buzzert.kordophone.backend.model.Conversation import net.buzzert.kordophone.backend.server.ChatRepository import javax.inject.Inject +import kotlin.coroutines.CoroutineContext @HiltViewModel class ConversationListViewModel @Inject constructor( @@ -28,7 +31,9 @@ class ConversationListViewModel @Inject constructor( // TODO: Need error handling (exceptions thrown below) viewModelScope.launch { - repository.synchronize() + withContext(Dispatchers.IO) { + repository.synchronize() + } } viewModelScope.launch { 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 058ce35..4114469 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 @@ -1,9 +1,11 @@ package net.buzzert.kordophone.backend.db +import io.realm.kotlin.MutableRealm import io.realm.kotlin.Realm import io.realm.kotlin.RealmConfiguration import io.realm.kotlin.UpdatePolicy import io.realm.kotlin.ext.toRealmList +import io.realm.kotlin.query.find import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.map @@ -44,7 +46,8 @@ class CachedChatDatabase (private val realmConfig: RealmConfiguration) { // Flow for watching changes to the database val conversationChanges: Flow> 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 @@ -57,37 +60,45 @@ class CachedChatDatabase (private val realmConfig: RealmConfiguration) { .map { it.list.map { it.toMessage(conversation) } } } - fun writeConversations(conversations: List) = realm.writeBlocking { - val dbConversations = conversations - // Convert to database conversations - .map { it.toDatabaseConversation() } + fun updateConversations(incomingConversations: List) = realm.writeBlocking { + val incomingDatabaseConversations = incomingConversations.map { it.toDatabaseConversation() } - // Look for existing conversations, if applicable - .map { - try { - val existingConvo = getConversationByGuid(it.guid) + var deletedConversations = realm.query(Conversation::class).find() + .minus(incomingDatabaseConversations) - // Update existing record - findLatest(existingConvo)?.apply { - displayName = it.displayName - participants = it.participants - date = it.date - unreadCount = it.unreadCount - } ?: existingConvo - } catch (e: NoSuchElementException) { - // This means object is unmanaged (i.e. it's new) - it - } + deletedConversations.forEach { conversation -> + findLatest(conversation)?.let { + delete(it) } + } - dbConversations.forEach { - copyToRealm(it, updatePolicy = UpdatePolicy.ALL) + writeManagedConversations(this, incomingDatabaseConversations) + } + + fun writeConversations(conversations: List) = realm.writeBlocking { + writeManagedConversations(this, conversations.map { it.toDatabaseConversation() }) + } + + private fun writeManagedConversations(mutableRealm: MutableRealm, conversations: List) { + 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) = realm.writeBlocking { conversations.forEach { inConversation -> - val conversation = getConversationByGuid(inConversation.guid) + val conversation = getManagedConversationByGuid(inConversation.guid) findLatest(conversation)?.let { delete(it) } @@ -95,12 +106,13 @@ class CachedChatDatabase (private val realmConfig: RealmConfiguration) { } fun fetchConversations(): List { - val items = realm.query(Conversation::class).find() + val itemResults = realm.query(Conversation::class).find() + val items = realm.copyFromRealm(itemResults) return items.map { it.toConversation() } } fun writeMessages(messages: List, conversation: ModelConversation) { - val dbConversation = getConversationByGuid(conversation.guid) + val dbConversation = getManagedConversationByGuid(conversation.guid) realm.writeBlocking { val dbMessages = messages .map { it.toDatabaseMessage() } @@ -120,9 +132,13 @@ class CachedChatDatabase (private val realmConfig: RealmConfiguration) { realm.close() } - fun getConversationByGuid(guid: GUID): Conversation { + private fun getManagedConversationByGuid(guid: GUID): Conversation { return realm.query(Conversation::class, "guid == '$guid'") .find() .first() } + + fun getConversationByGuid(guid: GUID): Conversation { + return realm.copyFromRealm(getManagedConversationByGuid(guid)) + } } \ No newline at end of file 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 7dd3c70..36439bb 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 @@ -22,6 +22,7 @@ open class Conversation( var date: RealmInstant, var unreadCount: Int, + var lastMessagePreview: String?, var messages: RealmList, ): RealmObject { @@ -32,6 +33,7 @@ open class Conversation( participants = realmListOf(), date = RealmInstant.now(), unreadCount = 0, + lastMessagePreview = null, messages = realmListOf() ) @@ -43,17 +45,29 @@ open class Conversation( date = Date.from(date.toInstant()), unreadCount = unreadCount, guid = guid, + lastMessagePreview = lastMessagePreview, lastMessage = null, - lastMessagePreview = null, ) if (messages.isNotEmpty()) { - conversation.lastMessage = messages.last().toMessage(conversation) - conversation.lastMessagePreview = messages.last().text + val lastMessage = sortedMessages().last() + conversation.lastMessage = lastMessage.toMessage(conversation) + conversation.lastMessagePreview = lastMessage.text } return conversation } + + private fun sortedMessages(): List { + 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 { @@ -63,6 +77,7 @@ fun ModelConversation.toDatabaseConversation(): Conversation { participants = from.participants.toRealmList() date = from.date.toInstant().toRealmInstant() unreadCount = from.unreadCount + lastMessagePreview = from.lastMessagePreview guid = from.guid } } 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 668f591..92acdc2 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 @@ -32,7 +32,7 @@ data class Message( text == o.text && sender == o.sender && date == o.date && - conversation == o.conversation + conversation.guid == o.conversation.guid ) } } \ No newline at end of file diff --git a/backend/src/main/java/net/buzzert/kordophone/backend/server/APIInterface.kt b/backend/src/main/java/net/buzzert/kordophone/backend/server/APIInterface.kt index 016d19e..f93f4a1 100644 --- a/backend/src/main/java/net/buzzert/kordophone/backend/server/APIInterface.kt +++ b/backend/src/main/java/net/buzzert/kordophone/backend/server/APIInterface.kt @@ -2,6 +2,7 @@ package net.buzzert.kordophone.backend.server import com.google.gson.annotations.SerializedName import net.buzzert.kordophone.backend.model.Conversation +import net.buzzert.kordophone.backend.model.GUID import net.buzzert.kordophone.backend.model.Message import okhttp3.ResponseBody import retrofit2.Call @@ -32,7 +33,12 @@ interface APIInterface { suspend fun getConversations(): Response> @GET("/messages") - suspend fun getMessages(@Query("guid") conversationGUID: String): Response> + suspend fun getMessages( + @Query("guid") conversationGUID: String, + @Query("limit") limit: Int? = null, + @Query("beforeMessageGUID") beforeMessageGUID: GUID? = null, + @Query("afterMessageGUID") afterMessageGUID: GUID? = null, + ): Response> @POST("/sendMessage") suspend fun sendMessage(@Body request: SendMessageRequest): Response 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 dd1c669..8b55955 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 @@ -122,16 +122,13 @@ class ChatRepository( // Sync conversations val serverConversations = fetchConversations() - val deletedConversations = conversations.minus(serverConversations) - - database.deleteConversations(deletedConversations) - database.writeConversations(serverConversations) + database.updateConversations(serverConversations) // 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) + val messages = fetchMessages(conversation, limit = 15) database.writeMessages(messages, conversation) } } @@ -146,15 +143,20 @@ class ChatRepository( return apiInterface.getConversations().bodyOnSuccessOrThrow() } - private suspend fun fetchMessages(conversation: Conversation): List { - return apiInterface.getMessages(conversation.guid) + private suspend fun fetchMessages( + conversation: Conversation, + limit: Int? = null, + before: Message? = null, + after: Message? = null, + ): List { + return apiInterface.getMessages(conversation.guid, limit, before?.guid, after?.guid) .bodyOnSuccessOrThrow() .onEach { it.conversation = conversation } } private fun handleConversationChangedUpdate(conversation: Conversation) { Log.d(REPO_LOG, "Handling conversation changed update") - database.writeConversations(conversations) + database.writeConversations(listOf(conversation)) } private fun handleMessageAddedUpdate(message: Message) { 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 9cbe2b8..38b770c 100644 --- a/backend/src/test/java/net/buzzert/kordophone/backend/BackendTests.kt +++ b/backend/src/test/java/net/buzzert/kordophone/backend/BackendTests.kt @@ -105,13 +105,13 @@ class BackendTests { repo.synchronize() // Check our count. - assertEquals(repo.conversations.count(), 10) + assertEquals(10, repo.conversations.count()) // Sync again: let's ensure we're de-duplicating conversations. repo.synchronize() // Should be no change... - assertEquals(repo.conversations.count(), 10) + assertEquals(10, repo.conversations.count()) // Say unread count + lastMessage preview changes on server. val someConversation = conversations.first().apply { diff --git a/backend/src/test/java/net/buzzert/kordophone/backend/DatabaseTests.kt b/backend/src/test/java/net/buzzert/kordophone/backend/DatabaseTests.kt index f81848e..aea741a 100644 --- a/backend/src/test/java/net/buzzert/kordophone/backend/DatabaseTests.kt +++ b/backend/src/test/java/net/buzzert/kordophone/backend/DatabaseTests.kt @@ -41,7 +41,7 @@ class DatabaseTests { val readMessages = db.fetchMessages(conversation) assertEquals(readMessages, messages) - assertEquals(readMessages[0].conversation, conversation) + assertEquals(readMessages[0].conversation.guid, conversation.guid) db.close() } @@ -72,5 +72,7 @@ class DatabaseTests { // Make sure our new name was written assertEquals(nowConversations.first().displayName, "wow") + + db.close() } } \ No newline at end of file diff --git a/backend/src/test/java/net/buzzert/kordophone/backend/MockServer.kt b/backend/src/test/java/net/buzzert/kordophone/backend/MockServer.kt index a803a15..97637a3 100644 --- a/backend/src/test/java/net/buzzert/kordophone/backend/MockServer.kt +++ b/backend/src/test/java/net/buzzert/kordophone/backend/MockServer.kt @@ -222,7 +222,12 @@ class MockServerInterface(private val server: MockServer): APIInterface { return Response.success(server.conversations) } - override suspend fun getMessages(conversationGUID: String): Response> { + override suspend fun getMessages( + conversationGUID: String, + limit: Int?, + beforeMessageGUID: GUID?, + afterMessageGUID: GUID? + ): Response> { val messages = server.getMessagesForConversationGUID(conversationGUID) return if (messages != null) {