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"> <activity android:name=".MainActivity">
<intent-filter android:label="@string/share_label"> <intent-filter android:label="@string/share_label">
<action android:name="android.intent.action.SEND"/> <action android:name="android.intent.action.SEND"/>
<action android:name="android.intent.action.SEND_MULTIPLE"/>
<category android:name="android.intent.category.DEFAULT"/> <category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="*/*"/> <data android:mimeType="*/*"/>
</intent-filter> </intent-filter>

View File

@ -1,64 +1,44 @@
package sushi.hardcore.aira package sushi.hardcore.aira
import android.content.ComponentName import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection import android.content.ServiceConnection
import android.os.Bundle import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import android.provider.OpenableColumns
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
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.appcompat.app.AppCompatActivity
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.recyclerview.widget.LinearLayoutManager 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.background_service.AIRAService import sushi.hardcore.aira.background_service.*
import sushi.hardcore.aira.background_service.Protocol
import sushi.hardcore.aira.background_service.ReceiveFileTransfer
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 java.io.FileNotFoundException
class ChatActivity : AppCompatActivity() { class ChatActivity : ServiceBoundActivity() {
private external fun generateFingerprint(publicKey: ByteArray): String private external fun generateFingerprint(publicKey: ByteArray): String
private lateinit var binding: ActivityChatBinding private lateinit var binding: ActivityChatBinding
private var sessionId = -1 private var sessionId = -1
private lateinit var sessionName: String private lateinit var sessionName: String
private lateinit var airaService: AIRAService
private lateinit var chatAdapter: ChatAdapter private lateinit var chatAdapter: ChatAdapter
private var lastLoadedMessageOffset = 0 private var lastLoadedMessageOffset = 0
private var isActivityInForeground = false private val filePicker = registerForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris ->
private val filePicker = registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> if (isServiceInitialized() && uris.size > 0) {
if (::airaService.isInitialized && uri != null) { airaService.sendFilesFromUris(sessionId, uris)?.let { msgs ->
contentResolver.query(uri, null, null, null, null)?.let { cursor -> for (msg in msgs) {
if (cursor.moveToFirst()) { chatAdapter.newMessage(ChatItem(true, msg))
try { if (airaService.contacts.contains(sessionId)) {
contentResolver.openInputStream(uri)?.let { inputStream -> lastLoadedMessageOffset += 1
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 ->
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() { addOnScrollListener(object : RecyclerView.OnScrollListener() {
fun loadMsgsIfNeeded(recyclerView: RecyclerView) { fun loadMsgsIfNeeded(recyclerView: RecyclerView) {
if (!recyclerView.canScrollVertically(-1) && ::airaService.isInitialized) { if (!recyclerView.canScrollVertically(-1) && isServiceInitialized()) {
airaService.contacts[sessionId]?.let { contact -> airaService.contacts[sessionId]?.let { contact ->
loadMsgs(contact.uuid) loadMsgs(contact.uuid)
} }
@ -97,109 +77,108 @@ class ChatActivity : AppCompatActivity() {
} }
}) })
} }
Intent(this, AIRAService::class.java).also { serviceIntent -> binding.buttonSend.setOnClickListener {
bindService(serviceIntent, object : ServiceConnection { val msg = binding.editMessage.text.toString()
override fun onServiceConnected(name: ComponentName?, service: IBinder) { airaService.sendTo(sessionId, Protocol.newMessage(msg))
val binder = service as AIRAService.AIRABinder binding.editMessage.text.clear()
airaService = binder.getService() chatAdapter.newMessage(ChatItem(true, Protocol.newMessage(msg)))
if (airaService.contacts.contains(sessionId)) {
lastLoadedMessageOffset += 1
}
binding.recyclerChat.smoothScrollToPosition(chatAdapter.itemCount)
}
binding.buttonAttach.setOnClickListener {
filePicker.launch("*/*")
}
serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder) {
val binder = service as AIRAService.AIRABinder
airaService = binder.getService()
airaService.contacts[sessionId]?.let { contact -> chatAdapter.clear()
displayIconTrustLevel(true, contact.verified) airaService.contacts[sessionId]?.let { contact ->
loadMsgs(contact.uuid) 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) { airaService.receiveFileTransfers[sessionId]?.let {
it.ask(this@ChatActivity, sessionName) 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 {
showBottomPanel()
}
} }
} }
airaService.savedMsgs[sessionId]?.let { override fun onSessionDisconnect(sessionId: Int) {
for (chatItem in it.asReversed()) { if (this@ChatActivity.sessionId == sessionId) {
chatAdapter.newLoadedMessage(chatItem) runOnUiThread {
findViewById<ConstraintLayout>(R.id.bottom_panel).visibility = View.GONE
binding.buttonSend.setOnClickListener(null)
binding.buttonAttach.setOnClickListener(null)
}
} }
} }
binding.recyclerChat.smoothScrollToPosition(chatAdapter.itemCount) override fun onNameTold(sessionId: Int, name: String) {
val onConnected = { if (this@ChatActivity.sessionId == sessionId) {
findViewById<ConstraintLayout>(R.id.bottom_panel).visibility = View.VISIBLE runOnUiThread {
binding.buttonSend.setOnClickListener { sessionName = name
val msg = binding.editMessage.text.toString() title = name
airaService.sendTo(sessionId, Protocol.newMessage(msg)) }
binding.editMessage.text.clear() }
chatAdapter.newMessage(ChatItem(true, Protocol.newMessage(msg))) }
override fun onNewMessage(sessionId: Int, data: ByteArray): Boolean {
return if (this@ChatActivity.sessionId == sessionId) {
runOnUiThread {
chatAdapter.newMessage(ChatItem(false, data))
binding.recyclerChat.smoothScrollToPosition(chatAdapter.itemCount)
}
if (airaService.contacts.contains(sessionId)) { if (airaService.contacts.contains(sessionId)) {
lastLoadedMessageOffset += 1 lastLoadedMessageOffset += 1
} }
binding.recyclerChat.smoothScrollToPosition(chatAdapter.itemCount) airaService.isAppInBackground
} } else {
binding.buttonAttach.setOnClickListener { false
filePicker.launch("*/*")
} }
} }
airaService.uiCallbacks = object : AIRAService.UiCallbacks {
override fun onNewSession(sessionId: Int, ip: String) {
if (this@ChatActivity.sessionId == sessionId) {
runOnUiThread {
onConnected()
}
}
}
override fun onSessionDisconnect(sessionId: Int) {
if (this@ChatActivity.sessionId == sessionId) {
runOnUiThread {
findViewById<ConstraintLayout>(R.id.bottom_panel).visibility = View.GONE
binding.buttonSend.setOnClickListener(null)
binding.buttonAttach.setOnClickListener(null)
}
}
}
override fun onNameTold(sessionId: Int, name: String) {
if (this@ChatActivity.sessionId == sessionId) {
runOnUiThread {
sessionName = name
title = name
}
}
}
override fun onNewMessage(sessionId: Int, data: ByteArray): Boolean {
return if (this@ChatActivity.sessionId == sessionId) {
runOnUiThread {
chatAdapter.newMessage(ChatItem(false, data))
binding.recyclerChat.smoothScrollToPosition(chatAdapter.itemCount)
}
if (airaService.contacts.contains(sessionId)) {
lastLoadedMessageOffset += 1
}
isActivityInForeground
} 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) { return if (this@ChatActivity.sessionId == sessionId) {
runOnUiThread { runOnUiThread {
receiveFileTransfer.ask(this@ChatActivity, name) filesReceiver.ask(this@ChatActivity, name)
}
true
} else {
false
} }
true
} else {
false
} }
} }
airaService.isAppInBackground = false
if (airaService.isOnline(sessionId)) {
onConnected()
binding.recyclerChat.updatePadding(bottom = 0)
}
airaService.setSeen(sessionId, true)
} }
override fun onServiceDisconnected(name: ComponentName?) {} airaService.isAppInBackground = false
}, Context.BIND_AUTO_CREATE) if (airaService.isOnline(sessionId)) {
showBottomPanel()
binding.recyclerChat.updatePadding(bottom = 0)
}
airaService.setSeen(sessionId, true)
}
override fun onServiceDisconnected(name: ComponentName?) {}
} }
} }
} }
} }
fun displayIconTrustLevel(isContact: Boolean, isVerified: Boolean) { private fun displayIconTrustLevel(isContact: Boolean, isVerified: Boolean) {
when { when {
isVerified -> { isVerified -> {
binding.imageTrustLevel.setImageResource(R.drawable.ic_verified) 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 { AIRADatabase.loadMsgs(contactUuid, lastLoadedMessageOffset, Constants.MSG_LOADING_COUNT)?.let {
for (chatItem in it.asReversed()) { for (chatItem in it.asReversed()) {
chatAdapter.newLoadedMessage(chatItem) chatAdapter.newLoadedMessage(chatItem)
@ -328,20 +307,10 @@ class ChatActivity : AppCompatActivity() {
} }
} }
override fun onResume() { override fun onStart() {
super.onResume() super.onStart()
isActivityInForeground = true if (isServiceInitialized()) {
if (::airaService.isInitialized) {
airaService.setSeen(sessionId, true) 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 package sushi.hardcore.aira
import android.content.ComponentName import android.content.ComponentName
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.ServiceConnection import android.content.ServiceConnection
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import android.provider.OpenableColumns
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
@ -15,11 +13,10 @@ import android.widget.AbsListView
import android.widget.AdapterView import android.widget.AdapterView
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import sushi.hardcore.aira.adapters.Session import sushi.hardcore.aira.adapters.Session
import sushi.hardcore.aira.adapters.SessionAdapter import sushi.hardcore.aira.adapters.SessionAdapter
import sushi.hardcore.aira.background_service.AIRAService 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.ActivityMainBinding
import sushi.hardcore.aira.databinding.DialogIpAddressesBinding import sushi.hardcore.aira.databinding.DialogIpAddressesBinding
import sushi.hardcore.aira.utils.FileUtils import sushi.hardcore.aira.utils.FileUtils
@ -27,9 +24,8 @@ import sushi.hardcore.aira.utils.StringUtils
import java.lang.StringBuilder import java.lang.StringBuilder
import java.net.NetworkInterface import java.net.NetworkInterface
class MainActivity : AppCompatActivity() { class MainActivity : ServiceBoundActivity() {
private lateinit var binding: ActivityMainBinding private lateinit var binding: ActivityMainBinding
private lateinit var airaService: AIRAService
private lateinit var onlineSessionAdapter: SessionAdapter private lateinit var onlineSessionAdapter: SessionAdapter
private var offlineSessionAdapter: SessionAdapter? = null private var offlineSessionAdapter: SessionAdapter? = null
private val onSessionsItemClickSendFile = AdapterView.OnItemClickListener { adapter, _, position, _ -> private val onSessionsItemClickSendFile = AdapterView.OnItemClickListener { adapter, _, position, _ ->
@ -70,9 +66,9 @@ class MainActivity : AppCompatActivity() {
return false return false
} }
override fun onAskLargeFile(sessionId: Int, name: String, receiveFileTransfer: ReceiveFileTransfer): Boolean { override fun onAskLargeFiles(sessionId: Int, name: String, filesReceiver: FilesReceiver): Boolean {
runOnUiThread { runOnUiThread {
receiveFileTransfer.ask(this@MainActivity, name) filesReceiver.ask(this@MainActivity, name)
} }
return true return true
} }
@ -86,7 +82,7 @@ class MainActivity : AppCompatActivity() {
val identityName = intent.getStringExtra("identityName") val identityName = intent.getStringExtra("identityName")
identityName?.let { title = it } 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) onlineSessionAdapter = SessionAdapter(this)
binding.onlineSessions.apply { binding.onlineSessions.apply {
@ -133,30 +129,29 @@ class MainActivity : AppCompatActivity() {
setOnScrollListener(onSessionsScrollListener) setOnScrollListener(onSessionsScrollListener)
} }
} }
Intent(this, AIRAService::class.java).also { serviceIntent -> serviceConnection = object : ServiceConnection {
bindService(serviceIntent, object : ServiceConnection { override fun onServiceConnected(name: ComponentName?, service: IBinder) {
override fun onServiceConnected(name: ComponentName?, service: IBinder) { val binder = service as AIRAService.AIRABinder
val binder = service as AIRAService.AIRABinder airaService = binder.getService()
airaService = binder.getService() airaService.uiCallbacks = uiCallbacks
airaService.uiCallbacks = uiCallbacks airaService.isAppInBackground = false
airaService.isAppInBackground = false refreshSessions()
loadContacts() if (AIRAService.isServiceRunning) {
if (AIRAService.isServiceRunning) { title = airaService.identityName
title = airaService.identityName } else {
loadSessions() airaService.identityName = identityName
} else { startService(serviceIntent)
airaService.identityName = identityName
startService(serviceIntent)
}
binding.refresher.setOnRefreshListener {
airaService.restartDiscovery()
binding.refresher.isRefreshing = false
}
} }
override fun onServiceDisconnected(name: ComponentName?) {} }
}, Context.BIND_AUTO_CREATE) override fun onServiceDisconnected(name: ComponentName?) {}
} }
binding.refresher.setOnRefreshListener {
if (isServiceInitialized()) {
airaService.restartDiscovery()
}
binding.refresher.isRefreshing = false
}
binding.buttonShowIp.setOnClickListener { binding.buttonShowIp.setOnClickListener {
val ipAddresses = StringBuilder() val ipAddresses = StringBuilder()
for (iface in NetworkInterface.getNetworkInterfaces()) { for (iface in NetworkInterface.getNetworkInterfaces()) {
@ -175,7 +170,7 @@ class MainActivity : AppCompatActivity() {
.show() .show()
} }
binding.editPeerIp.setOnEditorActionListener { _, _, _ -> binding.editPeerIp.setOnEditorActionListener { _, _, _ ->
if (::airaService.isInitialized){ if (isServiceInitialized()){
airaService.connectTo(binding.editPeerIp.text.toString()) airaService.connectTo(binding.editPeerIp.text.toString())
} }
binding.editPeerIp.text.clear() binding.editPeerIp.text.clear()
@ -196,7 +191,7 @@ class MainActivity : AppCompatActivity() {
true true
} }
R.id.close -> { R.id.close -> {
if (::airaService.isInitialized) { if (isServiceInitialized()) {
AlertDialog.Builder(this) AlertDialog.Builder(this)
.setTitle(R.string.warning) .setTitle(R.string.warning)
.setMessage(R.string.ask_log_out) .setMessage(R.string.ask_log_out)
@ -240,27 +235,13 @@ class MainActivity : AppCompatActivity() {
} }
} }
override fun onPause() { override fun onStop() {
super.onPause() super.onStop()
if (::airaService.isInitialized) { if (isServiceInitialized()) {
airaService.isAppInBackground = true 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() { override fun onBackPressed() {
if (isSelecting()) { if (isSelecting()) {
unSelectAll() unSelectAll()
@ -328,34 +309,37 @@ class MainActivity : AppCompatActivity() {
} }
private fun askShareFileTo(session: Session) { private fun askShareFileTo(session: Session) {
val uri = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM) var uris: ArrayList<Uri>? = null
if (uri == null) { when (intent.action) {
Toast.makeText(this, R.string.share_uri_null, Toast.LENGTH_SHORT).show() Intent.ACTION_SEND -> intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)?.let {
} else { uris = arrayListOf(it)
val cursor = contentResolver.query(uri, null, null, null, null)
if (cursor == null) {
Toast.makeText(this, R.string.file_open_failed, Toast.LENGTH_SHORT).show()
} 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()
} else {
AlertDialog.Builder(this)
.setTitle(R.string.warning)
.setMessage(getString(R.string.ask_send_file, fileName, FileUtils.formatSize(fileSize), session.name ?: session.ip))
.setPositiveButton(R.string.yes) { _, _ ->
airaService.sendFileTo(session.sessionId, fileName, fileSize, inputStream)
finish()
}
.setNegativeButton(R.string.cancel, null)
.show()
}
}
cursor.close()
} }
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 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 {
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(msg)
.setPositiveButton(R.string.yes) { _, _ ->
airaService.sendFilesFromUris(session.sessionId, uris!!)
finish()
}
.setNegativeButton(R.string.cancel, null)
.show()
} }
} }
} }

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

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

View File

@ -1,11 +1,18 @@
package sushi.hardcore.aira.utils package sushi.hardcore.aira.utils
import android.content.ContentResolver
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import android.net.Uri
import android.os.Environment import android.os.Environment
import android.provider.MediaStore import android.provider.MediaStore
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap 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.File
import java.io.FileNotFoundException
import java.io.OutputStream import java.io.OutputStream
import java.text.DecimalFormat import java.text.DecimalFormat
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@ -24,6 +31,26 @@ object FileUtils {
) + " " + units[digitGroups] ) + " " + 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? { fun openFileForDownload(context: Context, fileName: String): OutputStream? {
val fileExtension = fileName.substringAfterLast(".") val fileExtension = fileName.substringAfterLast(".")
val dateExtension = SimpleDateFormat("yyyyMMddHHmmss", Locale.getDefault()).format(Date()) val dateExtension = SimpleDateFormat("yyyyMMddHHmmss", Locale.getDefault()).format(Date())

View File

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

View File

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