From c18bb1cb6007ec7b0a78818cf82258aaa4b03ddc Mon Sep 17 00:00:00 2001 From: Hardcore Sushi Date: Thu, 27 May 2021 20:15:03 +0200 Subject: [PATCH] Avatars --- app/build.gradle | 5 +- app/src/main/AndroidManifest.xml | 4 +- .../java/sushi/hardcore/aira/AIRADatabase.kt | 16 +- .../java/sushi/hardcore/aira/ChatActivity.kt | 105 ++++--- .../java/sushi/hardcore/aira/Constants.kt | 1 + .../hardcore/aira/CreateIdentityFragment.kt | 25 +- .../java/sushi/hardcore/aira/LoginActivity.kt | 7 +- .../java/sushi/hardcore/aira/LoginFragment.kt | 16 +- .../java/sushi/hardcore/aira/MainActivity.kt | 29 +- .../sushi/hardcore/aira/SettingsActivity.kt | 172 +++++++---- .../sushi/hardcore/aira/adapters/Session.kt | 3 +- .../hardcore/aira/adapters/SessionAdapter.kt | 29 +- .../aira/background_service/AIRAService.kt | 120 ++++++-- .../aira/background_service/Contact.kt | 9 +- .../aira/background_service/FilesReceiver.kt | 2 +- .../aira/background_service/Protocol.kt | 35 ++- .../sushi/hardcore/aira/utils/AvatarPicker.kt | 56 ++++ .../sushi/hardcore/aira/widgets/Avatar.kt | 49 +++ .../sushi/hardcore/aira/widgets/TextAvatar.kt | 40 --- app/src/main/native/src/identity.rs | 281 ++++++++++-------- app/src/main/native/src/key_value_table.rs | 12 +- app/src/main/native/src/lib.rs | 180 ++++++----- app/src/main/native/src/utils.rs | 4 +- app/src/main/res/drawable/ic_face.xml | 5 + app/src/main/res/layout/activity_chat.xml | 10 +- app/src/main/res/layout/activity_login.xml | 3 +- app/src/main/res/layout/activity_main.xml | 2 + app/src/main/res/layout/adapter_chat_file.xml | 3 +- app/src/main/res/layout/adapter_session.xml | 8 +- app/src/main/res/layout/avatar.xml | 29 ++ .../main/res/layout/change_avatar_dialog.xml | 14 + app/src/main/res/layout/dialog_info.xml | 4 +- app/src/main/res/layout/dialog_password.xml | 9 +- .../res/layout/fragment_create_identity.xml | 103 ++++--- app/src/main/res/layout/fragment_login.xml | 82 +++-- app/src/main/res/layout/profile_toolbar.xml | 4 +- app/src/main/res/layout/text_avatar.xml | 13 - app/src/main/res/menu/chat_activity.xml | 4 +- app/src/main/res/values/attrs.xml | 2 +- app/src/main/res/values/dimens.xml | 1 + app/src/main/res/values/strings.xml | 18 +- app/src/main/res/values/styles.xml | 4 + app/src/main/res/xml/preferences.xml | 6 + build.gradle | 6 +- 44 files changed, 977 insertions(+), 553 deletions(-) create mode 100644 app/src/main/java/sushi/hardcore/aira/utils/AvatarPicker.kt create mode 100644 app/src/main/java/sushi/hardcore/aira/widgets/Avatar.kt delete mode 100644 app/src/main/java/sushi/hardcore/aira/widgets/TextAvatar.kt create mode 100644 app/src/main/res/drawable/ic_face.xml create mode 100644 app/src/main/res/layout/avatar.xml create mode 100644 app/src/main/res/layout/change_avatar_dialog.xml delete mode 100644 app/src/main/res/layout/text_avatar.xml diff --git a/app/build.gradle b/app/build.gradle index 17b5c4e..5c99947 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d89d63d..2b0effd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -37,7 +37,9 @@ - + diff --git a/app/src/main/java/sushi/hardcore/aira/AIRADatabase.kt b/app/src/main/java/sushi/hardcore/aira/AIRADatabase.kt index 2e0e813..1f98086 100644 --- a/app/src/main/java/sushi/hardcore/aira/AIRADatabase.kt +++ b/app/src/main/java/sushi/hardcore/aira/AIRADatabase.kt @@ -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? 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? 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) + } + } } \ No newline at end of file diff --git a/app/src/main/java/sushi/hardcore/aira/ChatActivity.kt b/app/src/main/java/sushi/hardcore/aira/ChatActivity.kt index 1d2b793..d3e96f1 100644 --- a/app/src/main/java/sushi/hardcore/aira/ChatActivity.kt +++ b/app/src/main/java/sushi/hardcore/aira/ChatActivity.kt @@ -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() + } } } \ No newline at end of file diff --git a/app/src/main/java/sushi/hardcore/aira/Constants.kt b/app/src/main/java/sushi/hardcore/aira/Constants.kt index 782402b..6119690 100644 --- a/app/src/main/java/sushi/hardcore/aira/Constants.kt +++ b/app/src/main/java/sushi/hardcore/aira/Constants.kt @@ -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 { diff --git a/app/src/main/java/sushi/hardcore/aira/CreateIdentityFragment.kt b/app/src/main/java/sushi/hardcore/aira/CreateIdentityFragment.kt index 1d3f463..fba464f 100644 --- a/app/src/main/java/sushi/hardcore/aira/CreateIdentityFragment.kt +++ b/app/src/main/java/sushi/hardcore/aira/CreateIdentityFragment.kt @@ -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() } diff --git a/app/src/main/java/sushi/hardcore/aira/LoginActivity.kt b/app/src/main/java/sushi/hardcore/aira/LoginActivity.kt index 5009ae5..a9d0b7b 100644 --- a/app/src/main/java/sushi/hardcore/aira/LoginActivity.kt +++ b/app/src/main/java/sushi/hardcore/aira/LoginActivity.kt @@ -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) } diff --git a/app/src/main/java/sushi/hardcore/aira/LoginFragment.kt b/app/src/main/java/sushi/hardcore/aira/LoginFragment.kt index dccc35d..46a4fa8 100644 --- a/app/src/main/java/sushi/hardcore/aira/LoginFragment.kt +++ b/app/src/main/java/sushi/hardcore/aira/LoginFragment.kt @@ -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(R.id.text_avatar).setLetterFrom(name) - view.findViewById(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) diff --git a/app/src/main/java/sushi/hardcore/aira/MainActivity.kt b/app/src/main/java/sushi/hardcore/aira/MainActivity.kt index 7cde35b..26c4397 100644 --- a/app/src/main/java/sushi/hardcore/aira/MainActivity.kt +++ b/app/src/main/java/sushi/hardcore/aira/MainActivity.kt @@ -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) { _, _ -> diff --git a/app/src/main/java/sushi/hardcore/aira/SettingsActivity.kt b/app/src/main/java/sushi/hardcore/aira/SettingsActivity.kt index 06d7d57..2da237d 100644 --- a/app/src/main/java/sushi/hardcore/aira/SettingsActivity.kt +++ b/app/src/main/java/sushi/hardcore/aira/SettingsActivity.kt @@ -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("identityAvatar")?.let { identityAvatarPreference = it } val identityNamePreference = findPreference("identityName") val paddingPreference = findPreference("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("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("identityPassword")?.setOnPreferenceClickListener { - activity?.let { activity -> - val dialogView = layoutInflater.inflate(R.layout.dialog_password, null) - val oldPasswordEditText = dialogView.findViewById(R.id.old_password) - val isIdentityProtected = AIRADatabase.isIdentityProtected(Constants.getDatabaseFolder(activity)) - if (!isIdentityProtected) { - oldPasswordEditText.visibility = View.GONE - } - val newPasswordEditText = dialogView.findViewById(R.id.new_password) - val newPasswordConfirmEditText = dialogView.findViewById(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) - } - } 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) - } - } - .setNegativeButton(R.string.cancel, null) - .show() + val dialogView = layoutInflater.inflate(R.layout.dialog_password, null) + val oldPasswordEditText = dialogView.findViewById(R.id.old_password) + val isIdentityProtected = AIRADatabase.isIdentityProtected(Constants.getDatabaseFolder(activity)) + if (!isIdentityProtected) { + oldPasswordEditText.visibility = View.GONE } + val newPasswordEditText = dialogView.findViewById(R.id.new_password) + val newPasswordConfirmEditText = dialogView.findViewById(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 { + 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() false } findPreference("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) { + glideBuilder.apply(RequestOptions().override(90)).circleCrop().into(object : CustomTarget() { + override fun onResourceReady(resource: Drawable, transition: Transition?) { + 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) } diff --git a/app/src/main/java/sushi/hardcore/aira/adapters/Session.kt b/app/src/main/java/sushi/hardcore/aira/adapters/Session.kt index 5f915ed..9a9e91d 100644 --- a/app/src/main/java/sushi/hardcore/aira/adapters/Session.kt +++ b/app/src/main/java/sushi/hardcore/aira/adapters/Session.kt @@ -6,5 +6,6 @@ class Session( val isVerified: Boolean, var seen: Boolean, val ip: String?, - var name: String? + var name: String?, + var avatar: ByteArray?, ) \ No newline at end of file diff --git a/app/src/main/java/sushi/hardcore/aira/adapters/SessionAdapter.kt b/app/src/main/java/sushi/hardcore/aira/adapters/SessionAdapter.kt index 2039da0..c0744eb 100644 --- a/app/src/main/java/sushi/hardcore/aira/adapters/SessionAdapter.kt +++ b/app/src/main/java/sushi/hardcore/aira/adapters/SessionAdapter.kt @@ -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() - private val inflater: LayoutInflater = LayoutInflater.from(context) + private val inflater: LayoutInflater = LayoutInflater.from(activity) val selectedItems = mutableListOf() 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(R.id.text_name).apply { - view.findViewById(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(R.id.avatar) + if (currentSession.avatar == null) { + avatar.setTextAvatar(avatarName) + } else { + avatar.setImageAvatar(currentSession.avatar!!) + } } view.findViewById(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(R.id.image_arrow).setColorFilter(ContextCompat.getColor(context, if (currentSession.seen) { + view.findViewById(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 diff --git a/app/src/main/java/sushi/hardcore/aira/background_service/AIRAService.kt b/app/src/main/java/sushi/hardcore/aira/background_service/AIRAService.kt index 95ea6e1..8f02046 100644 --- a/app/src/main/java/sushi/hardcore/aira/background_service/AIRAService.kt +++ b/app/src/main/java/sushi/hardcore/aira/background_service/AIRAService.kt @@ -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>() val savedNames = mutableMapOf() + val savedAvatars = mutableMapOf() val notSeen = mutableListOf() 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 diff --git a/app/src/main/java/sushi/hardcore/aira/background_service/Contact.kt b/app/src/main/java/sushi/hardcore/aira/background_service/Contact.kt index d27f37b..a718833 100644 --- a/app/src/main/java/sushi/hardcore/aira/background_service/Contact.kt +++ b/app/src/main/java/sushi/hardcore/aira/background_service/Contact.kt @@ -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) \ No newline at end of file +class Contact( + val uuid: String, + val publicKey: ByteArray, + var name: String, + var avatar: String?, + var verified: Boolean, + var seen: Boolean +) \ No newline at end of file diff --git a/app/src/main/java/sushi/hardcore/aira/background_service/FilesReceiver.kt b/app/src/main/java/sushi/hardcore/aira/background_service/FilesReceiver.kt index 05a5f8a..6d5beac 100644 --- a/app/src/main/java/sushi/hardcore/aira/background_service/FilesReceiver.kt +++ b/app/src/main/java/sushi/hardcore/aira/background_service/FilesReceiver.kt @@ -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) diff --git a/app/src/main/java/sushi/hardcore/aira/background_service/Protocol.kt b/app/src/main/java/sushi/hardcore/aira/background_service/Protocol.kt index 06a23fd..c0d97bb 100644 --- a/app/src/main/java/sushi/hardcore/aira/background_service/Protocol.kt +++ b/app/src/main/java/sushi/hardcore/aira/background_service/Protocol.kt @@ -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 { diff --git a/app/src/main/java/sushi/hardcore/aira/utils/AvatarPicker.kt b/app/src/main/java/sushi/hardcore/aira/utils/AvatarPicker.kt new file mode 100644 index 0000000..ce786b6 --- /dev/null +++ b/app/src/main/java/sushi/hardcore/aira/utils/AvatarPicker.kt @@ -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) -> Unit, +) { + private lateinit var picker: ActivityResultLauncher + private lateinit var avatar: RequestBuilder + 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() { + override fun onResourceReady(resource: Drawable, transition: Transition?) { + 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/*") + } +} \ No newline at end of file diff --git a/app/src/main/java/sushi/hardcore/aira/widgets/Avatar.kt b/app/src/main/java/sushi/hardcore/aira/widgets/Avatar.kt new file mode 100644 index 0000000..6305a40 --- /dev/null +++ b/app/src/main/java/sushi/hardcore/aira/widgets/Avatar.kt @@ -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(binding.imageAvatar) + binding.textAvatar.visibility = View.GONE + binding.imageAvatar.visibility = View.VISIBLE + } +} \ No newline at end of file diff --git a/app/src/main/java/sushi/hardcore/aira/widgets/TextAvatar.kt b/app/src/main/java/sushi/hardcore/aira/widgets/TextAvatar.kt deleted file mode 100644 index 9898876..0000000 --- a/app/src/main/java/sushi/hardcore/aira/widgets/TextAvatar.kt +++ /dev/null @@ -1,40 +0,0 @@ -package sushi.hardcore.aira.widgets - -import android.content.Context -import android.util.AttributeSet -import android.view.LayoutInflater -import android.widget.RelativeLayout -import android.widget.TextView -import sushi.hardcore.aira.R - -class TextAvatar @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyle: Int = 0 -) : RelativeLayout(context, attrs, defStyle) { - - private val view = LayoutInflater.from(context).inflate(R.layout.text_avatar, this, true) - - init { - attrs?.let { - val typedArray = context.obtainStyledAttributes(it, R.styleable.TextAvatar) - for (i in 0..typedArray.indexCount) { - val attr = typedArray.getIndex(i) - if (attr == R.styleable.TextAvatar_textSize) { - val textSize = typedArray.getDimension(attr, -1F) - if (textSize != -1F) { - view.findViewById(R.id.text_letter).textSize = textSize - } - break - } - } - typedArray.recycle() - } - } - - fun setLetterFrom(name: String) { - if (name.isNotEmpty()) { - view.findViewById(R.id.text_letter).text = name[0].toUpperCase().toString() - } - } -} \ No newline at end of file diff --git a/app/src/main/native/src/identity.rs b/app/src/main/native/src/identity.rs index 7fde962..fd4c610 100644 --- a/app/src/main/native/src/identity.rs +++ b/app/src/main/native/src/identity.rs @@ -11,6 +11,7 @@ const DB_NAME: &str = "AIRA.db"; const MAIN_TABLE: &str = "main"; const CONTACTS_TABLE: &str = "contacts"; const FILES_TABLE: &str = "files"; +const AVATARS_TABLE: &str = "avatars"; const DATABASE_CORRUPED_ERROR: &str = "Database corrupted"; @@ -21,6 +22,7 @@ impl<'a> DBKeys { pub const SALT: &'a str = "salt"; pub const MASTER_KEY: &'a str = "master_key"; pub const USE_PADDING: &'a str = "use_padding"; + pub const AVATAR: &'a str = "avatar"; } fn bool_to_byte(b: bool) -> u8 { @@ -53,6 +55,7 @@ pub struct Contact { pub uuid: Uuid, pub public_key: [u8; PUBLIC_KEY_LENGTH], pub name: String, + pub avatar: Option, pub verified: bool, pub seen: bool, } @@ -79,19 +82,23 @@ impl Identity { get_database_path(&self.database_folder) } - pub fn add_contact(&self, name: String, public_key: [u8; PUBLIC_KEY_LENGTH]) -> Result { + pub fn add_contact(&self, name: String, avatar_uuid: Option, public_key: [u8; PUBLIC_KEY_LENGTH]) -> Result { let db = Connection::open(self.get_database_path())?; - db.execute(&("CREATE TABLE IF NOT EXISTS ".to_owned()+CONTACTS_TABLE+"(uuid BLOB PRIMARY KEY, name BLOB, key BLOB, verified BLOB, seen BLOB)"), [])?; + db.execute(&("CREATE TABLE IF NOT EXISTS ".to_owned()+CONTACTS_TABLE+"(uuid BLOB PRIMARY KEY, name BLOB, avatar BLOB, key BLOB, verified BLOB, seen BLOB)"), [])?; let contact_uuid = Uuid::new_v4(); let encrypted_name = crypto::encrypt_data(name.as_bytes(), &self.master_key).unwrap(); let encrypted_public_key = crypto::encrypt_data(&public_key, &self.master_key).unwrap(); let encrypted_verified = crypto::encrypt_data(&[bool_to_byte(false)], &self.master_key).unwrap(); let encrypted_seen = crypto::encrypt_data(&[bool_to_byte(true)], &self.master_key).unwrap(); - db.execute(&("INSERT INTO ".to_owned()+CONTACTS_TABLE+" (uuid, name, key, verified, seen) VALUES (?1, ?2, ?3, ?4, ?5)"), params![&contact_uuid.as_bytes()[..], encrypted_name, encrypted_public_key, encrypted_verified, encrypted_seen])?; + match avatar_uuid { + Some(avatar_uuid) => db.execute(&format!("INSERT INTO {} (uuid, name, avatar, key, verified, seen) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", CONTACTS_TABLE), params![&contact_uuid.as_bytes()[..], encrypted_name, &avatar_uuid.as_bytes()[..], encrypted_public_key, encrypted_verified, encrypted_seen])?, + None => db.execute(&format!("INSERT INTO {} (uuid, name, key, verified, seen) VALUES (?1, ?2, ?3, ?4, ?5)", CONTACTS_TABLE), params![&contact_uuid.as_bytes()[..], encrypted_name, encrypted_public_key, encrypted_verified, encrypted_seen])? + }; Ok(Contact { uuid: contact_uuid, public_key: public_key, name: name, + avatar: avatar_uuid, verified: false, seen: true, }) @@ -115,6 +122,17 @@ impl Identity { db.execute(&format!("UPDATE {} SET name=?1 WHERE uuid=?2", CONTACTS_TABLE), [encrypted_name.as_slice(), uuid.as_bytes()]) } + pub fn set_contact_avatar(&self, contact_uuid: &Uuid, avatar_uuid: Option<&Uuid>) -> Result { + let db = Connection::open(self.get_database_path())?; + match avatar_uuid { + Some(avatar_uuid) => db.execute(&format!("UPDATE {} SET avatar=?1 WHERE uuid=?2", CONTACTS_TABLE), params![&avatar_uuid.as_bytes()[..], &contact_uuid.as_bytes()[..]]), + None => { + db.execute(&format!("DELETE FROM {} WHERE uuid=(SELECT avatar FROM {} WHERE uuid=?)", AVATARS_TABLE, CONTACTS_TABLE), params![&contact_uuid.as_bytes()[..]])?; + db.execute(&format!("UPDATE {} SET avatar=NULL WHERE uuid=?", CONTACTS_TABLE), params![&contact_uuid.as_bytes()[..]]) + } + } + } + pub fn set_contact_seen(&self, uuid: &Uuid, seen: bool) -> Result { let db = Connection::open(self.get_database_path())?; let encrypted_seen = crypto::encrypt_data(&[bool_to_byte(seen)], &self.master_key).unwrap(); @@ -124,107 +142,99 @@ impl Identity { pub fn load_contacts(&self) -> Option> { match Connection::open(self.get_database_path()) { Ok(db) => { - match db.prepare(&("SELECT uuid, name, key, verified, seen FROM ".to_owned()+CONTACTS_TABLE)) { - Ok(mut stmt) => { - let mut rows = stmt.query([]).unwrap(); - let mut contacts = Vec::new(); - while let Some(row) = rows.next().unwrap() { - let encrypted_public_key: Vec = row.get(2).unwrap(); - match crypto::decrypt_data(encrypted_public_key.as_slice(), &self.master_key) { - Ok(public_key) => { - if public_key.len() == PUBLIC_KEY_LENGTH { - let encrypted_name: Vec = row.get(1).unwrap(); - match crypto::decrypt_data(encrypted_name.as_slice(), &self.master_key) { - Ok(name) => { - let encrypted_verified: Vec = row.get(3).unwrap(); - match crypto::decrypt_data(encrypted_verified.as_slice(), &self.master_key) { - Ok(verified) => { - let encrypted_seen: Vec = row.get(4).unwrap(); - match crypto::decrypt_data(encrypted_seen.as_slice(), &self.master_key) { - Ok(seen) => { - let uuid: Vec = row.get(0).unwrap(); - match to_uuid_bytes(&uuid) { - Some(uuid_bytes) => { - contacts.push(Contact { - uuid: Uuid::from_bytes(uuid_bytes), - public_key: public_key.try_into().unwrap(), - name: std::str::from_utf8(name.as_slice()).unwrap().to_owned(), - verified: byte_to_bool(verified[0]).unwrap(), - seen: byte_to_bool(seen[0]).unwrap(), - }) - } - None => {} - } - } - Err(e) => print_error!(e) - } + if let Ok(mut stmt) = db.prepare(&("SELECT uuid, name, avatar, key, verified, seen FROM ".to_owned()+CONTACTS_TABLE)) { + let mut rows = stmt.query([]).unwrap(); + let mut contacts = Vec::new(); + while let Ok(Some(row)) = rows.next() { + let encrypted_public_key: Vec = row.get(3).unwrap(); + match crypto::decrypt_data(encrypted_public_key.as_slice(), &self.master_key) { + Ok(public_key) => { + let encrypted_name: Vec = row.get(1).unwrap(); + match crypto::decrypt_data(encrypted_name.as_slice(), &self.master_key) { + Ok(name) => { + let encrypted_verified: Vec = row.get(4).unwrap(); + match crypto::decrypt_data(encrypted_verified.as_slice(), &self.master_key) { + Ok(verified) => { + let encrypted_seen: Vec = row.get(5).unwrap(); + match crypto::decrypt_data(encrypted_seen.as_slice(), &self.master_key) { + Ok(seen) => { + let contact_uuid: Vec = row.get(0).unwrap(); + let avatar_result: Result, rusqlite::Error> = row.get(2); + let avatar = match avatar_result { + Ok(avatar_uuid) => Some(Uuid::from_bytes(to_uuid_bytes(&avatar_uuid).unwrap())), + Err(_) => None + }; + contacts.push(Contact { + uuid: Uuid::from_bytes(to_uuid_bytes(&contact_uuid).unwrap()), + public_key: public_key.try_into().unwrap(), + name: std::str::from_utf8(name.as_slice()).unwrap().to_owned(), + avatar, + verified: byte_to_bool(verified[0]).unwrap(), + seen: byte_to_bool(seen[0]).unwrap(), + }) } Err(e) => print_error!(e) } } Err(e) => print_error!(e) } - } else { - print_error!("Invalid public key length: database corrupted"); } + Err(e) => print_error!(e) } - Err(e) => print_error!(e) } + Err(e) => print_error!(e) } - Some(contacts) } - Err(e) => { - print_error!(e); - None - } - } - } - Err(e) => { - print_error!(e); - None + return Some(contacts); + } } + Err(e) => print_error!(e) } + None } - pub fn clear_temporary_files(&self) -> Result { + pub fn clear_cache(&self) -> Result<(), rusqlite::Error> { let db = Connection::open(self.get_database_path())?; - db.execute(&format!("DELETE FROM {} WHERE contact_uuid IS NULL", FILES_TABLE), []) + let mut stmt = db.prepare(&format!("SELECT name FROM sqlite_master WHERE type='table' AND name='{}'", CONTACTS_TABLE))?; + let mut rows = stmt.query([])?; + let contact_table_exists = rows.next()?.is_some(); + if contact_table_exists { + #[allow(unused_must_use)] + { + db.execute(&format!("DELETE FROM {} WHERE contact_uuid IS NULL", FILES_TABLE), []); + db.execute(&format!("DELETE FROM {} WHERE uuid NOT IN (SELECT avatar FROM {})", AVATARS_TABLE, CONTACTS_TABLE), []); + } + } else { + db.execute(&format!("DROP TABLE IF EXISTS {}", FILES_TABLE), [])?; + db.execute(&format!("DROP TABLE IF EXISTS {}", AVATARS_TABLE), [])?; + } + Ok(()) } pub fn load_file(&self, uuid: Uuid) -> Option> { match Connection::open(self.get_database_path()) { Ok(db) => { - match db.prepare(&format!("SELECT uuid, data FROM \"{}\"", FILES_TABLE)) { - Ok(mut stmt) => { - let mut rows = stmt.query([]).unwrap(); - while let Some(row) = rows.next().unwrap() { - let encrypted_uuid: Vec = row.get(0).unwrap(); - match crypto::decrypt_data(encrypted_uuid.as_slice(), &self.master_key){ - Ok(test_uuid) => { - if test_uuid == uuid.as_bytes() { - let encrypted_data: Vec = row.get(1).unwrap(); - match crypto::decrypt_data(encrypted_data.as_slice(), &self.master_key) { - Ok(data) => return Some(data), - Err(e) => print_error!(e) - } - } + let mut stmt = db.prepare(&format!("SELECT uuid, data FROM \"{}\"", FILES_TABLE)).unwrap(); + let mut rows = stmt.query([]).unwrap(); + while let Ok(Some(row)) = rows.next() { + let encrypted_uuid: Vec = row.get(0).unwrap(); + match crypto::decrypt_data(encrypted_uuid.as_slice(), &self.master_key){ + Ok(test_uuid) => { + if test_uuid == uuid.as_bytes() { + let encrypted_data: Vec = row.get(1).unwrap(); + match crypto::decrypt_data(encrypted_data.as_slice(), &self.master_key) { + Ok(data) => return Some(data), + Err(e) => print_error!(e) } - Err(e) => print_error!(e) } } - None - } - Err(e) => { - print_error!(e); - None + Err(e) => print_error!(e) } } } - Err(e) => { - print_error!(e); - None - } + Err(e) => print_error!(e) } + None } pub fn store_file(&self, contact_uuid: Option, data: &[u8]) -> Result { @@ -253,73 +263,44 @@ impl Identity { pub fn load_msgs(&self, contact_uuid: &Uuid, offset: usize, mut count: usize) -> Option)>> { match Connection::open(self.get_database_path()) { Ok(db) => { - match db.prepare(&format!("SELECT count(*) FROM \"{}\"", contact_uuid)) { - Ok(mut stmt) => { - let mut rows = stmt.query([]).unwrap(); - match rows.next() { - Ok(row) => if row.is_some() { - let total: usize = row.unwrap().get(0).unwrap(); - if offset >= total { - None - } else { - if offset+count >= total { - count = total-offset; - } - match db.prepare(&format!("SELECT outgoing, data FROM \"{}\" LIMIT {} OFFSET {}", contact_uuid, count, total-offset-count)) { - Ok(mut stmt) => { - let mut rows = stmt.query([]).unwrap(); - let mut msgs = Vec::new(); - while let Some(row) = rows.next().unwrap() { - let encrypted_outgoing: Vec = 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 = 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) - } - } - Err(_) => {} - } - - } + if let Ok(mut stmt) = db.prepare(&format!("SELECT count(*) FROM \"{}\"", contact_uuid)) { + let mut rows = stmt.query([]).unwrap(); + if let Ok(Some(row)) = rows.next() { + let total: usize = row.get(0).unwrap(); + if offset < total { + 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 rows = stmt.query([]).unwrap(); + let mut msgs = Vec::new(); + while let Ok(Some(row)) = rows.next() { + let encrypted_outgoing: Vec = 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 = 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) } } - Some(msgs) - } - Err(e) => { - print_error!(e); - None + Err(_) => {} } + } + Err(e) => print_error!(e) } - } else { - None } - Err(_) => None + return Some(msgs); } } - Err(e) => { - print_error!(e); - None - } } } - Err(e) => { - print_error!(e); - None - } + Err(e) => print_error!(e) } + None } #[allow(unused_must_use)] @@ -345,6 +326,29 @@ impl Identity { db.update(DBKeys::USE_PADDING, &encrypted_use_padding) } + pub fn store_avatar(&self, avatar: &[u8]) -> Result { + let db = Connection::open(self.get_database_path())?; + db.execute(&format!("CREATE TABLE IF NOT EXISTS \"{}\" (uuid BLOB PRIMARY KEY, data BLOB)", AVATARS_TABLE), [])?; + let uuid = Uuid::new_v4(); + let encrypted_avatar = crypto::encrypt_data(avatar, &self.master_key).unwrap(); + db.execute(&format!("INSERT INTO {} (uuid, data) VALUES (?1, ?2)", AVATARS_TABLE), params![&uuid.as_bytes()[..], encrypted_avatar])?; + Ok(uuid) + } + + pub fn get_avatar(&self, avatar_uuid: &Uuid) -> Option> { + let db = Connection::open(self.get_database_path()).ok()?; + let mut stmt = db.prepare(&format!("SELECT data FROM {} WHERE uuid=?", AVATARS_TABLE)).unwrap(); + let mut rows = stmt.query(params![&avatar_uuid.as_bytes()[..]]).unwrap(); + let encrypted_avatar: Vec = rows.next().ok()??.get(0).unwrap(); + match crypto::decrypt_data(&encrypted_avatar, &self.master_key) { + Ok(avatar) => Some(avatar), + Err(e) => { + print_error!(e); + None + } + } + } + pub fn zeroize(&mut self){ self.master_key.zeroize(); self.keypair.secret.zeroize(); @@ -490,4 +494,19 @@ impl Identity { Err(e) => Err(e.to_string()) } } + + pub fn set_identity_avatar(database_folder: &str, avatar: &[u8]) -> Result { + let db = KeyValueTable::new(&get_database_path(database_folder), MAIN_TABLE)?; + db.upsert(DBKeys::AVATAR, avatar) + } + + pub fn remove_identity_avatar(database_folder: &str) -> Result { + let db = KeyValueTable::new(&get_database_path(database_folder), MAIN_TABLE)?; + db.del(DBKeys::AVATAR) + } + + pub fn get_identity_avatar(database_folder: &str) -> Result, rusqlite::Error> { + let db = KeyValueTable::new(&get_database_path(database_folder), MAIN_TABLE)?; + db.get(DBKeys::AVATAR) + } } diff --git a/app/src/main/native/src/key_value_table.rs b/app/src/main/native/src/key_value_table.rs index b0556bd..e478233 100644 --- a/app/src/main/native/src/key_value_table.rs +++ b/app/src/main/native/src/key_value_table.rs @@ -22,13 +22,13 @@ impl<'a> KeyValueTable<'a> { None => Err(rusqlite::Error::QueryReturnedNoRows) } } - /*pub fn del(&self, key: &str) -> Result { - self.db.execute(&format!("DELETE FROM {} WHERE key=\"{}\"", self.table_name, key), NO_PARAMS) - }*/ + pub fn del(&self, key: &str) -> Result { + self.db.execute(&format!("DELETE FROM {} WHERE key=\"{}\"", self.table_name, key), []) + } pub fn update(&self, key: &str, value: &[u8]) -> Result { self.db.execute(&format!("UPDATE {} SET value=? WHERE key=\"{}\"", self.table_name, key), params![value]) } - /*pub fn upsert(&self, key: &str, value: &[u8]) -> Result { + pub fn upsert(&self, key: &str, value: &[u8]) -> Result { self.db.execute(&format!("INSERT INTO {} (key, value) VALUES(?1, ?2) ON CONFLICT(key) DO UPDATE SET value=?3", self.table_name), params![key, value, value]) - }*/ -} + } +} \ No newline at end of file diff --git a/app/src/main/native/src/lib.rs b/app/src/main/native/src/lib.rs index e319373..aa5f1b4 100644 --- a/app/src/main/native/src/lib.rs +++ b/app/src/main/native/src/lib.rs @@ -3,10 +3,9 @@ mod identity; mod crypto; mod utils; -use std::{convert::TryInto, str::FromStr, fmt::Display, sync::{Mutex}}; +use std::{convert::TryInto, fmt::Display, str::FromStr, sync::{Mutex}}; use lazy_static::lazy_static; use uuid::Uuid; -use log::*; use android_log; use identity::{Identity, Contact}; use crate::crypto::{HandshakeKeys, ApplicationKeys}; @@ -24,6 +23,13 @@ fn jstring_to_string(env: JNIEnv, input: JString) -> String { String::from(env.get_string(input).unwrap()) } +fn jstring_to_uuid(env: JNIEnv, input: JString) -> Option { + match env.get_string(input) { + Ok(uuid) => Some(Uuid::from_str(&String::from(uuid)).unwrap()), + Err(_) => None + } +} + fn jboolean_to_bool(input: jboolean) -> bool { input == 1 } @@ -32,14 +38,23 @@ fn bool_to_jboolean(input: bool) -> u8 { if input { 1 } else { 0 } } +fn result_to_jboolean(result: Result) -> jboolean { + match result { + Ok(_) => 1, + Err(e) => { + print_error!(e); + 0 + } + } +} + fn slice_to_jvalue<'a>(env: JNIEnv, input: &'a [u8]) -> JValue<'a> { JValue::Object(env.byte_array_from_slice(input).unwrap().into()) } -#[allow(unused_must_use)] -fn log_error(e: T) { - android_log::init("AIRA Native"); - error!("Error: {}", e) +#[no_mangle] +pub extern fn Java_sushi_hardcore_aira_LoginActivity_00024Companion_initLogging(_: JNIEnv, _: JClass) -> jboolean { + bool_to_jboolean(android_log::init("AIRA Native").is_ok()) } #[allow(non_snake_case)] @@ -53,7 +68,7 @@ pub extern fn Java_sushi_hardcore_aira_CreateIdentityFragment_createNewIdentity( 1 } Err(e) => { - log_error(e); + print_error!(e); 0 } } @@ -65,7 +80,7 @@ pub extern fn Java_sushi_hardcore_aira_LoginActivity_getIdentityName(env: JNIEnv *match Identity::get_identity_name(&jstring_to_string(env, database_folder)) { Ok(name) => *env.new_string(name).unwrap(), Err(e) => { - log_error(e); + print_error!(e); JObject::null() } } @@ -76,7 +91,7 @@ pub extern fn Java_sushi_hardcore_aira_AIRADatabase_isIdentityProtected(env: JNI match Identity::is_protected(jstring_to_string(env, database_folder)) { Ok(is_protected) => bool_to_jboolean(is_protected), Err(e) => { - log_error(e); + print_error!(e); 0 } } @@ -91,7 +106,7 @@ pub extern fn Java_sushi_hardcore_aira_AIRADatabase_loadIdentity(env: JNIEnv, _: 1 } Err(e) => { - log_error(e); + print_error!(e); 0 } } @@ -104,12 +119,36 @@ pub fn Java_sushi_hardcore_aira_AIRADatabase_changePassword(env: JNIEnv, _: JCla match Identity::change_password(database_folder, env.convert_byte_array(old_password).ok().as_deref(), env.convert_byte_array(new_password).ok().as_deref()) { Ok(success) => bool_to_jboolean(success), Err(e) => { - log_error(e); + print_error!(e); 0 } } } +#[allow(non_snake_case)] +#[no_mangle] +pub fn Java_sushi_hardcore_aira_AIRADatabase_setIdentityAvatar(env: JNIEnv, _: JClass, database_folder: JString, avatar: jbyteArray) -> jboolean { + result_to_jboolean(Identity::set_identity_avatar(&jstring_to_string(env, database_folder), &env.convert_byte_array(avatar).unwrap())) +} + +#[allow(non_snake_case)] +#[no_mangle] +pub fn Java_sushi_hardcore_aira_AIRADatabase_removeIdentityAvatar(env: JNIEnv, _: JClass, database_folder: JString) -> jboolean { + result_to_jboolean(Identity::remove_identity_avatar(&jstring_to_string(env, database_folder))) +} + +#[allow(non_snake_case)] +#[no_mangle] +pub fn Java_sushi_hardcore_aira_AIRADatabase_getIdentityAvatar(env: JNIEnv, _: JClass, database_folder: JString) -> jbyteArray { + match Identity::get_identity_avatar(&jstring_to_string(env, database_folder)) { + Ok(avatar) => env.byte_array_from_slice(&avatar).unwrap(), + Err(e) => { + print_error!(e); + *JObject::null() + } + } +} + #[allow(non_snake_case)] #[no_mangle] pub fn Java_sushi_hardcore_aira_AIRADatabase_getIdentityPublicKey(env: JNIEnv, _: JClass) -> jbyteArray { @@ -172,16 +211,27 @@ pub fn Java_sushi_hardcore_aira_background_1service_AIRAService_releaseIdentity( fn new_contact(env: JNIEnv, contact: Contact) -> JObject { let contact_class = env.find_class("sushi/hardcore/aira/background_service/Contact").unwrap(); - env.new_object(contact_class, "(Ljava/lang/String;[BLjava/lang/String;ZZ)V", &[JValue::Object(*env.new_string(contact.uuid.to_string()).unwrap()), slice_to_jvalue(env, &contact.public_key), JValue::Object(*env.new_string(contact.name).unwrap()), JValue::Bool(bool_to_jboolean(contact.verified)), JValue::Bool(bool_to_jboolean(contact.seen))]).unwrap() + let avatar_uuid = match contact.avatar { + Some(uuid) => JValue::Object(*env.new_string(uuid.to_string()).unwrap()), + None => JValue::Object(JObject::null()) + }; + env.new_object(contact_class, "(Ljava/lang/String;[BLjava/lang/String;Ljava/lang/String;ZZ)V", &[ + JValue::Object(*env.new_string(contact.uuid.to_string()).unwrap()), + slice_to_jvalue(env, &contact.public_key), + JValue::Object(*env.new_string(contact.name).unwrap()), + avatar_uuid, + JValue::Bool(bool_to_jboolean(contact.verified)), + JValue::Bool(bool_to_jboolean(contact.seen)) + ]).unwrap() } #[allow(non_snake_case)] #[no_mangle] -pub fn Java_sushi_hardcore_aira_AIRADatabase_addContact(env: JNIEnv, _: JClass, name: JString, public_key: jbyteArray) -> jobject { - *match loaded_identity.lock().unwrap().as_ref().unwrap().add_contact(jstring_to_string(env, name), env.convert_byte_array(public_key).unwrap().try_into().unwrap()) { +pub fn Java_sushi_hardcore_aira_AIRADatabase_addContact(env: JNIEnv, _: JClass, name: JString, avatarUuid: JString, public_key: jbyteArray) -> jobject { + *match loaded_identity.lock().unwrap().as_ref().unwrap().add_contact(jstring_to_string(env, name), jstring_to_uuid(env, avatarUuid), env.convert_byte_array(public_key).unwrap().try_into().unwrap()) { Ok(contact) => new_contact(env, contact), Err(e) => { - log_error(e); + print_error!(e); JObject::null() } } @@ -190,13 +240,7 @@ pub fn Java_sushi_hardcore_aira_AIRADatabase_addContact(env: JNIEnv, _: JClass, #[allow(non_snake_case)] #[no_mangle] pub fn Java_sushi_hardcore_aira_AIRADatabase_removeContact(env: JNIEnv, _: JClass, uuid: JString) -> jboolean { - match loaded_identity.lock().unwrap().as_ref().unwrap().remove_contact(&Uuid::from_str(&jstring_to_string(env, uuid)).unwrap()) { - Ok(_) => 1, - Err(e) => { - log_error(e); - 0 - } - } + result_to_jboolean(loaded_identity.lock().unwrap().as_ref().unwrap().remove_contact(&Uuid::from_str(&jstring_to_string(env, uuid)).unwrap())) } #[allow(non_snake_case)] @@ -220,55 +264,37 @@ pub fn Java_sushi_hardcore_aira_AIRADatabase_loadContacts(env: JNIEnv, _: JClass #[allow(non_snake_case)] #[no_mangle] pub fn Java_sushi_hardcore_aira_AIRADatabase_setVerified(env: JNIEnv, _: JClass, uuid: JString) -> jboolean { - match loaded_identity.lock().unwrap().as_ref().unwrap().set_verified(&Uuid::from_str(&jstring_to_string(env, uuid)).unwrap()) { - Ok(_) => 1, - Err(e) => { - log_error(e); - 0 - } - } + result_to_jboolean(loaded_identity.lock().unwrap().as_ref().unwrap().set_verified(&jstring_to_uuid(env, uuid).unwrap())) } #[allow(non_snake_case)] #[no_mangle] pub fn Java_sushi_hardcore_aira_AIRADatabase_changeContactName(env: JNIEnv, _: JClass, contactUuid: JString, newName: JString) -> jboolean { - match loaded_identity.lock().unwrap().as_ref().unwrap().change_contact_name(&Uuid::from_str(&jstring_to_string(env, contactUuid)).unwrap(), &jstring_to_string(env, newName)) { - Ok(_) => 1, - Err(e) => { - log_error(e); - 0 - } - } + result_to_jboolean(loaded_identity.lock().unwrap().as_ref().unwrap().change_contact_name(&jstring_to_uuid(env, contactUuid).unwrap(), &jstring_to_string(env, newName))) +} + +#[allow(non_snake_case)] +#[no_mangle] +pub fn Java_sushi_hardcore_aira_AIRADatabase_setContactAvatar(env: JNIEnv, _: JClass, contactUuid: JString, avatarUuid: JString) -> jboolean { + result_to_jboolean(loaded_identity.lock().unwrap().as_ref().unwrap().set_contact_avatar(&jstring_to_uuid(env, contactUuid).unwrap(), jstring_to_uuid(env, avatarUuid).as_ref())) } #[allow(non_snake_case)] #[no_mangle] pub fn Java_sushi_hardcore_aira_AIRADatabase_setContactSeen(env: JNIEnv, _: JClass, contactUuid: JString, seen: jboolean) -> jboolean { - match loaded_identity.lock().unwrap().as_ref().unwrap().set_contact_seen(&Uuid::from_str(&jstring_to_string(env, contactUuid)).unwrap(), jboolean_to_bool(seen)) { - Ok(_) => 1, - Err(e) => { - log_error(e); - 0 - } - } + result_to_jboolean(loaded_identity.lock().unwrap().as_ref().unwrap().set_contact_seen(&jstring_to_uuid(env, contactUuid).unwrap(), jboolean_to_bool(seen))) } #[allow(non_snake_case)] #[no_mangle] pub fn Java_sushi_hardcore_aira_AIRADatabase_storeMsg(env: JNIEnv, _: JClass, contactUuid: JString, outgoing: jboolean, data: jbyteArray) -> jboolean { - match loaded_identity.lock().unwrap().as_ref().unwrap().store_msg(&Uuid::from_str(&jstring_to_string(env, contactUuid)).unwrap(), jboolean_to_bool(outgoing), &env.convert_byte_array(data).unwrap()) { - Ok(_) => 1, - Err(e) => { - log_error(e); - 0 - } - } + 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())) } #[allow(non_snake_case)] #[no_mangle] pub fn Java_sushi_hardcore_aira_AIRADatabase_loadMsgs(env: JNIEnv, _: JClass, uuid: JString, offset: jint, count: jint) -> jobject { - *match loaded_identity.lock().unwrap().as_ref().unwrap().load_msgs(&Uuid::from_str(&jstring_to_string(env, uuid)).unwrap(), offset as usize, count as usize) { + *match loaded_identity.lock().unwrap().as_ref().unwrap().load_msgs(&jstring_to_uuid(env, uuid).unwrap(), offset as usize, count as usize) { Some(msgs) => { let array_list_class = env.find_class("java/util/ArrayList").unwrap(); let array_list = env.new_object(array_list_class, "(I)V", &[JValue::Int(msgs.len().try_into().unwrap())]).unwrap(); @@ -287,11 +313,7 @@ pub fn Java_sushi_hardcore_aira_AIRADatabase_loadMsgs(env: JNIEnv, _: JClass, uu #[allow(non_snake_case)] #[no_mangle] pub fn Java_sushi_hardcore_aira_AIRADatabase_storeFile(env: JNIEnv, _: JClass, contactUuid: JString, data: jbyteArray) -> jbyteArray { - let contact_uuid = match env.get_string(contactUuid) { - Ok(uuid) => Some(Uuid::from_str(&String::from(uuid)).unwrap()), - Err(_) => None - }; - match loaded_identity.lock().unwrap().as_ref().unwrap().store_file(contact_uuid, &env.convert_byte_array(data).unwrap()) { + match loaded_identity.lock().unwrap().as_ref().unwrap().store_file(jstring_to_uuid(env, contactUuid), &env.convert_byte_array(data).unwrap()) { Ok(uuid) => env.byte_array_from_slice(uuid.as_bytes()).unwrap(), Err(_) => *JObject::null() } @@ -309,25 +331,15 @@ pub fn Java_sushi_hardcore_aira_AIRADatabase_loadFile(env: JNIEnv, _: JClass, ra #[allow(non_snake_case)] #[no_mangle] pub fn Java_sushi_hardcore_aira_AIRADatabase_deleteConversation(env: JNIEnv, _: JClass, contactUuid: JString) -> jboolean { - let contact_uuid = Uuid::from_str(&String::from(env.get_string(contactUuid).unwrap())).unwrap(); - match loaded_identity.lock().unwrap().as_ref().unwrap().delete_conversation(&contact_uuid) { - Ok(_) => 1, - Err(e) => { - log_error(e); - 0 - } - } + let contact_uuid = jstring_to_uuid(env, contactUuid).unwrap(); + result_to_jboolean(loaded_identity.lock().unwrap().as_ref().unwrap().delete_conversation(&contact_uuid)) } #[allow(non_snake_case)] #[no_mangle] -pub fn Java_sushi_hardcore_aira_AIRADatabase_clearTemporaryFiles(_: JNIEnv, _: JClass) -> jint { - match loaded_identity.lock().unwrap().as_ref().unwrap().clear_temporary_files() { - Ok(r) => r.try_into().unwrap(), - Err(e) => { - log_error(e); - 0 - } +pub fn Java_sushi_hardcore_aira_AIRADatabase_clearCache(_: JNIEnv, _: JClass) { + if let Err(e) = loaded_identity.lock().unwrap().as_ref().unwrap().clear_cache() { + print_error!(e); } } @@ -338,7 +350,7 @@ pub fn Java_sushi_hardcore_aira_AIRADatabase_changeName(env: JNIEnv, _: JClass, match loaded_identity.lock().unwrap().as_mut().unwrap().change_name(new_name) { Ok(u) => bool_to_jboolean(u == 1), Err(e) => { - log_error(e); + print_error!(e); 0 } } @@ -353,12 +365,24 @@ pub fn Java_sushi_hardcore_aira_AIRADatabase_getUsePadding(_: JNIEnv, _: JClass) #[allow(non_snake_case)] #[no_mangle] pub fn Java_sushi_hardcore_aira_AIRADatabase_setUsePadding(_: JNIEnv, _: JClass, use_padding: jboolean) -> jboolean { - match loaded_identity.lock().unwrap().as_mut().unwrap().set_use_padding(jboolean_to_bool(use_padding)) { - Ok(_) => 1, - Err(e) => { - log_error(e); - 0 - } + result_to_jboolean(loaded_identity.lock().unwrap().as_mut().unwrap().set_use_padding(jboolean_to_bool(use_padding))) +} + +#[allow(non_snake_case)] +#[no_mangle] +pub fn Java_sushi_hardcore_aira_AIRADatabase_storeAvatar(env: JNIEnv, _: JClass, avatar: jbyteArray) -> jobject { + *match loaded_identity.lock().unwrap().as_ref().unwrap().store_avatar(&env.convert_byte_array(avatar).unwrap()) { + Ok(uuid) => *env.new_string(uuid.to_string()).unwrap(), + Err(_) => JObject::null() + } +} + +#[allow(non_snake_case)] +#[no_mangle] +pub fn Java_sushi_hardcore_aira_AIRADatabase_getAvatar(env: JNIEnv, _: JClass, avatarUuid: JString) -> jbyteArray { + match loaded_identity.lock().unwrap().as_ref().unwrap().get_avatar(&jstring_to_uuid(env, avatarUuid).unwrap()) { + Some(buffer) => env.byte_array_from_slice(&buffer).unwrap(), + None => *JObject::null() } } diff --git a/app/src/main/native/src/utils.rs b/app/src/main/native/src/utils.rs index c4074fa..2488ac5 100644 --- a/app/src/main/native/src/utils.rs +++ b/app/src/main/native/src/utils.rs @@ -15,9 +15,9 @@ pub fn to_uuid_bytes(bytes: &[u8]) -> Option { #[macro_export] macro_rules! print_error { ($arg:tt) => ({ - println!("[{}:{}] {}", file!(), line!(), $arg); + log::error!("[{}:{}] {}", file!(), line!(), $arg); }); ($($arg:tt)*) => ({ - println!("[{}:{}] {}", file!(), line!(), format_args!($($arg)*)); + log::error!("[{}:{}] {}", file!(), line!(), format_args!($($arg)*)); }) } diff --git a/app/src/main/res/drawable/ic_face.xml b/app/src/main/res/drawable/ic_face.xml new file mode 100644 index 0000000..ef34eab --- /dev/null +++ b/app/src/main/res/drawable/ic_face.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout/activity_chat.xml b/app/src/main/res/layout/activity_chat.xml index 94cb05f..c44c10b 100644 --- a/app/src/main/res/layout/activity_chat.xml +++ b/app/src/main/res/layout/activity_chat.xml @@ -30,16 +30,18 @@ android:layout_height="@dimen/image_button_size" android:src="@drawable/ic_attach_file" style="@style/ImageButton" + android:contentDescription="@string/send_file" app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintEnd_toStartOf="@id/image_trust_level" /> + app:layout_constraintEnd_toStartOf="@id/image_trust_level"/> + app:layout_constraintEnd_toEndOf="parent"/> diff --git a/app/src/main/res/layout/activity_login.xml b/app/src/main/res/layout/activity_login.xml index 1e8dce3..efcc6dc 100644 --- a/app/src/main/res/layout/activity_login.xml +++ b/app/src/main/res/layout/activity_login.xml @@ -8,6 +8,7 @@ + android:layout_height="match_parent" + android:layout_marginHorizontal="50dp"/> \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 89f039e..d848946 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -68,6 +68,7 @@ android:maxLines="1" android:imeOptions="actionGo" android:hint="@string/add_peer_ip" + android:autofillHints="ip" app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" @@ -79,6 +80,7 @@ android:layout_height="@dimen/image_button_size" android:src="@drawable/ic_info" style="@style/ImageButton" + android:contentDescription="@string/show_your_ips" app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toEndOf="@id/edit_peer_ip" diff --git a/app/src/main/res/layout/adapter_chat_file.xml b/app/src/main/res/layout/adapter_chat_file.xml index 1ffd92a..a95e6b3 100644 --- a/app/src/main/res/layout/adapter_chat_file.xml +++ b/app/src/main/res/layout/adapter_chat_file.xml @@ -19,7 +19,8 @@ android:layout_width="@dimen/image_button_size" android:layout_height="@dimen/image_button_size" android:src="@drawable/ic_save" - style="@style/ImageButton"/> + style="@style/ImageButton" + android:contentDescription="@string/download" /> - + app:layout_constraintStart_toEndOf="@id/avatar"/> @@ -53,6 +54,7 @@ android:layout_height="15dp" android:layout_marginEnd="10dp" android:src="@drawable/ic_arrow_forward" + android:contentDescription="@string/clickable_indicator" app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent"/> diff --git a/app/src/main/res/layout/avatar.xml b/app/src/main/res/layout/avatar.xml new file mode 100644 index 0000000..5d70d2b --- /dev/null +++ b/app/src/main/res/layout/avatar.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/change_avatar_dialog.xml b/app/src/main/res/layout/change_avatar_dialog.xml new file mode 100644 index 0000000..fb51d2e --- /dev/null +++ b/app/src/main/res/layout/change_avatar_dialog.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_info.xml b/app/src/main/res/layout/dialog_info.xml index 7fea2f2..7d49144 100644 --- a/app/src/main/res/layout/dialog_info.xml +++ b/app/src/main/res/layout/dialog_info.xml @@ -6,8 +6,8 @@ android:layout_height="match_parent" android:paddingHorizontal="20dp"> - + android:inputType="textPassword" + android:autofillHints="password"/> + android:inputType="textPassword" + android:autofillHints="password"/> + android:inputType="textPassword" + android:autofillHints="password"/> \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_create_identity.xml b/app/src/main/res/layout/fragment_create_identity.xml index b095092..c1b394d 100644 --- a/app/src/main/res/layout/fragment_create_identity.xml +++ b/app/src/main/res/layout/fragment_create_identity.xml @@ -1,60 +1,69 @@ - + android:layout_height="wrap_content" + android:orientation="vertical" + android:layout_gravity="center_vertical"> - + android:text="@string/create_identity_test" + style="@style/Label"/> - + - +