From e6762a12d059e50f31f9eb10e20fea5dc2469a0e Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 9 Jul 2024 13:33:42 +1000 Subject: [PATCH] Revert "Release/1.18.5 (#1535)" This reverts commit 7f90a9866b257527c5411b14ada35797c3e54aa2. --- app/build.gradle | 11 +- .../org/thoughtcrime/securesms/AppContext.kt | 5 +- .../securesms/BaseActionBarActivity.java | 12 +- .../securesms/MediaGalleryAdapter.java | 2 +- .../securesms/audio/AudioSlidePlayer.java | 5 - .../securesms/calls/WebRtcCallActivity.kt | 18 +- .../securesms/contacts/UserView.kt | 2 + .../conversation/ConversationActionBarView.kt | 2 + .../v2/AttachmentDownloadHandler.kt | 115 -------- .../conversation/v2/ConversationActivityV2.kt | 182 ++++++++---- .../conversation/v2/ConversationAdapter.kt | 16 +- .../conversation/v2/ConversationViewModel.kt | 60 +--- .../conversation/v2/MessageDetailActivity.kt | 3 +- .../v2/MessageDetailsViewModel.kt | 6 +- .../v2/components/AlbumThumbnailView.kt | 6 +- .../v2/components/LinkPreviewDraftView.kt | 4 +- .../conversation/v2/input_bar/InputBar.kt | 10 +- .../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/messages/ControlMessageView.kt | 11 +- .../v2/messages/LinkPreviewView.kt | 2 +- .../conversation/v2/messages/QuoteView.kt | 17 +- .../v2/messages/VisibleMessageContentView.kt | 21 +- .../v2/messages/VisibleMessageView.kt | 64 ++-- .../v2/messages/VoiceMessageView.kt | 2 +- .../v2/utilities/MentionManagerUtilities.kt | 34 +++ .../v2/utilities/MentionUtilities.kt | 153 +++------- .../v2/utilities/ThumbnailView.kt | 203 +++++++------ .../securesms/database/GroupMemberDatabase.kt | 16 - .../securesms/database/MmsDatabase.kt | 15 - .../database/SessionContactDatabase.kt | 10 - .../securesms/database/SessionJobDatabase.kt | 11 +- .../securesms/database/Storage.kt | 4 +- .../securesms/dependencies/AppModule.kt | 2 +- .../securesms/dependencies/ConfigFactory.kt | 9 + .../securesms/groups/OpenGroupManager.kt | 8 +- .../securesms/home/ConversationView.kt | 7 +- .../securesms/home/HomeActivity.kt | 4 +- .../securesms/home/HomeViewModel.kt | 12 +- .../mediapreview/MediaRailAdapter.java | 4 - .../messagerequests/MessageRequestView.kt | 8 +- .../notifications/DefaultMessageNotifier.java | 23 +- .../util/ConfigurationMessageUtilities.kt | 57 +++- .../thoughtcrime/securesms/util/FlowUtils.kt | 44 --- .../securesms/util/RoundedBackgroundSpan.kt | 71 ----- .../res/layout/activity_conversation_v2.xml | 92 +++--- 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 +- .../res/layout/view_mention_candidate_v2.xml | 6 +- .../main/res/layout/view_visible_message.xml | 48 ++- ...wstub_visible_message_marker_container.xml | 38 --- .../xml/network_security_configuration.xml | 1 - .../v2/ConversationViewModelTest.kt | 4 +- .../conversation/v2/MentionEditableTest.kt | 115 -------- .../conversation/v2/MentionViewModelTest.kt | 185 ------------ .../securesms/util/FlowUtilsTest.kt | 52 ---- .../test/resources/TestAndroidManifest.xml | 4 - app/src/test/resources/robolectric.properties | 3 - .../loki/messenger/libsession_util/Config.kt | 6 + .../libsession/database/StorageProtocol.kt | 2 +- .../messaging/jobs/AttachmentDownloadJob.kt | 48 +-- .../messaging/jobs/BackgroundGroupAddJob.kt | 1 - .../messaging/jobs/ConfigurationSyncJob.kt | 10 +- .../libsession/messaging/jobs/JobQueue.kt | 2 +- .../messaging/open_groups/GroupMember.kt | 9 +- .../messaging/open_groups/OpenGroupApi.kt | 126 ++++---- .../sending_receiving/MessageSender.kt | 9 - .../ReceivedMessageHandler.kt | 12 +- .../sending_receiving/pollers/Poller.kt | 44 +-- .../org/session/libsession/snode/SnodeAPI.kt | 4 +- .../session/libsession/utilities/IdUtil.kt | 2 +- .../libsignal/utilities/ThreadUtils.kt | 29 +- 78 files changed, 877 insertions(+), 1922 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/AttachmentDownloadHandler.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateAdapter.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidatesView.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionEditable.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionSpan.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionManagerUtilities.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/FlowUtils.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/RoundedBackgroundSpan.kt delete mode 100644 app/src/main/res/layout/viewstub_visible_message_marker_container.xml delete mode 100644 app/src/test/java/org/thoughtcrime/securesms/conversation/v2/MentionEditableTest.kt delete mode 100644 app/src/test/java/org/thoughtcrime/securesms/conversation/v2/MentionViewModelTest.kt delete mode 100644 app/src/test/java/org/thoughtcrime/securesms/util/FlowUtilsTest.kt delete mode 100644 app/src/test/resources/TestAndroidManifest.xml delete mode 100644 app/src/test/resources/robolectric.properties diff --git a/app/build.gradle b/app/build.gradle index 2e52adddd5..eb2c16e953 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -31,8 +31,8 @@ configurations.all { exclude module: "commons-logging" } -def canonicalVersionCode = 374 -def canonicalVersionName = "1.18.5" +def canonicalVersionCode = 373 +def canonicalVersionName = "1.18.4" def postFixSize = 10 def abiPostFix = ['armeabi-v7a' : 1, @@ -368,11 +368,8 @@ dependencies { androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.5.1' androidTestUtil 'androidx.test:orchestrator:1.4.2' - 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' - + testImplementation 'org.robolectric:robolectric:4.4' + testImplementation 'org.robolectric:shadows-multidex:4.4' 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/AppContext.kt b/app/src/main/java/org/thoughtcrime/securesms/AppContext.kt index 34ab960021..2588618b72 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/AppContext.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/AppContext.kt @@ -1,10 +1,9 @@ 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 +import org.session.libsignal.utilities.ThreadUtils import java.util.concurrent.Executors object AppContext { @@ -12,7 +11,7 @@ object AppContext { fun configureKovenant() { Kovenant.context { callbackContext.dispatcher = Executors.newSingleThreadExecutor().asDispatcher() - workerContext.dispatcher = Dispatchers.IO.asExecutor().asDispatcher() + workerContext.dispatcher = ThreadUtils.executorPool.asDispatcher() multipleCompletion = { v1, v2 -> Log.d("Loki", "Promise resolved more than once (first with $v1, then with $v2); ignoring $v2.") } diff --git a/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.java b/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.java index a99fe83430..c43d406575 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.java @@ -30,15 +30,13 @@ public abstract class BaseActionBarActivity extends AppCompatActivity { private static final String TAG = BaseActionBarActivity.class.getSimpleName(); public ThemeState currentThemeState; - private Resources.Theme modifiedTheme; - private TextSecurePreferences getPreferences() { ApplicationContext appContext = (ApplicationContext) getApplicationContext(); return appContext.textSecurePreferences; } @StyleRes - private int getDesiredTheme() { + public int getDesiredTheme() { ThemeState themeState = ActivityUtilitiesKt.themeState(getPreferences()); int userSelectedTheme = themeState.getTheme(); @@ -60,7 +58,7 @@ public abstract class BaseActionBarActivity extends AppCompatActivity { } @StyleRes @Nullable - private Integer getAccentTheme() { + public Integer getAccentTheme() { if (!getPreferences().hasPreference(SELECTED_ACCENT_COLOR)) return null; ThemeState themeState = ActivityUtilitiesKt.themeState(getPreferences()); return themeState.getAccentStyle(); @@ -68,12 +66,8 @@ public abstract class BaseActionBarActivity extends AppCompatActivity { @Override public Resources.Theme getTheme() { - if (modifiedTheme != null) { - return modifiedTheme; - } - // New themes - modifiedTheme = super.getTheme(); + Resources.Theme modifiedTheme = super.getTheme(); modifiedTheme.applyStyle(getDesiredTheme(), true); Integer accentTheme = getAccentTheme(); if (accentTheme != null) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.java index 62766d1cd7..0fd813cf4b 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); + thumbnailView.setImageResource(glideRequests, slide, false, null); } thumbnailView.setOnClickListener(view -> itemClickListener.onMediaClicked(mediaRecord)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java index ef404bb070..61a92105aa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java @@ -80,11 +80,6 @@ public class AudioSlidePlayer implements SensorEventListener { } } - @Nullable - public synchronized static AudioSlidePlayer getInstance() { - return playing.orNull(); - } - private AudioSlidePlayer(@NonNull Context context, @NonNull AudioSlide slide, @NonNull Listener listener) 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 d46321863b..afa6944645 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt @@ -21,7 +21,6 @@ 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 @@ -101,14 +100,7 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() { override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { super.onCreate(savedInstanceState, ready) - - // Only enable auto-rotate if system auto-rotate is enabled - if (isAutoRotateOn()) { - rotationListener.enable() - } else { - rotationListener.disable() - } - + rotationListener.enable() binding = ActivityWebrtcBinding.inflate(layoutInflater) setContentView(binding.root) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { @@ -193,14 +185,6 @@ 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 -> 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 b46243eeb0..36a8c1adf5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt @@ -7,8 +7,10 @@ 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 8f2da7a733..184869b9ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt @@ -21,6 +21,7 @@ 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 @@ -77,6 +78,7 @@ 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/AttachmentDownloadHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/AttachmentDownloadHandler.kt deleted file mode 100644 index 7278be71bc..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/AttachmentDownloadHandler.kt +++ /dev/null @@ -1,115 +0,0 @@ -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 e7f413b6fc..76bf7b875f 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,7 +46,6 @@ 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 @@ -62,12 +61,15 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.databinding.ActivityConversationV2Binding +import network.loki.messenger.databinding.ViewVisibleMessageBinding import network.loki.messenger.libsession_util.util.ExpiryMode import nl.komponents.kovenant.ui.successUi 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 @@ -117,8 +119,7 @@ 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.MentionCandidateAdapter -import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel +import org.thoughtcrime.securesms.conversation.v2.input_bar.mentions.MentionCandidatesView 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,7 +216,6 @@ 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())) { @@ -229,8 +229,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe ViewModelProvider(this, LinkPreviewViewModel.Factory(LinkPreviewRepository())) .get(LinkPreviewViewModel::class.java) } - - private val threadId: Long by lazy { + private val viewModel: ConversationViewModel by viewModels { var threadId = intent.getLongExtra(THREAD_ID, -1L) if (threadId == -1L) { intent.getParcelableExtra
(ADDRESS)?.let { it -> @@ -250,11 +249,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } ?: finish() } - - threadId - } - - private val viewModel: ConversationViewModel by viewModels { viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair()) } private var actionMode: ActionMode? = null @@ -267,12 +261,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private var isLockViewExpanded = false private var isShowingAttachmentOptions = false // Mentions - private val mentionViewModel: MentionViewModel by viewModels { - mentionViewModelFactory.create(threadId) - } - private val mentionCandidateAdapter = MentionCandidateAdapter { - mentionViewModel.onCandidateSelected(it.member.publicKey) - } + private val mentions = mutableListOf() + private var mentionCandidatesView: MentionCandidatesView? = null + private var previousText: CharSequence = "" + private var currentMentionStartIndex = -1 + private var isShowingMentionCandidatesView = false // Search val searchViewModel: SearchViewModel by viewModels() var searchViewItem: MenuItem? = null @@ -333,7 +326,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe onDeselect(message, position, it) } }, - onAttachmentNeedsDownload = viewModel::onAttachmentDownloadRequest, + onAttachmentNeedsDownload = { attachmentId, mmsId -> + lifecycleScope.launch(Dispatchers.IO) { + JobQueue.shared.add(AttachmentDownloadJob(attachmentId, mmsId)) + } + }, glide = glide, lifecycleCoroutineScope = lifecycleScope ) @@ -490,27 +487,6 @@ 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() { @@ -667,19 +643,23 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe binding.inputBar.delegate = this binding.inputBarRecordingView.delegate = this // GIF button - binding.gifButtonContainer.addView(gifButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + binding.gifButtonContainer.addView(gifButton) + gifButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) gifButton.onUp = { showGIFPicker() } gifButton.snIsEnabled = false // Document button - binding.documentButtonContainer.addView(documentButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + binding.documentButtonContainer.addView(documentButton) + documentButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) documentButton.onUp = { showDocumentPicker() } documentButton.snIsEnabled = false // Library button - binding.libraryButtonContainer.addView(libraryButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + binding.libraryButtonContainer.addView(libraryButton) + libraryButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) libraryButton.onUp = { pickFromLibrary() } libraryButton.snIsEnabled = false // Camera button - binding.cameraButtonContainer.addView(cameraButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + binding.cameraButtonContainer.addView(cameraButton) + cameraButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) cameraButton.onUp = { showCamera() } cameraButton.snIsEnabled = false } @@ -934,6 +914,7 @@ 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 { @@ -945,6 +926,76 @@ 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( @@ -1460,6 +1511,18 @@ 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 @@ -1495,7 +1558,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (indexInAdapter < 0 || indexInAdapter >= adapter.itemCount) { return } val viewHolder = binding?.conversationRecyclerView?.findViewHolderForAdapterPosition(indexInAdapter) as? ConversationAdapter.VisibleMessageViewHolder ?: return - viewHolder.view.playVoiceMessage() + val visibleMessageView = ViewVisibleMessageBinding.bind(viewHolder.view).visibleMessageView + visibleMessageView.playVoiceMessage() } override fun sendMessage() { @@ -1558,6 +1622,10 @@ 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 @@ -1602,6 +1670,10 @@ 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 @@ -1888,13 +1960,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val messageIterator = sortedMessages.iterator() while (messageIterator.hasNext()) { val message = messageIterator.next() - val body = MentionUtilities.highlightMentions( - text = message.body, - formatOnly = true, // no styling here, only text formatting - threadID = viewModel.threadId, - context = this - ) - + val body = MentionUtilities.highlightMentions(message.body, viewModel.threadId, this) if (TextUtils.isEmpty(body)) { continue } if (messageSize > 1) { val formattedTimestamp = DateUtils.getDisplayFormattedTimeSpanString(this, Locale.getDefault(), message.timestamp) @@ -2035,7 +2101,17 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // region General private fun getMessageBody(): String { - return mentionViewModel.normalizeMessageBody() + 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 } // endregion 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 340336e53b..7b01eba71e 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 @@ -5,7 +5,9 @@ import android.content.Intent import android.database.Cursor import android.util.SparseArray import android.util.SparseBooleanArray +import android.view.LayoutInflater import android.view.MotionEvent +import android.view.View import android.view.ViewGroup import androidx.annotation.WorkerThread import androidx.core.util.getOrDefault @@ -18,12 +20,14 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import network.loki.messenger.R +import network.loki.messenger.databinding.ViewVisibleMessageBinding import org.session.libsession.messaging.contacts.Contact -import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment +import org.session.libsession.utilities.TextSecurePreferences import org.thoughtcrime.securesms.conversation.v2.messages.ControlMessageView import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDelegate import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter +import org.thoughtcrime.securesms.database.MmsSmsColumns import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.mms.GlideRequests @@ -41,7 +45,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: (DatabaseAttachment) -> Unit, + private val onAttachmentNeedsDownload: (Long, Long) -> Unit, private val glide: GlideRequests, lifecycleCoroutineScope: LifecycleCoroutineScope ) : CursorRecyclerViewAdapter(context, cursor) { @@ -86,7 +90,7 @@ class ConversationAdapter( } } - class VisibleMessageViewHolder(val view: VisibleMessageView) : ViewHolder(view) + class VisibleMessageViewHolder(val view: View) : ViewHolder(view) class ControlMessageViewHolder(val view: ControlMessageView) : ViewHolder(view) override fun getItemViewType(cursor: Cursor): Int { @@ -99,7 +103,7 @@ class ConversationAdapter( @Suppress("NAME_SHADOWING") val viewType = ViewType.allValues[viewType] return when (viewType) { - ViewType.Visible -> VisibleMessageViewHolder(VisibleMessageView(context)) + ViewType.Visible -> VisibleMessageViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.view_visible_message, parent, false)) ViewType.Control -> ControlMessageViewHolder(ControlMessageView(context)) else -> throw IllegalStateException("Unexpected view type: $viewType.") } @@ -111,7 +115,7 @@ class ConversationAdapter( val messageBefore = getMessageBefore(position, cursor) when (viewHolder) { is VisibleMessageViewHolder -> { - val visibleMessageView = viewHolder.view + val visibleMessageView = ViewVisibleMessageBinding.bind(viewHolder.view).visibleMessageView val isSelected = selectedItems.contains(message) visibleMessageView.snIsSelected = isSelected visibleMessageView.indexInAdapter = position @@ -177,7 +181,7 @@ class ConversationAdapter( override fun onItemViewRecycled(viewHolder: ViewHolder?) { when (viewHolder) { - is VisibleMessageViewHolder -> viewHolder.view.recycle() + is VisibleMessageViewHolder -> viewHolder.view.findViewById(R.id.visibleMessageView).recycle() is ControlMessageViewHolder -> viewHolder.view.recycle() } super.onItemViewRecycled(viewHolder) 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 b29f9e1b49..80d6df87fe 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,44 +1,45 @@ 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.MmsSmsDatabase + 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 messageDataProvider: MessageDataProvider, - database: MmsDatabase, + private val storage: Storage ) : ViewModel() { val showSendAfterApprovalText: Boolean @@ -90,11 +91,6 @@ 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) { @@ -107,13 +103,6 @@ class ConversationViewModel( } } - override fun onCleared() { - super.onCleared() - - // Stop all voice message when exiting this page - AudioSlidePlayer.stopAll() - } - fun saveDraft(text: String) { GlobalScope.launch(Dispatchers.IO) { repository.saveDraft(threadId, text) @@ -153,20 +142,10 @@ class ConversationViewModel( } fun deleteLocally(message: MessageRecord) { - stopPlayingAudioMessage(message) val recipient = recipient ?: return Log.w("Loki", "Recipient was null for delete locally action") repository.deleteLocally(recipient, message) } - /** - * Stops audio player if its current playing is the one given in the message. - */ - private fun stopPlayingAudioMessage(message: MessageRecord) { - val mmsMessage = message as? MmsMessageRecord ?: return - val audioSlide = mmsMessage.slideDeck.audioSlide ?: return - AudioSlidePlayer.getInstance()?.takeIf { it.audioSlide == audioSlide }?.stop() - } - fun setRecipientApproved() { val recipient = recipient ?: return Log.w("Loki", "Recipient was null for set approved action") repository.setApproved(recipient, true) @@ -174,12 +153,10 @@ class ConversationViewModel( fun deleteForEveryone(message: MessageRecord) = viewModelScope.launch { val recipient = recipient ?: return@launch Log.w("Loki", "Recipient was null for delete for everyone - aborting delete operation.") - stopPlayingAudioMessage(message) repository.deleteForEveryone(threadId, recipient, message) .onSuccess { Log.d("Loki", "Deleted message ${message.id} ") - stopPlayingAudioMessage(message) } .onFailure { Log.w("Loki", "FAILED TO delete message ${message.id} ") @@ -245,7 +222,7 @@ class ConversationViewModel( currentUiState.copy(uiMessages = messages) } } - + fun messageShown(messageId: Long) { _uiState.update { currentUiState -> val messages = currentUiState.uiMessages.filterNot { it.id == messageId } @@ -268,10 +245,6 @@ 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 @@ -282,20 +255,11 @@ class ConversationViewModel( @Assisted private val threadId: Long, @Assisted private val edKeyPair: KeyPair?, private val repository: ConversationRepository, - private val storage: Storage, - private val mmsDatabase: MmsDatabase, - private val messageDataProvider: MessageDataProvider, + private val storage: Storage ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { - return ConversationViewModel( - threadId = threadId, - edKeyPair = edKeyPair, - repository = repository, - storage = storage, - messageDataProvider = messageDataProvider, - database = mmsDatabase - ) as T + return ConversationViewModel(threadId, edKeyPair, repository, storage) 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 925968f95b..d5e28fb936 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,7 +55,6 @@ 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 @@ -150,7 +149,7 @@ fun MessageDetails( onResend: (() -> Unit)? = null, onDelete: () -> Unit = {}, onClickImage: (Int) -> Unit = {}, - onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit = { _ -> } + onAttachmentNeedsDownload: (Long, Long) -> 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 fc54b652ae..ba153a6b36 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) + onAttachmentNeedsDownload(attachment.attachmentId.rowId, state.mmsRecord.getId()) } } @@ -137,9 +137,9 @@ class MessageDetailsViewModel @Inject constructor( } } - fun onAttachmentNeedsDownload(attachment: DatabaseAttachment) { + fun onAttachmentNeedsDownload(attachmentId: Long, mmsId: Long) { viewModelScope.launch(Dispatchers.IO) { - JobQueue.shared.add(AttachmentDownloadJob(attachment.attachmentId.rowId, attachment.mmsId)) + JobQueue.shared.add(AttachmentDownloadJob(attachmentId, 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 57c42a7719..d76e6f2b3d 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: (DatabaseAttachment) -> Unit) { + fun calculateHitObject(event: MotionEvent, mms: MmsMessageRecord, threadRecipient: Recipient, onAttachmentNeedsDownload: (Long, Long) -> 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) + onAttachmentNeedsDownload(attachment.attachmentId.rowId, mms.getId()) } } if (slide.isInProgress) return@forEach @@ -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) + thumbnailView.setImageResource(glideRequests, slide, isPreview = false, mms = message) } } 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 9c414f34fd..66164f100f 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.setRoundedCorners(toPx(4, resources)) + binding.thumbnailImageView.root.radius = toPx(4, resources) if (linkPreview.getThumbnail().isPresent) { // This internally fetches the thumbnail - binding.thumbnailImageView.root.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), false) + binding.thumbnailImageView.root.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), false, null) } binding.linkPreviewDraftTitleTextView.text = linkPreview.title } 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 2304149367..3544f11b10 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,7 +4,6 @@ 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 @@ -14,7 +13,6 @@ 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 @@ -121,8 +119,8 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li // region Updating override fun inputBarEditTextContentChanged(text: CharSequence) { - microphoneButton.isVisible = text.trim().isEmpty() - sendButton.isVisible = microphoneButton.isGone + sendButton.isVisible = text.isNotEmpty() + microphoneButton.isVisible = text.isEmpty() delegate?.inputBarEditTextContentChanged(text) } @@ -225,8 +223,8 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li binding.inputBarEditText.addTextChangedListener(textWatcher) } - fun setInputBarEditableFactory(factory: Editable.Factory) { - binding.inputBarEditText.setEditableFactory(factory) + fun setSelection(index: Int) { + binding.inputBarEditText.setSelection(index) } // 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 deleted file mode 100644 index daed43ce74..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateAdapter.kt +++ /dev/null @@ -1,44 +0,0 @@ -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 f790e7f1c6..2d8f745967 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,14 +1,42 @@ 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.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel +import org.session.libsession.messaging.mentions.Mention +import org.thoughtcrime.securesms.groups.OpenGroupManager +import org.thoughtcrime.securesms.mms.GlideRequests -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 -} +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 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 new file mode 100644 index 0000000000..e62f7f8f85 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidatesView.kt @@ -0,0 +1,90 @@ +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 deleted file mode 100644 index bc4b068b2a..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionEditable.kt +++ /dev/null @@ -1,188 +0,0 @@ -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 deleted file mode 100644 index d7fa4d56cd..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionSpan.kt +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index fbb4d2231f..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt +++ /dev/null @@ -1,274 +0,0 @@ -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/messages/ControlMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt index 1177b4afc9..88df4c4508 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt @@ -25,15 +25,16 @@ class ControlMessageView : LinearLayout { private val TAG = "ControlMessageView" - private val binding = ViewControlMessageBinding.inflate(LayoutInflater.from(context), this, true) + private lateinit var binding: ViewControlMessageBinding - constructor(context: Context) : super(context) - constructor(context: Context, attrs: AttributeSet) : super(context, attrs) - constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) + constructor(context: Context) : super(context) { initialize() } + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } @Inject lateinit var disappearingMessages: DisappearingMessages - init { + private fun initialize() { + binding = ViewControlMessageBinding.inflate(LayoutInflater.from(context), this, true) layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT) } 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 4e6066edb3..9677223894 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) + binding.thumbnailImageView.root.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), isPreview = false, message) 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 c4a29fea54..4e91400430 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 @@ -72,7 +72,7 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet? // Author val author = contactDb.getContactWithSessionID(authorPublicKey) val localNumber = TextSecurePreferences.getLocalNumber(context) - val quoteIsLocalUser = localNumber != null && authorPublicKey == localNumber + val quoteIsLocalUser = localNumber != null && localNumber == author?.sessionID val authorDisplayName = if (quoteIsLocalUser) context.getString(R.string.QuoteView_you) @@ -80,15 +80,7 @@ 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( - text = (body ?: "").toSpannable(), - isOutgoingMessage = isOutgoingMessage, - isQuote = true, - threadID = threadID, - context = context - ) + 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.setTextColor(getTextColor(isOutgoingMessage)) // Accent line / attachment preview val hasAttachments = (attachments != null && attachments.asAttachments().isNotEmpty()) && !isOriginalMissing @@ -116,9 +108,8 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet? attachments.thumbnailSlide != null -> { val slide = attachments.thumbnailSlide!! // This internally fetches the thumbnail - binding.quoteViewAttachmentThumbnailImageView - .root.setRoundedCorners(toPx(4, resources)) - binding.quoteViewAttachmentThumbnailImageView.root.setImageResource(glide, slide, false) + binding.quoteViewAttachmentThumbnailImageView.root.radius = toPx(4, resources) + binding.quoteViewAttachmentThumbnailImageView.root.setImageResource(glide, slide, false, null) 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/messages/VisibleMessageContentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt index 83c6904dec..7e220955d6 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: (DatabaseAttachment) -> Unit, + onAttachmentNeedsDownload: (Long, Long) -> Unit, suppressThumbnails: Boolean = false ) { // Background @@ -135,11 +135,19 @@ class VisibleMessageContentView : ConstraintLayout { if (message is MmsMessageRecord) { message.slideDeck.asAttachments().forEach { attach -> val dbAttachment = attach as? DatabaseAttachment ?: return@forEach - onAttachmentNeedsDownload(dbAttachment) + val attachmentId = dbAttachment.attachmentId.rowId + if (attach.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING + && MessagingModuleConfiguration.shared.storage.getAttachmentUploadJob(attachmentId) == null) { + onAttachmentNeedsDownload(attachmentId, dbAttachment.mmsId) + } } message.linkPreviews.forEach { preview -> val previewThumbnail = preview.getThumbnail().orNull() as? DatabaseAttachment ?: return@forEach - onAttachmentNeedsDownload(previewThumbnail) + val attachmentId = previewThumbnail.attachmentId.rowId + if (previewThumbnail.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING + && MessagingModuleConfiguration.shared.storage.getAttachmentUploadJob(attachmentId) == null) { + onAttachmentNeedsDownload(attachmentId, previewThumbnail.mmsId) + } } } @@ -274,12 +282,7 @@ class VisibleMessageContentView : ConstraintLayout { fun getBodySpans(context: Context, message: MessageRecord, searchQuery: String?): Spannable { var body = message.body.toSpannable() - body = MentionUtilities.highlightMentions( - text = body, - isOutgoingMessage = message.isOutgoing, - threadID = message.threadId, - context = context - ) + body = MentionUtilities.highlightMentions(body, message.isOutgoing, message.threadId, 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/messages/VisibleMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt index 9470d17de5..64017e2ad9 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 @@ -11,10 +11,8 @@ import android.os.Looper import android.util.AttributeSet import android.view.Gravity import android.view.HapticFeedbackConstants -import android.view.LayoutInflater import android.view.MotionEvent import android.view.View -import android.view.ViewGroup import android.widget.FrameLayout import android.widget.LinearLayout import androidx.annotation.ColorInt @@ -28,18 +26,17 @@ import androidx.core.view.isVisible import androidx.core.view.marginBottom import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R -import network.loki.messenger.databinding.ViewEmojiReactionsBinding import network.loki.messenger.databinding.ViewVisibleMessageBinding -import network.loki.messenger.databinding.ViewstubVisibleMessageMarkerContainerBinding 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.TextSecurePreferences import org.session.libsession.utilities.ViewUtil import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.modifyLayoutParams import org.session.libsignal.utilities.IdPrefix +import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.database.LastSentTimestampCache import org.thoughtcrime.securesms.database.LokiAPIDatabase @@ -68,7 +65,7 @@ import kotlin.math.sqrt private const val TAG = "VisibleMessageView" @AndroidEntryPoint -class VisibleMessageView : FrameLayout { +class VisibleMessageView : LinearLayout { private var replyDisabled: Boolean = false @Inject lateinit var threadDb: ThreadDatabase @Inject lateinit var lokiThreadDb: LokiThreadDatabase @@ -78,16 +75,7 @@ class VisibleMessageView : FrameLayout { @Inject lateinit var mmsDb: MmsDatabase @Inject lateinit var lastSentTimestampCache: LastSentTimestampCache - private val binding = ViewVisibleMessageBinding.inflate(LayoutInflater.from(context), this, true) - - private val markerContainerBinding = lazy(LazyThreadSafetyMode.NONE) { - ViewstubVisibleMessageMarkerContainerBinding.bind(binding.unreadMarkerContainerStub.inflate()) - } - - private val emojiReactionsBinding = lazy(LazyThreadSafetyMode.NONE) { - ViewEmojiReactionsBinding.bind(binding.emojiReactionsView.inflate()) - } - + private val binding by lazy { ViewVisibleMessageBinding.bind(this) } private val swipeToReplyIcon = ContextCompat.getDrawable(context, R.drawable.ic_baseline_reply_24)!!.mutate() private val swipeToReplyIconRect = Rect() private var dx = 0.0f @@ -106,7 +94,7 @@ class VisibleMessageView : FrameLayout { var onPress: ((event: MotionEvent) -> Unit)? = null var onSwipeToReply: (() -> Unit)? = null var onLongPress: (() -> Unit)? = null - val messageContentView: VisibleMessageContentView get() = binding.messageContentView.root + val messageContentView: VisibleMessageContentView by lazy { binding.messageContentView.root } companion object { const val swipeToReplyThreshold = 64.0f // dp @@ -120,7 +108,12 @@ class VisibleMessageView : FrameLayout { constructor(context: Context, attrs: AttributeSet) : super(context, attrs) constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) - init { + override fun onFinishInflate() { + super.onFinishInflate() + initialize() + } + + private fun initialize() { isHapticFeedbackEnabled = true setWillNotDraw(false) binding.root.disableClipping() @@ -128,11 +121,7 @@ class VisibleMessageView : FrameLayout { binding.messageInnerContainer.disableClipping() binding.messageInnerLayout.disableClipping() binding.messageContentView.root.disableClipping() - - // Default layout params - layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) } - // endregion // region Updating @@ -146,7 +135,7 @@ class VisibleMessageView : FrameLayout { senderSessionID: String, lastSeen: Long, delegate: VisibleMessageViewDelegate? = null, - onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit, + onAttachmentNeedsDownload: (Long, Long) -> Unit, lastSentMessageId: Long ) { replyDisabled = message.isOpenGroupInvitation @@ -214,13 +203,7 @@ class VisibleMessageView : FrameLayout { binding.senderNameTextView.text = contact?.displayName(contactContext) ?: senderSessionID // Unread marker - val shouldShowUnreadMarker = lastSeen != -1L && message.timestamp > lastSeen && (previous == null || previous.timestamp <= lastSeen) && !message.isOutgoing - if (shouldShowUnreadMarker) { - markerContainerBinding.value.root.isVisible = true - } else if (markerContainerBinding.isInitialized()) { - // Only need to hide the binding when the binding is inflated. (default is gone) - markerContainerBinding.value.root.isVisible = false - } + binding.unreadMarkerContainer.isVisible = lastSeen != -1L && message.timestamp > lastSeen && (previous == null || previous.timestamp <= lastSeen) && !message.isOutgoing // Date break val showDateBreak = isStartOfMessageCluster || snIsSelected @@ -231,22 +214,21 @@ class VisibleMessageView : FrameLayout { showStatusMessage(message) // Emoji Reactions + val emojiLayoutParams = binding.emojiReactionsView.root.layoutParams as ConstraintLayout.LayoutParams + emojiLayoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f + binding.emojiReactionsView.root.layoutParams = emojiLayoutParams + if (message.reactions.isNotEmpty()) { val capabilities = lokiThreadDb.getOpenGroupChat(threadID)?.server?.let { lokiApiDb.getServerCapabilities(it) } if (capabilities.isNullOrEmpty() || capabilities.contains(OpenGroupApi.Capability.REACTIONS.name.lowercase())) { - emojiReactionsBinding.value.root.let { root -> - root.setReactions(message.id, message.reactions, message.isOutgoing, delegate) - root.isVisible = true - (root.layoutParams as ConstraintLayout.LayoutParams).apply { - horizontalBias = if (message.isOutgoing) 1f else 0f - } - } - } else if (emojiReactionsBinding.isInitialized()) { - emojiReactionsBinding.value.root.isVisible = false + binding.emojiReactionsView.root.setReactions(message.id, message.reactions, message.isOutgoing, delegate) + binding.emojiReactionsView.root.isVisible = true + } else { + binding.emojiReactionsView.root.isVisible = false } } - else if (emojiReactionsBinding.isInitialized()) { - emojiReactionsBinding.value.root.isVisible = false + else { + binding.emojiReactionsView.root.isVisible = false } // Populate content view diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt index 06a5168a99..2b829af152 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt @@ -68,7 +68,7 @@ class VoiceMessageView : RelativeLayout, AudioSlidePlayer.Listener { return } - val player = AudioSlidePlayer.createFor(context.applicationContext, audio, this) + val player = AudioSlidePlayer.createFor(context, audio, this) this.player = player (audio.asAttachment() as? DatabaseAttachment)?.let { attachment -> 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 new file mode 100644 index 0000000000..ee1c7257c2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionManagerUtilities.kt @@ -0,0 +1,34 @@ +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/conversation/v2/utilities/MentionUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt index 3a4bfd6816..cb9a19ffc1 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,70 +9,51 @@ 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.libsession.utilities.getColorFromAttr -import org.session.libsession.utilities.truncateIdForDisplay +import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.util.RoundedBackgroundSpan +import org.thoughtcrime.securesms.util.UiModeUtilities import org.thoughtcrime.securesms.util.getAccentColor -import org.thoughtcrime.securesms.util.toPx +import org.thoughtcrime.securesms.util.getColorResourceIdFromAttr +import org.thoughtcrime.securesms.util.getMessageTextColourAttr import java.util.regex.Pattern object MentionUtilities { - 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 = false, - isQuote: Boolean = false, - formatOnly: Boolean = false, - threadID: Long, - context: Context - ): SpannableString { - @Suppress("NAME_SHADOWING") var text = text + fun highlightMentions(text: CharSequence, threadID: Long, context: Context): String { + return highlightMentions(text, false, threadID, context).toString() // isOutgoingMessage is irrelevant + } + @JvmStatic + fun highlightMentions(text: CharSequence, isOutgoingMessage: Boolean, 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 by lazy { DatabaseComponent.get(context).storage().getOpenGroup(threadID) } - - // format the mention text + val openGroup = DatabaseComponent.get(context).storage().getOpenGroup(threadID) if (matcher.find(startIndex)) { while (true) { val publicKey = text.subSequence(matcher.start() + 1, matcher.end()).toString() // +1 to get rid of the @ - val isYou = isYou(publicKey, userPublicKey, openGroup) - val userDisplayName: String? = if (isYou) { + val isUserBlindedPublicKey = openGroup?.let { SodiumUtilities.sessionId(userPublicKey, publicKey, it.publicKey) } ?: false + val userDisplayName: String? = if (publicKey.equals(userPublicKey, ignoreCase = true) || isUserBlindedPublicKey) { context.getString(R.string.MessageRecord_you) } else { val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithSessionID(publicKey) @Suppress("NAME_SHADOWING") val context = if (openGroup != null) Contact.ContactContext.OPEN_GROUP else Contact.ContactContext.REGULAR - contact?.displayName(context) ?: truncateIdForDisplay(publicKey) + contact?.displayName(context) } if (userDisplayName != null) { - val mention = "@$userDisplayName" - text = text.subSequence(0, matcher.start()).toString() + mention + text.subSequence(matcher.end(), text.length) + text = text.subSequence(0, matcher.start()).toString() + "@" + userDisplayName + text.subSequence(matcher.end(), text.length) val endIndex = matcher.start() + 1 + userDisplayName.length startIndex = endIndex mentions.add(Tuple2(Range.create(matcher.start(), endIndex), publicKey)) @@ -85,83 +66,37 @@ object MentionUtilities { } val result = SpannableString(text) - // 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) + 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) } - // 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) - } + 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) - 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 - ) + // 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) } } 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/conversation/v2/utilities/ThumbnailView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.kt index 02c683aac6..4a9986d6ec 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,13 +2,10 @@ 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 @@ -24,17 +21,18 @@ 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 @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : FrameLayout(context, attrs, defStyleAttr) { - +open class ThumbnailView: FrameLayout { companion object { private const val WIDTH = 0 private const val HEIGHT = 1 @@ -43,29 +41,30 @@ open class ThumbnailView @JvmOverloads constructor( 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 - 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) - ) + private fun initialize(attrs: AttributeSet?) { + if (attrs != null) { + val typedArray = context.theme.obtainStyledAttributes(attrs, R.styleable.ThumbnailView, 0, 0) - setRoundedCorners( - getDimensionPixelSize(R.styleable.ThumbnailView_thumbnail_radius, 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)) - recycle() - } + radius = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_thumbnail_radius, 0) + + typedArray.recycle() + } } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { @@ -85,118 +84,114 @@ open class ThumbnailView @JvmOverloads constructor( private fun getDefaultWidth() = maxOf(layoutParams?.width ?: 0, 0) private fun getDefaultHeight() = maxOf(layoutParams?.height ?: 0, 0) - // endregion // region Interaction - 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 + 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 - ): ListenableFuture = setImageResource(glide, slide, isPreview, 0, 0) + fun setImageResource(glide: GlideRequests, slide: Slide, + isPreview: Boolean, naturalWidth: Int, + naturalHeight: Int, mms: MmsMessageRecord?): ListenableFuture { + + val currentSlide = this.slide - 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)) + (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE || isPreview)) - if (equals(this.slide, slide)) { + if (equals(currentSlide, slide)) { // don't re-load slide return SettableFuture(false) } + + if (currentSlide != null && currentSlide.fastPreflightId != null && currentSlide.fastPreflightId == slide.fastPreflightId) { + // not reloading slide for fast preflight + this.slide = slide + } + 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() - 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) - } + 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 result } - 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) + fun buildThumbnailGlideRequest(glide: GlideRequests, slide: Slide): GlideRequest { - private fun buildPlaceholderGlideRequest( - glide: GlideRequests, - slide: Slide - ): GlideRequest = glide.asBitmap() - .load(slide.getPlaceholderRes(context.theme)) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .overrideDimensions() - .fitCenter() + 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() + } open fun clear(glideRequests: GlideRequests) { glideRequests.clear(binding.thumbnailImage) slide = null } - fun setImageResource( - glideRequests: GlideRequests, - uri: Uri - ): ListenableFuture = glideRequests.load(DecryptableUri(uri)) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .transition(DrawableTransitionOptions.withCrossFade()) - .transform(CenterCrop()) - .intoDrawableTargetAsFuture() + fun setImageResource(glideRequests: GlideRequests, uri: Uri): ListenableFuture { + val future = SettableFuture() - private fun GlideRequest.intoDrawableTargetAsFuture() = - SettableFuture().also { - binding.run { - GlideDrawableListeningTarget(thumbnailImage, thumbnailLoadIndicator, it) - }.let { into(it) } + 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.overrideDimensions() = - dimensDelegate.resourceSize().takeIf { 0 !in it } - ?.let { override(it[WIDTH], it[HEIGHT]) } - ?: override(getDefaultWidth(), getDefaultHeight()) -} + request.into(GlideDrawableListeningTarget(binding.thumbnailImage, binding.thumbnailLoadIndicator, future)) -private fun GlideRequest.missingThumbnailPicture( - inProgress: Boolean -) = takeIf { inProgress } ?: apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture)) + return future + } +} \ 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 e869f741c7..ff44ef2c9a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupMemberDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupMemberDatabase.kt @@ -3,12 +3,9 @@ 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) { @@ -54,19 +51,6 @@ 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 23a1af7ceb..5648cdace1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt @@ -218,21 +218,6 @@ 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 778af6c01c..49a6339368 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt @@ -4,7 +4,6 @@ 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 @@ -42,15 +41,6 @@ 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/database/SessionJobDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt index e83c464c7d..591755b88f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt @@ -3,7 +3,6 @@ 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 @@ -51,18 +50,14 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa databaseHelper.writableDatabase.delete(sessionJobTable, "${Companion.jobID} = ?", arrayOf( jobID )) } - fun getAllJobs(vararg types: String): Map { + fun getAllJobs(type: String): Map { val database = databaseHelper.readableDatabase - 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 -> + return database.getAll(sessionJobTable, "$jobType = ?", arrayOf( type )) { cursor -> val jobID = cursor.getString(jobID) try { jobID to jobFromCursor(cursor) } catch (e: Exception) { - Log.e("Loki", "Error deserializing job of type: $types.", e) + Log.e("Loki", "Error deserializing job of type: $type.", 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 dd0544b420..354ec05c46 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(vararg types: String): Map { - return DatabaseComponent.get(context).sessionJobDatabase().getAllJobs(*types) + override fun getAllPendingJobs(type: String): Map { + return DatabaseComponent.get(context).sessionJobDatabase().getAllJobs(type) } override fun getAttachmentUploadJob(attachmentID: Long): AttachmentUploadJob? { diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt index a9a72e7665..936e4f287f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt @@ -19,11 +19,11 @@ abstract class AppModule { @Binds abstract fun bindConversationRepository(repository: DefaultConversationRepository): ConversationRepository + } @EntryPoint @InstallIn(SingletonComponent::class) interface AppComponent { fun getPrefs(): TextSecurePreferences - } \ No newline at end of file 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 505a7939a8..8379e1a23b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt @@ -7,6 +7,7 @@ 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 @@ -71,6 +72,7 @@ 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( @@ -90,6 +92,7 @@ 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( @@ -109,6 +112,7 @@ 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( @@ -129,6 +133,7 @@ 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( @@ -202,6 +207,8 @@ class ConfigFactory( openGroupId: String?, visibleOnly: Boolean ): Boolean { + if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return true + val (_, userPublicKey) = maybeGetUserInfo() ?: return true if (openGroupId != null) { @@ -234,6 +241,8 @@ 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/groups/OpenGroupManager.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt index 01e1c514ff..2754c70f69 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt @@ -162,7 +162,13 @@ object OpenGroupManager { val memberDatabase = DatabaseComponent.get(context).groupMemberDatabase() val standardRoles = memberDatabase.getGroupMemberRoles(groupId, standardPublicKey) val blindedRoles = blindedPublicKey?.let { memberDatabase.getGroupMemberRoles(groupId, it) } ?: emptyList() - return standardRoles.any { it.isModerator } || blindedRoles.any { it.isModerator } + + // 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 } } } \ 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 36ea3a4371..c9896a5b8e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt @@ -103,12 +103,7 @@ class ConversationView : LinearLayout { R.drawable.ic_notifications_mentions } binding.muteIndicatorImageView.setImageResource(drawableRes) - binding.snippetTextView.text = highlightMentions( - text = thread.getSnippet(), - formatOnly = true, // no styling here, only text formatting - threadID = thread.threadId, - context = context - ) + binding.snippetTextView.text = highlightMentions(thread.getSnippet(), thread.threadId, 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/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index 4b0cf60c3c..c063f30538 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -27,6 +27,7 @@ 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 @@ -335,7 +336,8 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } private fun updateLegacyConfigView() { - binding.configOutdatedView.isVisible = textSecurePreferences.getHasLegacyConfig() + binding.configOutdatedView.isVisible = ConfigBase.isNewConfigEnabled(textSecurePreferences.hasForcedNewConfig(), SnodeAPI.nowWithOffset) + && textSecurePreferences.getHasLegacyConfig() } override fun onResume() { 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 dd6d24cd00..fa18a995b6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt @@ -2,6 +2,7 @@ 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 @@ -21,6 +22,7 @@ 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 org.session.libsession.utilities.TextSecurePreferences @@ -58,10 +60,12 @@ 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()) } @@ -77,7 +81,7 @@ class HomeViewModel @Inject constructor( hasHiddenMessageRequests(), latestUnapprovedConversationTimestamp(), ::createMessageRequests - ).flowOn(Dispatchers.IO) + ) private fun unapprovedConversationCount() = reloadTriggersAndContentChanges() .map { threadDb.unapprovedConversationCount } @@ -92,13 +96,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) } @@ -110,7 +114,7 @@ class HomeViewModel @Inject constructor( val messageRequests: MessageRequests? = null ) - private fun createMessageRequests( + fun createMessageRequests( count: Int, hidden: Boolean, timestamp: Long 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 6492069780..dd27c42502 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaRailAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaRailAdapter.java @@ -1,7 +1,5 @@ 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; @@ -153,8 +151,6 @@ 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/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt index a916d8e4d6..af3d269c6a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt @@ -39,13 +39,7 @@ 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( - text = rawSnippet, - formatOnly = true, // no styling here, only text formatting - threadID = thread.threadId, - context = context - ) - + val snippet = highlightMentions(rawSnippet, thread.threadId, 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 8a891fb9b9..b281e0798b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java @@ -56,6 +56,7 @@ 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; @@ -347,6 +348,7 @@ 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` @@ -442,30 +444,13 @@ public class DefaultMessageNotifier implements MessageNotifier { while(iterator.hasPrevious()) { NotificationItem item = iterator.previous(); builder.addMessageBody(item.getIndividualRecipient(), item.getRecipient(), - MentionUtilities.highlightMentions( - item.getText() != null ? item.getText() : "", - false, - false, - true, // no styling here, only text formatting - item.getThreadId(), - context - ) - ); + MentionUtilities.highlightMentions(item.getText(), 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( - text != null ? text : "", - false, - false, - true, // no styling here, only text formatting - notifications.get(0).getThreadId(), - context - ) - ); + MentionUtilities.highlightMentions(notifications.get(0).getText(), notifications.get(0).getThreadId(), context)); } builder.putStringExtra(LATEST_MESSAGE_ID_TAG, messageIdTag); 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 e0f0492445..9d10cfdab5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt @@ -17,6 +17,8 @@ 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 @@ -53,16 +55,61 @@ 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 - scheduleConfigSync(userPublicKey) + 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) } 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")) - // schedule job if none exist - // don't schedule job if we already have one - scheduleConfigSync(userPublicKey) - return Promise.ofSuccess(Unit) + 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 } private fun maybeUserSecretKey() = MessagingModuleConfiguration.shared.getUserED25519KeyPair()?.secretKey?.asBytes diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FlowUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/FlowUtils.kt deleted file mode 100644 index e5b35b8931..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FlowUtils.kt +++ /dev/null @@ -1,44 +0,0 @@ -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/main/java/org/thoughtcrime/securesms/util/RoundedBackgroundSpan.kt b/app/src/main/java/org/thoughtcrime/securesms/util/RoundedBackgroundSpan.kt deleted file mode 100644 index a4cf1b1e96..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/RoundedBackgroundSpan.kt +++ /dev/null @@ -1,71 +0,0 @@ -package org.thoughtcrime.securesms.util - -import android.content.Context -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 { - // If the span covers the whole text, and the height is not set, draw() will not be called for the span. - // To help with that we need to take the font metric into account - val metrics = paint.fontMetricsInt - if (fm != null) { - fm.top = metrics.top - fm.ascent = metrics.ascent - fm.descent = metrics.descent - - fm.bottom = metrics.bottom - } - - 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) - } -} diff --git a/app/src/main/res/layout/activity_conversation_v2.xml b/app/src/main/res/layout/activity_conversation_v2.xml index 4d38e2ab5b..6fe0c4db60 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 @@ - @@ -34,11 +31,9 @@ android:focusable="false" android:id="@+id/conversationRecyclerView" android:layout_width="match_parent" - 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" /> + android:layout_height="match_parent" + android:layout_above="@+id/typingIndicatorViewContainer" + android:layout_below="@id/toolbar" /> + android:layout_alignParentBottom="true" /> - + android:layout_alignBottom="@+id/conversationRecyclerView"/> - - - - + android:layout_alignWithParentIfMissing="true" + android:layout_above="@id/messageRequestBar"/> @@ -220,14 +197,14 @@ android:layout_height="wrap_content" android:layout_marginBottom="-12dp" android:visibility="gone" - app:layout_constraintBottom_toBottomOf="parent" /> + android:layout_alignParentBottom="true" /> - @@ -237,20 +214,20 @@ android:contentDescription="@string/AccessibilityId_blocked_banner_text" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_gravity="center" + android:layout_centerInParent="true" 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?" /> - + - @@ -260,14 +237,14 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:gravity="center_horizontal" - android:layout_gravity="center" + android:layout_centerInParent="true" 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" /> - + @@ -286,12 +263,11 @@ android:id="@+id/messageRequestBar" android:layout_width="match_parent" android:layout_height="wrap_content" - app:layout_constraintBottom_toTopOf="@+id/inputBar" - app:layout_constraintTop_toBottomOf="@+id/textSendAfterApproval" + android:layout_above="@id/inputBar" android:layout_marginBottom="@dimen/large_spacing" android:orientation="vertical" android:visibility="gone" - tools:visibility="gone"> + tools:visibility="visible"> - + diff --git a/app/src/main/res/layout/album_thumbnail_1.xml b/app/src/main/res/layout/album_thumbnail_1.xml index 2f3ffdaa79..cee81ba3e3 100644 --- a/app/src/main/res/layout/album_thumbnail_1.xml +++ b/app/src/main/res/layout/album_thumbnail_1.xml @@ -9,6 +9,11 @@ + android:layout_height="match_parent" + app:minWidth="@dimen/media_bubble_min_width" + app:maxWidth="@dimen/media_bubble_max_width" + app:minHeight="@dimen/media_bubble_min_height" + app:maxHeight="@dimen/media_bubble_max_height" + app:thumbnail_radius="1dp"/> \ 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 1712bdb741..52375d025c 100644 --- a/app/src/main/res/layout/album_thumbnail_2.xml +++ b/app/src/main/res/layout/album_thumbnail_2.xml @@ -10,12 +10,14 @@ + android:layout_height="@dimen/album_2_total_height" + app:thumbnail_radius="0dp"/> + android:layout_gravity="end" + app:thumbnail_radius="0dp"/> \ 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 d201565dab..b408ffd2bc 100644 --- a/app/src/main/res/layout/album_thumbnail_3.xml +++ b/app/src/main/res/layout/album_thumbnail_3.xml @@ -9,13 +9,15 @@ + android:layout_height="@dimen/album_3_total_height" + app:thumbnail_radius="0dp"/> + android:layout_gravity="end|top" + app:thumbnail_radius="0dp"/> + android:layout_gravity="center_horizontal|bottom" + app:thumbnail_radius="0dp"/> + android:layout_gravity="center" + android:background="@drawable/mediarail_media_outline" + app:thumbnail_radius="5dp"/> - + android:background="@drawable/mention_candidate_view_background"> diff --git a/app/src/main/res/layout/view_visible_message.xml b/app/src/main/res/layout/view_visible_message.xml index 1e099e319e..19f9b4f9ad 100644 --- a/app/src/main/res/layout/view_visible_message.xml +++ b/app/src/main/res/layout/view_visible_message.xml @@ -1,5 +1,5 @@ - - + android:layout_marginBottom="@dimen/small_spacing" + android:visibility="gone" + tools:visibility="visible"> + + + + - - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/viewstub_visible_message_marker_container.xml b/app/src/main/res/layout/viewstub_visible_message_marker_container.xml deleted file mode 100644 index d9a1cc15d0..0000000000 --- a/app/src/main/res/layout/viewstub_visible_message_marker_container.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/xml/network_security_configuration.xml b/app/src/main/res/xml/network_security_configuration.xml index 7469ebe106..f3a7419b55 100644 --- a/app/src/main/res/xml/network_security_configuration.xml +++ b/app/src/main/res/xml/network_security_configuration.xml @@ -2,7 +2,6 @@ 127.0.0.1 - public.loki.foundation seed1.getsession.org 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 7f4db828e4..37303e29d5 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,7 +23,6 @@ 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 @@ -33,7 +32,6 @@ class ConversationViewModelTest: BaseViewModelTest() { private val repository = mock() private val storage = mock() - private val mmsDatabase = mock() private val threadId = 123L private val edKeyPair = mock() @@ -41,7 +39,7 @@ class ConversationViewModelTest: BaseViewModelTest() { private lateinit var messageRecord: MessageRecord private val viewModel: ConversationViewModel by lazy { - ConversationViewModel(threadId, edKeyPair, repository, storage, mock(), mmsDatabase) + ConversationViewModel(threadId, edKeyPair, repository, storage) } @Before 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 deleted file mode 100644 index 8ae4cb43bb..0000000000 --- a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/MentionEditableTest.kt +++ /dev/null @@ -1,115 +0,0 @@ -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 deleted file mode 100644 index 3b3dd53e1c..0000000000 --- a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/MentionViewModelTest.kt +++ /dev/null @@ -1,185 +0,0 @@ -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/java/org/thoughtcrime/securesms/util/FlowUtilsTest.kt b/app/src/test/java/org/thoughtcrime/securesms/util/FlowUtilsTest.kt deleted file mode 100644 index f089586de9..0000000000 --- a/app/src/test/java/org/thoughtcrime/securesms/util/FlowUtilsTest.kt +++ /dev/null @@ -1,52 +0,0 @@ -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/app/src/test/resources/TestAndroidManifest.xml b/app/src/test/resources/TestAndroidManifest.xml deleted file mode 100644 index afc09b82b7..0000000000 --- a/app/src/test/resources/TestAndroidManifest.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/test/resources/robolectric.properties b/app/src/test/resources/robolectric.properties deleted file mode 100644 index 1ec9805b2a..0000000000 --- a/app/src/test/resources/robolectric.properties +++ /dev/null @@ -1,3 +0,0 @@ -manifest=TestAndroidManifest.xml -sdk=34 -application=android.app.Application \ No newline at end of file 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 03af4a3968..befd0d6d43 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,6 +27,12 @@ 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/database/StorageProtocol.kt b/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt index c984594bcc..260e254fe7 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(vararg types: String): Map + fun getAllPendingJobs(type: 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 ffa05bf1e6..6aae0c6c9b 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,8 +1,6 @@ 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 @@ -42,36 +40,6 @@ 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) { @@ -120,7 +88,21 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) return } - if (!eligibleForDownload(threadID, storage, messageDataProvider, databaseMessageID)) { + 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 handleFailure(Error.NoSender, null) return } diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/BackgroundGroupAddJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/BackgroundGroupAddJob.kt index f284f2539d..20442e5594 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/BackgroundGroupAddJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/BackgroundGroupAddJob.kt @@ -37,7 +37,6 @@ class BackgroundGroupAddJob(val joinUrl: String): Job { delegate?.handleJobFailed(this, dispatcherName, DuplicateGroupException()) return } - storage.addOpenGroup(openGroup.joinUrl()) storage.onOpenGroupAdded(openGroup.server, openGroup.room) } catch (e: Exception) { 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 d7dfdf768e..ec8de44163 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,5 +1,6 @@ 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 @@ -9,6 +10,7 @@ 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 @@ -24,10 +26,14 @@ 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 + 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 we don't have a user ed key pair for signing updates || userEdKeyPair == null // this will be useful to not handle null delegate cases @@ -61,7 +67,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.buildConfigMessageToSnode(destination.destinationPublicKey(), message) + val snodeMessage = MessageSender.buildWrappedMessageToSnode(destination, message, true) val authenticated = SnodeAPI.buildAuthenticatedStoreBatchInfo( destination.destinationPublicKey(), config.configNamespace(), 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 1450168ea9..71d62bc72f 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)", e) + Log.d(dispatcherName, "unhandledJobException: ${javaClass.simpleName} (id: $id)") this@JobQueue.handleJobFailed(this, dispatcherName, e) } } 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 47ec35e176..8335e0a2da 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,11 +6,6 @@ data class GroupMember( val role: GroupMemberRole ) -enum class GroupMemberRole(val isModerator: Boolean = false) { - STANDARD, - ZOOMBIE, - MODERATOR(true), - ADMIN(true), - HIDDEN_MODERATOR(true), - HIDDEN_ADMIN(true), +enum class GroupMemberRole { + STANDARD, ZOOMBIE, MODERATOR, ADMIN, HIDDEN_MODERATOR, HIDDEN_ADMIN } diff --git a/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt b/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt index 6e73c16f5e..a5203827ea 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt @@ -273,6 +273,7 @@ object OpenGroupApi { val queryParameters: Map = mapOf(), val parameters: Any? = null, val headers: Map = mapOf(), + val isAuthRequired: Boolean = true, val body: ByteArray? = null, /** * Always `true` under normal circumstances. You might want to disable @@ -318,72 +319,73 @@ object OpenGroupApi { ?: return Promise.ofFail(Error.NoEd25519KeyPair) val urlRequest = urlBuilder.toString() val headers = request.headers.toMutableMap() + if (request.isAuthRequired) { + val nonce = sodium.nonce(16) + val timestamp = TimeUnit.MILLISECONDS.toSeconds(SnodeAPI.nowWithOffset) + var pubKey = "" + var signature = ByteArray(Sign.BYTES) + var bodyHash = ByteArray(0) + if (request.parameters != null) { + val parameterBytes = JsonUtil.toJson(request.parameters).toByteArray() + val parameterHash = ByteArray(GenericHash.BYTES_MAX) + if (sodium.cryptoGenericHash( + parameterHash, + parameterHash.size, + parameterBytes, + parameterBytes.size.toLong() + ) + ) { + bodyHash = parameterHash + } + } else if (request.body != null) { + val byteHash = ByteArray(GenericHash.BYTES_MAX) + if (sodium.cryptoGenericHash( + byteHash, + byteHash.size, + request.body, + request.body.size.toLong() + ) + ) { + bodyHash = byteHash + } + } + val messageBytes = Hex.fromStringCondensed(publicKey) + .plus(nonce) + .plus("$timestamp".toByteArray(Charsets.US_ASCII)) + .plus(request.verb.rawValue.toByteArray()) + .plus("/${request.endpoint.value}".toByteArray()) + .plus(bodyHash) + if (serverCapabilities.isEmpty() || serverCapabilities.contains(Capability.BLIND.name.lowercase())) { + SodiumUtilities.blindedKeyPair(publicKey, ed25519KeyPair)?.let { keyPair -> + pubKey = SessionId( + IdPrefix.BLINDED, + keyPair.publicKey.asBytes + ).hexString - val nonce = sodium.nonce(16) - val timestamp = TimeUnit.MILLISECONDS.toSeconds(SnodeAPI.nowWithOffset) - var pubKey = "" - var signature = ByteArray(Sign.BYTES) - var bodyHash = ByteArray(0) - if (request.parameters != null) { - val parameterBytes = JsonUtil.toJson(request.parameters).toByteArray() - val parameterHash = ByteArray(GenericHash.BYTES_MAX) - if (sodium.cryptoGenericHash( - parameterHash, - parameterHash.size, - parameterBytes, - parameterBytes.size.toLong() - ) - ) { - bodyHash = parameterHash - } - } else if (request.body != null) { - val byteHash = ByteArray(GenericHash.BYTES_MAX) - if (sodium.cryptoGenericHash( - byteHash, - byteHash.size, - request.body, - request.body.size.toLong() - ) - ) { - bodyHash = byteHash - } - } - val messageBytes = Hex.fromStringCondensed(publicKey) - .plus(nonce) - .plus("$timestamp".toByteArray(Charsets.US_ASCII)) - .plus(request.verb.rawValue.toByteArray()) - .plus("/${request.endpoint.value}".toByteArray()) - .plus(bodyHash) - if (serverCapabilities.isEmpty() || serverCapabilities.contains(Capability.BLIND.name.lowercase())) { - SodiumUtilities.blindedKeyPair(publicKey, ed25519KeyPair)?.let { keyPair -> + signature = SodiumUtilities.sogsSignature( + messageBytes, + ed25519KeyPair.secretKey.asBytes, + keyPair.secretKey.asBytes, + keyPair.publicKey.asBytes + ) ?: return Promise.ofFail(Error.SigningFailed) + } ?: return Promise.ofFail(Error.SigningFailed) + } else { pubKey = SessionId( - IdPrefix.BLINDED, - keyPair.publicKey.asBytes + IdPrefix.UN_BLINDED, + ed25519KeyPair.publicKey.asBytes ).hexString - - signature = SodiumUtilities.sogsSignature( + sodium.cryptoSignDetached( + signature, messageBytes, - ed25519KeyPair.secretKey.asBytes, - keyPair.secretKey.asBytes, - keyPair.publicKey.asBytes - ) ?: return Promise.ofFail(Error.SigningFailed) - } ?: return Promise.ofFail(Error.SigningFailed) - } else { - pubKey = SessionId( - IdPrefix.UN_BLINDED, - ed25519KeyPair.publicKey.asBytes - ).hexString - sodium.cryptoSignDetached( - signature, - messageBytes, - messageBytes.size.toLong(), - ed25519KeyPair.secretKey.asBytes - ) + messageBytes.size.toLong(), + ed25519KeyPair.secretKey.asBytes + ) + } + headers["X-SOGS-Nonce"] = encodeBytes(nonce) + headers["X-SOGS-Timestamp"] = "$timestamp" + headers["X-SOGS-Pubkey"] = pubKey + headers["X-SOGS-Signature"] = encodeBytes(signature) } - headers["X-SOGS-Nonce"] = encodeBytes(nonce) - headers["X-SOGS-Timestamp"] = "$timestamp" - headers["X-SOGS-Pubkey"] = pubKey - headers["X-SOGS-Signature"] = encodeBytes(signature) val requestBuilder = okhttp3.Request.Builder() .url(urlRequest) @@ -925,7 +927,7 @@ object OpenGroupApi { } fun getCapabilities(server: String): Promise { - val request = Request(verb = GET, room = null, server = server, endpoint = Endpoint.Capabilities) + val request = Request(verb = GET, room = null, server = server, endpoint = Endpoint.Capabilities, isAuthRequired = false) return getResponseBody(request).map { response -> JsonUtil.fromJson(response, Capabilities::class.java) } 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 8081fe1d92..0968db27e2 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,15 +81,6 @@ 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/ReceivedMessageHandler.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt index dfd40f5025..e65472c1fe 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,10 +203,12 @@ private fun handleConfigurationMessage(message: ConfigurationMessage) { TextSecurePreferences.setConfigurationMessageSynced(context, true) TextSecurePreferences.setLastProfileUpdateTime(context, message.sentTimestamp!!) - - TextSecurePreferences.setHasLegacyConfig(context, true) - if (!firstTimeSync) return - + val isForceSync = TextSecurePreferences.hasForcedNewConfig(context) + val currentTime = SnodeAPI.nowWithOffset + if (ConfigBase.isNewConfigEnabled(isForceSync, currentTime)) { + TextSecurePreferences.setHasLegacyConfig(context, true) + if (!firstTimeSync) return + } val allClosedGroupPublicKeys = storage.getAllClosedGroupPublicKeys() for (closedGroup in message.closedGroups) { if (allClosedGroupPublicKeys.contains(closedGroup.publicKey)) { @@ -258,7 +260,7 @@ fun MessageReceiver.handleUnsendRequest(message: UnsendRequest): Long? { SnodeAPI.deleteMessage(author, listOf(serverHash)) } val deletedMessageId = messageDataProvider.updateMessageAsDeleted(timestamp, author) - if (!messageDataProvider.isOutgoingMessage(timestamp)) { + if (!messageDataProvider.isOutgoingMessage(messageIdToDelete)) { SSKEnvironment.shared.notificationManager.updateNotification(context) } 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 d05290a5fd..39ed79de1e 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,7 +25,6 @@ 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 @@ -127,26 +126,37 @@ 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 = 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() + val messages = SnodeAPI.parseRawMessagesResponse( + rawMessages, + snode, + userPublicKey, + namespace, + updateLatestHash = true, + updateStoredHashes = true, + ) - if (processed.isEmpty()) return + if (messages.isEmpty()) { + // no new messages to process + return + } var latestMessageTimestamp: Long? = null - processed.forEach { (body, hash, timestamp) -> + messages.forEach { (envelope, hash) -> try { - forConfigObject.merge(hash to body) - latestMessageTimestamp = if (timestamp > (latestMessageTimestamp ?: 0L)) { timestamp } else { latestMessageTimestamp } + 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 } + } } 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 d2cfa2de35..0f996bacac 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 { } } - fun updateLastMessageHashValueIfPossible(snode: Snode, publicKey: String, rawMessages: List<*>, namespace: Int) { + private 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 { } } - fun removeDuplicates(publicKey: String, rawMessages: List<*>, namespace: Int, updateStoredHashes: Boolean): List<*> { + private 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 -> diff --git a/libsession/src/main/java/org/session/libsession/utilities/IdUtil.kt b/libsession/src/main/java/org/session/libsession/utilities/IdUtil.kt index 1902b6e0ab..ccaa31c2f9 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/IdUtil.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/IdUtil.kt @@ -1,4 +1,4 @@ package org.session.libsession.utilities fun truncateIdForDisplay(id: String): String = - id.takeIf { it.length > 8 }?.run{ "${take(4)}…${takeLast(4)}" } ?: id + id.takeIf { it.length > 8 }?.apply{ "${take(4)}…${takeLast(4)}" } ?: id 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 6485babe80..e920d85b47 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/ThreadUtils.kt +++ b/libsignal/src/main/java/org/session/libsignal/utilities/ThreadUtils.kt @@ -1,13 +1,11 @@ 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 { @@ -15,16 +13,39 @@ 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) { - queue(target::run) + executorPool.execute { + try { + target.run() + } catch (e: Exception) { + Log.e(TAG, e) + } + } } fun queue(target: () -> Unit) { - Dispatchers.IO.dispatch(EmptyCoroutineContext) { + executorPool.execute { try { target() } catch (e: Exception) {