diff --git a/README.md b/README.md index bf56170..5a1f3a2 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # AIRA Android -AIRA is peer-to-peer encrypted communication tool for local networks built on the [PSEC protocol](https://forge.chapril.org/hardcoresushi/PSEC). It allows to securely send text messages and files without any server or Internet access. +AIRA is peer-to-peer encrypted communication tool for local networks built on the [PSEC protocol](https://forge.chapril.org/hardcoresushi/PSEC). It allows to securely send text messages and files without any server or Internet access. AIRA automatically discovers and connects to other peers on your network, so you don't need any prior configuration to start communicating. Here is the Android version. You can find the original AIRA desktop version [here](https://forge.chapril.org/hardcoresushi/AIRA). diff --git a/app/build.gradle b/app/build.gradle index 8de6b18..afba7ab 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -40,7 +40,7 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation 'androidx.core:core-ktx:1.3.2' implementation 'androidx.appcompat:appcompat:1.2.0' - implementation "androidx.fragment:fragment-ktx:1.3.2" + implementation "androidx.fragment:fragment-ktx:1.3.3" implementation "androidx.preference:preference-ktx:1.1.1" implementation 'com.google.android.material:material:1.3.0' //implementation 'androidx.constraintlayout:constraintlayout:2.0.4' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 24cd1b9..f3436ba 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,7 +1,8 @@ + package="sushi.hardcore.aira" + android:installLocation="auto"> diff --git a/app/src/main/java/sushi/hardcore/aira/ChatActivity.kt b/app/src/main/java/sushi/hardcore/aira/ChatActivity.kt index e3d228a..a121892 100644 --- a/app/src/main/java/sushi/hardcore/aira/ChatActivity.kt +++ b/app/src/main/java/sushi/hardcore/aira/ChatActivity.kt @@ -7,8 +7,6 @@ import android.provider.OpenableColumns import android.view.Menu import android.view.MenuItem import android.view.View -import android.widget.LinearLayout -import android.widget.TextView import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog @@ -21,6 +19,8 @@ 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.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.util.* @@ -36,7 +36,7 @@ class ChatActivity : AppCompatActivity() { private var lastLoadedMessageOffset = 0 private var isActivityInForeground = false private val filePicker = registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> - if (::airaService.isInitialized) { + if (::airaService.isInitialized && uri != null) { contentResolver.query(uri, null, null, null, null)?.let { cursor -> if (cursor.moveToFirst()) { contentResolver.openInputStream(uri)?.let { inputStream -> @@ -238,13 +238,14 @@ class ChatActivity : AppCompatActivity() { val contact = airaService.contacts[sessionId] val session = airaService.sessions[sessionId] val publicKey = contact?.publicKey ?: session?.peerPublicKey - val dialogView = layoutInflater.inflate(R.layout.dialog_info, null) - dialogView.findViewById(R.id.text_fingerprint).text = StringUtils.beautifyFingerprint(generateFingerprint(publicKey!!)) + val dialogBinding = DialogInfoBinding.inflate(layoutInflater) + dialogBinding.textAvatar.setLetterFrom(sessionName) + dialogBinding.textFingerprint.text = StringUtils.beautifyFingerprint(generateFingerprint(publicKey!!)) if (session == null) { - dialogView.findViewById(R.id.online_fields).visibility = View.GONE + dialogBinding.onlineFields.visibility = View.GONE } else { - dialogView.findViewById(R.id.text_ip).text = session.ip - dialogView.findViewById(R.id.text_outgoing).text = getString(if (session.outgoing) { + dialogBinding.textIp.text = session.ip + dialogBinding.textOutgoing.text = getString(if (session.outgoing) { R.string.outgoing } else { R.string.incoming @@ -252,7 +253,7 @@ class ChatActivity : AppCompatActivity() { } AlertDialog.Builder(this) .setTitle(sessionName) - .setView(dialogView) + .setView(dialogBinding.root) .setPositiveButton(R.string.ok, null) .show() true @@ -282,12 +283,12 @@ class ChatActivity : AppCompatActivity() { airaService.contacts[sessionId]?.let { contact -> val localFingerprint = StringUtils.beautifyFingerprint(AIRADatabase.getIdentityFingerprint()) val peerFingerprint = StringUtils.beautifyFingerprint(generateFingerprint(contact.publicKey)) - val dialogView = layoutInflater.inflate(R.layout.dialog_fingerprints, null) - dialogView.findViewById(R.id.text_local_fingerprint).text = localFingerprint - dialogView.findViewById(R.id.text_peer_fingerprint).text = peerFingerprint + val dialogBinding = DialogFingerprintsBinding.inflate(layoutInflater) + dialogBinding.textLocalFingerprint.text = localFingerprint + dialogBinding.textPeerFingerprint.text = peerFingerprint AlertDialog.Builder(this) .setTitle(R.string.verifying_contact) - .setView(dialogView) + .setView(dialogBinding.root) .setPositiveButton(R.string.they_match) { _, _ -> if (airaService.setVerified(sessionId)) { invalidateOptionsMenu() diff --git a/app/src/main/java/sushi/hardcore/aira/MainActivity.kt b/app/src/main/java/sushi/hardcore/aira/MainActivity.kt index b89d61c..8b7d908 100644 --- a/app/src/main/java/sushi/hardcore/aira/MainActivity.kt +++ b/app/src/main/java/sushi/hardcore/aira/MainActivity.kt @@ -10,6 +10,7 @@ import android.os.IBinder import android.provider.OpenableColumns import android.view.Menu import android.view.MenuItem +import android.widget.AbsListView import android.widget.AdapterView import android.widget.Toast import androidx.appcompat.app.AlertDialog @@ -19,13 +20,31 @@ import sushi.hardcore.aira.adapters.SessionAdapter import sushi.hardcore.aira.background_service.AIRAService import sushi.hardcore.aira.background_service.ReceiveFileTransfer import sushi.hardcore.aira.databinding.ActivityMainBinding +import sushi.hardcore.aira.databinding.DialogIpAddressesBinding import sushi.hardcore.aira.utils.FileUtils +import sushi.hardcore.aira.utils.StringUtils +import java.lang.StringBuilder +import java.net.NetworkInterface class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding private lateinit var airaService: AIRAService private lateinit var onlineSessionAdapter: SessionAdapter private lateinit var offlineSessionAdapter: SessionAdapter + private val onSessionsItemClick = AdapterView.OnItemClickListener { adapter, _, position, _ -> + launchChatActivity(adapter.getItemAtPosition(position) as Session) + } + private val onSessionsItemClickSendFile = AdapterView.OnItemClickListener { adapter, _, position, _ -> + askShareFileTo(adapter.getItemAtPosition(position) as Session) + } + private val onSessionsScrollListener = object : AbsListView.OnScrollListener { + override fun onScrollStateChanged(view: AbsListView?, scrollState: Int) {} + override fun onScroll(listView: AbsListView, firstVisibleItem: Int, visibleItemCount: Int, totalItemCount: Int) { + if (listView.getChildAt(0) != null) { + binding.refresher.isEnabled = listView.firstVisiblePosition == 0 && listView.getChildAt(0).top == 0 + } + } + } private val uiCallbacks = object : AIRAService.UiCallbacks { override fun onNewSession(sessionId: Int, ip: String) { runOnUiThread { @@ -75,27 +94,21 @@ class MainActivity : AppCompatActivity() { binding.onlineSessions.apply { adapter = onlineSessionAdapter onItemClickListener = if (openedToShareFile) { - AdapterView.OnItemClickListener { _, _, position, _ -> - askShareFileTo(onlineSessionAdapter.getItem(position)) - } + onSessionsItemClickSendFile } else { - AdapterView.OnItemClickListener { _, _, position, _ -> - launchChatActivity(onlineSessionAdapter.getItem(position)) - } + onSessionsItemClick } + setOnScrollListener(onSessionsScrollListener) } offlineSessionAdapter = SessionAdapter(this) binding.offlineSessions.apply { adapter = offlineSessionAdapter onItemClickListener = if (openedToShareFile) { - AdapterView.OnItemClickListener { _, _, position, _ -> - askShareFileTo(offlineSessionAdapter.getItem(position)) + onSessionsItemClickSendFile + } else { + onSessionsItemClick } - } else { - AdapterView.OnItemClickListener { _, _, position, _ -> - launchChatActivity(offlineSessionAdapter.getItem(position)) - } - } + setOnScrollListener(onSessionsScrollListener) } Intent(this, AIRAService::class.java).also { serviceIntent -> bindService(serviceIntent, object : ServiceConnection { @@ -121,6 +134,23 @@ class MainActivity : AppCompatActivity() { }, Context.BIND_AUTO_CREATE) } + binding.buttonShowIp.setOnClickListener { + val ipAddresses = StringBuilder() + for (iface in NetworkInterface.getNetworkInterfaces()) { + for (addr in iface.inetAddresses) { + if (!addr.isLoopbackAddress) { + ipAddresses.appendLine(StringUtils.getIpFromInetAddress(addr)+" ("+iface.displayName+')') + } + } + } + val dialogBinding = DialogIpAddressesBinding.inflate(layoutInflater) + dialogBinding.textIpAddresses.text = ipAddresses.substring(0, ipAddresses.length-1) //remove last LF + AlertDialog.Builder(this) + .setTitle(R.string.your_addresses) + .setView(dialogBinding.root) + .setPositiveButton(R.string.ok, null) + .show() + } binding.editPeerIp.setOnEditorActionListener { _, _, _ -> if (::airaService.isInitialized){ airaService.connectTo(binding.editPeerIp.text.toString()) diff --git a/app/src/main/java/sushi/hardcore/aira/adapters/SessionAdapter.kt b/app/src/main/java/sushi/hardcore/aira/adapters/SessionAdapter.kt index 87d5789..c719c54 100644 --- a/app/src/main/java/sushi/hardcore/aira/adapters/SessionAdapter.kt +++ b/app/src/main/java/sushi/hardcore/aira/adapters/SessionAdapter.kt @@ -31,14 +31,15 @@ class SessionAdapter(context: Context): BaseAdapter() { val view: View = convertView ?: inflater.inflate(R.layout.adapter_session, parent, false) val currentSession = getItem(position) view.findViewById(R.id.text_name).apply { - if (currentSession.name == null) { + view.findViewById(R.id.text_avatar).setLetterFrom(if (currentSession.name == null) { text = currentSession.ip setTextColor(Color.RED) + "?" } else { text = currentSession.name setTextColor(Color.WHITE) - } - view.findViewById(R.id.text_avatar).setLetterFrom(text.toString()) + currentSession.name!! + }) } view.findViewById(R.id.image_trust_level).apply { if (currentSession.isVerified) { diff --git a/app/src/main/java/sushi/hardcore/aira/background_service/AIRAService.kt b/app/src/main/java/sushi/hardcore/aira/background_service/AIRAService.kt index 5266827..e808229 100644 --- a/app/src/main/java/sushi/hardcore/aira/background_service/AIRAService.kt +++ b/app/src/main/java/sushi/hardcore/aira/background_service/AIRAService.kt @@ -610,42 +610,42 @@ class AIRAService : Service() { } Protocol.ASK_LARGE_FILE -> { if (receiveFileTransfers[sessionId] == null) { - val fileSize = ByteBuffer.wrap(buffer.sliceArray(1..8)).long - val fileName = buffer.sliceArray(9 until buffer.size).decodeToString() - val fileTransfer = ReceiveFileTransfer(fileName, 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) - var shouldSendNotification = true - if (!isAppInBackground) { - if (uiCallbacks?.onAskLargeFile(sessionId, name, fileTransfer) == true) { - shouldSendNotification = false + 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 + val name = getNameOf(sessionId) + var shouldSendNotification = true + if (!isAppInBackground) { + if (uiCallbacks?.onAskLargeFile(sessionId, name, fileTransfer) == true) { + shouldSendNotification = false + } + } + if (shouldSendNotification) { + val notificationBuilder = NotificationCompat.Builder(this, ASK_FILE_TRANSFER_NOTIFICATION_CHANNEL_ID) + .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)) + .setOngoing(true) //not cancelable + .setContentIntent( + PendingIntent.getActivity(this, 0, Intent(this, ChatActivity::class.java).apply { + putExtra("sessionId", sessionId) + putExtra("sessionName", name) + }, 0) + ) + .setDefaults(Notification.DEFAULT_ALL) + .apply { + priority = NotificationCompat.PRIORITY_HIGH + } + notificationManager.notify(notificationIdManager.getFileTransferNotificationId(sessionId), notificationBuilder.build()) } - } - if (shouldSendNotification) { - val notificationBuilder = NotificationCompat.Builder(this, ASK_FILE_TRANSFER_NOTIFICATION_CHANNEL_ID) - .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, ": $fileName")) - .setOngoing(true) //not cancelable - .setContentIntent( - PendingIntent.getActivity(this, 0, Intent(this, ChatActivity::class.java).apply { - putExtra("sessionId", sessionId) - putExtra("sessionName", name) - }, 0) - ) - .setDefaults(Notification.DEFAULT_ALL) - .apply { - priority = NotificationCompat.PRIORITY_HIGH - } - notificationManager.notify(notificationIdManager.getFileTransferNotificationId(sessionId), notificationBuilder.build()) } } } @@ -653,13 +653,16 @@ class AIRAService : Service() { when (buffer[0]){ Protocol.MESSAGE -> buffer Protocol.FILE -> { - val filenameLen = ByteBuffer.wrap(ByteArray(2) +buffer.sliceArray(1..2)).int - val filename = buffer.sliceArray(3 until 3+filenameLen) - val rawFileUuid = AIRADatabase.storeFile(contacts[sessionId]?.uuid, buffer.sliceArray(3+filenameLen until buffer.size)) - if (rawFileUuid == null) { + val smallFile = Protocol.parseSmallFile(buffer) + if (smallFile == null) { null } else { - byteArrayOf(Protocol.FILE)+rawFileUuid+filename + val rawFileUuid = AIRADatabase.storeFile(contacts[sessionId]?.uuid, smallFile.fileContent) + if (rawFileUuid == null) { + null + } else { + byteArrayOf(Protocol.FILE)+rawFileUuid+smallFile.rawFileName + } } } else -> { diff --git a/app/src/main/java/sushi/hardcore/aira/background_service/Protocol.kt b/app/src/main/java/sushi/hardcore/aira/background_service/Protocol.kt index 97c2f30..5e9c220 100644 --- a/app/src/main/java/sushi/hardcore/aira/background_service/Protocol.kt +++ b/app/src/main/java/sushi/hardcore/aira/background_service/Protocol.kt @@ -45,5 +45,30 @@ class Protocol { fun ackChunk(): ByteArray { return byteArrayOf(ACK_CHUNK) } + + class SmallFile(val rawFileName: ByteArray, val fileContent: ByteArray) + + fun parseSmallFile(buffer: ByteArray): SmallFile? { + if (buffer.size > 3) { + val filenameLen = ByteBuffer.wrap(ByteArray(2) +buffer.sliceArray(1..2)).int + if (buffer.size > 3+filenameLen) { + val rawFileName = buffer.sliceArray(3 until 3+filenameLen) + return SmallFile(rawFileName, buffer.sliceArray(3+filenameLen until buffer.size)) + } + } + 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) + } else { + null + } + } } } \ No newline at end of file diff --git a/app/src/main/java/sushi/hardcore/aira/utils/FileUtils.kt b/app/src/main/java/sushi/hardcore/aira/utils/FileUtils.kt index 7e40cf8..40e305c 100644 --- a/app/src/main/java/sushi/hardcore/aira/utils/FileUtils.kt +++ b/app/src/main/java/sushi/hardcore/aira/utils/FileUtils.kt @@ -46,7 +46,7 @@ object FileUtils { } } else { @Suppress("Deprecation") - File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), datedFilename).outputStream() + File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), File(datedFilename).name).outputStream() } } } \ No newline at end of file diff --git a/app/src/main/java/sushi/hardcore/aira/utils/StringUtils.kt b/app/src/main/java/sushi/hardcore/aira/utils/StringUtils.kt index 15b1f25..03a9251 100644 --- a/app/src/main/java/sushi/hardcore/aira/utils/StringUtils.kt +++ b/app/src/main/java/sushi/hardcore/aira/utils/StringUtils.kt @@ -1,5 +1,7 @@ package sushi.hardcore.aira.utils +import java.net.InetAddress + object StringUtils { fun beautifyFingerprint(fingerprint: String): String { val newFingerprint = StringBuilder(fingerprint.length+7) @@ -9,4 +11,14 @@ object StringUtils { newFingerprint.append(fingerprint.slice(fingerprint.length-4 until fingerprint.length)) return newFingerprint.toString() } + + fun getIpFromInetAddress(addr: InetAddress): String { + val rawIp = addr.hostAddress + val i = rawIp.lastIndexOf('%') + return if (i == -1) { + rawIp + } else { + rawIp.substring(0, i) + } + } } \ No newline at end of file diff --git a/app/src/main/java/sushi/hardcore/aira/widgets/TextAvatar.kt b/app/src/main/java/sushi/hardcore/aira/widgets/TextAvatar.kt index 11fdd63..9898876 100644 --- a/app/src/main/java/sushi/hardcore/aira/widgets/TextAvatar.kt +++ b/app/src/main/java/sushi/hardcore/aira/widgets/TextAvatar.kt @@ -33,6 +33,8 @@ class TextAvatar @JvmOverloads constructor( } fun setLetterFrom(name: String) { - view.findViewById(R.id.text_letter).text = name[0].toUpperCase().toString() + if (name.isNotEmpty()) { + view.findViewById(R.id.text_letter).text = name[0].toUpperCase().toString() + } } } \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index bff5e3a..62330d1 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -22,7 +22,7 @@ + android:layout_height="0dp" + app:layout_constraintTop_toBottomOf="@id/text_offline_sessions" + app:layout_constraintBottom_toTopOf="@+id/bottom_panel"/> - + android:orientation="horizontal" + android:layout_marginHorizontal="20dp" + app:layout_constraintBottom_toBottomOf="parent"> + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_info.xml b/app/src/main/res/layout/dialog_info.xml index 8573f47..fc324cd 100644 --- a/app/src/main/res/layout/dialog_info.xml +++ b/app/src/main/res/layout/dialog_info.xml @@ -1,9 +1,18 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5eab324..2f87cbf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -70,4 +70,5 @@ Add contact Remove contact Details + Your IP addresses: diff --git a/build.gradle b/build.gradle index 3dac433..0cbb05a 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlin_version = "1.4.21" + ext.kotlin_version = "1.4.32" repositories { google() jcenter()