Private
Public Access
1
0

Adds support for uploading attachments

This commit is contained in:
2024-04-07 23:03:33 -07:00
parent 35c720106e
commit 5a148e2b20
6 changed files with 106 additions and 43 deletions

View File

@@ -159,8 +159,12 @@ fun MessageListScreen(
onSendMessage = { text -> onSendMessage = { text ->
viewModel.enqueueOutgoingMessage( viewModel.enqueueOutgoingMessage(
text = text, text = text,
attachmentUris = attachmentUris attachmentUris = attachmentUris,
context = context
) )
// Clear pending attachments
attachmentUris = setOf()
} }
) )
} }

View File

@@ -4,6 +4,7 @@ import android.content.Context
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.core.net.toFile
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import coil.Coil import coil.Coil
@@ -35,12 +36,14 @@ import kotlinx.coroutines.launch
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.GUID
import net.buzzert.kordophone.backend.model.Message import net.buzzert.kordophone.backend.model.Message
import net.buzzert.kordophone.backend.model.OutgoingMessage
import net.buzzert.kordophone.backend.server.ChatRepository import net.buzzert.kordophone.backend.server.ChatRepository
import net.buzzert.kordophonedroid.ui.attachments.AttachmentFetchData import net.buzzert.kordophonedroid.ui.attachments.AttachmentFetchData
import net.buzzert.kordophonedroid.ui.attachments.AttachmentViewModel import net.buzzert.kordophonedroid.ui.attachments.AttachmentViewModel
import okio.ByteString.Companion.toByteString import okio.ByteString.Companion.toByteString
import java.io.BufferedInputStream import java.io.BufferedInputStream
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.InputStream
import java.nio.file.FileSystem import java.nio.file.FileSystem
import java.util.Date import java.util.Date
import java.util.UUID import java.util.UUID
@@ -64,11 +67,6 @@ class MessageListViewModel @Inject constructor(
} }
} }
private data class OutgoingMessage(
val requestGuid: String,
val message: Message,
)
private var conversation: Conversation? = null private var conversation: Conversation? = null
private val pendingMessages: MutableStateFlow<List<OutgoingMessage>> = MutableStateFlow(listOf()) private val pendingMessages: MutableStateFlow<List<OutgoingMessage>> = MutableStateFlow(listOf())
@@ -80,14 +78,14 @@ class MessageListViewModel @Inject constructor(
// By now, the repository should've committed this to the store. // By now, the repository should've committed this to the store.
repository.messageDeliveredChannel.collectLatest { event -> repository.messageDeliveredChannel.collectLatest { event ->
pendingMessages.value = pendingMessages.value =
pendingMessages.value.filter { it.requestGuid != event.requestGuid } pendingMessages.value.filter { it.guid != event.requestGuid }
} }
} }
} }
val messages: Flow<List<Message>> val messages: Flow<List<Message>>
get() = repository.messagesChanged(conversation!!) get() = repository.messagesChanged(conversation!!)
.combine(pendingMessages) { a, b -> a.union(b.map { it.message }) } .combine(pendingMessages) { a, b -> a.union(b.map { it.asMessage() }) }
.map { messages -> .map { messages ->
messages messages
.sortedBy { it.date } .sortedBy { it.date }
@@ -100,27 +98,24 @@ class MessageListViewModel @Inject constructor(
fun enqueueOutgoingMessage( fun enqueueOutgoingMessage(
text: String, text: String,
attachmentUris: Set<Uri> attachmentUris: Set<Uri>,
context: Context,
) { ) {
// TODO: Handle attachments! val outgoingMessage = OutgoingMessage(
// Probably make a special OutgoingMessage object for this, since a lot of Message fields are body = text,
// meaningless here. We don't have GUIDs yet either.
val outgoingMessage = Message(
guid = UUID.randomUUID().toString(),
text = text,
sender = null,
date = Date(),
conversation = conversation!!, conversation = conversation!!,
attachmentGUIDs = emptyList(), attachmentUris = attachmentUris,
attachmentDataSource = { uri ->
context.contentResolver.openInputStream(uri)
}
) )
val outgoingGUID = repository.enqueueOutgoingMessage(outgoingMessage, conversation!!) val outgoingGUID = repository.enqueueOutgoingMessage(outgoingMessage)
pendingMessages.value = pendingMessages.value + listOf(OutgoingMessage(outgoingGUID, outgoingMessage)) pendingMessages.value = pendingMessages.value + listOf(outgoingMessage)
} }
fun isPendingMessage(message: Message): Boolean { fun isPendingMessage(message: Message): Boolean {
return pendingMessages.value.any { it.message.guid == message.guid } return pendingMessages.value.any { it.guid == message.guid }
} }
fun markAsRead() = viewModelScope.launch { fun markAsRead() = viewModelScope.launch {

View File

@@ -1,6 +1,8 @@
package net.buzzert.kordophone.backend.model package net.buzzert.kordophone.backend.model
import android.net.Uri
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import java.io.InputStream
import java.util.Date import java.util.Date
import java.util.UUID import java.util.UUID
@@ -67,4 +69,25 @@ data class Message(
result = 31 * result + conversation.guid.hashCode() result = 31 * result + conversation.guid.hashCode()
return result return result
} }
} }
data class OutgoingMessage(
val body: String,
val conversation: Conversation,
val attachmentUris: Set<Uri>,
val attachmentDataSource: (Uri) -> InputStream?
) {
val guid: String = UUID.randomUUID().toString()
fun asMessage(): Message {
return Message(
guid = guid,
text = body,
sender = null,
date = Date(),
attachmentGUIDs = listOf(), // TODO: What to do here?
conversation = conversation
)
}
}

View File

@@ -9,6 +9,7 @@ import okhttp3.HttpUrl
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.Response import okhttp3.Response
import okhttp3.ResponseBody import okhttp3.ResponseBody
import okhttp3.Route import okhttp3.Route
@@ -121,6 +122,7 @@ class InvalidConfigurationAPIClient: APIClient {
override suspend fun sendMessage(request: SendMessageRequest): retrofit2.Response<SendMessageResponse> = throwError() override suspend fun sendMessage(request: SendMessageRequest): retrofit2.Response<SendMessageResponse> = throwError()
override suspend fun markConversation(conversationGUID: String): retrofit2.Response<Void> = throwError() override suspend fun markConversation(conversationGUID: String): retrofit2.Response<Void> = throwError()
override suspend fun fetchAttachment(guid: String, preview: Boolean): ResponseBody = throwError() override suspend fun fetchAttachment(guid: String, preview: Boolean): ResponseBody = throwError()
override suspend fun uploadAttachment(filename: String, body: RequestBody): retrofit2.Response<UploadAttachmentResponse> = throwError()
override suspend fun authenticate(request: AuthenticationRequest): retrofit2.Response<AuthenticationResponse> = throwError() override suspend fun authenticate(request: AuthenticationRequest): retrofit2.Response<AuthenticationResponse> = throwError()
override suspend fun getMessages( override suspend fun getMessages(
conversationGUID: String, conversationGUID: String,

View File

@@ -5,6 +5,7 @@ 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.GUID
import net.buzzert.kordophone.backend.model.Message import net.buzzert.kordophone.backend.model.Message
import okhttp3.RequestBody
import okhttp3.ResponseBody import okhttp3.ResponseBody
import retrofit2.Call import retrofit2.Call
import retrofit2.Response import retrofit2.Response
@@ -48,6 +49,11 @@ data class AuthenticationResponse(
} }
} }
data class UploadAttachmentResponse(
@SerializedName("fileTransferGUID")
val transferGUID: String
)
interface APIInterface { interface APIInterface {
@GET("/version") @GET("/version")
suspend fun getVersion(): ResponseBody suspend fun getVersion(): ResponseBody
@@ -72,6 +78,9 @@ interface APIInterface {
@GET("/attachment") @GET("/attachment")
suspend fun fetchAttachment(@Query("guid") guid: String, @Query("preview") preview: Boolean = false): ResponseBody suspend fun fetchAttachment(@Query("guid") guid: String, @Query("preview") preview: Boolean = false): ResponseBody
@POST("/uploadAttachment")
suspend fun uploadAttachment(@Query("filename") filename: String, @Body body: RequestBody): Response<UploadAttachmentResponse>
@POST("/authenticate") @POST("/authenticate")
suspend fun authenticate(@Body request: AuthenticationRequest): Response<AuthenticationResponse> suspend fun authenticate(@Body request: AuthenticationRequest): Response<AuthenticationResponse>
} }

View File

@@ -1,6 +1,10 @@
package net.buzzert.kordophone.backend.server package net.buzzert.kordophone.backend.server
import android.net.Uri
import android.os.Build
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.net.toFile
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
@@ -21,11 +25,17 @@ import net.buzzert.kordophone.backend.events.MessageDeliveredEvent
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.GUID
import net.buzzert.kordophone.backend.model.Message import net.buzzert.kordophone.backend.model.Message
import net.buzzert.kordophone.backend.model.OutgoingMessage
import net.buzzert.kordophone.backend.model.UpdateItem import net.buzzert.kordophone.backend.model.UpdateItem
import okhttp3.MediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.RequestBody
import okio.BufferedSource import okio.BufferedSource
import java.io.File
import java.io.InputStream
import java.lang.Error import java.lang.Error
import java.net.URL import java.net.URL
import java.util.Date
import java.util.Queue import java.util.Queue
import java.util.UUID import java.util.UUID
import java.util.concurrent.ArrayBlockingQueue import java.util.concurrent.ArrayBlockingQueue
@@ -85,14 +95,8 @@ class ChatRepository(
internal fun testingHarness(): TestingHarness = TestingHarness(this) internal fun testingHarness(): TestingHarness = TestingHarness(this)
private data class OutgoingMessageInfo (
val message: Message,
val conversation: Conversation,
val requestGuid: GUID,
)
private var apiInterface = apiClient.getAPIInterface() private var apiInterface = apiClient.getAPIInterface()
private val outgoingMessageQueue: ArrayBlockingQueue<OutgoingMessageInfo> = ArrayBlockingQueue(16) private val outgoingMessageQueue: ArrayBlockingQueue<OutgoingMessage> = ArrayBlockingQueue(16)
private var outgoingMessageThread: Thread? = null private var outgoingMessageThread: Thread? = null
private val _messageDeliveredChannel = MutableSharedFlow<MessageDeliveredEvent>() private val _messageDeliveredChannel = MutableSharedFlow<MessageDeliveredEvent>()
private val _errorEncounteredChannel = MutableSharedFlow<Error>() private val _errorEncounteredChannel = MutableSharedFlow<Error>()
@@ -142,11 +146,11 @@ class ChatRepository(
updateMonitor.stopMonitoringForUpdates() updateMonitor.stopMonitoringForUpdates()
} }
fun enqueueOutgoingMessage(message: Message, conversation: Conversation): GUID { fun enqueueOutgoingMessage(message: OutgoingMessage): GUID {
val guid = UUID.randomUUID().toString() val guid = UUID.randomUUID().toString()
Log.d(REPO_LOG, "Enqueuing outgoing message: $message ($guid)") Log.d(REPO_LOG, "Enqueuing outgoing message: $message ($guid)")
outgoingMessageQueue.add(OutgoingMessageInfo(message, conversation, guid)) outgoingMessageQueue.add(message)
if (outgoingMessageThread == null) { if (outgoingMessageThread == null) {
outgoingMessageThread = Thread { outgoingMessageQueueMain() } outgoingMessageThread = Thread { outgoingMessageQueueMain() }
@@ -203,6 +207,15 @@ class ChatRepository(
return apiInterface.fetchAttachment(guid, preview).source() return apiInterface.fetchAttachment(guid, preview).source()
} }
private suspend fun uploadAttachment(filename: String, mediaType: String, source: InputStream): String {
val attachmentData = source.readBytes()
val requestBody = RequestBody.create(MediaType.get(mediaType), attachmentData)
source.close()
val response = apiInterface.uploadAttachment(filename, requestBody)
return response.bodyOnSuccessOrThrow().transferGUID
}
fun close() { fun close() {
database.close() database.close()
} }
@@ -243,7 +256,7 @@ class ChatRepository(
database.writeMessages(listOf(event.message), event.conversation, outgoing = true) database.writeMessages(listOf(event.message), event.conversation, outgoing = true)
} }
private suspend fun retryMessageSend(info: OutgoingMessageInfo) { private suspend fun retryMessageSend(info: OutgoingMessage) {
delay(5000L) delay(5000L)
outgoingMessageQueue.add(info) outgoingMessageQueue.add(info)
} }
@@ -251,34 +264,51 @@ class ChatRepository(
private fun outgoingMessageQueueMain() { private fun outgoingMessageQueueMain() {
Log.d(REPO_LOG, "Outgoing Message Queue Main") Log.d(REPO_LOG, "Outgoing Message Queue Main")
while (true) { while (true) {
val outgoingMessageRequest = outgoingMessageQueue.take().let { outgoingMessageQueue.take().let {
runBlocking { runBlocking {
val outgoingMessage = it.message
val conversation = it.conversation val conversation = it.conversation
val requestGuid = it.requestGuid val requestGuid = it.guid
val body = it.body
Log.d(REPO_LOG, "Sending message to $conversation: $outgoingMessage") Log.d(REPO_LOG, "Sending message to $conversation: $requestGuid")
// Upload attachments first
val attachmentGUIDs = mutableListOf<String>()
try {
for (uri: Uri in it.attachmentUris) {
val inputStream = it.attachmentDataSource(uri)
?: throw java.lang.Exception("No input stream")
val filename = uri.lastPathSegment ?: "attachment.png"
val mediaType = "image/png" // TODO: Actually get this: it needs to be plumbed through ContentResolver
val guid = uploadAttachment(filename, mediaType, inputStream)
attachmentGUIDs.add(guid)
}
} catch (e: java.lang.Exception) {
Log.e(REPO_LOG, "Error uploading attachment (${e.message}). Dropping...")
}
try { try {
val result = apiInterface.sendMessage( val result = apiInterface.sendMessage(
SendMessageRequest( SendMessageRequest(
conversationGUID = conversation.guid, conversationGUID = conversation.guid,
body = outgoingMessage.text, body = body,
transferGUIDs = null, transferGUIDs = attachmentGUIDs,
) )
) )
if (result.isSuccessful) { if (result.isSuccessful) {
val messageGuid = result.body()?.sentMessageGUID ?: outgoingMessage.guid val messageGuid = result.body()?.sentMessageGUID ?: requestGuid
Log.d(REPO_LOG, "Successfully sent message: $messageGuid") Log.d(REPO_LOG, "Successfully sent message: $messageGuid")
val newMessage = Message( val newMessage = Message(
guid = messageGuid, guid = messageGuid,
text = outgoingMessage.text, text = body,
sender = null, sender = null,
conversation = it.conversation, conversation = it.conversation,
date = outgoingMessage.date, date = Date(),
attachmentGUIDs = null, attachmentGUIDs = attachmentGUIDs,
) )
_messageDeliveredChannel.emit( _messageDeliveredChannel.emit(