diff --git a/app/src/main/java/sushi/hardcore/aira/ChatActivity.kt b/app/src/main/java/sushi/hardcore/aira/ChatActivity.kt index 61f51fb..6a13a81 100644 --- a/app/src/main/java/sushi/hardcore/aira/ChatActivity.kt +++ b/app/src/main/java/sushi/hardcore/aira/ChatActivity.kt @@ -13,17 +13,15 @@ import android.widget.ImageView import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog -import androidx.core.view.updatePadding -import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import sushi.hardcore.aira.adapters.ChatAdapter +import sushi.hardcore.aira.adapters.FuckRecyclerView import sushi.hardcore.aira.background_service.* import sushi.hardcore.aira.databinding.ActivityChatBinding import sushi.hardcore.aira.databinding.DialogFingerprintsBinding import sushi.hardcore.aira.databinding.DialogInfoBinding import sushi.hardcore.aira.utils.FileUtils import sushi.hardcore.aira.utils.StringUtils -import sushi.hardcore.aira.utils.TimeUtils class ChatActivity : ServiceBoundActivity() { private external fun generateFingerprint(publicKey: ByteArray): String @@ -36,7 +34,108 @@ class ChatActivity : ServiceBoundActivity() { private var lastLoadedMessageOffset = 0 private val filePicker = registerForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris -> if (isServiceInitialized() && uris.size > 0) { - airaService.sendFilesFromUris(sessionId, uris) + airaService.sendFilesFromUris(sessionId, uris) { buffer -> + chatAdapter.newMessage(ChatItem(true, 0, buffer)) + scrollToBottom() + } + } + } + private val uiCallbacks = object : AIRAService.UiCallbacks { + override fun onConnectFailed(ip: String, errorMsg: String?) {} + override fun onNewSession(sessionId: Int, ip: String) { + if (this@ChatActivity.sessionId == sessionId) { + runOnUiThread { + val contact = airaService.contacts[sessionId] + if (contact == null) { + binding.bottomPanel.visibility = View.VISIBLE + } else { + binding.offlineWarning.visibility = View.GONE + if (airaService.pendingMsgs[sessionId]!!.size > 0) { + binding.sendingPendingMsgsIndicator.visibility = View.VISIBLE + //remove pending messages + reloadHistory(contact) + scrollToBottom() + } + } + invalidateOptionsMenu() + } + } + } + override fun onSessionDisconnect(sessionId: Int) { + if (this@ChatActivity.sessionId == sessionId) { + runOnUiThread { + if (airaService.isContact(sessionId)) { + binding.offlineWarning.visibility = View.VISIBLE + } else { + hideBottomPanel() + } + } + } + } + override fun onNameTold(sessionId: Int, name: String) { + if (this@ChatActivity.sessionId == sessionId) { + runOnUiThread { + sessionName = name + binding.toolbar.title.text = name + if (avatar == null) { + binding.toolbar.avatar.setTextAvatar(name) + } + } + } + } + override fun onAvatarChanged(sessionId: Int, avatar: ByteArray?) { + if (this@ChatActivity.sessionId == sessionId) { + runOnUiThread { + this@ChatActivity.avatar = avatar + if (avatar == null) { + binding.toolbar.avatar.setTextAvatar(sessionName) + } else { + binding.toolbar.avatar.setImageAvatar(avatar) + } + } + } + } + override fun onSent(sessionId: Int, timestamp: Long, buffer: ByteArray) { + if (this@ChatActivity.sessionId == sessionId) { + if (airaService.isContact(sessionId)) { + lastLoadedMessageOffset += 1 + } + runOnUiThread { + chatAdapter.newMessage(ChatItem(true, timestamp, buffer)) + scrollToBottom() + } + } + } + override fun onPendingMessagesSent(sessionId: Int) { + if (this@ChatActivity.sessionId == sessionId) { + runOnUiThread { + binding.sendingPendingMsgsIndicator.visibility = View.GONE + } + } + } + override fun onNewMessage(sessionId: Int, timestamp: Long, data: ByteArray): Boolean { + return if (this@ChatActivity.sessionId == sessionId) { + runOnUiThread { + chatAdapter.newMessage(ChatItem(false, timestamp, data)) + scrollToBottom() + } + if (airaService.isContact(sessionId)) { + lastLoadedMessageOffset += 1 + } + !airaService.isAppInBackground + } else { + false + } + } + override fun onAskLargeFiles(sessionId: Int, filesReceiver: FilesReceiver): Boolean { + return if (this@ChatActivity.sessionId == sessionId) { + runOnUiThread { + filesReceiver.ask(this@ChatActivity, sessionName ?: airaService.sessions[sessionId]!!.ip) + } + true + } else { + false + } } } @@ -52,7 +151,7 @@ class ChatActivity : ServiceBoundActivity() { chatAdapter = ChatAdapter(this@ChatActivity, ::onClickSaveFile) binding.recyclerChat.apply { adapter = chatAdapter - layoutManager = LinearLayoutManager(this@ChatActivity, LinearLayoutManager.VERTICAL, false).apply { + layoutManager = FuckRecyclerView(this@ChatActivity).apply { stackFromEnd = true } addOnScrollListener(object : RecyclerView.OnScrollListener() { @@ -76,13 +175,11 @@ class ChatActivity : ServiceBoundActivity() { } binding.buttonSend.setOnClickListener { val msg = binding.editMessage.text.toString() - airaService.sendTo(sessionId, Protocol.newMessage(msg)) binding.editMessage.text.clear() - chatAdapter.newMessage(ChatItem(true, TimeUtils.getTimestamp(), Protocol.newMessage(msg))) - if (airaService.contacts.contains(sessionId)) { - lastLoadedMessageOffset += 1 + if (!airaService.sendOrAddToPending(sessionId, Protocol.newMessage(msg))) { + chatAdapter.newMessage(ChatItem(true, 0, Protocol.newMessage(msg))) + scrollToBottom() } - binding.recyclerChat.smoothScrollToPosition(chatAdapter.itemCount) } binding.buttonAttach.setOnClickListener { filePicker.launch("*/*") @@ -95,9 +192,8 @@ class ChatActivity : ServiceBoundActivity() { val session = airaService.sessions[sessionId] val contact = airaService.contacts[sessionId] if (session == null && contact == null) { //may happen when resuming activity after session disconnect - onDisconnected() + hideBottomPanel() } else { - chatAdapter.clear() val avatar = if (contact == null) { displayIconTrustLevel(false, false) sessionName = airaService.savedNames[sessionId] @@ -117,93 +213,32 @@ class ChatActivity : ServiceBoundActivity() { binding.toolbar.avatar.setImageAvatar(image) } } - if (contact != null) { - loadMsgs(contact.uuid) - } - airaService.savedMsgs[sessionId]?.let { - for (chatItem in it.asReversed()) { - chatAdapter.newLoadedMessage(chatItem) + reloadHistory(contact) + airaService.pendingMsgs[sessionId]?.let { + for (msg in it) { + if (msg[0] == Protocol.MESSAGE ||msg[0] == Protocol.FILE) { + chatAdapter.newMessage(ChatItem(true, 0, msg)) + } } } + if (chatAdapter.itemCount > 0) { + scrollToBottom() + } airaService.receiveFileTransfers[sessionId]?.let { if (it.shouldAsk) { it.ask(this@ChatActivity, ipName) } } - binding.recyclerChat.smoothScrollToPosition(chatAdapter.itemCount) - if (airaService.isOnline(sessionId)) { - binding.bottomPanel.visibility = View.VISIBLE - binding.recyclerChat.updatePadding(bottom = 0) - } else { - onDisconnected() + if (session == null) { + if (contact == null) { + hideBottomPanel() + } else { + binding.offlineWarning.visibility = View.VISIBLE + } } airaService.setSeen(sessionId, true) } - airaService.uiCallbacks = object : AIRAService.UiCallbacks { - override fun onConnectFailed(ip: String, errorMsg: String?) {} - override fun onNewSession(sessionId: Int, ip: String) { - if (this@ChatActivity.sessionId == sessionId) { - runOnUiThread { - binding.bottomPanel.visibility = View.VISIBLE - invalidateOptionsMenu() - } - } - } - override fun onSessionDisconnect(sessionId: Int) { - if (this@ChatActivity.sessionId == sessionId) { - runOnUiThread { - onDisconnected() - } - } - } - override fun onNameTold(sessionId: Int, name: String) { - if (this@ChatActivity.sessionId == sessionId) { - runOnUiThread { - sessionName = name - binding.toolbar.title.text = name - if (avatar == null) { - binding.toolbar.avatar.setTextAvatar(name) - } - } - } - } - override fun onAvatarChanged(sessionId: Int, avatar: ByteArray?) { - if (this@ChatActivity.sessionId == sessionId) { - runOnUiThread { - this@ChatActivity.avatar = avatar - if (avatar == null) { - binding.toolbar.avatar.setTextAvatar(sessionName) - } else { - binding.toolbar.avatar.setImageAvatar(avatar) - } - } - } - } - override fun onNewMessage(sessionId: Int, timestamp: Long, data: ByteArray): Boolean { - return if (this@ChatActivity.sessionId == sessionId) { - runOnUiThread { - chatAdapter.newMessage(ChatItem(false, timestamp, data)) - binding.recyclerChat.smoothScrollToPosition(chatAdapter.itemCount) - } - if (airaService.contacts.contains(sessionId)) { - lastLoadedMessageOffset += 1 - } - !airaService.isAppInBackground - } else { - false - } - } - override fun onAskLargeFiles(sessionId: Int, filesReceiver: FilesReceiver): Boolean { - return if (this@ChatActivity.sessionId == sessionId) { - runOnUiThread { - filesReceiver.ask(this@ChatActivity, sessionName ?: airaService.sessions[sessionId]!!.ip) - } - true - } else { - false - } - } - } + airaService.uiCallbacks = uiCallbacks airaService.isAppInBackground = false } override fun onServiceDisconnected(name: ComponentName?) {} @@ -211,7 +246,7 @@ class ChatActivity : ServiceBoundActivity() { } } - private fun onDisconnected() { + private fun hideBottomPanel() { val inputManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager inputManager.hideSoftInputFromWindow(binding.editMessage.windowToken, 0) binding.bottomPanel.visibility = View.GONE @@ -254,6 +289,23 @@ class ChatActivity : ServiceBoundActivity() { } } + private fun reloadHistory(contact: Contact?) { + chatAdapter.clear() + lastLoadedMessageOffset = 0 + if (contact != null) { + loadMsgs(contact.uuid) + } + airaService.savedMsgs[sessionId]?.let { + for (msg in it.asReversed()) { + chatAdapter.newLoadedMessage(ChatItem(msg.outgoing, msg.timestamp, msg.data)) + } + } + } + + private fun scrollToBottom() { + binding.recyclerChat.smoothScrollToPosition(chatAdapter.itemCount-1) + } + override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.chat_activity, menu) val contact = airaService.contacts[sessionId] @@ -341,7 +393,7 @@ class ChatActivity : ServiceBoundActivity() { true } R.id.refresh_profile -> { - airaService.sendTo(sessionId, Protocol.askProfileInfo()) + airaService.sendOrAddToPending(sessionId, Protocol.askProfileInfo()) true } else -> super.onOptionsItemSelected(item) @@ -355,22 +407,12 @@ class ChatActivity : ServiceBoundActivity() { } } - override fun onPause() { - super.onPause() - lastLoadedMessageOffset = 0 - } - - private fun onClickSaveFile(fileName: String, rawUuid: ByteArray) { - val buffer = AIRADatabase.loadFile(rawUuid) - if (buffer == null) { - Toast.makeText(this, R.string.loadFile_failed, Toast.LENGTH_SHORT).show() - } else { - val file = FileUtils.openFileForDownload(this, fileName) - file.outputStream?.apply { - write(buffer) - close() - Toast.makeText(this@ChatActivity, getString(R.string.file_saved, file.fileName), Toast.LENGTH_SHORT).show() - } + private fun onClickSaveFile(fileName: String, fileContent: ByteArray) { + val file = FileUtils.openFileForDownload(this, fileName) + file.outputStream?.apply { + write(fileContent) + close() + Toast.makeText(this@ChatActivity, getString(R.string.file_saved, file.fileName), Toast.LENGTH_SHORT).show() } } diff --git a/app/src/main/java/sushi/hardcore/aira/ChatItem.kt b/app/src/main/java/sushi/hardcore/aira/ChatItem.kt index 034c0b9..927c51b 100644 --- a/app/src/main/java/sushi/hardcore/aira/ChatItem.kt +++ b/app/src/main/java/sushi/hardcore/aira/ChatItem.kt @@ -10,10 +10,12 @@ class ChatItem(val outgoing: Boolean, val timestamp: Long, val data: ByteArray) const val OUTGOING_FILE = 2 const val INCOMING_FILE = 3 } - val itemType = if (data[0] == Protocol.MESSAGE) { - if (outgoing) OUTGOING_MESSAGE else INCOMING_MESSAGE - } else { - if (outgoing) OUTGOING_FILE else INCOMING_FILE + val itemType: Int by lazy { + if (data[0] == Protocol.MESSAGE) { + if (outgoing) OUTGOING_MESSAGE else INCOMING_MESSAGE + } else { + if (outgoing) OUTGOING_FILE else INCOMING_FILE + } } val calendar: Calendar by lazy { diff --git a/app/src/main/java/sushi/hardcore/aira/MainActivity.kt b/app/src/main/java/sushi/hardcore/aira/MainActivity.kt index 725a005..0fd10b5 100644 --- a/app/src/main/java/sushi/hardcore/aira/MainActivity.kt +++ b/app/src/main/java/sushi/hardcore/aira/MainActivity.kt @@ -67,19 +67,19 @@ class MainActivity : ServiceBoundActivity() { onlineSessionAdapter.setName(sessionId, name) } } - override fun onAvatarChanged(sessionId: Int, avatar: ByteArray?) { runOnUiThread { onlineSessionAdapter.setAvatar(sessionId, avatar) } } + override fun onSent(sessionId: Int, timestamp: Long, buffer: ByteArray) {} + override fun onPendingMessagesSent(sessionId: Int) {} override fun onNewMessage(sessionId: Int, timestamp: Long, data: ByteArray): Boolean { runOnUiThread { onlineSessionAdapter.setSeen(sessionId, false) } return false } - override fun onAskLargeFiles(sessionId: Int, filesReceiver: FilesReceiver): Boolean { runOnUiThread { filesReceiver.ask(this@MainActivity, airaService.getNameOf(sessionId)) diff --git a/app/src/main/java/sushi/hardcore/aira/adapters/ChatAdapter.kt b/app/src/main/java/sushi/hardcore/aira/adapters/ChatAdapter.kt index 5e88367..14ebd54 100644 --- a/app/src/main/java/sushi/hardcore/aira/adapters/ChatAdapter.kt +++ b/app/src/main/java/sushi/hardcore/aira/adapters/ChatAdapter.kt @@ -3,8 +3,6 @@ package sushi.hardcore.aira.adapters import android.annotation.SuppressLint import android.content.Context import android.graphics.drawable.GradientDrawable -import android.os.Handler -import android.os.Looper import android.view.Gravity import android.view.LayoutInflater import android.view.View @@ -16,8 +14,10 @@ import androidx.core.content.ContextCompat import androidx.core.view.updateMargins import androidx.core.view.updatePadding import androidx.recyclerview.widget.RecyclerView +import sushi.hardcore.aira.AIRADatabase import sushi.hardcore.aira.ChatItem import sushi.hardcore.aira.R +import sushi.hardcore.aira.background_service.Protocol import sushi.hardcore.aira.utils.StringUtils import sushi.hardcore.aira.utils.TimeUtils import java.text.DateFormat @@ -41,9 +41,7 @@ class ChatAdapter( fun newMessage(chatItem: ChatItem) { chatItems.add(chatItem) - Handler(Looper.getMainLooper()).postDelayed({ - notifyItemChanged(chatItems.size-2) - }, 100) + notifyItemChanged(chatItems.size-2) notifyItemInserted(chatItems.size-1) } @@ -126,26 +124,44 @@ class ChatAdapter( bubble.background = backgroundDrawable } protected fun showDateAndTime(chatItem: ChatItem, previousChatItem: ChatItem?) { - val showDate = if (previousChatItem == null) { - true - } else { - !TimeUtils.isInTheSameDay(chatItem, previousChatItem) - } + var showTextPendingMsg = false + val textPendingMsg = itemView.findViewById(R.id.text_pending_msg) val textDate = itemView.findViewById(R.id.text_date) + val textHour = itemView.findViewById(R.id.text_hour) + val showDate = if (chatItem.timestamp == 0L) { + if (previousChatItem == null || previousChatItem.timestamp != 0L) { + showTextPendingMsg = true + } + textHour.visibility = View.GONE + false + } else { + textHour.apply { + visibility = View.VISIBLE + @SuppressLint("SetTextI18n") + text = StringUtils.toTwoDigits(chatItem.calendar.get(Calendar.HOUR_OF_DAY))+":"+StringUtils.toTwoDigits(chatItem.calendar.get(Calendar.MINUTE)) + setTextColor(ContextCompat.getColor(context, if (chatItem.outgoing) { + R.color.outgoingTimestamp + } else { + R.color.incomingTimestamp + })) + } + if (previousChatItem == null) { + true + } else { + !TimeUtils.isInTheSameDay(chatItem, previousChatItem) + } + } textDate.visibility = if (showDate) { textDate.text = DateFormat.getDateInstance().format(chatItem.calendar.time) View.VISIBLE } else { View.GONE } - val textHour = itemView.findViewById(R.id.text_hour) - @SuppressLint("SetTextI18n") - textHour.text = StringUtils.toTwoDigits(chatItem.calendar.get(Calendar.HOUR_OF_DAY))+":"+StringUtils.toTwoDigits(chatItem.calendar.get(Calendar.MINUTE)) - textHour.setTextColor(ContextCompat.getColor(context, if (chatItem.outgoing) { - R.color.outgoingTimestamp + textPendingMsg.visibility = if (showTextPendingMsg) { + View.VISIBLE } else { - R.color.incomingTimestamp - })) + View.GONE + } } } @@ -184,19 +200,31 @@ class ChatAdapter( } } - internal open class FileViewHolder(context: Context, itemView: View, private val onSavingFile: (filename: String, rawUuid: ByteArray) -> Unit): BubbleViewHolder(context, itemView) { + internal open class FileViewHolder(context: Context, itemView: View, private val onSavingFile: (fileName: String, fileContent: ByteArray) -> Unit): BubbleViewHolder(context, itemView) { protected fun bindFile(chatItem: ChatItem, outgoing: Boolean) { setBubbleContent(R.layout.file_bubble_content) - val filename = chatItem.data.sliceArray(17 until chatItem.data.size).decodeToString() + val buttonSave = itemView.findViewById(R.id.button_save) + val fileName: String + if (chatItem.timestamp == 0L) { //pending + val file = Protocol.parseSmallFile(chatItem.data)!! + fileName = file.rawFileName.decodeToString() + buttonSave.setOnClickListener { + onSavingFile(fileName, file.fileContent) + } + } else { + fileName = chatItem.data.sliceArray(17 until chatItem.data.size).decodeToString() + buttonSave.setOnClickListener { + AIRADatabase.loadFile(chatItem.data.sliceArray(1 until 17))?.let { + onSavingFile(fileName, it) + } + } + } itemView.findViewById(R.id.text_filename).apply { - text = filename + text = fileName if (!outgoing) { highlightColor = ContextCompat.getColor(context, R.color.incomingHighlight) } } - itemView.findViewById(R.id.button_save).setOnClickListener { - onSavingFile(filename, chatItem.data.sliceArray(1 until 17)) - } } } diff --git a/app/src/main/java/sushi/hardcore/aira/adapters/FuckRecyclerView.kt b/app/src/main/java/sushi/hardcore/aira/adapters/FuckRecyclerView.kt new file mode 100644 index 0000000..37a20c0 --- /dev/null +++ b/app/src/main/java/sushi/hardcore/aira/adapters/FuckRecyclerView.kt @@ -0,0 +1,18 @@ +package sushi.hardcore.aira.adapters + +import android.content.Context +import android.util.AttributeSet +import androidx.recyclerview.widget.LinearLayoutManager + +//https://stackoverflow.com/questions/36724898/notifyitemchanged-make-the-recyclerview-scroll-and-jump-to-up + +class FuckRecyclerView( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + defStyleRes: Int = 0, +): LinearLayoutManager(context, attrs, defStyleAttr, defStyleRes) { + override fun isAutoMeasureEnabled(): Boolean { + return false + } +} \ No newline at end of file diff --git a/app/src/main/java/sushi/hardcore/aira/background_service/AIRAService.kt b/app/src/main/java/sushi/hardcore/aira/background_service/AIRAService.kt index cf0bc83..44503c2 100644 --- a/app/src/main/java/sushi/hardcore/aira/background_service/AIRAService.kt +++ b/app/src/main/java/sushi/hardcore/aira/background_service/AIRAService.kt @@ -88,6 +88,7 @@ class AIRAService : Service() { override fun onServiceLost(serviceInfo: NsdServiceInfo?) {} } val savedMsgs = mutableMapOf>() + val pendingMsgs = mutableMapOf>() val savedNames = mutableMapOf() val savedAvatars = mutableMapOf() val notSeen = mutableListOf() @@ -109,6 +110,8 @@ class AIRAService : Service() { fun onSessionDisconnect(sessionId: Int) fun onNameTold(sessionId: Int, name: String) fun onAvatarChanged(sessionId: Int, avatar: ByteArray?) + fun onSent(sessionId: Int, timestamp: Long, buffer: ByteArray) + fun onPendingMessagesSent(sessionId: Int) fun onNewMessage(sessionId: Int, timestamp: Long, data: ByteArray): Boolean fun onAskLargeFiles(sessionId: Int, filesReceiver: FilesReceiver): Boolean } @@ -121,14 +124,20 @@ class AIRAService : Service() { } } - fun sendTo(sessionId: Int, buffer: ByteArray) { - serviceHandler.obtainMessage().apply { - what = MESSAGE_SEND_TO - data = Bundle().apply { - putInt("sessionId", sessionId) - putByteArray("buff", buffer) + fun sendOrAddToPending(sessionId: Int, buffer: ByteArray): Boolean { + return if (isOnline(sessionId)) { + serviceHandler.obtainMessage().apply { + what = MESSAGE_SEND_TO + data = Bundle().apply { + putInt("sessionId", sessionId) + putByteArray("buff", buffer) + } + serviceHandler.sendMessage(this) } - serviceHandler.sendMessage(this) + true + } else { + pendingMsgs[sessionId]?.add(buffer) + false } } @@ -140,7 +149,7 @@ class AIRAService : Service() { } } - fun sendFilesFromUris(sessionId: Int, uris: List) { + fun sendFilesFromUris(sessionId: Int, uris: List, onPendingSmallFile: ((ByteArray) -> Unit)? = null) { val files = mutableListOf() var useLargeFileTransfer = false for (uri in uris) { @@ -155,26 +164,22 @@ class AIRAService : Service() { sendLargeFilesTo(sessionId, files) } else { for (file in files) { - sendSmallFileTo(sessionId, file) + var buffer = file.inputStream.readBytes() + file.inputStream.close() + buffer = Protocol.newFile(file.fileName, buffer) + if (!sendOrAddToPending(sessionId, buffer)) { + onPendingSmallFile?.let { it(buffer) } + } } } } - private fun sendSmallFileTo(sessionId: Int, sendFile: SendFile) { - val buffer = sendFile.inputStream.readBytes() - sendFile.inputStream.close() - sendTo(sessionId, Protocol.newFile(sendFile.fileName, buffer)) - AIRADatabase.storeFile(contacts[sessionId]?.uuid, buffer)?.let { rawFileUuid -> - saveMsg(sessionId, TimeUtils.getTimestamp(), byteArrayOf(Protocol.FILE) + rawFileUuid + sendFile.fileName.toByteArray()) - } - } - private fun sendLargeFilesTo(sessionId: Int, files: MutableList) { if (sendFileTransfers[sessionId] == null && receiveFileTransfers[sessionId] == null) { val filesSender = FilesSender(files, this, notificationManager) initFileTransferNotification(sessionId, filesSender.fileTransferNotification, filesSender.files[0]) sendFileTransfers[sessionId] = filesSender - sendTo(sessionId, Protocol.askLargeFiles(files)) + sendOrAddToPending(sessionId, Protocol.askLargeFiles(files)) } else { Toast.makeText(this, R.string.file_transfer_already_in_progress, Toast.LENGTH_SHORT).show() } @@ -194,7 +199,7 @@ class AIRAService : Service() { return contacts[sessionId]?.name ?: savedNames[sessionId] ?: sessions[sessionId]!!.ip } - private fun isContact(sessionId: Int): Boolean { + fun isContact(sessionId: Int): Boolean { return contacts.contains(sessionId) } @@ -208,6 +213,7 @@ class AIRAService : Service() { } } savedNames.remove(sessionId) + pendingMsgs[sessionId] = mutableListOf() savedAvatars.remove(sessionId) return true } @@ -245,6 +251,7 @@ class AIRAService : Service() { contacts.remove(sessionId)?.let { return if (AIRADatabase.removeContact(it.uuid)) { savedMsgs[sessionId] = mutableListOf() + pendingMsgs.remove(sessionId) savedNames[sessionId] = it.name it.avatar?.let { avatarUuid -> savedAvatars[sessionId] = avatarUuid @@ -328,7 +335,12 @@ class AIRAService : Service() { val key = session.register(selector, SelectionKey.OP_READ) sessionIdByKey[key] = sessionId uiCallbacks?.onNewSession(sessionId, session.ip) - if (!isContact(sessionId)) { + if (isContact(sessionId)) { + for (i in 0 until pendingMsgs[sessionId]!!.size) { + sendAndSave(sessionId, pendingMsgs[sessionId]!!.removeAt(0)) + } + uiCallbacks?.onPendingMessagesSent(sessionId) + } else { session.encryptAndSend(Protocol.askProfileInfo(), usePadding) } } else { @@ -429,10 +441,20 @@ class AIRAService : Service() { } } - private fun sendAndSave(sessionId: Int, msg: ByteArray) { - sessions[sessionId]?.encryptAndSend(msg, usePadding) - if (msg[0] == Protocol.MESSAGE) { - saveMsg(sessionId, TimeUtils.getTimestamp(), msg) + private fun sendAndSave(sessionId: Int, buffer: ByteArray) { + sessions[sessionId]?.encryptAndSend(buffer, usePadding) + val timestamp = TimeUtils.getTimestamp() + if (buffer[0] == Protocol.MESSAGE) { + uiCallbacks?.onSent(sessionId, timestamp, buffer) + saveMsg(sessionId, timestamp, buffer) + } else if (buffer[0] == Protocol.FILE) { + Protocol.parseSmallFile(buffer)?.let { file -> + AIRADatabase.storeFile(contacts[sessionId]?.uuid, file.fileContent)?.let { rawFileUuid -> + val msg = byteArrayOf(Protocol.FILE) + rawFileUuid + file.rawFileName + uiCallbacks?.onSent(sessionId, timestamp, msg) + saveMsg(sessionId, timestamp, msg) + } + } } } @@ -533,6 +555,7 @@ class AIRAService : Service() { contacts = HashMap(contactList.size) for (contact in contactList) { contacts[sessionCounter] = contact + pendingMsgs[sessionCounter] = mutableListOf() if (!contact.seen) { notSeen.add(sessionCounter) } @@ -720,10 +743,10 @@ class AIRAService : Service() { filesReceiver.fileTransferNotification, filesReceiver.files[0], ) - sendTo(sessionId, Protocol.acceptLargeFiles()) + session.encryptAndSend(Protocol.acceptLargeFiles(), usePadding) }, { filesReceiver -> receiveFileTransfers.remove(sessionId) - sendTo(sessionId, Protocol.abortFilesTransfer()) + session.encryptAndSend(Protocol.abortFilesTransfer(), usePadding) filesReceiver.fileTransferNotification.cancel() }, this, diff --git a/app/src/main/java/sushi/hardcore/aira/background_service/NotificationBroadcastReceiver.kt b/app/src/main/java/sushi/hardcore/aira/background_service/NotificationBroadcastReceiver.kt index 0352076..5587fdb 100644 --- a/app/src/main/java/sushi/hardcore/aira/background_service/NotificationBroadcastReceiver.kt +++ b/app/src/main/java/sushi/hardcore/aira/background_service/NotificationBroadcastReceiver.kt @@ -22,7 +22,7 @@ class NotificationBroadcastReceiver: BroadcastReceiver() { ACTION_MARK_READ -> airaService.setSeen(sessionId, true) ACTION_CANCEL_FILE_TRANSFER -> airaService.cancelFileTransfer(sessionId) ACTION_REPLY -> RemoteInput.getResultsFromIntent(intent)?.getString(KEY_TEXT_REPLY)?.let { reply -> - airaService.sendTo(sessionId, Protocol.newMessage(reply)) + airaService.sendOrAddToPending(sessionId, Protocol.newMessage(reply)) airaService.setSeen(sessionId, true) } else -> {} diff --git a/app/src/main/res/drawable/offline_warning_background.xml b/app/src/main/res/drawable/offline_warning_background.xml new file mode 100644 index 0000000..6cddfd1 --- /dev/null +++ b/app/src/main/res/drawable/offline_warning_background.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/pending_msg_indicator_background.xml b/app/src/main/res/drawable/pending_msg_indicator_background.xml new file mode 100644 index 0000000..5977891 --- /dev/null +++ b/app/src/main/res/drawable/pending_msg_indicator_background.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/sending_pending_msg_indictor_background.xml b/app/src/main/res/drawable/sending_pending_msg_indictor_background.xml new file mode 100644 index 0000000..b159efe --- /dev/null +++ b/app/src/main/res/drawable/sending_pending_msg_indictor_background.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_chat.xml b/app/src/main/res/layout/activity_chat.xml index 93bbf89..6a045f0 100644 --- a/app/src/main/res/layout/activity_chat.xml +++ b/app/src/main/res/layout/activity_chat.xml @@ -13,14 +13,79 @@ android:layout_width="match_parent" android:layout_height="0dp" app:layout_constraintTop_toBottomOf="@id/toolbar" - app:layout_constraintBottom_toTopOf="@id/bottom_panel"/> + app:layout_constraintBottom_toTopOf="@id/offline_warning"/> + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/adapter_chat_item.xml b/app/src/main/res/layout/adapter_chat_item.xml index 41aca71..803249b 100644 --- a/app/src/main/res/layout/adapter_chat_item.xml +++ b/app/src/main/res/layout/adapter_chat_item.xml @@ -9,9 +9,19 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_horizontal" - android:visibility="gone" android:paddingVertical="5dp"/> + + #777777 #d7d7d7 @color/outgoingTextLink + #ff0000 + #909090 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9da6e54..5065d8b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -76,7 +76,6 @@ Another file transfer is already in progress Settings Log out - File extraction failed Copied to clipboard ! Identity About @@ -109,4 +108,9 @@ Clickable indicator Avatar Name + Your contact seems to be offline. + Sent messages will be stored until a connection is established. + Warning icon + Pending messages: + Sending pending messages… \ No newline at end of file diff --git a/build.gradle b/build.gradle index 241b034..8e35340 100644 --- a/build.gradle +++ b/build.gradle @@ -1,11 +1,11 @@ buildscript { - ext.kotlin_version = "1.5.10" + ext.kotlin_version = "1.5.21" repositories { google() mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:4.2.2' + classpath 'com.android.tools.build:gradle:7.0.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 5b34074..d2cb90a 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-all.zip