Adds support for uploading attachments
This commit is contained in:
@@ -159,8 +159,12 @@ fun MessageListScreen(
|
||||
onSendMessage = { text ->
|
||||
viewModel.enqueueOutgoingMessage(
|
||||
text = text,
|
||||
attachmentUris = attachmentUris
|
||||
attachmentUris = attachmentUris,
|
||||
context = context
|
||||
)
|
||||
|
||||
// Clear pending attachments
|
||||
attachmentUris = setOf()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user