mirror of
https://github.com/oxen-io/session-android.git
synced 2024-11-27 12:05:22 +00:00
Merge branch 'dev' into pr/1127
This commit is contained in:
commit
0bbb1c42b5
@ -368,8 +368,11 @@ dependencies {
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.5.1'
|
||||
androidTestUtil 'androidx.test:orchestrator:1.4.2'
|
||||
|
||||
testImplementation 'org.robolectric:robolectric:4.4'
|
||||
testImplementation 'org.robolectric:shadows-multidex:4.4'
|
||||
testImplementation 'org.robolectric:robolectric:4.12.2'
|
||||
testImplementation 'org.robolectric:shadows-multidex:4.12.2'
|
||||
testImplementation 'org.conscrypt:conscrypt-openjdk-uber:2.5.2' // For Robolectric
|
||||
testImplementation 'app.cash.turbine:turbine:1.1.0'
|
||||
|
||||
|
||||
implementation 'com.github.bumptech.glide:compose:1.0.0-alpha.5'
|
||||
implementation 'androidx.compose.ui:ui:1.5.2'
|
||||
|
@ -1,9 +1,10 @@
|
||||
package org.thoughtcrime.securesms
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.asExecutor
|
||||
import nl.komponents.kovenant.Kovenant
|
||||
import nl.komponents.kovenant.jvm.asDispatcher
|
||||
import org.session.libsignal.utilities.Log
|
||||
import org.session.libsignal.utilities.ThreadUtils
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
object AppContext {
|
||||
@ -11,7 +12,7 @@ object AppContext {
|
||||
fun configureKovenant() {
|
||||
Kovenant.context {
|
||||
callbackContext.dispatcher = Executors.newSingleThreadExecutor().asDispatcher()
|
||||
workerContext.dispatcher = ThreadUtils.executorPool.asDispatcher()
|
||||
workerContext.dispatcher = Dispatchers.IO.asExecutor().asDispatcher()
|
||||
multipleCompletion = { v1, v2 ->
|
||||
Log.d("Loki", "Promise resolved more than once (first with $v1, then with $v2); ignoring $v2.")
|
||||
}
|
||||
|
@ -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));
|
||||
|
@ -21,6 +21,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import android.provider.Settings
|
||||
import kotlinx.coroutines.launch
|
||||
import network.loki.messenger.R
|
||||
import network.loki.messenger.databinding.ActivityWebrtcBinding
|
||||
@ -100,7 +101,14 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
super.onCreate(savedInstanceState, ready)
|
||||
rotationListener.enable()
|
||||
|
||||
// Only enable auto-rotate if system auto-rotate is enabled
|
||||
if (isAutoRotateOn()) {
|
||||
rotationListener.enable()
|
||||
} else {
|
||||
rotationListener.disable()
|
||||
}
|
||||
|
||||
binding = ActivityWebrtcBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||
@ -191,6 +199,14 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
|
||||
|
||||
}
|
||||
|
||||
//Function to check if Android System Auto-rotate is on or off
|
||||
private fun isAutoRotateOn(): Boolean {
|
||||
return Settings.System.getInt(
|
||||
contentResolver,
|
||||
Settings.System.ACCELEROMETER_ROTATION, 0
|
||||
) == 1
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
hangupReceiver?.let { receiver ->
|
||||
|
@ -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
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -29,8 +29,8 @@ import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup.LayoutParams
|
||||
import android.view.WindowManager
|
||||
import android.widget.RelativeLayout
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
@ -46,6 +46,7 @@ import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.flowWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.loader.app.LoaderManager
|
||||
import androidx.loader.content.Loader
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
@ -67,8 +68,6 @@ import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||
import org.session.libsession.messaging.contacts.Contact
|
||||
import org.session.libsession.messaging.jobs.AttachmentDownloadJob
|
||||
import org.session.libsession.messaging.jobs.JobQueue
|
||||
import org.session.libsession.messaging.mentions.Mention
|
||||
import org.session.libsession.messaging.mentions.MentionsManager
|
||||
import org.session.libsession.messaging.messages.ExpirationConfiguration
|
||||
import org.session.libsession.messaging.messages.applyExpiryMode
|
||||
import org.session.libsession.messaging.messages.control.DataExtractionNotification
|
||||
@ -118,7 +117,8 @@ import org.thoughtcrime.securesms.conversation.v2.dialogs.SendSeedDialog
|
||||
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarButton
|
||||
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarDelegate
|
||||
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarRecordingViewDelegate
|
||||
import org.thoughtcrime.securesms.conversation.v2.input_bar.mentions.MentionCandidatesView
|
||||
import org.thoughtcrime.securesms.conversation.v2.input_bar.mentions.MentionCandidateAdapter
|
||||
import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel
|
||||
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallback
|
||||
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallbackDelegate
|
||||
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper
|
||||
@ -215,6 +215,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
@Inject lateinit var storage: Storage
|
||||
@Inject lateinit var reactionDb: ReactionDatabase
|
||||
@Inject lateinit var viewModelFactory: ConversationViewModel.AssistedFactory
|
||||
@Inject lateinit var mentionViewModelFactory: MentionViewModel.AssistedFactory
|
||||
|
||||
private val screenshotObserver by lazy {
|
||||
ScreenshotObserver(this, Handler(Looper.getMainLooper())) {
|
||||
@ -228,7 +229,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
ViewModelProvider(this, LinkPreviewViewModel.Factory(LinkPreviewRepository()))
|
||||
.get(LinkPreviewViewModel::class.java)
|
||||
}
|
||||
private val viewModel: ConversationViewModel by viewModels {
|
||||
|
||||
private val threadId: Long by lazy {
|
||||
var threadId = intent.getLongExtra(THREAD_ID, -1L)
|
||||
if (threadId == -1L) {
|
||||
intent.getParcelableExtra<Address>(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
|
||||
}
|
||||
@ -913,7 +934,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
if (textSecurePreferences.isLinkPreviewsEnabled()) {
|
||||
linkPreviewViewModel.onTextChanged(this, inputBarText, 0, 0)
|
||||
}
|
||||
showOrHideMentionCandidatesIfNeeded(newContent)
|
||||
if (LinkPreviewUtil.findWhitelistedUrls(newContent.toString()).isNotEmpty()
|
||||
&& !textSecurePreferences.isLinkPreviewsEnabled() && !textSecurePreferences.hasSeenLinkPreviewSuggestionDialog()) {
|
||||
LinkPreviewDialog {
|
||||
@ -925,76 +945,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
}
|
||||
}
|
||||
|
||||
private fun showOrHideMentionCandidatesIfNeeded(text: CharSequence) {
|
||||
if (text.length < previousText.length) {
|
||||
currentMentionStartIndex = -1
|
||||
hideMentionCandidates()
|
||||
val mentionsToRemove = mentions.filter { !text.contains(it.displayName) }
|
||||
mentions.removeAll(mentionsToRemove)
|
||||
}
|
||||
if (text.isNotEmpty()) {
|
||||
val lastCharIndex = text.lastIndex
|
||||
val lastChar = text[lastCharIndex]
|
||||
// Check if there is whitespace before the '@' or the '@' is the first character
|
||||
val isCharacterBeforeLastWhiteSpaceOrStartOfLine: Boolean
|
||||
if (text.length == 1) {
|
||||
isCharacterBeforeLastWhiteSpaceOrStartOfLine = true // Start of line
|
||||
} else {
|
||||
val charBeforeLast = text[lastCharIndex - 1]
|
||||
isCharacterBeforeLastWhiteSpaceOrStartOfLine = Character.isWhitespace(charBeforeLast)
|
||||
}
|
||||
if (lastChar == '@' && isCharacterBeforeLastWhiteSpaceOrStartOfLine) {
|
||||
currentMentionStartIndex = lastCharIndex
|
||||
showOrUpdateMentionCandidatesIfNeeded()
|
||||
} else if (Character.isWhitespace(lastChar) || lastChar == '@') { // the lastCharacter == "@" is to check for @@
|
||||
currentMentionStartIndex = -1
|
||||
hideMentionCandidates()
|
||||
} else if (currentMentionStartIndex != -1) {
|
||||
val query = text.substring(currentMentionStartIndex + 1) // + 1 to get rid of the "@"
|
||||
showOrUpdateMentionCandidatesIfNeeded(query)
|
||||
}
|
||||
} else {
|
||||
currentMentionStartIndex = -1
|
||||
hideMentionCandidates()
|
||||
}
|
||||
previousText = text
|
||||
}
|
||||
|
||||
private fun showOrUpdateMentionCandidatesIfNeeded(query: String = "") {
|
||||
val additionalContentContainer = binding?.additionalContentContainer ?: return
|
||||
val recipient = viewModel.recipient ?: return
|
||||
if (!isShowingMentionCandidatesView) {
|
||||
additionalContentContainer.removeAllViews()
|
||||
val view = MentionCandidatesView(this).apply {
|
||||
contentDescription = context.getString(R.string.AccessibilityId_mentions_list)
|
||||
}
|
||||
view.glide = glide
|
||||
view.onCandidateSelected = { handleMentionSelected(it) }
|
||||
additionalContentContainer.addView(view)
|
||||
val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, recipient.isCommunityRecipient)
|
||||
this.mentionCandidatesView = view
|
||||
view.show(candidates, viewModel.threadId)
|
||||
} else {
|
||||
val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, recipient.isCommunityRecipient)
|
||||
this.mentionCandidatesView!!.setMentionCandidates(candidates)
|
||||
}
|
||||
isShowingMentionCandidatesView = true
|
||||
}
|
||||
|
||||
private fun hideMentionCandidates() {
|
||||
if (isShowingMentionCandidatesView) {
|
||||
val mentionCandidatesView = mentionCandidatesView ?: return
|
||||
val animation = ValueAnimator.ofObject(FloatEvaluator(), mentionCandidatesView.alpha, 0.0f)
|
||||
animation.duration = 250L
|
||||
animation.addUpdateListener { animator ->
|
||||
mentionCandidatesView.alpha = animator.animatedValue as Float
|
||||
if (animator.animatedFraction == 1.0f) { binding?.additionalContentContainer?.removeAllViews() }
|
||||
}
|
||||
animation.start()
|
||||
}
|
||||
isShowingMentionCandidatesView = false
|
||||
}
|
||||
|
||||
override fun toggleAttachmentOptions() {
|
||||
val targetAlpha = if (isShowingAttachmentOptions) 0.0f else 1.0f
|
||||
val allButtonContainers = listOfNotNull(
|
||||
@ -1510,18 +1460,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
return hitRect.contains(x, y)
|
||||
}
|
||||
|
||||
private fun handleMentionSelected(mention: Mention) {
|
||||
val binding = binding ?: return
|
||||
if (currentMentionStartIndex == -1) { return }
|
||||
mentions.add(mention)
|
||||
val previousText = binding.inputBar.text
|
||||
val newText = previousText.substring(0, currentMentionStartIndex) + "@" + mention.displayName + " "
|
||||
binding.inputBar.text = newText
|
||||
binding.inputBar.setSelection(newText.length)
|
||||
currentMentionStartIndex = -1
|
||||
hideMentionCandidates()
|
||||
this.previousText = newText
|
||||
}
|
||||
|
||||
override fun scrollToMessageIfPossible(timestamp: Long) {
|
||||
val lastSeenItemPosition = adapter.getItemPositionForTimestamp(timestamp) ?: return
|
||||
@ -1620,10 +1558,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
binding?.inputBar?.text = ""
|
||||
binding?.inputBar?.cancelQuoteDraft()
|
||||
binding?.inputBar?.cancelLinkPreviewDraft()
|
||||
// Clear mentions
|
||||
previousText = ""
|
||||
currentMentionStartIndex = -1
|
||||
mentions.clear()
|
||||
// Put the message in the database
|
||||
message.id = smsDb.insertMessageOutbox(viewModel.threadId, outgoingTextMessage, false, message.sentTimestamp!!, null, true)
|
||||
// Send it
|
||||
@ -1668,10 +1602,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
|
||||
@ -1958,7 +1888,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)
|
||||
@ -2099,17 +2035,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
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -1,46 +1,44 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2
|
||||
|
||||
import android.content.Context
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
|
||||
import com.goterl.lazysodium.utils.KeyPair
|
||||
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
import org.session.libsession.database.MessageDataProvider
|
||||
import org.session.libsession.messaging.messages.ExpirationConfiguration
|
||||
import org.session.libsession.messaging.open_groups.OpenGroup
|
||||
import org.session.libsession.messaging.open_groups.OpenGroupApi
|
||||
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
|
||||
import org.session.libsession.messaging.utilities.SessionId
|
||||
import org.session.libsession.messaging.utilities.SodiumUtilities
|
||||
import org.session.libsession.utilities.Address
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.session.libsignal.utilities.IdPrefix
|
||||
import org.session.libsignal.utilities.Log
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase
|
||||
import org.thoughtcrime.securesms.audio.AudioSlidePlayer
|
||||
|
||||
import org.thoughtcrime.securesms.database.Storage
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.repository.ConversationRepository
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
class ConversationViewModel(
|
||||
val threadId: Long,
|
||||
val edKeyPair: KeyPair?,
|
||||
private val repository: ConversationRepository,
|
||||
private val storage: Storage
|
||||
private val storage: Storage,
|
||||
private val messageDataProvider: MessageDataProvider,
|
||||
database: MmsDatabase,
|
||||
) : ViewModel() {
|
||||
|
||||
val showSendAfterApprovalText: Boolean
|
||||
@ -92,6 +90,11 @@ class ConversationViewModel(
|
||||
// allow reactions if the open group is null (normal conversations) or the open group's capabilities include reactions
|
||||
get() = (openGroup == null || OpenGroupApi.Capability.REACTIONS.name.lowercase() in serverCapabilities)
|
||||
|
||||
private val attachmentDownloadHandler = AttachmentDownloadHandler(
|
||||
storage = storage,
|
||||
messageDataProvider = messageDataProvider,
|
||||
scope = viewModelScope,
|
||||
)
|
||||
|
||||
init {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -55,6 +55,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
import network.loki.messenger.R
|
||||
import network.loki.messenger.databinding.ViewVisibleMessageContentBinding
|
||||
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
|
||||
import org.thoughtcrime.securesms.MediaPreviewActivity.getPreviewIntent
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
|
||||
import org.thoughtcrime.securesms.database.Storage
|
||||
@ -149,7 +150,7 @@ fun MessageDetails(
|
||||
onResend: (() -> Unit)? = null,
|
||||
onDelete: () -> Unit = {},
|
||||
onClickImage: (Int) -> Unit = {},
|
||||
onAttachmentNeedsDownload: (Long, Long) -> Unit = { _, _ -> }
|
||||
onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit = { _ -> }
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
@ -13,6 +14,7 @@ import android.view.MotionEvent
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.widget.RelativeLayout
|
||||
import android.widget.TextView
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import network.loki.messenger.R
|
||||
import network.loki.messenger.databinding.ViewInputBarBinding
|
||||
@ -119,8 +121,8 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
|
||||
|
||||
// region Updating
|
||||
override fun inputBarEditTextContentChanged(text: CharSequence) {
|
||||
sendButton.isVisible = text.isNotEmpty()
|
||||
microphoneButton.isVisible = text.isEmpty()
|
||||
microphoneButton.isVisible = text.trim().isEmpty()
|
||||
sendButton.isVisible = microphoneButton.isGone
|
||||
delegate?.inputBarEditTextContentChanged(text)
|
||||
}
|
||||
|
||||
@ -223,8 +225,8 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
|
||||
binding.inputBarEditText.addTextChangedListener(textWatcher)
|
||||
}
|
||||
|
||||
fun setSelection(index: Int) {
|
||||
binding.inputBarEditText.setSelection(index)
|
||||
fun setInputBarEditableFactory(factory: Editable.Factory) {
|
||||
binding.inputBarEditText.setEditableFactory(factory)
|
||||
}
|
||||
// endregion
|
||||
}
|
||||
|
@ -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) }
|
||||
}
|
||||
}
|
@ -1,42 +1,14 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2.input_bar.mentions
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.RelativeLayout
|
||||
import network.loki.messenger.databinding.ViewMentionCandidateV2Binding
|
||||
import org.session.libsession.messaging.mentions.Mention
|
||||
import org.thoughtcrime.securesms.groups.OpenGroupManager
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests
|
||||
import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel
|
||||
|
||||
class MentionCandidateView : RelativeLayout {
|
||||
private lateinit var binding: ViewMentionCandidateV2Binding
|
||||
var candidate = Mention("", "")
|
||||
set(newValue) { field = newValue; update() }
|
||||
var glide: GlideRequests? = null
|
||||
var openGroupServer: String? = null
|
||||
var openGroupRoom: String? = null
|
||||
|
||||
constructor(context: Context) : this(context, null)
|
||||
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
|
||||
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() }
|
||||
|
||||
private fun initialize() {
|
||||
binding = ViewMentionCandidateV2Binding.inflate(LayoutInflater.from(context), this, true)
|
||||
}
|
||||
|
||||
private fun update() = with(binding) {
|
||||
mentionCandidateNameTextView.text = candidate.displayName
|
||||
profilePictureView.publicKey = candidate.publicKey
|
||||
profilePictureView.displayName = candidate.displayName
|
||||
profilePictureView.additionalPublicKey = null
|
||||
profilePictureView.update()
|
||||
if (openGroupServer != null && openGroupRoom != null) {
|
||||
val isUserModerator = OpenGroupManager.isUserModerator(context, "$openGroupRoom.$openGroupServer", candidate.publicKey)
|
||||
moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE
|
||||
} else {
|
||||
moderatorIconImageView.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
fun ViewMentionCandidateV2Binding.update(candidate: MentionViewModel.Candidate) {
|
||||
mentionCandidateNameTextView.text = candidate.nameHighlighted
|
||||
profilePictureView.publicKey = candidate.member.publicKey
|
||||
profilePictureView.displayName = candidate.member.name
|
||||
profilePictureView.additionalPublicKey = null
|
||||
profilePictureView.update()
|
||||
moderatorIconImageView.visibility = if (candidate.member.isModerator) View.VISIBLE else View.GONE
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
)
|
@ -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.sessionID,
|
||||
name = contact.displayName(contactContext).orEmpty(),
|
||||
isModerator = contact.sessionID in moderatorIDs,
|
||||
)
|
||||
}
|
||||
}
|
||||
.flowOn(dispatcher)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(10_000L), null)
|
||||
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val autoCompleteState: StateFlow<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
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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(),
|
||||
|
@ -34,6 +34,7 @@ import network.loki.messenger.databinding.ViewstubVisibleMessageMarkerContainerB
|
||||
import org.session.libsession.messaging.contacts.Contact
|
||||
import org.session.libsession.messaging.contacts.Contact.ContactContext
|
||||
import org.session.libsession.messaging.open_groups.OpenGroupApi
|
||||
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
|
||||
import org.session.libsession.utilities.Address
|
||||
import org.session.libsession.utilities.ViewUtil
|
||||
import org.session.libsession.utilities.getColorFromAttr
|
||||
@ -145,7 +146,7 @@ class VisibleMessageView : FrameLayout {
|
||||
senderSessionID: String,
|
||||
lastSeen: Long,
|
||||
delegate: VisibleMessageViewDelegate? = null,
|
||||
onAttachmentNeedsDownload: (Long, Long) -> Unit,
|
||||
onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit,
|
||||
lastSentMessageId: Long
|
||||
) {
|
||||
replyDisabled = message.isOpenGroupInvitation
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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().getContactWithSessionID(publicKey)
|
||||
@ -53,7 +70,8 @@ object MentionUtilities {
|
||||
contact?.displayName(context)
|
||||
}
|
||||
if (userDisplayName != null) {
|
||||
text = text.subSequence(0, matcher.start()).toString() + "@" + userDisplayName + text.subSequence(matcher.end(), text.length)
|
||||
val mention = "@$userDisplayName"
|
||||
text = text.subSequence(0, matcher.start()).toString() + mention + text.subSequence(matcher.end(), text.length)
|
||||
val endIndex = matcher.start() + 1 + userDisplayName.length
|
||||
startIndex = endIndex
|
||||
mentions.add(Tuple2(Range.create(matcher.start(), endIndex), publicKey))
|
||||
@ -66,37 +84,83 @@ object MentionUtilities {
|
||||
}
|
||||
val result = SpannableString(text)
|
||||
|
||||
var mentionTextColour: Int? = null
|
||||
// In dark themes..
|
||||
if (ThemeUtil.isDarkTheme(context)) {
|
||||
// ..we use the standard outgoing message colour for outgoing messages..
|
||||
if (isOutgoingMessage) {
|
||||
val mentionTextColourAttributeId = getMessageTextColourAttr(true)
|
||||
val mentionTextColourResourceId = getColorResourceIdFromAttr(context, mentionTextColourAttributeId)
|
||||
mentionTextColour = ResourcesCompat.getColor(context.resources, mentionTextColourResourceId, context.theme)
|
||||
}
|
||||
else // ..but we use the accent colour for incoming messages (i.e., someone mentioning us)..
|
||||
{
|
||||
mentionTextColour = context.getAccentColor()
|
||||
}
|
||||
}
|
||||
else // ..while in light themes we always just use the incoming or outgoing message text colour for mentions.
|
||||
{
|
||||
val mentionTextColourAttributeId = getMessageTextColourAttr(isOutgoingMessage)
|
||||
val mentionTextColourResourceId = getColorResourceIdFromAttr(context, mentionTextColourAttributeId)
|
||||
mentionTextColour = ResourcesCompat.getColor(context.resources, mentionTextColourResourceId, context.theme)
|
||||
// apply styling if required
|
||||
// Normal text color: black in dark mode and primary text color for light mode
|
||||
val mainTextColor by lazy {
|
||||
if (ThemeUtil.isDarkTheme(context)) context.getColor(R.color.black)
|
||||
else context.getColorFromAttr(android.R.attr.textColorPrimary)
|
||||
}
|
||||
|
||||
for (mention in mentions) {
|
||||
result.setSpan(ForegroundColorSpan(mentionTextColour), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
result.setSpan(StyleSpan(Typeface.BOLD), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
// Highlighted text color: primary/accent in dark mode and primary text color for light mode
|
||||
val highlightedTextColor by lazy {
|
||||
if (ThemeUtil.isDarkTheme(context)) context.getAccentColor()
|
||||
else context.getColorFromAttr(android.R.attr.textColorPrimary)
|
||||
}
|
||||
|
||||
// If we're using a light theme then we change the background colour of the mention to be the accent colour
|
||||
if (ThemeUtil.isLightTheme(context)) {
|
||||
val backgroundColour = context.getAccentColor();
|
||||
result.setSpan(BackgroundColorSpan(backgroundColour), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
if(!formatOnly) {
|
||||
for (mention in mentions) {
|
||||
val backgroundColor: Int?
|
||||
val foregroundColor: Int?
|
||||
|
||||
// quotes
|
||||
if(isQuote) {
|
||||
backgroundColor = null
|
||||
// the text color has different rule depending if the message is incoming or outgoing
|
||||
foregroundColor = if(isOutgoingMessage) null else highlightedTextColor
|
||||
}
|
||||
// incoming message mentioning you
|
||||
else if (isYou(mention.second, userPublicKey, openGroup)) {
|
||||
backgroundColor = context.getAccentColor()
|
||||
foregroundColor = mainTextColor
|
||||
}
|
||||
// outgoing message
|
||||
else if (isOutgoingMessage) {
|
||||
backgroundColor = null
|
||||
foregroundColor = mainTextColor
|
||||
}
|
||||
// incoming messages mentioning someone else
|
||||
else {
|
||||
backgroundColor = null
|
||||
// accent color for dark themes and primary text for light
|
||||
foregroundColor = highlightedTextColor
|
||||
}
|
||||
|
||||
// apply the background, if any
|
||||
backgroundColor?.let { background ->
|
||||
result.setSpan(
|
||||
RoundedBackgroundSpan(
|
||||
context = context,
|
||||
textColor = mainTextColor,
|
||||
backgroundColor = background
|
||||
),
|
||||
mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
}
|
||||
|
||||
// apply the foreground, if any
|
||||
foregroundColor?.let {
|
||||
result.setSpan(
|
||||
ForegroundColorSpan(it),
|
||||
mention.first.lower,
|
||||
mention.first.upper,
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
}
|
||||
|
||||
// apply bold on the mention
|
||||
result.setSpan(
|
||||
StyleSpan(Typeface.BOLD),
|
||||
mention.first.lower,
|
||||
mention.first.upper,
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun isYou(mentionedPublicKey: String, userPublicKey: String, openGroup: OpenGroup?): Boolean {
|
||||
val isUserBlindedPublicKey = openGroup?.let { SodiumUtilities.sessionId(userPublicKey, mentionedPublicKey, it.publicKey) } ?: false
|
||||
return mentionedPublicKey.equals(userPublicKey, ignoreCase = true) || isUserBlindedPublicKey
|
||||
}
|
||||
}
|
@ -2,10 +2,13 @@ package org.thoughtcrime.securesms.conversation.v2.utilities
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Outline
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.util.AttributeSet
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import android.view.ViewOutlineProvider
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.view.isVisible
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
@ -21,18 +24,17 @@ import org.session.libsignal.utilities.ListenableFuture
|
||||
import org.session.libsignal.utilities.SettableFuture
|
||||
import org.thoughtcrime.securesms.components.GlideBitmapListeningTarget
|
||||
import org.thoughtcrime.securesms.components.GlideDrawableListeningTarget
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri
|
||||
import org.thoughtcrime.securesms.mms.GlideRequest
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests
|
||||
import org.thoughtcrime.securesms.mms.Slide
|
||||
import kotlin.Boolean
|
||||
import kotlin.Int
|
||||
import kotlin.getValue
|
||||
import kotlin.lazy
|
||||
import kotlin.let
|
||||
|
||||
open class ThumbnailView: FrameLayout {
|
||||
open class ThumbnailView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : FrameLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
companion object {
|
||||
private const val WIDTH = 0
|
||||
private const val HEIGHT = 1
|
||||
@ -41,30 +43,29 @@ open class ThumbnailView: FrameLayout {
|
||||
private val binding: ThumbnailViewBinding by lazy { ThumbnailViewBinding.bind(this) }
|
||||
|
||||
// region Lifecycle
|
||||
constructor(context: Context) : super(context) { initialize(null) }
|
||||
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize(attrs) }
|
||||
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize(attrs) }
|
||||
|
||||
val loadIndicator: View by lazy { binding.thumbnailLoadIndicator }
|
||||
|
||||
private val dimensDelegate = ThumbnailDimensDelegate()
|
||||
|
||||
private var slide: Slide? = null
|
||||
var radius: Int = 0
|
||||
|
||||
private fun initialize(attrs: AttributeSet?) {
|
||||
if (attrs != null) {
|
||||
val typedArray = context.theme.obtainStyledAttributes(attrs, R.styleable.ThumbnailView, 0, 0)
|
||||
init {
|
||||
attrs?.let { context.theme.obtainStyledAttributes(it, R.styleable.ThumbnailView, 0, 0) }
|
||||
?.apply {
|
||||
dimensDelegate.setBounds(
|
||||
getDimensionPixelSize(R.styleable.ThumbnailView_minWidth, 0),
|
||||
getDimensionPixelSize(R.styleable.ThumbnailView_minHeight, 0),
|
||||
getDimensionPixelSize(R.styleable.ThumbnailView_maxWidth, 0),
|
||||
getDimensionPixelSize(R.styleable.ThumbnailView_maxHeight, 0)
|
||||
)
|
||||
|
||||
dimensDelegate.setBounds(typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_minWidth, 0),
|
||||
typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_minHeight, 0),
|
||||
typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxWidth, 0),
|
||||
typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxHeight, 0))
|
||||
setRoundedCorners(
|
||||
getDimensionPixelSize(R.styleable.ThumbnailView_thumbnail_radius, 0)
|
||||
)
|
||||
|
||||
radius = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_thumbnail_radius, 0)
|
||||
|
||||
typedArray.recycle()
|
||||
}
|
||||
recycle()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
@ -84,114 +85,118 @@ open class ThumbnailView: FrameLayout {
|
||||
|
||||
private fun getDefaultWidth() = maxOf(layoutParams?.width ?: 0, 0)
|
||||
private fun getDefaultHeight() = maxOf(layoutParams?.height ?: 0, 0)
|
||||
|
||||
// endregion
|
||||
|
||||
// region Interaction
|
||||
fun setImageResource(glide: GlideRequests, slide: Slide, isPreview: Boolean, mms: MmsMessageRecord?): ListenableFuture<Boolean> {
|
||||
return setImageResource(glide, slide, isPreview, 0, 0, mms)
|
||||
}
|
||||
|
||||
fun setImageResource(glide: GlideRequests, slide: Slide,
|
||||
isPreview: Boolean, naturalWidth: Int,
|
||||
naturalHeight: Int, mms: MmsMessageRecord?): ListenableFuture<Boolean> {
|
||||
|
||||
val currentSlide = this.slide
|
||||
|
||||
binding.playOverlay.isVisible = (slide.thumbnailUri != null && slide.hasPlayOverlay() &&
|
||||
(slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE || isPreview))
|
||||
|
||||
if (equals(currentSlide, slide)) {
|
||||
// don't re-load slide
|
||||
return SettableFuture(false)
|
||||
fun setRoundedCorners(radius: Int){
|
||||
// create an outline provider and clip the whole view to that shape
|
||||
// that way we can round the image and the background ( and any other artifacts that the view may contain )
|
||||
val mOutlineProvider = object : ViewOutlineProvider() {
|
||||
override fun getOutline(view: View, outline: Outline) {
|
||||
// all corners
|
||||
outline.setRoundRect(0, 0, view.width, view.height, radius.toFloat())
|
||||
}
|
||||
}
|
||||
|
||||
outlineProvider = mOutlineProvider
|
||||
clipToOutline = true
|
||||
}
|
||||
|
||||
if (currentSlide != null && currentSlide.fastPreflightId != null && currentSlide.fastPreflightId == slide.fastPreflightId) {
|
||||
// not reloading slide for fast preflight
|
||||
this.slide = slide
|
||||
fun setImageResource(
|
||||
glide: GlideRequests,
|
||||
slide: Slide,
|
||||
isPreview: Boolean
|
||||
): ListenableFuture<Boolean> = setImageResource(glide, slide, isPreview, 0, 0)
|
||||
|
||||
fun setImageResource(
|
||||
glide: GlideRequests, slide: Slide,
|
||||
isPreview: Boolean, naturalWidth: Int,
|
||||
naturalHeight: Int
|
||||
): ListenableFuture<Boolean> {
|
||||
binding.playOverlay.isVisible = (slide.thumbnailUri != null && slide.hasPlayOverlay() &&
|
||||
(slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE || isPreview))
|
||||
|
||||
if (equals(this.slide, slide)) {
|
||||
// don't re-load slide
|
||||
return SettableFuture(false)
|
||||
}
|
||||
|
||||
this.slide = slide
|
||||
|
||||
binding.thumbnailLoadIndicator.isVisible = slide.isInProgress
|
||||
binding.thumbnailDownloadIcon.isVisible = slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED
|
||||
binding.thumbnailDownloadIcon.isVisible =
|
||||
slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED
|
||||
|
||||
dimensDelegate.setDimens(naturalWidth, naturalHeight)
|
||||
invalidate()
|
||||
|
||||
val result = SettableFuture<Boolean>()
|
||||
|
||||
when {
|
||||
slide.thumbnailUri != null -> {
|
||||
buildThumbnailGlideRequest(glide, slide).into(GlideDrawableListeningTarget(binding.thumbnailImage, binding.thumbnailLoadIndicator, result))
|
||||
}
|
||||
slide.hasPlaceholder() -> {
|
||||
buildPlaceholderGlideRequest(glide, slide).into(GlideBitmapListeningTarget(binding.thumbnailImage, null, result))
|
||||
}
|
||||
else -> {
|
||||
glide.clear(binding.thumbnailImage)
|
||||
result.set(false)
|
||||
return SettableFuture<Boolean>().also {
|
||||
when {
|
||||
slide.thumbnailUri != null -> {
|
||||
buildThumbnailGlideRequest(glide, slide).into(
|
||||
GlideDrawableListeningTarget(binding.thumbnailImage, binding.thumbnailLoadIndicator, it)
|
||||
)
|
||||
}
|
||||
slide.hasPlaceholder() -> {
|
||||
buildPlaceholderGlideRequest(glide, slide).into(
|
||||
GlideBitmapListeningTarget(binding.thumbnailImage, null, it)
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
glide.clear(binding.thumbnailImage)
|
||||
it.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun buildThumbnailGlideRequest(glide: GlideRequests, slide: Slide): GlideRequest<Drawable> {
|
||||
private fun buildThumbnailGlideRequest(
|
||||
glide: GlideRequests,
|
||||
slide: Slide
|
||||
): GlideRequest<Drawable> = glide.load(DecryptableUri(slide.thumbnailUri!!))
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.overrideDimensions()
|
||||
.transition(DrawableTransitionOptions.withCrossFade())
|
||||
.transform(CenterCrop())
|
||||
.missingThumbnailPicture(slide.isInProgress)
|
||||
|
||||
val dimens = dimensDelegate.resourceSize()
|
||||
|
||||
val request = glide.load(DecryptableUri(slide.thumbnailUri!!))
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.let { request ->
|
||||
if (dimens[WIDTH] == 0 || dimens[HEIGHT] == 0) {
|
||||
request.override(getDefaultWidth(), getDefaultHeight())
|
||||
} else {
|
||||
request.override(dimens[WIDTH], dimens[HEIGHT])
|
||||
}
|
||||
}
|
||||
.transition(DrawableTransitionOptions.withCrossFade())
|
||||
.centerCrop()
|
||||
|
||||
return if (slide.isInProgress) request else request.apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture))
|
||||
}
|
||||
|
||||
fun buildPlaceholderGlideRequest(glide: GlideRequests, slide: Slide): GlideRequest<Bitmap> {
|
||||
|
||||
val dimens = dimensDelegate.resourceSize()
|
||||
|
||||
return glide.asBitmap()
|
||||
.load(slide.getPlaceholderRes(context.theme))
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.let { request ->
|
||||
if (dimens[WIDTH] == 0 || dimens[HEIGHT] == 0) {
|
||||
request.override(getDefaultWidth(), getDefaultHeight())
|
||||
} else {
|
||||
request.override(dimens[WIDTH], dimens[HEIGHT])
|
||||
}
|
||||
}
|
||||
.fitCenter()
|
||||
}
|
||||
private fun buildPlaceholderGlideRequest(
|
||||
glide: GlideRequests,
|
||||
slide: Slide
|
||||
): GlideRequest<Bitmap> = glide.asBitmap()
|
||||
.load(slide.getPlaceholderRes(context.theme))
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.overrideDimensions()
|
||||
.fitCenter()
|
||||
|
||||
open fun clear(glideRequests: GlideRequests) {
|
||||
glideRequests.clear(binding.thumbnailImage)
|
||||
slide = null
|
||||
}
|
||||
|
||||
fun setImageResource(glideRequests: GlideRequests, uri: Uri): ListenableFuture<Boolean> {
|
||||
val future = SettableFuture<Boolean>()
|
||||
fun setImageResource(
|
||||
glideRequests: GlideRequests,
|
||||
uri: Uri
|
||||
): ListenableFuture<Boolean> = glideRequests.load(DecryptableUri(uri))
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.transition(DrawableTransitionOptions.withCrossFade())
|
||||
.transform(CenterCrop())
|
||||
.intoDrawableTargetAsFuture()
|
||||
|
||||
var request: GlideRequest<Drawable> = glideRequests.load(DecryptableUri(uri))
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.transition(DrawableTransitionOptions.withCrossFade())
|
||||
|
||||
request = if (radius > 0) {
|
||||
request.transforms(CenterCrop(), RoundedCorners(radius))
|
||||
} else {
|
||||
request.transforms(CenterCrop())
|
||||
private fun GlideRequest<Drawable>.intoDrawableTargetAsFuture() =
|
||||
SettableFuture<Boolean>().also {
|
||||
binding.run {
|
||||
GlideDrawableListeningTarget(thumbnailImage, thumbnailLoadIndicator, it)
|
||||
}.let { into(it) }
|
||||
}
|
||||
|
||||
request.into(GlideDrawableListeningTarget(binding.thumbnailImage, binding.thumbnailLoadIndicator, future))
|
||||
|
||||
return future
|
||||
}
|
||||
private fun <T> GlideRequest<T>.overrideDimensions() =
|
||||
dimensDelegate.resourceSize().takeIf { 0 !in it }
|
||||
?.let { override(it[WIDTH], it[HEIGHT]) }
|
||||
?: override(getDefaultWidth(), getDefaultHeight())
|
||||
}
|
||||
|
||||
private fun <T> GlideRequest<T>.missingThumbnailPicture(
|
||||
inProgress: Boolean
|
||||
) = takeIf { inProgress } ?: apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture))
|
||||
|
@ -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 {
|
||||
|
@ -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"
|
||||
|
@ -4,6 +4,7 @@ import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import androidx.core.database.getStringOrNull
|
||||
import org.json.JSONArray
|
||||
import org.session.libsession.messaging.contacts.Contact
|
||||
import org.session.libsession.messaging.utilities.SessionId
|
||||
import org.session.libsignal.utilities.Base64
|
||||
@ -41,6 +42,15 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
|
||||
}
|
||||
}
|
||||
|
||||
fun getContacts(sessionIDs: Collection<String>): List<Contact> {
|
||||
val database = databaseHelper.readableDatabase
|
||||
return database.getAll(
|
||||
sessionContactTable,
|
||||
"$sessionID IN (SELECT value FROM json_each(?))",
|
||||
arrayOf(JSONArray(sessionIDs).toString())
|
||||
) { cursor -> contactFromCursor(cursor) }
|
||||
}
|
||||
|
||||
fun getAllContacts(): Set<Contact> {
|
||||
val database = databaseHelper.readableDatabase
|
||||
return database.getAll(sessionContactTable, null, null) { cursor ->
|
||||
|
@ -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()
|
||||
|
@ -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? {
|
||||
|
@ -7,7 +7,6 @@ import network.loki.messenger.libsession_util.Contacts
|
||||
import network.loki.messenger.libsession_util.ConversationVolatileConfig
|
||||
import network.loki.messenger.libsession_util.UserGroupsConfig
|
||||
import network.loki.messenger.libsession_util.UserProfile
|
||||
import org.session.libsession.snode.SnodeAPI
|
||||
import org.session.libsession.utilities.ConfigFactoryProtocol
|
||||
import org.session.libsession.utilities.ConfigFactoryUpdateListener
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
@ -72,7 +71,6 @@ class ConfigFactory(
|
||||
|
||||
override val user: UserProfile?
|
||||
get() = synchronizedWithLog(userLock) {
|
||||
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null
|
||||
if (_userConfig == null) {
|
||||
val (secretKey, publicKey) = maybeGetUserInfo() ?: return null
|
||||
val userDump = configDatabase.retrieveConfigAndHashes(
|
||||
@ -92,7 +90,6 @@ class ConfigFactory(
|
||||
|
||||
override val contacts: Contacts?
|
||||
get() = synchronizedWithLog(contactsLock) {
|
||||
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null
|
||||
if (_contacts == null) {
|
||||
val (secretKey, publicKey) = maybeGetUserInfo() ?: return null
|
||||
val contactsDump = configDatabase.retrieveConfigAndHashes(
|
||||
@ -112,7 +109,6 @@ class ConfigFactory(
|
||||
|
||||
override val convoVolatile: ConversationVolatileConfig?
|
||||
get() = synchronizedWithLog(convoVolatileLock) {
|
||||
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null
|
||||
if (_convoVolatileConfig == null) {
|
||||
val (secretKey, publicKey) = maybeGetUserInfo() ?: return null
|
||||
val convoDump = configDatabase.retrieveConfigAndHashes(
|
||||
@ -133,7 +129,6 @@ class ConfigFactory(
|
||||
|
||||
override val userGroups: UserGroupsConfig?
|
||||
get() = synchronizedWithLog(userGroupsLock) {
|
||||
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null
|
||||
if (_userGroups == null) {
|
||||
val (secretKey, publicKey) = maybeGetUserInfo() ?: return null
|
||||
val userGroupsDump = configDatabase.retrieveConfigAndHashes(
|
||||
@ -207,8 +202,6 @@ class ConfigFactory(
|
||||
openGroupId: String?,
|
||||
visibleOnly: Boolean
|
||||
): Boolean {
|
||||
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return true
|
||||
|
||||
val (_, userPublicKey) = maybeGetUserInfo() ?: return true
|
||||
|
||||
if (openGroupId != null) {
|
||||
@ -241,8 +234,6 @@ class ConfigFactory(
|
||||
}
|
||||
|
||||
override fun canPerformChange(variant: String, publicKey: String, changeTimestampMs: Long): Boolean {
|
||||
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return true
|
||||
|
||||
val lastUpdateTimestampMs = configDatabase.retrieveConfigLastUpdateTimestamp(variant, publicKey)
|
||||
|
||||
// Ensure the change occurred after the last config message was handled (minus the buffer period)
|
||||
|
@ -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 }
|
||||
}
|
||||
|
||||
}
|
@ -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) {
|
||||
|
@ -27,7 +27,6 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import network.loki.messenger.R
|
||||
import network.loki.messenger.databinding.ActivityHomeBinding
|
||||
import network.loki.messenger.libsession_util.ConfigBase
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import org.greenrobot.eventbus.Subscribe
|
||||
import org.greenrobot.eventbus.ThreadMode
|
||||
@ -336,8 +335,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
|
||||
}
|
||||
|
||||
private fun updateLegacyConfigView() {
|
||||
binding.configOutdatedView.isVisible = ConfigBase.isNewConfigEnabled(textSecurePreferences.hasForcedNewConfig(), SnodeAPI.nowWithOffset)
|
||||
&& textSecurePreferences.getHasLegacyConfig()
|
||||
binding.configOutdatedView.isVisible = textSecurePreferences.getHasLegacyConfig()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
|
@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.home
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.asFlow
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@ -22,7 +21,6 @@ import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
@ -60,12 +58,10 @@ class HomeViewModel @Inject constructor(
|
||||
observeTypingStatus(),
|
||||
messageRequests(),
|
||||
::Data
|
||||
)
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||
).stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||
|
||||
private fun hasHiddenMessageRequests() = TextSecurePreferences.events
|
||||
.filter { it == TextSecurePreferences.HAS_HIDDEN_MESSAGE_REQUESTS }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.map { prefs.hasHiddenMessageRequests() }
|
||||
.onStart { emit(prefs.hasHiddenMessageRequests()) }
|
||||
|
||||
@ -81,7 +77,7 @@ class HomeViewModel @Inject constructor(
|
||||
hasHiddenMessageRequests(),
|
||||
latestUnapprovedConversationTimestamp(),
|
||||
::createMessageRequests
|
||||
)
|
||||
).flowOn(Dispatchers.IO)
|
||||
|
||||
private fun unapprovedConversationCount() = reloadTriggersAndContentChanges()
|
||||
.map { threadDb.unapprovedConversationCount }
|
||||
@ -96,13 +92,13 @@ class HomeViewModel @Inject constructor(
|
||||
threadDb.readerFor(openCursor).run { generateSequence { next }.toList() }
|
||||
}
|
||||
}
|
||||
.flowOn(Dispatchers.IO)
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
private fun reloadTriggersAndContentChanges() = merge(
|
||||
manualReloadTrigger,
|
||||
contentResolver.observeChanges(DatabaseContentProviders.ConversationList.CONTENT_URI)
|
||||
)
|
||||
.flowOn(Dispatchers.IO)
|
||||
.debounce(CHANGE_NOTIFICATION_DEBOUNCE_MILLS)
|
||||
.onStart { emit(Unit) }
|
||||
|
||||
@ -114,7 +110,7 @@ class HomeViewModel @Inject constructor(
|
||||
val messageRequests: MessageRequests? = null
|
||||
)
|
||||
|
||||
fun createMessageRequests(
|
||||
private fun createMessageRequests(
|
||||
count: Int,
|
||||
hidden: Boolean,
|
||||
timestamp: Long
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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);
|
||||
|
@ -17,8 +17,6 @@ import org.session.libsession.messaging.jobs.ConfigurationSyncJob
|
||||
import org.session.libsession.messaging.jobs.JobQueue
|
||||
import org.session.libsession.messaging.messages.Destination
|
||||
import org.session.libsession.messaging.messages.control.ConfigurationMessage
|
||||
import org.session.libsession.messaging.sending_receiving.MessageSender
|
||||
import org.session.libsession.snode.SnodeAPI
|
||||
import org.session.libsession.utilities.Address
|
||||
import org.session.libsession.utilities.GroupUtil
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
@ -55,61 +53,16 @@ object ConfigurationMessageUtilities {
|
||||
fun syncConfigurationIfNeeded(context: Context) {
|
||||
// add if check here to schedule new config job process and return early
|
||||
val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return
|
||||
val forcedConfig = TextSecurePreferences.hasForcedNewConfig(context)
|
||||
val currentTime = SnodeAPI.nowWithOffset
|
||||
if (ConfigBase.isNewConfigEnabled(forcedConfig, currentTime)) {
|
||||
scheduleConfigSync(userPublicKey)
|
||||
return
|
||||
}
|
||||
val lastSyncTime = TextSecurePreferences.getLastConfigurationSyncTime(context)
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastSyncTime < 7 * 24 * 60 * 60 * 1000) return
|
||||
val contacts = ContactUtilities.getAllContacts(context).filter { recipient ->
|
||||
!recipient.name.isNullOrEmpty() && !recipient.isLocalNumber && recipient.address.serialize().isNotEmpty()
|
||||
}.map { recipient ->
|
||||
ConfigurationMessage.Contact(
|
||||
publicKey = recipient.address.serialize(),
|
||||
name = recipient.name!!,
|
||||
profilePicture = recipient.profileAvatar,
|
||||
profileKey = recipient.profileKey,
|
||||
isApproved = recipient.isApproved,
|
||||
isBlocked = recipient.isBlocked,
|
||||
didApproveMe = recipient.hasApprovedMe()
|
||||
)
|
||||
}
|
||||
val configurationMessage = ConfigurationMessage.getCurrent(contacts) ?: return
|
||||
MessageSender.send(configurationMessage, Address.fromSerialized(userPublicKey))
|
||||
TextSecurePreferences.setLastConfigurationSyncTime(context, now)
|
||||
scheduleConfigSync(userPublicKey)
|
||||
}
|
||||
|
||||
fun forceSyncConfigurationNowIfNeeded(context: Context): Promise<Unit, Exception> {
|
||||
// add if check here to schedule new config job process and return early
|
||||
val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return Promise.ofFail(NullPointerException("User Public Key is null"))
|
||||
val forcedConfig = TextSecurePreferences.hasForcedNewConfig(context)
|
||||
val currentTime = SnodeAPI.nowWithOffset
|
||||
if (ConfigBase.isNewConfigEnabled(forcedConfig, currentTime)) {
|
||||
// schedule job if none exist
|
||||
// don't schedule job if we already have one
|
||||
scheduleConfigSync(userPublicKey)
|
||||
return Promise.ofSuccess(Unit)
|
||||
}
|
||||
val contacts = ContactUtilities.getAllContacts(context).filter { recipient ->
|
||||
!recipient.isGroupRecipient && !recipient.name.isNullOrEmpty() && !recipient.isLocalNumber && recipient.address.serialize().isNotEmpty()
|
||||
}.map { recipient ->
|
||||
ConfigurationMessage.Contact(
|
||||
publicKey = recipient.address.serialize(),
|
||||
name = recipient.name!!,
|
||||
profilePicture = recipient.profileAvatar,
|
||||
profileKey = recipient.profileKey,
|
||||
isApproved = recipient.isApproved,
|
||||
isBlocked = recipient.isBlocked,
|
||||
didApproveMe = recipient.hasApprovedMe()
|
||||
)
|
||||
}
|
||||
val configurationMessage = ConfigurationMessage.getCurrent(contacts) ?: return Promise.ofSuccess(Unit)
|
||||
val promise = MessageSender.send(configurationMessage, Destination.from(Address.fromSerialized(userPublicKey)), isSyncMessage = true)
|
||||
TextSecurePreferences.setLastConfigurationSyncTime(context, System.currentTimeMillis())
|
||||
return promise
|
||||
// schedule job if none exist
|
||||
// don't schedule job if we already have one
|
||||
scheduleConfigSync(userPublicKey)
|
||||
return Promise.ofSuccess(Unit)
|
||||
}
|
||||
|
||||
private fun maybeUserSecretKey() = MessagingModuleConfiguration.shared.getUserED25519KeyPair()?.secretKey?.asBytes
|
||||
|
@ -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() }
|
@ -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)
|
||||
}
|
||||
}
|
@ -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"
|
||||
tools:text="@string/activity_conversation_empty_state_default"
|
||||
/>
|
||||
@ -263,11 +286,12 @@
|
||||
android:id="@+id/messageRequestBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_above="@id/inputBar"
|
||||
app:layout_constraintBottom_toTopOf="@+id/inputBar"
|
||||
app:layout_constraintTop_toBottomOf="@+id/textSendAfterApproval"
|
||||
android:layout_marginBottom="@dimen/large_spacing"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible">
|
||||
tools:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/messageRequestBlock"
|
||||
@ -321,4 +345,4 @@
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</RelativeLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
@ -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>
|
@ -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>
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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" />
|
||||
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
@ -0,0 +1,185 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2
|
||||
|
||||
import android.text.Selection
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mockito.kotlin.any
|
||||
import org.mockito.kotlin.doAnswer
|
||||
import org.mockito.kotlin.doReturn
|
||||
import org.mockito.kotlin.eq
|
||||
import org.mockito.kotlin.mock
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.session.libsession.messaging.contacts.Contact
|
||||
import org.session.libsession.messaging.open_groups.GroupMemberRole
|
||||
import org.session.libsession.messaging.open_groups.OpenGroup
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.MainCoroutineRule
|
||||
import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class MentionViewModelTest {
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@get:Rule
|
||||
val mainCoroutineRule = MainCoroutineRule()
|
||||
|
||||
private lateinit var mentionViewModel: MentionViewModel
|
||||
|
||||
private val threadID = 123L
|
||||
|
||||
private data class MemberInfo(
|
||||
val name: String,
|
||||
val pubKey: String,
|
||||
val roles: List<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.sessionID 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 ")
|
||||
}
|
||||
}
|
||||
}
|
@ -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])
|
||||
}
|
||||
}
|
4
app/src/test/resources/TestAndroidManifest.xml
Normal file
4
app/src/test/resources/TestAndroidManifest.xml
Normal file
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<manifest>
|
||||
|
||||
</manifest>
|
3
app/src/test/resources/robolectric.properties
Normal file
3
app/src/test/resources/robolectric.properties
Normal file
@ -0,0 +1,3 @@
|
||||
manifest=TestAndroidManifest.xml
|
||||
sdk=34
|
||||
application=android.app.Application
|
@ -27,12 +27,6 @@ sealed class ConfigBase(protected val /* yucky */ pointer: Long) {
|
||||
is UserGroupsConfig -> Kind.GROUPS
|
||||
}
|
||||
|
||||
// TODO: time in future to activate (hardcoded to 1st jan 2024 for testing, change before release)
|
||||
private const val ACTIVATE_TIME = 1690761600000
|
||||
|
||||
fun isNewConfigEnabled(forced: Boolean, currentTime: Long) =
|
||||
forced || currentTime >= ACTIVATE_TIME
|
||||
|
||||
const val PRIORITY_HIDDEN = -1
|
||||
const val PRIORITY_VISIBLE = 0
|
||||
const val PRIORITY_PINNED = 1
|
||||
|
@ -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?
|
||||
|
@ -1,6 +1,8 @@
|
||||
package org.session.libsession.messaging.jobs
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
import org.session.libsession.database.MessageDataProvider
|
||||
import org.session.libsession.database.StorageProtocol
|
||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||
import org.session.libsession.messaging.open_groups.OpenGroupApi
|
||||
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId
|
||||
@ -40,6 +42,36 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long)
|
||||
// Keys used for database storage
|
||||
private val ATTACHMENT_ID_KEY = "attachment_id"
|
||||
private val TS_INCOMING_MESSAGE_ID_KEY = "tsIncoming_message_id"
|
||||
|
||||
/**
|
||||
* Check if the attachment in the given message is eligible for download.
|
||||
*
|
||||
* Note that this function only checks for the eligibility of the attachment in the sense
|
||||
* of whether the download is allowed, it does not check if the download has already taken
|
||||
* place.
|
||||
*/
|
||||
fun eligibleForDownload(threadID: Long,
|
||||
storage: StorageProtocol,
|
||||
messageDataProvider: MessageDataProvider,
|
||||
databaseMessageID: Long): Boolean {
|
||||
val threadRecipient = storage.getRecipientForThread(threadID) ?: return false
|
||||
|
||||
// if we are the sender we are always eligible
|
||||
val selfSend = messageDataProvider.isMmsOutgoing(databaseMessageID)
|
||||
if (selfSend) {
|
||||
return true
|
||||
}
|
||||
|
||||
// you can't be eligible without a sender
|
||||
val sender = messageDataProvider.getIndividualRecipientForMms(databaseMessageID)?.address?.serialize()
|
||||
?: return false
|
||||
|
||||
// you can't be eligible without a contact entry
|
||||
val contact = storage.getContactWithSessionID(sender) ?: return false
|
||||
|
||||
// we are eligible if we are receiving a group message or the contact is trusted
|
||||
return threadRecipient.isGroupRecipient || contact.isTrusted
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun execute(dispatcherName: String) {
|
||||
@ -88,21 +120,7 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long)
|
||||
return
|
||||
}
|
||||
|
||||
val threadRecipient = storage.getRecipientForThread(threadID)
|
||||
val selfSend = messageDataProvider.isMmsOutgoing(databaseMessageID)
|
||||
val sender = if (selfSend) {
|
||||
storage.getUserPublicKey()
|
||||
} else {
|
||||
messageDataProvider.getIndividualRecipientForMms(databaseMessageID)?.address?.serialize()
|
||||
}
|
||||
val contact = sender?.let { storage.getContactWithSessionID(it) }
|
||||
if (threadRecipient == null || sender == null || (contact == null && !selfSend)) {
|
||||
handleFailure(Error.NoSender, null)
|
||||
return
|
||||
}
|
||||
if (!threadRecipient.isGroupRecipient && contact?.isTrusted != true && storage.getUserPublicKey() != sender) {
|
||||
// if we aren't receiving a group message, a message from ourselves (self-send) and the contact sending is not trusted:
|
||||
// do not continue, but do not fail
|
||||
if (!eligibleForDownload(threadID, storage, messageDataProvider, databaseMessageID)) {
|
||||
handleFailure(Error.NoSender, null)
|
||||
return
|
||||
}
|
||||
|
@ -1,6 +1,5 @@
|
||||
package org.session.libsession.messaging.jobs
|
||||
|
||||
import network.loki.messenger.libsession_util.ConfigBase
|
||||
import network.loki.messenger.libsession_util.ConfigBase.Companion.protoKindFor
|
||||
import nl.komponents.kovenant.functional.bind
|
||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||
@ -10,7 +9,6 @@ import org.session.libsession.messaging.sending_receiving.MessageSender
|
||||
import org.session.libsession.messaging.utilities.Data
|
||||
import org.session.libsession.snode.RawResponse
|
||||
import org.session.libsession.snode.SnodeAPI
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.session.libsignal.utilities.Log
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
@ -26,14 +24,10 @@ data class ConfigurationSyncJob(val destination: Destination): Job {
|
||||
|
||||
override suspend fun execute(dispatcherName: String) {
|
||||
val storage = MessagingModuleConfiguration.shared.storage
|
||||
val forcedConfig = TextSecurePreferences.hasForcedNewConfig(MessagingModuleConfiguration.shared.context)
|
||||
val currentTime = SnodeAPI.nowWithOffset
|
||||
val userEdKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair()
|
||||
val userPublicKey = storage.getUserPublicKey()
|
||||
val delegate = delegate
|
||||
if (destination is Destination.ClosedGroup // TODO: closed group configs will be handled in closed group feature
|
||||
// if we haven't enabled the new configs don't run
|
||||
|| !ConfigBase.isNewConfigEnabled(forcedConfig, currentTime)
|
||||
if (destination is Destination.ClosedGroup
|
||||
// if we don't have a user ed key pair for signing updates
|
||||
|| userEdKeyPair == null
|
||||
// this will be useful to not handle null delegate cases
|
||||
@ -67,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(),
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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),
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -203,12 +203,10 @@ private fun handleConfigurationMessage(message: ConfigurationMessage) {
|
||||
|
||||
TextSecurePreferences.setConfigurationMessageSynced(context, true)
|
||||
TextSecurePreferences.setLastProfileUpdateTime(context, message.sentTimestamp!!)
|
||||
val isForceSync = TextSecurePreferences.hasForcedNewConfig(context)
|
||||
val currentTime = SnodeAPI.nowWithOffset
|
||||
if (ConfigBase.isNewConfigEnabled(isForceSync, currentTime)) {
|
||||
TextSecurePreferences.setHasLegacyConfig(context, true)
|
||||
if (!firstTimeSync) return
|
||||
}
|
||||
|
||||
TextSecurePreferences.setHasLegacyConfig(context, true)
|
||||
if (!firstTimeSync) return
|
||||
|
||||
val allClosedGroupPublicKeys = storage.getAllClosedGroupPublicKeys()
|
||||
for (closedGroup in message.closedGroups) {
|
||||
if (allClosedGroupPublicKeys.contains(closedGroup.publicKey)) {
|
||||
|
@ -25,6 +25,7 @@ import org.session.libsession.snode.RawResponse
|
||||
import org.session.libsession.snode.SnodeAPI
|
||||
import org.session.libsession.snode.SnodeModule
|
||||
import org.session.libsession.utilities.ConfigFactoryProtocol
|
||||
import org.session.libsignal.utilities.Base64
|
||||
import org.session.libsignal.utilities.Log
|
||||
import org.session.libsignal.utilities.Namespace
|
||||
import org.session.libsignal.utilities.Snode
|
||||
@ -126,37 +127,26 @@ class Poller(private val configFactory: ConfigFactoryProtocol, debounceTimer: Ti
|
||||
private fun processConfig(snode: Snode, rawMessages: RawResponse, namespace: Int, forConfigObject: ConfigBase?) {
|
||||
if (forConfigObject == null) return
|
||||
|
||||
val messages = SnodeAPI.parseRawMessagesResponse(
|
||||
rawMessages,
|
||||
snode,
|
||||
userPublicKey,
|
||||
namespace,
|
||||
updateLatestHash = true,
|
||||
updateStoredHashes = true,
|
||||
)
|
||||
val messages = rawMessages["messages"] as? List<*>
|
||||
val processed = if (!messages.isNullOrEmpty()) {
|
||||
SnodeAPI.updateLastMessageHashValueIfPossible(snode, userPublicKey, messages, namespace)
|
||||
SnodeAPI.removeDuplicates(userPublicKey, messages, namespace, true).mapNotNull { messageBody ->
|
||||
val rawMessageAsJSON = messageBody as? Map<*, *> ?: return@mapNotNull null
|
||||
val hashValue = rawMessageAsJSON["hash"] as? String ?: return@mapNotNull null
|
||||
val b64EncodedBody = rawMessageAsJSON["data"] as? String ?: return@mapNotNull null
|
||||
val timestamp = rawMessageAsJSON["t"] as? Long ?: SnodeAPI.nowWithOffset
|
||||
val body = Base64.decode(b64EncodedBody)
|
||||
Triple(body, hashValue, timestamp)
|
||||
}
|
||||
} else emptyList()
|
||||
|
||||
if (messages.isEmpty()) {
|
||||
// no new messages to process
|
||||
return
|
||||
}
|
||||
if (processed.isEmpty()) return
|
||||
|
||||
var latestMessageTimestamp: Long? = null
|
||||
messages.forEach { (envelope, hash) ->
|
||||
processed.forEach { (body, hash, timestamp) ->
|
||||
try {
|
||||
val (message, _) = MessageReceiver.parse(data = envelope.toByteArray(),
|
||||
// assume no groups in personal poller messages
|
||||
openGroupServerID = null, currentClosedGroups = emptySet()
|
||||
)
|
||||
// sanity checks
|
||||
if (message !is SharedConfigurationMessage) {
|
||||
Log.w("Loki", "shared config message handled in configs wasn't SharedConfigurationMessage but was ${message.javaClass.simpleName}")
|
||||
return@forEach
|
||||
}
|
||||
val merged = forConfigObject.merge(hash!! to message.data).firstOrNull { it == hash }
|
||||
if (merged != null) {
|
||||
// We successfully merged the hash, we can now update the timestamp
|
||||
latestMessageTimestamp = if ((message.sentTimestamp ?: 0L) > (latestMessageTimestamp ?: 0L)) { message.sentTimestamp } else { latestMessageTimestamp }
|
||||
}
|
||||
forConfigObject.merge(hash to body)
|
||||
latestMessageTimestamp = if (timestamp > (latestMessageTimestamp ?: 0L)) { timestamp } else { latestMessageTimestamp }
|
||||
} catch (e: Exception) {
|
||||
Log.e("Loki", e)
|
||||
}
|
||||
|
@ -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 ->
|
||||
|
@ -1,11 +1,13 @@
|
||||
package org.session.libsignal.utilities
|
||||
|
||||
import android.os.Process
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.SynchronousQueue
|
||||
import java.util.concurrent.ThreadPoolExecutor
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
|
||||
object ThreadUtils {
|
||||
|
||||
@ -13,39 +15,16 @@ object ThreadUtils {
|
||||
|
||||
const val PRIORITY_IMPORTANT_BACKGROUND_THREAD = Process.THREAD_PRIORITY_DEFAULT + Process.THREAD_PRIORITY_LESS_FAVORABLE
|
||||
|
||||
// Paraphrased from: https://www.baeldung.com/kotlin/create-thread-pool
|
||||
// "A cached thread pool such as one created via:
|
||||
// `val executorPool: ExecutorService = Executors.newCachedThreadPool()`
|
||||
// will utilize resources according to the requirements of submitted tasks. It will try to reuse
|
||||
// existing threads for submitted tasks but will create as many threads as it needs if new tasks
|
||||
// keep pouring in (with a memory usage of at least 1MB per created thread). These threads will
|
||||
// live for up to 60 seconds of idle time before terminating by default. As such, it presents a
|
||||
// very sharp tool that doesn't include any backpressure mechanism - and a sudden peak in load
|
||||
// can bring the system down with an OutOfMemory error. We can achieve a similar effect but with
|
||||
// better control by creating a ThreadPoolExecutor manually."
|
||||
|
||||
private val corePoolSize = Runtime.getRuntime().availableProcessors() // Default thread pool size is our CPU core count
|
||||
private val maxPoolSize = corePoolSize * 4 // Allow a maximum pool size of up to 4 threads per core
|
||||
private val keepAliveTimeSecs = 100L // How long to keep idle threads in the pool before they are terminated
|
||||
private val workQueue = SynchronousQueue<Runnable>()
|
||||
val executorPool: ExecutorService = ThreadPoolExecutor(corePoolSize, maxPoolSize, keepAliveTimeSecs, TimeUnit.SECONDS, workQueue)
|
||||
|
||||
// Note: To see how many threads are running in our app at any given time we can use:
|
||||
// val threadCount = getAllStackTraces().size
|
||||
|
||||
@JvmStatic
|
||||
fun queue(target: Runnable) {
|
||||
executorPool.execute {
|
||||
try {
|
||||
target.run()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, e)
|
||||
}
|
||||
}
|
||||
queue(target::run)
|
||||
}
|
||||
|
||||
fun queue(target: () -> Unit) {
|
||||
executorPool.execute {
|
||||
Dispatchers.IO.dispatch(EmptyCoroutineContext) {
|
||||
try {
|
||||
target()
|
||||
} catch (e: Exception) {
|
||||
|
Loading…
Reference in New Issue
Block a user