diff --git a/app/build.gradle b/app/build.gradle index 55dd9bee17..979cb2824c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -368,8 +368,11 @@ dependencies { androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.5.1' androidTestUtil 'androidx.test:orchestrator:1.4.2' - testImplementation 'org.robolectric:robolectric:4.4' - testImplementation 'org.robolectric:shadows-multidex:4.4' + testImplementation 'org.robolectric:robolectric:4.12.2' + testImplementation 'org.robolectric:shadows-multidex:4.12.2' + testImplementation 'org.conscrypt:conscrypt-openjdk-uber:2.5.2' // For Robolectric + testImplementation 'app.cash.turbine:turbine:1.1.0' + implementation 'com.github.bumptech.glide:compose:1.0.0-alpha.5' implementation "androidx.compose.ui:ui:$composeVersion" diff --git a/app/src/main/java/org/thoughtcrime/securesms/AppContext.kt b/app/src/main/java/org/thoughtcrime/securesms/AppContext.kt index b9183939cf..34ab960021 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/AppContext.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/AppContext.kt @@ -5,7 +5,6 @@ import kotlinx.coroutines.asExecutor import nl.komponents.kovenant.Kovenant import nl.komponents.kovenant.jvm.asDispatcher import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.ThreadUtils import java.util.concurrent.Executors object AppContext { diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index ae9d243adc..f84ee3c908 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -216,7 +216,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO DatabaseModule.init(this); MessagingModuleConfiguration.configure(this); super.onCreate(); - messagingModuleConfiguration = new MessagingModuleConfiguration( this, storage, @@ -504,15 +503,23 @@ public class ApplicationContext extends Application implements DefaultLifecycleO }); } + // Method to clear the local data - returns true on success otherwise false + + /** + * Clear all local profile data and message history then restart the app after a brief delay. + * @return true on success, false otherwise. + */ @SuppressLint("ApplySharedPref") - public void clearAllData() { + public boolean clearAllData() { TextSecurePreferences.clearAll(this); getSharedPreferences(PREFERENCES_NAME, 0).edit().clear().commit(); if (!deleteDatabase(SQLCipherOpenHelper.DATABASE_NAME)) { Log.d("Loki", "Failed to delete database."); + return false; } configFactory.keyPairChanged(); Util.runOnMain(() -> new Handler().postDelayed(ApplicationContext.this::restartApplication, 200)); + return true; } public void restartApplication() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.java index 0fd813cf4b..62766d1cd7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.java @@ -114,7 +114,7 @@ class MediaGalleryAdapter extends StickyHeaderGridAdapter { Slide slide = MediaUtil.getSlideForAttachment(context, mediaRecord.getAttachment()); if (slide != null) { - thumbnailView.setImageResource(glideRequests, slide, false, null); + thumbnailView.setImageResource(glideRequests, slide, false); } thumbnailView.setOnClickListener(view -> itemClickListener.onMediaClicked(mediaRecord)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt index e2592d3c3b..f9fd528707 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt @@ -7,10 +7,8 @@ import android.view.View import android.widget.LinearLayout import network.loki.messenger.R import network.loki.messenger.databinding.ViewUserBinding -import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.contacts.Contact import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.mms.GlideRequests diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt index 184869b9ad..8f2da7a733 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt @@ -21,7 +21,6 @@ import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.utilities.ExpirationUtil import org.session.libsession.utilities.modifyLayoutParams import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.LokiAPIDatabase import org.thoughtcrime.securesms.util.DateUtils @@ -78,7 +77,6 @@ class ConversationActionBarView @JvmOverloads constructor( binding.profilePictureView.layoutParams = resources.getDimensionPixelSize( if (recipient.isClosedGroupRecipient) R.dimen.medium_profile_picture_size else R.dimen.small_profile_picture_size ).let { LayoutParams(it, it) } - MentionManagerUtilities.populateUserPublicKeyCacheIfNeeded(threadId, context) update(recipient, openGroup, config) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/AttachmentDownloadHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/AttachmentDownloadHandler.kt new file mode 100644 index 0000000000..7278be71bc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/AttachmentDownloadHandler.kt @@ -0,0 +1,115 @@ +package org.thoughtcrime.securesms.conversation.v2 + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus +import org.session.libsession.database.MessageDataProvider +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.jobs.AttachmentDownloadJob +import org.session.libsession.messaging.jobs.AttachmentUploadJob +import org.session.libsession.messaging.jobs.JobQueue +import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress +import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.util.flatten +import org.thoughtcrime.securesms.util.timedBuffer + +/** + * [AttachmentDownloadHandler] is responsible for handling attachment download requests. These + * requests will go through different level of checking before they are queued for download. + * + * To use this handler, call [onAttachmentDownloadRequest] with the attachment that needs to be + * downloaded. The call to [onAttachmentDownloadRequest] is cheap and can be called multiple times. + */ +class AttachmentDownloadHandler( + private val storage: StorageProtocol, + private val messageDataProvider: MessageDataProvider, + jobQueue: JobQueue = JobQueue.shared, + scope: CoroutineScope = CoroutineScope(Dispatchers.Default) + SupervisorJob(), +) { + companion object { + private const val BUFFER_TIMEOUT_MILLS = 500L + private const val BUFFER_MAX_ITEMS = 10 + private const val LOG_TAG = "AttachmentDownloadHelper" + } + + private val downloadRequests = Channel(UNLIMITED) + + init { + scope.launch(Dispatchers.Default) { + downloadRequests + .receiveAsFlow() + .timedBuffer(BUFFER_TIMEOUT_MILLS, BUFFER_MAX_ITEMS) + .map(::filterEligibleAttachments) + .flatten() + .collect { attachment -> + jobQueue.add( + AttachmentDownloadJob( + attachmentID = attachment.attachmentId.rowId, + databaseMessageID = attachment.mmsId + ) + ) + } + } + } + + /** + * Filter attachments that are eligible for creating download jobs. + * + */ + private fun filterEligibleAttachments(attachments: List): List { + val pendingAttachmentIDs = storage + .getAllPendingJobs(AttachmentDownloadJob.KEY, AttachmentUploadJob.KEY) + .values + .mapNotNull { + (it as? AttachmentUploadJob)?.attachmentID + ?: (it as? AttachmentDownloadJob)?.attachmentID + } + .toSet() + + + return attachments.filter { attachment -> + eligibleForDownloadTask( + attachment, + pendingAttachmentIDs, + ) + } + } + + /** + * Check if the attachment is eligible for download task. + */ + private fun eligibleForDownloadTask( + attachment: DatabaseAttachment, + pendingJobsAttachmentRowIDs: Set, + ): Boolean { + if (attachment.attachmentId.rowId in pendingJobsAttachmentRowIDs) { + return false + } + + val threadID = storage.getThreadIdForMms(attachment.mmsId) + + return AttachmentDownloadJob.eligibleForDownload( + threadID, storage, messageDataProvider, attachment.mmsId, + ) + } + + + fun onAttachmentDownloadRequest(attachment: DatabaseAttachment) { + if (attachment.transferState != AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING) { + Log.i( + LOG_TAG, + "Attachment ${attachment.attachmentId} is not pending, skipping download" + ) + return + } + + downloadRequests.trySend(attachment) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index d252826499..d14df393fc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -29,8 +29,8 @@ import android.view.Menu import android.view.MenuItem import android.view.MotionEvent import android.view.View +import android.view.ViewGroup.LayoutParams import android.view.WindowManager -import android.widget.RelativeLayout import android.widget.Toast import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -46,6 +46,7 @@ import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.loader.app.LoaderManager import androidx.loader.content.Loader import androidx.recyclerview.widget.LinearLayoutManager @@ -67,8 +68,6 @@ import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.jobs.AttachmentDownloadJob import org.session.libsession.messaging.jobs.JobQueue -import org.session.libsession.messaging.mentions.Mention -import org.session.libsession.messaging.mentions.MentionsManager import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.applyExpiryMode import org.session.libsession.messaging.messages.control.DataExtractionNotification @@ -117,7 +116,8 @@ import org.thoughtcrime.securesms.conversation.v2.dialogs.LinkPreviewDialog import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarButton import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarDelegate import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarRecordingViewDelegate -import org.thoughtcrime.securesms.conversation.v2.input_bar.mentions.MentionCandidatesView +import org.thoughtcrime.securesms.conversation.v2.input_bar.mentions.MentionCandidateAdapter +import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallback import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallbackDelegate import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper @@ -215,6 +215,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe @Inject lateinit var storage: Storage @Inject lateinit var reactionDb: ReactionDatabase @Inject lateinit var viewModelFactory: ConversationViewModel.AssistedFactory + @Inject lateinit var mentionViewModelFactory: MentionViewModel.AssistedFactory private val screenshotObserver by lazy { ScreenshotObserver(this, Handler(Looper.getMainLooper())) { @@ -228,7 +229,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe ViewModelProvider(this, LinkPreviewViewModel.Factory(LinkPreviewRepository())) .get(LinkPreviewViewModel::class.java) } - private val viewModel: ConversationViewModel by viewModels { + + private val threadId: Long by lazy { var threadId = intent.getLongExtra(THREAD_ID, -1L) if (threadId == -1L) { intent.getParcelableExtra
(ADDRESS)?.let { it -> @@ -248,6 +250,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } ?: finish() } + + threadId + } + + private val viewModel: ConversationViewModel by viewModels { viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair()) } private var actionMode: ActionMode? = null @@ -260,11 +267,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private var isLockViewExpanded = false private var isShowingAttachmentOptions = false // Mentions - private val mentions = mutableListOf() - private var mentionCandidatesView: MentionCandidatesView? = null - private var previousText: CharSequence = "" - private var currentMentionStartIndex = -1 - private var isShowingMentionCandidatesView = false + private val mentionViewModel: MentionViewModel by viewModels { + mentionViewModelFactory.create(threadId) + } + private val mentionCandidateAdapter = MentionCandidateAdapter { + mentionViewModel.onCandidateSelected(it.member.publicKey) + } // Search val searchViewModel: SearchViewModel by viewModels() var searchViewItem: MenuItem? = null @@ -325,11 +333,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe onDeselect(message, position, it) } }, - onAttachmentNeedsDownload = { attachmentId, mmsId -> - lifecycleScope.launch(Dispatchers.IO) { - JobQueue.shared.add(AttachmentDownloadJob(attachmentId, mmsId)) - } - }, + onAttachmentNeedsDownload = viewModel::onAttachmentDownloadRequest, glide = glide, lifecycleCoroutineScope = lifecycleScope ) @@ -486,6 +490,27 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } } + + setupMentionView() + } + + private fun setupMentionView() { + binding?.conversationMentionCandidates?.let { view -> + view.adapter = mentionCandidateAdapter + view.itemAnimator = null + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + mentionViewModel.autoCompleteState + .collectLatest { state -> + mentionCandidateAdapter.candidates = + (state as? MentionViewModel.AutoCompleteState.Result)?.members.orEmpty() + } + } + } + + binding?.inputBar?.setInputBarEditableFactory(mentionViewModel.editableFactory) } override fun onResume() { @@ -642,23 +667,19 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe binding.inputBar.delegate = this binding.inputBarRecordingView.delegate = this // GIF button - binding.gifButtonContainer.addView(gifButton) - gifButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) + binding.gifButtonContainer.addView(gifButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) gifButton.onUp = { showGIFPicker() } gifButton.snIsEnabled = false // Document button - binding.documentButtonContainer.addView(documentButton) - documentButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) + binding.documentButtonContainer.addView(documentButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) documentButton.onUp = { showDocumentPicker() } documentButton.snIsEnabled = false // Library button - binding.libraryButtonContainer.addView(libraryButton) - libraryButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) + binding.libraryButtonContainer.addView(libraryButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) libraryButton.onUp = { pickFromLibrary() } libraryButton.snIsEnabled = false // Camera button - binding.cameraButtonContainer.addView(cameraButton) - cameraButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) + binding.cameraButtonContainer.addView(cameraButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) cameraButton.onUp = { showCamera() } cameraButton.snIsEnabled = false } @@ -910,7 +931,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (textSecurePreferences.isLinkPreviewsEnabled()) { linkPreviewViewModel.onTextChanged(this, inputBarText, 0, 0) } - showOrHideMentionCandidatesIfNeeded(newContent) if (LinkPreviewUtil.findWhitelistedUrls(newContent.toString()).isNotEmpty() && !textSecurePreferences.isLinkPreviewsEnabled() && !textSecurePreferences.hasSeenLinkPreviewSuggestionDialog()) { LinkPreviewDialog { @@ -922,76 +942,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } - private fun showOrHideMentionCandidatesIfNeeded(text: CharSequence) { - if (text.length < previousText.length) { - currentMentionStartIndex = -1 - hideMentionCandidates() - val mentionsToRemove = mentions.filter { !text.contains(it.displayName) } - mentions.removeAll(mentionsToRemove) - } - if (text.isNotEmpty()) { - val lastCharIndex = text.lastIndex - val lastChar = text[lastCharIndex] - // Check if there is whitespace before the '@' or the '@' is the first character - val isCharacterBeforeLastWhiteSpaceOrStartOfLine: Boolean - if (text.length == 1) { - isCharacterBeforeLastWhiteSpaceOrStartOfLine = true // Start of line - } else { - val charBeforeLast = text[lastCharIndex - 1] - isCharacterBeforeLastWhiteSpaceOrStartOfLine = Character.isWhitespace(charBeforeLast) - } - if (lastChar == '@' && isCharacterBeforeLastWhiteSpaceOrStartOfLine) { - currentMentionStartIndex = lastCharIndex - showOrUpdateMentionCandidatesIfNeeded() - } else if (Character.isWhitespace(lastChar) || lastChar == '@') { // the lastCharacter == "@" is to check for @@ - currentMentionStartIndex = -1 - hideMentionCandidates() - } else if (currentMentionStartIndex != -1) { - val query = text.substring(currentMentionStartIndex + 1) // + 1 to get rid of the "@" - showOrUpdateMentionCandidatesIfNeeded(query) - } - } else { - currentMentionStartIndex = -1 - hideMentionCandidates() - } - previousText = text - } - - private fun showOrUpdateMentionCandidatesIfNeeded(query: String = "") { - val additionalContentContainer = binding?.additionalContentContainer ?: return - val recipient = viewModel.recipient ?: return - if (!isShowingMentionCandidatesView) { - additionalContentContainer.removeAllViews() - val view = MentionCandidatesView(this).apply { - contentDescription = context.getString(R.string.AccessibilityId_mentions_list) - } - view.glide = glide - view.onCandidateSelected = { handleMentionSelected(it) } - additionalContentContainer.addView(view) - val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, recipient.isCommunityRecipient) - this.mentionCandidatesView = view - view.show(candidates, viewModel.threadId) - } else { - val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, recipient.isCommunityRecipient) - this.mentionCandidatesView!!.setMentionCandidates(candidates) - } - isShowingMentionCandidatesView = true - } - - private fun hideMentionCandidates() { - if (isShowingMentionCandidatesView) { - val mentionCandidatesView = mentionCandidatesView ?: return - val animation = ValueAnimator.ofObject(FloatEvaluator(), mentionCandidatesView.alpha, 0.0f) - animation.duration = 250L - animation.addUpdateListener { animator -> - mentionCandidatesView.alpha = animator.animatedValue as Float - if (animator.animatedFraction == 1.0f) { binding?.additionalContentContainer?.removeAllViews() } - } - animation.start() - } - isShowingMentionCandidatesView = false - } - override fun toggleAttachmentOptions() { val targetAlpha = if (isShowingAttachmentOptions) 0.0f else 1.0f val allButtonContainers = listOfNotNull( @@ -1507,18 +1457,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe return hitRect.contains(x, y) } - private fun handleMentionSelected(mention: Mention) { - val binding = binding ?: return - if (currentMentionStartIndex == -1) { return } - mentions.add(mention) - val previousText = binding.inputBar.text - val newText = previousText.substring(0, currentMentionStartIndex) + "@" + mention.displayName + " " - binding.inputBar.text = newText - binding.inputBar.setSelection(newText.length) - currentMentionStartIndex = -1 - hideMentionCandidates() - this.previousText = newText - } override fun scrollToMessageIfPossible(timestamp: Long) { val lastSeenItemPosition = adapter.getItemPositionForTimestamp(timestamp) ?: return @@ -1615,10 +1553,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe binding?.inputBar?.text = "" binding?.inputBar?.cancelQuoteDraft() binding?.inputBar?.cancelLinkPreviewDraft() - // Clear mentions - previousText = "" - currentMentionStartIndex = -1 - mentions.clear() // Put the message in the database message.id = smsDb.insertMessageOutbox(viewModel.threadId, outgoingTextMessage, false, message.sentTimestamp!!, null, true) // Send it @@ -1663,10 +1597,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe binding?.inputBar?.text = "" binding?.inputBar?.cancelQuoteDraft() binding?.inputBar?.cancelLinkPreviewDraft() - // Clear mentions - previousText = "" - currentMentionStartIndex = -1 - mentions.clear() // Reset the attachment manager attachmentManager.clear() // Reset attachments button if needed @@ -1953,7 +1883,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val messageIterator = sortedMessages.iterator() while (messageIterator.hasNext()) { val message = messageIterator.next() - val body = MentionUtilities.highlightMentions(message.body, viewModel.threadId, this) + val body = MentionUtilities.highlightMentions( + text = message.body, + formatOnly = true, // no styling here, only text formatting + threadID = viewModel.threadId, + context = this + ) + if (TextUtils.isEmpty(body)) { continue } if (messageSize > 1) { val formattedTimestamp = DateUtils.getDisplayFormattedTimeSpanString(this, Locale.getDefault(), message.timestamp) @@ -2094,17 +2030,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // region General private fun getMessageBody(): String { - var result = binding?.inputBar?.text?.trim() ?: return "" - for (mention in mentions) { - try { - val startIndex = result.indexOf("@" + mention.displayName) - val endIndex = startIndex + mention.displayName.count() + 1 // + 1 to include the "@" - result = result.substring(0, startIndex) + "@" + mention.publicKey + result.substring(endIndex) - } catch (exception: Exception) { - Log.d("Loki", "Failed to process mention due to error: $exception") - } - } - return result + return mentionViewModel.normalizeMessageBody() } // endregion diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt index d092f15fed..40a089d4f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import network.loki.messenger.R import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.thoughtcrime.securesms.conversation.v2.messages.ControlMessageView import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDelegate @@ -40,7 +41,7 @@ class ConversationAdapter( private val onItemSwipeToReply: (MessageRecord, Int) -> Unit, private val onItemLongPress: (MessageRecord, Int, VisibleMessageView) -> Unit, private val onDeselect: (MessageRecord, Int) -> Unit, - private val onAttachmentNeedsDownload: (Long, Long) -> Unit, + private val onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit, private val glide: GlideRequests, lifecycleCoroutineScope: LifecycleCoroutineScope ) : CursorRecyclerViewAdapter(context, cursor) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index a84724a1d3..cbae0e757f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -1,46 +1,44 @@ package org.thoughtcrime.securesms.conversation.v2 import android.content.Context - import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope - import com.goterl.lazysodium.utils.KeyPair - import dagger.assisted.Assisted import dagger.assisted.AssistedInject - import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch - +import org.session.libsession.database.MessageDataProvider import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.messaging.utilities.AccountId import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.utilities.Address import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.database.MmsDatabase import org.thoughtcrime.securesms.audio.AudioSlidePlayer - import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.repository.ConversationRepository - import java.util.UUID class ConversationViewModel( val threadId: Long, val edKeyPair: KeyPair?, private val repository: ConversationRepository, - private val storage: Storage + private val storage: Storage, + private val messageDataProvider: MessageDataProvider, + database: MmsDatabase, ) : ViewModel() { val showSendAfterApprovalText: Boolean @@ -92,6 +90,11 @@ class ConversationViewModel( // allow reactions if the open group is null (normal conversations) or the open group's capabilities include reactions get() = (openGroup == null || OpenGroupApi.Capability.REACTIONS.name.lowercase() in serverCapabilities) + private val attachmentDownloadHandler = AttachmentDownloadHandler( + storage = storage, + messageDataProvider = messageDataProvider, + scope = viewModelScope, + ) init { viewModelScope.launch(Dispatchers.IO) { @@ -242,7 +245,7 @@ class ConversationViewModel( currentUiState.copy(uiMessages = messages) } } - + fun messageShown(messageId: Long) { _uiState.update { currentUiState -> val messages = currentUiState.uiMessages.filterNot { it.id == messageId } @@ -265,6 +268,10 @@ class ConversationViewModel( storage.getLastLegacyRecipient(address.serialize())?.let { Recipient.from(context, Address.fromSerialized(it), false) } } + fun onAttachmentDownloadRequest(attachment: DatabaseAttachment) { + attachmentDownloadHandler.onAttachmentDownloadRequest(attachment) + } + @dagger.assisted.AssistedFactory interface AssistedFactory { fun create(threadId: Long, edKeyPair: KeyPair?): Factory @@ -275,11 +282,20 @@ class ConversationViewModel( @Assisted private val threadId: Long, @Assisted private val edKeyPair: KeyPair?, private val repository: ConversationRepository, - private val storage: Storage + private val storage: Storage, + private val mmsDatabase: MmsDatabase, + private val messageDataProvider: MessageDataProvider, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { - return ConversationViewModel(threadId, edKeyPair, repository, storage) as T + return ConversationViewModel( + threadId = threadId, + edKeyPair = edKeyPair, + repository = repository, + storage = storage, + messageDataProvider = messageDataProvider, + database = mmsDatabase + ) as T } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt index 11dca06129..d43225b5d7 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 @@ -50,6 +50,7 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import network.loki.messenger.R import network.loki.messenger.databinding.ViewVisibleMessageContentBinding +import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.thoughtcrime.securesms.MediaPreviewActivity.getPreviewIntent import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.database.Storage @@ -146,7 +147,7 @@ fun MessageDetails( onResend: (() -> Unit)? = null, onDelete: () -> Unit = {}, onClickImage: (Int) -> Unit = {}, - onAttachmentNeedsDownload: (Long, Long) -> Unit = { _, _ -> } + onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit = { _ -> } ) { Column( modifier = Modifier diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt index ba153a6b36..fc54b652ae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt @@ -124,7 +124,7 @@ class MessageDetailsViewModel @Inject constructor( if (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED) { // Restart download here (on IO thread) (slide.asAttachment() as? DatabaseAttachment)?.let { attachment -> - onAttachmentNeedsDownload(attachment.attachmentId.rowId, state.mmsRecord.getId()) + onAttachmentNeedsDownload(attachment) } } @@ -137,9 +137,9 @@ class MessageDetailsViewModel @Inject constructor( } } - fun onAttachmentNeedsDownload(attachmentId: Long, mmsId: Long) { + fun onAttachmentNeedsDownload(attachment: DatabaseAttachment) { viewModelScope.launch(Dispatchers.IO) { - JobQueue.shared.add(AttachmentDownloadJob(attachmentId, mmsId)) + JobQueue.shared.add(AttachmentDownloadJob(attachment.attachmentId.rowId, attachment.mmsId)) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt index d76e6f2b3d..57c42a7719 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt @@ -48,7 +48,7 @@ class AlbumThumbnailView : RelativeLayout { // region Interaction - fun calculateHitObject(event: MotionEvent, mms: MmsMessageRecord, threadRecipient: Recipient, onAttachmentNeedsDownload: (Long, Long) -> Unit) { + fun calculateHitObject(event: MotionEvent, mms: MmsMessageRecord, threadRecipient: Recipient, onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit) { val rawXInt = event.rawX.toInt() val rawYInt = event.rawY.toInt() val eventRect = Rect(rawXInt, rawYInt, rawXInt, rawYInt) @@ -63,7 +63,7 @@ class AlbumThumbnailView : RelativeLayout { if (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED) { // Restart download here (on IO thread) (slide.asAttachment() as? DatabaseAttachment)?.let { attachment -> - onAttachmentNeedsDownload(attachment.attachmentId.rowId, mms.getId()) + onAttachmentNeedsDownload(attachment) } } if (slide.isInProgress) return@forEach @@ -104,7 +104,7 @@ class AlbumThumbnailView : RelativeLayout { // iterate binding slides.take(MAX_ALBUM_DISPLAY_SIZE).forEachIndexed { position, slide -> val thumbnailView = getThumbnailView(position) - thumbnailView.setImageResource(glideRequests, slide, isPreview = false, mms = message) + thumbnailView.setImageResource(glideRequests, slide, isPreview = false) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/LinkPreviewDraftView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/LinkPreviewDraftView.kt index 66164f100f..9c414f34fd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/LinkPreviewDraftView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/LinkPreviewDraftView.kt @@ -31,10 +31,10 @@ class LinkPreviewDraftView : LinearLayout { // Hide the loader and show the content view binding.linkPreviewDraftContainer.isVisible = true binding.linkPreviewDraftLoader.isVisible = false - binding.thumbnailImageView.root.radius = toPx(4, resources) + binding.thumbnailImageView.root.setRoundedCorners(toPx(4, resources)) if (linkPreview.getThumbnail().isPresent) { // This internally fetches the thumbnail - binding.thumbnailImageView.root.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), false, null) + binding.thumbnailImageView.root.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), false) } binding.linkPreviewDraftTitleTextView.text = linkPreview.title } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt index f4d359cdf2..d11f9d85ba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.res.Resources import android.graphics.PointF import android.net.Uri +import android.text.Editable import android.text.InputType import android.text.TextWatcher import android.util.AttributeSet @@ -227,8 +228,8 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li binding.inputBarEditText.addTextChangedListener(listener) } - fun setSelection(index: Int) { - binding.inputBarEditText.setSelection(index) + fun setInputBarEditableFactory(factory: Editable.Factory) { + binding.inputBarEditText.setEditableFactory(factory) } // endregion } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateAdapter.kt new file mode 100644 index 0000000000..daed43ce74 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateAdapter.kt @@ -0,0 +1,44 @@ +package org.thoughtcrime.securesms.conversation.v2.input_bar.mentions + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import network.loki.messenger.databinding.ViewMentionCandidateV2Binding +import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel + +class MentionCandidateAdapter( + private val onCandidateSelected: ((MentionViewModel.Candidate) -> Unit) +) : RecyclerView.Adapter() { + var candidates = listOf() + set(newValue) { + if (field != newValue) { + val result = DiffUtil.calculateDiff(object : DiffUtil.Callback() { + override fun getOldListSize() = field.size + override fun getNewListSize() = newValue.size + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) + = field[oldItemPosition].member.publicKey == newValue[newItemPosition].member.publicKey + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) + = field[oldItemPosition] == newValue[newItemPosition] + }) + + field = newValue + result.dispatchUpdatesTo(this) + } + } + + class ViewHolder(val binding: ViewMentionCandidateV2Binding) + : RecyclerView.ViewHolder(binding.root) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return ViewHolder(ViewMentionCandidateV2Binding.inflate(LayoutInflater.from(parent.context), parent, false)) + } + + override fun getItemCount(): Int = candidates.size + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val candidate = candidates[position] + holder.binding.update(candidate) + holder.binding.root.setOnClickListener { onCandidateSelected(candidate) } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt index 2d8f745967..f790e7f1c6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt @@ -1,42 +1,14 @@ package org.thoughtcrime.securesms.conversation.v2.input_bar.mentions -import android.content.Context -import android.util.AttributeSet -import android.view.LayoutInflater import android.view.View -import android.widget.RelativeLayout import network.loki.messenger.databinding.ViewMentionCandidateV2Binding -import org.session.libsession.messaging.mentions.Mention -import org.thoughtcrime.securesms.groups.OpenGroupManager -import org.thoughtcrime.securesms.mms.GlideRequests +import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel -class MentionCandidateView : RelativeLayout { - private lateinit var binding: ViewMentionCandidateV2Binding - var candidate = Mention("", "") - set(newValue) { field = newValue; update() } - var glide: GlideRequests? = null - var openGroupServer: String? = null - var openGroupRoom: String? = null - - constructor(context: Context) : this(context, null) - constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } - - private fun initialize() { - binding = ViewMentionCandidateV2Binding.inflate(LayoutInflater.from(context), this, true) - } - - private fun update() = with(binding) { - mentionCandidateNameTextView.text = candidate.displayName - profilePictureView.publicKey = candidate.publicKey - profilePictureView.displayName = candidate.displayName - profilePictureView.additionalPublicKey = null - profilePictureView.update() - if (openGroupServer != null && openGroupRoom != null) { - val isUserModerator = OpenGroupManager.isUserModerator(context, "$openGroupRoom.$openGroupServer", candidate.publicKey) - moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE - } else { - moderatorIconImageView.visibility = View.GONE - } - } -} \ No newline at end of file +fun ViewMentionCandidateV2Binding.update(candidate: MentionViewModel.Candidate) { + mentionCandidateNameTextView.text = candidate.nameHighlighted + profilePictureView.publicKey = candidate.member.publicKey + profilePictureView.displayName = candidate.member.name + profilePictureView.additionalPublicKey = null + profilePictureView.update() + moderatorIconImageView.visibility = if (candidate.member.isModerator) View.VISIBLE else View.GONE +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidatesView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidatesView.kt deleted file mode 100644 index e62f7f8f85..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidatesView.kt +++ /dev/null @@ -1,90 +0,0 @@ -package org.thoughtcrime.securesms.conversation.v2.input_bar.mentions - -import android.content.Context -import android.util.AttributeSet -import android.view.View -import android.view.ViewGroup -import android.widget.BaseAdapter -import android.widget.ListView -import dagger.hilt.android.AndroidEntryPoint -import network.loki.messenger.R -import org.session.libsession.messaging.mentions.Mention -import org.thoughtcrime.securesms.database.LokiThreadDatabase -import org.thoughtcrime.securesms.mms.GlideRequests -import org.thoughtcrime.securesms.util.toPx -import javax.inject.Inject - -@AndroidEntryPoint -class MentionCandidatesView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : ListView(context, attrs, defStyleAttr) { - private var candidates = listOf() - set(newValue) { field = newValue; snAdapter.candidates = newValue } - var glide: GlideRequests? = null - set(newValue) { field = newValue; snAdapter.glide = newValue } - var openGroupServer: String? = null - set(newValue) { field = newValue; snAdapter.openGroupServer = openGroupServer } - var openGroupRoom: String? = null - set(newValue) { field = newValue; snAdapter.openGroupRoom = openGroupRoom } - var onCandidateSelected: ((Mention) -> Unit)? = null - - @Inject lateinit var threadDb: LokiThreadDatabase - - private val snAdapter by lazy { Adapter(context) } - - private class Adapter(private val context: Context) : BaseAdapter() { - var candidates = listOf() - set(newValue) { field = newValue; notifyDataSetChanged() } - var glide: GlideRequests? = null - var openGroupServer: String? = null - var openGroupRoom: String? = null - - override fun getCount(): Int { return candidates.count() } - override fun getItemId(position: Int): Long { return position.toLong() } - override fun getItem(position: Int): Mention { return candidates[position] } - - override fun getView(position: Int, cellToBeReused: View?, parent: ViewGroup): View { - val cell = cellToBeReused as MentionCandidateView? ?: MentionCandidateView(context).apply { - contentDescription = context.getString(R.string.AccessibilityId_contact) - } - val mentionCandidate = getItem(position) - cell.glide = glide - cell.candidate = mentionCandidate - cell.openGroupServer = openGroupServer - cell.openGroupRoom = openGroupRoom - return cell - } - } - - constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) - constructor(context: Context) : this(context, null) - - init { - clipToOutline = true - adapter = snAdapter - snAdapter.candidates = candidates - setOnItemClickListener { _, _, position, _ -> - onCandidateSelected?.invoke(candidates[position]) - } - } - - fun show(candidates: List, threadID: Long) { - val openGroup = threadDb.getOpenGroupChat(threadID) - if (openGroup != null) { - openGroupServer = openGroup.server - openGroupRoom = openGroup.room - } - setMentionCandidates(candidates) - } - - fun setMentionCandidates(candidates: List) { - this.candidates = candidates - val layoutParams = this.layoutParams as ViewGroup.LayoutParams - layoutParams.height = toPx(Math.min(candidates.count(), 4) * 44, resources) - this.layoutParams = layoutParams - } - - fun hide() { - val layoutParams = this.layoutParams as ViewGroup.LayoutParams - layoutParams.height = 0 - this.layoutParams = layoutParams - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionEditable.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionEditable.kt new file mode 100644 index 0000000000..bc4b068b2a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionEditable.kt @@ -0,0 +1,188 @@ +package org.thoughtcrime.securesms.conversation.v2.mention + +import android.text.Selection +import android.text.SpannableStringBuilder +import androidx.core.text.getSpans +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart + +private const val SEARCH_QUERY_DEBOUNCE_MILLS = 100L + +/** + * A subclass of [SpannableStringBuilder] that provides a way to observe the mention search query, + * and also manages the [MentionSpan] in a way that treats the mention span as a whole. + */ +class MentionEditable : SpannableStringBuilder() { + private val queryChangeNotification = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_LATEST + ) + + fun observeMentionSearchQuery(): Flow { + @Suppress("OPT_IN_USAGE") + return queryChangeNotification + .debounce(SEARCH_QUERY_DEBOUNCE_MILLS) + .onStart { emit(Unit) } + .map { mentionSearchQuery } + .distinctUntilChanged() + } + + data class SearchQuery( + val mentionSymbolStartAt: Int, + val query: String + ) + + val mentionSearchQuery: SearchQuery? + get() { + val cursorPosition = Selection.getSelectionStart(this) + + // First, make sure we are not selecting text + if (cursorPosition != Selection.getSelectionEnd(this)) { + return null + } + + // Make sure we don't already have a mention span at the cursor position + if (getSpans(cursorPosition, cursorPosition, MentionSpan::class.java).isNotEmpty()) { + return null + } + + // Find the mention symbol '@' before the cursor position + val symbolIndex = findEligibleMentionSymbolIndexBefore(cursorPosition - 1) + if (symbolIndex < 0) { + return null + } + + // The query starts after the symbol '@' and ends at a whitespace, @ or the end + val queryStart = symbolIndex + 1 + var queryEnd = indexOfStartingAt(queryStart) { it.isWhitespace() || it == '@' } + if (queryEnd < 0) { + queryEnd = length + } + + return SearchQuery( + mentionSymbolStartAt = symbolIndex, + query = subSequence(queryStart, queryEnd).toString() + ) + } + + override fun setSpan(what: Any?, start: Int, end: Int, flags: Int) { + var normalisedStart = start + var normalisedEnd = end + + val isSelectionStart = what == Selection.SELECTION_START + val isSelectionEnd = what == Selection.SELECTION_END + + if (isSelectionStart || isSelectionEnd) { + assert(start == end) { "Selection spans must have zero length" } + val selection = start + + val mentionSpan = getSpans(selection, selection).firstOrNull() + if (mentionSpan != null) { + val spanStart = getSpanStart(mentionSpan) + val spanEnd = getSpanEnd(mentionSpan) + + if (isSelectionStart && selection != spanEnd) { + // A selection start will only be adjusted to the start of the mention span, + // if the selection start is not at the end the mention span. (A selection start + // at the end of the mention span is considered an escape path from the mention span) + normalisedStart = spanStart + normalisedEnd = normalisedStart + } else if (isSelectionEnd && selection != spanStart) { + normalisedEnd = spanEnd + normalisedStart = normalisedEnd + } + } + + queryChangeNotification.tryEmit(Unit) + } + + super.setSpan(what, normalisedStart, normalisedEnd, flags) + } + + override fun removeSpan(what: Any?) { + super.removeSpan(what) + queryChangeNotification.tryEmit(Unit) + } + + // The only method we need to override + override fun replace(st: Int, en: Int, source: CharSequence?, start: Int, end: Int): MentionEditable { + // Make sure the mention span is treated like a whole + var normalisedStart = st + var normalisedEnd = en + + if (st != en) { + // Find the mention span that intersects with the replaced range, and expand the range to include it, + // this does not apply to insertion operation (st == en) + for (mentionSpan in getSpans(st, en, MentionSpan::class.java)) { + val mentionStart = getSpanStart(mentionSpan) + val mentionEnd = getSpanEnd(mentionSpan) + + if (mentionStart < normalisedStart) { + normalisedStart = mentionStart + } + if (mentionEnd > normalisedEnd) { + normalisedEnd = mentionEnd + } + + removeSpan(mentionSpan) + } + } + + super.replace(normalisedStart, normalisedEnd, source, start, end) + queryChangeNotification.tryEmit(Unit) + return this + } + + fun addMention(member: MentionViewModel.Member, replaceRange: IntRange) { + val replaceWith = "@${member.name} " + replace(replaceRange.first, replaceRange.last, replaceWith) + setSpan( + MentionSpan(member), + replaceRange.first, + replaceRange.first + replaceWith.length - 1, + SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + + override fun delete(st: Int, en: Int) = replace(st, en, "", 0, 0) + + private fun findEligibleMentionSymbolIndexBefore(offset: Int): Int { + if (isEmpty()) { + return -1 + } + + var i = offset.coerceIn(indices) + while (i >= 0) { + val c = get(i) + if (c == '@') { + // Make sure there is no more '@' before this one or it's disqualified + if (i > 0 && get(i - 1) == '@') { + return -1 + } + + return i + } else if (c.isWhitespace()) { + break + } + i-- + } + return -1 + } +} + +private fun CharSequence.indexOfStartingAt(offset: Int, predicate: (Char) -> Boolean): Int { + var i = offset.coerceIn(0..length) + while (i < length) { + if (predicate(get(i))) { + return i + } + i++ + } + + return -1 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionSpan.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionSpan.kt new file mode 100644 index 0000000000..d7fa4d56cd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionSpan.kt @@ -0,0 +1,8 @@ +package org.thoughtcrime.securesms.conversation.v2.mention + +/** + * A span that represents a mention in the text. + */ +class MentionSpan( + val member: MentionViewModel.Member +) \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt new file mode 100644 index 0000000000..d4068a3e6c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt @@ -0,0 +1,274 @@ +package org.thoughtcrime.securesms.conversation.v2.mention + +import android.content.ContentResolver +import android.graphics.Typeface +import android.text.Editable +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.style.StyleSpan +import androidx.core.text.getSpans +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.withContext +import org.session.libsession.messaging.contacts.Contact +import org.thoughtcrime.securesms.database.DatabaseContentProviders.Conversation +import org.thoughtcrime.securesms.database.GroupDatabase +import org.thoughtcrime.securesms.database.GroupMemberDatabase +import org.thoughtcrime.securesms.database.MmsDatabase +import org.thoughtcrime.securesms.database.SessionContactDatabase +import org.thoughtcrime.securesms.database.Storage +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.util.observeChanges + +/** + * A ViewModel that provides the mention search functionality for a text input. + * + * To use this ViewModel, you (a view) will need to: + * 1. Observe the [autoCompleteState] to get the mention search results. + * 2. Set the EditText's editable factory to [editableFactory], via [android.widget.EditText.setEditableFactory] + */ +class MentionViewModel( + threadID: Long, + contentResolver: ContentResolver, + threadDatabase: ThreadDatabase, + groupDatabase: GroupDatabase, + mmsDatabase: MmsDatabase, + contactDatabase: SessionContactDatabase, + memberDatabase: GroupMemberDatabase, + storage: Storage, + dispatcher: CoroutineDispatcher = Dispatchers.IO, +) : ViewModel() { + private val editable = MentionEditable() + + /** + * A factory that creates a new [Editable] instance that is backed by the same source of truth + * used by this viewModel. + */ + val editableFactory = object : Editable.Factory() { + override fun newEditable(source: CharSequence?): Editable { + if (source === editable) { + return source + } + + if (source != null) { + editable.replace(0, editable.length, source) + } + + return editable + } + } + + @Suppress("OPT_IN_USAGE") + private val members: StateFlow?> = + (contentResolver.observeChanges(Conversation.getUriForThread(threadID)) as Flow) + .debounce(500L) + .onStart { emit(Unit) } + .mapLatest { + val recipient = checkNotNull(threadDatabase.getRecipientForThreadId(threadID)) { + "Recipient not found for thread ID: $threadID" + } + + val memberIDs = when { + recipient.isClosedGroupRecipient -> { + groupDatabase.getGroupMemberAddresses(recipient.address.toGroupString(), false) + .map { it.serialize() } + } + + recipient.isCommunityRecipient -> mmsDatabase.getRecentChatMemberIDs(threadID, 20) + recipient.isContactRecipient -> listOf(recipient.address.serialize()) + else -> listOf() + } + + val moderatorIDs = if (recipient.isCommunityRecipient) { + val groupId = storage.getOpenGroup(threadID)?.id + if (groupId.isNullOrBlank()) { + emptySet() + } else { + memberDatabase.getGroupMembersRoles(groupId, memberIDs) + .mapNotNullTo(hashSetOf()) { (memberId, roles) -> + memberId.takeIf { roles.any { it.isModerator } } + } + } + } else { + emptySet() + } + + val contactContext = if (recipient.isCommunityRecipient) { + Contact.ContactContext.OPEN_GROUP + } else { + Contact.ContactContext.REGULAR + } + + contactDatabase.getContacts(memberIDs).map { contact -> + Member( + publicKey = contact.accountID, + name = contact.displayName(contactContext).orEmpty(), + isModerator = contact.accountID 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/LinkPreviewView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt index 9677223894..4e6066edb3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt @@ -41,7 +41,7 @@ class LinkPreviewView : LinearLayout { // Thumbnail if (linkPreview.getThumbnail().isPresent) { // This internally fetches the thumbnail - binding.thumbnailImageView.root.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), isPreview = false, message) + binding.thumbnailImageView.root.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), isPreview = false) binding.thumbnailImageView.root.loadIndicator.isVisible = false } // Title diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt index 17c657561d..77565244a0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt @@ -80,7 +80,15 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet? binding.quoteViewAuthorTextView.text = authorDisplayName binding.quoteViewAuthorTextView.setTextColor(getTextColor(isOutgoingMessage)) // Body - binding.quoteViewBodyTextView.text = if (isOpenGroupInvitation) resources.getString(R.string.open_group_invitation_view__open_group_invitation) else MentionUtilities.highlightMentions((body ?: "").toSpannable(), threadID, context) + binding.quoteViewBodyTextView.text = if (isOpenGroupInvitation) + resources.getString(R.string.open_group_invitation_view__open_group_invitation) + else MentionUtilities.highlightMentions( + text = (body ?: "").toSpannable(), + isOutgoingMessage = isOutgoingMessage, + isQuote = true, + threadID = threadID, + context = context + ) binding.quoteViewBodyTextView.setTextColor(getTextColor(isOutgoingMessage)) // Accent line / attachment preview val hasAttachments = (attachments != null && attachments.asAttachments().isNotEmpty()) && !isOriginalMissing @@ -108,8 +116,9 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet? attachments.thumbnailSlide != null -> { val slide = attachments.thumbnailSlide!! // This internally fetches the thumbnail - binding.quoteViewAttachmentThumbnailImageView.root.radius = toPx(4, resources) - binding.quoteViewAttachmentThumbnailImageView.root.setImageResource(glide, slide, false, null) + binding.quoteViewAttachmentThumbnailImageView + .root.setRoundedCorners(toPx(4, resources)) + binding.quoteViewAttachmentThumbnailImageView.root.setImageResource(glide, slide, false) binding.quoteViewAttachmentThumbnailImageView.root.isVisible = true binding.quoteViewBodyTextView.text = if (MediaUtil.isVideo(slide.asAttachment())) resources.getString(R.string.Slide_video) else resources.getString(R.string.Slide_image) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt index 7e220955d6..83c6904dec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt @@ -66,7 +66,7 @@ class VisibleMessageContentView : ConstraintLayout { thread: Recipient, searchQuery: String? = null, contactIsTrusted: Boolean = true, - onAttachmentNeedsDownload: (Long, Long) -> Unit, + onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit, suppressThumbnails: Boolean = false ) { // Background @@ -135,19 +135,11 @@ class VisibleMessageContentView : ConstraintLayout { if (message is MmsMessageRecord) { message.slideDeck.asAttachments().forEach { attach -> val dbAttachment = attach as? DatabaseAttachment ?: return@forEach - val attachmentId = dbAttachment.attachmentId.rowId - if (attach.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING - && MessagingModuleConfiguration.shared.storage.getAttachmentUploadJob(attachmentId) == null) { - onAttachmentNeedsDownload(attachmentId, dbAttachment.mmsId) - } + onAttachmentNeedsDownload(dbAttachment) } message.linkPreviews.forEach { preview -> val previewThumbnail = preview.getThumbnail().orNull() as? DatabaseAttachment ?: return@forEach - val attachmentId = previewThumbnail.attachmentId.rowId - if (previewThumbnail.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING - && MessagingModuleConfiguration.shared.storage.getAttachmentUploadJob(attachmentId) == null) { - onAttachmentNeedsDownload(attachmentId, previewThumbnail.mmsId) - } + onAttachmentNeedsDownload(previewThumbnail) } } @@ -282,7 +274,12 @@ class VisibleMessageContentView : ConstraintLayout { fun getBodySpans(context: Context, message: MessageRecord, searchQuery: String?): Spannable { var body = message.body.toSpannable() - body = MentionUtilities.highlightMentions(body, message.isOutgoing, message.threadId, context) + body = MentionUtilities.highlightMentions( + text = body, + isOutgoingMessage = message.isOutgoing, + threadID = message.threadId, + context = context + ) body = SearchUtil.getHighlightedSpan(Locale.getDefault(), { BackgroundColorSpan(Color.WHITE) }, body, searchQuery) body = SearchUtil.getHighlightedSpan(Locale.getDefault(), diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt index 0d8f18fa3e..2019867f80 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt @@ -34,6 +34,7 @@ import network.loki.messenger.databinding.ViewstubVisibleMessageMarkerContainerB import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact.ContactContext import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.utilities.Address import org.session.libsession.utilities.ViewUtil import org.session.libsession.utilities.getColorFromAttr @@ -145,7 +146,7 @@ class VisibleMessageView : FrameLayout { senderAccountID: String, lastSeen: Long, delegate: VisibleMessageViewDelegate? = null, - onAttachmentNeedsDownload: (Long, Long) -> Unit, + onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit, lastSentMessageId: Long ) { replyDisabled = message.isOpenGroupInvitation diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionManagerUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionManagerUtilities.kt deleted file mode 100644 index ee1c7257c2..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionManagerUtilities.kt +++ /dev/null @@ -1,34 +0,0 @@ -package org.thoughtcrime.securesms.conversation.v2.utilities - -import android.content.Context -import org.session.libsession.messaging.mentions.MentionsManager -import org.session.libsession.utilities.TextSecurePreferences -import org.thoughtcrime.securesms.database.model.MessageRecord -import org.thoughtcrime.securesms.dependencies.DatabaseComponent - -object MentionManagerUtilities { - - fun populateUserPublicKeyCacheIfNeeded(threadID: Long, context: Context) { - val result = mutableSetOf() - val recipient = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(threadID) ?: return - if (recipient.address.isClosedGroup) { - val members = DatabaseComponent.get(context).groupDatabase().getGroupMembers(recipient.address.toGroupString(), false).map { it.address.serialize() } - result.addAll(members) - } else { - val messageDatabase = DatabaseComponent.get(context).mmsSmsDatabase() - val reader = messageDatabase.readerFor(messageDatabase.getConversation(threadID, true, 0, 200)) - var record: MessageRecord? = reader.next - while (record != null) { - result.add(record.individualRecipient.address.serialize()) - try { - record = reader.next - } catch (exception: Exception) { - record = null - } - } - reader.close() - result.add(TextSecurePreferences.getLocalNumber(context)!!) - } - MentionsManager.userPublicKeyCache[threadID] = result - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt index b1f0039423..efa4e41d54 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt @@ -1,7 +1,7 @@ package org.thoughtcrime.securesms.conversation.v2.utilities -import android.app.Application import android.content.Context +import android.graphics.Color import android.graphics.Typeface import android.text.Spannable import android.text.SpannableString @@ -9,43 +9,60 @@ import android.text.style.BackgroundColorSpan import android.text.style.ForegroundColorSpan import android.text.style.StyleSpan import android.util.Range -import androidx.appcompat.widget.ThemeUtils import androidx.core.content.res.ResourcesCompat import network.loki.messenger.R import nl.komponents.kovenant.combine.Tuple2 import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.ThemeUtil -import org.session.libsignal.utilities.Log +import org.session.libsession.utilities.getColorFromAttr import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.util.UiModeUtilities +import org.thoughtcrime.securesms.util.RoundedBackgroundSpan import org.thoughtcrime.securesms.util.getAccentColor -import org.thoughtcrime.securesms.util.getColorResourceIdFromAttr -import org.thoughtcrime.securesms.util.getMessageTextColourAttr +import org.thoughtcrime.securesms.util.toPx import java.util.regex.Pattern object MentionUtilities { - @JvmStatic - fun highlightMentions(text: CharSequence, threadID: Long, context: Context): String { - return highlightMentions(text, false, threadID, context).toString() // isOutgoingMessage is irrelevant - } + private val pattern by lazy { Pattern.compile("@[0-9a-fA-F]*") } + /** + * Highlights mentions in a given text. + * + * @param text The text to highlight mentions in. + * @param isOutgoingMessage Whether the message is outgoing. + * @param isQuote Whether the message is a quote. + * @param formatOnly Whether to only format the mentions. If true we only format the text itself, + * for example resolving an accountID to a username. If false we also apply styling, like colors and background. + * @param threadID The ID of the thread the message belongs to. + * @param context The context to use. + * @return A SpannableString with highlighted mentions. + */ @JvmStatic - fun highlightMentions(text: CharSequence, isOutgoingMessage: Boolean, threadID: Long, context: Context): SpannableString { + fun highlightMentions( + text: CharSequence, + isOutgoingMessage: Boolean = false, + isQuote: Boolean = false, + formatOnly: Boolean = false, + threadID: Long, + context: Context + ): SpannableString { @Suppress("NAME_SHADOWING") var text = text - val pattern = Pattern.compile("@[0-9a-fA-F]*") + var matcher = pattern.matcher(text) val mentions = mutableListOf, String>>() var startIndex = 0 val userPublicKey = TextSecurePreferences.getLocalNumber(context)!! - val openGroup = DatabaseComponent.get(context).storage().getOpenGroup(threadID) + val openGroup by lazy { DatabaseComponent.get(context).storage().getOpenGroup(threadID) } + + // format the mention text if (matcher.find(startIndex)) { while (true) { val publicKey = text.subSequence(matcher.start() + 1, matcher.end()).toString() // +1 to get rid of the @ - val isUserBlindedPublicKey = openGroup?.let { SodiumUtilities.sessionId(userPublicKey, publicKey, it.publicKey) } ?: false - val userDisplayName: String? = if (publicKey.equals(userPublicKey, ignoreCase = true) || isUserBlindedPublicKey) { + val isYou = isYou(publicKey, userPublicKey, openGroup) + val userDisplayName: String? = if (isYou) { context.getString(R.string.MessageRecord_you) } else { val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithAccountID(publicKey) @@ -53,7 +70,8 @@ object MentionUtilities { contact?.displayName(context) } if (userDisplayName != null) { - text = text.subSequence(0, matcher.start()).toString() + "@" + userDisplayName + text.subSequence(matcher.end(), text.length) + val mention = "@$userDisplayName" + text = text.subSequence(0, matcher.start()).toString() + mention + text.subSequence(matcher.end(), text.length) val endIndex = matcher.start() + 1 + userDisplayName.length startIndex = endIndex mentions.add(Tuple2(Range.create(matcher.start(), endIndex), publicKey)) @@ -66,37 +84,83 @@ object MentionUtilities { } val result = SpannableString(text) - var mentionTextColour: Int? = null - // In dark themes.. - if (ThemeUtil.isDarkTheme(context)) { - // ..we use the standard outgoing message colour for outgoing messages.. - if (isOutgoingMessage) { - val mentionTextColourAttributeId = getMessageTextColourAttr(true) - val mentionTextColourResourceId = getColorResourceIdFromAttr(context, mentionTextColourAttributeId) - mentionTextColour = ResourcesCompat.getColor(context.resources, mentionTextColourResourceId, context.theme) - } - else // ..but we use the accent colour for incoming messages (i.e., someone mentioning us).. - { - mentionTextColour = context.getAccentColor() - } - } - else // ..while in light themes we always just use the incoming or outgoing message text colour for mentions. - { - val mentionTextColourAttributeId = getMessageTextColourAttr(isOutgoingMessage) - val mentionTextColourResourceId = getColorResourceIdFromAttr(context, mentionTextColourAttributeId) - mentionTextColour = ResourcesCompat.getColor(context.resources, mentionTextColourResourceId, context.theme) + // apply styling if required + // Normal text color: black in dark mode and primary text color for light mode + val mainTextColor by lazy { + if (ThemeUtil.isDarkTheme(context)) context.getColor(R.color.black) + else context.getColorFromAttr(android.R.attr.textColorPrimary) } - for (mention in mentions) { - result.setSpan(ForegroundColorSpan(mentionTextColour), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - result.setSpan(StyleSpan(Typeface.BOLD), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + // Highlighted text color: primary/accent in dark mode and primary text color for light mode + val highlightedTextColor by lazy { + if (ThemeUtil.isDarkTheme(context)) context.getAccentColor() + else context.getColorFromAttr(android.R.attr.textColorPrimary) + } - // If we're using a light theme then we change the background colour of the mention to be the accent colour - if (ThemeUtil.isLightTheme(context)) { - val backgroundColour = context.getAccentColor(); - result.setSpan(BackgroundColorSpan(backgroundColour), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + if(!formatOnly) { + for (mention in mentions) { + val backgroundColor: Int? + val foregroundColor: Int? + + // quotes + if(isQuote) { + backgroundColor = null + // the text color has different rule depending if the message is incoming or outgoing + foregroundColor = if(isOutgoingMessage) null else highlightedTextColor + } + // incoming message mentioning you + else if (isYou(mention.second, userPublicKey, openGroup)) { + backgroundColor = context.getAccentColor() + foregroundColor = mainTextColor + } + // outgoing message + else if (isOutgoingMessage) { + backgroundColor = null + foregroundColor = mainTextColor + } + // incoming messages mentioning someone else + else { + backgroundColor = null + // accent color for dark themes and primary text for light + foregroundColor = highlightedTextColor + } + + // apply the background, if any + backgroundColor?.let { background -> + result.setSpan( + RoundedBackgroundSpan( + context = context, + textColor = mainTextColor, + backgroundColor = background + ), + mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + + // apply the foreground, if any + foregroundColor?.let { + result.setSpan( + ForegroundColorSpan(it), + mention.first.lower, + mention.first.upper, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + + // apply bold on the mention + result.setSpan( + StyleSpan(Typeface.BOLD), + mention.first.lower, + mention.first.upper, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) } } return result } + + private fun isYou(mentionedPublicKey: String, userPublicKey: String, openGroup: OpenGroup?): Boolean { + val isUserBlindedPublicKey = openGroup?.let { SodiumUtilities.sessionId(userPublicKey, mentionedPublicKey, it.publicKey) } ?: false + return mentionedPublicKey.equals(userPublicKey, ignoreCase = true) || isUserBlindedPublicKey + } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.kt index 4a9986d6ec..02c683aac6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.kt @@ -2,10 +2,13 @@ package org.thoughtcrime.securesms.conversation.v2.utilities import android.content.Context import android.graphics.Bitmap +import android.graphics.Outline import android.graphics.drawable.Drawable import android.net.Uri import android.util.AttributeSet +import android.util.TypedValue import android.view.View +import android.view.ViewOutlineProvider import android.widget.FrameLayout import androidx.core.view.isVisible import com.bumptech.glide.load.engine.DiskCacheStrategy @@ -21,18 +24,17 @@ import org.session.libsignal.utilities.ListenableFuture import org.session.libsignal.utilities.SettableFuture import org.thoughtcrime.securesms.components.GlideBitmapListeningTarget import org.thoughtcrime.securesms.components.GlideDrawableListeningTarget -import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri import org.thoughtcrime.securesms.mms.GlideRequest import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.Slide -import kotlin.Boolean -import kotlin.Int -import kotlin.getValue -import kotlin.lazy -import kotlin.let -open class ThumbnailView: FrameLayout { +open class ThumbnailView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { + companion object { private const val WIDTH = 0 private const val HEIGHT = 1 @@ -41,30 +43,29 @@ open class ThumbnailView: FrameLayout { private val binding: ThumbnailViewBinding by lazy { ThumbnailViewBinding.bind(this) } // region Lifecycle - constructor(context: Context) : super(context) { initialize(null) } - constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize(attrs) } - constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize(attrs) } val loadIndicator: View by lazy { binding.thumbnailLoadIndicator } private val dimensDelegate = ThumbnailDimensDelegate() private var slide: Slide? = null - var radius: Int = 0 - private fun initialize(attrs: AttributeSet?) { - if (attrs != null) { - val typedArray = context.theme.obtainStyledAttributes(attrs, R.styleable.ThumbnailView, 0, 0) + init { + attrs?.let { context.theme.obtainStyledAttributes(it, R.styleable.ThumbnailView, 0, 0) } + ?.apply { + dimensDelegate.setBounds( + getDimensionPixelSize(R.styleable.ThumbnailView_minWidth, 0), + getDimensionPixelSize(R.styleable.ThumbnailView_minHeight, 0), + getDimensionPixelSize(R.styleable.ThumbnailView_maxWidth, 0), + getDimensionPixelSize(R.styleable.ThumbnailView_maxHeight, 0) + ) - dimensDelegate.setBounds(typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_minWidth, 0), - typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_minHeight, 0), - typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxWidth, 0), - typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxHeight, 0)) + setRoundedCorners( + getDimensionPixelSize(R.styleable.ThumbnailView_thumbnail_radius, 0) + ) - radius = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_thumbnail_radius, 0) - - typedArray.recycle() - } + recycle() + } } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { @@ -84,114 +85,118 @@ open class ThumbnailView: FrameLayout { private fun getDefaultWidth() = maxOf(layoutParams?.width ?: 0, 0) private fun getDefaultHeight() = maxOf(layoutParams?.height ?: 0, 0) + // endregion // region Interaction - fun setImageResource(glide: GlideRequests, slide: Slide, isPreview: Boolean, mms: MmsMessageRecord?): ListenableFuture { - return setImageResource(glide, slide, isPreview, 0, 0, mms) - } - - fun setImageResource(glide: GlideRequests, slide: Slide, - isPreview: Boolean, naturalWidth: Int, - naturalHeight: Int, mms: MmsMessageRecord?): ListenableFuture { - - val currentSlide = this.slide - - binding.playOverlay.isVisible = (slide.thumbnailUri != null && slide.hasPlayOverlay() && - (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE || isPreview)) - - if (equals(currentSlide, slide)) { - // don't re-load slide - return SettableFuture(false) + fun setRoundedCorners(radius: Int){ + // create an outline provider and clip the whole view to that shape + // that way we can round the image and the background ( and any other artifacts that the view may contain ) + val mOutlineProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + // all corners + outline.setRoundRect(0, 0, view.width, view.height, radius.toFloat()) + } } + outlineProvider = mOutlineProvider + clipToOutline = true + } - if (currentSlide != null && currentSlide.fastPreflightId != null && currentSlide.fastPreflightId == slide.fastPreflightId) { - // not reloading slide for fast preflight - this.slide = slide + fun setImageResource( + glide: GlideRequests, + slide: Slide, + isPreview: Boolean + ): ListenableFuture = setImageResource(glide, slide, isPreview, 0, 0) + + fun setImageResource( + glide: GlideRequests, slide: Slide, + isPreview: Boolean, naturalWidth: Int, + naturalHeight: Int + ): ListenableFuture { + binding.playOverlay.isVisible = (slide.thumbnailUri != null && slide.hasPlayOverlay() && + (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE || isPreview)) + + if (equals(this.slide, slide)) { + // don't re-load slide + return SettableFuture(false) } this.slide = slide binding.thumbnailLoadIndicator.isVisible = slide.isInProgress - binding.thumbnailDownloadIcon.isVisible = slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED + binding.thumbnailDownloadIcon.isVisible = + slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED dimensDelegate.setDimens(naturalWidth, naturalHeight) invalidate() - val result = SettableFuture() - - when { - slide.thumbnailUri != null -> { - buildThumbnailGlideRequest(glide, slide).into(GlideDrawableListeningTarget(binding.thumbnailImage, binding.thumbnailLoadIndicator, result)) - } - slide.hasPlaceholder() -> { - buildPlaceholderGlideRequest(glide, slide).into(GlideBitmapListeningTarget(binding.thumbnailImage, null, result)) - } - else -> { - glide.clear(binding.thumbnailImage) - result.set(false) + return SettableFuture().also { + when { + slide.thumbnailUri != null -> { + buildThumbnailGlideRequest(glide, slide).into( + GlideDrawableListeningTarget(binding.thumbnailImage, binding.thumbnailLoadIndicator, it) + ) + } + slide.hasPlaceholder() -> { + buildPlaceholderGlideRequest(glide, slide).into( + GlideBitmapListeningTarget(binding.thumbnailImage, null, it) + ) + } + else -> { + glide.clear(binding.thumbnailImage) + it.set(false) + } } } - return result } - fun buildThumbnailGlideRequest(glide: GlideRequests, slide: Slide): GlideRequest { + private fun buildThumbnailGlideRequest( + glide: GlideRequests, + slide: Slide + ): GlideRequest = glide.load(DecryptableUri(slide.thumbnailUri!!)) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .overrideDimensions() + .transition(DrawableTransitionOptions.withCrossFade()) + .transform(CenterCrop()) + .missingThumbnailPicture(slide.isInProgress) - val dimens = dimensDelegate.resourceSize() - - val request = glide.load(DecryptableUri(slide.thumbnailUri!!)) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .let { request -> - if (dimens[WIDTH] == 0 || dimens[HEIGHT] == 0) { - request.override(getDefaultWidth(), getDefaultHeight()) - } else { - request.override(dimens[WIDTH], dimens[HEIGHT]) - } - } - .transition(DrawableTransitionOptions.withCrossFade()) - .centerCrop() - - return if (slide.isInProgress) request else request.apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture)) - } - - fun buildPlaceholderGlideRequest(glide: GlideRequests, slide: Slide): GlideRequest { - - val dimens = dimensDelegate.resourceSize() - - return glide.asBitmap() - .load(slide.getPlaceholderRes(context.theme)) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .let { request -> - if (dimens[WIDTH] == 0 || dimens[HEIGHT] == 0) { - request.override(getDefaultWidth(), getDefaultHeight()) - } else { - request.override(dimens[WIDTH], dimens[HEIGHT]) - } - } - .fitCenter() - } + private fun buildPlaceholderGlideRequest( + glide: GlideRequests, + slide: Slide + ): GlideRequest = glide.asBitmap() + .load(slide.getPlaceholderRes(context.theme)) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .overrideDimensions() + .fitCenter() open fun clear(glideRequests: GlideRequests) { glideRequests.clear(binding.thumbnailImage) slide = null } - fun setImageResource(glideRequests: GlideRequests, uri: Uri): ListenableFuture { - val future = SettableFuture() + fun setImageResource( + glideRequests: GlideRequests, + uri: Uri + ): ListenableFuture = glideRequests.load(DecryptableUri(uri)) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .transition(DrawableTransitionOptions.withCrossFade()) + .transform(CenterCrop()) + .intoDrawableTargetAsFuture() - var request: GlideRequest = glideRequests.load(DecryptableUri(uri)) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .transition(DrawableTransitionOptions.withCrossFade()) - - request = if (radius > 0) { - request.transforms(CenterCrop(), RoundedCorners(radius)) - } else { - request.transforms(CenterCrop()) + private fun GlideRequest.intoDrawableTargetAsFuture() = + SettableFuture().also { + binding.run { + GlideDrawableListeningTarget(thumbnailImage, thumbnailLoadIndicator, it) + }.let { into(it) } } - request.into(GlideDrawableListeningTarget(binding.thumbnailImage, binding.thumbnailLoadIndicator, future)) + private fun GlideRequest.overrideDimensions() = + dimensDelegate.resourceSize().takeIf { 0 !in it } + ?.let { override(it[WIDTH], it[HEIGHT]) } + ?: override(getDefaultWidth(), getDefaultHeight()) +} - return future - } -} \ No newline at end of file +private fun GlideRequest.missingThumbnailPicture( + inProgress: Boolean +) = takeIf { inProgress } ?: apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupMemberDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/GroupMemberDatabase.kt index ff44ef2c9a..e869f741c7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupMemberDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupMemberDatabase.kt @@ -3,9 +3,12 @@ package org.thoughtcrime.securesms.database import android.content.ContentValues import android.content.Context import android.database.Cursor +import org.json.JSONArray import org.session.libsession.messaging.open_groups.GroupMember import org.session.libsession.messaging.open_groups.GroupMemberRole import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper +import org.thoughtcrime.securesms.util.asSequence +import java.util.EnumSet class GroupMemberDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) { @@ -51,6 +54,19 @@ class GroupMemberDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab return mappings.map { it.role } } + fun getGroupMembersRoles(groupId: String, memberIDs: Collection): Map> { + val sql = """ + SELECT * FROM $TABLE_NAME + WHERE $GROUP_ID = ? AND $PROFILE_ID IN (SELECT value FROM json_each(?)) + """.trimIndent() + + return readableDatabase.rawQuery(sql, groupId, JSONArray(memberIDs).toString()).use { cursor -> + cursor.asSequence() + .map { readGroupMember(it) } + .groupBy(keySelector = { it.profileId }, valueTransform = { it.role }) + } + } + fun setGroupMembers(members: List) { writableDatabase.beginTransaction() try { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt index 5648cdace1..23a1af7ceb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt @@ -218,6 +218,21 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa return cursor } + fun getRecentChatMemberIDs(threadID: Long, limit: Int): List { + val sql = """ + SELECT DISTINCT $ADDRESS FROM $TABLE_NAME + WHERE $THREAD_ID = ? + ORDER BY $DATE_SENT DESC + LIMIT $limit + """.trimIndent() + + return databaseHelper.readableDatabase.rawQuery(sql, threadID).use { cursor -> + cursor.asSequence() + .map { it.getString(0) } + .toList() + } + } + val expireStartedMessages: Reader get() { val where = "$EXPIRE_STARTED > 0" diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt index 65c4fcc51e..c856295ea4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt @@ -4,6 +4,7 @@ import android.content.ContentValues import android.content.Context import android.database.Cursor import androidx.core.database.getStringOrNull +import org.json.JSONArray import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.utilities.AccountId import org.session.libsignal.utilities.Base64 @@ -41,6 +42,15 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da } } + fun getContacts(sessionIDs: Collection): List { + val database = databaseHelper.readableDatabase + return database.getAll( + sessionContactTable, + "$accountID 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 591755b88f..e83c464c7d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.database import android.content.ContentValues import android.content.Context import android.database.Cursor +import org.json.JSONArray import org.session.libsession.messaging.jobs.AttachmentDownloadJob import org.session.libsession.messaging.jobs.AttachmentUploadJob import org.session.libsession.messaging.jobs.BackgroundGroupAddJob @@ -50,14 +51,18 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa databaseHelper.writableDatabase.delete(sessionJobTable, "${Companion.jobID} = ?", arrayOf( jobID )) } - fun getAllJobs(type: String): Map { + fun getAllJobs(vararg types: String): Map { val database = databaseHelper.readableDatabase - return database.getAll(sessionJobTable, "$jobType = ?", arrayOf( type )) { cursor -> + return database.getAll( + sessionJobTable, + "$jobType IN (SELECT value FROM json_each(?))", // Use json_each to bypass limitation of SQLite's IN operator binding + arrayOf( JSONArray(types).toString() ) + ) { cursor -> val jobID = cursor.getString(jobID) try { jobID to jobFromCursor(cursor) } catch (e: Exception) { - Log.e("Loki", "Error deserializing job of type: $type.", e) + Log.e("Loki", "Error deserializing job of type: $types.", e) jobID to null } }.toMap() diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index 82e5b87875..9c66d0d1fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -397,8 +397,8 @@ open class Storage( DatabaseComponent.get(context).sessionJobDatabase().markJobAsFailedPermanently(jobId) } - override fun getAllPendingJobs(type: String): Map { - return DatabaseComponent.get(context).sessionJobDatabase().getAllJobs(type) + override fun getAllPendingJobs(vararg types: String): Map { + return DatabaseComponent.get(context).sessionJobDatabase().getAllJobs(*types) } override fun getAttachmentUploadJob(attachmentID: Long): AttachmentUploadJob? { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt index 2754c70f69..01e1c514ff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt @@ -162,13 +162,7 @@ object OpenGroupManager { val memberDatabase = DatabaseComponent.get(context).groupMemberDatabase() val standardRoles = memberDatabase.getGroupMemberRoles(groupId, standardPublicKey) val blindedRoles = blindedPublicKey?.let { memberDatabase.getGroupMemberRoles(groupId, it) } ?: emptyList() - - // roles to check against - val moderatorRoles = listOf( - GroupMemberRole.MODERATOR, GroupMemberRole.ADMIN, - GroupMemberRole.HIDDEN_MODERATOR, GroupMemberRole.HIDDEN_ADMIN - ) - return standardRoles.any { it in moderatorRoles } || blindedRoles.any { it in moderatorRoles } + return standardRoles.any { it.isModerator } || blindedRoles.any { it.isModerator } } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt index c9896a5b8e..36ea3a4371 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt @@ -103,7 +103,12 @@ class ConversationView : LinearLayout { R.drawable.ic_notifications_mentions } binding.muteIndicatorImageView.setImageResource(drawableRes) - binding.snippetTextView.text = highlightMentions(thread.getSnippet(), thread.threadId, context) + binding.snippetTextView.text = highlightMentions( + text = thread.getSnippet(), + formatOnly = true, // no styling here, only text formatting + threadID = thread.threadId, + context = context + ) binding.snippetTextView.typeface = if (unreadCount > 0 && !thread.isRead) Typeface.DEFAULT_BOLD else Typeface.DEFAULT binding.snippetTextView.visibility = if (isTyping) View.GONE else View.VISIBLE if (isTyping) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaRailAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaRailAdapter.java index dd27c42502..6492069780 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaRailAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaRailAdapter.java @@ -1,5 +1,7 @@ package org.thoughtcrime.securesms.mediapreview; +import static org.thoughtcrime.securesms.util.GeneralUtilitiesKt.toPx; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; @@ -151,6 +153,8 @@ public class MediaRailAdapter extends RecyclerView.Adapter railItemListener.onRailItemClicked(distanceFromActive)); + // set the rounded corners + image.setRoundedCorners(toPx(5, image.getResources())); outline.setVisibility(isActive ? View.VISIBLE : View.GONE); diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt index af3d269c6a..a916d8e4d6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt @@ -39,7 +39,13 @@ class MessageRequestView : LinearLayout { binding.displayNameTextView.text = senderDisplayName binding.timestampTextView.text = DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), thread.date) val rawSnippet = thread.getDisplayBody(context) - val snippet = highlightMentions(rawSnippet, thread.threadId, context) + val snippet = highlightMentions( + text = rawSnippet, + formatOnly = true, // no styling here, only text formatting + threadID = thread.threadId, + context = context + ) + binding.snippetTextView.text = snippet post { diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java index a697f9ec2c..c5f3905b61 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java @@ -56,7 +56,6 @@ import org.session.libsignal.utilities.Util; import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.contacts.ContactUtil; import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2; -import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities; import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities; import org.thoughtcrime.securesms.crypto.KeyPairUtilities; import org.thoughtcrime.securesms.database.LokiThreadDatabase; @@ -348,7 +347,6 @@ public class DefaultMessageNotifier implements MessageNotifier { builder.setThread(notifications.get(0).getRecipient()); builder.setMessageCount(notificationState.getMessageCount()); - MentionManagerUtilities.INSTANCE.populateUserPublicKeyCacheIfNeeded(notifications.get(0).getThreadId(),context); // TODO: Removing highlighting mentions in the notification because this context is the libsession one which // TODO: doesn't have access to the `R.attr.message_sent_text_color` and `R.attr.message_received_text_color` @@ -444,13 +442,30 @@ public class DefaultMessageNotifier implements MessageNotifier { while(iterator.hasPrevious()) { NotificationItem item = iterator.previous(); builder.addMessageBody(item.getIndividualRecipient(), item.getRecipient(), - MentionUtilities.highlightMentions(item.getText(), item.getThreadId(), context)); + MentionUtilities.highlightMentions( + item.getText() != null ? item.getText() : "", + false, + false, + true, // no styling here, only text formatting + item.getThreadId(), + context + ) + ); } if (signal) { builder.setAlarms(notificationState.getRingtone(context), notificationState.getVibrate()); + CharSequence text = notifications.get(0).getText(); builder.setTicker(notifications.get(0).getIndividualRecipient(), - MentionUtilities.highlightMentions(notifications.get(0).getText(), notifications.get(0).getThreadId(), context)); + MentionUtilities.highlightMentions( + text != null ? text : "", + false, + false, + true, // no styling here, only text formatting + notifications.get(0).getThreadId(), + context + ) + ); } builder.putStringExtra(LATEST_MESSAGE_ID_TAG, messageIdTag); diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt index 008efbfe42..17d97dec7b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt @@ -4,12 +4,13 @@ import android.app.Dialog import android.os.Bundle import android.view.LayoutInflater import android.view.View -import androidx.core.view.isGone +import android.widget.Toast import androidx.core.view.isVisible import androidx.fragment.app.DialogFragment import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DividerItemDecoration import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -24,17 +25,26 @@ import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities class ClearAllDataDialog : DialogFragment() { + private val TAG = "ClearAllDataDialog" + private lateinit var binding: DialogClearAllDataBinding - enum class Steps { + private enum class Steps { INFO_PROMPT, NETWORK_PROMPT, - DELETING + DELETING, + RETRY_LOCAL_DELETE_ONLY_PROMPT } - var clearJob: Job? = null + // Rather than passing a bool around we'll use an enum to clarify our intent + private enum class DeletionScope { + DeleteLocalDataOnly, + DeleteBothLocalAndNetworkData + } - var step = Steps.INFO_PROMPT + private var clearJob: Job? = null + + private var step = Steps.INFO_PROMPT set(value) { field = value updateUI() @@ -46,8 +56,8 @@ class ClearAllDataDialog : DialogFragment() { private fun createView(): View { binding = DialogClearAllDataBinding.inflate(LayoutInflater.from(requireContext())) - val device = radioOption("deviceOnly", R.string.dialog_clear_all_data_clear_device_only) - val network = radioOption("deviceAndNetwork", R.string.dialog_clear_all_data_clear_device_and_network) + val device = radioOption("deviceOnly", R.string.clearDeviceOnly) + val network = radioOption("deviceAndNetwork", R.string.clearDeviceAndNetwork) var selectedOption: RadioOption = device val optionAdapter = RadioOptionAdapter { selectedOption = it } binding.recyclerView.apply { @@ -57,18 +67,21 @@ class ClearAllDataDialog : DialogFragment() { setHasFixedSize(true) } optionAdapter.submitList(listOf(device, network)) + binding.cancelButton.setOnClickListener { dismiss() } + binding.clearAllDataButton.setOnClickListener { - when(step) { + when (step) { Steps.INFO_PROMPT -> if (selectedOption == network) { step = Steps.NETWORK_PROMPT } else { - clearAllData(false) + clearAllData(DeletionScope.DeleteLocalDataOnly) } - Steps.NETWORK_PROMPT -> clearAllData(true) + Steps.NETWORK_PROMPT -> clearAllData(DeletionScope.DeleteBothLocalAndNetworkData) Steps.DELETING -> { /* do nothing intentionally */ } + Steps.RETRY_LOCAL_DELETE_ONLY_PROMPT -> clearAllData(DeletionScope.DeleteLocalDataOnly) } } return binding.root @@ -86,8 +99,13 @@ class ClearAllDataDialog : DialogFragment() { binding.dialogDescriptionText.setText(R.string.dialog_clear_all_data_clear_device_and_network_confirmation) } Steps.DELETING -> { /* do nothing intentionally */ } + Steps.RETRY_LOCAL_DELETE_ONLY_PROMPT -> { + binding.dialogDescriptionText.setText(R.string.clearDataErrorDescriptionGeneric) + binding.clearAllDataButton.text = getString(R.string.clearDevice) + } } - binding.recyclerView.isGone = step == Steps.NETWORK_PROMPT + + binding.recyclerView.isVisible = step == Steps.INFO_PROMPT binding.cancelButton.isVisible = !isLoading binding.clearAllDataButton.isVisible = !isLoading binding.progressBar.isVisible = isLoading @@ -97,45 +115,55 @@ class ClearAllDataDialog : DialogFragment() { } } - private fun clearAllData(deleteNetworkMessages: Boolean) { - clearJob = lifecycleScope.launch(Dispatchers.IO) { - val previousStep = step - withContext(Dispatchers.Main) { - step = Steps.DELETING + private suspend fun performDeleteLocalDataOnlyStep() { + try { + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(requireContext()).get() + } catch (e: Exception) { + Log.e(TAG, "Failed to force sync when deleting data", e) + withContext(Main) { + Toast.makeText(ApplicationContext.getInstance(requireContext()), R.string.errorUnknown, Toast.LENGTH_LONG).show() } - - if (!deleteNetworkMessages) { - try { - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(requireContext()).get() - } catch (e: Exception) { - Log.e("Loki", "Failed to force sync", e) - } - ApplicationContext.getInstance(context).clearAllData() - withContext(Dispatchers.Main) { + return + } + ApplicationContext.getInstance(context).clearAllData().let { success -> + withContext(Main) { + if (success) { dismiss() + } else { + Toast.makeText(ApplicationContext.getInstance(requireContext()), R.string.errorUnknown, Toast.LENGTH_LONG).show() } - } else { - // finish - val result = try { - val openGroups = DatabaseComponent.get(requireContext()).lokiThreadDatabase().getAllOpenGroups() - openGroups.map { it.value.server }.toSet().forEach { server -> - OpenGroupApi.deleteAllInboxMessages(server).get() - } - SnodeAPI.deleteAllMessages().get() - } catch (e: Exception) { - null - } + } + } + } - if (result == null || result.values.any { !it } || result.isEmpty()) { - // didn't succeed (at least one) - withContext(Dispatchers.Main) { - step = previousStep + private fun clearAllData(deletionScope: DeletionScope) { + step = Steps.DELETING + + clearJob = lifecycleScope.launch(Dispatchers.IO) { + when (deletionScope) { + DeletionScope.DeleteLocalDataOnly -> { + performDeleteLocalDataOnlyStep() + } + DeletionScope.DeleteBothLocalAndNetworkData -> { + val deletionResultMap: Map? = try { + val openGroups = DatabaseComponent.get(requireContext()).lokiThreadDatabase().getAllOpenGroups() + openGroups.map { it.value.server }.toSet().forEach { server -> + OpenGroupApi.deleteAllInboxMessages(server).get() + } + SnodeAPI.deleteAllMessages().get() + } catch (e: Exception) { + Log.e(TAG, "Failed to delete network messages - offering user option to delete local data only.", e) + null } - } else if (result.values.all { it }) { - // don't force sync because all the messages are deleted? - ApplicationContext.getInstance(context).clearAllData() - withContext(Dispatchers.Main) { - dismiss() + + // If one or more deletions failed then inform the user and allow them to clear the device only if they wish.. + if (deletionResultMap == null || deletionResultMap.values.any { !it } || deletionResultMap.isEmpty()) { + withContext(Main) { step = Steps.RETRY_LOCAL_DELETE_ONLY_PROMPT } + } + else if (deletionResultMap.values.all { it }) { + // ..otherwise if the network data deletion was successful proceed to delete the local data as well. + ApplicationContext.getInstance(context).clearAllData() + withContext(Main) { dismiss() } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FlowUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/FlowUtils.kt new file mode 100644 index 0000000000..e5b35b8931 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FlowUtils.kt @@ -0,0 +1,44 @@ +package org.thoughtcrime.securesms.util + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.flatMapConcat + +/** + * Buffers items from the flow and emits them in batches. The batch will have size [maxItems] and + * time [timeoutMillis] limit. + */ +fun Flow.timedBuffer(timeoutMillis: Long, maxItems: Int): Flow> { + return channelFlow { + val buffer = mutableListOf() + var bufferBeganAt = -1L + + collectLatest { value -> + if (buffer.isEmpty()) { + bufferBeganAt = System.currentTimeMillis() + } + + buffer.add(value) + + if (buffer.size < maxItems) { + // If the buffer is not full, wait until the time limit is reached. + // The delay here, as a suspension point, will be cancelled by `collectLatest`, + // if another item is collected while we are waiting for the `delay` to complete. + // Once the delay is cancelled, another round of `collectLatest` will be restarted. + delay((System.currentTimeMillis() + timeoutMillis - bufferBeganAt).coerceAtLeast(0L)) + } + + // When we reach here, it's either the buffer is full, or the timeout has been reached: + // send out the buffer and reset the state + send(buffer.toList()) + buffer.clear() + } + } +} + +@OptIn(ExperimentalCoroutinesApi::class) +fun Flow>.flatten(): Flow = flatMapConcat { it.asFlow() } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RoundedBackgroundSpan.kt b/app/src/main/java/org/thoughtcrime/securesms/util/RoundedBackgroundSpan.kt new file mode 100644 index 0000000000..ebefc9c50c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/RoundedBackgroundSpan.kt @@ -0,0 +1,61 @@ +package org.thoughtcrime.securesms.util + +import android.content.Context +import android.content.res.Resources +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.text.style.ReplacementSpan + +/** + * A Span that draws text with a rounded background. + * + * @param textColor - The color of the text. + * @param backgroundColor - The color of the background. + * @param cornerRadius - The corner radius of the background in pixels. Defaults to 8dp. + * @param paddingHorizontal - The horizontal padding of the text in pixels. Defaults to 3dp. + * @param paddingVertical - The vertical padding of the text in pixels. Defaults to 3dp. + */ + + +class RoundedBackgroundSpan( + context: Context, + private val textColor: Int, + private val backgroundColor: Int, + private val cornerRadius: Float = toPx(8, context.resources).toFloat(), // setting some Session defaults + private val paddingHorizontal: Float = toPx(3, context.resources).toFloat(), + private val paddingVertical: Float = toPx(3, context.resources).toFloat() +) : ReplacementSpan() { + + override fun draw( + canvas: Canvas, text: CharSequence, start: Int, end: Int, + x: Float, top: Int, y: Int, bottom: Int, paint: Paint + ) { + // the top needs to take into account the font and the required vertical padding + val newTop = y + paint.fontMetrics.ascent - paddingVertical + val newBottom = y + paint.fontMetrics.descent + paddingVertical + val rect = RectF( + x, + newTop, + x + measureText(paint, text, start, end) + 2 * paddingHorizontal, + newBottom + ) + paint.color = backgroundColor + + canvas.drawRoundRect(rect, cornerRadius, cornerRadius, paint) + paint.color = textColor + canvas.drawText(text, start, end, x + paddingHorizontal, y.toFloat(), paint) + } + + override fun getSize( + paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt? + ): Int { + return (paint.measureText(text, start, end) + 2 * paddingHorizontal).toInt() + } + + private fun measureText( + paint: Paint, text: CharSequence, start: Int, end: Int + ): Float { + return paint.measureText(text, start, end) + } +} diff --git a/app/src/main/res/layout/activity_conversation_v2.xml b/app/src/main/res/layout/activity_conversation_v2.xml index 23d5f97e55..91c60f2126 100644 --- a/app/src/main/res/layout/activity_conversation_v2.xml +++ b/app/src/main/res/layout/activity_conversation_v2.xml @@ -1,5 +1,5 @@ - @@ -31,9 +34,11 @@ android:focusable="false" android:id="@+id/conversationRecyclerView" android:layout_width="match_parent" - android:layout_height="match_parent" - android:layout_above="@+id/typingIndicatorViewContainer" - android:layout_below="@id/toolbar" /> + android:layout_height="0dp" + app:layout_constraintVertical_weight="1" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintBottom_toTopOf="@+id/typingIndicatorViewContainer" + app:layout_constraintTop_toBottomOf="@id/toolbar" /> + tools:layout_height="60dp" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/messageRequestBar" + app:layout_constraintBottom_toBottomOf="parent" + /> - + tools:visibility="gone" + app:layout_constraintHeight_max="176dp" + app:layout_constraintBottom_toBottomOf="@+id/conversationRecyclerView" /> - - - - + tools:text="You'll be able to send" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/typingIndicatorViewContainer" + app:layout_constraintBottom_toTopOf="@+id/messageRequestBar" /> @@ -197,14 +220,14 @@ android:layout_height="wrap_content" android:layout_marginBottom="-12dp" android:visibility="gone" - android:layout_alignParentBottom="true" /> + app:layout_constraintBottom_toBottomOf="parent" /> - @@ -214,20 +237,20 @@ android:contentDescription="@string/AccessibilityId_blocked_banner_text" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_centerInParent="true" + android:layout_gravity="center" android:layout_margin="@dimen/medium_spacing" android:textColor="@color/white" android:textSize="@dimen/small_font_size" android:textStyle="bold" tools:text="Elon is blocked. Unblock them?" /> - + - @@ -237,14 +260,14 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:gravity="center_horizontal" - android:layout_centerInParent="true" + android:layout_gravity="center" android:layout_marginVertical="@dimen/very_small_spacing" android:layout_marginHorizontal="@dimen/medium_spacing" android:textColor="@color/black" android:textSize="@dimen/tiny_font_size" tools:text="This user's client is outdated, things may not work as expected" /> - + + tools:visibility="gone"> - + diff --git a/app/src/main/res/layout/album_thumbnail_1.xml b/app/src/main/res/layout/album_thumbnail_1.xml index cee81ba3e3..2f3ffdaa79 100644 --- a/app/src/main/res/layout/album_thumbnail_1.xml +++ b/app/src/main/res/layout/album_thumbnail_1.xml @@ -9,11 +9,6 @@ + android:layout_height="match_parent"/> \ No newline at end of file diff --git a/app/src/main/res/layout/album_thumbnail_2.xml b/app/src/main/res/layout/album_thumbnail_2.xml index 52375d025c..1712bdb741 100644 --- a/app/src/main/res/layout/album_thumbnail_2.xml +++ b/app/src/main/res/layout/album_thumbnail_2.xml @@ -10,14 +10,12 @@ + android:layout_height="@dimen/album_2_total_height"/> + android:layout_gravity="end"/> \ No newline at end of file diff --git a/app/src/main/res/layout/album_thumbnail_3.xml b/app/src/main/res/layout/album_thumbnail_3.xml index b408ffd2bc..d201565dab 100644 --- a/app/src/main/res/layout/album_thumbnail_3.xml +++ b/app/src/main/res/layout/album_thumbnail_3.xml @@ -9,15 +9,13 @@ + android:layout_height="@dimen/album_3_total_height"/> + android:layout_gravity="end|top"/> + android:layout_gravity="center_horizontal|bottom"/> + android:layout_gravity="center"/> - + xmlns:tools="http://schemas.android.com/tools"> diff --git a/app/src/main/res/values-fa-rIR/strings.xml b/app/src/main/res/values-fa-rIR/strings.xml index 6c630074ea..788062f67d 100644 --- a/app/src/main/res/values-fa-rIR/strings.xml +++ b/app/src/main/res/values-fa-rIR/strings.xml @@ -646,8 +646,6 @@ این گزینه به طور دائم پیام‌ها، جلسات و مخاطبین شما را حذف می‌کند. آیا فقط می‌خواهید این دستگاه را پاک کنید یا می‌خواهید کل اکانت را پاک کنید؟ این کار پیام‌ها و مخاطبین شما را برای همیشه حذف می‌کند. آیا می‌خواهید فقط این دستگاه را پاک کنید یا داتا خود را از شبکه نیز حذف کنید? - فقط پاک کردن دستگاه - پاک کردن دستگاه و شبکه آیا مطمئن هستید که می خواهید داتا های خود را از شبکه حذف کنید؟ اگر ادامه دهید، نمی‌توانید پیام‌ها یا مخاطبین خود را بازیابی کنید. پاک فقط حذف شود diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 8a032d81c6..7288b145a4 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -649,8 +649,6 @@ Cela supprimera définitivement vos messages, vos sessions et vos contacts. Souhaitez-vous effacer seulement cet appareil ou supprimer l\'ensemble de votre compte ? Cela supprimera définitivement vos messages, sessions et contacts. Voulez-vous uniquement effacer cet appareil ou supprimer l\'intégralité de votre compte ? - Effacer l\'appareil uniquement - Effacer l\'appareil et le réseau Êtes-vous sûr de vouloir supprimer vos données du réseau ? Si vous continuez, vous ne pourrez pas restaurer vos messages ou vos contacts. Effacer Effacer seulement diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 8a032d81c6..7288b145a4 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -649,8 +649,6 @@ Cela supprimera définitivement vos messages, vos sessions et vos contacts. Souhaitez-vous effacer seulement cet appareil ou supprimer l\'ensemble de votre compte ? Cela supprimera définitivement vos messages, sessions et contacts. Voulez-vous uniquement effacer cet appareil ou supprimer l\'intégralité de votre compte ? - Effacer l\'appareil uniquement - Effacer l\'appareil et le réseau Êtes-vous sûr de vouloir supprimer vos données du réseau ? Si vous continuez, vous ne pourrez pas restaurer vos messages ou vos contacts. Effacer Effacer seulement diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a6004f4837..cb27192dee 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -822,8 +822,6 @@ This will permanently delete your messages, sessions, and contacts. Would you like to clear only this device, or delete your entire account? This will permanently delete your messages, sessions, and contacts. Would you like to clear only this device, or delete your entire account? - Clear Device Only - Clear Device and Network Are you sure you want to delete your data from the network? If you continue you will not be able to restore your messages or contacts. Clear Delete Only diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt index 37303e29d5..7f4db828e4 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt @@ -23,6 +23,7 @@ import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.BaseViewModelTest import org.thoughtcrime.securesms.NoOpLogger +import org.thoughtcrime.securesms.database.MmsDatabase import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.repository.ConversationRepository @@ -32,6 +33,7 @@ class ConversationViewModelTest: BaseViewModelTest() { private val repository = mock() private val storage = mock() + private val mmsDatabase = mock() private val threadId = 123L private val edKeyPair = mock() @@ -39,7 +41,7 @@ class ConversationViewModelTest: BaseViewModelTest() { private lateinit var messageRecord: MessageRecord private val viewModel: ConversationViewModel by lazy { - ConversationViewModel(threadId, edKeyPair, repository, storage) + ConversationViewModel(threadId, edKeyPair, repository, storage, mock(), mmsDatabase) } @Before diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/MentionEditableTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/MentionEditableTest.kt new file mode 100644 index 0000000000..8ae4cb43bb --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/MentionEditableTest.kt @@ -0,0 +1,115 @@ +package org.thoughtcrime.securesms.conversation.v2 + +import android.text.Editable +import android.text.Selection +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.thoughtcrime.securesms.conversation.v2.mention.MentionEditable +import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel + +@RunWith(RobolectricTestRunner::class) +class MentionEditableTest { + private lateinit var mentionEditable: MentionEditable + + @Before + fun setUp() { + mentionEditable = MentionEditable() + } + + @Test + fun `should not have query when there is no 'at' symbol`() = runTest { + mentionEditable.observeMentionSearchQuery().test { + assertThat(awaitItem()).isNull() + mentionEditable.simulateTyping("Some text") + expectNoEvents() + } + } + + @Test + fun `should have empty query after typing 'at' symbol`() = runTest { + mentionEditable.observeMentionSearchQuery().test { + assertThat(awaitItem()).isNull() + + mentionEditable.simulateTyping("Some text") + expectNoEvents() + + mentionEditable.simulateTyping("@") + assertThat(awaitItem()) + .isEqualTo(MentionEditable.SearchQuery(9, "")) + } + } + + @Test + fun `should have some query after typing words following 'at' symbol`() = runTest { + mentionEditable.observeMentionSearchQuery().test { + assertThat(awaitItem()).isNull() + + mentionEditable.simulateTyping("Some text") + expectNoEvents() + + mentionEditable.simulateTyping("@words") + assertThat(awaitItem()) + .isEqualTo(MentionEditable.SearchQuery(9, "words")) + } + } + + @Test + fun `should cancel query after a whitespace or another 'at' is typed`() = runTest { + mentionEditable.observeMentionSearchQuery().test { + assertThat(awaitItem()).isNull() + + mentionEditable.simulateTyping("@words") + assertThat(awaitItem()) + .isEqualTo(MentionEditable.SearchQuery(0, "words")) + + mentionEditable.simulateTyping(" ") + assertThat(awaitItem()) + .isNull() + + mentionEditable.simulateTyping("@query@") + assertThat(awaitItem()) + .isEqualTo(MentionEditable.SearchQuery(13, "")) + } + } + + @Test + fun `should move pass the whole span while moving cursor around mentioned block `() { + mentionEditable.append("Mention @user here") + mentionEditable.addMention(MentionViewModel.Member("user", "User", false), 8..14) + + // Put cursor right before @user, it should then select nothing + Selection.setSelection(mentionEditable, 8) + assertThat(mentionEditable.selection()).isEqualTo(intArrayOf(8, 8)) + + // Put cursor right after '@', it should then select the whole @user + Selection.setSelection(mentionEditable, 9) + assertThat(mentionEditable.selection()).isEqualTo(intArrayOf(8, 13)) + + // Put cursor right after @user, it should then select nothing + Selection.setSelection(mentionEditable, 13) + assertThat(mentionEditable.selection()).isEqualTo(intArrayOf(13, 13)) + } + + @Test + fun `should delete the whole mention block while deleting only part of it`() { + mentionEditable.append("Mention @user here") + mentionEditable.addMention(MentionViewModel.Member("user", "User", false), 8..14) + + mentionEditable.delete(8, 9) + assertThat(mentionEditable.toString()).isEqualTo("Mention here") + } +} + +private fun CharSequence.selection(): IntArray { + return intArrayOf(Selection.getSelectionStart(this), Selection.getSelectionEnd(this)) +} + +private fun Editable.simulateTyping(text: String) { + this.append(text) + Selection.setSelection(this, this.length) +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/MentionViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/MentionViewModelTest.kt new file mode 100644 index 0000000000..33addc3aab --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/MentionViewModelTest.kt @@ -0,0 +1,184 @@ +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.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.accountID 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 new file mode 100644 index 0000000000..f089586de9 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/util/FlowUtilsTest.kt @@ -0,0 +1,52 @@ +package org.thoughtcrime.securesms.util + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.toCollection +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +class FlowUtilsTest { + + @Test + fun `timedBuffer should emit buffer when it's full`() = runTest { + // Given + val flow = flowOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10) + val timeoutMillis = 1000L + val maxItems = 5 + + // When + val result = flow.timedBuffer(timeoutMillis, maxItems).toList() + + // Then + assertEquals(2, result.size) + assertEquals(listOf(1, 2, 3, 4, 5), result[0]) + assertEquals(listOf(6, 7, 8, 9, 10), result[1]) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `timedBuffer should emit buffer when timeout expires`() = runTest { + // Given + val flow = flow { + emit(1) + emit(2) + emit(3) + testScheduler.advanceTimeBy(200L) + emit(4) + } + val timeoutMillis = 100L + val maxItems = 5 + + // When + val result = flow.timedBuffer(timeoutMillis, maxItems).toList() + + // Then + assertEquals(2, result.size) + assertEquals(listOf(1, 2, 3), result[0]) + assertEquals(listOf(4), result[1]) + } +} \ No newline at end of file diff --git a/app/src/test/resources/TestAndroidManifest.xml b/app/src/test/resources/TestAndroidManifest.xml new file mode 100644 index 0000000000..afc09b82b7 --- /dev/null +++ b/app/src/test/resources/TestAndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/test/resources/robolectric.properties b/app/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..1ec9805b2a --- /dev/null +++ b/app/src/test/resources/robolectric.properties @@ -0,0 +1,3 @@ +manifest=TestAndroidManifest.xml +sdk=34 +application=android.app.Application \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt b/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt index ed764fd1f6..581cc51570 100644 --- a/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt +++ b/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt @@ -53,7 +53,7 @@ interface StorageProtocol { fun persistJob(job: Job) fun markJobAsSucceeded(jobId: String) fun markJobAsFailedPermanently(jobId: String) - fun getAllPendingJobs(type: String): Map + fun getAllPendingJobs(vararg types: String): Map fun getAttachmentUploadJob(attachmentID: Long): AttachmentUploadJob? fun getMessageSendJob(messageSendJobID: String): MessageSendJob? fun getMessageReceiveJob(messageReceiveJobID: String): Job? diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt index 610a8bae31..25bfeea406 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt @@ -1,6 +1,8 @@ package org.session.libsession.messaging.jobs import okhttp3.HttpUrl +import org.session.libsession.database.MessageDataProvider +import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId @@ -40,6 +42,36 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) // Keys used for database storage private val ATTACHMENT_ID_KEY = "attachment_id" private val TS_INCOMING_MESSAGE_ID_KEY = "tsIncoming_message_id" + + /** + * Check if the attachment in the given message is eligible for download. + * + * Note that this function only checks for the eligibility of the attachment in the sense + * of whether the download is allowed, it does not check if the download has already taken + * place. + */ + fun eligibleForDownload(threadID: Long, + storage: StorageProtocol, + messageDataProvider: MessageDataProvider, + databaseMessageID: Long): Boolean { + val threadRecipient = storage.getRecipientForThread(threadID) ?: return false + + // if we are the sender we are always eligible + val selfSend = messageDataProvider.isMmsOutgoing(databaseMessageID) + if (selfSend) { + return true + } + + // you can't be eligible without a sender + val sender = messageDataProvider.getIndividualRecipientForMms(databaseMessageID)?.address?.serialize() + ?: return false + + // you can't be eligible without a contact entry + val contact = storage.getContactWithAccountID(sender) ?: return false + + // we are eligible if we are receiving a group message or the contact is trusted + return threadRecipient.isGroupRecipient || contact.isTrusted + } } override suspend fun execute(dispatcherName: String) { @@ -88,21 +120,7 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) return } - val threadRecipient = storage.getRecipientForThread(threadID) - val selfSend = messageDataProvider.isMmsOutgoing(databaseMessageID) - val sender = if (selfSend) { - storage.getUserPublicKey() - } else { - messageDataProvider.getIndividualRecipientForMms(databaseMessageID)?.address?.serialize() - } - val contact = sender?.let { storage.getContactWithAccountID(it) } - if (threadRecipient == null || sender == null || (contact == null && !selfSend)) { - handleFailure(Error.NoSender, null) - return - } - if (!threadRecipient.isGroupRecipient && contact?.isTrusted != true && storage.getUserPublicKey() != sender) { - // if we aren't receiving a group message, a message from ourselves (self-send) and the contact sending is not trusted: - // do not continue, but do not fail + if (!eligibleForDownload(threadID, storage, messageDataProvider, databaseMessageID)) { handleFailure(Error.NoSender, null) return } diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/ConfigurationSyncJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/ConfigurationSyncJob.kt index 241853df55..d7dfdf768e 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/ConfigurationSyncJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/ConfigurationSyncJob.kt @@ -61,7 +61,7 @@ data class ConfigurationSyncJob(val destination: Destination): Job { SharedConfigurationMessage(config.protoKindFor(), data, seqNo) to config }.map { (message, config) -> // return a list of batch request objects - val snodeMessage = MessageSender.buildWrappedMessageToSnode(destination, message, true) + val snodeMessage = MessageSender.buildConfigMessageToSnode(destination.destinationPublicKey(), message) val authenticated = SnodeAPI.buildAuthenticatedStoreBatchInfo( destination.destinationPublicKey(), config.configNamespace(), diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt index 71d62bc72f..1450168ea9 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt @@ -102,7 +102,7 @@ class JobQueue : JobDelegate { execute(dispatcherName) } catch (e: Exception) { - Log.d(dispatcherName, "unhandledJobException: ${javaClass.simpleName} (id: $id)") + Log.d(dispatcherName, "unhandledJobException: ${javaClass.simpleName} (id: $id)", e) this@JobQueue.handleJobFailed(this, dispatcherName, e) } } diff --git a/libsession/src/main/java/org/session/libsession/messaging/open_groups/GroupMember.kt b/libsession/src/main/java/org/session/libsession/messaging/open_groups/GroupMember.kt index 8335e0a2da..47ec35e176 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/open_groups/GroupMember.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/open_groups/GroupMember.kt @@ -6,6 +6,11 @@ data class GroupMember( val role: GroupMemberRole ) -enum class GroupMemberRole { - STANDARD, ZOOMBIE, MODERATOR, ADMIN, HIDDEN_MODERATOR, HIDDEN_ADMIN +enum class GroupMemberRole(val isModerator: Boolean = false) { + STANDARD, + ZOOMBIE, + MODERATOR(true), + ADMIN(true), + HIDDEN_MODERATOR(true), + HIDDEN_ADMIN(true), } 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 4f31a333f5..301648f97b 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt @@ -81,6 +81,15 @@ object MessageSender { } } + fun buildConfigMessageToSnode(destinationPubKey: String, message: SharedConfigurationMessage): SnodeMessage { + return SnodeMessage( + destinationPubKey, + Base64.encodeBytes(message.data), + ttl = message.ttl, + SnodeAPI.nowWithOffset + ) + } + // One-on-One Chats & Closed Groups @Throws(Exception::class) fun buildWrappedMessageToSnode(destination: Destination, message: Message, isSyncMessage: Boolean): SnodeMessage { diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt index 39ed79de1e..d05290a5fd 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt @@ -25,6 +25,7 @@ import org.session.libsession.snode.RawResponse import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeModule import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Namespace import org.session.libsignal.utilities.Snode @@ -126,37 +127,26 @@ class Poller(private val configFactory: ConfigFactoryProtocol, debounceTimer: Ti private fun processConfig(snode: Snode, rawMessages: RawResponse, namespace: Int, forConfigObject: ConfigBase?) { if (forConfigObject == null) return - val messages = SnodeAPI.parseRawMessagesResponse( - rawMessages, - snode, - userPublicKey, - namespace, - updateLatestHash = true, - updateStoredHashes = true, - ) + val messages = rawMessages["messages"] as? List<*> + val processed = if (!messages.isNullOrEmpty()) { + SnodeAPI.updateLastMessageHashValueIfPossible(snode, userPublicKey, messages, namespace) + SnodeAPI.removeDuplicates(userPublicKey, messages, namespace, true).mapNotNull { messageBody -> + val rawMessageAsJSON = messageBody as? Map<*, *> ?: return@mapNotNull null + val hashValue = rawMessageAsJSON["hash"] as? String ?: return@mapNotNull null + val b64EncodedBody = rawMessageAsJSON["data"] as? String ?: return@mapNotNull null + val timestamp = rawMessageAsJSON["t"] as? Long ?: SnodeAPI.nowWithOffset + val body = Base64.decode(b64EncodedBody) + Triple(body, hashValue, timestamp) + } + } else emptyList() - if (messages.isEmpty()) { - // no new messages to process - return - } + if (processed.isEmpty()) return var latestMessageTimestamp: Long? = null - messages.forEach { (envelope, hash) -> + processed.forEach { (body, hash, timestamp) -> try { - val (message, _) = MessageReceiver.parse(data = envelope.toByteArray(), - // assume no groups in personal poller messages - openGroupServerID = null, currentClosedGroups = emptySet() - ) - // sanity checks - if (message !is SharedConfigurationMessage) { - Log.w("Loki", "shared config message handled in configs wasn't SharedConfigurationMessage but was ${message.javaClass.simpleName}") - return@forEach - } - val merged = forConfigObject.merge(hash!! to message.data).firstOrNull { it == hash } - if (merged != null) { - // We successfully merged the hash, we can now update the timestamp - latestMessageTimestamp = if ((message.sentTimestamp ?: 0L) > (latestMessageTimestamp ?: 0L)) { message.sentTimestamp } else { latestMessageTimestamp } - } + forConfigObject.merge(hash to body) + latestMessageTimestamp = if (timestamp > (latestMessageTimestamp ?: 0L)) { timestamp } else { latestMessageTimestamp } } catch (e: Exception) { Log.e("Loki", e) } diff --git a/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt b/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt index 04b0f722c1..cf43c7b14a 100644 --- a/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt +++ b/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt @@ -25,7 +25,6 @@ import org.session.libsignal.utilities.Snode import org.session.libsignal.utilities.ThreadUtils import org.session.libsignal.utilities.recover import org.session.libsignal.utilities.toHexString -import java.util.Date import java.util.concurrent.atomic.AtomicReference import kotlin.collections.set 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 a3ecc740a5..07896ab474 100644 --- a/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt +++ b/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt @@ -829,7 +829,7 @@ object SnodeAPI { } } - private fun updateLastMessageHashValueIfPossible(snode: Snode, publicKey: String, rawMessages: List<*>, namespace: Int) { + fun updateLastMessageHashValueIfPossible(snode: Snode, publicKey: String, rawMessages: List<*>, namespace: Int) { val lastMessageAsJSON = rawMessages.lastOrNull() as? Map<*, *> val hashValue = lastMessageAsJSON?.get("hash") as? String if (hashValue != null) { @@ -839,7 +839,7 @@ object SnodeAPI { } } - private fun removeDuplicates(publicKey: String, rawMessages: List<*>, namespace: Int, updateStoredHashes: Boolean): List<*> { + fun removeDuplicates(publicKey: String, rawMessages: List<*>, namespace: Int, updateStoredHashes: Boolean): List<*> { val originalMessageHashValues = database.getReceivedMessageHashValues(publicKey, namespace)?.toMutableSet() ?: mutableSetOf() val receivedMessageHashValues = originalMessageHashValues.toMutableSet() val result = rawMessages.filter { rawMessage -> diff --git a/libsession/src/main/res/values/strings.xml b/libsession/src/main/res/values/strings.xml index 7e4ffed396..f0d20aedf0 100644 --- a/libsession/src/main/res/values/strings.xml +++ b/libsession/src/main/res/values/strings.xml @@ -73,4 +73,10 @@ %1$s has left the group. Unnamed group + + An unknown error occurred and your data was not deleted. Do you want to delete your data from just this device instead? + An unknown error occurred. + Clear Device + Clear device only + Clear device and network 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..e3560e3f4f 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/ThreadUtils.kt +++ b/libsignal/src/main/java/org/session/libsignal/utilities/ThreadUtils.kt @@ -4,7 +4,6 @@ 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