From 9f5f2d7af534d61783e369fbbe64854cfbfe9992 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 23 Mar 2024 17:04:14 -0700 Subject: [PATCH] Adds image attachment support --- app/build.gradle | 7 + .../ui/messagelist/MessageListScreen.kt | 242 +++++++++++++----- .../ui/messagelist/MessageListViewModel.kt | 82 +++++- backend/chat-cache-test | Bin 32768 -> 0 bytes backend/chat-cache-test.lock | Bin 1728 -> 0 bytes .../kordophone/backend/db/model/Message.kt | 9 + .../kordophone/backend/model/Message.kt | 10 + .../kordophone/backend/server/APIInterface.kt | 3 + .../backend/server/ChatRepository.kt | 9 +- .../kordophone/backend/BackendTests.kt | 3 +- .../buzzert/kordophone/backend/MockServer.kt | 14 +- 11 files changed, 308 insertions(+), 71 deletions(-) delete mode 100644 backend/chat-cache-test delete mode 100644 backend/chat-cache-test.lock diff --git a/app/build.gradle b/app/build.gradle index e39e7fd..1e1628d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -100,6 +100,13 @@ dependencies { implementation "androidx.hilt:hilt-navigation-compose:1.0.0" kapt "com.google.dagger:hilt-compiler:${hilt_version}" + // Coil (image loading library) + implementation "io.coil-kt:coil:2.4.0" + implementation "io.coil-kt:coil-compose:2.4.0" + + // Disk LRU Cache + implementation "com.jakewharton:disklrucache:2.0.2" + debugImplementation 'androidx.compose.ui:ui-tooling:1.4.3' } 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 f5800b6..b605c0b 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,35 +1,28 @@ package net.buzzert.kordophonedroid.ui.messagelist import android.util.Log -import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background 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.imePadding -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn 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.material.Button -import androidx.compose.material.Icon -import androidx.compose.material.IconButton +import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.material.Surface import androidx.compose.material.Text -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.LaunchedEffect import androidx.compose.runtime.getValue @@ -40,38 +33,53 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign 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 coil.Coil +import coil.ImageLoader +import coil.ImageLoaderFactory +import coil.compose.AsyncImage +import coil.compose.SubcomposeAsyncImage +import coil.imageLoader +import coil.request.ImageRequest +import coil.size.Size import net.buzzert.kordophone.backend.model.GUID -import net.buzzert.kordophone.backend.model.Message +import net.buzzert.kordophonedroid.R import net.buzzert.kordophonedroid.ui.theme.KordophoneTopAppBar +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import java.net.URL import java.text.SimpleDateFormat import java.time.Duration -import java.time.Instant -import java.time.LocalDate -import java.time.Period -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle import java.util.Date 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, +data class MessageMetadata( val fromAddress: String, val fromMe: Boolean, val date: Date, val delivered: Boolean = true, ) +interface MessageMetadataProvider { + val metadata: MessageMetadata +} + +sealed class MessageListItem: MessageMetadataProvider { + data class TextMessage(val text: String, override val metadata: MessageMetadata): MessageListItem() + data class ImageAttachmentMessage(val guid: String, override val metadata: MessageMetadata): MessageListItem() +} + @Composable fun MessageListScreen( conversationGUID: GUID, @@ -79,7 +87,8 @@ fun MessageListScreen( viewModel: MessageListViewModel = hiltViewModel(), ) { viewModel.conversationGUID = conversationGUID - + + // Synchronize on launch LaunchedEffect(Unit) { Log.d("MessageListScreen", "Launched effect") viewModel.markAsRead() @@ -88,14 +97,32 @@ fun MessageListScreen( val messages by viewModel.messages.collectAsStateWithLifecycle(initialValue = listOf()) - val messageItems = messages.map { - MessageListViewItem( - text = it.text, - fromMe = it.sender == null, - date = it.date, - fromAddress = it.sender ?: "", - delivered = !viewModel.isPendingMessage(it) + var messageItems = mutableListOf() + for (message in messages) { + val metadata = MessageMetadata( + fromMe = message.sender == null, + date = message.date, + fromAddress = message.sender ?: "", + delivered = !viewModel.isPendingMessage(message) ) + + // Collect attachments + message.attachmentGUIDs?.let { guids -> + guids.forEach { guid -> + val item = MessageListItem.ImageAttachmentMessage( + guid = guid, + metadata = metadata + ) + + messageItems.add(item) + } + } + + val displayText = message.displayText.trim() + if (displayText.isNotEmpty()) { + val textMessage = MessageListItem.TextMessage(text = displayText, metadata = metadata) + messageItems.add(textMessage) + } } Scaffold(topBar = { KordophoneTopAppBar(title = viewModel.title, backAction = backAction) }) { padding -> @@ -112,7 +139,7 @@ fun MessageListScreen( @Composable fun MessageTranscript( - messages: List, + messages: List, paddingValues: PaddingValues, showSenders: Boolean, onSendMessage: (text: String) -> Unit, @@ -149,7 +176,7 @@ fun MessageTranscript( @Composable fun Messages( - messages: List, + messages: List, showSenders: Boolean, modifier: Modifier = Modifier, scrollState: LazyListState @@ -169,14 +196,14 @@ fun Messages( for (index in messages.indices) { val content = messages[index] - var previousMessage: MessageListViewItem? = null + var previousMessage: MessageListItem? = null if ((index + 1) < messages.count()) { previousMessage = messages[index + 1] } val duration: Duration? = if (previousMessage != null) Duration.between( - previousMessage.date.toInstant(), - content.date.toInstant() + previousMessage.metadata.date.toInstant(), + content.metadata.date.toInstant() ) else null val leapMessage = ( @@ -187,23 +214,32 @@ fun Messages( val repeatMessage = !leapMessage && ( previousMessage == null || ( - (previousMessage.fromAddress == content.fromAddress) + (previousMessage.metadata.fromAddress == content.metadata.fromAddress) ) ) // Remember: This is upside down. item { - MessageBubble( - text = content.text, - mine = content.fromMe, - modifier = Modifier - .alpha(if (!content.delivered) 0.5F else 1.0f) - ) + when (content) { + is MessageListItem.TextMessage -> { + MessageBubble( + text = content.text, + mine = content.metadata.fromMe, + modifier = Modifier + .alpha(if (!content.metadata.delivered) 0.5F else 1.0f) + ) + } + + is MessageListItem.ImageAttachmentMessage -> { + ImageBubble(guid = content.guid, mine = content.metadata.fromMe) + } + } + // Sender - if (!content.fromMe && showSenders && !repeatMessage) { + if (!content.metadata.fromMe && showSenders && !repeatMessage) { Text( - text = content.fromAddress, + text = content.metadata.fromAddress, style = MaterialTheme.typography.subtitle2.copy( color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f) ), @@ -214,7 +250,7 @@ fun Messages( // Greater than 30 minutes: show date: if (duration != null) { if (duration.toMinutes() > 30) { - val formattedDate = dateFormatter.format(content.date) + val formattedDate = dateFormatter.format(content.metadata.date) Text( text = formattedDate, textAlign = TextAlign.Center, @@ -239,32 +275,22 @@ fun Messages( } @Composable -fun MessageBubble( - text: String, +fun BubbleScaffold( mine: Boolean, - modifier: Modifier = Modifier, + modifier: Modifier, + content: @Composable () -> Unit ) { - val backgroundBubbleColor = if (mine) MaterialTheme.colors.primary else MaterialTheme.colors.secondary - Column() { Row(modifier = modifier.fillMaxWidth()) { - if (mine) { Spacer(modifier = Modifier.weight(1f)) } + if (mine) { + Spacer(modifier = Modifier.weight(1f)) + } Row( modifier = Modifier.fillMaxWidth(0.8f), horizontalArrangement = if (mine) Arrangement.End else Arrangement.Start, ) { - Surface( - color = backgroundBubbleColor, - shape = if (mine) OutgoingChatBubbleShape else IncomingChatBubbleShape, - ) { - Text( - text = text, - style = MaterialTheme.typography.body2, - modifier = Modifier - .padding(12.dp) - ) - } + content() } if (!mine) { Spacer(modifier = Modifier.weight(1f)) } @@ -274,15 +300,101 @@ fun MessageBubble( } } +@Composable +fun MessageBubble( + text: String, + mine: Boolean, + modifier: Modifier = Modifier, +) { + val backgroundBubbleColor = if (mine) MaterialTheme.colors.primary else MaterialTheme.colors.secondary + + BubbleScaffold(mine = mine, modifier = modifier) { + Surface( + color = backgroundBubbleColor, + shape = if (mine) OutgoingChatBubbleShape else IncomingChatBubbleShape, + ) { + Text( + text = text, + style = MaterialTheme.typography.body2, + modifier = Modifier + .padding(12.dp) + ) + } + } +} + +@Composable +fun ImageBubble( + guid: String, + mine: Boolean, + modifier: Modifier = Modifier, +) { + val shape: RoundedCornerShape = if (mine) OutgoingChatBubbleShape else IncomingChatBubbleShape + val attachmentFetchData = AttachmentFetchData(guid, preview = true) + + BubbleScaffold(mine = mine, modifier = modifier) { + SubcomposeAsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(attachmentFetchData) + .crossfade(true) + .diskCacheKey(attachmentFetchData.guid) + .memoryCacheKey(attachmentFetchData.guid) + .build(), + loading = { + Box( + modifier = Modifier + .background(Color.LightGray) + .size(width = 220.dp, height = 200.dp) + ) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } + }, + error = { + Text("(Error loading attachment)") + }, + contentDescription = "Image attachment", + modifier = Modifier + .clip(shape) + ) + } +} + // - +private fun testMessageMetadata(fromMe: Boolean, delivered: Boolean): MessageMetadata { + return MessageMetadata( + fromMe = fromMe, + fromAddress = if (fromMe) "" else "cool@cool.com", + date = Date(), + delivered = delivered, + ) +} + +private fun makeTestTextMessageItem(text: String, fromMe: Boolean, delivered: Boolean = true): MessageListItem { + return MessageListItem.TextMessage( + text = text, + metadata = testMessageMetadata(fromMe = fromMe, delivered = delivered) + ) +} + +private fun makeTestImageMessageItem(fromMe: Boolean, delivered: Boolean = true): MessageListItem { + return MessageListItem.ImageAttachmentMessage( + guid = "asdf", + metadata = testMessageMetadata(fromMe, delivered) + ) +} + @Preview @Composable private fun MessageListScreenPreview() { - val messages = listOf( - MessageListViewItem(text = "Hello", fromMe = false, date = Date(), fromAddress = "cool@cool.com"), - MessageListViewItem(text = "Hey there, this is a longer text message that might wrap to another line", fromMe = true, date = Date(), fromAddress = ""), - MessageListViewItem(text = "How's it going", fromMe = true, delivered = false, date = Date(), fromAddress = "") + val messages = listOf( + makeTestImageMessageItem(false), + + makeTestTextMessageItem("Hello", false), + makeTestTextMessageItem( "Hey there, this is a longer text message that might wrap to another line", true), + makeTestTextMessageItem("How's it going", fromMe = true, delivered = false) ).reversed() Scaffold() { 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 index 9ee845f..a2569ca 100644 --- a/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListViewModel.kt +++ b/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListViewModel.kt @@ -1,11 +1,27 @@ package net.buzzert.kordophonedroid.ui.messagelist +import android.content.Context import android.util.Log import androidx.compose.runtime.mutableStateListOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import coil.Coil +import coil.ComponentRegistry +import coil.ImageLoader +import coil.ImageLoaderFactory +import coil.decode.DataSource +import coil.decode.ImageSource +import coil.disk.DiskCache +import coil.fetch.FetchResult +import coil.fetch.Fetcher +import coil.fetch.SourceResult +import coil.memory.MemoryCache +import coil.request.CachePolicy +import coil.request.Options import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.channels.consumeEach +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asFlow @@ -19,14 +35,29 @@ 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 okio.ByteString.Companion.toByteString +import java.io.BufferedInputStream +import java.io.ByteArrayInputStream +import java.nio.file.FileSystem import java.util.Date import java.util.UUID +import java.util.concurrent.TimeUnit import javax.inject.Inject +import javax.xml.transform.Source +import kotlin.time.Duration.Companion.seconds + +const val MVM_LOG: String = "MessageListViewModel" + +data class AttachmentFetchData( + val guid: String, + val preview: Boolean = false +) @HiltViewModel class MessageListViewModel @Inject constructor( private val repository: ChatRepository, -) : ViewModel() + @ApplicationContext val application: Context, +) : ViewModel(), ImageLoaderFactory, Fetcher.Factory { var conversationGUID: GUID? = null set(value) { @@ -47,6 +78,9 @@ class MessageListViewModel @Inject constructor( init { // TODO: Need to handle settings changes here!! + // Register Coil image loader + Coil.setImageLoader(this) + viewModelScope.launch { // Remove pending message after message is delivered. // By now, the repository should've committed this to the store. @@ -77,6 +111,7 @@ class MessageListViewModel @Inject constructor( sender = null, date = Date(), conversation = conversation!!, + attachmentGUIDs = emptyList(), ) val outgoingGUID = repository.enqueueOutgoingMessage(outgoingMessage, conversation!!) @@ -94,4 +129,49 @@ class MessageListViewModel @Inject constructor( fun synchronize() = viewModelScope.launch { repository.synchronizeConversation(conversation!!, limit = 100) } + + override fun newImageLoader(): ImageLoader { + val factory = this + return ImageLoader.Builder(application) + .memoryCache { + MemoryCache.Builder(application) + .maxSizePercent(0.25) + .build() + } + .diskCache { + DiskCache.Builder() + .directory(application.cacheDir.resolve("attachments")) + .maxSizePercent(0.02) + .build() + } + .components { + // Adds the FetcherFactory + add(factory) + } + .build() + } + + override fun create( + data: AttachmentFetchData, + options: Options, + imageLoader: ImageLoader + ): Fetcher { + return AttachmentFetcher(repository, application, data) + } +} + +private class AttachmentFetcher( + val repository: ChatRepository, + val context: Context, + val data: AttachmentFetchData +): Fetcher { + override suspend fun fetch(): FetchResult { + Log.d(MVM_LOG, "Loading attachment ${data.guid} from network") + val source = repository.fetchAttachmentDataSource(data.guid, data.preview) + return SourceResult( + source = ImageSource(source, context), + dataSource = DataSource.NETWORK, + mimeType = null, + ) + } } \ No newline at end of file diff --git a/backend/chat-cache-test b/backend/chat-cache-test deleted file mode 100644 index 473f39989f6fa02c0b14a243f693428a13054867..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 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 diff --git a/backend/chat-cache-test.lock b/backend/chat-cache-test.lock deleted file mode 100644 index 24d6e22a951cde11a3054e5f7a8a1e2215eb61ab..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 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 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 132be9b..c9c8af1 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 @@ -2,8 +2,11 @@ package net.buzzert.kordophone.backend.db.model import android.view.Display.Mode import io.realm.kotlin.Realm +import io.realm.kotlin.ext.realmListOf +import io.realm.kotlin.ext.toRealmList import io.realm.kotlin.types.EmbeddedRealmObject import io.realm.kotlin.types.RealmInstant +import io.realm.kotlin.types.RealmList import io.realm.kotlin.types.RealmObject import io.realm.kotlin.types.annotations.PrimaryKey import net.buzzert.kordophone.backend.db.model.Conversation @@ -20,6 +23,7 @@ open class Message( var text: String, var sender: String?, var date: RealmInstant, + var attachmentGUIDs: RealmList, var conversationGUID: GUID, ): RealmObject @@ -29,6 +33,7 @@ open class Message( text = "", sender = null, date = RealmInstant.now(), + attachmentGUIDs = realmListOf(), conversationGUID = ObjectId().toString(), ) @@ -38,6 +43,7 @@ open class Message( guid = guid, sender = sender, date = Date.from(date.toInstant()), + attachmentGUIDs = attachmentGUIDs.toList(), conversation = parentConversation, ) } @@ -51,5 +57,8 @@ fun ModelMessage.toDatabaseMessage(outgoing: Boolean = false): Message { sender = from.sender date = from.date.toInstant().toRealmInstant() conversationGUID = from.conversation.guid + from.attachmentGUIDs?.let { + attachmentGUIDs = it.toRealmList() + } } } 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 1318cba..45c29ad 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 @@ -17,6 +17,9 @@ data class Message( @SerializedName("date") val date: Date, + @SerializedName("fileTransferGUIDs") + val attachmentGUIDs: List?, + @Transient var conversation: Conversation, ) { @@ -27,11 +30,18 @@ data class Message( text = text, sender = sender, date = Date(), + attachmentGUIDs = emptyList(), conversation = conversation, ) } } + val displayText: String get() { + // Filter out attachment markers + val attachmentMarker = byteArrayOf(0xEF.toByte(), 0xBF.toByte(), 0xBC.toByte()).decodeToString() + return text.replace(attachmentMarker, "") + } + override fun toString(): String { return "Message(guid=$guid, text=$text, sender=$sender, date=$date, parent=${conversation.guid})" } 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 cbe9b3b..e3014f9 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 @@ -69,6 +69,9 @@ interface APIInterface { @POST("/markConversation") suspend fun markConversation(@Query("guid") conversationGUID: String): Response + @GET("/attachment") + suspend fun fetchAttachment(@Query("guid") guid: String, @Query("preview") preview: Boolean = false): ResponseBody + @POST("/authenticate") suspend fun authenticate(@Body request: AuthenticationRequest): 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 182e818..c47a271 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 @@ -21,6 +21,8 @@ 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.UpdateItem +import okhttp3.OkHttpClient +import okio.BufferedSource import java.lang.Error import java.net.URL import java.util.Queue @@ -189,6 +191,10 @@ class ChatRepository( Log.e(REPO_LOG, "Error marking conversation as read: ${e.message}") } + suspend fun fetchAttachmentDataSource(guid: String, preview: Boolean): BufferedSource { + return apiInterface.fetchAttachment(guid, preview).source() + } + fun close() { database.close() } @@ -262,7 +268,8 @@ class ChatRepository( text = outgoingMessage.text, sender = null, conversation = it.conversation, - date = outgoingMessage.date + date = outgoingMessage.date, + attachmentGUIDs = null, ) _messageDeliveredChannel.emit( 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 0cdc907..3d294c6 100644 --- a/backend/src/test/java/net/buzzert/kordophone/backend/BackendTests.kt +++ b/backend/src/test/java/net/buzzert/kordophone/backend/BackendTests.kt @@ -11,6 +11,7 @@ import net.buzzert.kordophone.backend.db.CachedChatDatabase import net.buzzert.kordophone.backend.model.Message import net.buzzert.kordophone.backend.server.APIClient import net.buzzert.kordophone.backend.server.APIInterface +import net.buzzert.kordophone.backend.server.Authentication import net.buzzert.kordophone.backend.server.ChatRepository import net.buzzert.kordophone.backend.server.RetrofitAPIClient import net.buzzert.kordophone.backend.server.UpdateMonitor @@ -23,7 +24,7 @@ import java.util.concurrent.TimeUnit class BackendTests { private fun liveRepository(host: String): Pair { - val client = RetrofitAPIClient(URL(host)) + val client = RetrofitAPIClient(URL(host), authentication = Authentication("test", "test")) val database = CachedChatDatabase.testDatabase() val repository = ChatRepository(client, database) return Pair(repository, client) 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 72c9d64..574cb89 100644 --- a/backend/src/test/java/net/buzzert/kordophone/backend/MockServer.kt +++ b/backend/src/test/java/net/buzzert/kordophone/backend/MockServer.kt @@ -29,6 +29,7 @@ import okhttp3.WebSocket import okhttp3.WebSocketListener import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer +import retrofit2.Call import retrofit2.Response import java.util.Date import java.util.UUID @@ -53,6 +54,7 @@ class MockServer { guid = UUID.randomUUID().toString(), sender = null, conversation = parentConversation, + attachmentGUIDs = null, ) } @@ -150,7 +152,8 @@ class MockServer { date = Date(), guid = UUID.randomUUID().toString(), sender = null, // me - conversation = conversation + conversation = conversation, + attachmentGUIDs = null, ) addMessagesToConversation(conversation, listOf(message)) @@ -169,14 +172,15 @@ class MockServerClient(private val server: MockServer): APIClient, WebSocketList override fun getWebSocketClient( serverPath: String, - authToken: String?, + queryParams: Map?, listener: WebSocketListener ): WebSocket { val webServer = server.getServer() + val params = queryParams ?: mapOf() val baseHTTPURL: HttpUrl = webServer.url("/") val baseURL = baseHTTPURL.toUrl() - val requestURL = baseURL.authenticatedWebSocketURL(serverPath, authToken) + val requestURL = baseURL.authenticatedWebSocketURL(serverPath, params) val request = Request.Builder() .url(requestURL) .build() @@ -260,6 +264,10 @@ class MockServerInterface(private val server: MockServer): APIInterface { return Response.success(null) } + override suspend fun fetchAttachment(guid: String, preview: Boolean): ResponseBody { + TODO("Not yet implemented") + } + override suspend fun authenticate(request: AuthenticationRequest): Response { // Anything goes! val response = AuthenticationResponse(