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 ->
viewModel.enqueueOutgoingMessage(
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.util.Log
import androidx.compose.runtime.mutableStateListOf
import androidx.core.net.toFile
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import coil.Coil
@@ -35,12 +36,14 @@ import kotlinx.coroutines.launch
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.OutgoingMessage
import net.buzzert.kordophone.backend.server.ChatRepository
import net.buzzert.kordophonedroid.ui.attachments.AttachmentFetchData
import net.buzzert.kordophonedroid.ui.attachments.AttachmentViewModel
import okio.ByteString.Companion.toByteString
import java.io.BufferedInputStream
import java.io.ByteArrayInputStream
import java.io.InputStream
import java.nio.file.FileSystem
import java.util.Date
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 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.
repository.messageDeliveredChannel.collectLatest { event ->
pendingMessages.value =
pendingMessages.value.filter { it.requestGuid != event.requestGuid }
pendingMessages.value.filter { it.guid != event.requestGuid }
}
}
}
val messages: Flow<List<Message>>
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 ->
messages
.sortedBy { it.date }
@@ -100,27 +98,24 @@ class MessageListViewModel @Inject constructor(
fun enqueueOutgoingMessage(
text: String,
attachmentUris: Set<Uri>
attachmentUris: Set<Uri>,
context: Context,
) {
// TODO: Handle attachments!
// Probably make a special OutgoingMessage object for this, since a lot of Message fields are
// meaningless here. We don't have GUIDs yet either.
val outgoingMessage = Message(
guid = UUID.randomUUID().toString(),
text = text,
sender = null,
date = Date(),
val outgoingMessage = OutgoingMessage(
body = text,
conversation = conversation!!,
attachmentGUIDs = emptyList(),
attachmentUris = attachmentUris,
attachmentDataSource = { uri ->
context.contentResolver.openInputStream(uri)
}
)
val outgoingGUID = repository.enqueueOutgoingMessage(outgoingMessage, conversation!!)
pendingMessages.value = pendingMessages.value + listOf(OutgoingMessage(outgoingGUID, outgoingMessage))
val outgoingGUID = repository.enqueueOutgoingMessage(outgoingMessage)
pendingMessages.value = pendingMessages.value + listOf(outgoingMessage)
}
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 {

View File

@@ -1,6 +1,8 @@
package net.buzzert.kordophone.backend.model
import android.net.Uri
import com.google.gson.annotations.SerializedName
import java.io.InputStream
import java.util.Date
import java.util.UUID
@@ -68,3 +70,24 @@ data class Message(
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.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.Response
import okhttp3.ResponseBody
import okhttp3.Route
@@ -121,6 +122,7 @@ class InvalidConfigurationAPIClient: APIClient {
override suspend fun sendMessage(request: SendMessageRequest): retrofit2.Response<SendMessageResponse> = throwError()
override suspend fun markConversation(conversationGUID: String): retrofit2.Response<Void> = 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 getMessages(
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.GUID
import net.buzzert.kordophone.backend.model.Message
import okhttp3.RequestBody
import okhttp3.ResponseBody
import retrofit2.Call
import retrofit2.Response
@@ -48,6 +49,11 @@ data class AuthenticationResponse(
}
}
data class UploadAttachmentResponse(
@SerializedName("fileTransferGUID")
val transferGUID: String
)
interface APIInterface {
@GET("/version")
suspend fun getVersion(): ResponseBody
@@ -72,6 +78,9 @@ interface APIInterface {
@GET("/attachment")
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")
suspend fun authenticate(@Body request: AuthenticationRequest): Response<AuthenticationResponse>
}

View File

@@ -1,6 +1,10 @@
package net.buzzert.kordophone.backend.server
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.net.toFile
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
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.GUID
import net.buzzert.kordophone.backend.model.Message
import net.buzzert.kordophone.backend.model.OutgoingMessage
import net.buzzert.kordophone.backend.model.UpdateItem
import okhttp3.MediaType
import okhttp3.OkHttpClient
import okhttp3.RequestBody
import okio.BufferedSource
import java.io.File
import java.io.InputStream
import java.lang.Error
import java.net.URL
import java.util.Date
import java.util.Queue
import java.util.UUID
import java.util.concurrent.ArrayBlockingQueue
@@ -85,14 +95,8 @@ class ChatRepository(
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 val outgoingMessageQueue: ArrayBlockingQueue<OutgoingMessageInfo> = ArrayBlockingQueue(16)
private val outgoingMessageQueue: ArrayBlockingQueue<OutgoingMessage> = ArrayBlockingQueue(16)
private var outgoingMessageThread: Thread? = null
private val _messageDeliveredChannel = MutableSharedFlow<MessageDeliveredEvent>()
private val _errorEncounteredChannel = MutableSharedFlow<Error>()
@@ -142,11 +146,11 @@ class ChatRepository(
updateMonitor.stopMonitoringForUpdates()
}
fun enqueueOutgoingMessage(message: Message, conversation: Conversation): GUID {
fun enqueueOutgoingMessage(message: OutgoingMessage): GUID {
val guid = UUID.randomUUID().toString()
Log.d(REPO_LOG, "Enqueuing outgoing message: $message ($guid)")
outgoingMessageQueue.add(OutgoingMessageInfo(message, conversation, guid))
outgoingMessageQueue.add(message)
if (outgoingMessageThread == null) {
outgoingMessageThread = Thread { outgoingMessageQueueMain() }
@@ -203,6 +207,15 @@ class ChatRepository(
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() {
database.close()
}
@@ -243,7 +256,7 @@ class ChatRepository(
database.writeMessages(listOf(event.message), event.conversation, outgoing = true)
}
private suspend fun retryMessageSend(info: OutgoingMessageInfo) {
private suspend fun retryMessageSend(info: OutgoingMessage) {
delay(5000L)
outgoingMessageQueue.add(info)
}
@@ -251,34 +264,51 @@ class ChatRepository(
private fun outgoingMessageQueueMain() {
Log.d(REPO_LOG, "Outgoing Message Queue Main")
while (true) {
val outgoingMessageRequest = outgoingMessageQueue.take().let {
outgoingMessageQueue.take().let {
runBlocking {
val outgoingMessage = it.message
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 {
val result = apiInterface.sendMessage(
SendMessageRequest(
conversationGUID = conversation.guid,
body = outgoingMessage.text,
transferGUIDs = null,
body = body,
transferGUIDs = attachmentGUIDs,
)
)
if (result.isSuccessful) {
val messageGuid = result.body()?.sentMessageGUID ?: outgoingMessage.guid
val messageGuid = result.body()?.sentMessageGUID ?: requestGuid
Log.d(REPO_LOG, "Successfully sent message: $messageGuid")
val newMessage = Message(
guid = messageGuid,
text = outgoingMessage.text,
text = body,
sender = null,
conversation = it.conversation,
date = outgoingMessage.date,
attachmentGUIDs = null,
date = Date(),
attachmentGUIDs = attachmentGUIDs,
)
_messageDeliveredChannel.emit(