Merge pull request #1539 from oxen-io/release/1.18.5

Master: Release/1.18.5
This commit is contained in:
ThomasSession 2024-07-09 14:35:47 +10:00 committed by GitHub
commit 84c5ebee6f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
78 changed files with 1926 additions and 881 deletions

View File

@ -31,8 +31,8 @@ configurations.all {
exclude module: "commons-logging" exclude module: "commons-logging"
} }
def canonicalVersionCode = 373 def canonicalVersionCode = 374
def canonicalVersionName = "1.18.4" def canonicalVersionName = "1.18.5"
def postFixSize = 10 def postFixSize = 10
def abiPostFix = ['armeabi-v7a' : 1, def abiPostFix = ['armeabi-v7a' : 1,
@ -368,8 +368,11 @@ dependencies {
androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.5.1' androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.5.1'
androidTestUtil 'androidx.test:orchestrator:1.4.2' androidTestUtil 'androidx.test:orchestrator:1.4.2'
testImplementation 'org.robolectric:robolectric:4.4' testImplementation 'org.robolectric:robolectric:4.12.2'
testImplementation 'org.robolectric:shadows-multidex:4.4' testImplementation 'org.robolectric:shadows-multidex:4.12.2'
testImplementation 'org.conscrypt:conscrypt-openjdk-uber:2.5.2' // For Robolectric
testImplementation 'app.cash.turbine:turbine:1.1.0'
implementation 'com.github.bumptech.glide:compose:1.0.0-alpha.5' implementation 'com.github.bumptech.glide:compose:1.0.0-alpha.5'
implementation 'androidx.compose.ui:ui:1.5.2' implementation 'androidx.compose.ui:ui:1.5.2'

View File

@ -1,9 +1,10 @@
package org.thoughtcrime.securesms package org.thoughtcrime.securesms
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import nl.komponents.kovenant.Kovenant import nl.komponents.kovenant.Kovenant
import nl.komponents.kovenant.jvm.asDispatcher import nl.komponents.kovenant.jvm.asDispatcher
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.ThreadUtils
import java.util.concurrent.Executors import java.util.concurrent.Executors
object AppContext { object AppContext {
@ -11,7 +12,7 @@ object AppContext {
fun configureKovenant() { fun configureKovenant() {
Kovenant.context { Kovenant.context {
callbackContext.dispatcher = Executors.newSingleThreadExecutor().asDispatcher() callbackContext.dispatcher = Executors.newSingleThreadExecutor().asDispatcher()
workerContext.dispatcher = ThreadUtils.executorPool.asDispatcher() workerContext.dispatcher = Dispatchers.IO.asExecutor().asDispatcher()
multipleCompletion = { v1, v2 -> multipleCompletion = { v1, v2 ->
Log.d("Loki", "Promise resolved more than once (first with $v1, then with $v2); ignoring $v2.") Log.d("Loki", "Promise resolved more than once (first with $v1, then with $v2); ignoring $v2.")
} }

View File

@ -30,13 +30,15 @@ public abstract class BaseActionBarActivity extends AppCompatActivity {
private static final String TAG = BaseActionBarActivity.class.getSimpleName(); private static final String TAG = BaseActionBarActivity.class.getSimpleName();
public ThemeState currentThemeState; public ThemeState currentThemeState;
private Resources.Theme modifiedTheme;
private TextSecurePreferences getPreferences() { private TextSecurePreferences getPreferences() {
ApplicationContext appContext = (ApplicationContext) getApplicationContext(); ApplicationContext appContext = (ApplicationContext) getApplicationContext();
return appContext.textSecurePreferences; return appContext.textSecurePreferences;
} }
@StyleRes @StyleRes
public int getDesiredTheme() { private int getDesiredTheme() {
ThemeState themeState = ActivityUtilitiesKt.themeState(getPreferences()); ThemeState themeState = ActivityUtilitiesKt.themeState(getPreferences());
int userSelectedTheme = themeState.getTheme(); int userSelectedTheme = themeState.getTheme();
@ -58,7 +60,7 @@ public abstract class BaseActionBarActivity extends AppCompatActivity {
} }
@StyleRes @Nullable @StyleRes @Nullable
public Integer getAccentTheme() { private Integer getAccentTheme() {
if (!getPreferences().hasPreference(SELECTED_ACCENT_COLOR)) return null; if (!getPreferences().hasPreference(SELECTED_ACCENT_COLOR)) return null;
ThemeState themeState = ActivityUtilitiesKt.themeState(getPreferences()); ThemeState themeState = ActivityUtilitiesKt.themeState(getPreferences());
return themeState.getAccentStyle(); return themeState.getAccentStyle();
@ -66,8 +68,12 @@ public abstract class BaseActionBarActivity extends AppCompatActivity {
@Override @Override
public Resources.Theme getTheme() { public Resources.Theme getTheme() {
if (modifiedTheme != null) {
return modifiedTheme;
}
// New themes // New themes
Resources.Theme modifiedTheme = super.getTheme(); modifiedTheme = super.getTheme();
modifiedTheme.applyStyle(getDesiredTheme(), true); modifiedTheme.applyStyle(getDesiredTheme(), true);
Integer accentTheme = getAccentTheme(); Integer accentTheme = getAccentTheme();
if (accentTheme != null) { if (accentTheme != null) {

View File

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

View File

@ -80,6 +80,11 @@ public class AudioSlidePlayer implements SensorEventListener {
} }
} }
@Nullable
public synchronized static AudioSlidePlayer getInstance() {
return playing.orNull();
}
private AudioSlidePlayer(@NonNull Context context, private AudioSlidePlayer(@NonNull Context context,
@NonNull AudioSlide slide, @NonNull AudioSlide slide,
@NonNull Listener listener) @NonNull Listener listener)

View File

@ -21,6 +21,7 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import android.provider.Settings
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityWebrtcBinding import network.loki.messenger.databinding.ActivityWebrtcBinding
@ -100,7 +101,14 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
super.onCreate(savedInstanceState, ready) 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) binding = ActivityWebrtcBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
@ -185,6 +193,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() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
hangupReceiver?.let { receiver -> hangupReceiver?.let { receiver ->

View File

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

View File

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

View File

@ -0,0 +1,115 @@
package org.thoughtcrime.securesms.conversation.v2
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import org.session.libsession.database.MessageDataProvider
import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.jobs.AttachmentDownloadJob
import org.session.libsession.messaging.jobs.AttachmentUploadJob
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.util.flatten
import org.thoughtcrime.securesms.util.timedBuffer
/**
* [AttachmentDownloadHandler] is responsible for handling attachment download requests. These
* requests will go through different level of checking before they are queued for download.
*
* To use this handler, call [onAttachmentDownloadRequest] with the attachment that needs to be
* downloaded. The call to [onAttachmentDownloadRequest] is cheap and can be called multiple times.
*/
class AttachmentDownloadHandler(
private val storage: StorageProtocol,
private val messageDataProvider: MessageDataProvider,
jobQueue: JobQueue = JobQueue.shared,
scope: CoroutineScope = CoroutineScope(Dispatchers.Default) + SupervisorJob(),
) {
companion object {
private const val BUFFER_TIMEOUT_MILLS = 500L
private const val BUFFER_MAX_ITEMS = 10
private const val LOG_TAG = "AttachmentDownloadHelper"
}
private val downloadRequests = Channel<DatabaseAttachment>(UNLIMITED)
init {
scope.launch(Dispatchers.Default) {
downloadRequests
.receiveAsFlow()
.timedBuffer(BUFFER_TIMEOUT_MILLS, BUFFER_MAX_ITEMS)
.map(::filterEligibleAttachments)
.flatten()
.collect { attachment ->
jobQueue.add(
AttachmentDownloadJob(
attachmentID = attachment.attachmentId.rowId,
databaseMessageID = attachment.mmsId
)
)
}
}
}
/**
* Filter attachments that are eligible for creating download jobs.
*
*/
private fun filterEligibleAttachments(attachments: List<DatabaseAttachment>): List<DatabaseAttachment> {
val pendingAttachmentIDs = storage
.getAllPendingJobs(AttachmentDownloadJob.KEY, AttachmentUploadJob.KEY)
.values
.mapNotNull {
(it as? AttachmentUploadJob)?.attachmentID
?: (it as? AttachmentDownloadJob)?.attachmentID
}
.toSet()
return attachments.filter { attachment ->
eligibleForDownloadTask(
attachment,
pendingAttachmentIDs,
)
}
}
/**
* Check if the attachment is eligible for download task.
*/
private fun eligibleForDownloadTask(
attachment: DatabaseAttachment,
pendingJobsAttachmentRowIDs: Set<Long>,
): Boolean {
if (attachment.attachmentId.rowId in pendingJobsAttachmentRowIDs) {
return false
}
val threadID = storage.getThreadIdForMms(attachment.mmsId)
return AttachmentDownloadJob.eligibleForDownload(
threadID, storage, messageDataProvider, attachment.mmsId,
)
}
fun onAttachmentDownloadRequest(attachment: DatabaseAttachment) {
if (attachment.transferState != AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING) {
Log.i(
LOG_TAG,
"Attachment ${attachment.attachmentId} is not pending, skipping download"
)
return
}
downloadRequests.trySend(attachment)
}
}

View File

@ -29,8 +29,8 @@ import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup.LayoutParams
import android.view.WindowManager import android.view.WindowManager
import android.widget.RelativeLayout
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@ -46,6 +46,7 @@ import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.loader.app.LoaderManager import androidx.loader.app.LoaderManager
import androidx.loader.content.Loader import androidx.loader.content.Loader
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@ -61,15 +62,12 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityConversationV2Binding import network.loki.messenger.databinding.ActivityConversationV2Binding
import network.loki.messenger.databinding.ViewVisibleMessageBinding
import network.loki.messenger.libsession_util.util.ExpiryMode import network.loki.messenger.libsession_util.util.ExpiryMode
import nl.komponents.kovenant.ui.successUi import nl.komponents.kovenant.ui.successUi
import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.jobs.AttachmentDownloadJob import org.session.libsession.messaging.jobs.AttachmentDownloadJob
import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.mentions.Mention
import org.session.libsession.messaging.mentions.MentionsManager
import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.messaging.messages.applyExpiryMode import org.session.libsession.messaging.messages.applyExpiryMode
import org.session.libsession.messaging.messages.control.DataExtractionNotification import org.session.libsession.messaging.messages.control.DataExtractionNotification
@ -119,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.InputBarButton
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarDelegate import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarDelegate
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarRecordingViewDelegate import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarRecordingViewDelegate
import org.thoughtcrime.securesms.conversation.v2.input_bar.mentions.MentionCandidatesView import org.thoughtcrime.securesms.conversation.v2.input_bar.mentions.MentionCandidateAdapter
import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallback import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallback
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallbackDelegate import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallbackDelegate
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper
@ -216,6 +215,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
@Inject lateinit var storage: Storage @Inject lateinit var storage: Storage
@Inject lateinit var reactionDb: ReactionDatabase @Inject lateinit var reactionDb: ReactionDatabase
@Inject lateinit var viewModelFactory: ConversationViewModel.AssistedFactory @Inject lateinit var viewModelFactory: ConversationViewModel.AssistedFactory
@Inject lateinit var mentionViewModelFactory: MentionViewModel.AssistedFactory
private val screenshotObserver by lazy { private val screenshotObserver by lazy {
ScreenshotObserver(this, Handler(Looper.getMainLooper())) { ScreenshotObserver(this, Handler(Looper.getMainLooper())) {
@ -229,7 +229,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
ViewModelProvider(this, LinkPreviewViewModel.Factory(LinkPreviewRepository())) ViewModelProvider(this, LinkPreviewViewModel.Factory(LinkPreviewRepository()))
.get(LinkPreviewViewModel::class.java) .get(LinkPreviewViewModel::class.java)
} }
private val viewModel: ConversationViewModel by viewModels {
private val threadId: Long by lazy {
var threadId = intent.getLongExtra(THREAD_ID, -1L) var threadId = intent.getLongExtra(THREAD_ID, -1L)
if (threadId == -1L) { if (threadId == -1L) {
intent.getParcelableExtra<Address>(ADDRESS)?.let { it -> intent.getParcelableExtra<Address>(ADDRESS)?.let { it ->
@ -249,6 +250,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
} ?: finish() } ?: finish()
} }
threadId
}
private val viewModel: ConversationViewModel by viewModels {
viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair()) viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair())
} }
private var actionMode: ActionMode? = null private var actionMode: ActionMode? = null
@ -261,11 +267,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private var isLockViewExpanded = false private var isLockViewExpanded = false
private var isShowingAttachmentOptions = false private var isShowingAttachmentOptions = false
// Mentions // Mentions
private val mentions = mutableListOf<Mention>() private val mentionViewModel: MentionViewModel by viewModels {
private var mentionCandidatesView: MentionCandidatesView? = null mentionViewModelFactory.create(threadId)
private var previousText: CharSequence = "" }
private var currentMentionStartIndex = -1 private val mentionCandidateAdapter = MentionCandidateAdapter {
private var isShowingMentionCandidatesView = false mentionViewModel.onCandidateSelected(it.member.publicKey)
}
// Search // Search
val searchViewModel: SearchViewModel by viewModels() val searchViewModel: SearchViewModel by viewModels()
var searchViewItem: MenuItem? = null var searchViewItem: MenuItem? = null
@ -326,11 +333,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
onDeselect(message, position, it) onDeselect(message, position, it)
} }
}, },
onAttachmentNeedsDownload = { attachmentId, mmsId -> onAttachmentNeedsDownload = viewModel::onAttachmentDownloadRequest,
lifecycleScope.launch(Dispatchers.IO) {
JobQueue.shared.add(AttachmentDownloadJob(attachmentId, mmsId))
}
},
glide = glide, glide = glide,
lifecycleCoroutineScope = lifecycleScope lifecycleCoroutineScope = lifecycleScope
) )
@ -487,6 +490,27 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
} }
} }
setupMentionView()
}
private fun setupMentionView() {
binding?.conversationMentionCandidates?.let { view ->
view.adapter = mentionCandidateAdapter
view.itemAnimator = null
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
mentionViewModel.autoCompleteState
.collectLatest { state ->
mentionCandidateAdapter.candidates =
(state as? MentionViewModel.AutoCompleteState.Result)?.members.orEmpty()
}
}
}
binding?.inputBar?.setInputBarEditableFactory(mentionViewModel.editableFactory)
} }
override fun onResume() { override fun onResume() {
@ -643,23 +667,19 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
binding.inputBar.delegate = this binding.inputBar.delegate = this
binding.inputBarRecordingView.delegate = this binding.inputBarRecordingView.delegate = this
// GIF button // GIF button
binding.gifButtonContainer.addView(gifButton) binding.gifButtonContainer.addView(gifButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
gifButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
gifButton.onUp = { showGIFPicker() } gifButton.onUp = { showGIFPicker() }
gifButton.snIsEnabled = false gifButton.snIsEnabled = false
// Document button // Document button
binding.documentButtonContainer.addView(documentButton) binding.documentButtonContainer.addView(documentButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
documentButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
documentButton.onUp = { showDocumentPicker() } documentButton.onUp = { showDocumentPicker() }
documentButton.snIsEnabled = false documentButton.snIsEnabled = false
// Library button // Library button
binding.libraryButtonContainer.addView(libraryButton) binding.libraryButtonContainer.addView(libraryButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
libraryButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
libraryButton.onUp = { pickFromLibrary() } libraryButton.onUp = { pickFromLibrary() }
libraryButton.snIsEnabled = false libraryButton.snIsEnabled = false
// Camera button // Camera button
binding.cameraButtonContainer.addView(cameraButton) binding.cameraButtonContainer.addView(cameraButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
cameraButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
cameraButton.onUp = { showCamera() } cameraButton.onUp = { showCamera() }
cameraButton.snIsEnabled = false cameraButton.snIsEnabled = false
} }
@ -914,7 +934,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
if (textSecurePreferences.isLinkPreviewsEnabled()) { if (textSecurePreferences.isLinkPreviewsEnabled()) {
linkPreviewViewModel.onTextChanged(this, inputBarText, 0, 0) linkPreviewViewModel.onTextChanged(this, inputBarText, 0, 0)
} }
showOrHideMentionCandidatesIfNeeded(newContent)
if (LinkPreviewUtil.findWhitelistedUrls(newContent.toString()).isNotEmpty() if (LinkPreviewUtil.findWhitelistedUrls(newContent.toString()).isNotEmpty()
&& !textSecurePreferences.isLinkPreviewsEnabled() && !textSecurePreferences.hasSeenLinkPreviewSuggestionDialog()) { && !textSecurePreferences.isLinkPreviewsEnabled() && !textSecurePreferences.hasSeenLinkPreviewSuggestionDialog()) {
LinkPreviewDialog { LinkPreviewDialog {
@ -926,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() { override fun toggleAttachmentOptions() {
val targetAlpha = if (isShowingAttachmentOptions) 0.0f else 1.0f val targetAlpha = if (isShowingAttachmentOptions) 0.0f else 1.0f
val allButtonContainers = listOfNotNull( val allButtonContainers = listOfNotNull(
@ -1511,18 +1460,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
return hitRect.contains(x, y) return hitRect.contains(x, y)
} }
private fun handleMentionSelected(mention: Mention) {
val binding = binding ?: return
if (currentMentionStartIndex == -1) { return }
mentions.add(mention)
val previousText = binding.inputBar.text
val newText = previousText.substring(0, currentMentionStartIndex) + "@" + mention.displayName + " "
binding.inputBar.text = newText
binding.inputBar.setSelection(newText.length)
currentMentionStartIndex = -1
hideMentionCandidates()
this.previousText = newText
}
override fun scrollToMessageIfPossible(timestamp: Long) { override fun scrollToMessageIfPossible(timestamp: Long) {
val lastSeenItemPosition = adapter.getItemPositionForTimestamp(timestamp) ?: return val lastSeenItemPosition = adapter.getItemPositionForTimestamp(timestamp) ?: return
@ -1558,8 +1495,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
if (indexInAdapter < 0 || indexInAdapter >= adapter.itemCount) { return } if (indexInAdapter < 0 || indexInAdapter >= adapter.itemCount) { return }
val viewHolder = binding?.conversationRecyclerView?.findViewHolderForAdapterPosition(indexInAdapter) as? ConversationAdapter.VisibleMessageViewHolder ?: return val viewHolder = binding?.conversationRecyclerView?.findViewHolderForAdapterPosition(indexInAdapter) as? ConversationAdapter.VisibleMessageViewHolder ?: return
val visibleMessageView = ViewVisibleMessageBinding.bind(viewHolder.view).visibleMessageView viewHolder.view.playVoiceMessage()
visibleMessageView.playVoiceMessage()
} }
override fun sendMessage() { override fun sendMessage() {
@ -1622,10 +1558,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
binding?.inputBar?.text = "" binding?.inputBar?.text = ""
binding?.inputBar?.cancelQuoteDraft() binding?.inputBar?.cancelQuoteDraft()
binding?.inputBar?.cancelLinkPreviewDraft() binding?.inputBar?.cancelLinkPreviewDraft()
// Clear mentions
previousText = ""
currentMentionStartIndex = -1
mentions.clear()
// Put the message in the database // Put the message in the database
message.id = smsDb.insertMessageOutbox(viewModel.threadId, outgoingTextMessage, false, message.sentTimestamp!!, null, true) message.id = smsDb.insertMessageOutbox(viewModel.threadId, outgoingTextMessage, false, message.sentTimestamp!!, null, true)
// Send it // Send it
@ -1670,10 +1602,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
binding?.inputBar?.text = "" binding?.inputBar?.text = ""
binding?.inputBar?.cancelQuoteDraft() binding?.inputBar?.cancelQuoteDraft()
binding?.inputBar?.cancelLinkPreviewDraft() binding?.inputBar?.cancelLinkPreviewDraft()
// Clear mentions
previousText = ""
currentMentionStartIndex = -1
mentions.clear()
// Reset the attachment manager // Reset the attachment manager
attachmentManager.clear() attachmentManager.clear()
// Reset attachments button if needed // Reset attachments button if needed
@ -1960,7 +1888,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val messageIterator = sortedMessages.iterator() val messageIterator = sortedMessages.iterator()
while (messageIterator.hasNext()) { while (messageIterator.hasNext()) {
val message = messageIterator.next() val message = messageIterator.next()
val body = MentionUtilities.highlightMentions(message.body, viewModel.threadId, this) val body = MentionUtilities.highlightMentions(
text = message.body,
formatOnly = true, // no styling here, only text formatting
threadID = viewModel.threadId,
context = this
)
if (TextUtils.isEmpty(body)) { continue } if (TextUtils.isEmpty(body)) { continue }
if (messageSize > 1) { if (messageSize > 1) {
val formattedTimestamp = DateUtils.getDisplayFormattedTimeSpanString(this, Locale.getDefault(), message.timestamp) val formattedTimestamp = DateUtils.getDisplayFormattedTimeSpanString(this, Locale.getDefault(), message.timestamp)
@ -2101,17 +2035,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
// region General // region General
private fun getMessageBody(): String { private fun getMessageBody(): String {
var result = binding?.inputBar?.text?.trim() ?: return "" return mentionViewModel.normalizeMessageBody()
for (mention in mentions) {
try {
val startIndex = result.indexOf("@" + mention.displayName)
val endIndex = startIndex + mention.displayName.count() + 1 // + 1 to include the "@"
result = result.substring(0, startIndex) + "@" + mention.publicKey + result.substring(endIndex)
} catch (exception: Exception) {
Log.d("Loki", "Failed to process mention due to error: $exception")
}
}
return result
} }
// endregion // endregion

View File

@ -5,9 +5,7 @@ import android.content.Intent
import android.database.Cursor import android.database.Cursor
import android.util.SparseArray import android.util.SparseArray
import android.util.SparseBooleanArray import android.util.SparseBooleanArray
import android.view.LayoutInflater
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.core.util.getOrDefault import androidx.core.util.getOrDefault
@ -20,14 +18,12 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ViewVisibleMessageBinding
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.conversation.v2.messages.ControlMessageView import org.thoughtcrime.securesms.conversation.v2.messages.ControlMessageView
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDelegate import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDelegate
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter
import org.thoughtcrime.securesms.database.MmsSmsColumns
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.GlideRequests
@ -45,7 +41,7 @@ class ConversationAdapter(
private val onItemSwipeToReply: (MessageRecord, Int) -> Unit, private val onItemSwipeToReply: (MessageRecord, Int) -> Unit,
private val onItemLongPress: (MessageRecord, Int, VisibleMessageView) -> Unit, private val onItemLongPress: (MessageRecord, Int, VisibleMessageView) -> Unit,
private val onDeselect: (MessageRecord, Int) -> Unit, private val onDeselect: (MessageRecord, Int) -> Unit,
private val onAttachmentNeedsDownload: (Long, Long) -> Unit, private val onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit,
private val glide: GlideRequests, private val glide: GlideRequests,
lifecycleCoroutineScope: LifecycleCoroutineScope lifecycleCoroutineScope: LifecycleCoroutineScope
) : CursorRecyclerViewAdapter<ViewHolder>(context, cursor) { ) : CursorRecyclerViewAdapter<ViewHolder>(context, cursor) {
@ -90,7 +86,7 @@ class ConversationAdapter(
} }
} }
class VisibleMessageViewHolder(val view: View) : ViewHolder(view) class VisibleMessageViewHolder(val view: VisibleMessageView) : ViewHolder(view)
class ControlMessageViewHolder(val view: ControlMessageView) : ViewHolder(view) class ControlMessageViewHolder(val view: ControlMessageView) : ViewHolder(view)
override fun getItemViewType(cursor: Cursor): Int { override fun getItemViewType(cursor: Cursor): Int {
@ -103,7 +99,7 @@ class ConversationAdapter(
@Suppress("NAME_SHADOWING") @Suppress("NAME_SHADOWING")
val viewType = ViewType.allValues[viewType] val viewType = ViewType.allValues[viewType]
return when (viewType) { return when (viewType) {
ViewType.Visible -> VisibleMessageViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.view_visible_message, parent, false)) ViewType.Visible -> VisibleMessageViewHolder(VisibleMessageView(context))
ViewType.Control -> ControlMessageViewHolder(ControlMessageView(context)) ViewType.Control -> ControlMessageViewHolder(ControlMessageView(context))
else -> throw IllegalStateException("Unexpected view type: $viewType.") else -> throw IllegalStateException("Unexpected view type: $viewType.")
} }
@ -115,7 +111,7 @@ class ConversationAdapter(
val messageBefore = getMessageBefore(position, cursor) val messageBefore = getMessageBefore(position, cursor)
when (viewHolder) { when (viewHolder) {
is VisibleMessageViewHolder -> { is VisibleMessageViewHolder -> {
val visibleMessageView = ViewVisibleMessageBinding.bind(viewHolder.view).visibleMessageView val visibleMessageView = viewHolder.view
val isSelected = selectedItems.contains(message) val isSelected = selectedItems.contains(message)
visibleMessageView.snIsSelected = isSelected visibleMessageView.snIsSelected = isSelected
visibleMessageView.indexInAdapter = position visibleMessageView.indexInAdapter = position
@ -181,7 +177,7 @@ class ConversationAdapter(
override fun onItemViewRecycled(viewHolder: ViewHolder?) { override fun onItemViewRecycled(viewHolder: ViewHolder?) {
when (viewHolder) { when (viewHolder) {
is VisibleMessageViewHolder -> viewHolder.view.findViewById<VisibleMessageView>(R.id.visibleMessageView).recycle() is VisibleMessageViewHolder -> viewHolder.view.recycle()
is ControlMessageViewHolder -> viewHolder.view.recycle() is ControlMessageViewHolder -> viewHolder.view.recycle()
} }
super.onItemViewRecycled(viewHolder) super.onItemViewRecycled(viewHolder)

View File

@ -1,45 +1,44 @@
package org.thoughtcrime.securesms.conversation.v2 package org.thoughtcrime.securesms.conversation.v2
import android.content.Context import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.goterl.lazysodium.utils.KeyPair import com.goterl.lazysodium.utils.KeyPair
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.session.libsession.database.MessageDataProvider
import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.session.libsession.messaging.utilities.SessionId import org.session.libsession.messaging.utilities.SessionId
import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.MmsDatabase
import org.thoughtcrime.securesms.audio.AudioSlidePlayer
import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.repository.ConversationRepository
import java.util.UUID import java.util.UUID
class ConversationViewModel( class ConversationViewModel(
val threadId: Long, val threadId: Long,
val edKeyPair: KeyPair?, val edKeyPair: KeyPair?,
private val repository: ConversationRepository, private val repository: ConversationRepository,
private val storage: Storage private val storage: Storage,
private val messageDataProvider: MessageDataProvider,
database: MmsDatabase,
) : ViewModel() { ) : ViewModel() {
val showSendAfterApprovalText: Boolean val showSendAfterApprovalText: Boolean
@ -91,6 +90,11 @@ class ConversationViewModel(
// allow reactions if the open group is null (normal conversations) or the open group's capabilities include reactions // allow reactions if the open group is null (normal conversations) or the open group's capabilities include reactions
get() = (openGroup == null || OpenGroupApi.Capability.REACTIONS.name.lowercase() in serverCapabilities) get() = (openGroup == null || OpenGroupApi.Capability.REACTIONS.name.lowercase() in serverCapabilities)
private val attachmentDownloadHandler = AttachmentDownloadHandler(
storage = storage,
messageDataProvider = messageDataProvider,
scope = viewModelScope,
)
init { init {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
@ -103,6 +107,13 @@ class ConversationViewModel(
} }
} }
override fun onCleared() {
super.onCleared()
// Stop all voice message when exiting this page
AudioSlidePlayer.stopAll()
}
fun saveDraft(text: String) { fun saveDraft(text: String) {
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
repository.saveDraft(threadId, text) repository.saveDraft(threadId, text)
@ -142,10 +153,20 @@ class ConversationViewModel(
} }
fun deleteLocally(message: MessageRecord) { fun deleteLocally(message: MessageRecord) {
stopPlayingAudioMessage(message)
val recipient = recipient ?: return Log.w("Loki", "Recipient was null for delete locally action") val recipient = recipient ?: return Log.w("Loki", "Recipient was null for delete locally action")
repository.deleteLocally(recipient, message) repository.deleteLocally(recipient, message)
} }
/**
* Stops audio player if its current playing is the one given in the message.
*/
private fun stopPlayingAudioMessage(message: MessageRecord) {
val mmsMessage = message as? MmsMessageRecord ?: return
val audioSlide = mmsMessage.slideDeck.audioSlide ?: return
AudioSlidePlayer.getInstance()?.takeIf { it.audioSlide == audioSlide }?.stop()
}
fun setRecipientApproved() { fun setRecipientApproved() {
val recipient = recipient ?: return Log.w("Loki", "Recipient was null for set approved action") val recipient = recipient ?: return Log.w("Loki", "Recipient was null for set approved action")
repository.setApproved(recipient, true) repository.setApproved(recipient, true)
@ -153,10 +174,12 @@ class ConversationViewModel(
fun deleteForEveryone(message: MessageRecord) = viewModelScope.launch { fun deleteForEveryone(message: MessageRecord) = viewModelScope.launch {
val recipient = recipient ?: return@launch Log.w("Loki", "Recipient was null for delete for everyone - aborting delete operation.") val recipient = recipient ?: return@launch Log.w("Loki", "Recipient was null for delete for everyone - aborting delete operation.")
stopPlayingAudioMessage(message)
repository.deleteForEveryone(threadId, recipient, message) repository.deleteForEveryone(threadId, recipient, message)
.onSuccess { .onSuccess {
Log.d("Loki", "Deleted message ${message.id} ") Log.d("Loki", "Deleted message ${message.id} ")
stopPlayingAudioMessage(message)
} }
.onFailure { .onFailure {
Log.w("Loki", "FAILED TO delete message ${message.id} ") Log.w("Loki", "FAILED TO delete message ${message.id} ")
@ -222,7 +245,7 @@ class ConversationViewModel(
currentUiState.copy(uiMessages = messages) currentUiState.copy(uiMessages = messages)
} }
} }
fun messageShown(messageId: Long) { fun messageShown(messageId: Long) {
_uiState.update { currentUiState -> _uiState.update { currentUiState ->
val messages = currentUiState.uiMessages.filterNot { it.id == messageId } val messages = currentUiState.uiMessages.filterNot { it.id == messageId }
@ -245,6 +268,10 @@ class ConversationViewModel(
storage.getLastLegacyRecipient(address.serialize())?.let { Recipient.from(context, Address.fromSerialized(it), false) } storage.getLastLegacyRecipient(address.serialize())?.let { Recipient.from(context, Address.fromSerialized(it), false) }
} }
fun onAttachmentDownloadRequest(attachment: DatabaseAttachment) {
attachmentDownloadHandler.onAttachmentDownloadRequest(attachment)
}
@dagger.assisted.AssistedFactory @dagger.assisted.AssistedFactory
interface AssistedFactory { interface AssistedFactory {
fun create(threadId: Long, edKeyPair: KeyPair?): Factory fun create(threadId: Long, edKeyPair: KeyPair?): Factory
@ -255,11 +282,20 @@ class ConversationViewModel(
@Assisted private val threadId: Long, @Assisted private val threadId: Long,
@Assisted private val edKeyPair: KeyPair?, @Assisted private val edKeyPair: KeyPair?,
private val repository: ConversationRepository, private val repository: ConversationRepository,
private val storage: Storage private val storage: Storage,
private val mmsDatabase: MmsDatabase,
private val messageDataProvider: MessageDataProvider,
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
return ConversationViewModel(threadId, edKeyPair, repository, storage) as T return ConversationViewModel(
threadId = threadId,
edKeyPair = edKeyPair,
repository = repository,
storage = storage,
messageDataProvider = messageDataProvider,
database = mmsDatabase
) as T
} }
} }
} }

View File

@ -55,6 +55,7 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ViewVisibleMessageContentBinding import network.loki.messenger.databinding.ViewVisibleMessageContentBinding
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.MediaPreviewActivity.getPreviewIntent import org.thoughtcrime.securesms.MediaPreviewActivity.getPreviewIntent
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.Storage
@ -149,7 +150,7 @@ fun MessageDetails(
onResend: (() -> Unit)? = null, onResend: (() -> Unit)? = null,
onDelete: () -> Unit = {}, onDelete: () -> Unit = {},
onClickImage: (Int) -> Unit = {}, onClickImage: (Int) -> Unit = {},
onAttachmentNeedsDownload: (Long, Long) -> Unit = { _, _ -> } onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit = { _ -> }
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@ import android.content.Context
import android.content.res.Resources import android.content.res.Resources
import android.graphics.PointF import android.graphics.PointF
import android.net.Uri import android.net.Uri
import android.text.Editable
import android.text.InputType import android.text.InputType
import android.text.TextWatcher import android.text.TextWatcher
import android.util.AttributeSet import android.util.AttributeSet
@ -13,6 +14,7 @@ import android.view.MotionEvent
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.widget.RelativeLayout import android.widget.RelativeLayout
import android.widget.TextView import android.widget.TextView
import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ViewInputBarBinding import network.loki.messenger.databinding.ViewInputBarBinding
@ -119,8 +121,8 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
// region Updating // region Updating
override fun inputBarEditTextContentChanged(text: CharSequence) { override fun inputBarEditTextContentChanged(text: CharSequence) {
sendButton.isVisible = text.isNotEmpty() microphoneButton.isVisible = text.trim().isEmpty()
microphoneButton.isVisible = text.isEmpty() sendButton.isVisible = microphoneButton.isGone
delegate?.inputBarEditTextContentChanged(text) delegate?.inputBarEditTextContentChanged(text)
} }
@ -223,8 +225,8 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
binding.inputBarEditText.addTextChangedListener(textWatcher) binding.inputBarEditText.addTextChangedListener(textWatcher)
} }
fun setSelection(index: Int) { fun setInputBarEditableFactory(factory: Editable.Factory) {
binding.inputBarEditText.setSelection(index) binding.inputBarEditText.setEditableFactory(factory)
} }
// endregion // endregion
} }

View File

@ -0,0 +1,44 @@
package org.thoughtcrime.securesms.conversation.v2.input_bar.mentions
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import network.loki.messenger.databinding.ViewMentionCandidateV2Binding
import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel
class MentionCandidateAdapter(
private val onCandidateSelected: ((MentionViewModel.Candidate) -> Unit)
) : RecyclerView.Adapter<MentionCandidateAdapter.ViewHolder>() {
var candidates = listOf<MentionViewModel.Candidate>()
set(newValue) {
if (field != newValue) {
val result = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
override fun getOldListSize() = field.size
override fun getNewListSize() = newValue.size
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int)
= field[oldItemPosition].member.publicKey == newValue[newItemPosition].member.publicKey
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int)
= field[oldItemPosition] == newValue[newItemPosition]
})
field = newValue
result.dispatchUpdatesTo(this)
}
}
class ViewHolder(val binding: ViewMentionCandidateV2Binding)
: RecyclerView.ViewHolder(binding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(ViewMentionCandidateV2Binding.inflate(LayoutInflater.from(parent.context), parent, false))
}
override fun getItemCount(): Int = candidates.size
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val candidate = candidates[position]
holder.binding.update(candidate)
holder.binding.root.setOnClickListener { onCandidateSelected(candidate) }
}
}

View File

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

View File

@ -1,90 +0,0 @@
package org.thoughtcrime.securesms.conversation.v2.input_bar.mentions
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.ListView
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
import org.session.libsession.messaging.mentions.Mention
import org.thoughtcrime.securesms.database.LokiThreadDatabase
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.util.toPx
import javax.inject.Inject
@AndroidEntryPoint
class MentionCandidatesView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : ListView(context, attrs, defStyleAttr) {
private var candidates = listOf<Mention>()
set(newValue) { field = newValue; snAdapter.candidates = newValue }
var glide: GlideRequests? = null
set(newValue) { field = newValue; snAdapter.glide = newValue }
var openGroupServer: String? = null
set(newValue) { field = newValue; snAdapter.openGroupServer = openGroupServer }
var openGroupRoom: String? = null
set(newValue) { field = newValue; snAdapter.openGroupRoom = openGroupRoom }
var onCandidateSelected: ((Mention) -> Unit)? = null
@Inject lateinit var threadDb: LokiThreadDatabase
private val snAdapter by lazy { Adapter(context) }
private class Adapter(private val context: Context) : BaseAdapter() {
var candidates = listOf<Mention>()
set(newValue) { field = newValue; notifyDataSetChanged() }
var glide: GlideRequests? = null
var openGroupServer: String? = null
var openGroupRoom: String? = null
override fun getCount(): Int { return candidates.count() }
override fun getItemId(position: Int): Long { return position.toLong() }
override fun getItem(position: Int): Mention { return candidates[position] }
override fun getView(position: Int, cellToBeReused: View?, parent: ViewGroup): View {
val cell = cellToBeReused as MentionCandidateView? ?: MentionCandidateView(context).apply {
contentDescription = context.getString(R.string.AccessibilityId_contact)
}
val mentionCandidate = getItem(position)
cell.glide = glide
cell.candidate = mentionCandidate
cell.openGroupServer = openGroupServer
cell.openGroupRoom = openGroupRoom
return cell
}
}
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
constructor(context: Context) : this(context, null)
init {
clipToOutline = true
adapter = snAdapter
snAdapter.candidates = candidates
setOnItemClickListener { _, _, position, _ ->
onCandidateSelected?.invoke(candidates[position])
}
}
fun show(candidates: List<Mention>, threadID: Long) {
val openGroup = threadDb.getOpenGroupChat(threadID)
if (openGroup != null) {
openGroupServer = openGroup.server
openGroupRoom = openGroup.room
}
setMentionCandidates(candidates)
}
fun setMentionCandidates(candidates: List<Mention>) {
this.candidates = candidates
val layoutParams = this.layoutParams as ViewGroup.LayoutParams
layoutParams.height = toPx(Math.min(candidates.count(), 4) * 44, resources)
this.layoutParams = layoutParams
}
fun hide() {
val layoutParams = this.layoutParams as ViewGroup.LayoutParams
layoutParams.height = 0
this.layoutParams = layoutParams
}
}

View File

@ -0,0 +1,188 @@
package org.thoughtcrime.securesms.conversation.v2.mention
import android.text.Selection
import android.text.SpannableStringBuilder
import androidx.core.text.getSpans
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
private const val SEARCH_QUERY_DEBOUNCE_MILLS = 100L
/**
* A subclass of [SpannableStringBuilder] that provides a way to observe the mention search query,
* and also manages the [MentionSpan] in a way that treats the mention span as a whole.
*/
class MentionEditable : SpannableStringBuilder() {
private val queryChangeNotification = MutableSharedFlow<Unit>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_LATEST
)
fun observeMentionSearchQuery(): Flow<SearchQuery?> {
@Suppress("OPT_IN_USAGE")
return queryChangeNotification
.debounce(SEARCH_QUERY_DEBOUNCE_MILLS)
.onStart { emit(Unit) }
.map { mentionSearchQuery }
.distinctUntilChanged()
}
data class SearchQuery(
val mentionSymbolStartAt: Int,
val query: String
)
val mentionSearchQuery: SearchQuery?
get() {
val cursorPosition = Selection.getSelectionStart(this)
// First, make sure we are not selecting text
if (cursorPosition != Selection.getSelectionEnd(this)) {
return null
}
// Make sure we don't already have a mention span at the cursor position
if (getSpans(cursorPosition, cursorPosition, MentionSpan::class.java).isNotEmpty()) {
return null
}
// Find the mention symbol '@' before the cursor position
val symbolIndex = findEligibleMentionSymbolIndexBefore(cursorPosition - 1)
if (symbolIndex < 0) {
return null
}
// The query starts after the symbol '@' and ends at a whitespace, @ or the end
val queryStart = symbolIndex + 1
var queryEnd = indexOfStartingAt(queryStart) { it.isWhitespace() || it == '@' }
if (queryEnd < 0) {
queryEnd = length
}
return SearchQuery(
mentionSymbolStartAt = symbolIndex,
query = subSequence(queryStart, queryEnd).toString()
)
}
override fun setSpan(what: Any?, start: Int, end: Int, flags: Int) {
var normalisedStart = start
var normalisedEnd = end
val isSelectionStart = what == Selection.SELECTION_START
val isSelectionEnd = what == Selection.SELECTION_END
if (isSelectionStart || isSelectionEnd) {
assert(start == end) { "Selection spans must have zero length" }
val selection = start
val mentionSpan = getSpans<MentionSpan>(selection, selection).firstOrNull()
if (mentionSpan != null) {
val spanStart = getSpanStart(mentionSpan)
val spanEnd = getSpanEnd(mentionSpan)
if (isSelectionStart && selection != spanEnd) {
// A selection start will only be adjusted to the start of the mention span,
// if the selection start is not at the end the mention span. (A selection start
// at the end of the mention span is considered an escape path from the mention span)
normalisedStart = spanStart
normalisedEnd = normalisedStart
} else if (isSelectionEnd && selection != spanStart) {
normalisedEnd = spanEnd
normalisedStart = normalisedEnd
}
}
queryChangeNotification.tryEmit(Unit)
}
super.setSpan(what, normalisedStart, normalisedEnd, flags)
}
override fun removeSpan(what: Any?) {
super.removeSpan(what)
queryChangeNotification.tryEmit(Unit)
}
// The only method we need to override
override fun replace(st: Int, en: Int, source: CharSequence?, start: Int, end: Int): MentionEditable {
// Make sure the mention span is treated like a whole
var normalisedStart = st
var normalisedEnd = en
if (st != en) {
// Find the mention span that intersects with the replaced range, and expand the range to include it,
// this does not apply to insertion operation (st == en)
for (mentionSpan in getSpans(st, en, MentionSpan::class.java)) {
val mentionStart = getSpanStart(mentionSpan)
val mentionEnd = getSpanEnd(mentionSpan)
if (mentionStart < normalisedStart) {
normalisedStart = mentionStart
}
if (mentionEnd > normalisedEnd) {
normalisedEnd = mentionEnd
}
removeSpan(mentionSpan)
}
}
super.replace(normalisedStart, normalisedEnd, source, start, end)
queryChangeNotification.tryEmit(Unit)
return this
}
fun addMention(member: MentionViewModel.Member, replaceRange: IntRange) {
val replaceWith = "@${member.name} "
replace(replaceRange.first, replaceRange.last, replaceWith)
setSpan(
MentionSpan(member),
replaceRange.first,
replaceRange.first + replaceWith.length - 1,
SPAN_EXCLUSIVE_EXCLUSIVE
)
}
override fun delete(st: Int, en: Int) = replace(st, en, "", 0, 0)
private fun findEligibleMentionSymbolIndexBefore(offset: Int): Int {
if (isEmpty()) {
return -1
}
var i = offset.coerceIn(indices)
while (i >= 0) {
val c = get(i)
if (c == '@') {
// Make sure there is no more '@' before this one or it's disqualified
if (i > 0 && get(i - 1) == '@') {
return -1
}
return i
} else if (c.isWhitespace()) {
break
}
i--
}
return -1
}
}
private fun CharSequence.indexOfStartingAt(offset: Int, predicate: (Char) -> Boolean): Int {
var i = offset.coerceIn(0..length)
while (i < length) {
if (predicate(get(i))) {
return i
}
i++
}
return -1
}

View File

@ -0,0 +1,8 @@
package org.thoughtcrime.securesms.conversation.v2.mention
/**
* A span that represents a mention in the text.
*/
class MentionSpan(
val member: MentionViewModel.Member
)

View File

@ -0,0 +1,274 @@
package org.thoughtcrime.securesms.conversation.v2.mention
import android.content.ContentResolver
import android.graphics.Typeface
import android.text.Editable
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.StyleSpan
import androidx.core.text.getSpans
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext
import org.session.libsession.messaging.contacts.Contact
import org.thoughtcrime.securesms.database.DatabaseContentProviders.Conversation
import org.thoughtcrime.securesms.database.GroupDatabase
import org.thoughtcrime.securesms.database.GroupMemberDatabase
import org.thoughtcrime.securesms.database.MmsDatabase
import org.thoughtcrime.securesms.database.SessionContactDatabase
import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.util.observeChanges
/**
* A ViewModel that provides the mention search functionality for a text input.
*
* To use this ViewModel, you (a view) will need to:
* 1. Observe the [autoCompleteState] to get the mention search results.
* 2. Set the EditText's editable factory to [editableFactory], via [android.widget.EditText.setEditableFactory]
*/
class MentionViewModel(
threadID: Long,
contentResolver: ContentResolver,
threadDatabase: ThreadDatabase,
groupDatabase: GroupDatabase,
mmsDatabase: MmsDatabase,
contactDatabase: SessionContactDatabase,
memberDatabase: GroupMemberDatabase,
storage: Storage,
dispatcher: CoroutineDispatcher = Dispatchers.IO,
) : ViewModel() {
private val editable = MentionEditable()
/**
* A factory that creates a new [Editable] instance that is backed by the same source of truth
* used by this viewModel.
*/
val editableFactory = object : Editable.Factory() {
override fun newEditable(source: CharSequence?): Editable {
if (source === editable) {
return source
}
if (source != null) {
editable.replace(0, editable.length, source)
}
return editable
}
}
@Suppress("OPT_IN_USAGE")
private val members: StateFlow<List<Member>?> =
(contentResolver.observeChanges(Conversation.getUriForThread(threadID)) as Flow<Any?>)
.debounce(500L)
.onStart { emit(Unit) }
.mapLatest {
val recipient = checkNotNull(threadDatabase.getRecipientForThreadId(threadID)) {
"Recipient not found for thread ID: $threadID"
}
val memberIDs = when {
recipient.isClosedGroupRecipient -> {
groupDatabase.getGroupMemberAddresses(recipient.address.toGroupString(), false)
.map { it.serialize() }
}
recipient.isCommunityRecipient -> mmsDatabase.getRecentChatMemberIDs(threadID, 20)
recipient.isContactRecipient -> listOf(recipient.address.serialize())
else -> listOf()
}
val moderatorIDs = if (recipient.isCommunityRecipient) {
val groupId = storage.getOpenGroup(threadID)?.id
if (groupId.isNullOrBlank()) {
emptySet()
} else {
memberDatabase.getGroupMembersRoles(groupId, memberIDs)
.mapNotNullTo(hashSetOf()) { (memberId, roles) ->
memberId.takeIf { roles.any { it.isModerator } }
}
}
} else {
emptySet()
}
val contactContext = if (recipient.isCommunityRecipient) {
Contact.ContactContext.OPEN_GROUP
} else {
Contact.ContactContext.REGULAR
}
contactDatabase.getContacts(memberIDs).map { contact ->
Member(
publicKey = contact.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
}
}
}

View File

@ -25,16 +25,15 @@ class ControlMessageView : LinearLayout {
private val TAG = "ControlMessageView" private val TAG = "ControlMessageView"
private lateinit var binding: ViewControlMessageBinding private val binding = ViewControlMessageBinding.inflate(LayoutInflater.from(context), this, true)
constructor(context: Context) : super(context) { initialize() } constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
@Inject lateinit var disappearingMessages: DisappearingMessages @Inject lateinit var disappearingMessages: DisappearingMessages
private fun initialize() { init {
binding = ViewControlMessageBinding.inflate(LayoutInflater.from(context), this, true)
layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT) layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT)
} }

View File

@ -41,7 +41,7 @@ class LinkPreviewView : LinearLayout {
// Thumbnail // Thumbnail
if (linkPreview.getThumbnail().isPresent) { if (linkPreview.getThumbnail().isPresent) {
// This internally fetches the thumbnail // This internally fetches the thumbnail
binding.thumbnailImageView.root.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), isPreview = false, message) binding.thumbnailImageView.root.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), isPreview = false)
binding.thumbnailImageView.root.loadIndicator.isVisible = false binding.thumbnailImageView.root.loadIndicator.isVisible = false
} }
// Title // Title

View File

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

View File

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

View File

@ -11,8 +11,10 @@ import android.os.Looper
import android.util.AttributeSet import android.util.AttributeSet
import android.view.Gravity import android.view.Gravity
import android.view.HapticFeedbackConstants import android.view.HapticFeedbackConstants
import android.view.LayoutInflater
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
@ -26,17 +28,18 @@ import androidx.core.view.isVisible
import androidx.core.view.marginBottom import androidx.core.view.marginBottom
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ViewEmojiReactionsBinding
import network.loki.messenger.databinding.ViewVisibleMessageBinding import network.loki.messenger.databinding.ViewVisibleMessageBinding
import network.loki.messenger.databinding.ViewstubVisibleMessageMarkerContainerBinding
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.contacts.Contact.ContactContext import org.session.libsession.messaging.contacts.Contact.ContactContext
import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.ViewUtil import org.session.libsession.utilities.ViewUtil
import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.getColorFromAttr
import org.session.libsession.utilities.modifyLayoutParams import org.session.libsession.utilities.modifyLayoutParams
import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.database.LastSentTimestampCache import org.thoughtcrime.securesms.database.LastSentTimestampCache
import org.thoughtcrime.securesms.database.LokiAPIDatabase import org.thoughtcrime.securesms.database.LokiAPIDatabase
@ -65,7 +68,7 @@ import kotlin.math.sqrt
private const val TAG = "VisibleMessageView" private const val TAG = "VisibleMessageView"
@AndroidEntryPoint @AndroidEntryPoint
class VisibleMessageView : LinearLayout { class VisibleMessageView : FrameLayout {
private var replyDisabled: Boolean = false private var replyDisabled: Boolean = false
@Inject lateinit var threadDb: ThreadDatabase @Inject lateinit var threadDb: ThreadDatabase
@Inject lateinit var lokiThreadDb: LokiThreadDatabase @Inject lateinit var lokiThreadDb: LokiThreadDatabase
@ -75,7 +78,16 @@ class VisibleMessageView : LinearLayout {
@Inject lateinit var mmsDb: MmsDatabase @Inject lateinit var mmsDb: MmsDatabase
@Inject lateinit var lastSentTimestampCache: LastSentTimestampCache @Inject lateinit var lastSentTimestampCache: LastSentTimestampCache
private val binding by lazy { ViewVisibleMessageBinding.bind(this) } private val binding = ViewVisibleMessageBinding.inflate(LayoutInflater.from(context), this, true)
private val markerContainerBinding = lazy(LazyThreadSafetyMode.NONE) {
ViewstubVisibleMessageMarkerContainerBinding.bind(binding.unreadMarkerContainerStub.inflate())
}
private val emojiReactionsBinding = lazy(LazyThreadSafetyMode.NONE) {
ViewEmojiReactionsBinding.bind(binding.emojiReactionsView.inflate())
}
private val swipeToReplyIcon = ContextCompat.getDrawable(context, R.drawable.ic_baseline_reply_24)!!.mutate() private val swipeToReplyIcon = ContextCompat.getDrawable(context, R.drawable.ic_baseline_reply_24)!!.mutate()
private val swipeToReplyIconRect = Rect() private val swipeToReplyIconRect = Rect()
private var dx = 0.0f private var dx = 0.0f
@ -94,7 +106,7 @@ class VisibleMessageView : LinearLayout {
var onPress: ((event: MotionEvent) -> Unit)? = null var onPress: ((event: MotionEvent) -> Unit)? = null
var onSwipeToReply: (() -> Unit)? = null var onSwipeToReply: (() -> Unit)? = null
var onLongPress: (() -> Unit)? = null var onLongPress: (() -> Unit)? = null
val messageContentView: VisibleMessageContentView by lazy { binding.messageContentView.root } val messageContentView: VisibleMessageContentView get() = binding.messageContentView.root
companion object { companion object {
const val swipeToReplyThreshold = 64.0f // dp const val swipeToReplyThreshold = 64.0f // dp
@ -108,12 +120,7 @@ class VisibleMessageView : LinearLayout {
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
override fun onFinishInflate() { init {
super.onFinishInflate()
initialize()
}
private fun initialize() {
isHapticFeedbackEnabled = true isHapticFeedbackEnabled = true
setWillNotDraw(false) setWillNotDraw(false)
binding.root.disableClipping() binding.root.disableClipping()
@ -121,7 +128,11 @@ class VisibleMessageView : LinearLayout {
binding.messageInnerContainer.disableClipping() binding.messageInnerContainer.disableClipping()
binding.messageInnerLayout.disableClipping() binding.messageInnerLayout.disableClipping()
binding.messageContentView.root.disableClipping() binding.messageContentView.root.disableClipping()
// Default layout params
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
} }
// endregion // endregion
// region Updating // region Updating
@ -135,7 +146,7 @@ class VisibleMessageView : LinearLayout {
senderSessionID: String, senderSessionID: String,
lastSeen: Long, lastSeen: Long,
delegate: VisibleMessageViewDelegate? = null, delegate: VisibleMessageViewDelegate? = null,
onAttachmentNeedsDownload: (Long, Long) -> Unit, onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit,
lastSentMessageId: Long lastSentMessageId: Long
) { ) {
replyDisabled = message.isOpenGroupInvitation replyDisabled = message.isOpenGroupInvitation
@ -203,7 +214,13 @@ class VisibleMessageView : LinearLayout {
binding.senderNameTextView.text = contact?.displayName(contactContext) ?: senderSessionID binding.senderNameTextView.text = contact?.displayName(contactContext) ?: senderSessionID
// Unread marker // Unread marker
binding.unreadMarkerContainer.isVisible = lastSeen != -1L && message.timestamp > lastSeen && (previous == null || previous.timestamp <= lastSeen) && !message.isOutgoing val shouldShowUnreadMarker = lastSeen != -1L && message.timestamp > lastSeen && (previous == null || previous.timestamp <= lastSeen) && !message.isOutgoing
if (shouldShowUnreadMarker) {
markerContainerBinding.value.root.isVisible = true
} else if (markerContainerBinding.isInitialized()) {
// Only need to hide the binding when the binding is inflated. (default is gone)
markerContainerBinding.value.root.isVisible = false
}
// Date break // Date break
val showDateBreak = isStartOfMessageCluster || snIsSelected val showDateBreak = isStartOfMessageCluster || snIsSelected
@ -214,21 +231,22 @@ class VisibleMessageView : LinearLayout {
showStatusMessage(message) showStatusMessage(message)
// Emoji Reactions // Emoji Reactions
val emojiLayoutParams = binding.emojiReactionsView.root.layoutParams as ConstraintLayout.LayoutParams
emojiLayoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f
binding.emojiReactionsView.root.layoutParams = emojiLayoutParams
if (message.reactions.isNotEmpty()) { if (message.reactions.isNotEmpty()) {
val capabilities = lokiThreadDb.getOpenGroupChat(threadID)?.server?.let { lokiApiDb.getServerCapabilities(it) } val capabilities = lokiThreadDb.getOpenGroupChat(threadID)?.server?.let { lokiApiDb.getServerCapabilities(it) }
if (capabilities.isNullOrEmpty() || capabilities.contains(OpenGroupApi.Capability.REACTIONS.name.lowercase())) { if (capabilities.isNullOrEmpty() || capabilities.contains(OpenGroupApi.Capability.REACTIONS.name.lowercase())) {
binding.emojiReactionsView.root.setReactions(message.id, message.reactions, message.isOutgoing, delegate) emojiReactionsBinding.value.root.let { root ->
binding.emojiReactionsView.root.isVisible = true root.setReactions(message.id, message.reactions, message.isOutgoing, delegate)
} else { root.isVisible = true
binding.emojiReactionsView.root.isVisible = false (root.layoutParams as ConstraintLayout.LayoutParams).apply {
horizontalBias = if (message.isOutgoing) 1f else 0f
}
}
} else if (emojiReactionsBinding.isInitialized()) {
emojiReactionsBinding.value.root.isVisible = false
} }
} }
else { else if (emojiReactionsBinding.isInitialized()) {
binding.emojiReactionsView.root.isVisible = false emojiReactionsBinding.value.root.isVisible = false
} }
// Populate content view // Populate content view

View File

@ -68,7 +68,7 @@ class VoiceMessageView : RelativeLayout, AudioSlidePlayer.Listener {
return return
} }
val player = AudioSlidePlayer.createFor(context, audio, this) val player = AudioSlidePlayer.createFor(context.applicationContext, audio, this)
this.player = player this.player = player
(audio.asAttachment() as? DatabaseAttachment)?.let { attachment -> (audio.asAttachment() as? DatabaseAttachment)?.let { attachment ->

View File

@ -1,34 +0,0 @@
package org.thoughtcrime.securesms.conversation.v2.utilities
import android.content.Context
import org.session.libsession.messaging.mentions.MentionsManager
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
object MentionManagerUtilities {
fun populateUserPublicKeyCacheIfNeeded(threadID: Long, context: Context) {
val result = mutableSetOf<String>()
val recipient = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(threadID) ?: return
if (recipient.address.isClosedGroup) {
val members = DatabaseComponent.get(context).groupDatabase().getGroupMembers(recipient.address.toGroupString(), false).map { it.address.serialize() }
result.addAll(members)
} else {
val messageDatabase = DatabaseComponent.get(context).mmsSmsDatabase()
val reader = messageDatabase.readerFor(messageDatabase.getConversation(threadID, true, 0, 200))
var record: MessageRecord? = reader.next
while (record != null) {
result.add(record.individualRecipient.address.serialize())
try {
record = reader.next
} catch (exception: Exception) {
record = null
}
}
reader.close()
result.add(TextSecurePreferences.getLocalNumber(context)!!)
}
MentionsManager.userPublicKeyCache[threadID] = result
}
}

View File

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

View File

@ -2,10 +2,13 @@ package org.thoughtcrime.securesms.conversation.v2.utilities
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Outline
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.net.Uri import android.net.Uri
import android.util.AttributeSet import android.util.AttributeSet
import android.util.TypedValue
import android.view.View import android.view.View
import android.view.ViewOutlineProvider
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.engine.DiskCacheStrategy
@ -21,18 +24,17 @@ import org.session.libsignal.utilities.ListenableFuture
import org.session.libsignal.utilities.SettableFuture import org.session.libsignal.utilities.SettableFuture
import org.thoughtcrime.securesms.components.GlideBitmapListeningTarget import org.thoughtcrime.securesms.components.GlideBitmapListeningTarget
import org.thoughtcrime.securesms.components.GlideDrawableListeningTarget import org.thoughtcrime.securesms.components.GlideDrawableListeningTarget
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri
import org.thoughtcrime.securesms.mms.GlideRequest import org.thoughtcrime.securesms.mms.GlideRequest
import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.mms.Slide import org.thoughtcrime.securesms.mms.Slide
import kotlin.Boolean
import kotlin.Int
import kotlin.getValue
import kotlin.lazy
import kotlin.let
open class ThumbnailView: FrameLayout { open class ThumbnailView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
companion object { companion object {
private const val WIDTH = 0 private const val WIDTH = 0
private const val HEIGHT = 1 private const val HEIGHT = 1
@ -41,30 +43,29 @@ open class ThumbnailView: FrameLayout {
private val binding: ThumbnailViewBinding by lazy { ThumbnailViewBinding.bind(this) } private val binding: ThumbnailViewBinding by lazy { ThumbnailViewBinding.bind(this) }
// region Lifecycle // region Lifecycle
constructor(context: Context) : super(context) { initialize(null) }
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize(attrs) }
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize(attrs) }
val loadIndicator: View by lazy { binding.thumbnailLoadIndicator } val loadIndicator: View by lazy { binding.thumbnailLoadIndicator }
private val dimensDelegate = ThumbnailDimensDelegate() private val dimensDelegate = ThumbnailDimensDelegate()
private var slide: Slide? = null private var slide: Slide? = null
var radius: Int = 0
private fun initialize(attrs: AttributeSet?) { init {
if (attrs != null) { attrs?.let { context.theme.obtainStyledAttributes(it, R.styleable.ThumbnailView, 0, 0) }
val typedArray = context.theme.obtainStyledAttributes(attrs, R.styleable.ThumbnailView, 0, 0) ?.apply {
dimensDelegate.setBounds(
getDimensionPixelSize(R.styleable.ThumbnailView_minWidth, 0),
getDimensionPixelSize(R.styleable.ThumbnailView_minHeight, 0),
getDimensionPixelSize(R.styleable.ThumbnailView_maxWidth, 0),
getDimensionPixelSize(R.styleable.ThumbnailView_maxHeight, 0)
)
dimensDelegate.setBounds(typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_minWidth, 0), setRoundedCorners(
typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_minHeight, 0), getDimensionPixelSize(R.styleable.ThumbnailView_thumbnail_radius, 0)
typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxWidth, 0), )
typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxHeight, 0))
radius = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_thumbnail_radius, 0) recycle()
}
typedArray.recycle()
}
} }
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { 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 getDefaultWidth() = maxOf(layoutParams?.width ?: 0, 0)
private fun getDefaultHeight() = maxOf(layoutParams?.height ?: 0, 0) private fun getDefaultHeight() = maxOf(layoutParams?.height ?: 0, 0)
// endregion // endregion
// region Interaction // region Interaction
fun setImageResource(glide: GlideRequests, slide: Slide, isPreview: Boolean, mms: MmsMessageRecord?): ListenableFuture<Boolean> { fun setRoundedCorners(radius: Int){
return setImageResource(glide, slide, isPreview, 0, 0, mms) // create an outline provider and clip the whole view to that shape
} // that way we can round the image and the background ( and any other artifacts that the view may contain )
val mOutlineProvider = object : ViewOutlineProvider() {
fun setImageResource(glide: GlideRequests, slide: Slide, override fun getOutline(view: View, outline: Outline) {
isPreview: Boolean, naturalWidth: Int, // all corners
naturalHeight: Int, mms: MmsMessageRecord?): ListenableFuture<Boolean> { outline.setRoundRect(0, 0, view.width, view.height, radius.toFloat())
}
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)
} }
outlineProvider = mOutlineProvider
clipToOutline = true
}
if (currentSlide != null && currentSlide.fastPreflightId != null && currentSlide.fastPreflightId == slide.fastPreflightId) { fun setImageResource(
// not reloading slide for fast preflight glide: GlideRequests,
this.slide = slide 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 this.slide = slide
binding.thumbnailLoadIndicator.isVisible = slide.isInProgress binding.thumbnailLoadIndicator.isVisible = slide.isInProgress
binding.thumbnailDownloadIcon.isVisible = slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED binding.thumbnailDownloadIcon.isVisible =
slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED
dimensDelegate.setDimens(naturalWidth, naturalHeight) dimensDelegate.setDimens(naturalWidth, naturalHeight)
invalidate() invalidate()
val result = SettableFuture<Boolean>() return SettableFuture<Boolean>().also {
when {
when { slide.thumbnailUri != null -> {
slide.thumbnailUri != null -> { buildThumbnailGlideRequest(glide, slide).into(
buildThumbnailGlideRequest(glide, slide).into(GlideDrawableListeningTarget(binding.thumbnailImage, binding.thumbnailLoadIndicator, result)) GlideDrawableListeningTarget(binding.thumbnailImage, binding.thumbnailLoadIndicator, it)
} )
slide.hasPlaceholder() -> { }
buildPlaceholderGlideRequest(glide, slide).into(GlideBitmapListeningTarget(binding.thumbnailImage, null, result)) slide.hasPlaceholder() -> {
} buildPlaceholderGlideRequest(glide, slide).into(
else -> { GlideBitmapListeningTarget(binding.thumbnailImage, null, it)
glide.clear(binding.thumbnailImage) )
result.set(false) }
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() private fun buildPlaceholderGlideRequest(
glide: GlideRequests,
val request = glide.load(DecryptableUri(slide.thumbnailUri!!)) slide: Slide
.diskCacheStrategy(DiskCacheStrategy.NONE) ): GlideRequest<Bitmap> = glide.asBitmap()
.let { request -> .load(slide.getPlaceholderRes(context.theme))
if (dimens[WIDTH] == 0 || dimens[HEIGHT] == 0) { .diskCacheStrategy(DiskCacheStrategy.NONE)
request.override(getDefaultWidth(), getDefaultHeight()) .overrideDimensions()
} else { .fitCenter()
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()
}
open fun clear(glideRequests: GlideRequests) { open fun clear(glideRequests: GlideRequests) {
glideRequests.clear(binding.thumbnailImage) glideRequests.clear(binding.thumbnailImage)
slide = null slide = null
} }
fun setImageResource(glideRequests: GlideRequests, uri: Uri): ListenableFuture<Boolean> { fun setImageResource(
val future = SettableFuture<Boolean>() glideRequests: GlideRequests,
uri: Uri
): ListenableFuture<Boolean> = glideRequests.load(DecryptableUri(uri))
.diskCacheStrategy(DiskCacheStrategy.NONE)
.transition(DrawableTransitionOptions.withCrossFade())
.transform(CenterCrop())
.intoDrawableTargetAsFuture()
var request: GlideRequest<Drawable> = glideRequests.load(DecryptableUri(uri)) private fun GlideRequest<Drawable>.intoDrawableTargetAsFuture() =
.diskCacheStrategy(DiskCacheStrategy.NONE) SettableFuture<Boolean>().also {
.transition(DrawableTransitionOptions.withCrossFade()) binding.run {
GlideDrawableListeningTarget(thumbnailImage, thumbnailLoadIndicator, it)
request = if (radius > 0) { }.let { into(it) }
request.transforms(CenterCrop(), RoundedCorners(radius))
} else {
request.transforms(CenterCrop())
} }
request.into(GlideDrawableListeningTarget(binding.thumbnailImage, binding.thumbnailLoadIndicator, future)) private fun <T> GlideRequest<T>.overrideDimensions() =
dimensDelegate.resourceSize().takeIf { 0 !in it }
?.let { override(it[WIDTH], it[HEIGHT]) }
?: override(getDefaultWidth(), getDefaultHeight())
}
return future private fun <T> GlideRequest<T>.missingThumbnailPicture(
} inProgress: Boolean
} ) = takeIf { inProgress } ?: apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture))

View File

@ -3,9 +3,12 @@ package org.thoughtcrime.securesms.database
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import org.json.JSONArray
import org.session.libsession.messaging.open_groups.GroupMember import org.session.libsession.messaging.open_groups.GroupMember
import org.session.libsession.messaging.open_groups.GroupMemberRole import org.session.libsession.messaging.open_groups.GroupMemberRole
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
import org.thoughtcrime.securesms.util.asSequence
import java.util.EnumSet
class GroupMemberDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) { class GroupMemberDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) {
@ -51,6 +54,19 @@ class GroupMemberDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
return mappings.map { it.role } return mappings.map { it.role }
} }
fun getGroupMembersRoles(groupId: String, memberIDs: Collection<String>): Map<String, List<GroupMemberRole>> {
val sql = """
SELECT * FROM $TABLE_NAME
WHERE $GROUP_ID = ? AND $PROFILE_ID IN (SELECT value FROM json_each(?))
""".trimIndent()
return readableDatabase.rawQuery(sql, groupId, JSONArray(memberIDs).toString()).use { cursor ->
cursor.asSequence()
.map { readGroupMember(it) }
.groupBy(keySelector = { it.profileId }, valueTransform = { it.role })
}
}
fun setGroupMembers(members: List<GroupMember>) { fun setGroupMembers(members: List<GroupMember>) {
writableDatabase.beginTransaction() writableDatabase.beginTransaction()
try { try {

View File

@ -218,6 +218,21 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
return cursor return cursor
} }
fun getRecentChatMemberIDs(threadID: Long, limit: Int): List<String> {
val sql = """
SELECT DISTINCT $ADDRESS FROM $TABLE_NAME
WHERE $THREAD_ID = ?
ORDER BY $DATE_SENT DESC
LIMIT $limit
""".trimIndent()
return databaseHelper.readableDatabase.rawQuery(sql, threadID).use { cursor ->
cursor.asSequence()
.map { it.getString(0) }
.toList()
}
}
val expireStartedMessages: Reader val expireStartedMessages: Reader
get() { get() {
val where = "$EXPIRE_STARTED > 0" val where = "$EXPIRE_STARTED > 0"

View File

@ -4,6 +4,7 @@ import android.content.ContentValues
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import androidx.core.database.getStringOrNull import androidx.core.database.getStringOrNull
import org.json.JSONArray
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.utilities.SessionId import org.session.libsession.messaging.utilities.SessionId
import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Base64
@ -41,6 +42,15 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
} }
} }
fun getContacts(sessionIDs: Collection<String>): List<Contact> {
val database = databaseHelper.readableDatabase
return database.getAll(
sessionContactTable,
"$sessionID IN (SELECT value FROM json_each(?))",
arrayOf(JSONArray(sessionIDs).toString())
) { cursor -> contactFromCursor(cursor) }
}
fun getAllContacts(): Set<Contact> { fun getAllContacts(): Set<Contact> {
val database = databaseHelper.readableDatabase val database = databaseHelper.readableDatabase
return database.getAll(sessionContactTable, null, null) { cursor -> return database.getAll(sessionContactTable, null, null) { cursor ->

View File

@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.database
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import org.json.JSONArray
import org.session.libsession.messaging.jobs.AttachmentDownloadJob import org.session.libsession.messaging.jobs.AttachmentDownloadJob
import org.session.libsession.messaging.jobs.AttachmentUploadJob import org.session.libsession.messaging.jobs.AttachmentUploadJob
import org.session.libsession.messaging.jobs.BackgroundGroupAddJob import org.session.libsession.messaging.jobs.BackgroundGroupAddJob
@ -50,14 +51,18 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa
databaseHelper.writableDatabase.delete(sessionJobTable, "${Companion.jobID} = ?", arrayOf( jobID )) databaseHelper.writableDatabase.delete(sessionJobTable, "${Companion.jobID} = ?", arrayOf( jobID ))
} }
fun getAllJobs(type: String): Map<String, Job?> { fun getAllJobs(vararg types: String): Map<String, Job?> {
val database = databaseHelper.readableDatabase val database = databaseHelper.readableDatabase
return database.getAll(sessionJobTable, "$jobType = ?", arrayOf( type )) { cursor -> return database.getAll(
sessionJobTable,
"$jobType IN (SELECT value FROM json_each(?))", // Use json_each to bypass limitation of SQLite's IN operator binding
arrayOf( JSONArray(types).toString() )
) { cursor ->
val jobID = cursor.getString(jobID) val jobID = cursor.getString(jobID)
try { try {
jobID to jobFromCursor(cursor) jobID to jobFromCursor(cursor)
} catch (e: Exception) { } catch (e: Exception) {
Log.e("Loki", "Error deserializing job of type: $type.", e) Log.e("Loki", "Error deserializing job of type: $types.", e)
jobID to null jobID to null
} }
}.toMap() }.toMap()

View File

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

View File

@ -19,11 +19,11 @@ abstract class AppModule {
@Binds @Binds
abstract fun bindConversationRepository(repository: DefaultConversationRepository): ConversationRepository abstract fun bindConversationRepository(repository: DefaultConversationRepository): ConversationRepository
} }
@EntryPoint @EntryPoint
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
interface AppComponent { interface AppComponent {
fun getPrefs(): TextSecurePreferences fun getPrefs(): TextSecurePreferences
} }

View File

@ -7,7 +7,6 @@ import network.loki.messenger.libsession_util.Contacts
import network.loki.messenger.libsession_util.ConversationVolatileConfig import network.loki.messenger.libsession_util.ConversationVolatileConfig
import network.loki.messenger.libsession_util.UserGroupsConfig import network.loki.messenger.libsession_util.UserGroupsConfig
import network.loki.messenger.libsession_util.UserProfile import network.loki.messenger.libsession_util.UserProfile
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ConfigFactoryProtocol
import org.session.libsession.utilities.ConfigFactoryUpdateListener import org.session.libsession.utilities.ConfigFactoryUpdateListener
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
@ -72,7 +71,6 @@ class ConfigFactory(
override val user: UserProfile? override val user: UserProfile?
get() = synchronizedWithLog(userLock) { get() = synchronizedWithLog(userLock) {
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null
if (_userConfig == null) { if (_userConfig == null) {
val (secretKey, publicKey) = maybeGetUserInfo() ?: return null val (secretKey, publicKey) = maybeGetUserInfo() ?: return null
val userDump = configDatabase.retrieveConfigAndHashes( val userDump = configDatabase.retrieveConfigAndHashes(
@ -92,7 +90,6 @@ class ConfigFactory(
override val contacts: Contacts? override val contacts: Contacts?
get() = synchronizedWithLog(contactsLock) { get() = synchronizedWithLog(contactsLock) {
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null
if (_contacts == null) { if (_contacts == null) {
val (secretKey, publicKey) = maybeGetUserInfo() ?: return null val (secretKey, publicKey) = maybeGetUserInfo() ?: return null
val contactsDump = configDatabase.retrieveConfigAndHashes( val contactsDump = configDatabase.retrieveConfigAndHashes(
@ -112,7 +109,6 @@ class ConfigFactory(
override val convoVolatile: ConversationVolatileConfig? override val convoVolatile: ConversationVolatileConfig?
get() = synchronizedWithLog(convoVolatileLock) { get() = synchronizedWithLog(convoVolatileLock) {
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null
if (_convoVolatileConfig == null) { if (_convoVolatileConfig == null) {
val (secretKey, publicKey) = maybeGetUserInfo() ?: return null val (secretKey, publicKey) = maybeGetUserInfo() ?: return null
val convoDump = configDatabase.retrieveConfigAndHashes( val convoDump = configDatabase.retrieveConfigAndHashes(
@ -133,7 +129,6 @@ class ConfigFactory(
override val userGroups: UserGroupsConfig? override val userGroups: UserGroupsConfig?
get() = synchronizedWithLog(userGroupsLock) { get() = synchronizedWithLog(userGroupsLock) {
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null
if (_userGroups == null) { if (_userGroups == null) {
val (secretKey, publicKey) = maybeGetUserInfo() ?: return null val (secretKey, publicKey) = maybeGetUserInfo() ?: return null
val userGroupsDump = configDatabase.retrieveConfigAndHashes( val userGroupsDump = configDatabase.retrieveConfigAndHashes(
@ -207,8 +202,6 @@ class ConfigFactory(
openGroupId: String?, openGroupId: String?,
visibleOnly: Boolean visibleOnly: Boolean
): Boolean { ): Boolean {
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return true
val (_, userPublicKey) = maybeGetUserInfo() ?: return true val (_, userPublicKey) = maybeGetUserInfo() ?: return true
if (openGroupId != null) { if (openGroupId != null) {
@ -241,8 +234,6 @@ class ConfigFactory(
} }
override fun canPerformChange(variant: String, publicKey: String, changeTimestampMs: Long): Boolean { override fun canPerformChange(variant: String, publicKey: String, changeTimestampMs: Long): Boolean {
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return true
val lastUpdateTimestampMs = configDatabase.retrieveConfigLastUpdateTimestamp(variant, publicKey) val lastUpdateTimestampMs = configDatabase.retrieveConfigLastUpdateTimestamp(variant, publicKey)
// Ensure the change occurred after the last config message was handled (minus the buffer period) // Ensure the change occurred after the last config message was handled (minus the buffer period)

View File

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

View File

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

View File

@ -27,7 +27,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityHomeBinding import network.loki.messenger.databinding.ActivityHomeBinding
import network.loki.messenger.libsession_util.ConfigBase
import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode import org.greenrobot.eventbus.ThreadMode
@ -336,8 +335,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
} }
private fun updateLegacyConfigView() { private fun updateLegacyConfigView() {
binding.configOutdatedView.isVisible = ConfigBase.isNewConfigEnabled(textSecurePreferences.hasForcedNewConfig(), SnodeAPI.nowWithOffset) binding.configOutdatedView.isVisible = textSecurePreferences.getHasLegacyConfig()
&& textSecurePreferences.getHasLegacyConfig()
} }
override fun onResume() { override fun onResume() {

View File

@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.home
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.asFlow import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@ -22,7 +21,6 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
@ -60,12 +58,10 @@ class HomeViewModel @Inject constructor(
observeTypingStatus(), observeTypingStatus(),
messageRequests(), messageRequests(),
::Data ::Data
) ).stateIn(viewModelScope, SharingStarted.Eagerly, null)
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
private fun hasHiddenMessageRequests() = TextSecurePreferences.events private fun hasHiddenMessageRequests() = TextSecurePreferences.events
.filter { it == TextSecurePreferences.HAS_HIDDEN_MESSAGE_REQUESTS } .filter { it == TextSecurePreferences.HAS_HIDDEN_MESSAGE_REQUESTS }
.flowOn(Dispatchers.IO)
.map { prefs.hasHiddenMessageRequests() } .map { prefs.hasHiddenMessageRequests() }
.onStart { emit(prefs.hasHiddenMessageRequests()) } .onStart { emit(prefs.hasHiddenMessageRequests()) }
@ -81,7 +77,7 @@ class HomeViewModel @Inject constructor(
hasHiddenMessageRequests(), hasHiddenMessageRequests(),
latestUnapprovedConversationTimestamp(), latestUnapprovedConversationTimestamp(),
::createMessageRequests ::createMessageRequests
) ).flowOn(Dispatchers.IO)
private fun unapprovedConversationCount() = reloadTriggersAndContentChanges() private fun unapprovedConversationCount() = reloadTriggersAndContentChanges()
.map { threadDb.unapprovedConversationCount } .map { threadDb.unapprovedConversationCount }
@ -96,13 +92,13 @@ class HomeViewModel @Inject constructor(
threadDb.readerFor(openCursor).run { generateSequence { next }.toList() } threadDb.readerFor(openCursor).run { generateSequence { next }.toList() }
} }
} }
.flowOn(Dispatchers.IO)
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
private fun reloadTriggersAndContentChanges() = merge( private fun reloadTriggersAndContentChanges() = merge(
manualReloadTrigger, manualReloadTrigger,
contentResolver.observeChanges(DatabaseContentProviders.ConversationList.CONTENT_URI) contentResolver.observeChanges(DatabaseContentProviders.ConversationList.CONTENT_URI)
) )
.flowOn(Dispatchers.IO)
.debounce(CHANGE_NOTIFICATION_DEBOUNCE_MILLS) .debounce(CHANGE_NOTIFICATION_DEBOUNCE_MILLS)
.onStart { emit(Unit) } .onStart { emit(Unit) }
@ -114,7 +110,7 @@ class HomeViewModel @Inject constructor(
val messageRequests: MessageRequests? = null val messageRequests: MessageRequests? = null
) )
fun createMessageRequests( private fun createMessageRequests(
count: Int, count: Int,
hidden: Boolean, hidden: Boolean,
timestamp: Long timestamp: Long

View File

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

View File

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

View File

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

View File

@ -17,8 +17,6 @@ import org.session.libsession.messaging.jobs.ConfigurationSyncJob
import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.Destination
import org.session.libsession.messaging.messages.control.ConfigurationMessage 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.Address
import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
@ -55,61 +53,16 @@ object ConfigurationMessageUtilities {
fun syncConfigurationIfNeeded(context: Context) { fun syncConfigurationIfNeeded(context: Context) {
// add if check here to schedule new config job process and return early // add if check here to schedule new config job process and return early
val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return
val forcedConfig = TextSecurePreferences.hasForcedNewConfig(context) scheduleConfigSync(userPublicKey)
val currentTime = SnodeAPI.nowWithOffset
if (ConfigBase.isNewConfigEnabled(forcedConfig, currentTime)) {
scheduleConfigSync(userPublicKey)
return
}
val lastSyncTime = TextSecurePreferences.getLastConfigurationSyncTime(context)
val now = System.currentTimeMillis()
if (now - lastSyncTime < 7 * 24 * 60 * 60 * 1000) return
val contacts = ContactUtilities.getAllContacts(context).filter { recipient ->
!recipient.name.isNullOrEmpty() && !recipient.isLocalNumber && recipient.address.serialize().isNotEmpty()
}.map { recipient ->
ConfigurationMessage.Contact(
publicKey = recipient.address.serialize(),
name = recipient.name!!,
profilePicture = recipient.profileAvatar,
profileKey = recipient.profileKey,
isApproved = recipient.isApproved,
isBlocked = recipient.isBlocked,
didApproveMe = recipient.hasApprovedMe()
)
}
val configurationMessage = ConfigurationMessage.getCurrent(contacts) ?: return
MessageSender.send(configurationMessage, Address.fromSerialized(userPublicKey))
TextSecurePreferences.setLastConfigurationSyncTime(context, now)
} }
fun forceSyncConfigurationNowIfNeeded(context: Context): Promise<Unit, Exception> { fun forceSyncConfigurationNowIfNeeded(context: Context): Promise<Unit, Exception> {
// add if check here to schedule new config job process and return early // 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 userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return Promise.ofFail(NullPointerException("User Public Key is null"))
val forcedConfig = TextSecurePreferences.hasForcedNewConfig(context) // schedule job if none exist
val currentTime = SnodeAPI.nowWithOffset // don't schedule job if we already have one
if (ConfigBase.isNewConfigEnabled(forcedConfig, currentTime)) { scheduleConfigSync(userPublicKey)
// schedule job if none exist return Promise.ofSuccess(Unit)
// don't schedule job if we already have one
scheduleConfigSync(userPublicKey)
return Promise.ofSuccess(Unit)
}
val contacts = ContactUtilities.getAllContacts(context).filter { recipient ->
!recipient.isGroupRecipient && !recipient.name.isNullOrEmpty() && !recipient.isLocalNumber && recipient.address.serialize().isNotEmpty()
}.map { recipient ->
ConfigurationMessage.Contact(
publicKey = recipient.address.serialize(),
name = recipient.name!!,
profilePicture = recipient.profileAvatar,
profileKey = recipient.profileKey,
isApproved = recipient.isApproved,
isBlocked = recipient.isBlocked,
didApproveMe = recipient.hasApprovedMe()
)
}
val configurationMessage = ConfigurationMessage.getCurrent(contacts) ?: return Promise.ofSuccess(Unit)
val promise = MessageSender.send(configurationMessage, Destination.from(Address.fromSerialized(userPublicKey)), isSyncMessage = true)
TextSecurePreferences.setLastConfigurationSyncTime(context, System.currentTimeMillis())
return promise
} }
private fun maybeUserSecretKey() = MessagingModuleConfiguration.shared.getUserED25519KeyPair()?.secretKey?.asBytes private fun maybeUserSecretKey() = MessagingModuleConfiguration.shared.getUserED25519KeyPair()?.secretKey?.asBytes

View File

@ -0,0 +1,44 @@
package org.thoughtcrime.securesms.util
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.flatMapConcat
/**
* Buffers items from the flow and emits them in batches. The batch will have size [maxItems] and
* time [timeoutMillis] limit.
*/
fun <T> Flow<T>.timedBuffer(timeoutMillis: Long, maxItems: Int): Flow<List<T>> {
return channelFlow {
val buffer = mutableListOf<T>()
var bufferBeganAt = -1L
collectLatest { value ->
if (buffer.isEmpty()) {
bufferBeganAt = System.currentTimeMillis()
}
buffer.add(value)
if (buffer.size < maxItems) {
// If the buffer is not full, wait until the time limit is reached.
// The delay here, as a suspension point, will be cancelled by `collectLatest`,
// if another item is collected while we are waiting for the `delay` to complete.
// Once the delay is cancelled, another round of `collectLatest` will be restarted.
delay((System.currentTimeMillis() + timeoutMillis - bufferBeganAt).coerceAtLeast(0L))
}
// When we reach here, it's either the buffer is full, or the timeout has been reached:
// send out the buffer and reset the state
send(buffer.toList())
buffer.clear()
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
fun <T> Flow<Iterable<T>>.flatten(): Flow<T> = flatMapConcat { it.asFlow() }

View File

@ -0,0 +1,71 @@
package org.thoughtcrime.securesms.util
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import android.text.style.ReplacementSpan
/**
* A Span that draws text with a rounded background.
*
* @param textColor - The color of the text.
* @param backgroundColor - The color of the background.
* @param cornerRadius - The corner radius of the background in pixels. Defaults to 8dp.
* @param paddingHorizontal - The horizontal padding of the text in pixels. Defaults to 3dp.
* @param paddingVertical - The vertical padding of the text in pixels. Defaults to 3dp.
*/
class RoundedBackgroundSpan(
context: Context,
private val textColor: Int,
private val backgroundColor: Int,
private val cornerRadius: Float = toPx(8, context.resources).toFloat(), // setting some Session defaults
private val paddingHorizontal: Float = toPx(3, context.resources).toFloat(),
private val paddingVertical: Float = toPx(3, context.resources).toFloat()
) : ReplacementSpan() {
override fun draw(
canvas: Canvas, text: CharSequence, start: Int, end: Int,
x: Float, top: Int, y: Int, bottom: Int, paint: Paint
) {
// the top needs to take into account the font and the required vertical padding
val newTop = y + paint.fontMetrics.ascent - paddingVertical
val newBottom = y + paint.fontMetrics.descent + paddingVertical
val rect = RectF(
x,
newTop,
x + measureText(paint, text, start, end) + 2 * paddingHorizontal,
newBottom
)
paint.color = backgroundColor
canvas.drawRoundRect(rect, cornerRadius, cornerRadius, paint)
paint.color = textColor
canvas.drawText(text, start, end, x + paddingHorizontal, y.toFloat(), paint)
}
override fun getSize(
paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?
): Int {
// If the span covers the whole text, and the height is not set, draw() will not be called for the span.
// To help with that we need to take the font metric into account
val metrics = paint.fontMetricsInt
if (fm != null) {
fm.top = metrics.top
fm.ascent = metrics.ascent
fm.descent = metrics.descent
fm.bottom = metrics.bottom
}
return (paint.measureText(text, start, end) + 2 * paddingHorizontal).toInt()
}
private fun measureText(
paint: Paint, text: CharSequence, start: Int, end: Int
): Float {
return paint.measureText(text, start, end)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView <LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
@ -8,45 +8,11 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical"> android:orientation="vertical">
<androidx.constraintlayout.widget.ConstraintLayout <ViewStub
android:id="@+id/unreadMarkerContainer"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/small_spacing" android:id="@+id/unreadMarkerContainerStub"
android:visibility="gone" android:layout="@layout/viewstub_visible_message_marker_container" />
tools:visibility="visible">
<View
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_marginStart="@dimen/medium_spacing"
android:layout_marginEnd="@dimen/small_spacing"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/unreadMarker"
android:background="?android:colorAccent" />
<TextView
android:id="@+id/unreadMarker"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:text="@string/unread_marker"
android:gravity="center"
android:textColor="?android:colorAccent"
android:textSize="@dimen/small_font_size"
android:textStyle="bold" />
<View
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_marginStart="@dimen/small_spacing"
android:layout_marginEnd="@dimen/medium_spacing"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/unreadMarker"
app:layout_constraintEnd_toEndOf="parent"
android:background="?android:colorAccent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView <TextView
android:id="@+id/dateBreakTextView" android:id="@+id/dateBreakTextView"
@ -129,8 +95,10 @@
</LinearLayout> </LinearLayout>
</FrameLayout> </FrameLayout>
<include layout="@layout/view_emoji_reactions" <ViewStub
android:layout="@layout/view_emoji_reactions"
android:id="@+id/emojiReactionsView" android:id="@+id/emojiReactionsView"
android:inflatedId="@+id/emojiReactionsView"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:visibility="gone" android:visibility="gone"
@ -176,4 +144,4 @@
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
</org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView> </LinearLayout>

View File

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/unreadMarkerContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/small_spacing"
android:gravity="center"
android:orientation="horizontal"
android:visibility="gone"
tools:visibility="visible">
<View
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_marginStart="@dimen/medium_spacing"
android:layout_marginEnd="@dimen/small_spacing"
android:layout_weight="1"
android:background="?android:colorAccent" />
<TextView
android:id="@+id/unreadMarker"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:text="@string/unread_marker"
android:textColor="?android:colorAccent"
android:textSize="@dimen/small_font_size"
android:textStyle="bold" />
<View
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_marginStart="@dimen/small_spacing"
android:layout_marginEnd="@dimen/medium_spacing"
android:layout_weight="1"
android:background="?android:colorAccent" />
</LinearLayout>

View File

@ -2,6 +2,7 @@
<network-security-config> <network-security-config>
<domain-config cleartextTrafficPermitted="true"> <domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">127.0.0.1</domain> <domain includeSubdomains="true">127.0.0.1</domain>
<domain includeSubdomains="true">public.loki.foundation</domain>
</domain-config> </domain-config>
<domain-config cleartextTrafficPermitted="false"> <domain-config cleartextTrafficPermitted="false">
<domain includeSubdomains="false">seed1.getsession.org</domain> <domain includeSubdomains="false">seed1.getsession.org</domain>

View File

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

View File

@ -0,0 +1,115 @@
package org.thoughtcrime.securesms.conversation.v2
import android.text.Editable
import android.text.Selection
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.thoughtcrime.securesms.conversation.v2.mention.MentionEditable
import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel
@RunWith(RobolectricTestRunner::class)
class MentionEditableTest {
private lateinit var mentionEditable: MentionEditable
@Before
fun setUp() {
mentionEditable = MentionEditable()
}
@Test
fun `should not have query when there is no 'at' symbol`() = runTest {
mentionEditable.observeMentionSearchQuery().test {
assertThat(awaitItem()).isNull()
mentionEditable.simulateTyping("Some text")
expectNoEvents()
}
}
@Test
fun `should have empty query after typing 'at' symbol`() = runTest {
mentionEditable.observeMentionSearchQuery().test {
assertThat(awaitItem()).isNull()
mentionEditable.simulateTyping("Some text")
expectNoEvents()
mentionEditable.simulateTyping("@")
assertThat(awaitItem())
.isEqualTo(MentionEditable.SearchQuery(9, ""))
}
}
@Test
fun `should have some query after typing words following 'at' symbol`() = runTest {
mentionEditable.observeMentionSearchQuery().test {
assertThat(awaitItem()).isNull()
mentionEditable.simulateTyping("Some text")
expectNoEvents()
mentionEditable.simulateTyping("@words")
assertThat(awaitItem())
.isEqualTo(MentionEditable.SearchQuery(9, "words"))
}
}
@Test
fun `should cancel query after a whitespace or another 'at' is typed`() = runTest {
mentionEditable.observeMentionSearchQuery().test {
assertThat(awaitItem()).isNull()
mentionEditable.simulateTyping("@words")
assertThat(awaitItem())
.isEqualTo(MentionEditable.SearchQuery(0, "words"))
mentionEditable.simulateTyping(" ")
assertThat(awaitItem())
.isNull()
mentionEditable.simulateTyping("@query@")
assertThat(awaitItem())
.isEqualTo(MentionEditable.SearchQuery(13, ""))
}
}
@Test
fun `should move pass the whole span while moving cursor around mentioned block `() {
mentionEditable.append("Mention @user here")
mentionEditable.addMention(MentionViewModel.Member("user", "User", false), 8..14)
// Put cursor right before @user, it should then select nothing
Selection.setSelection(mentionEditable, 8)
assertThat(mentionEditable.selection()).isEqualTo(intArrayOf(8, 8))
// Put cursor right after '@', it should then select the whole @user
Selection.setSelection(mentionEditable, 9)
assertThat(mentionEditable.selection()).isEqualTo(intArrayOf(8, 13))
// Put cursor right after @user, it should then select nothing
Selection.setSelection(mentionEditable, 13)
assertThat(mentionEditable.selection()).isEqualTo(intArrayOf(13, 13))
}
@Test
fun `should delete the whole mention block while deleting only part of it`() {
mentionEditable.append("Mention @user here")
mentionEditable.addMention(MentionViewModel.Member("user", "User", false), 8..14)
mentionEditable.delete(8, 9)
assertThat(mentionEditable.toString()).isEqualTo("Mention here")
}
}
private fun CharSequence.selection(): IntArray {
return intArrayOf(Selection.getSelectionStart(this), Selection.getSelectionEnd(this))
}
private fun Editable.simulateTyping(text: String) {
this.append(text)
Selection.setSelection(this, this.length)
}

View File

@ -0,0 +1,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 ")
}
}
}

View File

@ -0,0 +1,52 @@
package org.thoughtcrime.securesms.util
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.toCollection
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
class FlowUtilsTest {
@Test
fun `timedBuffer should emit buffer when it's full`() = runTest {
// Given
val flow = flowOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
val timeoutMillis = 1000L
val maxItems = 5
// When
val result = flow.timedBuffer(timeoutMillis, maxItems).toList()
// Then
assertEquals(2, result.size)
assertEquals(listOf(1, 2, 3, 4, 5), result[0])
assertEquals(listOf(6, 7, 8, 9, 10), result[1])
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `timedBuffer should emit buffer when timeout expires`() = runTest {
// Given
val flow = flow {
emit(1)
emit(2)
emit(3)
testScheduler.advanceTimeBy(200L)
emit(4)
}
val timeoutMillis = 100L
val maxItems = 5
// When
val result = flow.timedBuffer(timeoutMillis, maxItems).toList()
// Then
assertEquals(2, result.size)
assertEquals(listOf(1, 2, 3), result[0])
assertEquals(listOf(4), result[1])
}
}

View File

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

View File

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

View File

@ -27,12 +27,6 @@ sealed class ConfigBase(protected val /* yucky */ pointer: Long) {
is UserGroupsConfig -> Kind.GROUPS 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_HIDDEN = -1
const val PRIORITY_VISIBLE = 0 const val PRIORITY_VISIBLE = 0
const val PRIORITY_PINNED = 1 const val PRIORITY_PINNED = 1

View File

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

View File

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

View File

@ -37,6 +37,7 @@ class BackgroundGroupAddJob(val joinUrl: String): Job {
delegate?.handleJobFailed(this, dispatcherName, DuplicateGroupException()) delegate?.handleJobFailed(this, dispatcherName, DuplicateGroupException())
return return
} }
storage.addOpenGroup(openGroup.joinUrl()) storage.addOpenGroup(openGroup.joinUrl())
storage.onOpenGroupAdded(openGroup.server, openGroup.room) storage.onOpenGroupAdded(openGroup.server, openGroup.room)
} catch (e: Exception) { } catch (e: Exception) {

View File

@ -1,6 +1,5 @@
package org.session.libsession.messaging.jobs package org.session.libsession.messaging.jobs
import network.loki.messenger.libsession_util.ConfigBase
import network.loki.messenger.libsession_util.ConfigBase.Companion.protoKindFor import network.loki.messenger.libsession_util.ConfigBase.Companion.protoKindFor
import nl.komponents.kovenant.functional.bind import nl.komponents.kovenant.functional.bind
import org.session.libsession.messaging.MessagingModuleConfiguration 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.messaging.utilities.Data
import org.session.libsession.snode.RawResponse import org.session.libsession.snode.RawResponse
import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
@ -26,14 +24,10 @@ data class ConfigurationSyncJob(val destination: Destination): Job {
override suspend fun execute(dispatcherName: String) { override suspend fun execute(dispatcherName: String) {
val storage = MessagingModuleConfiguration.shared.storage val storage = MessagingModuleConfiguration.shared.storage
val forcedConfig = TextSecurePreferences.hasForcedNewConfig(MessagingModuleConfiguration.shared.context)
val currentTime = SnodeAPI.nowWithOffset
val userEdKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() val userEdKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair()
val userPublicKey = storage.getUserPublicKey() val userPublicKey = storage.getUserPublicKey()
val delegate = delegate val delegate = delegate
if (destination is Destination.ClosedGroup // TODO: closed group configs will be handled in closed group feature if (destination is Destination.ClosedGroup
// if we haven't enabled the new configs don't run
|| !ConfigBase.isNewConfigEnabled(forcedConfig, currentTime)
// if we don't have a user ed key pair for signing updates // if we don't have a user ed key pair for signing updates
|| userEdKeyPair == null || userEdKeyPair == null
// this will be useful to not handle null delegate cases // 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 SharedConfigurationMessage(config.protoKindFor(), data, seqNo) to config
}.map { (message, config) -> }.map { (message, config) ->
// return a list of batch request objects // return a list of batch request objects
val snodeMessage = MessageSender.buildWrappedMessageToSnode(destination, message, true) val snodeMessage = MessageSender.buildConfigMessageToSnode(destination.destinationPublicKey(), message)
val authenticated = SnodeAPI.buildAuthenticatedStoreBatchInfo( val authenticated = SnodeAPI.buildAuthenticatedStoreBatchInfo(
destination.destinationPublicKey(), destination.destinationPublicKey(),
config.configNamespace(), config.configNamespace(),

View File

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

View File

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

View File

@ -273,7 +273,6 @@ object OpenGroupApi {
val queryParameters: Map<String, String> = mapOf(), val queryParameters: Map<String, String> = mapOf(),
val parameters: Any? = null, val parameters: Any? = null,
val headers: Map<String, String> = mapOf(), val headers: Map<String, String> = mapOf(),
val isAuthRequired: Boolean = true,
val body: ByteArray? = null, val body: ByteArray? = null,
/** /**
* Always `true` under normal circumstances. You might want to disable * Always `true` under normal circumstances. You might want to disable
@ -319,73 +318,72 @@ object OpenGroupApi {
?: return Promise.ofFail(Error.NoEd25519KeyPair) ?: return Promise.ofFail(Error.NoEd25519KeyPair)
val urlRequest = urlBuilder.toString() val urlRequest = urlBuilder.toString()
val headers = request.headers.toMutableMap() val headers = request.headers.toMutableMap()
if (request.isAuthRequired) {
val nonce = sodium.nonce(16)
val timestamp = TimeUnit.MILLISECONDS.toSeconds(SnodeAPI.nowWithOffset)
var pubKey = ""
var signature = ByteArray(Sign.BYTES)
var bodyHash = ByteArray(0)
if (request.parameters != null) {
val parameterBytes = JsonUtil.toJson(request.parameters).toByteArray()
val parameterHash = ByteArray(GenericHash.BYTES_MAX)
if (sodium.cryptoGenericHash(
parameterHash,
parameterHash.size,
parameterBytes,
parameterBytes.size.toLong()
)
) {
bodyHash = parameterHash
}
} else if (request.body != null) {
val byteHash = ByteArray(GenericHash.BYTES_MAX)
if (sodium.cryptoGenericHash(
byteHash,
byteHash.size,
request.body,
request.body.size.toLong()
)
) {
bodyHash = byteHash
}
}
val messageBytes = Hex.fromStringCondensed(publicKey)
.plus(nonce)
.plus("$timestamp".toByteArray(Charsets.US_ASCII))
.plus(request.verb.rawValue.toByteArray())
.plus("/${request.endpoint.value}".toByteArray())
.plus(bodyHash)
if (serverCapabilities.isEmpty() || serverCapabilities.contains(Capability.BLIND.name.lowercase())) {
SodiumUtilities.blindedKeyPair(publicKey, ed25519KeyPair)?.let { keyPair ->
pubKey = SessionId(
IdPrefix.BLINDED,
keyPair.publicKey.asBytes
).hexString
signature = SodiumUtilities.sogsSignature( val nonce = sodium.nonce(16)
messageBytes, val timestamp = TimeUnit.MILLISECONDS.toSeconds(SnodeAPI.nowWithOffset)
ed25519KeyPair.secretKey.asBytes, var pubKey = ""
keyPair.secretKey.asBytes, var signature = ByteArray(Sign.BYTES)
keyPair.publicKey.asBytes var bodyHash = ByteArray(0)
) ?: return Promise.ofFail(Error.SigningFailed) if (request.parameters != null) {
} ?: return Promise.ofFail(Error.SigningFailed) val parameterBytes = JsonUtil.toJson(request.parameters).toByteArray()
} else { val parameterHash = ByteArray(GenericHash.BYTES_MAX)
pubKey = SessionId( if (sodium.cryptoGenericHash(
IdPrefix.UN_BLINDED, parameterHash,
ed25519KeyPair.publicKey.asBytes parameterHash.size,
).hexString parameterBytes,
sodium.cryptoSignDetached( parameterBytes.size.toLong()
signature,
messageBytes,
messageBytes.size.toLong(),
ed25519KeyPair.secretKey.asBytes
) )
) {
bodyHash = parameterHash
}
} else if (request.body != null) {
val byteHash = ByteArray(GenericHash.BYTES_MAX)
if (sodium.cryptoGenericHash(
byteHash,
byteHash.size,
request.body,
request.body.size.toLong()
)
) {
bodyHash = byteHash
} }
headers["X-SOGS-Nonce"] = encodeBytes(nonce)
headers["X-SOGS-Timestamp"] = "$timestamp"
headers["X-SOGS-Pubkey"] = pubKey
headers["X-SOGS-Signature"] = encodeBytes(signature)
} }
val messageBytes = Hex.fromStringCondensed(publicKey)
.plus(nonce)
.plus("$timestamp".toByteArray(Charsets.US_ASCII))
.plus(request.verb.rawValue.toByteArray())
.plus("/${request.endpoint.value}".toByteArray())
.plus(bodyHash)
if (serverCapabilities.isEmpty() || serverCapabilities.contains(Capability.BLIND.name.lowercase())) {
SodiumUtilities.blindedKeyPair(publicKey, ed25519KeyPair)?.let { keyPair ->
pubKey = SessionId(
IdPrefix.BLINDED,
keyPair.publicKey.asBytes
).hexString
signature = SodiumUtilities.sogsSignature(
messageBytes,
ed25519KeyPair.secretKey.asBytes,
keyPair.secretKey.asBytes,
keyPair.publicKey.asBytes
) ?: return Promise.ofFail(Error.SigningFailed)
} ?: return Promise.ofFail(Error.SigningFailed)
} else {
pubKey = SessionId(
IdPrefix.UN_BLINDED,
ed25519KeyPair.publicKey.asBytes
).hexString
sodium.cryptoSignDetached(
signature,
messageBytes,
messageBytes.size.toLong(),
ed25519KeyPair.secretKey.asBytes
)
}
headers["X-SOGS-Nonce"] = encodeBytes(nonce)
headers["X-SOGS-Timestamp"] = "$timestamp"
headers["X-SOGS-Pubkey"] = pubKey
headers["X-SOGS-Signature"] = encodeBytes(signature)
val requestBuilder = okhttp3.Request.Builder() val requestBuilder = okhttp3.Request.Builder()
.url(urlRequest) .url(urlRequest)
@ -927,7 +925,7 @@ object OpenGroupApi {
} }
fun getCapabilities(server: String): Promise<Capabilities, Exception> { fun getCapabilities(server: String): Promise<Capabilities, Exception> {
val request = Request(verb = GET, room = null, server = server, endpoint = Endpoint.Capabilities, isAuthRequired = false) val request = Request(verb = GET, room = null, server = server, endpoint = Endpoint.Capabilities)
return getResponseBody(request).map { response -> return getResponseBody(request).map { response ->
JsonUtil.fromJson(response, Capabilities::class.java) JsonUtil.fromJson(response, Capabilities::class.java)
} }

View File

@ -81,6 +81,15 @@ object MessageSender {
} }
} }
fun buildConfigMessageToSnode(destinationPubKey: String, message: SharedConfigurationMessage): SnodeMessage {
return SnodeMessage(
destinationPubKey,
Base64.encodeBytes(message.data),
ttl = message.ttl,
SnodeAPI.nowWithOffset
)
}
// One-on-One Chats & Closed Groups // One-on-One Chats & Closed Groups
@Throws(Exception::class) @Throws(Exception::class)
fun buildWrappedMessageToSnode(destination: Destination, message: Message, isSyncMessage: Boolean): SnodeMessage { fun buildWrappedMessageToSnode(destination: Destination, message: Message, isSyncMessage: Boolean): SnodeMessage {

View File

@ -203,12 +203,10 @@ private fun handleConfigurationMessage(message: ConfigurationMessage) {
TextSecurePreferences.setConfigurationMessageSynced(context, true) TextSecurePreferences.setConfigurationMessageSynced(context, true)
TextSecurePreferences.setLastProfileUpdateTime(context, message.sentTimestamp!!) TextSecurePreferences.setLastProfileUpdateTime(context, message.sentTimestamp!!)
val isForceSync = TextSecurePreferences.hasForcedNewConfig(context)
val currentTime = SnodeAPI.nowWithOffset TextSecurePreferences.setHasLegacyConfig(context, true)
if (ConfigBase.isNewConfigEnabled(isForceSync, currentTime)) { if (!firstTimeSync) return
TextSecurePreferences.setHasLegacyConfig(context, true)
if (!firstTimeSync) return
}
val allClosedGroupPublicKeys = storage.getAllClosedGroupPublicKeys() val allClosedGroupPublicKeys = storage.getAllClosedGroupPublicKeys()
for (closedGroup in message.closedGroups) { for (closedGroup in message.closedGroups) {
if (allClosedGroupPublicKeys.contains(closedGroup.publicKey)) { if (allClosedGroupPublicKeys.contains(closedGroup.publicKey)) {
@ -260,7 +258,7 @@ fun MessageReceiver.handleUnsendRequest(message: UnsendRequest): Long? {
SnodeAPI.deleteMessage(author, listOf(serverHash)) SnodeAPI.deleteMessage(author, listOf(serverHash))
} }
val deletedMessageId = messageDataProvider.updateMessageAsDeleted(timestamp, author) val deletedMessageId = messageDataProvider.updateMessageAsDeleted(timestamp, author)
if (!messageDataProvider.isOutgoingMessage(messageIdToDelete)) { if (!messageDataProvider.isOutgoingMessage(timestamp)) {
SSKEnvironment.shared.notificationManager.updateNotification(context) SSKEnvironment.shared.notificationManager.updateNotification(context)
} }

View File

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

View File

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

View File

@ -1,4 +1,4 @@
package org.session.libsession.utilities package org.session.libsession.utilities
fun truncateIdForDisplay(id: String): String = fun truncateIdForDisplay(id: String): String =
id.takeIf { it.length > 8 }?.apply{ "${take(4)}${takeLast(4)}" } ?: id id.takeIf { it.length > 8 }?.run{ "${take(4)}${takeLast(4)}" } ?: id

View File

@ -1,11 +1,13 @@
package org.session.libsignal.utilities package org.session.libsignal.utilities
import android.os.Process import android.os.Process
import kotlinx.coroutines.Dispatchers
import java.util.concurrent.ExecutorService import java.util.concurrent.ExecutorService
import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.SynchronousQueue import java.util.concurrent.SynchronousQueue
import java.util.concurrent.ThreadPoolExecutor import java.util.concurrent.ThreadPoolExecutor
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.coroutines.EmptyCoroutineContext
object ThreadUtils { object ThreadUtils {
@ -13,39 +15,16 @@ object ThreadUtils {
const val PRIORITY_IMPORTANT_BACKGROUND_THREAD = Process.THREAD_PRIORITY_DEFAULT + Process.THREAD_PRIORITY_LESS_FAVORABLE 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: // Note: To see how many threads are running in our app at any given time we can use:
// val threadCount = getAllStackTraces().size // val threadCount = getAllStackTraces().size
@JvmStatic @JvmStatic
fun queue(target: Runnable) { fun queue(target: Runnable) {
executorPool.execute { queue(target::run)
try {
target.run()
} catch (e: Exception) {
Log.e(TAG, e)
}
}
} }
fun queue(target: () -> Unit) { fun queue(target: () -> Unit) {
executorPool.execute { Dispatchers.IO.dispatch(EmptyCoroutineContext) {
try { try {
target() target()
} catch (e: Exception) { } catch (e: Exception) {