Private
Public Access
1
0

Adds image attachment support

This commit is contained in:
2024-03-23 17:04:14 -07:00
parent b23ab2dfe2
commit 9f5f2d7af5
11 changed files with 308 additions and 71 deletions

View File

@@ -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'
}

View File

@@ -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 ?: "<me>",
delivered = !viewModel.isPendingMessage(it)
var messageItems = mutableListOf<MessageListItem>()
for (message in messages) {
val metadata = MessageMetadata(
fromMe = message.sender == null,
date = message.date,
fromAddress = message.sender ?: "<me>",
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<MessageListViewItem>,
messages: List<MessageListItem>,
paddingValues: PaddingValues,
showSenders: Boolean,
onSendMessage: (text: String) -> Unit,
@@ -149,7 +176,7 @@ fun MessageTranscript(
@Composable
fun Messages(
messages: List<MessageListViewItem>,
messages: List<MessageListItem>,
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) "<me>" 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>(
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 = "<me>"),
MessageListViewItem(text = "How's it going", fromMe = true, delivered = false, date = Date(), fromAddress = "<me>")
val messages = listOf<MessageListItem>(
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() {

View File

@@ -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<AttachmentFetchData>
{
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,
)
}
}

Binary file not shown.

Binary file not shown.

View File

@@ -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<String>,
var conversationGUID: GUID,
): RealmObject
@@ -29,6 +33,7 @@ open class Message(
text = "",
sender = null,
date = RealmInstant.now(),
attachmentGUIDs = realmListOf<String>(),
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()
}
}
}

View File

@@ -17,6 +17,9 @@ data class Message(
@SerializedName("date")
val date: Date,
@SerializedName("fileTransferGUIDs")
val attachmentGUIDs: List<String>?,
@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})"
}

View File

@@ -69,6 +69,9 @@ interface APIInterface {
@POST("/markConversation")
suspend fun markConversation(@Query("guid") conversationGUID: String): Response<Void>
@GET("/attachment")
suspend fun fetchAttachment(@Query("guid") guid: String, @Query("preview") preview: Boolean = false): ResponseBody
@POST("/authenticate")
suspend fun authenticate(@Body request: AuthenticationRequest): Response<AuthenticationResponse>
}

View File

@@ -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(

View File

@@ -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<ChatRepository, RetrofitAPIClient> {
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)

View File

@@ -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<String, String>?,
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<AuthenticationResponse> {
// Anything goes!
val response = AuthenticationResponse(