diff --git a/app/build.gradle b/app/build.gradle index fc710a8e0..74b9f84f0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,3 +1,4 @@ + buildscript { repositories { google() @@ -13,6 +14,11 @@ buildscript { } } +plugins { + id 'kotlin-kapt' + id 'com.google.dagger.hilt.android' +} + apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply plugin: 'witness' @@ -22,11 +28,16 @@ apply plugin: 'com.google.gms.google-services' apply plugin: 'kotlinx-serialization' apply plugin: 'dagger.hilt.android.plugin' + configurations.all { exclude module: "commons-logging" } dependencies { + + implementation("com.google.dagger:hilt-android:2.46.1") + kapt("com.google.dagger:hilt-android-compiler:2.44") + implementation "androidx.appcompat:appcompat:$appcompatVersion" implementation 'androidx.recyclerview:recyclerview:1.2.1' implementation "com.google.android.material:material:$materialVersion" @@ -39,7 +50,6 @@ dependencies { implementation 'androidx.exifinterface:exifinterface:1.3.4' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion" - implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-process:$lifecycleVersion" @@ -154,6 +164,16 @@ dependencies { testImplementation 'org.robolectric:robolectric:4.4' testImplementation 'org.robolectric:shadows-multidex:4.4' + + implementation 'com.github.bumptech.glide:compose:1.0.0-alpha.1' + implementation 'androidx.compose.ui:ui:1.4.3' + implementation 'androidx.compose.ui:ui-tooling:1.4.3' + implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.31.5-beta" + implementation "com.google.accompanist:accompanist-pager-indicators:0.31.5-beta" + implementation "androidx.compose.runtime:runtime-livedata:1.4.3" + + implementation 'androidx.compose.foundation:foundation-layout:1.5.0-alpha02' + implementation 'androidx.compose.material:material:1.5.0-alpha02' } def canonicalVersionCode = 354 @@ -199,6 +219,13 @@ android { } } + buildFeatures { + compose true + } + composeOptions { + kotlinCompilerExtensionVersion '1.4.7' + } + defaultConfig { versionCode canonicalVersionCode * postFixSize versionName canonicalVersionName @@ -305,3 +332,8 @@ def autoResConfig() { .collect { matcher -> matcher.group(1) } .sort() } + +// Allow references to generated code +kapt { + correctErrorTypes = true +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java index 008a45cfd..f19a1fc45 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java @@ -147,6 +147,10 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im } }; + public static Intent getPreviewIntent(Context context, MediaPreviewArgs args) { + return getPreviewIntent(context, args.getSlide(), args.getMmsRecord(), args.getThread()); + } + public static Intent getPreviewIntent(Context context, Slide slide, MmsMessageRecord mms, Recipient threadRecipient) { Intent previewIntent = null; if (MediaPreviewActivity.isContentTypeSupported(slide.getContentType()) && slide.getUri() != null) { @@ -524,7 +528,6 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im @Override public void onLoadFinished(@NonNull Loader> loader, @Nullable Pair data) { if (data != null) { - @SuppressWarnings("ConstantConditions") CursorPagerAdapter adapter = new CursorPagerAdapter(this, GlideApp.with(this), getWindow(), data.first, data.second, leftIsRecent); mediaPager.setAdapter(adapter); adapter.setActive(true); diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewArgs.kt b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewArgs.kt new file mode 100644 index 000000000..00e2c3d6d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewArgs.kt @@ -0,0 +1,11 @@ +package org.thoughtcrime.securesms + +import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.mms.Slide + +data class MediaPreviewArgs( + val slide: Slide, + val mmsRecord: MmsMessageRecord?, + val thread: Recipient?, +) 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 ded6b4f14..b87eac12c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt @@ -249,18 +249,12 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() { viewModel.callState.collect { state -> Log.d("Loki", "Consuming view model state $state") when (state) { - CALL_RINGING -> { - if (wantsToAnswer) { - answerCall() - wantsToAnswer = false - } - } - CALL_OUTGOING -> { - } - CALL_CONNECTED -> { + CALL_RINGING -> if (wantsToAnswer) { + answerCall() wantsToAnswer = false } - else -> { /* do nothing */ } + CALL_CONNECTED -> wantsToAnswer = false + else -> {} } updateControls(state) } 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 0101677d8..604422460 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components import android.content.Context import android.util.AttributeSet +import android.view.LayoutInflater import android.view.View import android.widget.ImageView import android.widget.RelativeLayout @@ -9,6 +10,7 @@ import androidx.annotation.DimenRes import com.bumptech.glide.load.engine.DiskCacheStrategy import network.loki.messenger.R import network.loki.messenger.databinding.ViewProfilePictureBinding +import network.loki.messenger.databinding.ViewUserBinding import org.session.libsession.avatars.ContactColors import org.session.libsession.avatars.PlaceholderAvatarPhoto import org.session.libsession.avatars.ProfileContactPhoto @@ -18,13 +20,14 @@ import org.session.libsession.utilities.Address import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.mms.GlideRequests class ProfilePictureView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : RelativeLayout(context, attrs) { - private val binding: ViewProfilePictureBinding by lazy { ViewProfilePictureBinding.bind(this) } - lateinit var glide: GlideRequests + private val binding = ViewProfilePictureBinding.inflate(LayoutInflater.from(context), this) + private val glide: GlideRequests = GlideApp.with(this) var publicKey: String? = null var displayName: String? = null var additionalPublicKey: String? = null @@ -37,8 +40,13 @@ class ProfilePictureView @JvmOverloads constructor( private val unknownOpenGroupDrawable by lazy { ResourceContactPhoto(R.drawable.ic_notification) .asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) } + // endregion + constructor(context: Context, sender: Recipient): this(context) { + update(sender) + } + // region Updating fun update(recipient: Recipient) { fun getUserDisplayName(publicKey: String): String { @@ -80,7 +88,6 @@ class ProfilePictureView @JvmOverloads constructor( } fun update() { - if (!this::glide.isInitialized) return val publicKey = publicKey ?: return val additionalPublicKey = additionalPublicKey if (additionalPublicKey != null) { 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 72c9749f9..36a8c1adf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt @@ -55,8 +55,7 @@ class UserView : LinearLayout { return contact?.displayName(Contact.ContactContext.REGULAR) ?: publicKey } val address = user.address.serialize() - binding.profilePictureView.root.glide = glide - binding.profilePictureView.root.update(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) { @@ -88,7 +87,7 @@ class UserView : LinearLayout { } fun unbind() { - binding.profilePictureView.root.recycle() + binding.profilePictureView.recycle() } // endregion } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/ContactListAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/ContactListAdapter.kt index 99e7c9061..68e2f975c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/ContactListAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/ContactListAdapter.kt @@ -32,14 +32,13 @@ class ContactListAdapter( class ContactViewHolder(private val binding: ViewContactBinding) : RecyclerView.ViewHolder(binding.root) { fun bind(contact: ContactListItem.Contact, glide: GlideRequests, listener: (Recipient) -> Unit) { - binding.profilePictureView.root.glide = glide - binding.profilePictureView.root.update(contact.recipient) + binding.profilePictureView.update(contact.recipient) binding.nameTextView.text = contact.displayName binding.root.setOnClickListener { listener(contact.recipient) } } fun unbind() { - binding.profilePictureView.root.recycle() + binding.profilePictureView.recycle() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index e7b194549..233d43eae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -33,6 +33,8 @@ import android.view.WindowManager import android.widget.LinearLayout import android.widget.RelativeLayout import android.widget.Toast +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.annotation.DimenRes import androidx.core.text.set @@ -106,6 +108,10 @@ import org.thoughtcrime.securesms.contacts.SelectContactsActivity.Companion.sele import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnActionSelectedListener import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnReactionSelectedListener +import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.MESSAGE_TIMESTAMP +import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_REPLY +import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_RESEND +import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_DELETE import org.thoughtcrime.securesms.conversation.v2.dialogs.BlockedDialog import org.thoughtcrime.securesms.conversation.v2.dialogs.LinkPreviewDialog import org.thoughtcrime.securesms.conversation.v2.dialogs.SendSeedDialog @@ -582,10 +588,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe R.dimen.small_profile_picture_size } val size = resources.getDimension(sizeID).roundToInt() - binding.toolbarContent.profilePictureView.root.layoutParams = LinearLayout.LayoutParams(size, size) - binding.toolbarContent.profilePictureView.root.glide = glide + binding.toolbarContent.profilePictureView.layoutParams = LinearLayout.LayoutParams(size, size) MentionManagerUtilities.populateUserPublicKeyCacheIfNeeded(viewModel.threadId, this) - val profilePictureView = binding.toolbarContent.profilePictureView.root + val profilePictureView = binding.toolbarContent.profilePictureView viewModel.recipient?.let(profilePictureView::update) } @@ -795,7 +800,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe updateSendAfterApprovalText() showOrHideInputIfNeeded() - binding?.toolbarContent?.profilePictureView?.root?.update(threadRecipient) + binding?.toolbarContent?.profilePictureView?.update(threadRecipient) binding?.toolbarContent?.conversationTitleView?.text = when { threadRecipient.isLocalNumber -> getString(R.string.note_to_self) else -> threadRecipient.toShortString() @@ -1921,10 +1926,24 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe endActionMode() } + private val handleMessageDetail = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> + val message = result.data?.extras?.getLong(MESSAGE_TIMESTAMP) + ?.let(mmsSmsDb::getMessageForTimestamp) + + val set = setOfNotNull(message) + + when (result.resultCode) { + ON_REPLY -> reply(set) + ON_RESEND -> resendMessage(set) + ON_DELETE -> deleteMessages(set) + } + } + override fun showMessageDetail(messages: Set) { - val intent = Intent(this, MessageDetailActivity::class.java) - intent.putExtra(MessageDetailActivity.MESSAGE_TIMESTAMP, messages.first().timestamp) - push(intent) + Intent(this, MessageDetailActivity::class.java) + .apply { putExtra(MESSAGE_TIMESTAMP, messages.first().timestamp) } + .let { handleMessageDetail.launch(it) } + endActionMode() } @@ -1963,7 +1982,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe override fun reply(messages: Set) { val recipient = viewModel.recipient ?: return - binding?.inputBar?.draftQuote(recipient, messages.first(), glide) + messages.firstOrNull()?.let { binding?.inputBar?.draftQuote(recipient, it, glide) } endActionMode() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.java index 20462bef3..eee8b5ecd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.java @@ -695,9 +695,7 @@ public final class ConversationReactionOverlay extends FrameLayout { items.add(new ActionItem(R.attr.menu_trash_icon, getContext().getResources().getString(R.string.conversation_context__menu_ban_and_delete_all), () -> handleActionItemClicked(Action.BAN_AND_DELETE_ALL))); } // Message detail - if (message.isFailed()) { - items.add(new ActionItem(R.attr.menu_info_icon, getContext().getResources().getString(R.string.conversation_context__menu_message_details), () -> handleActionItemClicked(Action.VIEW_INFO))); - } + items.add(new ActionItem(R.attr.menu_info_icon, getContext().getResources().getString(R.string.conversation_context__menu_message_details), () -> handleActionItemClicked(Action.VIEW_INFO))); // Resend if (message.isFailed()) { items.add(new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_resend_message), () -> handleActionItemClicked(Action.RESEND))); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt index 0938b21dd..61732827f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt @@ -1,99 +1,401 @@ package org.thoughtcrime.securesms.conversation.v2 +import android.annotation.SuppressLint +import android.content.Intent import android.os.Bundle -import android.view.View -import androidx.core.view.isVisible +import android.view.LayoutInflater +import android.view.MotionEvent.ACTION_UP +import androidx.activity.viewModels +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Icon +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.lifecycleScope +import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi +import com.bumptech.glide.integration.compose.GlideImage import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch import network.loki.messenger.R -import network.loki.messenger.databinding.ActivityMessageDetailBinding -import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.open_groups.OpenGroupApi -import org.session.libsession.messaging.utilities.SessionId -import org.session.libsession.messaging.utilities.SodiumUtilities -import org.session.libsession.snode.SnodeAPI -import org.session.libsession.utilities.Address -import org.session.libsession.utilities.ExpirationUtil -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsignal.utilities.IdPrefix +import network.loki.messenger.databinding.ViewVisibleMessageContentBinding +import org.thoughtcrime.securesms.MediaPreviewActivity.getPreviewIntent import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity -import org.thoughtcrime.securesms.conversation.v2.utilities.ResendMessageUtilities import org.thoughtcrime.securesms.database.Storage -import org.thoughtcrime.securesms.database.model.MessageRecord -import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.util.DateUtils -import java.text.SimpleDateFormat -import java.util.* +import org.thoughtcrime.securesms.ui.AppTheme +import org.thoughtcrime.securesms.ui.Avatar +import org.thoughtcrime.securesms.ui.CarouselNextButton +import org.thoughtcrime.securesms.ui.CarouselPrevButton +import org.thoughtcrime.securesms.ui.Cell +import org.thoughtcrime.securesms.ui.CellNoMargin +import org.thoughtcrime.securesms.ui.CellWithPaddingAndMargin +import org.thoughtcrime.securesms.ui.Divider +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.HorizontalPagerIndicator +import org.thoughtcrime.securesms.ui.ItemButton +import org.thoughtcrime.securesms.ui.PreviewTheme +import org.thoughtcrime.securesms.ui.ThemeResPreviewParameterProvider +import org.thoughtcrime.securesms.ui.TitledText +import org.thoughtcrime.securesms.ui.blackAlpha40 +import org.thoughtcrime.securesms.ui.colorDestructive +import org.thoughtcrime.securesms.ui.destructiveButtonColors import javax.inject.Inject @AndroidEntryPoint -class MessageDetailActivity: PassphraseRequiredActionBarActivity() { - private lateinit var binding: ActivityMessageDetailBinding - var messageRecord: MessageRecord? = null +class MessageDetailActivity : PassphraseRequiredActionBarActivity() { @Inject lateinit var storage: Storage - // region Settings + private val viewModel: MessageDetailsViewModel by viewModels() + companion object { // Extras const val MESSAGE_TIMESTAMP = "message_timestamp" + + const val ON_REPLY = 1 + const val ON_RESEND = 2 + const val ON_DELETE = 3 } - // endregion override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { super.onCreate(savedInstanceState, ready) - binding = ActivityMessageDetailBinding.inflate(layoutInflater) - setContentView(binding.root) + title = resources.getString(R.string.conversation_context__menu_message_details) - val timestamp = intent.getLongExtra(MESSAGE_TIMESTAMP, -1L) - // We only show this screen for messages fail to send, - // so the author of the messages must be the current user. - val author = Address.fromSerialized(TextSecurePreferences.getLocalNumber(this)!!) - messageRecord = DatabaseComponent.get(this).mmsSmsDatabase().getMessageFor(timestamp, author) ?: run { - finish() - return - } - val threadId = messageRecord!!.threadId - val openGroup = storage.getOpenGroup(threadId) - val blindedKey = openGroup?.let { group -> - val userEdKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return@let null - val blindingEnabled = storage.getServerCapabilities(group.server).contains(OpenGroupApi.Capability.BLIND.name.lowercase()) - if (blindingEnabled) { - SodiumUtilities.blindedKeyPair(group.publicKey, userEdKeyPair)?.publicKey?.asBytes - ?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString - } else null - } - updateContent() - binding.resendButton.setOnClickListener { - ResendMessageUtilities.resend(this, messageRecord!!, blindedKey) - finish() + + viewModel.timestamp = intent.getLongExtra(MESSAGE_TIMESTAMP, -1L) + + ComposeView(this) + .apply { setContent { MessageDetailsScreen() } } + .let(::setContentView) + + lifecycleScope.launch { + viewModel.eventFlow.collect { + when (it) { + Event.Finish -> finish() + is Event.StartMediaPreview -> startActivity( + getPreviewIntent(this@MessageDetailActivity, it.args) + ) + } + } } } - fun updateContent() { - val dateLocale = Locale.getDefault() - val dateFormatter: SimpleDateFormat = DateUtils.getDetailedDateFormatter(this, dateLocale) - binding.sentTime.text = dateFormatter.format(Date(messageRecord!!.dateSent)) - - val errorMessage = DatabaseComponent.get(this).lokiMessageDatabase().getErrorMessage(messageRecord!!.getId()) - if (errorMessage != null) { - binding.errorMessage.text = errorMessage - binding.resendContainer.isVisible = true - binding.errorContainer.isVisible = true - } else { - binding.errorContainer.isVisible = false - binding.resendContainer.isVisible = false - } - - if (messageRecord!!.expiresIn <= 0 || messageRecord!!.expireStarted <= 0) { - binding.expiresContainer.visibility = View.GONE - } else { - binding.expiresContainer.visibility = View.VISIBLE - val elapsed = SnodeAPI.nowWithOffset - messageRecord!!.expireStarted - val remaining = messageRecord!!.expiresIn - elapsed - - val duration = ExpirationUtil.getExpirationDisplayValue(this, Math.max((remaining / 1000).toInt(), 1)) - binding.expiresIn.text = duration + @Composable + private fun MessageDetailsScreen() { + val state by viewModel.stateFlow.collectAsState() + AppTheme { + MessageDetails( + state = state, + onReply = { setResultAndFinish(ON_REPLY) }, + onResend = state.error?.let { { setResultAndFinish(ON_RESEND) } }, + onDelete = { setResultAndFinish(ON_DELETE) }, + onClickImage = { viewModel.onClickImage(it) }, + onAttachmentNeedsDownload = viewModel::onAttachmentNeedsDownload, + ) } } -} \ No newline at end of file + + private fun setResultAndFinish(code: Int) { + Bundle().apply { putLong(MESSAGE_TIMESTAMP, viewModel.timestamp) } + .let(Intent()::putExtras) + .let { setResult(code, it) } + + finish() + } +} + +@SuppressLint("ClickableViewAccessibility") +@Composable +fun MessageDetails( + state: MessageDetailsState, + onReply: () -> Unit = {}, + onResend: (() -> Unit)? = null, + onDelete: () -> Unit = {}, + onClickImage: (Int) -> Unit = {}, + onAttachmentNeedsDownload: (Long, Long) -> Unit = { _, _ -> } +) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + state.record?.let { message -> + AndroidView( + modifier = Modifier.padding(horizontal = 32.dp), + factory = { + ViewVisibleMessageContentBinding.inflate(LayoutInflater.from(it)).mainContainerConstraint.apply { + bind( + message, + thread = state.thread!!, + onAttachmentNeedsDownload = onAttachmentNeedsDownload, + suppressThumbnails = true + ) + + setOnTouchListener { _, event -> + if (event.actionMasked == ACTION_UP) onContentClick(event) + true + } + } + } + ) + } + Carousel(state.imageAttachments) { onClickImage(it) } + state.nonImageAttachmentFileDetails?.let { FileDetails(it) } + CellMetadata(state) + CellButtons( + onReply, + onResend, + onDelete, + ) + } +} + +@Composable +fun CellMetadata( + state: MessageDetailsState, +) { + state.apply { + if (listOfNotNull(sent, received, error, senderInfo).isEmpty()) return + CellWithPaddingAndMargin { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + TitledText(sent) + TitledText(received) + TitledErrorText(error) + senderInfo?.let { + TitledView(state.fromTitle) { + Row { + sender?.let { Avatar(it) } + TitledMonospaceText(it) + } + } + } + } + } + } +} + +@Composable +fun CellButtons( + onReply: () -> Unit = {}, + onResend: (() -> Unit)? = null, + onDelete: () -> Unit = {}, +) { + Cell { + Column { + ItemButton( + stringResource(R.string.reply), + R.drawable.ic_message_details__reply, + onClick = onReply + ) + Divider() + onResend?.let { + ItemButton( + stringResource(R.string.resend), + R.drawable.ic_message_details__refresh, + onClick = it + ) + Divider() + } + ItemButton( + stringResource(R.string.delete), + R.drawable.ic_message_details__trash, + colors = destructiveButtonColors(), + onClick = onDelete + ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun Carousel(attachments: List, onClick: (Int) -> Unit) { + if (attachments.isEmpty()) return + + val pagerState = rememberPagerState { attachments.size } + + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Row { + CarouselPrevButton(pagerState) + Box(modifier = Modifier.weight(1f)) { + CellCarousel(pagerState, attachments, onClick) + HorizontalPagerIndicator(pagerState) + ExpandButton( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(8.dp) + ) { onClick(pagerState.currentPage) } + } + CarouselNextButton(pagerState) + } + attachments.getOrNull(pagerState.currentPage)?.fileDetails?.let { FileDetails(it) } + } +} + +@OptIn( + ExperimentalFoundationApi::class, + ExperimentalGlideComposeApi::class +) +@Composable +private fun CellCarousel( + pagerState: PagerState, + attachments: List, + onClick: (Int) -> Unit +) { + CellNoMargin { + HorizontalPager(state = pagerState) { i -> + GlideImage( + contentScale = ContentScale.Crop, + modifier = Modifier + .aspectRatio(1f) + .clickable { onClick(i) }, + model = attachments[i].uri, + contentDescription = attachments[i].fileName ?: stringResource(id = R.string.image) + ) + } + } +} + +@Composable +fun ExpandButton(modifier: Modifier = Modifier, onClick: () -> Unit) { + Surface( + shape = CircleShape, + color = blackAlpha40, + modifier = modifier, + contentColor = Color.White, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_expand), + contentDescription = stringResource(id = R.string.expand), + modifier = Modifier.clickable { onClick() }, + ) + } +} + + +@Preview +@Composable +fun PreviewMessageDetails( + @PreviewParameter(ThemeResPreviewParameterProvider::class) themeResId: Int +) { + PreviewTheme(themeResId) { + MessageDetails( + state = MessageDetailsState( + nonImageAttachmentFileDetails = listOf( + TitledText(R.string.message_details_header__file_id, "Screen Shot 2023-07-06 at 11.35.50 am.png"), + TitledText(R.string.message_details_header__file_type, "image/png"), + TitledText(R.string.message_details_header__file_size, "195.6kB"), + TitledText(R.string.message_details_header__resolution, "342x312"), + ), + sent = TitledText(R.string.message_details_header__sent, "6:12 AM Tue, 09/08/2022"), + received = TitledText(R.string.message_details_header__received, "6:12 AM Tue, 09/08/2022"), + error = TitledText(R.string.message_details_header__error, "Message failed to send"), + senderInfo = TitledText("Connor", "d4f1g54sdf5g1d5f4g65ds4564df65f4g65d54"), + ) + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun FileDetails(fileDetails: List) { + if (fileDetails.isEmpty()) return + + CellWithPaddingAndMargin(padding = 0.dp) { + FlowRow( + modifier = Modifier.padding(vertical = 24.dp, horizontal = 12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + fileDetails.forEach { + BoxWithConstraints { + TitledText( + it, + modifier = Modifier + .widthIn(min = maxWidth.div(2)) + .padding(horizontal = 12.dp) + .width(IntrinsicSize.Max) + ) + } + } + } + } +} + +@Composable +fun TitledErrorText(titledText: TitledText?) { + TitledText( + titledText, + valueStyle = LocalTextStyle.current.copy(color = colorDestructive) + ) +} + +@Composable +fun TitledMonospaceText(titledText: TitledText?) { + TitledText( + titledText, + valueStyle = LocalTextStyle.current.copy(fontFamily = FontFamily.Monospace) + ) +} + +@Composable +fun TitledText( + titledText: TitledText?, + modifier: Modifier = Modifier, + valueStyle: TextStyle = LocalTextStyle.current, +) { + titledText?.apply { + TitledView(title, modifier) { + Text(text, style = valueStyle, modifier = Modifier.fillMaxWidth()) + } + } +} + +@Composable +fun TitledView(title: GetString, modifier: Modifier = Modifier, content: @Composable () -> Unit) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { + Title(title) + content() + } +} + +@Composable +fun Title(title: GetString) { + Text(title.string(), fontWeight = FontWeight.Bold) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt new file mode 100644 index 000000000..a73fe4113 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt @@ -0,0 +1,159 @@ +package org.thoughtcrime.securesms.conversation.v2 + +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import network.loki.messenger.R +import org.session.libsession.messaging.jobs.AttachmentDownloadJob +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.libsession.utilities.Util +import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.MediaPreviewArgs +import org.thoughtcrime.securesms.database.AttachmentDatabase +import org.thoughtcrime.securesms.database.LokiMessageDatabase +import org.thoughtcrime.securesms.database.MmsSmsDatabase +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.mms.ImageSlide +import org.thoughtcrime.securesms.mms.Slide +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.TitledText +import java.util.Date +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +@HiltViewModel +class MessageDetailsViewModel @Inject constructor( + private val attachmentDb: AttachmentDatabase, + private val lokiMessageDatabase: LokiMessageDatabase, + private val mmsSmsDatabase: MmsSmsDatabase, + private val threadDb: ThreadDatabase, +) : ViewModel() { + + private val state = MutableStateFlow(MessageDetailsState()) + val stateFlow = state.asStateFlow() + + private val event = Channel() + val eventFlow = event.receiveAsFlow() + + var timestamp: Long = 0L + set(value) { + field = value + val record = mmsSmsDatabase.getMessageForTimestamp(timestamp) + + if (record == null) { + viewModelScope.launch { event.send(Event.Finish) } + return + } + + val mmsRecord = record as? MmsMessageRecord + + state.value = record.run { + val slides = mmsRecord?.slideDeck?.slides ?: emptyList() + + MessageDetailsState( + attachments = slides.map(::Attachment), + record = record, + sent = dateSent.let(::Date).toString().let { TitledText(R.string.message_details_header__sent, it) }, + received = dateReceived.let(::Date).toString().let { TitledText(R.string.message_details_header__received, it) }, + error = lokiMessageDatabase.getErrorMessage(id)?.let { TitledText(R.string.message_details_header__error, it) }, + senderInfo = individualRecipient.run { name?.let { TitledText(it, address.serialize()) } }, + sender = individualRecipient, + thread = threadDb.getRecipientForThreadId(threadId)!!, + ) + } + } + + private val Slide.details: List + get() = listOfNotNull( + fileName.orNull()?.let { TitledText(R.string.message_details_header__file_id, it) }, + TitledText(R.string.message_details_header__file_type, asAttachment().contentType), + TitledText(R.string.message_details_header__file_size, Util.getPrettyFileSize(fileSize)), + takeIf { it is ImageSlide } + ?.let(Slide::asAttachment) + ?.run { "${width}x$height" } + ?.let { TitledText(R.string.message_details_header__resolution, it) }, + attachmentDb.duration(this)?.let { TitledText(R.string.message_details_header__duration, it) }, + ) + + private fun AttachmentDatabase.duration(slide: Slide): String? = + slide.takeIf { it.hasAudio() } + ?.run { asAttachment() as? DatabaseAttachment } + ?.run { getAttachmentAudioExtras(attachmentId)?.durationMs } + ?.takeIf { it > 0 } + ?.let { + String.format( + "%01d:%02d", + TimeUnit.MILLISECONDS.toMinutes(it), + TimeUnit.MILLISECONDS.toSeconds(it) % 60 + ) + } + + fun Attachment(slide: Slide): Attachment = + Attachment(slide.details, slide.fileName.orNull(), slide.uri, slide is ImageSlide) + + fun onClickImage(index: Int) { + val state = state.value ?: return + val mmsRecord = state.mmsRecord ?: return + val slide = mmsRecord.slideDeck.slides[index] ?: return + // only open to downloaded images + if (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED) { + // Restart download here (on IO thread) + (slide.asAttachment() as? DatabaseAttachment)?.let { attachment -> + onAttachmentNeedsDownload(attachment.attachmentId.rowId, state.mmsRecord.getId()) + } + } + + if (slide.isInProgress) return + + viewModelScope.launch { + MediaPreviewArgs(slide, state.mmsRecord, state.thread) + .let(Event::StartMediaPreview) + .let { event.send(it) } + } + } + + fun onAttachmentNeedsDownload(attachmentId: Long, mmsId: Long) { + viewModelScope.launch(Dispatchers.IO) { + JobQueue.shared.add(AttachmentDownloadJob(attachmentId, mmsId)) + } + } +} + +data class MessageDetailsState( + val attachments: List = emptyList(), + val imageAttachments: List = attachments.filter { it.hasImage }, + val nonImageAttachmentFileDetails: List? = attachments.firstOrNull { !it.hasImage }?.fileDetails, + val record: MessageRecord? = null, + val mmsRecord: MmsMessageRecord? = record as? MmsMessageRecord, + val sent: TitledText? = null, + val received: TitledText? = null, + val error: TitledText? = null, + val senderInfo: TitledText? = null, + val sender: Recipient? = null, + val thread: Recipient? = null, +) { + val fromTitle = GetString(R.string.message_details_header__from) +} + +data class Attachment( + val fileDetails: List, + val fileName: String?, + val uri: Uri?, + val hasImage: Boolean +) + +sealed class Event { + object Finish: Event() + data class StartMediaPreview(val args: MediaPreviewArgs): Event() +} \ 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 index 834b77ecc..d54426391 100644 --- 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 @@ -28,11 +28,10 @@ class MentionCandidateView : LinearLayout { private fun update() = with(binding) { mentionCandidateNameTextView.text = mentionCandidate.displayName - profilePictureView.root.publicKey = mentionCandidate.publicKey - profilePictureView.root.displayName = mentionCandidate.displayName - profilePictureView.root.additionalPublicKey = null - profilePictureView.root.glide = glide!! - profilePictureView.root.update() + 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 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 a21ba1b50..2d8f74596 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 @@ -28,11 +28,10 @@ class MentionCandidateView : RelativeLayout { private fun update() = with(binding) { mentionCandidateNameTextView.text = candidate.displayName - profilePictureView.root.publicKey = candidate.publicKey - profilePictureView.root.displayName = candidate.displayName - profilePictureView.root.additionalPublicKey = null - profilePictureView.root.glide = glide!! - profilePictureView.root.update() + 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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt index f86920f90..3746aa52e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt @@ -67,7 +67,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p menu.findItem(R.id.menu_context_copy_public_key).isVisible = (thread.isGroupRecipient && !thread.isOpenGroupRecipient && selectedItems.size == 1 && firstMessage.individualRecipient.address.toString() != userPublicKey) // Message detail - menu.findItem(R.id.menu_message_details).isVisible = (selectedItems.size == 1 && firstMessage.isOutgoing) + menu.findItem(R.id.menu_message_details).isVisible = selectedItems.size == 1 // Resend menu.findItem(R.id.menu_context_resend).isVisible = (selectedItems.size == 1 && firstMessage.isFailed) // Resync diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt index e7214cfa7..c812d0f73 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt @@ -36,6 +36,7 @@ import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities.getInt import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.SmsMessageRecord +import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.util.GlowViewUtilities import org.thoughtcrime.securesms.util.SearchUtil @@ -45,7 +46,6 @@ import kotlin.math.roundToInt class VisibleMessageContentView : ConstraintLayout { private val binding: ViewVisibleMessageContentBinding by lazy { ViewVisibleMessageContentBinding.bind(this) } - var onContentClick: MutableList<((event: MotionEvent) -> Unit)> = mutableListOf() var onContentDoubleTap: (() -> Unit)? = null var delegate: VisibleMessageViewDelegate? = null var indexInAdapter: Int = -1 @@ -59,13 +59,14 @@ class VisibleMessageContentView : ConstraintLayout { // region Updating fun bind( message: MessageRecord, - isStartOfMessageCluster: Boolean, - isEndOfMessageCluster: Boolean, - glide: GlideRequests, + isStartOfMessageCluster: Boolean = true, + isEndOfMessageCluster: Boolean = true, + glide: GlideRequests = GlideApp.with(this), thread: Recipient, - searchQuery: String?, - contactIsTrusted: Boolean, - onAttachmentNeedsDownload: (Long, Long) -> Unit + searchQuery: String? = null, + contactIsTrusted: Boolean = true, + onAttachmentNeedsDownload: (Long, Long) -> Unit, + suppressThumbnails: Boolean = false ) { // Background val color = if (message.isOutgoing) context.getAccentColor() @@ -184,7 +185,7 @@ class VisibleMessageContentView : ConstraintLayout { onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) } } } - message is MmsMessageRecord && message.slideDeck.asAttachments().isNotEmpty() -> { + message is MmsMessageRecord && !suppressThumbnails && message.slideDeck.asAttachments().isNotEmpty() -> { /* * Images / Video attachment */ @@ -237,6 +238,12 @@ class VisibleMessageContentView : ConstraintLayout { binding.contentParent.layoutParams = layoutParams } + private val onContentClick: MutableList<((event: MotionEvent) -> Unit)> = mutableListOf() + + fun onContentClick(event: MotionEvent) { + onContentClick.forEach { clickHandler -> clickHandler.invoke(event) } + } + private fun ViewVisibleMessageContentBinding.barrierViewsGone(): Boolean = listOf(albumThumbnailView.root, linkPreviewView.root, voiceMessageView.root, quoteView.root).none { it.isVisible } 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 90cb58cdc..9538148fd 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 @@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.conversation.v2.messages import android.content.Context import android.content.Intent -import android.content.res.Resources import android.graphics.Canvas import android.graphics.Rect import android.graphics.drawable.ColorDrawable @@ -48,6 +47,7 @@ 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 org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.disableClipping @@ -72,7 +72,6 @@ class VisibleMessageView : LinearLayout { @Inject lateinit var mmsDb: MmsDatabase private val binding by lazy { ViewVisibleMessageBinding.bind(this) } - private val screenWidth = Resources.getSystem().displayMetrics.widthPixels private val swipeToReplyIcon = ContextCompat.getDrawable(context, R.drawable.ic_baseline_reply_24)!!.mutate() private val swipeToReplyIconRect = Rect() private var dx = 0.0f @@ -124,14 +123,14 @@ class VisibleMessageView : LinearLayout { // region Updating fun bind( message: MessageRecord, - previous: MessageRecord?, - next: MessageRecord?, - glide: GlideRequests, - searchQuery: String?, - contact: Contact?, + previous: MessageRecord? = null, + next: MessageRecord? = null, + glide: GlideRequests = GlideApp.with(this), + searchQuery: String? = null, + contact: Contact? = null, senderSessionID: String, lastSeen: Long, - delegate: VisibleMessageViewDelegate?, + delegate: VisibleMessageViewDelegate? = null, onAttachmentNeedsDownload: (Long, Long) -> Unit ) { val threadID = message.threadId @@ -142,7 +141,7 @@ class VisibleMessageView : LinearLayout { // Show profile picture and sender name if this is a group thread AND // the message is incoming binding.moderatorIconImageView.isVisible = false - binding.profilePictureView.root.visibility = when { + binding.profilePictureView.visibility = when { thread.isGroupRecipient && !message.isOutgoing && isEndOfMessageCluster -> View.VISIBLE thread.isGroupRecipient -> View.INVISIBLE else -> View.GONE @@ -151,22 +150,21 @@ class VisibleMessageView : LinearLayout { val bottomMargin = if (isEndOfMessageCluster) resources.getDimensionPixelSize(R.dimen.small_spacing) else ViewUtil.dpToPx(context,2) - if (binding.profilePictureView.root.visibility == View.GONE) { + if (binding.profilePictureView.visibility == View.GONE) { val expirationParams = binding.messageInnerContainer.layoutParams as MarginLayoutParams expirationParams.bottomMargin = bottomMargin binding.messageInnerContainer.layoutParams = expirationParams } else { - val avatarLayoutParams = binding.profilePictureView.root.layoutParams as MarginLayoutParams + val avatarLayoutParams = binding.profilePictureView.layoutParams as MarginLayoutParams avatarLayoutParams.bottomMargin = bottomMargin - binding.profilePictureView.root.layoutParams = avatarLayoutParams + binding.profilePictureView.layoutParams = avatarLayoutParams } if (isGroupThread && !message.isOutgoing) { if (isEndOfMessageCluster) { - binding.profilePictureView.root.publicKey = senderSessionID - binding.profilePictureView.root.glide = glide - binding.profilePictureView.root.update(message.individualRecipient) - binding.profilePictureView.root.setOnClickListener { + binding.profilePictureView.publicKey = senderSessionID + binding.profilePictureView.update(message.individualRecipient) + binding.profilePictureView.setOnClickListener { if (thread.isOpenGroupRecipient) { val openGroup = lokiThreadDb.getOpenGroupChat(threadID) if (IdPrefix.fromValue(senderSessionID) == IdPrefix.BLINDED && openGroup?.canWrite == true) { @@ -398,7 +396,7 @@ class VisibleMessageView : LinearLayout { val spacing = context.resources.getDimensionPixelSize(R.dimen.small_spacing) val iconSize = toPx(24, context.resources) val left = binding.messageInnerContainer.left + binding.messageContentView.root.right + spacing - val top = height - (binding.messageInnerContainer.height / 2) - binding.profilePictureView.root.marginBottom - (iconSize / 2) + val top = height - (binding.messageInnerContainer.height / 2) - binding.profilePictureView.marginBottom - (iconSize / 2) val right = left + iconSize val bottom = top + iconSize swipeToReplyIconRect.left = left @@ -418,7 +416,7 @@ class VisibleMessageView : LinearLayout { } fun recycle() { - binding.profilePictureView.root.recycle() + binding.profilePictureView.recycle() binding.messageContentView.root.recycle() } @@ -519,7 +517,7 @@ class VisibleMessageView : LinearLayout { } fun onContentClick(event: MotionEvent) { - binding.messageContentView.root.onContentClick.iterator().forEach { clickHandler -> clickHandler.invoke(event) } + binding.messageContentView.root.onContentClick(event) } private fun onPress(event: MotionEvent) { 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 8d5acb244..31b281c6d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt @@ -65,7 +65,6 @@ class ConversationView : LinearLayout { } else { ContextCompat.getDrawable(context, R.drawable.conversation_view_background) } - binding.profilePictureView.root.glide = glide val unreadCount = thread.unreadCount if (thread.recipient.isBlocked) { binding.accentView.setBackgroundResource(R.color.destructive) @@ -125,11 +124,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.root.update(thread.recipient) + binding.profilePictureView.update(thread.recipient) } fun recycle() { - binding.profilePictureView.root.recycle() + binding.profilePictureView.recycle() } private fun getUserDisplayName(recipient: Recipient): String? { 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 e78b4388f..72bd098f4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -168,8 +168,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), // Set up Glide glide = GlideApp.with(this) // Set up toolbar buttons - binding.profileButton.root.glide = glide - binding.profileButton.root.setOnClickListener { openSettings() } + binding.profileButton.setOnClickListener { openSettings() } binding.searchViewContainer.setOnClickListener { binding.globalSearchInputLayout.requestFocus() } @@ -364,8 +363,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.root.recycle() // clear cached image before update tje profilePictureView - binding.profileButton.root.update() + binding.profileButton.recycle() // clear cached image before update tje profilePictureView + binding.profileButton.update() if (textSecurePreferences.getHasViewedSeed()) { binding.seedReminderView.isVisible = false } @@ -440,10 +439,10 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } private fun updateProfileButton() { - binding.profileButton.root.publicKey = publicKey - binding.profileButton.root.displayName = textSecurePreferences.getProfileName() - binding.profileButton.root.recycle() - binding.profileButton.root.update() + 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 b37bd886c..ad8f2d042 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt @@ -53,10 +53,9 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() { val recipient = Recipient.from(requireContext(), Address.fromSerialized(publicKey), false) val threadRecipient = threadDb.getRecipientForThreadId(threadID) ?: return dismiss() with(binding) { - profilePictureView.root.publicKey = publicKey - profilePictureView.root.glide = GlideApp.with(this@UserDetailsBottomSheet) - profilePictureView.root.isLarge = true - profilePictureView.root.update(recipient) + profilePictureView.publicKey = publicKey + profilePictureView.isLarge = true + 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 fab8bca99..7cf953be2 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 @@ -83,22 +83,20 @@ class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerVi override fun onViewRecycled(holder: RecyclerView.ViewHolder) { if (holder is ContentView) { - holder.binding.searchResultProfilePicture.root.recycle() + holder.binding.searchResultProfilePicture.recycle() } } class ContentView(view: View, private val modelCallback: (Model) -> Unit) : RecyclerView.ViewHolder(view) { - val binding = ViewGlobalSearchResultBinding.bind(view).apply { - searchResultProfilePicture.root.glide = GlideApp.with(root) - } + val binding = ViewGlobalSearchResultBinding.bind(view) fun bindPayload(newQuery: String, model: Model) { bindQuery(newQuery, model) } fun bind(query: String, model: Model) { - binding.searchResultProfilePicture.root.recycle() + 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 54f9290ff..5371bb71c 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 @@ -87,12 +87,12 @@ private fun getHighlight(query: String?, toSearch: String): Spannable? { } fun ContentView.bindModel(query: String?, model: GroupConversation) { - binding.searchResultProfilePicture.root.isVisible = true + binding.searchResultProfilePicture.isVisible = true binding.searchResultSavedMessages.isVisible = false 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.root.update(threadRecipient) + binding.searchResultProfilePicture.update(threadRecipient) val nameString = model.groupRecord.title binding.searchResultTitle.text = getHighlight(query, nameString) @@ -108,14 +108,14 @@ fun ContentView.bindModel(query: String?, model: GroupConversation) { } fun ContentView.bindModel(query: String?, model: ContactModel) { - binding.searchResultProfilePicture.root.isVisible = true + binding.searchResultProfilePicture.isVisible = true binding.searchResultSavedMessages.isVisible = false binding.searchResultSubtitle.isVisible = false binding.searchResultTimestamp.isVisible = false binding.searchResultSubtitle.text = null val recipient = Recipient.from(binding.root.context, Address.fromSerialized(model.contact.sessionID), false) - binding.searchResultProfilePicture.root.update(recipient) + binding.searchResultProfilePicture.update(recipient) val nameString = model.contact.getSearchName() binding.searchResultTitle.text = getHighlight(query, nameString) } @@ -124,12 +124,12 @@ fun ContentView.bindModel(model: SavedMessages) { binding.searchResultSubtitle.isVisible = false binding.searchResultTimestamp.isVisible = false binding.searchResultTitle.setText(R.string.note_to_self) - binding.searchResultProfilePicture.root.isVisible = false + binding.searchResultProfilePicture.isVisible = false binding.searchResultSavedMessages.isVisible = true } fun ContentView.bindModel(query: String?, model: Message) { - binding.searchResultProfilePicture.root.isVisible = true + binding.searchResultProfilePicture.isVisible = true binding.searchResultSavedMessages.isVisible = false binding.searchResultTimestamp.isVisible = true // val hasUnreads = model.unread > 0 @@ -138,7 +138,7 @@ fun ContentView.bindModel(query: String?, model: Message) { // binding.unreadCountTextView.text = model.unread.toString() // } binding.searchResultTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(binding.root.context, Locale.getDefault(), model.messageResult.sentTimestampMs) - binding.searchResultProfilePicture.root.update(model.messageResult.conversationRecipient) + binding.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/messagerequests/MessageRequestView.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt index 9a8d06129..af3d269c6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt @@ -34,7 +34,6 @@ class MessageRequestView : LinearLayout { // region Updating fun bind(thread: ThreadRecord, glide: GlideRequests) { this.thread = thread - binding.profilePictureView.root.glide = glide val senderDisplayName = getUserDisplayName(thread.recipient) ?: thread.recipient.address.toString() binding.displayNameTextView.text = senderDisplayName @@ -44,12 +43,12 @@ class MessageRequestView : LinearLayout { binding.snippetTextView.text = snippet post { - binding.profilePictureView.root.update(thread.recipient) + binding.profilePictureView.update(thread.recipient) } } fun recycle() { - binding.profilePictureView.root.recycle() + binding.profilePictureView.recycle() } private fun getUserDisplayName(recipient: Recipient): String? { 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 a75d53c4f..e0b92bdbe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsAdapter.kt @@ -38,7 +38,7 @@ class BlockedContactsAdapter(val viewModel: BlockedContactsViewModel) : ListAdap override fun onViewRecycled(holder: ViewHolder) { super.onViewRecycled(holder) - holder.binding.profilePictureView.root.recycle() + holder.binding.profilePictureView.recycle() } class ViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) { @@ -48,8 +48,7 @@ class BlockedContactsAdapter(val viewModel: BlockedContactsViewModel) : ListAdap fun bind(selectable: SelectableRecipient, toggle: (SelectableRecipient) -> Unit) { binding.recipientName.text = selectable.item.name - with (binding.profilePictureView.root) { - glide = this@ViewHolder.glide + with (binding.profilePictureView) { update(selectable.item) } binding.root.setOnClickListener { toggle(selectable) } 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 f3cdc269c..5f2485576 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt @@ -88,10 +88,8 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { val displayName = getDisplayName() glide = GlideApp.with(this) with(binding) { - setupProfilePictureView(profilePictureView.root) - profilePictureView.root.setOnClickListener { - showEditProfilePictureUI() - } + setupProfilePictureView(profilePictureView) + profilePictureView.setOnClickListener { showEditProfilePictureUI() } ctnGroupNameSection.setOnClickListener { startActionMode(DisplayNameEditActionModeCallback()) } btnGroupNameDisplay.text = displayName publicKeyTextView.text = hexEncodedPublicKey @@ -116,7 +114,6 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { TextSecurePreferences.getProfileName(this) ?: truncateIdForDisplay(hexEncodedPublicKey) private fun setupProfilePictureView(view: ProfilePictureView) { - view.glide = glide view.apply { publicKey = hexEncodedPublicKey displayName = getDisplayName() @@ -255,8 +252,8 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { binding.btnGroupNameDisplay.text = displayName } if (isUpdatingProfilePicture) { - binding.profilePictureView.root.recycle() // Clear the cached image before updating - binding.profilePictureView.root.update() + binding.profilePictureView.recycle() // Clear the cached image before updating + binding.profilePictureView.update() } binding.loader.isVisible = false } 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 f1cbea16c..1c05e68bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java @@ -144,7 +144,6 @@ final class ReactionRecipientsAdapter extends RecyclerView.Adapter Unit +) { + TextButton( + modifier = Modifier + .fillMaxWidth() + .height(60.dp), + colors = colors, + onClick = onClick, + shape = RectangleShape, + ) { + Box(modifier = Modifier + .width(80.dp) + .fillMaxHeight()) { + Icon( + painter = painterResource(id = icon), + contentDescription = contentDescription, + modifier = Modifier.align(Alignment.Center) + ) + } + Text(text, modifier = Modifier.fillMaxWidth()) + } +} + +@Composable +fun Cell(content: @Composable () -> Unit) { + CellWithPaddingAndMargin(padding = 0.dp) { content() } +} +@Composable +fun CellNoMargin(content: @Composable () -> Unit) { + CellWithPaddingAndMargin(padding = 0.dp, margin = 0.dp) { content() } +} + +@Composable +fun CellWithPaddingAndMargin( + padding: Dp = 24.dp, + margin: Dp = 32.dp, + content: @Composable () -> Unit +) { + Card( + backgroundColor = MaterialTheme.colors.cellColor, + shape = RoundedCornerShape(16.dp), + elevation = 0.dp, + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth() + .padding(horizontal = margin), + ) { + Box(Modifier.padding(padding)) { content() } + } +} + +private val Colors.cellColor: Color + @Composable + get() = LocalExtraColors.current.settingsBackground + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun BoxScope.HorizontalPagerIndicator(pagerState: PagerState) { + if (pagerState.pageCount >= 2) Card( + shape = RoundedCornerShape(50.dp), + backgroundColor = Color.Black.copy(alpha = 0.4f), + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(8.dp) + ) { + Box(modifier = Modifier.padding(8.dp)) { + HorizontalPagerIndicator( + pagerState = pagerState, + pageCount = pagerState.pageCount, + activeColor = Color.White, + inactiveColor = classicDarkColors[5]) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun RowScope.CarouselPrevButton(pagerState: PagerState) { + CarouselButton(pagerState, pagerState.canScrollBackward, R.drawable.ic_prev, -1) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun RowScope.CarouselNextButton(pagerState: PagerState) { + CarouselButton(pagerState, pagerState.canScrollForward, R.drawable.ic_next, 1) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun RowScope.CarouselButton( + pagerState: PagerState, + enabled: Boolean, + @DrawableRes id: Int, + delta: Int +) { + if (pagerState.pageCount <= 1) Spacer(modifier = Modifier.width(32.dp)) + else { + val animationScope = rememberCoroutineScope() + IconButton( + modifier = Modifier + .width(40.dp) + .align(Alignment.CenterVertically), + enabled = enabled, + onClick = { animationScope.launch { pagerState.animateScrollToPage(pagerState.currentPage + delta) } }) { + Icon( + painter = painterResource(id = id), + contentDescription = "", + ) + } + } +} + +@Composable +fun Divider() { + androidx.compose.material.Divider( + modifier = Modifier.padding(horizontal = 16.dp), + ) +} + +@Composable +fun RowScope.Avatar(recipient: Recipient) { + Box( + modifier = Modifier + .width(60.dp) + .align(Alignment.CenterVertically) + ) { + AndroidView( + factory = { + ProfilePictureView(it).apply { update(recipient) } + }, + modifier = Modifier + .width(46.dp) + .height(46.dp) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Data.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Data.kt new file mode 100644 index 000000000..44ff4a42d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Data.kt @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.ui + +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource + +/** + * Compatibility class to allow ViewModels to use strings and string resources interchangeably. + */ +sealed class GetString { + @Composable + abstract fun string(): String + data class FromString(val string: String): GetString() { + @Composable + override fun string(): String = string + } + data class FromResId(@StringRes val resId: Int): GetString() { + @Composable + override fun string(): String = stringResource(resId) + + } +} + +fun GetString(@StringRes resId: Int) = GetString.FromResId(resId) +fun GetString(string: String) = GetString.FromString(string) + + +/** + * Represents some text with an associated title. + */ +data class TitledText(val title: GetString, val text: String) { + constructor(title: String, text: String): this(GetString(title), text) + constructor(@StringRes title: Int, text: String): this(GetString(title), text) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt new file mode 100644 index 000000000..64bbd21d8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt @@ -0,0 +1,76 @@ +package org.thoughtcrime.securesms.ui + +import android.content.Context +import androidx.annotation.AttrRes +import androidx.appcompat.view.ContextThemeWrapper +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.google.accompanist.themeadapter.appcompat.AppCompatTheme +import com.google.android.material.color.MaterialColors +import network.loki.messenger.R + +val LocalExtraColors = staticCompositionLocalOf { error("No Custom Attribute value provided") } + + +data class ExtraColors( + val settingsBackground: Color, +) + +/** + * Converts current Theme to Compose Theme. + */ +@Composable +fun AppTheme( + content: @Composable () -> Unit +) { + val extraColors = LocalContext.current.run { + ExtraColors( + settingsBackground = getColorFromTheme(R.attr.colorSettingsBackground), + ) + } + + CompositionLocalProvider(LocalExtraColors provides extraColors) { + AppCompatTheme { + content() + } + } +} + +fun Context.getColorFromTheme(@AttrRes attr: Int, defaultValue: Int = 0x0): Color = + MaterialColors.getColor(this, attr, defaultValue).let(::Color) + +/** + * Set the theme and a background for Compose Previews. + */ +@Composable +fun PreviewTheme( + themeResId: Int, + content: @Composable () -> Unit +) { + CompositionLocalProvider( + LocalContext provides ContextThemeWrapper(LocalContext.current, themeResId) + ) { + AppTheme { + Box(modifier = Modifier.background(color = MaterialTheme.colors.background)) { + content() + } + } + } +} + +class ThemeResPreviewParameterProvider : PreviewParameterProvider { + override val values = sequenceOf( + R.style.Classic_Dark, + R.style.Classic_Light, + R.style.Ocean_Dark, + R.style.Ocean_Light, + ) +} diff --git a/app/src/main/res/drawable/ic_expand.xml b/app/src/main/res/drawable/ic_expand.xml new file mode 100644 index 000000000..3b2b816a4 --- /dev/null +++ b/app/src/main/res/drawable/ic_expand.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_message_details__refresh.xml b/app/src/main/res/drawable/ic_message_details__refresh.xml new file mode 100644 index 000000000..2aabe6fbe --- /dev/null +++ b/app/src/main/res/drawable/ic_message_details__refresh.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_message_details__reply.xml b/app/src/main/res/drawable/ic_message_details__reply.xml new file mode 100644 index 000000000..c9e1591a5 --- /dev/null +++ b/app/src/main/res/drawable/ic_message_details__reply.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_message_details__trash.xml b/app/src/main/res/drawable/ic_message_details__trash.xml new file mode 100644 index 000000000..85d421695 --- /dev/null +++ b/app/src/main/res/drawable/ic_message_details__trash.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_next.xml b/app/src/main/res/drawable/ic_next.xml new file mode 100644 index 000000000..1e72d86cb --- /dev/null +++ b/app/src/main/res/drawable/ic_next.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_prev.xml b/app/src/main/res/drawable/ic_prev.xml new file mode 100644 index 000000000..f72026167 --- /dev/null +++ b/app/src/main/res/drawable/ic_prev.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/layout/activity_conversation_v2_action_bar.xml b/app/src/main/res/layout/activity_conversation_v2_action_bar.xml index fe726f7cf..7322bb7f0 100644 --- a/app/src/main/res/layout/activity_conversation_v2_action_bar.xml +++ b/app/src/main/res/layout/activity_conversation_v2_action_bar.xml @@ -8,7 +8,7 @@ android:orientation="horizontal" android:gravity="center_vertical"> - diff --git a/app/src/main/res/layout/activity_home.xml b/app/src/main/res/layout/activity_home.xml index 64059eace..bf308612c 100644 --- a/app/src/main/res/layout/activity_home.xml +++ b/app/src/main/res/layout/activity_home.xml @@ -27,9 +27,8 @@ android:layout_marginLeft="20dp" android:layout_marginRight="20dp"> - - - - - - - - diff --git a/app/src/main/res/layout/view_conversation.xml b/app/src/main/res/layout/view_conversation.xml index d66a1722b..12a7a8ac8 100644 --- a/app/src/main/res/layout/view_conversation.xml +++ b/app/src/main/res/layout/view_conversation.xml @@ -14,7 +14,7 @@ android:layout_height="match_parent" android:background="?colorAccent" /> - - - - - - + - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/view_user.xml b/app/src/main/res/layout/view_user.xml index a9330ae64..177b3ff6c 100644 --- a/app/src/main/res/layout/view_user.xml +++ b/app/src/main/res/layout/view_user.xml @@ -15,7 +15,7 @@ android:gravity="center_vertical" android:paddingHorizontal="@dimen/medium_spacing"> - - Yes No Delete + Resend + Reply Ban Please wait… Save + Image Note to Self Version %s + Expand Create session ID @@ -516,6 +520,12 @@ To: From: With: + File Id: + File Type: + File Size: + Resolution: + Duration: + Create passphrase Select contacts diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 2d4ee8b7f..428d37c5e 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -342,6 +342,8 @@ ?colorAccent @color/classic_dark_3 + false + #1B1B1B #E5E5E8 @@ -424,6 +426,7 @@ ?colorPrimary true true + true true ?colorPrimary @@ -487,7 +490,6 @@ ?android:textColorPrimary @color/ocean_dark_5 ?colorPrimary - @color/default_background_start @color/navigation_bar ?colorPrimary ?colorPrimaryDark @@ -507,6 +509,8 @@ ?colorAccent @color/ocean_dark_4 + false + @color/ocean_dark_3 @color/ocean_dark_7 @@ -570,7 +574,6 @@ @color/ocean_light_6 @color/ocean_light_navigation_bar ?colorPrimary - @color/default_background_start @color/ocean_light_7 @color/ocean_light_6 @color/ocean_light_5 @@ -594,6 +597,7 @@ ?colorPrimary true true + true true ?colorPrimary diff --git a/app/src/sharedTest/java/org/thoughtcrime/securesms/LiveDataTestUtil.kt b/app/src/sharedTest/java/org/thoughtcrime/securesms/LiveDataTestUtil.kt index 03155a910..a8cff6341 100644 --- a/app/src/sharedTest/java/org/thoughtcrime/securesms/LiveDataTestUtil.kt +++ b/app/src/sharedTest/java/org/thoughtcrime/securesms/LiveDataTestUtil.kt @@ -22,7 +22,7 @@ fun LiveData.getOrAwaitValue( var data: T? = null val latch = CountDownLatch(1) val observer = object : Observer { - override fun onChanged(o: T?) { + override fun onChanged(o: T) { data = o latch.countDown() this@getOrAwaitValue.removeObserver(this) diff --git a/build.gradle b/build.gradle index 5d675445c..7d9857aaf 100644 --- a/build.gradle +++ b/build.gradle @@ -8,9 +8,14 @@ buildscript { classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" classpath "com.google.gms:google-services:$googleServicesVersion" classpath files('libs/gradle-witness.jar') + classpath "com.squareup:javapoet:1.13.0" } } +plugins{ + id("com.google.dagger.hilt.android") version "2.44" apply false +} + allprojects { repositories { google() diff --git a/gradle.properties b/gradle.properties index d8551a8c0..1d7bc62d4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,26 +12,28 @@ # org.gradle.parallel=true #Mon Jun 26 09:56:43 AEST 2023 android.enableJetifier=true + +gradlePluginVersion=7.3.1 +org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M" +org.gradle.unsafe.configuration-cache=true + +googleServicesVersion=4.3.12 +kotlinVersion=1.8.21 android.useAndroidX=true appcompatVersion=1.6.1 coreVersion=1.8.0 coroutinesVersion=1.6.4 curve25519Version=0.6.0 -daggerVersion=2.40.1 +daggerVersion=2.46.1 glideVersion=4.11.0 -googleServicesVersion=4.3.12 -gradlePluginVersion=7.3.1 jacksonDatabindVersion=2.9.8 junitVersion=4.13.2 -kotlinVersion=1.6.21 kotlinxJsonVersion=1.3.3 kovenantVersion=3.3.0 lifecycleVersion=2.5.1 materialVersion=1.8.0 mockitoKotlinVersion=4.1.0 okhttpVersion=3.12.1 -org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M" -org.gradle.unsafe.configuration-cache=true pagingVersion=3.0.0 preferenceVersion=1.2.0 protobufVersion=2.5.0 diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt index 3aea8a1e3..41301e13b 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt @@ -285,10 +285,9 @@ class BatchMessageReceiveJob( val openGroupID = data.getStringOrDefault(OPEN_GROUP_ID_KEY, null) val parameters = (0 until numMessages).map { index -> - val data = contents[index] val serverHash = serverHashes[index].let { if (it.isEmpty()) null else it } val serverId = openGroupMessageServerIDs[index].let { if (it == -1L) null else it } - MessageReceiveParameters(data, serverHash, serverId) + MessageReceiveParameters(contents[index], serverHash, serverId) } return BatchMessageReceiveJob(parameters, openGroupID) diff --git a/libsession/src/main/java/org/session/libsession/messaging/mentions/MentionsManager.kt b/libsession/src/main/java/org/session/libsession/messaging/mentions/MentionsManager.kt index e9eae0ba5..fd16061e6 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/mentions/MentionsManager.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/mentions/MentionsManager.kt @@ -2,6 +2,7 @@ package org.session.libsession.messaging.mentions import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.contacts.Contact +import java.util.* object MentionsManager { var userPublicKeyCache = mutableMapOf>() // Thread ID to set of user hex encoded public keys @@ -32,9 +33,9 @@ object MentionsManager { candidates.sortedBy { it.displayName } if (query.length >= 2) { // Filter out any non-matching candidates - candidates = candidates.filter { it.displayName.toLowerCase().contains(query.toLowerCase()) } + candidates = candidates.filter { it.displayName.lowercase(Locale.getDefault()).contains(query.lowercase(Locale.getDefault())) } // Sort based on where in the candidate the query occurs - candidates.sortedBy { it.displayName.toLowerCase().indexOf(query.toLowerCase()) } + candidates.sortedBy { it.displayName.lowercase(Locale.getDefault()).indexOf(query.lowercase(Locale.getDefault())) } } // Return return candidates diff --git a/libsession/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageBuilder.kt b/libsession/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageBuilder.kt index e4db056d8..6cf18ba5c 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageBuilder.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageBuilder.kt @@ -4,52 +4,51 @@ import android.content.Context import org.session.libsession.R import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.calls.CallMessageType +import org.session.libsession.messaging.calls.CallMessageType.CALL_FIRST_MISSED +import org.session.libsession.messaging.calls.CallMessageType.CALL_INCOMING +import org.session.libsession.messaging.calls.CallMessageType.CALL_MISSED +import org.session.libsession.messaging.calls.CallMessageType.CALL_OUTGOING import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage import org.session.libsession.utilities.ExpirationUtil import org.session.libsession.utilities.truncateIdForDisplay object UpdateMessageBuilder { + val storage = MessagingModuleConfiguration.shared.storage + + fun getSenderName(senderId: String) = storage.getContactWithSessionID(senderId) + ?.displayName(Contact.ContactContext.REGULAR) + ?: truncateIdForDisplay(senderId) fun buildGroupUpdateMessage(context: Context, updateMessageData: UpdateMessageData, senderId: String? = null, isOutgoing: Boolean = false): String { - var message = "" - val updateData = updateMessageData.kind ?: return message - if (!isOutgoing && senderId == null) return message - val storage = MessagingModuleConfiguration.shared.storage - val senderName: String = if (!isOutgoing) { - storage.getContactWithSessionID(senderId!!)?.displayName(Contact.ContactContext.REGULAR) ?: truncateIdForDisplay(senderId) - } else { context.getString(R.string.MessageRecord_you) } + val updateData = updateMessageData.kind + if (updateData == null || !isOutgoing && senderId == null) return "" + val senderName: String = if (isOutgoing) context.getString(R.string.MessageRecord_you) + else getSenderName(senderId!!) - when (updateData) { - is UpdateMessageData.Kind.GroupCreation -> { - message = if (isOutgoing) { - context.getString(R.string.MessageRecord_you_created_a_new_group) - } else { - context.getString(R.string.MessageRecord_s_added_you_to_the_group, senderName) - } + return when (updateData) { + is UpdateMessageData.Kind.GroupCreation -> if (isOutgoing) { + context.getString(R.string.MessageRecord_you_created_a_new_group) + } else { + context.getString(R.string.MessageRecord_s_added_you_to_the_group, senderName) } - is UpdateMessageData.Kind.GroupNameChange -> { - message = if (isOutgoing) { - context.getString(R.string.MessageRecord_you_renamed_the_group_to_s, updateData.name) - } else { - context.getString(R.string.MessageRecord_s_renamed_the_group_to_s, senderName, updateData.name) - } + is UpdateMessageData.Kind.GroupNameChange -> if (isOutgoing) { + context.getString(R.string.MessageRecord_you_renamed_the_group_to_s, updateData.name) + } else { + context.getString(R.string.MessageRecord_s_renamed_the_group_to_s, senderName, updateData.name) } is UpdateMessageData.Kind.GroupMemberAdded -> { - val members = updateData.updatedMembers.joinToString(", ") { - storage.getContactWithSessionID(it)?.displayName(Contact.ContactContext.REGULAR) ?: it - } - message = if (isOutgoing) { + val members = updateData.updatedMembers.joinToString(", ", transform = ::getSenderName) + if (isOutgoing) { context.getString(R.string.MessageRecord_you_added_s_to_the_group, members) } else { context.getString(R.string.MessageRecord_s_added_s_to_the_group, senderName, members) } } is UpdateMessageData.Kind.GroupMemberRemoved -> { - val storage = MessagingModuleConfiguration.shared.storage val userPublicKey = storage.getUserPublicKey()!! // 1st case: you are part of the removed members - message = if (userPublicKey in updateData.updatedMembers) { + return if (userPublicKey in updateData.updatedMembers) { if (isOutgoing) { context.getString(R.string.MessageRecord_left_group) } else { @@ -57,9 +56,7 @@ object UpdateMessageBuilder { } } else { // 2nd case: you are not part of the removed members - val members = updateData.updatedMembers.joinToString(", ") { - storage.getContactWithSessionID(it)?.displayName(Contact.ContactContext.REGULAR) ?: it - } + val members = updateData.updatedMembers.joinToString(", ", transform = ::getSenderName) if (isOutgoing) { context.getString(R.string.MessageRecord_you_removed_s_from_the_group, members) } else { @@ -67,23 +64,19 @@ object UpdateMessageBuilder { } } } - is UpdateMessageData.Kind.GroupMemberLeft -> { - message = if (isOutgoing) { - context.getString(R.string.MessageRecord_left_group) - } else { - context.getString(R.string.ConversationItem_group_action_left, senderName) - } + is UpdateMessageData.Kind.GroupMemberLeft -> if (isOutgoing) { + context.getString(R.string.MessageRecord_left_group) + } else { + context.getString(R.string.ConversationItem_group_action_left, senderName) } - is UpdateMessageData.Kind.OpenGroupInvitation -> { /*Handled externally*/ } + else -> return "" } - return message } fun buildExpirationTimerMessage(context: Context, duration: Long, senderId: String? = null, isOutgoing: Boolean = false): String { if (!isOutgoing && senderId == null) return "" - val storage = MessagingModuleConfiguration.shared.storage - val senderName: String? = if (!isOutgoing) { - storage.getContactWithSessionID(senderId!!)?.displayName(Contact.ContactContext.REGULAR) ?: truncateIdForDisplay(senderId) + val senderName: String = if (!isOutgoing) { + getSenderName(senderId!!) } else { context.getString(R.string.MessageRecord_you) } return if (duration <= 0) { if (isOutgoing) context.getString(R.string.MessageRecord_you_disabled_disappearing_messages) @@ -96,8 +89,7 @@ object UpdateMessageBuilder { } fun buildDataExtractionMessage(context: Context, kind: DataExtractionNotificationInfoMessage.Kind, senderId: String? = null): String { - val storage = MessagingModuleConfiguration.shared.storage - val senderName = storage.getContactWithSessionID(senderId!!)?.displayName(Contact.ContactContext.REGULAR) ?: truncateIdForDisplay(senderId) + val senderName = getSenderName(senderId!!) return when (kind) { DataExtractionNotificationInfoMessage.Kind.SCREENSHOT -> context.getString(R.string.MessageRecord_s_took_a_screenshot, senderName) @@ -106,18 +98,12 @@ object UpdateMessageBuilder { } } - fun buildCallMessage(context: Context, type: CallMessageType, sender: String): String { - val storage = MessagingModuleConfiguration.shared.storage - val senderName = storage.getContactWithSessionID(sender)?.displayName(Contact.ContactContext.REGULAR) ?: sender - return when (type) { - CallMessageType.CALL_MISSED -> - context.getString(R.string.MessageRecord_missed_call_from, senderName) - CallMessageType.CALL_INCOMING -> - context.getString(R.string.MessageRecord_s_called_you, senderName) - CallMessageType.CALL_OUTGOING -> - context.getString(R.string.MessageRecord_called_s, senderName) - CallMessageType.CALL_FIRST_MISSED -> - context.getString(R.string.MessageRecord_missed_call_from, senderName) + fun buildCallMessage(context: Context, type: CallMessageType, sender: String): String = + when (type) { + CALL_INCOMING -> R.string.MessageRecord_s_called_you + CALL_OUTGOING -> R.string.MessageRecord_called_s + CALL_MISSED, CALL_FIRST_MISSED -> R.string.MessageRecord_missed_call_from + }.let { + context.getString(it, storage.getContactWithSessionID(sender)?.displayName(Contact.ContactContext.REGULAR) ?: sender) } - } }