Adds support for uploading attachments
This commit is contained in:
@@ -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()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user