diff --git a/app/build.gradle b/app/build.gradle index eb31dce293..df003aa855 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -274,8 +274,6 @@ dependencies { implementation 'pl.tajchert:waitingdots:0.1.0' implementation 'com.vanniktech:android-image-cropper:4.5.0' implementation 'com.melnykov:floatingactionbutton:1.3.0' - implementation 'com.google.zxing:android-integration:3.1.0' - implementation 'com.google.zxing:core:3.2.1' implementation ('com.davemorrissey.labs:subsampling-scale-image-view:3.6.0') { exclude group: 'com.android.support', module: 'support-annotations' } @@ -352,7 +350,6 @@ dependencies { testImplementation 'org.conscrypt:conscrypt-openjdk-uber:2.5.2' // For Robolectric testImplementation 'app.cash.turbine:turbine:1.1.0' - implementation 'com.github.bumptech.glide:compose:1.0.0-alpha.5' implementation "androidx.compose.ui:ui:$composeVersion" implementation "androidx.compose.animation:animation:$composeVersion" @@ -371,7 +368,8 @@ dependencies { implementation "androidx.camera:camera-lifecycle:1.3.2" implementation "androidx.camera:camera-view:1.3.2" - implementation "com.google.mlkit:barcode-scanning:17.2.0" + // Note: ZXing 3.5.3 is the latest stable release as of 2024/08/21 + implementation "com.google.zxing:core:$zxingVersion" } static def getLastCommitTimestamp() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarSelection.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarSelection.kt index adf4b0927c..ddf7ac5c77 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarSelection.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarSelection.kt @@ -18,7 +18,6 @@ import org.session.libsignal.utilities.ExternalStorageUtil.getImageDir import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.NoExternalStorageException import org.thoughtcrime.securesms.util.FileProviderUtil -import org.thoughtcrime.securesms.util.IntentUtils import java.io.File import java.io.IOException import java.util.LinkedList @@ -104,13 +103,8 @@ class AvatarSelection( includeClear: Boolean ): Intent { val extraIntents: MutableList = LinkedList() - var galleryIntent = Intent(Intent.ACTION_PICK) - galleryIntent.setDataAndType(MediaStore.Images.Media.INTERNAL_CONTENT_URI, "image/*") - - if (!IntentUtils.isResolvable(context, galleryIntent)) { - galleryIntent = Intent(Intent.ACTION_GET_CONTENT) - galleryIntent.setType("image/*") - } + val galleryIntent = Intent(Intent.ACTION_OPEN_DOCUMENT) + galleryIntent.setType("image/*") if (tempCaptureFile != null) { val uri = FileProviderUtil.getUriFor(context, tempCaptureFile) diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt index 04acb8908b..2bded3cccb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt @@ -27,8 +27,8 @@ import network.loki.messenger.R import network.loki.messenger.databinding.ActivityWebrtcBinding import org.apache.commons.lang3.time.DurationFormatUtils import org.session.libsession.messaging.contacts.Contact -import org.session.libsession.utilities.Address import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.truncateIdForDisplay import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.dependencies.DatabaseComponent @@ -194,8 +194,13 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() { clipFloatingInsets() // set up the user avatar - TextSecurePreferences.getLocalNumber(this)?.let { - binding.userAvatar.load(Address.fromSerialized(it)) + TextSecurePreferences.getLocalNumber(this)?.let{ + val username = TextSecurePreferences.getProfileName(this) ?: truncateIdForDisplay(it) + binding.userAvatar.apply { + publicKey = it + displayName = username + update() + } } } @@ -327,6 +332,8 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() { launch { viewModel.recipient.collect { latestRecipient -> + binding.contactAvatar.recycle() + if (latestRecipient.recipient != null) { val contactPublicKey = latestRecipient.recipient.address.serialize() val contactDisplayName = getUserDisplayName(contactPublicKey) @@ -334,7 +341,11 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() { binding.remoteRecipientName.text = contactDisplayName // sort out the contact's avatar - binding.contactAvatar.load(latestRecipient.recipient) + binding.contactAvatar.apply { + publicKey = contactPublicKey + displayName = contactDisplayName + update() + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt index f2ea090179..9511bddb6a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt @@ -3,224 +3,164 @@ package org.thoughtcrime.securesms.components import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater -import android.widget.FrameLayout -import androidx.core.view.isVisible -import com.bumptech.glide.Glide +import android.view.View +import android.widget.ImageView +import android.widget.RelativeLayout import com.bumptech.glide.load.engine.DiskCacheStrategy -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.databinding.ViewProfilePictureBinding import org.session.libsession.avatars.ContactColors -import org.session.libsession.avatars.ContactPhoto import org.session.libsession.avatars.PlaceholderAvatarPhoto +import org.session.libsession.avatars.ProfileContactPhoto import org.session.libsession.avatars.ResourceContactPhoto +import org.session.libsession.messaging.contacts.Contact import org.session.libsession.utilities.Address -import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.AppTextSecurePreferences +import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.database.GroupDatabase -import javax.inject.Inject +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import com.bumptech.glide.Glide +import com.bumptech.glide.RequestManager -@AndroidEntryPoint -class ProfilePictureView : FrameLayout { - constructor(context: Context) : super(context) - constructor(context: Context, attrs: AttributeSet) : super(context, attrs) - constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super( - context, - attrs, - defStyleAttr - ) - - @Inject - lateinit var groupDatabase: GroupDatabase +class ProfilePictureView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null +) : RelativeLayout(context, attrs) { + private val TAG = "ProfilePictureView" private val binding = ViewProfilePictureBinding.inflate(LayoutInflater.from(context), this) - private var lastLoadJob: Job? = null - private var lastLoadAddress: Address? = null + private val glide: RequestManager = Glide.with(this) + private val prefs = AppTextSecurePreferences(context) + private val userPublicKey = prefs.getLocalNumber() + var publicKey: String? = null + var displayName: String? = null + var additionalPublicKey: String? = null + var additionalDisplayName: String? = null - private val unknownRecipientDrawable by lazy(LazyThreadSafetyMode.NONE) { - ResourceContactPhoto(R.drawable.ic_profile_default) - .asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) + private val profilePicturesCache = mutableMapOf() + private val resourcePadding by lazy { + context.resources.getDimensionPixelSize(R.dimen.normal_padding).toFloat() + } + private val unknownRecipientDrawable by lazy { ResourceContactPhoto(R.drawable.ic_profile_default) + .asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false, resourcePadding) } + private val unknownOpenGroupDrawable by lazy { ResourceContactPhoto(R.drawable.ic_notification) + .asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false, resourcePadding) } + + constructor(context: Context, sender: Recipient): this(context) { + update(sender) } - private val unknownOpenGroupDrawable by lazy(LazyThreadSafetyMode.NONE) { - ResourceContactPhoto(R.drawable.ic_notification) - .asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) + fun update(recipient: Recipient) { + recipient.run { update(address, isClosedGroupRecipient, isOpenGroupInboxRecipient) } } - private fun setShowAsDoubleMode(showAsDouble: Boolean) { - binding.doubleModeImageViewContainer.isVisible = showAsDouble - binding.singleModeImageView.isVisible = !showAsDouble - } + fun update( + address: Address, + isClosedGroupRecipient: Boolean = false, + isOpenGroupInboxRecipient: Boolean = false + ) { + fun getUserDisplayName(publicKey: String): String = prefs.takeIf { userPublicKey == publicKey }?.getProfileName() + ?: DatabaseComponent.get(context).sessionContactDatabase().getContactWithAccountID(publicKey)?.displayName(Contact.ContactContext.REGULAR) + ?: publicKey - private fun cancelLastLoadJob() { - lastLoadJob?.cancel() - lastLoadJob = null - } - - @OptIn(DelicateCoroutinesApi::class) - private fun loadAsDoubleImages(model: LoadModel) { - cancelLastLoadJob() - - // The use of GlobalScope is intentional here, as there is no better lifecycle scope that we can use - // to launch a coroutine from a view. The potential memory leak is not a concern here, as - // the coroutine is very short-lived. If you change the code here to be long live then you'll - // need to find a better scope to launch the coroutine from. - lastLoadJob = GlobalScope.launch(Dispatchers.Main) { - data class GroupMemberInfo( - val contactPhoto: ContactPhoto?, - val placeholderAvatarPhoto: PlaceholderAvatarPhoto, - ) - - // Load group avatar if available, otherwise load member avatars - val groupAvatarOrMemberAvatars = withContext(Dispatchers.Default) { - model.loadRecipient(context).contactPhoto - ?: groupDatabase.getGroupMembers(model.address.toGroupString(), true) - .map { - GroupMemberInfo( - contactPhoto = it.contactPhoto, - placeholderAvatarPhoto = PlaceholderAvatarPhoto( - hashString = it.address.serialize(), - displayName = it.displayName() - ) - ) - } + if (isClosedGroupRecipient) { + val members = DatabaseComponent.get(context).groupDatabase() + .getGroupMemberAddresses(address.toGroupString(), true) + .sorted() + .take(2) + if (members.size <= 1) { + publicKey = "" + displayName = "" + additionalPublicKey = "" + additionalDisplayName = "" + } else { + val pk = members.getOrNull(0)?.serialize() ?: "" + publicKey = pk + displayName = getUserDisplayName(pk) + val apk = members.getOrNull(1)?.serialize() ?: "" + additionalPublicKey = apk + additionalDisplayName = getUserDisplayName(apk) } - - when (groupAvatarOrMemberAvatars) { - is ContactPhoto -> { - setShowAsDoubleMode(false) - Glide.with(this@ProfilePictureView) - .load(groupAvatarOrMemberAvatars) - .error(unknownRecipientDrawable) - .circleCrop() - .diskCacheStrategy(DiskCacheStrategy.NONE) - .into(binding.singleModeImageView) - } - - is List<*> -> { - val first = groupAvatarOrMemberAvatars.getOrNull(0) as? GroupMemberInfo - val second = groupAvatarOrMemberAvatars.getOrNull(1) as? GroupMemberInfo - setShowAsDoubleMode(true) - Glide.with(binding.doubleModeImageView1) - .load(first?.let { it.contactPhoto ?: it.placeholderAvatarPhoto }) - .error(first?.placeholderAvatarPhoto ?: unknownRecipientDrawable) - .circleCrop() - .diskCacheStrategy(DiskCacheStrategy.NONE) - .into(binding.doubleModeImageView1) - - Glide.with(binding.doubleModeImageView2) - .load(second?.let { it.contactPhoto ?: it.placeholderAvatarPhoto }) - .error(second?.placeholderAvatarPhoto ?: unknownRecipientDrawable) - .circleCrop() - .diskCacheStrategy(DiskCacheStrategy.NONE) - .into(binding.doubleModeImageView2) - } - - else -> { - setShowAsDoubleMode(false) - binding.singleModeImageView.setImageDrawable(unknownRecipientDrawable) - } - } - } - } - - @OptIn(DelicateCoroutinesApi::class) - private fun loadAsSingleImage(model: LoadModel) { - cancelLastLoadJob() - - setShowAsDoubleMode(false) - - // Only clear the old image if the address has changed. This is important as we have a delay - // in loading the image, if this view is reused for another address before the image is loaded, - // the previous image could be displayed for a short period of time. We would want to avoid - // displaying the wrong image, even for a short time. - // However, if we are displaying the same user's image again, it's ok to show the old - // image until the new one is loaded. This is a trade-off between performance and correctness. - if (lastLoadAddress != model.address) { - Glide.with(this).clear(this) - } - - // The use of GlobalScope is intentional here, as there is no better lifecycle scope that we can use - // to launch a coroutine from a view. The potential memory leak is not a concern here, as - // the coroutine is very short-lived. If you change the code here to be long live then you'll - // need to find a better scope to launch the coroutine from. - lastLoadJob = GlobalScope.launch(Dispatchers.Main) { - val (contactPhoto, avatarPlaceholder) = withContext(Dispatchers.Default) { - model.loadRecipient(context).let { - it.contactPhoto to PlaceholderAvatarPhoto(it.address.serialize(), it.displayName()) - } - } - - val address = model.address - - val errorModel: Any = when { - address.isCommunity -> unknownOpenGroupDrawable - address.isContact -> avatarPlaceholder - else -> unknownRecipientDrawable - } - - Glide.with(this@ProfilePictureView) - .load(contactPhoto) - .error(errorModel) - .circleCrop() - .diskCacheStrategy(DiskCacheStrategy.NONE) - .into(binding.singleModeImageView) - } - } - - fun load(recipient: Recipient) { - if (recipient.address.isClosedGroup) { - loadAsDoubleImages(LoadModel.RecipientModel(recipient)) + } else if(isOpenGroupInboxRecipient) { + val publicKey = GroupUtil.getDecodedOpenGroupInboxAccountId(address.serialize()) + this.publicKey = publicKey + displayName = getUserDisplayName(publicKey) + additionalPublicKey = null } else { - loadAsSingleImage(LoadModel.RecipientModel(recipient)) + val publicKey = address.serialize() + this.publicKey = publicKey + displayName = getUserDisplayName(publicKey) + additionalPublicKey = null } - - lastLoadAddress = recipient.address + update() } - fun load(address: Address) { - if (address.isClosedGroup) { - loadAsDoubleImages(LoadModel.AddressModel(address)) - } else { - loadAsSingleImage(LoadModel.AddressModel(address)) + fun update() { + val publicKey = publicKey ?: return Log.w(TAG, "Could not find public key to update profile picture") + val additionalPublicKey = additionalPublicKey + // if we have a multi avatar setup + if (additionalPublicKey != null) { + setProfilePictureIfNeeded(binding.doubleModeImageView1, publicKey, displayName) + setProfilePictureIfNeeded(binding.doubleModeImageView2, additionalPublicKey, additionalDisplayName) + binding.doubleModeImageViewContainer.visibility = View.VISIBLE + + // clear single image + glide.clear(binding.singleModeImageView) + binding.singleModeImageView.visibility = View.INVISIBLE + } else { // single image mode + setProfilePictureIfNeeded(binding.singleModeImageView, publicKey, displayName) + binding.singleModeImageView.visibility = View.VISIBLE + + // clear multi image + glide.clear(binding.doubleModeImageView1) + glide.clear(binding.doubleModeImageView2) + binding.doubleModeImageViewContainer.visibility = View.INVISIBLE } - lastLoadAddress = address } - private fun Recipient.displayName(): String { - return if (isLocalNumber) { - TextSecurePreferences.getProfileName(context).orEmpty() - } else { - profileName ?: name ?: "" - } - } + private fun setProfilePictureIfNeeded(imageView: ImageView, publicKey: String, displayName: String?) { + if (publicKey.isNotEmpty()) { + val recipient = Recipient.from(context, Address.fromSerialized(publicKey), false) + if (profilePicturesCache[imageView] == recipient) return + profilePicturesCache[imageView] = recipient + val signalProfilePicture = recipient.contactPhoto + val avatar = (signalProfilePicture as? ProfileContactPhoto)?.avatarObject - private sealed interface LoadModel { - val address: Address + glide.clear(imageView) - /** - * Load the recipient if it's not already loaded. - */ - fun loadRecipient(context: Context): Recipient + val placeholder = PlaceholderAvatarPhoto(publicKey, displayName ?: "${publicKey.take(4)}...${publicKey.takeLast(4)}") - data class AddressModel(override val address: Address) : LoadModel { - override fun loadRecipient(context: Context): Recipient { - return Recipient.from(context, address, false) + if (signalProfilePicture != null && avatar != "0" && avatar != "") { + glide.load(signalProfilePicture) + .placeholder(unknownRecipientDrawable) + .centerCrop() + .error(glide.load(placeholder)) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .circleCrop() + .into(imageView) + } else if (recipient.isCommunityRecipient && recipient.groupAvatarId == null) { + glide.clear(imageView) + glide.load(unknownOpenGroupDrawable) + .centerCrop() + .circleCrop() + .into(imageView) + } else { + glide.load(placeholder) + .placeholder(unknownRecipientDrawable) + .centerCrop() + .circleCrop() + .diskCacheStrategy(DiskCacheStrategy.NONE).circleCrop().into(imageView) } - } - - data class RecipientModel(val recipient: Recipient) : LoadModel { - override val address: Address - get() = recipient.address - - override fun loadRecipient(context: Context): Recipient = recipient + } else { + glide.load(unknownRecipientDrawable) + .centerCrop() + .into(imageView) } } -} \ No newline at end of file + + fun recycle() { + profilePicturesCache.clear() + } + // endregion +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt index 3af63670dd..e0ca2a4242 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt @@ -53,7 +53,7 @@ class UserView : LinearLayout { return contact?.displayName(Contact.ContactContext.REGULAR) ?: publicKey } val address = user.address.serialize() - binding.profilePictureView.load(user) + binding.profilePictureView.update(user) binding.actionIndicatorImageView.setImageResource(R.drawable.ic_baseline_edit_24) binding.nameTextView.text = if (user.isGroupRecipient) user.name else getUserDisplayName(address) when (actionIndicator) { @@ -85,6 +85,7 @@ class UserView : LinearLayout { } fun unbind() { + binding.profilePictureView.recycle() } // endregion } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt index d8ba8122ba..8f2da7a733 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt @@ -81,7 +81,7 @@ class ConversationActionBarView @JvmOverloads constructor( } fun update(recipient: Recipient, openGroup: OpenGroup? = null, config: ExpirationConfiguration? = null) { - binding.profilePictureView.load(recipient) + binding.profilePictureView.update(recipient) binding.conversationTitleView.text = recipient.takeUnless { it.isLocalNumber }?.toShortString() ?: context.getString(R.string.note_to_self) updateSubtitle(recipient, openGroup, config) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt index a2cb95a6b4..df54f9cae8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt @@ -48,7 +48,7 @@ import org.thoughtcrime.securesms.ui.LoadingArcOr import org.thoughtcrime.securesms.ui.components.AppBarCloseIcon import org.thoughtcrime.securesms.ui.components.BackAppBar import org.thoughtcrime.securesms.ui.components.BorderlessButtonWithIcon -import org.thoughtcrime.securesms.ui.components.MaybeScanQrCode +import org.thoughtcrime.securesms.ui.components.QRScannerScreen import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField import org.thoughtcrime.securesms.ui.components.SessionTabRow @@ -89,7 +89,7 @@ internal fun NewMessage( HorizontalPager(pagerState) { when (TITLES[it]) { R.string.enter_account_id -> EnterAccountId(state, callbacks, onHelp) - R.string.qrScan -> MaybeScanQrCode(qrErrors, onScan = callbacks::onScanQrCode) + R.string.qrScan -> QRScannerScreen(qrErrors, onScan = callbacks::onScanQrCode) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt index 0a0829a02c..1c57dc8d5f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt @@ -27,7 +27,6 @@ import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.dependencies.DatabaseComponent import com.bumptech.glide.RequestManager -import org.thoughtcrime.securesms.database.getLong import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity import org.thoughtcrime.securesms.showSessionDialog import java.util.concurrent.atomic.AtomicLong diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateSelectionView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateSelectionView.kt new file mode 100644 index 0000000000..5698ddd0bb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateSelectionView.kt @@ -0,0 +1,87 @@ +package org.thoughtcrime.securesms.conversation.v2.components + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.BaseAdapter +import android.widget.ListView +import org.session.libsession.messaging.mentions.Mention +import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import com.bumptech.glide.RequestManager +import org.thoughtcrime.securesms.util.toPx + +class MentionCandidateSelectionView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : ListView(context, attrs, defStyleAttr) { + private var mentionCandidates = listOf() + set(newValue) { field = newValue; mentionCandidateSelectionViewAdapter.mentionCandidates = newValue } + var glide: RequestManager? = null + set(newValue) { field = newValue; mentionCandidateSelectionViewAdapter.glide = newValue } + var openGroupServer: String? = null + set(newValue) { field = newValue; mentionCandidateSelectionViewAdapter.openGroupServer = openGroupServer } + var openGroupRoom: String? = null + set(newValue) { field = newValue; mentionCandidateSelectionViewAdapter.openGroupRoom = openGroupRoom } + var onMentionCandidateSelected: ((Mention) -> Unit)? = null + + private val mentionCandidateSelectionViewAdapter by lazy { Adapter(context) } + + private class Adapter(private val context: Context) : BaseAdapter() { + var mentionCandidates = listOf() + set(newValue) { field = newValue; notifyDataSetChanged() } + var glide: RequestManager? = null + var openGroupServer: String? = null + var openGroupRoom: String? = null + + override fun getCount(): Int { + return mentionCandidates.count() + } + + override fun getItemId(position: Int): Long { + return position.toLong() + } + + override fun getItem(position: Int): Mention { + return mentionCandidates[position] + } + + override fun getView(position: Int, cellToBeReused: View?, parent: ViewGroup): View { + val cell = cellToBeReused as MentionCandidateView? ?: MentionCandidateView(context) + val mentionCandidate = getItem(position) + cell.glide = glide + cell.mentionCandidate = 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 = mentionCandidateSelectionViewAdapter + mentionCandidateSelectionViewAdapter.mentionCandidates = mentionCandidates + setOnItemClickListener { _, _, position, _ -> + onMentionCandidateSelected?.invoke(mentionCandidates[position]) + } + } + + fun show(mentionCandidates: List, threadID: Long) { + val openGroup = DatabaseComponent.get(context).lokiThreadDatabase().getOpenGroupChat(threadID) + if (openGroup != null) { + openGroupServer = openGroup.server + openGroupRoom = openGroup.room + } + this.mentionCandidates = mentionCandidates + val layoutParams = this.layoutParams as ViewGroup.LayoutParams + layoutParams.height = toPx(Math.min(mentionCandidates.count(), 4) * 44, resources) + this.layoutParams = layoutParams + } + + fun hide() { + val layoutParams = this.layoutParams as ViewGroup.LayoutParams + layoutParams.height = 0 + this.layoutParams = layoutParams + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateView.kt new file mode 100644 index 0000000000..14dc6263ab --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateView.kt @@ -0,0 +1,42 @@ +package org.thoughtcrime.securesms.conversation.v2.components + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.widget.LinearLayout +import network.loki.messenger.databinding.ViewMentionCandidateBinding +import org.session.libsession.messaging.mentions.Mention +import org.thoughtcrime.securesms.groups.OpenGroupManager +import com.bumptech.glide.RequestManager + +class MentionCandidateView : LinearLayout { + private lateinit var binding: ViewMentionCandidateBinding + var mentionCandidate = Mention("", "") + set(newValue) { field = newValue; update() } + var glide: RequestManager? = null + var openGroupServer: String? = null + var openGroupRoom: String? = null + + constructor(context: Context) : this(context, null) + constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } + + private fun initialize() { + binding = ViewMentionCandidateBinding.inflate(LayoutInflater.from(context), this, true) + } + + private fun update() = with(binding) { + mentionCandidateNameTextView.text = mentionCandidate.displayName + profilePictureView.publicKey = mentionCandidate.publicKey + profilePictureView.displayName = mentionCandidate.displayName + profilePictureView.additionalPublicKey = null + profilePictureView.update() + if (openGroupServer != null && openGroupRoom != null) { + val isUserModerator = OpenGroupManager.isUserModerator(context, "$openGroupRoom.$openGroupServer", mentionCandidate.publicKey) + moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE + } else { + moderatorIconImageView.visibility = View.GONE + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt index c1df0cb631..f790e7f1c6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt @@ -2,11 +2,13 @@ package org.thoughtcrime.securesms.conversation.v2.input_bar.mentions import android.view.View import network.loki.messenger.databinding.ViewMentionCandidateV2Binding -import org.session.libsession.utilities.Address import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel fun ViewMentionCandidateV2Binding.update(candidate: MentionViewModel.Candidate) { mentionCandidateNameTextView.text = candidate.nameHighlighted - profilePictureView.load(Address.fromSerialized(candidate.member.publicKey)) + profilePictureView.publicKey = candidate.member.publicKey + profilePictureView.displayName = candidate.member.name + profilePictureView.additionalPublicKey = null + profilePictureView.update() moderatorIconImageView.visibility = if (candidate.member.isModerator) View.VISIBLE else View.GONE } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt index 5f26d50251..9f7f620ab5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt @@ -16,6 +16,7 @@ import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.widget.FrameLayout +import android.widget.LinearLayout import androidx.annotation.ColorInt import androidx.annotation.DrawableRes import androidx.annotation.StringRes @@ -25,8 +26,6 @@ import androidx.core.content.ContextCompat import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.core.view.marginBottom -import com.bumptech.glide.Glide -import com.bumptech.glide.RequestManager import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import network.loki.messenger.databinding.ViewEmojiReactionsBinding @@ -53,6 +52,8 @@ import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.home.UserDetailsBottomSheet +import com.bumptech.glide.Glide +import com.bumptech.glide.RequestManager import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.disableClipping import org.thoughtcrime.securesms.util.toDp @@ -178,7 +179,8 @@ class VisibleMessageView : FrameLayout { if (isGroupThread && !message.isOutgoing) { if (isEndOfMessageCluster) { - binding.profilePictureView.load(message.individualRecipient) + binding.profilePictureView.publicKey = senderAccountID + binding.profilePictureView.update(message.individualRecipient) binding.profilePictureView.setOnClickListener { if (thread.isCommunityRecipient) { val openGroup = lokiThreadDb.getOpenGroupChat(threadID) @@ -454,6 +456,7 @@ class VisibleMessageView : FrameLayout { } fun recycle() { + binding.profilePictureView.recycle() binding.messageContentView.root.recycle() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/ContactPhotoFetcher.java b/app/src/main/java/org/thoughtcrime/securesms/glide/ContactPhotoFetcher.java index 7f74365604..6ab528b785 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/glide/ContactPhotoFetcher.java +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/ContactPhotoFetcher.java @@ -13,14 +13,14 @@ import org.session.libsession.avatars.ContactPhoto; import java.io.IOException; import java.io.InputStream; -public class ContactPhotoFetcher implements DataFetcher { +class ContactPhotoFetcher implements DataFetcher { private final Context context; private final ContactPhoto contactPhoto; private InputStream inputStream; - public ContactPhotoFetcher(@NonNull Context context, @NonNull ContactPhoto contactPhoto) { + ContactPhotoFetcher(@NonNull Context context, @NonNull ContactPhoto contactPhoto) { this.context = context.getApplicationContext(); this.contactPhoto = contactPhoto; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarFetcher.kt b/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarFetcher.kt index 36ff89580f..38d51877de 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarFetcher.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarFetcher.kt @@ -9,15 +9,12 @@ import org.session.libsession.avatars.PlaceholderAvatarPhoto import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.util.AvatarPlaceholderGenerator -class PlaceholderAvatarFetcher( - private val context: Context, - private val hashString: String, - private val displayName: String -): DataFetcher { +class PlaceholderAvatarFetcher(private val context: Context, + private val photo: PlaceholderAvatarPhoto): DataFetcher { override fun loadData(priority: Priority,callback: DataFetcher.DataCallback) { try { - val avatar = AvatarPlaceholderGenerator.generate(context, 128, hashString, displayName) + val avatar = AvatarPlaceholderGenerator.generate(context, 128, photo.hashString, photo.displayName) callback.onDataReady(avatar) } catch (e: Exception) { Log.e("Loki", "Error in fetching avatar") diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarLoader.kt index 235161ae32..b163b5ed90 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarLoader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarLoader.kt @@ -8,9 +8,6 @@ import com.bumptech.glide.load.model.ModelLoader.LoadData import com.bumptech.glide.load.model.ModelLoaderFactory import com.bumptech.glide.load.model.MultiModelLoaderFactory import org.session.libsession.avatars.PlaceholderAvatarPhoto -import org.session.libsession.utilities.Address -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsession.utilities.recipients.Recipient class PlaceholderAvatarLoader(private val appContext: Context): ModelLoader { @@ -20,15 +17,7 @@ class PlaceholderAvatarLoader(private val appContext: Context): ModelLoader { - val displayName: String = when { - !model.displayName.isNullOrBlank() -> model.displayName.orEmpty() - model.hashString == TextSecurePreferences.getLocalNumber(appContext) -> TextSecurePreferences.getProfileName(appContext).orEmpty() - else -> Recipient.from(appContext, Address.fromSerialized(model.hashString), false).let { - it.profileName ?: it.name ?: "" - } - } - - return LoadData(model, PlaceholderAvatarFetcher(appContext, model.hashString, displayName)) + return LoadData(model, PlaceholderAvatarFetcher(appContext, model)) } override fun handles(model: PlaceholderAvatarPhoto): Boolean = true diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt index 875c96ed23..68aea84417 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.res.Resources import android.graphics.Typeface import android.graphics.drawable.ColorDrawable +import android.text.TextUtils import android.util.AttributeSet import android.util.TypedValue import android.view.View @@ -127,10 +128,11 @@ class ConversationView : LinearLayout { thread.isRead -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_filled_circle_check) else -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_circle_check) } - binding.profilePictureView.load(thread.recipient) + binding.profilePictureView.update(thread.recipient) } fun recycle() { + binding.profilePictureView.recycle() } private fun getTitle(recipient: Recipient): String? = when { diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index bd8919ddc9..2d683abe8f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -347,7 +347,8 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ApplicationContext.getInstance(this).messageNotifier.setHomeScreenVisible(true) if (textSecurePreferences.getLocalNumber() == null) { return; } // This can be the case after a secondary device is auto-cleared IdentityKeyUtil.checkUpdate(this) - binding.profileButton.load(Address.fromSerialized(publicKey)) + binding.profileButton.recycle() // clear cached image before update tje profilePictureView + binding.profileButton.update() if (textSecurePreferences.getHasViewedSeed()) { binding.seedReminderView.isVisible = false } @@ -387,7 +388,10 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } private fun updateProfileButton() { - binding.profileButton.load(Address.fromSerialized(publicKey)) + binding.profileButton.publicKey = publicKey + binding.profileButton.displayName = textSecurePreferences.getProfileName() + binding.profileButton.recycle() + binding.profileButton.update() } // endregion diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt index a6d0627b27..cae399dcbf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt @@ -55,7 +55,8 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() { val recipient = Recipient.from(requireContext(), Address.fromSerialized(publicKey), false) val threadRecipient = threadDb.getRecipientForThreadId(threadID) ?: return dismiss() with(binding) { - profilePictureView.load(recipient) + profilePictureView.publicKey = publicKey + profilePictureView.update(recipient) nameTextViewContainer.visibility = View.VISIBLE nameTextViewContainer.setOnClickListener { if (recipient.isOpenGroupInboxRecipient || recipient.isOpenGroupOutboxRecipient) return@setOnClickListener diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt index f70053e44a..71c2c62506 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt @@ -11,6 +11,7 @@ import network.loki.messenger.databinding.ViewGlobalSearchHeaderBinding import network.loki.messenger.databinding.ViewGlobalSearchResultBinding import network.loki.messenger.databinding.ViewGlobalSearchSubheaderBinding import org.session.libsession.utilities.GroupRecord +import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.search.model.MessageResult import org.thoughtcrime.securesms.ui.GetString import java.security.InvalidParameterException @@ -98,6 +99,12 @@ class GlobalSearchAdapter(private val modelCallback: (Model)->Unit): RecyclerVie } } + override fun onViewRecycled(holder: RecyclerView.ViewHolder) { + if (holder is ContentView) { + holder.binding.searchResultProfilePicture.recycle() + } + } + class ContentView(view: View, private val modelCallback: (Model) -> Unit) : RecyclerView.ViewHolder(view) { val binding = ViewGlobalSearchResultBinding.bind(view) @@ -107,6 +114,7 @@ class GlobalSearchAdapter(private val modelCallback: (Model)->Unit): RecyclerVie } fun bind(query: String, model: Model) { + binding.searchResultProfilePicture.recycle() when (model) { is Model.GroupConversation -> bindModel(query, model) is Model.Contact -> bindModel(query, model) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt index 7ee46b8c8a..d390776d1c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt @@ -93,7 +93,7 @@ fun ContentView.bindModel(query: String?, model: GroupConversation) { binding.searchResultSubtitle.isVisible = model.groupRecord.isClosedGroup binding.searchResultTimestamp.isVisible = false val threadRecipient = Recipient.from(binding.root.context, Address.fromSerialized(model.groupRecord.encodedId), false) - binding.searchResultProfilePicture.load(threadRecipient) + binding.searchResultProfilePicture.update(threadRecipient) val nameString = model.groupRecord.title binding.searchResultTitle.text = getHighlight(query, nameString) @@ -111,7 +111,7 @@ fun ContentView.bindModel(query: String?, model: ContactModel) = binding.run { searchResultTimestamp.isVisible = false searchResultSubtitle.text = null val recipient = Recipient.from(root.context, Address.fromSerialized(model.contact.accountID), false) - searchResultProfilePicture.load(recipient) + searchResultProfilePicture.update(recipient) val nameString = if (model.isSelf) root.context.getString(R.string.note_to_self) else model.contact.getSearchName() searchResultTitle.text = getHighlight(query, nameString) @@ -121,7 +121,7 @@ fun ContentView.bindModel(model: SavedMessages) { binding.searchResultSubtitle.isVisible = false binding.searchResultTimestamp.isVisible = false binding.searchResultTitle.setText(R.string.note_to_self) - binding.searchResultProfilePicture.load(Address.fromSerialized(model.currentUserPublicKey)) + binding.searchResultProfilePicture.update(Address.fromSerialized(model.currentUserPublicKey)) binding.searchResultProfilePicture.isVisible = true } @@ -134,7 +134,7 @@ fun ContentView.bindModel(query: String?, model: Message) = binding.apply { // unreadCountTextView.text = model.unread.toString() // } searchResultTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(root.context, Locale.getDefault(), model.messageResult.sentTimestampMs) - searchResultProfilePicture.load(model.messageResult.conversationRecipient) + searchResultProfilePicture.update(model.messageResult.conversationRecipient) val textSpannable = SpannableStringBuilder() if (model.messageResult.conversationRecipient != model.messageResult.messageRecipient) { // group chat, bind diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewScreen.kt index f79d387138..bb2ff8ad27 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewScreen.kt @@ -144,6 +144,7 @@ fun MediaOverviewScreen( onSaveClicked = { showingSaveAttachmentWarning = true }, onDeleteClicked = { showingDeleteConfirmation = true }, onSelectAllClicked = viewModel::onSelectAllClicked, + numSelected = selectedItems.size, appBarScrollBehavior = appBarScrollBehavior ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewTopAppBar.kt b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewTopAppBar.kt index 7ae5ba516d..570885f212 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewTopAppBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewTopAppBar.kt @@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.ui.theme.LocalColors @OptIn(ExperimentalMaterial3Api::class) fun MediaOverviewTopAppBar( selectionMode: Boolean, + numSelected: Int, title: String, onBackClicked: () -> Unit, onSaveClicked: () -> Unit, @@ -25,7 +26,8 @@ fun MediaOverviewTopAppBar( ) { ActionAppBar( title = title, - navigationIcon = {AppBarBackIcon(onBack = onBackClicked)}, + actionModeTitle = numSelected.toString(), + navigationIcon = { AppBarBackIcon(onBack = onBackClicked) }, scrollBehavior = appBarScrollBehavior, actionMode = selectionMode, actionModeActions = { diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt index 6e5ca6d8cd..1fb1f38ce4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt @@ -49,11 +49,12 @@ class MessageRequestView : LinearLayout { binding.snippetTextView.text = snippet post { - binding.profilePictureView.load(thread.recipient) + binding.profilePictureView.update(thread.recipient) } } fun recycle() { + binding.profilePictureView.recycle() } private fun getUserDisplayName(recipient: Recipient): String? { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/SignalGlideModule.java b/app/src/main/java/org/thoughtcrime/securesms/mms/SignalGlideModule.java index ed8d3c7d85..02172b7248 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/SignalGlideModule.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/SignalGlideModule.java @@ -56,7 +56,6 @@ public class SignalGlideModule extends AppGlideModule { // builder.setDiskCache(new NoopDiskCacheFactory()); } - /** @noinspection unchecked*/ @Override public void registerComponents(@NonNull Context context, @NonNull Glide glide, @NonNull Registry registry) { AttachmentSecret attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(); @@ -75,7 +74,6 @@ public class SignalGlideModule extends AppGlideModule { registry.append(AttachmentModel.class, InputStream.class, new AttachmentStreamUriLoader.Factory()); registry.append(ChunkedImageUrl.class, InputStream.class, new ChunkedImageUrlLoader.Factory()); registry.append(PlaceholderAvatarPhoto.class, BitmapDrawable.class, new PlaceholderAvatarLoader.Factory(context)); - registry.replace(GlideUrl.class, InputStream.class, new OkHttpUrlLoader.Factory()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt index cc1033ee96..56d1c54ea4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt @@ -26,7 +26,7 @@ import network.loki.messenger.R import org.thoughtcrime.securesms.onboarding.ui.ContinuePrimaryOutlineButton import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.PreviewTheme -import org.thoughtcrime.securesms.ui.components.MaybeScanQrCode +import org.thoughtcrime.securesms.ui.components.QRScannerScreen import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField import org.thoughtcrime.securesms.ui.components.SessionTabRow import org.thoughtcrime.securesms.ui.theme.LocalType @@ -52,7 +52,7 @@ internal fun LoadAccountScreen( ) { page -> when (TITLES[page]) { R.string.sessionRecoveryPassword -> RecoveryPassword(state, onChange, onContinue) - R.string.qrScan -> MaybeScanQrCode(qrErrors, onScan = onScan) + R.string.qrScan -> QRScannerScreen(qrErrors, onScan = onScan) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountActivity.kt index 3c7a6f6a56..8669db87e4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountActivity.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject import kotlinx.coroutines.launch import network.loki.messenger.R import org.session.libsession.utilities.TextSecurePreferences @@ -14,7 +15,6 @@ import org.thoughtcrime.securesms.onboarding.manager.LoadAccountManager import org.thoughtcrime.securesms.onboarding.messagenotifications.MessageNotificationsActivity import org.thoughtcrime.securesms.ui.setComposeContent import org.thoughtcrime.securesms.util.start -import javax.inject.Inject @AndroidEntryPoint class LoadAccountActivity : BaseActionBarActivity() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountViewModel.kt index f98c725dea..bdb6716145 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountViewModel.kt @@ -4,6 +4,7 @@ import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -17,7 +18,6 @@ import org.session.libsignal.crypto.MnemonicCodec import org.session.libsignal.crypto.MnemonicCodec.DecodingError.InputTooShort import org.session.libsignal.crypto.MnemonicCodec.DecodingError.InvalidWord import org.thoughtcrime.securesms.crypto.MnemonicUtilities -import javax.inject.Inject class LoadAccountEvent(val mnemonic: ByteArray) @@ -54,6 +54,7 @@ internal class LoadAccountViewModel @Inject constructor( } fun onScanQrCode(string: String) { + viewModelScope.launch { try { codec.decodeMnemonicOrHexAsByteArray(string).let(::onSuccess) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsAdapter.kt index 589aabef1d..e59d86c912 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsAdapter.kt @@ -36,6 +36,11 @@ class BlockedContactsAdapter(val viewModel: BlockedContactsViewModel) : ListAdap else holder.select(getItem(position).isSelected) } + override fun onViewRecycled(holder: ViewHolder) { + super.onViewRecycled(holder) + holder.binding.profilePictureView.recycle() + } + class ViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) { val glide = Glide.with(itemView) @@ -43,7 +48,9 @@ class BlockedContactsAdapter(val viewModel: BlockedContactsViewModel) : ListAdap fun bind(selectable: SelectableRecipient, toggle: (SelectableRecipient) -> Unit) { binding.recipientName.text = selectable.item.name - binding.profilePictureView.load(selectable.item) + with (binding.profilePictureView) { + update(selectable.item) + } binding.root.setOnClickListener { toggle(selectable) } binding.selectButton.isSelected = selectable.isSelected } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt index 9778a1c8b8..52cb345fab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt @@ -27,7 +27,7 @@ import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.database.threadDatabase import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalColors -import org.thoughtcrime.securesms.ui.components.MaybeScanQrCode +import org.thoughtcrime.securesms.ui.components.QRScannerScreen import org.thoughtcrime.securesms.ui.components.QrImage import org.thoughtcrime.securesms.ui.components.SessionTabRow import org.thoughtcrime.securesms.ui.contentDescription @@ -54,7 +54,7 @@ class QRCodeActivity : PassphraseRequiredActionBarActivity() { } } - fun onScan(string: String) { + private fun onScan(string: String) { if (!PublicKeyValidation.isValid(string)) { errors.tryEmit(getString(R.string.this_qr_code_does_not_contain_an_account_id)) } else if (!isFinishing) { @@ -83,7 +83,7 @@ private fun Tabs(accountId: String, errors: Flow, onScan: (String) -> Un ) { page -> when (TITLES[page]) { R.string.view -> QrPage(accountId) - R.string.scan -> MaybeScanQrCode(errors, onScan = onScan) + R.string.scan -> QRScannerScreen(errors, onScan = onScan) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt index 72e9a5e1d4..fc52541987 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt @@ -168,7 +168,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { super.onStart() binding.run { - loadProfilePicture(profilePictureView) + setupProfilePictureView(profilePictureView) profilePictureView.setOnClickListener { showEditProfilePictureUI() } ctnGroupNameSection.setOnClickListener { startActionMode(DisplayNameEditActionModeCallback()) } btnGroupNameDisplay.text = getDisplayName() @@ -185,9 +185,12 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { private fun getDisplayName(): String = TextSecurePreferences.getProfileName(this) ?: truncateIdForDisplay(hexEncodedPublicKey) - private fun loadProfilePicture(view: ProfilePictureView) { - // Always reload the profile picture as it can change on this page. - view.load(Address.fromSerialized(hexEncodedPublicKey)) + private fun setupProfilePictureView(view: ProfilePictureView) { + view.apply { + publicKey = hexEncodedPublicKey + displayName = getDisplayName() + update() + } } override fun onSaveInstanceState(outState: Bundle) { @@ -328,7 +331,9 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@SettingsActivity) - loadProfilePicture(binding.profilePictureView) + // Update our visuals + binding.profilePictureView.recycle() + binding.profilePictureView.update() } // If the sync failed then inform the user @@ -403,7 +408,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { cancelButton() }.apply { val profilePic = findViewById(R.id.profile_picture_view) - ?.also(::loadProfilePicture) + ?.also(::setupProfilePictureView) val pictureIcon = findViewById(R.id.ic_pictures) diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java index c9a3f4ea13..79717eabb1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java @@ -13,6 +13,7 @@ import org.session.libsession.messaging.utilities.AccountId; import org.thoughtcrime.securesms.components.ProfilePictureView; import org.thoughtcrime.securesms.components.emoji.EmojiImageView; import org.thoughtcrime.securesms.database.model.MessageId; +import com.bumptech.glide.Glide; import java.util.Collections; import java.util.List; @@ -153,7 +154,7 @@ final class ReactionRecipientsAdapter extends RecyclerView.Adapter Unit = {}, actions: @Composable RowScope.() -> Unit = {}, -){ +) { CenterAlignedTopAppBar( modifier = modifier, title = { @@ -94,7 +98,7 @@ fun BackAppBar( scrollBehavior: TopAppBarScrollBehavior? = null, backgroundColor: Color = LocalColors.current.background, actions: @Composable RowScope.() -> Unit = {}, -){ +) { BasicAppBar( modifier = modifier, title = title, @@ -115,6 +119,7 @@ fun ActionAppBar( scrollBehavior: TopAppBarScrollBehavior? = null, backgroundColor: Color = LocalColors.current.background, actionMode: Boolean = false, + actionModeTitle: String = "", navigationIcon: @Composable () -> Unit = {}, actions: @Composable RowScope.() -> Unit = {}, actionModeActions: @Composable (RowScope.() -> Unit) = {}, @@ -126,7 +131,19 @@ fun ActionAppBar( AppBarText(title = title) } }, - navigationIcon =navigationIcon, + navigationIcon = { + if (actionMode) { + Row( + horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxxsSpacing), + verticalAlignment = Alignment.CenterVertically + ) { + navigationIcon() + AppBarText(title = actionModeTitle) + } + } else { + navigationIcon() + } + }, scrollBehavior = scrollBehavior, colors = appBarColors(backgroundColor), actions = { diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt index c58f7dc97f..9661b3bc06 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt @@ -47,25 +47,27 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.shouldShowRationale -import com.google.mlkit.vision.barcode.BarcodeScanner -import com.google.mlkit.vision.barcode.BarcodeScannerOptions -import com.google.mlkit.vision.barcode.BarcodeScanning -import com.google.mlkit.vision.barcode.common.Barcode -import com.google.mlkit.vision.common.InputImage +import com.google.zxing.BinaryBitmap +import com.google.zxing.ChecksumException +import com.google.zxing.FormatException +import com.google.zxing.NotFoundException +import com.google.zxing.PlanarYUVLuminanceSource +import com.google.zxing.Result +import com.google.zxing.common.HybridBinarizer +import com.google.zxing.qrcode.QRCodeReader +import java.util.concurrent.Executors import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import network.loki.messenger.R import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType -import java.util.concurrent.Executors private const val TAG = "NewMessageFragment" @OptIn(ExperimentalPermissionsApi::class) @Composable -fun MaybeScanQrCode( +fun QRScannerScreen( errors: Flow, onClickSettings: () -> Unit = LocalContext.current.run { { Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { @@ -137,17 +139,13 @@ fun ScanQrCode(errors: Flow, onScan: (String) -> Unit) { runCatching { cameraProvider.get().unbindAll() - val options = BarcodeScannerOptions.Builder() - .setBarcodeFormats(Barcode.FORMAT_QR_CODE) - .build() - val scanner = BarcodeScanning.getClient(options) - cameraProvider.get().bindToLifecycle( LocalLifecycleOwner.current, selector, preview, - buildAnalysisUseCase(scanner, onScan) + buildAnalysisUseCase(QRCodeReader(), onScan) ) + }.onFailure { Log.e(TAG, "error binding camera", it) } DisposableEffect(cameraProvider) { @@ -211,32 +209,51 @@ fun ScanQrCode(errors: Flow, onScan: (String) -> Unit) { @SuppressLint("UnsafeOptInUsageError") private fun buildAnalysisUseCase( - scanner: BarcodeScanner, + scanner: QRCodeReader, onBarcodeScanned: (String) -> Unit ): ImageAnalysis = ImageAnalysis.Builder() .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .build().apply { - setAnalyzer(Executors.newSingleThreadExecutor(), Analyzer(scanner, onBarcodeScanned)) + setAnalyzer(Executors.newSingleThreadExecutor(), QRCodeAnalyzer(scanner, onBarcodeScanned)) } -class Analyzer( - private val scanner: BarcodeScanner, +class QRCodeAnalyzer( + private val qrCodeReader: QRCodeReader, private val onBarcodeScanned: (String) -> Unit ): ImageAnalysis.Analyzer { + + // Note: This analyze method is called once per frame of the camera feed. @SuppressLint("UnsafeOptInUsageError") override fun analyze(image: ImageProxy) { - InputImage.fromMediaImage( - image.image!!, - image.imageInfo.rotationDegrees - ).let(scanner::process).apply { - addOnSuccessListener { barcodes -> - barcodes.forEach { - it.rawValue?.let(onBarcodeScanned) - } - } - addOnCompleteListener { - image.close() - } + // Grab the image data as a byte array so we can generate a PlanarYUVLuminanceSource from it + val buffer = image.planes[0].buffer + buffer.rewind() + val imageBytes = ByteArray(buffer.capacity()) + buffer.get(imageBytes) // IMPORTANT: This transfers data from the buffer INTO the imageBytes array, although it looks like it would go the other way around! + + // ZXing requires data as a BinaryBitmap to scan for QR codes, and to generate that we need to feed it a PlanarYUVLuminanceSource + val luminanceSource = PlanarYUVLuminanceSource(imageBytes, image.width, image.height, 0, 0, image.width, image.height, false) + val binaryBitmap = BinaryBitmap(HybridBinarizer(luminanceSource)) + + // Attempt to extract a QR code from the binary bitmap, and pass it through to our `onBarcodeScanned` method if we find one + try { + val result: Result = qrCodeReader.decode(binaryBitmap) + val resultTxt = result.text + // No need to close the image here - it'll always make it to the end, and calling `onBarcodeScanned` + // with a valid contact / recovery phrase / community code will stop calling this `analyze` method. + onBarcodeScanned(resultTxt) } + catch (nfe: NotFoundException) { /* Hits if there is no QR code in the image */ } + catch (fe: FormatException) { /* Hits if we found a QR code but failed to decode it */ } + catch (ce: ChecksumException) { /* Hits if we found a QR code which is corrupted */ } + catch (e: Exception) { + // Hits if there's a genuine problem + Log.e("QR", "error", e) + } + + // Remember to close the image when we're done with it! + // IMPORTANT: It is CLOSING the image that allows this method to run again! If we don't + // close the image this method runs precisely ONCE and that's it, which is essentially useless. + image.close() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/QRCodeUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/QRCodeUtilities.kt index 80eccae41a..ae4fd9a3f4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/QRCodeUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/QRCodeUtilities.kt @@ -6,6 +6,7 @@ import com.google.zxing.BarcodeFormat import com.google.zxing.EncodeHintType import com.google.zxing.qrcode.QRCodeWriter import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel +import org.session.libsignal.utilities.Log object QRCodeUtilities { @@ -34,5 +35,8 @@ object QRCodeUtilities { } } } - }.getOrNull() + }.getOrElse { + Log.e("QRCodeUtilities", "Failed to generate QR Code", it) + null + } } diff --git a/app/src/main/res/layout/view_mention_candidate.xml b/app/src/main/res/layout/view_mention_candidate.xml new file mode 100644 index 0000000000..bd96b482a5 --- /dev/null +++ b/app/src/main/res/layout/view_mention_candidate.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index d0e7a7e371..91d8222de7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -40,6 +40,7 @@ phraseVersion=1.2.0 preferenceVersion=1.2.0 protobufVersion=2.5.0 testCoreVersion=1.5.0 +zxingVersion=3.5.3 android.defaults.buildfeatures.buildconfig=true android.nonTransitiveRClass=false android.nonFinalResIds=false diff --git a/libsession/src/main/java/org/session/libsession/avatars/PlaceholderAvatarPhoto.kt b/libsession/src/main/java/org/session/libsession/avatars/PlaceholderAvatarPhoto.kt index 154825179c..916e9112de 100644 --- a/libsession/src/main/java/org/session/libsession/avatars/PlaceholderAvatarPhoto.kt +++ b/libsession/src/main/java/org/session/libsession/avatars/PlaceholderAvatarPhoto.kt @@ -3,12 +3,10 @@ package org.session.libsession.avatars import com.bumptech.glide.load.Key import java.security.MessageDigest -data class PlaceholderAvatarPhoto( - val hashString: String, - val displayName: String? -) : Key { +class PlaceholderAvatarPhoto(val hashString: String, + val displayName: String): Key { override fun updateDiskCacheKey(messageDigest: MessageDigest) { messageDigest.update(hashString.encodeToByteArray()) - messageDigest.update(displayName?.encodeToByteArray() ?: byteArrayOf()) + messageDigest.update(displayName.encodeToByteArray()) } } \ No newline at end of file