Compare commits

..

27 Commits

Author SHA1 Message Date
Matéo Duparc 19ae8c6092
Update dependencies 2022-05-16 15:07:24 +02:00
Matéo Duparc fcc7d64b9d
Hide irrelevant options when selecting sessions 2022-05-10 13:05:35 +02:00
Matéo Duparc 81dc567ff0
Spanish translation 2022-05-10 12:58:46 +02:00
Matéo Duparc 0dc19529f6
Make cargo clippy happy 2021-09-02 20:45:53 +02:00
Matéo Duparc eb36a8e879
Add AIRA logo & Update gradle 2021-09-02 19:39:32 +02:00
Matéo Duparc 2b50ffa409
Messaging style notifications 2021-08-18 13:06:00 +02:00
Matéo Duparc 928f97dc97
Don't re-compress avatars before storing in DB 2021-08-17 19:17:41 +02:00
Matéo Duparc 8354490940
Remove unnecessary permissions 2021-08-14 21:46:21 +02:00
Matéo Duparc c6f1e6d432
Fix share intent handling bugs 2021-08-14 21:44:52 +02:00
Matéo Duparc eac227085d
Start at boot feature 2021-08-14 14:50:32 +02:00
Matéo Duparc fc46326deb
Add stop button in background service notification 2021-08-06 21:29:19 +02:00
Matéo Duparc 3562362916
Fix thread bugs 2021-08-06 11:27:46 +02:00
Matéo Duparc cc31423cc4
Fix UI bugs in ChatActivity 2021-08-03 21:18:14 +02:00
Matéo Duparc b20fded45b
Remove only pending messages instead of reloading the whole history when sending pending messages 2021-08-02 22:03:37 +02:00
Matéo Duparc 5fc3f7d0cf
Pending messages 2021-08-02 19:45:22 +02:00
Matéo Duparc d618eb87c7
Update to PSEC v0.4 2021-07-25 16:01:06 +02:00
Matéo Duparc 37e4c0a105
Handle special characters in file names 2021-07-25 14:26:59 +02:00
Matéo Duparc f186dbd0cb
Send only read data during large file transfers 2021-07-15 10:50:57 +02:00
Matéo Duparc 4cb127004f
Add screenshots to README 2021-06-22 21:07:46 +02:00
Matéo Duparc e06044acb8
Show date in ChatAdapter 2021-06-22 19:28:23 +02:00
Matéo Duparc 05a9f5881d
Conditional message bubble corners 2021-06-22 15:45:31 +02:00
Matéo Duparc 5a2a9ccc4f
Add Download section in README 2021-06-20 19:09:27 +02:00
Matéo Duparc 335e786f79
Show file name on saving 2021-06-20 16:12:01 +02:00
Matéo Duparc f7418cf9c2
Decrease max message size to 16MB 2021-06-18 16:04:50 +02:00
Matéo Duparc 38b322ebc5
Change highlight color on received messages 2021-06-18 15:17:11 +02:00
Matéo Duparc 0d3848360b
Add margin between received and sent messages 2021-06-18 15:04:37 +02:00
Matéo Duparc 8f33a640ce
Message timestamps 2021-06-16 20:57:11 +02:00
55 changed files with 2536 additions and 617 deletions

View File

@ -3,6 +3,12 @@ AIRA is peer-to-peer encrypted communication tool for local networks built on th
Here is the Android version. You can find the original AIRA desktop version [here](https://forge.chapril.org/hardcoresushi/AIRA).
<p align="center">
<img alt="Screenshot of the main screen of AIRA-android, with Bob online and Angerfist and Barack Obama as contacts" src="https://forge.chapril.org/hardcoresushi/AIRA-android/raw/branch/master/screenshots/1.png" height="550"/>
<img alt="Screenshot of a conversation between Alice and Bob about AIRA" src="https://forge.chapril.org/hardcoresushi/AIRA-android/raw/branch/master/screenshots/2.png" height="550"/>
<img alt="Screenshot of the settings screen of AIRA-android" src="https://forge.chapril.org/hardcoresushi/AIRA-android/raw/branch/master/screenshots/3.png" height="550"/>
</p>
# Disclaimer
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.
@ -17,17 +23,28 @@ AIRA is still under developement and is not ready for production usage yet. Not
- IPv4/v6 compatibility
- Free/Libre and Open Source
# Download
AIRA releases are availables in the "Release" section. All APKs are signed with my PGP key available on keyservers. To download it:
`gpg --keyserver hkps://keyserver.ubuntu.com --recv-keys AFE384344A45E13A` \
Fingerprint: `B64E FE86 CEE1 D054 F082 1711 AFE3 8434 4A45 E13A` \
Email: `Hardcore Sushi <hardcore.sushi@disroot.org>`
Then, verify APK: `gpg --verify AIRA.apk.asc AIRA.apk`
__Don't install the APK if the verification fails!__
# Build
### Install Rust
AIRA android uses some code from the desktop version which is written in Rust. Therefore, you need to compile this Rust code first.
```
curl --proto '=https' --tlsv1.3 -sSf https://sh.rustup.rs | sh
rustup target add aarch64-linux-android armv7-linux-androideabi
rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android
```
### Install NDK
The Rust code uses a crate called "rusqlite" to store data in SQLite databases. This crates uses the original SQLite3 library written in C. Therefore, to compile it you need the Android NDK. You can find instructions to install the NDK here: https://developer.android.com/ndk/guides
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
Once installed, you need to define the $ANDROID_NDK_HOME environment variable (if not already set):
Once installed, you need to define the `$ANDROID_NDK_HOME` environment variable (if not already set):
```
export ANDROID_NDK_HOME=/home/<user>/Android/SDK/ndk/<NDK version>"
```

View File

@ -4,20 +4,20 @@ plugins {
}
android {
compileSdkVersion 30
buildToolsVersion "30.0.3"
compileSdkVersion 32
buildToolsVersion "32.0.0"
defaultConfig {
applicationId "sushi.hardcore.aira"
minSdkVersion 19
targetSdkVersion 30
versionCode 1
versionName "0.0.1"
targetSdkVersion 32
versionCode 3
versionName "0.1.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
ndk {
abiFilters "x86", "armeabi-v7a", "arm64-v8a"
abiFilters "x86", "x86_64", "armeabi-v7a", "arm64-v8a"
}
}
@ -31,7 +31,7 @@ android {
buildTypes {
release {
minifyEnabled false
minifyEnabled false // curve25519-android doesn't seem to support minification
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
@ -42,20 +42,21 @@ android {
kotlinOptions {
jvmTarget = '1.8'
}
namespace 'sushi.hardcore.aira'
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation 'androidx.core:core-ktx:1.5.0'
implementation 'androidx.appcompat:appcompat:1.3.0'
implementation "androidx.fragment:fragment-ktx:1.3.4"
implementation "androidx.preference:preference-ktx:1.1.1"
implementation 'com.google.android.material:material:1.3.0'
implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.appcompat:appcompat:1.4.1'
implementation "androidx.fragment:fragment-ktx:1.4.1"
implementation "androidx.preference:preference-ktx:1.2.0"
implementation 'com.google.android.material:material:1.6.0'
implementation 'net.i2p.crypto:eddsa:0.3.0'
implementation "org.whispersystems:curve25519-android:0.5.0"
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
implementation 'com.github.bumptech.glide:glide:4.12.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0'
}
}

View File

@ -1,13 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="sushi.hardcore.aira"
android:installLocation="auto">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_PHONE_STATE" tools:node="remove"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" tools:node="remove"/>
<application
android:icon="@drawable/ic_launcher"
@ -27,11 +28,18 @@
</intent-filter>
</receiver>
<receiver android:name=".background_service.SystemBroadcastReceiver" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
</intent-filter>
</receiver>
<activity android:name=".ChatActivity" android:theme="@style/Theme.AIRA.NoActionBar"/>
<activity android:name=".MainActivity" android:theme="@style/Theme.AIRA.NoActionBar"/>
<activity
android:name=".LoginActivity"
android:windowSoftInputMode="adjustResize">
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>

View File

@ -3,7 +3,9 @@ package sushi.hardcore.aira
import sushi.hardcore.aira.background_service.Contact
object AIRADatabase {
external fun initLogging(): Boolean
external fun isIdentityProtected(databaseFolder: String): Boolean
external fun getIdentityName(databaseFolder: String): String?
external fun loadIdentity(databaseFolder: String, password: ByteArray?): Boolean
external fun addContact(name: String, avatarUuid: String?, publicKey: ByteArray): Contact?
external fun removeContact(uuid: String): Boolean
@ -12,7 +14,7 @@ object AIRADatabase {
external fun setContactSeen(contactUuid: String, seen: Boolean): Boolean
external fun changeContactName(contactUuid: String, newName: String): Boolean
external fun setContactAvatar(contactUuid: String, avatarUuid: String?): Boolean
external fun storeMsg(contactUuid: String, outgoing: Boolean, data: ByteArray): Boolean
external fun storeMsg(contactUuid: String, outgoing: Boolean, timestamp: Long, data: ByteArray): Boolean
external fun storeFile(contactUuid: String?, data: ByteArray): ByteArray?
external fun loadMsgs(uuid: String, offset: Int, count: Int): ArrayList<ChatItem>?
external fun loadFile(rawUuid: ByteArray): ByteArray?
@ -30,6 +32,11 @@ object AIRADatabase {
external fun removeIdentityAvatar(databaseFolder: String): Boolean
external fun getIdentityAvatar(databaseFolder: String): ByteArray?
fun init() {
System.loadLibrary("aira")
initLogging()
}
fun loadAvatar(avatarUuid: String?): ByteArray? {
return avatarUuid?.let {
getAvatar(it)

View File

@ -13,10 +13,9 @@ import android.widget.ImageView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import sushi.hardcore.aira.adapters.ChatAdapter
import sushi.hardcore.aira.adapters.FuckRecyclerView
import sushi.hardcore.aira.background_service.*
import sushi.hardcore.aira.databinding.ActivityChatBinding
import sushi.hardcore.aira.databinding.DialogFingerprintsBinding
@ -35,7 +34,108 @@ class ChatActivity : ServiceBoundActivity() {
private var lastLoadedMessageOffset = 0
private val filePicker = registerForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris ->
if (isServiceInitialized() && uris.size > 0) {
airaService.sendFilesFromUris(sessionId, uris)
airaService.sendFilesFromUris(sessionId, uris) { buffer ->
chatAdapter.newMessage(ChatItem(true, 0, buffer))
scrollToBottom()
}
}
}
private val uiCallbacks = object : AIRAService.UiCallbacks {
override fun onConnectFailed(ip: String, errorMsg: String?) {}
override fun onNewSession(sessionId: Int, ip: String) {
if (this@ChatActivity.sessionId == sessionId) {
val contact = airaService.contacts[sessionId]
val hasPendingMsgs = airaService.pendingMsgs[sessionId]?.size ?: 0 > 0
runOnUiThread {
if (contact == null) {
binding.bottomPanel.visibility = View.VISIBLE
} else {
binding.offlineWarning.visibility = View.GONE
if (hasPendingMsgs) {
binding.sendingPendingMsgsIndicator.visibility = View.VISIBLE
chatAdapter.removePendingMessages()
scrollToBottom()
}
}
invalidateOptionsMenu()
}
}
}
override fun onSessionDisconnect(sessionId: Int) {
if (this@ChatActivity.sessionId == sessionId) {
runOnUiThread {
if (airaService.isContact(sessionId)) {
binding.offlineWarning.visibility = View.VISIBLE
} else {
hideBottomPanel()
}
}
}
}
override fun onNameTold(sessionId: Int, name: String) {
if (this@ChatActivity.sessionId == sessionId) {
runOnUiThread {
sessionName = name
binding.toolbar.title.text = name
if (avatar == null) {
binding.toolbar.avatar.setTextAvatar(name)
}
}
}
}
override fun onAvatarChanged(sessionId: Int, avatar: ByteArray?) {
if (this@ChatActivity.sessionId == sessionId) {
runOnUiThread {
this@ChatActivity.avatar = avatar
if (avatar == null) {
binding.toolbar.avatar.setTextAvatar(sessionName)
} else {
binding.toolbar.avatar.setImageAvatar(avatar)
}
}
}
}
override fun onSent(sessionId: Int, timestamp: Long, buffer: ByteArray) {
if (this@ChatActivity.sessionId == sessionId) {
if (airaService.isContact(sessionId)) {
lastLoadedMessageOffset += 1
}
runOnUiThread {
chatAdapter.newMessage(ChatItem(true, timestamp, buffer))
scrollToBottom()
}
}
}
override fun onPendingMessagesSent(sessionId: Int) {
if (this@ChatActivity.sessionId == sessionId) {
runOnUiThread {
binding.sendingPendingMsgsIndicator.visibility = View.GONE
}
}
}
override fun onNewMessage(sessionId: Int, timestamp: Long, data: ByteArray): Boolean {
return if (this@ChatActivity.sessionId == sessionId) {
runOnUiThread {
chatAdapter.newMessage(ChatItem(false, timestamp, data))
scrollToBottom()
}
if (airaService.isContact(sessionId)) {
lastLoadedMessageOffset += 1
}
!airaService.isAppInBackground
} else {
false
}
}
override fun onAskLargeFiles(sessionId: Int, filesReceiver: FilesReceiver): Boolean {
return if (this@ChatActivity.sessionId == sessionId) {
runOnUiThread {
filesReceiver.ask(this@ChatActivity, sessionName ?: airaService.sessions[sessionId]!!.ip)
}
true
} else {
false
}
}
}
@ -51,7 +151,7 @@ class ChatActivity : ServiceBoundActivity() {
chatAdapter = ChatAdapter(this@ChatActivity, ::onClickSaveFile)
binding.recyclerChat.apply {
adapter = chatAdapter
layoutManager = LinearLayoutManager(this@ChatActivity, LinearLayoutManager.VERTICAL, false).apply {
layoutManager = FuckRecyclerView(this@ChatActivity).apply {
stackFromEnd = true
}
addOnScrollListener(object : RecyclerView.OnScrollListener() {
@ -75,13 +175,11 @@ class ChatActivity : ServiceBoundActivity() {
}
binding.buttonSend.setOnClickListener {
val msg = binding.editMessage.text.toString()
airaService.sendTo(sessionId, Protocol.newMessage(msg))
binding.editMessage.text.clear()
chatAdapter.newMessage(ChatItem(true, Protocol.newMessage(msg)))
if (airaService.contacts.contains(sessionId)) {
lastLoadedMessageOffset += 1
if (!airaService.sendOrAddToPending(sessionId, Protocol.newMessage(msg))) {
chatAdapter.newMessage(ChatItem(true, 0, Protocol.newMessage(msg)))
scrollToBottom()
}
binding.recyclerChat.smoothScrollToPosition(chatAdapter.itemCount)
}
binding.buttonAttach.setOnClickListener {
filePicker.launch("*/*")
@ -94,9 +192,8 @@ class ChatActivity : ServiceBoundActivity() {
val session = airaService.sessions[sessionId]
val contact = airaService.contacts[sessionId]
if (session == null && contact == null) { //may happen when resuming activity after session disconnect
onDisconnected()
hideBottomPanel()
} else {
chatAdapter.clear()
val avatar = if (contact == null) {
displayIconTrustLevel(false, false)
sessionName = airaService.savedNames[sessionId]
@ -116,93 +213,53 @@ class ChatActivity : ServiceBoundActivity() {
binding.toolbar.avatar.setImageAvatar(image)
}
}
chatAdapter.clear()
lastLoadedMessageOffset = 0
if (contact != null) {
loadMsgs(contact.uuid)
}
airaService.savedMsgs[sessionId]?.let {
for (chatItem in it.asReversed()) {
chatAdapter.newLoadedMessage(chatItem)
for (msg in it.asReversed()) {
chatAdapter.newLoadedMessage(ChatItem(msg.outgoing, msg.timestamp, msg.data))
}
}
var hasPendingMsgs = false
airaService.pendingMsgs[sessionId]?.let {
if (it.size > 0) {
hasPendingMsgs = true
for (msg in it) {
if (msg[0] == Protocol.MESSAGE ||msg[0] == Protocol.FILE) {
chatAdapter.newMessage(ChatItem(true, 0, msg))
}
}
}
}
if (chatAdapter.itemCount > 0) {
scrollToBottom()
}
airaService.receiveFileTransfers[sessionId]?.let {
if (it.shouldAsk) {
it.ask(this@ChatActivity, ipName)
}
}
binding.recyclerChat.smoothScrollToPosition(chatAdapter.itemCount)
if (airaService.isOnline(sessionId)) {
binding.bottomPanel.visibility = View.VISIBLE
binding.recyclerChat.updatePadding(bottom = 0)
binding.sendingPendingMsgsIndicator.visibility = if (session == null) {
if (contact == null) {
hideBottomPanel()
} else {
binding.offlineWarning.visibility = View.VISIBLE
}
View.GONE
} else {
onDisconnected()
binding.offlineWarning.visibility = View.GONE
if (hasPendingMsgs) {
View.VISIBLE
} else {
View.GONE
}
}
airaService.setSeen(sessionId, true)
}
airaService.uiCallbacks = object : AIRAService.UiCallbacks {
override fun onConnectFailed(ip: String, errorMsg: String?) {}
override fun onNewSession(sessionId: Int, ip: String) {
if (this@ChatActivity.sessionId == sessionId) {
runOnUiThread {
binding.bottomPanel.visibility = View.VISIBLE
invalidateOptionsMenu()
}
}
}
override fun onSessionDisconnect(sessionId: Int) {
if (this@ChatActivity.sessionId == sessionId) {
runOnUiThread {
onDisconnected()
}
}
}
override fun onNameTold(sessionId: Int, name: String) {
if (this@ChatActivity.sessionId == sessionId) {
runOnUiThread {
sessionName = name
binding.toolbar.title.text = name
if (avatar == null) {
binding.toolbar.avatar.setTextAvatar(name)
}
}
}
}
override fun onAvatarChanged(sessionId: Int, avatar: ByteArray?) {
if (this@ChatActivity.sessionId == sessionId) {
runOnUiThread {
this@ChatActivity.avatar = avatar
if (avatar == null) {
binding.toolbar.avatar.setTextAvatar(sessionName)
} else {
binding.toolbar.avatar.setImageAvatar(avatar)
}
}
}
}
override fun onNewMessage(sessionId: Int, data: ByteArray): Boolean {
return if (this@ChatActivity.sessionId == sessionId) {
runOnUiThread {
chatAdapter.newMessage(ChatItem(false, data))
binding.recyclerChat.smoothScrollToPosition(chatAdapter.itemCount)
}
if (airaService.contacts.contains(sessionId)) {
lastLoadedMessageOffset += 1
}
!airaService.isAppInBackground
} else {
false
}
}
override fun onAskLargeFiles(sessionId: Int, filesReceiver: FilesReceiver): Boolean {
return if (this@ChatActivity.sessionId == sessionId) {
runOnUiThread {
filesReceiver.ask(this@ChatActivity, sessionName ?: airaService.sessions[sessionId]!!.ip)
}
true
} else {
false
}
}
}
airaService.uiCallbacks = uiCallbacks
airaService.isAppInBackground = false
}
override fun onServiceDisconnected(name: ComponentName?) {}
@ -210,7 +267,7 @@ class ChatActivity : ServiceBoundActivity() {
}
}
private fun onDisconnected() {
private fun hideBottomPanel() {
val inputManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
inputManager.hideSoftInputFromWindow(binding.editMessage.windowToken, 0)
binding.bottomPanel.visibility = View.GONE
@ -253,6 +310,13 @@ class ChatActivity : ServiceBoundActivity() {
}
}
private fun scrollToBottom() {
val target = chatAdapter.itemCount-1
if (target >= 0) {
binding.recyclerChat.smoothScrollToPosition(target)
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.chat_activity, menu)
val contact = airaService.contacts[sessionId]
@ -299,6 +363,10 @@ class ChatActivity : ServiceBoundActivity() {
if (airaService.removeContact(sessionId)) {
invalidateOptionsMenu()
displayIconTrustLevel(false, false)
if (!airaService.isOnline(sessionId)) {
hideBottomPanel()
binding.offlineWarning.visibility = View.GONE
}
}
}
.setNegativeButton(R.string.cancel, null)
@ -340,7 +408,7 @@ class ChatActivity : ServiceBoundActivity() {
true
}
R.id.refresh_profile -> {
airaService.sendTo(sessionId, Protocol.askProfileInfo())
airaService.sendOrAddToPending(sessionId, Protocol.askProfileInfo())
true
}
else -> super.onOptionsItemSelected(item)
@ -354,21 +422,12 @@ class ChatActivity : ServiceBoundActivity() {
}
}
override fun onPause() {
super.onPause()
lastLoadedMessageOffset = 0
}
private fun onClickSaveFile(fileName: String, rawUuid: ByteArray) {
val buffer = AIRADatabase.loadFile(rawUuid)
if (buffer == null) {
Toast.makeText(this, R.string.loadFile_failed, Toast.LENGTH_SHORT).show()
} else {
FileUtils.openFileForDownload(this, fileName)?.apply {
write(buffer)
close()
Toast.makeText(this@ChatActivity, R.string.file_saved, Toast.LENGTH_SHORT).show()
}
private fun onClickSaveFile(fileName: String, fileContent: ByteArray) {
val file = FileUtils.openFileForDownload(this, fileName)
file.outputStream?.apply {
write(fileContent)
close()
Toast.makeText(this@ChatActivity, getString(R.string.file_saved, file.fileName), Toast.LENGTH_SHORT).show()
}
}

View File

@ -1,17 +1,32 @@
package sushi.hardcore.aira
import sushi.hardcore.aira.background_service.Protocol
import java.util.*
class ChatItem(val outgoing: Boolean, val data: ByteArray) {
class ChatItem(val outgoing: Boolean, val timestamp: Long, val data: ByteArray) {
companion object {
const val OUTGOING_MESSAGE = 0
const val INCOMING_MESSAGE = 1
const val OUTGOING_FILE = 2
const val INCOMING_FILE = 3
}
val itemType = if (data[0] == Protocol.MESSAGE) {
if (outgoing) OUTGOING_MESSAGE else INCOMING_MESSAGE
} else {
if (outgoing) OUTGOING_FILE else INCOMING_FILE
val itemType: Int by lazy {
if (data[0] == Protocol.MESSAGE) {
if (outgoing) OUTGOING_MESSAGE else INCOMING_MESSAGE
} else {
if (outgoing) OUTGOING_FILE else INCOMING_FILE
}
}
val calendar: Calendar by lazy {
Calendar.getInstance().apply {
time = Date(timestamp * 1000)
}
}
val year by lazy {
calendar.get(Calendar.YEAR)
}
val dayOfYear by lazy {
calendar.get(Calendar.DAY_OF_YEAR)
}
}

View File

@ -7,7 +7,7 @@ object Constants {
const val port = 7530
const val mDNSServiceName = "AIRA Node"
const val mDNSServiceType = "_aira._tcp"
const val fileSizeLimit = 32760000
const val fileSizeLimit = 16380000
const val MSG_LOADING_COUNT = 20
const val FILE_CHUNK_SIZE = 1023996
const val MAX_AVATAR_SIZE = 10000000

View File

@ -9,6 +9,7 @@ import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.bumptech.glide.Glide
import sushi.hardcore.aira.databinding.FragmentCreateIdentityBinding
import sushi.hardcore.aira.utils.AvatarPicker
@ -25,11 +26,9 @@ class CreateIdentityFragment(private val activity: AppCompatActivity) : Fragment
}
}
private val avatarPicker = AvatarPicker(activity) { picker, avatar ->
picker.setOnAvatarCompressed { compressedAvatar ->
AIRADatabase.setIdentityAvatar(Constants.getDatabaseFolder(activity), compressedAvatar)
}
avatar.circleCrop().into(binding.avatar)
private val avatarPicker = AvatarPicker(activity) { avatar ->
AIRADatabase.setIdentityAvatar(Constants.getDatabaseFolder(activity), avatar)
Glide.with(this).load(avatar).circleCrop().into(binding.avatar)
}
private lateinit var binding: FragmentCreateIdentityBinding
@ -80,7 +79,7 @@ class CreateIdentityFragment(private val activity: AppCompatActivity) : Fragment
bundle.getBinder(LoginActivity.BINDER_ARG)?.let { binder ->
val databaseFolder = Constants.getDatabaseFolder(requireContext())
if (createNewIdentity(databaseFolder, identityName, password)) {
(binder as LoginActivity.ActivityLauncher).launch(identityName)
(binder as LoginActivity.ActivityLauncher).launch()
success = true
}
}

View File

@ -9,21 +9,18 @@ import sushi.hardcore.aira.background_service.AIRAService
import java.io.File
class LoginActivity : AppCompatActivity() {
private external fun getIdentityName(databaseFolder: String): String?
companion object {
const val NAME_ARG = "identityName"
const val BINDER_ARG = "binder"
private external fun initLogging()
init {
System.loadLibrary("aira")
initLogging()
}
}
init {
AIRADatabase.init()
}
inner class ActivityLauncher: Binder() {
fun launch(identityName: String) {
startMainActivity(identityName)
fun launch() {
startMainActivity()
}
}
@ -38,13 +35,13 @@ class LoginActivity : AppCompatActivity() {
}
}
val isProtected = AIRADatabase.isIdentityProtected(databaseFolder)
val name = getIdentityName(databaseFolder)
val name = AIRADatabase.getIdentityName(databaseFolder)
if (AIRAService.isServiceRunning) {
startMainActivity(null)
startMainActivity()
} else if (name != null && !isProtected) {
if (AIRADatabase.loadIdentity(databaseFolder, null)) {
AIRADatabase.clearCache()
startMainActivity(name)
startMainActivity()
} else {
Toast.makeText(this, R.string.identity_load_failed, Toast.LENGTH_SHORT).show()
}
@ -62,11 +59,10 @@ class LoginActivity : AppCompatActivity() {
}
}
private fun startMainActivity(identityName: String?) {
private fun startMainActivity() {
val mainActivityIntent = Intent(this, MainActivity::class.java)
mainActivityIntent.action = intent.action
mainActivityIntent.putExtras(intent)
mainActivityIntent.putExtra(NAME_ARG, identityName)
startActivity(mainActivityIntent)
finish()
}

View File

@ -42,7 +42,7 @@ class LoginFragment : Fragment() {
binding.buttonLogin.setOnClickListener {
if (AIRADatabase.loadIdentity(databaseFolder, binding.editPassword.text.toString().toByteArray())) {
AIRADatabase.clearCache()
(binder as LoginActivity.ActivityLauncher).launch(name)
(binder as LoginActivity.ActivityLauncher).launch()
} else {
Toast.makeText(activity, R.string.identity_load_failed, Toast.LENGTH_SHORT).show()
}

View File

@ -8,7 +8,6 @@ import android.os.Bundle
import android.os.IBinder
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.AbsListView
import android.widget.AdapterView
import android.widget.Toast
@ -17,6 +16,7 @@ import sushi.hardcore.aira.adapters.Session
import sushi.hardcore.aira.adapters.SessionAdapter
import sushi.hardcore.aira.background_service.AIRAService
import sushi.hardcore.aira.background_service.FilesReceiver
import sushi.hardcore.aira.background_service.NotificationBroadcastReceiver
import sushi.hardcore.aira.databinding.ActivityMainBinding
import sushi.hardcore.aira.databinding.DialogIpAddressesBinding
import sushi.hardcore.aira.utils.FileUtils
@ -67,19 +67,19 @@ class MainActivity : ServiceBoundActivity() {
onlineSessionAdapter.setName(sessionId, name)
}
}
override fun onAvatarChanged(sessionId: Int, avatar: ByteArray?) {
runOnUiThread {
onlineSessionAdapter.setAvatar(sessionId, avatar)
}
}
override fun onNewMessage(sessionId: Int, data: ByteArray): Boolean {
override fun onSent(sessionId: Int, timestamp: Long, buffer: ByteArray) {}
override fun onPendingMessagesSent(sessionId: Int) {}
override fun onNewMessage(sessionId: Int, timestamp: Long, data: ByteArray): Boolean {
runOnUiThread {
onlineSessionAdapter.setSeen(sessionId, false)
}
return false
}
override fun onAskLargeFiles(sessionId: Int, filesReceiver: FilesReceiver): Boolean {
runOnUiThread {
filesReceiver.ask(this@MainActivity, airaService.getNameOf(sessionId))
@ -94,11 +94,6 @@ class MainActivity : ServiceBoundActivity() {
setContentView(binding.root)
setSupportActionBar(binding.toolbar.toolbar)
val identityName = intent.getStringExtra(LoginActivity.NAME_ARG)
identityName?.let {
initToolbar(it)
}
val openedToShareFile = intent.action == Intent.ACTION_SEND || intent.action == Intent.ACTION_SEND_MULTIPLE
onlineSessionAdapter = SessionAdapter(this)
@ -121,30 +116,28 @@ class MainActivity : ServiceBoundActivity() {
}
setOnScrollListener(onSessionsScrollListener)
}
if (openedToShareFile) {
binding.offlineSessions.visibility = View.GONE
binding.textOfflineSessions.visibility = View.GONE
} else {
offlineSessionAdapter = SessionAdapter(this)
binding.offlineSessions.apply {
adapter = offlineSessionAdapter
onItemClickListener = if (openedToShareFile) {
onSessionsItemClickSendFile
} else {
AdapterView.OnItemClickListener { _, _, position, _ ->
if (isSelecting()) {
changeSelection(offlineSessionAdapter!!, position)
} else {
launchChatActivity(offlineSessionAdapter!!.getItem(position))
}
offlineSessionAdapter = SessionAdapter(this)
binding.offlineSessions.apply {
adapter = offlineSessionAdapter
onItemClickListener = if (openedToShareFile) {
onSessionsItemClickSendFile
} else {
AdapterView.OnItemClickListener { _, _, position, _ ->
if (isSelecting()) {
changeSelection(offlineSessionAdapter!!, position)
} else {
launchChatActivity(offlineSessionAdapter!!.getItem(position))
}
}
onItemLongClickListener = AdapterView.OnItemLongClickListener { _, _, position, _ ->
changeSelection(offlineSessionAdapter!!, position)
true
}
setOnScrollListener(onSessionsScrollListener)
}
onItemLongClickListener = AdapterView.OnItemLongClickListener { _, _, position, _ ->
changeSelection(offlineSessionAdapter!!, position)
true
}
setOnScrollListener(onSessionsScrollListener)
}
if (intent.action == NotificationBroadcastReceiver.ACTION_LOGOUT) {
askLogOut()
}
serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder) {
@ -153,12 +146,10 @@ class MainActivity : ServiceBoundActivity() {
airaService.uiCallbacks = uiCallbacks
airaService.isAppInBackground = false
refreshSessions()
if (AIRAService.isServiceRunning) {
airaService.identityName?.let { initToolbar(it) }
} else {
airaService.identityName = identityName
if (!AIRAService.isServiceRunning) {
startService(serviceIntent)
}
initToolbar(airaService.identityName)
}
override fun onServiceDisconnected(name: ComponentName?) {}
}
@ -197,7 +188,10 @@ class MainActivity : ServiceBoundActivity() {
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.main_activity, menu)
menu.findItem(R.id.remove_contact).isVisible = isSelecting()
val isSelecting = isSelecting()
menu.findItem(R.id.settings).isVisible = !isSelecting
menu.findItem(R.id.close).isVisible = !isSelecting
menu.findItem(R.id.remove_contact).isVisible = isSelecting
return true
}
@ -209,18 +203,7 @@ class MainActivity : ServiceBoundActivity() {
}
R.id.close -> {
if (isServiceInitialized()) {
AlertDialog.Builder(this, R.style.CustomAlertDialog)
.setTitle(R.string.warning)
.setMessage(R.string.ask_log_out)
.setPositiveButton(R.string.yes) { _, _ ->
airaService.logOut()
if (AIRADatabase.isIdentityProtected(Constants.getDatabaseFolder(this))) {
startActivity(Intent(this, LoginActivity::class.java))
}
finish()
}
.setNegativeButton(R.string.cancel, null)
.show()
askLogOut()
}
true
}
@ -334,6 +317,21 @@ class MainActivity : ServiceBoundActivity() {
})
}
private fun askLogOut() {
AlertDialog.Builder(this, R.style.CustomAlertDialog)
.setTitle(R.string.warning)
.setMessage(R.string.ask_log_out)
.setPositiveButton(R.string.yes) { _, _ ->
airaService.logOut()
if (AIRADatabase.isIdentityProtected(Constants.getDatabaseFolder(this))) {
startActivity(Intent(this, LoginActivity::class.java))
}
finish()
}
.setNegativeButton(R.string.cancel, null)
.show()
}
private fun askShareFileTo(session: Session) {
var uris: ArrayList<Uri>? = null
when (intent.action) {
@ -346,13 +344,15 @@ class MainActivity : ServiceBoundActivity() {
Toast.makeText(this, R.string.open_uri_failed, Toast.LENGTH_SHORT).show()
} else {
val msg = if (uris!!.size == 1) {
val sendFile = FileUtils.openFileFromUri(this, uris!![0])
if (sendFile == null) {
Toast.makeText(this, R.string.open_uri_failed, Toast.LENGTH_SHORT).show()
val result = FileUtils.openFileFromUri(this, uris!![0])
if (result.file == null) {
if (!result.errorHandled) {
Toast.makeText(this, R.string.open_uri_failed, Toast.LENGTH_SHORT).show()
}
return
} else {
sendFile.inputStream.close()
getString(R.string.ask_send_single_file, sendFile.fileName, FileUtils.formatSize(sendFile.fileSize), session.name ?: session.ip)
result.file.inputStream.close()
getString(R.string.ask_send_single_file, result.file.fileName, FileUtils.formatSize(result.file.fileSize), session.name ?: session.ip)
}
} else {
getString(R.string.ask_send_multiple_files, uris!!.size, session.name ?: session.ip)

View File

@ -10,7 +10,6 @@ import android.widget.EditText
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.preference.EditTextPreference
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.SwitchPreferenceCompat
@ -28,16 +27,16 @@ import sushi.hardcore.aira.utils.StringUtils
class SettingsActivity: AppCompatActivity() {
class MySettingsFragment(private val activity: AppCompatActivity): PreferenceFragmentCompat() {
private lateinit var databaseFolder: String
private lateinit var airaService: AIRAService
private val avatarPicker = AvatarPicker(activity) { picker, avatar ->
private val avatarPicker = AvatarPicker(activity) { avatar ->
if (::airaService.isInitialized) {
picker.setOnAvatarCompressed { compressedAvatar ->
airaService.changeAvatar(compressedAvatar)
}
airaService.changeAvatar(avatar)
}
displayAvatar(avatar)
}
private lateinit var identityAvatarPreference: Preference
private lateinit var startAtBootSwitch: SwitchPreferenceCompat
override fun onAttach(context: Context) {
super.onAttach(context)
@ -46,10 +45,13 @@ class SettingsActivity: AppCompatActivity() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.preferences, rootKey)
databaseFolder = Constants.getDatabaseFolder(activity)
findPreference<Preference>("identityAvatar")?.let { identityAvatarPreference = it }
startAtBootSwitch = findPreference("startAtBoot")!!
updateStartAtBootSwitch(AIRADatabase.isIdentityProtected(databaseFolder))
val paddingPreference = findPreference<SwitchPreferenceCompat>("psecPadding")
paddingPreference?.isPersistent = false
AIRADatabase.getIdentityAvatar(Constants.getDatabaseFolder(activity))?.let { avatar ->
AIRADatabase.getIdentityAvatar(databaseFolder)?.let { avatar ->
displayAvatar(avatar)
}
Intent(activity, AIRAService::class.java).also { serviceIntent ->
@ -69,9 +71,9 @@ class SettingsActivity: AppCompatActivity() {
avatarPicker.launch()
}
val dialogBinding = ChangeAvatarDialogBinding.inflate(layoutInflater)
val avatar = AIRADatabase.getIdentityAvatar(Constants.getDatabaseFolder(activity))
val avatar = AIRADatabase.getIdentityAvatar(databaseFolder)
if (avatar == null) {
dialogBinding.avatar.setTextAvatar(airaService.identityName!!)
dialogBinding.avatar.setTextAvatar(airaService.identityName)
} else {
dialogBinding.avatar.setImageAvatar(avatar)
dialogBuilder.setNegativeButton(R.string.remove) { _, _ ->
@ -113,7 +115,7 @@ class SettingsActivity: AppCompatActivity() {
findPreference<Preference>("identityPassword")?.setOnPreferenceClickListener {
val dialogView = layoutInflater.inflate(R.layout.dialog_password, null)
val oldPasswordEditText = dialogView.findViewById<EditText>(R.id.old_password)
val isIdentityProtected = AIRADatabase.isIdentityProtected(Constants.getDatabaseFolder(activity))
val isIdentityProtected = AIRADatabase.isIdentityProtected(databaseFolder)
if (!isIdentityProtected) {
oldPasswordEditText.visibility = View.GONE
}
@ -124,24 +126,24 @@ class SettingsActivity: AppCompatActivity() {
.setTitle(R.string.change_password)
.setPositiveButton(R.string.ok) { _, _ ->
val newPassword = newPasswordEditText.text.toString().toByteArray()
if (newPassword.isEmpty()) {
if (isIdentityProtected) { //don't change password if identity is not protected and new password is blank
changePassword(isIdentityProtected, oldPasswordEditText, null)
val newPasswordConfirm = newPasswordConfirmEditText.text.toString().toByteArray()
if (newPassword.contentEquals(newPasswordConfirm)) {
if (newPassword.isEmpty()) {
if (isIdentityProtected) { //don't change password if identity is not protected and new password is blank
changePassword(isIdentityProtected, oldPasswordEditText, null)
}
} else {
changePassword(isIdentityProtected, oldPasswordEditText, newPassword)
}
} else {
val newPasswordConfirm = newPasswordConfirmEditText.text.toString().toByteArray()
if (newPassword.contentEquals(newPasswordConfirm)) {
changePassword(isIdentityProtected, oldPasswordEditText, newPassword)
} else {
AlertDialog.Builder(activity, R.style.CustomAlertDialog)
.setMessage(R.string.password_mismatch)
.setTitle(R.string.error)
.setPositiveButton(R.string.ok, null)
.show()
}
newPassword.fill(0)
newPasswordConfirm.fill(0)
AlertDialog.Builder(activity, R.style.CustomAlertDialog)
.setMessage(R.string.password_mismatch)
.setTitle(R.string.error)
.setPositiveButton(R.string.ok, null)
.show()
}
newPassword.fill(0)
newPasswordConfirm.fill(0)
}
.setNegativeButton(R.string.cancel, null)
.show()
@ -170,26 +172,33 @@ class SettingsActivity: AppCompatActivity() {
if (avatar == null) {
identityAvatarPreference.setIcon(R.drawable.ic_face)
} else {
displayAvatar(Glide.with(this).load(avatar))
Glide
.with(this)
.load(avatar)
.apply(RequestOptions().override(90)) //reduce image to be the same size as other icons
.circleCrop()
.into(object : CustomTarget<Drawable>() {
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
identityAvatarPreference.icon = resource
}
override fun onLoadCleared(placeholder: Drawable?) {}
})
}
}
private fun displayAvatar(glideBuilder: RequestBuilder<Drawable>) {
glideBuilder.apply(RequestOptions().override(90)).circleCrop().into(object : CustomTarget<Drawable>() {
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
identityAvatarPreference.icon = resource
}
override fun onLoadCleared(placeholder: Drawable?) {}
})
}
private fun changePassword(isIdentityProtected: Boolean, oldPasswordEditText: EditText, newPassword: ByteArray?) {
val oldPassword = if (isIdentityProtected) {
oldPasswordEditText.text.toString().toByteArray()
} else {
null
}
if (!AIRADatabase.changePassword(Constants.getDatabaseFolder(activity), oldPassword, newPassword)) {
if (AIRADatabase.changePassword(databaseFolder, oldPassword, newPassword)) {
val isNowIdentityProtected = newPassword != null
updateStartAtBootSwitch(isNowIdentityProtected)
if (isIdentityProtected && !isNowIdentityProtected ) {
startAtBootSwitch.isChecked = true
}
} else {
AlertDialog.Builder(activity, R.style.CustomAlertDialog)
.setMessage(R.string.change_password_failed)
.setTitle(R.string.error)
@ -198,6 +207,15 @@ class SettingsActivity: AppCompatActivity() {
}
oldPassword?.fill(0)
}
private fun updateStartAtBootSwitch(isIdentityProtected: Boolean) {
startAtBootSwitch.isEnabled = !isIdentityProtected
startAtBootSwitch.summary = getString(if (isIdentityProtected) {
R.string.start_at_boot_summary_identity_protected
} else {
R.string.start_at_boot_summary
})
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {

View File

@ -1,8 +1,8 @@
package sushi.hardcore.aira.adapters
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.drawable.GradientDrawable
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
@ -11,10 +11,17 @@ import android.widget.ImageButton
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.view.updateMargins
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.RecyclerView
import sushi.hardcore.aira.AIRADatabase
import sushi.hardcore.aira.ChatItem
import sushi.hardcore.aira.R
import sushi.hardcore.aira.background_service.Protocol
import sushi.hardcore.aira.utils.StringUtils
import sushi.hardcore.aira.utils.TimeUtils
import java.text.DateFormat
import java.util.*
class ChatAdapter(
private val context: Context,
@ -22,8 +29,11 @@ class ChatAdapter(
): RecyclerView.Adapter<RecyclerView.ViewHolder>() {
companion object {
const val CONTAINER_MARGIN = 150
const val BUBBLE_HORIZONTAL_PADDING = 40
const val BUBBLE_MARGIN = 150
const val CONTAINER_PADDING = 40
const val BUBBLE_VERTICAL_MARGIN = 30
const val BUBBLE_CORNER_NORMAL = 50f
const val BUBBLE_CORNER_ARROW = 20f
}
private val inflater: LayoutInflater = LayoutInflater.from(context)
@ -31,12 +41,22 @@ class ChatAdapter(
fun newMessage(chatItem: ChatItem) {
chatItems.add(chatItem)
notifyItemChanged(chatItems.size-2)
notifyItemInserted(chatItems.size-1)
}
fun newLoadedMessage(chatItem: ChatItem) {
chatItems.add(0, chatItem)
notifyItemInserted(0)
notifyItemChanged(1)
}
fun removePendingMessages() {
val oldSize = chatItems.size
chatItems.removeAll {
it.timestamp == 0L
}
notifyItemRangeRemoved(chatItems.size, oldSize-chatItems.size)
}
fun clear() {
@ -44,95 +64,205 @@ class ChatAdapter(
notifyDataSetChanged()
}
internal open class BubbleViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) {
protected fun setPadding(outgoing: Boolean) {
if (outgoing) {
itemView.updatePadding(right = BUBBLE_HORIZONTAL_PADDING)
} else {
itemView.updatePadding(left = BUBBLE_HORIZONTAL_PADDING)
internal open class BubbleViewHolder(private val context: Context, itemView: View): RecyclerView.ViewHolder(itemView) {
private fun generateCorners(topLeft: Float, topRight: Float, bottomRight: Float, bottomLeft: Float): FloatArray {
return floatArrayOf(topLeft, topLeft, topRight, topRight, bottomRight, bottomRight, bottomLeft, bottomLeft)
}
protected fun setBubbleContent(layoutResource: Int) {
//if the view was recycled bubble_content will be null and we don't need to inflate a layout
itemView.findViewById<View>(R.id.bubble_content)?.let { placeHolder ->
val parent = placeHolder.parent as ViewGroup
val index = parent.indexOfChild(placeHolder)
parent.removeView(placeHolder)
val bubbleContent = LayoutInflater.from(context).inflate(layoutResource, parent, false)
parent.addView(bubbleContent, index)
}
}
protected fun configureBubble(context: Context, view: View, outgoing: Boolean) {
view.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
gravity = if (outgoing) {
marginStart = CONTAINER_MARGIN
protected fun configureContainer(outgoing: Boolean, previousOutgoing: Boolean?, isLast: Boolean) {
val layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
if (previousOutgoing != null && previousOutgoing != outgoing) {
layoutParams.updateMargins(top = BUBBLE_VERTICAL_MARGIN)
}
if (isLast) {
layoutParams.updateMargins(bottom = BUBBLE_VERTICAL_MARGIN)
}
itemView.layoutParams = layoutParams //set layoutParams anyway to reset margins if the view was recycled
if (outgoing) {
itemView.updatePadding(right = CONTAINER_PADDING)
} else {
itemView.updatePadding(left = CONTAINER_PADDING)
}
}
protected fun configureBubble(chatItem: ChatItem, previousChatItem: ChatItem?, nextChatItem: ChatItem?) {
val bubble = itemView.findViewById<LinearLayout>(R.id.bubble)
bubble.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
gravity = if (chatItem.outgoing) {
marginStart = BUBBLE_MARGIN
Gravity.END
} else {
marginEnd = CONTAINER_MARGIN
marginEnd = BUBBLE_MARGIN
Gravity.START
}
}
if (!outgoing) {
view.background.colorFilter = PorterDuffColorFilter(
ContextCompat.getColor(context, R.color.incomingBubbleBackground),
PorterDuff.Mode.SRC
)
val backgroundDrawable = GradientDrawable()
backgroundDrawable.setColor(ContextCompat.getColor(context, if (chatItem.outgoing) {
R.color.bubbleBackground
} else {
R.color.incomingBubbleBackground
}))
var topLeft = BUBBLE_CORNER_NORMAL
var topRight = BUBBLE_CORNER_NORMAL
var bottomRight = BUBBLE_CORNER_NORMAL
var bottomLeft = BUBBLE_CORNER_NORMAL
if (previousChatItem?.outgoing == chatItem.outgoing && TimeUtils.isInTheSameDay(chatItem, previousChatItem)) {
if (chatItem.outgoing) {
topRight = BUBBLE_CORNER_ARROW
} else {
topLeft = BUBBLE_CORNER_ARROW
}
}
if (nextChatItem?.outgoing == chatItem.outgoing && TimeUtils.isInTheSameDay(chatItem, nextChatItem)) {
if (chatItem.outgoing) {
bottomRight = BUBBLE_CORNER_ARROW
} else {
bottomLeft = BUBBLE_CORNER_ARROW
}
}
backgroundDrawable.cornerRadii = generateCorners(topLeft, topRight, bottomRight, bottomLeft)
bubble.background = backgroundDrawable
}
protected fun showDateAndTime(chatItem: ChatItem, previousChatItem: ChatItem?) {
var showTextPendingMsg = false
val textPendingMsg = itemView.findViewById<TextView>(R.id.text_pending_msg)
val textDate = itemView.findViewById<TextView>(R.id.text_date)
val textHour = itemView.findViewById<TextView>(R.id.text_hour)
val showDate = if (chatItem.timestamp == 0L) {
if (previousChatItem == null || previousChatItem.timestamp != 0L) {
showTextPendingMsg = true
}
textHour.visibility = View.GONE
false
} else {
textHour.apply {
visibility = View.VISIBLE
@SuppressLint("SetTextI18n")
text = StringUtils.toTwoDigits(chatItem.calendar.get(Calendar.HOUR_OF_DAY))+":"+StringUtils.toTwoDigits(chatItem.calendar.get(Calendar.MINUTE))
setTextColor(ContextCompat.getColor(context, if (chatItem.outgoing) {
R.color.outgoingTimestamp
} else {
R.color.incomingTimestamp
}))
}
if (previousChatItem == null) {
true
} else {
!TimeUtils.isInTheSameDay(chatItem, previousChatItem)
}
}
textDate.visibility = if (showDate) {
textDate.text = DateFormat.getDateInstance().format(chatItem.calendar.time)
View.VISIBLE
} else {
View.GONE
}
textPendingMsg.visibility = if (showTextPendingMsg) {
View.VISIBLE
} else {
View.GONE
}
}
}
internal open class MessageViewHolder(itemView: View): BubbleViewHolder(itemView) {
protected fun bindMessage(chatItem: ChatItem): TextView {
internal open class MessageViewHolder(context: Context, itemView: View): BubbleViewHolder(context, itemView) {
protected fun bindMessage(chatItem: ChatItem, outgoing: Boolean): TextView {
setBubbleContent(R.layout.message_bubble_content)
itemView.findViewById<TextView>(R.id.text_message).apply {
text = chatItem.data.sliceArray(1 until chatItem.data.size).decodeToString()
if (!outgoing) {
highlightColor = ContextCompat.getColor(context, R.color.incomingHighlight)
}
return this
}
}
}
internal class OutgoingMessageViewHolder(private val context: Context, itemView: View): MessageViewHolder(itemView) {
fun bind(chatItem: ChatItem) {
configureBubble(context, bindMessage(chatItem).apply {
setLinkTextColor(ContextCompat.getColor(context, R.color.outgoingTextLink))
}, true)
setPadding(true)
}
}
internal class IncomingMessageViewHolder(private val context: Context, itemView: View): MessageViewHolder(itemView) {
fun bind(chatItem: ChatItem) {
configureBubble(context, bindMessage(chatItem).apply {
setLinkTextColor(ContextCompat.getColor(context, R.color.incomingTextLink))
}, false)
setPadding(false)
}
}
internal open class FileViewHolder(itemView: View, private val onSavingFile: (filename: String, rawUuid: ByteArray) -> Unit): BubbleViewHolder(itemView) {
protected fun bindFile(chatItem: ChatItem): LinearLayout {
val filename = chatItem.data.sliceArray(17 until chatItem.data.size).decodeToString()
itemView.findViewById<TextView>(R.id.text_filename).text = filename
itemView.findViewById<ImageButton>(R.id.button_save).setOnClickListener {
onSavingFile(filename, chatItem.data.sliceArray(1 until 17))
internal class OutgoingMessageViewHolder(context: Context, itemView: View): MessageViewHolder(context, itemView) {
fun bind(chatItem: ChatItem, previousChatItem: ChatItem?, nextChatItem: ChatItem?) {
bindMessage(chatItem, true).apply {
setLinkTextColor(ContextCompat.getColor(context, R.color.outgoingTextLink))
}
return itemView.findViewById(R.id.bubble_content)
showDateAndTime(chatItem, previousChatItem)
configureBubble(chatItem, previousChatItem, nextChatItem)
configureContainer(true, previousChatItem?.outgoing, nextChatItem == null)
}
}
internal class OutgoingFileViewHolder(private val context: Context, itemView: View, onSavingFile: (filename: String, rawUuid: ByteArray) -> Unit): FileViewHolder(itemView, onSavingFile) {
fun bind(chatItem: ChatItem) {
configureBubble(context, bindFile(chatItem), true)
setPadding(true)
internal class IncomingMessageViewHolder(context: Context, itemView: View): MessageViewHolder(context, itemView) {
fun bind(chatItem: ChatItem, previousChatItem: ChatItem?, nextChatItem: ChatItem?) {
bindMessage(chatItem, false).apply {
setLinkTextColor(ContextCompat.getColor(context, R.color.incomingTextLink))
}
showDateAndTime(chatItem, previousChatItem)
configureBubble(chatItem, previousChatItem, nextChatItem)
configureContainer(false, previousChatItem?.outgoing, nextChatItem == null)
}
}
internal class IncomingFileViewHolder(private val context: Context, itemView: View, onSavingFile: (filename: String, rawUuid: ByteArray) -> Unit): FileViewHolder(itemView, onSavingFile) {
fun bind(chatItem: ChatItem) {
configureBubble(context, bindFile(chatItem), false)
setPadding(false)
internal open class FileViewHolder(context: Context, itemView: View, private val onSavingFile: (fileName: String, fileContent: ByteArray) -> Unit): BubbleViewHolder(context, itemView) {
protected fun bindFile(chatItem: ChatItem, outgoing: Boolean) {
setBubbleContent(R.layout.file_bubble_content)
val buttonSave = itemView.findViewById<ImageButton>(R.id.button_save)
val fileName: String
if (chatItem.timestamp == 0L) { //pending
val file = Protocol.parseSmallFile(chatItem.data)!!
fileName = file.rawFileName.decodeToString()
buttonSave.setOnClickListener {
onSavingFile(fileName, file.fileContent)
}
} else {
fileName = chatItem.data.sliceArray(17 until chatItem.data.size).decodeToString()
buttonSave.setOnClickListener {
AIRADatabase.loadFile(chatItem.data.sliceArray(1 until 17))?.let {
onSavingFile(fileName, it)
}
}
}
itemView.findViewById<TextView>(R.id.text_filename).apply {
text = fileName
if (!outgoing) {
highlightColor = ContextCompat.getColor(context, R.color.incomingHighlight)
}
}
}
}
internal class OutgoingFileViewHolder(context: Context, itemView: View, onSavingFile: (filename: String, rawUuid: ByteArray) -> Unit): FileViewHolder(context, itemView, onSavingFile) {
fun bind(chatItem: ChatItem, previousChatItem: ChatItem?, nextChatItem: ChatItem?) {
bindFile(chatItem, true)
showDateAndTime(chatItem, previousChatItem)
configureBubble(chatItem, previousChatItem, nextChatItem)
configureContainer(true, previousChatItem?.outgoing, nextChatItem == null)
}
}
internal class IncomingFileViewHolder(context: Context, itemView: View, onSavingFile: (filename: String, rawUuid: ByteArray) -> Unit): FileViewHolder(context, itemView, onSavingFile) {
fun bind(chatItem: ChatItem, previousChatItem: ChatItem?, nextChatItem: ChatItem?) {
bindFile(chatItem, false)
showDateAndTime(chatItem, previousChatItem)
configureBubble(chatItem, previousChatItem, nextChatItem)
configureContainer(false, previousChatItem?.outgoing, nextChatItem == null)
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val view = inflater.inflate(R.layout.adapter_chat_item, parent, false)
return if (viewType == ChatItem.OUTGOING_MESSAGE || viewType == ChatItem.INCOMING_MESSAGE) {
val view = inflater.inflate(R.layout.adapter_chat_message, parent, false)
if (viewType == ChatItem.OUTGOING_MESSAGE) {
OutgoingMessageViewHolder(context, view)
} else {
IncomingMessageViewHolder(context, view)
}
} else {
val view = inflater.inflate(R.layout.adapter_chat_file, parent, false)
if (viewType == ChatItem.OUTGOING_FILE) {
OutgoingFileViewHolder(context, view, onSavingFile)
} else {
@ -143,11 +273,21 @@ class ChatAdapter(
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val chatItem = chatItems[position]
val previousChatItem = if (position == 0) {
null
} else {
chatItems[position-1]
}
val nextChatItem = if (position == chatItems.size - 1) {
null
} else {
chatItems[position+1]
}
when (chatItem.itemType) {
ChatItem.OUTGOING_MESSAGE -> (holder as OutgoingMessageViewHolder).bind(chatItem)
ChatItem.INCOMING_MESSAGE -> (holder as IncomingMessageViewHolder).bind(chatItem)
ChatItem.OUTGOING_FILE -> (holder as OutgoingFileViewHolder).bind(chatItem)
ChatItem.INCOMING_FILE -> (holder as IncomingFileViewHolder).bind(chatItem)
ChatItem.OUTGOING_MESSAGE -> (holder as OutgoingMessageViewHolder).bind(chatItem, previousChatItem, nextChatItem)
ChatItem.INCOMING_MESSAGE -> (holder as IncomingMessageViewHolder).bind(chatItem, previousChatItem, nextChatItem)
ChatItem.OUTGOING_FILE -> (holder as OutgoingFileViewHolder).bind(chatItem, previousChatItem, nextChatItem)
ChatItem.INCOMING_FILE -> (holder as IncomingFileViewHolder).bind(chatItem, previousChatItem, nextChatItem)
}
}

View File

@ -0,0 +1,18 @@
package sushi.hardcore.aira.adapters
import android.content.Context
import android.util.AttributeSet
import androidx.recyclerview.widget.LinearLayoutManager
//https://stackoverflow.com/questions/36724898/notifyitemchanged-make-the-recyclerview-scroll-and-jump-to-up
class FuckRecyclerView(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
defStyleRes: Int = 0,
): LinearLayoutManager(context, attrs, defStyleAttr, defStyleRes) {
override fun isAutoMeasureEnabled(): Boolean {
return false
}
}

View File

@ -1,5 +1,6 @@
package sushi.hardcore.aira.background_service
import android.annotation.SuppressLint
import android.app.*
import android.content.Context
import android.content.Intent
@ -13,14 +14,19 @@ import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.Person
import androidx.core.app.RemoteInput
import androidx.core.graphics.drawable.IconCompat
import com.bumptech.glide.Glide
import sushi.hardcore.aira.*
import sushi.hardcore.aira.utils.FileUtils
import sushi.hardcore.aira.utils.StringUtils
import sushi.hardcore.aira.utils.TimeUtils
import java.io.IOException
import java.net.*
import java.nio.channels.*
@SuppressLint("UnspecifiedImmutableFlag")
class AIRAService : Service() {
private external fun releaseIdentity()
@ -35,6 +41,7 @@ class AIRAService : Service() {
const val MESSAGE_SEND_NAME = 3
const val MESSAGE_SEND_AVATAR = 4
const val MESSAGE_CANCEL_FILE_TRANSFER = 5
const val FLAG_PENDING_INTENT = PendingIntent.FLAG_UPDATE_CURRENT
var isServiceRunning = false
}
@ -42,9 +49,11 @@ class AIRAService : Service() {
private val binder = AIRABinder()
val sessions = mutableMapOf<Int, Session>()
private var sessionCounter = 0
private var server: ServerSocketChannel? = null
private lateinit var selector: Selector
private val sessionIdByKey = mutableMapOf<SelectionKey, Int>()
private val databaseFolder by lazy {
Constants.getDatabaseFolder(this)
}
private val notificationIdManager = NotificationIdManager()
private val sendFileTransfers = mutableMapOf<Int, FilesSender>()
val receiveFileTransfers = mutableMapOf<Int, FilesReceiver>()
@ -86,13 +95,14 @@ class AIRAService : Service() {
}
override fun onServiceLost(serviceInfo: NsdServiceInfo?) {}
}
lateinit var identityName: String
val savedMsgs = mutableMapOf<Int, MutableList<ChatItem>>()
val pendingMsgs = mutableMapOf<Int, MutableList<ByteArray>>()
val savedNames = mutableMapOf<Int, String>()
val savedAvatars = mutableMapOf<Int, String>()
val notSeen = mutableListOf<Int>()
var uiCallbacks: UiCallbacks? = null
var isAppInBackground = true
var identityName: String? = null
inner class AIRABinder : Binder() {
fun getService(): AIRAService = this@AIRAService
@ -108,7 +118,9 @@ class AIRAService : Service() {
fun onSessionDisconnect(sessionId: Int)
fun onNameTold(sessionId: Int, name: String)
fun onAvatarChanged(sessionId: Int, avatar: ByteArray?)
fun onNewMessage(sessionId: Int, data: ByteArray): Boolean
fun onSent(sessionId: Int, timestamp: Long, buffer: ByteArray)
fun onPendingMessagesSent(sessionId: Int)
fun onNewMessage(sessionId: Int, timestamp: Long, data: ByteArray): Boolean
fun onAskLargeFiles(sessionId: Int, filesReceiver: FilesReceiver): Boolean
}
@ -120,7 +132,7 @@ class AIRAService : Service() {
}
}
fun sendTo(sessionId: Int, buffer: ByteArray) {
private fun sendTo(sessionId: Int, buffer: ByteArray) {
serviceHandler.obtainMessage().apply {
what = MESSAGE_SEND_TO
data = Bundle().apply {
@ -131,6 +143,16 @@ class AIRAService : Service() {
}
}
fun sendOrAddToPending(sessionId: Int, buffer: ByteArray): Boolean {
return if (isOnline(sessionId)) {
sendTo(sessionId, buffer)
true
} else {
pendingMsgs[sessionId]?.add(buffer)
false
}
}
fun cancelFileTransfer(sessionId: Int) {
serviceHandler.obtainMessage().apply {
what = MESSAGE_CANCEL_FILE_TRANSFER
@ -139,11 +161,11 @@ class AIRAService : Service() {
}
}
fun sendFilesFromUris(sessionId: Int, uris: List<Uri>) {
fun sendFilesFromUris(sessionId: Int, uris: List<Uri>, onPendingSmallFile: ((ByteArray) -> Unit)? = null) {
val files = mutableListOf<SendFile>()
var useLargeFileTransfer = false
for (uri in uris) {
FileUtils.openFileFromUri(this, uri)?.let { sendFile ->
FileUtils.openFileFromUri(this, uri).file?.let { sendFile ->
files.add(sendFile)
if (sendFile.fileSize > Constants.fileSizeLimit) {
useLargeFileTransfer = true
@ -154,26 +176,22 @@ class AIRAService : Service() {
sendLargeFilesTo(sessionId, files)
} else {
for (file in files) {
sendSmallFileTo(sessionId, file)
var buffer = file.inputStream.readBytes()
file.inputStream.close()
buffer = Protocol.newFile(file.fileName, buffer)
if (!sendOrAddToPending(sessionId, buffer)) {
onPendingSmallFile?.let { it(buffer) }
}
}
}
}
private fun sendSmallFileTo(sessionId: Int, sendFile: SendFile) {
val buffer = sendFile.inputStream.readBytes()
sendFile.inputStream.close()
sendTo(sessionId, Protocol.newFile(sendFile.fileName, buffer))
AIRADatabase.storeFile(contacts[sessionId]?.uuid, buffer)?.let { rawFileUuid ->
saveMsg(sessionId, byteArrayOf(Protocol.FILE) + rawFileUuid + sendFile.fileName.toByteArray())
}
}
private fun sendLargeFilesTo(sessionId: Int, files: MutableList<SendFile>) {
if (sendFileTransfers[sessionId] == null && receiveFileTransfers[sessionId] == null) {
val filesSender = FilesSender(files, this, notificationManager)
initFileTransferNotification(sessionId, filesSender.fileTransferNotification, filesSender.files[0])
sendFileTransfers[sessionId] = filesSender
sendTo(sessionId, Protocol.askLargeFiles(files))
sendOrAddToPending(sessionId, Protocol.askLargeFiles(files))
} else {
Toast.makeText(this, R.string.file_transfer_already_in_progress, Toast.LENGTH_SHORT).show()
}
@ -193,7 +211,7 @@ class AIRAService : Service() {
return contacts[sessionId]?.name ?: savedNames[sessionId] ?: sessions[sessionId]!!.ip
}
private fun isContact(sessionId: Int): Boolean {
fun isContact(sessionId: Int): Boolean {
return contacts.contains(sessionId)
}
@ -203,10 +221,11 @@ class AIRAService : Service() {
contacts[sessionId] = contact
savedMsgs.remove(sessionId)?.let { msgs ->
for (msg in msgs) {
AIRADatabase.storeMsg(contact.uuid, msg.outgoing, msg.data)
AIRADatabase.storeMsg(contact.uuid, msg.outgoing, msg.timestamp, msg.data)
}
}
savedNames.remove(sessionId)
pendingMsgs[sessionId] = mutableListOf()
savedAvatars.remove(sessionId)
return true
}
@ -244,6 +263,7 @@ class AIRAService : Service() {
contacts.remove(sessionId)?.let {
return if (AIRADatabase.removeContact(it.uuid)) {
savedMsgs[sessionId] = mutableListOf()
pendingMsgs.remove(sessionId)
savedNames[sessionId] = it.name
it.avatar?.let { avatarUuid ->
savedAvatars[sessionId] = avatarUuid
@ -279,7 +299,6 @@ class AIRAService : Service() {
}
fun changeAvatar(avatar: ByteArray?): Boolean {
val databaseFolder = Constants.getDatabaseFolder(applicationContext)
val success = if (avatar == null) {
AIRADatabase.removeIdentityAvatar(databaseFolder)
} else {
@ -327,7 +346,12 @@ class AIRAService : Service() {
val key = session.register(selector, SelectionKey.OP_READ)
sessionIdByKey[key] = sessionId
uiCallbacks?.onNewSession(sessionId, session.ip)
if (!isContact(sessionId)) {
if (isContact(sessionId)) {
for (i in 0 until pendingMsgs[sessionId]!!.size) {
sendAndSave(sessionId, pendingMsgs[sessionId]!!.removeAt(0))
}
uiCallbacks?.onPendingMessagesSent(sessionId)
} else {
session.encryptAndSend(Protocol.askProfileInfo(), usePadding)
}
} else {
@ -343,65 +367,82 @@ class AIRAService : Service() {
}.start()
}
private fun sendNotification(sessionId: Int, msgContent: ByteArray) {
val notificationBuilder = NotificationCompat.Builder(this, MESSAGES_NOTIFICATION_CHANNEL_ID)
private fun avatarToIcon(avatar: ByteArray): IconCompat {
return IconCompat.createWithBitmap(
Glide.with(this)
.asBitmap()
.load(avatar)
.submit()
.get()
)
}
private fun sendNotification(sessionId: Int, msgContent: ByteArray, timestamp: Long) {
val name = getNameOf(sessionId)
val text = if (msgContent[0] == Protocol.MESSAGE) {
msgContent.decodeToString(1)
} else { //file
msgContent.decodeToString(17)
}
val notification = NotificationCompat.Builder(this, MESSAGES_NOTIFICATION_CHANNEL_ID)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setSmallIcon(R.drawable.ic_launcher)
.setContentTitle(getNameOf(sessionId))
.setContentText(
if (msgContent[0] == Protocol.MESSAGE) {
msgContent.decodeToString(1)
} else { //file
msgContent.decodeToString(17)
.setContentTitle(name)
.setContentText(text)
.setStyle(NotificationCompat.MessagingStyle(
Person.Builder()
.setName(identityName)
.apply {
AIRADatabase.getIdentityAvatar(databaseFolder)?.let {
setIcon(avatarToIcon(it))
}
}
.build()
)
.addMessage(text, timestamp, Person.Builder()
.setName(name)
.apply {
(savedAvatars[sessionId] ?: contacts[sessionId]?.avatar)?.let { uuid ->
AIRADatabase.loadAvatar(uuid)?.let {
setIcon(avatarToIcon(it))
}
}
}
.build()
)
)
.setContentIntent(
PendingIntent.getActivity(this, 0, Intent(this, ChatActivity::class.java).apply {
putExtra("sessionId", sessionId)
}, 0)
}, FLAG_PENDING_INTENT)
)
.setAutoCancel(true)
.setDefaults(Notification.DEFAULT_ALL)
.apply {
priority = NotificationCompat.PRIORITY_HIGH
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH) {
val markReadIntent = PendingIntent.getBroadcast(this, 0,
Intent(this, NotificationBroadcastReceiver::class.java).apply {
val bundle = Bundle()
bundle.putBinder("binder", AIRABinder())
bundle.putInt("sessionId", sessionId)
putExtra("bundle", bundle)
action = NotificationBroadcastReceiver.ACTION_MARK_READ
}, PendingIntent.FLAG_UPDATE_CURRENT)
notificationBuilder.addAction(
NotificationCompat.Action(
R.drawable.ic_launcher,
getString(R.string.mark_read),
markReadIntent
.addAction(NotificationCompat.Action(
R.drawable.ic_launcher,
getString(R.string.mark_read),
newActionPendingIntent {
it.putBinder(sessionId)
it.action = NotificationBroadcastReceiver.ACTION_MARK_READ
}
))
.addAction(
NotificationCompat.Action.Builder(R.drawable.ic_launcher, getString(R.string.reply), newActionPendingIntent {
it.putBinder(sessionId)
it.action = NotificationBroadcastReceiver.ACTION_REPLY
})
.addRemoteInput(
RemoteInput.Builder(NotificationBroadcastReceiver.KEY_TEXT_REPLY)
.setLabel(getString(R.string.reply))
.build()
)
)
val replyPendingIntent: PendingIntent =
PendingIntent.getBroadcast(this, 0,
Intent(this, NotificationBroadcastReceiver::class.java).apply {
val bundle = Bundle()
bundle.putBinder("binder", AIRABinder())
bundle.putInt("sessionId", sessionId)
putExtra("bundle", bundle)
action = NotificationBroadcastReceiver.ACTION_REPLY
}, PendingIntent.FLAG_UPDATE_CURRENT)
notificationBuilder.addAction(
NotificationCompat.Action.Builder(R.drawable.ic_launcher, getString(R.string.reply), replyPendingIntent)
.addRemoteInput(
RemoteInput.Builder(NotificationBroadcastReceiver.KEY_TEXT_REPLY)
.setLabel(getString(R.string.reply))
.build()
)
.build()
)
}
notificationManager.notify(notificationIdManager.getMessageNotificationId(sessionId), notificationBuilder.build())
.build()
)
.build()
notificationManager.notify(notificationIdManager.getMessageNotificationId(sessionId), notification)
}
private fun initFileTransferNotification(sessionId: Int, fileTransferNotification: FileTransferNotification, file: PendingFile) {
@ -410,29 +451,43 @@ class AIRAService : Service() {
file.fileName,
file.fileSize.toInt(),
Intent(this, NotificationBroadcastReceiver::class.java).apply {
val bundle = Bundle()
bundle.putBinder("binder", AIRABinder())
bundle.putInt("sessionId", sessionId)
putExtra("bundle", bundle)
putBinder(sessionId)
action = NotificationBroadcastReceiver.ACTION_CANCEL_FILE_TRANSFER
}
)
}
private fun saveMsg(sessionId: Int, msg: ByteArray) {
private fun Intent.putBinder(sessionId: Int) {
val bundle = Bundle()
bundle.putBinder("binder", AIRABinder())
bundle.putInt("sessionId", sessionId)
putExtra("bundle", bundle)
}
private fun saveMsg(sessionId: Int, timestamp: Long, msg: ByteArray) {
var msgSaved = false
contacts[sessionId]?.uuid?.let { uuid ->
msgSaved = AIRADatabase.storeMsg(uuid, true, msg)
msgSaved = AIRADatabase.storeMsg(uuid, true, timestamp, msg)
}
if (!msgSaved) {
savedMsgs[sessionId]?.add(ChatItem(true, msg))
savedMsgs[sessionId]?.add(ChatItem(true, timestamp, msg))
}
}
private fun sendAndSave(sessionId: Int, msg: ByteArray) {
sessions[sessionId]?.encryptAndSend(msg, usePadding)
if (msg[0] == Protocol.MESSAGE) {
saveMsg(sessionId, msg)
private fun sendAndSave(sessionId: Int, buffer: ByteArray) {
sessions[sessionId]?.encryptAndSend(buffer, usePadding)
val timestamp = TimeUtils.getTimestamp()
if (buffer[0] == Protocol.MESSAGE) {
uiCallbacks?.onSent(sessionId, timestamp, buffer)
saveMsg(sessionId, timestamp, buffer)
} else if (buffer[0] == Protocol.FILE) {
Protocol.parseSmallFile(buffer)?.let { file ->
AIRADatabase.storeFile(contacts[sessionId]?.uuid, file.fileContent)?.let { rawFileUuid ->
val msg = byteArrayOf(Protocol.FILE) + rawFileUuid + file.rawFileName
uiCallbacks?.onSent(sessionId, timestamp, msg)
saveMsg(sessionId, timestamp, msg)
}
}
}
}
@ -482,14 +537,12 @@ class AIRAService : Service() {
}
}
MESSAGE_SEND_NAME -> {
identityName?.let {
val tellingName = Protocol.name(it)
for (session in sessions.values) {
try {
session.encryptAndSend(tellingName, usePadding)
} catch (e: SocketException) {
e.printStackTrace()
}
val tellingName = Protocol.name(identityName)
for (session in sessions.values) {
try {
session.encryptAndSend(tellingName, usePadding)
} catch (e: SocketException) {
e.printStackTrace()
}
}
}
@ -514,10 +567,6 @@ class AIRAService : Service() {
quit()
stopSelf()
uiCallbacks = null
for (session in sessions.values) {
session.close()
}
server?.close()
}
}
} catch (e: IOException) {
@ -526,6 +575,7 @@ class AIRAService : Service() {
}
}
}
identityName = AIRADatabase.getIdentityName(databaseFolder)!!
val contactList = AIRADatabase.loadContacts()
if (contactList == null) {
contacts = HashMap(0)
@ -533,6 +583,7 @@ class AIRAService : Service() {
contacts = HashMap(contactList.size)
for (contact in contactList) {
contacts[sessionCounter] = contact
pendingMsgs[sessionCounter] = mutableListOf()
if (!contact.seen) {
notSeen.add(sessionCounter)
}
@ -565,8 +616,8 @@ class AIRAService : Service() {
0
}
filesSender.nextChunk = if (read > 0) {
filesSender.lastChunkSizes.add(nextChunk.size)
session.encrypt(nextChunk, usePadding)
filesSender.lastChunkSizes.add(read+1)
session.encrypt(nextChunk.sliceArray(0 until read+1), usePadding)
} else {
null
}
@ -601,7 +652,7 @@ class AIRAService : Service() {
}
private fun startListening() {
server = try {
val server = try {
ServerSocketChannel.open().apply {
configureBlocking(false)
socket().bind(InetSocketAddress(Constants.port))
@ -632,7 +683,7 @@ class AIRAService : Service() {
receiveFileTransfers[sessionId]?.let { filesReceiver ->
val file = filesReceiver.files[filesReceiver.index]
if (file.outputStream == null) {
val outputStream = FileUtils.openFileForDownload(this, file.fileName)
val outputStream = FileUtils.openFileForDownload(this, file.fileName).outputStream
if (outputStream == null) {
cancelFileTransfer(sessionId)
} else {
@ -746,7 +797,7 @@ class AIRAService : Service() {
.setContentIntent(
PendingIntent.getActivity(this, 0, Intent(this, ChatActivity::class.java).apply {
putExtra("sessionId", sessionId)
}, 0)
}, FLAG_PENDING_INTENT)
)
.setDefaults(Notification.DEFAULT_ALL)
.apply {
@ -758,10 +809,8 @@ class AIRAService : Service() {
}
}
Protocol.ASK_PROFILE_INFO -> {
identityName?.let { name ->
session.encryptAndSend(Protocol.name(name), usePadding)
}
AIRADatabase.getIdentityAvatar(Constants.getDatabaseFolder(this))?.let { avatar ->
session.encryptAndSend(Protocol.name(identityName), usePadding)
AIRADatabase.getIdentityAvatar(databaseFolder)?.let { avatar ->
session.encryptAndSend(Protocol.avatar(avatar), usePadding)
}
}
@ -810,20 +859,21 @@ class AIRAService : Service() {
null
}
}?.let { handledMsg ->
val timestamp = TimeUtils.getTimestamp()
var seen = false
uiCallbacks?.let { uiCallbacks ->
seen = uiCallbacks.onNewMessage(sessionId, handledMsg)
seen = uiCallbacks.onNewMessage(sessionId, timestamp, handledMsg)
}
setSeen(sessionId, seen)
var msgSaved = false
contacts[sessionId]?.let { contact ->
msgSaved = AIRADatabase.storeMsg(contact.uuid, false, handledMsg)
msgSaved = AIRADatabase.storeMsg(contact.uuid, false, timestamp, handledMsg)
}
if (!msgSaved){
savedMsgs[sessionId]?.add(ChatItem(false, handledMsg))
savedMsgs[sessionId]?.add(ChatItem(false, timestamp, handledMsg))
}
if (isAppInBackground) {
sendNotification(sessionId, handledMsg)
sendNotification(sessionId, handledMsg, timestamp)
}
}
}
@ -852,7 +902,12 @@ class AIRAService : Service() {
}
}
}
keys.clear()
}
for (session in sessions.values) {
session.close()
}
server?.close()
}.start()
}
@ -901,21 +956,37 @@ class AIRAService : Service() {
)
}
private fun newActionPendingIntent(intentBuilder: (Intent) -> Unit): PendingIntent {
return PendingIntent.getBroadcast(this, 0,
Intent(this, NotificationBroadcastReceiver::class.java).apply {
intentBuilder(this)
}, FLAG_PENDING_INTENT
)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
notificationManager = NotificationManagerCompat.from(this)
val notificationBuilder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
createNotificationChannels()
Notification.Builder(this, SERVICE_NOTIFICATION_CHANNEL_ID)
} else {
@Suppress("Deprecation")
Notification.Builder(this)
}
val notificationBuilder = NotificationCompat.Builder(this, SERVICE_NOTIFICATION_CHANNEL_ID)
val notification: Notification = notificationBuilder
.setContentTitle(getString(R.string.background_service))
.setSmallIcon(R.drawable.ic_launcher)
.setContentIntent(
PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java), 0)
PendingIntent.getActivity(this, 0,
Intent(this, MainActivity::class.java).apply {
setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
},
FLAG_PENDING_INTENT)
)
.addAction(NotificationCompat.Action(
R.drawable.ic_launcher,
getString(R.string.stop),
newActionPendingIntent {
it.action = NotificationBroadcastReceiver.ACTION_LOGOUT
}
))
.build()
startForeground(1, notification)

View File

@ -4,28 +4,38 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.app.RemoteInput
import sushi.hardcore.aira.MainActivity
class NotificationBroadcastReceiver: BroadcastReceiver() {
companion object {
const val ACTION_LOGOUT = "logout"
const val ACTION_MARK_READ = "mark_read"
const val ACTION_CANCEL_FILE_TRANSFER = "cancel"
const val ACTION_REPLY = "reply"
const val KEY_TEXT_REPLY = "key_text_reply"
}
override fun onReceive(context: Context?, intent: Intent) {
intent.getBundleExtra("bundle")?.let { bundle ->
(bundle.getBinder("binder") as AIRAService.AIRABinder?)?.let { binder ->
val sessionId = bundle.getInt("sessionId")
val airaService = binder.getService()
when (intent.action) {
ACTION_MARK_READ -> airaService.setSeen(sessionId, true)
ACTION_CANCEL_FILE_TRANSFER -> airaService.cancelFileTransfer(sessionId)
ACTION_REPLY -> RemoteInput.getResultsFromIntent(intent)?.getString(KEY_TEXT_REPLY)?.let { reply ->
airaService.sendTo(sessionId, Protocol.newMessage(reply))
airaService.setSeen(sessionId, true)
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == ACTION_LOGOUT) {
context.startActivity(Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
action = ACTION_LOGOUT
})
context.sendBroadcast(Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS))
} else {
intent.getBundleExtra("bundle")?.let { bundle ->
(bundle.getBinder("binder") as AIRAService.AIRABinder?)?.let { binder ->
val sessionId = bundle.getInt("sessionId")
val airaService = binder.getService()
when (intent.action) {
ACTION_MARK_READ -> airaService.setSeen(sessionId, true)
ACTION_CANCEL_FILE_TRANSFER -> airaService.cancelFileTransfer(sessionId)
ACTION_REPLY -> RemoteInput.getResultsFromIntent(intent)?.getString(KEY_TEXT_REPLY)?.let { reply ->
airaService.sendOrAddToPending(sessionId, Protocol.newMessage(reply))
airaService.setSeen(sessionId, true)
}
else -> {}
}
else -> {}
}
}
}

View File

@ -1,6 +1,5 @@
package sushi.hardcore.aira.background_service
import sushi.hardcore.aira.widgets.Avatar
import java.nio.ByteBuffer
class Protocol {
@ -38,15 +37,17 @@ class Protocol {
}
fun newFile(fileName: String, buffer: ByteArray): ByteArray {
return byteArrayOf(FILE)+ByteBuffer.allocate(2).putShort(fileName.length.toShort()).array()+fileName.toByteArray()+buffer
val fileNameBytes = fileName.toByteArray()
return byteArrayOf(FILE)+ByteBuffer.allocate(2).putShort(fileNameBytes.size.toShort()).array()+fileNameBytes+buffer
}
fun askLargeFiles(files: List<SendFile>): ByteArray {
var buff = byteArrayOf(ASK_LARGE_FILES)
for (file in files) {
val fileName = file.fileName.toByteArray()
buff += ByteBuffer.allocate(8).putLong(file.fileSize).array()
buff += ByteBuffer.allocate(2).putShort(file.fileName.length.toShort()).array()
buff += file.fileName.toByteArray()
buff += ByteBuffer.allocate(2).putShort(fileName.size.toShort()).array()
buff += fileName
}
return buff
}

View File

@ -38,8 +38,8 @@ class Session(private val socket: SocketChannel, val outgoing: Boolean): Selecta
private const val handshakeBufferLen = (2*(RANDOM_LEN+PUBLIC_KEY_LEN))+SIGNATURE_LEN+AES_TAG_LEN
private const val CIPHER_TYPE = "AES/GCM/NoPadding"
private const val MESSAGE_LEN_LEN = 4
private const val PADDED_MAX_SIZE = 32768000
private const val MAX_RECV_SIZE = MESSAGE_LEN_LEN + PADDED_MAX_SIZE + AES_TAG_LEN
private const val PADDED_MAX_SIZE = 16384000
private const val MAX_RECV_SIZE = PADDED_MAX_SIZE + AES_TAG_LEN
}
private val prng = SecureRandom()
@ -188,7 +188,7 @@ class Session(private val socket: SocketChannel, val outgoing: Boolean): Selecta
fun encrypt(plainText: ByteArray, usePadding: Boolean): ByteArray {
val padded = pad(plainText, usePadding)
val rawMsgLen = ByteBuffer.allocate(MESSAGE_LEN_LEN).putInt(padded.size).array()
val rawMsgLen = ByteBuffer.allocate(MESSAGE_LEN_LEN).putInt(padded.size+AES_TAG_LEN).array()
val nonce = ivToNonce(applicationKeys.localIv, localCounter)
localCounter++
localCipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(applicationKeys.localKey, "AES"), GCMParameterSpec(AES_TAG_LEN*8, nonce))
@ -219,7 +219,7 @@ class Session(private val socket: SocketChannel, val outgoing: Boolean): Selecta
val rawMessageLen = ByteBuffer.allocate(MESSAGE_LEN_LEN)
if (readAll(rawMessageLen)) {
rawMessageLen.position(0)
val messageLen = rawMessageLen.int + AES_TAG_LEN
val messageLen = rawMessageLen.int
if (messageLen in 1..MAX_RECV_SIZE) {
val cipherText = ByteBuffer.allocate(messageLen)
if (readAll(cipherText)) {

View File

@ -0,0 +1,36 @@
package sushi.hardcore.aira.background_service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.preference.PreferenceManager
import sushi.hardcore.aira.AIRADatabase
import sushi.hardcore.aira.Constants
class SystemBroadcastReceiver: BroadcastReceiver() {
init {
AIRADatabase.init()
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
if (PreferenceManager.getDefaultSharedPreferences(context).getBoolean("startAtBoot", true) && !AIRAService.isServiceRunning) {
val databaseFolder = Constants.getDatabaseFolder(context)
val isProtected = AIRADatabase.isIdentityProtected(databaseFolder)
val name = AIRADatabase.getIdentityName(databaseFolder)
if (name != null && !isProtected) {
if (AIRADatabase.loadIdentity(databaseFolder, null)) {
AIRADatabase.clearCache()
val serviceIntent = Intent(context, AIRAService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(serviceIntent)
} else {
context.startService(serviceIntent)
}
}
}
}
}
}
}

View File

@ -1,26 +1,17 @@
package sushi.hardcore.aira.utils
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.graphics.drawable.toBitmap
import com.bumptech.glide.Glide
import com.bumptech.glide.RequestBuilder
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import sushi.hardcore.aira.Constants
import sushi.hardcore.aira.R
import java.io.ByteArrayOutputStream
class AvatarPicker(
private val activity: AppCompatActivity,
private val onAvatarPicked: (AvatarPicker, RequestBuilder<Drawable>) -> Unit,
private val onAvatarPicked: (ByteArray) -> Unit,
) {
private lateinit var picker: ActivityResultLauncher<String>
private lateinit var avatar: RequestBuilder<Drawable>
fun register() {
picker = activity.registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri != null) {
@ -30,26 +21,13 @@ class AvatarPicker(
if (image.size > Constants.MAX_AVATAR_SIZE) {
Toast.makeText(activity, R.string.avatar_too_large, Toast.LENGTH_SHORT).show()
} else {
avatar = Glide.with(activity).load(image).centerCrop()
onAvatarPicked(this, avatar)
onAvatarPicked(image)
}
}
}
}
}
fun setOnAvatarCompressed(onCompressed: (ByteArray) -> Unit) {
avatar.into(object: CustomTarget<Drawable>() {
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
val avatar = ByteArrayOutputStream()
if (resource.toBitmap().compress(Bitmap.CompressFormat.PNG, 100, avatar)) {
onCompressed(avatar.toByteArray())
}
}
override fun onLoadCleared(placeholder: Drawable?) {}
})
}
fun launch() {
picker.launch("image/*")
}

View File

@ -1,15 +1,14 @@
package sushi.hardcore.aira.utils
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Environment
import android.provider.MediaStore
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import android.widget.Toast
import sushi.hardcore.aira.background_service.ReceiveFile
import sushi.hardcore.aira.background_service.SendFile
import java.io.File
import java.io.FileNotFoundException
@ -17,8 +16,8 @@ import java.io.OutputStream
import java.text.DecimalFormat
import java.text.SimpleDateFormat
import java.util.*
import kotlin.math.pow
import kotlin.math.log10
import kotlin.math.pow
object FileUtils {
private val units = arrayOf("B", "kB", "MB", "GB", "TB")
@ -31,8 +30,16 @@ object FileUtils {
) + " " + units[digitGroups]
}
fun openFileFromUri(context: Context, uri: Uri): SendFile? {
class SendFileResult(val file: SendFile? = null, val errorHandled: Boolean = false)
fun openFileFromUri(context: Context, uri: Uri): SendFileResult {
var sendFile: SendFile? = null
try {
context.grantUriPermission(context.packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
} catch (e: SecurityException) {
Toast.makeText(context, e.localizedMessage, Toast.LENGTH_LONG).show()
return SendFileResult(sendFile, true)
}
val cursor = context.contentResolver.query(uri, null, null, null, null)
if (cursor != null) {
if (cursor.moveToFirst()) {
@ -48,10 +55,12 @@ object FileUtils {
}
cursor.close()
}
return sendFile
return SendFileResult(sendFile)
}
fun openFileForDownload(context: Context, fileName: String): OutputStream? {
class DownloadFile(val fileName: String, val outputStream: OutputStream?)
fun openFileForDownload(context: Context, fileName: String): DownloadFile {
val fileExtension = fileName.substringAfterLast(".")
val dateExtension = SimpleDateFormat("yyyyMMddHHmmss", Locale.getDefault()).format(Date())
val datedFilename = if (fileName.contains(".")) {
@ -60,7 +69,7 @@ object FileUtils {
} else {
fileName + "_" + dateExtension
}
return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
val outputStream = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
context.contentResolver.insert(
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
ContentValues().apply {
@ -75,5 +84,6 @@ object FileUtils {
@Suppress("Deprecation")
File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), File(datedFilename).name).outputStream()
}
return DownloadFile(datedFilename, outputStream)
}
}

View File

@ -25,4 +25,12 @@ object StringUtils {
fun sanitizeName(name: String): String {
return name.replace('\n', ' ')
}
fun toTwoDigits(number: Int): String {
return if (number < 10) {
"0$number"
} else {
number.toString()
}
}
}

View File

@ -0,0 +1,12 @@
package sushi.hardcore.aira.utils
import sushi.hardcore.aira.ChatItem
object TimeUtils {
fun getTimestamp(): Long {
return System.currentTimeMillis()/1000
}
fun isInTheSameDay(first: ChatItem, second: ChatItem): Boolean {
return first.year == second.year && first.dayOfYear == second.dayOfYear
}
}

View File

@ -1,2 +1 @@
Cargo.lock
target

898
app/src/main/native/Cargo.lock generated Normal file
View File

@ -0,0 +1,898 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "aead"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8aeaececfd937b9762778f2aca063ad24fdc827c48c07aeae36fb20c03afa11a"
dependencies = [
"generic-array",
]
[[package]]
name = "aes"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99446914425f48a667458b33c7fb920e24cf9e7c149a072a9fc420731b353835"
dependencies = [
"cfg-if",
"cipher 0.3.0",
"cpufeatures 0.1.0",
"opaque-debug",
]
[[package]]
name = "aes-gcm"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "614d3cc56d7840c3dba6ea1c1754afd3d0aa244c4902776b8d109a7be764f31f"
dependencies = [
"aead",
"aes",
"cipher 0.3.0",
"ctr",
"ghash",
"subtle",
]
[[package]]
name = "aes-gcm-siv"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e28b4604398f52cd06867fe0531b62b35f0edddb561e806945cbab9af7fe6a8"
dependencies = [
"aead",
"aes",
"cipher 0.3.0",
"ctr",
"polyval",
"subtle",
"zeroize",
]
[[package]]
name = "ahash"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43bb833f0bf979d8475d38fbf09ed3b8a55e1885fe93ad3f93239fc6a4f17b98"
dependencies = [
"getrandom 0.2.2",
"once_cell",
"version_check",
]
[[package]]
name = "aira"
version = "0.1.0"
dependencies = [
"aes-gcm",
"aes-gcm-siv",
"android_log",
"ed25519-dalek",
"hex",
"hkdf",
"hmac",
"jni",
"lazy_static",
"log 0.4.14",
"rand 0.7.3",
"rand 0.8.3",
"rusqlite",
"scrypt",
"sha2 0.10.2",
"strum_macros",
"uuid",
"zeroize",
]
[[package]]
name = "android_liblog-sys"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aaf82c031178ca72b38595a54d16df8a257df9deea7d97a8992870e5c6a738e7"
dependencies = [
"libc",
]
[[package]]
name = "android_log"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc00e0d3a060cce3fa338f9644ce9a93901c79f5405330891aeca69c9957009a"
dependencies = [
"android_liblog-sys",
"log 0.3.9",
]
[[package]]
name = "base64ct"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0d27fb6b6f1e43147af148af49d49329413ba781aa0d5e10979831c210173b5"
[[package]]
name = "bitflags"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693"
[[package]]
name = "block-buffer"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4"
dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf7fe51849ea569fd452f37822f606a5cabb684dc918707a0193fd4664ff324"
dependencies = [
"generic-array",
]
[[package]]
name = "byteorder"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae44d1a3d5a19df61dd0c8beb138458ac2a53a7ac09eba97d55592540004306b"
[[package]]
name = "bytes"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b700ce4376041dcd0a327fd0097c41095743c4c8af8887265942faf1100bd040"
[[package]]
name = "cc"
version = "1.0.66"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c0496836a84f8d0495758516b8621a622beb77c0fed418570e50764093ced48"
[[package]]
name = "cesu8"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "cipher"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ee52072ec15386f770805afd189a01c8841be8696bed250fa2f13c4c0d6dfb7"
dependencies = [
"generic-array",
]
[[package]]
name = "cipher"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1873270f8f7942c191139cb8a40fd228da6c3fd2fc376d7e92d47aa14aeb59e"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "combine"
version = "4.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4369b5e4c0cddf64ad8981c0111e7df4f7078f4d6ba98fb31f2e17c4c57b7e"
dependencies = [
"bytes",
"memchr",
]
[[package]]
name = "cpufeatures"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cd5a7748210e7ec1a9696610b1015e6e31fbf58f77a160801f124bd1c36592a"
[[package]]
name = "cpufeatures"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59a6001667ab124aebae2a495118e11d30984c3a653e99d86d58971708cf5e4b"
dependencies = [
"libc",
]
[[package]]
name = "cpuid-bool"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8aebca1129a03dc6dc2b127edd729435bbc4a37e1d5f4d7513165089ceb02634"
[[package]]
name = "crypto-common"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57952ca27b5e3606ff4dd79b0020231aaf9d6aa76dc05fd30137538c50bd3ce8"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "ctr"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a232f92a03f37dd7d7dd2adc67166c77e9cd88de5b019b9a9eecfaeaf7bfd481"
dependencies = [
"cipher 0.3.0",
]
[[package]]
name = "curve25519-dalek"
version = "3.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f627126b946c25a4638eec0ea634fc52506dea98db118aae985118ce7c3d723f"
dependencies = [
"byteorder",
"digest 0.9.0",
"rand_core 0.5.1",
"subtle",
"zeroize",
]
[[package]]
name = "digest"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066"
dependencies = [
"generic-array",
]
[[package]]
name = "digest"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2fb860ca6fafa5552fb6d0e816a69c8e49f0908bf524e30a90d97c85892d506"
dependencies = [
"block-buffer 0.10.2",
"crypto-common",
"subtle",
]
[[package]]
name = "ed25519"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37c66a534cbb46ab4ea03477eae19d5c22c01da8258030280b7bd9d8433fb6ef"
dependencies = [
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c762bae6dcaf24c4c84667b8579785430908723d5c889f469d76a41d59cc7a9d"
dependencies = [
"curve25519-dalek",
"ed25519",
"rand 0.7.3",
"serde",
"sha2 0.9.3",
"zeroize",
]
[[package]]
name = "fallible-iterator"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "generic-array"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "501466ecc8a30d1d3b7fc9229b122b2ce8ed6e9d9223f1138d4babb253e51817"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce"
dependencies = [
"cfg-if",
"libc",
"wasi 0.9.0+wasi-snapshot-preview1",
]
[[package]]
name = "getrandom"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9495705279e7140bf035dde1f6e750c162df8b625267cd52cc44e0b156732c8"
dependencies = [
"cfg-if",
"libc",
"wasi 0.10.1+wasi-snapshot-preview1",
]
[[package]]
name = "ghash"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90df9c2964d71ba6591379cc3bff5127cc19dcbe6a62088a1b3718d6cbfe08cf"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]]
name = "hashbrown"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e"
dependencies = [
"ahash",
]
[[package]]
name = "hashlink"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7249a3129cbc1ffccd74857f81464a323a152173cdb134e0fd81bc803b29facf"
dependencies = [
"hashbrown",
]
[[package]]
name = "heck"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hkdf"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "791a029f6b9fc27657f6f188ec6e5e43f6911f6f878e0dc5501396e09809d437"
dependencies = [
"hmac",
]
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest 0.10.3",
]
[[package]]
name = "inout"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5"
dependencies = [
"generic-array",
]
[[package]]
name = "jni"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6df18c2e3db7e453d3c6ac5b3e9d5182664d28788126d39b91f2d1e22b017ec"
dependencies = [
"cesu8",
"combine",
"jni-sys",
"log 0.4.14",
"thiserror",
"walkdir",
]
[[package]]
name = "jni-sys"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
[[package]]
name = "lazy_static"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
[[package]]
name = "libc"
version = "0.2.82"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89203f3fba0a3795506acaad8ebce3c80c0af93f994d5a1d7a0b1eeb23271929"
[[package]]
name = "libsqlite3-sys"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "898745e570c7d0453cc1fbc4a701eb6c662ed54e8fec8b7d14be137ebeeb9d14"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "log"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e19e8d5c34a3e0e2223db8e060f9e8264aeeb5c5fc64a4ee9965c062211c024b"
dependencies = [
"log 0.4.14",
]
[[package]]
name = "log"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710"
dependencies = [
"cfg-if",
]
[[package]]
name = "memchr"
version = "2.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ee1c47aaa256ecabcaea351eae4a9b01ef39ed810004e298d2511ed284b1525"
[[package]]
name = "once_cell"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87f3e037eac156d1775da914196f0f37741a274155e34a0b7e427c35d2a2ecb9"
[[package]]
name = "opaque-debug"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
[[package]]
name = "password-hash"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e029e94abc8fb0065241c308f1ac6bc8d20f450e8f7c5f0b25cd9b8d526ba294"
dependencies = [
"base64ct",
"rand_core 0.6.2",
"subtle",
]
[[package]]
name = "pbkdf2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917"
dependencies = [
"digest 0.10.3",
]
[[package]]
name = "pkg-config"
version = "0.3.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3831453b3449ceb48b6d9c7ad7c96d5ea673e9b470a1dc578c2ce6521230884c"
[[package]]
name = "polyval"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "864231b0b86ce05168a8e6da0fea2e67275dacf25f75b00a62cfd341aab904a9"
dependencies = [
"cpufeatures 0.1.0",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "ppv-lite86"
version = "0.2.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac74c624d6b2d21f425f752262f42188365d7b8ff1aff74c82e45136510a4857"
[[package]]
name = "proc-macro2"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0704ee1a7e00d7bb417d0770ea303c1bccbabf0ef1667dae92b5967f5f8a71"
dependencies = [
"unicode-xid",
]
[[package]]
name = "quote"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "991431c3519a3f36861882da93630ce66b52918dcf1b8e2fd66b397fc96f28df"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03"
dependencies = [
"getrandom 0.1.16",
"libc",
"rand_chacha 0.2.2",
"rand_core 0.5.1",
"rand_hc 0.2.0",
]
[[package]]
name = "rand"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ef9e7e66b4468674bfcb0c81af8b7fa0bb154fa9f28eb840da5c447baeb8d7e"
dependencies = [
"libc",
"rand_chacha 0.3.0",
"rand_core 0.6.2",
"rand_hc 0.3.0",
]
[[package]]
name = "rand_chacha"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402"
dependencies = [
"ppv-lite86",
"rand_core 0.5.1",
]
[[package]]
name = "rand_chacha"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e12735cf05c9e10bf21534da50a147b924d555dc7a547c42e6bb2d5b6017ae0d"
dependencies = [
"ppv-lite86",
"rand_core 0.6.2",
]
[[package]]
name = "rand_core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19"
dependencies = [
"getrandom 0.1.16",
]
[[package]]
name = "rand_core"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34cf66eb183df1c5876e2dcf6b13d57340741e8dc255b48e40a26de954d06ae7"
dependencies = [
"getrandom 0.2.2",
]
[[package]]
name = "rand_hc"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c"
dependencies = [
"rand_core 0.5.1",
]
[[package]]
name = "rand_hc"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3190ef7066a446f2e7f42e239d161e905420ccab01eb967c9eb27d21b2322a73"
dependencies = [
"rand_core 0.6.2",
]
[[package]]
name = "rusqlite"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85127183a999f7db96d1a976a309eebbfb6ea3b0b400ddd8340190129de6eb7a"
dependencies = [
"bitflags",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"memchr",
"smallvec",
]
[[package]]
name = "rustversion"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2cc38e8fa666e2de3c4aba7edeb5ffc5246c1c2ed0e3d17e560aeeba736b23f"
[[package]]
name = "salsa20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
dependencies = [
"cipher 0.4.3",
]
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "scrypt"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f9e24d2b632954ded8ab2ef9fea0a0c769ea56ea98bddbafbad22caeeadf45d"
dependencies = [
"hmac",
"password-hash",
"pbkdf2",
"salsa20",
"sha2 0.10.2",
]
[[package]]
name = "serde"
version = "1.0.120"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "166b2349061381baf54a58e4b13c89369feb0ef2eaa57198899e2312aac30aab"
[[package]]
name = "sha2"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa827a14b29ab7f44778d14a88d3cb76e949c45083f7dbfa507d0cb699dc12de"
dependencies = [
"block-buffer 0.9.0",
"cfg-if",
"cpuid-bool",
"digest 0.9.0",
"opaque-debug",
]
[[package]]
name = "sha2"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55deaec60f81eefe3cce0dc50bda92d6d8e88f2a27df7c5033b42afeb1ed2676"
dependencies = [
"cfg-if",
"cpufeatures 0.2.2",
"digest 0.10.3",
]
[[package]]
name = "signature"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f0242b8e50dd9accdd56170e94ca1ebd223b098eb9c83539a6e367d0f36ae68"
[[package]]
name = "smallvec"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe0f37c9e8f3c5a4a66ad655a93c74daac4ad00c441533bf5c6e7990bb42604e"
[[package]]
name = "strum_macros"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6878079b17446e4d3eba6192bb0a2950d5b14f0ed8424b852310e5a94345d0ef"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn",
]
[[package]]
name = "subtle"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e81da0851ada1f3e9d4312c704aa4f8806f0f9d69faaf8df2f3464b4a9437c2"
[[package]]
name = "syn"
version = "1.0.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc60a3d73ea6594cd712d830cc1f0390fd71542d8c8cd24e70cc54cdfd5e05d5"
dependencies = [
"proc-macro2",
"quote",
"unicode-xid",
]
[[package]]
name = "synstructure"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b834f2d66f734cb897113e34aaff2f1ab4719ca946f9a7358dba8f8064148701"
dependencies = [
"proc-macro2",
"quote",
"syn",
"unicode-xid",
]
[[package]]
name = "thiserror"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76cc616c6abf8c8928e2fdcc0dbfab37175edd8fb49a4641066ad1364fdab146"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9be73a2caec27583d0046ef3796c3794f868a5bc813db689eed00c7631275cd1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "typenum"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987"
[[package]]
name = "unicode-xid"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564"
[[package]]
name = "universal-hash"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8326b2c654932e3e4f9196e69d08fdf7cfd718e1dc6f66b347e6024a0c961402"
dependencies = [
"generic-array",
"subtle",
]
[[package]]
name = "uuid"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cfcd319456c4d6ea10087ed423473267e1a071f3bc0aa89f80d60997843c6f0"
dependencies = [
"getrandom 0.2.2",
]
[[package]]
name = "vcpkg"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b00bca6106a5e23f3eee943593759b7fcddb00554332e856d990c893966879fb"
[[package]]
name = "version_check"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5a972e5669d67ba988ce3dc826706fb0a8b01471c088cb0b6110b805cc36aed"
[[package]]
name = "walkdir"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "777182bc735b6424e1a57516d35ed72cb8019d85c8c9bf536dccb3445c1a2f7d"
dependencies = [
"same-file",
"winapi",
"winapi-util",
]
[[package]]
name = "wasi"
version = "0.9.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519"
[[package]]
name = "wasi"
version = "0.10.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93c6c3420963c5c64bca373b25e77acb562081b9bb4dd5bb864187742186cea9"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178"
dependencies = [
"winapi",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "zeroize"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4756f7db3f7b5574938c3eb1c117038b8e07f95ee6718c0efad4ac21508f1efd"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3f369ddb18862aba61aa49bf31e74d29f0f162dec753063200e1dc084345d16"
dependencies = [
"proc-macro2",
"quote",
"syn",
"synstructure",
]

View File

@ -1,6 +1,6 @@
[package]
name = "aira"
version = "0.0.1"
version = "0.1.0"
authors = ["Hardcore Sushi <hardcore.sushi@disroot.org>"]
edition = "2018"
@ -11,21 +11,20 @@ jni = { version = "0.19", default-features = false }
crate-type = ["dylib"]
[dependencies]
rand = "0.8.3"
rand-7 = {package = "rand", version = "0.7.3"}
lazy_static = "1.4.0"
rusqlite = { version = "0.25.1", features = ["bundled"] }
rand = "0.8"
rand-7 = {package = "rand", version = "0.7"}
lazy_static = "1.4"
rusqlite = { version = "0.27", features = ["bundled"] }
ed25519-dalek = "1" #for singing
x25519-dalek = "1.1" #for shared secret
sha2 = "0.9.3"
hkdf = "0.11.0"
aes-gcm = "0.9.0" #PSEC
aes-gcm-siv = "0.10.0" #Database
hmac = "0.11.0"
hex = "0.4.3"
strum_macros = "0.20.1" #display enums
uuid = { version = "0.8", features = ["v4"] }
scrypt = "0.7.0"
zeroize = "1.2.0"
log = "0.4.14"
android_log = "0.1.3"
sha2 = "0.10"
hkdf = "0.12"
aes-gcm = "0.9" #PSEC
aes-gcm-siv = "0.10" #Database
hmac = "0.12"
hex = "0.4"
strum_macros = "0.24" #display enums
uuid = { version = "1.0", features = ["v4"] }
scrypt = "0.10"
zeroize = "1.3"
log = "0.4"
android_log = "0.1"

View File

@ -4,10 +4,15 @@ if [ -z ${ANDROID_NDK_HOME+x} ]; then
echo "Error: \$ANDROID_NDK_HOME is not defined."
else
export PATH=$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/:$PATH
declare -a androidABIs=("arm64-v8a" "armeabi-v7a" "x86")
declare -a targets=("aarch64-linux-android" "armv7-linux-androideabi" "i686-linux-android")
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=aarch64-linux-android21-clang
export CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_LINKER=armv7a-linux-androideabi21-clang
export CARGO_TARGET_X86_64_LINUX_ANDROID_LINKER=x86_64-linux-android21-clang
export CARGO_TARGET_I686_LINUX_ANDROID_LINKER=i686-linux-android21-clang
declare -a androidABIs=("arm64-v8a" "armeabi-v7a" "x86_64" "x86")
declare -a targets=("aarch64-linux-android" "armv7-linux-androideabi" "x86_64-linux-android" "i686-linux-android")
for (( i=0; i < ${#targets[@]}; i++ )) do
cargo build --target ${targets[i]} --release
cp target/${targets[i]}/release/libaira.so ../jniLibs/${androidABIs[i]}/
cargo build --target ${targets[i]} --release || exit 1
TARGET_DIR=../jniLibs/${androidABIs[i]}
mkdir -p $TARGET_DIR && cp target/${targets[i]}/release/libaira.so $TARGET_DIR
done
fi

View File

@ -1,7 +1,7 @@
use std::{convert::TryInto, fmt::Display};
use hkdf::Hkdf;
use sha2::Sha384;
use hmac::{Hmac, NewMac, Mac};
use hmac::{Hmac, Mac};
use scrypt::{scrypt, Params};
use rand::{RngCore, rngs::OsRng};
use aes_gcm::{aead::Aead, NewAead, Nonce};
@ -86,10 +86,10 @@ impl HandshakeKeys {
HandshakeKeys {
local_key: local_handshake_key,
local_iv: local_handshake_iv,
local_handshake_traffic_secret: local_handshake_traffic_secret,
local_handshake_traffic_secret,
peer_key: peer_handshake_key,
peer_iv: peer_handshake_iv,
peer_handshake_traffic_secret: peer_handshake_traffic_secret,
peer_handshake_traffic_secret,
handshake_secret: handshake_secret.as_slice().try_into().unwrap(),
}
}
@ -148,7 +148,7 @@ pub fn verify_handshake_finished(peer_handshake_finished: [u8; HASH_OUTPUT_LEN],
hkdf_expand_label(&peer_handshake_traffic_secret, "finished", None, &mut peer_finished_key);
let mut hmac = Hmac::<Sha384>::new_from_slice(&peer_finished_key).unwrap();
hmac.update(&handshake_hash);
hmac.verify(&peer_handshake_finished).is_ok()
hmac.verify_slice(&peer_handshake_finished).is_ok()
}

View File

@ -43,12 +43,11 @@ fn get_database_path(database_folder: &str) -> String {
Path::new(database_folder).join(DB_NAME).to_str().unwrap().to_owned()
}
struct EncryptedIdentity {
name: String,
encrypted_keypair: Vec<u8>,
salt: Vec<u8>,
encrypted_master_key: Vec<u8>,
encrypted_use_padding: Vec<u8>,
#[derive(Debug, Clone)]
pub struct Message {
pub outgoing: bool,
pub timestamp: u64,
pub data: Vec<u8>,
}
pub struct Contact {
@ -60,6 +59,14 @@ pub struct Contact {
pub seen: bool,
}
struct EncryptedIdentity {
name: String,
encrypted_keypair: Vec<u8>,
salt: Vec<u8>,
encrypted_master_key: Vec<u8>,
encrypted_use_padding: Vec<u8>,
}
pub struct Identity {
pub name: String,
keypair: Keypair,
@ -96,8 +103,8 @@ impl Identity {
};
Ok(Contact {
uuid: contact_uuid,
public_key: public_key,
name: name,
public_key,
name,
avatar: avatar_uuid,
verified: false,
seen: true,
@ -107,7 +114,7 @@ impl Identity {
pub fn remove_contact(&self, uuid: &Uuid) -> Result<usize, rusqlite::Error> {
let db = Connection::open(self.get_database_path())?;
self.delete_conversation(uuid)?;
db.execute(&("DELETE FROM ".to_owned()+CONTACTS_TABLE+" WHERE uuid=?"), [&uuid.as_bytes()[..]])
db.execute(&format!("DELETE FROM {} WHERE uuid=?", CONTACTS_TABLE), [&uuid.as_bytes()[..]])
}
pub fn set_verified(&self, uuid: &Uuid) -> Result<usize, rusqlite::Error> {
@ -251,16 +258,17 @@ impl Identity {
Ok(file_uuid)
}
pub fn store_msg(&self, contact_uuid: &Uuid, outgoing: bool, data: &[u8]) -> Result<usize, rusqlite::Error> {
pub fn store_msg(&self, contact_uuid: &Uuid, message: Message) -> Result<usize, rusqlite::Error> {
let db = Connection::open(self.get_database_path())?;
db.execute(&format!("CREATE TABLE IF NOT EXISTS \"{}\" (outgoing BLOB, data BLOB)", contact_uuid), [])?;
let outgoing_byte: u8 = bool_to_byte(outgoing);
db.execute(&format!("CREATE TABLE IF NOT EXISTS \"{}\" (outgoing BLOB, timestamp BLOB, data BLOB)", contact_uuid), [])?;
let outgoing_byte: u8 = bool_to_byte(message.outgoing);
let encrypted_outgoing = crypto::encrypt_data(&[outgoing_byte], &self.master_key).unwrap();
let encrypted_data = crypto::encrypt_data(data, &self.master_key).unwrap();
db.execute(&format!("INSERT INTO \"{}\" (outgoing, data) VALUES (?1, ?2)", contact_uuid), params![encrypted_outgoing, encrypted_data])
let encrypted_timestamp = crypto::encrypt_data(&message.timestamp.to_be_bytes(), &self.master_key).unwrap();
let encrypted_data = crypto::encrypt_data(&message.data, &self.master_key).unwrap();
db.execute(&format!("INSERT INTO \"{}\" (outgoing, timestamp, data) VALUES (?1, ?2, ?3)", contact_uuid), params![encrypted_outgoing, encrypted_timestamp, encrypted_data])
}
pub fn load_msgs(&self, contact_uuid: &Uuid, offset: usize, mut count: usize) -> Option<Vec<(bool, Vec<u8>)>> {
pub fn load_msgs(&self, contact_uuid: &Uuid, offset: usize, mut count: usize) -> Option<Vec<Message>> {
match Connection::open(self.get_database_path()) {
Ok(db) => {
if let Ok(mut stmt) = db.prepare(&format!("SELECT count(*) FROM \"{}\"", contact_uuid)) {
@ -271,24 +279,30 @@ impl Identity {
if offset+count >= total {
count = total-offset;
}
let mut stmt = db.prepare(&format!("SELECT outgoing, data FROM \"{}\" LIMIT {} OFFSET {}", contact_uuid, count, total-offset-count)).unwrap();
let mut stmt = db.prepare(&format!("SELECT outgoing, timestamp, data FROM \"{}\" LIMIT {} OFFSET {}", contact_uuid, count, total-offset-count)).unwrap();
let mut rows = stmt.query([]).unwrap();
let mut msgs = Vec::new();
while let Ok(Some(row)) = rows.next() {
let encrypted_outgoing: Vec<u8> = row.get(0).unwrap();
match crypto::decrypt_data(encrypted_outgoing.as_slice(), &self.master_key){
Ok(outgoing) => {
match byte_to_bool(outgoing[0]) {
Ok(outgoing) => {
let encrypted_data: Vec<u8> = row.get(1).unwrap();
match crypto::decrypt_data(encrypted_data.as_slice(), &self.master_key) {
Ok(data) => msgs.push((outgoing, data)),
Err(e) => print_error!(e)
if let Ok(outgoing) = byte_to_bool(outgoing[0]) {
let encrypted_timestamp: Vec<u8> = row.get(1).unwrap();
match crypto::decrypt_data(&encrypted_timestamp, &self.master_key) {
Ok(timestamp) => {
let encrypted_data: Vec<u8> = row.get(2).unwrap();
match crypto::decrypt_data(encrypted_data.as_slice(), &self.master_key) {
Ok(data) => msgs.push(Message {
outgoing,
timestamp: u64::from_be_bytes(timestamp.try_into().unwrap()),
data,
}),
Err(e) => print_error!(e)
}
}
Err(e) => print_error!(e)
}
Err(_) => {}
}
}
Err(e) => print_error!(e)
}
@ -302,7 +316,7 @@ impl Identity {
}
None
}
#[allow(unused_must_use)]
pub fn delete_conversation(&self, contact_uuid: &Uuid) -> Result<usize, rusqlite::Error> {
let db = Connection::open(self.get_database_path())?;
@ -373,14 +387,8 @@ impl Identity {
pub fn load_identity(database_folder: String, password: Option<&[u8]>) -> Result<Identity, String> {
match Identity::load_encrypted_identity(&database_folder) {
Ok(encrypted_identity) => {
let master_key: [u8; crypto::MASTER_KEY_LEN] = if password.is_none() {
if encrypted_identity.encrypted_master_key.len() == crypto::MASTER_KEY_LEN {
encrypted_identity.encrypted_master_key.try_into().unwrap()
} else {
return Err(String::from(DATABASE_CORRUPED_ERROR))
}
} else {
match crypto::decrypt_master_key(&encrypted_identity.encrypted_master_key, password.unwrap(), &encrypted_identity.salt) {
let master_key: [u8; crypto::MASTER_KEY_LEN] = match password {
Some(password) => match crypto::decrypt_master_key(&encrypted_identity.encrypted_master_key, password, &encrypted_identity.salt) {
Ok(master_key) => master_key,
Err(e) => return Err(
match e {
@ -389,6 +397,11 @@ impl Identity {
}
)
}
None => if encrypted_identity.encrypted_master_key.len() == crypto::MASTER_KEY_LEN {
encrypted_identity.encrypted_master_key.try_into().unwrap()
} else {
return Err(String::from(DATABASE_CORRUPED_ERROR))
}
};
match crypto::decrypt_data(&encrypted_identity.encrypted_keypair, &master_key) {
Ok(keypair) => {
@ -399,7 +412,7 @@ impl Identity {
keypair: Keypair::from_bytes(&keypair[..]).unwrap(),
master_key,
use_padding: byte_to_bool(use_padding[0]).unwrap(),
database_folder: database_folder,
database_folder,
})
}
Err(e) => {
@ -435,13 +448,16 @@ impl Identity {
let db = KeyValueTable::new(&get_database_path(&database_folder), MAIN_TABLE)?;
db.set(DBKeys::NAME, name.as_bytes())?;
db.set(DBKeys::KEYPAIR, &encrypted_keypair)?;
let salt = if password.is_none() { //no password
db.set(DBKeys::MASTER_KEY, &master_key)?; //storing master_key in plaintext
[0; crypto::SALT_LEN]
} else {
let (salt, encrypted_master_key) = crypto::encrypt_master_key(master_key, password.unwrap());
db.set(DBKeys::MASTER_KEY, &encrypted_master_key)?;
salt
let salt = match password {
Some(password) => {
let (salt, encrypted_master_key) = crypto::encrypt_master_key(master_key, password);
db.set(DBKeys::MASTER_KEY, &encrypted_master_key)?;
salt
}
None => {
db.set(DBKeys::MASTER_KEY, &master_key)?; //storing master_key in plaintext
[0; crypto::SALT_LEN]
}
};
db.set(DBKeys::SALT, &salt)?;
let encrypted_use_padding = crypto::encrypt_data(&[bool_to_byte(true)], &master_key).unwrap();
@ -457,13 +473,16 @@ impl Identity {
fn update_master_key(database_folder: String, master_key: [u8; crypto::MASTER_KEY_LEN], new_password: Option<&[u8]>) -> Result<usize, rusqlite::Error> {
let db = KeyValueTable::new(&get_database_path(&database_folder), MAIN_TABLE)?;
let salt = if new_password.is_none() { //no password
db.update(DBKeys::MASTER_KEY, &master_key)?;
[0; crypto::SALT_LEN]
} else {
let (salt, encrypted_master_key) = crypto::encrypt_master_key(master_key, new_password.unwrap());
db.update(DBKeys::MASTER_KEY, &encrypted_master_key)?;
salt
let salt = match new_password {
Some(new_password) => {
let (salt, encrypted_master_key) = crypto::encrypt_master_key(master_key, new_password);
db.update(DBKeys::MASTER_KEY, &encrypted_master_key)?;
salt
}
None => {
db.update(DBKeys::MASTER_KEY, &master_key)?;
[0; crypto::SALT_LEN]
}
};
db.update(DBKeys::SALT, &salt)
}
@ -471,20 +490,19 @@ impl Identity {
pub fn change_password(database_folder: String, old_password: Option<&[u8]>, new_password: Option<&[u8]>) -> Result<bool, String> {
match Identity::load_encrypted_identity(&database_folder) {
Ok(encrypted_identity) => {
let master_key: [u8; crypto::MASTER_KEY_LEN] = if old_password.is_none() {
if encrypted_identity.encrypted_master_key.len() == crypto::MASTER_KEY_LEN {
encrypted_identity.encrypted_master_key.try_into().unwrap()
} else {
return Err(String::from(DATABASE_CORRUPED_ERROR))
}
} else {
match crypto::decrypt_master_key(&encrypted_identity.encrypted_master_key, old_password.unwrap(), &encrypted_identity.salt) {
let master_key: [u8; crypto::MASTER_KEY_LEN] = match old_password {
Some(old_password) => match crypto::decrypt_master_key(&encrypted_identity.encrypted_master_key, old_password, &encrypted_identity.salt) {
Ok(master_key) => master_key,
Err(e) => return match e {
CryptoError::DecryptionFailed => Ok(false),
CryptoError::InvalidLength => Err(String::from(DATABASE_CORRUPED_ERROR))
}
}
None => if encrypted_identity.encrypted_master_key.len() == crypto::MASTER_KEY_LEN {
encrypted_identity.encrypted_master_key.try_into().unwrap()
} else {
return Err(String::from(DATABASE_CORRUPED_ERROR))
}
};
match Identity::update_master_key(database_folder, master_key, new_password) {
Ok(_) => Ok(true),

View File

@ -12,7 +12,7 @@ impl<'a> KeyValueTable<'a> {
Ok(KeyValueTable {db, table_name})
}
pub fn set(&self, key: &str, value: &[u8]) -> Result<usize, Error> {
Ok(self.db.execute(&format!("INSERT INTO {} (key, value) VALUES (?1, ?2)", self.table_name), params![key, value])?)
self.db.execute(&format!("INSERT INTO {} (key, value) VALUES (?1, ?2)", self.table_name), params![key, value])
}
pub fn get(&self, key: &str) -> Result<Vec<u8>, Error> {
let mut stmt = self.db.prepare(&format!("SELECT value FROM {} WHERE key=\"{}\"", self.table_name, key))?;

View File

@ -6,8 +6,7 @@ mod utils;
use std::{convert::TryInto, fmt::Display, str::FromStr, sync::{Mutex}};
use lazy_static::lazy_static;
use uuid::Uuid;
use android_log;
use identity::{Identity, Contact};
use identity::{Identity, Contact, Message};
use crate::crypto::{HandshakeKeys, ApplicationKeys};
lazy_static! {
@ -17,7 +16,7 @@ lazy_static! {
#[cfg(target_os="android")]
use jni::JNIEnv;
use jni::objects::{JClass, JObject, JString, JList, JValue};
use jni::sys::{jboolean, jint, jbyteArray, jobject};
use jni::sys::{jboolean, jint, jlong, jbyteArray, jobject};
fn jstring_to_string(env: JNIEnv, input: JString) -> String {
String::from(env.get_string(input).unwrap())
@ -53,7 +52,7 @@ fn slice_to_jvalue<'a>(env: JNIEnv, input: &'a [u8]) -> JValue<'a> {
}
#[no_mangle]
pub extern fn Java_sushi_hardcore_aira_LoginActivity_00024Companion_initLogging(_: JNIEnv, _: JClass) -> jboolean {
pub extern fn Java_sushi_hardcore_aira_AIRADatabase_initLogging(_: JNIEnv, _: JClass) -> jboolean {
bool_to_jboolean(android_log::init("AIRA Native").is_ok())
}
@ -76,7 +75,7 @@ pub extern fn Java_sushi_hardcore_aira_CreateIdentityFragment_createNewIdentity(
#[no_mangle]
pub extern fn Java_sushi_hardcore_aira_LoginActivity_getIdentityName(env: JNIEnv, _: JClass, database_folder: JString) -> jobject {
pub extern fn Java_sushi_hardcore_aira_AIRADatabase_getIdentityName(env: JNIEnv, _: JClass, database_folder: JString) -> jobject {
*match Identity::get_identity_name(&jstring_to_string(env, database_folder)) {
Ok(name) => *env.new_string(name).unwrap(),
Err(e) => {
@ -287,8 +286,13 @@ pub fn Java_sushi_hardcore_aira_AIRADatabase_setContactSeen(env: JNIEnv, _: JCla
#[allow(non_snake_case)]
#[no_mangle]
pub fn Java_sushi_hardcore_aira_AIRADatabase_storeMsg(env: JNIEnv, _: JClass, contactUuid: JString, outgoing: jboolean, data: jbyteArray) -> jboolean {
result_to_jboolean(loaded_identity.lock().unwrap().as_ref().unwrap().store_msg(&jstring_to_uuid(env, contactUuid).unwrap(), jboolean_to_bool(outgoing), &env.convert_byte_array(data).unwrap()))
pub fn Java_sushi_hardcore_aira_AIRADatabase_storeMsg(env: JNIEnv, _: JClass, contactUuid: JString, outgoing: jboolean, timestamp: jlong, data: jbyteArray) -> jboolean {
let message = Message {
outgoing: jboolean_to_bool(outgoing),
timestamp: timestamp as u64,
data: env.convert_byte_array(data).unwrap(),
};
result_to_jboolean(loaded_identity.lock().unwrap().as_ref().unwrap().store_msg(&jstring_to_uuid(env, contactUuid).unwrap(), message))
}
#[allow(non_snake_case)]
@ -301,7 +305,11 @@ pub fn Java_sushi_hardcore_aira_AIRADatabase_loadMsgs(env: JNIEnv, _: JClass, uu
let array_list = JList::from_env(&env, array_list).unwrap();
let chat_item_class = env.find_class("sushi/hardcore/aira/ChatItem").unwrap();
for msg in msgs {
let chat_item_object = env.new_object(chat_item_class, "(Z[B)V", &[JValue::Bool(bool_to_jboolean(msg.0)), slice_to_jvalue(env, &msg.1)]).unwrap();
let chat_item_object = env.new_object(chat_item_class, "(ZJ[B)V", &[
JValue::Bool(bool_to_jboolean(msg.outgoing)),
JValue::Long(msg.timestamp as jlong),
slice_to_jvalue(env, &msg.data),
]).unwrap();
array_list.add(chat_item_object).unwrap();
}
*array_list

View File

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/bubbleBackground"/>
<corners android:radius="20dp"/>
</shape>
</item>
</selector>

View File

@ -1,5 +1,5 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M6,13c-0.55,0 -1,0.45 -1,1s0.45,1 1,1 1,-0.45 1,-1 -0.45,-1 -1,-1zM6,17c-0.55,0 -1,0.45 -1,1s0.45,1 1,1 1,-0.45 1,-1 -0.45,-1 -1,-1zM6,9c-0.55,0 -1,0.45 -1,1s0.45,1 1,1 1,-0.45 1,-1 -0.45,-1 -1,-1zM3,9.5c-0.28,0 -0.5,0.22 -0.5,0.5s0.22,0.5 0.5,0.5 0.5,-0.22 0.5,-0.5 -0.22,-0.5 -0.5,-0.5zM6,5c-0.55,0 -1,0.45 -1,1s0.45,1 1,1 1,-0.45 1,-1 -0.45,-1 -1,-1zM21,10.5c0.28,0 0.5,-0.22 0.5,-0.5s-0.22,-0.5 -0.5,-0.5 -0.5,0.22 -0.5,0.5 0.22,0.5 0.5,0.5zM14,7c0.55,0 1,-0.45 1,-1s-0.45,-1 -1,-1 -1,0.45 -1,1 0.45,1 1,1zM14,3.5c0.28,0 0.5,-0.22 0.5,-0.5s-0.22,-0.5 -0.5,-0.5 -0.5,0.22 -0.5,0.5 0.22,0.5 0.5,0.5zM3,13.5c-0.28,0 -0.5,0.22 -0.5,0.5s0.22,0.5 0.5,0.5 0.5,-0.22 0.5,-0.5 -0.22,-0.5 -0.5,-0.5zM10,20.5c-0.28,0 -0.5,0.22 -0.5,0.5s0.22,0.5 0.5,0.5 0.5,-0.22 0.5,-0.5 -0.22,-0.5 -0.5,-0.5zM10,3.5c0.28,0 0.5,-0.22 0.5,-0.5s-0.22,-0.5 -0.5,-0.5 -0.5,0.22 -0.5,0.5 0.22,0.5 0.5,0.5zM10,7c0.55,0 1,-0.45 1,-1s-0.45,-1 -1,-1 -1,0.45 -1,1 0.45,1 1,1zM10,12.5c-0.83,0 -1.5,0.67 -1.5,1.5s0.67,1.5 1.5,1.5 1.5,-0.67 1.5,-1.5 -0.67,-1.5 -1.5,-1.5zM18,13c-0.55,0 -1,0.45 -1,1s0.45,1 1,1 1,-0.45 1,-1 -0.45,-1 -1,-1zM18,17c-0.55,0 -1,0.45 -1,1s0.45,1 1,1 1,-0.45 1,-1 -0.45,-1 -1,-1zM18,9c-0.55,0 -1,0.45 -1,1s0.45,1 1,1 1,-0.45 1,-1 -0.45,-1 -1,-1zM18,5c-0.55,0 -1,0.45 -1,1s0.45,1 1,1 1,-0.45 1,-1 -0.45,-1 -1,-1zM21,13.5c-0.28,0 -0.5,0.22 -0.5,0.5s0.22,0.5 0.5,0.5 0.5,-0.22 0.5,-0.5 -0.22,-0.5 -0.5,-0.5zM14,17c-0.55,0 -1,0.45 -1,1s0.45,1 1,1 1,-0.45 1,-1 -0.45,-1 -1,-1zM14,20.5c-0.28,0 -0.5,0.22 -0.5,0.5s0.22,0.5 0.5,0.5 0.5,-0.22 0.5,-0.5 -0.22,-0.5 -0.5,-0.5zM10,8.5c-0.83,0 -1.5,0.67 -1.5,1.5s0.67,1.5 1.5,1.5 1.5,-0.67 1.5,-1.5 -0.67,-1.5 -1.5,-1.5zM10,17c-0.55,0 -1,0.45 -1,1s0.45,1 1,1 1,-0.45 1,-1 -0.45,-1 -1,-1zM14,12.5c-0.83,0 -1.5,0.67 -1.5,1.5s0.67,1.5 1.5,1.5 1.5,-0.67 1.5,-1.5 -0.67,-1.5 -1.5,-1.5zM14,8.5c-0.83,0 -1.5,0.67 -1.5,1.5s0.67,1.5 1.5,1.5 1.5,-0.67 1.5,-1.5 -0.67,-1.5 -1.5,-1.5z"/>
<path android:fillColor="#ffffff" android:pathData="M6,13c-0.55,0 -1,0.45 -1,1s0.45,1 1,1 1,-0.45 1,-1 -0.45,-1 -1,-1zM6,17c-0.55,0 -1,0.45 -1,1s0.45,1 1,1 1,-0.45 1,-1 -0.45,-1 -1,-1zM6,9c-0.55,0 -1,0.45 -1,1s0.45,1 1,1 1,-0.45 1,-1 -0.45,-1 -1,-1zM3,9.5c-0.28,0 -0.5,0.22 -0.5,0.5s0.22,0.5 0.5,0.5 0.5,-0.22 0.5,-0.5 -0.22,-0.5 -0.5,-0.5zM6,5c-0.55,0 -1,0.45 -1,1s0.45,1 1,1 1,-0.45 1,-1 -0.45,-1 -1,-1zM21,10.5c0.28,0 0.5,-0.22 0.5,-0.5s-0.22,-0.5 -0.5,-0.5 -0.5,0.22 -0.5,0.5 0.22,0.5 0.5,0.5zM14,7c0.55,0 1,-0.45 1,-1s-0.45,-1 -1,-1 -1,0.45 -1,1 0.45,1 1,1zM14,3.5c0.28,0 0.5,-0.22 0.5,-0.5s-0.22,-0.5 -0.5,-0.5 -0.5,0.22 -0.5,0.5 0.22,0.5 0.5,0.5zM3,13.5c-0.28,0 -0.5,0.22 -0.5,0.5s0.22,0.5 0.5,0.5 0.5,-0.22 0.5,-0.5 -0.22,-0.5 -0.5,-0.5zM10,20.5c-0.28,0 -0.5,0.22 -0.5,0.5s0.22,0.5 0.5,0.5 0.5,-0.22 0.5,-0.5 -0.22,-0.5 -0.5,-0.5zM10,3.5c0.28,0 0.5,-0.22 0.5,-0.5s-0.22,-0.5 -0.5,-0.5 -0.5,0.22 -0.5,0.5 0.22,0.5 0.5,0.5zM10,7c0.55,0 1,-0.45 1,-1s-0.45,-1 -1,-1 -1,0.45 -1,1 0.45,1 1,1zM10,12.5c-0.83,0 -1.5,0.67 -1.5,1.5s0.67,1.5 1.5,1.5 1.5,-0.67 1.5,-1.5 -0.67,-1.5 -1.5,-1.5zM18,13c-0.55,0 -1,0.45 -1,1s0.45,1 1,1 1,-0.45 1,-1 -0.45,-1 -1,-1zM18,17c-0.55,0 -1,0.45 -1,1s0.45,1 1,1 1,-0.45 1,-1 -0.45,-1 -1,-1zM18,9c-0.55,0 -1,0.45 -1,1s0.45,1 1,1 1,-0.45 1,-1 -0.45,-1 -1,-1zM18,5c-0.55,0 -1,0.45 -1,1s0.45,1 1,1 1,-0.45 1,-1 -0.45,-1 -1,-1zM21,13.5c-0.28,0 -0.5,0.22 -0.5,0.5s0.22,0.5 0.5,0.5 0.5,-0.22 0.5,-0.5 -0.22,-0.5 -0.5,-0.5zM14,17c-0.55,0 -1,0.45 -1,1s0.45,1 1,1 1,-0.45 1,-1 -0.45,-1 -1,-1zM14,20.5c-0.28,0 -0.5,0.22 -0.5,0.5s0.22,0.5 0.5,0.5 0.5,-0.22 0.5,-0.5 -0.22,-0.5 -0.5,-0.5zM10,8.5c-0.83,0 -1.5,0.67 -1.5,1.5s0.67,1.5 1.5,1.5 1.5,-0.67 1.5,-1.5 -0.67,-1.5 -1.5,-1.5zM10,17c-0.55,0 -1,0.45 -1,1s0.45,1 1,1 1,-0.45 1,-1 -0.45,-1 -1,-1zM14,12.5c-0.83,0 -1.5,0.67 -1.5,1.5s0.67,1.5 1.5,1.5 1.5,-0.67 1.5,-1.5 -0.67,-1.5 -1.5,-1.5zM14,8.5c-0.83,0 -1.5,0.67 -1.5,1.5s0.67,1.5 1.5,1.5 1.5,-0.67 1.5,-1.5 -0.67,-1.5 -1.5,-1.5z"/>
</vector>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

View File

@ -0,0 +1,300 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="191.7dp"
android:height="168.39dp"
android:viewportWidth="191.7"
android:viewportHeight="168.39">
<path
android:pathData="m72.76,168.384c14.507,-12.545 13.641,-67.907 13.641,-67.907l18.56,0s-0.865,55.362 13.641,67.907z"
android:fillColor="#803300"/>
<path
android:pathData="M9.905,46.504m-8.681,0a8.681,8.681 0,1 1,17.362 0a8.681,8.681 0,1 1,-17.362 0"
android:fillColor="#19a52c"
android:fillType="evenOdd"/>
<path
android:pathData="M30.859,67.255m-8.681,0a8.681,8.681 0,1 1,17.362 0a8.681,8.681 0,1 1,-17.362 0"
android:fillColor="#19a52c"
android:fillType="evenOdd"/>
<path
android:pathData="M70.85,78.933m-8.681,0a8.681,8.681 0,1 1,17.362 0a8.681,8.681 0,1 1,-17.362 0"
android:fillColor="#19a52c"
android:fillType="evenOdd"/>
<path
android:pathData="M59.056,112.634m-8.681,0a8.681,8.681 0,1 1,17.362 0a8.681,8.681 0,1 1,-17.362 0"
android:fillColor="#19a52c"
android:fillType="evenOdd"/>
<path
android:pathData="M96.713,53.515m-8.681,0a8.681,8.681 0,1 1,17.362 0a8.681,8.681 0,1 1,-17.362 0"
android:fillColor="#19a52c"
android:fillType="evenOdd"/>
<path
android:pathData="M29.607,111.094m-8.681,0a8.681,8.681 0,1 1,17.362 0a8.681,8.681 0,1 1,-17.362 0"
android:fillColor="#19a52c"
android:fillType="evenOdd"/>
<path
android:pathData="M117.963,103.294m-8.681,0a8.681,8.681 0,1 1,17.362 0a8.681,8.681 0,1 1,-17.362 0"
android:fillColor="#19a52c"
android:fillType="evenOdd"/>
<path
android:pathData="M88.672,98.684m-8.681,0a8.681,8.681 0,1 1,17.362 0a8.681,8.681 0,1 1,-17.362 0"
android:fillColor="#19a52c"
android:fillType="evenOdd"/>
<path
android:pathData="M10.523,88.614m-8.681,0a8.681,8.681 0,1 1,17.362 0a8.681,8.681 0,1 1,-17.362 0"
android:fillColor="#19a52c"
android:fillType="evenOdd"/>
<path
android:pathData="M38.991,38.909m-8.681,0a8.681,8.681 0,1 1,17.362 0a8.681,8.681 0,1 1,-17.362 0"
android:fillColor="#19a52c"
android:fillType="evenOdd"/>
<path
android:pathData="M86.556,23.775m-8.681,0a8.681,8.681 0,1 1,17.362 0a8.681,8.681 0,1 1,-17.362 0"
android:fillColor="#19a52c"
android:fillType="evenOdd"/>
<path
android:pathData="M67.987,46.865m-8.681,0a8.681,8.681 0,1 1,17.362 0a8.681,8.681 0,1 1,-17.362 0"
android:fillColor="#19a52c"
android:fillType="evenOdd"/>
<path
android:pathData="M111.893,8.68m-8.681,0a8.681,8.681 0,1 1,17.362 0a8.681,8.681 0,1 1,-17.362 0"
android:fillColor="#19a52c"
android:fillType="evenOdd"/>
<path
android:pathData="M110.583,77.617m-8.681,0a8.681,8.681 0,1 1,17.362 0a8.681,8.681 0,1 1,-17.362 0"
android:fillColor="#19a52c"
android:fillType="evenOdd"/>
<path
android:pathData="M42.798,88.024m-8.681,0a8.681,8.681 0,1 1,17.362 0a8.681,8.681 0,1 1,-17.362 0"
android:fillColor="#19a52c"
android:fillType="evenOdd"/>
<path
android:pathData="m29.607,111.094 l-19.084,-22.481"
android:strokeWidth="1.8939"
android:fillColor="#19a52c"
android:strokeColor="#19a52c"/>
<path
android:pathData="m10.523,88.614 l20.336,-21.356"
android:strokeWidth="1.8939"
android:fillColor="#19a52c"
android:strokeColor="#19a52c"/>
<path
android:pathData="m9.905,46.504 l20.953,20.75"
android:strokeWidth="1.8939"
android:fillColor="#19a52c"
android:strokeColor="#19a52c"/>
<path
android:pathData="m42.798,88.024 l16.258,24.602"
android:strokeWidth="1.8939"
android:fillColor="#19a52c"
android:strokeColor="#19a52c"/>
<path
android:pathData="m42.798,88.024 l28.052,-9.094"
android:strokeWidth="1.8939"
android:fillColor="#19a52c"
android:strokeColor="#19a52c"/>
<path
android:pathData="m38.991,38.909 l-8.132,28.346"
android:strokeWidth="1.8939"
android:fillColor="#19a52c"
android:strokeColor="#19a52c"/>
<path
android:pathData="m38.991,38.909 l28.996,7.956"
android:strokeWidth="1.8939"
android:fillColor="#19a52c"
android:strokeColor="#19a52c"/>
<path
android:pathData="m67.987,46.865 l28.73,6.65"
android:strokeWidth="1.8939"
android:fillColor="#19a52c"
android:strokeColor="#19a52c"/>
<path
android:pathData="m67.987,46.865 l18.569,-23.089"
android:strokeWidth="1.8939"
android:fillColor="#19a52c"
android:strokeColor="#19a52c"/>
<path
android:pathData="m86.556,23.775 l25.333,-15.095"
android:strokeWidth="1.8939"
android:fillColor="#19a52c"
android:strokeColor="#19a52c"/>
<path
android:pathData="m96.713,53.515 l13.863,24.103"
android:strokeWidth="1.8939"
android:fillColor="#19a52c"
android:strokeColor="#19a52c"/>
<path
android:pathData="m64.435,112.604 l24.237,-13.916"
android:strokeWidth="1.8939"
android:fillColor="#19a52c"
android:strokeColor="#19a52c"/>
<path
android:pathData="m88.672,98.684 l21.907,-21.071"
android:strokeWidth="1.8939"
android:fillColor="#19a52c"
android:strokeColor="#19a52c"/>
<path
android:pathData="m88.672,98.684 l29.289,4.606"
android:strokeWidth="1.8939"
android:fillColor="#19a52c"
android:strokeColor="#19a52c"/>
<path
android:pathData="m8.68,132.524 l20.926,-21.434"
android:strokeWidth="1.8939"
android:fillColor="#19a52c"
android:strokeColor="#19a52c"/>
<path
android:pathData="M8.68,132.524m-8.681,0a8.681,8.681 0,1 1,17.362 0a8.681,8.681 0,1 1,-17.362 0"
android:fillColor="#19a52c"
android:fillType="evenOdd"/>
<path
android:pathData="M44.718,138.394m-8.681,0a8.681,8.681 0,1 1,17.362 0a8.681,8.681 0,1 1,-17.362 0"
android:fillColor="#19a52c"
android:fillType="evenOdd"/>
<path
android:pathData="m29.607,111.094 l15.111,27.303"
android:strokeWidth="1.8939"
android:fillColor="#19a52c"
android:strokeColor="#19a52c"/>
<path
android:pathData="m117.963,103.294 l13.863,24.103"
android:strokeWidth="1.8939"
android:fillColor="#19a52c"
android:strokeColor="#19a52c"/>
<path
android:pathData="M131.822,127.394m-8.681,0a8.681,8.681 0,1 1,17.362 0a8.681,8.681 0,1 1,-17.362 0"
android:fillColor="#19a52c"
android:fillType="evenOdd"/>
<path
android:pathData="m131.822,127.394 l20.559,-17.279"
android:strokeWidth="1.8939"
android:fillColor="#19a52c"
android:strokeColor="#19a52c"/>
<path
android:pathData="M152.382,110.114m-8.681,0a8.681,8.681 0,1 1,17.362 0a8.681,8.681 0,1 1,-17.362 0"
android:fillColor="#19a52c"
android:fillType="evenOdd"/>
<path
android:pathData="M139.622,84.614m-8.681,0a8.681,8.681 0,1 1,17.362 0a8.681,8.681 0,1 1,-17.362 0"
android:fillColor="#19a52c"
android:fillType="evenOdd"/>
<path
android:pathData="M152.782,60.155m-8.681,0a8.681,8.681 0,1 1,17.362 0a8.681,8.681 0,1 1,-17.362 0"
android:fillColor="#19a52c"
android:fillType="evenOdd"/>
<path
android:pathData="M125.293,52.545m-8.681,0a8.681,8.681 0,1 1,17.362 0a8.681,8.681 0,1 1,-17.362 0"
android:fillColor="#19a52c"
android:fillType="evenOdd"/>
<path
android:pathData="M163.313,23.778m-8.681,0a8.681,8.681 0,1 1,17.362 0a8.681,8.681 0,1 1,-17.362 0"
android:fillColor="#19a52c"
android:fillType="evenOdd"/>
<path
android:pathData="M183.022,111.944m-8.681,0a8.681,8.681 0,1 1,17.362 0a8.681,8.681 0,1 1,-17.362 0"
android:fillColor="#19a52c"
android:fillType="evenOdd"/>
<path
android:pathData="M166.242,134.224m-8.681,0a8.681,8.681 0,1 1,17.362 0a8.681,8.681 0,1 1,-17.362 0"
android:fillColor="#19a52c"
android:fillType="evenOdd"/>
<path
android:pathData="M59.081,13.061m-8.681,0a8.681,8.681 0,1 1,17.362 0a8.681,8.681 0,1 1,-17.362 0"
android:fillColor="#19a52c"
android:fillType="evenOdd"/>
<path
android:pathData="M134.802,24.57m-8.681,0a8.681,8.681 0,1 1,17.362 0a8.681,8.681 0,1 1,-17.362 0"
android:fillColor="#19a52c"
android:fillType="evenOdd"/>
<path
android:pathData="m152.382,110.114 l13.863,24.103"
android:strokeWidth="1.8939"
android:fillColor="#19a52c"
android:strokeColor="#19a52c"/>
<path
android:pathData="m166.242,134.224 l16.778,-22.271"
android:strokeWidth="1.8939"
android:fillColor="#19a52c"
android:strokeColor="#19a52c"/>
<path
android:pathData="m110.583,77.617 l29.041,6.993"
android:strokeWidth="1.8939"
android:fillColor="#19a52c"
android:strokeColor="#19a52c"/>
<path
android:pathData="m139.622,84.614 l12.763,25.506"
android:strokeWidth="1.8939"
android:fillColor="#19a52c"
android:strokeColor="#19a52c"/>
<path
android:pathData="m96.713,53.515 l28.58,-0.97"
android:strokeWidth="1.8939"
android:fillColor="#19a52c"
android:strokeColor="#19a52c"/>
<path
android:pathData="m139.622,84.614 l29.455,-1.419"
android:strokeWidth="1.8939"
android:fillColor="#19a52c"
android:strokeColor="#19a52c"/>
<path
android:pathData="m134.802,24.57 l28.511,-0.792"
android:strokeWidth="1.8939"
android:fillColor="#19a52c"
android:strokeColor="#19a52c"/>
<path
android:pathData="m111.893,8.68 l22.914,15.89"
android:strokeWidth="1.8939"
android:fillColor="#19a52c"
android:strokeColor="#19a52c"/>
<path
android:pathData="m29.607,111.094 l29.449,1.538"
android:strokeWidth="1.8939"
android:fillColor="#19a52c"
android:strokeColor="#19a52c"/>
<path
android:pathData="m67.987,46.865 l2.863,32.068"
android:strokeWidth="1.8939"
android:fillColor="#19a52c"
android:strokeColor="#19a52c"/>
<path
android:pathData="m59.081,13.061 l27.474,10.715"
android:strokeWidth="1.8939"
android:fillColor="#19a52c"
android:strokeColor="#19a52c"/>
<path
android:pathData="m38.991,38.909 l20.091,-25.848"
android:strokeWidth="1.8939"
android:fillColor="#19a52c"
android:strokeColor="#19a52c"/>
<path
android:pathData="m134.802,24.57 l-9.506,27.975"
android:strokeWidth="1.8939"
android:fillColor="#19a52c"
android:strokeColor="#19a52c"/>
<path
android:pathData="M169.072,83.194m-8.681,0a8.681,8.681 0,1 1,17.362 0a8.681,8.681 0,1 1,-17.362 0"
android:fillColor="#19a52c"
android:fillType="evenOdd"/>
<path
android:pathData="m163.313,23.778 l16.663,26.606"
android:strokeWidth="1.8939"
android:fillColor="#19a52c"
android:strokeColor="#19a52c"/>
<path
android:pathData="m125.293,52.545 l27.488,7.61"
android:strokeWidth="1.8939"
android:fillColor="#19a52c"
android:strokeColor="#19a52c"/>
<path
android:pathData="M179.972,50.384m-8.681,0a8.681,8.681 0,1 1,17.362 0a8.681,8.681 0,1 1,-17.362 0"
android:fillColor="#19a52c"
android:fillType="evenOdd"/>
<path
android:pathData="m179.972,50.384 l-27.193,9.771"
android:strokeWidth="1.8939"
android:fillColor="#19a52c"
android:strokeColor="#19a52c"/>
<path
android:pathData="m152.782,60.155 l16.292,23.036"
android:strokeWidth="1.8939"
android:fillColor="#19a52c"
android:strokeColor="#19a52c"/>
</vector>

View File

@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512.004"
android:viewportHeight="512.004">
<path
android:fillColor="#FFF"
android:pathData="m130.239,138.268 l-44.358,3.427c-12.343,0.954 -23.336,7.423 -30.162,17.748l-51.157,77.372c-5.177,7.83 -6,17.629 -2.203,26.213 3.798,8.584 11.603,14.566 20.878,16.003l40.615,6.29c9.501,-50.42 32.245,-100.716 66.387,-147.053z"/>
<path
android:fillColor="#FFF"
android:pathData="m226.682,448.151 l6.291,40.615c1.437,9.275 7.419,17.08 16.002,20.877 3.571,1.58 7.351,2.36 11.112,2.36 5.283,0 10.529,-1.539 15.102,-4.563l77.374,-51.156c10.325,-6.827 16.794,-17.821 17.746,-30.162l3.427,-44.358c-46.338,34.143 -96.633,56.887 -147.054,66.387z"/>
<path
android:fillColor="#FFF"
android:pathData="m211.407,420c1.41,0 2.828,-0.116 4.243,-0.352 21.124,-3.532 41.484,-9.482 60.906,-17.27l-166.93,-166.93c-7.788,19.421 -13.738,39.781 -17.27,60.906 -1.392,8.327 1.401,16.81 7.37,22.78l93.144,93.144c4.956,4.955 11.645,7.722 18.537,7.722z"/>
<path
android:fillColor="#FFF"
android:pathData="m471.178,227.003c40.849,-78.974 42.362,-162.43 40.227,-201.57 -0.731,-13.411 -11.423,-24.103 -24.835,-24.834 -6.373,-0.348 -13.926,-0.599 -22.439,-0.599 -43.766,0 -113.017,6.629 -179.131,40.826 -52.542,27.177 -121.439,87.018 -162.087,165.66 0.48,0.375 0.949,0.773 1.391,1.215l180,180c0.442,0.442 0.839,0.91 1.214,1.39 78.642,-40.649 138.483,-109.546 165.66,-162.088zM297.698,108.24c29.241,-29.241 76.822,-29.244 106.065,0 14.166,14.165 21.967,33 21.967,53.033s-7.801,38.868 -21.967,53.033c-14.619,14.619 -33.829,21.93 -53.032,21.932 -19.209,0.001 -38.41,-7.309 -53.033,-21.932 -14.166,-14.165 -21.968,-33 -21.968,-53.033s7.802,-38.868 21.968,-53.033z"/>
<path
android:fillColor="#FFF"
android:pathData="m318.911,193.092c17.545,17.545 46.095,17.546 63.64,0 8.499,-8.5 13.18,-19.8 13.18,-31.82s-4.681,-23.32 -13.18,-31.819c-8.772,-8.773 -20.296,-13.159 -31.82,-13.159 -11.523,0 -23.047,4.386 -31.819,13.159 -8.499,8.499 -13.181,19.799 -13.181,31.819s4.681,23.321 13.18,31.82z"/>
<path
android:fillColor="#FFF"
android:pathData="m15.305,421.938c3.839,0 7.678,-1.464 10.606,-4.394l48.973,-48.973c5.858,-5.858 5.858,-15.355 0,-21.213 -5.857,-5.858 -15.355,-5.858 -21.213,0l-48.973,48.973c-5.858,5.858 -5.858,15.355 0,21.213 2.929,2.929 6.768,4.394 10.607,4.394z"/>
<path
android:fillColor="#FFF"
android:pathData="m119.765,392.239c-5.857,-5.858 -15.355,-5.858 -21.213,0l-94.155,94.155c-5.858,5.858 -5.858,15.355 0,21.213 2.929,2.929 6.768,4.393 10.607,4.393s7.678,-1.464 10.606,-4.394l94.154,-94.154c5.859,-5.858 5.859,-15.355 0.001,-21.213z"/>
<path
android:fillColor="#FFF"
android:pathData="m143.432,437.12 l-48.972,48.973c-5.858,5.858 -5.858,15.355 0,21.213 2.929,2.929 6.768,4.394 10.606,4.394s7.678,-1.464 10.606,-4.394l48.973,-48.973c5.858,-5.858 5.858,-15.355 0,-21.213 -5.857,-5.858 -15.355,-5.858 -21.213,0z"/>
</vector>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:bottom="-2dp" android:left="-2dp" android:right="-2dp">
<shape android:shape="rectangle">
<stroke android:width="1dp" android:color="@color/offline_warning"/>
<solid android:color="@color/transparent"/>
<padding android:top="15dp"/>
</shape>
</item>
</layer-list>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:bottom="-2dp" android:left="-2dp" android:right="-2dp">
<shape android:shape="rectangle">
<stroke android:width="1dp" android:color="@color/pending_msg_indicator"/>
<solid android:color="@color/transparent"/>
<padding android:top="5dp"/>
</shape>
</item>
</layer-list>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:bottom="-2dp" android:left="-2dp" android:right="-2dp">
<shape android:shape="rectangle">
<stroke android:width="1dp" android:color="@color/secondary"/>
<solid android:color="@color/transparent"/>
<padding android:top="15dp"/>
</shape>
</item>
</layer-list>

View File

@ -13,14 +13,79 @@
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/toolbar"
app:layout_constraintBottom_toTopOf="@id/bottom_panel"/>
app:layout_constraintBottom_toTopOf="@id/offline_warning"/>
<LinearLayout
android:id="@+id/sending_pending_msgs_indicator"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/recycler_chat"
app:layout_constraintBottom_toTopOf="@id/bottom_panel"
android:visibility="gone"
android:gravity="center"
android:paddingVertical="10dp"
android:background="@drawable/sending_pending_msg_indictor_background">
<ProgressBar
android:layout_width="50dp"
android:layout_height="50dp"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/sending_pending_messages"
android:layout_gravity="center"
android:layout_marginStart="20dp"/>
</LinearLayout>
<LinearLayout
android:id="@+id/offline_warning"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/recycler_chat"
app:layout_constraintBottom_toTopOf="@id/bottom_panel"
android:visibility="gone"
android:paddingHorizontal="30dp"
android:layout_marginBottom="15dp"
android:background="@drawable/offline_warning_background">
<ImageView
android:layout_width="50dp"
android:layout_height="50dp"
android:src="@drawable/ic_warning"
app:tint="@color/offline_warning"
android:contentDescription="@string/warning_desc"
android:layout_gravity="center_vertical"/>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginStart="20dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="16sp"
android:textStyle="bold"
android:text="@string/offline_warning_title"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/offline_warning_msg"
android:textColor="#80ffffff"/>
</LinearLayout>
</LinearLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/bottom_panel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
android:visibility="gone"
android:paddingVertical="5dp"
android:background="@color/primary">
@ -54,7 +119,7 @@
android:hint="@string/message_hint"
android:autofillHints="message"
style="@style/EditText"
android:inputType="textShortMessage|textAutoCorrect|textCapSentences"
android:inputType="textMultiLine|textShortMessage|textAutoCorrect|textCapSentences"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/bottom_image_trust_level"

View File

@ -1,34 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical" android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingVertical="1dp">
<LinearLayout
android:id="@+id/bubble_content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center"
style="@style/Bubble"
android:paddingStart="6dp"
android:paddingVertical="5dp">
<ImageButton
android:id="@+id/button_save"
android:layout_width="@dimen/image_button_size"
android:layout_height="@dimen/image_button_size"
android:src="@drawable/ic_save"
style="@style/ImageButton"
android:contentDescription="@string/download" />
<TextView
android:id="@+id/text_filename"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textIsSelectable="true"
android:textColor="@color/messageTextColor"/>
</LinearLayout>
</LinearLayout>

View File

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical" android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingVertical="1dp">
<TextView
android:id="@+id/text_date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:paddingVertical="5dp"/>
<TextView
android:id="@+id/text_pending_msg"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="20dp"
android:layout_marginVertical="10dp"
android:textAlignment="center"
android:text="@string/pending_messages"
android:textColor="@color/pending_msg_indicator"
android:background="@drawable/pending_msg_indicator_background"/>
<LinearLayout
android:id="@+id/bubble"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
style="@style/Bubble">
<View
android:id="@+id/bubble_content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
<TextView
android:id="@+id/text_hour"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"/>
</LinearLayout>
</LinearLayout>

View File

@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical" android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingVertical="1dp">
<TextView
android:id="@+id/text_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textIsSelectable="true"
android:autoLink="all"
android:textColor="@color/messageTextColor"
style="@style/Bubble"/>
</LinearLayout>

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_vertical">
<ImageButton
android:id="@+id/button_save"
android:layout_width="@dimen/image_button_size"
android:layout_height="@dimen/image_button_size"
android:src="@drawable/ic_save"
android:scaleType="fitXY"
android:background="@color/transparent"
android:layout_marginEnd="8dp"
android:contentDescription="@string/download" />
<TextView
android:id="@+id/text_filename"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textIsSelectable="true"
android:textColor="@color/messageTextColor"/>
</LinearLayout>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/text_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:autoLink="all"
android:textColor="@color/messageTextColor"
android:textIsSelectable="true"/>

View File

@ -0,0 +1,121 @@
<resources>
<string name="app_name">AIRA</string>
<string name="create_identity_test">Crear una nueva identidad:</string>
<string name="identity_name">Nombre de la identidad</string>
<string name="password_hint">Contraseña</string>
<string name="password_confirm_hint">Contraseña (confirmación)</string>
<string name="create">Crear</string>
<string name="password_mismatch">¡Las contraseñas no coinciden!</string>
<string name="online_peers">Pares en línea:</string>
<string name="offline_contacts">Contactos desconectados:</string>
<string name="identity_create_failed">Fallo al crear la identidad</string>
<string name="identity_load_failed">Fallo al cargar la identidad. Por favor, verifica tu contraseña</string>
<string name="service_name">Servicio en segundo plano de AIRA</string>
<string name="login">Acceder</string>
<string name="add_peer_ip">Añadir pares por IP</string>
<string name="unknown">Desconocido</string>
<string name="file_saved">Archivo guardado en %s !</string>
<string name="enter_password">Ingresa tu contraseña:</string>
<string name="yes"></string>
<string name="no">No</string>
<string name="warning">¡Advertencia!</string>
<string name="ask_log_out">¿En verdad quieres cerrar sesión? (No recibirás nuevos mensajes hasta que accedas nuevamente)</string>
<string name="cancel">Cancelar</string>
<string name="background_service">Servicio en segundo plano</string>
<string name="verifying_contact">Verificación del contacto</string>
<string name="fingerprints_instructions">Compara las siguientes huellas digitales por un modo de comunicación seguro (como en persona) y asegurate de que coinciden..</string>
<string name="local_fingerprint">Huellas digital local:</string>
<string name="peer_fingerprint">Huellas digital del par:</string>
<string name="summary_name">El nombre de tu identiad. Se muestra a todas las sesiones activas.</string>
<string name="delete_identity">Eliminar identidad</string>
<string name="summary_delete_identity">Eliminar todos tus datos. Ya no podrás ser reconocido por tus contactos.</string>
<string name="preference_password">Contraseña de identidad</string>
<string name="summary_password">Sin esta contraseña no puedes acceder a tus datos ni ser reconocido por tus contactos.</string>
<string name="change_password">Cambiar contraseña:</string>
<string name="ok">De acuerdo</string>
<string name="change_password_failed">Operación fallida. Por favor, compruebe su antigua contraseña.</string>
<string name="error">Error</string>
<string name="current_password">Contraseña actual</string>
<string name="new_password">Nueva contraseña (vacío para no usar contraseña)</string>
<string name="new_password_confirm">Nueva contraseña (confirmación)</string>
<string name="confirm_delete">¿Estás seguro de que quieres eliminar todas tus conversaciones (mensajes y archivos), todos tus contactos y tu clave privada?</string>
<string name="they_match">Coinciden</string>
<string name="ask_delete_conversation">Borrar una conversación sólo te afecta a ti. Tu contacto seguirá teniendo una copia de esta conversación si no la borra también. ¿Realmente quieres borrar toda esta conversación (mensajes y archivos)?</string>
<string name="delete">Eliminar</string>
<string name="ask_remove_contact">Al borrar el contacto se eliminará su clave de identidad y su conversación (mensajes y archivos). Ya no podrás reconocerlo. Esta acción sólo te afecta a ti. ¿Realmente quieres eliminar este contacto?</string>
<string name="ask_remove_contacts">Al borrar los contactos se eliminarán sus claves de identidad y sus conversaciones (mensajes y archivos). Ya no podrás reconocerlos. Esta acción sólo te afecta a ti. ¿Realmente quieres eliminar estos contactos?</string>
<string name="enable_password">Encriptar con contraseña</string>
<string name="msg_notification_channel_name">Nuevos mensajes</string>
<string name="mark_read">Marcar como leído</string>
<string name="ask_file_notification_channel">Solicitudes de descarga de archivos</string>
<string name="db_mkdir_failed">Fallo en la creación del directorio de bases de datos</string>
<string name="want_to_send_files">%s quiere enviarte algunos archivos</string>
<string name="download_file_request">Solicitud de descarga de archivos</string>
<string name="download">Descargar</string>
<string name="refuse">Rechazar</string>
<string name="file_transfers">Transferencias de archivos</string>
<string name="transfer_aborted">Transferencia abortada</string>
<string name="transfer_completed">Transferencia completa</string>
<string name="reply">Responder</string>
<string name="open_uri_failed">Fallo en la apertura de la URI</string>
<string name="ask_send_single_file">¿Enviar %s (%s) a %s ?</string>
<string name="ask_send_multiple_files">¿Enviar %d archivos a %s?</string>
<string name="share_label">Enviar con AIRA</string>
<string name="identity_fingerprint">La huella digital de tu identidad:</string>
<string name="fingerprint">Huella digital:</string>
<string name="peer_ip">IP de par:</string>
<string name="connection">Conexión:</string>
<string name="outgoing">Saliente</string>
<string name="incoming">Entrante</string>
<string name="delete_conversation">Eliminar la convesación</string>
<string name="verify">Verificando</string>
<string name="add_contact">Añadir contacto</string>
<string name="remove_contact">Eliminar contacto</string>
<string name="details">Detalles</string>
<string name="your_addresses">Tu dirección IP:</string>
<string name="file_transfer_already_in_progress">Ya está en marcha otra transferencia de archivos</string>
<string name="settings">Configuraciones</string>
<string name="log_out">Cerrar sesión</string>
<string name="copied">¡Copiado al portapapeles!</string>
<string name="identity">Identidad</string>
<string name="about">Acerca de</string>
<string name="version">Versión de AIRA</string>
<string name="refresh_profile">Actualizar perfil</string>
<string name="security">Seguridad</string>
<string name="use_psec_padding">Usar PSEC padding</string>
<string name="psec_padding_summary">PSEC padding ofusca la longitud de sus mensajes pero utiliza más ancho de banda de la red.</string>
<string name="is_contact">Es un contacto:</string>
<string name="is_verified">Está verificado:</string>
<string name="avatar_too_large">El avatar no puede ser mayor de 10MB.</string>
<string name="your_avatar">Tu avatar:</string>
<string name="set_a_new_one">Establecer uno nuevo</string>
<string name="remove">Eliminar</string>
<string name="choose_avatar">Seleccionar avatar</string>
<string name="message_hint">Enviar un mensaje…</string>
<string name="no_name_error">¡Esta sesión no tiene nombre!</string>
<string name="invalid_ip">Dirección IP inválida</string>
<string name="unable_to_connect_to">No se puede conectar con %s</string>
<string name="github">GitHub</string>
<string name="github_summary">Repositorio AIRA-android en GitHub.</string>
<string name="gitea">Gitea</string>
<string name="gitea_summary">Repositorio AIRA-android en la instancia Gitea del proyecto Chapril. A diferencia de GitHub, Gitea es un software totalmente libre y autoalojado.</string>
<string name="offline_warning_title">Su contacto parece estar desconectado.</string>
<string name="offline_warning_msg">Los mensajes enviados se almacenarán hasta que se establezca la conexión.</string>
<string name="pending_messages">Mensajes pendientes:</string>
<string name="sending_pending_messages">Enviando de mensajes pendientes…</string>
<string name="stop">Detener</string>
<string name="app">Aplicación</string>
<string name="start_at_boot">Iniciar el servicio AIRA en el arranque</string>
<string name="start_at_boot_summary">Si se desactiva, no recibirás mensajes hasta que abras la aplicación manualmente.</string>
<string name="start_at_boot_summary_identity_protected">Sólo está disponible si la identidad no está protegida por una contraseña.</string>
<!--accessibility strings-->
<string name="send_file">Enviar archivo</string>
<string name="trust_level_indicator">Indicador del nivel de confianza</string>
<string name="send_message">Envíar mensaje</string>
<string name="show_your_ips">Muestra tus IPs</string>
<string name="clickable_indicator">Indicador de clicks</string>
<string name="avatar">Avatar</string>
<string name="name">Nombre</string>
<string name="warning_desc">Icono de advertencia</string>
</resources>

View File

@ -12,4 +12,9 @@
<color name="itemSelected">#66666666</color>
<color name="transparent">#00000000</color>
<color name="sessionArrow">#777777</color>
<color name="outgoingTimestamp">#777777</color>
<color name="incomingTimestamp">#d7d7d7</color>
<color name="incomingHighlight">@color/outgoingTextLink</color>
<color name="offline_warning">#ff0000</color>
<color name="pending_msg_indicator">#909090</color>
</resources>

View File

@ -14,7 +14,7 @@
<string name="login">Login</string>
<string name="add_peer_ip">Add peer by IP</string>
<string name="unknown">Unknown</string>
<string name="file_saved">File saved !</string>
<string name="file_saved">File saved to %s !</string>
<string name="enter_password">Enter your password:</string>
<string name="yes">Yes</string>
<string name="no">No</string>
@ -76,7 +76,6 @@
<string name="file_transfer_already_in_progress">Another file transfer is already in progress</string>
<string name="settings">Settings</string>
<string name="log_out">Log out</string>
<string name="loadFile_failed">File extraction failed</string>
<string name="copied">Copied to clipboard !</string>
<string name="identity">Identity</string>
<string name="about">About</string>
@ -100,6 +99,15 @@
<string name="github_summary">AIRA-android repository on GitHub.</string>
<string name="gitea">Gitea</string>
<string name="gitea_summary">AIRA-android repository on the Gitea instance of the Chapril project. Unlike GitHub, Gitea is fully free software and self-hosted.</string>
<string name="offline_warning_title">Your contact seems to be offline.</string>
<string name="offline_warning_msg">Sent messages will be stored until a connection is established.</string>
<string name="pending_messages">Pending messages:</string>
<string name="sending_pending_messages">Sending pending messages…</string>
<string name="stop">Stop</string>
<string name="app">App</string>
<string name="start_at_boot">Start AIRA service at boot</string>
<string name="start_at_boot_summary">If disabled, you won\'t receive messages until you open the app manually.</string>
<string name="start_at_boot_summary_identity_protected">Only available if identity is not protected by a password.</string>
<!--accessibility strings-->
<string name="send_file">Send file</string>
@ -109,4 +117,5 @@
<string name="clickable_indicator">Clickable indicator</string>
<string name="avatar">Avatar</string>
<string name="name">Name</string>
<string name="warning_desc">Warning icon</string>
</resources>

View File

@ -7,7 +7,6 @@
<item name="android:paddingBottom">8dp</item>
</style>
<style name="Bubble">
<item name="android:background">@drawable/background_adapter_bubble</item>
<item name="android:paddingTop">10dp</item>
<item name="android:paddingBottom">10dp</item>
<item name="android:paddingStart">15dp</item>

View File

@ -34,6 +34,17 @@
</PreferenceCategory>
<PreferenceCategory android:title="@string/app">
<SwitchPreferenceCompat
android:key="startAtBoot"
android:defaultValue="true"
android:title="@string/start_at_boot"
android:summary="@string/start_at_boot_summary"
android:icon="@drawable/ic_shuttle"/>
</PreferenceCategory>
<PreferenceCategory android:title="@string/security">
<SwitchPreferenceCompat

View File

@ -1,11 +1,11 @@
buildscript {
ext.kotlin_version = "1.5.10"
ext.kotlin_version = "1.6.21"
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:4.2.1'
classpath 'com.android.tools.build:gradle:7.2.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}

View File

@ -1,6 +1,6 @@
#Thu Jan 21 14:03:53 UTC 2021
#Thu Sep 02 16:55:14 UTC 2021
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-all.zip
zipStoreBase=GRADLE_USER_HOME

BIN
screenshots/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
screenshots/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
screenshots/3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB