Display local IP addresses & Safe messages parsing

This commit is contained in:
Matéo Duparc 2021-05-04 20:01:06 +02:00
parent 19ccc94453
commit 490673052d
Signed by: hardcoresushi
GPG Key ID: 007F84120107191E
16 changed files with 217 additions and 87 deletions

View File

@ -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).

View File

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

View File

@ -1,7 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="sushi.hardcore.aira">
package="sushi.hardcore.aira"
android:installLocation="auto">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.INTERNET"/>

View File

@ -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<TextView>(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<LinearLayout>(R.id.online_fields).visibility = View.GONE
dialogBinding.onlineFields.visibility = View.GONE
} else {
dialogView.findViewById<TextView>(R.id.text_ip).text = session.ip
dialogView.findViewById<TextView>(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<TextView>(R.id.text_local_fingerprint).text = localFingerprint
dialogView.findViewById<TextView>(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()

View File

@ -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())

View File

@ -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<TextView>(R.id.text_name).apply {
if (currentSession.name == null) {
view.findViewById<TextAvatar>(R.id.text_avatar).setLetterFrom(if (currentSession.name == null) {
text = currentSession.ip
setTextColor(Color.RED)
"?"
} else {
text = currentSession.name
setTextColor(Color.WHITE)
}
view.findViewById<TextAvatar>(R.id.text_avatar).setLetterFrom(text.toString())
currentSession.name!!
})
}
view.findViewById<ImageView>(R.id.image_trust_level).apply {
if (currentSession.isVerified) {

View File

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

View File

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

View File

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

View File

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

View File

@ -33,6 +33,8 @@ class TextAvatar @JvmOverloads constructor(
}
fun setLetterFrom(name: String) {
view.findViewById<TextView>(R.id.text_letter).text = name[0].toUpperCase().toString()
if (name.isNotEmpty()) {
view.findViewById<TextView>(R.id.text_letter).text = name[0].toUpperCase().toString()
}
}
}

View File

@ -22,7 +22,7 @@
<ListView
android:id="@+id/online_sessions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/text_online_sessions"/>
<TextView
@ -36,19 +36,46 @@
<ListView
android:id="@+id/offline_sessions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/text_offline_sessions"/>
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/text_offline_sessions"
app:layout_constraintBottom_toTopOf="@+id/bottom_panel"/>
<EditText
android:id="@+id/edit_peer_ip"
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/bottom_panel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:maxLines="1"
android:imeOptions="actionGo"
android:hint="@string/add_peer_ip"
android:layout_margin="30dp"
app:layout_constraintBottom_toBottomOf="parent"/>
android:orientation="horizontal"
android:layout_marginHorizontal="20dp"
app:layout_constraintBottom_toBottomOf="parent">
<EditText
android:id="@+id/edit_peer_ip"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:inputType="text"
android:maxLines="1"
android:imeOptions="actionGo"
android:hint="@string/add_peer_ip"
android:layout_marginEnd="10dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/button_show_ip"/>
<ImageButton
android:id="@+id/button_show_ip"
android:layout_width="30dp"
android:layout_height="30dp"
android:src="@drawable/ic_info"
android:scaleType="fitXY"
android:background="#00000000"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/edit_peer_ip"
app:layout_constraintEnd_toEndOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

View File

@ -1,9 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical" android:layout_width="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingHorizontal="20dp">
<sushi.hardcore.aira.widgets.TextAvatar
android:id="@+id/text_avatar"
android:layout_width="100dp"
android:layout_height="100dp"
app:textSize="20sp"
android:layout_gravity="center_horizontal"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical" android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/your_addresses"
style="@style/Label"/>
<TextView
android:id="@+id/text_ip_addresses"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAlignment="center"/>
</LinearLayout>

View File

@ -70,4 +70,5 @@
<string name="add_contact">Add contact</string>
<string name="remove_contact">Remove contact</string>
<string name="details">Details</string>
<string name="your_addresses">Your IP addresses:</string>
</resources>

View File

@ -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()