Display local IP addresses & Safe messages parsing

master
Hardcore Sushi 1 year ago
parent 19ccc94453
commit 490673052d
Signed by: hardcoresushi
GPG Key ID: 007F84120107191E
  1. 2
      README.md
  2. 2
      app/build.gradle
  3. 3
      app/src/main/AndroidManifest.xml
  4. 27
      app/src/main/java/sushi/hardcore/aira/ChatActivity.kt
  5. 56
      app/src/main/java/sushi/hardcore/aira/MainActivity.kt
  6. 7
      app/src/main/java/sushi/hardcore/aira/adapters/SessionAdapter.kt
  7. 83
      app/src/main/java/sushi/hardcore/aira/background_service/AIRAService.kt
  8. 25
      app/src/main/java/sushi/hardcore/aira/background_service/Protocol.kt
  9. 2
      app/src/main/java/sushi/hardcore/aira/utils/FileUtils.kt
  10. 12
      app/src/main/java/sushi/hardcore/aira/utils/StringUtils.kt
  11. 4
      app/src/main/java/sushi/hardcore/aira/widgets/TextAvatar.kt
  12. 49
      app/src/main/res/layout/activity_main.xml
  13. 11
      app/src/main/res/layout/dialog_info.xml
  14. 18
      app/src/main/res/layout/dialog_ip_addresses.xml
  15. 1
      app/src/main/res/values/strings.xml
  16. 2
      build.gradle

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

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

@ -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"/>

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

@ -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))
}
} else {
AdapterView.OnItemClickListener { _, _, position, _ ->
launchChatActivity(offlineSessionAdapter.getItem(position))
onSessionsItemClickSendFile
} else {
onSessionsItemClick
}
}
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())

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

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

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

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

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

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

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

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

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

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

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

Loading…
Cancel
Save