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'
androidTestUtil 'androidx.test:orchestrator:1.4.2'
testImplementation 'org.robolectric:robolectric:4.4'
testImplementation 'org.robolectric:shadows-multidex:4.4'
testImplementation 'org.robolectric:robolectric:4.12.2'
testImplementation 'org.robolectric:shadows-multidex:4.12.2'
testImplementation 'org.conscrypt:conscrypt-openjdk-uber:2.5.2' // For Robolectric
testImplementation 'app.cash.turbine:turbine:1.1.0'
implementation 'com.github.bumptech.glide:compose:1.0.0-alpha.5'
implementation "androidx.compose.ui:ui:$composeVersion"

View File

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

View File

@ -216,7 +216,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
DatabaseModule.init(this);
MessagingModuleConfiguration.configure(this);
super.onCreate();
messagingModuleConfiguration = new MessagingModuleConfiguration(
this,
storage,
@ -504,15 +503,23 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
});
}
// Method to clear the local data - returns true on success otherwise false
/**
* Clear all local profile data and message history then restart the app after a brief delay.
* @return true on success, false otherwise.
*/
@SuppressLint("ApplySharedPref")
public void clearAllData() {
public boolean clearAllData() {
TextSecurePreferences.clearAll(this);
getSharedPreferences(PREFERENCES_NAME, 0).edit().clear().commit();
if (!deleteDatabase(SQLCipherOpenHelper.DATABASE_NAME)) {
Log.d("Loki", "Failed to delete database.");
return false;
}
configFactory.keyPairChanged();
Util.runOnMain(() -> new Handler().postDelayed(ApplicationContext.this::restartApplication, 200));
return true;
}
public void restartApplication() {

View File

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

View File

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

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

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.MotionEvent
import android.view.View
import android.view.ViewGroup.LayoutParams
import android.view.WindowManager
import android.widget.RelativeLayout
import android.widget.Toast
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@ -46,6 +46,7 @@ import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.loader.app.LoaderManager
import androidx.loader.content.Loader
import androidx.recyclerview.widget.LinearLayoutManager
@ -67,8 +68,6 @@ import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.jobs.AttachmentDownloadJob
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.mentions.Mention
import org.session.libsession.messaging.mentions.MentionsManager
import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.messaging.messages.applyExpiryMode
import org.session.libsession.messaging.messages.control.DataExtractionNotification
@ -117,7 +116,8 @@ import org.thoughtcrime.securesms.conversation.v2.dialogs.LinkPreviewDialog
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarButton
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarDelegate
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarRecordingViewDelegate
import org.thoughtcrime.securesms.conversation.v2.input_bar.mentions.MentionCandidatesView
import org.thoughtcrime.securesms.conversation.v2.input_bar.mentions.MentionCandidateAdapter
import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallback
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallbackDelegate
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper
@ -215,6 +215,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
@Inject lateinit var storage: Storage
@Inject lateinit var reactionDb: ReactionDatabase
@Inject lateinit var viewModelFactory: ConversationViewModel.AssistedFactory
@Inject lateinit var mentionViewModelFactory: MentionViewModel.AssistedFactory
private val screenshotObserver by lazy {
ScreenshotObserver(this, Handler(Looper.getMainLooper())) {
@ -228,7 +229,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
ViewModelProvider(this, LinkPreviewViewModel.Factory(LinkPreviewRepository()))
.get(LinkPreviewViewModel::class.java)
}
private val viewModel: ConversationViewModel by viewModels {
private val threadId: Long by lazy {
var threadId = intent.getLongExtra(THREAD_ID, -1L)
if (threadId == -1L) {
intent.getParcelableExtra<Address>(ADDRESS)?.let { it ->
@ -248,6 +250,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
} ?: finish()
}
threadId
}
private val viewModel: ConversationViewModel by viewModels {
viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair())
}
private var actionMode: ActionMode? = null
@ -260,11 +267,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private var isLockViewExpanded = false
private var isShowingAttachmentOptions = false
// Mentions
private val mentions = mutableListOf<Mention>()
private var mentionCandidatesView: MentionCandidatesView? = null
private var previousText: CharSequence = ""
private var currentMentionStartIndex = -1
private var isShowingMentionCandidatesView = false
private val mentionViewModel: MentionViewModel by viewModels {
mentionViewModelFactory.create(threadId)
}
private val mentionCandidateAdapter = MentionCandidateAdapter {
mentionViewModel.onCandidateSelected(it.member.publicKey)
}
// Search
val searchViewModel: SearchViewModel by viewModels()
var searchViewItem: MenuItem? = null
@ -325,11 +333,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
onDeselect(message, position, it)
}
},
onAttachmentNeedsDownload = { attachmentId, mmsId ->
lifecycleScope.launch(Dispatchers.IO) {
JobQueue.shared.add(AttachmentDownloadJob(attachmentId, mmsId))
}
},
onAttachmentNeedsDownload = viewModel::onAttachmentDownloadRequest,
glide = glide,
lifecycleCoroutineScope = lifecycleScope
)
@ -486,6 +490,27 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
}
}
setupMentionView()
}
private fun setupMentionView() {
binding?.conversationMentionCandidates?.let { view ->
view.adapter = mentionCandidateAdapter
view.itemAnimator = null
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
mentionViewModel.autoCompleteState
.collectLatest { state ->
mentionCandidateAdapter.candidates =
(state as? MentionViewModel.AutoCompleteState.Result)?.members.orEmpty()
}
}
}
binding?.inputBar?.setInputBarEditableFactory(mentionViewModel.editableFactory)
}
override fun onResume() {
@ -642,23 +667,19 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
binding.inputBar.delegate = this
binding.inputBarRecordingView.delegate = this
// GIF button
binding.gifButtonContainer.addView(gifButton)
gifButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
binding.gifButtonContainer.addView(gifButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
gifButton.onUp = { showGIFPicker() }
gifButton.snIsEnabled = false
// Document button
binding.documentButtonContainer.addView(documentButton)
documentButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
binding.documentButtonContainer.addView(documentButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
documentButton.onUp = { showDocumentPicker() }
documentButton.snIsEnabled = false
// Library button
binding.libraryButtonContainer.addView(libraryButton)
libraryButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
binding.libraryButtonContainer.addView(libraryButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
libraryButton.onUp = { pickFromLibrary() }
libraryButton.snIsEnabled = false
// Camera button
binding.cameraButtonContainer.addView(cameraButton)
cameraButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
binding.cameraButtonContainer.addView(cameraButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
cameraButton.onUp = { showCamera() }
cameraButton.snIsEnabled = false
}
@ -910,7 +931,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
if (textSecurePreferences.isLinkPreviewsEnabled()) {
linkPreviewViewModel.onTextChanged(this, inputBarText, 0, 0)
}
showOrHideMentionCandidatesIfNeeded(newContent)
if (LinkPreviewUtil.findWhitelistedUrls(newContent.toString()).isNotEmpty()
&& !textSecurePreferences.isLinkPreviewsEnabled() && !textSecurePreferences.hasSeenLinkPreviewSuggestionDialog()) {
LinkPreviewDialog {
@ -922,76 +942,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
}
private fun showOrHideMentionCandidatesIfNeeded(text: CharSequence) {
if (text.length < previousText.length) {
currentMentionStartIndex = -1
hideMentionCandidates()
val mentionsToRemove = mentions.filter { !text.contains(it.displayName) }
mentions.removeAll(mentionsToRemove)
}
if (text.isNotEmpty()) {
val lastCharIndex = text.lastIndex
val lastChar = text[lastCharIndex]
// Check if there is whitespace before the '@' or the '@' is the first character
val isCharacterBeforeLastWhiteSpaceOrStartOfLine: Boolean
if (text.length == 1) {
isCharacterBeforeLastWhiteSpaceOrStartOfLine = true // Start of line
} else {
val charBeforeLast = text[lastCharIndex - 1]
isCharacterBeforeLastWhiteSpaceOrStartOfLine = Character.isWhitespace(charBeforeLast)
}
if (lastChar == '@' && isCharacterBeforeLastWhiteSpaceOrStartOfLine) {
currentMentionStartIndex = lastCharIndex
showOrUpdateMentionCandidatesIfNeeded()
} else if (Character.isWhitespace(lastChar) || lastChar == '@') { // the lastCharacter == "@" is to check for @@
currentMentionStartIndex = -1
hideMentionCandidates()
} else if (currentMentionStartIndex != -1) {
val query = text.substring(currentMentionStartIndex + 1) // + 1 to get rid of the "@"
showOrUpdateMentionCandidatesIfNeeded(query)
}
} else {
currentMentionStartIndex = -1
hideMentionCandidates()
}
previousText = text
}
private fun showOrUpdateMentionCandidatesIfNeeded(query: String = "") {
val additionalContentContainer = binding?.additionalContentContainer ?: return
val recipient = viewModel.recipient ?: return
if (!isShowingMentionCandidatesView) {
additionalContentContainer.removeAllViews()
val view = MentionCandidatesView(this).apply {
contentDescription = context.getString(R.string.AccessibilityId_mentions_list)
}
view.glide = glide
view.onCandidateSelected = { handleMentionSelected(it) }
additionalContentContainer.addView(view)
val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, recipient.isCommunityRecipient)
this.mentionCandidatesView = view
view.show(candidates, viewModel.threadId)
} else {
val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, recipient.isCommunityRecipient)
this.mentionCandidatesView!!.setMentionCandidates(candidates)
}
isShowingMentionCandidatesView = true
}
private fun hideMentionCandidates() {
if (isShowingMentionCandidatesView) {
val mentionCandidatesView = mentionCandidatesView ?: return
val animation = ValueAnimator.ofObject(FloatEvaluator(), mentionCandidatesView.alpha, 0.0f)
animation.duration = 250L
animation.addUpdateListener { animator ->
mentionCandidatesView.alpha = animator.animatedValue as Float
if (animator.animatedFraction == 1.0f) { binding?.additionalContentContainer?.removeAllViews() }
}
animation.start()
}
isShowingMentionCandidatesView = false
}
override fun toggleAttachmentOptions() {
val targetAlpha = if (isShowingAttachmentOptions) 0.0f else 1.0f
val allButtonContainers = listOfNotNull(
@ -1507,18 +1457,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
return hitRect.contains(x, y)
}
private fun handleMentionSelected(mention: Mention) {
val binding = binding ?: return
if (currentMentionStartIndex == -1) { return }
mentions.add(mention)
val previousText = binding.inputBar.text
val newText = previousText.substring(0, currentMentionStartIndex) + "@" + mention.displayName + " "
binding.inputBar.text = newText
binding.inputBar.setSelection(newText.length)
currentMentionStartIndex = -1
hideMentionCandidates()
this.previousText = newText
}
override fun scrollToMessageIfPossible(timestamp: Long) {
val lastSeenItemPosition = adapter.getItemPositionForTimestamp(timestamp) ?: return
@ -1615,10 +1553,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
binding?.inputBar?.text = ""
binding?.inputBar?.cancelQuoteDraft()
binding?.inputBar?.cancelLinkPreviewDraft()
// Clear mentions
previousText = ""
currentMentionStartIndex = -1
mentions.clear()
// Put the message in the database
message.id = smsDb.insertMessageOutbox(viewModel.threadId, outgoingTextMessage, false, message.sentTimestamp!!, null, true)
// Send it
@ -1663,10 +1597,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
binding?.inputBar?.text = ""
binding?.inputBar?.cancelQuoteDraft()
binding?.inputBar?.cancelLinkPreviewDraft()
// Clear mentions
previousText = ""
currentMentionStartIndex = -1
mentions.clear()
// Reset the attachment manager
attachmentManager.clear()
// Reset attachments button if needed
@ -1953,7 +1883,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val messageIterator = sortedMessages.iterator()
while (messageIterator.hasNext()) {
val message = messageIterator.next()
val body = MentionUtilities.highlightMentions(message.body, viewModel.threadId, this)
val body = MentionUtilities.highlightMentions(
text = message.body,
formatOnly = true, // no styling here, only text formatting
threadID = viewModel.threadId,
context = this
)
if (TextUtils.isEmpty(body)) { continue }
if (messageSize > 1) {
val formattedTimestamp = DateUtils.getDisplayFormattedTimeSpanString(this, Locale.getDefault(), message.timestamp)
@ -2094,17 +2030,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
// region General
private fun getMessageBody(): String {
var result = binding?.inputBar?.text?.trim() ?: return ""
for (mention in mentions) {
try {
val startIndex = result.indexOf("@" + mention.displayName)
val endIndex = startIndex + mention.displayName.count() + 1 // + 1 to include the "@"
result = result.substring(0, startIndex) + "@" + mention.publicKey + result.substring(endIndex)
} catch (exception: Exception) {
Log.d("Loki", "Failed to process mention due to error: $exception")
}
}
return result
return mentionViewModel.normalizeMessageBody()
}
// endregion

View File

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

View File

@ -1,46 +1,44 @@
package org.thoughtcrime.securesms.conversation.v2
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.goterl.lazysodium.utils.KeyPair
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.session.libsession.database.MessageDataProvider
import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.session.libsession.messaging.utilities.AccountId
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.database.MmsDatabase
import org.thoughtcrime.securesms.audio.AudioSlidePlayer
import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.repository.ConversationRepository
import java.util.UUID
class ConversationViewModel(
val threadId: Long,
val edKeyPair: KeyPair?,
private val repository: ConversationRepository,
private val storage: Storage
private val storage: Storage,
private val messageDataProvider: MessageDataProvider,
database: MmsDatabase,
) : ViewModel() {
val showSendAfterApprovalText: Boolean
@ -92,6 +90,11 @@ class ConversationViewModel(
// allow reactions if the open group is null (normal conversations) or the open group's capabilities include reactions
get() = (openGroup == null || OpenGroupApi.Capability.REACTIONS.name.lowercase() in serverCapabilities)
private val attachmentDownloadHandler = AttachmentDownloadHandler(
storage = storage,
messageDataProvider = messageDataProvider,
scope = viewModelScope,
)
init {
viewModelScope.launch(Dispatchers.IO) {
@ -265,6 +268,10 @@ class ConversationViewModel(
storage.getLastLegacyRecipient(address.serialize())?.let { Recipient.from(context, Address.fromSerialized(it), false) }
}
fun onAttachmentDownloadRequest(attachment: DatabaseAttachment) {
attachmentDownloadHandler.onAttachmentDownloadRequest(attachment)
}
@dagger.assisted.AssistedFactory
interface AssistedFactory {
fun create(threadId: Long, edKeyPair: KeyPair?): Factory
@ -275,11 +282,20 @@ class ConversationViewModel(
@Assisted private val threadId: Long,
@Assisted private val edKeyPair: KeyPair?,
private val repository: ConversationRepository,
private val storage: Storage
private val storage: Storage,
private val mmsDatabase: MmsDatabase,
private val messageDataProvider: MessageDataProvider,
) : ViewModelProvider.Factory {
override fun <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 network.loki.messenger.R
import network.loki.messenger.databinding.ViewVisibleMessageContentBinding
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.MediaPreviewActivity.getPreviewIntent
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.database.Storage
@ -146,7 +147,7 @@ fun MessageDetails(
onResend: (() -> Unit)? = null,
onDelete: () -> Unit = {},
onClickImage: (Int) -> Unit = {},
onAttachmentNeedsDownload: (Long, Long) -> Unit = { _, _ -> }
onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit = { _ -> }
) {
Column(
modifier = Modifier

View File

@ -124,7 +124,7 @@ class MessageDetailsViewModel @Inject constructor(
if (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED) {
// Restart download here (on IO thread)
(slide.asAttachment() as? DatabaseAttachment)?.let { attachment ->
onAttachmentNeedsDownload(attachment.attachmentId.rowId, state.mmsRecord.getId())
onAttachmentNeedsDownload(attachment)
}
}
@ -137,9 +137,9 @@ class MessageDetailsViewModel @Inject constructor(
}
}
fun onAttachmentNeedsDownload(attachmentId: Long, mmsId: Long) {
fun onAttachmentNeedsDownload(attachment: DatabaseAttachment) {
viewModelScope.launch(Dispatchers.IO) {
JobQueue.shared.add(AttachmentDownloadJob(attachmentId, mmsId))
JobQueue.shared.add(AttachmentDownloadJob(attachment.attachmentId.rowId, attachment.mmsId))
}
}
}

View File

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

View File

@ -31,10 +31,10 @@ class LinkPreviewDraftView : LinearLayout {
// Hide the loader and show the content view
binding.linkPreviewDraftContainer.isVisible = true
binding.linkPreviewDraftLoader.isVisible = false
binding.thumbnailImageView.root.radius = toPx(4, resources)
binding.thumbnailImageView.root.setRoundedCorners(toPx(4, resources))
if (linkPreview.getThumbnail().isPresent) {
// This internally fetches the thumbnail
binding.thumbnailImageView.root.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), false, null)
binding.thumbnailImageView.root.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), false)
}
binding.linkPreviewDraftTitleTextView.text = linkPreview.title
}

View File

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

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
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.RelativeLayout
import network.loki.messenger.databinding.ViewMentionCandidateV2Binding
import org.session.libsession.messaging.mentions.Mention
import org.thoughtcrime.securesms.groups.OpenGroupManager
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel
class MentionCandidateView : RelativeLayout {
private lateinit var binding: ViewMentionCandidateV2Binding
var candidate = Mention("", "")
set(newValue) { field = newValue; update() }
var glide: GlideRequests? = null
var openGroupServer: String? = null
var openGroupRoom: String? = null
constructor(context: Context) : this(context, null)
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() }
private fun initialize() {
binding = ViewMentionCandidateV2Binding.inflate(LayoutInflater.from(context), this, true)
}
private fun update() = with(binding) {
mentionCandidateNameTextView.text = candidate.displayName
profilePictureView.publicKey = candidate.publicKey
profilePictureView.displayName = candidate.displayName
fun ViewMentionCandidateV2Binding.update(candidate: MentionViewModel.Candidate) {
mentionCandidateNameTextView.text = candidate.nameHighlighted
profilePictureView.publicKey = candidate.member.publicKey
profilePictureView.displayName = candidate.member.name
profilePictureView.additionalPublicKey = null
profilePictureView.update()
if (openGroupServer != null && openGroupRoom != null) {
val isUserModerator = OpenGroupManager.isUserModerator(context, "$openGroupRoom.$openGroupServer", candidate.publicKey)
moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE
} else {
moderatorIconImageView.visibility = View.GONE
}
}
moderatorIconImageView.visibility = if (candidate.member.isModerator) View.VISIBLE else 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
if (linkPreview.getThumbnail().isPresent) {
// This internally fetches the thumbnail
binding.thumbnailImageView.root.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), isPreview = false, message)
binding.thumbnailImageView.root.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), isPreview = false)
binding.thumbnailImageView.root.loadIndicator.isVisible = false
}
// Title

View File

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

View File

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

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

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

View File

@ -2,10 +2,13 @@ package org.thoughtcrime.securesms.conversation.v2.utilities
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Outline
import android.graphics.drawable.Drawable
import android.net.Uri
import android.util.AttributeSet
import android.util.TypedValue
import android.view.View
import android.view.ViewOutlineProvider
import android.widget.FrameLayout
import androidx.core.view.isVisible
import com.bumptech.glide.load.engine.DiskCacheStrategy
@ -21,18 +24,17 @@ import org.session.libsignal.utilities.ListenableFuture
import org.session.libsignal.utilities.SettableFuture
import org.thoughtcrime.securesms.components.GlideBitmapListeningTarget
import org.thoughtcrime.securesms.components.GlideDrawableListeningTarget
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri
import org.thoughtcrime.securesms.mms.GlideRequest
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.mms.Slide
import kotlin.Boolean
import kotlin.Int
import kotlin.getValue
import kotlin.lazy
import kotlin.let
open class ThumbnailView: FrameLayout {
open class ThumbnailView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
companion object {
private const val WIDTH = 0
private const val HEIGHT = 1
@ -41,29 +43,28 @@ open class ThumbnailView: FrameLayout {
private val binding: ThumbnailViewBinding by lazy { ThumbnailViewBinding.bind(this) }
// region Lifecycle
constructor(context: Context) : super(context) { initialize(null) }
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize(attrs) }
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize(attrs) }
val loadIndicator: View by lazy { binding.thumbnailLoadIndicator }
private val dimensDelegate = ThumbnailDimensDelegate()
private var slide: Slide? = null
var radius: Int = 0
private fun initialize(attrs: AttributeSet?) {
if (attrs != null) {
val typedArray = context.theme.obtainStyledAttributes(attrs, R.styleable.ThumbnailView, 0, 0)
init {
attrs?.let { context.theme.obtainStyledAttributes(it, R.styleable.ThumbnailView, 0, 0) }
?.apply {
dimensDelegate.setBounds(
getDimensionPixelSize(R.styleable.ThumbnailView_minWidth, 0),
getDimensionPixelSize(R.styleable.ThumbnailView_minHeight, 0),
getDimensionPixelSize(R.styleable.ThumbnailView_maxWidth, 0),
getDimensionPixelSize(R.styleable.ThumbnailView_maxHeight, 0)
)
dimensDelegate.setBounds(typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_minWidth, 0),
typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_minHeight, 0),
typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxWidth, 0),
typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxHeight, 0))
setRoundedCorners(
getDimensionPixelSize(R.styleable.ThumbnailView_thumbnail_radius, 0)
)
radius = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_thumbnail_radius, 0)
typedArray.recycle()
recycle()
}
}
@ -84,114 +85,118 @@ open class ThumbnailView: FrameLayout {
private fun getDefaultWidth() = maxOf(layoutParams?.width ?: 0, 0)
private fun getDefaultHeight() = maxOf(layoutParams?.height ?: 0, 0)
// endregion
// region Interaction
fun setImageResource(glide: GlideRequests, slide: Slide, isPreview: Boolean, mms: MmsMessageRecord?): ListenableFuture<Boolean> {
return setImageResource(glide, slide, isPreview, 0, 0, mms)
fun setRoundedCorners(radius: Int){
// create an outline provider and clip the whole view to that shape
// that way we can round the image and the background ( and any other artifacts that the view may contain )
val mOutlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
// all corners
outline.setRoundRect(0, 0, view.width, view.height, radius.toFloat())
}
}
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,
naturalHeight: Int, mms: MmsMessageRecord?): ListenableFuture<Boolean> {
val currentSlide = this.slide
naturalHeight: Int
): ListenableFuture<Boolean> {
binding.playOverlay.isVisible = (slide.thumbnailUri != null && slide.hasPlayOverlay() &&
(slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE || isPreview))
if (equals(currentSlide, slide)) {
if (equals(this.slide, slide)) {
// don't re-load slide
return SettableFuture(false)
}
if (currentSlide != null && currentSlide.fastPreflightId != null && currentSlide.fastPreflightId == slide.fastPreflightId) {
// not reloading slide for fast preflight
this.slide = slide
}
this.slide = slide
binding.thumbnailLoadIndicator.isVisible = slide.isInProgress
binding.thumbnailDownloadIcon.isVisible = slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED
binding.thumbnailDownloadIcon.isVisible =
slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED
dimensDelegate.setDimens(naturalWidth, naturalHeight)
invalidate()
val result = SettableFuture<Boolean>()
return SettableFuture<Boolean>().also {
when {
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() -> {
buildPlaceholderGlideRequest(glide, slide).into(GlideBitmapListeningTarget(binding.thumbnailImage, null, result))
buildPlaceholderGlideRequest(glide, slide).into(
GlideBitmapListeningTarget(binding.thumbnailImage, null, it)
)
}
else -> {
glide.clear(binding.thumbnailImage)
result.set(false)
it.set(false)
}
}
}
return result
}
fun buildThumbnailGlideRequest(glide: GlideRequests, slide: Slide): GlideRequest<Drawable> {
val dimens = dimensDelegate.resourceSize()
val request = glide.load(DecryptableUri(slide.thumbnailUri!!))
private fun buildThumbnailGlideRequest(
glide: GlideRequests,
slide: Slide
): GlideRequest<Drawable> = glide.load(DecryptableUri(slide.thumbnailUri!!))
.diskCacheStrategy(DiskCacheStrategy.NONE)
.let { request ->
if (dimens[WIDTH] == 0 || dimens[HEIGHT] == 0) {
request.override(getDefaultWidth(), getDefaultHeight())
} else {
request.override(dimens[WIDTH], dimens[HEIGHT])
}
}
.overrideDimensions()
.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))
}
fun buildPlaceholderGlideRequest(glide: GlideRequests, slide: Slide): GlideRequest<Bitmap> {
val dimens = dimensDelegate.resourceSize()
return glide.asBitmap()
private fun buildPlaceholderGlideRequest(
glide: GlideRequests,
slide: Slide
): GlideRequest<Bitmap> = glide.asBitmap()
.load(slide.getPlaceholderRes(context.theme))
.diskCacheStrategy(DiskCacheStrategy.NONE)
.let { request ->
if (dimens[WIDTH] == 0 || dimens[HEIGHT] == 0) {
request.override(getDefaultWidth(), getDefaultHeight())
} else {
request.override(dimens[WIDTH], dimens[HEIGHT])
}
}
.overrideDimensions()
.fitCenter()
}
open fun clear(glideRequests: GlideRequests) {
glideRequests.clear(binding.thumbnailImage)
slide = null
}
fun setImageResource(glideRequests: GlideRequests, uri: Uri): ListenableFuture<Boolean> {
val future = SettableFuture<Boolean>()
var request: GlideRequest<Drawable> = glideRequests.load(DecryptableUri(uri))
fun setImageResource(
glideRequests: GlideRequests,
uri: Uri
): ListenableFuture<Boolean> = glideRequests.load(DecryptableUri(uri))
.diskCacheStrategy(DiskCacheStrategy.NONE)
.transition(DrawableTransitionOptions.withCrossFade())
.transform(CenterCrop())
.intoDrawableTargetAsFuture()
request = if (radius > 0) {
request.transforms(CenterCrop(), RoundedCorners(radius))
} else {
request.transforms(CenterCrop())
private fun GlideRequest<Drawable>.intoDrawableTargetAsFuture() =
SettableFuture<Boolean>().also {
binding.run {
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.Context
import android.database.Cursor
import org.json.JSONArray
import org.session.libsession.messaging.open_groups.GroupMember
import org.session.libsession.messaging.open_groups.GroupMemberRole
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
import org.thoughtcrime.securesms.util.asSequence
import java.util.EnumSet
class GroupMemberDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) {
@ -51,6 +54,19 @@ class GroupMemberDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
return mappings.map { it.role }
}
fun getGroupMembersRoles(groupId: String, memberIDs: Collection<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>) {
writableDatabase.beginTransaction()
try {

View File

@ -218,6 +218,21 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
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
get() {
val where = "$EXPIRE_STARTED > 0"

View File

@ -4,6 +4,7 @@ import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import androidx.core.database.getStringOrNull
import org.json.JSONArray
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.utilities.AccountId
import org.session.libsignal.utilities.Base64
@ -41,6 +42,15 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
}
}
fun getContacts(sessionIDs: Collection<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> {
val database = databaseHelper.readableDatabase
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.Context
import android.database.Cursor
import org.json.JSONArray
import org.session.libsession.messaging.jobs.AttachmentDownloadJob
import org.session.libsession.messaging.jobs.AttachmentUploadJob
import org.session.libsession.messaging.jobs.BackgroundGroupAddJob
@ -50,14 +51,18 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa
databaseHelper.writableDatabase.delete(sessionJobTable, "${Companion.jobID} = ?", arrayOf( jobID ))
}
fun getAllJobs(type: String): Map<String, Job?> {
fun getAllJobs(vararg types: String): Map<String, Job?> {
val database = databaseHelper.readableDatabase
return database.getAll(sessionJobTable, "$jobType = ?", arrayOf( type )) { cursor ->
return database.getAll(
sessionJobTable,
"$jobType IN (SELECT value FROM json_each(?))", // Use json_each to bypass limitation of SQLite's IN operator binding
arrayOf( JSONArray(types).toString() )
) { cursor ->
val jobID = cursor.getString(jobID)
try {
jobID to jobFromCursor(cursor)
} catch (e: Exception) {
Log.e("Loki", "Error deserializing job of type: $type.", e)
Log.e("Loki", "Error deserializing job of type: $types.", e)
jobID to null
}
}.toMap()

View File

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

View File

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

View File

@ -103,7 +103,12 @@ class ConversationView : LinearLayout {
R.drawable.ic_notifications_mentions
}
binding.muteIndicatorImageView.setImageResource(drawableRes)
binding.snippetTextView.text = highlightMentions(thread.getSnippet(), thread.threadId, context)
binding.snippetTextView.text = highlightMentions(
text = thread.getSnippet(),
formatOnly = true, // no styling here, only text formatting
threadID = thread.threadId,
context = context
)
binding.snippetTextView.typeface = if (unreadCount > 0 && !thread.isRead) Typeface.DEFAULT_BOLD else Typeface.DEFAULT
binding.snippetTextView.visibility = if (isTyping) View.GONE else View.VISIBLE
if (isTyping) {

View File

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

View File

@ -39,7 +39,13 @@ class MessageRequestView : LinearLayout {
binding.displayNameTextView.text = senderDisplayName
binding.timestampTextView.text = DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), thread.date)
val rawSnippet = thread.getDisplayBody(context)
val snippet = highlightMentions(rawSnippet, thread.threadId, context)
val snippet = highlightMentions(
text = rawSnippet,
formatOnly = true, // no styling here, only text formatting
threadID = thread.threadId,
context = context
)
binding.snippetTextView.text = snippet
post {

View File

@ -56,7 +56,6 @@ import org.session.libsignal.utilities.Util;
import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.contacts.ContactUtil;
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2;
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities;
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities;
import org.thoughtcrime.securesms.crypto.KeyPairUtilities;
import org.thoughtcrime.securesms.database.LokiThreadDatabase;
@ -348,7 +347,6 @@ public class DefaultMessageNotifier implements MessageNotifier {
builder.setThread(notifications.get(0).getRecipient());
builder.setMessageCount(notificationState.getMessageCount());
MentionManagerUtilities.INSTANCE.populateUserPublicKeyCacheIfNeeded(notifications.get(0).getThreadId(),context);
// TODO: Removing highlighting mentions in the notification because this context is the libsession one which
// TODO: doesn't have access to the `R.attr.message_sent_text_color` and `R.attr.message_received_text_color`
@ -444,13 +442,30 @@ public class DefaultMessageNotifier implements MessageNotifier {
while(iterator.hasPrevious()) {
NotificationItem item = iterator.previous();
builder.addMessageBody(item.getIndividualRecipient(), item.getRecipient(),
MentionUtilities.highlightMentions(item.getText(), item.getThreadId(), context));
MentionUtilities.highlightMentions(
item.getText() != null ? item.getText() : "",
false,
false,
true, // no styling here, only text formatting
item.getThreadId(),
context
)
);
}
if (signal) {
builder.setAlarms(notificationState.getRingtone(context), notificationState.getVibrate());
CharSequence text = notifications.get(0).getText();
builder.setTicker(notifications.get(0).getIndividualRecipient(),
MentionUtilities.highlightMentions(notifications.get(0).getText(), notifications.get(0).getThreadId(), context));
MentionUtilities.highlightMentions(
text != null ? text : "",
false,
false,
true, // no styling here, only text formatting
notifications.get(0).getThreadId(),
context
)
);
}
builder.putStringExtra(LATEST_MESSAGE_ID_TAG, messageIdTag);

View File

@ -4,12 +4,13 @@ import android.app.Dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import androidx.core.view.isGone
import android.widget.Toast
import androidx.core.view.isVisible
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DividerItemDecoration
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -24,17 +25,26 @@ import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
class ClearAllDataDialog : DialogFragment() {
private val TAG = "ClearAllDataDialog"
private lateinit var binding: DialogClearAllDataBinding
enum class Steps {
private enum class Steps {
INFO_PROMPT,
NETWORK_PROMPT,
DELETING
DELETING,
RETRY_LOCAL_DELETE_ONLY_PROMPT
}
var clearJob: Job? = null
// Rather than passing a bool around we'll use an enum to clarify our intent
private enum class DeletionScope {
DeleteLocalDataOnly,
DeleteBothLocalAndNetworkData
}
var step = Steps.INFO_PROMPT
private var clearJob: Job? = null
private var step = Steps.INFO_PROMPT
set(value) {
field = value
updateUI()
@ -46,8 +56,8 @@ class ClearAllDataDialog : DialogFragment() {
private fun createView(): View {
binding = DialogClearAllDataBinding.inflate(LayoutInflater.from(requireContext()))
val device = radioOption("deviceOnly", R.string.dialog_clear_all_data_clear_device_only)
val network = radioOption("deviceAndNetwork", R.string.dialog_clear_all_data_clear_device_and_network)
val device = radioOption("deviceOnly", R.string.clearDeviceOnly)
val network = radioOption("deviceAndNetwork", R.string.clearDeviceAndNetwork)
var selectedOption: RadioOption<String> = device
val optionAdapter = RadioOptionAdapter { selectedOption = it }
binding.recyclerView.apply {
@ -57,18 +67,21 @@ class ClearAllDataDialog : DialogFragment() {
setHasFixedSize(true)
}
optionAdapter.submitList(listOf(device, network))
binding.cancelButton.setOnClickListener {
dismiss()
}
binding.clearAllDataButton.setOnClickListener {
when (step) {
Steps.INFO_PROMPT -> if (selectedOption == network) {
step = Steps.NETWORK_PROMPT
} else {
clearAllData(false)
clearAllData(DeletionScope.DeleteLocalDataOnly)
}
Steps.NETWORK_PROMPT -> clearAllData(true)
Steps.NETWORK_PROMPT -> clearAllData(DeletionScope.DeleteBothLocalAndNetworkData)
Steps.DELETING -> { /* do nothing intentionally */ }
Steps.RETRY_LOCAL_DELETE_ONLY_PROMPT -> clearAllData(DeletionScope.DeleteLocalDataOnly)
}
}
return binding.root
@ -86,8 +99,13 @@ class ClearAllDataDialog : DialogFragment() {
binding.dialogDescriptionText.setText(R.string.dialog_clear_all_data_clear_device_and_network_confirmation)
}
Steps.DELETING -> { /* do nothing intentionally */ }
Steps.RETRY_LOCAL_DELETE_ONLY_PROMPT -> {
binding.dialogDescriptionText.setText(R.string.clearDataErrorDescriptionGeneric)
binding.clearAllDataButton.text = getString(R.string.clearDevice)
}
binding.recyclerView.isGone = step == Steps.NETWORK_PROMPT
}
binding.recyclerView.isVisible = step == Steps.INFO_PROMPT
binding.cancelButton.isVisible = !isLoading
binding.clearAllDataButton.isVisible = !isLoading
binding.progressBar.isVisible = isLoading
@ -97,45 +115,55 @@ class ClearAllDataDialog : DialogFragment() {
}
}
private fun clearAllData(deleteNetworkMessages: Boolean) {
clearJob = lifecycleScope.launch(Dispatchers.IO) {
val previousStep = step
withContext(Dispatchers.Main) {
step = Steps.DELETING
}
if (!deleteNetworkMessages) {
private suspend fun performDeleteLocalDataOnlyStep() {
try {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(requireContext()).get()
} 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()
withContext(Dispatchers.Main) {
return
}
ApplicationContext.getInstance(context).clearAllData().let { success ->
withContext(Main) {
if (success) {
dismiss()
}
} else {
// finish
val result = try {
Toast.makeText(ApplicationContext.getInstance(requireContext()), R.string.errorUnknown, Toast.LENGTH_LONG).show()
}
}
}
}
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()
openGroups.map { it.value.server }.toSet().forEach { server ->
OpenGroupApi.deleteAllInboxMessages(server).get()
}
SnodeAPI.deleteAllMessages().get()
} catch (e: Exception) {
Log.e(TAG, "Failed to delete network messages - offering user option to delete local data only.", e)
null
}
if (result == null || result.values.any { !it } || result.isEmpty()) {
// didn't succeed (at least one)
withContext(Dispatchers.Main) {
step = previousStep
// If one or more deletions failed then inform the user and allow them to clear the device only if they wish..
if (deletionResultMap == null || deletionResultMap.values.any { !it } || deletionResultMap.isEmpty()) {
withContext(Main) { step = Steps.RETRY_LOCAL_DELETE_ONLY_PROMPT }
}
} else if (result.values.all { it }) {
// don't force sync because all the messages are deleted?
else if (deletionResultMap.values.all { it }) {
// ..otherwise if the network data deletion was successful proceed to delete the local data as well.
ApplicationContext.getInstance(context).clearAllData()
withContext(Dispatchers.Main) {
dismiss()
withContext(Main) { 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"?>
<RelativeLayout
<androidx.constraintlayout.widget.ConstraintLayout
android:focusable="false"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
@ -13,6 +13,9 @@
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@+id/conversationRecyclerView"
app:layout_constraintStart_toStartOf="parent"
android:background="?colorPrimary"
app:contentInsetStart="0dp">
@ -31,9 +34,11 @@
android:focusable="false"
android:id="@+id/conversationRecyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_above="@+id/typingIndicatorViewContainer"
android:layout_below="@id/toolbar" />
android:layout_height="0dp"
app:layout_constraintVertical_weight="1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toTopOf="@+id/typingIndicatorViewContainer"
app:layout_constraintTop_toBottomOf="@id/toolbar" />
<org.thoughtcrime.securesms.conversation.v2.components.TypingIndicatorViewContainer
@ -42,20 +47,27 @@
android:layout_width="match_parent"
android:layout_height="36dp"
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
android:id="@+id/inputBar"
android:layout_width="match_parent"
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
android:id="@+id/searchBottomBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
app:layout_constraintBottom_toBottomOf="parent"
android:visibility="gone"/>
<FrameLayout
@ -75,11 +87,18 @@
android:inflatedId="@+id/conversation_reaction_scrubber"
android:layout="@layout/conversation_reaction_scrubber"/>
<FrameLayout
android:id="@+id/additionalContentContainer"
<androidx.recyclerview.widget.RecyclerView
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_height="wrap_content"
android:layout_alignBottom="@+id/conversationRecyclerView"/>
tools:visibility="gone"
app:layout_constraintHeight_max="176dp"
app:layout_constraintBottom_toBottomOf="@+id/conversationRecyclerView" />
<LinearLayout
android:id="@+id/attachmentOptionsContainer"
@ -87,19 +106,19 @@
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/small_spacing"
android:elevation="8dp"
android:layout_alignParentStart="true"
android:layout_alignParentBottom="true"
android:layout_marginBottom="60dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toTopOf="@+id/inputBar"
android:layout_marginBottom="16dp"
android:orientation="vertical">
<RelativeLayout
<FrameLayout
android:id="@+id/gifButtonContainer"
android:layout_width="@dimen/input_bar_button_expanded_size"
android:layout_height="@dimen/input_bar_button_expanded_size"
android:contentDescription="@string/AccessibilityId_gif_button"
android:alpha="0" />
<RelativeLayout
<FrameLayout
android:id="@+id/documentButtonContainer"
android:layout_marginTop="8dp"
android:layout_width="@dimen/input_bar_button_expanded_size"
@ -107,7 +126,7 @@
android:contentDescription="@string/AccessibilityId_documents_folder"
android:alpha="0" />
<RelativeLayout
<FrameLayout
android:id="@+id/libraryButtonContainer"
android:layout_marginTop="8dp"
android:layout_width="@dimen/input_bar_button_expanded_size"
@ -115,7 +134,7 @@
android:contentDescription="@string/AccessibilityId_images_folder"
android:alpha="0" />
<RelativeLayout
<FrameLayout
android:id="@+id/cameraButtonContainer"
android:layout_marginTop="8dp"
android:layout_width="@dimen/input_bar_button_expanded_size"
@ -129,22 +148,26 @@
android:id="@+id/textSendAfterApproval"
android:text="@string/ConversationActivity_send_after_approval"
android:visibility="gone"
tools:visibility="visible"
android:textAlignment="center"
android:textColor="@color/classic_light_2"
android:padding="22dp"
android:textSize="12sp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignWithParentIfMissing="true"
android:layout_above="@id/messageRequestBar"/>
tools:text="You'll be able to send"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/typingIndicatorViewContainer"
app:layout_constraintBottom_toTopOf="@+id/messageRequestBar" />
<RelativeLayout
android:id="@+id/scrollToBottomButton"
tools:visibility="visible"
android:visibility="gone"
android:layout_width="40dp"
android:layout_height="50dp"
android:layout_alignParentEnd="true"
android:layout_above="@+id/messageRequestBar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toTopOf="@+id/messageRequestBar"
android:layout_alignWithParentIfMissing="true"
android:layout_marginEnd="12dp"
android:layout_marginBottom="32dp">
@ -197,14 +220,14 @@
android:layout_height="wrap_content"
android:layout_marginBottom="-12dp"
android:visibility="gone"
android:layout_alignParentBottom="true" />
app:layout_constraintBottom_toBottomOf="parent" />
<RelativeLayout
<FrameLayout
android:id="@+id/blockedBanner"
android:contentDescription="@string/AccessibilityId_blocked_banner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/toolbar"
app:layout_constraintTop_toBottomOf="@+id/toolbar"
android:background="@color/destructive"
android:visibility="gone"
tools:visibility="visible">
@ -214,20 +237,20 @@
android:contentDescription="@string/AccessibilityId_blocked_banner_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:layout_gravity="center"
android:layout_margin="@dimen/medium_spacing"
android:textColor="@color/white"
android:textSize="@dimen/small_font_size"
android:textStyle="bold"
tools:text="Elon is blocked. Unblock them?" />
</RelativeLayout>
</FrameLayout>
<RelativeLayout
<FrameLayout
android:id="@+id/outdatedBanner"
android:layout_width="match_parent"
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:visibility="gone"
tools:visibility="visible">
@ -237,14 +260,14 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:layout_centerInParent="true"
android:layout_gravity="center"
android:layout_marginVertical="@dimen/very_small_spacing"
android:layout_marginHorizontal="@dimen/medium_spacing"
android:textColor="@color/black"
android:textSize="@dimen/tiny_font_size"
tools:text="This user's client is outdated, things may not work as expected" />
</RelativeLayout>
</FrameLayout>
<TextView
android:padding="@dimen/medium_spacing"
@ -254,7 +277,7 @@
android:id="@+id/placeholderText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/blockedBanner"
app:layout_constraintTop_toBottomOf="@+id/outdatedBanner"
android:elevation="8dp"
android:contentDescription="@string/AccessibilityId_control_message"
tools:text="@string/activity_conversation_empty_state_default"
@ -264,11 +287,12 @@
android:id="@+id/messageRequestBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_above="@id/inputBar"
app:layout_constraintBottom_toTopOf="@+id/inputBar"
app:layout_constraintTop_toBottomOf="@+id/textSendAfterApproval"
android:layout_marginBottom="@dimen/large_spacing"
android:orientation="vertical"
android:visibility="gone"
tools:visibility="visible">
tools:visibility="gone">
<TextView
android:id="@+id/messageRequestBlock"
@ -322,4 +346,4 @@
</LinearLayout>
</RelativeLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -9,11 +9,6 @@
<include layout="@layout/thumbnail_view"
android:id="@+id/album_cell_1"
android:layout_width="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"/>
android:layout_height="match_parent"/>
</FrameLayout>

View File

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

View File

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

View File

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

View File

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

View File

@ -646,8 +646,6 @@
<string name="dialog_clear_all_data_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_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">پاک</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_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_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">Effacer</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_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_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">Effacer</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_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_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">Clear</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.thoughtcrime.securesms.BaseViewModelTest
import org.thoughtcrime.securesms.NoOpLogger
import org.thoughtcrime.securesms.database.MmsDatabase
import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.repository.ConversationRepository
@ -32,6 +33,7 @@ class ConversationViewModelTest: BaseViewModelTest() {
private val repository = mock<ConversationRepository>()
private val storage = mock<Storage>()
private val mmsDatabase = mock<MmsDatabase>()
private val threadId = 123L
private val edKeyPair = mock<KeyPair>()
@ -39,7 +41,7 @@ class ConversationViewModelTest: BaseViewModelTest() {
private lateinit var messageRecord: MessageRecord
private val viewModel: ConversationViewModel by lazy {
ConversationViewModel(threadId, edKeyPair, repository, storage)
ConversationViewModel(threadId, edKeyPair, repository, storage, mock(), mmsDatabase)
}
@Before

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 markJobAsSucceeded(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 getMessageSendJob(messageSendJobID: String): MessageSendJob?
fun getMessageReceiveJob(messageReceiveJobID: String): Job?

View File

@ -1,6 +1,8 @@
package org.session.libsession.messaging.jobs
import okhttp3.HttpUrl
import org.session.libsession.database.MessageDataProvider
import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId
@ -40,6 +42,36 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long)
// Keys used for database storage
private val ATTACHMENT_ID_KEY = "attachment_id"
private val TS_INCOMING_MESSAGE_ID_KEY = "tsIncoming_message_id"
/**
* Check if the attachment in the given message is eligible for download.
*
* Note that this function only checks for the eligibility of the attachment in the sense
* of whether the download is allowed, it does not check if the download has already taken
* place.
*/
fun eligibleForDownload(threadID: Long,
storage: StorageProtocol,
messageDataProvider: MessageDataProvider,
databaseMessageID: Long): Boolean {
val threadRecipient = storage.getRecipientForThread(threadID) ?: return false
// if we are the sender we are always eligible
val selfSend = messageDataProvider.isMmsOutgoing(databaseMessageID)
if (selfSend) {
return true
}
// you can't be eligible without a sender
val sender = messageDataProvider.getIndividualRecipientForMms(databaseMessageID)?.address?.serialize()
?: return false
// you can't be eligible without a contact entry
val contact = storage.getContactWithAccountID(sender) ?: return false
// we are eligible if we are receiving a group message or the contact is trusted
return threadRecipient.isGroupRecipient || contact.isTrusted
}
}
override suspend fun execute(dispatcherName: String) {
@ -88,21 +120,7 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long)
return
}
val threadRecipient = storage.getRecipientForThread(threadID)
val selfSend = messageDataProvider.isMmsOutgoing(databaseMessageID)
val sender = if (selfSend) {
storage.getUserPublicKey()
} else {
messageDataProvider.getIndividualRecipientForMms(databaseMessageID)?.address?.serialize()
}
val contact = sender?.let { storage.getContactWithAccountID(it) }
if (threadRecipient == null || sender == null || (contact == null && !selfSend)) {
handleFailure(Error.NoSender, null)
return
}
if (!threadRecipient.isGroupRecipient && contact?.isTrusted != true && storage.getUserPublicKey() != sender) {
// if we aren't receiving a group message, a message from ourselves (self-send) and the contact sending is not trusted:
// do not continue, but do not fail
if (!eligibleForDownload(threadID, storage, messageDataProvider, databaseMessageID)) {
handleFailure(Error.NoSender, null)
return
}

View File

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

View File

@ -102,7 +102,7 @@ class JobQueue : JobDelegate {
execute(dispatcherName)
}
catch (e: Exception) {
Log.d(dispatcherName, "unhandledJobException: ${javaClass.simpleName} (id: $id)")
Log.d(dispatcherName, "unhandledJobException: ${javaClass.simpleName} (id: $id)", e)
this@JobQueue.handleJobFailed(this, dispatcherName, e)
}
}

View File

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

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

View File

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

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 hashValue = lastMessageAsJSON?.get("hash") as? String
if (hashValue != null) {
@ -839,7 +839,7 @@ object SnodeAPI {
}
}
private fun removeDuplicates(publicKey: String, rawMessages: List<*>, namespace: Int, updateStoredHashes: Boolean): List<*> {
fun removeDuplicates(publicKey: String, rawMessages: List<*>, namespace: Int, updateStoredHashes: Boolean): List<*> {
val originalMessageHashValues = database.getReceivedMessageHashValues(publicKey, namespace)?.toMutableSet() ?: mutableSetOf()
val receivedMessageHashValues = originalMessageHashValues.toMutableSet()
val result = rawMessages.filter { rawMessage ->

View File

@ -73,4 +73,10 @@
<string name="ConversationItem_group_action_left">%1$s has left the group.</string>
<!-- RecipientProvider -->
<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>

View File

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