master
Hardcore Sushi 2 years ago
parent 004277dc42
commit c18bb1cb60
Signed by: hardcoresushi
GPG Key ID: 007F84120107191E

@ -47,12 +47,11 @@ dependencies {
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.constraintlayout:constraintlayout:2.0.4'
//implementation 'androidx.legacy:legacy-support-v4:1.0.0'
//testImplementation 'junit:junit:4.13.1'
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.swiperefreshlayout:swiperefreshlayout:1.1.0"
implementation 'com.github.bumptech.glide:glide:4.12.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0'
}

@ -37,7 +37,9 @@
<data android:mimeType="*/*"/>
</intent-filter>
</activity>
<activity android:name=".LoginActivity">
<activity
android:name=".LoginActivity"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>

@ -5,22 +5,34 @@ import sushi.hardcore.aira.background_service.Contact
object AIRADatabase {
external fun isIdentityProtected(databaseFolder: String): Boolean
external fun loadIdentity(databaseFolder: String, password: ByteArray?): Boolean
external fun addContact(name: String, publicKey: ByteArray): Contact?
external fun addContact(name: String, avatarUuid: String?, publicKey: ByteArray): Contact?
external fun removeContact(uuid: String): Boolean
external fun loadContacts(): ArrayList<Contact>?
external fun setVerified(uuid: String): Boolean
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 storeFile(contactUuid: String?, data: ByteArray): ByteArray?
external fun loadMsgs(uuid: String, offset: Int, count: Int): ArrayList<ChatItem>?
external fun loadFile(rawUuid: ByteArray): ByteArray?
external fun deleteConversation(contactUuid: String): Boolean
external fun clearTemporaryFiles(): Int
external fun clearCache()
external fun getIdentityPublicKey(): ByteArray
external fun getIdentityFingerprint(): String
external fun getUsePadding(): Boolean
external fun setUsePadding(usePadding: Boolean): Boolean
external fun storeAvatar(avatar: ByteArray): String?
external fun getAvatar(avatarUuid: String): ByteArray?
external fun changeName(newName: String): Boolean
external fun changePassword(databaseFolder: String, oldPassword: ByteArray?, newPassword: ByteArray?): Boolean
external fun setIdentityAvatar(databaseFolder: String, avatar: ByteArray): Boolean
external fun removeIdentityAvatar(databaseFolder: String): Boolean
external fun getIdentityAvatar(databaseFolder: String): ByteArray?
fun loadAvatar(avatarUuid: String?): ByteArray? {
return avatarUuid?.let {
getAvatar(it)
}
}
}

@ -29,6 +29,7 @@ class ChatActivity : ServiceBoundActivity() {
private lateinit var binding: ActivityChatBinding
private var sessionId = -1
private lateinit var sessionName: String
private var avatar: ByteArray? = null
private lateinit var chatAdapter: ChatAdapter
private var lastLoadedMessageOffset = 0
private val filePicker = registerForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris ->
@ -48,7 +49,7 @@ class ChatActivity : ServiceBoundActivity() {
if (sessionId != -1) {
intent.getStringExtra("sessionName")?.let { name ->
sessionName = name
binding.toolbar.textAvatar.setLetterFrom(name)
binding.toolbar.avatar.setTextAvatar(name)
binding.toolbar.title.text = name
chatAdapter = ChatAdapter(this@ChatActivity, ::onClickSaveFile)
binding.recyclerChat.apply {
@ -94,7 +95,18 @@ class ChatActivity : ServiceBoundActivity() {
airaService = binder.getService()
chatAdapter.clear()
airaService.contacts[sessionId]?.let { contact ->
val contact = airaService.contacts[sessionId]
if (contact == null) {
airaService.savedAvatars[sessionId]
} else {
contact.avatar
}?.let {
AIRADatabase.loadAvatar(it)?.let { image ->
avatar = image
binding.toolbar.avatar.setImageAvatar(image)
}
}
if (contact != null) {
displayIconTrustLevel(true, contact.verified)
loadMsgs(contact.uuid)
}
@ -126,6 +138,7 @@ class ChatActivity : ServiceBoundActivity() {
val inputManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
inputManager.hideSoftInputFromWindow(binding.editMessage.windowToken, 0)
binding.bottomPanel.visibility = View.GONE
invalidateOptionsMenu()
}
}
}
@ -133,7 +146,19 @@ class ChatActivity : ServiceBoundActivity() {
if (this@ChatActivity.sessionId == sessionId) {
runOnUiThread {
sessionName = name
title = name
binding.toolbar.title.text = 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)
}
}
}
}
@ -151,7 +176,6 @@ class ChatActivity : ServiceBoundActivity() {
false
}
}
override fun onAskLargeFiles(sessionId: Int, name: String, filesReceiver: FilesReceiver): Boolean {
return if (this@ChatActivity.sessionId == sessionId) {
runOnUiThread {
@ -202,15 +226,17 @@ class ChatActivity : ServiceBoundActivity() {
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.chat_activity, menu)
val contact = airaService.contacts[sessionId]
val isOnline = airaService.isOnline(sessionId)
menu.findItem(R.id.delete_conversation).isVisible = contact != null
menu.findItem(R.id.set_as_contact).isVisible = contact == null
menu.findItem(R.id.set_as_contact).isVisible = contact == null && isOnline
menu.findItem(R.id.remove_contact).isVisible = contact != null
if (contact == null) {
menu.findItem(R.id.verify).isVisible = false
} else {
menu.findItem(R.id.verify).isVisible = !contact.verified
}
menu.findItem(R.id.refresh_name).isEnabled = airaService.isOnline(sessionId)
menu.findItem(R.id.refresh_profile).isEnabled = isOnline
menu.findItem(R.id.session_info).isVisible = isOnline || contact != null
return true
}
@ -232,7 +258,7 @@ class ChatActivity : ServiceBoundActivity() {
true
}
R.id.remove_contact -> {
AlertDialog.Builder(this)
AlertDialog.Builder(this, R.style.CustomAlertDialog)
.setTitle(R.string.warning)
.setMessage(R.string.ask_remove_contact)
.setPositiveButton(R.string.delete) { _, _ ->
@ -252,7 +278,7 @@ class ChatActivity : ServiceBoundActivity() {
val dialogBinding = DialogFingerprintsBinding.inflate(layoutInflater)
dialogBinding.textLocalFingerprint.text = localFingerprint
dialogBinding.textPeerFingerprint.text = peerFingerprint
AlertDialog.Builder(this)
AlertDialog.Builder(this, R.style.CustomAlertDialog)
.setTitle(R.string.verifying_contact)
.setView(dialogBinding.root)
.setPositiveButton(R.string.they_match) { _, _ ->
@ -267,7 +293,7 @@ class ChatActivity : ServiceBoundActivity() {
true
}
R.id.delete_conversation -> {
AlertDialog.Builder(this)
AlertDialog.Builder(this, R.style.CustomAlertDialog)
.setTitle(R.string.warning)
.setMessage(R.string.ask_delete_conversation)
.setPositiveButton(R.string.delete) { _, _ ->
@ -279,8 +305,8 @@ class ChatActivity : ServiceBoundActivity() {
.show()
true
}
R.id.refresh_name -> {
airaService.sendTo(sessionId, Protocol.askName())
R.id.refresh_profile -> {
airaService.sendTo(sessionId, Protocol.askProfileInfo())
true
}
else -> super.onOptionsItemSelected(item)
@ -315,35 +341,40 @@ class ChatActivity : ServiceBoundActivity() {
private fun showSessionInfo() {
val contact = airaService.contacts[sessionId]
val session = airaService.sessions[sessionId]
val publicKey = contact?.publicKey ?: session?.peerPublicKey
val dialogBinding = DialogInfoBinding.inflate(layoutInflater)
dialogBinding.textAvatar.setLetterFrom(sessionName)
dialogBinding.textFingerprint.text = StringUtils.beautifyFingerprint(generateFingerprint(publicKey!!))
if (session == null) {
dialogBinding.onlineFields.visibility = View.GONE
} else {
dialogBinding.textIp.text = session.ip
dialogBinding.textOutgoing.text = getString(if (session.outgoing) {
R.string.outgoing
(contact?.publicKey ?: session?.peerPublicKey)?.let { publicKey -> //can be null if disconnected and not a contact
val dialogBinding = DialogInfoBinding.inflate(layoutInflater)
if (avatar == null) {
dialogBinding.avatar.setTextAvatar(sessionName)
} else {
R.string.incoming
})
}
dialogBinding.textIsContact.text = getString(if (contact == null) {
dialogBinding.fieldIsVerified.visibility = View.GONE
R.string.no
} else {
dialogBinding.textIsVerified.text = getString(if (contact.verified) {
R.string.yes
dialogBinding.avatar.setImageAvatar(avatar!!)
}
dialogBinding.textFingerprint.text = StringUtils.beautifyFingerprint(generateFingerprint(publicKey))
if (session == null) {
dialogBinding.onlineFields.visibility = View.GONE
} else {
dialogBinding.textIp.text = session.ip
dialogBinding.textOutgoing.text = getString(if (session.outgoing) {
R.string.outgoing
} else {
R.string.incoming
})
}
dialogBinding.textIsContact.text = getString(if (contact == null) {
dialogBinding.fieldIsVerified.visibility = View.GONE
R.string.no
} else {
dialogBinding.textIsVerified.text = getString(if (contact.verified) {
R.string.yes
} else {
R.string.no
})
R.string.yes
})
R.string.yes
})
AlertDialog.Builder(this)
.setTitle(sessionName)
.setView(dialogBinding.root)
.setPositiveButton(R.string.ok, null)
.show()
AlertDialog.Builder(this, R.style.CustomAlertDialog)
.setTitle(sessionName)
.setView(dialogBinding.root)
.setPositiveButton(R.string.ok, null)
.show()
}
}
}

@ -10,6 +10,7 @@ object Constants {
const val fileSizeLimit = 32760000
const val MSG_LOADING_COUNT = 20
const val FILE_CHUNK_SIZE = 1023996
const val MAX_AVATAR_SIZE = 10000000
private const val databaseName = "AIRA.db"
fun getDatabaseFolder(context: Context): String {

@ -1,5 +1,6 @@
package sushi.hardcore.aira
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.fragment.app.Fragment
@ -7,25 +8,41 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import sushi.hardcore.aira.databinding.FragmentCreateIdentityBinding
import sushi.hardcore.aira.utils.AvatarPicker
class CreateIdentityFragment : Fragment() {
class CreateIdentityFragment(private val activity: AppCompatActivity) : Fragment() {
private external fun createNewIdentity(databaseFolder: String, name: String, password: ByteArray?): Boolean
companion object {
fun newInstance(): CreateIdentityFragment {
return CreateIdentityFragment()
fun newInstance(activity: AppCompatActivity): CreateIdentityFragment {
return CreateIdentityFragment(activity)
}
}
private val avatarPicker = AvatarPicker(activity) { picker, avatar ->
picker.setOnAvatarCompressed { compressedAvatar ->
AIRADatabase.setIdentityAvatar(Constants.getDatabaseFolder(activity), compressedAvatar)
}
avatar.circleCrop().into(binding.avatar)
}
private lateinit var binding: FragmentCreateIdentityBinding
override fun onAttach(context: Context) {
super.onAttach(context)
avatarPicker.register()
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
binding = FragmentCreateIdentityBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.buttonSetAvatar.setOnClickListener {
avatarPicker.launch()
}
binding.checkboxEnablePassword.setOnCheckedChangeListener { _, isChecked ->
if (isChecked) {
binding.editPassword.visibility = View.VISIBLE
@ -63,7 +80,7 @@ class CreateIdentityFragment : Fragment() {
val intent = Intent(activity, MainActivity::class.java)
intent.putExtra("identityName", identityName)
startActivity(intent)
activity?.finish()
activity.finish()
} else {
Toast.makeText(activity, R.string.identity_create_failed, Toast.LENGTH_SHORT).show()
}

@ -11,8 +11,10 @@ class LoginActivity : AppCompatActivity() {
private external fun getIdentityName(databaseFolder: String): String?
companion object {
private external fun initLogging()
init {
System.loadLibrary("aira")
initLogging()
}
}
@ -33,7 +35,7 @@ class LoginActivity : AppCompatActivity() {
finish()
} else if (name != null && !isProtected) {
if (AIRADatabase.loadIdentity(databaseFolder, null)) {
AIRADatabase.clearTemporaryFiles()
AIRADatabase.clearCache()
startMainActivity(name)
} else {
Toast.makeText(this, R.string.identity_load_failed, Toast.LENGTH_SHORT).show()
@ -42,7 +44,8 @@ class LoginActivity : AppCompatActivity() {
supportFragmentManager.beginTransaction()
.add(
R.id.fragment_container, if (name == null) {
CreateIdentityFragment.newInstance()
AIRADatabase.removeIdentityAvatar(databaseFolder)
CreateIdentityFragment.newInstance(this)
} else {
LoginFragment.newInstance(name)
}

@ -5,11 +5,9 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import android.widget.Toast
import androidx.fragment.app.Fragment
import sushi.hardcore.aira.databinding.FragmentLoginBinding
import sushi.hardcore.aira.widgets.TextAvatar
class LoginFragment : Fragment() {
companion object {
@ -31,11 +29,17 @@ class LoginFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
arguments?.let { bundle ->
bundle.getString(NAME_ARG)?.let { name ->
view.findViewById<TextAvatar>(R.id.text_avatar).setLetterFrom(name)
view.findViewById<TextView>(R.id.text_identity_name).text = name
val databaseFolder = Constants.getDatabaseFolder(requireContext())
val avatar = AIRADatabase.getIdentityAvatar(databaseFolder)
if (avatar == null) {
binding.avatar.setTextAvatar(name)
} else {
binding.avatar.setImageAvatar(avatar)
}
binding.textIdentityName.text = name
binding.buttonLogin.setOnClickListener {
if (AIRADatabase.loadIdentity(Constants.getDatabaseFolder(requireContext()), binding.editPassword.text.toString().toByteArray())) {
AIRADatabase.clearTemporaryFiles()
if (AIRADatabase.loadIdentity(databaseFolder, binding.editPassword.text.toString().toByteArray())) {
AIRADatabase.clearCache()
val intent = Intent(activity, MainActivity::class.java)
intent.putExtra("identityName", name)
startActivity(intent)

@ -58,6 +58,12 @@ 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 {
runOnUiThread {
onlineSessionAdapter.setSeen(sessionId, false)
@ -165,7 +171,7 @@ class MainActivity : ServiceBoundActivity() {
}
val dialogBinding = DialogIpAddressesBinding.inflate(layoutInflater)
dialogBinding.textIpAddresses.text = ipAddresses.substring(0, ipAddresses.length-1) //remove last LF
AlertDialog.Builder(this)
AlertDialog.Builder(this, R.style.CustomAlertDialog)
.setTitle(R.string.your_addresses)
.setView(dialogBinding.root)
.setPositiveButton(R.string.ok, null)
@ -194,7 +200,7 @@ class MainActivity : ServiceBoundActivity() {
}
R.id.close -> {
if (isServiceInitialized()) {
AlertDialog.Builder(this)
AlertDialog.Builder(this, R.style.CustomAlertDialog)
.setTitle(R.string.warning)
.setMessage(R.string.ask_log_out)
.setPositiveButton(R.string.yes) { _, _ ->
@ -210,7 +216,7 @@ class MainActivity : ServiceBoundActivity() {
true
}
R.id.remove_contact -> {
AlertDialog.Builder(this)
AlertDialog.Builder(this, R.style.CustomAlertDialog)
.setTitle(R.string.warning)
.setMessage(R.string.ask_remove_contacts)
.setPositiveButton(R.string.delete) { _, _ ->
@ -253,7 +259,12 @@ class MainActivity : ServiceBoundActivity() {
}
private fun initToolbar(identityName: String) {
binding.toolbar.textAvatar.setLetterFrom(identityName)
val avatar = AIRADatabase.getIdentityAvatar(Constants.getDatabaseFolder(this))
if (avatar == null) {
binding.toolbar.avatar.setTextAvatar(identityName)
} else {
binding.toolbar.avatar.setImageAvatar(avatar)
}
binding.toolbar.title.text = identityName
}
@ -286,7 +297,7 @@ class MainActivity : ServiceBoundActivity() {
private fun loadContacts() {
if (offlineSessionAdapter != null) {
for ((sessionId, contact) in airaService.contacts) {
offlineSessionAdapter!!.add(Session(sessionId, true, contact.verified, contact.seen, null, contact.name))
offlineSessionAdapter!!.add(Session(sessionId, true, contact.verified, contact.seen, null, contact.name, AIRADatabase.loadAvatar(contact.avatar)))
}
}
}
@ -301,9 +312,9 @@ class MainActivity : ServiceBoundActivity() {
val seen = !airaService.notSeen.contains(sessionId)
val contact = airaService.contacts[sessionId]
if (contact == null) {
onlineSessionAdapter.add(Session(sessionId, false, false, seen, ip, airaService.savedNames[sessionId]))
onlineSessionAdapter.add(Session(sessionId, false, false, seen, ip, airaService.savedNames[sessionId], AIRADatabase.loadAvatar(airaService.savedAvatars[sessionId])))
} else {
onlineSessionAdapter.add(Session(sessionId, true, contact.verified, seen, ip, contact.name))
onlineSessionAdapter.add(Session(sessionId, true, contact.verified, seen, ip, contact.name, AIRADatabase.loadAvatar(contact.avatar)))
offlineSessionAdapter?.remove(sessionId)
}
}
@ -311,7 +322,7 @@ class MainActivity : ServiceBoundActivity() {
private fun launchChatActivity(session: Session) {
startActivity(Intent(this, ChatActivity::class.java).apply {
putExtra("sessionId", session.sessionId)
putExtra("sessionName", session.name)
putExtra("sessionName", airaService.getNameOf(session.sessionId))
})
}
@ -338,7 +349,7 @@ class MainActivity : ServiceBoundActivity() {
} else {
getString(R.string.ask_send_multiple_files, uris!!.size, session.name ?: session.ip)
}
AlertDialog.Builder(this)
AlertDialog.Builder(this, R.style.CustomAlertDialog)
.setTitle(R.string.warning)
.setMessage(msg)
.setPositiveButton(R.string.yes) { _, _ ->

@ -1,6 +1,7 @@
package sushi.hardcore.aira
import android.content.*
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.os.IBinder
import android.view.MenuItem
@ -13,21 +14,47 @@ import androidx.preference.EditTextPreference
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.SwitchPreferenceCompat
import com.bumptech.glide.Glide
import com.bumptech.glide.RequestBuilder
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import sushi.hardcore.aira.background_service.AIRAService
import sushi.hardcore.aira.databinding.ActivitySettingsBinding
import sushi.hardcore.aira.databinding.ChangeAvatarDialogBinding
import sushi.hardcore.aira.utils.AvatarPicker
import sushi.hardcore.aira.utils.StringUtils
class SettingsActivity: AppCompatActivity() {
class MySettingsFragment : PreferenceFragmentCompat() {
class MySettingsFragment(private val activity: AppCompatActivity): PreferenceFragmentCompat() {
private lateinit var airaService: AIRAService
private val avatarPicker = AvatarPicker(activity) { picker, avatar ->
if (::airaService.isInitialized) {
picker.setOnAvatarCompressed { compressedAvatar ->
airaService.changeAvatar(compressedAvatar)
}
}
displayAvatar(avatar)
}
private lateinit var identityAvatarPreference: Preference
override fun onAttach(context: Context) {
super.onAttach(context)
avatarPicker.register()
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.preferences, rootKey)
findPreference<Preference>("identityAvatar")?.let { identityAvatarPreference = it }
val identityNamePreference = findPreference<EditTextPreference>("identityName")
val paddingPreference = findPreference<SwitchPreferenceCompat>("psecPadding")
identityNamePreference?.isPersistent = false
paddingPreference?.isPersistent = false
AIRADatabase.getIdentityAvatar(Constants.getDatabaseFolder(activity))?.let { avatar ->
displayAvatar(avatar)
}
Intent(activity, AIRAService::class.java).also { serviceIntent ->
activity?.bindService(serviceIntent, object : ServiceConnection {
activity.bindService(serviceIntent, object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder) {
val binder = service as AIRAService.AIRABinder
airaService = binder.getService()
@ -37,6 +64,26 @@ class SettingsActivity: AppCompatActivity() {
override fun onServiceDisconnected(name: ComponentName?) {}
}, Context.BIND_AUTO_CREATE)
}
identityAvatarPreference.setOnPreferenceClickListener {
val dialogBuilder = AlertDialog.Builder(activity, R.style.CustomAlertDialog)
.setTitle(R.string.your_avatar)
.setPositiveButton(R.string.set_a_new_one) { _, _ ->
avatarPicker.launch()
}
val dialogBinding = ChangeAvatarDialogBinding.inflate(layoutInflater)
val avatar = AIRADatabase.getIdentityAvatar(Constants.getDatabaseFolder(activity))
if (avatar == null) {
dialogBinding.avatar.setTextAvatar(airaService.identityName!!)
} else {
dialogBinding.avatar.setImageAvatar(avatar)
dialogBuilder.setNegativeButton(R.string.remove) { _, _ ->
displayAvatar(null)
airaService.changeAvatar(null)
}
}
dialogBuilder.setView(dialogBinding.root).show()
false
}
identityNamePreference?.setOnPreferenceChangeListener { _, newValue ->
if (airaService.changeName(newValue as String)) {
identityNamePreference.text = newValue
@ -44,70 +91,66 @@ class SettingsActivity: AppCompatActivity() {
false
}
findPreference<Preference>("deleteIdentity")?.setOnPreferenceClickListener {
activity?.let { activity ->
AlertDialog.Builder(activity)
.setMessage(R.string.confirm_delete)
.setTitle(R.string.warning)
.setPositiveButton(R.string.ok) { _, _ ->
if (Constants.getDatabasePath(activity).delete()) {
airaService.logOut()
startActivity(Intent(activity, LoginActivity::class.java))
activity.finish()
}
AlertDialog.Builder(activity, R.style.CustomAlertDialog)
.setMessage(R.string.confirm_delete)
.setTitle(R.string.warning)
.setPositiveButton(R.string.ok) { _, _ ->
if (Constants.getDatabasePath(activity).delete()) {
airaService.logOut()
startActivity(Intent(activity, LoginActivity::class.java))
activity.finish()
}
.setNegativeButton(R.string.cancel, null)
.show()
}
}
.setNegativeButton(R.string.cancel, null)
.show()
false
}
findPreference<Preference>("identityPassword")?.setOnPreferenceClickListener {
activity?.let { activity ->
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))
if (!isIdentityProtected) {
oldPasswordEditText.visibility = View.GONE
}
val newPasswordEditText = dialogView.findViewById<EditText>(R.id.new_password)
val newPasswordConfirmEditText = dialogView.findViewById<EditText>(R.id.new_password_confirm)
AlertDialog.Builder(activity)
.setView(dialogView)
.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(activity, isIdentityProtected, oldPasswordEditText, null)
}
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))
if (!isIdentityProtected) {
oldPasswordEditText.visibility = View.GONE
}
val newPasswordEditText = dialogView.findViewById<EditText>(R.id.new_password)
val newPasswordConfirmEditText = dialogView.findViewById<EditText>(R.id.new_password_confirm)
AlertDialog.Builder(activity, R.style.CustomAlertDialog)
.setView(dialogView)
.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)
} else {
val newPasswordConfirm = newPasswordConfirmEditText.text.toString().toByteArray()
if (newPassword.contentEquals(newPasswordConfirm)) {
changePassword(activity, isIdentityProtected, oldPasswordEditText, newPassword)
} else {
AlertDialog.Builder(activity)
.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()
}
}
.setNegativeButton(R.string.cancel, null)
.show()
false
}
findPreference<Preference>("fingerprint")?.let { fingerprintPreference ->
val fingerprint = StringUtils.beautifyFingerprint(AIRADatabase.getIdentityFingerprint())
fingerprintPreference.summary = fingerprint
fingerprintPreference.setOnPreferenceClickListener {
activity?.getSystemService(CLIPBOARD_SERVICE)?.let { service ->
activity.getSystemService(CLIPBOARD_SERVICE)?.let { service ->
val clipboardManager = service as ClipboardManager
clipboardManager.setPrimaryClip(ClipData.newPlainText("", fingerprint))
}
Toast.makeText(activity, R.string.fingerprint_copied, Toast.LENGTH_SHORT).show()
Toast.makeText(activity, R.string.copied, Toast.LENGTH_SHORT).show()
false
}
}
@ -118,14 +161,31 @@ class SettingsActivity: AppCompatActivity() {
}
}
private fun changePassword(context: Context, isIdentityProtected: Boolean, oldPasswordEditText: EditText, newPassword: ByteArray?) {
private fun displayAvatar(avatar: ByteArray?) {
if (avatar == null) {
identityAvatarPreference.setIcon(R.drawable.ic_face)
} else {
displayAvatar(Glide.with(this).load(avatar))
}
}
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(context), oldPassword, newPassword)) {
AlertDialog.Builder(context)
if (!AIRADatabase.changePassword(Constants.getDatabaseFolder(activity), oldPassword, newPassword)) {
AlertDialog.Builder(activity, R.style.CustomAlertDialog)
.setMessage(R.string.change_password_failed)
.setTitle(R.string.error)
.setPositiveButton(R.string.ok, null)
@ -150,7 +210,7 @@ class SettingsActivity: AppCompatActivity() {
setContentView(binding.root)
supportFragmentManager
.beginTransaction()
.replace(R.id.settings_container, MySettingsFragment())
.replace(R.id.settings_container, MySettingsFragment(this))
.commit()
supportActionBar?.setDisplayHomeAsUpEnabled(true)
}

@ -6,5 +6,6 @@ class Session(
val isVerified: Boolean,
var seen: Boolean,
val ip: String?,
var name: String?
var name: String?,
var avatar: ByteArray?,
)

@ -1,18 +1,18 @@
package sushi.hardcore.aira.adapters
import android.content.Context
import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import sushi.hardcore.aira.R
import sushi.hardcore.aira.widgets.TextAvatar
import sushi.hardcore.aira.widgets.Avatar
class SessionAdapter(private val context: Context): BaseAdapter() {
class SessionAdapter(private val activity: AppCompatActivity): BaseAdapter() {
private val sessions = mutableListOf<Session>()
private val inflater: LayoutInflater = LayoutInflater.from(context)
private val inflater: LayoutInflater = LayoutInflater.from(activity)
val selectedItems = mutableListOf<Int>()
override fun getCount(): Int {
@ -31,7 +31,7 @@ class SessionAdapter(private val context: Context): BaseAdapter() {
val view: View = convertView ?: inflater.inflate(R.layout.adapter_session, parent, false)
val currentSession = getItem(position)
view.findViewById<TextView>(R.id.text_name).apply {
view.findViewById<TextAvatar>(R.id.text_avatar).setLetterFrom(if (currentSession.name == null) {
val avatarName = if (currentSession.name == null) {
text = currentSession.ip
setTextColor(Color.RED)
"?"
@ -39,7 +39,13 @@ class SessionAdapter(private val context: Context): BaseAdapter() {
text = currentSession.name
setTextColor(Color.WHITE)
currentSession.name!!
})
}
val avatar = view.findViewById<Avatar>(R.id.avatar)
if (currentSession.avatar == null) {
avatar.setTextAvatar(avatarName)
} else {
avatar.setImageAvatar(currentSession.avatar!!)
}
}
view.findViewById<ImageView>(R.id.image_trust_level).apply {
if (currentSession.isVerified) {
@ -55,12 +61,12 @@ class SessionAdapter(private val context: Context): BaseAdapter() {
} else {
View.VISIBLE
}
view.findViewById<ImageView>(R.id.image_arrow).setColorFilter(ContextCompat.getColor(context, if (currentSession.seen) {
view.findViewById<ImageView>(R.id.image_arrow).setColorFilter(ContextCompat.getColor(activity, if (currentSession.seen) {
R.color.sessionArrow
} else {
R.color.secondary
}))
view.setBackgroundColor(ContextCompat.getColor(context, if (selectedItems.contains(position)) {
view.setBackgroundColor(ContextCompat.getColor(activity, if (selectedItems.contains(position)) {
R.color.itemSelected
} else {
R.color.sessionBackground
@ -98,6 +104,13 @@ class SessionAdapter(private val context: Context): BaseAdapter() {
}
}
fun setAvatar(sessionId: Int, avatar: ByteArray?) {
getSessionById(sessionId)?.let {
it.avatar = avatar
notifyDataSetChanged()
}
}
fun setSeen(sessionId: Int, seen: Boolean) {
getSessionById(sessionId)?.let {
it.seen = seen

@ -32,8 +32,9 @@ class AIRAService : Service() {
const val MESSAGE_CONNECT_TO = 0
const val MESSAGE_SEND_TO = 1
const val MESSAGE_LOGOUT = 2
const val MESSAGE_TELL_NAME = 3
const val MESSAGE_CANCEL_FILE_TRANSFER = 4
const val MESSAGE_SEND_NAME = 3
const val MESSAGE_SEND_AVATAR = 4
const val MESSAGE_CANCEL_FILE_TRANSFER = 5
var isServiceRunning = false
}
@ -87,6 +88,7 @@ class AIRAService : Service() {
}
val savedMsgs = mutableMapOf<Int, MutableList<ChatItem>>()
val savedNames = mutableMapOf<Int, String>()
val savedAvatars = mutableMapOf<Int, String>()
val notSeen = mutableListOf<Int>()
var uiCallbacks: UiCallbacks? = null
var isAppInBackground = true
@ -104,6 +106,7 @@ class AIRAService : Service() {
fun onNewSession(sessionId: Int, ip: String)
fun onSessionDisconnect(sessionId: Int)
fun onNameTold(sessionId: Int, name: String)
fun onAvatarChanged(sessionId: Int, avatar: ByteArray?)
fun onNewMessage(sessionId: Int, data: ByteArray): Boolean
fun onAskLargeFiles(sessionId: Int, name: String, filesReceiver: FilesReceiver): Boolean
}
@ -185,7 +188,7 @@ class AIRAService : Service() {
return sessions.contains(sessionId)
}
private fun getNameOf(sessionId: Int): String {
fun getNameOf(sessionId: Int): String {
return contacts[sessionId]?.name ?: savedNames[sessionId] ?: sessions[sessionId]!!.ip
}
@ -195,7 +198,7 @@ class AIRAService : Service() {
fun setAsContact(sessionId: Int, name: String): Boolean {
sessions[sessionId]?.peerPublicKey?.let {
AIRADatabase.addContact(name, it)?.let { contact ->
AIRADatabase.addContact(name, savedAvatars[sessionId], it)?.let { contact ->
contacts[sessionId] = contact
savedMsgs.remove(sessionId)?.let { msgs ->
for (msg in msgs) {
@ -203,6 +206,7 @@ class AIRAService : Service() {
}
}
savedNames.remove(sessionId)
savedAvatars.remove(sessionId)
return true
}
}
@ -240,6 +244,9 @@ class AIRAService : Service() {
return if (AIRADatabase.removeContact(it.uuid)) {
savedMsgs[sessionId] = mutableListOf()
savedNames[sessionId] = it.name
it.avatar?.let { avatarUuid ->
savedAvatars[sessionId] = avatarUuid
}
true
} else {
false
@ -263,7 +270,26 @@ class AIRAService : Service() {
fun changeName(newName: String): Boolean {
return if (AIRADatabase.changeName(newName)) {
identityName = newName
serviceHandler.sendEmptyMessage(MESSAGE_TELL_NAME)
serviceHandler.sendEmptyMessage(MESSAGE_SEND_NAME)
true
} else {
false
}
}
fun changeAvatar(avatar: ByteArray?): Boolean {
val databaseFolder = Constants.getDatabaseFolder(applicationContext)
val success = if (avatar == null) {
AIRADatabase.removeIdentityAvatar(databaseFolder)
} else {
AIRADatabase.setIdentityAvatar(databaseFolder, avatar)
}
return if (success) {
serviceHandler.obtainMessage().apply {
what = MESSAGE_SEND_AVATAR
data = Bundle().apply { putByteArray("avatar", avatar) }
serviceHandler.sendMessage(this)
}
true
} else {
false
@ -301,7 +327,7 @@ class AIRAService : Service() {
sessionIdByKey[key] = sessionId
uiCallbacks?.onNewSession(sessionId, session.ip)
if (!isContact(sessionId)) {
session.encryptAndSend(Protocol.askName(), usePadding)
session.encryptAndSend(Protocol.askProfileInfo(), usePadding)
}
} else {
session.close()
@ -449,17 +475,33 @@ class AIRAService : Service() {
cancelFileTransfer(sessionId, session, true)
}
}
MESSAGE_TELL_NAME -> {
MESSAGE_SEND_NAME -> {
identityName?.let {
val tellingName = Protocol.name(it)
for (session in sessions.values) {
try {
session.encryptAndSend(Protocol.tellName(it), usePadding)
session.encryptAndSend(tellingName, usePadding)
} catch (e: SocketException) {
e.printStackTrace()
}
}
}
}
MESSAGE_SEND_AVATAR -> {
val avatar = msg.data.getByteArray("avatar")
val avatarMsg = if (avatar == null) {
Protocol.removeAvatar()
} else {
Protocol.avatar(avatar)
}
for (session in sessions.values) {
try {
session.encryptAndSend(avatarMsg, usePadding)
} catch (e: SocketException) {
e.printStackTrace()
}
}
}
MESSAGE_LOGOUT -> {
nsdManager.unregisterService(nsdRegistrationListener)
stopDiscovery()
@ -494,6 +536,20 @@ class AIRAService : Service() {
usePadding = AIRADatabase.getUsePadding()
}
private fun setAvatarUuid(sessionId: Int, avatarUuid: String?) {
val contact = contacts[sessionId]
if (contact == null) {
if (avatarUuid == null) {
savedAvatars.remove(sessionId)
} else {
savedAvatars[sessionId] = avatarUuid
}
} else {
AIRADatabase.setContactAvatar(contact.uuid, avatarUuid)
contact.avatar = avatarUuid
}
}
private fun encryptNextChunk(session: Session, filesSender: FilesSender) {
val nextChunk = ByteArray(Constants.FILE_CHUNK_SIZE)
nextChunk[0] = Protocol.LARGE_FILE_CHUNK
@ -566,22 +622,6 @@ class AIRAService : Service() {
shouldCloseSession = true
} else {
when (buffer[0]) {
Protocol.ASK_NAME -> {
identityName?.let { name ->
session.encryptAndSend(Protocol.tellName(name), usePadding)
}
}
Protocol.TELL_NAME -> {
val name = StringUtils.sanitizeName(buffer.sliceArray(1 until buffer.size).decodeToString())
uiCallbacks?.onNameTold(sessionId, name)
val contact = contacts[sessionId]
if (contact == null) {
savedNames[sessionId] = name
} else {
contact.name = name
AIRADatabase.changeContactName(contact.uuid, name)
}
}
Protocol.LARGE_FILE_CHUNK -> {
receiveFileTransfers[sessionId]?.let { filesReceiver ->
val file = filesReceiver.files[filesReceiver.index]
@ -714,6 +754,38 @@ 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.avatar(avatar), usePadding)
}
}
Protocol.NAME -> {
val name = StringUtils.sanitizeName(buffer.sliceArray(1 until buffer.size).decodeToString())
uiCallbacks?.onNameTold(sessionId, name)
val contact = contacts[sessionId]
if (contact == null) {
savedNames[sessionId] = name
} else {
contact.name = name
AIRADatabase.changeContactName(contact.uuid, name)
}
}
Protocol.AVATAR -> {
if (buffer.size < Constants.MAX_AVATAR_SIZE) {
val avatar = buffer.sliceArray(1 until buffer.size)
uiCallbacks?.onAvatarChanged(sessionId, avatar)
AIRADatabase.storeAvatar(avatar)?.let { avatarUuid ->
setAvatarUuid(sessionId, avatarUuid)
}
}
}
Protocol.REMOVE_AVATAR -> {
uiCallbacks?.onAvatarChanged(sessionId, null)
setAvatarUuid(sessionId, null)
}
else -> {
when (buffer[0]){
Protocol.MESSAGE -> buffer

@ -1,3 +1,10 @@
package sushi.hardcore.aira.background_service
class Contact(val uuid: String, val publicKey: ByteArray, var name: String, var verified: Boolean, var seen: Boolean)
class Contact(
val uuid: String,
val publicKey: ByteArray,
var name: String,
var avatar: String?,
var verified: Boolean,
var seen: Boolean
)

@ -28,7 +28,7 @@ class FilesReceiver(
filesInfo.appendLine(file.fileName+" ("+FileUtils.formatSize(file.fileSize)+')')
}
dialogBinding.textFilesInfo.text = filesInfo.substring(0, filesInfo.length-1)
AlertDialog.Builder(activity)
AlertDialog.Builder(activity, R.style.CustomAlertDialog)
.setTitle(R.string.download_file_request)
.setView(dialogBinding.root)
.setCancelable(false)

@ -1,25 +1,36 @@
package sushi.hardcore.aira.background_service
import sushi.hardcore.aira.widgets.Avatar
import java.nio.ByteBuffer
class Protocol {
companion object {
const val MESSAGE: Byte = 0x00
const val ASK_NAME: Byte = 0x01
const val TELL_NAME: Byte = 0x02
const val FILE: Byte = 0x03
const val ASK_LARGE_FILES: Byte = 0x04
const val ACCEPT_LARGE_FILES: Byte = 0x05
const val LARGE_FILE_CHUNK: Byte = 0x06
const val ACK_CHUNK: Byte = 0x07
const val ABORT_FILES_TRANSFER: Byte = 0x08
const val FILE: Byte = 0x01
const val ASK_PROFILE_INFO: Byte = 0x02
const val NAME: Byte = 0x03
const val AVATAR: Byte = 0x04
const val REMOVE_AVATAR: Byte = 0x05
const val ASK_LARGE_FILES: Byte = 0x06
const val ACCEPT_LARGE_FILES: Byte = 0x07
const val LARGE_FILE_CHUNK: Byte = 0x08
const val ACK_CHUNK: Byte = 0x09
const val ABORT_FILES_TRANSFER: Byte = 0x0a
fun askName(): ByteArray {
return byteArrayOf(ASK_NAME)
fun askProfileInfo(): ByteArray {
return byteArrayOf(ASK_PROFILE_INFO)
}
fun tellName(name: String): ByteArray {
return byteArrayOf(TELL_NAME)+name.toByteArray()
fun name(name: String): ByteArray {
return byteArrayOf(NAME)+name.toByteArray()
}
fun avatar(avatar: ByteArray): ByteArray {
return byteArrayOf(AVATAR)+avatar
}
fun removeAvatar(): ByteArray {
return byteArrayOf(REMOVE_AVATAR)
}
fun newMessage(msg: String): ByteArray {

@ -0,0 +1,56 @@
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 lateinit var picker: ActivityResultLauncher<String>
private lateinit var avatar: RequestBuilder<Drawable>
fun register() {
picker = activity.registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri != null) {
activity.contentResolver.openInputStream(uri)?.let { stream ->
val image = stream.readBytes()
stream.close()
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)
}
}
}
}
}
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/*")
}
}

@ -0,0 +1,49 @@
package sushi.hardcore.aira.widgets
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.RelativeLayout
import com.bumptech.glide.Glide
import sushi.hardcore.aira.R
import sushi.hardcore.aira.databinding.AvatarBinding
class Avatar @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0
) : RelativeLayout(context, attrs, defStyle) {
private val binding = AvatarBinding.inflate(LayoutInflater.from(context), this, true)
init {
attrs?.let {
val typedArray = context.obtainStyledAttributes(it, R.styleable.Avatar)
for (i in 0..typedArray.indexCount) {
val attr = typedArray.getIndex(i)
if (attr == R.styleable.Avatar_textSize) {
val textSize = typedArray.getDimension(attr, -1F)
if (textSize != -1F) {
binding.textLetter.textSize = textSize
}
}
}
typedArray.recycle()
}
}
fun setTextAvatar(name: String) {
if (name.isNotEmpty()) {
binding.textLetter.text = name[0].toString()
binding.imageAvatar.visibility = View.GONE
binding.textAvatar.visibility = View.VISIBLE
}
}
fun setImageAvatar(avatar: ByteArray) {
Glide.with(this).load(avatar).circleCrop().into(<