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