diff --git a/apps/Android/app/src/main/AndroidManifest.xml b/apps/Android/app/src/main/AndroidManifest.xml
index 85aefc5..a4e2626 100644
--- a/apps/Android/app/src/main/AndroidManifest.xml
+++ b/apps/Android/app/src/main/AndroidManifest.xml
@@ -19,6 +19,7 @@
+
+
+
+
+
diff --git a/apps/Android/app/src/main/java/com/revertron/mimir/AccountsActivity.kt b/apps/Android/app/src/main/java/com/revertron/mimir/AccountsActivity.kt
index 4c0e495..80f18e1 100644
--- a/apps/Android/app/src/main/java/com/revertron/mimir/AccountsActivity.kt
+++ b/apps/Android/app/src/main/java/com/revertron/mimir/AccountsActivity.kt
@@ -1,17 +1,26 @@
package com.revertron.mimir
+import android.Manifest
+import android.app.AlertDialog
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Intent
+import android.content.pm.PackageManager
import android.graphics.Bitmap
+import android.net.Uri
import android.os.Bundle
+import android.provider.MediaStore
import android.util.Log
+import android.view.ContextThemeWrapper
import android.view.MenuItem
import android.view.View
import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.widget.AppCompatEditText
import androidx.appcompat.widget.AppCompatImageView
import androidx.appcompat.widget.Toolbar
+import androidx.core.content.ContextCompat
+import androidx.core.content.FileProvider
import com.revertron.mimir.ChatActivity.Companion.PICK_IMAGE_REQUEST_CODE
import com.revertron.mimir.storage.AccountInfo
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
@@ -21,9 +30,15 @@ import java.net.URLEncoder
class AccountsActivity: BaseActivity(), Toolbar.OnMenuItemClickListener {
+ companion object {
+ private const val TAG = "AccountsActivity"
+ private const val TAKE_PHOTO_REQUEST_CODE = 124
+ }
+
val accountNumber = 1 //TODO make multi account
lateinit var accountInfo: AccountInfo
lateinit var myNameEdit: AppCompatEditText
+ private var currentPhotoUri: Uri? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -43,10 +58,19 @@ class AccountsActivity: BaseActivity(), Toolbar.OnMenuItemClickListener {
val avatar = loadRoundedAvatar(this, accountInfo.avatar, 128, 8)
avatarView.setImageDrawable(avatar)
}
- avatarView.setOnClickListener {
+
+ // Camera button - select/take photo
+ val cameraButton = findViewById(R.id.camera_button)
+ cameraButton.setOnClickListener {
selectPicture()
}
+ // Delete button - remove avatar
+ val deleteButton = findViewById(R.id.delete_button)
+ deleteButton.setOnClickListener {
+ deleteAvatar()
+ }
+
myNameEdit = findViewById(R.id.contact_name)
myNameEdit.setText(name)
@@ -114,41 +138,153 @@ class AccountsActivity: BaseActivity(), Toolbar.OnMenuItemClickListener {
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
- if (requestCode == PICK_IMAGE_REQUEST_CODE && resultCode == RESULT_OK) {
- if (data == null || data.data == null) {
- Log.e(ChatActivity.Companion.TAG, "Error getting picture")
- return
- }
- val selectedPictureUri = data.data!!
- Thread {
- val bmp = loadSquareAvatar(this.applicationContext, selectedPictureUri, 256)
- if (bmp != null) {
- val avatarsDir = File(filesDir, "avatars")
- if (!avatarsDir.exists()) {
- avatarsDir.mkdirs()
+ when (requestCode) {
+ PICK_IMAGE_REQUEST_CODE -> {
+ if (resultCode == RESULT_OK) {
+ if (data == null || data.data == null) {
+ Log.e(TAG, "Error getting picture from gallery")
+ return
}
- val fileName = accountInfo.avatar.ifEmpty {
- randomString(16) + ".jpg"
- }
-
- // TODO support avatars with opacity like PNG
- val f = File(avatarsDir, fileName)
- f.outputStream().use {
- bmp.compress(Bitmap.CompressFormat.JPEG, 95, it)
+ val selectedPictureUri = data.data!!
+ processAvatarImage(selectedPictureUri)
+ }
+ }
+ TAKE_PHOTO_REQUEST_CODE -> {
+ if (resultCode == RESULT_OK) {
+ currentPhotoUri?.let { uri ->
+ processAvatarImage(uri)
+ } ?: run {
+ Log.e(TAG, "Error: photo URI is null")
+ Toast.makeText(this, R.string.error_taking_photo, Toast.LENGTH_SHORT).show()
}
- getStorage().updateAvatar(accountNumber, fileName)
- runOnUiThread({
- val avatarView = findViewById(R.id.avatar)
- avatarView.setImageBitmap(bmp)
- })
}
- }.start()
+ }
}
}
+ private fun processAvatarImage(uri: Uri) {
+ Thread {
+ val bmp = loadSquareAvatar(this.applicationContext, uri, 256)
+ if (bmp != null) {
+ val avatarsDir = File(filesDir, "avatars")
+ if (!avatarsDir.exists()) {
+ avatarsDir.mkdirs()
+ }
+ val fileName = accountInfo.avatar.ifEmpty {
+ randomString(16) + ".jpg"
+ }
+
+ // TODO support avatars with opacity like PNG
+ val f = File(avatarsDir, fileName)
+ f.outputStream().use {
+ bmp.compress(Bitmap.CompressFormat.JPEG, 95, it)
+ }
+ getStorage().updateAvatar(accountNumber, fileName)
+ runOnUiThread {
+ val avatarView = findViewById(R.id.avatar)
+ avatarView.setImageBitmap(bmp)
+ }
+ }
+ }.start()
+ }
+
private fun selectPicture() {
+ showImageSourceDialog()
+ }
+
+ private fun showImageSourceDialog() {
+ val wrapper = ContextThemeWrapper(this, R.style.MimirDialog)
+ AlertDialog.Builder(wrapper)
+ .setTitle(R.string.choose_image_source)
+ .setItems(arrayOf(
+ getString(R.string.take_photo),
+ getString(R.string.choose_from_gallery)
+ )) { _, which ->
+ when (which) {
+ 0 -> checkCameraPermissionAndTakePhoto()
+ 1 -> pickImageFromGallery()
+ }
+ }
+ .show()
+ }
+
+ private fun checkCameraPermissionAndTakePhoto() {
+ when {
+ ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED -> {
+ takePhoto()
+ }
+ else -> {
+ requestCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
+ }
+ }
+ }
+
+ private val requestCameraPermissionLauncher =
+ registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
+ if (isGranted) {
+ takePhoto()
+ } else {
+ Toast.makeText(this, R.string.toast_no_permission, Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ private fun takePhoto() {
+ try {
+ val cameraDir = File(cacheDir, "camera")
+ if (!cameraDir.exists()) {
+ cameraDir.mkdirs()
+ }
+
+ val photoFile = File(cameraDir, "avatar_${System.currentTimeMillis()}.jpg")
+ currentPhotoUri = FileProvider.getUriForFile(
+ this,
+ "${packageName}.file_provider",
+ photoFile
+ )
+
+ val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
+ intent.putExtra(MediaStore.EXTRA_OUTPUT, currentPhotoUri)
+ startActivityForResult(intent, TAKE_PHOTO_REQUEST_CODE)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error creating camera intent", e)
+ Toast.makeText(this, R.string.error_taking_photo, Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ private fun pickImageFromGallery() {
val intent = Intent(Intent.ACTION_PICK)
intent.type = "image/*"
startActivityForResult(intent, PICK_IMAGE_REQUEST_CODE)
}
+
+ private fun deleteAvatar() {
+ if (accountInfo.avatar.isEmpty()) {
+ Toast.makeText(this, R.string.no_avatar_to_delete, Toast.LENGTH_SHORT).show()
+ return
+ }
+
+ val wrapper = ContextThemeWrapper(this, R.style.MimirDialog)
+ AlertDialog.Builder(wrapper)
+ .setTitle(R.string.delete_avatar)
+ .setMessage(R.string.confirm_delete_avatar)
+ .setPositiveButton(R.string.menu_delete) { _, _ ->
+ // Delete avatar file
+ val avatarsDir = File(filesDir, "avatars")
+ val avatarFile = File(avatarsDir, accountInfo.avatar)
+ if (avatarFile.exists()) {
+ avatarFile.delete()
+ }
+
+ // Update database
+ getStorage().updateAvatar(accountNumber, "")
+
+ // Update UI
+ val avatarView = findViewById(R.id.avatar)
+ avatarView.setImageResource(R.drawable.button_rounded_white)
+
+ Toast.makeText(this, R.string.avatar_deleted, Toast.LENGTH_SHORT).show()
+ }
+ .setNegativeButton(R.string.cancel, null)
+ .show()
+ }
}
\ No newline at end of file
diff --git a/apps/Android/app/src/main/java/com/revertron/mimir/ChatActivity.kt b/apps/Android/app/src/main/java/com/revertron/mimir/ChatActivity.kt
index 0ccd996..2ce4b7b 100644
--- a/apps/Android/app/src/main/java/com/revertron/mimir/ChatActivity.kt
+++ b/apps/Android/app/src/main/java/com/revertron/mimir/ChatActivity.kt
@@ -14,6 +14,7 @@ import android.media.AudioAttributes
import android.media.MediaPlayer
import android.net.Uri
import android.os.Bundle
+import android.provider.MediaStore
import android.util.Log
import android.view.*
import android.widget.Toast
@@ -21,6 +22,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.widget.*
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
+import androidx.core.content.FileProvider
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.LinearLayoutManager
@@ -33,14 +35,16 @@ import com.revertron.mimir.ui.SettingsData.KEY_IMAGES_FORMAT
import com.revertron.mimir.ui.SettingsData.KEY_IMAGES_QUALITY
import org.bouncycastle.util.encoders.Hex
import org.json.JSONObject
+import java.io.File
import java.lang.Thread.sleep
-class ChatActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, StorageListener, View.OnClickListener {
+class ChatActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, StorageListener {
companion object {
const val TAG = "ChatActivity"
const val PICK_IMAGE_REQUEST_CODE = 123
+ private const val TAKE_PHOTO_REQUEST_CODE = 125
}
lateinit var contact: Contact
@@ -49,8 +53,10 @@ class ChatActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, StorageLis
private lateinit var replyText: AppCompatTextView
private lateinit var attachmentPanel: ConstraintLayout
private lateinit var attachmentPreview: AppCompatImageView
+ private lateinit var adapter: MessageAdapter
private var attachmentJson: JSONObject? = null
private var isVisible: Boolean = false
+ private var currentPhotoUri: Uri? = null
var replyTo = 0L
private val peerStatusReceiver = object : BroadcastReceiver() {
@@ -65,6 +71,26 @@ class ChatActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, StorageLis
}
}
+ private val reactionReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent) {
+ if (intent.action == "ACTION_REACTION_UPDATED") {
+ val messageGuid = intent.getLongExtra("messageGuid", 0L)
+ if (messageGuid != 0L) {
+ // Find the message in adapter and refresh it
+ val messageId = getStorage().getMessageIdByGuid(messageGuid)
+ if (messageId != null) {
+ val position = adapter.getMessageIdPosition(messageId)
+ if (position >= 0) {
+ runOnUiThread {
+ adapter.notifyItemChanged(position)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_chat)
@@ -140,7 +166,7 @@ class ChatActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, StorageLis
getImageFromUri(image)
}
- val adapter = MessageAdapter(getStorage(), contact.id, groupChat = false, contact.name, this, onClickOnReply(), onClickOnPicture())
+ adapter = MessageAdapter(getStorage(), contact.id, groupChat = false, contact.name, onLongClickMessage(), onClickOnReply(), onClickOnPicture())
val recycler = findViewById(R.id.messages_list)
recycler.adapter = adapter
recycler.layoutManager = LinearLayoutManager(this).apply { stackFromEnd = true }
@@ -148,6 +174,7 @@ class ChatActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, StorageLis
showOnlineState(PeerStatus.Connecting)
LocalBroadcastManager.getInstance(this).registerReceiver(peerStatusReceiver, IntentFilter("ACTION_PEER_STATUS"))
+ LocalBroadcastManager.getInstance(this).registerReceiver(reactionReceiver, IntentFilter("ACTION_REACTION_UPDATED"))
fetchStatus(this, contact.pubkey)
Thread {
@@ -235,6 +262,7 @@ class ChatActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, StorageLis
override fun onDestroy() {
getStorage().listeners.remove(this)
LocalBroadcastManager.getInstance(this).unregisterReceiver(peerStatusReceiver)
+ LocalBroadcastManager.getInstance(this).unregisterReceiver(reactionReceiver)
super.onDestroy()
}
@@ -251,13 +279,27 @@ class ChatActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, StorageLis
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
- if (requestCode == PICK_IMAGE_REQUEST_CODE && resultCode == RESULT_OK) {
- if (data == null || data.data == null) {
- Log.e(TAG, "Error getting picture")
- return
+ when (requestCode) {
+ PICK_IMAGE_REQUEST_CODE -> {
+ if (resultCode == RESULT_OK) {
+ if (data == null || data.data == null) {
+ Log.e(TAG, "Error getting picture from gallery")
+ return
+ }
+ val selectedPictureUri = data.data!!
+ getImageFromUri(selectedPictureUri)
+ }
+ }
+ TAKE_PHOTO_REQUEST_CODE -> {
+ if (resultCode == RESULT_OK) {
+ currentPhotoUri?.let { uri ->
+ getImageFromUri(uri)
+ } ?: run {
+ Log.e(TAG, "Error: photo URI is null")
+ Toast.makeText(this, R.string.error_taking_photo, Toast.LENGTH_SHORT).show()
+ }
+ }
}
- val selectedPictureUri = data.data!!
- getImageFromUri(selectedPictureUri)
}
}
@@ -312,6 +354,69 @@ class ChatActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, StorageLis
}
private fun selectAndSendPicture() {
+ showImageSourceDialog()
+ }
+
+ private fun showImageSourceDialog() {
+ val wrapper = ContextThemeWrapper(this, R.style.MimirDialog)
+ AlertDialog.Builder(wrapper)
+ .setTitle(R.string.choose_image_source)
+ .setItems(arrayOf(
+ getString(R.string.take_photo),
+ getString(R.string.choose_from_gallery)
+ )) { _, which ->
+ when (which) {
+ 0 -> checkCameraPermissionAndTakePhoto()
+ 1 -> pickImageFromGallery()
+ }
+ }
+ .show()
+ }
+
+ private fun checkCameraPermissionAndTakePhoto() {
+ when {
+ ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED -> {
+ takePhoto()
+ }
+ else -> {
+ requestCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
+ }
+ }
+ }
+
+ private val requestCameraPermissionLauncher =
+ registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
+ if (isGranted) {
+ takePhoto()
+ } else {
+ Toast.makeText(this, R.string.toast_no_permission, Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ private fun takePhoto() {
+ try {
+ val cameraDir = File(cacheDir, "camera")
+ if (!cameraDir.exists()) {
+ cameraDir.mkdirs()
+ }
+
+ val photoFile = File(cameraDir, "photo_${System.currentTimeMillis()}.jpg")
+ currentPhotoUri = FileProvider.getUriForFile(
+ this,
+ "${packageName}.file_provider",
+ photoFile
+ )
+
+ val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
+ intent.putExtra(MediaStore.EXTRA_OUTPUT, currentPhotoUri)
+ startActivityForResult(intent, TAKE_PHOTO_REQUEST_CODE)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error creating camera intent", e)
+ Toast.makeText(this, R.string.error_taking_photo, Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ private fun pickImageFromGallery() {
val intent = Intent(Intent.ACTION_PICK)
intent.type = "image/*"
startActivityForResult(intent, PICK_IMAGE_REQUEST_CODE)
@@ -357,42 +462,127 @@ class ChatActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, StorageLis
return false
}
- override fun onClick(view: View) {
- val popup = PopupMenu(this, view, Gravity.TOP or Gravity.END)
- popup.inflate(R.menu.menu_context_message)
- popup.setForceShowIcon(true)
- popup.setOnMenuItemClickListener {
- when (it.itemId) {
- R.id.menu_copy -> {
- val textview = view.findViewById(R.id.text)
- val clipboard: ClipboardManager = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
- val clip = ClipData.newPlainText("Mimir message", textview.text)
- clipboard.setPrimaryClip(clip)
- Toast.makeText(applicationContext,R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
- true
- }
- R.id.menu_reply -> {
- val id = (view.tag as Long)
- val message = getStorage().getMessage(id)
- replyName.text = contact.name
- replyText.text = message?.getText(this)
- replyPanel.visibility = View.VISIBLE
- replyTo = message?.guid ?: 0L
- Log.i(TAG, "Replying to guid $replyTo")
- false
- }
- R.id.menu_forward -> { false }
- R.id.menu_delete -> {
- showDeleteMessageConfirmDialog(view.tag as Long)
- true
- }
- else -> {
- Log.w(TAG, "Not implemented handler for menu item ${it.itemId}")
- false
+ private fun onLongClickMessage() = View.OnLongClickListener { view ->
+ // Show combined reactions and context menu
+ showCombinedContextMenu(view)
+ true
+ }
+
+ private var contextMenuWindow: android.widget.PopupWindow? = null
+
+ private fun showCombinedContextMenu(messageView: View) {
+ // Dismiss existing window if any
+ contextMenuWindow?.dismiss()
+
+ val messageId = messageView.tag as? Long ?: return
+ val message = getStorage().getMessage(messageId) ?: return
+
+ // Inflate combined menu (reactions + actions)
+ val menuView = layoutInflater.inflate(R.layout.message_context_menu, null)
+
+ val reactions = mapOf(
+ R.id.reaction_thumbsup to "👍",
+ R.id.reaction_thumbsdown to "👎",
+ R.id.reaction_fire to "🔥",
+ R.id.reaction_laugh to "😂",
+ R.id.reaction_sad to "😢"
+ )
+
+ // Get current user pubkey for reaction handling
+ val currentUserPubkey = getStorage().getAccountInfo(1, 0L)?.let {
+ (it.keyPair.public as org.bouncycastle.crypto.params.Ed25519PublicKeyParameters).encoded
+ }
+
+ // Setup reaction buttons
+ for ((viewId, emoji) in reactions) {
+ menuView.findViewById(viewId)?.setOnClickListener {
+ if (currentUserPubkey != null) {
+ // Check if user has an existing reaction
+ val existingReaction = getStorage().getUserCurrentReaction(message.guid, null, currentUserPubkey)
+
+ // Toggle the reaction locally
+ val added = getStorage().toggleReaction(message.guid, null, currentUserPubkey, emoji)
+ adapter.notifyDataSetChanged()
+
+ // If user had a different reaction, send removal first
+ if (existingReaction != null && existingReaction != emoji) {
+ val removeIntent = Intent(this, ConnectionService::class.java)
+ removeIntent.putExtra("command", "ACTION_SEND_REACTION")
+ removeIntent.putExtra("messageGuid", message.guid)
+ removeIntent.putExtra("emoji", existingReaction)
+ removeIntent.putExtra("add", false)
+ removeIntent.putExtra("contactPubkey", contact.pubkey)
+ startService(removeIntent)
+ }
+
+ // Send the new reaction
+ val intent = Intent(this, ConnectionService::class.java)
+ intent.putExtra("command", "ACTION_SEND_REACTION")
+ intent.putExtra("messageGuid", message.guid)
+ intent.putExtra("emoji", emoji)
+ intent.putExtra("add", added)
+ intent.putExtra("contactPubkey", contact.pubkey)
+ startService(intent)
}
+
+ contextMenuWindow?.dismiss()
}
}
- popup.show()
+
+ // Setup action menu items
+ menuView.findViewById(R.id.menu_reply)?.setOnClickListener {
+ replyName.text = contact.name
+ replyText.text = message.getText(this)
+ replyPanel.visibility = View.VISIBLE
+ replyTo = message.guid
+ Log.i(TAG, "Replying to guid $replyTo")
+ contextMenuWindow?.dismiss()
+ }
+
+ menuView.findViewById(R.id.menu_copy)?.setOnClickListener {
+ val textview = messageView.findViewById(R.id.text)
+ val clipboard: ClipboardManager = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
+ val clip = ClipData.newPlainText("Mimir message", textview.text)
+ clipboard.setPrimaryClip(clip)
+ Toast.makeText(applicationContext, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
+ contextMenuWindow?.dismiss()
+ }
+
+ menuView.findViewById(R.id.menu_forward)?.setOnClickListener {
+ // Forward functionality not implemented yet
+ contextMenuWindow?.dismiss()
+ }
+
+ menuView.findViewById(R.id.menu_delete)?.setOnClickListener {
+ showDeleteMessageConfirmDialog(messageId)
+ contextMenuWindow?.dismiss()
+ }
+
+ // Create popup window
+ val popupWindow = android.widget.PopupWindow(
+ menuView,
+ android.view.ViewGroup.LayoutParams.WRAP_CONTENT,
+ android.view.ViewGroup.LayoutParams.WRAP_CONTENT,
+ true
+ )
+ popupWindow.isOutsideTouchable = true
+ popupWindow.isFocusable = true
+
+ // Calculate position above the message
+ val location = IntArray(2)
+ messageView.getLocationOnScreen(location)
+
+ // Measure the popup
+ menuView.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED)
+ val popupHeight = menuView.measuredHeight
+ val popupWidth = menuView.measuredWidth
+
+ // Position above the message, centered
+ val xOffset = (messageView.width - popupWidth) / 2
+ val yOffset = -(popupHeight + 10) // 10dp above message
+
+ popupWindow.showAsDropDown(messageView, xOffset, yOffset)
+ contextMenuWindow = popupWindow
}
private fun showDeleteMessageConfirmDialog(messageId: Long) {
diff --git a/apps/Android/app/src/main/java/com/revertron/mimir/ConnectionService.kt b/apps/Android/app/src/main/java/com/revertron/mimir/ConnectionService.kt
index a607530..01d042a 100644
--- a/apps/Android/app/src/main/java/com/revertron/mimir/ConnectionService.kt
+++ b/apps/Android/app/src/main/java/com/revertron/mimir/ConnectionService.kt
@@ -188,6 +188,32 @@ class ConnectionService : Service(), EventListener, InfoProvider {
}.start()
}
}
+ "ACTION_SEND_REACTION" -> {
+ val messageGuid = intent.getLongExtra("messageGuid", 0L)
+ val emoji = intent.getStringExtra("emoji")
+ val add = intent.getBooleanExtra("add", true)
+ val chatId = if (intent.hasExtra("chatId")) intent.getLongExtra("chatId", 0L) else null
+ val contactPubkey = intent.getByteArrayExtra("contactPubkey")
+
+ if (messageGuid != 0L && emoji != null) {
+ Log.i(TAG, "Sending reaction: emoji=$emoji, messageGuid=$messageGuid, add=$add, chatId=$chatId")
+
+ // For personal chats, send directly to contact
+ if (contactPubkey != null) {
+ val sent = mimirServer?.sendReaction(contactPubkey, messageGuid, emoji, add, chatId) ?: false
+ if (!sent) {
+ // No active connection - queue for later
+ Log.i(TAG, "No connection available, queueing reaction for later")
+ storage.addPendingReaction(messageGuid, chatId, contactPubkey, emoji, add)
+ }
+ }
+ // For group chats, send via mediator (TODO: implement)
+ else if (chatId != null) {
+ // Group chat reactions would be sent via mediator
+ Log.w(TAG, "Group chat reactions not yet implemented")
+ }
+ }
+ }
"online" -> {
mimirServer?.reconnectPeers()
Log.i(TAG, "Resending unsent messages")
@@ -1099,6 +1125,11 @@ class ConnectionService : Service(), EventListener, InfoProvider {
val expiration = getUtcTime() + IP_CACHE_DEFAULT_TTL
val storage = (application as App).storage
storage.saveIp(from, address, 0, clientId, 0, expiration)
+
+ // Send any pending reactions for this contact
+ Thread {
+ sendPendingReactionsForContact(from, storage)
+ }.start()
}
override fun onMessageReceived(from: ByteArray, guid: Long, replyTo: Long, sendTime: Long, editTime: Long, type: Int, message: ByteArray) {
@@ -1144,6 +1175,28 @@ class ConnectionService : Service(), EventListener, InfoProvider {
(application as App).storage.setMessageDelivered(to, guid, delivered)
}
+ override fun onReactionReceived(from: ByteArray, messageGuid: Long, emoji: String, add: Boolean, chatId: Long?) {
+ val storage = (application as App).storage
+ Log.i(TAG, "onReactionReceived: emoji=$emoji, messageGuid=$messageGuid, add=$add, chatId=$chatId")
+
+ if (add) {
+ storage.addReaction(messageGuid, chatId, from, emoji)
+ } else {
+ storage.removeReaction(messageGuid, chatId, from, emoji)
+ }
+
+ // Broadcast reaction update to active activities
+ val intent = Intent("ACTION_REACTION_UPDATED").apply {
+ putExtra("messageGuid", messageGuid)
+ putExtra("emoji", emoji)
+ putExtra("add", add)
+ if (chatId != null) {
+ putExtra("chatId", chatId)
+ }
+ }
+ LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
+ }
+
override fun onPeerStatusChanged(from: ByteArray, status: PeerStatus) {
val contact = Hex.toHexString(from)
peerStatuses.put(contact, status)
@@ -1200,6 +1253,38 @@ class ConnectionService : Service(), EventListener, InfoProvider {
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
}
+ private fun sendPendingReactionsForContact(contactPubkey: ByteArray, storage: SqlStorage) {
+ val pendingReactions = storage.getPendingReactionsForContact(contactPubkey)
+ if (pendingReactions.isEmpty()) {
+ return
+ }
+
+ Log.i(TAG, "Sending ${pendingReactions.size} pending reactions for contact ${Hex.toHexString(contactPubkey).take(16)}...")
+
+ for (pending in pendingReactions) {
+ val sent = mimirServer?.sendReaction(
+ contactPubkey,
+ pending.messageGuid,
+ pending.emoji,
+ pending.add,
+ pending.chatId
+ ) ?: false
+
+ if (sent) {
+ // Remove from pending queue
+ storage.removePendingReaction(pending.id)
+ Log.i(TAG, "Sent pending reaction: emoji=${pending.emoji}, messageGuid=${pending.messageGuid}")
+ } else {
+ // Connection lost again, stop trying
+ Log.w(TAG, "Failed to send pending reaction, connection lost")
+ break
+ }
+
+ // Small delay between reactions to avoid flooding
+ Thread.sleep(100)
+ }
+ }
+
private fun updateTick(forced: Boolean = false) {
if (BuildConfig.DEBUG && !forced) {
Log.i(TAG, "Skipping update check in debug build")
diff --git a/apps/Android/app/src/main/java/com/revertron/mimir/FontSizeActivity.kt b/apps/Android/app/src/main/java/com/revertron/mimir/FontSizeActivity.kt
new file mode 100644
index 0000000..be75bc0
--- /dev/null
+++ b/apps/Android/app/src/main/java/com/revertron/mimir/FontSizeActivity.kt
@@ -0,0 +1,75 @@
+package com.revertron.mimir
+
+import android.content.SharedPreferences
+import android.os.Build
+import android.os.Bundle
+import android.util.TypedValue
+import android.view.View
+import android.widget.SeekBar
+import android.widget.SeekBar.OnSeekBarChangeListener
+import androidx.appcompat.widget.AppCompatTextView
+import androidx.appcompat.widget.Toolbar
+import androidx.core.content.edit
+import androidx.preference.PreferenceManager
+import com.revertron.mimir.ui.SettingsData.KEY_MESSAGE_FONT_SIZE
+
+
+class FontSizeActivity: BaseActivity() {
+
+ lateinit var prefs: SharedPreferences
+
+ private val fontSizeMin = 11
+ private val fontSizeMax = 23
+ private val fontSizeDefault = 15
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setContentView(R.layout.font_size_activity)
+
+ val toolbar = findViewById(R.id.toolbar) as Toolbar
+ setSupportActionBar(toolbar)
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+
+ prefs = PreferenceManager.getDefaultSharedPreferences(this)
+
+ val fontSizeValue = findViewById(R.id.font_size_value)
+ val previewText = findViewById(R.id.preview_text)
+
+ val seekBar = findViewById(R.id.font_size_seekbar)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ seekBar.min = fontSizeMin
+ }
+ seekBar.max = fontSizeMax
+
+ val currentFontSize = prefs.getInt(KEY_MESSAGE_FONT_SIZE, fontSizeDefault)
+ fontSizeValue.text = "${currentFontSize}sp"
+ previewText.setTextSize(TypedValue.COMPLEX_UNIT_SP, currentFontSize.toFloat())
+ seekBar.progress = currentFontSize
+
+ seekBar.setOnSeekBarChangeListener(object : OnSeekBarChangeListener {
+ override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
+ val size = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ progress
+ } else {
+ progress.coerceAtLeast(fontSizeMin)
+ }
+ fontSizeValue.text = "${size}sp"
+ previewText.setTextSize(TypedValue.COMPLEX_UNIT_SP, size.toFloat())
+ prefs.edit {
+ putInt(KEY_MESSAGE_FONT_SIZE, size)
+ commit()
+ }
+ }
+
+ override fun onStartTrackingTouch(seekBar: SeekBar?) {
+ //
+ }
+
+ override fun onStopTrackingTouch(seekBar: SeekBar?) {
+ //
+ }
+
+ })
+ }
+}
diff --git a/apps/Android/app/src/main/java/com/revertron/mimir/GroupChatActivity.kt b/apps/Android/app/src/main/java/com/revertron/mimir/GroupChatActivity.kt
index 23d0885..672c1ab 100644
--- a/apps/Android/app/src/main/java/com/revertron/mimir/GroupChatActivity.kt
+++ b/apps/Android/app/src/main/java/com/revertron/mimir/GroupChatActivity.kt
@@ -1,5 +1,6 @@
package com.revertron.mimir
+import android.Manifest
import android.app.AlertDialog
import android.content.BroadcastReceiver
import android.content.ClipData
@@ -7,11 +8,13 @@ import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
+import android.content.pm.PackageManager
import android.graphics.PorterDuff
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.os.Looper
+import android.provider.MediaStore
import android.util.Log
import android.view.ContextThemeWrapper
import android.view.Gravity
@@ -19,6 +22,7 @@ import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.widget.AppCompatEditText
import androidx.appcompat.widget.AppCompatImageButton
import androidx.appcompat.widget.AppCompatImageView
@@ -27,6 +31,8 @@ import androidx.appcompat.widget.LinearLayoutCompat
import androidx.appcompat.widget.PopupMenu
import androidx.appcompat.widget.Toolbar
import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.core.content.ContextCompat
+import androidx.core.content.FileProvider
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.LinearLayoutManager
@@ -40,6 +46,7 @@ import com.revertron.mimir.ui.SettingsData.KEY_IMAGES_QUALITY
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
import org.bouncycastle.util.encoders.Hex
import org.json.JSONObject
+import java.io.File
/**
* Group chat activity using ConnectionService for server-mediated group messaging.
@@ -55,7 +62,7 @@ import org.json.JSONObject
* follows the same pattern as NewChatActivity by delegating all mediator operations to
* ConnectionService and receiving responses via LocalBroadcast.
*/
-class GroupChatActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, StorageListener, View.OnClickListener {
+class GroupChatActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, StorageListener {
companion object {
const val TAG = "GroupChatActivity"
@@ -67,6 +74,7 @@ class GroupChatActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, Stora
private const val REQUEST_SELECT_CONTACT = 100
private const val PICK_IMAGE_REQUEST_CODE = 123
+ private const val TAKE_PHOTO_REQUEST_CODE = 126
}
private lateinit var groupChat: GroupChat
@@ -83,6 +91,7 @@ class GroupChatActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, Stora
private var replyTo = 0L
private var attachmentJson: JSONObject? = null
private var isVisible: Boolean = false
+ private var currentPhotoUri: Uri? = null
private val mainHandler = Handler(Looper.getMainLooper())
private val mediatorReceiver = object : BroadcastReceiver() {
@@ -115,6 +124,25 @@ class GroupChatActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, Stora
}
}
}
+ "ACTION_REACTION_UPDATED" -> {
+ val messageGuid = intent.getLongExtra("messageGuid", 0L)
+ val receivedChatId = if (intent.hasExtra("chatId")) intent.getLongExtra("chatId", 0L) else null
+
+ // Only update if reaction is for this group chat
+ if (receivedChatId == groupChat.chatId && messageGuid != 0L) {
+ Log.i(TAG, "Reaction updated for message $messageGuid in chat ${groupChat.chatId}")
+ mainHandler.post {
+ // Find message and update it
+ val messageId = getStorage().getGroupMessageIdByGuid(messageGuid, groupChat.chatId)
+ if (messageId != null) {
+ val position = adapter.getMessageIdPosition(messageId)
+ if (position >= 0) {
+ adapter.notifyItemChanged(position)
+ }
+ }
+ }
+ }
+ }
"ACTION_MEDIATOR_LEFT_CHAT" -> {
val chatId = intent.getLongExtra("chat_id", 0)
if (chatId == groupChat.chatId) {
@@ -197,6 +225,7 @@ class GroupChatActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, Stora
val filter = IntentFilter().apply {
addAction("ACTION_MEDIATOR_MESSAGE_SENT")
addAction("ACTION_GROUP_MESSAGE_RECEIVED")
+ addAction("ACTION_REACTION_UPDATED")
addAction("ACTION_MEDIATOR_LEFT_CHAT")
addAction("ACTION_MEDIATOR_ERROR")
}
@@ -278,7 +307,7 @@ class GroupChatActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, Stora
chatId,
groupChat = true, // Enable group-chat mode to show sender names
groupChat.name,
- this,
+ onLongClickMessage(),
onClickOnReply(),
onClickOnPicture()
)
@@ -573,11 +602,23 @@ class GroupChatActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, Stora
}
}
PICK_IMAGE_REQUEST_CODE -> {
- if (resultCode == RESULT_OK && data != null && data.data != null) {
+ if (resultCode == RESULT_OK) {
+ if (data == null || data.data == null) {
+ Log.e(TAG, "Error getting picture from gallery")
+ return
+ }
val selectedPictureUri = data.data!!
getImageFromUri(selectedPictureUri)
- } else {
- Log.e(TAG, "Error getting picture")
+ }
+ }
+ TAKE_PHOTO_REQUEST_CODE -> {
+ if (resultCode == RESULT_OK) {
+ currentPhotoUri?.let { uri ->
+ getImageFromUri(uri)
+ } ?: run {
+ Log.e(TAG, "Error: photo URI is null")
+ Toast.makeText(this, R.string.error_taking_photo, Toast.LENGTH_SHORT).show()
+ }
}
}
}
@@ -604,6 +645,69 @@ class GroupChatActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, Stora
// Image attachment handling
private fun selectAndSendPicture() {
+ showImageSourceDialog()
+ }
+
+ private fun showImageSourceDialog() {
+ val wrapper = ContextThemeWrapper(this, R.style.MimirDialog)
+ AlertDialog.Builder(wrapper)
+ .setTitle(R.string.choose_image_source)
+ .setItems(arrayOf(
+ getString(R.string.take_photo),
+ getString(R.string.choose_from_gallery)
+ )) { _, which ->
+ when (which) {
+ 0 -> checkCameraPermissionAndTakePhoto()
+ 1 -> pickImageFromGallery()
+ }
+ }
+ .show()
+ }
+
+ private fun checkCameraPermissionAndTakePhoto() {
+ when {
+ ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED -> {
+ takePhoto()
+ }
+ else -> {
+ requestCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
+ }
+ }
+ }
+
+ private val requestCameraPermissionLauncher =
+ registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
+ if (isGranted) {
+ takePhoto()
+ } else {
+ Toast.makeText(this, R.string.toast_no_permission, Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ private fun takePhoto() {
+ try {
+ val cameraDir = File(cacheDir, "camera")
+ if (!cameraDir.exists()) {
+ cameraDir.mkdirs()
+ }
+
+ val photoFile = File(cameraDir, "group_photo_${System.currentTimeMillis()}.jpg")
+ currentPhotoUri = FileProvider.getUriForFile(
+ this,
+ "${packageName}.file_provider",
+ photoFile
+ )
+
+ val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
+ intent.putExtra(MediaStore.EXTRA_OUTPUT, currentPhotoUri)
+ startActivityForResult(intent, TAKE_PHOTO_REQUEST_CODE)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error creating camera intent", e)
+ Toast.makeText(this, R.string.error_taking_photo, Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ private fun pickImageFromGallery() {
val intent = Intent(Intent.ACTION_PICK)
intent.type = "image/*"
startActivityForResult(intent, PICK_IMAGE_REQUEST_CODE)
@@ -632,20 +736,121 @@ class GroupChatActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, Stora
// Click handlers
- override fun onClick(view: View) {
- val popup = PopupMenu(this, view, Gravity.TOP or Gravity.END)
- popup.inflate(R.menu.menu_context_message) // Same menu resource
- popup.setForceShowIcon(true)
- popup.setOnMenuItemClickListener { menuItem ->
- when (menuItem.itemId) {
- R.id.menu_copy -> handleCopy(view)
- R.id.menu_reply -> handleReply(view)
- R.id.menu_forward -> handleForward(view)
- R.id.menu_delete -> handleDelete(view)
- else -> false
+ private var contextMenuWindow: android.widget.PopupWindow? = null
+
+ private fun onLongClickMessage() = View.OnLongClickListener { view ->
+ // Show combined reactions and context menu
+ showCombinedContextMenu(view)
+ true
+ }
+
+ private fun showCombinedContextMenu(messageView: View) {
+ // Dismiss existing window if any
+ contextMenuWindow?.dismiss()
+
+ val messageId = messageView.tag as? Long ?: return
+ val message = getStorage().getGroupMessage(groupChat.chatId, messageId) ?: return
+
+ // Inflate combined menu (reactions + actions)
+ val menuView = layoutInflater.inflate(R.layout.message_context_menu, null)
+
+ val reactions = mapOf(
+ R.id.reaction_thumbsup to "👍",
+ R.id.reaction_thumbsdown to "👎",
+ R.id.reaction_fire to "🔥",
+ R.id.reaction_laugh to "😂",
+ R.id.reaction_sad to "😢"
+ )
+
+ // Get current user pubkey for reaction handling
+ val currentUserPubkey = getStorage().getAccountInfo(1, 0L)?.let {
+ (it.keyPair.public as org.bouncycastle.crypto.params.Ed25519PublicKeyParameters).encoded
+ }
+
+ // Setup reaction buttons
+ for ((viewId, emoji) in reactions) {
+ menuView.findViewById(viewId)?.setOnClickListener {
+ if (currentUserPubkey != null) {
+ // Check if user has an existing reaction
+ val existingReaction = getStorage().getUserCurrentReaction(message.guid, groupChat.chatId, currentUserPubkey)
+
+ // Toggle the reaction locally
+ val added = getStorage().toggleReaction(message.guid, groupChat.chatId, currentUserPubkey, emoji)
+ adapter.notifyDataSetChanged()
+
+ // If user had a different reaction, send removal first
+ if (existingReaction != null && existingReaction != emoji) {
+ val removeIntent = Intent(this, ConnectionService::class.java)
+ removeIntent.putExtra("command", "ACTION_SEND_GROUP_REACTION")
+ removeIntent.putExtra("messageGuid", message.guid)
+ removeIntent.putExtra("chatId", groupChat.chatId)
+ removeIntent.putExtra("emoji", existingReaction)
+ removeIntent.putExtra("add", false)
+ removeIntent.putExtra("mediatorAddress", mediatorAddress)
+ startService(removeIntent)
+ }
+
+ // Send the new reaction
+ val intent = Intent(this, ConnectionService::class.java)
+ intent.putExtra("command", "ACTION_SEND_GROUP_REACTION")
+ intent.putExtra("messageGuid", message.guid)
+ intent.putExtra("chatId", groupChat.chatId)
+ intent.putExtra("emoji", emoji)
+ intent.putExtra("add", added)
+ intent.putExtra("mediatorAddress", mediatorAddress)
+ startService(intent)
+ }
+
+ contextMenuWindow?.dismiss()
}
}
- popup.show()
+
+ // Setup action menu items
+ menuView.findViewById(R.id.menu_reply)?.setOnClickListener {
+ handleReply(messageView)
+ contextMenuWindow?.dismiss()
+ }
+
+ menuView.findViewById(R.id.menu_copy)?.setOnClickListener {
+ handleCopy(messageView)
+ contextMenuWindow?.dismiss()
+ }
+
+ menuView.findViewById(R.id.menu_forward)?.setOnClickListener {
+ handleForward(messageView)
+ contextMenuWindow?.dismiss()
+ }
+
+ menuView.findViewById(R.id.menu_delete)?.setOnClickListener {
+ handleDelete(messageView)
+ contextMenuWindow?.dismiss()
+ }
+
+ // Create popup window
+ val popupWindow = android.widget.PopupWindow(
+ menuView,
+ android.view.ViewGroup.LayoutParams.WRAP_CONTENT,
+ android.view.ViewGroup.LayoutParams.WRAP_CONTENT,
+ true
+ )
+ popupWindow.isOutsideTouchable = true
+ popupWindow.isFocusable = true
+
+ // Calculate position above the message
+ val location = IntArray(2)
+ messageView.getLocationOnScreen(location)
+
+ // Measure the popup
+ menuView.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED)
+ val popupHeight = menuView.measuredHeight
+ val popupWidth = menuView.measuredWidth
+
+ // Position above the message, centered
+ val xOffset = (messageView.width - popupWidth) / 2
+ val yOffset = -(popupHeight + 10) // 10dp above message
+
+ popupWindow.showAsDropDown(messageView, xOffset, yOffset)
+ contextMenuWindow = popupWindow
}
private fun onClickOnReply() = fun(it: View) {
diff --git a/apps/Android/app/src/main/java/com/revertron/mimir/SettingsActivity.kt b/apps/Android/app/src/main/java/com/revertron/mimir/SettingsActivity.kt
index 0fd8af0..19f6e2b 100644
--- a/apps/Android/app/src/main/java/com/revertron/mimir/SettingsActivity.kt
+++ b/apps/Android/app/src/main/java/com/revertron/mimir/SettingsActivity.kt
@@ -68,6 +68,10 @@ class SettingsActivity : BaseActivity(), SettingsAdapter.Listener {
val intent = Intent(this, ImageSettingsActivity::class.java)
startActivity(intent, animFromRight.toBundle())
}
+ R.string.message_font_size -> {
+ val intent = Intent(this, FontSizeActivity::class.java)
+ startActivity(intent, animFromRight.toBundle())
+ }
R.string.check_for_updates -> {
val intent = Intent(this@SettingsActivity, ConnectionService::class.java).apply {
putExtra("command", "check_updates")
diff --git a/apps/Android/app/src/main/java/com/revertron/mimir/Utils.kt b/apps/Android/app/src/main/java/com/revertron/mimir/Utils.kt
index 7567df6..9638e2a 100644
--- a/apps/Android/app/src/main/java/com/revertron/mimir/Utils.kt
+++ b/apps/Android/app/src/main/java/com/revertron/mimir/Utils.kt
@@ -229,8 +229,9 @@ fun formatDuration(elapsed: Long): String {
*
* Large images are
* 1. down-scaled so that the *smaller* dimension becomes [maxSize],
- * 2. centre-cropped to a square,
- * 3. returned as an immutable [Bitmap].
+ * 2. rotated according to EXIF orientation,
+ * 3. centre-cropped to a square,
+ * 4. returned as an immutable [Bitmap].
*
* **Call this from a background thread only** – the function blocks.
*
@@ -250,12 +251,6 @@ fun loadSquareAvatar(context: Context, uri: Uri, maxSize: Int): Bitmap? {
val (w, h) = o.outWidth to o.outHeight
if (w <= 0 || h <= 0) return null
- if (w == h && w < maxSize) {
- val bmp = cr.openInputStream(uri).use { ins ->
- BitmapFactory.decodeStream(ins)
- } ?: return null
- return bmp
- }
val shortEdge = min(w, h)
val scaleFactor = shortEdge / maxSize // integer scale
@@ -273,17 +268,22 @@ fun loadSquareAvatar(context: Context, uri: Uri, maxSize: Int): Bitmap? {
} ?: return null
/* ---------------------------------------------------------------------
- 3. Fine-scale so that the *smaller* side becomes exactly maxSize
+ 3. Rotate according to EXIF orientation
------------------------------------------------------------------ */
- val scale = maxSize.toFloat() / min(bmp.width, bmp.height)
+ val rotated = rotateBitmapAccordingToExif(bmp, uri, cr)
+
+ /* ---------------------------------------------------------------------
+ 4. Fine-scale so that the *smaller* side becomes exactly maxSize
+ ------------------------------------------------------------------ */
+ val scale = maxSize.toFloat() / min(rotated.width, rotated.height)
val matrix = Matrix().apply { postScale(scale, scale) }
val scaled = Bitmap.createBitmap(
- bmp, 0, 0, bmp.width, bmp.height, matrix, true
+ rotated, 0, 0, rotated.width, rotated.height, matrix, true
)
/* ---------------------------------------------------------------------
- 4. Centre-crop to square
+ 5. Centre-crop to square
------------------------------------------------------------------ */
val cropSize = min(scaled.width, scaled.height)
val cropX = (scaled.width - cropSize) / 2
@@ -294,9 +294,10 @@ fun loadSquareAvatar(context: Context, uri: Uri, maxSize: Int): Bitmap? {
)
/* ---------------------------------------------------------------------
- 5. Clean up if we created an intermediate bitmap
+ 6. Clean up if we created intermediate bitmaps
------------------------------------------------------------------ */
- if (scaled != bmp) scaled.recycle()
+ if (scaled != rotated) scaled.recycle()
+ if (rotated != bmp) rotated.recycle()
return avatar
}
@@ -370,8 +371,34 @@ fun loadRoundedAvatar(context: Context, fileName: String?, size: Int = 32, corne
try {
f.inputStream().use {
val iconSize = TypedValue.applyDimension(COMPLEX_UNIT_DIP, size.toFloat(), resources.displayMetrics).toInt()
- val raw = BitmapFactory.decodeFile(f.absolutePath)
- val scaled = raw.scale(iconSize, iconSize)
+
+ // Decode the bitmap
+ val raw = BitmapFactory.decodeFile(f.absolutePath) ?: return null
+
+ // Apply EXIF rotation if needed
+ val rotated = try {
+ val exif = ExifInterface(f.absolutePath)
+ val orientation = exif.getAttributeInt(
+ ExifInterface.TAG_ORIENTATION,
+ ExifInterface.ORIENTATION_NORMAL
+ )
+ val matrix = Matrix()
+ when (orientation) {
+ ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
+ ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
+ ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
+ }
+ if (orientation != ExifInterface.ORIENTATION_NORMAL) {
+ Bitmap.createBitmap(raw, 0, 0, raw.width, raw.height, matrix, true)
+ .also { if (it != raw) raw.recycle() }
+ } else {
+ raw
+ }
+ } catch (_: Exception) {
+ raw
+ }
+
+ val scaled = rotated.scale(iconSize, iconSize)
val drawable = RoundedBitmapDrawableFactory.create(resources, scaled).apply {
cornerRadius = TypedValue.applyDimension(COMPLEX_UNIT_DIP, corners.toFloat(), resources.displayMetrics)
@@ -440,52 +467,55 @@ private fun rotateBitmapAccordingToExif(bitmap: Bitmap, uri: Uri, contentResolve
}
/**
- * Copies picture file to app directory, creates preview
+ * Copies picture file to app directory, creates preview with EXIF rotation applied
*
* @return Hash of file, null if some error occurs
*/
fun prepareFileForMessage(context: Context, uri: Uri, imageSize: ImageSize, quality: Int): JSONObject? {
val tag = "prepareFileForMessage"
- // TODO fix getting size
- var size = uri.length(context)
+ val originalSize = uri.length(context)
- val inputStream = if (size <= PICTURE_MAX_SIZE) {
- val contentResolver = context.contentResolver
- contentResolver.openInputStream(uri)
- } else {
- Log.i(tag, "File is too big, will try to resize")
- val resized = uri.resizeAndCompress(context, imageSize, quality)
- Log.i(tag, "Resized from $size to ${resized.size}")
- val newSize = resized.size.toLong()
- if (newSize > size || newSize > PICTURE_MAX_SIZE) return null
- size = newSize
- ByteArrayInputStream(resized)
+ // Always process through resizeAndCompress to ensure EXIF rotation is applied
+ // Even if the file is small, this ensures proper orientation
+ val resized = try {
+ uri.resizeAndCompress(context, imageSize, quality)
+ } catch (e: Exception) {
+ Log.e(tag, "Error processing image", e)
+ return null
+ }
+
+ Log.i(tag, "Processed image from $originalSize to ${resized.size} bytes")
+
+ val newSize = resized.size.toLong()
+ if (newSize > PICTURE_MAX_SIZE) {
+ Log.e(tag, "Processed image is still too big: $newSize > $PICTURE_MAX_SIZE")
+ return null
}
+
val imagesDir = File(context.filesDir, "files")
if (!imagesDir.exists()) {
imagesDir.mkdirs()
}
+
val fileName = randomString(16)
- val ext = getMimeType(context, uri)
+ val ext = "jpg" // Always use jpg since resizeAndCompress outputs JPEG
val fullName = "$fileName.$ext"
val outputFile = File(imagesDir, fullName)
- val outputStream = FileOutputStream(outputFile)
- inputStream.use { input ->
- outputStream.use { output ->
- val copied = (input?.copyTo(output) ?: {
- Log.e(tag, "File is not accessible")
- null
- }) as Long
- if (copied != size) {
- Log.e(tag, "Error copying file to app storage!")
- return null
- }
+
+ try {
+ FileOutputStream(outputFile).use { output ->
+ output.write(resized)
+ output.flush()
}
+ } catch (e: Exception) {
+ Log.e(tag, "Error writing file", e)
+ return null
}
+
val hash = getFileHash(outputFile)
val json = JSONObject()
json.put("name", fullName)
- json.put("size", size)
+ json.put("size", newSize)
json.put("hash", Hex.toHexString(hash))
return json
}
diff --git a/apps/Android/app/src/main/java/com/revertron/mimir/net/ConnectionHandler.kt b/apps/Android/app/src/main/java/com/revertron/mimir/net/ConnectionHandler.kt
index bce9f53..c2579f4 100644
--- a/apps/Android/app/src/main/java/com/revertron/mimir/net/ConnectionHandler.kt
+++ b/apps/Android/app/src/main/java/com/revertron/mimir/net/ConnectionHandler.kt
@@ -336,6 +336,15 @@ class ConnectionHandler(
peer?.let { listener.onMessageReceived(it, message.guid, message.replyTo, message.sendTime, message.editTime, message.type, message.data) }
}
}
+ MSG_TYPE_REACTION -> {
+ val reaction = readMessageReaction(dis) ?: return ProcessResult.Failed
+ Log.i(TAG, "Got reaction: emoji=${reaction.emoji}, messageGuid=${reaction.messageGuid}, add=${reaction.add}, chatId=${reaction.chatId}")
+ synchronized(listener) {
+ peer?.let {
+ listener.onReactionReceived(it, reaction.messageGuid, reaction.emoji, reaction.add, reaction.chatId)
+ }
+ }
+ }
MSG_TYPE_CALL_OFFER -> {
val offer = readCallOffer(dis) ?: return ProcessResult.Failed
Log.i(TAG, "Got call offer: $offer")
@@ -412,6 +421,33 @@ class ConnectionHandler(
}
}
+ /**
+ * Sends a reaction to a message.
+ * @param messageGuid GUID of the message being reacted to
+ * @param emoji The emoji reaction
+ * @param add true to add reaction, false to remove
+ * @param chatId Optional group chat ID (null for personal chats)
+ */
+ fun sendReaction(messageGuid: Long, emoji: String, add: Boolean, chatId: Long? = null) {
+ if (peerStatus != Status.Auth2Done) {
+ Log.w(TAG, "Cannot send reaction, not authenticated")
+ return
+ }
+
+ try {
+ val reaction = MessageReaction(messageGuid, emoji, add, chatId)
+ val baos = ByteArrayOutputStream()
+ val dos = DataOutputStream(baos)
+ writeMessageReaction(dos, reaction)
+ val bytes = baos.toByteArray()
+ connection.writeWithTimeout(bytes, 2000)
+ lastActiveTime = System.currentTimeMillis()
+ Log.i(TAG, "Reaction sent: emoji=$emoji, messageGuid=$messageGuid, add=$add, chatId=$chatId")
+ } catch (e: Exception) {
+ Log.e(TAG, "Error sending reaction", e)
+ }
+ }
+
fun sendData(bytes: ByteArray) {
try {
connection.writeWithTimeout(bytes, 2000)
diff --git a/apps/Android/app/src/main/java/com/revertron/mimir/net/Messages.kt b/apps/Android/app/src/main/java/com/revertron/mimir/net/Messages.kt
index 252059d..73d2451 100644
--- a/apps/Android/app/src/main/java/com/revertron/mimir/net/Messages.kt
+++ b/apps/Android/app/src/main/java/com/revertron/mimir/net/Messages.kt
@@ -19,6 +19,7 @@ const val MSG_TYPE_INFO_RESPONSE = 7
const val MSG_TYPE_PING = 8
const val MSG_TYPE_PONG = 9
const val MSG_TYPE_MESSAGE_TEXT = 1000
+const val MSG_TYPE_REACTION = 1001
const val MSG_TYPE_CALL_OFFER = 2000
const val MSG_TYPE_CALL_ANSWER = 2001
const val MSG_TYPE_CALL_HANG = 2002
@@ -33,6 +34,7 @@ data class Challenge(val data: ByteArray)
data class ChallengeAnswer(val data: ByteArray)
data class InfoResponse(val time: Long, val nickname: String, val info: String, val avatar: ByteArray?)
data class Message(val guid: Long, val replyTo: Long, val sendTime: Long, val editTime: Long, val type: Int, val data: ByteArray)
+data class MessageReaction(val messageGuid: Long, val emoji: String, val add: Boolean, val chatId: Long? = null)
data class CallOffer(val mimeType: String, val sampleRate: Int, val channelCount: Int = 1)
data class CallAnswer(val ok: Boolean, val error: String = "")
data class CallPacket(val data: ByteArray)
@@ -473,4 +475,59 @@ fun readAndDismiss(dis: DataInputStream, size: Long) {
if (read < 0) return
size -= read
}
+}
+
+/**
+ * Reads a message reaction from the socket
+ */
+fun readMessageReaction(dis: DataInputStream): MessageReaction? {
+ return try {
+ val messageGuid = dis.readLong()
+ val emojiLength = dis.readInt()
+ val emojiBytes = ByteArray(emojiLength)
+ var count = 0
+ while (count < emojiLength) {
+ val read = dis.read(emojiBytes, count, emojiLength - count)
+ if (read < 0) return null
+ count += read
+ }
+ val emoji = String(emojiBytes)
+ val add = dis.readBoolean()
+
+ // Read optional chatId
+ val hasChatId = dis.readBoolean()
+ val chatId = if (hasChatId) dis.readLong() else null
+
+ MessageReaction(messageGuid, emoji, add, chatId)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error reading reaction: $e")
+ null
+ }
+}
+
+/**
+ * Writes a message reaction to the socket
+ */
+fun writeMessageReaction(dos: DataOutputStream, reaction: MessageReaction, stream: Int = 0, type: Int = MSG_TYPE_REACTION): Boolean {
+ return try {
+ val emojiBytes = reaction.emoji.toByteArray()
+ val hasChatId = reaction.chatId != null
+ val size = 8 + 4 + emojiBytes.size + 1 + 1 + (if (hasChatId) 8 else 0)
+
+ writeHeader(dos, stream, type, size)
+
+ dos.writeLong(reaction.messageGuid)
+ dos.writeInt(emojiBytes.size)
+ dos.write(emojiBytes)
+ dos.writeBoolean(reaction.add)
+ dos.writeBoolean(hasChatId)
+ if (hasChatId) {
+ dos.writeLong(reaction.chatId!!)
+ }
+ dos.flush()
+ true
+ } catch (e: Exception) {
+ Log.e(TAG, "Error writing reaction: $e")
+ false
+ }
}
\ No newline at end of file
diff --git a/apps/Android/app/src/main/java/com/revertron/mimir/net/MimirServer.kt b/apps/Android/app/src/main/java/com/revertron/mimir/net/MimirServer.kt
index 2ef624b..3447fe2 100644
--- a/apps/Android/app/src/main/java/com/revertron/mimir/net/MimirServer.kt
+++ b/apps/Android/app/src/main/java/com/revertron/mimir/net/MimirServer.kt
@@ -398,6 +398,26 @@ class MimirServer(
}
}
+ /**
+ * Sends a reaction to a contact.
+ * @return true if sent successfully, false if no connection available
+ */
+ fun sendReaction(contact: ByteArray, messageGuid: Long, emoji: String, add: Boolean, chatId: Long? = null): Boolean {
+ val contactString = Hex.toHexString(contact)
+ Log.i(TAG, "Sending reaction to $contactString: emoji=$emoji, messageGuid=$messageGuid, add=$add")
+
+ synchronized(connections) {
+ val handler = connections.get(contactString)
+ if (handler != null) {
+ handler.sendReaction(messageGuid, emoji, add, chatId)
+ return true
+ } else {
+ Log.w(TAG, "No active connection to $contactString, cannot send reaction")
+ return false
+ }
+ }
+ }
+
fun reconnectPeers() {
messenger.retryPeersNow()
announceTtl = 0L
@@ -631,6 +651,10 @@ class MimirServer(
listener.onMessageDelivered(to, guid, delivered)
}
+ override fun onReactionReceived(from: ByteArray, messageGuid: Long, emoji: String, add: Boolean, chatId: Long?) {
+ listener.onReactionReceived(from, messageGuid, emoji, add, chatId)
+ }
+
override fun onIncomingCall(from: ByteArray, inCall: Boolean): Boolean {
Log.i(TAG, "onIncomingCall status: $callStatus")
if (callStatus != CallStatus.Idle) {
@@ -785,6 +809,7 @@ interface EventListener {
fun onClientConnected(from: ByteArray, address: String, clientId: Int)
fun onMessageReceived(from: ByteArray, guid: Long, replyTo: Long, sendTime: Long, editTime: Long, type: Int, message: ByteArray)
fun onMessageDelivered(to: ByteArray, guid: Long, delivered: Boolean)
+ fun onReactionReceived(from: ByteArray, messageGuid: Long, emoji: String, add: Boolean, chatId: Long?) {}
fun onIncomingCall(from: ByteArray, inCall: Boolean): Boolean { return false }
fun onCallStatusChanged(status: CallStatus, from: ByteArray?) {}
fun onConnectionClosed(from: ByteArray, address: String, deadPeer: Boolean) {}
diff --git a/apps/Android/app/src/main/java/com/revertron/mimir/storage/SqlStorage.kt b/apps/Android/app/src/main/java/com/revertron/mimir/storage/SqlStorage.kt
index b8638cc..7270e89 100644
--- a/apps/Android/app/src/main/java/com/revertron/mimir/storage/SqlStorage.kt
+++ b/apps/Android/app/src/main/java/com/revertron/mimir/storage/SqlStorage.kt
@@ -38,7 +38,7 @@ class SqlStorage(val context: Context): SQLiteOpenHelper(context, DATABASE_NAME,
companion object {
const val TAG = "SqlStorage"
// If we change the database schema, we must increment the database version.
- const val DATABASE_VERSION = 11
+ const val DATABASE_VERSION = 13
const val DATABASE_NAME = "data.db"
const val CREATE_ACCOUNTS = "CREATE TABLE accounts (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, privkey TEXT, pubkey TEXT, client INTEGER, info TEXT, avatar TEXT, updated INTEGER)"
const val CREATE_CONTACTS = "CREATE TABLE contacts (id INTEGER PRIMARY KEY AUTOINCREMENT, pubkey BLOB, name TEXT, info TEXT, avatar TEXT, updated INTEGER, renamed BOOL, last_seen INTEGER)"
@@ -48,6 +48,17 @@ class SqlStorage(val context: Context): SQLiteOpenHelper(context, DATABASE_NAME,
// Group chat tables (version 8+)
const val CREATE_GROUP_CHATS = "CREATE TABLE group_chats (chat_id INTEGER PRIMARY KEY, name TEXT NOT NULL, description TEXT, avatar TEXT, mediator_pubkey BLOB NOT NULL, owner_pubkey BLOB NOT NULL, shared_key BLOB NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_message_time INTEGER DEFAULT 0, unread_count INTEGER DEFAULT 0, muted BOOL DEFAULT 0)"
const val CREATE_GROUP_INVITES = "CREATE TABLE group_invites (id INTEGER PRIMARY KEY AUTOINCREMENT, chat_id INTEGER NOT NULL, from_pubkey BLOB NOT NULL, timestamp INTEGER NOT NULL, chat_name TEXT NOT NULL, chat_description TEXT, chat_avatar TEXT, encrypted_data BLOB NOT NULL, status INTEGER DEFAULT 0)"
+
+ // Reactions table (version 12+)
+ // Stores reactions for both personal and group chat messages
+ // For personal chats: message_guid is the guid from messages table, chat_id is NULL
+ // For group chats: message_guid is the guid from messages_ table, chat_id is the group chat ID
+ const val CREATE_REACTIONS = "CREATE TABLE reactions (id INTEGER PRIMARY KEY AUTOINCREMENT, message_guid INTEGER NOT NULL, chat_id INTEGER, reactor_pubkey BLOB NOT NULL, emoji TEXT NOT NULL, timestamp INTEGER NOT NULL, UNIQUE(message_guid, chat_id, reactor_pubkey, emoji))"
+
+ // Pending reactions table (version 13+)
+ // Stores reactions that failed to send and need to be retried
+ // contact_pubkey is the recipient for personal chats, NULL for group chats
+ const val CREATE_PENDING_REACTIONS = "CREATE TABLE pending_reactions (id INTEGER PRIMARY KEY AUTOINCREMENT, message_guid INTEGER NOT NULL, chat_id INTEGER, contact_pubkey BLOB, emoji TEXT NOT NULL, add_reaction BOOL NOT NULL, timestamp INTEGER NOT NULL)"
}
data class Message(
@@ -108,6 +119,8 @@ class SqlStorage(val context: Context): SQLiteOpenHelper(context, DATABASE_NAME,
db.execSQL(CREATE_MESSAGES)
db.execSQL(CREATE_GROUP_CHATS)
db.execSQL(CREATE_GROUP_INVITES)
+ db.execSQL(CREATE_REACTIONS)
+ db.execSQL(CREATE_PENDING_REACTIONS)
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
@@ -179,6 +192,16 @@ class SqlStorage(val context: Context): SQLiteOpenHelper(context, DATABASE_NAME,
if (newVersion > oldVersion && newVersion > 10) {
migrateGroupMessageReplyToColumn(db)
}
+
+ if (oldVersion < 12 && newVersion >= 12) {
+ // Add reactions table
+ db.execSQL(CREATE_REACTIONS)
+ }
+
+ if (oldVersion < 13 && newVersion >= 13) {
+ // Add pending reactions table
+ db.execSQL(CREATE_PENDING_REACTIONS)
+ }
}
private fun migrateGroupMessageIncomingColumn(db: SQLiteDatabase) {
@@ -526,11 +549,13 @@ class SqlStorage(val context: Context): SQLiteOpenHelper(context, DATABASE_NAME,
}
if (this.writableDatabase.update("messages", values, "guid = ? AND contact = ?", arrayOf("$guid", "$contact")) > 0) {
val id = getMessageIdByGuid(guid)
- Log.i(TAG, "Message $id with guid $guid delivered = $delivered")
- for (listener in listeners) {
- listener.onMessageDelivered(id, delivered)
+ if (id != null) {
+ Log.i(TAG, "Message $id with guid $guid delivered = $delivered")
+ for (listener in listeners) {
+ listener.onMessageDelivered(id, delivered)
+ }
+ notificationManager.onMessageDelivered(id, delivered)
}
- notificationManager.onMessageDelivered(id, delivered)
}
}
@@ -836,14 +861,30 @@ class SqlStorage(val context: Context): SQLiteOpenHelper(context, DATABASE_NAME,
return this.writableDatabase.delete(messagesTable, "id = ?", arrayOf(messageId.toString())) > 0
}
- private fun getMessageIdByGuid(guid: Long): Long {
+ fun getMessageIdByGuid(guid: Long): Long? {
val db = this.readableDatabase
val statement = db.compileStatement("SELECT id FROM messages WHERE guid=? LIMIT 1")
statement.bindLong(1, guid)
val result = try {
statement.simpleQueryForLong()
} catch (e: SQLiteDoneException) {
- -1
+ statement.close()
+ return null
+ }
+ statement.close()
+ return result
+ }
+
+ fun getGroupMessageIdByGuid(guid: Long, chatId: Long): Long? {
+ val messagesTable = "messages_$chatId"
+ val db = this.readableDatabase
+ val statement = db.compileStatement("SELECT id FROM $messagesTable WHERE guid=? LIMIT 1")
+ statement.bindLong(1, guid)
+ val result = try {
+ statement.simpleQueryForLong()
+ } catch (e: SQLiteDoneException) {
+ statement.close()
+ return null
}
statement.close()
return result
@@ -2291,8 +2332,375 @@ class SqlStorage(val context: Context): SQLiteOpenHelper(context, DATABASE_NAME,
db.endTransaction()
}
}
+
+ // ==================== REACTIONS ====================
+
+ /**
+ * Adds or updates a reaction to a message.
+ * If the same user reacts with the same emoji again, it's treated as a duplicate (UNIQUE constraint).
+ * @param messageGuid GUID of the message
+ * @param chatId Group chat ID (null for personal chats)
+ * @param reactorPubkey Public key of the reactor
+ * @param emoji The emoji reaction
+ * @return The reaction ID, or -1 if failed
+ */
+ fun addReaction(messageGuid: Long, chatId: Long?, reactorPubkey: ByteArray, emoji: String): Long {
+ val values = ContentValues().apply {
+ put("message_guid", messageGuid)
+ if (chatId != null) {
+ put("chat_id", chatId)
+ }
+ put("reactor_pubkey", reactorPubkey)
+ put("emoji", emoji)
+ put("timestamp", System.currentTimeMillis())
+ }
+
+ val id = try {
+ writableDatabase.insertWithOnConflict("reactions", null, values, SQLiteDatabase.CONFLICT_IGNORE)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error adding reaction", e)
+ -1L
+ }
+
+ if (id > 0) {
+ // Notify listeners
+ for (listener in listeners) {
+ listener.onReactionAdded(messageGuid, chatId, emoji)
+ }
+ }
+
+ return id
+ }
+
+ /**
+ * Removes a specific reaction from a message.
+ * @param messageGuid GUID of the message
+ * @param chatId Group chat ID (null for personal chats)
+ * @param reactorPubkey Public key of the reactor
+ * @param emoji The emoji to remove
+ * @return true if successfully removed
+ */
+ fun removeReaction(messageGuid: Long, chatId: Long?, reactorPubkey: ByteArray, emoji: String): Boolean {
+ val pubkeyHex = Hex.toHexString(reactorPubkey)
+ val deleted = try {
+ if (chatId != null) {
+ writableDatabase.delete(
+ "reactions",
+ "message_guid = ? AND chat_id = ? AND HEX(reactor_pubkey) = ? AND emoji = ?",
+ arrayOf(messageGuid.toString(), chatId.toString(), pubkeyHex.uppercase(), emoji)
+ )
+ } else {
+ writableDatabase.delete(
+ "reactions",
+ "message_guid = ? AND chat_id IS NULL AND HEX(reactor_pubkey) = ? AND emoji = ?",
+ arrayOf(messageGuid.toString(), pubkeyHex.uppercase(), emoji)
+ )
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error removing reaction", e)
+ 0
+ }
+
+ if (deleted > 0) {
+ // Notify listeners
+ for (listener in listeners) {
+ listener.onReactionRemoved(messageGuid, chatId, emoji)
+ }
+ }
+
+ return deleted > 0
+ }
+
+ /**
+ * Gets all reactions for a specific message.
+ * @param messageGuid GUID of the message
+ * @param chatId Group chat ID (null for personal chats)
+ * @return List of reactions
+ */
+ fun getReactions(messageGuid: Long, chatId: Long?): List {
+ val reactions = mutableListOf()
+
+ val whereClause = if (chatId != null) {
+ "message_guid = ? AND chat_id = ?"
+ } else {
+ "message_guid = ? AND chat_id IS NULL"
+ }
+
+ val whereArgs = if (chatId != null) {
+ arrayOf(messageGuid.toString(), chatId.toString())
+ } else {
+ arrayOf(messageGuid.toString())
+ }
+
+ val cursor = readableDatabase.query(
+ "reactions",
+ arrayOf("id", "message_guid", "chat_id", "reactor_pubkey", "emoji", "timestamp"),
+ whereClause,
+ whereArgs,
+ null, null,
+ "timestamp ASC"
+ )
+
+ while (cursor.moveToNext()) {
+ reactions.add(Reaction(
+ id = cursor.getLong(0),
+ messageGuid = cursor.getLong(1),
+ chatId = cursor.getLong(2).takeIf { !cursor.isNull(2) },
+ reactorPubkey = cursor.getBlob(3), // Read as BLOB, not string
+ emoji = cursor.getString(4),
+ timestamp = cursor.getLong(5)
+ ))
+ }
+ cursor.close()
+
+ return reactions
+ }
+
+ /**
+ * Gets aggregated reaction counts for a message.
+ * Returns a map of emoji -> list of reactor pubkeys
+ */
+ fun getReactionCounts(messageGuid: Long, chatId: Long?): Map> {
+ val reactions = getReactions(messageGuid, chatId)
+ val grouped = reactions.groupBy { it.emoji }
+ return grouped.mapValues { entry -> entry.value.map { it.reactorPubkey } }
+ }
+
+ /**
+ * Checks if a user has reacted with a specific emoji.
+ */
+ fun hasReacted(messageGuid: Long, chatId: Long?, reactorPubkey: ByteArray, emoji: String): Boolean {
+ val pubkeyHex = Hex.toHexString(reactorPubkey)
+ val cursor = if (chatId != null) {
+ readableDatabase.query(
+ "reactions",
+ arrayOf("id"),
+ "message_guid = ? AND chat_id = ? AND HEX(reactor_pubkey) = ? AND emoji = ?",
+ arrayOf(messageGuid.toString(), chatId.toString(), pubkeyHex.uppercase(), emoji),
+ null, null, null, "1"
+ )
+ } else {
+ readableDatabase.query(
+ "reactions",
+ arrayOf("id"),
+ "message_guid = ? AND chat_id IS NULL AND HEX(reactor_pubkey) = ? AND emoji = ?",
+ arrayOf(messageGuid.toString(), pubkeyHex.uppercase(), emoji),
+ null, null, null, "1"
+ )
+ }
+
+ val exists = cursor.count > 0
+ cursor.close()
+ return exists
+ }
+
+ /**
+ * Gets the current reaction emoji from a specific user on a message.
+ * Returns null if the user hasn't reacted yet.
+ */
+ fun getUserCurrentReaction(messageGuid: Long, chatId: Long?, reactorPubkey: ByteArray): String? {
+ val pubkeyHex = Hex.toHexString(reactorPubkey)
+ val cursor = if (chatId != null) {
+ readableDatabase.query(
+ "reactions",
+ arrayOf("emoji"),
+ "message_guid = ? AND chat_id = ? AND HEX(reactor_pubkey) = ?",
+ arrayOf(messageGuid.toString(), chatId.toString(), pubkeyHex.uppercase()),
+ null, null, null, "1"
+ )
+ } else {
+ readableDatabase.query(
+ "reactions",
+ arrayOf("emoji"),
+ "message_guid = ? AND chat_id IS NULL AND HEX(reactor_pubkey) = ?",
+ arrayOf(messageGuid.toString(), pubkeyHex.uppercase()),
+ null, null, null, "1"
+ )
+ }
+
+ val emoji = if (cursor.moveToFirst()) {
+ cursor.getString(0)
+ } else {
+ null
+ }
+ cursor.close()
+ return emoji
+ }
+
+ /**
+ * Toggles a reaction - adds it if not present, removes it if present.
+ * Important: Each user can only have ONE reaction per message.
+ * If adding a new reaction, any existing reaction from this user is removed first.
+ * @return true if added, false if removed
+ */
+ fun toggleReaction(messageGuid: Long, chatId: Long?, reactorPubkey: ByteArray, emoji: String): Boolean {
+ return if (hasReacted(messageGuid, chatId, reactorPubkey, emoji)) {
+ // User clicked on their existing reaction - remove it
+ removeReaction(messageGuid, chatId, reactorPubkey, emoji)
+ false
+ } else {
+ // User is adding a new reaction
+ // First, remove any existing reaction from this user on this message
+ removeAllReactionsFromUser(messageGuid, chatId, reactorPubkey)
+ // Then add the new reaction
+ addReaction(messageGuid, chatId, reactorPubkey, emoji)
+ true
+ }
+ }
+
+ /**
+ * Removes all reactions from a specific user on a message.
+ * Used to enforce "one reaction per user" policy.
+ */
+ private fun removeAllReactionsFromUser(messageGuid: Long, chatId: Long?, reactorPubkey: ByteArray): Int {
+ val pubkeyHex = Hex.toHexString(reactorPubkey)
+ return try {
+ if (chatId != null) {
+ writableDatabase.delete(
+ "reactions",
+ "message_guid = ? AND chat_id = ? AND HEX(reactor_pubkey) = ?",
+ arrayOf(messageGuid.toString(), chatId.toString(), pubkeyHex.uppercase())
+ )
+ } else {
+ writableDatabase.delete(
+ "reactions",
+ "message_guid = ? AND chat_id IS NULL AND HEX(reactor_pubkey) = ?",
+ arrayOf(messageGuid.toString(), pubkeyHex.uppercase())
+ )
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error removing all user reactions", e)
+ 0
+ }
+ }
+
+ // ==================== PENDING REACTIONS ====================
+
+ /**
+ * Adds a pending reaction to the queue for later sending.
+ * Called when a reaction cannot be sent immediately due to no connection.
+ */
+ fun addPendingReaction(messageGuid: Long, chatId: Long?, contactPubkey: ByteArray?, emoji: String, add: Boolean): Long {
+ val values = ContentValues().apply {
+ put("message_guid", messageGuid)
+ if (chatId != null) {
+ put("chat_id", chatId)
+ }
+ if (contactPubkey != null) {
+ put("contact_pubkey", contactPubkey)
+ }
+ put("emoji", emoji)
+ put("add_reaction", add)
+ put("timestamp", System.currentTimeMillis())
+ }
+
+ return try {
+ writableDatabase.insert("pending_reactions", null, values)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error adding pending reaction", e)
+ -1L
+ }
+ }
+
+ /**
+ * Gets all pending reactions for a specific contact.
+ * Used when connection is established to send queued reactions.
+ */
+ fun getPendingReactionsForContact(contactPubkey: ByteArray): List {
+ val pubkeyHex = Hex.toHexString(contactPubkey)
+ val reactions = mutableListOf()
+
+ val cursor = readableDatabase.query(
+ "pending_reactions",
+ arrayOf("id", "message_guid", "chat_id", "emoji", "add_reaction", "timestamp"),
+ "HEX(contact_pubkey) = ? AND chat_id IS NULL",
+ arrayOf(pubkeyHex.uppercase()),
+ null, null, "timestamp ASC"
+ )
+
+ while (cursor.moveToNext()) {
+ reactions.add(PendingReaction(
+ id = cursor.getLong(0),
+ messageGuid = cursor.getLong(1),
+ chatId = cursor.getLong(2).takeIf { !cursor.isNull(2) },
+ contactPubkey = contactPubkey,
+ emoji = cursor.getString(3),
+ add = cursor.getInt(4) != 0,
+ timestamp = cursor.getLong(5)
+ ))
+ }
+ cursor.close()
+
+ return reactions
+ }
+
+ /**
+ * Gets all pending reactions for a specific group chat.
+ */
+ fun getPendingReactionsForChat(chatId: Long): List {
+ val reactions = mutableListOf()
+
+ val cursor = readableDatabase.query(
+ "pending_reactions",
+ arrayOf("id", "message_guid", "emoji", "add_reaction", "timestamp"),
+ "chat_id = ?",
+ arrayOf(chatId.toString()),
+ null, null, "timestamp ASC"
+ )
+
+ while (cursor.moveToNext()) {
+ reactions.add(PendingReaction(
+ id = cursor.getLong(0),
+ messageGuid = cursor.getLong(1),
+ chatId = chatId,
+ contactPubkey = null,
+ emoji = cursor.getString(2),
+ add = cursor.getInt(3) != 0,
+ timestamp = cursor.getLong(4)
+ ))
+ }
+ cursor.close()
+
+ return reactions
+ }
+
+ /**
+ * Removes a pending reaction after it has been successfully sent.
+ */
+ fun removePendingReaction(id: Long): Boolean {
+ return try {
+ writableDatabase.delete("pending_reactions", "id = ?", arrayOf(id.toString())) > 0
+ } catch (e: Exception) {
+ Log.e(TAG, "Error removing pending reaction", e)
+ false
+ }
+ }
+
+ /**
+ * Clears all pending reactions (e.g., for testing or cleanup).
+ */
+ fun clearAllPendingReactions(): Boolean {
+ return try {
+ writableDatabase.delete("pending_reactions", null, null)
+ true
+ } catch (e: Exception) {
+ Log.e(TAG, "Error clearing pending reactions", e)
+ false
+ }
+ }
}
+// Data class for pending reactions
+data class PendingReaction(
+ val id: Long,
+ val messageGuid: Long,
+ val chatId: Long?,
+ val contactPubkey: ByteArray?,
+ val emoji: String,
+ val add: Boolean,
+ val timestamp: Long
+)
+
// Data classes for group chat support
data class GroupChatInfo(
val chatId: Long,
@@ -2349,6 +2757,24 @@ data class GroupMemberInfo(
data class AccountInfo(val name: String, val info: String, val avatar: String, val updated: Long, val clientId: Int, val keyPair: AsymmetricCipherKeyPair)
data class Peer(val address: String, val clientId: Int, val priority: Int, val expiration: Long)
+/**
+ * Represents a reaction on a message.
+ * @param id Database row ID
+ * @param messageGuid GUID of the message being reacted to
+ * @param chatId Group chat ID (null for personal chats)
+ * @param reactorPubkey Public key of the user who reacted
+ * @param emoji The emoji reaction (UTF-8 string)
+ * @param timestamp When the reaction was added
+ */
+data class Reaction(
+ val id: Long,
+ val messageGuid: Long,
+ val chatId: Long?,
+ val reactorPubkey: ByteArray,
+ val emoji: String,
+ val timestamp: Long
+)
+
interface StorageListener {
fun onContactAdded(id: Long) {}
fun onContactRemoved(id: Long) {}
@@ -2363,4 +2789,7 @@ interface StorageListener {
fun onGroupMessageRead(chatId: Long, id: Long) {}
fun onGroupChatChanged(chatId: Long): Boolean { return false }
fun onGroupInviteReceived(inviteId: Long, chatId: Long, fromPubkey: ByteArray) {}
+
+ fun onReactionAdded(messageGuid: Long, chatId: Long?, emoji: String) {}
+ fun onReactionRemoved(messageGuid: Long, chatId: Long?, emoji: String) {}
}
\ No newline at end of file
diff --git a/apps/Android/app/src/main/java/com/revertron/mimir/ui/MessageAdapter.kt b/apps/Android/app/src/main/java/com/revertron/mimir/ui/MessageAdapter.kt
index b36f33f..f6482da 100644
--- a/apps/Android/app/src/main/java/com/revertron/mimir/ui/MessageAdapter.kt
+++ b/apps/Android/app/src/main/java/com/revertron/mimir/ui/MessageAdapter.kt
@@ -4,6 +4,7 @@ import android.graphics.PorterDuff
import android.graphics.drawable.Drawable
import android.net.Uri
import android.util.Log
+import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -12,6 +13,7 @@ import androidx.appcompat.widget.AppCompatImageView
import androidx.appcompat.widget.AppCompatTextView
import androidx.core.content.ContextCompat
import androidx.core.view.updateMargins
+import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.RecyclerView
import com.revertron.mimir.R
import com.revertron.mimir.getAvatarColor
@@ -27,7 +29,7 @@ class MessageAdapter(
private val chatId: Long,
private val groupChat: Boolean,
private val contactName: String,
- private val onClick: View.OnClickListener,
+ private val onLongClick: View.OnLongClickListener,
private val onReplyClick: View.OnClickListener,
private val onPictureClick: View.OnClickListener
): RecyclerView.Adapter() {
@@ -56,6 +58,7 @@ class MessageAdapter(
val replyToName: AppCompatTextView = view.findViewById(R.id.reply_contact_name)
val replyToText: AppCompatTextView = view.findViewById(R.id.reply_text)
val replyToPanel: View = view.findViewById(R.id.reply_panel)
+ val reactionsContainer: androidx.appcompat.widget.LinearLayoutCompat = view.findViewById(R.id.reactions_container)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
@@ -65,8 +68,14 @@ class MessageAdapter(
R.layout.message_outgoing_layout
}
val view = LayoutInflater.from(parent.context).inflate(layout, parent, false)
- view.setOnClickListener(onClick)
- //TODO make item background reflect touches
+
+ // Single tap shows context menu with reactions
+ view.setOnClickListener { v ->
+ onLongClick.onLongClick(v) // Trigger long click behavior on regular click
+ }
+ // Keep long click as fallback
+ view.setOnLongClickListener(onLongClick)
+
view.findViewById(R.id.reply_panel).setOnClickListener(onReplyClick)
view.findViewById(R.id.picture).setOnClickListener(onPictureClick)
if (!groupChat) {
@@ -83,6 +92,11 @@ class MessageAdapter(
storage.getMessage(messageIds[position].first)
} ?: return
+ val prefs = PreferenceManager.getDefaultSharedPreferences(holder.itemView.context)
+ val fontSize = prefs.getInt(SettingsData.KEY_MESSAGE_FONT_SIZE, 15)
+ holder.message.setTextSize(TypedValue.COMPLEX_UNIT_SP, fontSize.toFloat())
+ holder.replyToText.setTextSize(TypedValue.COMPLEX_UNIT_SP, (fontSize - 2).toFloat())
+
if (groupChat) {
if (message.incoming) {
val user = users.getOrPut(message.contact, {
@@ -223,6 +237,9 @@ class MessageAdapter(
} else {
storage.setGroupMessageRead(chatId, message.id, true)
}
+
+ // Display reactions for this message
+ displayReactions(holder, message.guid)
}
private fun formatTime(time: Long): String {
@@ -275,4 +292,226 @@ class MessageAdapter(
}
return -1
}
+
+ /**
+ * Shows the reaction picker popup when a message is tapped.
+ */
+ // Deprecated: Reaction picker is now shown in ChatActivity alongside context menu
+ @Deprecated("Moved to ChatActivity.showQuickReactions()")
+ private fun showReactionPicker(messageView: View) {
+ // This method is no longer used but kept for reference
+ return
+ val messageId = messageView.tag as? Long ?: return
+
+ // Get the message to find its GUID
+ val message = if (groupChat) {
+ storage.getGroupMessage(chatId, messageId)
+ } else {
+ storage.getMessage(messageId)
+ } ?: return
+
+ val chatIdForReactions = if (groupChat) chatId else null
+
+ // Create popup window
+ val popupView = LayoutInflater.from(messageView.context)
+ .inflate(R.layout.reaction_picker_popup, null)
+
+ // Measure the popup view to get its dimensions
+ popupView.measure(
+ View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
+ View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
+ )
+
+ val popupWindow = android.widget.PopupWindow(
+ popupView,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ true
+ )
+
+ popupWindow.elevation = 8f
+ popupWindow.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(android.graphics.Color.TRANSPARENT))
+ popupWindow.isOutsideTouchable = true
+ popupWindow.isFocusable = true
+
+ // Get current user's pubkey
+ val currentUserPubkey = storage.getAccountInfo(1, 0L)?.let {
+ (it.keyPair.public as org.bouncycastle.crypto.params.Ed25519PublicKeyParameters).encoded
+ }
+
+ // Set up emoji click listeners (compact: 5 most popular)
+ val emojis = listOf(
+ R.id.emoji_like to "👍",
+ R.id.emoji_love to "❤️",
+ R.id.emoji_laugh to "😂",
+ R.id.emoji_fire to "🔥",
+ R.id.emoji_clap to "👏"
+ )
+
+ for ((viewId, emoji) in emojis) {
+ popupView.findViewById(viewId)?.setOnClickListener {
+ popupWindow.dismiss()
+
+ if (currentUserPubkey != null) {
+ // Check if user has an existing reaction (to send removal to peer)
+ val existingReaction = storage.getUserCurrentReaction(message.guid, chatIdForReactions, currentUserPubkey)
+
+ // Toggle the reaction locally
+ val added = storage.toggleReaction(message.guid, chatIdForReactions, currentUserPubkey, emoji)
+
+ // Post update to main thread to avoid RecyclerView inconsistency
+ (messageView.context as? android.app.Activity)?.runOnUiThread {
+ try {
+ notifyDataSetChanged()
+ } catch (e: Exception) {
+ android.util.Log.e("MessageAdapter", "Error updating reactions", e)
+ }
+ }
+
+ // If user had a different reaction, send removal first
+ if (existingReaction != null && existingReaction != emoji) {
+ val removeIntent = android.content.Intent(messageView.context, com.revertron.mimir.ConnectionService::class.java)
+ removeIntent.putExtra("command", "ACTION_SEND_REACTION")
+ removeIntent.putExtra("messageGuid", message.guid)
+ removeIntent.putExtra("emoji", existingReaction)
+ removeIntent.putExtra("add", false)
+ if (chatIdForReactions != null) {
+ removeIntent.putExtra("chatId", chatIdForReactions)
+ }
+ if (!groupChat) {
+ val contactPubkey = storage.getContactPubkey(chatId)
+ if (contactPubkey != null) {
+ removeIntent.putExtra("contactPubkey", contactPubkey)
+ }
+ }
+ messageView.context.startService(removeIntent)
+ }
+
+ // Send the new reaction to peer via ConnectionService
+ val intent = android.content.Intent(messageView.context, com.revertron.mimir.ConnectionService::class.java)
+ intent.putExtra("command", "ACTION_SEND_REACTION")
+ intent.putExtra("messageGuid", message.guid)
+ intent.putExtra("emoji", emoji)
+ intent.putExtra("add", added)
+ if (chatIdForReactions != null) {
+ intent.putExtra("chatId", chatIdForReactions)
+ }
+ // For personal chats, need contact pubkey
+ if (!groupChat) {
+ val contactPubkey = storage.getContactPubkey(chatId)
+ if (contactPubkey != null) {
+ intent.putExtra("contactPubkey", contactPubkey)
+ }
+ }
+ messageView.context.startService(intent)
+ }
+ }
+ }
+
+ // Calculate position: show popup centered above the message
+ val location = IntArray(2)
+ messageView.getLocationOnScreen(location)
+ val messageX = location[0]
+ val messageY = location[1]
+
+ val popupWidth = popupView.measuredWidth
+ val xOffset = (messageView.width - popupWidth) / 2
+ val yOffset = -messageView.height - popupView.measuredHeight - 8
+
+ // Show popup above the message
+ popupWindow.showAsDropDown(messageView, xOffset, yOffset, android.view.Gravity.NO_GRAVITY)
+ }
+
+ /**
+ * Displays reactions for a message in the reactions container.
+ * Shows up to 4 reactions per row, wraps to new rows as needed.
+ * @param holder ViewHolder containing the reactions container
+ * @param messageGuid GUID of the message
+ */
+ private fun displayReactions(holder: ViewHolder, messageGuid: Long) {
+ // Get reactions from storage
+ val chatIdForReactions = if (groupChat) chatId else null
+ val reactionCounts = storage.getReactionCounts(messageGuid, chatIdForReactions)
+
+ if (reactionCounts.isEmpty()) {
+ holder.reactionsContainer.visibility = View.GONE
+ return
+ }
+
+ holder.reactionsContainer.visibility = View.VISIBLE
+ holder.reactionsContainer.removeAllViews()
+
+ // Get current user's pubkey to highlight their reactions
+ val currentUserPubkey = storage.getAccountInfo(1, 0L)?.let {
+ (it.keyPair.public as org.bouncycastle.crypto.params.Ed25519PublicKeyParameters).encoded
+ }
+
+ // Create reaction badges with max 4 per row
+ var currentRow: androidx.appcompat.widget.LinearLayoutCompat? = null
+ var itemsInRow = 0
+ val maxPerRow = 4
+
+ for ((emoji, reactors) in reactionCounts) {
+ // Create new row if needed
+ if (currentRow == null || itemsInRow >= maxPerRow) {
+ currentRow = androidx.appcompat.widget.LinearLayoutCompat(holder.itemView.context)
+ currentRow.orientation = androidx.appcompat.widget.LinearLayoutCompat.HORIZONTAL
+ holder.reactionsContainer.addView(currentRow)
+ itemsInRow = 0
+ }
+
+ // Inflate reaction badge
+ val badgeView = LayoutInflater.from(holder.itemView.context)
+ .inflate(R.layout.reaction_badge, currentRow, false)
+
+ val emojiView = badgeView.findViewById(R.id.reaction_emoji)
+ val countView = badgeView.findViewById(R.id.reaction_count)
+
+ emojiView.text = emoji
+ // Show count only if more than 1 reactor
+ if (reactors.size > 1) {
+ countView.text = reactors.size.toString()
+ countView.visibility = View.VISIBLE
+ } else {
+ countView.visibility = View.GONE
+ }
+
+ // Highlight if current user has this reaction
+ val userReacted = currentUserPubkey != null && reactors.any { it.contentEquals(currentUserPubkey) }
+ if (userReacted) {
+ badgeView.setBackgroundResource(R.drawable.reaction_badge_background_selected)
+ } else {
+ badgeView.setBackgroundResource(R.drawable.reaction_badge_background)
+ }
+
+ // Click to toggle reaction
+ badgeView.setOnClickListener {
+ if (currentUserPubkey != null) {
+ val added = storage.toggleReaction(messageGuid, chatIdForReactions, currentUserPubkey, emoji)
+ notifyItemChanged(holder.bindingAdapterPosition)
+
+ // Send reaction to peer via ConnectionService
+ val intent = android.content.Intent(holder.itemView.context, com.revertron.mimir.ConnectionService::class.java)
+ intent.putExtra("command", "ACTION_SEND_REACTION")
+ intent.putExtra("messageGuid", messageGuid)
+ intent.putExtra("emoji", emoji)
+ intent.putExtra("add", added)
+ if (chatIdForReactions != null) {
+ intent.putExtra("chatId", chatIdForReactions)
+ }
+ // For personal chats, need contact pubkey
+ if (!groupChat) {
+ val contactPubkey = storage.getContactPubkey(chatId)
+ if (contactPubkey != null) {
+ intent.putExtra("contactPubkey", contactPubkey)
+ }
+ }
+ holder.itemView.context.startService(intent)
+ }
+ }
+
+ currentRow.addView(badgeView)
+ itemsInRow++
+ }
+ }
}
\ No newline at end of file
diff --git a/apps/Android/app/src/main/java/com/revertron/mimir/ui/SettingsData.kt b/apps/Android/app/src/main/java/com/revertron/mimir/ui/SettingsData.kt
index 2bf5049..73a6eb1 100644
--- a/apps/Android/app/src/main/java/com/revertron/mimir/ui/SettingsData.kt
+++ b/apps/Android/app/src/main/java/com/revertron/mimir/ui/SettingsData.kt
@@ -9,6 +9,7 @@ object SettingsData {
const val KEY_AUTO_UPDATES = "auto-updates"
const val KEY_IMAGES_FORMAT = "images-format"
const val KEY_IMAGES_QUALITY = "images-quality"
+ const val KEY_MESSAGE_FONT_SIZE = "message-font-size"
fun create(context: Context): List {
val sp = PreferenceManager.getDefaultSharedPreferences(context)
@@ -30,6 +31,14 @@ object SettingsData {
checked = false
),
+ SettingsAdapter.Item(
+ id = R.string.message_font_size,
+ titleRes = R.string.message_font_size,
+ descriptionRes = R.string.message_font_size_desc,
+ isSwitch = false,
+ checked = false
+ ),
+
SettingsAdapter.Item(
id = R.string.automatic_updates_checking,
titleRes = R.string.automatic_updates_checking,
diff --git a/apps/Android/app/src/main/res/drawable/reaction_badge_background.xml b/apps/Android/app/src/main/res/drawable/reaction_badge_background.xml
new file mode 100644
index 0000000..8a3d479
--- /dev/null
+++ b/apps/Android/app/src/main/res/drawable/reaction_badge_background.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/apps/Android/app/src/main/res/drawable/reaction_badge_background_selected.xml b/apps/Android/app/src/main/res/drawable/reaction_badge_background_selected.xml
new file mode 100644
index 0000000..8582805
--- /dev/null
+++ b/apps/Android/app/src/main/res/drawable/reaction_badge_background_selected.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/apps/Android/app/src/main/res/drawable/reaction_picker_background.xml b/apps/Android/app/src/main/res/drawable/reaction_picker_background.xml
new file mode 100644
index 0000000..a170cf3
--- /dev/null
+++ b/apps/Android/app/src/main/res/drawable/reaction_picker_background.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/apps/Android/app/src/main/res/layout/activity_accounts.xml b/apps/Android/app/src/main/res/layout/activity_accounts.xml
index 21ef531..7aba9bc 100644
--- a/apps/Android/app/src/main/res/layout/activity_accounts.xml
+++ b/apps/Android/app/src/main/res/layout/activity_accounts.xml
@@ -51,19 +51,25 @@
+ android:padding="8dp"
+ android:background="?attr/selectableItemBackgroundBorderless"
+ android:clickable="true"
+ android:focusable="true" />
+ android:padding="8dp"
+ android:background="?attr/selectableItemBackgroundBorderless"
+ android:clickable="true"
+ android:focusable="true" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/Android/app/src/main/res/layout/message_context_menu.xml b/apps/Android/app/src/main/res/layout/message_context_menu.xml
new file mode 100644
index 0000000..695bebb
--- /dev/null
+++ b/apps/Android/app/src/main/res/layout/message_context_menu.xml
@@ -0,0 +1,222 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/Android/app/src/main/res/layout/message_incoming_layout.xml b/apps/Android/app/src/main/res/layout/message_incoming_layout.xml
index 7479d79..5a44ec6 100644
--- a/apps/Android/app/src/main/res/layout/message_incoming_layout.xml
+++ b/apps/Android/app/src/main/res/layout/message_incoming_layout.xml
@@ -92,6 +92,18 @@
app:layout_constraintTop_toBottomOf="@id/text"
app:srcCompat="@drawable/ic_message_delivered" />
+
+
+
\ No newline at end of file
diff --git a/apps/Android/app/src/main/res/layout/message_outgoing_layout.xml b/apps/Android/app/src/main/res/layout/message_outgoing_layout.xml
index d42588d..e1af44c 100644
--- a/apps/Android/app/src/main/res/layout/message_outgoing_layout.xml
+++ b/apps/Android/app/src/main/res/layout/message_outgoing_layout.xml
@@ -83,5 +83,17 @@
app:layout_constraintTop_toBottomOf="@id/text"
app:srcCompat="@drawable/ic_message_delivered" />
+
+
+
\ No newline at end of file
diff --git a/apps/Android/app/src/main/res/layout/reaction_badge.xml b/apps/Android/app/src/main/res/layout/reaction_badge.xml
new file mode 100644
index 0000000..d3738b8
--- /dev/null
+++ b/apps/Android/app/src/main/res/layout/reaction_badge.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/Android/app/src/main/res/layout/reaction_picker_popup.xml b/apps/Android/app/src/main/res/layout/reaction_picker_popup.xml
new file mode 100644
index 0000000..7cff93b
--- /dev/null
+++ b/apps/Android/app/src/main/res/layout/reaction_picker_popup.xml
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/Android/app/src/main/res/layout/reactions_quick_bar.xml b/apps/Android/app/src/main/res/layout/reactions_quick_bar.xml
new file mode 100644
index 0000000..27b3537
--- /dev/null
+++ b/apps/Android/app/src/main/res/layout/reactions_quick_bar.xml
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/Android/app/src/main/res/values-night/colors.xml b/apps/Android/app/src/main/res/values-night/colors.xml
new file mode 100644
index 0000000..69bbdfb
--- /dev/null
+++ b/apps/Android/app/src/main/res/values-night/colors.xml
@@ -0,0 +1,12 @@
+
+
+
+ #FF383838
+ #FF606060
+ #FF1A3A52
+ #FF2196F3
+
+
+ #FF2A2A2A
+ #FF606060
+
diff --git a/apps/Android/app/src/main/res/values-ru/strings.xml b/apps/Android/app/src/main/res/values-ru/strings.xml
index 4f32adb..669c44c 100644
--- a/apps/Android/app/src/main/res/values-ru/strings.xml
+++ b/apps/Android/app/src/main/res/values-ru/strings.xml
@@ -80,7 +80,7 @@
tcp://1.2.3.4:1234 или tls://example.com:443
Включена оптимизация батареи. Вы должны внести это приложение в исключения, чтобы оно работало без перебоев.
Разрешить
- У прилоения запрещены уведомления. Пожалуйста, разрешите их чтобы получать сообщения и звонки.
+ У приложения запрещены уведомления. Пожалуйста, разрешите их чтобы получать сообщения и звонки.
Выберите контакт
Добавить свой узел
Добавить
@@ -191,4 +191,18 @@
%1$d участников, %2$d в сети
%d участников
Системное сообщение
+ Размер шрифта сообщений
+ Настройка размера текста в сообщениях чатов.
+ Настройка размера шрифта
+ Размер текста сообщений:
+ Предпросмотр:
+ Это предпросмотр того, как будут выглядеть ваши сообщения с выбранным размером шрифта.
+ Выберите источник изображения
+ Сделать фото
+ Выбрать из галереи
+ Ошибка при съёмке фото
+ Удалить аватар
+ Вы уверены, что хотите удалить свой аватар?
+ Нет аватара для удаления
+ Аватар удалён
\ No newline at end of file
diff --git a/apps/Android/app/src/main/res/values/colors.xml b/apps/Android/app/src/main/res/values/colors.xml
index 022079b..f8005e9 100644
--- a/apps/Android/app/src/main/res/values/colors.xml
+++ b/apps/Android/app/src/main/res/values/colors.xml
@@ -22,5 +22,15 @@
#FF305090
#FF4565A5
+
+ #F0F0F0F0
+ #FFD0D0D0
+ #FFE8F4FD
+ #FF2196F3
+
+
+ #FFFFFFFF
+ #FFD0D0D0
+
#00000000
\ No newline at end of file
diff --git a/apps/Android/app/src/main/res/values/strings.xml b/apps/Android/app/src/main/res/values/strings.xml
index deb73fc..706845f 100644
--- a/apps/Android/app/src/main/res/values/strings.xml
+++ b/apps/Android/app/src/main/res/values/strings.xml
@@ -203,5 +203,19 @@
%1$d members, %2$d online
%d members
System message
+ Message font size
+ Adjust the size of text in chat messages.
+ Font size settings
+ Message text size:
+ Preview:
+ This is a preview of how your messages will look with the selected font size.
+ Choose image source
+ Take photo
+ Choose from gallery
+ Error taking photo
+ Delete avatar
+ Are you sure you want to delete your avatar?
+ No avatar to delete
+ Avatar deleted
\ No newline at end of file
diff --git a/apps/Android/app/src/main/res/xml/file_paths.xml b/apps/Android/app/src/main/res/xml/file_paths.xml
index ea865d3..eaed88a 100644
--- a/apps/Android/app/src/main/res/xml/file_paths.xml
+++ b/apps/Android/app/src/main/res/xml/file_paths.xml
@@ -4,4 +4,6 @@
+
+
\ No newline at end of file