Multiple Files Transfers

This commit is contained in:
Matéo Duparc 2021-05-06 22:57:47 +02:00
parent 458be75114
commit 51ebfb22db
Signed by: hardcoresushi
GPG Key ID: 007F84120107191E
18 changed files with 612 additions and 433 deletions

View File

@ -32,6 +32,7 @@
<activity android:name=".MainActivity">
<intent-filter android:label="@string/share_label">
<action android:name="android.intent.action.SEND"/>
<action android:name="android.intent.action.SEND_MULTIPLE"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="*/*"/>
</intent-filter>

View File

@ -1,64 +1,44 @@
package sushi.hardcore.aira
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Bundle
import android.os.IBinder
import android.provider.OpenableColumns
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.widget.ConstraintLayout
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.background_service.AIRAService
import sushi.hardcore.aira.background_service.Protocol
import sushi.hardcore.aira.background_service.ReceiveFileTransfer
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 java.io.FileNotFoundException
class ChatActivity : AppCompatActivity() {
class ChatActivity : ServiceBoundActivity() {
private external fun generateFingerprint(publicKey: ByteArray): String
private lateinit var binding: ActivityChatBinding
private var sessionId = -1
private lateinit var sessionName: String
private lateinit var airaService: AIRAService
private lateinit var chatAdapter: ChatAdapter
private var lastLoadedMessageOffset = 0
private var isActivityInForeground = false
private val filePicker = registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (::airaService.isInitialized && uri != null) {
contentResolver.query(uri, null, null, null, null)?.let { cursor ->
if (cursor.moveToFirst()) {
try {
contentResolver.openInputStream(uri)?.let { inputStream ->
val fileName = cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME))
val fileSize = cursor.getLong(cursor.getColumnIndex(OpenableColumns.SIZE))
airaService.sendFileTo(sessionId, fileName, fileSize, inputStream)?.let { msg ->
private val filePicker = registerForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris ->
if (isServiceInitialized() && uris.size > 0) {
airaService.sendFilesFromUris(sessionId, uris)?.let { msgs ->
for (msg in msgs) {
chatAdapter.newMessage(ChatItem(true, msg))
}
if (airaService.contacts.contains(sessionId)) {
lastLoadedMessageOffset += 1
}
}
} catch (e: FileNotFoundException) {
Toast.makeText(this, e.localizedMessage, Toast.LENGTH_SHORT).show()
}
}
cursor.close()
}
}
}
@ -83,7 +63,7 @@ class ChatActivity : AppCompatActivity() {
}
addOnScrollListener(object : RecyclerView.OnScrollListener() {
fun loadMsgsIfNeeded(recyclerView: RecyclerView) {
if (!recyclerView.canScrollVertically(-1) && ::airaService.isInitialized) {
if (!recyclerView.canScrollVertically(-1) && isServiceInitialized()) {
airaService.contacts[sessionId]?.let { contact ->
loadMsgs(contact.uuid)
}
@ -97,29 +77,6 @@ class ChatActivity : AppCompatActivity() {
}
})
}
Intent(this, AIRAService::class.java).also { serviceIntent ->
bindService(serviceIntent, object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder) {
val binder = service as AIRAService.AIRABinder
airaService = binder.getService()
airaService.contacts[sessionId]?.let { contact ->
displayIconTrustLevel(true, contact.verified)
loadMsgs(contact.uuid)
}
airaService.receiveFileTransfers[sessionId]?.let {
if (it.shouldAsk) {
it.ask(this@ChatActivity, sessionName)
}
}
airaService.savedMsgs[sessionId]?.let {
for (chatItem in it.asReversed()) {
chatAdapter.newLoadedMessage(chatItem)
}
}
binding.recyclerChat.smoothScrollToPosition(chatAdapter.itemCount)
val onConnected = {
findViewById<ConstraintLayout>(R.id.bottom_panel).visibility = View.VISIBLE
binding.buttonSend.setOnClickListener {
val msg = binding.editMessage.text.toString()
airaService.sendTo(sessionId, Protocol.newMessage(msg))
@ -133,12 +90,35 @@ class ChatActivity : AppCompatActivity() {
binding.buttonAttach.setOnClickListener {
filePicker.launch("*/*")
}
serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder) {
val binder = service as AIRAService.AIRABinder
airaService = binder.getService()
chatAdapter.clear()
airaService.contacts[sessionId]?.let { contact ->
displayIconTrustLevel(true, contact.verified)
loadMsgs(contact.uuid)
}
airaService.savedMsgs[sessionId]?.let {
for (chatItem in it.asReversed()) {
chatAdapter.newLoadedMessage(chatItem)
}
}
airaService.receiveFileTransfers[sessionId]?.let {
if (it.shouldAsk) {
it.ask(this@ChatActivity, sessionName)
}
}
binding.recyclerChat.smoothScrollToPosition(chatAdapter.itemCount)
val showBottomPanel = {
findViewById<ConstraintLayout>(R.id.bottom_panel).visibility = View.VISIBLE
}
airaService.uiCallbacks = object : AIRAService.UiCallbacks {
override fun onNewSession(sessionId: Int, ip: String) {
if (this@ChatActivity.sessionId == sessionId) {
runOnUiThread {
onConnected()
showBottomPanel()
}
}
}
@ -168,16 +148,16 @@ class ChatActivity : AppCompatActivity() {
if (airaService.contacts.contains(sessionId)) {
lastLoadedMessageOffset += 1
}
isActivityInForeground
airaService.isAppInBackground
} else {
false
}
}
override fun onAskLargeFile(sessionId: Int, name: String, receiveFileTransfer: ReceiveFileTransfer): Boolean {
override fun onAskLargeFiles(sessionId: Int, name: String, filesReceiver: FilesReceiver): Boolean {
return if (this@ChatActivity.sessionId == sessionId) {
runOnUiThread {
receiveFileTransfer.ask(this@ChatActivity, name)
filesReceiver.ask(this@ChatActivity, name)
}
true
} else {
@ -187,19 +167,18 @@ class ChatActivity : AppCompatActivity() {
}
airaService.isAppInBackground = false
if (airaService.isOnline(sessionId)) {
onConnected()
showBottomPanel()
binding.recyclerChat.updatePadding(bottom = 0)
}
airaService.setSeen(sessionId, true)
}
override fun onServiceDisconnected(name: ComponentName?) {}
}, Context.BIND_AUTO_CREATE)
}
}
}
}
fun displayIconTrustLevel(isContact: Boolean, isVerified: Boolean) {
private fun displayIconTrustLevel(isContact: Boolean, isVerified: Boolean) {
when {
isVerified -> {
binding.imageTrustLevel.setImageResource(R.drawable.ic_verified)
@ -213,7 +192,7 @@ class ChatActivity : AppCompatActivity() {
}
}
fun loadMsgs(contactUuid: String) {
private fun loadMsgs(contactUuid: String) {
AIRADatabase.loadMsgs(contactUuid, lastLoadedMessageOffset, Constants.MSG_LOADING_COUNT)?.let {
for (chatItem in it.asReversed()) {
chatAdapter.newLoadedMessage(chatItem)
@ -328,20 +307,10 @@ class ChatActivity : AppCompatActivity() {
}
}
override fun onResume() {
super.onResume()
isActivityInForeground = true
if (::airaService.isInitialized) {
override fun onStart() {
super.onStart()
if (isServiceInitialized()) {
airaService.setSeen(sessionId, true)
airaService.isAppInBackground = false
}
}
override fun onPause() {
super.onPause()
isActivityInForeground = false
if (::airaService.isInitialized) {
airaService.isAppInBackground = true
}
}

View File

@ -1,13 +1,11 @@
package sushi.hardcore.aira
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.net.Uri
import android.os.Bundle
import android.os.IBinder
import android.provider.OpenableColumns
import android.view.Menu
import android.view.MenuItem
import android.view.View
@ -15,11 +13,10 @@ import android.widget.AbsListView
import android.widget.AdapterView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import sushi.hardcore.aira.adapters.Session
import sushi.hardcore.aira.adapters.SessionAdapter
import sushi.hardcore.aira.background_service.AIRAService
import sushi.hardcore.aira.background_service.ReceiveFileTransfer
import sushi.hardcore.aira.background_service.FilesReceiver
import sushi.hardcore.aira.databinding.ActivityMainBinding
import sushi.hardcore.aira.databinding.DialogIpAddressesBinding
import sushi.hardcore.aira.utils.FileUtils
@ -27,9 +24,8 @@ import sushi.hardcore.aira.utils.StringUtils
import java.lang.StringBuilder
import java.net.NetworkInterface
class MainActivity : AppCompatActivity() {
class MainActivity : ServiceBoundActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var airaService: AIRAService
private lateinit var onlineSessionAdapter: SessionAdapter
private var offlineSessionAdapter: SessionAdapter? = null
private val onSessionsItemClickSendFile = AdapterView.OnItemClickListener { adapter, _, position, _ ->
@ -70,9 +66,9 @@ class MainActivity : AppCompatActivity() {
return false
}
override fun onAskLargeFile(sessionId: Int, name: String, receiveFileTransfer: ReceiveFileTransfer): Boolean {
override fun onAskLargeFiles(sessionId: Int, name: String, filesReceiver: FilesReceiver): Boolean {
runOnUiThread {
receiveFileTransfer.ask(this@MainActivity, name)
filesReceiver.ask(this@MainActivity, name)
}
return true
}
@ -86,7 +82,7 @@ class MainActivity : AppCompatActivity() {
val identityName = intent.getStringExtra("identityName")
identityName?.let { title = it }
val openedToShareFile = intent.action == Intent.ACTION_SEND
val openedToShareFile = intent.action == Intent.ACTION_SEND || intent.action == Intent.ACTION_SEND_MULTIPLE
onlineSessionAdapter = SessionAdapter(this)
binding.onlineSessions.apply {
@ -133,30 +129,29 @@ class MainActivity : AppCompatActivity() {
setOnScrollListener(onSessionsScrollListener)
}
}
Intent(this, AIRAService::class.java).also { serviceIntent ->
bindService(serviceIntent, object : ServiceConnection {
serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder) {
val binder = service as AIRAService.AIRABinder
airaService = binder.getService()
airaService.uiCallbacks = uiCallbacks
airaService.isAppInBackground = false
loadContacts()
refreshSessions()
if (AIRAService.isServiceRunning) {
title = airaService.identityName
loadSessions()
} else {
airaService.identityName = identityName
startService(serviceIntent)
}
binding.refresher.setOnRefreshListener {
airaService.restartDiscovery()
binding.refresher.isRefreshing = false
}
}
override fun onServiceDisconnected(name: ComponentName?) {}
}, Context.BIND_AUTO_CREATE)
}
binding.refresher.setOnRefreshListener {
if (isServiceInitialized()) {
airaService.restartDiscovery()
}
binding.refresher.isRefreshing = false
}
binding.buttonShowIp.setOnClickListener {
val ipAddresses = StringBuilder()
for (iface in NetworkInterface.getNetworkInterfaces()) {
@ -175,7 +170,7 @@ class MainActivity : AppCompatActivity() {
.show()
}
binding.editPeerIp.setOnEditorActionListener { _, _, _ ->
if (::airaService.isInitialized){
if (isServiceInitialized()){
airaService.connectTo(binding.editPeerIp.text.toString())
}
binding.editPeerIp.text.clear()
@ -196,7 +191,7 @@ class MainActivity : AppCompatActivity() {
true
}
R.id.close -> {
if (::airaService.isInitialized) {
if (isServiceInitialized()) {
AlertDialog.Builder(this)
.setTitle(R.string.warning)
.setMessage(R.string.ask_log_out)
@ -240,27 +235,13 @@ class MainActivity : AppCompatActivity() {
}
}
override fun onPause() {
super.onPause()
if (::airaService.isInitialized) {
override fun onStop() {
super.onStop()
if (isServiceInitialized()) {
airaService.isAppInBackground = true
}
}
override fun onResume() {
super.onResume()
if (::airaService.isInitialized) {
if (AIRAService.isServiceRunning) {
airaService.isAppInBackground = false
airaService.uiCallbacks = uiCallbacks //restoring callbacks
refreshSessions()
title = airaService.identityName
} else {
finish()
}
}
}
override fun onBackPressed() {
if (isSelecting()) {
unSelectAll()
@ -328,34 +309,37 @@ class MainActivity : AppCompatActivity() {
}
private fun askShareFileTo(session: Session) {
val uri = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
if (uri == null) {
Toast.makeText(this, R.string.share_uri_null, Toast.LENGTH_SHORT).show()
var uris: ArrayList<Uri>? = null
when (intent.action) {
Intent.ACTION_SEND -> intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)?.let {
uris = arrayListOf(it)
}
Intent.ACTION_SEND_MULTIPLE -> uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
}
if (uris == null) {
Toast.makeText(this, R.string.open_uri_failed, Toast.LENGTH_SHORT).show()
} else {
val cursor = contentResolver.query(uri, null, null, null, null)
if (cursor == null) {
Toast.makeText(this, R.string.file_open_failed, Toast.LENGTH_SHORT).show()
val msg = if (uris!!.size == 1) {
val sendFile = FileUtils.openFileFromUri(this, uris!![0])
if (sendFile == null) {
Toast.makeText(this, R.string.open_uri_failed, Toast.LENGTH_SHORT).show()
return
} else {
if (cursor.moveToFirst()) {
val fileName = cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME))
val fileSize = cursor.getLong(cursor.getColumnIndex(OpenableColumns.SIZE))
val inputStream = contentResolver.openInputStream(uri)
if (inputStream == null) {
Toast.makeText(this, R.string.file_open_failed, Toast.LENGTH_SHORT).show()
sendFile.inputStream.close()
getString(R.string.ask_send_single_file, sendFile.fileName, FileUtils.formatSize(sendFile.fileSize), session.name ?: session.ip)
}
} else {
getString(R.string.ask_send_multiple_files, uris!!.size, session.name ?: session.ip)
}
AlertDialog.Builder(this)
.setTitle(R.string.warning)
.setMessage(getString(R.string.ask_send_file, fileName, FileUtils.formatSize(fileSize), session.name ?: session.ip))
.setMessage(msg)
.setPositiveButton(R.string.yes) { _, _ ->
airaService.sendFileTo(session.sessionId, fileName, fileSize, inputStream)
airaService.sendFilesFromUris(session.sessionId, uris!!)
finish()
}
.setNegativeButton(R.string.cancel, null)
.show()
}
}
cursor.close()
}
}
}
}

View File

@ -0,0 +1,32 @@
package sushi.hardcore.aira
import android.content.Intent
import android.content.ServiceConnection
import androidx.appcompat.app.AppCompatActivity
import sushi.hardcore.aira.background_service.AIRAService
open class ServiceBoundActivity: AppCompatActivity() {
protected lateinit var airaService: AIRAService
protected lateinit var serviceConnection: ServiceConnection
protected lateinit var serviceIntent: Intent
protected fun isServiceInitialized(): Boolean {
return ::airaService.isInitialized
}
override fun onStop() {
super.onStop()
if (::airaService.isInitialized) {
airaService.isAppInBackground = true
airaService.uiCallbacks = null
unbindService(serviceConnection)
}
}
override fun onStart() {
super.onStart()
if (!::serviceIntent.isInitialized) {
serviceIntent = Intent(this, AIRAService::class.java)
}
}
}

View File

@ -3,6 +3,7 @@ package sushi.hardcore.aira.background_service
import android.app.*
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.*
@ -14,9 +15,9 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.RemoteInput
import sushi.hardcore.aira.*
import sushi.hardcore.aira.utils.FileUtils
import sushi.hardcore.aira.utils.StringUtils
import java.io.IOException
import java.io.InputStream
import java.net.*
import java.nio.channels.*
@ -44,8 +45,8 @@ class AIRAService : Service() {
private lateinit var selector: Selector
private val sessionIdByKey = mutableMapOf<SelectionKey, Int>()
private val notificationIdManager = NotificationIdManager()
private val sendFileTransfers = mutableMapOf<Int, SendFileTransfer>()
val receiveFileTransfers = mutableMapOf<Int, ReceiveFileTransfer>()
private val sendFileTransfers = mutableMapOf<Int, FilesSender>()
val receiveFileTransfers = mutableMapOf<Int, FilesReceiver>()
lateinit var contacts: HashMap<Int, Contact>
private lateinit var serviceHandler: Handler
private lateinit var notificationManager: NotificationManagerCompat
@ -103,7 +104,7 @@ class AIRAService : Service() {
fun onSessionDisconnect(sessionId: Int)
fun onNameTold(sessionId: Int, name: String)
fun onNewMessage(sessionId: Int, data: ByteArray): Boolean
fun onAskLargeFile(sessionId: Int, name: String, receiveFileTransfer: ReceiveFileTransfer): Boolean
fun onAskLargeFiles(sessionId: Int, name: String, filesReceiver: FilesReceiver): Boolean
}
fun connectTo(ip: String) {
@ -133,28 +134,53 @@ class AIRAService : Service() {
}
}
fun sendFileTo(sessionId: Int, fileName: String, fileSize: Long, inputStream: InputStream): ByteArray? {
if (fileSize <= Constants.fileSizeLimit) {
val buffer = inputStream.readBytes()
inputStream.close()
sendTo(sessionId, Protocol.newFile(fileName, buffer))
fun sendFilesFromUris(sessionId: Int, uris: List<Uri>): List<ByteArray>? {
val files = mutableListOf<SendFile>()
var useLargeFileTransfer = false
for (uri in uris) {
FileUtils.openFileFromUri(this, uri)?.let { sendFile ->
files.add(sendFile)
if (sendFile.fileSize > Constants.fileSizeLimit) {
useLargeFileTransfer = true
}
}
}
return if (useLargeFileTransfer) {
sendLargeFilesTo(sessionId, files)
null
} else {
val msgs = mutableListOf<ByteArray>()
for (file in files) {
sendSmallFileTo(sessionId, file)?.let { msg ->
msgs.add(msg)
}
}
msgs
}
}
private fun sendSmallFileTo(sessionId: Int, sendFile: SendFile): ByteArray? {
val buffer = sendFile.inputStream.readBytes()
sendFile.inputStream.close()
sendTo(sessionId, Protocol.newFile(sendFile.fileName, buffer))
AIRADatabase.storeFile(contacts[sessionId]?.uuid, buffer)?.let { rawFileUuid ->
val msg = byteArrayOf(Protocol.FILE)+rawFileUuid+fileName.toByteArray()
val msg = byteArrayOf(Protocol.FILE) + rawFileUuid + sendFile.fileName.toByteArray()
saveMsg(sessionId, msg)
return msg
}
} else {
return null
}
private fun sendLargeFilesTo(sessionId: Int, files: MutableList<SendFile>) {
if (sendFileTransfers[sessionId] == null && receiveFileTransfers[sessionId] == null) {
val fileTransfer = SendFileTransfer(fileName, fileSize, inputStream)
sendFileTransfers[sessionId] = fileTransfer
createFileTransferNotification(sessionId, fileTransfer)
sendTo(sessionId, Protocol.askLargeFile(fileSize, fileName))
val filesSender = FilesSender(files, this, notificationManager, getNameOf(sessionId))
initFileTransferNotification(sessionId, filesSender.fileTransferNotification, filesSender.files[0])
sendFileTransfers[sessionId] = filesSender
sendTo(sessionId, Protocol.askLargeFiles(files))
} else {
Toast.makeText(this, R.string.file_transfer_already_in_progress, Toast.LENGTH_SHORT).show()
}
}
return null
}
fun logOut() {
serviceHandler.sendEmptyMessage(MESSAGE_LOGOUT)
@ -358,33 +384,19 @@ class AIRAService : Service() {
notificationManager.notify(notificationIdManager.getMessageNotificationId(sessionId), notificationBuilder.build())
}
private fun createFileTransferNotification(sessionId: Int, fileTransfer: FileTransfer) {
fileTransfer.notificationBuilder = NotificationCompat.Builder(this, FILE_TRANSFER_NOTIFICATION_CHANNEL_ID)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.setSmallIcon(R.drawable.ic_launcher)
.setContentTitle(getNameOf(sessionId))
.setContentText(fileTransfer.fileName)
.setOngoing(true)
.setProgress(fileTransfer.fileSize.toInt(), fileTransfer.transferred, true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH) {
val cancelIntent = PendingIntent.getBroadcast(this, 0,
private fun initFileTransferNotification(sessionId: Int, fileTransferNotification: FileTransferNotification, file: PendingFile) {
fileTransferNotification.initFileTransferNotification(
notificationIdManager.getFileTransferNotificationId(sessionId),
file.fileName,
file.fileSize.toInt(),
Intent(this, NotificationBroadcastReceiver::class.java).apply {
val bundle = Bundle()
bundle.putBinder("binder", AIRABinder())
bundle.putInt("sessionId", sessionId)
putExtra("bundle", bundle)
action = NotificationBroadcastReceiver.ACTION_CANCEL_FILE_TRANSFER
}, PendingIntent.FLAG_UPDATE_CURRENT)
fileTransfer.notificationBuilder.addAction(
NotificationCompat.Action(
R.drawable.ic_launcher,
getString(R.string.cancel),
cancelIntent
)
)
}
fileTransfer.notificationId = notificationIdManager.getFileTransferNotificationId(sessionId)
notificationManager.notify(fileTransfer.notificationId, fileTransfer.notificationBuilder.build())
)
}
private fun saveMsg(sessionId: Int, msg: ByteArray) {
@ -485,41 +497,47 @@ class AIRAService : Service() {
}
}
private fun encryptNextChunk(session: Session, fileTransfer: SendFileTransfer) {
private fun encryptNextChunk(session: Session, filesSender: FilesSender) {
val nextChunk = ByteArray(Constants.FILE_CHUNK_SIZE)
nextChunk[0] = Protocol.LARGE_FILE_CHUNK
val read = fileTransfer.inputStream.read(nextChunk, 1, Constants.FILE_CHUNK_SIZE-1)
fileTransfer.nextChunk = if (read > 0) {
val read = try {
filesSender.files[filesSender.index].inputStream.read(nextChunk, 1, Constants.FILE_CHUNK_SIZE-1)
} catch (e: IOException) {
0
}
filesSender.nextChunk = if (read > 0) {
filesSender.lastChunkSizes.add(nextChunk.size)
session.encrypt(nextChunk)
} else {
null
}
}
private fun flushSendFileTransfer(sessionId: Int, session: Session, fileTransfer: SendFileTransfer) {
fileTransfer.nextChunk?.let {
private fun flushSendFileTransfer(sessionId: Int, session: Session, filesSender: FilesSender) {
synchronized(filesSender) { //prevent sending nextChunk two times when canceling
filesSender.nextChunk?.let {
session.writeAll(it)
while (fileTransfer.msgQueue.size > 0) {
val msg = fileTransfer.msgQueue.removeAt(0)
filesSender.nextChunk = null
while (filesSender.msgQueue.size > 0) {
val msg = filesSender.msgQueue.removeAt(0)
sendAndSave(sessionId, msg)
}
}
}
}
private fun cancelFileTransfer(sessionId: Int, session: Session, outgoing: Boolean) {
sendFileTransfers[sessionId]?.let {
it.inputStream.close()
it.onAborted(this, notificationManager, getNameOf(sessionId))
it.files[it.index].inputStream.close()
flushSendFileTransfer(sessionId, session, it)
sendFileTransfers.remove(sessionId)
sendFileTransfers.remove(sessionId)!!.fileTransferNotification.onAborted()
}
receiveFileTransfers[sessionId]?.let {
it.outputStream?.close()
it.onAborted(this, notificationManager, getNameOf(sessionId))
receiveFileTransfers.remove(sessionId)
it.files[it.index].outputStream?.close()
receiveFileTransfers.remove(sessionId)!!.fileTransferNotification.onAborted()
}
if (outgoing) {
session.encryptAndSend(Protocol.abortFileTransfer())
session.encryptAndSend(Protocol.abortFilesTransfer())
}
}
@ -568,19 +586,38 @@ class AIRAService : Service() {
}
}
Protocol.LARGE_FILE_CHUNK -> {
receiveFileTransfers[sessionId]?.let { fileTransfer ->
fileTransfer.outputStream?.let { outputStream ->
receiveFileTransfers[sessionId]?.let { filesReceiver ->
val file = filesReceiver.files[filesReceiver.index]
if (file.outputStream == null) {
val outputStream = FileUtils.openFileForDownload(this, file.fileName)
if (outputStream == null) {
cancelFileTransfer(sessionId)
} else {
file.outputStream = outputStream
}
}
file.outputStream?.let { outputStream ->
val chunk = buffer.sliceArray(1 until buffer.size)
try {
outputStream.write(chunk)
session.encryptAndSend(Protocol.ackChunk())
fileTransfer.transferred += chunk.size
if (fileTransfer.transferred >= fileTransfer.fileSize) {
file.transferred += chunk.size
if (file.transferred >= file.fileSize) {
outputStream.close()
if (filesReceiver.index == filesReceiver.files.size-1) {
receiveFileTransfers.remove(sessionId)
fileTransfer.onCompleted(this, notificationManager, getNameOf(sessionId))
filesReceiver.fileTransferNotification.onCompleted()
} else {
fileTransfer.updateNotificationProgress(notificationManager)
filesReceiver.index += 1
val nextFile = filesReceiver.files[filesReceiver.index]
initFileTransferNotification(
sessionId,
filesReceiver.fileTransferNotification,
nextFile
)
}
} else {
filesReceiver.fileTransferNotification.updateNotificationProgress(chunk.size)
}
} catch (e: IOException) {
cancelFileTransfer(sessionId)
@ -589,46 +626,72 @@ class AIRAService : Service() {
}
}
Protocol.ACK_CHUNK -> {
sendFileTransfers[sessionId]?.let { fileTransfer ->
flushSendFileTransfer(sessionId, session, fileTransfer)
fileTransfer.transferred += fileTransfer.nextChunk?.size ?: 0
if (fileTransfer.transferred >= fileTransfer.fileSize) {
fileTransfer.inputStream.close()
sendFileTransfers[sessionId]?.let { filesSender ->
flushSendFileTransfer(sessionId, session, filesSender)
val file = filesSender.files[filesSender.index]
val chunkSize = filesSender.lastChunkSizes.removeAt(0)
file.transferred += chunkSize
if (file.transferred >= file.fileSize) {
file.inputStream.close()
if (filesSender.index == filesSender.files.size-1) {
sendFileTransfers.remove(sessionId)
fileTransfer.onCompleted(this, notificationManager, getNameOf(sessionId))
filesSender.fileTransferNotification.onCompleted()
} else {
encryptNextChunk(session, fileTransfer)
fileTransfer.updateNotificationProgress(notificationManager)
}
}
}
Protocol.ABORT_FILE_TRANSFER -> cancelFileTransfer(sessionId, session, false)
Protocol.ACCEPT_LARGE_FILE -> {
sendFileTransfers[sessionId]?.let { fileTransfer ->
encryptNextChunk(session, fileTransfer)
fileTransfer.nextChunk?.let {
filesSender.index += 1
val nextFile = filesSender.files[filesSender.index]
initFileTransferNotification(
sessionId,
filesSender.fileTransferNotification,
nextFile
)
encryptNextChunk(session, filesSender)
filesSender.nextChunk?.let {
session.writeAll(it)
fileTransfer.transferred += fileTransfer.nextChunk?.size ?: 0
encryptNextChunk(session, fileTransfer)
encryptNextChunk(session, filesSender)
}
}
} else {
encryptNextChunk(session, filesSender)
filesSender.fileTransferNotification.updateNotificationProgress(chunkSize)
}
}
}
Protocol.ASK_LARGE_FILE -> {
if (receiveFileTransfers[sessionId] == null) {
Protocol.parseAskFile(buffer)?.let { fileInfo ->
val fileTransfer = ReceiveFileTransfer(fileInfo.fileName, fileInfo.fileSize, { fileTransfer ->
createFileTransferNotification(sessionId, fileTransfer)
sendTo(sessionId, Protocol.acceptLargeFile())
}, {
receiveFileTransfers.remove(sessionId)
sendTo(sessionId, Protocol.abortFileTransfer())
notificationManager.cancel(notificationIdManager.getFileTransferNotificationId(sessionId))
})
receiveFileTransfers[sessionId] = fileTransfer
Protocol.ABORT_FILES_TRANSFER -> cancelFileTransfer(sessionId, session, false)
Protocol.ACCEPT_LARGE_FILES -> {
sendFileTransfers[sessionId]?.let { filesSender ->
encryptNextChunk(session, filesSender)
filesSender.nextChunk?.let {
session.writeAll(it)
encryptNextChunk(session, filesSender)
}
}
}
Protocol.ASK_LARGE_FILES -> {
if (!receiveFileTransfers.containsKey(sessionId) && !sendFileTransfers.containsKey(sessionId)) {
Protocol.parseAskFiles(buffer)?.let { files ->
val name = getNameOf(sessionId)
val filesReceiver = FilesReceiver(
files,
{ filesReceiver ->
initFileTransferNotification(
sessionId,
filesReceiver.fileTransferNotification,
filesReceiver.files[0]
)
sendTo(sessionId, Protocol.acceptLargeFiles())
}, { filesReceiver ->
receiveFileTransfers.remove(sessionId)
sendTo(sessionId, Protocol.abortFilesTransfer())
filesReceiver.fileTransferNotification.cancel()
},
this,
notificationManager,
name
)
receiveFileTransfers[sessionId] = filesReceiver
var shouldSendNotification = true
if (!isAppInBackground) {
if (uiCallbacks?.onAskLargeFile(sessionId, name, fileTransfer) == true) {
if (uiCallbacks?.onAskLargeFiles(sessionId, name, filesReceiver) == true) {
shouldSendNotification = false
}
}
@ -637,7 +700,7 @@ class AIRAService : Service() {
.setCategory(NotificationCompat.CATEGORY_EVENT)
.setSmallIcon(R.drawable.ic_launcher)
.setContentTitle(getString(R.string.download_file_request))
.setContentText(getString(R.string.want_to_send_a_file, name, ": "+fileInfo.fileName))
.setContentText(getString(R.string.want_to_send_files, name))
.setOngoing(true) //not cancelable
.setContentIntent(
PendingIntent.getActivity(this, 0, Intent(this, ChatActivity::class.java).apply {
@ -705,16 +768,8 @@ class AIRAService : Service() {
sessions.remove(sessionId)
savedMsgs.remove(sessionId)
savedNames.remove(sessionId)
sendFileTransfers.remove(sessionId)?.notificationId?.let {
if (it != -1) {
notificationManager.cancel(it)
}
}
receiveFileTransfers.remove(sessionId)?.notificationId?.let {
if (it != -1) {
notificationManager.cancel(it)
}
}
sendFileTransfers.remove(sessionId)?.fileTransferNotification?.cancel()
receiveFileTransfers.remove(sessionId)?.fileTransferNotification?.cancel()
}
}
}

View File

@ -1,39 +0,0 @@
package sushi.hardcore.aira.background_service
import android.content.Context
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import sushi.hardcore.aira.R
open class FileTransfer(val fileName: String, val fileSize: Long) {
var transferred = 0
lateinit var notificationBuilder: NotificationCompat.Builder
var notificationId = -1
fun updateNotificationProgress(notificationManager: NotificationManagerCompat) {
notificationBuilder.setProgress(fileSize.toInt(), transferred, false)
notificationManager.notify(notificationId, notificationBuilder.build())
}
private fun endNotification(context: Context, notificationManager: NotificationManagerCompat, sessionName: String, string: Int) {
notificationManager.notify(
notificationId,
NotificationCompat.Builder(context, AIRAService.FILE_TRANSFER_NOTIFICATION_CHANNEL_ID)
.setCategory(NotificationCompat.CATEGORY_EVENT)
.setSmallIcon(R.drawable.ic_launcher)
.setContentTitle(sessionName)
.setContentText(context.getString(string))
.build()
)
}
fun onAborted(context: Context, notificationManager: NotificationManagerCompat, sessionName: String) {
if (::notificationBuilder.isInitialized) {
endNotification(context, notificationManager, sessionName, R.string.transfer_aborted)
}
}
fun onCompleted(context: Context, notificationManager: NotificationManagerCompat, sessionName: String) {
endNotification(context, notificationManager, sessionName, R.string.transfer_completed)
}
}

View File

@ -0,0 +1,88 @@
package sushi.hardcore.aira.background_service
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import sushi.hardcore.aira.R
class FileTransferNotification(
private val context: Context,
private val notificationManager: NotificationManagerCompat,
private val sessionName: String
) {
private var fileSize = -1
private var transferred = 0
private lateinit var notificationBuilder: NotificationCompat.Builder
private var notificationId = -1
private var isEnded = false
fun initFileTransferNotification(id: Int, fileName: String, size: Int, cancelIntent: Intent) {
fileSize = size
transferred = 0
notificationBuilder = NotificationCompat.Builder(context, AIRAService.FILE_TRANSFER_NOTIFICATION_CHANNEL_ID)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.setSmallIcon(R.drawable.ic_launcher)
.setContentTitle(sessionName)
.setContentText(fileName)
.setOngoing(true)
.setProgress(fileSize, 0, true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH) {
val cancelPendingIntent = PendingIntent.getBroadcast(context, transferred, cancelIntent, PendingIntent.FLAG_UPDATE_CURRENT)
notificationBuilder.addAction(
NotificationCompat.Action(
R.drawable.ic_launcher,
context.getString(R.string.cancel),
cancelPendingIntent
)
)
}
synchronized(this) {
if (!isEnded) {
notificationId = id
notificationManager.notify(notificationId, notificationBuilder.build())
}
}
}
fun updateNotificationProgress(size: Int) {
transferred += size
notificationBuilder.setProgress(fileSize, transferred, false)
synchronized(this) {
if (!isEnded) {
notificationManager.notify(notificationId, notificationBuilder.build())
}
}
}
private fun endNotification(string: Int) {
synchronized(this) {
notificationManager.notify(
notificationId,
NotificationCompat.Builder(context, AIRAService.FILE_TRANSFER_NOTIFICATION_CHANNEL_ID)
.setCategory(NotificationCompat.CATEGORY_EVENT)
.setSmallIcon(R.drawable.ic_launcher)
.setContentTitle(sessionName)
.setContentText(context.getString(string))
.build()
)
isEnded = true
}
}
fun onAborted() {
if (::notificationBuilder.isInitialized) {
endNotification(R.string.transfer_aborted)
}
}
fun onCompleted() {
endNotification(R.string.transfer_completed)
}
fun cancel() {
notificationManager.cancel(notificationId)
}
}

View File

@ -0,0 +1,46 @@
package sushi.hardcore.aira.background_service
import android.annotation.SuppressLint
import android.app.AlertDialog
import android.content.Context
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.NotificationManagerCompat
import sushi.hardcore.aira.R
import sushi.hardcore.aira.databinding.DialogAskFileBinding
import sushi.hardcore.aira.utils.FileUtils
class FilesReceiver(
val files: List<ReceiveFile>,
private val onAccepted: (FilesReceiver) -> Unit,
private val onAborted: (FilesReceiver) -> Unit,
context: Context,
notificationManager: NotificationManagerCompat,
sessionName: String
): FilesTransfer(context, notificationManager, sessionName) {
var shouldAsk = true
@SuppressLint("SetTextI18n")
fun ask(activity: AppCompatActivity, senderName: String) {
val dialogBinding = DialogAskFileBinding.inflate(activity.layoutInflater)
dialogBinding.textTitle.text = activity.getString(R.string.want_to_send_files, senderName)+':'
val filesInfo = StringBuilder()
for (file in files) {
filesInfo.appendLine(file.fileName+" ("+FileUtils.formatSize(file.fileSize)+')')
}
dialogBinding.textFilesInfo.text = filesInfo.substring(0, filesInfo.length-1)
AlertDialog.Builder(activity)
.setTitle(R.string.download_file_request)
.setView(dialogBinding.root)
.setCancelable(false)
.setPositiveButton(R.string.download) { _, _ ->
onAccepted(this)
}
.setNegativeButton(R.string.refuse) { _, _ ->
onAborted(this)
}
.setOnDismissListener {
shouldAsk = false
}
.show()
}
}

View File

@ -0,0 +1,15 @@
package sushi.hardcore.aira.background_service
import android.content.Context
import androidx.core.app.NotificationManagerCompat
class FilesSender(
val files: List<SendFile>,
context: Context,
notificationManager: NotificationManagerCompat,
sessionName: String
): FilesTransfer(context, notificationManager, sessionName) {
val lastChunkSizes = mutableListOf<Int>()
var nextChunk: ByteArray? = null
val msgQueue = mutableListOf<ByteArray>()
}

View File

@ -0,0 +1,9 @@
package sushi.hardcore.aira.background_service
import android.content.Context
import androidx.core.app.NotificationManagerCompat
open class FilesTransfer(context: Context, notificationManager: NotificationManagerCompat, sessionName: String) {
val fileTransferNotification = FileTransferNotification(context, notificationManager, sessionName)
var index = 0
}

View File

@ -0,0 +1,5 @@
package sushi.hardcore.aira.background_service
open class PendingFile(val fileName: String, val fileSize: Long) {
var transferred = 0
}

View File

@ -8,11 +8,11 @@ class Protocol {
const val ASK_NAME: Byte = 0x01
const val TELL_NAME: Byte = 0x02
const val FILE: Byte = 0x03
const val ASK_LARGE_FILE: Byte = 0x04
const val ACCEPT_LARGE_FILE: Byte = 0x05
const val ASK_LARGE_FILES: Byte = 0x04
const val ACCEPT_LARGE_FILES: Byte = 0x05
const val LARGE_FILE_CHUNK: Byte = 0x06
const val ACK_CHUNK: Byte = 0x07
const val ABORT_FILE_TRANSFER: Byte = 0x08
const val ABORT_FILES_TRANSFER: Byte = 0x08
fun askName(): ByteArray {
return byteArrayOf(ASK_NAME)
@ -30,16 +30,22 @@ class Protocol {
return byteArrayOf(FILE)+ByteBuffer.allocate(2).putShort(fileName.length.toShort()).array()+fileName.toByteArray()+buffer
}
fun askLargeFile(fileSize: Long, fileName: String): ByteArray {
return byteArrayOf(ASK_LARGE_FILE)+ByteBuffer.allocate(8).putLong(fileSize).array()+fileName.toByteArray()
fun askLargeFiles(files: List<SendFile>): ByteArray {
var buff = byteArrayOf(ASK_LARGE_FILES)
for (file in files) {
buff += ByteBuffer.allocate(8).putLong(file.fileSize).array()
buff += ByteBuffer.allocate(2).putShort(file.fileName.length.toShort()).array()
buff += file.fileName.toByteArray()
}
return buff
}
fun acceptLargeFile(): ByteArray {
return byteArrayOf(ACCEPT_LARGE_FILE)
fun acceptLargeFiles(): ByteArray {
return byteArrayOf(ACCEPT_LARGE_FILES)
}
fun abortFileTransfer(): ByteArray {
return byteArrayOf(ABORT_FILE_TRANSFER)
fun abortFilesTransfer(): ByteArray {
return byteArrayOf(ABORT_FILES_TRANSFER)
}
fun ackChunk(): ByteArray {
@ -59,16 +65,25 @@ class Protocol {
return null
}
class FileInfo(val fileName: String, val fileSize: Long)
fun parseAskFile(buffer: ByteArray): FileInfo? {
return if (buffer.size > 9) {
val fileSize = ByteBuffer.wrap(buffer.sliceArray(1..8)).long
val fileName = buffer.sliceArray(9 until buffer.size).decodeToString()
FileInfo(fileName, fileSize)
fun parseAskFiles(buffer: ByteArray): List<ReceiveFile>? {
val files = mutableListOf<ReceiveFile>()
var n = 1
while (n < buffer.size) {
if (buffer.size > n+10) {
val fileSize = ByteBuffer.wrap(buffer.sliceArray(n..n+8)).long
val fileNameLen = ByteBuffer.wrap(buffer.sliceArray(n+8..n+10)).short
if (buffer.size >= n+10+fileNameLen) {
val fileName = buffer.sliceArray(n+10 until n+10+fileNameLen).decodeToString()
files.add(ReceiveFile(fileName, fileSize))
n += 10+fileNameLen
} else {
null
return null
}
} else {
return null
}
}
return files
}
}
}

View File

@ -0,0 +1,10 @@
package sushi.hardcore.aira.background_service
import java.io.OutputStream
class ReceiveFile (
fileName: String,
fileSize: Long
): PendingFile(fileName, fileSize) {
var outputStream: OutputStream? = null
}

View File

@ -1,44 +0,0 @@
package sushi.hardcore.aira.background_service
import android.annotation.SuppressLint
import android.app.AlertDialog
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import sushi.hardcore.aira.R
import sushi.hardcore.aira.utils.FileUtils
import java.io.OutputStream
class ReceiveFileTransfer(
fileName: String,
fileSize: Long,
private val onAccepted: (fileTransfer: ReceiveFileTransfer) -> Unit,
private val onAborted: () -> Unit,
): FileTransfer(fileName, fileSize) {
var shouldAsk = true
var outputStream: OutputStream? = null
@SuppressLint("SetTextI18n")
fun ask(activity: AppCompatActivity, senderName: String) {
val dialogView = activity.layoutInflater.inflate(R.layout.dialog_ask_file, null)
dialogView.findViewById<TextView>(R.id.text_title).text = activity.getString(R.string.want_to_send_a_file, senderName, ":")
dialogView.findViewById<TextView>(R.id.text_file_info).text = fileName+" ("+FileUtils.formatSize(fileSize)+")"
AlertDialog.Builder(activity)
.setTitle(R.string.download_file_request)
.setView(dialogView)
.setCancelable(false)
.setPositiveButton(R.string.download) { _, _ ->
outputStream = FileUtils.openFileForDownload(activity, fileName)
if (outputStream == null) {
onAborted()
} else {
onAccepted(this)
}
shouldAsk = false
}
.setNegativeButton(R.string.refuse) { _, _ ->
onAborted()
shouldAsk = false
}
.show()
}
}

View File

@ -2,11 +2,8 @@ package sushi.hardcore.aira.background_service
import java.io.InputStream
class SendFileTransfer(
class SendFile(
fileName: String,
fileSize: Long,
val inputStream: InputStream
): FileTransfer(fileName, fileSize) {
var nextChunk: ByteArray? = null
val msgQueue = mutableListOf<ByteArray>()
}
): PendingFile(fileName, fileSize)

View File

@ -1,11 +1,18 @@
package sushi.hardcore.aira.utils
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.os.Environment
import android.provider.MediaStore
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import android.widget.Toast
import sushi.hardcore.aira.background_service.ReceiveFile
import sushi.hardcore.aira.background_service.SendFile
import java.io.File
import java.io.FileNotFoundException
import java.io.OutputStream
import java.text.DecimalFormat
import java.text.SimpleDateFormat
@ -24,6 +31,26 @@ object FileUtils {
) + " " + units[digitGroups]
}
fun openFileFromUri(context: Context, uri: Uri): SendFile? {
var sendFile: SendFile? = null
val cursor = context.contentResolver.query(uri, null, null, null, null)
if (cursor != null) {
if (cursor.moveToFirst()) {
try {
context.contentResolver.openInputStream(uri)?.let { inputStream ->
val fileName = cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME))
val fileSize = cursor.getLong(cursor.getColumnIndex(OpenableColumns.SIZE))
sendFile = SendFile(fileName, fileSize, inputStream)
}
} catch (e: FileNotFoundException) {
Toast.makeText(context, e.localizedMessage, Toast.LENGTH_SHORT).show()
}
}
cursor.close()
}
return sendFile
}
fun openFileForDownload(context: Context, fileName: String): OutputStream? {
val fileExtension = fileName.substringAfterLast(".")
val dateExtension = SimpleDateFormat("yyyyMMddHHmmss", Locale.getDefault()).format(Date())

View File

@ -10,10 +10,19 @@
android:layout_height="wrap_content"
style="@style/Label"/>
<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/text_file_info"
android:id="@+id/text_files_info"
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@style/Label"/>
android:layout_marginVertical="5dp"
android:layout_marginHorizontal="15dp"
android:paddingBottom="5dp"
android:textAlignment="center"/>
</ScrollView>
</LinearLayout>

View File

@ -48,7 +48,7 @@
<string name="mark_read">Mark read</string>
<string name="ask_file_notification_channel">Download file requests</string>
<string name="db_mkdir_failed">Databases directory creation failed</string>
<string name="want_to_send_a_file">%s wants to send you a file%s</string>
<string name="want_to_send_files">%s wants to send you some files</string>
<string name="download_file_request">Download file request</string>
<string name="download">Download</string>
<string name="refuse">Refuse</string>
@ -56,9 +56,9 @@
<string name="transfer_aborted">Transfer aborted</string>
<string name="transfer_completed">Transfer completed</string>
<string name="reply">Reply</string>
<string name="share_uri_null">Failed to retrieve URI</string>
<string name="ask_send_file">Send %s (%s) to %s ?</string>
<string name="file_open_failed">Failed to open file</string>
<string name="open_uri_failed">Failed to open URI</string>
<string name="ask_send_single_file">Send %s (%s) to %s ?</string>
<string name="ask_send_multiple_files">Send %d files to %s ?</string>
<string name="share_label">Send with AIRA</string>
<string name="identity_fingerprint_label">Your identity\'s fingerprint:</string>
<string name="fingerprint">Fingerprint:</string>