├── .gitignore
├── LICENSE
├── README.md
├── app
├── .gitignore
├── build.gradle
├── proguard-rules.pro
└── src
│ ├── androidTest
│ └── java
│ │ └── sushi
│ │ └── hardcore
│ │ └── aira
│ │ └── ExampleInstrumentedTest.kt
│ ├── main
│ ├── AndroidManifest.xml
│ ├── java
│ │ └── sushi
│ │ │ └── hardcore
│ │ │ └── aira
│ │ │ ├── AIRADatabase.kt
│ │ │ ├── ChatActivity.kt
│ │ │ ├── ChatItem.kt
│ │ │ ├── Constants.kt
│ │ │ ├── CreateIdentityFragment.kt
│ │ │ ├── LoginActivity.kt
│ │ │ ├── LoginFragment.kt
│ │ │ ├── MainActivity.kt
│ │ │ ├── ServiceBoundActivity.kt
│ │ │ ├── SettingsActivity.kt
│ │ │ ├── adapters
│ │ │ ├── ChatAdapter.kt
│ │ │ ├── FuckRecyclerView.kt
│ │ │ ├── Session.kt
│ │ │ └── SessionAdapter.kt
│ │ │ ├── background_service
│ │ │ ├── AIRAService.kt
│ │ │ ├── ApplicationKeys.kt
│ │ │ ├── Contact.kt
│ │ │ ├── FileTransferNotification.kt
│ │ │ ├── FilesReceiver.kt
│ │ │ ├── FilesSender.kt
│ │ │ ├── FilesTransfer.kt
│ │ │ ├── HandshakeKeys.kt
│ │ │ ├── NotificationBroadcastReceiver.kt
│ │ │ ├── NotificationIdManager.kt
│ │ │ ├── PendingFile.kt
│ │ │ ├── Protocol.kt
│ │ │ ├── ReceiveFile.kt
│ │ │ ├── SendFile.kt
│ │ │ ├── Session.kt
│ │ │ └── SystemBroadcastReceiver.kt
│ │ │ ├── utils
│ │ │ ├── AvatarPicker.kt
│ │ │ ├── FileUtils.kt
│ │ │ ├── StringUtils.kt
│ │ │ └── TimeUtils.kt
│ │ │ └── widgets
│ │ │ └── Avatar.kt
│ ├── native
│ │ ├── .gitignore
│ │ ├── Cargo.lock
│ │ ├── Cargo.toml
│ │ ├── build.sh
│ │ ├── check.sh
│ │ └── src
│ │ │ ├── crypto.rs
│ │ │ ├── identity.rs
│ │ │ ├── key_value_table.rs
│ │ │ ├── lib.rs
│ │ │ └── utils.rs
│ └── res
│ │ ├── drawable
│ │ ├── ic_add.xml
│ │ ├── ic_arrow_forward.xml
│ │ ├── ic_attach_file.xml
│ │ ├── ic_blur.xml
│ │ ├── ic_close.xml
│ │ ├── ic_delete_conversation.xml
│ │ ├── ic_delete_forever.xml
│ │ ├── ic_face.xml
│ │ ├── ic_fingerprint.xml
│ │ ├── ic_gitea.xml
│ │ ├── ic_github.xml
│ │ ├── ic_info.xml
│ │ ├── ic_launcher.xml
│ │ ├── ic_lock.xml
│ │ ├── ic_person.xml
│ │ ├── ic_person_add.xml
│ │ ├── ic_person_remove.xml
│ │ ├── ic_save.xml
│ │ ├── ic_send.xml
│ │ ├── ic_settings.xml
│ │ ├── ic_shuttle.xml
│ │ ├── ic_verified.xml
│ │ ├── ic_warning.xml
│ │ ├── offline_warning_background.xml
│ │ ├── pending_msg_indicator_background.xml
│ │ ├── round_background.xml
│ │ └── sending_pending_msg_indictor_background.xml
│ │ ├── layout
│ │ ├── activity_chat.xml
│ │ ├── activity_login.xml
│ │ ├── activity_main.xml
│ │ ├── activity_settings.xml
│ │ ├── adapter_chat_item.xml
│ │ ├── adapter_session.xml
│ │ ├── avatar.xml
│ │ ├── change_avatar_dialog.xml
│ │ ├── dialog_ask_file.xml
│ │ ├── dialog_edit_text.xml
│ │ ├── dialog_fingerprints.xml
│ │ ├── dialog_info.xml
│ │ ├── dialog_ip_addresses.xml
│ │ ├── dialog_password.xml
│ │ ├── file_bubble_content.xml
│ │ ├── fragment_create_identity.xml
│ │ ├── fragment_login.xml
│ │ ├── message_bubble_content.xml
│ │ └── profile_toolbar.xml
│ │ ├── menu
│ │ ├── chat_activity.xml
│ │ └── main_activity.xml
│ │ ├── values-es
│ │ └── strings-es.xml
│ │ ├── values
│ │ ├── attrs.xml
│ │ ├── colors.xml
│ │ ├── dimens.xml
│ │ ├── strings.xml
│ │ ├── styles.xml
│ │ └── themes.xml
│ │ └── xml
│ │ └── preferences.xml
│ └── test
│ └── java
│ └── sushi
│ └── hardcore
│ └── aira
│ └── ExampleUnitTest.kt
├── build.gradle
├── gradle.properties
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── screenshots
├── 1.png
├── 2.png
└── 3.png
└── settings.gradle
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | .Trashes
3 | ehthumbs.db
4 | Thumbs.db
5 | build
6 | .cxx
7 | /app/release
8 | .gradle
9 | local.properties
10 | .idea/
11 | *.apk
12 | *.ap_
13 | *.dex
14 | *.class
15 | # Compiled Object files, Static and Dynamic libs (Shared Objects)
16 | *.o
17 | *.a
18 | *.so
19 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # AIRA Android
2 | 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.
3 |
4 | Here is the Android version. You can find the original AIRA desktop version [here](https://forge.chapril.org/hardcoresushi/AIRA).
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | # Disclaimer
13 | AIRA is still under developement and is not ready for production usage yet. Not all features have been implemented and bugs are expected. Neither the code or the PSEC protocol received any security audit and therefore shouldn't be considered fully secure. AIRA is provided "as is", without any warranty of any kind.
14 |
15 | # Features
16 | - End-to-End encryption using the [PSEC protocol](https://forge.chapril.org/hardcoresushi/PSEC)
17 | - Automatic peer discovery using mDNS
18 | - Manual peer connection
19 | - File transferts
20 | - Notifications
21 | - Encrypted database
22 | - Contact verification
23 | - IPv4/v6 compatibility
24 | - Free/Libre and Open Source
25 |
26 | # Download
27 | AIRA releases are availables in the "Release" section. All APKs are signed with my PGP key available on keyservers. To download it:
28 |
29 | `gpg --keyserver hkps://keyserver.ubuntu.com --recv-keys AFE384344A45E13A` \
30 | Fingerprint: `B64E FE86 CEE1 D054 F082 1711 AFE3 8434 4A45 E13A` \
31 | Email: `Hardcore Sushi `
32 |
33 | Then, verify APK: `gpg --verify AIRA.apk.asc AIRA.apk`
34 |
35 | __Don't install the APK if the verification fails!__
36 |
37 | # Build
38 | ### Install Rust
39 | AIRA android uses some code from the desktop version which is written in Rust. Therefore, you need to compile this Rust code first.
40 | ```
41 | curl --proto '=https' --tlsv1.3 -sSf https://sh.rustup.rs | sh
42 | rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android
43 | ```
44 | ### Install NDK
45 | We also need the Android NDK to cross-compile the rust code to Android. Currently, only versions up to __r22b__ are supported. You can find instructions to install the NDK here: https://developer.android.com/ndk/guides
46 |
47 | Once installed, you need to define the `$ANDROID_NDK_HOME` environment variable (if not already set):
48 | ```
49 | export ANDROID_NDK_HOME=/home//Android/SDK/ndk/"
50 | ```
51 | ### Download AIRA
52 | ```
53 | git clone --depth=1 https://forge.chapril.org/hardcoresushi/AIRA-android.git && cd AIRA-android
54 | ```
55 | ### Verify commit
56 | ```
57 | git verify-commit HEAD
58 | ```
59 | ### Build AIRA Rust code
60 | ```
61 | cd app/src/main/native
62 | ./build.sh
63 | ```
64 | ### Build final APK
65 | If you have AndroidStudio installed, you can just open the project directory and then start the build process. Otherwise, you can use Gradle from the command line:
66 |
67 | Generate a signed APK with your keystore:
68 | ```
69 | # From the project root directory:
70 | ./gradlew assembleRelease -Pandroid.injected.signing.store.file= -Pandroid.injected.signing.store.password= -Pandroid.injected.signing.key.alias= -Pandroid.injected.signing.key.password=
71 | ```
72 | Generate an unsigned APK:
73 | ```
74 | ./gradlew assembleRelease
75 | ```
76 | Once completed, the APKs will be located under `app/build/outputs/apk/release/`.
77 |
78 | If you generate an unsigned APK you won't be able to install it as-is on your device. You will need to force install it with ADB:
79 | ```
80 | adb install app-release-unsigned.apk
81 | ```
82 |
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/app/build.gradle:
--------------------------------------------------------------------------------
1 | plugins {
2 | id 'com.android.application'
3 | id 'kotlin-android'
4 | }
5 |
6 | android {
7 | compileSdkVersion 32
8 | buildToolsVersion "32.0.0"
9 |
10 | defaultConfig {
11 | applicationId "sushi.hardcore.aira"
12 | minSdkVersion 19
13 | targetSdkVersion 32
14 | versionCode 3
15 | versionName "0.1.1"
16 |
17 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
18 |
19 | ndk {
20 | abiFilters "x86", "x86_64", "armeabi-v7a", "arm64-v8a"
21 | }
22 | }
23 |
24 | applicationVariants.all { variant ->
25 | variant.resValue "string", "versionName", variant.versionName
26 | }
27 |
28 | buildFeatures {
29 | viewBinding true
30 | }
31 |
32 | buildTypes {
33 | release {
34 | minifyEnabled false // curve25519-android doesn't seem to support minification
35 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
36 | }
37 | }
38 | compileOptions {
39 | sourceCompatibility JavaVersion.VERSION_1_8
40 | targetCompatibility JavaVersion.VERSION_1_8
41 | }
42 | kotlinOptions {
43 | jvmTarget = '1.8'
44 | }
45 | namespace 'sushi.hardcore.aira'
46 | }
47 |
48 | dependencies {
49 | implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
50 | implementation 'androidx.core:core-ktx:1.7.0'
51 | implementation 'androidx.appcompat:appcompat:1.4.1'
52 | implementation "androidx.fragment:fragment-ktx:1.4.1"
53 | implementation "androidx.preference:preference-ktx:1.2.0"
54 | implementation 'com.google.android.material:material:1.6.0'
55 |
56 | implementation 'net.i2p.crypto:eddsa:0.3.0'
57 | implementation "org.whispersystems:curve25519-android:0.5.0"
58 | implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
59 | implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
60 | implementation 'com.github.bumptech.glide:glide:4.12.0'
61 | annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0'
62 | }
63 |
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/app/src/androidTest/java/sushi/hardcore/aira/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package sushi.hardcore.aira
2 |
3 | import androidx.test.platform.app.InstrumentationRegistry
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 |
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | import org.junit.Assert.*
10 |
11 | /**
12 | * Instrumented test, which will execute on an Android device.
13 | *
14 | * See [testing documentation](http://d.android.com/tools/testing).
15 | */
16 | @RunWith(AndroidJUnit4::class)
17 | class ExampleInstrumentedTest {
18 | @Test
19 | fun useAppContext() {
20 | // Context of the app under test.
21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext
22 | assertEquals("sushi.hardcore.aira", appContext.packageName)
23 | }
24 | }
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
19 |
20 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
--------------------------------------------------------------------------------
/app/src/main/java/sushi/hardcore/aira/AIRADatabase.kt:
--------------------------------------------------------------------------------
1 | package sushi.hardcore.aira
2 |
3 | import sushi.hardcore.aira.background_service.Contact
4 |
5 | object AIRADatabase {
6 | external fun initLogging(): Boolean
7 | external fun isIdentityProtected(databaseFolder: String): Boolean
8 | external fun getIdentityName(databaseFolder: String): String?
9 | external fun loadIdentity(databaseFolder: String, password: ByteArray?): Boolean
10 | external fun addContact(name: String, avatarUuid: String?, publicKey: ByteArray): Contact?
11 | external fun removeContact(uuid: String): Boolean
12 | external fun loadContacts(): ArrayList?
13 | external fun setVerified(uuid: String): Boolean
14 | external fun setContactSeen(contactUuid: String, seen: Boolean): Boolean
15 | external fun changeContactName(contactUuid: String, newName: String): Boolean
16 | external fun setContactAvatar(contactUuid: String, avatarUuid: String?): Boolean
17 | external fun storeMsg(contactUuid: String, outgoing: Boolean, timestamp: Long, data: ByteArray): Boolean
18 | external fun storeFile(contactUuid: String?, data: ByteArray): ByteArray?
19 | external fun loadMsgs(uuid: String, offset: Int, count: Int): ArrayList?
20 | external fun loadFile(rawUuid: ByteArray): ByteArray?
21 | external fun deleteConversation(contactUuid: String): Boolean
22 | external fun clearCache()
23 | external fun getIdentityPublicKey(): ByteArray
24 | external fun getIdentityFingerprint(): String
25 | external fun getUsePadding(): Boolean
26 | external fun setUsePadding(usePadding: Boolean): Boolean
27 | external fun storeAvatar(avatar: ByteArray): String?
28 | external fun getAvatar(avatarUuid: String): ByteArray?
29 | external fun changeName(newName: String): Boolean
30 | external fun changePassword(databaseFolder: String, oldPassword: ByteArray?, newPassword: ByteArray?): Boolean
31 | external fun setIdentityAvatar(databaseFolder: String, avatar: ByteArray): Boolean
32 | external fun removeIdentityAvatar(databaseFolder: String): Boolean
33 | external fun getIdentityAvatar(databaseFolder: String): ByteArray?
34 |
35 | fun init() {
36 | System.loadLibrary("aira")
37 | initLogging()
38 | }
39 |
40 | fun loadAvatar(avatarUuid: String?): ByteArray? {
41 | return avatarUuid?.let {
42 | getAvatar(it)
43 | }
44 | }
45 | }
--------------------------------------------------------------------------------
/app/src/main/java/sushi/hardcore/aira/ChatItem.kt:
--------------------------------------------------------------------------------
1 | package sushi.hardcore.aira
2 |
3 | import sushi.hardcore.aira.background_service.Protocol
4 | import java.util.*
5 |
6 | class ChatItem(val outgoing: Boolean, val timestamp: Long, val data: ByteArray) {
7 | companion object {
8 | const val OUTGOING_MESSAGE = 0
9 | const val INCOMING_MESSAGE = 1
10 | const val OUTGOING_FILE = 2
11 | const val INCOMING_FILE = 3
12 | }
13 | val itemType: Int by lazy {
14 | if (data[0] == Protocol.MESSAGE) {
15 | if (outgoing) OUTGOING_MESSAGE else INCOMING_MESSAGE
16 | } else {
17 | if (outgoing) OUTGOING_FILE else INCOMING_FILE
18 | }
19 | }
20 |
21 | val calendar: Calendar by lazy {
22 | Calendar.getInstance().apply {
23 | time = Date(timestamp * 1000)
24 | }
25 | }
26 | val year by lazy {
27 | calendar.get(Calendar.YEAR)
28 | }
29 | val dayOfYear by lazy {
30 | calendar.get(Calendar.DAY_OF_YEAR)
31 | }
32 | }
--------------------------------------------------------------------------------
/app/src/main/java/sushi/hardcore/aira/Constants.kt:
--------------------------------------------------------------------------------
1 | package sushi.hardcore.aira
2 |
3 | import android.content.Context
4 | import java.io.File
5 |
6 | object Constants {
7 | const val port = 7530
8 | const val mDNSServiceName = "AIRA Node"
9 | const val mDNSServiceType = "_aira._tcp"
10 | const val fileSizeLimit = 16380000
11 | const val MSG_LOADING_COUNT = 20
12 | const val FILE_CHUNK_SIZE = 1023996
13 | const val MAX_AVATAR_SIZE = 10000000
14 | private const val databaseName = "AIRA.db"
15 |
16 | fun getDatabaseFolder(context: Context): String {
17 | return getDatabasePath(context).parent!!
18 | }
19 |
20 | fun getDatabasePath(context: Context): File {
21 | return context.getDatabasePath(databaseName)
22 | }
23 | }
--------------------------------------------------------------------------------
/app/src/main/java/sushi/hardcore/aira/CreateIdentityFragment.kt:
--------------------------------------------------------------------------------
1 | package sushi.hardcore.aira
2 |
3 | import android.content.Context
4 | import android.os.Binder
5 | import android.os.Bundle
6 | import androidx.fragment.app.Fragment
7 | import android.view.LayoutInflater
8 | import android.view.View
9 | import android.view.ViewGroup
10 | import android.widget.Toast
11 | import androidx.appcompat.app.AppCompatActivity
12 | import com.bumptech.glide.Glide
13 | import sushi.hardcore.aira.databinding.FragmentCreateIdentityBinding
14 | import sushi.hardcore.aira.utils.AvatarPicker
15 |
16 | class CreateIdentityFragment(private val activity: AppCompatActivity) : Fragment() {
17 | private external fun createNewIdentity(databaseFolder: String, name: String, password: ByteArray?): Boolean
18 |
19 | companion object {
20 | fun newInstance(activity: AppCompatActivity, binder: Binder): CreateIdentityFragment {
21 | return CreateIdentityFragment(activity).apply {
22 | arguments = Bundle().apply {
23 | putBinder(LoginActivity.BINDER_ARG, binder)
24 | }
25 | }
26 | }
27 | }
28 |
29 | private val avatarPicker = AvatarPicker(activity) { avatar ->
30 | AIRADatabase.setIdentityAvatar(Constants.getDatabaseFolder(activity), avatar)
31 | Glide.with(this).load(avatar).circleCrop().into(binding.avatar)
32 | }
33 | private lateinit var binding: FragmentCreateIdentityBinding
34 |
35 | override fun onAttach(context: Context) {
36 | super.onAttach(context)
37 | avatarPicker.register()
38 | }
39 |
40 | override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
41 | binding = FragmentCreateIdentityBinding.inflate(inflater, container, false)
42 | return binding.root
43 | }
44 |
45 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
46 | binding.buttonSetAvatar.setOnClickListener {
47 | avatarPicker.launch()
48 | }
49 | binding.checkboxEnablePassword.setOnCheckedChangeListener { _, isChecked ->
50 | if (isChecked) {
51 | binding.editPassword.visibility = View.VISIBLE
52 | binding.editPasswordConfirm.visibility = View.VISIBLE
53 | } else {
54 | binding.editPassword.visibility = View.GONE
55 | binding.editPasswordConfirm.visibility = View.GONE
56 | }
57 | }
58 | binding.buttonCreate.setOnClickListener {
59 | val identityName = binding.editName.text.toString()
60 | val password = binding.editPassword.text.toString().toByteArray()
61 | if (password.isEmpty()) {
62 | createIdentity(identityName, null)
63 | } else {
64 | val passwordConfirm = binding.editPasswordConfirm.text.toString().toByteArray()
65 | if (password.contentEquals(passwordConfirm)) {
66 | createIdentity(identityName, password)
67 | } else {
68 | Toast.makeText(activity, R.string.password_mismatch, Toast.LENGTH_SHORT).show()
69 | }
70 | passwordConfirm.fill(0)
71 | password.fill(0)
72 | }
73 | }
74 | }
75 |
76 | private fun createIdentity(identityName: String, password: ByteArray?) {
77 | var success = false
78 | arguments?.let { bundle ->
79 | bundle.getBinder(LoginActivity.BINDER_ARG)?.let { binder ->
80 | val databaseFolder = Constants.getDatabaseFolder(requireContext())
81 | if (createNewIdentity(databaseFolder, identityName, password)) {
82 | (binder as LoginActivity.ActivityLauncher).launch()
83 | success = true
84 | }
85 | }
86 | }
87 | if (!success) {
88 | Toast.makeText(activity, R.string.identity_create_failed, Toast.LENGTH_SHORT).show()
89 | }
90 | }
91 | }
--------------------------------------------------------------------------------
/app/src/main/java/sushi/hardcore/aira/LoginActivity.kt:
--------------------------------------------------------------------------------
1 | package sushi.hardcore.aira
2 |
3 | import android.content.Intent
4 | import android.os.Binder
5 | import android.os.Bundle
6 | import android.widget.Toast
7 | import androidx.appcompat.app.AppCompatActivity
8 | import sushi.hardcore.aira.background_service.AIRAService
9 | import java.io.File
10 |
11 | class LoginActivity : AppCompatActivity() {
12 | companion object {
13 | const val NAME_ARG = "identityName"
14 | const val BINDER_ARG = "binder"
15 | }
16 |
17 | init {
18 | AIRADatabase.init()
19 | }
20 |
21 | inner class ActivityLauncher: Binder() {
22 | fun launch() {
23 | startMainActivity()
24 | }
25 | }
26 |
27 | override fun onCreate(savedInstanceState: Bundle?) {
28 | super.onCreate(savedInstanceState)
29 | setContentView(R.layout.activity_login)
30 | val databaseFolder = Constants.getDatabaseFolder(this)
31 | val dbFile = File(databaseFolder)
32 | if (!dbFile.isDirectory) {
33 | if (!dbFile.mkdir()) {
34 | Toast.makeText(this, R.string.db_mkdir_failed, Toast.LENGTH_SHORT).show()
35 | }
36 | }
37 | val isProtected = AIRADatabase.isIdentityProtected(databaseFolder)
38 | val name = AIRADatabase.getIdentityName(databaseFolder)
39 | if (AIRAService.isServiceRunning) {
40 | startMainActivity()
41 | } else if (name != null && !isProtected) {
42 | if (AIRADatabase.loadIdentity(databaseFolder, null)) {
43 | AIRADatabase.clearCache()
44 | startMainActivity()
45 | } else {
46 | Toast.makeText(this, R.string.identity_load_failed, Toast.LENGTH_SHORT).show()
47 | }
48 | } else {
49 | supportFragmentManager.beginTransaction()
50 | .add(
51 | R.id.fragment_container, if (name == null) {
52 | AIRADatabase.removeIdentityAvatar(databaseFolder)
53 | CreateIdentityFragment.newInstance(this, ActivityLauncher())
54 | } else {
55 | LoginFragment.newInstance(name, ActivityLauncher())
56 | }
57 | )
58 | .commit()
59 | }
60 | }
61 |
62 | private fun startMainActivity() {
63 | val mainActivityIntent = Intent(this, MainActivity::class.java)
64 | mainActivityIntent.action = intent.action
65 | mainActivityIntent.putExtras(intent)
66 | startActivity(mainActivityIntent)
67 | finish()
68 | }
69 | }
--------------------------------------------------------------------------------
/app/src/main/java/sushi/hardcore/aira/LoginFragment.kt:
--------------------------------------------------------------------------------
1 | package sushi.hardcore.aira
2 |
3 | import android.os.Bundle
4 | import android.view.LayoutInflater
5 | import android.view.View
6 | import android.view.ViewGroup
7 | import android.widget.Toast
8 | import androidx.fragment.app.Fragment
9 | import sushi.hardcore.aira.databinding.FragmentLoginBinding
10 |
11 | class LoginFragment : Fragment() {
12 | companion object {
13 | fun newInstance(name: String, binder: LoginActivity.ActivityLauncher): LoginFragment {
14 | return LoginFragment().apply {
15 | arguments = Bundle().apply {
16 | putBinder(LoginActivity.BINDER_ARG, binder)
17 | putString(LoginActivity.NAME_ARG, name)
18 | }
19 | }
20 | }
21 | }
22 |
23 | private lateinit var binding: FragmentLoginBinding
24 |
25 | override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
26 | binding = FragmentLoginBinding.inflate(inflater, container, false)
27 | return binding.root
28 | }
29 |
30 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
31 | arguments?.let { bundle ->
32 | bundle.getString(LoginActivity.NAME_ARG)?.let { name ->
33 | bundle.getBinder(LoginActivity.BINDER_ARG)?.let { binder ->
34 | val databaseFolder = Constants.getDatabaseFolder(requireContext())
35 | val avatar = AIRADatabase.getIdentityAvatar(databaseFolder)
36 | if (avatar == null) {
37 | binding.avatar.setTextAvatar(name)
38 | } else {
39 | binding.avatar.setImageAvatar(avatar)
40 | }
41 | binding.textIdentityName.text = name
42 | binding.buttonLogin.setOnClickListener {
43 | if (AIRADatabase.loadIdentity(databaseFolder, binding.editPassword.text.toString().toByteArray())) {
44 | AIRADatabase.clearCache()
45 | (binder as LoginActivity.ActivityLauncher).launch()
46 | } else {
47 | Toast.makeText(activity, R.string.identity_load_failed, Toast.LENGTH_SHORT).show()
48 | }
49 | }
50 | }
51 | }
52 | }
53 | }
54 | }
--------------------------------------------------------------------------------
/app/src/main/java/sushi/hardcore/aira/ServiceBoundActivity.kt:
--------------------------------------------------------------------------------
1 | package sushi.hardcore.aira
2 |
3 | import android.content.Context
4 | import android.content.Intent
5 | import android.content.ServiceConnection
6 | import androidx.appcompat.app.AppCompatActivity
7 | import sushi.hardcore.aira.background_service.AIRAService
8 |
9 | open class ServiceBoundActivity: AppCompatActivity() {
10 | protected lateinit var airaService: AIRAService
11 | protected lateinit var serviceConnection: ServiceConnection
12 | protected lateinit var serviceIntent: Intent
13 |
14 | protected fun isServiceInitialized(): Boolean {
15 | return ::airaService.isInitialized
16 | }
17 |
18 | override fun onPause() {
19 | super.onPause()
20 | if (::airaService.isInitialized) {
21 | airaService.isAppInBackground = true
22 | airaService.uiCallbacks = null
23 | unbindService(serviceConnection)
24 | }
25 | }
26 |
27 | override fun onResume() {
28 | super.onResume()
29 | if (!::serviceIntent.isInitialized) {
30 | serviceIntent = Intent(this, AIRAService::class.java)
31 | }
32 | bindService(serviceIntent, serviceConnection, Context.BIND_AUTO_CREATE)
33 | }
34 | }
--------------------------------------------------------------------------------
/app/src/main/java/sushi/hardcore/aira/SettingsActivity.kt:
--------------------------------------------------------------------------------
1 | package sushi.hardcore.aira
2 |
3 | import android.content.*
4 | import android.graphics.drawable.Drawable
5 | import android.os.Bundle
6 | import android.os.IBinder
7 | import android.view.MenuItem
8 | import android.view.View
9 | import android.widget.EditText
10 | import android.widget.Toast
11 | import androidx.appcompat.app.AlertDialog
12 | import androidx.appcompat.app.AppCompatActivity
13 | import androidx.preference.Preference
14 | import androidx.preference.PreferenceFragmentCompat
15 | import androidx.preference.SwitchPreferenceCompat
16 | import com.bumptech.glide.Glide
17 | import com.bumptech.glide.RequestBuilder
18 | import com.bumptech.glide.request.RequestOptions
19 | import com.bumptech.glide.request.target.CustomTarget
20 | import com.bumptech.glide.request.transition.Transition
21 | import sushi.hardcore.aira.background_service.AIRAService
22 | import sushi.hardcore.aira.databinding.ActivitySettingsBinding
23 | import sushi.hardcore.aira.databinding.ChangeAvatarDialogBinding
24 | import sushi.hardcore.aira.databinding.DialogEditTextBinding
25 | import sushi.hardcore.aira.utils.AvatarPicker
26 | import sushi.hardcore.aira.utils.StringUtils
27 |
28 | class SettingsActivity: AppCompatActivity() {
29 | class MySettingsFragment(private val activity: AppCompatActivity): PreferenceFragmentCompat() {
30 | private lateinit var databaseFolder: String
31 | private lateinit var airaService: AIRAService
32 | private val avatarPicker = AvatarPicker(activity) { avatar ->
33 | if (::airaService.isInitialized) {
34 | airaService.changeAvatar(avatar)
35 | }
36 | displayAvatar(avatar)
37 | }
38 | private lateinit var identityAvatarPreference: Preference
39 | private lateinit var startAtBootSwitch: SwitchPreferenceCompat
40 |
41 | override fun onAttach(context: Context) {
42 | super.onAttach(context)
43 | avatarPicker.register()
44 | }
45 |
46 | override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
47 | setPreferencesFromResource(R.xml.preferences, rootKey)
48 | databaseFolder = Constants.getDatabaseFolder(activity)
49 | findPreference("identityAvatar")?.let { identityAvatarPreference = it }
50 | startAtBootSwitch = findPreference("startAtBoot")!!
51 | updateStartAtBootSwitch(AIRADatabase.isIdentityProtected(databaseFolder))
52 | val paddingPreference = findPreference("psecPadding")
53 | paddingPreference?.isPersistent = false
54 | AIRADatabase.getIdentityAvatar(databaseFolder)?.let { avatar ->
55 | displayAvatar(avatar)
56 | }
57 | Intent(activity, AIRAService::class.java).also { serviceIntent ->
58 | activity.bindService(serviceIntent, object : ServiceConnection {
59 | override fun onServiceConnected(name: ComponentName?, service: IBinder) {
60 | val binder = service as AIRAService.AIRABinder
61 | airaService = binder.getService()
62 | paddingPreference?.isChecked = airaService.usePadding
63 | }
64 | override fun onServiceDisconnected(name: ComponentName?) {}
65 | }, Context.BIND_AUTO_CREATE)
66 | }
67 | identityAvatarPreference.setOnPreferenceClickListener {
68 | val dialogBuilder = AlertDialog.Builder(activity, R.style.CustomAlertDialog)
69 | .setTitle(R.string.your_avatar)
70 | .setPositiveButton(R.string.set_a_new_one) { _, _ ->
71 | avatarPicker.launch()
72 | }
73 | val dialogBinding = ChangeAvatarDialogBinding.inflate(layoutInflater)
74 | val avatar = AIRADatabase.getIdentityAvatar(databaseFolder)
75 | if (avatar == null) {
76 | dialogBinding.avatar.setTextAvatar(airaService.identityName)
77 | } else {
78 | dialogBinding.avatar.setImageAvatar(avatar)
79 | dialogBuilder.setNegativeButton(R.string.remove) { _, _ ->
80 | displayAvatar(null)
81 | airaService.changeAvatar(null)
82 | }
83 | }
84 | dialogBuilder.setView(dialogBinding.root).show()
85 | false
86 | }
87 | findPreference("identityName")?.setOnPreferenceClickListener {
88 | val dialogBinding = DialogEditTextBinding.inflate(layoutInflater)
89 | dialogBinding.editText.setText(airaService.identityName)
90 | AlertDialog.Builder(activity, R.style.CustomAlertDialog)
91 | .setTitle(it.title)
92 | .setView(dialogBinding.root)
93 | .setPositiveButton(R.string.ok) { _, _ ->
94 | airaService.changeName(dialogBinding.editText.text.toString())
95 | }
96 | .setNegativeButton(R.string.cancel, null)
97 | .show()
98 | false
99 | }
100 | findPreference("deleteIdentity")?.setOnPreferenceClickListener {
101 | AlertDialog.Builder(activity, R.style.CustomAlertDialog)
102 | .setMessage(R.string.confirm_delete)
103 | .setTitle(R.string.warning)
104 | .setPositiveButton(R.string.ok) { _, _ ->
105 | if (Constants.getDatabasePath(activity).delete()) {
106 | airaService.logOut()
107 | startActivity(Intent(activity, LoginActivity::class.java))
108 | activity.finish()
109 | }
110 | }
111 | .setNegativeButton(R.string.cancel, null)
112 | .show()
113 | false
114 | }
115 | findPreference("identityPassword")?.setOnPreferenceClickListener {
116 | val dialogView = layoutInflater.inflate(R.layout.dialog_password, null)
117 | val oldPasswordEditText = dialogView.findViewById(R.id.old_password)
118 | val isIdentityProtected = AIRADatabase.isIdentityProtected(databaseFolder)
119 | if (!isIdentityProtected) {
120 | oldPasswordEditText.visibility = View.GONE
121 | }
122 | val newPasswordEditText = dialogView.findViewById(R.id.new_password)
123 | val newPasswordConfirmEditText = dialogView.findViewById(R.id.new_password_confirm)
124 | AlertDialog.Builder(activity, R.style.CustomAlertDialog)
125 | .setView(dialogView)
126 | .setTitle(R.string.change_password)
127 | .setPositiveButton(R.string.ok) { _, _ ->
128 | val newPassword = newPasswordEditText.text.toString().toByteArray()
129 | val newPasswordConfirm = newPasswordConfirmEditText.text.toString().toByteArray()
130 | if (newPassword.contentEquals(newPasswordConfirm)) {
131 | if (newPassword.isEmpty()) {
132 | if (isIdentityProtected) { //don't change password if identity is not protected and new password is blank
133 | changePassword(isIdentityProtected, oldPasswordEditText, null)
134 | }
135 | } else {
136 | changePassword(isIdentityProtected, oldPasswordEditText, newPassword)
137 | }
138 | } else {
139 | AlertDialog.Builder(activity, R.style.CustomAlertDialog)
140 | .setMessage(R.string.password_mismatch)
141 | .setTitle(R.string.error)
142 | .setPositiveButton(R.string.ok, null)
143 | .show()
144 | }
145 | newPassword.fill(0)
146 | newPasswordConfirm.fill(0)
147 | }
148 | .setNegativeButton(R.string.cancel, null)
149 | .show()
150 | false
151 | }
152 | findPreference("fingerprint")?.let { fingerprintPreference ->
153 | val fingerprint = StringUtils.beautifyFingerprint(AIRADatabase.getIdentityFingerprint())
154 | fingerprintPreference.summary = fingerprint
155 | fingerprintPreference.setOnPreferenceClickListener {
156 | activity.getSystemService(CLIPBOARD_SERVICE)?.let { service ->
157 | val clipboardManager = service as ClipboardManager
158 | clipboardManager.setPrimaryClip(ClipData.newPlainText("", fingerprint))
159 | }
160 | Toast.makeText(activity, R.string.copied, Toast.LENGTH_SHORT).show()
161 | false
162 | }
163 | }
164 | paddingPreference?.setOnPreferenceChangeListener { _, checked ->
165 | airaService.usePadding = checked as Boolean
166 | AIRADatabase.setUsePadding(checked)
167 | true
168 | }
169 | }
170 |
171 | private fun displayAvatar(avatar: ByteArray?) {
172 | if (avatar == null) {
173 | identityAvatarPreference.setIcon(R.drawable.ic_face)
174 | } else {
175 | Glide
176 | .with(this)
177 | .load(avatar)
178 | .apply(RequestOptions().override(90)) //reduce image to be the same size as other icons
179 | .circleCrop()
180 | .into(object : CustomTarget() {
181 | override fun onResourceReady(resource: Drawable, transition: Transition?) {
182 | identityAvatarPreference.icon = resource
183 | }
184 | override fun onLoadCleared(placeholder: Drawable?) {}
185 | })
186 | }
187 | }
188 |
189 | private fun changePassword(isIdentityProtected: Boolean, oldPasswordEditText: EditText, newPassword: ByteArray?) {
190 | val oldPassword = if (isIdentityProtected) {
191 | oldPasswordEditText.text.toString().toByteArray()
192 | } else {
193 | null
194 | }
195 | if (AIRADatabase.changePassword(databaseFolder, oldPassword, newPassword)) {
196 | val isNowIdentityProtected = newPassword != null
197 | updateStartAtBootSwitch(isNowIdentityProtected)
198 | if (isIdentityProtected && !isNowIdentityProtected ) {
199 | startAtBootSwitch.isChecked = true
200 | }
201 | } else {
202 | AlertDialog.Builder(activity, R.style.CustomAlertDialog)
203 | .setMessage(R.string.change_password_failed)
204 | .setTitle(R.string.error)
205 | .setPositiveButton(R.string.ok, null)
206 | .show()
207 | }
208 | oldPassword?.fill(0)
209 | }
210 |
211 | private fun updateStartAtBootSwitch(isIdentityProtected: Boolean) {
212 | startAtBootSwitch.isEnabled = !isIdentityProtected
213 | startAtBootSwitch.summary = getString(if (isIdentityProtected) {
214 | R.string.start_at_boot_summary_identity_protected
215 | } else {
216 | R.string.start_at_boot_summary
217 | })
218 | }
219 | }
220 |
221 | override fun onOptionsItemSelected(item: MenuItem): Boolean {
222 | return if (item.itemId == android.R.id.home) {
223 | finish()
224 | true
225 | } else {
226 | super.onOptionsItemSelected(item)
227 | }
228 | }
229 |
230 | override fun onCreate(savedInstanceState: Bundle?) {
231 | super.onCreate(savedInstanceState)
232 | val binding = ActivitySettingsBinding.inflate(layoutInflater)
233 | setContentView(binding.root)
234 | supportFragmentManager
235 | .beginTransaction()
236 | .replace(R.id.settings_container, MySettingsFragment(this))
237 | .commit()
238 | supportActionBar?.setDisplayHomeAsUpEnabled(true)
239 | }
240 | }
--------------------------------------------------------------------------------
/app/src/main/java/sushi/hardcore/aira/adapters/FuckRecyclerView.kt:
--------------------------------------------------------------------------------
1 | package sushi.hardcore.aira.adapters
2 |
3 | import android.content.Context
4 | import android.util.AttributeSet
5 | import androidx.recyclerview.widget.LinearLayoutManager
6 |
7 | //https://stackoverflow.com/questions/36724898/notifyitemchanged-make-the-recyclerview-scroll-and-jump-to-up
8 |
9 | class FuckRecyclerView(
10 | context: Context,
11 | attrs: AttributeSet? = null,
12 | defStyleAttr: Int = 0,
13 | defStyleRes: Int = 0,
14 | ): LinearLayoutManager(context, attrs, defStyleAttr, defStyleRes) {
15 | override fun isAutoMeasureEnabled(): Boolean {
16 | return false
17 | }
18 | }
--------------------------------------------------------------------------------
/app/src/main/java/sushi/hardcore/aira/adapters/Session.kt:
--------------------------------------------------------------------------------
1 | package sushi.hardcore.aira.adapters
2 |
3 | class Session(
4 | val sessionId: Int,
5 | val isContact: Boolean,
6 | val isVerified: Boolean,
7 | var seen: Boolean,
8 | val ip: String?,
9 | var name: String?,
10 | var avatar: ByteArray?,
11 | )
--------------------------------------------------------------------------------
/app/src/main/java/sushi/hardcore/aira/adapters/SessionAdapter.kt:
--------------------------------------------------------------------------------
1 | package sushi.hardcore.aira.adapters
2 |
3 | import android.graphics.Color
4 | import android.view.LayoutInflater
5 | import android.view.View
6 | import android.view.ViewGroup
7 | import android.widget.*
8 | import androidx.appcompat.app.AppCompatActivity
9 | import androidx.core.content.ContextCompat
10 | import sushi.hardcore.aira.R
11 | import sushi.hardcore.aira.widgets.Avatar
12 |
13 | class SessionAdapter(private val activity: AppCompatActivity): BaseAdapter() {
14 | private val sessions = mutableListOf()
15 | private val inflater: LayoutInflater = LayoutInflater.from(activity)
16 | val selectedItems = mutableListOf()
17 |
18 | override fun getCount(): Int {
19 | return sessions.size
20 | }
21 |
22 | override fun getItem(position: Int): Session {
23 | return sessions[position]
24 | }
25 |
26 | override fun getItemId(position: Int): Long {
27 | return 0
28 | }
29 |
30 | override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
31 | val view: View = convertView ?: inflater.inflate(R.layout.adapter_session, parent, false)
32 | val currentSession = getItem(position)
33 | view.findViewById(R.id.text_name).apply {
34 | setTextColor(if (currentSession.name == null) {
35 | text = currentSession.ip
36 | Color.RED
37 | } else {
38 | text = currentSession.name
39 | Color.WHITE
40 | })
41 | val avatar = view.findViewById(R.id.avatar)
42 | if (currentSession.avatar == null) {
43 | avatar.setTextAvatar(currentSession.name)
44 | } else {
45 | avatar.setImageAvatar(currentSession.avatar!!)
46 | }
47 | }
48 | view.findViewById(R.id.image_trust_level).apply {
49 | if (currentSession.isVerified) {
50 | setImageResource(R.drawable.ic_verified)
51 | } else if (!currentSession.isContact) {
52 | setImageResource(R.drawable.ic_warning)
53 | } else {
54 | setImageDrawable(null)
55 | }
56 | }
57 | view.findViewById(R.id.marker_not_seen).visibility = if (currentSession.seen) {
58 | View.GONE
59 | } else {
60 | View.VISIBLE
61 | }
62 | view.findViewById(R.id.image_arrow).setColorFilter(ContextCompat.getColor(activity, if (currentSession.seen) {
63 | R.color.sessionArrow
64 | } else {
65 | R.color.secondary
66 | }))
67 | view.setBackgroundColor(ContextCompat.getColor(activity, if (selectedItems.contains(position)) {
68 | R.color.itemSelected
69 | } else {
70 | R.color.sessionBackground
71 | }))
72 | return view
73 | }
74 |
75 | fun add(session: Session) {
76 | sessions.add(session)
77 | notifyDataSetChanged()
78 | }
79 |
80 | private fun getSessionById(sessionId: Int): Session? {
81 | for (session in sessions){
82 | if (session.sessionId == sessionId) {
83 | return session
84 | }
85 | }
86 | return null
87 | }
88 |
89 | fun remove(sessionId: Int): Session? {
90 | getSessionById(sessionId)?.let {
91 | sessions.remove(it)
92 | notifyDataSetChanged()
93 | return it
94 | }
95 | return null
96 | }
97 |
98 | fun setName(sessionId: Int, name: String) {
99 | getSessionById(sessionId)?.let {
100 | it.name = name
101 | notifyDataSetChanged()
102 | }
103 | }
104 |
105 | fun setAvatar(sessionId: Int, avatar: ByteArray?) {
106 | getSessionById(sessionId)?.let {
107 | it.avatar = avatar
108 | notifyDataSetChanged()
109 | }
110 | }
111 |
112 | fun setSeen(sessionId: Int, seen: Boolean) {
113 | getSessionById(sessionId)?.let {
114 | it.seen = seen
115 | notifyDataSetChanged()
116 | }
117 | }
118 |
119 | fun reset() {
120 | sessions.clear()
121 | notifyDataSetChanged()
122 | }
123 |
124 | fun onSelectionChanged(position: Int) {
125 | if (!selectedItems.contains(position)) {
126 | selectedItems.add(position)
127 | } else {
128 | selectedItems.remove(position)
129 | }
130 | notifyDataSetChanged()
131 | }
132 |
133 | fun unSelectAll() {
134 | selectedItems.clear()
135 | notifyDataSetChanged()
136 | }
137 |
138 | fun getSelectedSessionIds(): List {
139 | return selectedItems.map { position -> sessions[position].sessionId }
140 | }
141 | }
--------------------------------------------------------------------------------
/app/src/main/java/sushi/hardcore/aira/background_service/ApplicationKeys.kt:
--------------------------------------------------------------------------------
1 | package sushi.hardcore.aira.background_service
2 |
3 | class ApplicationKeys(
4 | val localKey: ByteArray,
5 | val localIv: ByteArray,
6 | val peerKey: ByteArray,
7 | val peerIv: ByteArray,
8 | )
--------------------------------------------------------------------------------
/app/src/main/java/sushi/hardcore/aira/background_service/Contact.kt:
--------------------------------------------------------------------------------
1 | package sushi.hardcore.aira.background_service
2 |
3 | class Contact(
4 | val uuid: String,
5 | val publicKey: ByteArray,
6 | var name: String,
7 | var avatar: String?,
8 | var verified: Boolean,
9 | var seen: Boolean
10 | )
--------------------------------------------------------------------------------
/app/src/main/java/sushi/hardcore/aira/background_service/FileTransferNotification.kt:
--------------------------------------------------------------------------------
1 | package sushi.hardcore.aira.background_service
2 |
3 | import android.app.PendingIntent
4 | import android.content.Context
5 | import android.content.Intent
6 | import android.os.Build
7 | import androidx.core.app.NotificationCompat
8 | import androidx.core.app.NotificationManagerCompat
9 | import sushi.hardcore.aira.R
10 |
11 | class FileTransferNotification(
12 | private val context: Context,
13 | private val notificationManager: NotificationManagerCompat,
14 | private val total: Int,
15 | ) {
16 | private var fileName: String? = null
17 | private var fileSize = -1
18 | private var index = 0
19 | private var transferred = 0
20 | private lateinit var notificationBuilder: NotificationCompat.Builder
21 | private var notificationId = -1
22 | private var isEnded = false
23 |
24 | fun initFileTransferNotification(id: Int, fileName: String, size: Int, cancelIntent: Intent) {
25 | this.fileName = fileName
26 | fileSize = size
27 | index += 1
28 | transferred = 0
29 | notificationBuilder = NotificationCompat.Builder(context, AIRAService.FILE_TRANSFER_NOTIFICATION_CHANNEL_ID)
30 | .setCategory(NotificationCompat.CATEGORY_PROGRESS)
31 | .setSmallIcon(R.drawable.ic_launcher)
32 | .setContentTitle(fileName)
33 | .setContentText("0% ($index/$total)")
34 | .setOngoing(true)
35 | .setProgress(fileSize, 0, true)
36 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH) {
37 | val cancelPendingIntent = PendingIntent.getBroadcast(context, transferred, cancelIntent, PendingIntent.FLAG_UPDATE_CURRENT)
38 | notificationBuilder.addAction(
39 | NotificationCompat.Action(
40 | R.drawable.ic_launcher,
41 | context.getString(R.string.cancel),
42 | cancelPendingIntent
43 | )
44 | )
45 | }
46 | synchronized(this) {
47 | if (!isEnded) {
48 | notificationId = id
49 | notificationManager.notify(notificationId, notificationBuilder.build())
50 | }
51 | }
52 | }
53 |
54 | fun updateNotificationProgress(size: Int) {
55 | transferred += size
56 | val percent = (transferred.toFloat()/fileSize)*100
57 | notificationBuilder
58 | .setContentText("${"%.2f".format(percent)}% ($index/$total)")
59 | .setProgress(fileSize, transferred, false)
60 | synchronized(this) {
61 | if (!isEnded) {
62 | notificationManager.notify(notificationId, notificationBuilder.build())
63 | }
64 | }
65 | }
66 |
67 | private fun endNotification(string: Int) {
68 | synchronized(this) {
69 | notificationManager.notify(
70 | notificationId,
71 | NotificationCompat.Builder(context, AIRAService.FILE_TRANSFER_NOTIFICATION_CHANNEL_ID)
72 | .setCategory(NotificationCompat.CATEGORY_EVENT)
73 | .setSmallIcon(R.drawable.ic_launcher)
74 | .setContentTitle(fileName)
75 | .setContentText(context.getString(string))
76 | .build()
77 | )
78 | isEnded = true
79 | }
80 | }
81 |
82 | fun onAborted() {
83 | if (::notificationBuilder.isInitialized) {
84 | endNotification(R.string.transfer_aborted)
85 | }
86 | }
87 |
88 | fun onCompleted() {
89 | endNotification(R.string.transfer_completed)
90 | }
91 |
92 | fun cancel() {
93 | notificationManager.cancel(notificationId)
94 | }
95 | }
--------------------------------------------------------------------------------
/app/src/main/java/sushi/hardcore/aira/background_service/FilesReceiver.kt:
--------------------------------------------------------------------------------
1 | package sushi.hardcore.aira.background_service
2 |
3 | import android.annotation.SuppressLint
4 | import android.app.AlertDialog
5 | import android.content.Context
6 | import androidx.appcompat.app.AppCompatActivity
7 | import androidx.core.app.NotificationManagerCompat
8 | import sushi.hardcore.aira.R
9 | import sushi.hardcore.aira.databinding.DialogAskFileBinding
10 | import sushi.hardcore.aira.utils.FileUtils
11 |
12 | class FilesReceiver(
13 | val files: List,
14 | private val onAccepted: (FilesReceiver) -> Unit,
15 | private val onAborted: (FilesReceiver) -> Unit,
16 | context: Context,
17 | notificationManager: NotificationManagerCompat,
18 | ): FilesTransfer(context, notificationManager, files.size) {
19 | var shouldAsk = true
20 |
21 | @SuppressLint("SetTextI18n")
22 | fun ask(activity: AppCompatActivity, sessionName: String) {
23 | val dialogBinding = DialogAskFileBinding.inflate(activity.layoutInflater)
24 | dialogBinding.textTitle.text = activity.getString(R.string.want_to_send_files, sessionName)+':'
25 | val filesInfo = StringBuilder()
26 | for (file in files) {
27 | filesInfo.appendLine(file.fileName+" ("+FileUtils.formatSize(file.fileSize)+')')
28 | }
29 | dialogBinding.textFilesInfo.text = filesInfo.substring(0, filesInfo.length-1)
30 | AlertDialog.Builder(activity, R.style.CustomAlertDialog)
31 | .setTitle(R.string.download_file_request)
32 | .setView(dialogBinding.root)
33 | .setCancelable(false)
34 | .setPositiveButton(R.string.download) { _, _ ->
35 | onAccepted(this)
36 | }
37 | .setNegativeButton(R.string.refuse) { _, _ ->
38 | onAborted(this)
39 | }
40 | .setOnDismissListener {
41 | shouldAsk = false
42 | }
43 | .show()
44 | }
45 | }
--------------------------------------------------------------------------------
/app/src/main/java/sushi/hardcore/aira/background_service/FilesSender.kt:
--------------------------------------------------------------------------------
1 | package sushi.hardcore.aira.background_service
2 |
3 | import android.content.Context
4 | import androidx.core.app.NotificationManagerCompat
5 |
6 | class FilesSender(
7 | val files: List,
8 | context: Context,
9 | notificationManager: NotificationManagerCompat,
10 | ): FilesTransfer(context, notificationManager, files.size) {
11 | val lastChunkSizes = mutableListOf()
12 | var nextChunk: ByteArray? = null
13 | val msgQueue = mutableListOf()
14 | }
--------------------------------------------------------------------------------
/app/src/main/java/sushi/hardcore/aira/background_service/FilesTransfer.kt:
--------------------------------------------------------------------------------
1 | package sushi.hardcore.aira.background_service
2 |
3 | import android.content.Context
4 | import androidx.core.app.NotificationManagerCompat
5 |
6 | open class FilesTransfer(context: Context, notificationManager: NotificationManagerCompat, total: Int) {
7 | val fileTransferNotification = FileTransferNotification(context, notificationManager, total)
8 | var index = 0
9 | }
--------------------------------------------------------------------------------
/app/src/main/java/sushi/hardcore/aira/background_service/HandshakeKeys.kt:
--------------------------------------------------------------------------------
1 | package sushi.hardcore.aira.background_service
2 |
3 | class HandshakeKeys(
4 | val localKey: ByteArray,
5 | val localIv: ByteArray,
6 | val localHandshakeTrafficSecret: ByteArray,
7 | val peerKey: ByteArray,
8 | val peerIv: ByteArray,
9 | val peerHandshakeTrafficSecret: ByteArray,
10 | val handshakeSecret: ByteArray
11 | )
--------------------------------------------------------------------------------
/app/src/main/java/sushi/hardcore/aira/background_service/NotificationBroadcastReceiver.kt:
--------------------------------------------------------------------------------
1 | package sushi.hardcore.aira.background_service
2 |
3 | import android.content.BroadcastReceiver
4 | import android.content.Context
5 | import android.content.Intent
6 | import androidx.core.app.RemoteInput
7 | import sushi.hardcore.aira.MainActivity
8 |
9 | class NotificationBroadcastReceiver: BroadcastReceiver() {
10 | companion object {
11 | const val ACTION_LOGOUT = "logout"
12 | const val ACTION_MARK_READ = "mark_read"
13 | const val ACTION_CANCEL_FILE_TRANSFER = "cancel"
14 | const val ACTION_REPLY = "reply"
15 | const val KEY_TEXT_REPLY = "key_text_reply"
16 | }
17 |
18 | override fun onReceive(context: Context, intent: Intent) {
19 | if (intent.action == ACTION_LOGOUT) {
20 | context.startActivity(Intent(context, MainActivity::class.java).apply {
21 | flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
22 | action = ACTION_LOGOUT
23 | })
24 | context.sendBroadcast(Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS))
25 | } else {
26 | intent.getBundleExtra("bundle")?.let { bundle ->
27 | (bundle.getBinder("binder") as AIRAService.AIRABinder?)?.let { binder ->
28 | val sessionId = bundle.getInt("sessionId")
29 | val airaService = binder.getService()
30 | when (intent.action) {
31 | ACTION_MARK_READ -> airaService.setSeen(sessionId, true)
32 | ACTION_CANCEL_FILE_TRANSFER -> airaService.cancelFileTransfer(sessionId)
33 | ACTION_REPLY -> RemoteInput.getResultsFromIntent(intent)?.getString(KEY_TEXT_REPLY)?.let { reply ->
34 | airaService.sendOrAddToPending(sessionId, Protocol.newMessage(reply))
35 | airaService.setSeen(sessionId, true)
36 | }
37 | else -> {}
38 | }
39 | }
40 | }
41 | }
42 | }
43 | }
--------------------------------------------------------------------------------
/app/src/main/java/sushi/hardcore/aira/background_service/NotificationIdManager.kt:
--------------------------------------------------------------------------------
1 | package sushi.hardcore.aira.background_service
2 |
3 | class NotificationIdManager {
4 |
5 | private enum class NotificationType {
6 | MESSAGE,
7 | FILE_TRANSFER
8 | }
9 |
10 | private inner class Notification(
11 | val sessionId: Int,
12 | val type: NotificationType
13 | )
14 |
15 |
16 | private val notificationIds = mutableMapOf()
17 | private var lastNotificationId = 1 //got some bugs when starting before 1
18 |
19 | private fun registerNewId(sessionId: Int, type: NotificationType): Int {
20 | lastNotificationId++
21 | notificationIds[lastNotificationId] = Notification(sessionId, type)
22 | return lastNotificationId
23 | }
24 |
25 | fun getMessageNotificationId(sessionId: Int): Int {
26 | for ((id, notification) in notificationIds) {
27 | if (notification.sessionId == sessionId) {
28 | if (notification.type == NotificationType.MESSAGE) {
29 | return id
30 | }
31 | }
32 | }
33 | return registerNewId(sessionId, NotificationType.MESSAGE)
34 | }
35 |
36 | fun getFileTransferNotificationId(sessionId: Int): Int {
37 | for ((id, notification) in notificationIds) {
38 | if (notification.sessionId == sessionId) {
39 | if (notification.type == NotificationType.FILE_TRANSFER) {
40 | return id
41 | }
42 | }
43 | }
44 | return registerNewId(sessionId, NotificationType.FILE_TRANSFER)
45 | }
46 | }
--------------------------------------------------------------------------------
/app/src/main/java/sushi/hardcore/aira/background_service/PendingFile.kt:
--------------------------------------------------------------------------------
1 | package sushi.hardcore.aira.background_service
2 |
3 | open class PendingFile(val fileName: String, val fileSize: Long) {
4 | var transferred = 0
5 | }
--------------------------------------------------------------------------------
/app/src/main/java/sushi/hardcore/aira/background_service/Protocol.kt:
--------------------------------------------------------------------------------
1 | package sushi.hardcore.aira.background_service
2 |
3 | import java.nio.ByteBuffer
4 |
5 | class Protocol {
6 | companion object {
7 | const val MESSAGE: Byte = 0x00
8 | const val FILE: Byte = 0x01
9 | const val ASK_PROFILE_INFO: Byte = 0x02
10 | const val NAME: Byte = 0x03
11 | const val AVATAR: Byte = 0x04
12 | const val REMOVE_AVATAR: Byte = 0x05
13 | const val ASK_LARGE_FILES: Byte = 0x06
14 | const val ACCEPT_LARGE_FILES: Byte = 0x07
15 | const val LARGE_FILE_CHUNK: Byte = 0x08
16 | const val ACK_CHUNK: Byte = 0x09
17 | const val ABORT_FILES_TRANSFER: Byte = 0x0a
18 |
19 | fun askProfileInfo(): ByteArray {
20 | return byteArrayOf(ASK_PROFILE_INFO)
21 | }
22 |
23 | fun name(name: String): ByteArray {
24 | return byteArrayOf(NAME)+name.toByteArray()
25 | }
26 |
27 | fun avatar(avatar: ByteArray): ByteArray {
28 | return byteArrayOf(AVATAR)+avatar
29 | }
30 |
31 | fun removeAvatar(): ByteArray {
32 | return byteArrayOf(REMOVE_AVATAR)
33 | }
34 |
35 | fun newMessage(msg: String): ByteArray {
36 | return byteArrayOf(MESSAGE)+msg.toByteArray()
37 | }
38 |
39 | fun newFile(fileName: String, buffer: ByteArray): ByteArray {
40 | val fileNameBytes = fileName.toByteArray()
41 | return byteArrayOf(FILE)+ByteBuffer.allocate(2).putShort(fileNameBytes.size.toShort()).array()+fileNameBytes+buffer
42 | }
43 |
44 | fun askLargeFiles(files: List): ByteArray {
45 | var buff = byteArrayOf(ASK_LARGE_FILES)
46 | for (file in files) {
47 | val fileName = file.fileName.toByteArray()
48 | buff += ByteBuffer.allocate(8).putLong(file.fileSize).array()
49 | buff += ByteBuffer.allocate(2).putShort(fileName.size.toShort()).array()
50 | buff += fileName
51 | }
52 | return buff
53 | }
54 |
55 | fun acceptLargeFiles(): ByteArray {
56 | return byteArrayOf(ACCEPT_LARGE_FILES)
57 | }
58 |
59 | fun abortFilesTransfer(): ByteArray {
60 | return byteArrayOf(ABORT_FILES_TRANSFER)
61 | }
62 |
63 | fun ackChunk(): ByteArray {
64 | return byteArrayOf(ACK_CHUNK)
65 | }
66 |
67 | class SmallFile(val rawFileName: ByteArray, val fileContent: ByteArray)
68 |
69 | fun parseSmallFile(buffer: ByteArray): SmallFile? {
70 | if (buffer.size > 3) {
71 | val filenameLen = ByteBuffer.wrap(ByteArray(2) +buffer.sliceArray(1..2)).int
72 | if (buffer.size > 3+filenameLen) {
73 | val rawFileName = buffer.sliceArray(3 until 3+filenameLen)
74 | return SmallFile(rawFileName, buffer.sliceArray(3+filenameLen until buffer.size))
75 | }
76 | }
77 | return null
78 | }
79 |
80 | fun parseAskFiles(buffer: ByteArray): List? {
81 | val files = mutableListOf()
82 | var n = 1
83 | while (n < buffer.size) {
84 | if (buffer.size > n+10) {
85 | val fileSize = ByteBuffer.wrap(buffer.sliceArray(n..n+8)).long
86 | val fileNameLen = ByteBuffer.wrap(buffer.sliceArray(n+8..n+10)).short
87 | if (buffer.size >= n+10+fileNameLen) {
88 | val fileName = buffer.sliceArray(n+10 until n+10+fileNameLen).decodeToString()
89 | files.add(ReceiveFile(fileName, fileSize))
90 | n += 10+fileNameLen
91 | } else {
92 | return null
93 | }
94 | } else {
95 | return null
96 | }
97 | }
98 | return files
99 | }
100 | }
101 | }
--------------------------------------------------------------------------------
/app/src/main/java/sushi/hardcore/aira/background_service/ReceiveFile.kt:
--------------------------------------------------------------------------------
1 | package sushi.hardcore.aira.background_service
2 |
3 | import java.io.OutputStream
4 |
5 | class ReceiveFile (
6 | fileName: String,
7 | fileSize: Long
8 | ): PendingFile(fileName, fileSize) {
9 | var outputStream: OutputStream? = null
10 | }
--------------------------------------------------------------------------------
/app/src/main/java/sushi/hardcore/aira/background_service/SendFile.kt:
--------------------------------------------------------------------------------
1 | package sushi.hardcore.aira.background_service
2 |
3 | import java.io.InputStream
4 |
5 | class SendFile(
6 | fileName: String,
7 | fileSize: Long,
8 | val inputStream: InputStream
9 | ): PendingFile(fileName, fileSize)
--------------------------------------------------------------------------------
/app/src/main/java/sushi/hardcore/aira/background_service/Session.kt:
--------------------------------------------------------------------------------
1 | package sushi.hardcore.aira.background_service
2 |
3 | import android.util.Log
4 | import net.i2p.crypto.eddsa.EdDSAEngine
5 | import net.i2p.crypto.eddsa.EdDSAPublicKey
6 | import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable
7 | import net.i2p.crypto.eddsa.spec.EdDSAPublicKeySpec
8 | import org.whispersystems.curve25519.Curve25519
9 | import sushi.hardcore.aira.AIRADatabase
10 | import java.io.ByteArrayOutputStream
11 | import java.io.OutputStream
12 | import java.nio.ByteBuffer
13 | import java.nio.channels.*
14 | import java.nio.channels.spi.SelectorProvider
15 | import java.security.MessageDigest
16 | import java.security.SecureRandom
17 | import javax.crypto.AEADBadTagException
18 | import javax.crypto.BadPaddingException
19 | import javax.crypto.Cipher
20 | import javax.crypto.spec.GCMParameterSpec
21 | import javax.crypto.spec.SecretKeySpec
22 | import kotlin.experimental.xor
23 |
24 | class Session(private val socket: SocketChannel, val outgoing: Boolean): SelectableChannel() {
25 | private external fun deriveHandshakeKeys(sharedSecret: ByteArray, handshakeHash: ByteArray, iAmBob: Boolean): HandshakeKeys
26 | private external fun sign(input: ByteArray): ByteArray
27 | private external fun computeHandshakeFinished(localHandshakeTrafficSecret: ByteArray, handshakeHash: ByteArray): ByteArray
28 | private external fun verifyHandshakeFinished(peerHandshakeFinished: ByteArray, peerHandshakeTrafficSecret: ByteArray, handshakeHash: ByteArray): Boolean
29 | private external fun deriveApplicationKeys(handshakeSecret: ByteArray, handshakeHash: ByteArray, iAmBob: Boolean): ApplicationKeys
30 |
31 | companion object {
32 | private const val RANDOM_LEN = 64
33 | private const val PUBLIC_KEY_LEN = 32
34 | private const val SIGNATURE_LEN = 64
35 | private const val AES_TAG_LEN = 16
36 | private const val IV_LEN = 12
37 | private const val HASH_OUTPUT_LEN = 48
38 | private const val handshakeBufferLen = (2*(RANDOM_LEN+PUBLIC_KEY_LEN))+SIGNATURE_LEN+AES_TAG_LEN
39 | private const val CIPHER_TYPE = "AES/GCM/NoPadding"
40 | private const val MESSAGE_LEN_LEN = 4
41 | private const val PADDED_MAX_SIZE = 16384000
42 | private const val MAX_RECV_SIZE = PADDED_MAX_SIZE + AES_TAG_LEN
43 | }
44 |
45 | private val prng = SecureRandom()
46 | private val peerCipher = Cipher.getInstance(CIPHER_TYPE)
47 | private val localCipher = Cipher.getInstance(CIPHER_TYPE)
48 | private var peerCounter = 0L
49 | private var localCounter = 0L
50 | private lateinit var applicationKeys: ApplicationKeys
51 | lateinit var peerPublicKey: ByteArray
52 | val ip: String = socket.socket().inetAddress.hostAddress
53 |
54 | private fun handshakeWrite(buffer: ByteArray, handshakeSentBuff: OutputStream) {
55 | writeAll(buffer)
56 | handshakeSentBuff.write(buffer)
57 | }
58 |
59 | private fun handshakeRead(buffer: ByteBuffer, handshakeRecvBuff: OutputStream): Boolean {
60 | return if (readAll(buffer)) {
61 | handshakeRecvBuff.write(buffer.array())
62 | true
63 | } else {
64 | false
65 | }
66 | }
67 |
68 | private fun hashHandshake(iAmBob: Boolean, handshakeSentBuff: ByteArray, handshakeRecvBuff: ByteArray): ByteArray {
69 | MessageDigest.getInstance("SHA-384").apply {
70 | if (iAmBob) {
71 | update(handshakeSentBuff)
72 | update(handshakeRecvBuff)
73 | } else {
74 | update(handshakeRecvBuff)
75 | update(handshakeSentBuff)
76 | }
77 | return digest()
78 | }
79 | }
80 |
81 | private fun amIBob(handshakeSentBuff: ByteArray, handshakeRecvBuff: ByteArray): Boolean {
82 | for (i in handshakeSentBuff.indices) {
83 | if (handshakeSentBuff[i] != handshakeRecvBuff[i]) {
84 | return handshakeSentBuff[i].toInt() and 0xff < handshakeRecvBuff[i].toInt() and 0xff
85 | }
86 | }
87 | throw SecurityException("Handshake buffers are identical")
88 | }
89 |
90 | private fun ivToNonce(iv: ByteArray, counter: Long): ByteArray {
91 | val nonce = ByteArray(IV_LEN-Long.SIZE_BYTES)+ByteBuffer.allocate(Long.SIZE_BYTES).putLong(counter).array()
92 | for (i in nonce.indices) {
93 | nonce[i] = nonce[i] xor iv[i]
94 | }
95 | return nonce
96 | }
97 |
98 | fun doHandshake(): Boolean {
99 | val handshakeSentBuff = ByteArrayOutputStream(handshakeBufferLen)
100 | val handshakeRecvBuff = ByteArrayOutputStream(handshakeBufferLen)
101 |
102 | val randomBuffer = ByteArray(RANDOM_LEN)
103 | prng.nextBytes(randomBuffer)
104 | val curve25519Cipher = Curve25519.getInstance(Curve25519.BEST)
105 | val keypair = curve25519Cipher.generateKeyPair()
106 | handshakeWrite(randomBuffer+keypair.publicKey, handshakeSentBuff)
107 |
108 | var recvBuffer = ByteBuffer.allocate(RANDOM_LEN+PUBLIC_KEY_LEN)
109 | if (handshakeRead(recvBuffer, handshakeRecvBuff)) {
110 | val peerEphemeralPublicKey = recvBuffer.array().sliceArray(RANDOM_LEN until recvBuffer.capacity())
111 | val sharedSecret = curve25519Cipher.calculateAgreement(peerEphemeralPublicKey, keypair.privateKey)
112 | val iAmBob = amIBob(handshakeSentBuff.toByteArray(), handshakeRecvBuff.toByteArray()) //mutual consensus for keys attribution
113 | var handshakeHash = hashHandshake(iAmBob, handshakeSentBuff.toByteArray(), handshakeRecvBuff.toByteArray())
114 | val handshakeKeys = deriveHandshakeKeys(sharedSecret, handshakeHash, iAmBob)
115 |
116 | prng.nextBytes(randomBuffer)
117 | val localCipher = Cipher.getInstance(CIPHER_TYPE)
118 | localCipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(handshakeKeys.localKey, "AES"), GCMParameterSpec(AES_TAG_LEN*8, handshakeKeys.localIv))
119 | handshakeWrite(localCipher.doFinal(randomBuffer+AIRADatabase.getIdentityPublicKey()+sign(keypair.publicKey)), handshakeSentBuff)
120 |
121 | recvBuffer = ByteBuffer.allocate(RANDOM_LEN+PUBLIC_KEY_LEN+SIGNATURE_LEN+AES_TAG_LEN)
122 | if (handshakeRead(recvBuffer, handshakeRecvBuff)) {
123 | val peerCipher = Cipher.getInstance(CIPHER_TYPE)
124 | peerCipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(handshakeKeys.peerKey, "AES"), GCMParameterSpec(AES_TAG_LEN*8, handshakeKeys.peerIv))
125 | val plainText: ByteArray
126 | try {
127 | plainText = peerCipher.doFinal(recvBuffer.array())
128 | } catch (e: BadPaddingException) {
129 | Log.w("BadPaddingException", ip)
130 | return false
131 | } catch (e: AEADBadTagException) {
132 | Log.w("AEADBadTagException", ip)
133 | return false
134 | }
135 | peerPublicKey = plainText.sliceArray(RANDOM_LEN until RANDOM_LEN+PUBLIC_KEY_LEN)
136 | val signature = plainText.sliceArray(RANDOM_LEN+PUBLIC_KEY_LEN until plainText.size)
137 |
138 | val edDSAEngine = EdDSAEngine().apply {
139 | initVerify(EdDSAPublicKey(EdDSAPublicKeySpec(peerPublicKey, EdDSANamedCurveTable.ED_25519_CURVE_SPEC)))
140 | }
141 | if (edDSAEngine.verifyOneShot(peerEphemeralPublicKey, signature)) {
142 | handshakeHash = hashHandshake(iAmBob, handshakeSentBuff.toByteArray(), handshakeRecvBuff.toByteArray())
143 | val handshakeFinished = computeHandshakeFinished(handshakeKeys.localHandshakeTrafficSecret, handshakeHash)
144 | writeAll(handshakeFinished)
145 | val peerHandshakeFinished = ByteBuffer.allocate(HASH_OUTPUT_LEN)
146 | socket.read(peerHandshakeFinished)
147 | if (verifyHandshakeFinished(peerHandshakeFinished.array(), handshakeKeys.peerHandshakeTrafficSecret, handshakeHash)){
148 | applicationKeys = deriveApplicationKeys(handshakeKeys.handshakeSecret, handshakeHash, iAmBob)
149 | return true
150 | } else {
151 | Log.w("Handshake", "Final verification failed")
152 | }
153 | } else {
154 | Log.w("Handshake", "Signature verification failed")
155 | }
156 | }
157 | }
158 | return false
159 | }
160 |
161 | private fun pad(input: ByteArray, usePadding: Boolean): ByteArray {
162 | val encodedLen = ByteBuffer.allocate(MESSAGE_LEN_LEN).putInt(input.size).array()
163 | return if (usePadding) {
164 | val msgLen = input.size + MESSAGE_LEN_LEN
165 | var len = 1000
166 | while (len < msgLen) {
167 | len *= 2
168 | }
169 | val padding = ByteArray(len-msgLen)
170 | prng.nextBytes(padding)
171 | encodedLen + input + padding
172 | } else {
173 | encodedLen + input
174 | }
175 | }
176 |
177 | private fun unpad(input: ByteArray): ByteArray {
178 | val messageLen = ByteBuffer.wrap(input.sliceArray(0..MESSAGE_LEN_LEN)).int
179 | return input.sliceArray(MESSAGE_LEN_LEN until MESSAGE_LEN_LEN+messageLen)
180 | }
181 |
182 | fun writeAll(buffer: ByteArray) {
183 | val byteBuffer = ByteBuffer.wrap(buffer)
184 | while (byteBuffer.remaining() > 0) {
185 | socket.write(byteBuffer)
186 | }
187 | }
188 |
189 | fun encrypt(plainText: ByteArray, usePadding: Boolean): ByteArray {
190 | val padded = pad(plainText, usePadding)
191 | val rawMsgLen = ByteBuffer.allocate(MESSAGE_LEN_LEN).putInt(padded.size+AES_TAG_LEN).array()
192 | val nonce = ivToNonce(applicationKeys.localIv, localCounter)
193 | localCounter++
194 | localCipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(applicationKeys.localKey, "AES"), GCMParameterSpec(AES_TAG_LEN*8, nonce))
195 | localCipher.updateAAD(rawMsgLen)
196 | return rawMsgLen+localCipher.doFinal(padded)
197 | }
198 |
199 | fun encryptAndSend(plainText: ByteArray, usePadding: Boolean) {
200 | writeAll(encrypt(plainText, usePadding))
201 | }
202 |
203 | fun ByteArray.toHexString() = joinToString("") { "%02x".format(it) }
204 |
205 | private fun readAll(buffer: ByteBuffer): Boolean {
206 | while (buffer.position() != buffer.capacity()) {
207 | try {
208 | if (socket.read(buffer) < 0) {
209 | return false
210 | }
211 | } catch (e: ClosedChannelException) {
212 | return false
213 | }
214 | }
215 | return true
216 | }
217 |
218 | fun receiveAndDecrypt(): ByteArray? {
219 | val rawMessageLen = ByteBuffer.allocate(MESSAGE_LEN_LEN)
220 | if (readAll(rawMessageLen)) {
221 | rawMessageLen.position(0)
222 | val messageLen = rawMessageLen.int
223 | if (messageLen in 1..MAX_RECV_SIZE) {
224 | val cipherText = ByteBuffer.allocate(messageLen)
225 | if (readAll(cipherText)) {
226 | val nonce = ivToNonce(applicationKeys.peerIv, peerCounter)
227 | peerCounter++
228 | peerCipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(applicationKeys.peerKey, "AES"), GCMParameterSpec(AES_TAG_LEN*8, nonce))
229 | rawMessageLen.position(0)
230 | peerCipher.updateAAD(rawMessageLen)
231 | try {
232 | return unpad(peerCipher.doFinal(cipherText.array()))
233 | } catch (e: AEADBadTagException) {
234 | Log.w("AEADBadTagException", ip)
235 | }
236 | }
237 | } else {
238 | Log.w("Message too large", "$messageLen from $ip")
239 | }
240 | }
241 | return null
242 | }
243 |
244 | override fun implCloseChannel() {
245 | socket.close()
246 | }
247 | override fun provider(): SelectorProvider {
248 | return socket.provider()
249 | }
250 | override fun validOps(): Int {
251 | return socket.validOps()
252 | }
253 | override fun isRegistered(): Boolean {
254 | return socket.isRegistered
255 | }
256 | override fun keyFor(sel: Selector?): SelectionKey {
257 | return socket.keyFor(sel)
258 | }
259 | override fun register(sel: Selector?, ops: Int, att: Any?): SelectionKey {
260 | return socket.register(sel, ops, att)
261 | }
262 | override fun configureBlocking(block: Boolean): SelectableChannel {
263 | return socket.configureBlocking(block)
264 | }
265 | override fun isBlocking(): Boolean {
266 | return socket.isBlocking
267 | }
268 | override fun blockingLock(): Any {
269 | return socket.blockingLock()
270 | }
271 | }
--------------------------------------------------------------------------------
/app/src/main/java/sushi/hardcore/aira/background_service/SystemBroadcastReceiver.kt:
--------------------------------------------------------------------------------
1 | package sushi.hardcore.aira.background_service
2 |
3 | import android.content.BroadcastReceiver
4 | import android.content.Context
5 | import android.content.Intent
6 | import android.os.Build
7 | import androidx.preference.PreferenceManager
8 | import sushi.hardcore.aira.AIRADatabase
9 | import sushi.hardcore.aira.Constants
10 |
11 | class SystemBroadcastReceiver: BroadcastReceiver() {
12 | init {
13 | AIRADatabase.init()
14 | }
15 |
16 | override fun onReceive(context: Context, intent: Intent) {
17 | if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
18 | if (PreferenceManager.getDefaultSharedPreferences(context).getBoolean("startAtBoot", true) && !AIRAService.isServiceRunning) {
19 | val databaseFolder = Constants.getDatabaseFolder(context)
20 | val isProtected = AIRADatabase.isIdentityProtected(databaseFolder)
21 | val name = AIRADatabase.getIdentityName(databaseFolder)
22 | if (name != null && !isProtected) {
23 | if (AIRADatabase.loadIdentity(databaseFolder, null)) {
24 | AIRADatabase.clearCache()
25 | val serviceIntent = Intent(context, AIRAService::class.java)
26 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
27 | context.startForegroundService(serviceIntent)
28 | } else {
29 | context.startService(serviceIntent)
30 | }
31 | }
32 | }
33 | }
34 | }
35 | }
36 | }
--------------------------------------------------------------------------------
/app/src/main/java/sushi/hardcore/aira/utils/AvatarPicker.kt:
--------------------------------------------------------------------------------
1 | package sushi.hardcore.aira.utils
2 |
3 | import android.widget.Toast
4 | import androidx.activity.result.ActivityResultLauncher
5 | import androidx.activity.result.contract.ActivityResultContracts
6 | import androidx.appcompat.app.AppCompatActivity
7 | import sushi.hardcore.aira.Constants
8 | import sushi.hardcore.aira.R
9 |
10 | class AvatarPicker(
11 | private val activity: AppCompatActivity,
12 | private val onAvatarPicked: (ByteArray) -> Unit,
13 | ) {
14 | private lateinit var picker: ActivityResultLauncher
15 | fun register() {
16 | picker = activity.registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
17 | if (uri != null) {
18 | activity.contentResolver.openInputStream(uri)?.let { stream ->
19 | val image = stream.readBytes()
20 | stream.close()
21 | if (image.size > Constants.MAX_AVATAR_SIZE) {
22 | Toast.makeText(activity, R.string.avatar_too_large, Toast.LENGTH_SHORT).show()
23 | } else {
24 | onAvatarPicked(image)
25 | }
26 | }
27 | }
28 | }
29 | }
30 |
31 | fun launch() {
32 | picker.launch("image/*")
33 | }
34 | }
--------------------------------------------------------------------------------
/app/src/main/java/sushi/hardcore/aira/utils/FileUtils.kt:
--------------------------------------------------------------------------------
1 | package sushi.hardcore.aira.utils
2 |
3 | import android.content.ContentValues
4 | import android.content.Context
5 | import android.content.Intent
6 | import android.net.Uri
7 | import android.os.Environment
8 | import android.provider.MediaStore
9 | import android.provider.OpenableColumns
10 | import android.webkit.MimeTypeMap
11 | import android.widget.Toast
12 | import sushi.hardcore.aira.background_service.SendFile
13 | import java.io.File
14 | import java.io.FileNotFoundException
15 | import java.io.OutputStream
16 | import java.text.DecimalFormat
17 | import java.text.SimpleDateFormat
18 | import java.util.*
19 | import kotlin.math.log10
20 | import kotlin.math.pow
21 |
22 | object FileUtils {
23 | private val units = arrayOf("B", "kB", "MB", "GB", "TB")
24 | fun formatSize(size: Long): String {
25 | if (size <= 0) {
26 | return "0 B"
27 | }
28 | val digitGroups = (log10(size.toDouble()) / log10(1000.0)).toInt()
29 | return DecimalFormat("#,##0.#").format(size / 1000.0.pow(digitGroups.toDouble())
30 | ) + " " + units[digitGroups]
31 | }
32 |
33 | class SendFileResult(val file: SendFile? = null, val errorHandled: Boolean = false)
34 |
35 | fun openFileFromUri(context: Context, uri: Uri): SendFileResult {
36 | var sendFile: SendFile? = null
37 | try {
38 | context.grantUriPermission(context.packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
39 | } catch (e: SecurityException) {
40 | Toast.makeText(context, e.localizedMessage, Toast.LENGTH_LONG).show()
41 | return SendFileResult(sendFile, true)
42 | }
43 | val cursor = context.contentResolver.query(uri, null, null, null, null)
44 | if (cursor != null) {
45 | if (cursor.moveToFirst()) {
46 | try {
47 | context.contentResolver.openInputStream(uri)?.let { inputStream ->
48 | val fileName = cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME))
49 | val fileSize = cursor.getLong(cursor.getColumnIndex(OpenableColumns.SIZE))
50 | sendFile = SendFile(fileName, fileSize, inputStream)
51 | }
52 | } catch (e: FileNotFoundException) {
53 | Toast.makeText(context, e.localizedMessage, Toast.LENGTH_SHORT).show()
54 | }
55 | }
56 | cursor.close()
57 | }
58 | return SendFileResult(sendFile)
59 | }
60 |
61 | class DownloadFile(val fileName: String, val outputStream: OutputStream?)
62 |
63 | fun openFileForDownload(context: Context, fileName: String): DownloadFile {
64 | val fileExtension = fileName.substringAfterLast(".")
65 | val dateExtension = SimpleDateFormat("yyyyMMddHHmmss", Locale.getDefault()).format(Date())
66 | val datedFilename = if (fileName.contains(".")) {
67 | val basename = fileName.substringBeforeLast(".")
68 | """${basename}_$dateExtension.$fileExtension"""
69 | } else {
70 | fileName + "_" + dateExtension
71 | }
72 | val outputStream = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
73 | context.contentResolver.insert(
74 | MediaStore.Downloads.EXTERNAL_CONTENT_URI,
75 | ContentValues().apply {
76 | put(MediaStore.Images.Media.TITLE, datedFilename)
77 | put(MediaStore.Images.Media.DISPLAY_NAME, datedFilename)
78 | put(MediaStore.Images.Media.MIME_TYPE, MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension))
79 | }
80 | )?.let {
81 | context.contentResolver.openOutputStream(it)
82 | }
83 | } else {
84 | @Suppress("Deprecation")
85 | File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), File(datedFilename).name).outputStream()
86 | }
87 | return DownloadFile(datedFilename, outputStream)
88 | }
89 | }
--------------------------------------------------------------------------------
/app/src/main/java/sushi/hardcore/aira/utils/StringUtils.kt:
--------------------------------------------------------------------------------
1 | package sushi.hardcore.aira.utils
2 |
3 | import java.net.InetAddress
4 |
5 | object StringUtils {
6 | fun beautifyFingerprint(fingerprint: String): String {
7 | val newFingerprint = StringBuilder(fingerprint.length+7)
8 | for (i in 0..fingerprint.length-8 step 4) {
9 | newFingerprint.append(fingerprint.slice(i until i+4)+" ")
10 | }
11 | newFingerprint.append(fingerprint.slice(fingerprint.length-4 until fingerprint.length))
12 | return newFingerprint.toString()
13 | }
14 |
15 | fun getIpFromInetAddress(addr: InetAddress): String {
16 | val rawIp = addr.hostAddress
17 | val i = rawIp.lastIndexOf('%')
18 | return if (i == -1) {
19 | rawIp
20 | } else {
21 | rawIp.substring(0, i)
22 | }
23 | }
24 |
25 | fun sanitizeName(name: String): String {
26 | return name.replace('\n', ' ')
27 | }
28 |
29 | fun toTwoDigits(number: Int): String {
30 | return if (number < 10) {
31 | "0$number"
32 | } else {
33 | number.toString()
34 | }
35 | }
36 | }
--------------------------------------------------------------------------------
/app/src/main/java/sushi/hardcore/aira/utils/TimeUtils.kt:
--------------------------------------------------------------------------------
1 | package sushi.hardcore.aira.utils
2 |
3 | import sushi.hardcore.aira.ChatItem
4 |
5 | object TimeUtils {
6 | fun getTimestamp(): Long {
7 | return System.currentTimeMillis()/1000
8 | }
9 | fun isInTheSameDay(first: ChatItem, second: ChatItem): Boolean {
10 | return first.year == second.year && first.dayOfYear == second.dayOfYear
11 | }
12 | }
--------------------------------------------------------------------------------
/app/src/main/java/sushi/hardcore/aira/widgets/Avatar.kt:
--------------------------------------------------------------------------------
1 | package sushi.hardcore.aira.widgets
2 |
3 | import android.content.Context
4 | import android.util.AttributeSet
5 | import android.view.LayoutInflater
6 | import android.view.View
7 | import android.widget.RelativeLayout
8 | import com.bumptech.glide.Glide
9 | import sushi.hardcore.aira.R
10 | import sushi.hardcore.aira.databinding.AvatarBinding
11 |
12 | class Avatar @JvmOverloads constructor(
13 | context: Context,
14 | attrs: AttributeSet? = null,
15 | defStyle: Int = 0
16 | ) : RelativeLayout(context, attrs, defStyle) {
17 |
18 | private val binding = AvatarBinding.inflate(LayoutInflater.from(context), this, true)
19 |
20 | init {
21 | attrs?.let {
22 | val typedArray = context.obtainStyledAttributes(it, R.styleable.Avatar)
23 | for (i in 0..typedArray.indexCount) {
24 | val attr = typedArray.getIndex(i)
25 | if (attr == R.styleable.Avatar_textSize) {
26 | val textSize = typedArray.getDimension(attr, -1F)
27 | if (textSize != -1F) {
28 | binding.textLetter.textSize = textSize
29 | }
30 | }
31 | }
32 | typedArray.recycle()
33 | }
34 | }
35 |
36 | fun setTextAvatar(name: String?) {
37 | val char = if (name == null || name.isEmpty()) {
38 | '?'
39 | } else {
40 | name[0]
41 | }
42 | binding.textLetter.text = char.toString()
43 | binding.imageAvatar.visibility = View.GONE
44 | binding.textAvatar.visibility = View.VISIBLE
45 | }
46 |
47 | fun setImageAvatar(avatar: ByteArray) {
48 | Glide.with(this).load(avatar).circleCrop().into(binding.imageAvatar)
49 | binding.textAvatar.visibility = View.GONE
50 | binding.imageAvatar.visibility = View.VISIBLE
51 | }
52 | }
--------------------------------------------------------------------------------
/app/src/main/native/.gitignore:
--------------------------------------------------------------------------------
1 | target
2 |
--------------------------------------------------------------------------------
/app/src/main/native/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "aira"
3 | version = "0.1.0"
4 | authors = ["Hardcore Sushi "]
5 | edition = "2018"
6 |
7 | [target.'cfg(target_os="android")'.dependencies]
8 | jni = { version = "0.19", default-features = false }
9 |
10 | [lib]
11 | crate-type = ["dylib"]
12 |
13 | [dependencies]
14 | rand = "0.8"
15 | rand-7 = {package = "rand", version = "0.7"}
16 | lazy_static = "1.4"
17 | rusqlite = { version = "0.27", features = ["bundled"] }
18 | ed25519-dalek = "1" #for singing
19 | sha2 = "0.10"
20 | hkdf = "0.12"
21 | aes-gcm = "0.9" #PSEC
22 | aes-gcm-siv = "0.10" #Database
23 | hmac = "0.12"
24 | hex = "0.4"
25 | strum_macros = "0.24" #display enums
26 | uuid = { version = "1.0", features = ["v4"] }
27 | scrypt = "0.10"
28 | zeroize = "1.3"
29 | log = "0.4"
30 | android_log = "0.1"
--------------------------------------------------------------------------------
/app/src/main/native/build.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | if [ -z ${ANDROID_NDK_HOME+x} ]; then
4 | echo "Error: \$ANDROID_NDK_HOME is not defined."
5 | else
6 | export PATH=$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/:$PATH
7 | export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=aarch64-linux-android21-clang
8 | export CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_LINKER=armv7a-linux-androideabi21-clang
9 | export CARGO_TARGET_X86_64_LINUX_ANDROID_LINKER=x86_64-linux-android21-clang
10 | export CARGO_TARGET_I686_LINUX_ANDROID_LINKER=i686-linux-android21-clang
11 | declare -a androidABIs=("arm64-v8a" "armeabi-v7a" "x86_64" "x86")
12 | declare -a targets=("aarch64-linux-android" "armv7-linux-androideabi" "x86_64-linux-android" "i686-linux-android")
13 | for (( i=0; i < ${#targets[@]}; i++ )) do
14 | cargo build --target ${targets[i]} --release || exit 1
15 | TARGET_DIR=../jniLibs/${androidABIs[i]}
16 | mkdir -p $TARGET_DIR && cp target/${targets[i]}/release/libaira.so $TARGET_DIR
17 | done
18 | fi
19 |
--------------------------------------------------------------------------------
/app/src/main/native/check.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | if [ -z ${ANDROID_NDK_HOME+x} ]; then
4 | echo "Error: \$ANDROID_NDK_HOME is not defined."
5 | else
6 | export PATH=$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/:$PATH
7 | cargo check --target aarch64-linux-android
8 | fi
9 |
--------------------------------------------------------------------------------
/app/src/main/native/src/crypto.rs:
--------------------------------------------------------------------------------
1 | use std::{convert::TryInto, fmt::Display};
2 | use hkdf::Hkdf;
3 | use sha2::Sha384;
4 | use hmac::{Hmac, Mac};
5 | use scrypt::{scrypt, Params};
6 | use rand::{RngCore, rngs::OsRng};
7 | use aes_gcm::{aead::Aead, NewAead, Nonce};
8 | use aes_gcm_siv::Aes256GcmSiv;
9 | use zeroize::Zeroize;
10 |
11 | pub const HASH_OUTPUT_LEN: usize = 48; //SHA384
12 | const KEY_LEN: usize = 16;
13 | pub const IV_LEN: usize = 12;
14 | pub const AES_TAG_LEN: usize = 16;
15 | pub const SALT_LEN: usize = 32;
16 | const PASSWORD_HASH_LEN: usize = 32;
17 | pub const MASTER_KEY_LEN: usize = 32;
18 |
19 | fn hkdf_expand_label(key: &[u8], label: &str, context: Option<&[u8]>, okm: &mut [u8]) {
20 | let hkdf = Hkdf::::from_prk(key).unwrap();
21 | //can't set info conditionnally because of different array size
22 | match context {
23 | Some(context) => {
24 | let info = [&(label.len() as u32).to_be_bytes(), label.as_bytes(), &(context.len() as u32).to_be_bytes(), context];
25 | hkdf.expand_multi_info(&info, okm).unwrap();
26 | }
27 | None => {
28 | let info = [&(label.len() as u32).to_be_bytes(), label.as_bytes()];
29 | hkdf.expand_multi_info(&info, okm).unwrap();
30 | }
31 | };
32 | }
33 |
34 | fn get_labels(handshake: bool, i_am_bob: bool) -> (String, String) {
35 | let mut label = if handshake {
36 | "handshake"
37 | } else {
38 | "application"
39 | }.to_owned();
40 | label += "_i_am_";
41 | let local_label = label.clone() + if i_am_bob {
42 | "bob"
43 | } else {
44 | "alice"
45 | };
46 | let peer_label = label + if i_am_bob {
47 | "alice"
48 | } else {
49 | "bob"
50 | };
51 | (local_label, peer_label)
52 | }
53 |
54 | pub struct HandshakeKeys {
55 | pub local_key: [u8; KEY_LEN],
56 | pub local_iv: [u8; IV_LEN],
57 | pub local_handshake_traffic_secret: [u8; HASH_OUTPUT_LEN],
58 | pub peer_key: [u8; KEY_LEN],
59 | pub peer_iv: [u8; IV_LEN],
60 | pub peer_handshake_traffic_secret: [u8; HASH_OUTPUT_LEN],
61 | pub handshake_secret: [u8; HASH_OUTPUT_LEN],
62 | }
63 |
64 | impl HandshakeKeys {
65 | pub fn derive_keys(shared_secret: [u8; 32], handshake_hash: [u8; HASH_OUTPUT_LEN], i_am_bob: bool) -> HandshakeKeys {
66 | let (handshake_secret, _) = Hkdf::::extract(None, &shared_secret);
67 |
68 | let (local_label, peer_label) = get_labels(true, i_am_bob);
69 |
70 | let mut local_handshake_traffic_secret = [0; HASH_OUTPUT_LEN];
71 | hkdf_expand_label(handshake_secret.as_slice(), &local_label, Some(&handshake_hash), &mut local_handshake_traffic_secret);
72 |
73 | let mut peer_handshake_traffic_secret = [0; HASH_OUTPUT_LEN];
74 | hkdf_expand_label(handshake_secret.as_slice(), &peer_label, Some(&handshake_hash), &mut peer_handshake_traffic_secret);
75 |
76 | let mut local_handshake_key = [0; KEY_LEN];
77 | hkdf_expand_label(&local_handshake_traffic_secret, "key", None, &mut local_handshake_key);
78 | let mut local_handshake_iv = [0; IV_LEN];
79 | hkdf_expand_label(&local_handshake_traffic_secret, "iv", None, &mut local_handshake_iv);
80 |
81 | let mut peer_handshake_key = [0; KEY_LEN];
82 | hkdf_expand_label(&peer_handshake_traffic_secret, "key", None, &mut peer_handshake_key);
83 | let mut peer_handshake_iv = [0; IV_LEN];
84 | hkdf_expand_label(&peer_handshake_traffic_secret,"iv", None, &mut peer_handshake_iv);
85 |
86 | HandshakeKeys {
87 | local_key: local_handshake_key,
88 | local_iv: local_handshake_iv,
89 | local_handshake_traffic_secret,
90 | peer_key: peer_handshake_key,
91 | peer_iv: peer_handshake_iv,
92 | peer_handshake_traffic_secret,
93 | handshake_secret: handshake_secret.as_slice().try_into().unwrap(),
94 | }
95 | }
96 | }
97 |
98 | pub struct ApplicationKeys {
99 | pub local_key: [u8; KEY_LEN],
100 | pub local_iv: [u8; IV_LEN],
101 | pub peer_key: [u8; KEY_LEN],
102 | pub peer_iv: [u8; IV_LEN],
103 | }
104 |
105 | impl ApplicationKeys {
106 | pub fn derive_keys(handshake_secret: [u8; HASH_OUTPUT_LEN], handshake_hash: [u8; HASH_OUTPUT_LEN], i_am_bob: bool) -> ApplicationKeys {
107 | let mut derived_secret = [0; HASH_OUTPUT_LEN];
108 | hkdf_expand_label(&handshake_secret, "derived", None, &mut derived_secret);
109 | let (master_secret, _) = Hkdf::::extract(Some(&derived_secret), b"");
110 |
111 | let (local_label, peer_label) = get_labels(false, i_am_bob);
112 |
113 | let mut local_application_traffic_secret = [0; HASH_OUTPUT_LEN];
114 | hkdf_expand_label(&master_secret, &local_label, Some(&handshake_hash), &mut local_application_traffic_secret);
115 |
116 | let mut peer_application_traffic_secret = [0; HASH_OUTPUT_LEN];
117 | hkdf_expand_label(&master_secret, &peer_label, Some(&handshake_hash), &mut peer_application_traffic_secret);
118 |
119 | let mut local_application_key = [0; KEY_LEN];
120 | hkdf_expand_label(&local_application_traffic_secret, "key", None, &mut local_application_key);
121 | let mut local_application_iv = [0; IV_LEN];
122 | hkdf_expand_label(&local_application_traffic_secret, "iv", None, &mut local_application_iv);
123 |
124 | let mut peer_application_key = [0; KEY_LEN];
125 | hkdf_expand_label(&peer_application_traffic_secret, "key", None, &mut peer_application_key);
126 | let mut peer_application_iv = [0; IV_LEN];
127 | hkdf_expand_label(&peer_application_traffic_secret,"iv", None, &mut peer_application_iv);
128 |
129 | ApplicationKeys {
130 | local_key: local_application_key,
131 | local_iv: local_application_iv,
132 | peer_key: peer_application_key,
133 | peer_iv: peer_application_iv,
134 | }
135 | }
136 | }
137 |
138 | pub fn compute_handshake_finished(local_handshake_traffic_secret: [u8; HASH_OUTPUT_LEN], handshake_hash: [u8; HASH_OUTPUT_LEN]) -> [u8; HASH_OUTPUT_LEN] {
139 | let mut finished_key = [0; HASH_OUTPUT_LEN];
140 | hkdf_expand_label(&local_handshake_traffic_secret, "finished", None, &mut finished_key);
141 | let mut hmac = Hmac::::new_from_slice(&finished_key).unwrap();
142 | hmac.update(&handshake_hash);
143 | hmac.finalize().into_bytes().as_slice().try_into().unwrap()
144 | }
145 |
146 | pub fn verify_handshake_finished(peer_handshake_finished: [u8; HASH_OUTPUT_LEN], peer_handshake_traffic_secret: [u8; HASH_OUTPUT_LEN], handshake_hash: [u8; HASH_OUTPUT_LEN]) -> bool {
147 | let mut peer_finished_key = [0; HASH_OUTPUT_LEN];
148 | hkdf_expand_label(&peer_handshake_traffic_secret, "finished", None, &mut peer_finished_key);
149 | let mut hmac = Hmac::::new_from_slice(&peer_finished_key).unwrap();
150 | hmac.update(&handshake_hash);
151 | hmac.verify_slice(&peer_handshake_finished).is_ok()
152 | }
153 |
154 |
155 |
156 | pub fn generate_fingerprint(public_key: &[u8]) -> String {
157 | let mut raw_fingerprint = [0; 16];
158 | Hkdf::::new(None, public_key).expand(&[], &mut raw_fingerprint).unwrap();
159 | hex::encode(raw_fingerprint).to_uppercase()
160 | }
161 |
162 |
163 |
164 | pub fn generate_master_key() -> [u8; MASTER_KEY_LEN] {
165 | let mut master_key = [0; MASTER_KEY_LEN];
166 | OsRng.fill_bytes(&mut master_key);
167 | master_key
168 | }
169 |
170 | pub fn encrypt_data(data: &[u8], master_key: &[u8]) -> Result, CryptoError> {
171 | if master_key.len() != MASTER_KEY_LEN {
172 | return Err(CryptoError::InvalidLength);
173 | }
174 | let cipher = Aes256GcmSiv::new_from_slice(master_key).unwrap();
175 | let mut iv = [0; IV_LEN];
176 | OsRng.fill_bytes(&mut iv); //use it for IV for now
177 | let mut cipher_text = iv.to_vec();
178 | cipher_text.extend(cipher.encrypt(Nonce::from_slice(&iv), data).unwrap());
179 | Ok(cipher_text)
180 | }
181 |
182 | #[derive(Debug, PartialEq, Eq)]
183 | pub enum CryptoError {
184 | DecryptionFailed,
185 | InvalidLength
186 | }
187 |
188 | impl Display for CryptoError {
189 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
190 | f.write_str(match self {
191 | CryptoError::DecryptionFailed => "Decryption failed",
192 | CryptoError::InvalidLength => "Invalid length",
193 | })
194 | }
195 | }
196 |
197 | pub fn decrypt_data(data: &[u8], master_key: &[u8]) -> Result, CryptoError> {
198 | if data.len() <= IV_LEN || master_key.len() != MASTER_KEY_LEN {
199 | return Err(CryptoError::InvalidLength);
200 | }
201 | let cipher = Aes256GcmSiv::new_from_slice(master_key).unwrap();
202 | match cipher.decrypt(Nonce::from_slice(&data[..IV_LEN]), &data[IV_LEN..]) {
203 | Ok(data) => {
204 | Ok(data)
205 | },
206 | Err(_) => Err(CryptoError::DecryptionFailed)
207 | }
208 | }
209 |
210 | fn scrypt_params() -> Params {
211 | Params::new(16, 8, 1).unwrap()
212 | }
213 |
214 | pub fn encrypt_master_key(mut master_key: [u8; MASTER_KEY_LEN], password: &[u8]) -> (
215 | [u8; SALT_LEN], //salt
216 | [u8; IV_LEN+MASTER_KEY_LEN+AES_TAG_LEN] //encrypted masterkey with IV
217 | ) {
218 | let mut salt = [0; SALT_LEN];
219 | OsRng.fill_bytes(&mut salt);
220 | let mut password_hash = [0; PASSWORD_HASH_LEN];
221 | scrypt(password, &salt, &scrypt_params(), &mut password_hash).unwrap();
222 | let mut output = [0; IV_LEN+MASTER_KEY_LEN+AES_TAG_LEN];
223 | OsRng.fill_bytes(&mut output); //use it for IV for now
224 | let cipher = Aes256GcmSiv::new_from_slice(&password_hash).unwrap();
225 | let encrypted_master_key = cipher.encrypt(Nonce::from_slice(&output[..IV_LEN]), master_key.as_ref()).unwrap();
226 | master_key.zeroize();
227 | password_hash.zeroize();
228 | encrypted_master_key.into_iter().enumerate().for_each(|i|{
229 | output[IV_LEN+i.0] = i.1; //append encrypted master key to IV
230 | });
231 | (salt, output)
232 | }
233 |
234 | pub fn decrypt_master_key(encrypted_master_key: &[u8], password: &[u8], salt: &[u8]) -> Result<[u8; MASTER_KEY_LEN], CryptoError> {
235 | if encrypted_master_key.len() != IV_LEN+MASTER_KEY_LEN+AES_TAG_LEN || salt.len() != SALT_LEN {
236 | return Err(CryptoError::InvalidLength);
237 | }
238 | let mut password_hash = [0; PASSWORD_HASH_LEN];
239 | scrypt(password, salt, &scrypt_params(), &mut password_hash).unwrap();
240 | let cipher = Aes256GcmSiv::new_from_slice(&password_hash).unwrap();
241 | let result = match cipher.decrypt(Nonce::from_slice(&encrypted_master_key[..IV_LEN]), &encrypted_master_key[IV_LEN..]) {
242 | Ok(master_key) => {
243 | if master_key.len() == MASTER_KEY_LEN {
244 | Ok(master_key.try_into().unwrap())
245 | } else {
246 | return Err(CryptoError::InvalidLength)
247 | }
248 | },
249 | Err(_) => Err(CryptoError::DecryptionFailed)
250 | };
251 | password_hash.zeroize();
252 | result
253 | }
254 |
--------------------------------------------------------------------------------
/app/src/main/native/src/key_value_table.rs:
--------------------------------------------------------------------------------
1 | use rusqlite::{Connection, Error, params};
2 |
3 | pub struct KeyValueTable<'a> {
4 | db: Connection,
5 | table_name: &'a str,
6 | }
7 |
8 | impl<'a> KeyValueTable<'a> {
9 | pub fn new(db_path: &str, table_name: &'a str) -> Result, Error> {
10 | let db = Connection::open(db_path)?;
11 | db.execute(&format!("CREATE TABLE IF NOT EXISTS {} (key TEXT PRIMARY KEY, value BLOB)", table_name), [])?;
12 | Ok(KeyValueTable {db, table_name})
13 | }
14 | pub fn set(&self, key: &str, value: &[u8]) -> Result {
15 | self.db.execute(&format!("INSERT INTO {} (key, value) VALUES (?1, ?2)", self.table_name), params![key, value])
16 | }
17 | pub fn get(&self, key: &str) -> Result, Error> {
18 | let mut stmt = self.db.prepare(&format!("SELECT value FROM {} WHERE key=\"{}\"", self.table_name, key))?;
19 | let mut rows = stmt.query([])?;
20 | match rows.next()? {
21 | Some(row) => Ok(row.get(0)?),
22 | None => Err(rusqlite::Error::QueryReturnedNoRows)
23 | }
24 | }
25 | pub fn del(&self, key: &str) -> Result {
26 | self.db.execute(&format!("DELETE FROM {} WHERE key=\"{}\"", self.table_name, key), [])
27 | }
28 | pub fn update(&self, key: &str, value: &[u8]) -> Result {
29 | self.db.execute(&format!("UPDATE {} SET value=? WHERE key=\"{}\"", self.table_name, key), params![value])
30 | }
31 | pub fn upsert(&self, key: &str, value: &[u8]) -> Result {
32 | self.db.execute(&format!("INSERT INTO {} (key, value) VALUES(?1, ?2) ON CONFLICT(key) DO UPDATE SET value=?3", self.table_name), params![key, value, value])
33 | }
34 | }
--------------------------------------------------------------------------------
/app/src/main/native/src/utils.rs:
--------------------------------------------------------------------------------
1 | use std::convert::TryInto;
2 | use uuid::Bytes;
3 | use crate::print_error;
4 |
5 | pub fn to_uuid_bytes(bytes: &[u8]) -> Option {
6 | match bytes.try_into() {
7 | Ok(uuid) => Some(uuid),
8 | Err(e) => {
9 | print_error!(e);
10 | None
11 | }
12 | }
13 | }
14 |
15 | #[macro_export]
16 | macro_rules! print_error {
17 | ($arg:tt) => ({
18 | log::error!("[{}:{}] {}", file!(), line!(), $arg);
19 | });
20 | ($($arg:tt)*) => ({
21 | log::error!("[{}:{}] {}", file!(), line!(), format_args!($($arg)*));
22 | })
23 | }
24 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_add.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_arrow_forward.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_attach_file.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_blur.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_close.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_delete_conversation.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_delete_forever.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_face.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_fingerprint.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_gitea.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_github.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_info.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
13 |
17 |
21 |
25 |
29 |
33 |
37 |
41 |
45 |
49 |
53 |
57 |
61 |
65 |
69 |
74 |
79 |
84 |
89 |
94 |
99 |
104 |
109 |
114 |
119 |
124 |
129 |
134 |
139 |
144 |
148 |
152 |
157 |
162 |
166 |
171 |
175 |
179 |
183 |
187 |
191 |
195 |
199 |
203 |
207 |
212 |
217 |
222 |
227 |
232 |
237 |
242 |
247 |
252 |
257 |
262 |
267 |
272 |
276 |
281 |
286 |
290 |
295 |
300 |
301 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_lock.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_person.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_person_add.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_person_remove.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_save.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_send.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_settings.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_shuttle.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
12 |
15 |
18 |
21 |
24 |
27 |
30 |
31 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_verified.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_warning.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/offline_warning_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | -
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/pending_msg_indicator_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | -
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/round_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | -
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/sending_pending_msg_indictor_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | -
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_chat.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
11 |
17 |
18 |
28 |
29 |
32 |
33 |
39 |
40 |
41 |
42 |
52 |
53 |
60 |
61 |
66 |
67 |
73 |
74 |
79 |
80 |
81 |
82 |
83 |
84 |
91 |
92 |
103 |
104 |
114 |
115 |
127 |
128 |
139 |
140 |
141 |
142 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_login.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
13 |
14 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_main.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
11 |
17 |
18 |
21 |
22 |
29 |
30 |
36 |
37 |
44 |
45 |
52 |
53 |
62 |
63 |
77 |
78 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_settings.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/adapter_chat_item.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
13 |
14 |
24 |
25 |
31 |
32 |
36 |
37 |
42 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/adapter_session.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
16 |
17 |
23 |
24 |
32 |
33 |
41 |
42 |
50 |
51 |
61 |
62 |
63 |
64 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/avatar.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
12 |
13 |
19 |
20 |
21 |
22 |
28 |
29 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/change_avatar_dialog.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
13 |
14 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/dialog_ask_file.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
12 |
13 |
16 |
17 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/dialog_edit_text.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
14 |
15 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/dialog_fingerprints.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
12 |
13 |
19 |
20 |
25 |
26 |
32 |
33 |
38 |
39 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/dialog_info.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
16 |
17 |
22 |
23 |
28 |
29 |
35 |
36 |
41 |
42 |
43 |
44 |
49 |
50 |
56 |
57 |
62 |
63 |
64 |
65 |
66 |
67 |
72 |
73 |
79 |
80 |
85 |
86 |
87 |
88 |
94 |
95 |
101 |
102 |
107 |
108 |
109 |
110 |
116 |
117 |
122 |
123 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/dialog_ip_addresses.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
11 |
12 |
17 |
18 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/dialog_password.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
14 |
15 |
22 |
23 |
30 |
31 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/file_bubble_content.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
16 |
17 |
23 |
24 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/fragment_create_identity.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
13 |
14 |
21 |
22 |
28 |
29 |
36 |
37 |
43 |
44 |
52 |
53 |
61 |
62 |
68 |
69 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/fragment_login.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
15 |
16 |
24 |
25 |
30 |
31 |
38 |
39 |
45 |
46 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/message_bubble_content.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/profile_toolbar.xml:
--------------------------------------------------------------------------------
1 |
2 |
10 |
11 |
14 |
15 |
21 |
22 |
30 |
31 |
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/app/src/main/res/menu/chat_activity.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/app/src/main/res/menu/main_activity.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/app/src/main/res/values-es/strings-es.xml:
--------------------------------------------------------------------------------
1 |
2 | AIRA
3 | Crear una nueva identidad:
4 | Nombre de la identidad
5 | Contraseña
6 | Contraseña (confirmación)
7 | Crear
8 | ¡Las contraseñas no coinciden!
9 | Pares en línea:
10 | Contactos desconectados:
11 | Fallo al crear la identidad
12 | Fallo al cargar la identidad. Por favor, verifica tu contraseña
13 | Servicio en segundo plano de AIRA
14 | Acceder
15 | Añadir pares por IP
16 | Desconocido
17 | Archivo guardado en %s !
18 | Ingresa tu contraseña:
19 | Sí
20 | No
21 | ¡Advertencia!
22 | ¿En verdad quieres cerrar sesión? (No recibirás nuevos mensajes hasta que accedas nuevamente)
23 | Cancelar
24 | Servicio en segundo plano
25 | Verificación del contacto
26 | Compara las siguientes huellas digitales por un modo de comunicación seguro (como en persona) y asegurate de que coinciden..
27 | Huellas digital local:
28 | Huellas digital del par:
29 | El nombre de tu identiad. Se muestra a todas las sesiones activas.
30 | Eliminar identidad
31 | Eliminar todos tus datos. Ya no podrás ser reconocido por tus contactos.
32 | Contraseña de identidad
33 | Sin esta contraseña no puedes acceder a tus datos ni ser reconocido por tus contactos.
34 | Cambiar contraseña:
35 | De acuerdo
36 | Operación fallida. Por favor, compruebe su antigua contraseña.
37 | Error
38 | Contraseña actual
39 | Nueva contraseña (vacío para no usar contraseña)
40 | Nueva contraseña (confirmación)
41 | ¿Estás seguro de que quieres eliminar todas tus conversaciones (mensajes y archivos), todos tus contactos y tu clave privada?
42 | Coinciden
43 | Borrar una conversación sólo te afecta a ti. Tu contacto seguirá teniendo una copia de esta conversación si no la borra también. ¿Realmente quieres borrar toda esta conversación (mensajes y archivos)?
44 | Eliminar
45 | Al borrar el contacto se eliminará su clave de identidad y su conversación (mensajes y archivos). Ya no podrás reconocerlo. Esta acción sólo te afecta a ti. ¿Realmente quieres eliminar este contacto?
46 | Al borrar los contactos se eliminarán sus claves de identidad y sus conversaciones (mensajes y archivos). Ya no podrás reconocerlos. Esta acción sólo te afecta a ti. ¿Realmente quieres eliminar estos contactos?
47 | Encriptar con contraseña
48 | Nuevos mensajes
49 | Marcar como leído
50 | Solicitudes de descarga de archivos
51 | Fallo en la creación del directorio de bases de datos
52 | %s quiere enviarte algunos archivos
53 | Solicitud de descarga de archivos
54 | Descargar
55 | Rechazar
56 | Transferencias de archivos
57 | Transferencia abortada
58 | Transferencia completa
59 | Responder
60 | Fallo en la apertura de la URI
61 | ¿Enviar %s (%s) a %s ?
62 | ¿Enviar %d archivos a %s?
63 | Enviar con AIRA
64 | La huella digital de tu identidad:
65 | Huella digital:
66 | IP de par:
67 | Conexión:
68 | Saliente
69 | Entrante
70 | Eliminar la convesación
71 | Verificando
72 | Añadir contacto
73 | Eliminar contacto
74 | Detalles
75 | Tu dirección IP:
76 | Ya está en marcha otra transferencia de archivos
77 | Configuraciones
78 | Cerrar sesión
79 | ¡Copiado al portapapeles!
80 | Identidad
81 | Acerca de
82 | Versión de AIRA
83 | Actualizar perfil
84 | Seguridad
85 | Usar PSEC padding
86 | PSEC padding ofusca la longitud de sus mensajes pero utiliza más ancho de banda de la red.
87 | Es un contacto:
88 | Está verificado:
89 | El avatar no puede ser mayor de 10MB.
90 | Tu avatar:
91 | Establecer uno nuevo
92 | Eliminar
93 | Seleccionar avatar
94 | Enviar un mensaje…
95 | ¡Esta sesión no tiene nombre!
96 | Dirección IP inválida
97 | No se puede conectar con %s
98 | GitHub
99 | Repositorio AIRA-android en GitHub.
100 | Gitea
101 | Repositorio AIRA-android en la instancia Gitea del proyecto Chapril. A diferencia de GitHub, Gitea es un software totalmente libre y autoalojado.
102 | Su contacto parece estar desconectado.
103 | Los mensajes enviados se almacenarán hasta que se establezca la conexión.
104 | Mensajes pendientes:
105 | Enviando de mensajes pendientes…
106 | Detener
107 | Aplicación
108 | Iniciar el servicio AIRA en el arranque
109 | Si se desactiva, no recibirás mensajes hasta que abras la aplicación manualmente.
110 | Sólo está disponible si la identidad no está protegida por una contraseña.
111 |
112 |
113 | Enviar archivo
114 | Indicador del nivel de confianza
115 | Envíar mensaje
116 | Muestra tus IPs
117 | Indicador de clicks
118 | Avatar
119 | Nombre
120 | Icono de advertencia
121 |
--------------------------------------------------------------------------------
/app/src/main/res/values/attrs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #262626
4 | #19a52c
5 | #111111
6 | #1A1A1A
7 | @color/sessionBackground
8 | @color/secondary
9 | #d4866a
10 | #7d0839
11 | #ffffff
12 | #66666666
13 | #00000000
14 | #777777
15 | #777777
16 | #d7d7d7
17 | @color/outgoingTextLink
18 | #ff0000
19 | #909090
20 |
--------------------------------------------------------------------------------
/app/src/main/res/values/dimens.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 30dp
4 | 5sp
5 | 130dp
6 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | AIRA
3 | Create a new identity:
4 | Identity Name
5 | Password
6 | Password (confirm)
7 | Create
8 | Passwords don\'t match !
9 | Online peers:
10 | Offline contacts:
11 | Failed to create identity
12 | Failed to load identity. Please check your password
13 | AIRA Background Service
14 | Login
15 | Add peer by IP
16 | Unknown
17 | File saved to %s !
18 | Enter your password:
19 | Yes
20 | No
21 | Warning !
22 | Do you really want to log out ? (You will no longer receive messages until you login again)
23 | Cancel
24 | Background service
25 | Verifying contact
26 | Compare the following fingerprints by a trusted way of communication (such as real life) and be sure they match..
27 | Local fingerprint:
28 | Peer fingerprint:
29 | The name of your identity. Shown to all active sessions.
30 | Delete Identity
31 | Delete all your data. You won\'t be able to be recognized by your contacts anymore.
32 | Identity Password
33 | You can\'t access your data or be recognized by your contacts without this password.
34 | Change password:
35 | OK
36 | Operation failed. Please check your old password.
37 | Error
38 | Current password
39 | New password (empty for no password)
40 | New password (confirm)
41 | Are you sure you want to delete all your conversations (messages and files), all your contacts, and your private key ?
42 | They match
43 | Deleting a conversation only affects you. Your contact will still have a copy of this conversation if she/he doesn\'t delete it too. Do you really want to delete all this conversation (messages and files) ?
44 | Delete
45 | Deleting contact will remove her/his identity key and your conversation (messages and files). You won\'t be able to recognize her/him anymore. This action only affects you. Do you really want to remove this contact ?
46 | Deleting contacts will remove their identity keys and your conversations (messages and files). You won\'t be able to recognize them anymore. This action only affects you. Do you really want to remove these contacts ?
47 | Encrypt with a password
48 | New Messages
49 | Mark read
50 | Download file requests
51 | Databases directory creation failed
52 | %s wants to send you some files
53 | Download file request
54 | Download
55 | Refuse
56 | File transfers
57 | Transfer aborted
58 | Transfer completed
59 | Reply
60 | Failed to open URI
61 | Send %s (%s) to %s ?
62 | Send %d files to %s ?
63 | Send with AIRA
64 | Your identity\'s fingerprint:
65 | Fingerprint:
66 | Peer IP:
67 | Connection:
68 | Outgoing
69 | Incoming
70 | Delete conversation
71 | Verify
72 | Add contact
73 | Remove contact
74 | Details
75 | Your IP addresses:
76 | Another file transfer is already in progress
77 | Settings
78 | Log out
79 | Copied to clipboard !
80 | Identity
81 | About
82 | AIRA version
83 | Refresh profile
84 | Security
85 | Use PSEC padding
86 | PSEC padding obfuscates the length of your messages but uses more network bandwidth.
87 | Is contact:
88 | Is verified:
89 | Avatar cannot be larger than 10MB.
90 | Your avatar:
91 | Set a new one
92 | Remove
93 | Choose avatar
94 | Send a message…
95 | This session has no name !
96 | Invalid IP address
97 | Unable to connect to %s
98 | GitHub
99 | AIRA-android repository on GitHub.
100 | Gitea
101 | AIRA-android repository on the Gitea instance of the Chapril project. Unlike GitHub, Gitea is fully free software and self-hosted.
102 | Your contact seems to be offline.
103 | Sent messages will be stored until a connection is established.
104 | Pending messages:
105 | Sending pending messages…
106 | Stop
107 | App
108 | Start AIRA service at boot
109 | If disabled, you won\'t receive messages until you open the app manually.
110 | Only available if identity is not protected by a password.
111 |
112 |
113 | Send file
114 | Trust level indicator
115 | Send message
116 | Show your IPs
117 | Clickable indicator
118 | Avatar
119 | Name
120 | Warning icon
121 |
--------------------------------------------------------------------------------
/app/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
15 |
20 |
24 |
28 |
32 |
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/preferences.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
11 |
12 |
17 |
18 |
23 |
24 |
29 |
30 |
34 |
35 |
36 |
37 |
38 |
39 |
45 |
46 |
47 |
48 |
49 |
50 |
55 |
56 |
57 |
58 |
59 |
60 |
65 |
66 |
70 |
71 |
72 |
73 |
77 |
78 |
79 |
80 |
81 |
82 |
--------------------------------------------------------------------------------
/app/src/test/java/sushi/hardcore/aira/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package sushi.hardcore.aira
2 |
3 | import org.junit.Test
4 |
5 | import org.junit.Assert.*
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * See [testing documentation](http://d.android.com/tools/testing).
11 | */
12 | class ExampleUnitTest {
13 | @Test
14 | fun addition_isCorrect() {
15 | assertEquals(4, 2 + 2)
16 | }
17 | }
--------------------------------------------------------------------------------
/build.gradle:
--------------------------------------------------------------------------------
1 | buildscript {
2 | ext.kotlin_version = "1.6.21"
3 | repositories {
4 | google()
5 | mavenCentral()
6 | }
7 | dependencies {
8 | classpath 'com.android.tools.build:gradle:7.2.0'
9 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
10 | }
11 | }
12 |
13 | allprojects {
14 | repositories {
15 | google()
16 | mavenCentral()
17 | }
18 | }
19 |
20 | task clean(type: Delete) {
21 | delete rootProject.buildDir
22 | }
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. More details, visit
12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app"s APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Automatically convert third-party libraries to use AndroidX
19 | android.enableJetifier=true
20 | # Kotlin code style for this project: "official" or "obsolete":
21 | kotlin.code.style=official
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hardcore-sushi/AIRA-android/19ae8c609233368f7aadeff36dc9e384d4cd5c10/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Thu Sep 02 16:55:14 UTC 2021
2 | distributionBase=GRADLE_USER_HOME
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip
4 | distributionPath=wrapper/dists
5 | zipStorePath=wrapper/dists
6 | zipStoreBase=GRADLE_USER_HOME
7 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | ##############################################################################
4 | ##
5 | ## Gradle start up script for UN*X
6 | ##
7 | ##############################################################################
8 |
9 | # Attempt to set APP_HOME
10 | # Resolve links: $0 may be a link
11 | PRG="$0"
12 | # Need this for relative symlinks.
13 | while [ -h "$PRG" ] ; do
14 | ls=`ls -ld "$PRG"`
15 | link=`expr "$ls" : '.*-> \(.*\)$'`
16 | if expr "$link" : '/.*' > /dev/null; then
17 | PRG="$link"
18 | else
19 | PRG=`dirname "$PRG"`"/$link"
20 | fi
21 | done
22 | SAVED="`pwd`"
23 | cd "`dirname \"$PRG\"`/" >/dev/null
24 | APP_HOME="`pwd -P`"
25 | cd "$SAVED" >/dev/null
26 |
27 | APP_NAME="Gradle"
28 | APP_BASE_NAME=`basename "$0"`
29 |
30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
31 | DEFAULT_JVM_OPTS=""
32 |
33 | # Use the maximum available, or set MAX_FD != -1 to use that value.
34 | MAX_FD="maximum"
35 |
36 | warn () {
37 | echo "$*"
38 | }
39 |
40 | die () {
41 | echo
42 | echo "$*"
43 | echo
44 | exit 1
45 | }
46 |
47 | # OS specific support (must be 'true' or 'false').
48 | cygwin=false
49 | msys=false
50 | darwin=false
51 | nonstop=false
52 | case "`uname`" in
53 | CYGWIN* )
54 | cygwin=true
55 | ;;
56 | Darwin* )
57 | darwin=true
58 | ;;
59 | MINGW* )
60 | msys=true
61 | ;;
62 | NONSTOP* )
63 | nonstop=true
64 | ;;
65 | esac
66 |
67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
68 |
69 | # Determine the Java command to use to start the JVM.
70 | if [ -n "$JAVA_HOME" ] ; then
71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
72 | # IBM's JDK on AIX uses strange locations for the executables
73 | JAVACMD="$JAVA_HOME/jre/sh/java"
74 | else
75 | JAVACMD="$JAVA_HOME/bin/java"
76 | fi
77 | if [ ! -x "$JAVACMD" ] ; then
78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
79 |
80 | Please set the JAVA_HOME variable in your environment to match the
81 | location of your Java installation."
82 | fi
83 | else
84 | JAVACMD="java"
85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
86 |
87 | Please set the JAVA_HOME variable in your environment to match the
88 | location of your Java installation."
89 | fi
90 |
91 | # Increase the maximum file descriptors if we can.
92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
93 | MAX_FD_LIMIT=`ulimit -H -n`
94 | if [ $? -eq 0 ] ; then
95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
96 | MAX_FD="$MAX_FD_LIMIT"
97 | fi
98 | ulimit -n $MAX_FD
99 | if [ $? -ne 0 ] ; then
100 | warn "Could not set maximum file descriptor limit: $MAX_FD"
101 | fi
102 | else
103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
104 | fi
105 | fi
106 |
107 | # For Darwin, add options to specify how the application appears in the dock
108 | if $darwin; then
109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
110 | fi
111 |
112 | # For Cygwin, switch paths to Windows format before running java
113 | if $cygwin ; then
114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
116 | JAVACMD=`cygpath --unix "$JAVACMD"`
117 |
118 | # We build the pattern for arguments to be converted via cygpath
119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
120 | SEP=""
121 | for dir in $ROOTDIRSRAW ; do
122 | ROOTDIRS="$ROOTDIRS$SEP$dir"
123 | SEP="|"
124 | done
125 | OURCYGPATTERN="(^($ROOTDIRS))"
126 | # Add a user-defined pattern to the cygpath arguments
127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
129 | fi
130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
131 | i=0
132 | for arg in "$@" ; do
133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
135 |
136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
138 | else
139 | eval `echo args$i`="\"$arg\""
140 | fi
141 | i=$((i+1))
142 | done
143 | case $i in
144 | (0) set -- ;;
145 | (1) set -- "$args0" ;;
146 | (2) set -- "$args0" "$args1" ;;
147 | (3) set -- "$args0" "$args1" "$args2" ;;
148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
154 | esac
155 | fi
156 |
157 | # Escape application args
158 | save () {
159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
160 | echo " "
161 | }
162 | APP_ARGS=$(save "$@")
163 |
164 | # Collect all arguments for the java command, following the shell quoting and substitution rules
165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
166 |
167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
169 | cd "$(dirname "$0")"
170 | fi
171 |
172 | exec "$JAVACMD" "$@"
173 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @if "%DEBUG%" == "" @echo off
2 | @rem ##########################################################################
3 | @rem
4 | @rem Gradle startup script for Windows
5 | @rem
6 | @rem ##########################################################################
7 |
8 | @rem Set local scope for the variables with windows NT shell
9 | if "%OS%"=="Windows_NT" setlocal
10 |
11 | set DIRNAME=%~dp0
12 | if "%DIRNAME%" == "" set DIRNAME=.
13 | set APP_BASE_NAME=%~n0
14 | set APP_HOME=%DIRNAME%
15 |
16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
17 | set DEFAULT_JVM_OPTS=
18 |
19 | @rem Find java.exe
20 | if defined JAVA_HOME goto findJavaFromJavaHome
21 |
22 | set JAVA_EXE=java.exe
23 | %JAVA_EXE% -version >NUL 2>&1
24 | if "%ERRORLEVEL%" == "0" goto init
25 |
26 | echo.
27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
28 | echo.
29 | echo Please set the JAVA_HOME variable in your environment to match the
30 | echo location of your Java installation.
31 |
32 | goto fail
33 |
34 | :findJavaFromJavaHome
35 | set JAVA_HOME=%JAVA_HOME:"=%
36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
37 |
38 | if exist "%JAVA_EXE%" goto init
39 |
40 | echo.
41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
42 | echo.
43 | echo Please set the JAVA_HOME variable in your environment to match the
44 | echo location of your Java installation.
45 |
46 | goto fail
47 |
48 | :init
49 | @rem Get command-line arguments, handling Windows variants
50 |
51 | if not "%OS%" == "Windows_NT" goto win9xME_args
52 |
53 | :win9xME_args
54 | @rem Slurp the command line arguments.
55 | set CMD_LINE_ARGS=
56 | set _SKIP=2
57 |
58 | :win9xME_args_slurp
59 | if "x%~1" == "x" goto execute
60 |
61 | set CMD_LINE_ARGS=%*
62 |
63 | :execute
64 | @rem Setup the command line
65 |
66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
67 |
68 | @rem Execute Gradle
69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
70 |
71 | :end
72 | @rem End local scope for the variables with windows NT shell
73 | if "%ERRORLEVEL%"=="0" goto mainEnd
74 |
75 | :fail
76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
77 | rem the _cmd.exe /c_ return code!
78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
79 | exit /b 1
80 |
81 | :mainEnd
82 | if "%OS%"=="Windows_NT" endlocal
83 |
84 | :omega
85 |
--------------------------------------------------------------------------------
/screenshots/1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hardcore-sushi/AIRA-android/19ae8c609233368f7aadeff36dc9e384d4cd5c10/screenshots/1.png
--------------------------------------------------------------------------------
/screenshots/2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hardcore-sushi/AIRA-android/19ae8c609233368f7aadeff36dc9e384d4cd5c10/screenshots/2.png
--------------------------------------------------------------------------------
/screenshots/3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hardcore-sushi/AIRA-android/19ae8c609233368f7aadeff36dc9e384d4cd5c10/screenshots/3.png
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | include ':app'
2 | rootProject.name = "AIRA"
--------------------------------------------------------------------------------