From 60ea5ba3a7fec595df8e62eaba859b071bf23b16 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 9 Aug 2023 20:02:51 +0930 Subject: [PATCH 01/13] Hide send button when message contains only whitespace --- .../securesms/conversation/v2/input_bar/InputBar.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt index 73e2d571c0..ffdc425c5b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt @@ -13,6 +13,7 @@ import android.view.MotionEvent import android.view.inputmethod.EditorInfo import android.widget.RelativeLayout import android.widget.TextView +import androidx.core.view.isGone import androidx.core.view.isVisible import network.loki.messenger.R import network.loki.messenger.databinding.ViewInputBarBinding @@ -118,8 +119,8 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li // region Updating override fun inputBarEditTextContentChanged(text: CharSequence) { - sendButton.isVisible = text.isNotEmpty() - microphoneButton.isVisible = text.isEmpty() + microphoneButton.isVisible = text.all { it.isWhitespace() } + sendButton.isVisible = microphoneButton.isGone delegate?.inputBarEditTextContentChanged(text) } From c647bab35ea78659d17b9e03c6f63584432230c4 Mon Sep 17 00:00:00 2001 From: Rugved Darwhekar Date: Sun, 29 Oct 2023 15:58:54 -0700 Subject: [PATCH 02/13] Fixed video call auto rotate, when auto rotate is disabled --- .../securesms/calls/WebRtcCallActivity.kt | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt index b87eac12c4..1567531cd2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt @@ -21,6 +21,7 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.isActive +import android.provider.Settings import kotlinx.coroutines.launch import network.loki.messenger.R import network.loki.messenger.databinding.ActivityWebrtcBinding @@ -99,7 +100,14 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() { override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { super.onCreate(savedInstanceState, ready) - rotationListener.enable() + + // Only enable auto-rotate if system auto-rotate is enabled + if (isAutoRotateOn()) { + rotationListener.enable() + } else { + rotationListener.disable() + } + binding = ActivityWebrtcBinding.inflate(layoutInflater) setContentView(binding.root) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { @@ -183,6 +191,14 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() { } + //Function to check if Android System Auto-rotate is on or off + private fun isAutoRotateOn(): Boolean { + return Settings.System.getInt( + contentResolver, + Settings.System.ACCELEROMETER_ROTATION, 0 + ) == 1 + } + override fun onDestroy() { super.onDestroy() hangupReceiver?.let { receiver -> From 172edde628f6d982d3c7396a19415d78faf8e6d3 Mon Sep 17 00:00:00 2001 From: fanchao Date: Tue, 4 Jun 2024 10:41:15 +1000 Subject: [PATCH 03/13] Correct the usage of flowOn --- .../thoughtcrime/securesms/home/HomeViewModel.kt | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt index fa18a995b6..6819054b31 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt @@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.home import android.content.ContentResolver import android.content.Context -import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope @@ -22,9 +21,9 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.withContext import org.session.libsession.utilities.TextSecurePreferences import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.database.DatabaseContentProviders @@ -60,12 +59,10 @@ class HomeViewModel @Inject constructor( observeTypingStatus(), messageRequests(), ::Data - ) - .stateIn(viewModelScope, SharingStarted.Eagerly, null) + ).stateIn(viewModelScope, SharingStarted.Eagerly, null) private fun hasHiddenMessageRequests() = TextSecurePreferences.events .filter { it == TextSecurePreferences.HAS_HIDDEN_MESSAGE_REQUESTS } - .flowOn(Dispatchers.IO) .map { prefs.hasHiddenMessageRequests() } .onStart { emit(prefs.hasHiddenMessageRequests()) } @@ -81,7 +78,7 @@ class HomeViewModel @Inject constructor( hasHiddenMessageRequests(), latestUnapprovedConversationTimestamp(), ::createMessageRequests - ) + ).flowOn(Dispatchers.IO) private fun unapprovedConversationCount() = reloadTriggersAndContentChanges() .map { threadDb.unapprovedConversationCount } @@ -96,13 +93,13 @@ class HomeViewModel @Inject constructor( threadDb.readerFor(openCursor).run { generateSequence { next }.toList() } } } + .flowOn(Dispatchers.IO) @OptIn(FlowPreview::class) private fun reloadTriggersAndContentChanges() = merge( manualReloadTrigger, contentResolver.observeChanges(DatabaseContentProviders.ConversationList.CONTENT_URI) ) - .flowOn(Dispatchers.IO) .debounce(CHANGE_NOTIFICATION_DEBOUNCE_MILLS) .onStart { emit(Unit) } @@ -114,7 +111,7 @@ class HomeViewModel @Inject constructor( val messageRequests: MessageRequests? = null ) - fun createMessageRequests( + private fun createMessageRequests( count: Int, hidden: Boolean, timestamp: Long From 6e24df0547cb6dabe560939bb7fed8b22ce2e988 Mon Sep 17 00:00:00 2001 From: fanchao Date: Tue, 4 Jun 2024 10:46:02 +1000 Subject: [PATCH 04/13] Import --- .../main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt index 6819054b31..dd6d24cd00 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt @@ -23,7 +23,6 @@ import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.withContext import org.session.libsession.utilities.TextSecurePreferences import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.database.DatabaseContentProviders From 0547dde554774741dd612b5eb7301d1fc69cc062 Mon Sep 17 00:00:00 2001 From: fanchao Date: Mon, 24 Jun 2024 14:05:06 +1000 Subject: [PATCH 05/13] Remove the use of executor in ThreadUtils --- .../org/thoughtcrime/securesms/AppContext.kt | 4 ++- .../libsignal/utilities/ThreadUtils.kt | 29 +++---------------- 2 files changed, 7 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/AppContext.kt b/app/src/main/java/org/thoughtcrime/securesms/AppContext.kt index 2588618b72..b9183939cf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/AppContext.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/AppContext.kt @@ -1,5 +1,7 @@ package org.thoughtcrime.securesms +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asExecutor import nl.komponents.kovenant.Kovenant import nl.komponents.kovenant.jvm.asDispatcher import org.session.libsignal.utilities.Log @@ -11,7 +13,7 @@ object AppContext { fun configureKovenant() { Kovenant.context { callbackContext.dispatcher = Executors.newSingleThreadExecutor().asDispatcher() - workerContext.dispatcher = ThreadUtils.executorPool.asDispatcher() + workerContext.dispatcher = Dispatchers.IO.asExecutor().asDispatcher() multipleCompletion = { v1, v2 -> Log.d("Loki", "Promise resolved more than once (first with $v1, then with $v2); ignoring $v2.") } diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/ThreadUtils.kt b/libsignal/src/main/java/org/session/libsignal/utilities/ThreadUtils.kt index e920d85b47..6485babe80 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/ThreadUtils.kt +++ b/libsignal/src/main/java/org/session/libsignal/utilities/ThreadUtils.kt @@ -1,11 +1,13 @@ package org.session.libsignal.utilities import android.os.Process +import kotlinx.coroutines.Dispatchers import java.util.concurrent.ExecutorService import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.SynchronousQueue import java.util.concurrent.ThreadPoolExecutor import java.util.concurrent.TimeUnit +import kotlin.coroutines.EmptyCoroutineContext object ThreadUtils { @@ -13,39 +15,16 @@ object ThreadUtils { const val PRIORITY_IMPORTANT_BACKGROUND_THREAD = Process.THREAD_PRIORITY_DEFAULT + Process.THREAD_PRIORITY_LESS_FAVORABLE - // Paraphrased from: https://www.baeldung.com/kotlin/create-thread-pool - // "A cached thread pool such as one created via: - // `val executorPool: ExecutorService = Executors.newCachedThreadPool()` - // will utilize resources according to the requirements of submitted tasks. It will try to reuse - // existing threads for submitted tasks but will create as many threads as it needs if new tasks - // keep pouring in (with a memory usage of at least 1MB per created thread). These threads will - // live for up to 60 seconds of idle time before terminating by default. As such, it presents a - // very sharp tool that doesn't include any backpressure mechanism - and a sudden peak in load - // can bring the system down with an OutOfMemory error. We can achieve a similar effect but with - // better control by creating a ThreadPoolExecutor manually." - - private val corePoolSize = Runtime.getRuntime().availableProcessors() // Default thread pool size is our CPU core count - private val maxPoolSize = corePoolSize * 4 // Allow a maximum pool size of up to 4 threads per core - private val keepAliveTimeSecs = 100L // How long to keep idle threads in the pool before they are terminated - private val workQueue = SynchronousQueue() - val executorPool: ExecutorService = ThreadPoolExecutor(corePoolSize, maxPoolSize, keepAliveTimeSecs, TimeUnit.SECONDS, workQueue) - // Note: To see how many threads are running in our app at any given time we can use: // val threadCount = getAllStackTraces().size @JvmStatic fun queue(target: Runnable) { - executorPool.execute { - try { - target.run() - } catch (e: Exception) { - Log.e(TAG, e) - } - } + queue(target::run) } fun queue(target: () -> Unit) { - executorPool.execute { + Dispatchers.IO.dispatch(EmptyCoroutineContext) { try { target() } catch (e: Exception) { From 031a18061dd273b988db15ab63deeecd6c6ce022 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 24 Jun 2024 17:26:36 +1000 Subject: [PATCH 06/13] Using trim and empty to capture semantic concept of nothing being in there --- .../securesms/conversation/v2/input_bar/InputBar.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt index f183cff13f..c036ed38b4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt @@ -120,7 +120,7 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li // region Updating override fun inputBarEditTextContentChanged(text: CharSequence) { - microphoneButton.isVisible = text.all { it.isWhitespace() } + microphoneButton.isVisible = text.trim().isEmpty() sendButton.isVisible = microphoneButton.isGone delegate?.inputBarEditTextContentChanged(text) } From d22cb1e2c250dae262c62fd9bc6df1a29d422d96 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 25 Jun 2024 09:25:46 +1000 Subject: [PATCH 07/13] Remove config checks (PR 1294) Refactor: remove checks for whether new config is enabled throughout config factory generation. First commit from PR 1294. --- .../securesms/dependencies/ConfigFactory.kt | 9 --- .../securesms/home/HomeActivity.kt | 4 +- .../util/ConfigurationMessageUtilities.kt | 57 ++----------------- .../loki/messenger/libsession_util/Config.kt | 6 -- .../messaging/jobs/ConfigurationSyncJob.kt | 8 +-- .../ReceivedMessageHandler.kt | 10 ++-- 6 files changed, 11 insertions(+), 83 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt index 8379e1a23b..505a7939a8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt @@ -7,7 +7,6 @@ import network.loki.messenger.libsession_util.Contacts import network.loki.messenger.libsession_util.ConversationVolatileConfig import network.loki.messenger.libsession_util.UserGroupsConfig import network.loki.messenger.libsession_util.UserProfile -import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ConfigFactoryUpdateListener import org.session.libsession.utilities.TextSecurePreferences @@ -72,7 +71,6 @@ class ConfigFactory( override val user: UserProfile? get() = synchronizedWithLog(userLock) { - if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null if (_userConfig == null) { val (secretKey, publicKey) = maybeGetUserInfo() ?: return null val userDump = configDatabase.retrieveConfigAndHashes( @@ -92,7 +90,6 @@ class ConfigFactory( override val contacts: Contacts? get() = synchronizedWithLog(contactsLock) { - if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null if (_contacts == null) { val (secretKey, publicKey) = maybeGetUserInfo() ?: return null val contactsDump = configDatabase.retrieveConfigAndHashes( @@ -112,7 +109,6 @@ class ConfigFactory( override val convoVolatile: ConversationVolatileConfig? get() = synchronizedWithLog(convoVolatileLock) { - if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null if (_convoVolatileConfig == null) { val (secretKey, publicKey) = maybeGetUserInfo() ?: return null val convoDump = configDatabase.retrieveConfigAndHashes( @@ -133,7 +129,6 @@ class ConfigFactory( override val userGroups: UserGroupsConfig? get() = synchronizedWithLog(userGroupsLock) { - if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null if (_userGroups == null) { val (secretKey, publicKey) = maybeGetUserInfo() ?: return null val userGroupsDump = configDatabase.retrieveConfigAndHashes( @@ -207,8 +202,6 @@ class ConfigFactory( openGroupId: String?, visibleOnly: Boolean ): Boolean { - if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return true - val (_, userPublicKey) = maybeGetUserInfo() ?: return true if (openGroupId != null) { @@ -241,8 +234,6 @@ class ConfigFactory( } override fun canPerformChange(variant: String, publicKey: String, changeTimestampMs: Long): Boolean { - if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return true - val lastUpdateTimestampMs = configDatabase.retrieveConfigLastUpdateTimestamp(variant, publicKey) // Ensure the change occurred after the last config message was handled (minus the buffer period) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index c063f30538..4b0cf60c3c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -27,7 +27,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.databinding.ActivityHomeBinding -import network.loki.messenger.libsession_util.ConfigBase import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -336,8 +335,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } private fun updateLegacyConfigView() { - binding.configOutdatedView.isVisible = ConfigBase.isNewConfigEnabled(textSecurePreferences.hasForcedNewConfig(), SnodeAPI.nowWithOffset) - && textSecurePreferences.getHasLegacyConfig() + binding.configOutdatedView.isVisible = textSecurePreferences.getHasLegacyConfig() } override fun onResume() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt index 9d10cfdab5..e0f0492445 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt @@ -17,8 +17,6 @@ import org.session.libsession.messaging.jobs.ConfigurationSyncJob import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.control.ConfigurationMessage -import org.session.libsession.messaging.sending_receiving.MessageSender -import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.Address import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.TextSecurePreferences @@ -55,61 +53,16 @@ object ConfigurationMessageUtilities { fun syncConfigurationIfNeeded(context: Context) { // add if check here to schedule new config job process and return early val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return - val forcedConfig = TextSecurePreferences.hasForcedNewConfig(context) - val currentTime = SnodeAPI.nowWithOffset - if (ConfigBase.isNewConfigEnabled(forcedConfig, currentTime)) { - scheduleConfigSync(userPublicKey) - return - } - val lastSyncTime = TextSecurePreferences.getLastConfigurationSyncTime(context) - val now = System.currentTimeMillis() - if (now - lastSyncTime < 7 * 24 * 60 * 60 * 1000) return - val contacts = ContactUtilities.getAllContacts(context).filter { recipient -> - !recipient.name.isNullOrEmpty() && !recipient.isLocalNumber && recipient.address.serialize().isNotEmpty() - }.map { recipient -> - ConfigurationMessage.Contact( - publicKey = recipient.address.serialize(), - name = recipient.name!!, - profilePicture = recipient.profileAvatar, - profileKey = recipient.profileKey, - isApproved = recipient.isApproved, - isBlocked = recipient.isBlocked, - didApproveMe = recipient.hasApprovedMe() - ) - } - val configurationMessage = ConfigurationMessage.getCurrent(contacts) ?: return - MessageSender.send(configurationMessage, Address.fromSerialized(userPublicKey)) - TextSecurePreferences.setLastConfigurationSyncTime(context, now) + scheduleConfigSync(userPublicKey) } fun forceSyncConfigurationNowIfNeeded(context: Context): Promise { // add if check here to schedule new config job process and return early val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return Promise.ofFail(NullPointerException("User Public Key is null")) - val forcedConfig = TextSecurePreferences.hasForcedNewConfig(context) - val currentTime = SnodeAPI.nowWithOffset - if (ConfigBase.isNewConfigEnabled(forcedConfig, currentTime)) { - // schedule job if none exist - // don't schedule job if we already have one - scheduleConfigSync(userPublicKey) - return Promise.ofSuccess(Unit) - } - val contacts = ContactUtilities.getAllContacts(context).filter { recipient -> - !recipient.isGroupRecipient && !recipient.name.isNullOrEmpty() && !recipient.isLocalNumber && recipient.address.serialize().isNotEmpty() - }.map { recipient -> - ConfigurationMessage.Contact( - publicKey = recipient.address.serialize(), - name = recipient.name!!, - profilePicture = recipient.profileAvatar, - profileKey = recipient.profileKey, - isApproved = recipient.isApproved, - isBlocked = recipient.isBlocked, - didApproveMe = recipient.hasApprovedMe() - ) - } - val configurationMessage = ConfigurationMessage.getCurrent(contacts) ?: return Promise.ofSuccess(Unit) - val promise = MessageSender.send(configurationMessage, Destination.from(Address.fromSerialized(userPublicKey)), isSyncMessage = true) - TextSecurePreferences.setLastConfigurationSyncTime(context, System.currentTimeMillis()) - return promise + // schedule job if none exist + // don't schedule job if we already have one + scheduleConfigSync(userPublicKey) + return Promise.ofSuccess(Unit) } private fun maybeUserSecretKey() = MessagingModuleConfiguration.shared.getUserED25519KeyPair()?.secretKey?.asBytes diff --git a/libsession-util/src/main/java/network/loki/messenger/libsession_util/Config.kt b/libsession-util/src/main/java/network/loki/messenger/libsession_util/Config.kt index befd0d6d43..03af4a3968 100644 --- a/libsession-util/src/main/java/network/loki/messenger/libsession_util/Config.kt +++ b/libsession-util/src/main/java/network/loki/messenger/libsession_util/Config.kt @@ -27,12 +27,6 @@ sealed class ConfigBase(protected val /* yucky */ pointer: Long) { is UserGroupsConfig -> Kind.GROUPS } - // TODO: time in future to activate (hardcoded to 1st jan 2024 for testing, change before release) - private const val ACTIVATE_TIME = 1690761600000 - - fun isNewConfigEnabled(forced: Boolean, currentTime: Long) = - forced || currentTime >= ACTIVATE_TIME - const val PRIORITY_HIDDEN = -1 const val PRIORITY_VISIBLE = 0 const val PRIORITY_PINNED = 1 diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/ConfigurationSyncJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/ConfigurationSyncJob.kt index ec8de44163..241853df55 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/ConfigurationSyncJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/ConfigurationSyncJob.kt @@ -1,6 +1,5 @@ package org.session.libsession.messaging.jobs -import network.loki.messenger.libsession_util.ConfigBase import network.loki.messenger.libsession_util.ConfigBase.Companion.protoKindFor import nl.komponents.kovenant.functional.bind import org.session.libsession.messaging.MessagingModuleConfiguration @@ -10,7 +9,6 @@ import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.utilities.Data import org.session.libsession.snode.RawResponse import org.session.libsession.snode.SnodeAPI -import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log import java.util.concurrent.atomic.AtomicBoolean @@ -26,14 +24,10 @@ data class ConfigurationSyncJob(val destination: Destination): Job { override suspend fun execute(dispatcherName: String) { val storage = MessagingModuleConfiguration.shared.storage - val forcedConfig = TextSecurePreferences.hasForcedNewConfig(MessagingModuleConfiguration.shared.context) - val currentTime = SnodeAPI.nowWithOffset val userEdKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() val userPublicKey = storage.getUserPublicKey() val delegate = delegate - if (destination is Destination.ClosedGroup // TODO: closed group configs will be handled in closed group feature - // if we haven't enabled the new configs don't run - || !ConfigBase.isNewConfigEnabled(forcedConfig, currentTime) + if (destination is Destination.ClosedGroup // if we don't have a user ed key pair for signing updates || userEdKeyPair == null // this will be useful to not handle null delegate cases diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt index 6450d927a3..dfd40f5025 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt @@ -203,12 +203,10 @@ private fun handleConfigurationMessage(message: ConfigurationMessage) { TextSecurePreferences.setConfigurationMessageSynced(context, true) TextSecurePreferences.setLastProfileUpdateTime(context, message.sentTimestamp!!) - val isForceSync = TextSecurePreferences.hasForcedNewConfig(context) - val currentTime = SnodeAPI.nowWithOffset - if (ConfigBase.isNewConfigEnabled(isForceSync, currentTime)) { - TextSecurePreferences.setHasLegacyConfig(context, true) - if (!firstTimeSync) return - } + + TextSecurePreferences.setHasLegacyConfig(context, true) + if (!firstTimeSync) return + val allClosedGroupPublicKeys = storage.getAllClosedGroupPublicKeys() for (closedGroup in message.closedGroups) { if (allClosedGroupPublicKeys.contains(closedGroup.publicKey)) { From 0f470761920ad8f6a359ae96d2f91aef5bcb8ecc Mon Sep 17 00:00:00 2001 From: Fanchao Liu <273191+simophin@users.noreply.github.com> Date: Thu, 27 Jun 2024 10:12:54 +1000 Subject: [PATCH 08/13] [SES-2162] - Remove wrapping of config message (#1517) * Remove wrapping of config message * Addresses feedback * Merged in ThreadUtils fix * JDK installation * Revert JDK change --------- Co-authored-by: fanchao --- .../org/thoughtcrime/securesms/AppContext.kt | 1 - libsession-util/libsession-util | 2 +- .../messaging/jobs/ConfigurationSyncJob.kt | 2 +- .../sending_receiving/MessageSender.kt | 9 ++++ .../sending_receiving/pollers/Poller.kt | 44 +++++++------------ .../org/session/libsession/snode/SnodeAPI.kt | 4 +- 6 files changed, 30 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/AppContext.kt b/app/src/main/java/org/thoughtcrime/securesms/AppContext.kt index b9183939cf..34ab960021 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/AppContext.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/AppContext.kt @@ -5,7 +5,6 @@ import kotlinx.coroutines.asExecutor import nl.komponents.kovenant.Kovenant import nl.komponents.kovenant.jvm.asDispatcher import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.ThreadUtils import java.util.concurrent.Executors object AppContext { diff --git a/libsession-util/libsession-util b/libsession-util/libsession-util index 626b6628a2..4b6f595fdb 160000 --- a/libsession-util/libsession-util +++ b/libsession-util/libsession-util @@ -1 +1 @@ -Subproject commit 626b6628a2af8fff798042416b3b469b8bfc6ecf +Subproject commit 4b6f595fdbd3b5f6fba380253e560d8ee296b734 diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/ConfigurationSyncJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/ConfigurationSyncJob.kt index 241853df55..d7dfdf768e 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/ConfigurationSyncJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/ConfigurationSyncJob.kt @@ -61,7 +61,7 @@ data class ConfigurationSyncJob(val destination: Destination): Job { SharedConfigurationMessage(config.protoKindFor(), data, seqNo) to config }.map { (message, config) -> // return a list of batch request objects - val snodeMessage = MessageSender.buildWrappedMessageToSnode(destination, message, true) + val snodeMessage = MessageSender.buildConfigMessageToSnode(destination.destinationPublicKey(), message) val authenticated = SnodeAPI.buildAuthenticatedStoreBatchInfo( destination.destinationPublicKey(), config.configNamespace(), diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt index 0968db27e2..8081fe1d92 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt @@ -81,6 +81,15 @@ object MessageSender { } } + fun buildConfigMessageToSnode(destinationPubKey: String, message: SharedConfigurationMessage): SnodeMessage { + return SnodeMessage( + destinationPubKey, + Base64.encodeBytes(message.data), + ttl = message.ttl, + SnodeAPI.nowWithOffset + ) + } + // One-on-One Chats & Closed Groups @Throws(Exception::class) fun buildWrappedMessageToSnode(destination: Destination, message: Message, isSyncMessage: Boolean): SnodeMessage { diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt index 39ed79de1e..d05290a5fd 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt @@ -25,6 +25,7 @@ import org.session.libsession.snode.RawResponse import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeModule import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Namespace import org.session.libsignal.utilities.Snode @@ -126,37 +127,26 @@ class Poller(private val configFactory: ConfigFactoryProtocol, debounceTimer: Ti private fun processConfig(snode: Snode, rawMessages: RawResponse, namespace: Int, forConfigObject: ConfigBase?) { if (forConfigObject == null) return - val messages = SnodeAPI.parseRawMessagesResponse( - rawMessages, - snode, - userPublicKey, - namespace, - updateLatestHash = true, - updateStoredHashes = true, - ) + val messages = rawMessages["messages"] as? List<*> + val processed = if (!messages.isNullOrEmpty()) { + SnodeAPI.updateLastMessageHashValueIfPossible(snode, userPublicKey, messages, namespace) + SnodeAPI.removeDuplicates(userPublicKey, messages, namespace, true).mapNotNull { messageBody -> + val rawMessageAsJSON = messageBody as? Map<*, *> ?: return@mapNotNull null + val hashValue = rawMessageAsJSON["hash"] as? String ?: return@mapNotNull null + val b64EncodedBody = rawMessageAsJSON["data"] as? String ?: return@mapNotNull null + val timestamp = rawMessageAsJSON["t"] as? Long ?: SnodeAPI.nowWithOffset + val body = Base64.decode(b64EncodedBody) + Triple(body, hashValue, timestamp) + } + } else emptyList() - if (messages.isEmpty()) { - // no new messages to process - return - } + if (processed.isEmpty()) return var latestMessageTimestamp: Long? = null - messages.forEach { (envelope, hash) -> + processed.forEach { (body, hash, timestamp) -> try { - val (message, _) = MessageReceiver.parse(data = envelope.toByteArray(), - // assume no groups in personal poller messages - openGroupServerID = null, currentClosedGroups = emptySet() - ) - // sanity checks - if (message !is SharedConfigurationMessage) { - Log.w("Loki", "shared config message handled in configs wasn't SharedConfigurationMessage but was ${message.javaClass.simpleName}") - return@forEach - } - val merged = forConfigObject.merge(hash!! to message.data).firstOrNull { it == hash } - if (merged != null) { - // We successfully merged the hash, we can now update the timestamp - latestMessageTimestamp = if ((message.sentTimestamp ?: 0L) > (latestMessageTimestamp ?: 0L)) { message.sentTimestamp } else { latestMessageTimestamp } - } + forConfigObject.merge(hash to body) + latestMessageTimestamp = if (timestamp > (latestMessageTimestamp ?: 0L)) { timestamp } else { latestMessageTimestamp } } catch (e: Exception) { Log.e("Loki", e) } diff --git a/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt b/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt index 0f996bacac..d2cfa2de35 100644 --- a/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt +++ b/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt @@ -829,7 +829,7 @@ object SnodeAPI { } } - private fun updateLastMessageHashValueIfPossible(snode: Snode, publicKey: String, rawMessages: List<*>, namespace: Int) { + fun updateLastMessageHashValueIfPossible(snode: Snode, publicKey: String, rawMessages: List<*>, namespace: Int) { val lastMessageAsJSON = rawMessages.lastOrNull() as? Map<*, *> val hashValue = lastMessageAsJSON?.get("hash") as? String if (hashValue != null) { @@ -839,7 +839,7 @@ object SnodeAPI { } } - private fun removeDuplicates(publicKey: String, rawMessages: List<*>, namespace: Int, updateStoredHashes: Boolean): List<*> { + fun removeDuplicates(publicKey: String, rawMessages: List<*>, namespace: Int, updateStoredHashes: Boolean): List<*> { val originalMessageHashValues = database.getReceivedMessageHashValues(publicKey, namespace)?.toMutableSet() ?: mutableSetOf() val receivedMessageHashValues = originalMessageHashValues.toMutableSet() val result = rawMessages.filter { rawMessage -> From 2dbdd6b0aeaa88ca037ec729838a716ab42d0843 Mon Sep 17 00:00:00 2001 From: Andrew Date: Thu, 27 Jun 2024 17:07:45 +0930 Subject: [PATCH 09/13] Update libsession --- libsession-util/libsession-util | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libsession-util/libsession-util b/libsession-util/libsession-util index 4b6f595fdb..626b6628a2 160000 --- a/libsession-util/libsession-util +++ b/libsession-util/libsession-util @@ -1 +1 @@ -Subproject commit 4b6f595fdbd3b5f6fba380253e560d8ee296b734 +Subproject commit 626b6628a2af8fff798042416b3b469b8bfc6ecf From 1d80bb0ba9b836da5897249d48911d5526fab030 Mon Sep 17 00:00:00 2001 From: Andrew Date: Mon, 1 Jul 2024 09:11:46 +0930 Subject: [PATCH 10/13] [SES-337] Add rounded corners to thumbnail in QuoteView (#1285) * Add rounded corners to thumbnail in QuoteView * Simplify ThumbnailView * Cleanup ThumbnailView * Removed include custom attributes The custom attributes are not passed to the view. I added the radius programatically instead. * Clipping whole thumbnail view instead of just the image requests --------- Co-authored-by: AL-Session <160798022+AL-Session@users.noreply.github.com> Co-authored-by: ThomasSession --- .../securesms/MediaGalleryAdapter.java | 2 +- .../v2/components/AlbumThumbnailView.kt | 2 +- .../v2/components/LinkPreviewDraftView.kt | 4 +- .../v2/messages/LinkPreviewView.kt | 2 +- .../conversation/v2/messages/QuoteView.kt | 5 +- .../v2/utilities/ThumbnailView.kt | 211 +++++++++--------- .../mediapreview/MediaRailAdapter.java | 4 + app/src/main/res/layout/album_thumbnail_1.xml | 7 +- app/src/main/res/layout/album_thumbnail_2.xml | 6 +- app/src/main/res/layout/album_thumbnail_3.xml | 9 +- .../main/res/layout/mediarail_media_item.xml | 4 +- 11 files changed, 127 insertions(+), 129 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.java index 0fd813cf4b..62766d1cd7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.java @@ -114,7 +114,7 @@ class MediaGalleryAdapter extends StickyHeaderGridAdapter { Slide slide = MediaUtil.getSlideForAttachment(context, mediaRecord.getAttachment()); if (slide != null) { - thumbnailView.setImageResource(glideRequests, slide, false, null); + thumbnailView.setImageResource(glideRequests, slide, false); } thumbnailView.setOnClickListener(view -> itemClickListener.onMediaClicked(mediaRecord)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt index d76e6f2b3d..f646ace972 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt @@ -104,7 +104,7 @@ class AlbumThumbnailView : RelativeLayout { // iterate binding slides.take(MAX_ALBUM_DISPLAY_SIZE).forEachIndexed { position, slide -> val thumbnailView = getThumbnailView(position) - thumbnailView.setImageResource(glideRequests, slide, isPreview = false, mms = message) + thumbnailView.setImageResource(glideRequests, slide, isPreview = false) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/LinkPreviewDraftView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/LinkPreviewDraftView.kt index 66164f100f..9c414f34fd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/LinkPreviewDraftView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/LinkPreviewDraftView.kt @@ -31,10 +31,10 @@ class LinkPreviewDraftView : LinearLayout { // Hide the loader and show the content view binding.linkPreviewDraftContainer.isVisible = true binding.linkPreviewDraftLoader.isVisible = false - binding.thumbnailImageView.root.radius = toPx(4, resources) + binding.thumbnailImageView.root.setRoundedCorners(toPx(4, resources)) if (linkPreview.getThumbnail().isPresent) { // This internally fetches the thumbnail - binding.thumbnailImageView.root.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), false, null) + binding.thumbnailImageView.root.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), false) } binding.linkPreviewDraftTitleTextView.text = linkPreview.title } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt index 9677223894..4e6066edb3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt @@ -41,7 +41,7 @@ class LinkPreviewView : LinearLayout { // Thumbnail if (linkPreview.getThumbnail().isPresent) { // This internally fetches the thumbnail - binding.thumbnailImageView.root.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), isPreview = false, message) + binding.thumbnailImageView.root.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), isPreview = false) binding.thumbnailImageView.root.loadIndicator.isVisible = false } // Title diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt index 2e0dae6b0d..927ad5f60f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt @@ -108,8 +108,9 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet? attachments.thumbnailSlide != null -> { val slide = attachments.thumbnailSlide!! // This internally fetches the thumbnail - binding.quoteViewAttachmentThumbnailImageView.root.radius = toPx(4, resources) - binding.quoteViewAttachmentThumbnailImageView.root.setImageResource(glide, slide, false, null) + binding.quoteViewAttachmentThumbnailImageView + .root.setRoundedCorners(toPx(4, resources)) + binding.quoteViewAttachmentThumbnailImageView.root.setImageResource(glide, slide, false) binding.quoteViewAttachmentThumbnailImageView.root.isVisible = true binding.quoteViewBodyTextView.text = if (MediaUtil.isVideo(slide.asAttachment())) resources.getString(R.string.Slide_video) else resources.getString(R.string.Slide_image) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.kt index 4a9986d6ec..02c683aac6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.kt @@ -2,10 +2,13 @@ package org.thoughtcrime.securesms.conversation.v2.utilities import android.content.Context import android.graphics.Bitmap +import android.graphics.Outline import android.graphics.drawable.Drawable import android.net.Uri import android.util.AttributeSet +import android.util.TypedValue import android.view.View +import android.view.ViewOutlineProvider import android.widget.FrameLayout import androidx.core.view.isVisible import com.bumptech.glide.load.engine.DiskCacheStrategy @@ -21,18 +24,17 @@ import org.session.libsignal.utilities.ListenableFuture import org.session.libsignal.utilities.SettableFuture import org.thoughtcrime.securesms.components.GlideBitmapListeningTarget import org.thoughtcrime.securesms.components.GlideDrawableListeningTarget -import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri import org.thoughtcrime.securesms.mms.GlideRequest import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.Slide -import kotlin.Boolean -import kotlin.Int -import kotlin.getValue -import kotlin.lazy -import kotlin.let -open class ThumbnailView: FrameLayout { +open class ThumbnailView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { + companion object { private const val WIDTH = 0 private const val HEIGHT = 1 @@ -41,30 +43,29 @@ open class ThumbnailView: FrameLayout { private val binding: ThumbnailViewBinding by lazy { ThumbnailViewBinding.bind(this) } // region Lifecycle - constructor(context: Context) : super(context) { initialize(null) } - constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize(attrs) } - constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize(attrs) } val loadIndicator: View by lazy { binding.thumbnailLoadIndicator } private val dimensDelegate = ThumbnailDimensDelegate() private var slide: Slide? = null - var radius: Int = 0 - private fun initialize(attrs: AttributeSet?) { - if (attrs != null) { - val typedArray = context.theme.obtainStyledAttributes(attrs, R.styleable.ThumbnailView, 0, 0) + init { + attrs?.let { context.theme.obtainStyledAttributes(it, R.styleable.ThumbnailView, 0, 0) } + ?.apply { + dimensDelegate.setBounds( + getDimensionPixelSize(R.styleable.ThumbnailView_minWidth, 0), + getDimensionPixelSize(R.styleable.ThumbnailView_minHeight, 0), + getDimensionPixelSize(R.styleable.ThumbnailView_maxWidth, 0), + getDimensionPixelSize(R.styleable.ThumbnailView_maxHeight, 0) + ) - dimensDelegate.setBounds(typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_minWidth, 0), - typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_minHeight, 0), - typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxWidth, 0), - typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxHeight, 0)) + setRoundedCorners( + getDimensionPixelSize(R.styleable.ThumbnailView_thumbnail_radius, 0) + ) - radius = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_thumbnail_radius, 0) - - typedArray.recycle() - } + recycle() + } } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { @@ -84,114 +85,118 @@ open class ThumbnailView: FrameLayout { private fun getDefaultWidth() = maxOf(layoutParams?.width ?: 0, 0) private fun getDefaultHeight() = maxOf(layoutParams?.height ?: 0, 0) + // endregion // region Interaction - fun setImageResource(glide: GlideRequests, slide: Slide, isPreview: Boolean, mms: MmsMessageRecord?): ListenableFuture { - return setImageResource(glide, slide, isPreview, 0, 0, mms) - } - - fun setImageResource(glide: GlideRequests, slide: Slide, - isPreview: Boolean, naturalWidth: Int, - naturalHeight: Int, mms: MmsMessageRecord?): ListenableFuture { - - val currentSlide = this.slide - - binding.playOverlay.isVisible = (slide.thumbnailUri != null && slide.hasPlayOverlay() && - (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE || isPreview)) - - if (equals(currentSlide, slide)) { - // don't re-load slide - return SettableFuture(false) + fun setRoundedCorners(radius: Int){ + // create an outline provider and clip the whole view to that shape + // that way we can round the image and the background ( and any other artifacts that the view may contain ) + val mOutlineProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + // all corners + outline.setRoundRect(0, 0, view.width, view.height, radius.toFloat()) + } } + outlineProvider = mOutlineProvider + clipToOutline = true + } - if (currentSlide != null && currentSlide.fastPreflightId != null && currentSlide.fastPreflightId == slide.fastPreflightId) { - // not reloading slide for fast preflight - this.slide = slide + fun setImageResource( + glide: GlideRequests, + slide: Slide, + isPreview: Boolean + ): ListenableFuture = setImageResource(glide, slide, isPreview, 0, 0) + + fun setImageResource( + glide: GlideRequests, slide: Slide, + isPreview: Boolean, naturalWidth: Int, + naturalHeight: Int + ): ListenableFuture { + binding.playOverlay.isVisible = (slide.thumbnailUri != null && slide.hasPlayOverlay() && + (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE || isPreview)) + + if (equals(this.slide, slide)) { + // don't re-load slide + return SettableFuture(false) } this.slide = slide binding.thumbnailLoadIndicator.isVisible = slide.isInProgress - binding.thumbnailDownloadIcon.isVisible = slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED + binding.thumbnailDownloadIcon.isVisible = + slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED dimensDelegate.setDimens(naturalWidth, naturalHeight) invalidate() - val result = SettableFuture() - - when { - slide.thumbnailUri != null -> { - buildThumbnailGlideRequest(glide, slide).into(GlideDrawableListeningTarget(binding.thumbnailImage, binding.thumbnailLoadIndicator, result)) - } - slide.hasPlaceholder() -> { - buildPlaceholderGlideRequest(glide, slide).into(GlideBitmapListeningTarget(binding.thumbnailImage, null, result)) - } - else -> { - glide.clear(binding.thumbnailImage) - result.set(false) + return SettableFuture().also { + when { + slide.thumbnailUri != null -> { + buildThumbnailGlideRequest(glide, slide).into( + GlideDrawableListeningTarget(binding.thumbnailImage, binding.thumbnailLoadIndicator, it) + ) + } + slide.hasPlaceholder() -> { + buildPlaceholderGlideRequest(glide, slide).into( + GlideBitmapListeningTarget(binding.thumbnailImage, null, it) + ) + } + else -> { + glide.clear(binding.thumbnailImage) + it.set(false) + } } } - return result } - fun buildThumbnailGlideRequest(glide: GlideRequests, slide: Slide): GlideRequest { + private fun buildThumbnailGlideRequest( + glide: GlideRequests, + slide: Slide + ): GlideRequest = glide.load(DecryptableUri(slide.thumbnailUri!!)) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .overrideDimensions() + .transition(DrawableTransitionOptions.withCrossFade()) + .transform(CenterCrop()) + .missingThumbnailPicture(slide.isInProgress) - val dimens = dimensDelegate.resourceSize() - - val request = glide.load(DecryptableUri(slide.thumbnailUri!!)) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .let { request -> - if (dimens[WIDTH] == 0 || dimens[HEIGHT] == 0) { - request.override(getDefaultWidth(), getDefaultHeight()) - } else { - request.override(dimens[WIDTH], dimens[HEIGHT]) - } - } - .transition(DrawableTransitionOptions.withCrossFade()) - .centerCrop() - - return if (slide.isInProgress) request else request.apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture)) - } - - fun buildPlaceholderGlideRequest(glide: GlideRequests, slide: Slide): GlideRequest { - - val dimens = dimensDelegate.resourceSize() - - return glide.asBitmap() - .load(slide.getPlaceholderRes(context.theme)) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .let { request -> - if (dimens[WIDTH] == 0 || dimens[HEIGHT] == 0) { - request.override(getDefaultWidth(), getDefaultHeight()) - } else { - request.override(dimens[WIDTH], dimens[HEIGHT]) - } - } - .fitCenter() - } + private fun buildPlaceholderGlideRequest( + glide: GlideRequests, + slide: Slide + ): GlideRequest = glide.asBitmap() + .load(slide.getPlaceholderRes(context.theme)) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .overrideDimensions() + .fitCenter() open fun clear(glideRequests: GlideRequests) { glideRequests.clear(binding.thumbnailImage) slide = null } - fun setImageResource(glideRequests: GlideRequests, uri: Uri): ListenableFuture { - val future = SettableFuture() + fun setImageResource( + glideRequests: GlideRequests, + uri: Uri + ): ListenableFuture = glideRequests.load(DecryptableUri(uri)) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .transition(DrawableTransitionOptions.withCrossFade()) + .transform(CenterCrop()) + .intoDrawableTargetAsFuture() - var request: GlideRequest = glideRequests.load(DecryptableUri(uri)) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .transition(DrawableTransitionOptions.withCrossFade()) - - request = if (radius > 0) { - request.transforms(CenterCrop(), RoundedCorners(radius)) - } else { - request.transforms(CenterCrop()) + private fun GlideRequest.intoDrawableTargetAsFuture() = + SettableFuture().also { + binding.run { + GlideDrawableListeningTarget(thumbnailImage, thumbnailLoadIndicator, it) + }.let { into(it) } } - request.into(GlideDrawableListeningTarget(binding.thumbnailImage, binding.thumbnailLoadIndicator, future)) + private fun GlideRequest.overrideDimensions() = + dimensDelegate.resourceSize().takeIf { 0 !in it } + ?.let { override(it[WIDTH], it[HEIGHT]) } + ?: override(getDefaultWidth(), getDefaultHeight()) +} - return future - } -} \ No newline at end of file +private fun GlideRequest.missingThumbnailPicture( + inProgress: Boolean +) = takeIf { inProgress } ?: apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaRailAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaRailAdapter.java index dd27c42502..6492069780 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaRailAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaRailAdapter.java @@ -1,5 +1,7 @@ package org.thoughtcrime.securesms.mediapreview; +import static org.thoughtcrime.securesms.util.GeneralUtilitiesKt.toPx; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; @@ -151,6 +153,8 @@ public class MediaRailAdapter extends RecyclerView.Adapter railItemListener.onRailItemClicked(distanceFromActive)); + // set the rounded corners + image.setRoundedCorners(toPx(5, image.getResources())); outline.setVisibility(isActive ? View.VISIBLE : View.GONE); diff --git a/app/src/main/res/layout/album_thumbnail_1.xml b/app/src/main/res/layout/album_thumbnail_1.xml index cee81ba3e3..2f3ffdaa79 100644 --- a/app/src/main/res/layout/album_thumbnail_1.xml +++ b/app/src/main/res/layout/album_thumbnail_1.xml @@ -9,11 +9,6 @@ + android:layout_height="match_parent"/> \ No newline at end of file diff --git a/app/src/main/res/layout/album_thumbnail_2.xml b/app/src/main/res/layout/album_thumbnail_2.xml index 52375d025c..1712bdb741 100644 --- a/app/src/main/res/layout/album_thumbnail_2.xml +++ b/app/src/main/res/layout/album_thumbnail_2.xml @@ -10,14 +10,12 @@ + android:layout_height="@dimen/album_2_total_height"/> + android:layout_gravity="end"/> \ No newline at end of file diff --git a/app/src/main/res/layout/album_thumbnail_3.xml b/app/src/main/res/layout/album_thumbnail_3.xml index b408ffd2bc..d201565dab 100644 --- a/app/src/main/res/layout/album_thumbnail_3.xml +++ b/app/src/main/res/layout/album_thumbnail_3.xml @@ -9,15 +9,13 @@ + android:layout_height="@dimen/album_3_total_height"/> + android:layout_gravity="end|top"/> + android:layout_gravity="center_horizontal|bottom"/> + android:layout_gravity="center"/> Date: Mon, 1 Jul 2024 14:52:18 +1000 Subject: [PATCH 11/13] Highlight @You mentions (#985) * Highlight @You mentions * fix: resolve merge conflicts * Setting the proper design rules for mentions * New RoundedBackgroundSpan, applied to "you" mentions The rounded background highlighter can take padding, so there is no need to add those extra spaces at the start and end. * Better mention highlight logic Some mention highlight should only format the text and not apply any styling. Also making sure we cater for all cases properly * Updated the text color logic based on design rules * Fine tuning the color rules * Removing usage of Resources.getSystem() Only making the db call if there actually is a mention * Moving color definition outside the loop to avoid repetitions --------- Co-authored-by: charles Co-authored-by: 0x330a <92654767+0x330a@users.noreply.github.com> Co-authored-by: ThomasSession --- .../conversation/v2/ConversationActivityV2.kt | 8 +- .../conversation/v2/messages/QuoteView.kt | 10 +- .../v2/messages/VisibleMessageContentView.kt | 7 +- .../v2/utilities/MentionUtilities.kt | 148 +++++++++++++----- .../securesms/home/ConversationView.kt | 7 +- .../messagerequests/MessageRequestView.kt | 8 +- .../notifications/DefaultMessageNotifier.java | 21 ++- .../securesms/util/RoundedBackgroundSpan.kt | 61 ++++++++ 8 files changed, 221 insertions(+), 49 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/RoundedBackgroundSpan.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 187ded770e..d5e8c43fd9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -1958,7 +1958,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val messageIterator = sortedMessages.iterator() while (messageIterator.hasNext()) { val message = messageIterator.next() - val body = MentionUtilities.highlightMentions(message.body, viewModel.threadId, this) + val body = MentionUtilities.highlightMentions( + text = message.body, + formatOnly = true, // no styling here, only text formatting + threadID = viewModel.threadId, + context = this + ) + if (TextUtils.isEmpty(body)) { continue } if (messageSize > 1) { val formattedTimestamp = DateUtils.getDisplayFormattedTimeSpanString(this, Locale.getDefault(), message.timestamp) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt index 927ad5f60f..c4a29fea54 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt @@ -80,7 +80,15 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet? binding.quoteViewAuthorTextView.text = authorDisplayName binding.quoteViewAuthorTextView.setTextColor(getTextColor(isOutgoingMessage)) // Body - binding.quoteViewBodyTextView.text = if (isOpenGroupInvitation) resources.getString(R.string.open_group_invitation_view__open_group_invitation) else MentionUtilities.highlightMentions((body ?: "").toSpannable(), threadID, context) + binding.quoteViewBodyTextView.text = if (isOpenGroupInvitation) + resources.getString(R.string.open_group_invitation_view__open_group_invitation) + else MentionUtilities.highlightMentions( + text = (body ?: "").toSpannable(), + isOutgoingMessage = isOutgoingMessage, + isQuote = true, + threadID = threadID, + context = context + ) binding.quoteViewBodyTextView.setTextColor(getTextColor(isOutgoingMessage)) // Accent line / attachment preview val hasAttachments = (attachments != null && attachments.asAttachments().isNotEmpty()) && !isOriginalMissing diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt index 7e220955d6..baf80e56aa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt @@ -282,7 +282,12 @@ class VisibleMessageContentView : ConstraintLayout { fun getBodySpans(context: Context, message: MessageRecord, searchQuery: String?): Spannable { var body = message.body.toSpannable() - body = MentionUtilities.highlightMentions(body, message.isOutgoing, message.threadId, context) + body = MentionUtilities.highlightMentions( + text = body, + isOutgoingMessage = message.isOutgoing, + threadID = message.threadId, + context = context + ) body = SearchUtil.getHighlightedSpan(Locale.getDefault(), { BackgroundColorSpan(Color.WHITE) }, body, searchQuery) body = SearchUtil.getHighlightedSpan(Locale.getDefault(), diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt index cb9a19ffc1..3edfcffc97 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt @@ -1,7 +1,7 @@ package org.thoughtcrime.securesms.conversation.v2.utilities -import android.app.Application import android.content.Context +import android.graphics.Color import android.graphics.Typeface import android.text.Spannable import android.text.SpannableString @@ -9,43 +9,60 @@ import android.text.style.BackgroundColorSpan import android.text.style.ForegroundColorSpan import android.text.style.StyleSpan import android.util.Range -import androidx.appcompat.widget.ThemeUtils import androidx.core.content.res.ResourcesCompat import network.loki.messenger.R import nl.komponents.kovenant.combine.Tuple2 import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.ThemeUtil -import org.session.libsignal.utilities.Log +import org.session.libsession.utilities.getColorFromAttr import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.util.UiModeUtilities +import org.thoughtcrime.securesms.util.RoundedBackgroundSpan import org.thoughtcrime.securesms.util.getAccentColor -import org.thoughtcrime.securesms.util.getColorResourceIdFromAttr -import org.thoughtcrime.securesms.util.getMessageTextColourAttr +import org.thoughtcrime.securesms.util.toPx import java.util.regex.Pattern object MentionUtilities { - @JvmStatic - fun highlightMentions(text: CharSequence, threadID: Long, context: Context): String { - return highlightMentions(text, false, threadID, context).toString() // isOutgoingMessage is irrelevant - } + private val pattern by lazy { Pattern.compile("@[0-9a-fA-F]*") } + /** + * Highlights mentions in a given text. + * + * @param text The text to highlight mentions in. + * @param isOutgoingMessage Whether the message is outgoing. + * @param isQuote Whether the message is a quote. + * @param formatOnly Whether to only format the mentions. If true we only format the text itself, + * for example resolving an accountID to a username. If false we also apply styling, like colors and background. + * @param threadID The ID of the thread the message belongs to. + * @param context The context to use. + * @return A SpannableString with highlighted mentions. + */ @JvmStatic - fun highlightMentions(text: CharSequence, isOutgoingMessage: Boolean, threadID: Long, context: Context): SpannableString { + fun highlightMentions( + text: CharSequence, + isOutgoingMessage: Boolean = false, + isQuote: Boolean = false, + formatOnly: Boolean = false, + threadID: Long, + context: Context + ): SpannableString { @Suppress("NAME_SHADOWING") var text = text - val pattern = Pattern.compile("@[0-9a-fA-F]*") + var matcher = pattern.matcher(text) val mentions = mutableListOf, String>>() var startIndex = 0 val userPublicKey = TextSecurePreferences.getLocalNumber(context)!! - val openGroup = DatabaseComponent.get(context).storage().getOpenGroup(threadID) + val openGroup by lazy { DatabaseComponent.get(context).storage().getOpenGroup(threadID) } + + // format the mention text if (matcher.find(startIndex)) { while (true) { val publicKey = text.subSequence(matcher.start() + 1, matcher.end()).toString() // +1 to get rid of the @ - val isUserBlindedPublicKey = openGroup?.let { SodiumUtilities.sessionId(userPublicKey, publicKey, it.publicKey) } ?: false - val userDisplayName: String? = if (publicKey.equals(userPublicKey, ignoreCase = true) || isUserBlindedPublicKey) { + val isYou = isYou(publicKey, userPublicKey, openGroup) + val userDisplayName: String? = if (isYou) { context.getString(R.string.MessageRecord_you) } else { val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithSessionID(publicKey) @@ -53,7 +70,8 @@ object MentionUtilities { contact?.displayName(context) } if (userDisplayName != null) { - text = text.subSequence(0, matcher.start()).toString() + "@" + userDisplayName + text.subSequence(matcher.end(), text.length) + val mention = "@$userDisplayName" + text = text.subSequence(0, matcher.start()).toString() + mention + text.subSequence(matcher.end(), text.length) val endIndex = matcher.start() + 1 + userDisplayName.length startIndex = endIndex mentions.add(Tuple2(Range.create(matcher.start(), endIndex), publicKey)) @@ -66,37 +84,83 @@ object MentionUtilities { } val result = SpannableString(text) - var mentionTextColour: Int? = null - // In dark themes.. - if (ThemeUtil.isDarkTheme(context)) { - // ..we use the standard outgoing message colour for outgoing messages.. - if (isOutgoingMessage) { - val mentionTextColourAttributeId = getMessageTextColourAttr(true) - val mentionTextColourResourceId = getColorResourceIdFromAttr(context, mentionTextColourAttributeId) - mentionTextColour = ResourcesCompat.getColor(context.resources, mentionTextColourResourceId, context.theme) - } - else // ..but we use the accent colour for incoming messages (i.e., someone mentioning us).. - { - mentionTextColour = context.getAccentColor() - } - } - else // ..while in light themes we always just use the incoming or outgoing message text colour for mentions. - { - val mentionTextColourAttributeId = getMessageTextColourAttr(isOutgoingMessage) - val mentionTextColourResourceId = getColorResourceIdFromAttr(context, mentionTextColourAttributeId) - mentionTextColour = ResourcesCompat.getColor(context.resources, mentionTextColourResourceId, context.theme) + // apply styling if required + // Normal text color: black in dark mode and primary text color for light mode + val mainTextColor by lazy { + if (ThemeUtil.isDarkTheme(context)) context.getColor(R.color.black) + else context.getColorFromAttr(android.R.attr.textColorPrimary) } - for (mention in mentions) { - result.setSpan(ForegroundColorSpan(mentionTextColour), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - result.setSpan(StyleSpan(Typeface.BOLD), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + // Highlighted text color: primary/accent in dark mode and primary text color for light mode + val highlightedTextColor by lazy { + if (ThemeUtil.isDarkTheme(context)) context.getAccentColor() + else context.getColorFromAttr(android.R.attr.textColorPrimary) + } - // If we're using a light theme then we change the background colour of the mention to be the accent colour - if (ThemeUtil.isLightTheme(context)) { - val backgroundColour = context.getAccentColor(); - result.setSpan(BackgroundColorSpan(backgroundColour), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + if(!formatOnly) { + for (mention in mentions) { + val backgroundColor: Int? + val foregroundColor: Int? + + // quotes + if(isQuote) { + backgroundColor = null + // the text color has different rule depending if the message is incoming or outgoing + foregroundColor = if(isOutgoingMessage) null else highlightedTextColor + } + // incoming message mentioning you + else if (isYou(mention.second, userPublicKey, openGroup)) { + backgroundColor = context.getAccentColor() + foregroundColor = mainTextColor + } + // outgoing message + else if (isOutgoingMessage) { + backgroundColor = null + foregroundColor = mainTextColor + } + // incoming messages mentioning someone else + else { + backgroundColor = null + // accent color for dark themes and primary text for light + foregroundColor = highlightedTextColor + } + + // apply the background, if any + backgroundColor?.let { background -> + result.setSpan( + RoundedBackgroundSpan( + context = context, + textColor = mainTextColor, + backgroundColor = background + ), + mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + + // apply the foreground, if any + foregroundColor?.let { + result.setSpan( + ForegroundColorSpan(it), + mention.first.lower, + mention.first.upper, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + + // apply bold on the mention + result.setSpan( + StyleSpan(Typeface.BOLD), + mention.first.lower, + mention.first.upper, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) } } return result } + + private fun isYou(mentionedPublicKey: String, userPublicKey: String, openGroup: OpenGroup?): Boolean { + val isUserBlindedPublicKey = openGroup?.let { SodiumUtilities.sessionId(userPublicKey, mentionedPublicKey, it.publicKey) } ?: false + return mentionedPublicKey.equals(userPublicKey, ignoreCase = true) || isUserBlindedPublicKey + } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt index c9896a5b8e..36ea3a4371 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt @@ -103,7 +103,12 @@ class ConversationView : LinearLayout { R.drawable.ic_notifications_mentions } binding.muteIndicatorImageView.setImageResource(drawableRes) - binding.snippetTextView.text = highlightMentions(thread.getSnippet(), thread.threadId, context) + binding.snippetTextView.text = highlightMentions( + text = thread.getSnippet(), + formatOnly = true, // no styling here, only text formatting + threadID = thread.threadId, + context = context + ) binding.snippetTextView.typeface = if (unreadCount > 0 && !thread.isRead) Typeface.DEFAULT_BOLD else Typeface.DEFAULT binding.snippetTextView.visibility = if (isTyping) View.GONE else View.VISIBLE if (isTyping) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt index af3d269c6a..a916d8e4d6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt @@ -39,7 +39,13 @@ class MessageRequestView : LinearLayout { binding.displayNameTextView.text = senderDisplayName binding.timestampTextView.text = DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), thread.date) val rawSnippet = thread.getDisplayBody(context) - val snippet = highlightMentions(rawSnippet, thread.threadId, context) + val snippet = highlightMentions( + text = rawSnippet, + formatOnly = true, // no styling here, only text formatting + threadID = thread.threadId, + context = context + ) + binding.snippetTextView.text = snippet post { diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java index b281e0798b..6aa514c8a9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java @@ -444,13 +444,30 @@ public class DefaultMessageNotifier implements MessageNotifier { while(iterator.hasPrevious()) { NotificationItem item = iterator.previous(); builder.addMessageBody(item.getIndividualRecipient(), item.getRecipient(), - MentionUtilities.highlightMentions(item.getText(), item.getThreadId(), context)); + MentionUtilities.highlightMentions( + item.getText() != null ? item.getText() : "", + false, + false, + true, // no styling here, only text formatting + item.getThreadId(), + context + ) + ); } if (signal) { builder.setAlarms(notificationState.getRingtone(context), notificationState.getVibrate()); + CharSequence text = notifications.get(0).getText(); builder.setTicker(notifications.get(0).getIndividualRecipient(), - MentionUtilities.highlightMentions(notifications.get(0).getText(), notifications.get(0).getThreadId(), context)); + MentionUtilities.highlightMentions( + text != null ? text : "", + false, + false, + true, // no styling here, only text formatting + notifications.get(0).getThreadId(), + context + ) + ); } builder.putStringExtra(LATEST_MESSAGE_ID_TAG, messageIdTag); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RoundedBackgroundSpan.kt b/app/src/main/java/org/thoughtcrime/securesms/util/RoundedBackgroundSpan.kt new file mode 100644 index 0000000000..ebefc9c50c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/RoundedBackgroundSpan.kt @@ -0,0 +1,61 @@ +package org.thoughtcrime.securesms.util + +import android.content.Context +import android.content.res.Resources +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.text.style.ReplacementSpan + +/** + * A Span that draws text with a rounded background. + * + * @param textColor - The color of the text. + * @param backgroundColor - The color of the background. + * @param cornerRadius - The corner radius of the background in pixels. Defaults to 8dp. + * @param paddingHorizontal - The horizontal padding of the text in pixels. Defaults to 3dp. + * @param paddingVertical - The vertical padding of the text in pixels. Defaults to 3dp. + */ + + +class RoundedBackgroundSpan( + context: Context, + private val textColor: Int, + private val backgroundColor: Int, + private val cornerRadius: Float = toPx(8, context.resources).toFloat(), // setting some Session defaults + private val paddingHorizontal: Float = toPx(3, context.resources).toFloat(), + private val paddingVertical: Float = toPx(3, context.resources).toFloat() +) : ReplacementSpan() { + + override fun draw( + canvas: Canvas, text: CharSequence, start: Int, end: Int, + x: Float, top: Int, y: Int, bottom: Int, paint: Paint + ) { + // the top needs to take into account the font and the required vertical padding + val newTop = y + paint.fontMetrics.ascent - paddingVertical + val newBottom = y + paint.fontMetrics.descent + paddingVertical + val rect = RectF( + x, + newTop, + x + measureText(paint, text, start, end) + 2 * paddingHorizontal, + newBottom + ) + paint.color = backgroundColor + + canvas.drawRoundRect(rect, cornerRadius, cornerRadius, paint) + paint.color = textColor + canvas.drawText(text, start, end, x + paddingHorizontal, y.toFloat(), paint) + } + + override fun getSize( + paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt? + ): Int { + return (paint.measureText(text, start, end) + 2 * paddingHorizontal).toInt() + } + + private fun measureText( + paint: Paint, text: CharSequence, start: Int, end: Int + ): Float { + return paint.measureText(text, start, end) + } +} From fec67e282a97868b2f82dd4125ab43b538ddeb98 Mon Sep 17 00:00:00 2001 From: Fanchao Liu <273191+simophin@users.noreply.github.com> Date: Mon, 1 Jul 2024 17:31:03 +1000 Subject: [PATCH 12/13] [SES-2018] Refactor mention (#1510) * Refactor mention * Fixes robolectric test problem * Fixes tests * Naming and comments * Naming * Dispatcher --------- Co-authored-by: fanchao --- app/build.gradle | 7 +- .../securesms/contacts/UserView.kt | 2 - .../conversation/ConversationActionBarView.kt | 2 - .../conversation/v2/ConversationActivityV2.kt | 164 +++-------- .../conversation/v2/input_bar/InputBar.kt | 5 +- .../mentions/MentionCandidateAdapter.kt | 44 +++ .../mentions/MentionCandidateView.kt | 46 +-- .../mentions/MentionCandidatesView.kt | 90 ------ .../v2/mention/MentionEditable.kt | 188 ++++++++++++ .../conversation/v2/mention/MentionSpan.kt | 8 + .../v2/mention/MentionViewModel.kt | 274 ++++++++++++++++++ .../v2/utilities/MentionManagerUtilities.kt | 34 --- .../securesms/database/GroupMemberDatabase.kt | 16 + .../securesms/database/MmsDatabase.kt | 15 + .../database/SessionContactDatabase.kt | 10 + .../securesms/groups/OpenGroupManager.kt | 8 +- .../notifications/DefaultMessageNotifier.java | 2 - .../res/layout/activity_conversation_v2.xml | 92 +++--- .../res/layout/view_mention_candidate_v2.xml | 6 +- .../conversation/v2/MentionEditableTest.kt | 115 ++++++++ .../conversation/v2/MentionViewModelTest.kt | 185 ++++++++++++ .../test/resources/TestAndroidManifest.xml | 4 + app/src/test/resources/robolectric.properties | 3 + .../messaging/open_groups/GroupMember.kt | 9 +- 24 files changed, 992 insertions(+), 337 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateAdapter.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidatesView.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionEditable.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionSpan.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionManagerUtilities.kt create mode 100644 app/src/test/java/org/thoughtcrime/securesms/conversation/v2/MentionEditableTest.kt create mode 100644 app/src/test/java/org/thoughtcrime/securesms/conversation/v2/MentionViewModelTest.kt create mode 100644 app/src/test/resources/TestAndroidManifest.xml create mode 100644 app/src/test/resources/robolectric.properties diff --git a/app/build.gradle b/app/build.gradle index eb2c16e953..aa3ad4e779 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -368,8 +368,11 @@ dependencies { androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.5.1' androidTestUtil 'androidx.test:orchestrator:1.4.2' - testImplementation 'org.robolectric:robolectric:4.4' - testImplementation 'org.robolectric:shadows-multidex:4.4' + testImplementation 'org.robolectric:robolectric:4.12.2' + testImplementation 'org.robolectric:shadows-multidex:4.12.2' + testImplementation 'org.conscrypt:conscrypt-openjdk-uber:2.5.2' // For Robolectric + testImplementation 'app.cash.turbine:turbine:1.1.0' + implementation 'com.github.bumptech.glide:compose:1.0.0-alpha.5' implementation 'androidx.compose.ui:ui:1.5.2' diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt index 36a8c1adf5..b46243eeb0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt @@ -7,10 +7,8 @@ import android.view.View import android.widget.LinearLayout import network.loki.messenger.R import network.loki.messenger.databinding.ViewUserBinding -import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.contacts.Contact import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.mms.GlideRequests diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt index 184869b9ad..8f2da7a733 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt @@ -21,7 +21,6 @@ import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.utilities.ExpirationUtil import org.session.libsession.utilities.modifyLayoutParams import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.LokiAPIDatabase import org.thoughtcrime.securesms.util.DateUtils @@ -78,7 +77,6 @@ class ConversationActionBarView @JvmOverloads constructor( binding.profilePictureView.layoutParams = resources.getDimensionPixelSize( if (recipient.isClosedGroupRecipient) R.dimen.medium_profile_picture_size else R.dimen.small_profile_picture_size ).let { LayoutParams(it, it) } - MentionManagerUtilities.populateUserPublicKeyCacheIfNeeded(threadId, context) update(recipient, openGroup, config) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index d5e8c43fd9..7fdca6936e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -29,8 +29,8 @@ import android.view.Menu import android.view.MenuItem import android.view.MotionEvent import android.view.View +import android.view.ViewGroup.LayoutParams import android.view.WindowManager -import android.widget.RelativeLayout import android.widget.Toast import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -46,6 +46,7 @@ import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.loader.app.LoaderManager import androidx.loader.content.Loader import androidx.recyclerview.widget.LinearLayoutManager @@ -67,8 +68,6 @@ import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.jobs.AttachmentDownloadJob import org.session.libsession.messaging.jobs.JobQueue -import org.session.libsession.messaging.mentions.Mention -import org.session.libsession.messaging.mentions.MentionsManager import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.applyExpiryMode import org.session.libsession.messaging.messages.control.DataExtractionNotification @@ -118,7 +117,8 @@ import org.thoughtcrime.securesms.conversation.v2.dialogs.SendSeedDialog import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarButton import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarDelegate import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarRecordingViewDelegate -import org.thoughtcrime.securesms.conversation.v2.input_bar.mentions.MentionCandidatesView +import org.thoughtcrime.securesms.conversation.v2.input_bar.mentions.MentionCandidateAdapter +import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallback import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallbackDelegate import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper @@ -215,6 +215,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe @Inject lateinit var storage: Storage @Inject lateinit var reactionDb: ReactionDatabase @Inject lateinit var viewModelFactory: ConversationViewModel.AssistedFactory + @Inject lateinit var mentionViewModelFactory: MentionViewModel.AssistedFactory private val screenshotObserver by lazy { ScreenshotObserver(this, Handler(Looper.getMainLooper())) { @@ -228,7 +229,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe ViewModelProvider(this, LinkPreviewViewModel.Factory(LinkPreviewRepository())) .get(LinkPreviewViewModel::class.java) } - private val viewModel: ConversationViewModel by viewModels { + + private val threadId: Long by lazy { var threadId = intent.getLongExtra(THREAD_ID, -1L) if (threadId == -1L) { intent.getParcelableExtra
(ADDRESS)?.let { it -> @@ -248,6 +250,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } ?: finish() } + + threadId + } + + private val viewModel: ConversationViewModel by viewModels { viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair()) } private var actionMode: ActionMode? = null @@ -260,11 +267,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private var isLockViewExpanded = false private var isShowingAttachmentOptions = false // Mentions - private val mentions = mutableListOf() - private var mentionCandidatesView: MentionCandidatesView? = null - private var previousText: CharSequence = "" - private var currentMentionStartIndex = -1 - private var isShowingMentionCandidatesView = false + private val mentionViewModel: MentionViewModel by viewModels { + mentionViewModelFactory.create(threadId) + } + private val mentionCandidateAdapter = MentionCandidateAdapter { + mentionViewModel.onCandidateSelected(it.member.publicKey) + } // Search val searchViewModel: SearchViewModel by viewModels() var searchViewItem: MenuItem? = null @@ -486,6 +494,27 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } } + + setupMentionView() + } + + private fun setupMentionView() { + binding?.conversationMentionCandidates?.let { view -> + view.adapter = mentionCandidateAdapter + view.itemAnimator = null + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + mentionViewModel.autoCompleteState + .collectLatest { state -> + mentionCandidateAdapter.candidates = + (state as? MentionViewModel.AutoCompleteState.Result)?.members.orEmpty() + } + } + } + + binding?.inputBar?.setInputBarEditableFactory(mentionViewModel.editableFactory) } override fun onResume() { @@ -642,23 +671,19 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe binding.inputBar.delegate = this binding.inputBarRecordingView.delegate = this // GIF button - binding.gifButtonContainer.addView(gifButton) - gifButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) + binding.gifButtonContainer.addView(gifButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) gifButton.onUp = { showGIFPicker() } gifButton.snIsEnabled = false // Document button - binding.documentButtonContainer.addView(documentButton) - documentButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) + binding.documentButtonContainer.addView(documentButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) documentButton.onUp = { showDocumentPicker() } documentButton.snIsEnabled = false // Library button - binding.libraryButtonContainer.addView(libraryButton) - libraryButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) + binding.libraryButtonContainer.addView(libraryButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) libraryButton.onUp = { pickFromLibrary() } libraryButton.snIsEnabled = false // Camera button - binding.cameraButtonContainer.addView(cameraButton) - cameraButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) + binding.cameraButtonContainer.addView(cameraButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) cameraButton.onUp = { showCamera() } cameraButton.snIsEnabled = false } @@ -913,7 +938,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (textSecurePreferences.isLinkPreviewsEnabled()) { linkPreviewViewModel.onTextChanged(this, inputBarText, 0, 0) } - showOrHideMentionCandidatesIfNeeded(newContent) if (LinkPreviewUtil.findWhitelistedUrls(newContent.toString()).isNotEmpty() && !textSecurePreferences.isLinkPreviewsEnabled() && !textSecurePreferences.hasSeenLinkPreviewSuggestionDialog()) { LinkPreviewDialog { @@ -925,76 +949,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } - private fun showOrHideMentionCandidatesIfNeeded(text: CharSequence) { - if (text.length < previousText.length) { - currentMentionStartIndex = -1 - hideMentionCandidates() - val mentionsToRemove = mentions.filter { !text.contains(it.displayName) } - mentions.removeAll(mentionsToRemove) - } - if (text.isNotEmpty()) { - val lastCharIndex = text.lastIndex - val lastChar = text[lastCharIndex] - // Check if there is whitespace before the '@' or the '@' is the first character - val isCharacterBeforeLastWhiteSpaceOrStartOfLine: Boolean - if (text.length == 1) { - isCharacterBeforeLastWhiteSpaceOrStartOfLine = true // Start of line - } else { - val charBeforeLast = text[lastCharIndex - 1] - isCharacterBeforeLastWhiteSpaceOrStartOfLine = Character.isWhitespace(charBeforeLast) - } - if (lastChar == '@' && isCharacterBeforeLastWhiteSpaceOrStartOfLine) { - currentMentionStartIndex = lastCharIndex - showOrUpdateMentionCandidatesIfNeeded() - } else if (Character.isWhitespace(lastChar) || lastChar == '@') { // the lastCharacter == "@" is to check for @@ - currentMentionStartIndex = -1 - hideMentionCandidates() - } else if (currentMentionStartIndex != -1) { - val query = text.substring(currentMentionStartIndex + 1) // + 1 to get rid of the "@" - showOrUpdateMentionCandidatesIfNeeded(query) - } - } else { - currentMentionStartIndex = -1 - hideMentionCandidates() - } - previousText = text - } - - private fun showOrUpdateMentionCandidatesIfNeeded(query: String = "") { - val additionalContentContainer = binding?.additionalContentContainer ?: return - val recipient = viewModel.recipient ?: return - if (!isShowingMentionCandidatesView) { - additionalContentContainer.removeAllViews() - val view = MentionCandidatesView(this).apply { - contentDescription = context.getString(R.string.AccessibilityId_mentions_list) - } - view.glide = glide - view.onCandidateSelected = { handleMentionSelected(it) } - additionalContentContainer.addView(view) - val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, recipient.isCommunityRecipient) - this.mentionCandidatesView = view - view.show(candidates, viewModel.threadId) - } else { - val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, recipient.isCommunityRecipient) - this.mentionCandidatesView!!.setMentionCandidates(candidates) - } - isShowingMentionCandidatesView = true - } - - private fun hideMentionCandidates() { - if (isShowingMentionCandidatesView) { - val mentionCandidatesView = mentionCandidatesView ?: return - val animation = ValueAnimator.ofObject(FloatEvaluator(), mentionCandidatesView.alpha, 0.0f) - animation.duration = 250L - animation.addUpdateListener { animator -> - mentionCandidatesView.alpha = animator.animatedValue as Float - if (animator.animatedFraction == 1.0f) { binding?.additionalContentContainer?.removeAllViews() } - } - animation.start() - } - isShowingMentionCandidatesView = false - } - override fun toggleAttachmentOptions() { val targetAlpha = if (isShowingAttachmentOptions) 0.0f else 1.0f val allButtonContainers = listOfNotNull( @@ -1510,18 +1464,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe return hitRect.contains(x, y) } - private fun handleMentionSelected(mention: Mention) { - val binding = binding ?: return - if (currentMentionStartIndex == -1) { return } - mentions.add(mention) - val previousText = binding.inputBar.text - val newText = previousText.substring(0, currentMentionStartIndex) + "@" + mention.displayName + " " - binding.inputBar.text = newText - binding.inputBar.setSelection(newText.length) - currentMentionStartIndex = -1 - hideMentionCandidates() - this.previousText = newText - } override fun scrollToMessageIfPossible(timestamp: Long) { val lastSeenItemPosition = adapter.getItemPositionForTimestamp(timestamp) ?: return @@ -1620,10 +1562,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe binding?.inputBar?.text = "" binding?.inputBar?.cancelQuoteDraft() binding?.inputBar?.cancelLinkPreviewDraft() - // Clear mentions - previousText = "" - currentMentionStartIndex = -1 - mentions.clear() // Put the message in the database message.id = smsDb.insertMessageOutbox(viewModel.threadId, outgoingTextMessage, false, message.sentTimestamp!!, null, true) // Send it @@ -1668,10 +1606,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe binding?.inputBar?.text = "" binding?.inputBar?.cancelQuoteDraft() binding?.inputBar?.cancelLinkPreviewDraft() - // Clear mentions - previousText = "" - currentMentionStartIndex = -1 - mentions.clear() // Reset the attachment manager attachmentManager.clear() // Reset attachments button if needed @@ -2105,17 +2039,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // region General private fun getMessageBody(): String { - var result = binding?.inputBar?.text?.trim() ?: return "" - for (mention in mentions) { - try { - val startIndex = result.indexOf("@" + mention.displayName) - val endIndex = startIndex + mention.displayName.count() + 1 // + 1 to include the "@" - result = result.substring(0, startIndex) + "@" + mention.publicKey + result.substring(endIndex) - } catch (exception: Exception) { - Log.d("Loki", "Failed to process mention due to error: $exception") - } - } - return result + return mentionViewModel.normalizeMessageBody() } // endregion diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt index c036ed38b4..2304149367 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.res.Resources import android.graphics.PointF import android.net.Uri +import android.text.Editable import android.text.InputType import android.text.TextWatcher import android.util.AttributeSet @@ -224,8 +225,8 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li binding.inputBarEditText.addTextChangedListener(textWatcher) } - fun setSelection(index: Int) { - binding.inputBarEditText.setSelection(index) + fun setInputBarEditableFactory(factory: Editable.Factory) { + binding.inputBarEditText.setEditableFactory(factory) } // endregion } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateAdapter.kt new file mode 100644 index 0000000000..daed43ce74 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateAdapter.kt @@ -0,0 +1,44 @@ +package org.thoughtcrime.securesms.conversation.v2.input_bar.mentions + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import network.loki.messenger.databinding.ViewMentionCandidateV2Binding +import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel + +class MentionCandidateAdapter( + private val onCandidateSelected: ((MentionViewModel.Candidate) -> Unit) +) : RecyclerView.Adapter() { + var candidates = listOf() + set(newValue) { + if (field != newValue) { + val result = DiffUtil.calculateDiff(object : DiffUtil.Callback() { + override fun getOldListSize() = field.size + override fun getNewListSize() = newValue.size + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) + = field[oldItemPosition].member.publicKey == newValue[newItemPosition].member.publicKey + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) + = field[oldItemPosition] == newValue[newItemPosition] + }) + + field = newValue + result.dispatchUpdatesTo(this) + } + } + + class ViewHolder(val binding: ViewMentionCandidateV2Binding) + : RecyclerView.ViewHolder(binding.root) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return ViewHolder(ViewMentionCandidateV2Binding.inflate(LayoutInflater.from(parent.context), parent, false)) + } + + override fun getItemCount(): Int = candidates.size + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val candidate = candidates[position] + holder.binding.update(candidate) + holder.binding.root.setOnClickListener { onCandidateSelected(candidate) } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt index 2d8f745967..f790e7f1c6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt @@ -1,42 +1,14 @@ package org.thoughtcrime.securesms.conversation.v2.input_bar.mentions -import android.content.Context -import android.util.AttributeSet -import android.view.LayoutInflater import android.view.View -import android.widget.RelativeLayout import network.loki.messenger.databinding.ViewMentionCandidateV2Binding -import org.session.libsession.messaging.mentions.Mention -import org.thoughtcrime.securesms.groups.OpenGroupManager -import org.thoughtcrime.securesms.mms.GlideRequests +import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel -class MentionCandidateView : RelativeLayout { - private lateinit var binding: ViewMentionCandidateV2Binding - var candidate = Mention("", "") - set(newValue) { field = newValue; update() } - var glide: GlideRequests? = null - var openGroupServer: String? = null - var openGroupRoom: String? = null - - constructor(context: Context) : this(context, null) - constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } - - private fun initialize() { - binding = ViewMentionCandidateV2Binding.inflate(LayoutInflater.from(context), this, true) - } - - private fun update() = with(binding) { - mentionCandidateNameTextView.text = candidate.displayName - profilePictureView.publicKey = candidate.publicKey - profilePictureView.displayName = candidate.displayName - profilePictureView.additionalPublicKey = null - profilePictureView.update() - if (openGroupServer != null && openGroupRoom != null) { - val isUserModerator = OpenGroupManager.isUserModerator(context, "$openGroupRoom.$openGroupServer", candidate.publicKey) - moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE - } else { - moderatorIconImageView.visibility = View.GONE - } - } -} \ No newline at end of file +fun ViewMentionCandidateV2Binding.update(candidate: MentionViewModel.Candidate) { + mentionCandidateNameTextView.text = candidate.nameHighlighted + profilePictureView.publicKey = candidate.member.publicKey + profilePictureView.displayName = candidate.member.name + profilePictureView.additionalPublicKey = null + profilePictureView.update() + moderatorIconImageView.visibility = if (candidate.member.isModerator) View.VISIBLE else View.GONE +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidatesView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidatesView.kt deleted file mode 100644 index e62f7f8f85..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidatesView.kt +++ /dev/null @@ -1,90 +0,0 @@ -package org.thoughtcrime.securesms.conversation.v2.input_bar.mentions - -import android.content.Context -import android.util.AttributeSet -import android.view.View -import android.view.ViewGroup -import android.widget.BaseAdapter -import android.widget.ListView -import dagger.hilt.android.AndroidEntryPoint -import network.loki.messenger.R -import org.session.libsession.messaging.mentions.Mention -import org.thoughtcrime.securesms.database.LokiThreadDatabase -import org.thoughtcrime.securesms.mms.GlideRequests -import org.thoughtcrime.securesms.util.toPx -import javax.inject.Inject - -@AndroidEntryPoint -class MentionCandidatesView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : ListView(context, attrs, defStyleAttr) { - private var candidates = listOf() - set(newValue) { field = newValue; snAdapter.candidates = newValue } - var glide: GlideRequests? = null - set(newValue) { field = newValue; snAdapter.glide = newValue } - var openGroupServer: String? = null - set(newValue) { field = newValue; snAdapter.openGroupServer = openGroupServer } - var openGroupRoom: String? = null - set(newValue) { field = newValue; snAdapter.openGroupRoom = openGroupRoom } - var onCandidateSelected: ((Mention) -> Unit)? = null - - @Inject lateinit var threadDb: LokiThreadDatabase - - private val snAdapter by lazy { Adapter(context) } - - private class Adapter(private val context: Context) : BaseAdapter() { - var candidates = listOf() - set(newValue) { field = newValue; notifyDataSetChanged() } - var glide: GlideRequests? = null - var openGroupServer: String? = null - var openGroupRoom: String? = null - - override fun getCount(): Int { return candidates.count() } - override fun getItemId(position: Int): Long { return position.toLong() } - override fun getItem(position: Int): Mention { return candidates[position] } - - override fun getView(position: Int, cellToBeReused: View?, parent: ViewGroup): View { - val cell = cellToBeReused as MentionCandidateView? ?: MentionCandidateView(context).apply { - contentDescription = context.getString(R.string.AccessibilityId_contact) - } - val mentionCandidate = getItem(position) - cell.glide = glide - cell.candidate = mentionCandidate - cell.openGroupServer = openGroupServer - cell.openGroupRoom = openGroupRoom - return cell - } - } - - constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) - constructor(context: Context) : this(context, null) - - init { - clipToOutline = true - adapter = snAdapter - snAdapter.candidates = candidates - setOnItemClickListener { _, _, position, _ -> - onCandidateSelected?.invoke(candidates[position]) - } - } - - fun show(candidates: List, threadID: Long) { - val openGroup = threadDb.getOpenGroupChat(threadID) - if (openGroup != null) { - openGroupServer = openGroup.server - openGroupRoom = openGroup.room - } - setMentionCandidates(candidates) - } - - fun setMentionCandidates(candidates: List) { - this.candidates = candidates - val layoutParams = this.layoutParams as ViewGroup.LayoutParams - layoutParams.height = toPx(Math.min(candidates.count(), 4) * 44, resources) - this.layoutParams = layoutParams - } - - fun hide() { - val layoutParams = this.layoutParams as ViewGroup.LayoutParams - layoutParams.height = 0 - this.layoutParams = layoutParams - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionEditable.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionEditable.kt new file mode 100644 index 0000000000..bc4b068b2a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionEditable.kt @@ -0,0 +1,188 @@ +package org.thoughtcrime.securesms.conversation.v2.mention + +import android.text.Selection +import android.text.SpannableStringBuilder +import androidx.core.text.getSpans +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart + +private const val SEARCH_QUERY_DEBOUNCE_MILLS = 100L + +/** + * A subclass of [SpannableStringBuilder] that provides a way to observe the mention search query, + * and also manages the [MentionSpan] in a way that treats the mention span as a whole. + */ +class MentionEditable : SpannableStringBuilder() { + private val queryChangeNotification = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_LATEST + ) + + fun observeMentionSearchQuery(): Flow { + @Suppress("OPT_IN_USAGE") + return queryChangeNotification + .debounce(SEARCH_QUERY_DEBOUNCE_MILLS) + .onStart { emit(Unit) } + .map { mentionSearchQuery } + .distinctUntilChanged() + } + + data class SearchQuery( + val mentionSymbolStartAt: Int, + val query: String + ) + + val mentionSearchQuery: SearchQuery? + get() { + val cursorPosition = Selection.getSelectionStart(this) + + // First, make sure we are not selecting text + if (cursorPosition != Selection.getSelectionEnd(this)) { + return null + } + + // Make sure we don't already have a mention span at the cursor position + if (getSpans(cursorPosition, cursorPosition, MentionSpan::class.java).isNotEmpty()) { + return null + } + + // Find the mention symbol '@' before the cursor position + val symbolIndex = findEligibleMentionSymbolIndexBefore(cursorPosition - 1) + if (symbolIndex < 0) { + return null + } + + // The query starts after the symbol '@' and ends at a whitespace, @ or the end + val queryStart = symbolIndex + 1 + var queryEnd = indexOfStartingAt(queryStart) { it.isWhitespace() || it == '@' } + if (queryEnd < 0) { + queryEnd = length + } + + return SearchQuery( + mentionSymbolStartAt = symbolIndex, + query = subSequence(queryStart, queryEnd).toString() + ) + } + + override fun setSpan(what: Any?, start: Int, end: Int, flags: Int) { + var normalisedStart = start + var normalisedEnd = end + + val isSelectionStart = what == Selection.SELECTION_START + val isSelectionEnd = what == Selection.SELECTION_END + + if (isSelectionStart || isSelectionEnd) { + assert(start == end) { "Selection spans must have zero length" } + val selection = start + + val mentionSpan = getSpans(selection, selection).firstOrNull() + if (mentionSpan != null) { + val spanStart = getSpanStart(mentionSpan) + val spanEnd = getSpanEnd(mentionSpan) + + if (isSelectionStart && selection != spanEnd) { + // A selection start will only be adjusted to the start of the mention span, + // if the selection start is not at the end the mention span. (A selection start + // at the end of the mention span is considered an escape path from the mention span) + normalisedStart = spanStart + normalisedEnd = normalisedStart + } else if (isSelectionEnd && selection != spanStart) { + normalisedEnd = spanEnd + normalisedStart = normalisedEnd + } + } + + queryChangeNotification.tryEmit(Unit) + } + + super.setSpan(what, normalisedStart, normalisedEnd, flags) + } + + override fun removeSpan(what: Any?) { + super.removeSpan(what) + queryChangeNotification.tryEmit(Unit) + } + + // The only method we need to override + override fun replace(st: Int, en: Int, source: CharSequence?, start: Int, end: Int): MentionEditable { + // Make sure the mention span is treated like a whole + var normalisedStart = st + var normalisedEnd = en + + if (st != en) { + // Find the mention span that intersects with the replaced range, and expand the range to include it, + // this does not apply to insertion operation (st == en) + for (mentionSpan in getSpans(st, en, MentionSpan::class.java)) { + val mentionStart = getSpanStart(mentionSpan) + val mentionEnd = getSpanEnd(mentionSpan) + + if (mentionStart < normalisedStart) { + normalisedStart = mentionStart + } + if (mentionEnd > normalisedEnd) { + normalisedEnd = mentionEnd + } + + removeSpan(mentionSpan) + } + } + + super.replace(normalisedStart, normalisedEnd, source, start, end) + queryChangeNotification.tryEmit(Unit) + return this + } + + fun addMention(member: MentionViewModel.Member, replaceRange: IntRange) { + val replaceWith = "@${member.name} " + replace(replaceRange.first, replaceRange.last, replaceWith) + setSpan( + MentionSpan(member), + replaceRange.first, + replaceRange.first + replaceWith.length - 1, + SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + + override fun delete(st: Int, en: Int) = replace(st, en, "", 0, 0) + + private fun findEligibleMentionSymbolIndexBefore(offset: Int): Int { + if (isEmpty()) { + return -1 + } + + var i = offset.coerceIn(indices) + while (i >= 0) { + val c = get(i) + if (c == '@') { + // Make sure there is no more '@' before this one or it's disqualified + if (i > 0 && get(i - 1) == '@') { + return -1 + } + + return i + } else if (c.isWhitespace()) { + break + } + i-- + } + return -1 + } +} + +private fun CharSequence.indexOfStartingAt(offset: Int, predicate: (Char) -> Boolean): Int { + var i = offset.coerceIn(0..length) + while (i < length) { + if (predicate(get(i))) { + return i + } + i++ + } + + return -1 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionSpan.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionSpan.kt new file mode 100644 index 0000000000..d7fa4d56cd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionSpan.kt @@ -0,0 +1,8 @@ +package org.thoughtcrime.securesms.conversation.v2.mention + +/** + * A span that represents a mention in the text. + */ +class MentionSpan( + val member: MentionViewModel.Member +) \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt new file mode 100644 index 0000000000..fbb4d2231f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt @@ -0,0 +1,274 @@ +package org.thoughtcrime.securesms.conversation.v2.mention + +import android.content.ContentResolver +import android.graphics.Typeface +import android.text.Editable +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.style.StyleSpan +import androidx.core.text.getSpans +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.withContext +import org.session.libsession.messaging.contacts.Contact +import org.thoughtcrime.securesms.database.DatabaseContentProviders.Conversation +import org.thoughtcrime.securesms.database.GroupDatabase +import org.thoughtcrime.securesms.database.GroupMemberDatabase +import org.thoughtcrime.securesms.database.MmsDatabase +import org.thoughtcrime.securesms.database.SessionContactDatabase +import org.thoughtcrime.securesms.database.Storage +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.util.observeChanges + +/** + * A ViewModel that provides the mention search functionality for a text input. + * + * To use this ViewModel, you (a view) will need to: + * 1. Observe the [autoCompleteState] to get the mention search results. + * 2. Set the EditText's editable factory to [editableFactory], via [android.widget.EditText.setEditableFactory] + */ +class MentionViewModel( + threadID: Long, + contentResolver: ContentResolver, + threadDatabase: ThreadDatabase, + groupDatabase: GroupDatabase, + mmsDatabase: MmsDatabase, + contactDatabase: SessionContactDatabase, + memberDatabase: GroupMemberDatabase, + storage: Storage, + dispatcher: CoroutineDispatcher = Dispatchers.IO, +) : ViewModel() { + private val editable = MentionEditable() + + /** + * A factory that creates a new [Editable] instance that is backed by the same source of truth + * used by this viewModel. + */ + val editableFactory = object : Editable.Factory() { + override fun newEditable(source: CharSequence?): Editable { + if (source === editable) { + return source + } + + if (source != null) { + editable.replace(0, editable.length, source) + } + + return editable + } + } + + @Suppress("OPT_IN_USAGE") + private val members: StateFlow?> = + (contentResolver.observeChanges(Conversation.getUriForThread(threadID)) as Flow) + .debounce(500L) + .onStart { emit(Unit) } + .mapLatest { + val recipient = checkNotNull(threadDatabase.getRecipientForThreadId(threadID)) { + "Recipient not found for thread ID: $threadID" + } + + val memberIDs = when { + recipient.isClosedGroupRecipient -> { + groupDatabase.getGroupMemberAddresses(recipient.address.toGroupString(), false) + .map { it.serialize() } + } + + recipient.isCommunityRecipient -> mmsDatabase.getRecentChatMemberIDs(threadID, 20) + recipient.isContactRecipient -> listOf(recipient.address.serialize()) + else -> listOf() + } + + val moderatorIDs = if (recipient.isCommunityRecipient) { + val groupId = storage.getOpenGroup(threadID)?.id + if (groupId.isNullOrBlank()) { + emptySet() + } else { + memberDatabase.getGroupMembersRoles(groupId, memberIDs) + .mapNotNullTo(hashSetOf()) { (memberId, roles) -> + memberId.takeIf { roles.any { it.isModerator } } + } + } + } else { + emptySet() + } + + val contactContext = if (recipient.isCommunityRecipient) { + Contact.ContactContext.OPEN_GROUP + } else { + Contact.ContactContext.REGULAR + } + + contactDatabase.getContacts(memberIDs).map { contact -> + Member( + publicKey = contact.sessionID, + name = contact.displayName(contactContext).orEmpty(), + isModerator = contact.sessionID in moderatorIDs, + ) + } + } + .flowOn(dispatcher) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(10_000L), null) + + + @OptIn(ExperimentalCoroutinesApi::class) + val autoCompleteState: StateFlow = editable + .observeMentionSearchQuery() + .flatMapLatest { query -> + if (query == null) { + return@flatMapLatest flowOf(AutoCompleteState.Idle) + } + + members.mapLatest { members -> + if (members == null) { + return@mapLatest AutoCompleteState.Loading + } + + withContext(Dispatchers.Default) { + val filtered = if (query.query.isBlank()) { + members.mapTo(mutableListOf()) { Candidate(it, it.name, 0) } + } else { + members.mapNotNullTo(mutableListOf()) { searchAndHighlight(it, query.query) } + } + + filtered.sortWith(Candidate.MENTION_LIST_COMPARATOR) + AutoCompleteState.Result(filtered, query.query) + } + } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), AutoCompleteState.Idle) + + private fun searchAndHighlight( + haystack: Member, + needle: String + ): Candidate? { + val startIndex = haystack.name.indexOf(needle, ignoreCase = true) + + return if (startIndex >= 0) { + val endIndex = startIndex + needle.length + val spanned = SpannableStringBuilder(haystack.name) + spanned.setSpan( + StyleSpan(Typeface.BOLD), + startIndex, + endIndex, + Spanned.SPAN_INCLUSIVE_EXCLUSIVE + ) + Candidate(member = haystack, nameHighlighted = spanned, matchScore = startIndex) + } else { + null + } + } + + fun onCandidateSelected(candidatePublicKey: String) { + val query = editable.mentionSearchQuery ?: return + val autoCompleteState = autoCompleteState.value as? AutoCompleteState.Result ?: return + val candidate = autoCompleteState.members.find { it.member.publicKey == candidatePublicKey } ?: return + + editable.addMention( + candidate.member, + query.mentionSymbolStartAt .. (query.mentionSymbolStartAt + query.query.length + 1) + ) + } + + /** + * Given a message body, normalize it by replacing the display name following '@' with their public key. + * + * As "@123456" is the standard format for mentioning a user, this method will replace "@Alice" with "@123456" + */ + fun normalizeMessageBody(): String { + val spansWithRanges = editable.getSpans() + .mapTo(mutableListOf()) { span -> + span to (editable.getSpanStart(span)..editable.getSpanEnd(span)) + } + + spansWithRanges.sortBy { it.second.first } + + val sb = StringBuilder() + var offset = 0 + for ((span, range) in spansWithRanges) { + // Add content before the mention span + sb.append(editable, offset, range.first) + + // Replace the mention span with "@public key" + sb.append('@').append(span.member.publicKey).append(' ') + + offset = range.last + 1 + } + + // Add the remaining content + sb.append(editable, offset, editable.length) + return sb.toString() + } + + data class Member( + val publicKey: String, + val name: String, + val isModerator: Boolean, + ) + + data class Candidate( + val member: Member, + // The name with the matching keyword highlighted. + val nameHighlighted: CharSequence, + // The score of matching the query keyword. Lower is better. + val matchScore: Int, + ) { + companion object { + val MENTION_LIST_COMPARATOR = compareBy { it.matchScore } + .then(compareBy { it.member.name }) + } + } + + sealed interface AutoCompleteState { + object Idle : AutoCompleteState + object Loading : AutoCompleteState + data class Result(val members: List, val query: String) : AutoCompleteState + object Error : AutoCompleteState + } + + @dagger.assisted.AssistedFactory + interface AssistedFactory { + fun create(threadId: Long): Factory + } + + class Factory @AssistedInject constructor( + @Assisted private val threadId: Long, + private val contentResolver: ContentResolver, + private val threadDatabase: ThreadDatabase, + private val groupDatabase: GroupDatabase, + private val mmsDatabase: MmsDatabase, + private val contactDatabase: SessionContactDatabase, + private val storage: Storage, + private val memberDatabase: GroupMemberDatabase, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return MentionViewModel( + threadID = threadId, + contentResolver = contentResolver, + threadDatabase = threadDatabase, + groupDatabase = groupDatabase, + mmsDatabase = mmsDatabase, + contactDatabase = contactDatabase, + memberDatabase = memberDatabase, + storage = storage, + ) as T + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionManagerUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionManagerUtilities.kt deleted file mode 100644 index ee1c7257c2..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionManagerUtilities.kt +++ /dev/null @@ -1,34 +0,0 @@ -package org.thoughtcrime.securesms.conversation.v2.utilities - -import android.content.Context -import org.session.libsession.messaging.mentions.MentionsManager -import org.session.libsession.utilities.TextSecurePreferences -import org.thoughtcrime.securesms.database.model.MessageRecord -import org.thoughtcrime.securesms.dependencies.DatabaseComponent - -object MentionManagerUtilities { - - fun populateUserPublicKeyCacheIfNeeded(threadID: Long, context: Context) { - val result = mutableSetOf() - val recipient = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(threadID) ?: return - if (recipient.address.isClosedGroup) { - val members = DatabaseComponent.get(context).groupDatabase().getGroupMembers(recipient.address.toGroupString(), false).map { it.address.serialize() } - result.addAll(members) - } else { - val messageDatabase = DatabaseComponent.get(context).mmsSmsDatabase() - val reader = messageDatabase.readerFor(messageDatabase.getConversation(threadID, true, 0, 200)) - var record: MessageRecord? = reader.next - while (record != null) { - result.add(record.individualRecipient.address.serialize()) - try { - record = reader.next - } catch (exception: Exception) { - record = null - } - } - reader.close() - result.add(TextSecurePreferences.getLocalNumber(context)!!) - } - MentionsManager.userPublicKeyCache[threadID] = result - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupMemberDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/GroupMemberDatabase.kt index ff44ef2c9a..e869f741c7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupMemberDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupMemberDatabase.kt @@ -3,9 +3,12 @@ package org.thoughtcrime.securesms.database import android.content.ContentValues import android.content.Context import android.database.Cursor +import org.json.JSONArray import org.session.libsession.messaging.open_groups.GroupMember import org.session.libsession.messaging.open_groups.GroupMemberRole import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper +import org.thoughtcrime.securesms.util.asSequence +import java.util.EnumSet class GroupMemberDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) { @@ -51,6 +54,19 @@ class GroupMemberDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab return mappings.map { it.role } } + fun getGroupMembersRoles(groupId: String, memberIDs: Collection): Map> { + val sql = """ + SELECT * FROM $TABLE_NAME + WHERE $GROUP_ID = ? AND $PROFILE_ID IN (SELECT value FROM json_each(?)) + """.trimIndent() + + return readableDatabase.rawQuery(sql, groupId, JSONArray(memberIDs).toString()).use { cursor -> + cursor.asSequence() + .map { readGroupMember(it) } + .groupBy(keySelector = { it.profileId }, valueTransform = { it.role }) + } + } + fun setGroupMembers(members: List) { writableDatabase.beginTransaction() try { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt index 5648cdace1..23a1af7ceb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt @@ -218,6 +218,21 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa return cursor } + fun getRecentChatMemberIDs(threadID: Long, limit: Int): List { + val sql = """ + SELECT DISTINCT $ADDRESS FROM $TABLE_NAME + WHERE $THREAD_ID = ? + ORDER BY $DATE_SENT DESC + LIMIT $limit + """.trimIndent() + + return databaseHelper.readableDatabase.rawQuery(sql, threadID).use { cursor -> + cursor.asSequence() + .map { it.getString(0) } + .toList() + } + } + val expireStartedMessages: Reader get() { val where = "$EXPIRE_STARTED > 0" diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt index 49a6339368..778af6c01c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt @@ -4,6 +4,7 @@ import android.content.ContentValues import android.content.Context import android.database.Cursor import androidx.core.database.getStringOrNull +import org.json.JSONArray import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.utilities.SessionId import org.session.libsignal.utilities.Base64 @@ -41,6 +42,15 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da } } + fun getContacts(sessionIDs: Collection): List { + val database = databaseHelper.readableDatabase + return database.getAll( + sessionContactTable, + "$sessionID IN (SELECT value FROM json_each(?))", + arrayOf(JSONArray(sessionIDs).toString()) + ) { cursor -> contactFromCursor(cursor) } + } + fun getAllContacts(): Set { val database = databaseHelper.readableDatabase return database.getAll(sessionContactTable, null, null) { cursor -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt index 2754c70f69..01e1c514ff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt @@ -162,13 +162,7 @@ object OpenGroupManager { val memberDatabase = DatabaseComponent.get(context).groupMemberDatabase() val standardRoles = memberDatabase.getGroupMemberRoles(groupId, standardPublicKey) val blindedRoles = blindedPublicKey?.let { memberDatabase.getGroupMemberRoles(groupId, it) } ?: emptyList() - - // roles to check against - val moderatorRoles = listOf( - GroupMemberRole.MODERATOR, GroupMemberRole.ADMIN, - GroupMemberRole.HIDDEN_MODERATOR, GroupMemberRole.HIDDEN_ADMIN - ) - return standardRoles.any { it in moderatorRoles } || blindedRoles.any { it in moderatorRoles } + return standardRoles.any { it.isModerator } || blindedRoles.any { it.isModerator } } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java index 6aa514c8a9..8a891fb9b9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java @@ -56,7 +56,6 @@ import org.session.libsignal.utilities.Util; import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.contacts.ContactUtil; import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2; -import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities; import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities; import org.thoughtcrime.securesms.crypto.KeyPairUtilities; import org.thoughtcrime.securesms.database.LokiThreadDatabase; @@ -348,7 +347,6 @@ public class DefaultMessageNotifier implements MessageNotifier { builder.setThread(notifications.get(0).getRecipient()); builder.setMessageCount(notificationState.getMessageCount()); - MentionManagerUtilities.INSTANCE.populateUserPublicKeyCacheIfNeeded(notifications.get(0).getThreadId(),context); // TODO: Removing highlighting mentions in the notification because this context is the libsession one which // TODO: doesn't have access to the `R.attr.message_sent_text_color` and `R.attr.message_received_text_color` diff --git a/app/src/main/res/layout/activity_conversation_v2.xml b/app/src/main/res/layout/activity_conversation_v2.xml index 6fe0c4db60..4d38e2ab5b 100644 --- a/app/src/main/res/layout/activity_conversation_v2.xml +++ b/app/src/main/res/layout/activity_conversation_v2.xml @@ -1,5 +1,5 @@ - @@ -31,9 +34,11 @@ android:focusable="false" android:id="@+id/conversationRecyclerView" android:layout_width="match_parent" - android:layout_height="match_parent" - android:layout_above="@+id/typingIndicatorViewContainer" - android:layout_below="@id/toolbar" /> + android:layout_height="0dp" + app:layout_constraintVertical_weight="1" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintBottom_toTopOf="@+id/typingIndicatorViewContainer" + app:layout_constraintTop_toBottomOf="@id/toolbar" /> + tools:layout_height="60dp" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/messageRequestBar" + app:layout_constraintBottom_toBottomOf="parent" + /> - + tools:visibility="gone" + app:layout_constraintHeight_max="176dp" + app:layout_constraintBottom_toBottomOf="@+id/conversationRecyclerView" /> - - - - + tools:text="You'll be able to send" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/typingIndicatorViewContainer" + app:layout_constraintBottom_toTopOf="@+id/messageRequestBar" /> @@ -197,14 +220,14 @@ android:layout_height="wrap_content" android:layout_marginBottom="-12dp" android:visibility="gone" - android:layout_alignParentBottom="true" /> + app:layout_constraintBottom_toBottomOf="parent" /> - @@ -214,20 +237,20 @@ android:contentDescription="@string/AccessibilityId_blocked_banner_text" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_centerInParent="true" + android:layout_gravity="center" android:layout_margin="@dimen/medium_spacing" android:textColor="@color/white" android:textSize="@dimen/small_font_size" android:textStyle="bold" tools:text="Elon is blocked. Unblock them?" /> - + - @@ -237,14 +260,14 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:gravity="center_horizontal" - android:layout_centerInParent="true" + android:layout_gravity="center" android:layout_marginVertical="@dimen/very_small_spacing" android:layout_marginHorizontal="@dimen/medium_spacing" android:textColor="@color/black" android:textSize="@dimen/tiny_font_size" tools:text="This user's client is outdated, things may not work as expected" /> - + @@ -263,11 +286,12 @@ android:id="@+id/messageRequestBar" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_above="@id/inputBar" + app:layout_constraintBottom_toTopOf="@+id/inputBar" + app:layout_constraintTop_toBottomOf="@+id/textSendAfterApproval" android:layout_marginBottom="@dimen/large_spacing" android:orientation="vertical" android:visibility="gone" - tools:visibility="visible"> + tools:visibility="gone"> - + diff --git a/app/src/main/res/layout/view_mention_candidate_v2.xml b/app/src/main/res/layout/view_mention_candidate_v2.xml index f81dd73f84..a28c23fa15 100644 --- a/app/src/main/res/layout/view_mention_candidate_v2.xml +++ b/app/src/main/res/layout/view_mention_candidate_v2.xml @@ -1,9 +1,8 @@ - + xmlns:tools="http://schemas.android.com/tools"> diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/MentionEditableTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/MentionEditableTest.kt new file mode 100644 index 0000000000..8ae4cb43bb --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/MentionEditableTest.kt @@ -0,0 +1,115 @@ +package org.thoughtcrime.securesms.conversation.v2 + +import android.text.Editable +import android.text.Selection +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.thoughtcrime.securesms.conversation.v2.mention.MentionEditable +import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel + +@RunWith(RobolectricTestRunner::class) +class MentionEditableTest { + private lateinit var mentionEditable: MentionEditable + + @Before + fun setUp() { + mentionEditable = MentionEditable() + } + + @Test + fun `should not have query when there is no 'at' symbol`() = runTest { + mentionEditable.observeMentionSearchQuery().test { + assertThat(awaitItem()).isNull() + mentionEditable.simulateTyping("Some text") + expectNoEvents() + } + } + + @Test + fun `should have empty query after typing 'at' symbol`() = runTest { + mentionEditable.observeMentionSearchQuery().test { + assertThat(awaitItem()).isNull() + + mentionEditable.simulateTyping("Some text") + expectNoEvents() + + mentionEditable.simulateTyping("@") + assertThat(awaitItem()) + .isEqualTo(MentionEditable.SearchQuery(9, "")) + } + } + + @Test + fun `should have some query after typing words following 'at' symbol`() = runTest { + mentionEditable.observeMentionSearchQuery().test { + assertThat(awaitItem()).isNull() + + mentionEditable.simulateTyping("Some text") + expectNoEvents() + + mentionEditable.simulateTyping("@words") + assertThat(awaitItem()) + .isEqualTo(MentionEditable.SearchQuery(9, "words")) + } + } + + @Test + fun `should cancel query after a whitespace or another 'at' is typed`() = runTest { + mentionEditable.observeMentionSearchQuery().test { + assertThat(awaitItem()).isNull() + + mentionEditable.simulateTyping("@words") + assertThat(awaitItem()) + .isEqualTo(MentionEditable.SearchQuery(0, "words")) + + mentionEditable.simulateTyping(" ") + assertThat(awaitItem()) + .isNull() + + mentionEditable.simulateTyping("@query@") + assertThat(awaitItem()) + .isEqualTo(MentionEditable.SearchQuery(13, "")) + } + } + + @Test + fun `should move pass the whole span while moving cursor around mentioned block `() { + mentionEditable.append("Mention @user here") + mentionEditable.addMention(MentionViewModel.Member("user", "User", false), 8..14) + + // Put cursor right before @user, it should then select nothing + Selection.setSelection(mentionEditable, 8) + assertThat(mentionEditable.selection()).isEqualTo(intArrayOf(8, 8)) + + // Put cursor right after '@', it should then select the whole @user + Selection.setSelection(mentionEditable, 9) + assertThat(mentionEditable.selection()).isEqualTo(intArrayOf(8, 13)) + + // Put cursor right after @user, it should then select nothing + Selection.setSelection(mentionEditable, 13) + assertThat(mentionEditable.selection()).isEqualTo(intArrayOf(13, 13)) + } + + @Test + fun `should delete the whole mention block while deleting only part of it`() { + mentionEditable.append("Mention @user here") + mentionEditable.addMention(MentionViewModel.Member("user", "User", false), 8..14) + + mentionEditable.delete(8, 9) + assertThat(mentionEditable.toString()).isEqualTo("Mention here") + } +} + +private fun CharSequence.selection(): IntArray { + return intArrayOf(Selection.getSelectionStart(this), Selection.getSelectionEnd(this)) +} + +private fun Editable.simulateTyping(text: String) { + this.append(text) + Selection.setSelection(this, this.length) +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/MentionViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/MentionViewModelTest.kt new file mode 100644 index 0000000000..3b3dd53e1c --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/MentionViewModelTest.kt @@ -0,0 +1,185 @@ +package org.thoughtcrime.securesms.conversation.v2 + +import android.text.Selection +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.robolectric.RobolectricTestRunner +import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.messaging.open_groups.GroupMemberRole +import org.session.libsession.messaging.open_groups.OpenGroup +import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.MainCoroutineRule +import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel + +@RunWith(RobolectricTestRunner::class) +class MentionViewModelTest { + @OptIn(ExperimentalCoroutinesApi::class) + @get:Rule + val mainCoroutineRule = MainCoroutineRule() + + private lateinit var mentionViewModel: MentionViewModel + + private val threadID = 123L + + private data class MemberInfo( + val name: String, + val pubKey: String, + val roles: List + ) + + private val threadMembers = listOf( + MemberInfo("Alice", "pubkey1", listOf(GroupMemberRole.ADMIN)), + MemberInfo("Bob", "pubkey2", listOf(GroupMemberRole.STANDARD)), + MemberInfo("Charlie", "pubkey3", listOf(GroupMemberRole.MODERATOR)), + MemberInfo("David", "pubkey4", listOf(GroupMemberRole.HIDDEN_ADMIN)), + MemberInfo("Eve", "pubkey5", listOf(GroupMemberRole.HIDDEN_MODERATOR)), + MemberInfo("李云海", "pubkey6", listOf(GroupMemberRole.ZOOMBIE)), + ) + + private val memberContacts = threadMembers.map { m -> + Contact(m.pubKey).also { + it.name = m.name + } + } + + private val openGroup = OpenGroup( + server = "", + room = "", + id = "open_group_id_1", + name = "Open Group", + publicKey = "", + imageId = null, + infoUpdates = 0, + canWrite = true + ) + + @Before + fun setUp() { + @Suppress("UNCHECKED_CAST") + mentionViewModel = MentionViewModel( + threadID, + contentResolver = mock { }, + threadDatabase = mock { + on { getRecipientForThreadId(threadID) } doAnswer { + mock { + on { isClosedGroupRecipient } doReturn false + on { isCommunityRecipient } doReturn true + on { isContactRecipient } doReturn false + } + } + }, + groupDatabase = mock { + }, + mmsDatabase = mock { + on { getRecentChatMemberIDs(eq(threadID), any()) } doAnswer { + val limit = it.arguments[1] as Int + threadMembers.take(limit).map { m -> m.pubKey } + } + }, + contactDatabase = mock { + on { getContacts(any()) } doAnswer { + val ids = it.arguments[0] as Collection + memberContacts.filter { contact -> contact.sessionID in ids } + } + }, + memberDatabase = mock { + on { getGroupMembersRoles(eq(openGroup.id), any()) } doAnswer { + val memberIDs = it.arguments[1] as Collection + memberIDs.associateWith { id -> + threadMembers.first { m -> m.pubKey == id }.roles + } + } + }, + storage = mock { + on { getOpenGroup(threadID) } doReturn openGroup + }, + dispatcher = StandardTestDispatcher() + ) + } + + @Test + fun `should show candidates after 'at' symbol`() = runTest { + mentionViewModel.autoCompleteState.test { + assertThat(awaitItem()) + .isEqualTo(MentionViewModel.AutoCompleteState.Idle) + + val editable = mentionViewModel.editableFactory.newEditable("") + editable.append("Hello @") + expectNoEvents() // Nothing should happen before cursor is put after @ + Selection.setSelection(editable, editable.length) + + assertThat(awaitItem()) + .isEqualTo(MentionViewModel.AutoCompleteState.Loading) + + // Should show all the candidates + awaitItem().let { result -> + assertThat(result) + .isInstanceOf(MentionViewModel.AutoCompleteState.Result::class.java) + result as MentionViewModel.AutoCompleteState.Result + + assertThat(result.members).isEqualTo(threadMembers.mapIndexed { index, m -> + val name = + memberContacts[index].displayName(Contact.ContactContext.OPEN_GROUP).orEmpty() + + MentionViewModel.Candidate( + MentionViewModel.Member(m.pubKey, name, m.roles.any { it.isModerator }), + name, + 0 + ) + }) + } + + + // Continue typing to filter candidates + editable.append("li") + Selection.setSelection(editable, editable.length) + + // Should show only Alice and Charlie + awaitItem().let { result -> + assertThat(result) + .isInstanceOf(MentionViewModel.AutoCompleteState.Result::class.java) + result as MentionViewModel.AutoCompleteState.Result + + assertThat(result.members[0].member.name).isEqualTo("Alice (pubk...key1)") + assertThat(result.members[1].member.name).isEqualTo("Charlie (pubk...key3)") + } + } + } + + @Test + fun `should have normalised message with candidates selected`() = runTest { + mentionViewModel.autoCompleteState.test { + assertThat(awaitItem()) + .isEqualTo(MentionViewModel.AutoCompleteState.Idle) + + val editable = mentionViewModel.editableFactory.newEditable("") + editable.append("Hi @") + Selection.setSelection(editable, editable.length) + + assertThat(awaitItem()) + .isEqualTo(MentionViewModel.AutoCompleteState.Loading) + + // Select a candidate now + assertThat(awaitItem()) + .isInstanceOf(MentionViewModel.AutoCompleteState.Result::class.java) + mentionViewModel.onCandidateSelected("pubkey1") + + // Should have normalised message with selected candidate + assertThat(mentionViewModel.normalizeMessageBody()) + .isEqualTo("Hi @pubkey1 ") + } + } +} \ No newline at end of file diff --git a/app/src/test/resources/TestAndroidManifest.xml b/app/src/test/resources/TestAndroidManifest.xml new file mode 100644 index 0000000000..afc09b82b7 --- /dev/null +++ b/app/src/test/resources/TestAndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/test/resources/robolectric.properties b/app/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..1ec9805b2a --- /dev/null +++ b/app/src/test/resources/robolectric.properties @@ -0,0 +1,3 @@ +manifest=TestAndroidManifest.xml +sdk=34 +application=android.app.Application \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/open_groups/GroupMember.kt b/libsession/src/main/java/org/session/libsession/messaging/open_groups/GroupMember.kt index 8335e0a2da..47ec35e176 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/open_groups/GroupMember.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/open_groups/GroupMember.kt @@ -6,6 +6,11 @@ data class GroupMember( val role: GroupMemberRole ) -enum class GroupMemberRole { - STANDARD, ZOOMBIE, MODERATOR, ADMIN, HIDDEN_MODERATOR, HIDDEN_ADMIN +enum class GroupMemberRole(val isModerator: Boolean = false) { + STANDARD, + ZOOMBIE, + MODERATOR(true), + ADMIN(true), + HIDDEN_MODERATOR(true), + HIDDEN_ADMIN(true), } From 0da949c8e69e8cc8803a4a4c34d7e0ed74b74f9e Mon Sep 17 00:00:00 2001 From: Fanchao Liu <273191+simophin@users.noreply.github.com> Date: Mon, 1 Jul 2024 17:36:50 +1000 Subject: [PATCH 13/13] [SES-1966] Attachment batch download and tidy-up (#1507) * Attachment batch download * Addressed feedback and test issues * Feedback fixes * timedWindow for flow * Feedback * Dispatchers * Remove `flowOn` * New implementation of timedBuffer * Organise import * Feedback * Fix test * Tidied up logic around `eligibleForDownload` * Updated comment --------- Co-authored-by: fanchao --- .../v2/AttachmentDownloadHandler.kt | 115 ++++++++++++++++++ .../conversation/v2/ConversationActivityV2.kt | 6 +- .../conversation/v2/ConversationAdapter.kt | 3 +- .../conversation/v2/ConversationViewModel.kt | 38 ++++-- .../conversation/v2/MessageDetailActivity.kt | 3 +- .../v2/MessageDetailsViewModel.kt | 6 +- .../v2/components/AlbumThumbnailView.kt | 4 +- .../v2/messages/VisibleMessageContentView.kt | 14 +-- .../v2/messages/VisibleMessageView.kt | 3 +- .../securesms/database/SessionJobDatabase.kt | 11 +- .../securesms/database/Storage.kt | 4 +- .../thoughtcrime/securesms/util/FlowUtils.kt | 44 +++++++ .../v2/ConversationViewModelTest.kt | 4 +- .../securesms/util/FlowUtilsTest.kt | 52 ++++++++ .../libsession/database/StorageProtocol.kt | 2 +- .../messaging/jobs/AttachmentDownloadJob.kt | 48 +++++--- .../libsession/messaging/jobs/JobQueue.kt | 2 +- 17 files changed, 301 insertions(+), 58 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/AttachmentDownloadHandler.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/FlowUtils.kt create mode 100644 app/src/test/java/org/thoughtcrime/securesms/util/FlowUtilsTest.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/AttachmentDownloadHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/AttachmentDownloadHandler.kt new file mode 100644 index 0000000000..7278be71bc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/AttachmentDownloadHandler.kt @@ -0,0 +1,115 @@ +package org.thoughtcrime.securesms.conversation.v2 + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus +import org.session.libsession.database.MessageDataProvider +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.jobs.AttachmentDownloadJob +import org.session.libsession.messaging.jobs.AttachmentUploadJob +import org.session.libsession.messaging.jobs.JobQueue +import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress +import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.util.flatten +import org.thoughtcrime.securesms.util.timedBuffer + +/** + * [AttachmentDownloadHandler] is responsible for handling attachment download requests. These + * requests will go through different level of checking before they are queued for download. + * + * To use this handler, call [onAttachmentDownloadRequest] with the attachment that needs to be + * downloaded. The call to [onAttachmentDownloadRequest] is cheap and can be called multiple times. + */ +class AttachmentDownloadHandler( + private val storage: StorageProtocol, + private val messageDataProvider: MessageDataProvider, + jobQueue: JobQueue = JobQueue.shared, + scope: CoroutineScope = CoroutineScope(Dispatchers.Default) + SupervisorJob(), +) { + companion object { + private const val BUFFER_TIMEOUT_MILLS = 500L + private const val BUFFER_MAX_ITEMS = 10 + private const val LOG_TAG = "AttachmentDownloadHelper" + } + + private val downloadRequests = Channel(UNLIMITED) + + init { + scope.launch(Dispatchers.Default) { + downloadRequests + .receiveAsFlow() + .timedBuffer(BUFFER_TIMEOUT_MILLS, BUFFER_MAX_ITEMS) + .map(::filterEligibleAttachments) + .flatten() + .collect { attachment -> + jobQueue.add( + AttachmentDownloadJob( + attachmentID = attachment.attachmentId.rowId, + databaseMessageID = attachment.mmsId + ) + ) + } + } + } + + /** + * Filter attachments that are eligible for creating download jobs. + * + */ + private fun filterEligibleAttachments(attachments: List): List { + val pendingAttachmentIDs = storage + .getAllPendingJobs(AttachmentDownloadJob.KEY, AttachmentUploadJob.KEY) + .values + .mapNotNull { + (it as? AttachmentUploadJob)?.attachmentID + ?: (it as? AttachmentDownloadJob)?.attachmentID + } + .toSet() + + + return attachments.filter { attachment -> + eligibleForDownloadTask( + attachment, + pendingAttachmentIDs, + ) + } + } + + /** + * Check if the attachment is eligible for download task. + */ + private fun eligibleForDownloadTask( + attachment: DatabaseAttachment, + pendingJobsAttachmentRowIDs: Set, + ): Boolean { + if (attachment.attachmentId.rowId in pendingJobsAttachmentRowIDs) { + return false + } + + val threadID = storage.getThreadIdForMms(attachment.mmsId) + + return AttachmentDownloadJob.eligibleForDownload( + threadID, storage, messageDataProvider, attachment.mmsId, + ) + } + + + fun onAttachmentDownloadRequest(attachment: DatabaseAttachment) { + if (attachment.transferState != AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING) { + Log.i( + LOG_TAG, + "Attachment ${attachment.attachmentId} is not pending, skipping download" + ) + return + } + + downloadRequests.trySend(attachment) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 7fdca6936e..e7f413b6fc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -333,11 +333,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe onDeselect(message, position, it) } }, - onAttachmentNeedsDownload = { attachmentId, mmsId -> - lifecycleScope.launch(Dispatchers.IO) { - JobQueue.shared.add(AttachmentDownloadJob(attachmentId, mmsId)) - } - }, + onAttachmentNeedsDownload = viewModel::onAttachmentDownloadRequest, glide = glide, lifecycleCoroutineScope = lifecycleScope ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt index d051d7d93c..340336e53b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import network.loki.messenger.R import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.thoughtcrime.securesms.conversation.v2.messages.ControlMessageView import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDelegate @@ -40,7 +41,7 @@ class ConversationAdapter( private val onItemSwipeToReply: (MessageRecord, Int) -> Unit, private val onItemLongPress: (MessageRecord, Int, VisibleMessageView) -> Unit, private val onDeselect: (MessageRecord, Int) -> Unit, - private val onAttachmentNeedsDownload: (Long, Long) -> Unit, + private val onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit, private val glide: GlideRequests, lifecycleCoroutineScope: LifecycleCoroutineScope ) : CursorRecyclerViewAdapter(context, cursor) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 1a036eee11..b29f9e1b49 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -1,46 +1,44 @@ package org.thoughtcrime.securesms.conversation.v2 import android.content.Context - import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope - import com.goterl.lazysodium.utils.KeyPair - import dagger.assisted.Assisted import dagger.assisted.AssistedInject - import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch - +import org.session.libsession.database.MessageDataProvider import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.messaging.utilities.SessionId import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.utilities.Address import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.database.MmsDatabase import org.thoughtcrime.securesms.audio.AudioSlidePlayer - import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.repository.ConversationRepository - import java.util.UUID class ConversationViewModel( val threadId: Long, val edKeyPair: KeyPair?, private val repository: ConversationRepository, - private val storage: Storage + private val storage: Storage, + private val messageDataProvider: MessageDataProvider, + database: MmsDatabase, ) : ViewModel() { val showSendAfterApprovalText: Boolean @@ -92,6 +90,11 @@ class ConversationViewModel( // allow reactions if the open group is null (normal conversations) or the open group's capabilities include reactions get() = (openGroup == null || OpenGroupApi.Capability.REACTIONS.name.lowercase() in serverCapabilities) + private val attachmentDownloadHandler = AttachmentDownloadHandler( + storage = storage, + messageDataProvider = messageDataProvider, + scope = viewModelScope, + ) init { viewModelScope.launch(Dispatchers.IO) { @@ -242,7 +245,7 @@ class ConversationViewModel( currentUiState.copy(uiMessages = messages) } } - + fun messageShown(messageId: Long) { _uiState.update { currentUiState -> val messages = currentUiState.uiMessages.filterNot { it.id == messageId } @@ -265,6 +268,10 @@ class ConversationViewModel( storage.getLastLegacyRecipient(address.serialize())?.let { Recipient.from(context, Address.fromSerialized(it), false) } } + fun onAttachmentDownloadRequest(attachment: DatabaseAttachment) { + attachmentDownloadHandler.onAttachmentDownloadRequest(attachment) + } + @dagger.assisted.AssistedFactory interface AssistedFactory { fun create(threadId: Long, edKeyPair: KeyPair?): Factory @@ -275,11 +282,20 @@ class ConversationViewModel( @Assisted private val threadId: Long, @Assisted private val edKeyPair: KeyPair?, private val repository: ConversationRepository, - private val storage: Storage + private val storage: Storage, + private val mmsDatabase: MmsDatabase, + private val messageDataProvider: MessageDataProvider, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { - return ConversationViewModel(threadId, edKeyPair, repository, storage) as T + return ConversationViewModel( + threadId = threadId, + edKeyPair = edKeyPair, + repository = repository, + storage = storage, + messageDataProvider = messageDataProvider, + database = mmsDatabase + ) as T } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt index d5e28fb936..925968f95b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt @@ -55,6 +55,7 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import network.loki.messenger.R import network.loki.messenger.databinding.ViewVisibleMessageContentBinding +import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.thoughtcrime.securesms.MediaPreviewActivity.getPreviewIntent import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.database.Storage @@ -149,7 +150,7 @@ fun MessageDetails( onResend: (() -> Unit)? = null, onDelete: () -> Unit = {}, onClickImage: (Int) -> Unit = {}, - onAttachmentNeedsDownload: (Long, Long) -> Unit = { _, _ -> } + onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit = { _ -> } ) { Column( modifier = Modifier diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt index ba153a6b36..fc54b652ae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt @@ -124,7 +124,7 @@ class MessageDetailsViewModel @Inject constructor( if (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED) { // Restart download here (on IO thread) (slide.asAttachment() as? DatabaseAttachment)?.let { attachment -> - onAttachmentNeedsDownload(attachment.attachmentId.rowId, state.mmsRecord.getId()) + onAttachmentNeedsDownload(attachment) } } @@ -137,9 +137,9 @@ class MessageDetailsViewModel @Inject constructor( } } - fun onAttachmentNeedsDownload(attachmentId: Long, mmsId: Long) { + fun onAttachmentNeedsDownload(attachment: DatabaseAttachment) { viewModelScope.launch(Dispatchers.IO) { - JobQueue.shared.add(AttachmentDownloadJob(attachmentId, mmsId)) + JobQueue.shared.add(AttachmentDownloadJob(attachment.attachmentId.rowId, attachment.mmsId)) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt index f646ace972..57c42a7719 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt @@ -48,7 +48,7 @@ class AlbumThumbnailView : RelativeLayout { // region Interaction - fun calculateHitObject(event: MotionEvent, mms: MmsMessageRecord, threadRecipient: Recipient, onAttachmentNeedsDownload: (Long, Long) -> Unit) { + fun calculateHitObject(event: MotionEvent, mms: MmsMessageRecord, threadRecipient: Recipient, onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit) { val rawXInt = event.rawX.toInt() val rawYInt = event.rawY.toInt() val eventRect = Rect(rawXInt, rawYInt, rawXInt, rawYInt) @@ -63,7 +63,7 @@ class AlbumThumbnailView : RelativeLayout { if (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED) { // Restart download here (on IO thread) (slide.asAttachment() as? DatabaseAttachment)?.let { attachment -> - onAttachmentNeedsDownload(attachment.attachmentId.rowId, mms.getId()) + onAttachmentNeedsDownload(attachment) } } if (slide.isInProgress) return@forEach diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt index baf80e56aa..83c6904dec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt @@ -66,7 +66,7 @@ class VisibleMessageContentView : ConstraintLayout { thread: Recipient, searchQuery: String? = null, contactIsTrusted: Boolean = true, - onAttachmentNeedsDownload: (Long, Long) -> Unit, + onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit, suppressThumbnails: Boolean = false ) { // Background @@ -135,19 +135,11 @@ class VisibleMessageContentView : ConstraintLayout { if (message is MmsMessageRecord) { message.slideDeck.asAttachments().forEach { attach -> val dbAttachment = attach as? DatabaseAttachment ?: return@forEach - val attachmentId = dbAttachment.attachmentId.rowId - if (attach.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING - && MessagingModuleConfiguration.shared.storage.getAttachmentUploadJob(attachmentId) == null) { - onAttachmentNeedsDownload(attachmentId, dbAttachment.mmsId) - } + onAttachmentNeedsDownload(dbAttachment) } message.linkPreviews.forEach { preview -> val previewThumbnail = preview.getThumbnail().orNull() as? DatabaseAttachment ?: return@forEach - val attachmentId = previewThumbnail.attachmentId.rowId - if (previewThumbnail.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING - && MessagingModuleConfiguration.shared.storage.getAttachmentUploadJob(attachmentId) == null) { - onAttachmentNeedsDownload(attachmentId, previewThumbnail.mmsId) - } + onAttachmentNeedsDownload(previewThumbnail) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt index ec26e39986..9470d17de5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt @@ -34,6 +34,7 @@ import network.loki.messenger.databinding.ViewstubVisibleMessageMarkerContainerB import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact.ContactContext import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.utilities.Address import org.session.libsession.utilities.ViewUtil import org.session.libsession.utilities.getColorFromAttr @@ -145,7 +146,7 @@ class VisibleMessageView : FrameLayout { senderSessionID: String, lastSeen: Long, delegate: VisibleMessageViewDelegate? = null, - onAttachmentNeedsDownload: (Long, Long) -> Unit, + onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit, lastSentMessageId: Long ) { replyDisabled = message.isOpenGroupInvitation diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt index 591755b88f..e83c464c7d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.database import android.content.ContentValues import android.content.Context import android.database.Cursor +import org.json.JSONArray import org.session.libsession.messaging.jobs.AttachmentDownloadJob import org.session.libsession.messaging.jobs.AttachmentUploadJob import org.session.libsession.messaging.jobs.BackgroundGroupAddJob @@ -50,14 +51,18 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa databaseHelper.writableDatabase.delete(sessionJobTable, "${Companion.jobID} = ?", arrayOf( jobID )) } - fun getAllJobs(type: String): Map { + fun getAllJobs(vararg types: String): Map { val database = databaseHelper.readableDatabase - return database.getAll(sessionJobTable, "$jobType = ?", arrayOf( type )) { cursor -> + return database.getAll( + sessionJobTable, + "$jobType IN (SELECT value FROM json_each(?))", // Use json_each to bypass limitation of SQLite's IN operator binding + arrayOf( JSONArray(types).toString() ) + ) { cursor -> val jobID = cursor.getString(jobID) try { jobID to jobFromCursor(cursor) } catch (e: Exception) { - Log.e("Loki", "Error deserializing job of type: $type.", e) + Log.e("Loki", "Error deserializing job of type: $types.", e) jobID to null } }.toMap() diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index 354ec05c46..dd0544b420 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -397,8 +397,8 @@ open class Storage( DatabaseComponent.get(context).sessionJobDatabase().markJobAsFailedPermanently(jobId) } - override fun getAllPendingJobs(type: String): Map { - return DatabaseComponent.get(context).sessionJobDatabase().getAllJobs(type) + override fun getAllPendingJobs(vararg types: String): Map { + return DatabaseComponent.get(context).sessionJobDatabase().getAllJobs(*types) } override fun getAttachmentUploadJob(attachmentID: Long): AttachmentUploadJob? { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FlowUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/FlowUtils.kt new file mode 100644 index 0000000000..e5b35b8931 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FlowUtils.kt @@ -0,0 +1,44 @@ +package org.thoughtcrime.securesms.util + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.flatMapConcat + +/** + * Buffers items from the flow and emits them in batches. The batch will have size [maxItems] and + * time [timeoutMillis] limit. + */ +fun Flow.timedBuffer(timeoutMillis: Long, maxItems: Int): Flow> { + return channelFlow { + val buffer = mutableListOf() + var bufferBeganAt = -1L + + collectLatest { value -> + if (buffer.isEmpty()) { + bufferBeganAt = System.currentTimeMillis() + } + + buffer.add(value) + + if (buffer.size < maxItems) { + // If the buffer is not full, wait until the time limit is reached. + // The delay here, as a suspension point, will be cancelled by `collectLatest`, + // if another item is collected while we are waiting for the `delay` to complete. + // Once the delay is cancelled, another round of `collectLatest` will be restarted. + delay((System.currentTimeMillis() + timeoutMillis - bufferBeganAt).coerceAtLeast(0L)) + } + + // When we reach here, it's either the buffer is full, or the timeout has been reached: + // send out the buffer and reset the state + send(buffer.toList()) + buffer.clear() + } + } +} + +@OptIn(ExperimentalCoroutinesApi::class) +fun Flow>.flatten(): Flow = flatMapConcat { it.asFlow() } \ No newline at end of file diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt index 37303e29d5..7f4db828e4 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt @@ -23,6 +23,7 @@ import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.BaseViewModelTest import org.thoughtcrime.securesms.NoOpLogger +import org.thoughtcrime.securesms.database.MmsDatabase import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.repository.ConversationRepository @@ -32,6 +33,7 @@ class ConversationViewModelTest: BaseViewModelTest() { private val repository = mock() private val storage = mock() + private val mmsDatabase = mock() private val threadId = 123L private val edKeyPair = mock() @@ -39,7 +41,7 @@ class ConversationViewModelTest: BaseViewModelTest() { private lateinit var messageRecord: MessageRecord private val viewModel: ConversationViewModel by lazy { - ConversationViewModel(threadId, edKeyPair, repository, storage) + ConversationViewModel(threadId, edKeyPair, repository, storage, mock(), mmsDatabase) } @Before diff --git a/app/src/test/java/org/thoughtcrime/securesms/util/FlowUtilsTest.kt b/app/src/test/java/org/thoughtcrime/securesms/util/FlowUtilsTest.kt new file mode 100644 index 0000000000..f089586de9 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/util/FlowUtilsTest.kt @@ -0,0 +1,52 @@ +package org.thoughtcrime.securesms.util + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.toCollection +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +class FlowUtilsTest { + + @Test + fun `timedBuffer should emit buffer when it's full`() = runTest { + // Given + val flow = flowOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10) + val timeoutMillis = 1000L + val maxItems = 5 + + // When + val result = flow.timedBuffer(timeoutMillis, maxItems).toList() + + // Then + assertEquals(2, result.size) + assertEquals(listOf(1, 2, 3, 4, 5), result[0]) + assertEquals(listOf(6, 7, 8, 9, 10), result[1]) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `timedBuffer should emit buffer when timeout expires`() = runTest { + // Given + val flow = flow { + emit(1) + emit(2) + emit(3) + testScheduler.advanceTimeBy(200L) + emit(4) + } + val timeoutMillis = 100L + val maxItems = 5 + + // When + val result = flow.timedBuffer(timeoutMillis, maxItems).toList() + + // Then + assertEquals(2, result.size) + assertEquals(listOf(1, 2, 3), result[0]) + assertEquals(listOf(4), result[1]) + } +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt b/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt index 260e254fe7..c984594bcc 100644 --- a/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt +++ b/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt @@ -53,7 +53,7 @@ interface StorageProtocol { fun persistJob(job: Job) fun markJobAsSucceeded(jobId: String) fun markJobAsFailedPermanently(jobId: String) - fun getAllPendingJobs(type: String): Map + fun getAllPendingJobs(vararg types: String): Map fun getAttachmentUploadJob(attachmentID: Long): AttachmentUploadJob? fun getMessageSendJob(messageSendJobID: String): MessageSendJob? fun getMessageReceiveJob(messageReceiveJobID: String): Job? diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt index 6aae0c6c9b..ffa05bf1e6 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt @@ -1,6 +1,8 @@ package org.session.libsession.messaging.jobs import okhttp3.HttpUrl +import org.session.libsession.database.MessageDataProvider +import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId @@ -40,6 +42,36 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) // Keys used for database storage private val ATTACHMENT_ID_KEY = "attachment_id" private val TS_INCOMING_MESSAGE_ID_KEY = "tsIncoming_message_id" + + /** + * Check if the attachment in the given message is eligible for download. + * + * Note that this function only checks for the eligibility of the attachment in the sense + * of whether the download is allowed, it does not check if the download has already taken + * place. + */ + fun eligibleForDownload(threadID: Long, + storage: StorageProtocol, + messageDataProvider: MessageDataProvider, + databaseMessageID: Long): Boolean { + val threadRecipient = storage.getRecipientForThread(threadID) ?: return false + + // if we are the sender we are always eligible + val selfSend = messageDataProvider.isMmsOutgoing(databaseMessageID) + if (selfSend) { + return true + } + + // you can't be eligible without a sender + val sender = messageDataProvider.getIndividualRecipientForMms(databaseMessageID)?.address?.serialize() + ?: return false + + // you can't be eligible without a contact entry + val contact = storage.getContactWithSessionID(sender) ?: return false + + // we are eligible if we are receiving a group message or the contact is trusted + return threadRecipient.isGroupRecipient || contact.isTrusted + } } override suspend fun execute(dispatcherName: String) { @@ -88,21 +120,7 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) return } - val threadRecipient = storage.getRecipientForThread(threadID) - val selfSend = messageDataProvider.isMmsOutgoing(databaseMessageID) - val sender = if (selfSend) { - storage.getUserPublicKey() - } else { - messageDataProvider.getIndividualRecipientForMms(databaseMessageID)?.address?.serialize() - } - val contact = sender?.let { storage.getContactWithSessionID(it) } - if (threadRecipient == null || sender == null || (contact == null && !selfSend)) { - handleFailure(Error.NoSender, null) - return - } - if (!threadRecipient.isGroupRecipient && contact?.isTrusted != true && storage.getUserPublicKey() != sender) { - // if we aren't receiving a group message, a message from ourselves (self-send) and the contact sending is not trusted: - // do not continue, but do not fail + if (!eligibleForDownload(threadID, storage, messageDataProvider, databaseMessageID)) { handleFailure(Error.NoSender, null) return } diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt index 71d62bc72f..1450168ea9 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt @@ -102,7 +102,7 @@ class JobQueue : JobDelegate { execute(dispatcherName) } catch (e: Exception) { - Log.d(dispatcherName, "unhandledJobException: ${javaClass.simpleName} (id: $id)") + Log.d(dispatcherName, "unhandledJobException: ${javaClass.simpleName} (id: $id)", e) this@JobQueue.handleJobFailed(this, dispatcherName, e) } }