Merge branch 'od' into on-2

This commit is contained in:
bemusementpark 2024-07-03 09:38:12 +09:30
commit 7111bb7725
68 changed files with 1757 additions and 661 deletions

View File

@ -368,8 +368,11 @@ dependencies {
androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.5.1' androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.5.1'
androidTestUtil 'androidx.test:orchestrator:1.4.2' androidTestUtil 'androidx.test:orchestrator:1.4.2'
testImplementation 'org.robolectric:robolectric:4.4' testImplementation 'org.robolectric:robolectric:4.12.2'
testImplementation 'org.robolectric:shadows-multidex:4.4' 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 'com.github.bumptech.glide:compose:1.0.0-alpha.5'
implementation "androidx.compose.ui:ui:$composeVersion" implementation "androidx.compose.ui:ui:$composeVersion"

View File

@ -5,7 +5,6 @@ import kotlinx.coroutines.asExecutor
import nl.komponents.kovenant.Kovenant import nl.komponents.kovenant.Kovenant
import nl.komponents.kovenant.jvm.asDispatcher import nl.komponents.kovenant.jvm.asDispatcher
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.ThreadUtils
import java.util.concurrent.Executors import java.util.concurrent.Executors
object AppContext { object AppContext {

View File

@ -216,7 +216,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
DatabaseModule.init(this); DatabaseModule.init(this);
MessagingModuleConfiguration.configure(this); MessagingModuleConfiguration.configure(this);
super.onCreate(); super.onCreate();
messagingModuleConfiguration = new MessagingModuleConfiguration( messagingModuleConfiguration = new MessagingModuleConfiguration(
this, this,
storage, 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") @SuppressLint("ApplySharedPref")
public void clearAllData() { public boolean clearAllData() {
TextSecurePreferences.clearAll(this); TextSecurePreferences.clearAll(this);
getSharedPreferences(PREFERENCES_NAME, 0).edit().clear().commit(); getSharedPreferences(PREFERENCES_NAME, 0).edit().clear().commit();
if (!deleteDatabase(SQLCipherOpenHelper.DATABASE_NAME)) { if (!deleteDatabase(SQLCipherOpenHelper.DATABASE_NAME)) {
Log.d("Loki", "Failed to delete database."); Log.d("Loki", "Failed to delete database.");
return false;
} }
configFactory.keyPairChanged(); configFactory.keyPairChanged();
Util.runOnMain(() -> new Handler().postDelayed(ApplicationContext.this::restartApplication, 200)); Util.runOnMain(() -> new Handler().postDelayed(ApplicationContext.this::restartApplication, 200));
return true;
} }
public void restartApplication() { public void restartApplication() {

View File

@ -114,7 +114,7 @@ class MediaGalleryAdapter extends StickyHeaderGridAdapter {
Slide slide = MediaUtil.getSlideForAttachment(context, mediaRecord.getAttachment()); Slide slide = MediaUtil.getSlideForAttachment(context, mediaRecord.getAttachment());
if (slide != null) { if (slide != null) {
thumbnailView.setImageResource(glideRequests, slide, false, null); thumbnailView.setImageResource(glideRequests, slide, false);
} }
thumbnailView.setOnClickListener(view -> itemClickListener.onMediaClicked(mediaRecord)); thumbnailView.setOnClickListener(view -> itemClickListener.onMediaClicked(mediaRecord));

View File

@ -7,10 +7,8 @@ import android.view.View
import android.widget.LinearLayout import android.widget.LinearLayout
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ViewUserBinding import network.loki.messenger.databinding.ViewUserBinding
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.utilities.recipients.Recipient 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.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.GlideRequests

View File

@ -21,7 +21,6 @@ import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.utilities.ExpirationUtil import org.session.libsession.utilities.ExpirationUtil
import org.session.libsession.utilities.modifyLayoutParams import org.session.libsession.utilities.modifyLayoutParams
import org.session.libsession.utilities.recipients.Recipient 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.GroupDatabase
import org.thoughtcrime.securesms.database.LokiAPIDatabase import org.thoughtcrime.securesms.database.LokiAPIDatabase
import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.DateUtils
@ -78,7 +77,6 @@ class ConversationActionBarView @JvmOverloads constructor(
binding.profilePictureView.layoutParams = resources.getDimensionPixelSize( binding.profilePictureView.layoutParams = resources.getDimensionPixelSize(
if (recipient.isClosedGroupRecipient) R.dimen.medium_profile_picture_size else R.dimen.small_profile_picture_size if (recipient.isClosedGroupRecipient) R.dimen.medium_profile_picture_size else R.dimen.small_profile_picture_size
).let { LayoutParams(it, it) } ).let { LayoutParams(it, it) }
MentionManagerUtilities.populateUserPublicKeyCacheIfNeeded(threadId, context)
update(recipient, openGroup, config) update(recipient, openGroup, config)
} }

View File

@ -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<DatabaseAttachment>(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<DatabaseAttachment>): List<DatabaseAttachment> {
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<Long>,
): 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)
}
}

View File

@ -29,8 +29,8 @@ import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup.LayoutParams
import android.view.WindowManager import android.view.WindowManager
import android.widget.RelativeLayout
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@ -46,6 +46,7 @@ import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.loader.app.LoaderManager import androidx.loader.app.LoaderManager
import androidx.loader.content.Loader import androidx.loader.content.Loader
import androidx.recyclerview.widget.LinearLayoutManager 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.contacts.Contact
import org.session.libsession.messaging.jobs.AttachmentDownloadJob import org.session.libsession.messaging.jobs.AttachmentDownloadJob
import org.session.libsession.messaging.jobs.JobQueue 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.ExpirationConfiguration
import org.session.libsession.messaging.messages.applyExpiryMode import org.session.libsession.messaging.messages.applyExpiryMode
import org.session.libsession.messaging.messages.control.DataExtractionNotification 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.InputBarButton
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarDelegate 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.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.ConversationActionModeCallback
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallbackDelegate import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallbackDelegate
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper
@ -215,6 +215,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
@Inject lateinit var storage: Storage @Inject lateinit var storage: Storage
@Inject lateinit var reactionDb: ReactionDatabase @Inject lateinit var reactionDb: ReactionDatabase
@Inject lateinit var viewModelFactory: ConversationViewModel.AssistedFactory @Inject lateinit var viewModelFactory: ConversationViewModel.AssistedFactory
@Inject lateinit var mentionViewModelFactory: MentionViewModel.AssistedFactory
private val screenshotObserver by lazy { private val screenshotObserver by lazy {
ScreenshotObserver(this, Handler(Looper.getMainLooper())) { ScreenshotObserver(this, Handler(Looper.getMainLooper())) {
@ -228,7 +229,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
ViewModelProvider(this, LinkPreviewViewModel.Factory(LinkPreviewRepository())) ViewModelProvider(this, LinkPreviewViewModel.Factory(LinkPreviewRepository()))
.get(LinkPreviewViewModel::class.java) .get(LinkPreviewViewModel::class.java)
} }
private val viewModel: ConversationViewModel by viewModels {
private val threadId: Long by lazy {
var threadId = intent.getLongExtra(THREAD_ID, -1L) var threadId = intent.getLongExtra(THREAD_ID, -1L)
if (threadId == -1L) { if (threadId == -1L) {
intent.getParcelableExtra<Address>(ADDRESS)?.let { it -> intent.getParcelableExtra<Address>(ADDRESS)?.let { it ->
@ -248,6 +250,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
} ?: finish() } ?: finish()
} }
threadId
}
private val viewModel: ConversationViewModel by viewModels {
viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair()) viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair())
} }
private var actionMode: ActionMode? = null private var actionMode: ActionMode? = null
@ -260,11 +267,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private var isLockViewExpanded = false private var isLockViewExpanded = false
private var isShowingAttachmentOptions = false private var isShowingAttachmentOptions = false
// Mentions // Mentions
private val mentions = mutableListOf<Mention>() private val mentionViewModel: MentionViewModel by viewModels {
private var mentionCandidatesView: MentionCandidatesView? = null mentionViewModelFactory.create(threadId)
private var previousText: CharSequence = "" }
private var currentMentionStartIndex = -1 private val mentionCandidateAdapter = MentionCandidateAdapter {
private var isShowingMentionCandidatesView = false mentionViewModel.onCandidateSelected(it.member.publicKey)
}
// Search // Search
val searchViewModel: SearchViewModel by viewModels() val searchViewModel: SearchViewModel by viewModels()
var searchViewItem: MenuItem? = null var searchViewItem: MenuItem? = null
@ -325,11 +333,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
onDeselect(message, position, it) onDeselect(message, position, it)
} }
}, },
onAttachmentNeedsDownload = { attachmentId, mmsId -> onAttachmentNeedsDownload = viewModel::onAttachmentDownloadRequest,
lifecycleScope.launch(Dispatchers.IO) {
JobQueue.shared.add(AttachmentDownloadJob(attachmentId, mmsId))
}
},
glide = glide, glide = glide,
lifecycleCoroutineScope = lifecycleScope 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() { override fun onResume() {
@ -642,23 +667,19 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
binding.inputBar.delegate = this binding.inputBar.delegate = this
binding.inputBarRecordingView.delegate = this binding.inputBarRecordingView.delegate = this
// GIF button // GIF button
binding.gifButtonContainer.addView(gifButton) binding.gifButtonContainer.addView(gifButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
gifButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
gifButton.onUp = { showGIFPicker() } gifButton.onUp = { showGIFPicker() }
gifButton.snIsEnabled = false gifButton.snIsEnabled = false
// Document button // Document button
binding.documentButtonContainer.addView(documentButton) binding.documentButtonContainer.addView(documentButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
documentButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
documentButton.onUp = { showDocumentPicker() } documentButton.onUp = { showDocumentPicker() }
documentButton.snIsEnabled = false documentButton.snIsEnabled = false
// Library button // Library button
binding.libraryButtonContainer.addView(libraryButton) binding.libraryButtonContainer.addView(libraryButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
libraryButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
libraryButton.onUp = { pickFromLibrary() } libraryButton.onUp = { pickFromLibrary() }
libraryButton.snIsEnabled = false libraryButton.snIsEnabled = false
// Camera button // Camera button
binding.cameraButtonContainer.addView(cameraButton) binding.cameraButtonContainer.addView(cameraButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
cameraButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
cameraButton.onUp = { showCamera() } cameraButton.onUp = { showCamera() }
cameraButton.snIsEnabled = false cameraButton.snIsEnabled = false
} }
@ -910,7 +931,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
if (textSecurePreferences.isLinkPreviewsEnabled()) { if (textSecurePreferences.isLinkPreviewsEnabled()) {
linkPreviewViewModel.onTextChanged(this, inputBarText, 0, 0) linkPreviewViewModel.onTextChanged(this, inputBarText, 0, 0)
} }
showOrHideMentionCandidatesIfNeeded(newContent)
if (LinkPreviewUtil.findWhitelistedUrls(newContent.toString()).isNotEmpty() if (LinkPreviewUtil.findWhitelistedUrls(newContent.toString()).isNotEmpty()
&& !textSecurePreferences.isLinkPreviewsEnabled() && !textSecurePreferences.hasSeenLinkPreviewSuggestionDialog()) { && !textSecurePreferences.isLinkPreviewsEnabled() && !textSecurePreferences.hasSeenLinkPreviewSuggestionDialog()) {
LinkPreviewDialog { 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() { override fun toggleAttachmentOptions() {
val targetAlpha = if (isShowingAttachmentOptions) 0.0f else 1.0f val targetAlpha = if (isShowingAttachmentOptions) 0.0f else 1.0f
val allButtonContainers = listOfNotNull( val allButtonContainers = listOfNotNull(
@ -1507,18 +1457,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
return hitRect.contains(x, y) 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) { override fun scrollToMessageIfPossible(timestamp: Long) {
val lastSeenItemPosition = adapter.getItemPositionForTimestamp(timestamp) ?: return val lastSeenItemPosition = adapter.getItemPositionForTimestamp(timestamp) ?: return
@ -1615,10 +1553,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
binding?.inputBar?.text = "" binding?.inputBar?.text = ""
binding?.inputBar?.cancelQuoteDraft() binding?.inputBar?.cancelQuoteDraft()
binding?.inputBar?.cancelLinkPreviewDraft() binding?.inputBar?.cancelLinkPreviewDraft()
// Clear mentions
previousText = ""
currentMentionStartIndex = -1
mentions.clear()
// Put the message in the database // Put the message in the database
message.id = smsDb.insertMessageOutbox(viewModel.threadId, outgoingTextMessage, false, message.sentTimestamp!!, null, true) message.id = smsDb.insertMessageOutbox(viewModel.threadId, outgoingTextMessage, false, message.sentTimestamp!!, null, true)
// Send it // Send it
@ -1663,10 +1597,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
binding?.inputBar?.text = "" binding?.inputBar?.text = ""
binding?.inputBar?.cancelQuoteDraft() binding?.inputBar?.cancelQuoteDraft()
binding?.inputBar?.cancelLinkPreviewDraft() binding?.inputBar?.cancelLinkPreviewDraft()
// Clear mentions
previousText = ""
currentMentionStartIndex = -1
mentions.clear()
// Reset the attachment manager // Reset the attachment manager
attachmentManager.clear() attachmentManager.clear()
// Reset attachments button if needed // Reset attachments button if needed
@ -1953,7 +1883,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val messageIterator = sortedMessages.iterator() val messageIterator = sortedMessages.iterator()
while (messageIterator.hasNext()) { while (messageIterator.hasNext()) {
val message = messageIterator.next() 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 (TextUtils.isEmpty(body)) { continue }
if (messageSize > 1) { if (messageSize > 1) {
val formattedTimestamp = DateUtils.getDisplayFormattedTimeSpanString(this, Locale.getDefault(), message.timestamp) val formattedTimestamp = DateUtils.getDisplayFormattedTimeSpanString(this, Locale.getDefault(), message.timestamp)
@ -2094,17 +2030,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
// region General // region General
private fun getMessageBody(): String { private fun getMessageBody(): String {
var result = binding?.inputBar?.text?.trim() ?: return "" return mentionViewModel.normalizeMessageBody()
for (mention in mentions) {
try {
val startIndex = result.indexOf("@" + mention.displayName)
val endIndex = startIndex + mention.displayName.count() + 1 // + 1 to include the "@"
result = result.substring(0, startIndex) + "@" + mention.publicKey + result.substring(endIndex)
} catch (exception: Exception) {
Log.d("Loki", "Failed to process mention due to error: $exception")
}
}
return result
} }
// endregion // endregion

View File

@ -19,6 +19,7 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import network.loki.messenger.R import network.loki.messenger.R
import org.session.libsession.messaging.contacts.Contact 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.ControlMessageView
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDelegate import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDelegate
@ -40,7 +41,7 @@ class ConversationAdapter(
private val onItemSwipeToReply: (MessageRecord, Int) -> Unit, private val onItemSwipeToReply: (MessageRecord, Int) -> Unit,
private val onItemLongPress: (MessageRecord, Int, VisibleMessageView) -> Unit, private val onItemLongPress: (MessageRecord, Int, VisibleMessageView) -> Unit,
private val onDeselect: (MessageRecord, Int) -> Unit, private val onDeselect: (MessageRecord, Int) -> Unit,
private val onAttachmentNeedsDownload: (Long, Long) -> Unit, private val onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit,
private val glide: GlideRequests, private val glide: GlideRequests,
lifecycleCoroutineScope: LifecycleCoroutineScope lifecycleCoroutineScope: LifecycleCoroutineScope
) : CursorRecyclerViewAdapter<ViewHolder>(context, cursor) { ) : CursorRecyclerViewAdapter<ViewHolder>(context, cursor) {

View File

@ -1,46 +1,44 @@
package org.thoughtcrime.securesms.conversation.v2 package org.thoughtcrime.securesms.conversation.v2
import android.content.Context import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.goterl.lazysodium.utils.KeyPair import com.goterl.lazysodium.utils.KeyPair
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.session.libsession.database.MessageDataProvider
import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.messaging.open_groups.OpenGroupApi 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.AccountId
import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.database.MmsDatabase
import org.thoughtcrime.securesms.audio.AudioSlidePlayer import org.thoughtcrime.securesms.audio.AudioSlidePlayer
import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.repository.ConversationRepository
import java.util.UUID import java.util.UUID
class ConversationViewModel( class ConversationViewModel(
val threadId: Long, val threadId: Long,
val edKeyPair: KeyPair?, val edKeyPair: KeyPair?,
private val repository: ConversationRepository, private val repository: ConversationRepository,
private val storage: Storage private val storage: Storage,
private val messageDataProvider: MessageDataProvider,
database: MmsDatabase,
) : ViewModel() { ) : ViewModel() {
val showSendAfterApprovalText: Boolean 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 // 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) get() = (openGroup == null || OpenGroupApi.Capability.REACTIONS.name.lowercase() in serverCapabilities)
private val attachmentDownloadHandler = AttachmentDownloadHandler(
storage = storage,
messageDataProvider = messageDataProvider,
scope = viewModelScope,
)
init { init {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
@ -265,6 +268,10 @@ class ConversationViewModel(
storage.getLastLegacyRecipient(address.serialize())?.let { Recipient.from(context, Address.fromSerialized(it), false) } storage.getLastLegacyRecipient(address.serialize())?.let { Recipient.from(context, Address.fromSerialized(it), false) }
} }
fun onAttachmentDownloadRequest(attachment: DatabaseAttachment) {
attachmentDownloadHandler.onAttachmentDownloadRequest(attachment)
}
@dagger.assisted.AssistedFactory @dagger.assisted.AssistedFactory
interface AssistedFactory { interface AssistedFactory {
fun create(threadId: Long, edKeyPair: KeyPair?): Factory fun create(threadId: Long, edKeyPair: KeyPair?): Factory
@ -275,11 +282,20 @@ class ConversationViewModel(
@Assisted private val threadId: Long, @Assisted private val threadId: Long,
@Assisted private val edKeyPair: KeyPair?, @Assisted private val edKeyPair: KeyPair?,
private val repository: ConversationRepository, private val repository: ConversationRepository,
private val storage: Storage private val storage: Storage,
private val mmsDatabase: MmsDatabase,
private val messageDataProvider: MessageDataProvider,
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): 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
} }
} }
} }

View File

@ -50,6 +50,7 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ViewVisibleMessageContentBinding 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.MediaPreviewActivity.getPreviewIntent
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.Storage
@ -146,7 +147,7 @@ fun MessageDetails(
onResend: (() -> Unit)? = null, onResend: (() -> Unit)? = null,
onDelete: () -> Unit = {}, onDelete: () -> Unit = {},
onClickImage: (Int) -> Unit = {}, onClickImage: (Int) -> Unit = {},
onAttachmentNeedsDownload: (Long, Long) -> Unit = { _, _ -> } onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit = { _ -> }
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier

View File

@ -124,7 +124,7 @@ class MessageDetailsViewModel @Inject constructor(
if (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED) { if (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED) {
// Restart download here (on IO thread) // Restart download here (on IO thread)
(slide.asAttachment() as? DatabaseAttachment)?.let { attachment -> (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) { viewModelScope.launch(Dispatchers.IO) {
JobQueue.shared.add(AttachmentDownloadJob(attachmentId, mmsId)) JobQueue.shared.add(AttachmentDownloadJob(attachment.attachmentId.rowId, attachment.mmsId))
} }
} }
} }

View File

@ -48,7 +48,7 @@ class AlbumThumbnailView : RelativeLayout {
// region Interaction // 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 rawXInt = event.rawX.toInt()
val rawYInt = event.rawY.toInt() val rawYInt = event.rawY.toInt()
val eventRect = Rect(rawXInt, rawYInt, rawXInt, rawYInt) val eventRect = Rect(rawXInt, rawYInt, rawXInt, rawYInt)
@ -63,7 +63,7 @@ class AlbumThumbnailView : RelativeLayout {
if (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED) { if (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED) {
// Restart download here (on IO thread) // Restart download here (on IO thread)
(slide.asAttachment() as? DatabaseAttachment)?.let { attachment -> (slide.asAttachment() as? DatabaseAttachment)?.let { attachment ->
onAttachmentNeedsDownload(attachment.attachmentId.rowId, mms.getId()) onAttachmentNeedsDownload(attachment)
} }
} }
if (slide.isInProgress) return@forEach if (slide.isInProgress) return@forEach
@ -104,7 +104,7 @@ class AlbumThumbnailView : RelativeLayout {
// iterate binding // iterate binding
slides.take(MAX_ALBUM_DISPLAY_SIZE).forEachIndexed { position, slide -> slides.take(MAX_ALBUM_DISPLAY_SIZE).forEachIndexed { position, slide ->
val thumbnailView = getThumbnailView(position) val thumbnailView = getThumbnailView(position)
thumbnailView.setImageResource(glideRequests, slide, isPreview = false, mms = message) thumbnailView.setImageResource(glideRequests, slide, isPreview = false)
} }
} }

View File

@ -31,10 +31,10 @@ class LinkPreviewDraftView : LinearLayout {
// Hide the loader and show the content view // Hide the loader and show the content view
binding.linkPreviewDraftContainer.isVisible = true binding.linkPreviewDraftContainer.isVisible = true
binding.linkPreviewDraftLoader.isVisible = false binding.linkPreviewDraftLoader.isVisible = false
binding.thumbnailImageView.root.radius = toPx(4, resources) binding.thumbnailImageView.root.setRoundedCorners(toPx(4, resources))
if (linkPreview.getThumbnail().isPresent) { if (linkPreview.getThumbnail().isPresent) {
// This internally fetches the thumbnail // 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 binding.linkPreviewDraftTitleTextView.text = linkPreview.title
} }

View File

@ -4,6 +4,7 @@ import android.content.Context
import android.content.res.Resources import android.content.res.Resources
import android.graphics.PointF import android.graphics.PointF
import android.net.Uri import android.net.Uri
import android.text.Editable
import android.text.InputType import android.text.InputType
import android.text.TextWatcher import android.text.TextWatcher
import android.util.AttributeSet import android.util.AttributeSet
@ -227,8 +228,8 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
binding.inputBarEditText.addTextChangedListener(listener) binding.inputBarEditText.addTextChangedListener(listener)
} }
fun setSelection(index: Int) { fun setInputBarEditableFactory(factory: Editable.Factory) {
binding.inputBarEditText.setSelection(index) binding.inputBarEditText.setEditableFactory(factory)
} }
// endregion // endregion
} }

View File

@ -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<MentionCandidateAdapter.ViewHolder>() {
var candidates = listOf<MentionViewModel.Candidate>()
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) }
}
}

View File

@ -1,42 +1,14 @@
package org.thoughtcrime.securesms.conversation.v2.input_bar.mentions 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.view.View
import android.widget.RelativeLayout
import network.loki.messenger.databinding.ViewMentionCandidateV2Binding import network.loki.messenger.databinding.ViewMentionCandidateV2Binding
import org.session.libsession.messaging.mentions.Mention import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel
import org.thoughtcrime.securesms.groups.OpenGroupManager
import org.thoughtcrime.securesms.mms.GlideRequests
class MentionCandidateView : RelativeLayout { fun ViewMentionCandidateV2Binding.update(candidate: MentionViewModel.Candidate) {
private lateinit var binding: ViewMentionCandidateV2Binding mentionCandidateNameTextView.text = candidate.nameHighlighted
var candidate = Mention("", "") profilePictureView.publicKey = candidate.member.publicKey
set(newValue) { field = newValue; update() } profilePictureView.displayName = candidate.member.name
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.additionalPublicKey = null
profilePictureView.update() profilePictureView.update()
if (openGroupServer != null && openGroupRoom != null) { moderatorIconImageView.visibility = if (candidate.member.isModerator) View.VISIBLE else View.GONE
val isUserModerator = OpenGroupManager.isUserModerator(context, "$openGroupRoom.$openGroupServer", candidate.publicKey)
moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE
} else {
moderatorIconImageView.visibility = View.GONE
}
}
} }

View File

@ -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<Mention>()
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<Mention>()
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<Mention>, threadID: Long) {
val openGroup = threadDb.getOpenGroupChat(threadID)
if (openGroup != null) {
openGroupServer = openGroup.server
openGroupRoom = openGroup.room
}
setMentionCandidates(candidates)
}
fun setMentionCandidates(candidates: List<Mention>) {
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
}
}

View File

@ -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<Unit>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_LATEST
)
fun observeMentionSearchQuery(): Flow<SearchQuery?> {
@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<MentionSpan>(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
}

View File

@ -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
)

View File

@ -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<List<Member>?> =
(contentResolver.observeChanges(Conversation.getUriForThread(threadID)) as Flow<Any?>)
.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<AutoCompleteState> = 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<MentionSpan>()
.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<Candidate> { it.matchScore }
.then(compareBy { it.member.name })
}
}
sealed interface AutoCompleteState {
object Idle : AutoCompleteState
object Loading : AutoCompleteState
data class Result(val members: List<Candidate>, 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 <T : ViewModel> create(modelClass: Class<T>): T {
return MentionViewModel(
threadID = threadId,
contentResolver = contentResolver,
threadDatabase = threadDatabase,
groupDatabase = groupDatabase,
mmsDatabase = mmsDatabase,
contactDatabase = contactDatabase,
memberDatabase = memberDatabase,
storage = storage,
) as T
}
}
}

View File

@ -41,7 +41,7 @@ class LinkPreviewView : LinearLayout {
// Thumbnail // Thumbnail
if (linkPreview.getThumbnail().isPresent) { if (linkPreview.getThumbnail().isPresent) {
// This internally fetches the thumbnail // 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 binding.thumbnailImageView.root.loadIndicator.isVisible = false
} }
// Title // Title

View File

@ -80,7 +80,15 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
binding.quoteViewAuthorTextView.text = authorDisplayName binding.quoteViewAuthorTextView.text = authorDisplayName
binding.quoteViewAuthorTextView.setTextColor(getTextColor(isOutgoingMessage)) binding.quoteViewAuthorTextView.setTextColor(getTextColor(isOutgoingMessage))
// Body // 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)) binding.quoteViewBodyTextView.setTextColor(getTextColor(isOutgoingMessage))
// Accent line / attachment preview // Accent line / attachment preview
val hasAttachments = (attachments != null && attachments.asAttachments().isNotEmpty()) && !isOriginalMissing val hasAttachments = (attachments != null && attachments.asAttachments().isNotEmpty()) && !isOriginalMissing
@ -108,8 +116,9 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
attachments.thumbnailSlide != null -> { attachments.thumbnailSlide != null -> {
val slide = attachments.thumbnailSlide!! val slide = attachments.thumbnailSlide!!
// This internally fetches the thumbnail // This internally fetches the thumbnail
binding.quoteViewAttachmentThumbnailImageView.root.radius = toPx(4, resources) binding.quoteViewAttachmentThumbnailImageView
binding.quoteViewAttachmentThumbnailImageView.root.setImageResource(glide, slide, false, null) .root.setRoundedCorners(toPx(4, resources))
binding.quoteViewAttachmentThumbnailImageView.root.setImageResource(glide, slide, false)
binding.quoteViewAttachmentThumbnailImageView.root.isVisible = true 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) binding.quoteViewBodyTextView.text = if (MediaUtil.isVideo(slide.asAttachment())) resources.getString(R.string.Slide_video) else resources.getString(R.string.Slide_image)
} }

View File

@ -66,7 +66,7 @@ class VisibleMessageContentView : ConstraintLayout {
thread: Recipient, thread: Recipient,
searchQuery: String? = null, searchQuery: String? = null,
contactIsTrusted: Boolean = true, contactIsTrusted: Boolean = true,
onAttachmentNeedsDownload: (Long, Long) -> Unit, onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit,
suppressThumbnails: Boolean = false suppressThumbnails: Boolean = false
) { ) {
// Background // Background
@ -135,19 +135,11 @@ class VisibleMessageContentView : ConstraintLayout {
if (message is MmsMessageRecord) { if (message is MmsMessageRecord) {
message.slideDeck.asAttachments().forEach { attach -> message.slideDeck.asAttachments().forEach { attach ->
val dbAttachment = attach as? DatabaseAttachment ?: return@forEach val dbAttachment = attach as? DatabaseAttachment ?: return@forEach
val attachmentId = dbAttachment.attachmentId.rowId onAttachmentNeedsDownload(dbAttachment)
if (attach.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING
&& MessagingModuleConfiguration.shared.storage.getAttachmentUploadJob(attachmentId) == null) {
onAttachmentNeedsDownload(attachmentId, dbAttachment.mmsId)
}
} }
message.linkPreviews.forEach { preview -> message.linkPreviews.forEach { preview ->
val previewThumbnail = preview.getThumbnail().orNull() as? DatabaseAttachment ?: return@forEach val previewThumbnail = preview.getThumbnail().orNull() as? DatabaseAttachment ?: return@forEach
val attachmentId = previewThumbnail.attachmentId.rowId onAttachmentNeedsDownload(previewThumbnail)
if (previewThumbnail.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING
&& MessagingModuleConfiguration.shared.storage.getAttachmentUploadJob(attachmentId) == null) {
onAttachmentNeedsDownload(attachmentId, previewThumbnail.mmsId)
}
} }
} }
@ -282,7 +274,12 @@ class VisibleMessageContentView : ConstraintLayout {
fun getBodySpans(context: Context, message: MessageRecord, searchQuery: String?): Spannable { fun getBodySpans(context: Context, message: MessageRecord, searchQuery: String?): Spannable {
var body = message.body.toSpannable() 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(), body = SearchUtil.getHighlightedSpan(Locale.getDefault(),
{ BackgroundColorSpan(Color.WHITE) }, body, searchQuery) { BackgroundColorSpan(Color.WHITE) }, body, searchQuery)
body = SearchUtil.getHighlightedSpan(Locale.getDefault(), body = SearchUtil.getHighlightedSpan(Locale.getDefault(),

View File

@ -34,6 +34,7 @@ import network.loki.messenger.databinding.ViewstubVisibleMessageMarkerContainerB
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.contacts.Contact.ContactContext import org.session.libsession.messaging.contacts.Contact.ContactContext
import org.session.libsession.messaging.open_groups.OpenGroupApi 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.Address
import org.session.libsession.utilities.ViewUtil import org.session.libsession.utilities.ViewUtil
import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.getColorFromAttr
@ -145,7 +146,7 @@ class VisibleMessageView : FrameLayout {
senderAccountID: String, senderAccountID: String,
lastSeen: Long, lastSeen: Long,
delegate: VisibleMessageViewDelegate? = null, delegate: VisibleMessageViewDelegate? = null,
onAttachmentNeedsDownload: (Long, Long) -> Unit, onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit,
lastSentMessageId: Long lastSentMessageId: Long
) { ) {
replyDisabled = message.isOpenGroupInvitation replyDisabled = message.isOpenGroupInvitation

View File

@ -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<String>()
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
}
}

View File

@ -1,7 +1,7 @@
package org.thoughtcrime.securesms.conversation.v2.utilities package org.thoughtcrime.securesms.conversation.v2.utilities
import android.app.Application
import android.content.Context import android.content.Context
import android.graphics.Color
import android.graphics.Typeface import android.graphics.Typeface
import android.text.Spannable import android.text.Spannable
import android.text.SpannableString import android.text.SpannableString
@ -9,43 +9,60 @@ import android.text.style.BackgroundColorSpan
import android.text.style.ForegroundColorSpan import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan import android.text.style.StyleSpan
import android.util.Range import android.util.Range
import androidx.appcompat.widget.ThemeUtils
import androidx.core.content.res.ResourcesCompat import androidx.core.content.res.ResourcesCompat
import network.loki.messenger.R import network.loki.messenger.R
import nl.komponents.kovenant.combine.Tuple2 import nl.komponents.kovenant.combine.Tuple2
import org.session.libsession.messaging.contacts.Contact 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.messaging.utilities.SodiumUtilities
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.ThemeUtil 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.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.getAccentColor
import org.thoughtcrime.securesms.util.getColorResourceIdFromAttr import org.thoughtcrime.securesms.util.toPx
import org.thoughtcrime.securesms.util.getMessageTextColourAttr
import java.util.regex.Pattern import java.util.regex.Pattern
object MentionUtilities { object MentionUtilities {
@JvmStatic private val pattern by lazy { Pattern.compile("@[0-9a-fA-F]*") }
fun highlightMentions(text: CharSequence, threadID: Long, context: Context): String {
return highlightMentions(text, false, threadID, context).toString() // isOutgoingMessage is irrelevant
}
/**
* 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 @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 @Suppress("NAME_SHADOWING") var text = text
val pattern = Pattern.compile("@[0-9a-fA-F]*")
var matcher = pattern.matcher(text) var matcher = pattern.matcher(text)
val mentions = mutableListOf<Tuple2<Range<Int>, String>>() val mentions = mutableListOf<Tuple2<Range<Int>, String>>()
var startIndex = 0 var startIndex = 0
val userPublicKey = TextSecurePreferences.getLocalNumber(context)!! 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)) { if (matcher.find(startIndex)) {
while (true) { while (true) {
val publicKey = text.subSequence(matcher.start() + 1, matcher.end()).toString() // +1 to get rid of the @ 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 isYou = isYou(publicKey, userPublicKey, openGroup)
val userDisplayName: String? = if (publicKey.equals(userPublicKey, ignoreCase = true) || isUserBlindedPublicKey) { val userDisplayName: String? = if (isYou) {
context.getString(R.string.MessageRecord_you) context.getString(R.string.MessageRecord_you)
} else { } else {
val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithAccountID(publicKey) val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithAccountID(publicKey)
@ -53,7 +70,8 @@ object MentionUtilities {
contact?.displayName(context) contact?.displayName(context)
} }
if (userDisplayName != null) { 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 val endIndex = matcher.start() + 1 + userDisplayName.length
startIndex = endIndex startIndex = endIndex
mentions.add(Tuple2(Range.create(matcher.start(), endIndex), publicKey)) mentions.add(Tuple2(Range.create(matcher.start(), endIndex), publicKey))
@ -66,37 +84,83 @@ object MentionUtilities {
} }
val result = SpannableString(text) val result = SpannableString(text)
var mentionTextColour: Int? = null // apply styling if required
// In dark themes.. // Normal text color: black in dark mode and primary text color for light mode
if (ThemeUtil.isDarkTheme(context)) { val mainTextColor by lazy {
// ..we use the standard outgoing message colour for outgoing messages.. if (ThemeUtil.isDarkTheme(context)) context.getColor(R.color.black)
if (isOutgoingMessage) { else context.getColorFromAttr(android.R.attr.textColorPrimary)
val mentionTextColourAttributeId = getMessageTextColourAttr(true)
val mentionTextColourResourceId = getColorResourceIdFromAttr(context, mentionTextColourAttributeId)
mentionTextColour = ResourcesCompat.getColor(context.resources, mentionTextColourResourceId, context.theme)
}
else // ..but we use the accent colour for incoming messages (i.e., someone mentioning us)..
{
mentionTextColour = context.getAccentColor()
}
}
else // ..while in light themes we always just use the incoming or outgoing message text colour for mentions.
{
val mentionTextColourAttributeId = getMessageTextColourAttr(isOutgoingMessage)
val mentionTextColourResourceId = getColorResourceIdFromAttr(context, mentionTextColourAttributeId)
mentionTextColour = ResourcesCompat.getColor(context.resources, mentionTextColourResourceId, context.theme)
} }
// Highlighted text color: primary/accent in dark mode and primary text color for light mode
val highlightedTextColor by lazy {
if (ThemeUtil.isDarkTheme(context)) context.getAccentColor()
else context.getColorFromAttr(android.R.attr.textColorPrimary)
}
if(!formatOnly) {
for (mention in mentions) { for (mention in mentions) {
result.setSpan(ForegroundColorSpan(mentionTextColour), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) val backgroundColor: Int?
result.setSpan(StyleSpan(Typeface.BOLD), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) val foregroundColor: Int?
// If we're using a light theme then we change the background colour of the mention to be the accent colour // quotes
if (ThemeUtil.isLightTheme(context)) { if(isQuote) {
val backgroundColour = context.getAccentColor(); backgroundColor = null
result.setSpan(BackgroundColorSpan(backgroundColour), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) // 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 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
}
} }

View File

@ -2,10 +2,13 @@ package org.thoughtcrime.securesms.conversation.v2.utilities
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Outline
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.net.Uri import android.net.Uri
import android.util.AttributeSet import android.util.AttributeSet
import android.util.TypedValue
import android.view.View import android.view.View
import android.view.ViewOutlineProvider
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.bumptech.glide.load.engine.DiskCacheStrategy 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.session.libsignal.utilities.SettableFuture
import org.thoughtcrime.securesms.components.GlideBitmapListeningTarget import org.thoughtcrime.securesms.components.GlideBitmapListeningTarget
import org.thoughtcrime.securesms.components.GlideDrawableListeningTarget 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.DecryptableStreamUriLoader.DecryptableUri
import org.thoughtcrime.securesms.mms.GlideRequest import org.thoughtcrime.securesms.mms.GlideRequest
import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.mms.Slide 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 { companion object {
private const val WIDTH = 0 private const val WIDTH = 0
private const val HEIGHT = 1 private const val HEIGHT = 1
@ -41,29 +43,28 @@ open class ThumbnailView: FrameLayout {
private val binding: ThumbnailViewBinding by lazy { ThumbnailViewBinding.bind(this) } private val binding: ThumbnailViewBinding by lazy { ThumbnailViewBinding.bind(this) }
// region Lifecycle // 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 } val loadIndicator: View by lazy { binding.thumbnailLoadIndicator }
private val dimensDelegate = ThumbnailDimensDelegate() private val dimensDelegate = ThumbnailDimensDelegate()
private var slide: Slide? = null private var slide: Slide? = null
var radius: Int = 0
private fun initialize(attrs: AttributeSet?) { init {
if (attrs != null) { attrs?.let { context.theme.obtainStyledAttributes(it, R.styleable.ThumbnailView, 0, 0) }
val typedArray = context.theme.obtainStyledAttributes(attrs, 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), setRoundedCorners(
typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_minHeight, 0), getDimensionPixelSize(R.styleable.ThumbnailView_thumbnail_radius, 0)
typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxWidth, 0), )
typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxHeight, 0))
radius = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_thumbnail_radius, 0) recycle()
typedArray.recycle()
} }
} }
@ -84,114 +85,118 @@ open class ThumbnailView: FrameLayout {
private fun getDefaultWidth() = maxOf(layoutParams?.width ?: 0, 0) private fun getDefaultWidth() = maxOf(layoutParams?.width ?: 0, 0)
private fun getDefaultHeight() = maxOf(layoutParams?.height ?: 0, 0) private fun getDefaultHeight() = maxOf(layoutParams?.height ?: 0, 0)
// endregion // endregion
// region Interaction // region Interaction
fun setImageResource(glide: GlideRequests, slide: Slide, isPreview: Boolean, mms: MmsMessageRecord?): ListenableFuture<Boolean> { fun setRoundedCorners(radius: Int){
return setImageResource(glide, slide, isPreview, 0, 0, mms) // 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())
}
} }
fun setImageResource(glide: GlideRequests, slide: Slide, outlineProvider = mOutlineProvider
clipToOutline = true
}
fun setImageResource(
glide: GlideRequests,
slide: Slide,
isPreview: Boolean
): ListenableFuture<Boolean> = setImageResource(glide, slide, isPreview, 0, 0)
fun setImageResource(
glide: GlideRequests, slide: Slide,
isPreview: Boolean, naturalWidth: Int, isPreview: Boolean, naturalWidth: Int,
naturalHeight: Int, mms: MmsMessageRecord?): ListenableFuture<Boolean> { naturalHeight: Int
): ListenableFuture<Boolean> {
val currentSlide = this.slide
binding.playOverlay.isVisible = (slide.thumbnailUri != null && slide.hasPlayOverlay() && binding.playOverlay.isVisible = (slide.thumbnailUri != null && slide.hasPlayOverlay() &&
(slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE || isPreview)) (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE || isPreview))
if (equals(currentSlide, slide)) { if (equals(this.slide, slide)) {
// don't re-load slide // don't re-load slide
return SettableFuture(false) return SettableFuture(false)
} }
if (currentSlide != null && currentSlide.fastPreflightId != null && currentSlide.fastPreflightId == slide.fastPreflightId) {
// not reloading slide for fast preflight
this.slide = slide
}
this.slide = slide this.slide = slide
binding.thumbnailLoadIndicator.isVisible = slide.isInProgress 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) dimensDelegate.setDimens(naturalWidth, naturalHeight)
invalidate() invalidate()
val result = SettableFuture<Boolean>() return SettableFuture<Boolean>().also {
when { when {
slide.thumbnailUri != null -> { slide.thumbnailUri != null -> {
buildThumbnailGlideRequest(glide, slide).into(GlideDrawableListeningTarget(binding.thumbnailImage, binding.thumbnailLoadIndicator, result)) buildThumbnailGlideRequest(glide, slide).into(
GlideDrawableListeningTarget(binding.thumbnailImage, binding.thumbnailLoadIndicator, it)
)
} }
slide.hasPlaceholder() -> { slide.hasPlaceholder() -> {
buildPlaceholderGlideRequest(glide, slide).into(GlideBitmapListeningTarget(binding.thumbnailImage, null, result)) buildPlaceholderGlideRequest(glide, slide).into(
GlideBitmapListeningTarget(binding.thumbnailImage, null, it)
)
} }
else -> { else -> {
glide.clear(binding.thumbnailImage) glide.clear(binding.thumbnailImage)
result.set(false) it.set(false)
}
} }
} }
return result
} }
fun buildThumbnailGlideRequest(glide: GlideRequests, slide: Slide): GlideRequest<Drawable> { private fun buildThumbnailGlideRequest(
glide: GlideRequests,
val dimens = dimensDelegate.resourceSize() slide: Slide
): GlideRequest<Drawable> = glide.load(DecryptableUri(slide.thumbnailUri!!))
val request = glide.load(DecryptableUri(slide.thumbnailUri!!))
.diskCacheStrategy(DiskCacheStrategy.NONE) .diskCacheStrategy(DiskCacheStrategy.NONE)
.let { request -> .overrideDimensions()
if (dimens[WIDTH] == 0 || dimens[HEIGHT] == 0) {
request.override(getDefaultWidth(), getDefaultHeight())
} else {
request.override(dimens[WIDTH], dimens[HEIGHT])
}
}
.transition(DrawableTransitionOptions.withCrossFade()) .transition(DrawableTransitionOptions.withCrossFade())
.centerCrop() .transform(CenterCrop())
.missingThumbnailPicture(slide.isInProgress)
return if (slide.isInProgress) request else request.apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture)) private fun buildPlaceholderGlideRequest(
} glide: GlideRequests,
slide: Slide
fun buildPlaceholderGlideRequest(glide: GlideRequests, slide: Slide): GlideRequest<Bitmap> { ): GlideRequest<Bitmap> = glide.asBitmap()
val dimens = dimensDelegate.resourceSize()
return glide.asBitmap()
.load(slide.getPlaceholderRes(context.theme)) .load(slide.getPlaceholderRes(context.theme))
.diskCacheStrategy(DiskCacheStrategy.NONE) .diskCacheStrategy(DiskCacheStrategy.NONE)
.let { request -> .overrideDimensions()
if (dimens[WIDTH] == 0 || dimens[HEIGHT] == 0) {
request.override(getDefaultWidth(), getDefaultHeight())
} else {
request.override(dimens[WIDTH], dimens[HEIGHT])
}
}
.fitCenter() .fitCenter()
}
open fun clear(glideRequests: GlideRequests) { open fun clear(glideRequests: GlideRequests) {
glideRequests.clear(binding.thumbnailImage) glideRequests.clear(binding.thumbnailImage)
slide = null slide = null
} }
fun setImageResource(glideRequests: GlideRequests, uri: Uri): ListenableFuture<Boolean> { fun setImageResource(
val future = SettableFuture<Boolean>() glideRequests: GlideRequests,
uri: Uri
var request: GlideRequest<Drawable> = glideRequests.load(DecryptableUri(uri)) ): ListenableFuture<Boolean> = glideRequests.load(DecryptableUri(uri))
.diskCacheStrategy(DiskCacheStrategy.NONE) .diskCacheStrategy(DiskCacheStrategy.NONE)
.transition(DrawableTransitionOptions.withCrossFade()) .transition(DrawableTransitionOptions.withCrossFade())
.transform(CenterCrop())
.intoDrawableTargetAsFuture()
request = if (radius > 0) { private fun GlideRequest<Drawable>.intoDrawableTargetAsFuture() =
request.transforms(CenterCrop(), RoundedCorners(radius)) SettableFuture<Boolean>().also {
} else { binding.run {
request.transforms(CenterCrop()) GlideDrawableListeningTarget(thumbnailImage, thumbnailLoadIndicator, it)
}.let { into(it) }
} }
request.into(GlideDrawableListeningTarget(binding.thumbnailImage, binding.thumbnailLoadIndicator, future)) private fun <T> GlideRequest<T>.overrideDimensions() =
dimensDelegate.resourceSize().takeIf { 0 !in it }
?.let { override(it[WIDTH], it[HEIGHT]) }
?: override(getDefaultWidth(), getDefaultHeight())
}
return future private fun <T> GlideRequest<T>.missingThumbnailPicture(
} inProgress: Boolean
} ) = takeIf { inProgress } ?: apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture))

View File

@ -3,9 +3,12 @@ package org.thoughtcrime.securesms.database
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import org.json.JSONArray
import org.session.libsession.messaging.open_groups.GroupMember import org.session.libsession.messaging.open_groups.GroupMember
import org.session.libsession.messaging.open_groups.GroupMemberRole import org.session.libsession.messaging.open_groups.GroupMemberRole
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper 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) { 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 } return mappings.map { it.role }
} }
fun getGroupMembersRoles(groupId: String, memberIDs: Collection<String>): Map<String, List<GroupMemberRole>> {
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<GroupMember>) { fun setGroupMembers(members: List<GroupMember>) {
writableDatabase.beginTransaction() writableDatabase.beginTransaction()
try { try {

View File

@ -218,6 +218,21 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
return cursor return cursor
} }
fun getRecentChatMemberIDs(threadID: Long, limit: Int): List<String> {
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 val expireStartedMessages: Reader
get() { get() {
val where = "$EXPIRE_STARTED > 0" val where = "$EXPIRE_STARTED > 0"

View File

@ -4,6 +4,7 @@ import android.content.ContentValues
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import androidx.core.database.getStringOrNull import androidx.core.database.getStringOrNull
import org.json.JSONArray
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.utilities.AccountId import org.session.libsession.messaging.utilities.AccountId
import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Base64
@ -41,6 +42,15 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
} }
} }
fun getContacts(sessionIDs: Collection<String>): List<Contact> {
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<Contact> { fun getAllContacts(): Set<Contact> {
val database = databaseHelper.readableDatabase val database = databaseHelper.readableDatabase
return database.getAll(sessionContactTable, null, null) { cursor -> return database.getAll(sessionContactTable, null, null) { cursor ->

View File

@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.database
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import org.json.JSONArray
import org.session.libsession.messaging.jobs.AttachmentDownloadJob import org.session.libsession.messaging.jobs.AttachmentDownloadJob
import org.session.libsession.messaging.jobs.AttachmentUploadJob import org.session.libsession.messaging.jobs.AttachmentUploadJob
import org.session.libsession.messaging.jobs.BackgroundGroupAddJob 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 )) databaseHelper.writableDatabase.delete(sessionJobTable, "${Companion.jobID} = ?", arrayOf( jobID ))
} }
fun getAllJobs(type: String): Map<String, Job?> { fun getAllJobs(vararg types: String): Map<String, Job?> {
val database = databaseHelper.readableDatabase 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) val jobID = cursor.getString(jobID)
try { try {
jobID to jobFromCursor(cursor) jobID to jobFromCursor(cursor)
} catch (e: Exception) { } 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 jobID to null
} }
}.toMap() }.toMap()

View File

@ -397,8 +397,8 @@ open class Storage(
DatabaseComponent.get(context).sessionJobDatabase().markJobAsFailedPermanently(jobId) DatabaseComponent.get(context).sessionJobDatabase().markJobAsFailedPermanently(jobId)
} }
override fun getAllPendingJobs(type: String): Map<String, Job?> { override fun getAllPendingJobs(vararg types: String): Map<String, Job?> {
return DatabaseComponent.get(context).sessionJobDatabase().getAllJobs(type) return DatabaseComponent.get(context).sessionJobDatabase().getAllJobs(*types)
} }
override fun getAttachmentUploadJob(attachmentID: Long): AttachmentUploadJob? { override fun getAttachmentUploadJob(attachmentID: Long): AttachmentUploadJob? {

View File

@ -162,13 +162,7 @@ object OpenGroupManager {
val memberDatabase = DatabaseComponent.get(context).groupMemberDatabase() val memberDatabase = DatabaseComponent.get(context).groupMemberDatabase()
val standardRoles = memberDatabase.getGroupMemberRoles(groupId, standardPublicKey) val standardRoles = memberDatabase.getGroupMemberRoles(groupId, standardPublicKey)
val blindedRoles = blindedPublicKey?.let { memberDatabase.getGroupMemberRoles(groupId, it) } ?: emptyList() val blindedRoles = blindedPublicKey?.let { memberDatabase.getGroupMemberRoles(groupId, it) } ?: emptyList()
return standardRoles.any { it.isModerator } || blindedRoles.any { it.isModerator }
// roles to check against
val moderatorRoles = listOf(
GroupMemberRole.MODERATOR, GroupMemberRole.ADMIN,
GroupMemberRole.HIDDEN_MODERATOR, GroupMemberRole.HIDDEN_ADMIN
)
return standardRoles.any { it in moderatorRoles } || blindedRoles.any { it in moderatorRoles }
} }
} }

View File

@ -103,7 +103,12 @@ class ConversationView : LinearLayout {
R.drawable.ic_notifications_mentions R.drawable.ic_notifications_mentions
} }
binding.muteIndicatorImageView.setImageResource(drawableRes) 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.typeface = if (unreadCount > 0 && !thread.isRead) Typeface.DEFAULT_BOLD else Typeface.DEFAULT
binding.snippetTextView.visibility = if (isTyping) View.GONE else View.VISIBLE binding.snippetTextView.visibility = if (isTyping) View.GONE else View.VISIBLE
if (isTyping) { if (isTyping) {

View File

@ -1,5 +1,7 @@
package org.thoughtcrime.securesms.mediapreview; package org.thoughtcrime.securesms.mediapreview;
import static org.thoughtcrime.securesms.util.GeneralUtilitiesKt.toPx;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
@ -151,6 +153,8 @@ public class MediaRailAdapter extends RecyclerView.Adapter<MediaRailAdapter.Medi
{ {
image.setImageResource(glideRequests, media.getUri()); image.setImageResource(glideRequests, media.getUri());
image.setOnClickListener(v -> railItemListener.onRailItemClicked(distanceFromActive)); image.setOnClickListener(v -> railItemListener.onRailItemClicked(distanceFromActive));
// set the rounded corners
image.setRoundedCorners(toPx(5, image.getResources()));
outline.setVisibility(isActive ? View.VISIBLE : View.GONE); outline.setVisibility(isActive ? View.VISIBLE : View.GONE);

View File

@ -39,7 +39,13 @@ class MessageRequestView : LinearLayout {
binding.displayNameTextView.text = senderDisplayName binding.displayNameTextView.text = senderDisplayName
binding.timestampTextView.text = DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), thread.date) binding.timestampTextView.text = DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), thread.date)
val rawSnippet = thread.getDisplayBody(context) 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 binding.snippetTextView.text = snippet
post { post {

View File

@ -56,7 +56,6 @@ import org.session.libsignal.utilities.Util;
import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.contacts.ContactUtil; import org.thoughtcrime.securesms.contacts.ContactUtil;
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2; 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.conversation.v2.utilities.MentionUtilities;
import org.thoughtcrime.securesms.crypto.KeyPairUtilities; import org.thoughtcrime.securesms.crypto.KeyPairUtilities;
import org.thoughtcrime.securesms.database.LokiThreadDatabase; import org.thoughtcrime.securesms.database.LokiThreadDatabase;
@ -348,7 +347,6 @@ public class DefaultMessageNotifier implements MessageNotifier {
builder.setThread(notifications.get(0).getRecipient()); builder.setThread(notifications.get(0).getRecipient());
builder.setMessageCount(notificationState.getMessageCount()); 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: 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` // 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()) { while(iterator.hasPrevious()) {
NotificationItem item = iterator.previous(); NotificationItem item = iterator.previous();
builder.addMessageBody(item.getIndividualRecipient(), item.getRecipient(), 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) { if (signal) {
builder.setAlarms(notificationState.getRingtone(context), notificationState.getVibrate()); builder.setAlarms(notificationState.getRingtone(context), notificationState.getVibrate());
CharSequence text = notifications.get(0).getText();
builder.setTicker(notifications.get(0).getIndividualRecipient(), 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); builder.putStringExtra(LATEST_MESSAGE_ID_TAG, messageIdTag);

View File

@ -4,12 +4,13 @@ import android.app.Dialog
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import androidx.core.view.isGone import android.widget.Toast
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -24,17 +25,26 @@ import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
class ClearAllDataDialog : DialogFragment() { class ClearAllDataDialog : DialogFragment() {
private val TAG = "ClearAllDataDialog"
private lateinit var binding: DialogClearAllDataBinding private lateinit var binding: DialogClearAllDataBinding
enum class Steps { private enum class Steps {
INFO_PROMPT, INFO_PROMPT,
NETWORK_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) { set(value) {
field = value field = value
updateUI() updateUI()
@ -46,8 +56,8 @@ class ClearAllDataDialog : DialogFragment() {
private fun createView(): View { private fun createView(): View {
binding = DialogClearAllDataBinding.inflate(LayoutInflater.from(requireContext())) binding = DialogClearAllDataBinding.inflate(LayoutInflater.from(requireContext()))
val device = radioOption("deviceOnly", R.string.dialog_clear_all_data_clear_device_only) val device = radioOption("deviceOnly", R.string.clearDeviceOnly)
val network = radioOption("deviceAndNetwork", R.string.dialog_clear_all_data_clear_device_and_network) val network = radioOption("deviceAndNetwork", R.string.clearDeviceAndNetwork)
var selectedOption: RadioOption<String> = device var selectedOption: RadioOption<String> = device
val optionAdapter = RadioOptionAdapter { selectedOption = it } val optionAdapter = RadioOptionAdapter { selectedOption = it }
binding.recyclerView.apply { binding.recyclerView.apply {
@ -57,18 +67,21 @@ class ClearAllDataDialog : DialogFragment() {
setHasFixedSize(true) setHasFixedSize(true)
} }
optionAdapter.submitList(listOf(device, network)) optionAdapter.submitList(listOf(device, network))
binding.cancelButton.setOnClickListener { binding.cancelButton.setOnClickListener {
dismiss() dismiss()
} }
binding.clearAllDataButton.setOnClickListener { binding.clearAllDataButton.setOnClickListener {
when (step) { when (step) {
Steps.INFO_PROMPT -> if (selectedOption == network) { Steps.INFO_PROMPT -> if (selectedOption == network) {
step = Steps.NETWORK_PROMPT step = Steps.NETWORK_PROMPT
} else { } else {
clearAllData(false) clearAllData(DeletionScope.DeleteLocalDataOnly)
} }
Steps.NETWORK_PROMPT -> clearAllData(true) Steps.NETWORK_PROMPT -> clearAllData(DeletionScope.DeleteBothLocalAndNetworkData)
Steps.DELETING -> { /* do nothing intentionally */ } Steps.DELETING -> { /* do nothing intentionally */ }
Steps.RETRY_LOCAL_DELETE_ONLY_PROMPT -> clearAllData(DeletionScope.DeleteLocalDataOnly)
} }
} }
return binding.root return binding.root
@ -86,8 +99,13 @@ class ClearAllDataDialog : DialogFragment() {
binding.dialogDescriptionText.setText(R.string.dialog_clear_all_data_clear_device_and_network_confirmation) binding.dialogDescriptionText.setText(R.string.dialog_clear_all_data_clear_device_and_network_confirmation)
} }
Steps.DELETING -> { /* do nothing intentionally */ } 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.cancelButton.isVisible = !isLoading
binding.clearAllDataButton.isVisible = !isLoading binding.clearAllDataButton.isVisible = !isLoading
binding.progressBar.isVisible = isLoading binding.progressBar.isVisible = isLoading
@ -97,45 +115,55 @@ class ClearAllDataDialog : DialogFragment() {
} }
} }
private fun clearAllData(deleteNetworkMessages: Boolean) { private suspend fun performDeleteLocalDataOnlyStep() {
clearJob = lifecycleScope.launch(Dispatchers.IO) {
val previousStep = step
withContext(Dispatchers.Main) {
step = Steps.DELETING
}
if (!deleteNetworkMessages) {
try { try {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(requireContext()).get() ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(requireContext()).get()
} catch (e: Exception) { } catch (e: Exception) {
Log.e("Loki", "Failed to force sync", e) 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()
} }
ApplicationContext.getInstance(context).clearAllData() return
withContext(Dispatchers.Main) { }
ApplicationContext.getInstance(context).clearAllData().let { success ->
withContext(Main) {
if (success) {
dismiss() dismiss()
}
} else { } else {
// finish Toast.makeText(ApplicationContext.getInstance(requireContext()), R.string.errorUnknown, Toast.LENGTH_LONG).show()
val result = try { }
}
}
}
private fun clearAllData(deletionScope: DeletionScope) {
step = Steps.DELETING
clearJob = lifecycleScope.launch(Dispatchers.IO) {
when (deletionScope) {
DeletionScope.DeleteLocalDataOnly -> {
performDeleteLocalDataOnlyStep()
}
DeletionScope.DeleteBothLocalAndNetworkData -> {
val deletionResultMap: Map<String, Boolean>? = try {
val openGroups = DatabaseComponent.get(requireContext()).lokiThreadDatabase().getAllOpenGroups() val openGroups = DatabaseComponent.get(requireContext()).lokiThreadDatabase().getAllOpenGroups()
openGroups.map { it.value.server }.toSet().forEach { server -> openGroups.map { it.value.server }.toSet().forEach { server ->
OpenGroupApi.deleteAllInboxMessages(server).get() OpenGroupApi.deleteAllInboxMessages(server).get()
} }
SnodeAPI.deleteAllMessages().get() SnodeAPI.deleteAllMessages().get()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to delete network messages - offering user option to delete local data only.", e)
null null
} }
if (result == null || result.values.any { !it } || result.isEmpty()) { // If one or more deletions failed then inform the user and allow them to clear the device only if they wish..
// didn't succeed (at least one) if (deletionResultMap == null || deletionResultMap.values.any { !it } || deletionResultMap.isEmpty()) {
withContext(Dispatchers.Main) { withContext(Main) { step = Steps.RETRY_LOCAL_DELETE_ONLY_PROMPT }
step = previousStep
} }
} else if (result.values.all { it }) { else if (deletionResultMap.values.all { it }) {
// don't force sync because all the messages are deleted? // ..otherwise if the network data deletion was successful proceed to delete the local data as well.
ApplicationContext.getInstance(context).clearAllData() ApplicationContext.getInstance(context).clearAllData()
withContext(Dispatchers.Main) { withContext(Main) { dismiss() }
dismiss()
} }
} }
} }

View File

@ -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 <T> Flow<T>.timedBuffer(timeoutMillis: Long, maxItems: Int): Flow<List<T>> {
return channelFlow {
val buffer = mutableListOf<T>()
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 <T> Flow<Iterable<T>>.flatten(): Flow<T> = flatMapConcat { it.asFlow() }

View File

@ -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)
}
}

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<RelativeLayout <androidx.constraintlayout.widget.ConstraintLayout
android:focusable="false" android:focusable="false"
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
@ -13,6 +13,9 @@
android:id="@+id/toolbar" android:id="@+id/toolbar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@+id/conversationRecyclerView"
app:layout_constraintStart_toStartOf="parent"
android:background="?colorPrimary" android:background="?colorPrimary"
app:contentInsetStart="0dp"> app:contentInsetStart="0dp">
@ -31,9 +34,11 @@
android:focusable="false" android:focusable="false"
android:id="@+id/conversationRecyclerView" android:id="@+id/conversationRecyclerView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="0dp"
android:layout_above="@+id/typingIndicatorViewContainer" app:layout_constraintVertical_weight="1"
android:layout_below="@id/toolbar" /> app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toTopOf="@+id/typingIndicatorViewContainer"
app:layout_constraintTop_toBottomOf="@id/toolbar" />
<org.thoughtcrime.securesms.conversation.v2.components.TypingIndicatorViewContainer <org.thoughtcrime.securesms.conversation.v2.components.TypingIndicatorViewContainer
@ -42,20 +47,27 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="36dp" android:layout_height="36dp"
android:visibility="gone" android:visibility="gone"
android:layout_above="@+id/textSendAfterApproval" tools:visibility="visible"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/conversationRecyclerView"
app:layout_constraintBottom_toTopOf="@+id/textSendAfterApproval"
/> />
<org.thoughtcrime.securesms.conversation.v2.input_bar.InputBar <org.thoughtcrime.securesms.conversation.v2.input_bar.InputBar
android:id="@+id/inputBar" android:id="@+id/inputBar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_alignParentBottom="true" /> tools:layout_height="60dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/messageRequestBar"
app:layout_constraintBottom_toBottomOf="parent"
/>
<org.thoughtcrime.securesms.conversation.v2.search.SearchBottomBar <org.thoughtcrime.securesms.conversation.v2.search.SearchBottomBar
android:id="@+id/searchBottomBar" android:id="@+id/searchBottomBar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_alignParentBottom="true" app:layout_constraintBottom_toBottomOf="parent"
android:visibility="gone"/> android:visibility="gone"/>
<FrameLayout <FrameLayout
@ -75,11 +87,18 @@
android:inflatedId="@+id/conversation_reaction_scrubber" android:inflatedId="@+id/conversation_reaction_scrubber"
android:layout="@layout/conversation_reaction_scrubber"/> android:layout="@layout/conversation_reaction_scrubber"/>
<FrameLayout <androidx.recyclerview.widget.RecyclerView
android:id="@+id/additionalContentContainer" android:id="@+id/conversation_mention_candidates"
android:clipToOutline="true"
android:contentDescription="@string/AccessibilityId_mentions_list"
tools:listitem="@layout/view_mention_candidate_v2"
android:background="@drawable/mention_candidate_view_background"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_alignBottom="@+id/conversationRecyclerView"/> tools:visibility="gone"
app:layout_constraintHeight_max="176dp"
app:layout_constraintBottom_toBottomOf="@+id/conversationRecyclerView" />
<LinearLayout <LinearLayout
android:id="@+id/attachmentOptionsContainer" android:id="@+id/attachmentOptionsContainer"
@ -87,19 +106,19 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="@dimen/small_spacing" android:layout_marginStart="@dimen/small_spacing"
android:elevation="8dp" android:elevation="8dp"
android:layout_alignParentStart="true" app:layout_constraintStart_toStartOf="parent"
android:layout_alignParentBottom="true" app:layout_constraintBottom_toTopOf="@+id/inputBar"
android:layout_marginBottom="60dp" android:layout_marginBottom="16dp"
android:orientation="vertical"> android:orientation="vertical">
<RelativeLayout <FrameLayout
android:id="@+id/gifButtonContainer" android:id="@+id/gifButtonContainer"
android:layout_width="@dimen/input_bar_button_expanded_size" android:layout_width="@dimen/input_bar_button_expanded_size"
android:layout_height="@dimen/input_bar_button_expanded_size" android:layout_height="@dimen/input_bar_button_expanded_size"
android:contentDescription="@string/AccessibilityId_gif_button" android:contentDescription="@string/AccessibilityId_gif_button"
android:alpha="0" /> android:alpha="0" />
<RelativeLayout <FrameLayout
android:id="@+id/documentButtonContainer" android:id="@+id/documentButtonContainer"
android:layout_marginTop="8dp" android:layout_marginTop="8dp"
android:layout_width="@dimen/input_bar_button_expanded_size" android:layout_width="@dimen/input_bar_button_expanded_size"
@ -107,7 +126,7 @@
android:contentDescription="@string/AccessibilityId_documents_folder" android:contentDescription="@string/AccessibilityId_documents_folder"
android:alpha="0" /> android:alpha="0" />
<RelativeLayout <FrameLayout
android:id="@+id/libraryButtonContainer" android:id="@+id/libraryButtonContainer"
android:layout_marginTop="8dp" android:layout_marginTop="8dp"
android:layout_width="@dimen/input_bar_button_expanded_size" android:layout_width="@dimen/input_bar_button_expanded_size"
@ -115,7 +134,7 @@
android:contentDescription="@string/AccessibilityId_images_folder" android:contentDescription="@string/AccessibilityId_images_folder"
android:alpha="0" /> android:alpha="0" />
<RelativeLayout <FrameLayout
android:id="@+id/cameraButtonContainer" android:id="@+id/cameraButtonContainer"
android:layout_marginTop="8dp" android:layout_marginTop="8dp"
android:layout_width="@dimen/input_bar_button_expanded_size" android:layout_width="@dimen/input_bar_button_expanded_size"
@ -129,22 +148,26 @@
android:id="@+id/textSendAfterApproval" android:id="@+id/textSendAfterApproval"
android:text="@string/ConversationActivity_send_after_approval" android:text="@string/ConversationActivity_send_after_approval"
android:visibility="gone" android:visibility="gone"
tools:visibility="visible"
android:textAlignment="center" android:textAlignment="center"
android:textColor="@color/classic_light_2" android:textColor="@color/classic_light_2"
android:padding="22dp" android:padding="22dp"
android:textSize="12sp" android:textSize="12sp"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_alignWithParentIfMissing="true" tools:text="You'll be able to send"
android:layout_above="@id/messageRequestBar"/> app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/typingIndicatorViewContainer"
app:layout_constraintBottom_toTopOf="@+id/messageRequestBar" />
<RelativeLayout <RelativeLayout
android:id="@+id/scrollToBottomButton" android:id="@+id/scrollToBottomButton"
tools:visibility="visible"
android:visibility="gone" android:visibility="gone"
android:layout_width="40dp" android:layout_width="40dp"
android:layout_height="50dp" android:layout_height="50dp"
android:layout_alignParentEnd="true" app:layout_constraintEnd_toEndOf="parent"
android:layout_above="@+id/messageRequestBar" app:layout_constraintBottom_toTopOf="@+id/messageRequestBar"
android:layout_alignWithParentIfMissing="true" android:layout_alignWithParentIfMissing="true"
android:layout_marginEnd="12dp" android:layout_marginEnd="12dp"
android:layout_marginBottom="32dp"> android:layout_marginBottom="32dp">
@ -197,14 +220,14 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="-12dp" android:layout_marginBottom="-12dp"
android:visibility="gone" android:visibility="gone"
android:layout_alignParentBottom="true" /> app:layout_constraintBottom_toBottomOf="parent" />
<RelativeLayout <FrameLayout
android:id="@+id/blockedBanner" android:id="@+id/blockedBanner"
android:contentDescription="@string/AccessibilityId_blocked_banner" android:contentDescription="@string/AccessibilityId_blocked_banner"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_below="@+id/toolbar" app:layout_constraintTop_toBottomOf="@+id/toolbar"
android:background="@color/destructive" android:background="@color/destructive"
android:visibility="gone" android:visibility="gone"
tools:visibility="visible"> tools:visibility="visible">
@ -214,20 +237,20 @@
android:contentDescription="@string/AccessibilityId_blocked_banner_text" android:contentDescription="@string/AccessibilityId_blocked_banner_text"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_centerInParent="true" android:layout_gravity="center"
android:layout_margin="@dimen/medium_spacing" android:layout_margin="@dimen/medium_spacing"
android:textColor="@color/white" android:textColor="@color/white"
android:textSize="@dimen/small_font_size" android:textSize="@dimen/small_font_size"
android:textStyle="bold" android:textStyle="bold"
tools:text="Elon is blocked. Unblock them?" /> tools:text="Elon is blocked. Unblock them?" />
</RelativeLayout> </FrameLayout>
<RelativeLayout <FrameLayout
android:id="@+id/outdatedBanner" android:id="@+id/outdatedBanner"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_below="@+id/blockedBanner" app:layout_constraintTop_toBottomOf="@+id/blockedBanner"
android:background="@color/outdated_client_banner_background_color" android:background="@color/outdated_client_banner_background_color"
android:visibility="gone" android:visibility="gone"
tools:visibility="visible"> tools:visibility="visible">
@ -237,14 +260,14 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center_horizontal" android:gravity="center_horizontal"
android:layout_centerInParent="true" android:layout_gravity="center"
android:layout_marginVertical="@dimen/very_small_spacing" android:layout_marginVertical="@dimen/very_small_spacing"
android:layout_marginHorizontal="@dimen/medium_spacing" android:layout_marginHorizontal="@dimen/medium_spacing"
android:textColor="@color/black" android:textColor="@color/black"
android:textSize="@dimen/tiny_font_size" android:textSize="@dimen/tiny_font_size"
tools:text="This user's client is outdated, things may not work as expected" /> tools:text="This user's client is outdated, things may not work as expected" />
</RelativeLayout> </FrameLayout>
<TextView <TextView
android:padding="@dimen/medium_spacing" android:padding="@dimen/medium_spacing"
@ -254,7 +277,7 @@
android:id="@+id/placeholderText" android:id="@+id/placeholderText"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_below="@+id/blockedBanner" app:layout_constraintTop_toBottomOf="@+id/outdatedBanner"
android:elevation="8dp" android:elevation="8dp"
android:contentDescription="@string/AccessibilityId_control_message" android:contentDescription="@string/AccessibilityId_control_message"
tools:text="@string/activity_conversation_empty_state_default" tools:text="@string/activity_conversation_empty_state_default"
@ -264,11 +287,12 @@
android:id="@+id/messageRequestBar" android:id="@+id/messageRequestBar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_above="@id/inputBar" app:layout_constraintBottom_toTopOf="@+id/inputBar"
app:layout_constraintTop_toBottomOf="@+id/textSendAfterApproval"
android:layout_marginBottom="@dimen/large_spacing" android:layout_marginBottom="@dimen/large_spacing"
android:orientation="vertical" android:orientation="vertical"
android:visibility="gone" android:visibility="gone"
tools:visibility="visible"> tools:visibility="gone">
<TextView <TextView
android:id="@+id/messageRequestBlock" android:id="@+id/messageRequestBlock"
@ -322,4 +346,4 @@
</LinearLayout> </LinearLayout>
</RelativeLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -9,11 +9,6 @@
<include layout="@layout/thumbnail_view" <include layout="@layout/thumbnail_view"
android:id="@+id/album_cell_1" android:id="@+id/album_cell_1"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"/>
app:minWidth="@dimen/media_bubble_min_width"
app:maxWidth="@dimen/media_bubble_max_width"
app:minHeight="@dimen/media_bubble_min_height"
app:maxHeight="@dimen/media_bubble_max_height"
app:thumbnail_radius="1dp"/>
</FrameLayout> </FrameLayout>

View File

@ -10,14 +10,12 @@
<include layout="@layout/thumbnail_view" <include layout="@layout/thumbnail_view"
android:id="@+id/album_cell_1" android:id="@+id/album_cell_1"
android:layout_width="@dimen/album_2_cell_width" android:layout_width="@dimen/album_2_cell_width"
android:layout_height="@dimen/album_2_total_height" android:layout_height="@dimen/album_2_total_height"/>
app:thumbnail_radius="0dp"/>
<include layout="@layout/thumbnail_view" <include layout="@layout/thumbnail_view"
android:id="@+id/album_cell_2" android:id="@+id/album_cell_2"
android:layout_width="@dimen/album_2_cell_width" android:layout_width="@dimen/album_2_cell_width"
android:layout_height="@dimen/album_2_total_height" android:layout_height="@dimen/album_2_total_height"
android:layout_gravity="end" android:layout_gravity="end"/>
app:thumbnail_radius="0dp"/>
</FrameLayout> </FrameLayout>

View File

@ -9,15 +9,13 @@
<include layout="@layout/thumbnail_view" <include layout="@layout/thumbnail_view"
android:id="@+id/album_cell_1" android:id="@+id/album_cell_1"
android:layout_width="@dimen/album_3_cell_width_big" android:layout_width="@dimen/album_3_cell_width_big"
android:layout_height="@dimen/album_3_total_height" android:layout_height="@dimen/album_3_total_height"/>
app:thumbnail_radius="0dp"/>
<include layout="@layout/thumbnail_view" <include layout="@layout/thumbnail_view"
android:id="@+id/album_cell_2" android:id="@+id/album_cell_2"
android:layout_width="@dimen/album_3_cell_size_small" android:layout_width="@dimen/album_3_cell_size_small"
android:layout_height="@dimen/album_3_cell_size_small" android:layout_height="@dimen/album_3_cell_size_small"
android:layout_gravity="end|top" android:layout_gravity="end|top"/>
app:thumbnail_radius="0dp"/>
<FrameLayout <FrameLayout
@ -29,8 +27,7 @@
android:id="@+id/album_cell_3" android:id="@+id/album_cell_3"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_gravity="center_horizontal|bottom" android:layout_gravity="center_horizontal|bottom"/>
app:thumbnail_radius="0dp"/>
<TextView <TextView
tools:visibility="visible" tools:visibility="visible"

View File

@ -12,9 +12,7 @@
android:id="@+id/rail_item_image" android:id="@+id/rail_item_image"
android:layout_width="56dp" android:layout_width="56dp"
android:layout_height="56dp" android:layout_height="56dp"
android:layout_gravity="center" android:layout_gravity="center"/>
android:background="@drawable/mediarail_media_outline"
app:thumbnail_radius="5dp"/>
<ImageView <ImageView
android:id="@+id/rail_item_outline" android:id="@+id/rail_item_outline"

View File

@ -1,9 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<RelativeLayout <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="44dp" android:layout_height="44dp"
android:background="@drawable/mention_candidate_view_background"> xmlns:tools="http://schemas.android.com/tools">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
@ -42,6 +41,7 @@
android:textSize="@dimen/small_font_size" android:textSize="@dimen/small_font_size"
android:textColor="?android:textColorPrimary" android:textColor="?android:textColorPrimary"
android:maxLines="1" android:maxLines="1"
tools:text="Alice"
android:contentDescription="@string/AccessibilityId_contact_mentions" android:contentDescription="@string/AccessibilityId_contact_mentions"
android:ellipsize="end" /> android:ellipsize="end" />

View File

@ -646,8 +646,6 @@
<string name="dialog_clear_all_data_explanation">این گزینه به طور دائم پیام‌ها، جلسات و مخاطبین شما را حذف می‌کند.</string> <string name="dialog_clear_all_data_explanation">این گزینه به طور دائم پیام‌ها، جلسات و مخاطبین شما را حذف می‌کند.</string>
<string name="dialog_clear_all_data_network_explanation">آیا فقط می‌خواهید این دستگاه را پاک کنید یا می‌خواهید کل اکانت را پاک کنید؟</string> <string name="dialog_clear_all_data_network_explanation">آیا فقط می‌خواهید این دستگاه را پاک کنید یا می‌خواهید کل اکانت را پاک کنید؟</string>
<string name="dialog_clear_all_data_message">این کار پیام‌ها و مخاطبین شما را برای همیشه حذف می‌کند. آیا می‌خواهید فقط این دستگاه را پاک کنید یا داتا خود را از شبکه نیز حذف کنید?</string> <string name="dialog_clear_all_data_message">این کار پیام‌ها و مخاطبین شما را برای همیشه حذف می‌کند. آیا می‌خواهید فقط این دستگاه را پاک کنید یا داتا خود را از شبکه نیز حذف کنید?</string>
<string name="dialog_clear_all_data_clear_device_only">فقط پاک کردن دستگاه</string>
<string name="dialog_clear_all_data_clear_device_and_network">پاک کردن دستگاه و شبکه</string>
<string name="dialog_clear_all_data_clear_device_and_network_confirmation">آیا مطمئن هستید که می خواهید داتا های خود را از شبکه حذف کنید؟ اگر ادامه دهید، نمی‌توانید پیام‌ها یا مخاطبین خود را بازیابی کنید.</string> <string name="dialog_clear_all_data_clear_device_and_network_confirmation">آیا مطمئن هستید که می خواهید داتا های خود را از شبکه حذف کنید؟ اگر ادامه دهید، نمی‌توانید پیام‌ها یا مخاطبین خود را بازیابی کنید.</string>
<string name="dialog_clear_all_data_clear">پاک</string> <string name="dialog_clear_all_data_clear">پاک</string>
<string name="dialog_clear_all_data_local_only">فقط حذف شود</string> <string name="dialog_clear_all_data_local_only">فقط حذف شود</string>

View File

@ -649,8 +649,6 @@
<string name="dialog_clear_all_data_explanation">Cela supprimera définitivement vos messages, vos sessions et vos contacts.</string> <string name="dialog_clear_all_data_explanation">Cela supprimera définitivement vos messages, vos sessions et vos contacts.</string>
<string name="dialog_clear_all_data_network_explanation">Souhaitez-vous effacer seulement cet appareil ou supprimer l\'ensemble de votre compte ?</string> <string name="dialog_clear_all_data_network_explanation">Souhaitez-vous effacer seulement cet appareil ou supprimer l\'ensemble de votre compte ?</string>
<string name="dialog_clear_all_data_message">Cela supprimera définitivement vos messages, sessions et contacts. Voulez-vous uniquement effacer cet appareil ou supprimer l\'intégralité de votre compte ?</string> <string name="dialog_clear_all_data_message">Cela supprimera définitivement vos messages, sessions et contacts. Voulez-vous uniquement effacer cet appareil ou supprimer l\'intégralité de votre compte ?</string>
<string name="dialog_clear_all_data_clear_device_only">Effacer l\'appareil uniquement</string>
<string name="dialog_clear_all_data_clear_device_and_network">Effacer l\'appareil et le réseau</string>
<string name="dialog_clear_all_data_clear_device_and_network_confirmation">Ê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.</string> <string name="dialog_clear_all_data_clear_device_and_network_confirmation">Ê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.</string>
<string name="dialog_clear_all_data_clear">Effacer</string> <string name="dialog_clear_all_data_clear">Effacer</string>
<string name="dialog_clear_all_data_local_only">Effacer seulement</string> <string name="dialog_clear_all_data_local_only">Effacer seulement</string>

View File

@ -649,8 +649,6 @@
<string name="dialog_clear_all_data_explanation">Cela supprimera définitivement vos messages, vos sessions et vos contacts.</string> <string name="dialog_clear_all_data_explanation">Cela supprimera définitivement vos messages, vos sessions et vos contacts.</string>
<string name="dialog_clear_all_data_network_explanation">Souhaitez-vous effacer seulement cet appareil ou supprimer l\'ensemble de votre compte ?</string> <string name="dialog_clear_all_data_network_explanation">Souhaitez-vous effacer seulement cet appareil ou supprimer l\'ensemble de votre compte ?</string>
<string name="dialog_clear_all_data_message">Cela supprimera définitivement vos messages, sessions et contacts. Voulez-vous uniquement effacer cet appareil ou supprimer l\'intégralité de votre compte ?</string> <string name="dialog_clear_all_data_message">Cela supprimera définitivement vos messages, sessions et contacts. Voulez-vous uniquement effacer cet appareil ou supprimer l\'intégralité de votre compte ?</string>
<string name="dialog_clear_all_data_clear_device_only">Effacer l\'appareil uniquement</string>
<string name="dialog_clear_all_data_clear_device_and_network">Effacer l\'appareil et le réseau</string>
<string name="dialog_clear_all_data_clear_device_and_network_confirmation">Ê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.</string> <string name="dialog_clear_all_data_clear_device_and_network_confirmation">Ê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.</string>
<string name="dialog_clear_all_data_clear">Effacer</string> <string name="dialog_clear_all_data_clear">Effacer</string>
<string name="dialog_clear_all_data_local_only">Effacer seulement</string> <string name="dialog_clear_all_data_local_only">Effacer seulement</string>

View File

@ -822,8 +822,6 @@
<string name="dialog_clear_all_data_explanation">This will permanently delete your messages, sessions, and contacts.</string> <string name="dialog_clear_all_data_explanation">This will permanently delete your messages, sessions, and contacts.</string>
<string name="dialog_clear_all_data_network_explanation">Would you like to clear only this device, or delete your entire account?</string> <string name="dialog_clear_all_data_network_explanation">Would you like to clear only this device, or delete your entire account?</string>
<string name="dialog_clear_all_data_message">This will permanently delete your messages, sessions, and contacts. Would you like to clear only this device, or delete your entire account?</string> <string name="dialog_clear_all_data_message">This will permanently delete your messages, sessions, and contacts. Would you like to clear only this device, or delete your entire account?</string>
<string name="dialog_clear_all_data_clear_device_only">Clear Device Only</string>
<string name="dialog_clear_all_data_clear_device_and_network">Clear Device and Network</string>
<string name="dialog_clear_all_data_clear_device_and_network_confirmation">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.</string> <string name="dialog_clear_all_data_clear_device_and_network_confirmation">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.</string>
<string name="dialog_clear_all_data_clear">Clear</string> <string name="dialog_clear_all_data_clear">Clear</string>
<string name="dialog_clear_all_data_local_only">Delete Only</string> <string name="dialog_clear_all_data_local_only">Delete Only</string>

View File

@ -23,6 +23,7 @@ import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.BaseViewModelTest import org.thoughtcrime.securesms.BaseViewModelTest
import org.thoughtcrime.securesms.NoOpLogger import org.thoughtcrime.securesms.NoOpLogger
import org.thoughtcrime.securesms.database.MmsDatabase
import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.repository.ConversationRepository
@ -32,6 +33,7 @@ class ConversationViewModelTest: BaseViewModelTest() {
private val repository = mock<ConversationRepository>() private val repository = mock<ConversationRepository>()
private val storage = mock<Storage>() private val storage = mock<Storage>()
private val mmsDatabase = mock<MmsDatabase>()
private val threadId = 123L private val threadId = 123L
private val edKeyPair = mock<KeyPair>() private val edKeyPair = mock<KeyPair>()
@ -39,7 +41,7 @@ class ConversationViewModelTest: BaseViewModelTest() {
private lateinit var messageRecord: MessageRecord private lateinit var messageRecord: MessageRecord
private val viewModel: ConversationViewModel by lazy { private val viewModel: ConversationViewModel by lazy {
ConversationViewModel(threadId, edKeyPair, repository, storage) ConversationViewModel(threadId, edKeyPair, repository, storage, mock(), mmsDatabase)
} }
@Before @Before

View File

@ -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)
}

View File

@ -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<GroupMemberRole>
)
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<Recipient> {
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<String>
memberContacts.filter { contact -> contact.accountID in ids }
}
},
memberDatabase = mock {
on { getGroupMembersRoles(eq(openGroup.id), any()) } doAnswer {
val memberIDs = it.arguments[1] as Collection<String>
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 ")
}
}
}

View File

@ -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])
}
}

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8" ?>
<manifest>
</manifest>

View File

@ -0,0 +1,3 @@
manifest=TestAndroidManifest.xml
sdk=34
application=android.app.Application

View File

@ -53,7 +53,7 @@ interface StorageProtocol {
fun persistJob(job: Job) fun persistJob(job: Job)
fun markJobAsSucceeded(jobId: String) fun markJobAsSucceeded(jobId: String)
fun markJobAsFailedPermanently(jobId: String) fun markJobAsFailedPermanently(jobId: String)
fun getAllPendingJobs(type: String): Map<String,Job?> fun getAllPendingJobs(vararg types: String): Map<String,Job?>
fun getAttachmentUploadJob(attachmentID: Long): AttachmentUploadJob? fun getAttachmentUploadJob(attachmentID: Long): AttachmentUploadJob?
fun getMessageSendJob(messageSendJobID: String): MessageSendJob? fun getMessageSendJob(messageSendJobID: String): MessageSendJob?
fun getMessageReceiveJob(messageReceiveJobID: String): Job? fun getMessageReceiveJob(messageReceiveJobID: String): Job?

View File

@ -1,6 +1,8 @@
package org.session.libsession.messaging.jobs package org.session.libsession.messaging.jobs
import okhttp3.HttpUrl 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.MessagingModuleConfiguration
import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId 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 // Keys used for database storage
private val ATTACHMENT_ID_KEY = "attachment_id" private val ATTACHMENT_ID_KEY = "attachment_id"
private val TS_INCOMING_MESSAGE_ID_KEY = "tsIncoming_message_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) { override suspend fun execute(dispatcherName: String) {
@ -88,21 +120,7 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long)
return return
} }
val threadRecipient = storage.getRecipientForThread(threadID) if (!eligibleForDownload(threadID, storage, messageDataProvider, databaseMessageID)) {
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
handleFailure(Error.NoSender, null) handleFailure(Error.NoSender, null)
return return
} }

View File

@ -61,7 +61,7 @@ data class ConfigurationSyncJob(val destination: Destination): Job {
SharedConfigurationMessage(config.protoKindFor(), data, seqNo) to config SharedConfigurationMessage(config.protoKindFor(), data, seqNo) to config
}.map { (message, config) -> }.map { (message, config) ->
// return a list of batch request objects // 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( val authenticated = SnodeAPI.buildAuthenticatedStoreBatchInfo(
destination.destinationPublicKey(), destination.destinationPublicKey(),
config.configNamespace(), config.configNamespace(),

View File

@ -102,7 +102,7 @@ class JobQueue : JobDelegate {
execute(dispatcherName) execute(dispatcherName)
} }
catch (e: Exception) { 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) this@JobQueue.handleJobFailed(this, dispatcherName, e)
} }
} }

View File

@ -6,6 +6,11 @@ data class GroupMember(
val role: GroupMemberRole val role: GroupMemberRole
) )
enum class GroupMemberRole { enum class GroupMemberRole(val isModerator: Boolean = false) {
STANDARD, ZOOMBIE, MODERATOR, ADMIN, HIDDEN_MODERATOR, HIDDEN_ADMIN STANDARD,
ZOOMBIE,
MODERATOR(true),
ADMIN(true),
HIDDEN_MODERATOR(true),
HIDDEN_ADMIN(true),
} }

View File

@ -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 // One-on-One Chats & Closed Groups
@Throws(Exception::class) @Throws(Exception::class)
fun buildWrappedMessageToSnode(destination: Destination, message: Message, isSyncMessage: Boolean): SnodeMessage { fun buildWrappedMessageToSnode(destination: Destination, message: Message, isSyncMessage: Boolean): SnodeMessage {

View File

@ -25,6 +25,7 @@ import org.session.libsession.snode.RawResponse
import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeAPI
import org.session.libsession.snode.SnodeModule import org.session.libsession.snode.SnodeModule
import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ConfigFactoryProtocol
import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.Namespace import org.session.libsignal.utilities.Namespace
import org.session.libsignal.utilities.Snode 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?) { private fun processConfig(snode: Snode, rawMessages: RawResponse, namespace: Int, forConfigObject: ConfigBase?) {
if (forConfigObject == null) return if (forConfigObject == null) return
val messages = SnodeAPI.parseRawMessagesResponse( val messages = rawMessages["messages"] as? List<*>
rawMessages, val processed = if (!messages.isNullOrEmpty()) {
snode, SnodeAPI.updateLastMessageHashValueIfPossible(snode, userPublicKey, messages, namespace)
userPublicKey, SnodeAPI.removeDuplicates(userPublicKey, messages, namespace, true).mapNotNull { messageBody ->
namespace, val rawMessageAsJSON = messageBody as? Map<*, *> ?: return@mapNotNull null
updateLatestHash = true, val hashValue = rawMessageAsJSON["hash"] as? String ?: return@mapNotNull null
updateStoredHashes = true, val b64EncodedBody = rawMessageAsJSON["data"] as? String ?: return@mapNotNull null
) val timestamp = rawMessageAsJSON["t"] as? Long ?: SnodeAPI.nowWithOffset
val body = Base64.decode(b64EncodedBody)
if (messages.isEmpty()) { Triple(body, hashValue, timestamp)
// no new messages to process
return
} }
} else emptyList()
if (processed.isEmpty()) return
var latestMessageTimestamp: Long? = null var latestMessageTimestamp: Long? = null
messages.forEach { (envelope, hash) -> processed.forEach { (body, hash, timestamp) ->
try { try {
val (message, _) = MessageReceiver.parse(data = envelope.toByteArray(), forConfigObject.merge(hash to body)
// assume no groups in personal poller messages latestMessageTimestamp = if (timestamp > (latestMessageTimestamp ?: 0L)) { timestamp } else { latestMessageTimestamp }
openGroupServerID = null, currentClosedGroups = emptySet()
)
// sanity checks
if (message !is SharedConfigurationMessage) {
Log.w("Loki", "shared config message handled in configs wasn't SharedConfigurationMessage but was ${message.javaClass.simpleName}")
return@forEach
}
val merged = forConfigObject.merge(hash!! to message.data).firstOrNull { it == hash }
if (merged != null) {
// We successfully merged the hash, we can now update the timestamp
latestMessageTimestamp = if ((message.sentTimestamp ?: 0L) > (latestMessageTimestamp ?: 0L)) { message.sentTimestamp } else { latestMessageTimestamp }
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e("Loki", e) Log.e("Loki", e)
} }

View File

@ -25,7 +25,6 @@ import org.session.libsignal.utilities.Snode
import org.session.libsignal.utilities.ThreadUtils import org.session.libsignal.utilities.ThreadUtils
import org.session.libsignal.utilities.recover import org.session.libsignal.utilities.recover
import org.session.libsignal.utilities.toHexString import org.session.libsignal.utilities.toHexString
import java.util.Date
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
import kotlin.collections.set import kotlin.collections.set

View File

@ -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 lastMessageAsJSON = rawMessages.lastOrNull() as? Map<*, *>
val hashValue = lastMessageAsJSON?.get("hash") as? String val hashValue = lastMessageAsJSON?.get("hash") as? String
if (hashValue != null) { 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 originalMessageHashValues = database.getReceivedMessageHashValues(publicKey, namespace)?.toMutableSet() ?: mutableSetOf()
val receivedMessageHashValues = originalMessageHashValues.toMutableSet() val receivedMessageHashValues = originalMessageHashValues.toMutableSet()
val result = rawMessages.filter { rawMessage -> val result = rawMessages.filter { rawMessage ->

View File

@ -73,4 +73,10 @@
<string name="ConversationItem_group_action_left">%1$s has left the group.</string> <string name="ConversationItem_group_action_left">%1$s has left the group.</string>
<!-- RecipientProvider --> <!-- RecipientProvider -->
<string name="RecipientProvider_unnamed_group">Unnamed group</string> <string name="RecipientProvider_unnamed_group">Unnamed group</string>
<string name="clearDataErrorDescriptionGeneric">An unknown error occurred and your data was not deleted. Do you want to delete your data from just this device instead?</string>
<string name="errorUnknown">An unknown error occurred.</string>
<string name="clearDevice">Clear Device</string>
<string name="clearDeviceOnly">Clear device only</string>
<string name="clearDeviceAndNetwork">Clear device and network</string>
</resources> </resources>

View File

@ -4,7 +4,6 @@ import android.os.Process
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import java.util.concurrent.ExecutorService import java.util.concurrent.ExecutorService
import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.SynchronousQueue
import java.util.concurrent.ThreadPoolExecutor import java.util.concurrent.ThreadPoolExecutor
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.EmptyCoroutineContext