├── .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 | Screenshot of the main screen of AIRA-android, with Bob online and Angerfist and Barack Obama as contacts 8 | Screenshot of a conversation between Alice and Bob about AIRA 9 | Screenshot of the settings screen of AIRA-android 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 |