diff --git a/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListScreen.kt b/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListScreen.kt index ae78f40..354f809 100644 --- a/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListScreen.kt +++ b/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListScreen.kt @@ -159,8 +159,12 @@ fun MessageListScreen( onSendMessage = { text -> viewModel.enqueueOutgoingMessage( text = text, - attachmentUris = attachmentUris + attachmentUris = attachmentUris, + context = context ) + + // Clear pending attachments + attachmentUris = setOf() } ) } diff --git a/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListViewModel.kt b/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListViewModel.kt index 8dc1cbd..de2a094 100644 --- a/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListViewModel.kt +++ b/app/src/main/java/net/buzzert/kordophonedroid/ui/messagelist/MessageListViewModel.kt @@ -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> = 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> 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 + attachmentUris: Set, + 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 { diff --git a/backend/src/main/java/net/buzzert/kordophone/backend/model/Message.kt b/backend/src/main/java/net/buzzert/kordophone/backend/model/Message.kt index 45c29ad..66344dc 100644 --- a/backend/src/main/java/net/buzzert/kordophone/backend/model/Message.kt +++ b/backend/src/main/java/net/buzzert/kordophone/backend/model/Message.kt @@ -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 @@ -67,4 +69,25 @@ data class Message( result = 31 * result + conversation.guid.hashCode() return result } -} \ No newline at end of file +} + +data class OutgoingMessage( + val body: String, + val conversation: Conversation, + val attachmentUris: Set, + 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 + ) + } +} + diff --git a/backend/src/main/java/net/buzzert/kordophone/backend/server/APIClient.kt b/backend/src/main/java/net/buzzert/kordophone/backend/server/APIClient.kt index c36fa8f..d4ec9c8 100644 --- a/backend/src/main/java/net/buzzert/kordophone/backend/server/APIClient.kt +++ b/backend/src/main/java/net/buzzert/kordophone/backend/server/APIClient.kt @@ -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 = throwError() override suspend fun markConversation(conversationGUID: String): retrofit2.Response = throwError() override suspend fun fetchAttachment(guid: String, preview: Boolean): ResponseBody = throwError() + override suspend fun uploadAttachment(filename: String, body: RequestBody): retrofit2.Response = throwError() override suspend fun authenticate(request: AuthenticationRequest): retrofit2.Response = throwError() override suspend fun getMessages( conversationGUID: String, diff --git a/backend/src/main/java/net/buzzert/kordophone/backend/server/APIInterface.kt b/backend/src/main/java/net/buzzert/kordophone/backend/server/APIInterface.kt index e3014f9..821f0f8 100644 --- a/backend/src/main/java/net/buzzert/kordophone/backend/server/APIInterface.kt +++ b/backend/src/main/java/net/buzzert/kordophone/backend/server/APIInterface.kt @@ -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 + @POST("/authenticate") suspend fun authenticate(@Body request: AuthenticationRequest): Response } diff --git a/backend/src/main/java/net/buzzert/kordophone/backend/server/ChatRepository.kt b/backend/src/main/java/net/buzzert/kordophone/backend/server/ChatRepository.kt index bcc0c7f..9ff18c2 100644 --- a/backend/src/main/java/net/buzzert/kordophone/backend/server/ChatRepository.kt +++ b/backend/src/main/java/net/buzzert/kordophone/backend/server/ChatRepository.kt @@ -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 = ArrayBlockingQueue(16) + private val outgoingMessageQueue: ArrayBlockingQueue = ArrayBlockingQueue(16) private var outgoingMessageThread: Thread? = null private val _messageDeliveredChannel = MutableSharedFlow() private val _errorEncounteredChannel = MutableSharedFlow() @@ -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() + 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(