Compare commits

...

27 Commits

Author SHA1 Message Date
Hardcore Sushi 19ae8c6092
Update dependencies 1 year ago
Hardcore Sushi fcc7d64b9d
Hide irrelevant options when selecting sessions 1 year ago
Hardcore Sushi 81dc567ff0
Spanish translation 1 year ago
Hardcore Sushi 0dc19529f6
Make cargo clippy happy 2 years ago
Hardcore Sushi eb36a8e879
Add AIRA logo & Update gradle 2 years ago
Hardcore Sushi 2b50ffa409
Messaging style notifications 2 years ago
Hardcore Sushi 928f97dc97
Don't re-compress avatars before storing in DB 2 years ago
Hardcore Sushi 8354490940
Remove unnecessary permissions 2 years ago
Hardcore Sushi c6f1e6d432
Fix share intent handling bugs 2 years ago
Hardcore Sushi eac227085d
Start at boot feature 2 years ago
Hardcore Sushi fc46326deb
Add stop button in background service notification 2 years ago
Hardcore Sushi 3562362916
Fix thread bugs 2 years ago
Hardcore Sushi cc31423cc4
Fix UI bugs in ChatActivity 2 years ago
Hardcore Sushi b20fded45b
Remove only pending messages instead of reloading the whole history when sending pending messages 2 years ago
Hardcore Sushi 5fc3f7d0cf
Pending messages 2 years ago
Hardcore Sushi d618eb87c7
Update to PSEC v0.4 2 years ago
Hardcore Sushi 37e4c0a105
Handle special characters in file names 2 years ago
Hardcore Sushi f186dbd0cb
Send only read data during large file transfers 2 years ago
Hardcore Sushi 4cb127004f
Add screenshots to README 2 years ago
Hardcore Sushi e06044acb8
Show date in ChatAdapter 2 years ago
Hardcore Sushi 05a9f5881d
Conditional message bubble corners 2 years ago
Hardcore Sushi 5a2a9ccc4f
Add Download section in README 2 years ago
Hardcore Sushi 335e786f79
Show file name on saving 2 years ago
Hardcore Sushi f7418cf9c2
Decrease max message size to 16MB 2 years ago
Hardcore Sushi 38b322ebc5
Change highlight color on received messages 2 years ago
Hardcore Sushi 0d3848360b
Add margin between received and sent messages 2 years ago
Hardcore Sushi 8f33a640ce
Message timestamps 2 years ago

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

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

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

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

@ -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)
}
}
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)
} else {
onDisconnected()
}
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()
}
for (msg in it.asReversed()) {
chatAdapter.newLoadedMessage(ChatItem(msg.outgoing, msg.timestamp, msg.data))
}
}
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)
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))
}
}
}
}
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)
}
}
if (chatAdapter.itemCount > 0) {
scrollToBottom()
}
airaService.receiveFileTransfers[sessionId]?.let {
if (it.shouldAsk) {
it.ask(this@ChatActivity, ipName)
}
}
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
binding.sendingPendingMsgsIndicator.visibility = if (session == null) {
if (contact == null) {
hideBottomPanel()
} else {
false
binding.offlineWarning.visibility = View.VISIBLE
}
}
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
View.GONE
} else {
binding.offlineWarning.visibility = View.GONE
if (hasPendingMsgs) {
View.VISIBLE
} else {
false
View.GONE
}
}
airaService.setSeen(sessionId, true)
}
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()
}
}

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

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

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

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

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

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

@ -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)
}
} else {
val newPasswordConfirm = newPasswordConfirmEditText.text.toString().toByteArray()
if (newPassword.contentEquals(newPasswordConfirm)) {
changePassword(isIdentityProtected, oldPasswordEditText, newPassword)
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 {
AlertDialog.Builder(activity, R.style.CustomAlertDialog)
.setMessage(R.string.password_mismatch)
.setTitle(R.string.error)
.setPositiveButton(R.string.ok, null)
.show()
changePassword(isIdentityProtected, oldPasswordEditText, newPassword)
}
newPassword.fill(0)
newPasswordConfirm.fill(0)
} 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)
}
.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 {

@ -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) {
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 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 = BUBBLE_HORIZONTAL_PADDING)
itemView.updatePadding(right = CONTAINER_PADDING)
} else {
itemView.updatePadding(left = BUBBLE_HORIZONTAL_PADDING)
itemView.updatePadding(left = CONTAINER_PADDING)
}
}
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 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")