Pending messages

This commit is contained in:
Matéo Duparc 2021-07-31 22:07:32 +02:00
parent d618eb87c7
commit 5fc3f7d0cf
Signed by: hardcoresushi
GPG Key ID: 007F84120107191E
16 changed files with 395 additions and 171 deletions

View File

@ -13,17 +13,15 @@ import android.widget.ImageView
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import sushi.hardcore.aira.adapters.ChatAdapter import sushi.hardcore.aira.adapters.ChatAdapter
import sushi.hardcore.aira.adapters.FuckRecyclerView
import sushi.hardcore.aira.background_service.* import sushi.hardcore.aira.background_service.*
import sushi.hardcore.aira.databinding.ActivityChatBinding import sushi.hardcore.aira.databinding.ActivityChatBinding
import sushi.hardcore.aira.databinding.DialogFingerprintsBinding import sushi.hardcore.aira.databinding.DialogFingerprintsBinding
import sushi.hardcore.aira.databinding.DialogInfoBinding import sushi.hardcore.aira.databinding.DialogInfoBinding
import sushi.hardcore.aira.utils.FileUtils import sushi.hardcore.aira.utils.FileUtils
import sushi.hardcore.aira.utils.StringUtils import sushi.hardcore.aira.utils.StringUtils
import sushi.hardcore.aira.utils.TimeUtils
class ChatActivity : ServiceBoundActivity() { class ChatActivity : ServiceBoundActivity() {
private external fun generateFingerprint(publicKey: ByteArray): String private external fun generateFingerprint(publicKey: ByteArray): String
@ -36,7 +34,108 @@ class ChatActivity : ServiceBoundActivity() {
private var lastLoadedMessageOffset = 0 private var lastLoadedMessageOffset = 0
private val filePicker = registerForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris -> private val filePicker = registerForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris ->
if (isServiceInitialized() && uris.size > 0) { 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) chatAdapter = ChatAdapter(this@ChatActivity, ::onClickSaveFile)
binding.recyclerChat.apply { binding.recyclerChat.apply {
adapter = chatAdapter adapter = chatAdapter
layoutManager = LinearLayoutManager(this@ChatActivity, LinearLayoutManager.VERTICAL, false).apply { layoutManager = FuckRecyclerView(this@ChatActivity).apply {
stackFromEnd = true stackFromEnd = true
} }
addOnScrollListener(object : RecyclerView.OnScrollListener() { addOnScrollListener(object : RecyclerView.OnScrollListener() {
@ -76,13 +175,11 @@ class ChatActivity : ServiceBoundActivity() {
} }
binding.buttonSend.setOnClickListener { binding.buttonSend.setOnClickListener {
val msg = binding.editMessage.text.toString() val msg = binding.editMessage.text.toString()
airaService.sendTo(sessionId, Protocol.newMessage(msg))
binding.editMessage.text.clear() binding.editMessage.text.clear()
chatAdapter.newMessage(ChatItem(true, TimeUtils.getTimestamp(), Protocol.newMessage(msg))) if (!airaService.sendOrAddToPending(sessionId, Protocol.newMessage(msg))) {
if (airaService.contacts.contains(sessionId)) { chatAdapter.newMessage(ChatItem(true, 0, Protocol.newMessage(msg)))
lastLoadedMessageOffset += 1 scrollToBottom()
} }
binding.recyclerChat.smoothScrollToPosition(chatAdapter.itemCount)
} }
binding.buttonAttach.setOnClickListener { binding.buttonAttach.setOnClickListener {
filePicker.launch("*/*") filePicker.launch("*/*")
@ -95,9 +192,8 @@ class ChatActivity : ServiceBoundActivity() {
val session = airaService.sessions[sessionId] val session = airaService.sessions[sessionId]
val contact = airaService.contacts[sessionId] val contact = airaService.contacts[sessionId]
if (session == null && contact == null) { //may happen when resuming activity after session disconnect if (session == null && contact == null) { //may happen when resuming activity after session disconnect
onDisconnected() hideBottomPanel()
} else { } else {
chatAdapter.clear()
val avatar = if (contact == null) { val avatar = if (contact == null) {
displayIconTrustLevel(false, false) displayIconTrustLevel(false, false)
sessionName = airaService.savedNames[sessionId] sessionName = airaService.savedNames[sessionId]
@ -117,93 +213,32 @@ class ChatActivity : ServiceBoundActivity() {
binding.toolbar.avatar.setImageAvatar(image) binding.toolbar.avatar.setImageAvatar(image)
} }
} }
if (contact != null) { reloadHistory(contact)
loadMsgs(contact.uuid) airaService.pendingMsgs[sessionId]?.let {
} for (msg in it) {
airaService.savedMsgs[sessionId]?.let { if (msg[0] == Protocol.MESSAGE ||msg[0] == Protocol.FILE) {
for (chatItem in it.asReversed()) { chatAdapter.newMessage(ChatItem(true, 0, msg))
chatAdapter.newLoadedMessage(chatItem) }
} }
} }
if (chatAdapter.itemCount > 0) {
scrollToBottom()
}
airaService.receiveFileTransfers[sessionId]?.let { airaService.receiveFileTransfers[sessionId]?.let {
if (it.shouldAsk) { if (it.shouldAsk) {
it.ask(this@ChatActivity, ipName) it.ask(this@ChatActivity, ipName)
} }
} }
binding.recyclerChat.smoothScrollToPosition(chatAdapter.itemCount) if (session == null) {
if (airaService.isOnline(sessionId)) { if (contact == null) {
binding.bottomPanel.visibility = View.VISIBLE hideBottomPanel()
binding.recyclerChat.updatePadding(bottom = 0) } else {
} else { binding.offlineWarning.visibility = View.VISIBLE
onDisconnected() }
} }
airaService.setSeen(sessionId, true) airaService.setSeen(sessionId, true)
} }
airaService.uiCallbacks = object : AIRAService.UiCallbacks { airaService.uiCallbacks = 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.isAppInBackground = false airaService.isAppInBackground = false
} }
override fun onServiceDisconnected(name: ComponentName?) {} 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 val inputManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
inputManager.hideSoftInputFromWindow(binding.editMessage.windowToken, 0) inputManager.hideSoftInputFromWindow(binding.editMessage.windowToken, 0)
binding.bottomPanel.visibility = View.GONE 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 { override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.chat_activity, menu) menuInflater.inflate(R.menu.chat_activity, menu)
val contact = airaService.contacts[sessionId] val contact = airaService.contacts[sessionId]
@ -341,7 +393,7 @@ class ChatActivity : ServiceBoundActivity() {
true true
} }
R.id.refresh_profile -> { R.id.refresh_profile -> {
airaService.sendTo(sessionId, Protocol.askProfileInfo()) airaService.sendOrAddToPending(sessionId, Protocol.askProfileInfo())
true true
} }
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
@ -355,22 +407,12 @@ class ChatActivity : ServiceBoundActivity() {
} }
} }
override fun onPause() { private fun onClickSaveFile(fileName: String, fileContent: ByteArray) {
super.onPause() val file = FileUtils.openFileForDownload(this, fileName)
lastLoadedMessageOffset = 0 file.outputStream?.apply {
} write(fileContent)
close()
private fun onClickSaveFile(fileName: String, rawUuid: ByteArray) { Toast.makeText(this@ChatActivity, getString(R.string.file_saved, file.fileName), Toast.LENGTH_SHORT).show()
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()
}
} }
} }

View File

@ -10,10 +10,12 @@ class ChatItem(val outgoing: Boolean, val timestamp: Long, val data: ByteArray)
const val OUTGOING_FILE = 2 const val OUTGOING_FILE = 2
const val INCOMING_FILE = 3 const val INCOMING_FILE = 3
} }
val itemType = if (data[0] == Protocol.MESSAGE) { val itemType: Int by lazy {
if (outgoing) OUTGOING_MESSAGE else INCOMING_MESSAGE if (data[0] == Protocol.MESSAGE) {
} else { if (outgoing) OUTGOING_MESSAGE else INCOMING_MESSAGE
if (outgoing) OUTGOING_FILE else INCOMING_FILE } else {
if (outgoing) OUTGOING_FILE else INCOMING_FILE
}
} }
val calendar: Calendar by lazy { val calendar: Calendar by lazy {

View File

@ -67,19 +67,19 @@ class MainActivity : ServiceBoundActivity() {
onlineSessionAdapter.setName(sessionId, name) onlineSessionAdapter.setName(sessionId, name)
} }
} }
override fun onAvatarChanged(sessionId: Int, avatar: ByteArray?) { override fun onAvatarChanged(sessionId: Int, avatar: ByteArray?) {
runOnUiThread { runOnUiThread {
onlineSessionAdapter.setAvatar(sessionId, avatar) 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 { override fun onNewMessage(sessionId: Int, timestamp: Long, data: ByteArray): Boolean {
runOnUiThread { runOnUiThread {
onlineSessionAdapter.setSeen(sessionId, false) onlineSessionAdapter.setSeen(sessionId, false)
} }
return false return false
} }
override fun onAskLargeFiles(sessionId: Int, filesReceiver: FilesReceiver): Boolean { override fun onAskLargeFiles(sessionId: Int, filesReceiver: FilesReceiver): Boolean {
runOnUiThread { runOnUiThread {
filesReceiver.ask(this@MainActivity, airaService.getNameOf(sessionId)) filesReceiver.ask(this@MainActivity, airaService.getNameOf(sessionId))

View File

@ -3,8 +3,6 @@ package sushi.hardcore.aira.adapters
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.graphics.drawable.GradientDrawable import android.graphics.drawable.GradientDrawable
import android.os.Handler
import android.os.Looper
import android.view.Gravity import android.view.Gravity
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
@ -16,8 +14,10 @@ import androidx.core.content.ContextCompat
import androidx.core.view.updateMargins import androidx.core.view.updateMargins
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import sushi.hardcore.aira.AIRADatabase
import sushi.hardcore.aira.ChatItem import sushi.hardcore.aira.ChatItem
import sushi.hardcore.aira.R import sushi.hardcore.aira.R
import sushi.hardcore.aira.background_service.Protocol
import sushi.hardcore.aira.utils.StringUtils import sushi.hardcore.aira.utils.StringUtils
import sushi.hardcore.aira.utils.TimeUtils import sushi.hardcore.aira.utils.TimeUtils
import java.text.DateFormat import java.text.DateFormat
@ -41,9 +41,7 @@ class ChatAdapter(
fun newMessage(chatItem: ChatItem) { fun newMessage(chatItem: ChatItem) {
chatItems.add(chatItem) chatItems.add(chatItem)
Handler(Looper.getMainLooper()).postDelayed({ notifyItemChanged(chatItems.size-2)
notifyItemChanged(chatItems.size-2)
}, 100)
notifyItemInserted(chatItems.size-1) notifyItemInserted(chatItems.size-1)
} }
@ -126,26 +124,44 @@ class ChatAdapter(
bubble.background = backgroundDrawable bubble.background = backgroundDrawable
} }
protected fun showDateAndTime(chatItem: ChatItem, previousChatItem: ChatItem?) { protected fun showDateAndTime(chatItem: ChatItem, previousChatItem: ChatItem?) {
val showDate = if (previousChatItem == null) { var showTextPendingMsg = false
true val textPendingMsg = itemView.findViewById<TextView>(R.id.text_pending_msg)
} else {
!TimeUtils.isInTheSameDay(chatItem, previousChatItem)
}
val textDate = itemView.findViewById<TextView>(R.id.text_date) val textDate = itemView.findViewById<TextView>(R.id.text_date)
val textHour = itemView.findViewById<TextView>(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.visibility = if (showDate) {
textDate.text = DateFormat.getDateInstance().format(chatItem.calendar.time) textDate.text = DateFormat.getDateInstance().format(chatItem.calendar.time)
View.VISIBLE View.VISIBLE
} else { } else {
View.GONE View.GONE
} }
val textHour = itemView.findViewById<TextView>(R.id.text_hour) textPendingMsg.visibility = if (showTextPendingMsg) {
@SuppressLint("SetTextI18n") View.VISIBLE
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
} else { } 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) { protected fun bindFile(chatItem: ChatItem, outgoing: Boolean) {
setBubbleContent(R.layout.file_bubble_content) setBubbleContent(R.layout.file_bubble_content)
val filename = chatItem.data.sliceArray(17 until chatItem.data.size).decodeToString() val buttonSave = itemView.findViewById<ImageButton>(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<TextView>(R.id.text_filename).apply { itemView.findViewById<TextView>(R.id.text_filename).apply {
text = filename text = fileName
if (!outgoing) { if (!outgoing) {
highlightColor = ContextCompat.getColor(context, R.color.incomingHighlight) highlightColor = ContextCompat.getColor(context, R.color.incomingHighlight)
} }
} }
itemView.findViewById<ImageButton>(R.id.button_save).setOnClickListener {
onSavingFile(filename, chatItem.data.sliceArray(1 until 17))
}
} }
} }

View File

@ -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
}
}

View File

@ -88,6 +88,7 @@ class AIRAService : Service() {
override fun onServiceLost(serviceInfo: NsdServiceInfo?) {} override fun onServiceLost(serviceInfo: NsdServiceInfo?) {}
} }
val savedMsgs = mutableMapOf<Int, MutableList<ChatItem>>() val savedMsgs = mutableMapOf<Int, MutableList<ChatItem>>()
val pendingMsgs = mutableMapOf<Int, MutableList<ByteArray>>()
val savedNames = mutableMapOf<Int, String>() val savedNames = mutableMapOf<Int, String>()
val savedAvatars = mutableMapOf<Int, String>() val savedAvatars = mutableMapOf<Int, String>()
val notSeen = mutableListOf<Int>() val notSeen = mutableListOf<Int>()
@ -109,6 +110,8 @@ class AIRAService : Service() {
fun onSessionDisconnect(sessionId: Int) fun onSessionDisconnect(sessionId: Int)
fun onNameTold(sessionId: Int, name: String) fun onNameTold(sessionId: Int, name: String)
fun onAvatarChanged(sessionId: Int, avatar: ByteArray?) 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 onNewMessage(sessionId: Int, timestamp: Long, data: ByteArray): Boolean
fun onAskLargeFiles(sessionId: Int, filesReceiver: FilesReceiver): Boolean fun onAskLargeFiles(sessionId: Int, filesReceiver: FilesReceiver): Boolean
} }
@ -121,14 +124,20 @@ class AIRAService : Service() {
} }
} }
fun sendTo(sessionId: Int, buffer: ByteArray) { fun sendOrAddToPending(sessionId: Int, buffer: ByteArray): Boolean {
serviceHandler.obtainMessage().apply { return if (isOnline(sessionId)) {
what = MESSAGE_SEND_TO serviceHandler.obtainMessage().apply {
data = Bundle().apply { what = MESSAGE_SEND_TO
putInt("sessionId", sessionId) data = Bundle().apply {
putByteArray("buff", buffer) 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<Uri>) { fun sendFilesFromUris(sessionId: Int, uris: List<Uri>, onPendingSmallFile: ((ByteArray) -> Unit)? = null) {
val files = mutableListOf<SendFile>() val files = mutableListOf<SendFile>()
var useLargeFileTransfer = false var useLargeFileTransfer = false
for (uri in uris) { for (uri in uris) {
@ -155,26 +164,22 @@ class AIRAService : Service() {
sendLargeFilesTo(sessionId, files) sendLargeFilesTo(sessionId, files)
} else { } else {
for (file in files) { 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<SendFile>) { private fun sendLargeFilesTo(sessionId: Int, files: MutableList<SendFile>) {
if (sendFileTransfers[sessionId] == null && receiveFileTransfers[sessionId] == null) { if (sendFileTransfers[sessionId] == null && receiveFileTransfers[sessionId] == null) {
val filesSender = FilesSender(files, this, notificationManager) val filesSender = FilesSender(files, this, notificationManager)
initFileTransferNotification(sessionId, filesSender.fileTransferNotification, filesSender.files[0]) initFileTransferNotification(sessionId, filesSender.fileTransferNotification, filesSender.files[0])
sendFileTransfers[sessionId] = filesSender sendFileTransfers[sessionId] = filesSender
sendTo(sessionId, Protocol.askLargeFiles(files)) sendOrAddToPending(sessionId, Protocol.askLargeFiles(files))
} else { } else {
Toast.makeText(this, R.string.file_transfer_already_in_progress, Toast.LENGTH_SHORT).show() 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 return contacts[sessionId]?.name ?: savedNames[sessionId] ?: sessions[sessionId]!!.ip
} }
private fun isContact(sessionId: Int): Boolean { fun isContact(sessionId: Int): Boolean {
return contacts.contains(sessionId) return contacts.contains(sessionId)
} }
@ -208,6 +213,7 @@ class AIRAService : Service() {
} }
} }
savedNames.remove(sessionId) savedNames.remove(sessionId)
pendingMsgs[sessionId] = mutableListOf()
savedAvatars.remove(sessionId) savedAvatars.remove(sessionId)
return true return true
} }
@ -245,6 +251,7 @@ class AIRAService : Service() {
contacts.remove(sessionId)?.let { contacts.remove(sessionId)?.let {
return if (AIRADatabase.removeContact(it.uuid)) { return if (AIRADatabase.removeContact(it.uuid)) {
savedMsgs[sessionId] = mutableListOf() savedMsgs[sessionId] = mutableListOf()
pendingMsgs.remove(sessionId)
savedNames[sessionId] = it.name savedNames[sessionId] = it.name
it.avatar?.let { avatarUuid -> it.avatar?.let { avatarUuid ->
savedAvatars[sessionId] = avatarUuid savedAvatars[sessionId] = avatarUuid
@ -328,7 +335,12 @@ class AIRAService : Service() {
val key = session.register(selector, SelectionKey.OP_READ) val key = session.register(selector, SelectionKey.OP_READ)
sessionIdByKey[key] = sessionId sessionIdByKey[key] = sessionId
uiCallbacks?.onNewSession(sessionId, session.ip) 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) session.encryptAndSend(Protocol.askProfileInfo(), usePadding)
} }
} else { } else {
@ -429,10 +441,20 @@ class AIRAService : Service() {
} }
} }
private fun sendAndSave(sessionId: Int, msg: ByteArray) { private fun sendAndSave(sessionId: Int, buffer: ByteArray) {
sessions[sessionId]?.encryptAndSend(msg, usePadding) sessions[sessionId]?.encryptAndSend(buffer, usePadding)
if (msg[0] == Protocol.MESSAGE) { val timestamp = TimeUtils.getTimestamp()
saveMsg(sessionId, TimeUtils.getTimestamp(), msg) 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) contacts = HashMap(contactList.size)
for (contact in contactList) { for (contact in contactList) {
contacts[sessionCounter] = contact contacts[sessionCounter] = contact
pendingMsgs[sessionCounter] = mutableListOf()
if (!contact.seen) { if (!contact.seen) {
notSeen.add(sessionCounter) notSeen.add(sessionCounter)
} }
@ -720,10 +743,10 @@ class AIRAService : Service() {
filesReceiver.fileTransferNotification, filesReceiver.fileTransferNotification,
filesReceiver.files[0], filesReceiver.files[0],
) )
sendTo(sessionId, Protocol.acceptLargeFiles()) session.encryptAndSend(Protocol.acceptLargeFiles(), usePadding)
}, { filesReceiver -> }, { filesReceiver ->
receiveFileTransfers.remove(sessionId) receiveFileTransfers.remove(sessionId)
sendTo(sessionId, Protocol.abortFilesTransfer()) session.encryptAndSend(Protocol.abortFilesTransfer(), usePadding)
filesReceiver.fileTransferNotification.cancel() filesReceiver.fileTransferNotification.cancel()
}, },
this, this,

View File

@ -22,7 +22,7 @@ class NotificationBroadcastReceiver: BroadcastReceiver() {
ACTION_MARK_READ -> airaService.setSeen(sessionId, true) ACTION_MARK_READ -> airaService.setSeen(sessionId, true)
ACTION_CANCEL_FILE_TRANSFER -> airaService.cancelFileTransfer(sessionId) ACTION_CANCEL_FILE_TRANSFER -> airaService.cancelFileTransfer(sessionId)
ACTION_REPLY -> RemoteInput.getResultsFromIntent(intent)?.getString(KEY_TEXT_REPLY)?.let { reply -> 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) airaService.setSeen(sessionId, true)
} }
else -> {} else -> {}

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:bottom="-2dp" android:left="-2dp" android:right="-2dp">
<shape android:shape="rectangle">
<stroke android:width="1dp" android:color="@color/offline_warning"/>
<solid android:color="@color/transparent"/>
<padding android:top="15dp"/>
</shape>
</item>
</layer-list>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:bottom="-2dp" android:left="-2dp" android:right="-2dp">
<shape android:shape="rectangle">
<stroke android:width="1dp" android:color="@color/pending_msg_indicator"/>
<solid android:color="@color/transparent"/>
<padding android:top="5dp"/>
</shape>
</item>
</layer-list>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:bottom="-2dp" android:left="-2dp" android:right="-2dp">
<shape android:shape="rectangle">
<stroke android:width="1dp" android:color="@color/secondary"/>
<solid android:color="@color/transparent"/>
<padding android:top="15dp"/>
</shape>
</item>
</layer-list>

View File

@ -13,14 +13,79 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/toolbar" app:layout_constraintTop_toBottomOf="@id/toolbar"
app:layout_constraintBottom_toTopOf="@id/bottom_panel"/> app:layout_constraintBottom_toTopOf="@id/offline_warning"/>
<LinearLayout
android:id="@+id/sending_pending_msgs_indicator"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/recycler_chat"
app:layout_constraintBottom_toTopOf="@id/bottom_panel"
android:visibility="gone"
android:gravity="center"
android:paddingVertical="10dp"
android:background="@drawable/sending_pending_msg_indictor_background">
<ProgressBar
android:layout_width="50dp"
android:layout_height="50dp"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/sending_pending_messages"
android:layout_gravity="center"
android:layout_marginStart="20dp"/>
</LinearLayout>
<LinearLayout
android:id="@+id/offline_warning"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/recycler_chat"
app:layout_constraintBottom_toTopOf="@id/bottom_panel"
android:visibility="gone"
android:paddingHorizontal="30dp"
android:layout_marginBottom="15dp"
android:background="@drawable/offline_warning_background">
<ImageView
android:layout_width="50dp"
android:layout_height="50dp"
android:src="@drawable/ic_warning"
app:tint="@color/offline_warning"
android:contentDescription="@string/warning_desc"
android:layout_gravity="center_vertical"/>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginStart="20dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="16sp"
android:textStyle="bold"
android:text="@string/offline_warning_title"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/offline_warning_msg"
android:textColor="#80ffffff"/>
</LinearLayout>
</LinearLayout>
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/bottom_panel" android:id="@+id/bottom_panel"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
android:visibility="gone"
android:paddingVertical="5dp" android:paddingVertical="5dp"
android:background="@color/primary"> android:background="@color/primary">

View File

@ -9,9 +9,19 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center_horizontal" android:layout_gravity="center_horizontal"
android:visibility="gone"
android:paddingVertical="5dp"/> android:paddingVertical="5dp"/>
<TextView
android:id="@+id/text_pending_msg"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="20dp"
android:layout_marginVertical="10dp"
android:textAlignment="center"
android:text="@string/pending_messages"
android:textColor="@color/pending_msg_indicator"
android:background="@drawable/pending_msg_indicator_background"/>
<LinearLayout <LinearLayout
android:id="@+id/bubble" android:id="@+id/bubble"
android:layout_width="wrap_content" android:layout_width="wrap_content"

View File

@ -15,4 +15,6 @@
<color name="outgoingTimestamp">#777777</color> <color name="outgoingTimestamp">#777777</color>
<color name="incomingTimestamp">#d7d7d7</color> <color name="incomingTimestamp">#d7d7d7</color>
<color name="incomingHighlight">@color/outgoingTextLink</color> <color name="incomingHighlight">@color/outgoingTextLink</color>
<color name="offline_warning">#ff0000</color>
<color name="pending_msg_indicator">#909090</color>
</resources> </resources>

View File

@ -76,7 +76,6 @@
<string name="file_transfer_already_in_progress">Another file transfer is already in progress</string> <string name="file_transfer_already_in_progress">Another file transfer is already in progress</string>
<string name="settings">Settings</string> <string name="settings">Settings</string>
<string name="log_out">Log out</string> <string name="log_out">Log out</string>
<string name="loadFile_failed">File extraction failed</string>
<string name="copied">Copied to clipboard !</string> <string name="copied">Copied to clipboard !</string>
<string name="identity">Identity</string> <string name="identity">Identity</string>
<string name="about">About</string> <string name="about">About</string>
@ -109,4 +108,9 @@
<string name="clickable_indicator">Clickable indicator</string> <string name="clickable_indicator">Clickable indicator</string>
<string name="avatar">Avatar</string> <string name="avatar">Avatar</string>
<string name="name">Name</string> <string name="name">Name</string>
<string name="offline_warning_title">Your contact seems to be offline.</string>
<string name="offline_warning_msg">Sent messages will be stored until a connection is established.</string>
<string name="warning_desc">Warning icon</string>
<string name="pending_messages">Pending messages:</string>
<string name="sending_pending_messages">Sending pending messages…</string>
</resources> </resources>

View File

@ -1,11 +1,11 @@
buildscript { buildscript {
ext.kotlin_version = "1.5.10" ext.kotlin_version = "1.5.21"
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
} }
dependencies { 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" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
} }
} }

View File

@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists 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