diff --git a/app/build.gradle b/app/build.gradle index 5002756607..ca8b3e6d5d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -321,9 +321,9 @@ dependencies { implementation "com.opencsv:opencsv:4.6" testImplementation "junit:junit:$junitVersion" testImplementation 'org.assertj:assertj-core:3.11.1' - testImplementation "org.mockito:mockito-inline:4.10.0" + testImplementation "org.mockito:mockito-inline:4.11.0" testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion" - androidTestImplementation "org.mockito:mockito-android:4.10.0" + androidTestImplementation "org.mockito:mockito-android:4.11.0" androidTestImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion" testImplementation "androidx.test:core:$testCoreVersion" testImplementation "androidx.arch.core:core-testing:2.2.0" @@ -343,6 +343,7 @@ dependencies { // Assertions androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.ext:truth:1.5.0' + testImplementation 'com.google.truth:truth:1.1.3' androidTestImplementation 'com.google.truth:truth:1.1.3' // Espresso dependencies diff --git a/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt b/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt index 59cb8ede08..157085135e 100644 --- a/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt +++ b/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt @@ -7,16 +7,25 @@ import androidx.test.filters.SmallTest import androidx.test.platform.app.InstrumentationRegistry import network.loki.messenger.libsession_util.ConfigBase import network.loki.messenger.libsession_util.Contacts +import network.loki.messenger.libsession_util.ConversationVolatileConfig import network.loki.messenger.libsession_util.util.Contact +import network.loki.messenger.libsession_util.util.Conversation import network.loki.messenger.libsession_util.util.ExpiryMode +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.CoreMatchers.instanceOf +import org.hamcrest.MatcherAssert.assertThat import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.any import org.mockito.kotlin.argThat +import org.mockito.kotlin.argWhere import org.mockito.kotlin.eq import org.mockito.kotlin.spy import org.mockito.kotlin.verify import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.snode.SnodeAPI +import org.session.libsession.utilities.Address import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.KeyHelper import org.session.libsignal.utilities.hexEncodedPublicKey @@ -50,13 +59,22 @@ class LibSessionTests { private fun buildContactMessage(contactList: List): ByteArray { val (key,_) = maybeGetUserInfo()!! - val contacts = Contacts.Companion.newInstance(key) + val contacts = Contacts.newInstance(key) contactList.forEach { contact -> contacts.set(contact) } return contacts.push().config } + private fun buildVolatileMessage(conversations: List): ByteArray { + val (key, _) = maybeGetUserInfo()!! + val volatile = ConversationVolatileConfig.newInstance(key) + conversations.forEach { conversation -> + volatile.set(conversation) + } + return volatile.push().config + } + private fun fakePollNewConfig(configBase: ConfigBase, toMerge: ByteArray) { configBase.merge(nextFakeHash to toMerge) MessagingModuleConfiguration.shared.configFactory.persist(configBase, System.currentTimeMillis()) @@ -95,8 +113,83 @@ class LibSessionTests { fakePollNewConfig(contacts, newContactMerge) verify(storageSpy).addLibSessionContacts(argThat { first().let { it.id == newContactId && it.approved } && size == 1 - }) + }, any()) verify(storageSpy).setRecipientApproved(argThat { address.serialize() == newContactId }, eq(true)) } + @Test + fun test_expected_configs() { + val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext + val storageSpy = spy(app.storage) + app.storage = storageSpy + + val randomRecipient = randomSessionId() + val newContact = Contact( + id = randomRecipient, + approved = true, + expiryMode = ExpiryMode.AfterSend(1000) + ) + val newConvo = Conversation.OneToOne( + randomRecipient, + SnodeAPI.nowWithOffset, + false + ) + val volatiles = MessagingModuleConfiguration.shared.configFactory.convoVolatile!! + val contacts = MessagingModuleConfiguration.shared.configFactory.contacts!! + val newContactMerge = buildContactMessage(listOf(newContact)) + val newVolatileMerge = buildVolatileMessage(listOf(newConvo)) + fakePollNewConfig(contacts, newContactMerge) + fakePollNewConfig(volatiles, newVolatileMerge) + verify(storageSpy).setExpirationConfiguration(argWhere { config -> + config.expiryMode is ExpiryMode.AfterSend + && config.expiryMode.expirySeconds == 1000L + }) + val threadId = storageSpy.getThreadId(Address.fromSerialized(randomRecipient))!! + val newExpiry = storageSpy.getExpirationConfiguration(threadId)!! + assertThat(newExpiry.expiryMode, instanceOf(ExpiryMode.AfterSend::class.java)) + assertThat(newExpiry.expiryMode.expirySeconds, equalTo(1000)) + assertThat(newExpiry.expiryMode.expiryMillis, equalTo(1000000)) + } + + @Test + fun test_overwrite_config() { + val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext + val storageSpy = spy(app.storage) + app.storage = storageSpy + + // Initial state + val randomRecipient = randomSessionId() + val currentContact = Contact( + id = randomRecipient, + approved = true, + expiryMode = ExpiryMode.NONE + ) + val newConvo = Conversation.OneToOne( + randomRecipient, + SnodeAPI.nowWithOffset, + false + ) + val volatiles = MessagingModuleConfiguration.shared.configFactory.convoVolatile!! + val contacts = MessagingModuleConfiguration.shared.configFactory.contacts!! + val newContactMerge = buildContactMessage(listOf(currentContact)) + val newVolatileMerge = buildVolatileMessage(listOf(newConvo)) + fakePollNewConfig(contacts, newContactMerge) + fakePollNewConfig(volatiles, newVolatileMerge) + verify(storageSpy).setExpirationConfiguration(argWhere { config -> + config.expiryMode == ExpiryMode.NONE + }) + val threadId = storageSpy.getThreadId(Address.fromSerialized(randomRecipient))!! + val currentExpiryConfig = storageSpy.getExpirationConfiguration(threadId)!! + assertThat(currentExpiryConfig.expiryMode, equalTo(ExpiryMode.NONE)) + assertThat(currentExpiryConfig.expiryMode.expirySeconds, equalTo(0)) + assertThat(currentExpiryConfig.expiryMode.expiryMillis, equalTo(0)) + // Set new state and overwrite + val updatedContact = currentContact.copy(expiryMode = ExpiryMode.AfterSend(1000)) + val updateContactMerge = buildContactMessage(listOf(updatedContact)) + fakePollNewConfig(contacts, updateContactMerge) + val updatedExpiryConfig = storageSpy.getExpirationConfiguration(threadId)!! + assertThat(updatedExpiryConfig.expiryMode, instanceOf(ExpiryMode.AfterSend::class.java)) + assertThat(updatedExpiryConfig.expiryMode.expirySeconds, equalTo(1000)) + } + } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f2a4642b7a..c89fca8a70 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -178,6 +178,9 @@ android:screenOrientation="portrait" /> + Unit -): AlertDialog { - val view = LayoutInflater.from(this).inflate(R.layout.expiration_dialog, null) - val numberPickerView = view.findViewById(R.id.expiration_number_picker) - - fun updateText(index: Int) { - view.findViewById(R.id.expiration_details).text = when (index) { - 0 -> getString(R.string.ExpirationDialog_your_messages_will_not_expire) - else -> getString( - R.string.ExpirationDialog_your_messages_will_disappear_s_after_they_have_been_seen, - numberPickerView.displayedValues[index] - ) - } - } - - val expirationTimes = resources.getIntArray(R.array.expiration_times) - val expirationDisplayValues = expirationTimes - .map { ExpirationUtil.getExpirationDisplayValue(this, it) } - .toTypedArray() - - val selectedIndex = expirationTimes.run { indexOfFirst { it >= expiration }.coerceIn(indices) } - - numberPickerView.apply { - displayedValues = expirationDisplayValues - minValue = 0 - maxValue = expirationTimes.lastIndex - setOnValueChangedListener { _, _, index -> updateText(index) } - value = selectedIndex - } - - updateText(selectedIndex) - - return showSessionDialog { - title(getString(R.string.ExpirationDialog_disappearing_messages)) - view(view) - okButton { onExpirationTime(numberPickerView.let { expirationTimes[it.value] }) } - cancelButton() - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt b/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt index 44c30741ef..a99b40f36f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt @@ -105,7 +105,7 @@ class SessionDialogBuilder(val context: Context) { fun destructiveButton( @StringRes text: Int, - @StringRes contentDescription: Int, + @StringRes contentDescription: Int = text, listener: () -> Unit = {} ) = button( text, diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt index b00ed7d2ee..3fd91f7f63 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt @@ -186,7 +186,7 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) else DatabaseComponent.get(context).mmsDatabase() messagingDatabase.deleteMessage(messageID) DatabaseComponent.get(context).lokiMessageDatabase().deleteMessage(messageID, isSms) - DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHash(messageID) + DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHash(messageID, mms = !isSms) } override fun deleteMessages(messageIDs: List, threadId: Long, isSms: Boolean) { @@ -195,7 +195,7 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) messagingDatabase.deleteMessages(messageIDs.toLongArray(), threadId) DatabaseComponent.get(context).lokiMessageDatabase().deleteMessages(messageIDs) - DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHashes(messageIDs) + DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHashes(messageIDs, mms = !isSms) } override fun updateMessageAsDeleted(timestamp: Long, author: String): Long? { @@ -212,15 +212,12 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) return message.id } - override fun getServerHashForMessage(messageID: Long): String? { - val messageDB = DatabaseComponent.get(context).lokiMessageDatabase() - return messageDB.getMessageServerHash(messageID) - } + override fun getServerHashForMessage(messageID: Long, mms: Boolean): String? = + DatabaseComponent.get(context).lokiMessageDatabase().getMessageServerHash(messageID, mms) - override fun getDatabaseAttachment(attachmentId: Long): DatabaseAttachment? { - val attachmentDatabase = DatabaseComponent.get(context).attachmentDatabase() - return attachmentDatabase.getAttachment(AttachmentId(attachmentId, 0)) - } + override fun getDatabaseAttachment(attachmentId: Long): DatabaseAttachment? = + DatabaseComponent.get(context).attachmentDatabase() + .getAttachment(AttachmentId(attachmentId, 0)) private fun scaleAndStripExif(attachmentDatabase: AttachmentDatabase, constraints: MediaConstraints, attachment: Attachment): Attachment? { return try { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java deleted file mode 100644 index 1ac4f8442b..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java +++ /dev/null @@ -1,149 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.content.res.TypedArray; -import android.os.AsyncTask; -import android.util.AttributeSet; -import android.view.View; -import android.widget.ImageView; -import android.widget.LinearLayout; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.session.libsession.snode.SnodeAPI; -import org.thoughtcrime.securesms.ApplicationContext; -import org.thoughtcrime.securesms.conversation.v2.components.ExpirationTimerView; -import org.thoughtcrime.securesms.database.model.MessageRecord; -import org.thoughtcrime.securesms.dependencies.DatabaseComponent; -import org.thoughtcrime.securesms.service.ExpiringMessageManager; -import org.thoughtcrime.securesms.util.DateUtils; - -import java.util.Locale; - -import network.loki.messenger.R; - -public class ConversationItemFooter extends LinearLayout { - - private TextView dateView; - private ExpirationTimerView timerView; - private ImageView insecureIndicatorView; - private DeliveryStatusView deliveryStatusView; - - public ConversationItemFooter(Context context) { - super(context); - init(null); - } - - public ConversationItemFooter(Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - init(attrs); - } - - public ConversationItemFooter(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - init(attrs); - } - - private void init(@Nullable AttributeSet attrs) { - inflate(getContext(), R.layout.conversation_item_footer, this); - - dateView = findViewById(R.id.footer_date); - timerView = findViewById(R.id.footer_expiration_timer); - insecureIndicatorView = findViewById(R.id.footer_insecure_indicator); - deliveryStatusView = findViewById(R.id.footer_delivery_status); - - if (attrs != null) { - TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.ConversationItemFooter, 0, 0); - setTextColor(typedArray.getInt(R.styleable.ConversationItemFooter_footer_text_color, getResources().getColor(R.color.core_white))); - setIconColor(typedArray.getInt(R.styleable.ConversationItemFooter_footer_icon_color, getResources().getColor(R.color.core_white))); - typedArray.recycle(); - } - } - - @Override - protected void onDetachedFromWindow() { - super.onDetachedFromWindow(); - timerView.stopAnimation(); - } - - public void setMessageRecord(@NonNull MessageRecord messageRecord, @NonNull Locale locale) { - presentDate(messageRecord, locale); - presentTimer(messageRecord); - presentInsecureIndicator(messageRecord); - presentDeliveryStatus(messageRecord); - } - - public void setTextColor(int color) { - dateView.setTextColor(color); - } - - public void setIconColor(int color) { - timerView.setColorFilter(color); - insecureIndicatorView.setColorFilter(color); - deliveryStatusView.setTint(color); - } - - private void presentDate(@NonNull MessageRecord messageRecord, @NonNull Locale locale) { - dateView.forceLayout(); - - if (messageRecord.isFailed()) { - dateView.setText(R.string.ConversationItem_error_not_delivered); - } else { - dateView.setText(DateUtils.getExtendedRelativeTimeSpanString(getContext(), locale, messageRecord.getTimestamp())); - } - } - - @SuppressLint("StaticFieldLeak") - private void presentTimer(@NonNull final MessageRecord messageRecord) { - if (messageRecord.getExpiresIn() > 0 && !messageRecord.isPending()) { - this.timerView.setVisibility(View.VISIBLE); - this.timerView.setPercentComplete(0); - - if (messageRecord.getExpireStarted() > 0) { - this.timerView.setExpirationTime(messageRecord.getExpireStarted(), - messageRecord.getExpiresIn()); - this.timerView.startAnimation(); - - if (messageRecord.getExpireStarted() + messageRecord.getExpiresIn() <= SnodeAPI.getNowWithOffset()) { - ApplicationContext.getInstance(getContext()).getExpiringMessageManager().checkSchedule(); - } - } else if (!messageRecord.isOutgoing() && !messageRecord.isMediaPending()) { - new AsyncTask() { - @Override - protected Void doInBackground(Void... params) { - ExpiringMessageManager expirationManager = ApplicationContext.getInstance(getContext()).getExpiringMessageManager(); - long id = messageRecord.getId(); - boolean mms = messageRecord.isMms(); - - if (mms) DatabaseComponent.get(getContext()).mmsDatabase().markExpireStarted(id); - else DatabaseComponent.get(getContext()).smsDatabase().markExpireStarted(id); - - expirationManager.scheduleDeletion(id, mms, messageRecord.getExpiresIn()); - return null; - } - }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - } else { - this.timerView.setVisibility(View.GONE); - } - } - - private void presentInsecureIndicator(@NonNull MessageRecord messageRecord) { - insecureIndicatorView.setVisibility(View.GONE); - } - - private void presentDeliveryStatus(@NonNull MessageRecord messageRecord) { - if (!messageRecord.isFailed()) { - if (!messageRecord.isOutgoing()) deliveryStatusView.setNone(); - else if (messageRecord.isPending()) deliveryStatusView.setPending(); - else if (messageRecord.isRead()) deliveryStatusView.setRead(); - else if (messageRecord.isDelivered()) deliveryStatusView.setDelivered(); - else deliveryStatusView.setSent(); - } else { - deliveryStatusView.setNone(); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/menu/ActionItem.kt b/app/src/main/java/org/thoughtcrime/securesms/components/menu/ActionItem.kt index d0b101a9f1..700534fad1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/menu/ActionItem.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/menu/ActionItem.kt @@ -1,13 +1,17 @@ package org.thoughtcrime.securesms.components.menu +import android.content.Context import androidx.annotation.AttrRes +import androidx.annotation.ColorRes /** * Represents an action to be rendered */ -data class ActionItem @JvmOverloads constructor( +data class ActionItem( @AttrRes val iconRes: Int, - val title: CharSequence, + val title: Int, val action: Runnable, - val contentDescription: String? = null + val contentDescription: Int? = null, + val subtitle: ((Context) -> CharSequence?)? = null, + @ColorRes val color: Int? = null, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/menu/ContextMenuList.kt b/app/src/main/java/org/thoughtcrime/securesms/components/menu/ContextMenuList.kt index c86b40dfa5..31870e9b7a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/menu/ContextMenuList.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/menu/ContextMenuList.kt @@ -1,12 +1,21 @@ package org.thoughtcrime.securesms.components.menu +import android.content.Context +import android.content.res.ColorStateList import android.util.TypedValue import android.view.View import android.widget.ImageView import android.widget.TextView import androidx.core.content.ContextCompat +import androidx.core.view.isGone +import androidx.core.widget.ImageViewCompat import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import network.loki.messenger.R import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter @@ -34,30 +43,23 @@ class ContextMenuList(recyclerView: RecyclerView, onItemClick: () -> Unit) { mappingAdapter.submitList(items.toAdapterItems()) } - private fun List.toAdapterItems(): List { - return this.mapIndexed { index, item -> - val displayType: DisplayType = when { - this.size == 1 -> DisplayType.ONLY + private fun List.toAdapterItems(): List = + mapIndexed { index, item -> + when { + size == 1 -> DisplayType.ONLY index == 0 -> DisplayType.TOP - index == this.size - 1 -> DisplayType.BOTTOM + index == size - 1 -> DisplayType.BOTTOM else -> DisplayType.MIDDLE - } - - DisplayItem(item, displayType) + }.let { DisplayItem(item, it) } } - } private data class DisplayItem( val item: ActionItem, val displayType: DisplayType ) : MappingModel { - override fun areItemsTheSame(newItem: DisplayItem): Boolean { - return this == newItem - } + override fun areItemsTheSame(newItem: DisplayItem): Boolean = this == newItem - override fun areContentsTheSame(newItem: DisplayItem): Boolean { - return this == newItem - } + override fun areContentsTheSame(newItem: DisplayItem): Boolean = this == newItem } private enum class DisplayType { @@ -68,28 +70,61 @@ class ContextMenuList(recyclerView: RecyclerView, onItemClick: () -> Unit) { itemView: View, private val onItemClick: () -> Unit, ) : MappingViewHolder(itemView) { + private var subtitleJob: Job? = null val icon: ImageView = itemView.findViewById(R.id.context_menu_item_icon) val title: TextView = itemView.findViewById(R.id.context_menu_item_title) + val subtitle: TextView = itemView.findViewById(R.id.context_menu_item_subtitle) override fun bind(model: DisplayItem) { - if (model.item.iconRes > 0) { + val item = model.item + val color = item.color?.let { ContextCompat.getColor(context, it) } + + if (item.iconRes > 0) { val typedValue = TypedValue() - context.theme.resolveAttribute(model.item.iconRes, typedValue, true) + context.theme.resolveAttribute(item.iconRes, typedValue, true) icon.setImageDrawable(ContextCompat.getDrawable(context, typedValue.resourceId)) + + icon.imageTintList = color?.let(ColorStateList::valueOf) } - itemView.contentDescription = model.item.contentDescription - title.text = model.item.title + item.contentDescription?.let(context.resources::getString)?.let { itemView.contentDescription = it } + title.setText(item.title) + color?.let(title::setTextColor) + color?.let(subtitle::setTextColor) + subtitle.isGone = true + item.subtitle?.let { startSubtitleJob(subtitle, it) } itemView.setOnClickListener { - model.item.action.run() + item.action.run() onItemClick() } when (model.displayType) { - DisplayType.TOP -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_top) - DisplayType.BOTTOM -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_bottom) - DisplayType.MIDDLE -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_middle) - DisplayType.ONLY -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_only) + DisplayType.TOP -> R.drawable.context_menu_item_background_top + DisplayType.BOTTOM -> R.drawable.context_menu_item_background_bottom + DisplayType.MIDDLE -> R.drawable.context_menu_item_background_middle + DisplayType.ONLY -> R.drawable.context_menu_item_background_only + }.let(itemView::setBackgroundResource) + } + + private fun startSubtitleJob(textView: TextView, getSubtitle: (Context) -> CharSequence?) { + fun updateText() = getSubtitle(context).let { + textView.isGone = it == null + textView.text = it } + updateText() + + subtitleJob?.cancel() + subtitleJob = CoroutineScope(Dispatchers.Main).launch { + while (true) { + updateText() + delay(200) + } + } + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + // naive job cancellation, will break if many items are added to context menu. + subtitleJob?.cancel() } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt new file mode 100644 index 0000000000..4bbcc3e021 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt @@ -0,0 +1,184 @@ +package org.thoughtcrime.securesms.conversation + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback +import com.google.android.material.tabs.TabLayoutMediator +import dagger.hilt.android.AndroidEntryPoint +import network.loki.messenger.R +import network.loki.messenger.databinding.ViewConversationActionBarBinding +import network.loki.messenger.databinding.ViewConversationSettingBinding +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.session.libsession.messaging.messages.ExpirationConfiguration +import org.session.libsession.messaging.open_groups.OpenGroup +import org.session.libsession.utilities.ExpirationUtil +import org.session.libsession.utilities.modifyLayoutParams +import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities +import org.thoughtcrime.securesms.database.GroupDatabase +import org.thoughtcrime.securesms.database.LokiAPIDatabase +import org.thoughtcrime.securesms.util.DateUtils +import java.util.Locale +import javax.inject.Inject + +@AndroidEntryPoint +class ConversationActionBarView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr) { + private val binding = ViewConversationActionBarBinding.inflate(LayoutInflater.from(context), this, true) + + @Inject lateinit var lokiApiDb: LokiAPIDatabase + @Inject lateinit var groupDb: GroupDatabase + + var delegate: ConversationActionBarDelegate? = null + + private val settingsAdapter = ConversationSettingsAdapter { setting -> + if (setting.settingType == ConversationSettingType.EXPIRATION) { + delegate?.onDisappearingMessagesClicked() + } + } + + init { + var previousState: Int + var currentState = 0 + binding.settingsPager.registerOnPageChangeCallback(object : OnPageChangeCallback() { + override fun onPageScrollStateChanged(state: Int) { + val currentPage: Int = binding.settingsPager.currentItem + val lastPage = maxOf( (binding.settingsPager.adapter?.itemCount ?: 0) - 1, 0) + if (currentPage == lastPage || currentPage == 0) { + previousState = currentState + currentState = state + if (previousState == 1 && currentState == 0) { + binding.settingsPager.setCurrentItem(if (currentPage == 0) lastPage else 0, true) + } + } + } + }) + binding.settingsPager.adapter = settingsAdapter + TabLayoutMediator(binding.settingsTabLayout, binding.settingsPager) { _, _ -> }.attach() + } + + fun bind( + delegate: ConversationActionBarDelegate, + threadId: Long, + recipient: Recipient, + config: ExpirationConfiguration? = null, + openGroup: OpenGroup? = null + ) { + this.delegate = delegate + binding.profilePictureView.layoutParams = resources.getDimensionPixelSize( + if (recipient.isClosedGroupRecipient) R.dimen.medium_profile_picture_size else R.dimen.small_profile_picture_size + ).let { LayoutParams(it, it) } + MentionManagerUtilities.populateUserPublicKeyCacheIfNeeded(threadId, context) + update(recipient, openGroup, config) + } + + fun update(recipient: Recipient, openGroup: OpenGroup? = null, config: ExpirationConfiguration? = null) { + binding.profilePictureView.update(recipient) + binding.conversationTitleView.text = recipient.takeUnless { it.isLocalNumber }?.toShortString() ?: context.getString(R.string.note_to_self) + updateSubtitle(recipient, openGroup, config) + + binding.conversationTitleContainer.modifyLayoutParams { + marginEnd = if (recipient.showCallMenu()) 0 else binding.profilePictureView.width + } + } + + fun updateSubtitle(recipient: Recipient, openGroup: OpenGroup? = null, config: ExpirationConfiguration? = null) { + val settings = mutableListOf() + if (config?.isEnabled == true) { + val prefix = when (config.expiryMode) { + is ExpiryMode.AfterRead -> R.string.expiration_type_disappear_after_read + else -> R.string.expiration_type_disappear_after_send + }.let(context::getString) + settings += ConversationSetting( + "$prefix - ${ExpirationUtil.getExpirationAbbreviatedDisplayValue(context, config.expiryMode.expirySeconds)}", + ConversationSettingType.EXPIRATION, + R.drawable.ic_timer, + resources.getString(R.string.AccessibilityId_disappearing_messages_type_and_time) + ) + } + if (recipient.isMuted) { + settings += ConversationSetting( + recipient.mutedUntil.takeUnless { it == Long.MAX_VALUE } + ?.let { context.getString(R.string.ConversationActivity_muted_until_date, DateUtils.getFormattedDateTime(it, "EEE, MMM d, yyyy HH:mm", Locale.getDefault())) } + ?: context.getString(R.string.ConversationActivity_muted_forever), + ConversationSettingType.NOTIFICATION, + R.drawable.ic_outline_notifications_off_24 + ) + } + if (recipient.isGroupRecipient) { + val title = if (recipient.isOpenGroupRecipient) { + val userCount = openGroup?.let { lokiApiDb.getUserCount(it.room, it.server) } ?: 0 + context.getString(R.string.ConversationActivity_active_member_count, userCount) + } else { + val userCount = groupDb.getGroupMemberAddresses(recipient.address.toGroupString(), true).size + context.getString(R.string.ConversationActivity_member_count, userCount) + } + settings += ConversationSetting(title, ConversationSettingType.MEMBER_COUNT) + } + settingsAdapter.submitList(settings) + binding.settingsTabLayout.isVisible = settings.size > 1 + } + + class ConversationSettingsAdapter( + private val settingsListener: (ConversationSetting) -> Unit + ) : ListAdapter(SettingsDiffer()) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SettingViewHolder { + val layoutInflater = LayoutInflater.from(parent.context) + return SettingViewHolder(ViewConversationSettingBinding.inflate(layoutInflater, parent, false)) + } + + override fun onBindViewHolder(holder: SettingViewHolder, position: Int) { + holder.bind(getItem(position), itemCount) { + settingsListener.invoke(it) + } + } + + class SettingViewHolder( + private val binding: ViewConversationSettingBinding + ): RecyclerView.ViewHolder(binding.root) { + + fun bind(setting: ConversationSetting, itemCount: Int, listener: (ConversationSetting) -> Unit) { + binding.root.setOnClickListener { listener.invoke(setting) } + binding.root.contentDescription = setting.contentDescription + binding.iconImageView.setImageResource(setting.iconResId) + binding.iconImageView.isVisible = setting.iconResId > 0 + binding.titleView.text = setting.title + binding.leftArrowImageView.isVisible = itemCount > 1 + binding.rightArrowImageView.isVisible = itemCount > 1 + } + } + + class SettingsDiffer: DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: ConversationSetting, newItem: ConversationSetting): Boolean = oldItem.settingType === newItem.settingType + override fun areContentsTheSame(oldItem: ConversationSetting, newItem: ConversationSetting): Boolean = oldItem == newItem + } + } +} + +fun interface ConversationActionBarDelegate { + fun onDisappearingMessagesClicked() +} + +data class ConversationSetting( + val title: String, + val settingType: ConversationSettingType, + val iconResId: Int = 0, + val contentDescription: String = "" +) + +enum class ConversationSettingType { + EXPIRATION, + MEMBER_COUNT, + NOTIFICATION +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt new file mode 100644 index 0000000000..d336c967ce --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt @@ -0,0 +1,72 @@ +package org.thoughtcrime.securesms.conversation.disappearingmessages + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import network.loki.messenger.R +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.messages.ExpirationConfiguration +import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate +import org.session.libsession.messaging.sending_receiving.MessageSender +import org.session.libsession.snode.SnodeAPI +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.ExpirationUtil +import org.session.libsession.utilities.SSKEnvironment.MessageExpirationManagerProtocol +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.getExpirationTypeDisplayValue +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.showSessionDialog +import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds + +class DisappearingMessages @Inject constructor( + @ApplicationContext private val context: Context, + private val textSecurePreferences: TextSecurePreferences, + private val messageExpirationManager: MessageExpirationManagerProtocol, +) { + fun set(threadId: Long, address: Address, mode: ExpiryMode, isGroup: Boolean) { + val expiryChangeTimestampMs = SnodeAPI.nowWithOffset + MessagingModuleConfiguration.shared.storage.setExpirationConfiguration(ExpirationConfiguration(threadId, mode, expiryChangeTimestampMs)) + + val message = ExpirationTimerUpdate(isGroup = isGroup).apply { + expiryMode = mode + sender = textSecurePreferences.getLocalNumber() + isSenderSelf = true + recipient = address.serialize() + sentTimestamp = expiryChangeTimestampMs + } + + messageExpirationManager.insertExpirationTimerMessage(message) + MessageSender.send(message, address) + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) + } + + fun showFollowSettingDialog(context: Context, message: MessageRecord) = context.showSessionDialog { + title(R.string.dialog_disappearing_messages_follow_setting_title) + text(if (message.expiresIn == 0L) { + context.getString(R.string.dialog_disappearing_messages_follow_setting_off_body) + } else { + context.getString( + R.string.dialog_disappearing_messages_follow_setting_on_body, + ExpirationUtil.getExpirationDisplayValue( + context, + message.expiresIn.milliseconds + ), + context.getExpirationTypeDisplayValue(message.isNotDisappearAfterRead) + ) + }) + destructiveButton( + text = if (message.expiresIn == 0L) R.string.dialog_disappearing_messages_follow_setting_confirm else R.string.dialog_disappearing_messages_follow_setting_set, + contentDescription = if (message.expiresIn == 0L) R.string.AccessibilityId_confirm else R.string.AccessibilityId_set_button + ) { + set(message.threadId, message.recipient.address, message.expiryMode, message.recipient.isClosedGroupRecipient) + } + cancelButton() + } +} + +val MessageRecord.expiryMode get() = if (expiresIn <= 0) ExpiryMode.NONE + else if (expireStarted == timestamp) ExpiryMode.AfterSend(expiresIn / 1000) + else ExpiryMode.AfterRead(expiresIn / 1000) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesActivity.kt new file mode 100644 index 0000000000..16e74cdde9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesActivity.kt @@ -0,0 +1,94 @@ +package org.thoughtcrime.securesms.conversation.disappearingmessages + +import android.os.Bundle +import android.widget.Toast +import androidx.activity.viewModels +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import network.loki.messenger.R +import network.loki.messenger.databinding.ActivityDisappearingMessagesBinding +import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity +import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.DisappearingMessages +import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.UiState +import org.thoughtcrime.securesms.database.RecipientDatabase +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.ui.AppTheme +import javax.inject.Inject + +@AndroidEntryPoint +class DisappearingMessagesActivity: PassphraseRequiredActionBarActivity() { + + private lateinit var binding : ActivityDisappearingMessagesBinding + + @Inject lateinit var recipientDb: RecipientDatabase + @Inject lateinit var threadDb: ThreadDatabase + @Inject lateinit var viewModelFactory: DisappearingMessagesViewModel.AssistedFactory + + private val threadId: Long by lazy { + intent.getLongExtra(THREAD_ID, -1) + } + + private val viewModel: DisappearingMessagesViewModel by viewModels { + viewModelFactory.create(threadId) + } + + override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { + super.onCreate(savedInstanceState, ready) + binding = ActivityDisappearingMessagesBinding.inflate(layoutInflater) + setContentView(binding.root) + + setUpToolbar() + + binding.container.setContent { DisappearingMessagesScreen() } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.event.collect { + when (it) { + Event.SUCCESS -> finish() + Event.FAIL -> showToast(getString(R.string.DisappearingMessagesActivity_settings_not_updated)) + } + } + } + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.state.collect { + supportActionBar?.subtitle = it.subtitle(this@DisappearingMessagesActivity) + } + } + } + } + + private fun showToast(message: String) { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() + } + + private fun setUpToolbar() { + setSupportActionBar(binding.toolbar) + supportActionBar?.apply { + title = getString(R.string.activity_disappearing_messages_title) + setDisplayHomeAsUpEnabled(true) + setHomeButtonEnabled(true) + } + } + + companion object { + const val THREAD_ID = "thread_id" + } + + @Composable + fun DisappearingMessagesScreen() { + val uiState by viewModel.uiState.collectAsState(UiState()) + AppTheme { + DisappearingMessages(uiState, callbacks = viewModel) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt new file mode 100644 index 0000000000..32e20b73d9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt @@ -0,0 +1,129 @@ +package org.thoughtcrime.securesms.conversation.disappearingmessages + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import network.loki.messenger.BuildConfig +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.session.libsession.messaging.messages.ExpirationConfiguration +import org.session.libsession.utilities.SSKEnvironment.MessageExpirationManagerProtocol +import org.session.libsession.utilities.TextSecurePreferences +import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.ExpiryCallbacks +import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.UiState +import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.toUiState +import org.thoughtcrime.securesms.database.GroupDatabase +import org.thoughtcrime.securesms.database.Storage +import org.thoughtcrime.securesms.database.ThreadDatabase + +class DisappearingMessagesViewModel( + private val threadId: Long, + private val application: Application, + private val textSecurePreferences: TextSecurePreferences, + private val messageExpirationManager: MessageExpirationManagerProtocol, + private val disappearingMessages: DisappearingMessages, + private val threadDb: ThreadDatabase, + private val groupDb: GroupDatabase, + private val storage: Storage, + isNewConfigEnabled: Boolean, + showDebugOptions: Boolean +) : AndroidViewModel(application), ExpiryCallbacks { + + private val _event = Channel() + val event = _event.receiveAsFlow() + + private val _state = MutableStateFlow( + State( + isNewConfigEnabled = isNewConfigEnabled, + showDebugOptions = showDebugOptions + ) + ) + val state = _state.asStateFlow() + + val uiState = _state + .map(State::toUiState) + .stateIn(viewModelScope, SharingStarted.Eagerly, UiState()) + + init { + viewModelScope.launch { + val expiryMode = storage.getExpirationConfiguration(threadId)?.expiryMode?.maybeConvertToLegacy(isNewConfigEnabled) ?: ExpiryMode.NONE + val recipient = threadDb.getRecipientForThreadId(threadId) + val groupRecord = recipient?.takeIf { it.isClosedGroupRecipient } + ?.run { groupDb.getGroup(address.toGroupString()).orNull() } + + _state.update { + it.copy( + address = recipient?.address, + isGroup = groupRecord != null, + isNoteToSelf = recipient?.address?.serialize() == textSecurePreferences.getLocalNumber(), + isSelfAdmin = groupRecord == null || groupRecord.admins.any{ it.serialize() == textSecurePreferences.getLocalNumber() }, + expiryMode = expiryMode, + persistedMode = expiryMode + ) + } + } + } + + override fun setValue(value: ExpiryMode) = _state.update { it.copy(expiryMode = value) } + + override fun onSetClick() = viewModelScope.launch { + val state = _state.value + val mode = state.expiryMode?.coerceLegacyToAfterSend() + val address = state.address + if (address == null || mode == null) { + _event.send(Event.FAIL) + return@launch + } + + disappearingMessages.set(threadId, address, mode, state.isGroup) + + _event.send(Event.SUCCESS) + } + + private fun ExpiryMode.coerceLegacyToAfterSend() = takeUnless { it is ExpiryMode.Legacy } ?: ExpiryMode.AfterSend(expirySeconds) + + @dagger.assisted.AssistedFactory + interface AssistedFactory { + fun create(threadId: Long): Factory + } + + @Suppress("UNCHECKED_CAST") + class Factory @AssistedInject constructor( + @Assisted private val threadId: Long, + private val application: Application, + private val textSecurePreferences: TextSecurePreferences, + private val messageExpirationManager: MessageExpirationManagerProtocol, + private val disappearingMessages: DisappearingMessages, + private val threadDb: ThreadDatabase, + private val groupDb: GroupDatabase, + private val storage: Storage + ) : ViewModelProvider.Factory { + + override fun create(modelClass: Class): T = DisappearingMessagesViewModel( + threadId, + application, + textSecurePreferences, + messageExpirationManager, + disappearingMessages, + threadDb, + groupDb, + storage, + ExpirationConfiguration.isNewConfigEnabled, + BuildConfig.DEBUG + ) as T + } +} + +private fun ExpiryMode.maybeConvertToLegacy(isNewConfigEnabled: Boolean): ExpiryMode = takeIf { isNewConfigEnabled } ?: ExpiryMode.Legacy(expirySeconds) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/State.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/State.kt new file mode 100644 index 0000000000..cc968df398 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/State.kt @@ -0,0 +1,90 @@ +package org.thoughtcrime.securesms.conversation.disappearingmessages + +import androidx.annotation.StringRes +import network.loki.messenger.R +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.session.libsession.utilities.Address +import org.thoughtcrime.securesms.ui.GetString +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours + +enum class Event { + SUCCESS, FAIL +} + +data class State( + val isGroup: Boolean = false, + val isSelfAdmin: Boolean = true, + val address: Address? = null, + val isNoteToSelf: Boolean = false, + val expiryMode: ExpiryMode? = null, + val isNewConfigEnabled: Boolean = true, + val persistedMode: ExpiryMode? = null, + val showDebugOptions: Boolean = false +) { + val subtitle get() = when { + isGroup || isNoteToSelf -> GetString(R.string.activity_disappearing_messages_subtitle_sent) + else -> GetString(R.string.activity_disappearing_messages_subtitle) + } + + val typeOptionsHidden get() = isNoteToSelf || (isGroup && isNewConfigEnabled) + + val nextType get() = when { + expiryType == ExpiryType.AFTER_READ -> ExpiryType.AFTER_READ + isNewConfigEnabled -> ExpiryType.AFTER_SEND + else -> ExpiryType.LEGACY + } + + val duration get() = expiryMode?.duration + val expiryType get() = expiryMode?.type + + val isTimeOptionsEnabled = isNoteToSelf || isSelfAdmin && (isNewConfigEnabled || expiryType == ExpiryType.LEGACY) +} + + +enum class ExpiryType( + private val createMode: (Long) -> ExpiryMode, + @StringRes val title: Int, + @StringRes val subtitle: Int? = null, + @StringRes val contentDescription: Int = title, +) { + NONE( + { ExpiryMode.NONE }, + R.string.expiration_off, + contentDescription = R.string.AccessibilityId_disable_disappearing_messages, + ), + LEGACY( + ExpiryMode::Legacy, + R.string.expiration_type_disappear_legacy, + contentDescription = R.string.expiration_type_disappear_legacy_description + ), + AFTER_READ( + ExpiryMode::AfterRead, + R.string.expiration_type_disappear_after_read, + R.string.expiration_type_disappear_after_read_description, + R.string.AccessibilityId_disappear_after_read_option + ), + AFTER_SEND( + ExpiryMode::AfterSend, + R.string.expiration_type_disappear_after_send, + R.string.expiration_type_disappear_after_read_description, + R.string.AccessibilityId_disappear_after_send_option + ); + + fun mode(seconds: Long) = if (seconds != 0L) createMode(seconds) else ExpiryMode.NONE + fun mode(duration: Duration) = mode(duration.inWholeSeconds) + + fun defaultMode(persistedMode: ExpiryMode?) = when(this) { + persistedMode?.type -> persistedMode + AFTER_READ -> mode(12.hours) + else -> mode(1.days) + } +} + +val ExpiryMode.type: ExpiryType get() = when(this) { + is ExpiryMode.Legacy -> ExpiryType.LEGACY + is ExpiryMode.AfterSend -> ExpiryType.AFTER_SEND + is ExpiryMode.AfterRead -> ExpiryType.AFTER_READ + else -> ExpiryType.NONE +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/Adapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/Adapter.kt new file mode 100644 index 0000000000..6ddc28c688 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/Adapter.kt @@ -0,0 +1,98 @@ +package org.thoughtcrime.securesms.conversation.disappearingmessages.ui + +import network.loki.messenger.R +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType +import org.thoughtcrime.securesms.conversation.disappearingmessages.State +import org.thoughtcrime.securesms.ui.GetString +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +fun State.toUiState() = UiState( + cards = listOfNotNull( + typeOptions()?.let { ExpiryOptionsCard(GetString(R.string.activity_disappearing_messages_delete_type), it) }, + timeOptions()?.let { ExpiryOptionsCard(GetString(R.string.activity_disappearing_messages_timer), it) } + ), + showGroupFooter = isGroup && isNewConfigEnabled, + showSetButton = isSelfAdmin +) + +private fun State.typeOptions(): List? = if (typeOptionsHidden) null else { + buildList { + add(offTypeOption()) + if (!isNewConfigEnabled) add(legacyTypeOption()) + if (!isGroup) add(afterReadTypeOption()) + add(afterSendTypeOption()) + } +} + +private fun State.timeOptions(): List? { + // Don't show times card if we have a types card, and type is off. + if (!typeOptionsHidden && expiryType == ExpiryType.NONE) return null + + return nextType.let { type -> + when (type) { + ExpiryType.AFTER_READ -> afterReadTimes + else -> afterSendTimes + }.map { timeOption(type, it) } + }.let { + buildList { + if (typeOptionsHidden) add(offTypeOption()) + addAll(debugOptions()) + addAll(it) + } + } +} + +private fun State.offTypeOption() = typeOption(ExpiryType.NONE) +private fun State.legacyTypeOption() = typeOption(ExpiryType.LEGACY) +private fun State.afterReadTypeOption() = newTypeOption(ExpiryType.AFTER_READ) +private fun State.afterSendTypeOption() = newTypeOption(ExpiryType.AFTER_SEND) +private fun State.newTypeOption(type: ExpiryType) = typeOption(type, isNewConfigEnabled && isSelfAdmin) + +private fun State.typeOption( + type: ExpiryType, + enabled: Boolean = isSelfAdmin, +) = ExpiryRadioOption( + value = type.defaultMode(persistedMode), + title = GetString(type.title), + subtitle = type.subtitle?.let(::GetString), + contentDescription = GetString(type.contentDescription), + selected = expiryType == type, + enabled = enabled +) + +private fun debugTimes(isDebug: Boolean) = if (isDebug) listOf(10.seconds, 30.seconds, 1.minutes) else emptyList() +private fun debugModes(isDebug: Boolean, type: ExpiryType) = + debugTimes(isDebug).map { type.mode(it.inWholeSeconds) } +private fun State.debugOptions(): List = + debugModes(showDebugOptions, nextType).map { timeOption(it, subtitle = GetString("for testing purposes")) } + +private val afterSendTimes = listOf(12.hours, 1.days, 7.days, 14.days) + +private val afterReadTimes = buildList { + add(5.minutes) + add(1.hours) + addAll(afterSendTimes) +} + +private fun State.timeOption( + type: ExpiryType, + time: Duration +) = timeOption(type.mode(time)) + +private fun State.timeOption( + mode: ExpiryMode, + title: GetString = GetString(mode.duration), + subtitle: GetString? = null, +) = ExpiryRadioOption( + value = mode, + title = title, + subtitle = subtitle, + contentDescription = title, + selected = mode.duration == expiryMode?.duration, + enabled = isTimeOptionsEnabled +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt new file mode 100644 index 0000000000..3fec60a0a3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt @@ -0,0 +1,75 @@ +package org.thoughtcrime.securesms.conversation.disappearingmessages.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import network.loki.messenger.R +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.thoughtcrime.securesms.ui.Callbacks +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.NoOpCallbacks +import org.thoughtcrime.securesms.ui.OptionsCard +import org.thoughtcrime.securesms.ui.OutlineButton +import org.thoughtcrime.securesms.ui.RadioOption +import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.fadingEdges + +typealias ExpiryCallbacks = Callbacks +typealias ExpiryRadioOption = RadioOption + +@Composable +fun DisappearingMessages( + state: UiState, + modifier: Modifier = Modifier, + callbacks: ExpiryCallbacks = NoOpCallbacks +) { + val scrollState = rememberScrollState() + + Column(modifier = modifier.padding(horizontal = 32.dp)) { + Box(modifier = Modifier.weight(1f)) { + Column( + modifier = Modifier + .padding(bottom = 20.dp) + .verticalScroll(scrollState) + .fadingEdges(scrollState), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + state.cards.forEach { + OptionsCard(it, callbacks) + } + + if (state.showGroupFooter) Text(text = stringResource(R.string.activity_disappearing_messages_group_footer), + style = TextStyle( + fontSize = 11.sp, + fontWeight = FontWeight(400), + color = Color(0xFFA1A2A1), + textAlign = TextAlign.Center), + modifier = Modifier.fillMaxWidth()) + } + } + + if (state.showSetButton) OutlineButton( + GetString(R.string.disappearing_messages_set_button_title), + modifier = Modifier + .contentDescription(GetString(R.string.AccessibilityId_set_button)) + .align(Alignment.CenterHorizontally) + .padding(bottom = 20.dp), + onClick = callbacks::onSetClick + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessagesPreview.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessagesPreview.kt new file mode 100644 index 0000000000..c2524bf261 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessagesPreview.kt @@ -0,0 +1,62 @@ +package org.thoughtcrime.securesms.conversation.disappearingmessages.ui + +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import network.loki.messenger.R +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType +import org.thoughtcrime.securesms.conversation.disappearingmessages.State +import org.thoughtcrime.securesms.ui.PreviewTheme +import org.thoughtcrime.securesms.ui.ThemeResPreviewParameterProvider + +@Preview(widthDp = 450, heightDp = 700) +@Composable +fun PreviewStates( + @PreviewParameter(StatePreviewParameterProvider::class) state: State +) { + PreviewTheme(R.style.Classic_Dark) { + DisappearingMessages( + state.toUiState() + ) + } +} + +class StatePreviewParameterProvider : PreviewParameterProvider { + override val values = newConfigValues.filter { it.expiryType != ExpiryType.LEGACY } + newConfigValues.map { it.copy(isNewConfigEnabled = false) } + + private val newConfigValues get() = sequenceOf( + // new 1-1 + State(expiryMode = ExpiryMode.NONE), + State(expiryMode = ExpiryMode.Legacy(43200)), + State(expiryMode = ExpiryMode.AfterRead(300)), + State(expiryMode = ExpiryMode.AfterSend(43200)), + // new group non-admin + State(isGroup = true, isSelfAdmin = false), + State(isGroup = true, isSelfAdmin = false, expiryMode = ExpiryMode.Legacy(43200)), + State(isGroup = true, isSelfAdmin = false, expiryMode = ExpiryMode.AfterSend(43200)), + // new group admin + State(isGroup = true), + State(isGroup = true, expiryMode = ExpiryMode.Legacy(43200)), + State(isGroup = true, expiryMode = ExpiryMode.AfterSend(43200)), + // new note-to-self + State(isNoteToSelf = true), + ) +} + +@Preview +@Composable +fun PreviewThemes( + @PreviewParameter(ThemeResPreviewParameterProvider::class) themeResId: Int +) { + PreviewTheme(themeResId) { + DisappearingMessages( + State(expiryMode = ExpiryMode.AfterSend(43200)).toUiState(), + modifier = Modifier.size(400.dp, 600.dp) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/UiState.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/UiState.kt new file mode 100644 index 0000000000..40f917427c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/UiState.kt @@ -0,0 +1,32 @@ +package org.thoughtcrime.securesms.conversation.disappearingmessages.ui + +import androidx.annotation.StringRes +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.RadioOption + +typealias ExpiryOptionsCard = OptionsCard + +data class UiState( + val cards: List = emptyList(), + val showGroupFooter: Boolean = false, + val showSetButton: Boolean = true +) { + constructor( + vararg cards: ExpiryOptionsCard, + showGroupFooter: Boolean = false, + showSetButton: Boolean = true, + ): this( + cards.asList(), + showGroupFooter, + showSetButton + ) +} + +data class OptionsCard( + val title: GetString, + val options: List> +) { + constructor(title: GetString, vararg options: RadioOption): this(title, options.asList()) + constructor(@StringRes title: Int, vararg options: RadioOption): this(GetString(title), options.asList()) +} 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 8c566c060b..5fb295dcdd 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 @@ -30,13 +30,11 @@ import android.view.MenuItem import android.view.MotionEvent import android.view.View 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 import androidx.core.text.toSpannable import androidx.core.view.drawToBitmap @@ -46,6 +44,7 @@ import androidx.fragment.app.DialogFragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.loader.app.LoaderManager @@ -60,11 +59,13 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.databinding.ActivityConversationV2Binding import network.loki.messenger.databinding.ViewVisibleMessageBinding +import network.loki.messenger.libsession_util.util.ExpiryMode import nl.komponents.kovenant.ui.successUi import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.contacts.Contact @@ -72,8 +73,9 @@ import org.session.libsession.messaging.jobs.AttachmentDownloadJob import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.mentions.Mention import org.session.libsession.messaging.mentions.MentionsManager +import org.session.libsession.messaging.messages.ExpirationConfiguration +import org.session.libsession.messaging.messages.applyExpiryMode import org.session.libsession.messaging.messages.control.DataExtractionNotification -import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingTextMessage import org.session.libsession.messaging.messages.visible.Reaction @@ -105,6 +107,8 @@ import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.attachments.ScreenshotObserver import org.thoughtcrime.securesms.audio.AudioRecorder import org.thoughtcrime.securesms.contacts.SelectContactsActivity.Companion.selectedContactsKey +import org.thoughtcrime.securesms.conversation.ConversationActionBarDelegate +import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesActivity 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 @@ -126,19 +130,16 @@ import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDel import org.thoughtcrime.securesms.conversation.v2.search.SearchBottomBar import org.thoughtcrime.securesms.conversation.v2.search.SearchViewModel import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager -import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities import org.thoughtcrime.securesms.conversation.v2.utilities.ResendMessageUtilities import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.crypto.MnemonicUtilities import org.thoughtcrime.securesms.database.GroupDatabase -import org.thoughtcrime.securesms.database.LokiAPIDatabase import org.thoughtcrime.securesms.database.LokiMessageDatabase import org.thoughtcrime.securesms.database.LokiThreadDatabase import org.thoughtcrime.securesms.database.MmsDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.ReactionDatabase -import org.thoughtcrime.securesms.database.RecipientDatabase import org.thoughtcrime.securesms.database.SessionContactDatabase import org.thoughtcrime.securesms.database.SmsDatabase import org.thoughtcrime.securesms.database.Storage @@ -166,7 +167,6 @@ import org.thoughtcrime.securesms.mms.VideoSlide import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.reactions.ReactionsDialogFragment import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiDialogFragment -import org.thoughtcrime.securesms.showExpirationDialog import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.util.ActivityDispatcher import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities @@ -176,8 +176,11 @@ import org.thoughtcrime.securesms.util.SaveAttachmentTask import org.thoughtcrime.securesms.util.SimpleTextWatcher import org.thoughtcrime.securesms.util.isScrolledToBottom import org.thoughtcrime.securesms.util.push +import org.thoughtcrime.securesms.util.show import org.thoughtcrime.securesms.util.toPx import java.lang.ref.WeakReference +import java.time.Instant +import java.util.Date import java.util.Locale import java.util.concurrent.ExecutionException import java.util.concurrent.atomic.AtomicBoolean @@ -188,6 +191,10 @@ import kotlin.math.abs import kotlin.math.min import kotlin.math.roundToInt import kotlin.math.sqrt +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +private const val TAG = "ConversationActivityV2" // Some things that seemingly belong to the input bar (e.g. the voice message recording UI) are actually // part of the conversation activity layout. This is just because it makes the layout a lot simpler. The @@ -196,7 +203,7 @@ import kotlin.math.sqrt class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDelegate, InputBarRecordingViewDelegate, AttachmentManager.AttachmentListener, ActivityDispatcher, ConversationActionModeCallbackDelegate, VisibleMessageViewDelegate, RecipientModifiedListener, - SearchBottomBar.EventListener, LoaderManager.LoaderCallbacks, + SearchBottomBar.EventListener, LoaderManager.LoaderCallbacks, ConversationActionBarDelegate, OnReactionSelectedListener, ReactWithAnyEmojiDialogFragment.Callback, ReactionsDialogFragment.Callback, ConversationMenuHelper.ConversationMenuListener { @@ -208,8 +215,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe @Inject lateinit var lokiThreadDb: LokiThreadDatabase @Inject lateinit var sessionContactDb: SessionContactDatabase @Inject lateinit var groupDb: GroupDatabase - @Inject lateinit var recipientDb: RecipientDatabase - @Inject lateinit var lokiApiDb: LokiAPIDatabase @Inject lateinit var smsDb: SmsDatabase @Inject lateinit var mmsDb: MmsDatabase @Inject lateinit var lokiMessageDb: LokiMessageDatabase @@ -410,7 +415,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } updateUnreadCountIndicator() - updateSubtitle() updatePlaceholder() setUpBlockedBanner() binding!!.searchBottomBar.setEventListener(this) @@ -438,6 +442,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe setUpRecipientObserver() getLatestOpenGroupInfoIfNeeded() setUpSearchResultObserver() + scrollToFirstUnreadMessageIfNeeded() + setUpOutdatedClientBanner() if (author != null && messageTimestamp >= 0 && targetPosition >= 0) { binding?.conversationRecyclerView?.scrollToPosition(targetPosition) @@ -453,18 +459,21 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe reactionDelegate = ConversationReactionDelegate(reactionOverlayStub) reactionDelegate.setOnReactionSelectedListener(this) lifecycleScope.launch { - lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { // only update the conversation every 3 seconds maximum // channel is rendezvous and shouldn't block on try send calls as often as we want - val bufferedFlow = bufferedLastSeenChannel.consumeAsFlow() - bufferedFlow.filter { - it > storage.getLastSeen(viewModel.threadId) - }.collectLatest { latestMessageRead -> - withContext(Dispatchers.IO) { - storage.markConversationAsRead(viewModel.threadId, latestMessageRead) + bufferedLastSeenChannel.receiveAsFlow() + .flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED) + .collectLatest { + withContext(Dispatchers.IO) { + try { + if (it > storage.getLastSeen(viewModel.threadId)) { + storage.markConversationAsRead(viewModel.threadId, it) + } + } catch (e: Exception) { + Log.e(TAG, "bufferedLastSeenChannel collectLatest", e) + } + } } - } - } } } @@ -477,6 +486,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe true, screenshotObserver ) + viewModel.run { + binding?.toolbarContent?.update(recipient ?: return, openGroup, expirationConfiguration) + } } override fun onPause() { @@ -493,8 +505,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } override fun dispatchIntent(body: (Context) -> Intent?) { - val intent = body(this) ?: return - push(intent, false) + body(this)?.let { push(it, false) } } override fun showDialog(dialogFragment: DialogFragment, tag: String?) { @@ -526,16 +537,16 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (author != null && messageTimestamp >= 0) { jumpToMessage(author, messageTimestamp, firstLoad.get(), null) - } - else if (firstLoad.getAndSet(false)) { - scrollToFirstUnreadMessageIfNeeded(true) - handleRecyclerViewScrolled() - } - else if (oldCount != newCount) { + } else { + if (firstLoad.getAndSet(false)) scrollToFirstUnreadMessageIfNeeded(true) handleRecyclerViewScrolled() } } updatePlaceholder() + viewModel.recipient?.let { + maybeUpdateToolbar(recipient = it) + setUpOutdatedClientBanner() + } } override fun onLoaderReset(cursor: Loader) { @@ -574,20 +585,14 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe actionBar.title = "" actionBar.setDisplayHomeAsUpEnabled(true) actionBar.setHomeButtonEnabled(true) - binding.toolbarContent.conversationTitleView.text = when { - recipient.isLocalNumber -> getString(R.string.note_to_self) - else -> recipient.toShortString() - } - @DimenRes val sizeID: Int = if (viewModel.recipient?.isClosedGroupRecipient == true) { - R.dimen.medium_profile_picture_size - } else { - R.dimen.small_profile_picture_size - } - val size = resources.getDimension(sizeID).roundToInt() - binding.toolbarContent.profilePictureView.layoutParams = LinearLayout.LayoutParams(size, size) - MentionManagerUtilities.populateUserPublicKeyCacheIfNeeded(viewModel.threadId, this) - val profilePictureView = binding.toolbarContent.profilePictureView - viewModel.recipient?.let(profilePictureView::update) + binding!!.toolbarContent.bind( + this, + viewModel.threadId, + recipient, + viewModel.expirationConfiguration, + viewModel.openGroup + ) + maybeUpdateToolbar(recipient) } // called from onCreate @@ -679,23 +684,36 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } private fun getLatestOpenGroupInfoIfNeeded() { - viewModel.openGroup?.let { - OpenGroupApi.getMemberCount(it.room, it.server).successUi { updateSubtitle() } + val openGroup = viewModel.openGroup ?: return + OpenGroupApi.getMemberCount(openGroup.room, openGroup.server) successUi { + binding?.toolbarContent?.updateSubtitle(viewModel.recipient!!, openGroup, viewModel.expirationConfiguration) + maybeUpdateToolbar(viewModel.recipient!!) } } // called from onCreate private fun setUpBlockedBanner() { - val recipient = viewModel.recipient ?: return - if (recipient.isGroupRecipient) { return } + val recipient = viewModel.recipient?.takeUnless { it.isGroupRecipient } ?: return val sessionID = recipient.address.toString() - val contact = sessionContactDb.getContactWithSessionID(sessionID) - val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID + val name = sessionContactDb.getContactWithSessionID(sessionID)?.displayName(Contact.ContactContext.REGULAR) ?: sessionID binding?.blockedBannerTextView?.text = resources.getString(R.string.activity_conversation_blocked_banner_text, name) binding?.blockedBanner?.isVisible = recipient.isBlocked binding?.blockedBanner?.setOnClickListener { viewModel.unblock() } } + private fun setUpOutdatedClientBanner() { + val legacyRecipient = viewModel.legacyBannerRecipient(this) + + val shouldShowLegacy = ExpirationConfiguration.isNewConfigEnabled && + legacyRecipient != null + + binding?.outdatedBanner?.isVisible = shouldShowLegacy + if (shouldShowLegacy) { + binding?.outdatedBannerTextView?.text = + resources.getString(R.string.activity_conversation_outdated_client_banner_text, legacyRecipient!!.name) + } + } + private fun setUpLinkPreviewObserver() { if (!textSecurePreferences.isLinkPreviewsEnabled()) { linkPreviewViewModel.onUserCancel(); return @@ -766,10 +784,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe menu, menuInflater, recipient, - viewModel.threadId, this - ) { onOptionsItemSelected(it) } + ) } + maybeUpdateToolbar(recipient) return true } @@ -778,7 +796,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe tearDownRecipientObserver() super.onDestroy() binding = null -// actionBarBinding = null } // endregion @@ -793,31 +810,25 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } setUpMessageRequestsBar() invalidateOptionsMenu() - updateSubtitle() updateSendAfterApprovalText() showOrHideInputIfNeeded() - binding?.toolbarContent?.profilePictureView?.update(threadRecipient) - binding?.toolbarContent?.conversationTitleView?.text = when { - threadRecipient.isLocalNumber -> getString(R.string.note_to_self) - else -> threadRecipient.toShortString() - } + maybeUpdateToolbar(threadRecipient) } } + private fun maybeUpdateToolbar(recipient: Recipient) { + binding?.toolbarContent?.update(recipient, viewModel.openGroup, viewModel.expirationConfiguration) + } + private fun updateSendAfterApprovalText() { binding?.textSendAfterApproval?.isVisible = viewModel.showSendAfterApprovalText } private fun showOrHideInputIfNeeded() { - val recipient = viewModel.recipient - if (recipient != null && recipient.isClosedGroupRecipient) { - val group = groupDb.getGroup(recipient.address.toGroupString()).orNull() - val isActive = (group?.isActive == true) - binding?.inputBar?.showInput = isActive - } else { - binding?.inputBar?.showInput = true - } + binding?.inputBar?.showInput = viewModel.recipient?.takeIf { it.isClosedGroupRecipient } + ?.run { address.toGroupString().let(groupDb::getGroup).orNull()?.isActive == true } + ?: true } private fun setUpMessageRequestsBar() { @@ -847,21 +858,15 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } - private fun isOutgoingMessageRequestThread(): Boolean { - val recipient = viewModel.recipient ?: return false - return !recipient.isGroupRecipient && - !recipient.isLocalNumber && - !(recipient.hasApprovedMe() || viewModel.hasReceived()) - } + private fun isOutgoingMessageRequestThread(): Boolean = viewModel.recipient?.run { + !isGroupRecipient && !isLocalNumber && + !(hasApprovedMe() || viewModel.hasReceived()) + } ?: false - private fun isIncomingMessageRequestThread(): Boolean { - val recipient = viewModel.recipient ?: return false - return !recipient.isGroupRecipient && - !recipient.isApproved && - !recipient.isLocalNumber && - !threadDb.getLastSeenAndHasSent(viewModel.threadId).second() && - threadDb.getMessageCount(viewModel.threadId) > 0 - } + private fun isIncomingMessageRequestThread(): Boolean = viewModel.recipient?.run { + !isGroupRecipient && !isApproved && !isLocalNumber && + !threadDb.getLastSeenAndHasSent(viewModel.threadId).second() && threadDb.getMessageCount(viewModel.threadId) > 0 + } ?: false override fun inputBarEditTextContentChanged(newContent: CharSequence) { val inputBarText = binding?.inputBar?.text ?: return // TODO check if we should be referencing newContent here instead @@ -1041,16 +1046,16 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val maybeTargetVisiblePosition = if (reverseMessageList) layoutManager?.findFirstVisibleItemPosition() else layoutManager?.findLastVisibleItemPosition() val targetVisiblePosition = maybeTargetVisiblePosition ?: RecyclerView.NO_POSITION if (!firstLoad.get() && targetVisiblePosition != RecyclerView.NO_POSITION) { - val visibleItemTimestamp = adapter.getTimestampForItemAt(targetVisiblePosition) - if (visibleItemTimestamp != null) { - bufferedLastSeenChannel.trySend(visibleItemTimestamp) + adapter.getTimestampForItemAt(targetVisiblePosition)?.let { visibleItemTimestamp -> + bufferedLastSeenChannel.trySend(visibleItemTimestamp).apply { + if (isFailure) Log.e(TAG, "trySend failed", exceptionOrNull()) + } } } if (reverseMessageList) { unreadCount = min(unreadCount, targetVisiblePosition).coerceAtLeast(0) - } - else { + } else { val layoutUnreadCount = layoutManager?.let { (it.itemCount - 1) - it.findLastVisibleItemPosition() } ?: RecyclerView.NO_POSITION unreadCount = min(unreadCount, layoutUnreadCount).coerceAtLeast(0) @@ -1104,33 +1109,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe binding.unreadCountIndicator.isVisible = (unreadCount != 0) } - private fun updateSubtitle() { - val actionBarBinding = binding?.toolbarContent ?: return - val recipient = viewModel.recipient ?: return - actionBarBinding.muteIconImageView.isVisible = recipient.isMuted - actionBarBinding.conversationSubtitleView.isVisible = true - if (recipient.isMuted) { - if (recipient.mutedUntil != Long.MAX_VALUE) { - actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_muted_until_date, DateUtils.getFormattedDateTime(recipient.mutedUntil, "EEE, MMM d, yyyy HH:mm", Locale.getDefault())) - } else { - actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_muted_forever) - } - } else if (recipient.isGroupRecipient) { - viewModel.openGroup?.let { openGroup -> - val userCount = lokiApiDb.getUserCount(openGroup.room, openGroup.server) ?: 0 - actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_active_member_count, userCount) - } ?: run { - val userCount = groupDb.getGroupMemberAddresses(recipient.address.toGroupString(), true).size - actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_member_count, userCount) - } - viewModel - } else { - actionBarBinding.conversationSubtitleView.isVisible = false - } - } // endregion // region Interaction + override fun onDisappearingMessagesClicked() { + viewModel.recipient?.let { showDisappearingMessages(it) } + } + override fun onOptionsItemSelected(item: MenuItem): Boolean { if (item.itemId == android.R.id.home) { return false @@ -1174,20 +1159,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() } - override fun showExpiringMessagesDialog(thread: Recipient) { + override fun showDisappearingMessages(thread: Recipient) { if (thread.isClosedGroupRecipient) { - val group = groupDb.getGroup(thread.address.toGroupString()).orNull() - if (group?.isActive == false) { return } - } - showExpirationDialog(thread.expireMessages) { expirationTime -> - storage.setExpirationTimer(thread.address.serialize(), expirationTime) - val message = ExpirationTimerUpdate(expirationTime) - message.recipient = thread.address.serialize() - message.sentTimestamp = SnodeAPI.nowWithOffset - ApplicationContext.getInstance(this).expiringMessageManager.setExpirationTimer(message) - MessageSender.send(message, thread.address) - invalidateOptionsMenu() + groupDb.getGroup(thread.address.toGroupString()).orNull()?.run { if (!isActive) return } } + Intent(this, DisappearingMessagesActivity::class.java) + .apply { putExtra(DisappearingMessagesActivity.THREAD_ID, viewModel.threadId) } + .also { show(it, true) } } override fun unblock() { @@ -1584,10 +1562,14 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe return null } // Create the message - val message = VisibleMessage() + val message = VisibleMessage().applyExpiryMode(viewModel.threadId) message.sentTimestamp = sentTimestamp message.text = text - val outgoingTextMessage = OutgoingTextMessage.from(message, recipient) + val expiresInMillis = viewModel.expirationConfiguration?.expiryMode?.expiryMillis ?: 0 + val expireStartedAt = if (viewModel.expirationConfiguration?.expiryMode is ExpiryMode.AfterSend) { + message.sentTimestamp!! + } else 0 + val outgoingTextMessage = OutgoingTextMessage.from(message, recipient, expiresInMillis, expireStartedAt) // Clear the input bar binding?.inputBar?.text = "" binding?.inputBar?.cancelQuoteDraft() @@ -1615,7 +1597,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val sentTimestamp = SnodeAPI.nowWithOffset processMessageRequestApproval() // Create the message - val message = VisibleMessage() + val message = VisibleMessage().applyExpiryMode(viewModel.threadId) message.sentTimestamp = sentTimestamp message.text = body val quote = quotedMessage?.let { @@ -1631,7 +1613,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe else it.individualRecipient.address quote?.copy(author = sender) } - val outgoingTextMessage = OutgoingMediaMessage.from(message, recipient, attachments, localQuote, linkPreview) + val expiresInMs = viewModel.expirationConfiguration?.expiryMode?.expiryMillis ?: 0 + val expireStartedAtMs = if (viewModel.expirationConfiguration?.expiryMode is ExpiryMode.AfterSend) { + sentTimestamp + } else 0 + val outgoingTextMessage = OutgoingMediaMessage.from(message, recipient, attachments, localQuote, linkPreview, expiresInMs, expireStartedAtMs) // Clear the input bar binding?.inputBar?.text = "" binding?.inputBar?.cancelQuoteDraft() @@ -1696,6 +1682,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults) } + @Deprecated("Deprecated in Java") override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { super.onActivityResult(requestCode, resultCode, intent) val mediaPreppedListener = object : ListenableFuture.Listener { @@ -1815,7 +1802,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe override fun deleteMessages(messages: Set) { val recipient = viewModel.recipient ?: return val allSentByCurrentUser = messages.all { it.isOutgoing } - val allHasHash = messages.all { lokiMessageDb.getMessageServerHash(it.id) != null } + val allHasHash = messages.all { lokiMessageDb.getMessageServerHash(it.id, it.isMms) != null } if (recipient.isOpenGroupRecipient) { val messageCount = 1 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 deleted file mode 100644 index eee8b5ecd5..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.java +++ /dev/null @@ -1,902 +0,0 @@ -package org.thoughtcrime.securesms.conversation.v2; - -import android.animation.Animator; -import android.animation.AnimatorSet; -import android.animation.ObjectAnimator; -import android.animation.ValueAnimator; -import android.app.Activity; -import android.content.Context; -import android.graphics.Bitmap; -import android.graphics.PointF; -import android.graphics.Rect; -import android.graphics.drawable.BitmapDrawable; -import android.util.AttributeSet; -import android.view.HapticFeedbackConstants; -import android.view.MotionEvent; -import android.view.View; -import android.view.Window; -import android.view.animation.DecelerateInterpolator; -import android.view.animation.Interpolator; -import android.widget.FrameLayout; -import android.widget.LinearLayout; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.constraintlayout.widget.ConstraintLayout; -import androidx.core.content.ContextCompat; -import androidx.core.view.ViewKt; -import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat; - -import com.annimon.stream.Stream; - -import org.session.libsession.messaging.open_groups.OpenGroup; -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsession.utilities.ThemeUtil; -import org.session.libsession.utilities.recipients.Recipient; -import org.thoughtcrime.securesms.components.emoji.EmojiImageView; -import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel; -import org.thoughtcrime.securesms.components.menu.ActionItem; -import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuItemHelper; -import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; -import org.thoughtcrime.securesms.database.model.MessageRecord; -import org.thoughtcrime.securesms.database.model.ReactionRecord; -import org.thoughtcrime.securesms.dependencies.DatabaseComponent; -import org.thoughtcrime.securesms.util.AnimationCompleteListener; -import org.thoughtcrime.securesms.util.DateUtils; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Locale; - -import kotlin.Unit; -import network.loki.messenger.R; - -public final class ConversationReactionOverlay extends FrameLayout { - - public static final float LONG_PRESS_SCALE_FACTOR = 0.95f; - private static final Interpolator INTERPOLATOR = new DecelerateInterpolator(); - - private final Rect emojiViewGlobalRect = new Rect(); - private final Rect emojiStripViewBounds = new Rect(); - private float segmentSize; - - private final Boundary horizontalEmojiBoundary = new Boundary(); - private final Boundary verticalScrubBoundary = new Boundary(); - private final PointF deadzoneTouchPoint = new PointF(); - - private Activity activity; - private MessageRecord messageRecord; - private SelectedConversationModel selectedConversationModel; - private String blindedPublicKey; - private OverlayState overlayState = OverlayState.HIDDEN; - private RecentEmojiPageModel recentEmojiPageModel; - - private boolean downIsOurs; - private int selected = -1; - private int customEmojiIndex; - private int originalStatusBarColor; - private int originalNavigationBarColor; - - private View dropdownAnchor; - private LinearLayout conversationItem; - private View conversationBubble; - private TextView conversationTimestamp; - private View backgroundView; - private ConstraintLayout foregroundView; - private EmojiImageView[] emojiViews; - - private ConversationContextMenu contextMenu; - - private float touchDownDeadZoneSize; - private float distanceFromTouchDownPointToBottomOfScrubberDeadZone; - private int scrubberWidth; - private int selectedVerticalTranslation; - private int scrubberHorizontalMargin; - private int animationEmojiStartDelayFactor; - private int statusBarHeight; - - private OnReactionSelectedListener onReactionSelectedListener; - private OnActionSelectedListener onActionSelectedListener; - private OnHideListener onHideListener; - - private AnimatorSet revealAnimatorSet = new AnimatorSet(); - private AnimatorSet hideAnimatorSet = new AnimatorSet(); - - public ConversationReactionOverlay(@NonNull Context context) { - super(context); - } - - public ConversationReactionOverlay(@NonNull Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - } - - @Override - protected void onFinishInflate() { - super.onFinishInflate(); - - dropdownAnchor = findViewById(R.id.dropdown_anchor); - conversationItem = findViewById(R.id.conversation_item); - conversationBubble = conversationItem.findViewById(R.id.conversation_item_bubble); - conversationTimestamp = conversationItem.findViewById(R.id.conversation_item_timestamp); - backgroundView = findViewById(R.id.conversation_reaction_scrubber_background); - foregroundView = findViewById(R.id.conversation_reaction_scrubber_foreground); - - emojiViews = new EmojiImageView[] { findViewById(R.id.reaction_1), - findViewById(R.id.reaction_2), - findViewById(R.id.reaction_3), - findViewById(R.id.reaction_4), - findViewById(R.id.reaction_5), - findViewById(R.id.reaction_6), - findViewById(R.id.reaction_7) }; - - customEmojiIndex = emojiViews.length - 1; - - distanceFromTouchDownPointToBottomOfScrubberDeadZone = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_scrub_deadzone_distance_from_touch_bottom); - - touchDownDeadZoneSize = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_touch_deadzone_size); - scrubberWidth = getResources().getDimensionPixelOffset(R.dimen.reaction_scrubber_width); - selectedVerticalTranslation = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_vertical_translation); - scrubberHorizontalMargin = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_horizontal_margin); - - animationEmojiStartDelayFactor = getResources().getInteger(R.integer.reaction_scrubber_emoji_reveal_duration_start_delay_factor); - - initAnimators(); - } - - public void show(@NonNull Activity activity, - @NonNull MessageRecord messageRecord, - @NonNull PointF lastSeenDownPoint, - @NonNull SelectedConversationModel selectedConversationModel, - @Nullable String blindedPublicKey) - { - if (overlayState != OverlayState.HIDDEN) { - return; - } - - this.messageRecord = messageRecord; - this.selectedConversationModel = selectedConversationModel; - this.blindedPublicKey = blindedPublicKey; - overlayState = OverlayState.UNINITAILIZED; - selected = -1; - recentEmojiPageModel = new RecentEmojiPageModel(activity); - - setupSelectedEmoji(); - - View statusBarBackground = activity.findViewById(android.R.id.statusBarBackground); - statusBarHeight = statusBarBackground == null ? 0 : statusBarBackground.getHeight(); - - Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap(); - - conversationBubble.setLayoutParams(new LinearLayout.LayoutParams(conversationItemSnapshot.getWidth(), conversationItemSnapshot.getHeight())); - conversationBubble.setBackground(new BitmapDrawable(getResources(), conversationItemSnapshot)); - conversationTimestamp.setText(DateUtils.getDisplayFormattedTimeSpanString(getContext(), Locale.getDefault(), messageRecord.getTimestamp())); - - updateConversationTimestamp(messageRecord); - - boolean isMessageOnLeft = selectedConversationModel.isOutgoing() ^ ViewUtil.isLtr(this); - - conversationItem.setScaleX(LONG_PRESS_SCALE_FACTOR); - conversationItem.setScaleY(LONG_PRESS_SCALE_FACTOR); - - setVisibility(View.INVISIBLE); - - this.activity = activity; - updateSystemUiOnShow(activity); - - ViewKt.doOnLayout(this, v -> { - showAfterLayout(messageRecord, lastSeenDownPoint, isMessageOnLeft); - return Unit.INSTANCE; - }); - } - - private void updateConversationTimestamp(MessageRecord message) { - if (message.isOutgoing()) conversationBubble.bringToFront(); - else conversationTimestamp.bringToFront(); - } - - private void showAfterLayout(@NonNull MessageRecord messageRecord, - @NonNull PointF lastSeenDownPoint, - boolean isMessageOnLeft) { - contextMenu = new ConversationContextMenu(dropdownAnchor, getMenuActionItems(messageRecord)); - - float endX = isMessageOnLeft ? scrubberHorizontalMargin : - selectedConversationModel.getBubbleX() - conversationItem.getWidth() + selectedConversationModel.getBubbleWidth(); - float endY = selectedConversationModel.getBubbleY() - statusBarHeight; - conversationItem.setX(endX); - conversationItem.setY(endY); - - Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap(); - boolean isWideLayout = contextMenu.getMaxWidth() + scrubberWidth < getWidth(); - - int overlayHeight = getHeight(); - int bubbleWidth = selectedConversationModel.getBubbleWidth(); - - float endApparentTop = endY; - float endScale = 1f; - - float menuPadding = DimensionUnit.DP.toPixels(12f); - float reactionBarTopPadding = DimensionUnit.DP.toPixels(32f); - int reactionBarHeight = backgroundView.getHeight(); - - float reactionBarBackgroundY; - - if (isWideLayout) { - boolean everythingFitsVertically = reactionBarHeight + menuPadding + reactionBarTopPadding + conversationItemSnapshot.getHeight() < overlayHeight; - if (everythingFitsVertically) { - boolean reactionBarFitsAboveItem = conversationItem.getY() > reactionBarHeight + menuPadding + reactionBarTopPadding; - - if (reactionBarFitsAboveItem) { - reactionBarBackgroundY = conversationItem.getY() - menuPadding - reactionBarHeight; - } else { - endY = reactionBarHeight + menuPadding + reactionBarTopPadding; - reactionBarBackgroundY = reactionBarTopPadding; - } - } else { - float spaceAvailableForItem = overlayHeight - reactionBarHeight - menuPadding - reactionBarTopPadding; - - endScale = spaceAvailableForItem / conversationItem.getHeight(); - endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1); - endY = reactionBarHeight + menuPadding + reactionBarTopPadding - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale); - reactionBarBackgroundY = reactionBarTopPadding; - } - } else { - float reactionBarOffset = DimensionUnit.DP.toPixels(48); - float spaceForReactionBar = Math.max(reactionBarHeight + reactionBarOffset, 0); - boolean everythingFitsVertically = contextMenu.getMaxHeight() + conversationItemSnapshot.getHeight() + menuPadding + spaceForReactionBar < overlayHeight; - - if (everythingFitsVertically) { - float bubbleBottom = selectedConversationModel.getBubbleY() + conversationItemSnapshot.getHeight(); - boolean menuFitsBelowItem = bubbleBottom + menuPadding + contextMenu.getMaxHeight() <= overlayHeight + statusBarHeight; - - if (menuFitsBelowItem) { - if (conversationItem.getY() < 0) { - endY = 0; - } - float contextMenuTop = endY + conversationItemSnapshot.getHeight(); - reactionBarBackgroundY = getReactionBarOffsetForTouch(selectedConversationModel.getBubbleY(), contextMenuTop, menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY); - - if (reactionBarBackgroundY <= reactionBarTopPadding) { - endY = backgroundView.getHeight() + menuPadding + reactionBarTopPadding; - } - } else { - endY = overlayHeight - contextMenu.getMaxHeight() - menuPadding - conversationItemSnapshot.getHeight(); - reactionBarBackgroundY = endY - reactionBarHeight - menuPadding; - } - - endApparentTop = endY; - } else if (reactionBarOffset + reactionBarHeight + contextMenu.getMaxHeight() + menuPadding < overlayHeight) { - float spaceAvailableForItem = (float) overlayHeight - contextMenu.getMaxHeight() - menuPadding - spaceForReactionBar; - - endScale = spaceAvailableForItem / conversationItemSnapshot.getHeight(); - endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1); - endY = spaceForReactionBar - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale); - - float contextMenuTop = endY + (conversationItemSnapshot.getHeight() * endScale); - reactionBarBackgroundY = reactionBarTopPadding;//getReactionBarOffsetForTouch(selectedConversationModel.getBubbleY(), contextMenuTop + Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale), menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY); - endApparentTop = endY + Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale); - } else { - contextMenu.setHeight(contextMenu.getMaxHeight() / 2); - - int menuHeight = contextMenu.getHeight(); - boolean fitsVertically = menuHeight + conversationItem.getHeight() + menuPadding * 2 + reactionBarHeight + reactionBarTopPadding < overlayHeight; - - if (fitsVertically) { - float bubbleBottom = selectedConversationModel.getBubbleY() + conversationItemSnapshot.getHeight(); - boolean menuFitsBelowItem = bubbleBottom + menuPadding + menuHeight <= overlayHeight + statusBarHeight; - - if (menuFitsBelowItem) { - reactionBarBackgroundY = conversationItem.getY() - menuPadding - reactionBarHeight; - - if (reactionBarBackgroundY < reactionBarTopPadding) { - endY = reactionBarTopPadding + reactionBarHeight + menuPadding; - reactionBarBackgroundY = reactionBarTopPadding; - } - } else { - endY = overlayHeight - menuHeight - menuPadding - conversationItemSnapshot.getHeight(); - reactionBarBackgroundY = endY - reactionBarHeight - menuPadding; - } - endApparentTop = endY; - } else { - float spaceAvailableForItem = (float) overlayHeight - menuHeight - menuPadding * 2 - reactionBarHeight - reactionBarTopPadding; - - endScale = spaceAvailableForItem / conversationItemSnapshot.getHeight(); - endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1); - endY = reactionBarHeight - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale) + menuPadding + reactionBarTopPadding; - reactionBarBackgroundY = reactionBarTopPadding; - endApparentTop = reactionBarHeight + menuPadding + reactionBarTopPadding; - } - } - } - - reactionBarBackgroundY = Math.max(reactionBarBackgroundY, -statusBarHeight); - - hideAnimatorSet.end(); - setVisibility(View.VISIBLE); - - float scrubberX; - if (isMessageOnLeft) { - scrubberX = scrubberHorizontalMargin; - } else { - scrubberX = getWidth() - scrubberWidth - scrubberHorizontalMargin; - } - - foregroundView.setX(scrubberX); - foregroundView.setY(reactionBarBackgroundY + reactionBarHeight / 2f - foregroundView.getHeight() / 2f); - - backgroundView.setX(scrubberX); - backgroundView.setY(reactionBarBackgroundY); - - verticalScrubBoundary.update(reactionBarBackgroundY, - lastSeenDownPoint.y + distanceFromTouchDownPointToBottomOfScrubberDeadZone); - - updateBoundsOnLayoutChanged(); - - revealAnimatorSet.start(); - - if (isWideLayout) { - float scrubberRight = scrubberX + scrubberWidth; - float offsetX = isMessageOnLeft ? scrubberRight + menuPadding : scrubberX - contextMenu.getMaxWidth() - menuPadding; - contextMenu.show((int) offsetX, (int) Math.min(backgroundView.getY(), overlayHeight - contextMenu.getMaxHeight())); - } else { - float contentX = isMessageOnLeft ? scrubberHorizontalMargin : selectedConversationModel.getBubbleX(); - float offsetX = isMessageOnLeft ? contentX : -contextMenu.getMaxWidth() + contentX + bubbleWidth; - - float menuTop = endApparentTop + (conversationItemSnapshot.getHeight() * endScale); - contextMenu.show((int) offsetX, (int) (menuTop + menuPadding)); - } - - int revealDuration = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_duration); - - conversationBubble.animate() - .scaleX(endScale) - .scaleY(endScale) - .setDuration(revealDuration); - - conversationItem.animate() - .x(endX) - .y(endY) - .setDuration(revealDuration); - } - - private float getReactionBarOffsetForTouch(float itemY, - float contextMenuTop, - float contextMenuPadding, - float reactionBarOffset, - int reactionBarHeight, - float spaceNeededBetweenTopOfScreenAndTopOfReactionBar, - float messageTop) - { - float adjustedTouchY = itemY - statusBarHeight; - float reactionStartingPoint = Math.min(adjustedTouchY, contextMenuTop); - - float spaceBetweenTopOfMessageAndTopOfContextMenu = Math.abs(messageTop - contextMenuTop); - - if (spaceBetweenTopOfMessageAndTopOfContextMenu < DimensionUnit.DP.toPixels(150)) { - float offsetToMakeReactionBarOffsetMatchMenuPadding = reactionBarOffset - contextMenuPadding; - reactionStartingPoint = messageTop + offsetToMakeReactionBarOffsetMatchMenuPadding; - } - - return Math.max(reactionStartingPoint - reactionBarOffset - reactionBarHeight, spaceNeededBetweenTopOfScreenAndTopOfReactionBar); - } - - private void updateSystemUiOnShow(@NonNull Activity activity) { - Window window = activity.getWindow(); - int barColor = ContextCompat.getColor(getContext(), R.color.reactions_screen_dark_shade_color); - - originalStatusBarColor = window.getStatusBarColor(); - WindowUtil.setStatusBarColor(window, barColor); - - originalNavigationBarColor = window.getNavigationBarColor(); - WindowUtil.setNavigationBarColor(window, barColor); - - if (!ThemeUtil.isDarkTheme(getContext())) { - WindowUtil.clearLightStatusBar(window); - WindowUtil.clearLightNavigationBar(window); - } - } - - public void hide() { - hideInternal(onHideListener); - } - - public void hideForReactWithAny() { - hideInternal(onHideListener); - } - - private void hideInternal(@Nullable OnHideListener onHideListener) { - overlayState = OverlayState.HIDDEN; - - AnimatorSet animatorSet = newHideAnimatorSet(); - hideAnimatorSet = animatorSet; - - revealAnimatorSet.end(); - animatorSet.start(); - - if (onHideListener != null) { - onHideListener.startHide(); - } - - if (selectedConversationModel.getFocusedView() != null) { - ViewUtil.focusAndShowKeyboard(selectedConversationModel.getFocusedView()); - } - - animatorSet.addListener(new AnimationCompleteListener() { - @Override public void onAnimationEnd(Animator animation) { - animatorSet.removeListener(this); - - if (onHideListener != null) { - onHideListener.onHide(); - } - } - }); - - if (contextMenu != null) { - contextMenu.dismiss(); - } - } - - public boolean isShowing() { - return overlayState != OverlayState.HIDDEN; - } - - public @NonNull MessageRecord getMessageRecord() { - return messageRecord; - } - - @Override - protected void onLayout(boolean changed, int l, int t, int r, int b) { - super.onLayout(changed, l, t, r, b); - - updateBoundsOnLayoutChanged(); - } - - private void updateBoundsOnLayoutChanged() { - backgroundView.getGlobalVisibleRect(emojiStripViewBounds); - emojiViews[0].getGlobalVisibleRect(emojiViewGlobalRect); - emojiStripViewBounds.left = getStart(emojiViewGlobalRect); - emojiViews[emojiViews.length - 1].getGlobalVisibleRect(emojiViewGlobalRect); - emojiStripViewBounds.right = getEnd(emojiViewGlobalRect); - - segmentSize = emojiStripViewBounds.width() / (float) emojiViews.length; - } - - private int getStart(@NonNull Rect rect) { - if (ViewUtil.isLtr(this)) { - return rect.left; - } else { - return rect.right; - } - } - - private int getEnd(@NonNull Rect rect) { - if (ViewUtil.isLtr(this)) { - return rect.right; - } else { - return rect.left; - } - } - - public boolean applyTouchEvent(@NonNull MotionEvent motionEvent) { - if (!isShowing()) { - throw new IllegalStateException("Touch events should only be propagated to this method if we are displaying the scrubber."); - } - - if ((motionEvent.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK) != 0) { - return true; - } - - if (overlayState == OverlayState.UNINITAILIZED) { - downIsOurs = false; - - deadzoneTouchPoint.set(motionEvent.getX(), motionEvent.getY()); - - overlayState = OverlayState.DEADZONE; - } - - if (overlayState == OverlayState.DEADZONE) { - float deltaX = Math.abs(deadzoneTouchPoint.x - motionEvent.getX()); - float deltaY = Math.abs(deadzoneTouchPoint.y - motionEvent.getY()); - - if (deltaX > touchDownDeadZoneSize || deltaY > touchDownDeadZoneSize) { - overlayState = OverlayState.SCRUB; - } else { - if (motionEvent.getAction() == MotionEvent.ACTION_UP) { - overlayState = OverlayState.TAP; - - if (downIsOurs) { - handleUpEvent(); - return true; - } - } - - return MotionEvent.ACTION_MOVE == motionEvent.getAction(); - } - } - - switch (motionEvent.getAction()) { - case MotionEvent.ACTION_DOWN: - selected = getSelectedIndexViaDownEvent(motionEvent); - - deadzoneTouchPoint.set(motionEvent.getX(), motionEvent.getY()); - overlayState = OverlayState.DEADZONE; - downIsOurs = true; - return true; - case MotionEvent.ACTION_MOVE: - selected = getSelectedIndexViaMoveEvent(motionEvent); - return true; - case MotionEvent.ACTION_UP: - handleUpEvent(); - return downIsOurs; - case MotionEvent.ACTION_CANCEL: - hide(); - return downIsOurs; - default: - return false; - } - } - - private void setupSelectedEmoji() { - final List emojis = recentEmojiPageModel.getEmoji(); - - for (int i = 0; i < emojiViews.length; i++) { - final EmojiImageView view = emojiViews[i]; - - view.setScaleX(1.0f); - view.setScaleY(1.0f); - view.setTranslationY(0); - - boolean isAtCustomIndex = i == customEmojiIndex; - - if (isAtCustomIndex) { - view.setImageDrawable(ContextCompat.getDrawable(getContext(), R.drawable.ic_baseline_add_24)); - view.setTag(null); - } else { - view.setImageEmoji(emojis.get(i)); - } - } - } - - private int getSelectedIndexViaDownEvent(@NonNull MotionEvent motionEvent) { - return getSelectedIndexViaMotionEvent(motionEvent, new Boundary(emojiStripViewBounds.top, emojiStripViewBounds.bottom)); - } - - private int getSelectedIndexViaMoveEvent(@NonNull MotionEvent motionEvent) { - return getSelectedIndexViaMotionEvent(motionEvent, verticalScrubBoundary); - } - - private int getSelectedIndexViaMotionEvent(@NonNull MotionEvent motionEvent, @NonNull Boundary boundary) { - int selected = -1; - - if (backgroundView.getVisibility() != View.VISIBLE) { - return selected; - } - - for (int i = 0; i < emojiViews.length; i++) { - final float emojiLeft = (segmentSize * i) + emojiStripViewBounds.left; - horizontalEmojiBoundary.update(emojiLeft, emojiLeft + segmentSize); - - if (horizontalEmojiBoundary.contains(motionEvent.getX()) && boundary.contains(motionEvent.getY())) { - selected = i; - } - } - - if (this.selected != -1 && this.selected != selected) { - shrinkView(emojiViews[this.selected]); - } - - if (this.selected != selected && selected != -1) { - growView(emojiViews[selected]); - } - - return selected; - } - - private void growView(@NonNull View view) { - view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP); - view.animate() - .scaleY(1.5f) - .scaleX(1.5f) - .translationY(-selectedVerticalTranslation) - .setDuration(200) - .setInterpolator(INTERPOLATOR) - .start(); - } - - private void shrinkView(@NonNull View view) { - view.animate() - .scaleX(1.0f) - .scaleY(1.0f) - .translationY(0) - .setDuration(200) - .setInterpolator(INTERPOLATOR) - .start(); - } - - private void handleUpEvent() { - if (selected != -1 && onReactionSelectedListener != null && backgroundView.getVisibility() == View.VISIBLE) { - if (selected == customEmojiIndex) { - onReactionSelectedListener.onCustomReactionSelected(messageRecord, emojiViews[selected].getTag() != null); - } else { - onReactionSelectedListener.onReactionSelected(messageRecord, recentEmojiPageModel.getEmoji().get(selected)); - } - } else { - hide(); - } - } - - public void setOnReactionSelectedListener(@Nullable OnReactionSelectedListener onReactionSelectedListener) { - this.onReactionSelectedListener = onReactionSelectedListener; - } - - public void setOnActionSelectedListener(@Nullable OnActionSelectedListener onActionSelectedListener) { - this.onActionSelectedListener = onActionSelectedListener; - } - - public void setOnHideListener(@Nullable OnHideListener onHideListener) { - this.onHideListener = onHideListener; - } - - private @Nullable String getOldEmoji(@NonNull MessageRecord messageRecord) { - return Stream.of(messageRecord.getReactions()) - .filter(record -> record.getAuthor().equals(TextSecurePreferences.getLocalNumber(getContext()))) - .findFirst() - .map(ReactionRecord::getEmoji) - .orElse(null); - } - - private @NonNull List getMenuActionItems(@NonNull MessageRecord message) { - List items = new ArrayList<>(); - - // Prepare - boolean containsControlMessage = message.isUpdate(); - boolean hasText = !message.getBody().isEmpty(); - OpenGroup openGroup = DatabaseComponent.get(getContext()).lokiThreadDatabase().getOpenGroupChat(message.getThreadId()); - Recipient recipient = DatabaseComponent.get(getContext()).threadDatabase().getRecipientForThreadId(message.getThreadId()); - if (recipient == null) return Collections.emptyList(); - - String userPublicKey = TextSecurePreferences.getLocalNumber(getContext()); - // Select message - items.add(new ActionItem(R.attr.menu_select_icon, getContext().getResources().getString(R.string.conversation_context__menu_select), () -> handleActionItemClicked(Action.SELECT), - getContext().getResources().getString(R.string.AccessibilityId_select))); - // Reply - boolean canWrite = openGroup == null || openGroup.getCanWrite(); - if (canWrite && !message.isPending() && !message.isFailed()) { - items.add( - new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_reply), () -> handleActionItemClicked(Action.REPLY), - getContext().getResources().getString(R.string.AccessibilityId_reply_message)) - ); - } - // Copy message text - if (!containsControlMessage && hasText) { - items.add(new ActionItem(R.attr.menu_copy_icon, getContext().getResources().getString(R.string.copy), () -> handleActionItemClicked(Action.COPY_MESSAGE))); - } - // Copy Session ID - if (recipient.isGroupRecipient() && !recipient.isOpenGroupRecipient() && !message.getRecipient().getAddress().toString().equals(userPublicKey)) { - items.add(new ActionItem( - R.attr.menu_copy_icon, getContext().getResources().getString(R.string.activity_conversation_menu_copy_session_id), () -> handleActionItemClicked(Action.COPY_SESSION_ID)) - ); - } - // Delete message - if (ConversationMenuItemHelper.userCanDeleteSelectedItems(getContext(), message, openGroup, userPublicKey, blindedPublicKey)) { - items.add(new ActionItem(R.attr.menu_trash_icon, getContext().getResources().getString(R.string.delete), - () -> handleActionItemClicked(Action.DELETE), - getContext().getResources().getString(R.string.AccessibilityId_delete_message) - ) - ); - } - // Ban user - if (ConversationMenuItemHelper.userCanBanSelectedUsers(getContext(), message, openGroup, userPublicKey, blindedPublicKey)) { - items.add(new ActionItem(R.attr.menu_block_icon, getContext().getResources().getString(R.string.conversation_context__menu_ban_user), () -> handleActionItemClicked(Action.BAN_USER))); - } - // Ban and delete all - if (ConversationMenuItemHelper.userCanBanSelectedUsers(getContext(), message, openGroup, userPublicKey, blindedPublicKey)) { - 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 - 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))); - } - // Resync - if (message.isSyncFailed()) { - items.add(new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_resync_message), () -> handleActionItemClicked(Action.RESYNC))); - } - // Save media - if (message.isMms() && ((MediaMmsMessageRecord)message).containsMediaSlide()) { - items.add(new ActionItem(R.attr.menu_save_icon, getContext().getResources().getString(R.string.conversation_context_image__save_attachment), () -> handleActionItemClicked(Action.DOWNLOAD), - getContext().getResources().getString(R.string.AccessibilityId_save_attachment)) - ); - } - - backgroundView.setVisibility(View.VISIBLE); - foregroundView.setVisibility(View.VISIBLE); - - return items; - } - - private void handleActionItemClicked(@NonNull Action action) { - hideInternal(new OnHideListener() { - @Override public void startHide() { - if (onHideListener != null) { - onHideListener.startHide(); - } - } - - @Override public void onHide() { - if (onHideListener != null) { - onHideListener.onHide(); - } - - if (onActionSelectedListener != null) { - onActionSelectedListener.onActionSelected(action); - } - } - }); - } - - private void initAnimators() { - - int revealDuration = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_duration); - int revealOffset = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_offset); - - List reveals = Stream.of(emojiViews) - .mapIndexed((idx, v) -> { - Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_reveal); - anim.setTarget(v); - anim.setStartDelay(idx * animationEmojiStartDelayFactor); - return anim; - }) - .toList(); - - Animator backgroundRevealAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_in); - backgroundRevealAnim.setTarget(backgroundView); - backgroundRevealAnim.setDuration(revealDuration); - backgroundRevealAnim.setStartDelay(revealOffset); - reveals.add(backgroundRevealAnim); - - revealAnimatorSet.setInterpolator(INTERPOLATOR); - revealAnimatorSet.playTogether(reveals); - } - - private @NonNull AnimatorSet newHideAnimatorSet() { - AnimatorSet set = new AnimatorSet(); - - set.addListener(new AnimationCompleteListener() { - @Override - public void onAnimationEnd(Animator animation) { - setVisibility(View.GONE); - } - }); - set.setInterpolator(INTERPOLATOR); - - set.playTogether(newHideAnimators()); - - return set; - } - - private @NonNull List newHideAnimators() { - int duration = getContext().getResources().getInteger(R.integer.reaction_scrubber_hide_duration); - - List animators = new ArrayList<>(Stream.of(emojiViews) - .mapIndexed((idx, v) -> { - Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_hide); - anim.setTarget(v); - return anim; - }) - .toList()); - - Animator backgroundHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out); - backgroundHideAnim.setTarget(backgroundView); - backgroundHideAnim.setDuration(duration); - animators.add(backgroundHideAnim); - - ObjectAnimator itemScaleXAnim = new ObjectAnimator(); - itemScaleXAnim.setProperty(View.SCALE_X); - itemScaleXAnim.setFloatValues(1f); - itemScaleXAnim.setTarget(conversationItem); - itemScaleXAnim.setDuration(duration); - animators.add(itemScaleXAnim); - - ObjectAnimator itemScaleYAnim = new ObjectAnimator(); - itemScaleYAnim.setProperty(View.SCALE_Y); - itemScaleYAnim.setFloatValues(1f); - itemScaleYAnim.setTarget(conversationItem); - itemScaleYAnim.setDuration(duration); - animators.add(itemScaleYAnim); - - ObjectAnimator itemXAnim = new ObjectAnimator(); - itemXAnim.setProperty(View.X); - itemXAnim.setFloatValues(selectedConversationModel.getBubbleX()); - itemXAnim.setTarget(conversationItem); - itemXAnim.setDuration(duration); - animators.add(itemXAnim); - - ObjectAnimator itemYAnim = new ObjectAnimator(); - itemYAnim.setProperty(View.Y); - itemYAnim.setFloatValues(selectedConversationModel.getBubbleY() - statusBarHeight); - itemYAnim.setTarget(conversationItem); - itemYAnim.setDuration(duration); - animators.add(itemYAnim); - - if (activity != null) { - ValueAnimator statusBarAnim = ValueAnimator.ofArgb(activity.getWindow().getStatusBarColor(), originalStatusBarColor); - statusBarAnim.setDuration(duration); - statusBarAnim.addUpdateListener(animation -> { - WindowUtil.setStatusBarColor(activity.getWindow(), (int) animation.getAnimatedValue()); - }); - animators.add(statusBarAnim); - - ValueAnimator navigationBarAnim = ValueAnimator.ofArgb(activity.getWindow().getStatusBarColor(), originalNavigationBarColor); - navigationBarAnim.setDuration(duration); - navigationBarAnim.addUpdateListener(animation -> { - WindowUtil.setNavigationBarColor(activity.getWindow(), (int) animation.getAnimatedValue()); - }); - animators.add(navigationBarAnim); - } - - return animators; - } - - public interface OnHideListener { - void startHide(); - void onHide(); - } - - public interface OnReactionSelectedListener { - void onReactionSelected(@NonNull MessageRecord messageRecord, String emoji); - void onCustomReactionSelected(@NonNull MessageRecord messageRecord, boolean hasAddedCustomEmoji); - } - - public interface OnActionSelectedListener { - void onActionSelected(@NonNull Action action); - } - - private static class Boundary { - private float min; - private float max; - - Boundary() {} - - Boundary(float min, float max) { - update(min, max); - } - - private void update(float min, float max) { - this.min = min; - this.max = max; - } - - public boolean contains(float value) { - if (min < max) { - return this.min < value && this.max > value; - } else { - return this.min > value && this.max < value; - } - } - } - - private enum OverlayState { - HIDDEN, - UNINITAILIZED, - DEADZONE, - SCRUB, - TAP - } - - public enum Action { - REPLY, - RESEND, - RESYNC, - DOWNLOAD, - COPY_MESSAGE, - COPY_SESSION_ID, - VIEW_INFO, - SELECT, - DELETE, - BAN_USER, - BAN_AND_DELETE_ALL, - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt new file mode 100644 index 0000000000..405d2a3c0e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt @@ -0,0 +1,720 @@ +package org.thoughtcrime.securesms.conversation.v2 + +import android.animation.Animator +import android.animation.AnimatorSet +import android.animation.ObjectAnimator +import android.animation.ValueAnimator +import android.app.Activity +import android.content.Context +import android.graphics.PointF +import android.graphics.Rect +import android.graphics.drawable.BitmapDrawable +import android.util.AttributeSet +import android.view.HapticFeedbackConstants +import android.view.MotionEvent +import android.view.View +import android.view.animation.DecelerateInterpolator +import android.view.animation.Interpolator +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.ContextCompat +import androidx.core.view.doOnLayout +import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import network.loki.messenger.R +import org.session.libsession.snode.SnodeAPI +import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber +import org.session.libsession.utilities.ThemeUtil +import org.thoughtcrime.securesms.components.emoji.EmojiImageView +import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel +import org.thoughtcrime.securesms.components.menu.ActionItem +import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuItemHelper.userCanBanSelectedUsers +import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuItemHelper.userCanDeleteSelectedItems +import org.thoughtcrime.securesms.database.MmsSmsDatabase +import org.thoughtcrime.securesms.database.SessionContactDatabase +import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.database.model.ReactionRecord +import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get +import org.thoughtcrime.securesms.repository.ConversationRepository +import org.thoughtcrime.securesms.util.AnimationCompleteListener +import org.thoughtcrime.securesms.util.DateUtils +import java.util.Locale +import javax.inject.Inject +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +@AndroidEntryPoint +class ConversationReactionOverlay : FrameLayout { + private val emojiViewGlobalRect = Rect() + private val emojiStripViewBounds = Rect() + private var segmentSize = 0f + private val horizontalEmojiBoundary = Boundary() + private val verticalScrubBoundary = Boundary() + private val deadzoneTouchPoint = PointF() + private lateinit var activity: Activity + lateinit var messageRecord: MessageRecord + private lateinit var selectedConversationModel: SelectedConversationModel + private var blindedPublicKey: String? = null + private var overlayState = OverlayState.HIDDEN + private lateinit var recentEmojiPageModel: RecentEmojiPageModel + private var downIsOurs = false + private var selected = -1 + private var customEmojiIndex = 0 + private var originalStatusBarColor = 0 + private var originalNavigationBarColor = 0 + private lateinit var dropdownAnchor: View + private lateinit var conversationItem: LinearLayout + private lateinit var conversationBubble: View + private lateinit var conversationTimestamp: TextView + private lateinit var backgroundView: View + private lateinit var foregroundView: ConstraintLayout + private lateinit var emojiViews: List + private var contextMenu: ConversationContextMenu? = null + private var touchDownDeadZoneSize = 0f + private var distanceFromTouchDownPointToBottomOfScrubberDeadZone = 0f + private var scrubberWidth = 0 + private var selectedVerticalTranslation = 0 + private var scrubberHorizontalMargin = 0 + private var animationEmojiStartDelayFactor = 0 + private var statusBarHeight = 0 + private var onReactionSelectedListener: OnReactionSelectedListener? = null + private var onActionSelectedListener: OnActionSelectedListener? = null + private var onHideListener: OnHideListener? = null + private val revealAnimatorSet = AnimatorSet() + private var hideAnimatorSet = AnimatorSet() + + @Inject lateinit var mmsSmsDatabase: MmsSmsDatabase + @Inject lateinit var repository: ConversationRepository + private val scope = CoroutineScope(Dispatchers.Default) + private var job: Job? = null + + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + + override fun onFinishInflate() { + super.onFinishInflate() + dropdownAnchor = findViewById(R.id.dropdown_anchor) + conversationItem = findViewById(R.id.conversation_item) + conversationBubble = conversationItem.findViewById(R.id.conversation_item_bubble) + conversationTimestamp = conversationItem.findViewById(R.id.conversation_item_timestamp) + backgroundView = findViewById(R.id.conversation_reaction_scrubber_background) + foregroundView = findViewById(R.id.conversation_reaction_scrubber_foreground) + emojiViews = listOf(R.id.reaction_1, R.id.reaction_2, R.id.reaction_3, R.id.reaction_4, R.id.reaction_5, R.id.reaction_6, R.id.reaction_7).map { findViewById(it) } + customEmojiIndex = emojiViews.size - 1 + distanceFromTouchDownPointToBottomOfScrubberDeadZone = resources.getDimensionPixelSize(R.dimen.conversation_reaction_scrub_deadzone_distance_from_touch_bottom).toFloat() + touchDownDeadZoneSize = resources.getDimensionPixelSize(R.dimen.conversation_reaction_touch_deadzone_size).toFloat() + scrubberWidth = resources.getDimensionPixelOffset(R.dimen.reaction_scrubber_width) + selectedVerticalTranslation = resources.getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_vertical_translation) + scrubberHorizontalMargin = resources.getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_horizontal_margin) + animationEmojiStartDelayFactor = resources.getInteger(R.integer.reaction_scrubber_emoji_reveal_duration_start_delay_factor) + initAnimators() + } + + fun show(activity: Activity, + messageRecord: MessageRecord, + lastSeenDownPoint: PointF, + selectedConversationModel: SelectedConversationModel, + blindedPublicKey: String?) { + job?.cancel() + if (overlayState != OverlayState.HIDDEN) return + this.messageRecord = messageRecord + this.selectedConversationModel = selectedConversationModel + this.blindedPublicKey = blindedPublicKey + overlayState = OverlayState.UNINITAILIZED + selected = -1 + recentEmojiPageModel = RecentEmojiPageModel(activity) + setupSelectedEmoji() + val statusBarBackground = activity.findViewById(android.R.id.statusBarBackground) + statusBarHeight = statusBarBackground?.height ?: 0 + val conversationItemSnapshot = selectedConversationModel.bitmap + conversationBubble.layoutParams = LinearLayout.LayoutParams(conversationItemSnapshot.width, conversationItemSnapshot.height) + conversationBubble.background = BitmapDrawable(resources, conversationItemSnapshot) + conversationTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), messageRecord.timestamp) + updateConversationTimestamp(messageRecord) + val isMessageOnLeft = selectedConversationModel.isOutgoing xor ViewUtil.isLtr(this) + conversationItem.scaleX = LONG_PRESS_SCALE_FACTOR + conversationItem.scaleY = LONG_PRESS_SCALE_FACTOR + visibility = INVISIBLE + this.activity = activity + updateSystemUiOnShow(activity) + doOnLayout { showAfterLayout(messageRecord, lastSeenDownPoint, isMessageOnLeft) } + + job = scope.launch(Dispatchers.IO) { + repository.changes(messageRecord.threadId) + .filter { mmsSmsDatabase.getMessageForTimestamp(messageRecord.timestamp) == null } + .collect { withContext(Dispatchers.Main) { hide() } } + } + } + + private fun updateConversationTimestamp(message: MessageRecord) { + if (message.isOutgoing) conversationBubble.bringToFront() else conversationTimestamp.bringToFront() + } + + private fun showAfterLayout(messageRecord: MessageRecord, + lastSeenDownPoint: PointF, + isMessageOnLeft: Boolean) { + val contextMenu = ConversationContextMenu(dropdownAnchor, getMenuActionItems(messageRecord)) + this.contextMenu = contextMenu + var endX = if (isMessageOnLeft) scrubberHorizontalMargin.toFloat() else selectedConversationModel.bubbleX - conversationItem.width + selectedConversationModel.bubbleWidth + var endY = selectedConversationModel.bubbleY - statusBarHeight + conversationItem.x = endX + conversationItem.y = endY + val conversationItemSnapshot = selectedConversationModel.bitmap + val isWideLayout = contextMenu.getMaxWidth() + scrubberWidth < width + val overlayHeight = height + val bubbleWidth = selectedConversationModel.bubbleWidth + var endApparentTop = endY + var endScale = 1f + val menuPadding = DimensionUnit.DP.toPixels(12f) + val reactionBarTopPadding = DimensionUnit.DP.toPixels(32f) + val reactionBarHeight = backgroundView.height + var reactionBarBackgroundY: Float + if (isWideLayout) { + val everythingFitsVertically = reactionBarHeight + menuPadding + reactionBarTopPadding + conversationItemSnapshot.height < overlayHeight + if (everythingFitsVertically) { + val reactionBarFitsAboveItem = conversationItem.y > reactionBarHeight + menuPadding + reactionBarTopPadding + if (reactionBarFitsAboveItem) { + reactionBarBackgroundY = conversationItem.y - menuPadding - reactionBarHeight + } else { + endY = reactionBarHeight + menuPadding + reactionBarTopPadding + reactionBarBackgroundY = reactionBarTopPadding + } + } else { + val spaceAvailableForItem = overlayHeight - reactionBarHeight - menuPadding - reactionBarTopPadding + endScale = spaceAvailableForItem / conversationItem.height + endX += Util.halfOffsetFromScale(conversationItemSnapshot.width, endScale) * if (isMessageOnLeft) -1 else 1 + endY = reactionBarHeight + menuPadding + reactionBarTopPadding - Util.halfOffsetFromScale(conversationItemSnapshot.height, endScale) + reactionBarBackgroundY = reactionBarTopPadding + } + } else { + val reactionBarOffset = DimensionUnit.DP.toPixels(48f) + val spaceForReactionBar = Math.max(reactionBarHeight + reactionBarOffset, 0f) + val everythingFitsVertically = contextMenu.getMaxHeight() + conversationItemSnapshot.height + menuPadding + spaceForReactionBar < overlayHeight + if (everythingFitsVertically) { + val bubbleBottom = selectedConversationModel.bubbleY + conversationItemSnapshot.height + val menuFitsBelowItem = bubbleBottom + menuPadding + contextMenu.getMaxHeight() <= overlayHeight + statusBarHeight + if (menuFitsBelowItem) { + if (conversationItem.y < 0) { + endY = 0f + } + val contextMenuTop = endY + conversationItemSnapshot.height + reactionBarBackgroundY = getReactionBarOffsetForTouch(selectedConversationModel.bubbleY, contextMenuTop, menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY) + if (reactionBarBackgroundY <= reactionBarTopPadding) { + endY = backgroundView.height + menuPadding + reactionBarTopPadding + } + } else { + endY = overlayHeight - contextMenu.getMaxHeight() - menuPadding - conversationItemSnapshot.height + reactionBarBackgroundY = endY - reactionBarHeight - menuPadding + } + endApparentTop = endY + } else if (reactionBarOffset + reactionBarHeight + contextMenu.getMaxHeight() + menuPadding < overlayHeight) { + val spaceAvailableForItem = overlayHeight.toFloat() - contextMenu.getMaxHeight() - menuPadding - spaceForReactionBar + endScale = spaceAvailableForItem / conversationItemSnapshot.height + endX += Util.halfOffsetFromScale(conversationItemSnapshot.width, endScale) * if (isMessageOnLeft) -1 else 1 + endY = spaceForReactionBar - Util.halfOffsetFromScale(conversationItemSnapshot.height, endScale) + val contextMenuTop = endY + conversationItemSnapshot.height * endScale + reactionBarBackgroundY = reactionBarTopPadding //getReactionBarOffsetForTouch(selectedConversationModel.getBubbleY(), contextMenuTop + Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale), menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY); + endApparentTop = endY + Util.halfOffsetFromScale(conversationItemSnapshot.height, endScale) + } else { + contextMenu.height = contextMenu.getMaxHeight() / 2 + val menuHeight = contextMenu.height + val fitsVertically = menuHeight + conversationItem.height + menuPadding * 2 + reactionBarHeight + reactionBarTopPadding < overlayHeight + if (fitsVertically) { + val bubbleBottom = selectedConversationModel.bubbleY + conversationItemSnapshot.height + val menuFitsBelowItem = bubbleBottom + menuPadding + menuHeight <= overlayHeight + statusBarHeight + if (menuFitsBelowItem) { + reactionBarBackgroundY = conversationItem.y - menuPadding - reactionBarHeight + if (reactionBarBackgroundY < reactionBarTopPadding) { + endY = reactionBarTopPadding + reactionBarHeight + menuPadding + reactionBarBackgroundY = reactionBarTopPadding + } + } else { + endY = overlayHeight - menuHeight - menuPadding - conversationItemSnapshot.height + reactionBarBackgroundY = endY - reactionBarHeight - menuPadding + } + endApparentTop = endY + } else { + val spaceAvailableForItem = overlayHeight.toFloat() - menuHeight - menuPadding * 2 - reactionBarHeight - reactionBarTopPadding + endScale = spaceAvailableForItem / conversationItemSnapshot.height + endX += Util.halfOffsetFromScale(conversationItemSnapshot.width, endScale) * if (isMessageOnLeft) -1 else 1 + endY = reactionBarHeight - Util.halfOffsetFromScale(conversationItemSnapshot.height, endScale) + menuPadding + reactionBarTopPadding + reactionBarBackgroundY = reactionBarTopPadding + endApparentTop = reactionBarHeight + menuPadding + reactionBarTopPadding + } + } + } + reactionBarBackgroundY = Math.max(reactionBarBackgroundY, -statusBarHeight.toFloat()) + hideAnimatorSet.end() + visibility = VISIBLE + val scrubberX = if (isMessageOnLeft) { + scrubberHorizontalMargin.toFloat() + } else { + (width - scrubberWidth - scrubberHorizontalMargin).toFloat() + } + foregroundView.x = scrubberX + foregroundView.y = reactionBarBackgroundY + reactionBarHeight / 2f - foregroundView.height / 2f + backgroundView.x = scrubberX + backgroundView.y = reactionBarBackgroundY + verticalScrubBoundary.update(reactionBarBackgroundY, + lastSeenDownPoint.y + distanceFromTouchDownPointToBottomOfScrubberDeadZone) + updateBoundsOnLayoutChanged() + revealAnimatorSet.start() + if (isWideLayout) { + val scrubberRight = scrubberX + scrubberWidth + val offsetX = if (isMessageOnLeft) scrubberRight + menuPadding else scrubberX - contextMenu.getMaxWidth() - menuPadding + contextMenu.show(offsetX.toInt(), Math.min(backgroundView.y, (overlayHeight - contextMenu.getMaxHeight()).toFloat()).toInt()) + } else { + val contentX = if (isMessageOnLeft) scrubberHorizontalMargin.toFloat() else selectedConversationModel.bubbleX + val offsetX = if (isMessageOnLeft) contentX else -contextMenu.getMaxWidth() + contentX + bubbleWidth + val menuTop = endApparentTop + conversationItemSnapshot.height * endScale + contextMenu.show(offsetX.toInt(), (menuTop + menuPadding).toInt()) + } + val revealDuration = context.resources.getInteger(R.integer.reaction_scrubber_reveal_duration) + conversationBubble.animate() + .scaleX(endScale) + .scaleY(endScale) + .setDuration(revealDuration.toLong()) + conversationItem.animate() + .x(endX) + .y(endY) + .setDuration(revealDuration.toLong()) + } + + private fun getReactionBarOffsetForTouch(itemY: Float, + contextMenuTop: Float, + contextMenuPadding: Float, + reactionBarOffset: Float, + reactionBarHeight: Int, + spaceNeededBetweenTopOfScreenAndTopOfReactionBar: Float, + messageTop: Float): Float { + val adjustedTouchY = itemY - statusBarHeight + var reactionStartingPoint = Math.min(adjustedTouchY, contextMenuTop) + val spaceBetweenTopOfMessageAndTopOfContextMenu = Math.abs(messageTop - contextMenuTop) + if (spaceBetweenTopOfMessageAndTopOfContextMenu < DimensionUnit.DP.toPixels(150f)) { + val offsetToMakeReactionBarOffsetMatchMenuPadding = reactionBarOffset - contextMenuPadding + reactionStartingPoint = messageTop + offsetToMakeReactionBarOffsetMatchMenuPadding + } + return Math.max(reactionStartingPoint - reactionBarOffset - reactionBarHeight, spaceNeededBetweenTopOfScreenAndTopOfReactionBar) + } + + private fun updateSystemUiOnShow(activity: Activity) { + val window = activity.window + val barColor = ContextCompat.getColor(context, R.color.reactions_screen_dark_shade_color) + originalStatusBarColor = window.statusBarColor + WindowUtil.setStatusBarColor(window, barColor) + originalNavigationBarColor = window.navigationBarColor + WindowUtil.setNavigationBarColor(window, barColor) + if (!ThemeUtil.isDarkTheme(context)) { + WindowUtil.clearLightStatusBar(window) + WindowUtil.clearLightNavigationBar(window) + } + } + + fun hide() { + hideInternal(onHideListener) + } + + fun hideForReactWithAny() { + hideInternal(onHideListener) + } + + private fun hideInternal(onHideListener: OnHideListener?) { + job?.cancel() + overlayState = OverlayState.HIDDEN + val animatorSet = newHideAnimatorSet() + hideAnimatorSet = animatorSet + revealAnimatorSet.end() + animatorSet.start() + onHideListener?.startHide() + selectedConversationModel.focusedView?.let(ViewUtil::focusAndShowKeyboard) + animatorSet.addListener(object : AnimationCompleteListener() { + override fun onAnimationEnd(animation: Animator) { + animatorSet.removeListener(this) + onHideListener?.onHide() + } + }) + contextMenu?.dismiss() + } + + val isShowing: Boolean + get() = overlayState != OverlayState.HIDDEN + + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + super.onLayout(changed, l, t, r, b) + updateBoundsOnLayoutChanged() + } + + private fun updateBoundsOnLayoutChanged() { + backgroundView.getGlobalVisibleRect(emojiStripViewBounds) + emojiViews[0].getGlobalVisibleRect(emojiViewGlobalRect) + emojiStripViewBounds.left = getStart(emojiViewGlobalRect) + emojiViews[emojiViews.size - 1].getGlobalVisibleRect(emojiViewGlobalRect) + emojiStripViewBounds.right = getEnd(emojiViewGlobalRect) + segmentSize = emojiStripViewBounds.width() / emojiViews.size.toFloat() + } + + private fun getStart(rect: Rect): Int = if (ViewUtil.isLtr(this)) rect.left else rect.right + + private fun getEnd(rect: Rect): Int = if (ViewUtil.isLtr(this)) rect.right else rect.left + + fun applyTouchEvent(motionEvent: MotionEvent): Boolean { + check(isShowing) { "Touch events should only be propagated to this method if we are displaying the scrubber." } + if (motionEvent.action and MotionEvent.ACTION_POINTER_INDEX_MASK != 0) { + return true + } + if (overlayState == OverlayState.UNINITAILIZED) { + downIsOurs = false + deadzoneTouchPoint[motionEvent.x] = motionEvent.y + overlayState = OverlayState.DEADZONE + } + if (overlayState == OverlayState.DEADZONE) { + val deltaX = Math.abs(deadzoneTouchPoint.x - motionEvent.x) + val deltaY = Math.abs(deadzoneTouchPoint.y - motionEvent.y) + if (deltaX > touchDownDeadZoneSize || deltaY > touchDownDeadZoneSize) { + overlayState = OverlayState.SCRUB + } else { + if (motionEvent.action == MotionEvent.ACTION_UP) { + overlayState = OverlayState.TAP + if (downIsOurs) { + handleUpEvent() + return true + } + } + return MotionEvent.ACTION_MOVE == motionEvent.action + } + } + return when (motionEvent.action) { + MotionEvent.ACTION_DOWN -> { + selected = getSelectedIndexViaDownEvent(motionEvent) + deadzoneTouchPoint[motionEvent.x] = motionEvent.y + overlayState = OverlayState.DEADZONE + downIsOurs = true + true + } + + MotionEvent.ACTION_MOVE -> { + selected = getSelectedIndexViaMoveEvent(motionEvent) + true + } + + MotionEvent.ACTION_UP -> { + handleUpEvent() + downIsOurs + } + + MotionEvent.ACTION_CANCEL -> { + hide() + downIsOurs + } + + else -> false + } + } + + private fun setupSelectedEmoji() { + val emojis = recentEmojiPageModel.emoji + emojiViews.forEachIndexed { i, view -> + view.scaleX = 1.0f + view.scaleY = 1.0f + view.translationY = 0f + val isAtCustomIndex = i == customEmojiIndex + if (isAtCustomIndex) { + view.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_baseline_add_24)) + view.tag = null + } else { + view.setImageEmoji(emojis[i]) + } + } + } + + private fun getSelectedIndexViaDownEvent(motionEvent: MotionEvent): Int = + getSelectedIndexViaMotionEvent(motionEvent, Boundary(emojiStripViewBounds.top.toFloat(), emojiStripViewBounds.bottom.toFloat())) + + private fun getSelectedIndexViaMoveEvent(motionEvent: MotionEvent): Int = + getSelectedIndexViaMotionEvent(motionEvent, verticalScrubBoundary) + + private fun getSelectedIndexViaMotionEvent(motionEvent: MotionEvent, boundary: Boundary): Int { + var selected = -1 + if (backgroundView.visibility != VISIBLE) { + return selected + } + for (i in emojiViews.indices) { + val emojiLeft = segmentSize * i + emojiStripViewBounds.left + horizontalEmojiBoundary.update(emojiLeft, emojiLeft + segmentSize) + if (horizontalEmojiBoundary.contains(motionEvent.x) && boundary.contains(motionEvent.y)) { + selected = i + } + } + if (this.selected != -1 && this.selected != selected) { + shrinkView(emojiViews[this.selected]) + } + if (this.selected != selected && selected != -1) { + growView(emojiViews[selected]) + } + return selected + } + + private fun growView(view: View) { + view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) + view.animate() + .scaleY(1.5f) + .scaleX(1.5f) + .translationY(-selectedVerticalTranslation.toFloat()) + .setDuration(200) + .setInterpolator(INTERPOLATOR) + .start() + } + + private fun shrinkView(view: View) { + view.animate() + .scaleX(1.0f) + .scaleY(1.0f) + .translationY(0f) + .setDuration(200) + .setInterpolator(INTERPOLATOR) + .start() + } + + private fun handleUpEvent() { + val onReactionSelectedListener = onReactionSelectedListener + if (selected != -1 && onReactionSelectedListener != null && backgroundView.visibility == VISIBLE) { + if (selected == customEmojiIndex) { + onReactionSelectedListener.onCustomReactionSelected(messageRecord, emojiViews[selected].tag != null) + } else { + onReactionSelectedListener.onReactionSelected(messageRecord, recentEmojiPageModel.emoji[selected]) + } + } else { + hide() + } + } + + fun setOnReactionSelectedListener(onReactionSelectedListener: OnReactionSelectedListener?) { + this.onReactionSelectedListener = onReactionSelectedListener + } + + fun setOnActionSelectedListener(onActionSelectedListener: OnActionSelectedListener?) { + this.onActionSelectedListener = onActionSelectedListener + } + + fun setOnHideListener(onHideListener: OnHideListener?) { + this.onHideListener = onHideListener + } + + private fun getOldEmoji(messageRecord: MessageRecord): String? = + messageRecord.reactions + .filter { it.author == getLocalNumber(context) } + .firstOrNull() + ?.let(ReactionRecord::emoji) + + private fun getMenuActionItems(message: MessageRecord): List { + val items: MutableList = ArrayList() + + // Prepare + val containsControlMessage = message.isUpdate + val hasText = !message.body.isEmpty() + val openGroup = get(context).lokiThreadDatabase().getOpenGroupChat(message.threadId) + val recipient = get(context).threadDatabase().getRecipientForThreadId(message.threadId) + ?: return emptyList() + val userPublicKey = getLocalNumber(context)!! + // Select message + items += ActionItem(R.attr.menu_select_icon, R.string.conversation_context__menu_select, { handleActionItemClicked(Action.SELECT) }, R.string.AccessibilityId_select) + // Reply + val canWrite = openGroup == null || openGroup.canWrite + if (canWrite && !message.isPending && !message.isFailed) { + items += ActionItem(R.attr.menu_reply_icon, R.string.conversation_context__menu_reply, { handleActionItemClicked(Action.REPLY) }, R.string.AccessibilityId_reply_message) + } + // Copy message text + if (!containsControlMessage && hasText) { + items += ActionItem(R.attr.menu_copy_icon, R.string.copy, { handleActionItemClicked(Action.COPY_MESSAGE) }) + } + // Copy Session ID + if (recipient.isGroupRecipient && !recipient.isOpenGroupRecipient && message.recipient.address.toString() != userPublicKey) { + items += ActionItem(R.attr.menu_copy_icon, R.string.activity_conversation_menu_copy_session_id, { handleActionItemClicked(Action.COPY_SESSION_ID) }) + } + // Delete message + if (userCanDeleteSelectedItems(context, message, openGroup, userPublicKey, blindedPublicKey)) { + items += ActionItem(R.attr.menu_trash_icon, R.string.delete, { handleActionItemClicked(Action.DELETE) }, R.string.AccessibilityId_delete_message, message.subtitle, R.color.destructive) + } + // Ban user + if (userCanBanSelectedUsers(context, message, openGroup, userPublicKey, blindedPublicKey)) { + items += ActionItem(R.attr.menu_block_icon, R.string.conversation_context__menu_ban_user, { handleActionItemClicked(Action.BAN_USER) }) + } + // Ban and delete all + if (userCanBanSelectedUsers(context, message, openGroup, userPublicKey, blindedPublicKey)) { + items += ActionItem(R.attr.menu_trash_icon, R.string.conversation_context__menu_ban_and_delete_all, { handleActionItemClicked(Action.BAN_AND_DELETE_ALL) }) + } + // Message detail + items += ActionItem(R.attr.menu_info_icon, R.string.conversation_context__menu_message_details, { handleActionItemClicked(Action.VIEW_INFO) }) + // Resend + if (message.isFailed) { + items += ActionItem(R.attr.menu_reply_icon, R.string.conversation_context__menu_resend_message, { handleActionItemClicked(Action.RESEND) }) + } + // Resync + if (message.isSyncFailed) { + items += ActionItem(R.attr.menu_reply_icon, R.string.conversation_context__menu_resync_message, { handleActionItemClicked(Action.RESYNC) }) + } + // Save media + if (message.isMms && (message as MediaMmsMessageRecord).containsMediaSlide()) { + items += ActionItem(R.attr.menu_save_icon, R.string.conversation_context_image__save_attachment, { handleActionItemClicked(Action.DOWNLOAD) }, R.string.AccessibilityId_save_attachment) + } + backgroundView.visibility = VISIBLE + foregroundView.visibility = VISIBLE + return items + } + + private fun handleActionItemClicked(action: Action) { + hideInternal(object : OnHideListener { + override fun startHide() { + onHideListener?.startHide() + } + + override fun onHide() { + onHideListener?.onHide() + onActionSelectedListener?.onActionSelected(action) + } + }) + } + + private fun initAnimators() { + val revealDuration = context.resources.getInteger(R.integer.reaction_scrubber_reveal_duration) + val revealOffset = context.resources.getInteger(R.integer.reaction_scrubber_reveal_offset) + val reveals = emojiViews.mapIndexed { idx: Int, v: EmojiImageView? -> + AnimatorInflaterCompat.loadAnimator(context, R.animator.reactions_scrubber_reveal).apply { + setTarget(v) + startDelay = (idx * animationEmojiStartDelayFactor).toLong() + } + } + AnimatorInflaterCompat.loadAnimator(context, android.R.animator.fade_in).apply { + setTarget(backgroundView) + setDuration(revealDuration.toLong()) + startDelay = revealOffset.toLong() + } + revealAnimatorSet.interpolator = INTERPOLATOR + revealAnimatorSet.playTogether(reveals) + } + + private fun newHideAnimatorSet() = AnimatorSet().apply { + addListener(object : AnimationCompleteListener() { + override fun onAnimationEnd(animation: Animator) { + visibility = GONE + } + }) + interpolator = INTERPOLATOR + playTogether(newHideAnimators()) + } + + private fun newHideAnimators(): List { + val duration = context.resources.getInteger(R.integer.reaction_scrubber_hide_duration).toLong() + fun conversationItemAnimator(configure: ObjectAnimator.() -> Unit) = ObjectAnimator().apply { + target = conversationItem + setDuration(duration) + configure() + } + return emojiViews.map { + AnimatorInflaterCompat.loadAnimator(context, R.animator.reactions_scrubber_hide).apply { setTarget(it) } + } + AnimatorInflaterCompat.loadAnimator(context, android.R.animator.fade_out).apply { + setTarget(backgroundView) + setDuration(duration) + } + conversationItemAnimator { + setProperty(SCALE_X) + setFloatValues(1f) + } + conversationItemAnimator { + setProperty(SCALE_Y) + setFloatValues(1f) + } + conversationItemAnimator { + setProperty(X) + setFloatValues(selectedConversationModel.bubbleX) + } + conversationItemAnimator { + setProperty(Y) + setFloatValues(selectedConversationModel.bubbleY - statusBarHeight) + } + ValueAnimator.ofArgb(activity.window.statusBarColor, originalStatusBarColor).apply { + setDuration(duration) + addUpdateListener { animation: ValueAnimator -> WindowUtil.setStatusBarColor(activity.window, animation.animatedValue as Int) } + } + ValueAnimator.ofArgb(activity.window.statusBarColor, originalNavigationBarColor).apply { + setDuration(duration) + addUpdateListener { animation: ValueAnimator -> WindowUtil.setNavigationBarColor(activity.window, animation.animatedValue as Int) } + } + } + + interface OnHideListener { + fun startHide() + fun onHide() + } + + interface OnReactionSelectedListener { + fun onReactionSelected(messageRecord: MessageRecord, emoji: String) + fun onCustomReactionSelected(messageRecord: MessageRecord, hasAddedCustomEmoji: Boolean) + } + + interface OnActionSelectedListener { + fun onActionSelected(action: Action) + } + + private class Boundary(private var min: Float = 0f, private var max: Float = 0f) { + + fun update(min: Float, max: Float) { + this.min = min + this.max = max + } + + operator fun contains(value: Float) = if (min < max) { + min < value && max > value + } else { + min > value && max < value + } + } + + private enum class OverlayState { + HIDDEN, + UNINITAILIZED, + DEADZONE, + SCRUB, + TAP + } + + enum class Action { + REPLY, + RESEND, + RESYNC, + DOWNLOAD, + COPY_MESSAGE, + COPY_SESSION_ID, + VIEW_INFO, + SELECT, + DELETE, + BAN_USER, + BAN_AND_DELETE_ALL + } + + companion object { + const val LONG_PRESS_SCALE_FACTOR = 0.95f + private val INTERPOLATOR: Interpolator = DecelerateInterpolator() + } +} + +private fun Duration.to2partString(): String? = + toComponents { days, hours, minutes, seconds, nanoseconds -> listOf(days.days, hours.hours, minutes.minutes, seconds.seconds) } + .filter { it.inWholeSeconds > 0L }.take(2).takeIf { it.isNotEmpty() }?.joinToString(" ") + +private val MessageRecord.subtitle: ((Context) -> CharSequence?)? + get() = if (expiresIn <= 0) { + null + } else { context -> + (expiresIn - (SnodeAPI.nowWithOffset - (expireStarted.takeIf { it > 0 } ?: timestamp))) + .coerceAtLeast(0L) + .milliseconds + .to2partString() + ?.let { context.getString(R.string.auto_deletes_in, it) } + } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 532e65e196..677dd22f60 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.conversation.v2 +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope @@ -12,10 +13,12 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.utilities.SessionId import org.session.libsession.messaging.utilities.SodiumUtilities +import org.session.libsession.utilities.Address import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log @@ -40,6 +43,9 @@ class ConversationViewModel( private var _recipient: RetrieveOnce = RetrieveOnce { repository.maybeGetRecipientForThreadId(threadId) } + val expirationConfiguration: ExpirationConfiguration? + get() = storage.getExpirationConfiguration(threadId) + val recipient: Recipient? get() = _recipient.value @@ -215,8 +221,11 @@ class ConversationViewModel( } fun hidesInputBar(): Boolean = openGroup?.canWrite != true && - blindedRecipient?.blocksCommunityMessageRequests == true + blindedRecipient?.blocksCommunityMessageRequests == true + fun legacyBannerRecipient(context: Context): Recipient? = recipient?.run { + storage.getLastLegacyRecipient(address.serialize())?.let { Recipient.from(context, Address.fromSerialized(it), false) } + } @dagger.assisted.AssistedFactory interface AssistedFactory { 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 index a73fe41139..4ebc1f27b3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt @@ -5,9 +5,11 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import network.loki.messenger.R @@ -26,6 +28,7 @@ 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.repository.ConversationRepository import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.TitledText import java.util.Date @@ -38,8 +41,11 @@ class MessageDetailsViewModel @Inject constructor( private val lokiMessageDatabase: LokiMessageDatabase, private val mmsSmsDatabase: MmsSmsDatabase, private val threadDb: ThreadDatabase, + private val repository: ConversationRepository, ) : ViewModel() { + private var job: Job? = null + private val state = MutableStateFlow(MessageDetailsState()) val stateFlow = state.asStateFlow() @@ -48,6 +54,8 @@ class MessageDetailsViewModel @Inject constructor( var timestamp: Long = 0L set(value) { + job?.cancel() + field = value val record = mmsSmsDatabase.getMessageForTimestamp(timestamp) @@ -58,6 +66,12 @@ class MessageDetailsViewModel @Inject constructor( val mmsRecord = record as? MmsMessageRecord + job = viewModelScope.launch { + repository.changes(record.threadId) + .filter { mmsSmsDatabase.getMessageForTimestamp(value) == null } + .collect { event.send(Event.Finish) } + } + state.value = record.run { val slides = mmsRecord?.slideDeck?.slides ?: emptyList() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/ExpirationTimerView.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/ExpirationTimerView.java deleted file mode 100644 index 6765232c77..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/ExpirationTimerView.java +++ /dev/null @@ -1,129 +0,0 @@ -package org.thoughtcrime.securesms.conversation.v2.components; - -import android.content.Context; -import android.util.AttributeSet; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.session.libsession.utilities.Util; - -import java.lang.ref.WeakReference; -import java.util.concurrent.TimeUnit; - -import network.loki.messenger.R; - -public class ExpirationTimerView extends androidx.appcompat.widget.AppCompatImageView { - - private long startedAt; - private long expiresIn; - - private boolean visible = false; - private boolean stopped = true; - - private final int[] frames = new int[]{ R.drawable.timer00, - R.drawable.timer05, - R.drawable.timer10, - R.drawable.timer15, - R.drawable.timer20, - R.drawable.timer25, - R.drawable.timer30, - R.drawable.timer35, - R.drawable.timer40, - R.drawable.timer45, - R.drawable.timer50, - R.drawable.timer55, - R.drawable.timer60 }; - - public ExpirationTimerView(Context context) { - super(context); - } - - public ExpirationTimerView(Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - } - - public ExpirationTimerView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - public void setExpirationTime(long startedAt, long expiresIn) { - this.startedAt = startedAt; - this.expiresIn = expiresIn; - setPercentComplete(calculateProgress(this.startedAt, this.expiresIn)); - } - - public void setPercentComplete(float percentage) { - float percentFull = 1 - percentage; - int frame = (int) Math.ceil(percentFull * (frames.length - 1)); - - frame = Math.max(0, Math.min(frame, frames.length - 1)); - setImageResource(frames[frame]); - } - - public void startAnimation() { - synchronized (this) { - visible = true; - if (!stopped) return; - else stopped = false; - } - - Util.runOnMainDelayed(new AnimationUpdateRunnable(this), calculateAnimationDelay(this.startedAt, this.expiresIn)); - } - - public void stopAnimation() { - synchronized (this) { - visible = false; - } - } - - private float calculateProgress(long startedAt, long expiresIn) { - long progressed = System.currentTimeMillis() - startedAt; - float percentComplete = (float)progressed / (float)expiresIn; - - return Math.max(0, Math.min(percentComplete, 1)); - } - - private long calculateAnimationDelay(long startedAt, long expiresIn) { - long progressed = System.currentTimeMillis() - startedAt; - long remaining = expiresIn - progressed; - - if (remaining <= 0) { - return 0; - } else if (remaining < TimeUnit.SECONDS.toMillis(30)) { - return 1000; - } else { - return 5000; - } - } - - private static class AnimationUpdateRunnable implements Runnable { - - private final WeakReference expirationTimerViewReference; - - private AnimationUpdateRunnable(@NonNull ExpirationTimerView expirationTimerView) { - this.expirationTimerViewReference = new WeakReference<>(expirationTimerView); - } - - @Override - public void run() { - ExpirationTimerView timerView = expirationTimerViewReference.get(); - if (timerView == null) return; - - long nextUpdate = timerView.calculateAnimationDelay(timerView.startedAt, timerView.expiresIn); - synchronized (timerView) { - if (timerView.visible) { - timerView.setExpirationTime(timerView.startedAt, timerView.expiresIn); - } else { - timerView.stopped = true; - return; - } - if (nextUpdate <= 0) { - timerView.stopped = true; - return; - } - } - Util.runOnMainDelayed(this, nextUpdate); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/ExpirationTimerView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/ExpirationTimerView.kt new file mode 100644 index 0000000000..d173dacfef --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/ExpirationTimerView.kt @@ -0,0 +1,61 @@ +package org.thoughtcrime.securesms.conversation.v2.components + +import android.content.Context +import android.graphics.drawable.AnimationDrawable +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatImageView +import androidx.core.content.ContextCompat +import network.loki.messenger.R +import org.session.libsession.snode.SnodeAPI.nowWithOffset +import kotlin.math.round + +class ExpirationTimerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : AppCompatImageView(context, attrs, defStyleAttr) { + private val frames = intArrayOf( + R.drawable.timer00, + R.drawable.timer05, + R.drawable.timer10, + R.drawable.timer15, + R.drawable.timer20, + R.drawable.timer25, + R.drawable.timer30, + R.drawable.timer35, + R.drawable.timer40, + R.drawable.timer45, + R.drawable.timer50, + R.drawable.timer55, + R.drawable.timer60 + ) + + fun setTimerIcon() { + setExpirationTime(0L, 0L) + } + + fun setExpirationTime(startedAt: Long, expiresIn: Long) { + if (expiresIn == 0L) { + setImageResource(R.drawable.timer55) + return + } + + if (startedAt == 0L) { + // timer has not started + setImageResource(R.drawable.timer60) + return + } + + val elapsedTime = nowWithOffset - startedAt + val remainingTime = expiresIn - elapsedTime + val remainingPercent = (remainingTime / expiresIn.toFloat()).coerceIn(0f, 1f) + + val frameCount = round(frames.size * remainingPercent).toInt().coerceIn(1, frames.size) + val frameTime = round(remainingTime / frameCount.toFloat()).toInt() + + AnimationDrawable().apply { + frames.take(frameCount).reversed().forEach { addFrame(ContextCompat.getDrawable(context, it)!!, frameTime) } + isOneShot = true + }.also(::setImageDrawable).apply(AnimationDrawable::start) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt index 02ee4ae45f..dadf138ead 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt @@ -4,16 +4,11 @@ import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.graphics.BitmapFactory -import android.graphics.PorterDuff -import android.graphics.PorterDuffColorFilter import android.os.AsyncTask import android.view.Menu import android.view.MenuInflater import android.view.MenuItem -import android.widget.ImageView -import android.widget.TextView import android.widget.Toast -import androidx.annotation.ColorInt import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.ContextThemeWrapper import androidx.appcompat.widget.SearchView @@ -24,10 +19,8 @@ import androidx.core.graphics.drawable.IconCompat import network.loki.messenger.R import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.leave -import org.session.libsession.utilities.ExpirationUtil import org.session.libsession.utilities.GroupUtil.doubleDecodeGroupID import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.toHexString @@ -42,8 +35,8 @@ import org.thoughtcrime.securesms.groups.EditClosedGroupActivity import org.thoughtcrime.securesms.groups.EditClosedGroupActivity.Companion.groupIDKey import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity import org.thoughtcrime.securesms.service.WebRtcCallService -import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.showMuteDialog +import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.util.BitmapUtil import java.io.IOException @@ -53,9 +46,7 @@ object ConversationMenuHelper { menu: Menu, inflater: MenuInflater, thread: Recipient, - threadId: Long, - context: Context, - onOptionsItemSelected: (MenuItem) -> Unit + context: Context ) { // Prepare menu.clear() @@ -63,21 +54,8 @@ object ConversationMenuHelper { // Base menu (options that should always be present) inflater.inflate(R.menu.menu_conversation, menu) // Expiring messages - if (!isOpenGroup && (thread.hasApprovedMe() || thread.isClosedGroupRecipient) && !thread.isBlocked) { - if (thread.expireMessages > 0) { - inflater.inflate(R.menu.menu_conversation_expiration_on, menu) - val item = menu.findItem(R.id.menu_expiring_messages) - item.actionView?.let { actionView -> - val iconView = actionView.findViewById(R.id.menu_badge_icon) - val badgeView = actionView.findViewById(R.id.expiration_badge) - @ColorInt val color = context.getColorFromAttr(android.R.attr.textColorPrimary) - iconView.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.MULTIPLY) - badgeView.text = ExpirationUtil.getExpirationAbbreviatedDisplayValue(context, thread.expireMessages) - actionView.setOnClickListener { onOptionsItemSelected(item) } - } - } else { - inflater.inflate(R.menu.menu_conversation_expiration_off, menu) - } + if (!isOpenGroup && (thread.hasApprovedMe() || thread.isClosedGroupRecipient || thread.isLocalNumber)) { + inflater.inflate(R.menu.menu_conversation_expiration, menu) } // One-on-one chat menu allows copying the session id if (thread.isContactRecipient) { @@ -110,7 +88,7 @@ object ConversationMenuHelper { inflater.inflate(R.menu.menu_conversation_notification_settings, menu) } - if (!thread.isGroupRecipient && thread.hasApprovedMe()) { + if (thread.showCallMenu()) { inflater.inflate(R.menu.menu_conversation_call, menu) } @@ -153,8 +131,7 @@ object ConversationMenuHelper { R.id.menu_view_all_media -> { showAllMedia(context, thread) } R.id.menu_search -> { search(context) } R.id.menu_add_shortcut -> { addShortcut(context, thread) } - R.id.menu_expiring_messages -> { showExpiringMessagesDialog(context, thread) } - R.id.menu_expiring_messages_off -> { showExpiringMessagesDialog(context, thread) } + R.id.menu_expiring_messages -> { showDisappearingMessages(context, thread) } R.id.menu_unblock -> { unblock(context, thread) } R.id.menu_block -> { block(context, thread, deleteThread = false) } R.id.menu_block_delete -> { blockAndDelete(context, thread) } @@ -210,6 +187,7 @@ object ConversationMenuHelper { private fun addShortcut(context: Context, thread: Recipient) { object : AsyncTask() { + @Deprecated("Deprecated in Java") override fun doInBackground(vararg params: Void?): IconCompat? { var icon: IconCompat? = null val contactPhoto = thread.contactPhoto @@ -228,6 +206,7 @@ object ConversationMenuHelper { return icon } + @Deprecated("Deprecated in Java") override fun onPostExecute(icon: IconCompat?) { val name = Optional.fromNullable(thread.name) .or(Optional.fromNullable(thread.profileName)) @@ -244,9 +223,9 @@ object ConversationMenuHelper { }.execute() } - private fun showExpiringMessagesDialog(context: Context, thread: Recipient) { + private fun showDisappearingMessages(context: Context, thread: Recipient) { val listener = context as? ConversationMenuListener ?: return - listener.showExpiringMessagesDialog(thread) + listener.showDisappearingMessages(thread) } private fun unblock(context: Context, thread: Recipient) { @@ -348,7 +327,7 @@ object ConversationMenuHelper { fun unblock() fun copySessionID(sessionId: String) fun copyOpenGroupUrl(thread: Recipient) - fun showExpiringMessagesDialog(thread: Recipient) + fun showDisappearingMessages(thread: Recipient) } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt index 3e370104ea..88df4c4508 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt @@ -3,50 +3,80 @@ package org.thoughtcrime.securesms.conversation.v2.messages import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater -import android.view.View import android.widget.LinearLayout import androidx.core.content.res.ResourcesCompat +import androidx.core.view.isGone +import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView +import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import network.loki.messenger.databinding.ViewControlMessageBinding +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.messages.ExpirationConfiguration +import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessages +import org.thoughtcrime.securesms.conversation.disappearingmessages.expiryMode import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import javax.inject.Inject +@AndroidEntryPoint class ControlMessageView : LinearLayout { + private val TAG = "ControlMessageView" + private lateinit var binding: ViewControlMessageBinding - // region Lifecycle constructor(context: Context) : super(context) { initialize() } constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } + @Inject lateinit var disappearingMessages: DisappearingMessages + private fun initialize() { binding = ViewControlMessageBinding.inflate(LayoutInflater.from(context), this, true) layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT) } - // endregion - // region Updating fun bind(message: MessageRecord, previous: MessageRecord?) { binding.dateBreakTextView.showDateBreak(message, previous) - binding.iconImageView.visibility = View.GONE + binding.iconImageView.isGone = true + binding.expirationTimerView.isGone = true + binding.followSetting.isGone = true var messageBody: CharSequence = message.getDisplayBody(context) - binding.root.contentDescription= null + binding.root.contentDescription = null + binding.textView.text = messageBody when { message.isExpirationTimerUpdate -> { - binding.iconImageView.setImageDrawable( - ResourcesCompat.getDrawable(resources, R.drawable.ic_timer, context.theme) - ) - binding.iconImageView.visibility = View.VISIBLE + binding.apply { + expirationTimerView.isVisible = true + + val threadRecipient = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(message.threadId) + + if (threadRecipient?.isClosedGroupRecipient == true) { + expirationTimerView.setTimerIcon() + } else { + expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn) + } + + followSetting.isVisible = ExpirationConfiguration.isNewConfigEnabled + && !message.isOutgoing + && message.expiryMode != (MessagingModuleConfiguration.shared.storage.getExpirationConfiguration(message.threadId)?.expiryMode ?: ExpiryMode.NONE) + && threadRecipient?.isGroupRecipient != true + + followSetting.setOnClickListener { disappearingMessages.showFollowSettingDialog(context, message) } + } } message.isMediaSavedNotification -> { - binding.iconImageView.setImageDrawable( - ResourcesCompat.getDrawable(resources, R.drawable.ic_file_download_white_36dp, context.theme) - ) - binding.iconImageView.visibility = View.VISIBLE + binding.iconImageView.apply { + setImageDrawable( + ResourcesCompat.getDrawable(resources, R.drawable.ic_file_download_white_36dp, context.theme) + ) + isVisible = true + } } message.isMessageRequestResponse -> { - messageBody = context.getString(R.string.message_requests_accepted) + binding.textView.text = context.getString(R.string.message_requests_accepted) binding.root.contentDescription=context.getString(R.string.AccessibilityId_message_request_config_message) } message.isCallLog -> { @@ -56,16 +86,22 @@ class ControlMessageView : LinearLayout { message.isFirstMissedCall -> R.drawable.ic_info_outline_light else -> R.drawable.ic_missed_call } - binding.iconImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, drawable, context.theme)) - binding.iconImageView.visibility = View.VISIBLE + binding.textView.isVisible = false + binding.callTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(ResourcesCompat.getDrawable(resources, drawable, context.theme), null, null, null) + binding.callTextView.text = messageBody + + if (message.expireStarted > 0 && message.expiresIn > 0) { + binding.expirationTimerView.isVisible = true + binding.expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn) + } } } - binding.textView.text = messageBody + binding.textView.isGone = message.isCallLog + binding.callView.isVisible = message.isCallLog } fun recycle() { } - // endregion } \ No newline at end of file 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 1b41625218..7e220955d6 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 @@ -27,6 +27,7 @@ import org.session.libsession.messaging.sending_receiving.attachments.Attachment import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.utilities.ThemeUtil import org.session.libsession.utilities.getColorFromAttr +import org.session.libsession.utilities.modifyLayoutParams import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.ModalUrlBottomSheet @@ -198,9 +199,9 @@ class VisibleMessageContentView : ConstraintLayout { isStart = isStartOfMessageCluster, isEnd = isEndOfMessageCluster ) - val layoutParams = binding.albumThumbnailView.root.layoutParams as ConstraintLayout.LayoutParams - layoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f - binding.albumThumbnailView.root.layoutParams = layoutParams + binding.albumThumbnailView.root.modifyLayoutParams { + horizontalBias = if (message.isOutgoing) 1f else 0f + } onContentClick.add { event -> binding.albumThumbnailView.root.calculateHitObject(event, message, thread, onAttachmentNeedsDownload) } @@ -233,9 +234,9 @@ class VisibleMessageContentView : ConstraintLayout { } } } - val layoutParams = binding.contentParent.layoutParams as ConstraintLayout.LayoutParams - layoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f - binding.contentParent.layoutParams = layoutParams + binding.contentParent.modifyLayoutParams { + horizontalBias = if (message.isOutgoing) 1f else 0f + } } private val onContentClick: MutableList<((event: MotionEvent) -> Unit)> = mutableListOf() @@ -306,17 +307,9 @@ class VisibleMessageContentView : ConstraintLayout { } @ColorInt - fun getTextColor(context: Context, message: MessageRecord): Int { - val colorAttribute = if (message.isOutgoing) { - // sent - R.attr.message_sent_text_color - } else { - // received - R.attr.message_received_text_color - } - return context.getColorFromAttr(colorAttribute) - } + fun getTextColor(context: Context, message: MessageRecord): Int = context.getColorFromAttr( + if (message.isOutgoing) R.attr.message_sent_text_color else R.attr.message_received_text_color + ) } // endregion - } 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 1062f9f218..e46be2094f 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 @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.conversation.v2.messages +import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.graphics.Canvas @@ -8,6 +9,7 @@ import android.graphics.drawable.ColorDrawable import android.os.Handler import android.os.Looper import android.util.AttributeSet +import android.util.Log import android.view.Gravity import android.view.HapticFeedbackConstants import android.view.MotionEvent @@ -21,7 +23,7 @@ import androidx.appcompat.app.AppCompatActivity import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.ContextCompat import androidx.core.os.bundleOf -import androidx.core.view.isInvisible +import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.marginBottom import dagger.hilt.android.AndroidEntryPoint @@ -34,6 +36,7 @@ import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.Address import org.session.libsession.utilities.ViewUtil import org.session.libsession.utilities.getColorFromAttr +import org.session.libsession.utilities.modifyLayoutParams import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.ThreadUtils import org.thoughtcrime.securesms.ApplicationContext @@ -61,6 +64,8 @@ import kotlin.math.min import kotlin.math.roundToInt import kotlin.math.sqrt +private const val TAG = "VisibleMessageView" + @AndroidEntryPoint class VisibleMessageView : LinearLayout { @Inject lateinit var threadDb: ThreadDatabase @@ -201,43 +206,7 @@ class VisibleMessageView : LinearLayout { binding.dateBreakTextView.text = if (showDateBreak) DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), message.timestamp) else null binding.dateBreakTextView.isVisible = showDateBreak // Message status indicator - if (message.isOutgoing) { - val (iconID, iconColor, textId, contentDescription) = getMessageStatusImage(message) - if (textId != null) { - binding.messageStatusTextView.setText(textId) - - if (iconColor != null) { - binding.messageStatusTextView.setTextColor(iconColor) - } - } - if (iconID != null) { - val drawable = ContextCompat.getDrawable(context, iconID)?.mutate() - if (iconColor != null) { - drawable?.setTint(iconColor) - } - binding.messageStatusImageView.setImageDrawable(drawable) - } - binding.messageStatusImageView.contentDescription = contentDescription - - val lastMessageID = mmsSmsDb.getLastMessageID(message.threadId) - binding.messageStatusTextView.isVisible = ( - textId != null && ( - !message.isSent || - message.id == lastMessageID - ) - ) - binding.messageStatusImageView.isVisible = ( - iconID != null && ( - !message.isSent || - message.id == lastMessageID - ) - ) - } else { - binding.messageStatusTextView.isVisible = false - binding.messageStatusImageView.isVisible = false - } - // Expiration timer - updateExpirationTimer(message) + showStatusMessage(message) // Emoji Reactions val emojiLayoutParams = binding.emojiReactionsView.root.layoutParams as ConstraintLayout.LayoutParams emojiLayoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f @@ -272,122 +241,106 @@ class VisibleMessageView : LinearLayout { onDoubleTap = { binding.messageContentView.root.onContentDoubleTap?.invoke() } } - private fun isStartOfMessageCluster(current: MessageRecord, previous: MessageRecord?, isGroupThread: Boolean): Boolean { - return if (isGroupThread) { - previous == null || previous.isUpdate || !DateUtils.isSameHour(current.timestamp, previous.timestamp) - || current.recipient.address != previous.recipient.address + private fun showStatusMessage(message: MessageRecord) { + val disappearing = message.expiresIn > 0 + + binding.messageInnerLayout.modifyLayoutParams { + gravity = if (message.isOutgoing) Gravity.END else Gravity.START + } + + binding.statusContainer.modifyLayoutParams { + horizontalBias = if (message.isOutgoing) 1f else 0f + } + + binding.expirationTimerView.isGone = true + + if (message.isOutgoing || disappearing) { + val (iconID, iconColor, textId) = getMessageStatusImage(message) + textId?.let(binding.messageStatusTextView::setText) + iconColor?.let(binding.messageStatusTextView::setTextColor) + iconID?.let { ContextCompat.getDrawable(context, it) } + ?.run { iconColor?.let { mutate().apply { setTint(it) } } ?: this } + ?.let(binding.messageStatusImageView::setImageDrawable) + + val lastMessageID = mmsSmsDb.getLastMessageID(message.threadId) + val isLastMessage = message.id == lastMessageID + binding.messageStatusTextView.isVisible = + textId != null && (!message.isSent || isLastMessage || disappearing) + val showTimer = disappearing && !message.isPending + binding.messageStatusImageView.isVisible = + iconID != null && !showTimer && (!message.isSent || isLastMessage) + + binding.messageStatusImageView.bringToFront() + binding.expirationTimerView.bringToFront() + binding.expirationTimerView.isVisible = showTimer + if (showTimer) updateExpirationTimer(message) } else { - previous == null || previous.isUpdate || !DateUtils.isSameHour(current.timestamp, previous.timestamp) - || current.isOutgoing != previous.isOutgoing + binding.messageStatusTextView.isVisible = false + binding.messageStatusImageView.isVisible = false } } - private fun isEndOfMessageCluster(current: MessageRecord, next: MessageRecord?, isGroupThread: Boolean): Boolean { - return if (isGroupThread) { - next == null || next.isUpdate || !DateUtils.isSameHour(current.timestamp, next.timestamp) - || current.recipient.address != next.recipient.address + private fun isStartOfMessageCluster(current: MessageRecord, previous: MessageRecord?, isGroupThread: Boolean): Boolean = + previous == null || previous.isUpdate || !DateUtils.isSameHour(current.timestamp, previous.timestamp) || if (isGroupThread) { + current.recipient.address != previous.recipient.address } else { - next == null || next.isUpdate || !DateUtils.isSameHour(current.timestamp, next.timestamp) - || current.isOutgoing != next.isOutgoing + current.isOutgoing != previous.isOutgoing + } + + private fun isEndOfMessageCluster(current: MessageRecord, next: MessageRecord?, isGroupThread: Boolean): Boolean = + next == null || next.isUpdate || !DateUtils.isSameHour(current.timestamp, next.timestamp) || if (isGroupThread) { + current.recipient.address != next.recipient.address + } else { + current.isOutgoing != next.isOutgoing } - } data class MessageStatusInfo(@DrawableRes val iconId: Int?, @ColorInt val iconTint: Int?, - @StringRes val messageText: Int?, - val contentDescription: String?) + @StringRes val messageText: Int?) private fun getMessageStatusImage(message: MessageRecord): MessageStatusInfo = when { message.isFailed -> MessageStatusInfo( R.drawable.ic_delivery_status_failed, resources.getColor(R.color.destructive, context.theme), - R.string.delivery_status_failed, - null + R.string.delivery_status_failed ) message.isSyncFailed -> MessageStatusInfo( R.drawable.ic_delivery_status_failed, context.getColor(R.color.accent_orange), - R.string.delivery_status_sync_failed, - null + R.string.delivery_status_sync_failed ) message.isPending -> MessageStatusInfo( R.drawable.ic_delivery_status_sending, - context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_sending, - context.getString(R.string.AccessibilityId_message_sent_status_pending) + context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_sending ) message.isResyncing -> MessageStatusInfo( R.drawable.ic_delivery_status_sending, - context.getColor(R.color.accent_orange), R.string.delivery_status_syncing, - context.getString(R.string.AccessibilityId_message_sent_status_syncing) + context.getColor(R.color.accent_orange), R.string.delivery_status_syncing ) - message.isRead -> + message.isRead || !message.isOutgoing -> MessageStatusInfo( R.drawable.ic_delivery_status_read, - context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_read, - null + context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_read ) else -> MessageStatusInfo( R.drawable.ic_delivery_status_sent, context.getColorFromAttr(R.attr.message_status_color), - R.string.delivery_status_sent, - context.getString(R.string.AccessibilityId_message_sent_status_tick) + R.string.delivery_status_sent ) } private fun updateExpirationTimer(message: MessageRecord) { - val container = binding.messageInnerContainer - val layout = binding.messageInnerLayout - - if (message.isOutgoing) binding.messageContentView.root.bringToFront() - else binding.expirationTimerView.bringToFront() - - layout.layoutParams = layout.layoutParams.let { it as FrameLayout.LayoutParams } - .apply { gravity = if (message.isOutgoing) Gravity.END else Gravity.START } - - val containerParams = container.layoutParams as ConstraintLayout.LayoutParams - containerParams.horizontalBias = if (message.isOutgoing) 1f else 0f - container.layoutParams = containerParams - if (message.expiresIn > 0 && !message.isPending) { - binding.expirationTimerView.setColorFilter(context.getColorFromAttr(android.R.attr.textColorPrimary)) - binding.expirationTimerView.isInvisible = false - binding.expirationTimerView.setPercentComplete(0.0f) - if (message.expireStarted > 0) { - binding.expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn) - binding.expirationTimerView.startAnimation() - if (message.expireStarted + message.expiresIn <= SnodeAPI.nowWithOffset) { - ApplicationContext.getInstance(context).expiringMessageManager.checkSchedule() - } - } else if (!message.isMediaPending) { - binding.expirationTimerView.setPercentComplete(0.0f) - binding.expirationTimerView.stopAnimation() - ThreadUtils.queue { - val expirationManager = ApplicationContext.getInstance(context).expiringMessageManager - val id = message.getId() - val mms = message.isMms - if (mms) mmsDb.markExpireStarted(id) else smsDb.markExpireStarted(id) - expirationManager.scheduleDeletion(id, mms, message.expiresIn) - } - } else { - binding.expirationTimerView.stopAnimation() - binding.expirationTimerView.setPercentComplete(0.0f) - } - } else { - binding.expirationTimerView.isInvisible = true - } - container.requestLayout() + if (!message.isOutgoing) binding.messageStatusTextView.bringToFront() + binding.expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn) } private fun handleIsSelectedChanged() { - background = if (snIsSelected) { - ColorDrawable(context.getColorFromAttr(R.attr.message_selected)) - } else { - null - } + background = if (snIsSelected) ColorDrawable(context.getColorFromAttr(R.attr.message_selected)) else null } override fun onDraw(canvas: Canvas) { @@ -424,6 +377,7 @@ class VisibleMessageView : LinearLayout { // endregion // region Interaction + @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(event: MotionEvent): Boolean { if (onPress == null || onSwipeToReply == null || onLongPress == null) { return false } when (event.action) { @@ -524,14 +478,13 @@ class VisibleMessageView : LinearLayout { } private fun maybeShowUserDetails(publicKey: String, threadID: Long) { - val userDetailsBottomSheet = UserDetailsBottomSheet() - val bundle = bundleOf( + UserDetailsBottomSheet().apply { + arguments = bundleOf( UserDetailsBottomSheet.ARGUMENT_PUBLIC_KEY to publicKey, UserDetailsBottomSheet.ARGUMENT_THREAD_ID to threadID - ) - userDetailsBottomSheet.arguments = bundle - val activity = context as AppCompatActivity - userDetailsBottomSheet.show(activity.supportFragmentManager, userDetailsBottomSheet.tag) + ) + show((context as AppCompatActivity).supportFragmentManager, tag) + } } fun playVoiceMessage() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailProgressBar.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailProgressBar.kt index 8188c4f7dd..d5ef6434ee 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailProgressBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailProgressBar.kt @@ -31,7 +31,6 @@ class ThumbnailProgressBar: View { private val drawingRect = Rect() override fun dispatchDraw(canvas: Canvas) { - getDrawingRect(objectRect) drawingRect.set(objectRect) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationConfigurationDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationConfigurationDatabase.kt new file mode 100644 index 0000000000..a65c22545b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationConfigurationDatabase.kt @@ -0,0 +1,85 @@ +package org.thoughtcrime.securesms.database + +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import org.session.libsession.messaging.messages.ExpirationConfiguration +import org.session.libsession.messaging.messages.ExpirationDatabaseMetadata +import org.session.libsession.utilities.GroupUtil.CLOSED_GROUP_PREFIX +import org.session.libsession.utilities.GroupUtil.OPEN_GROUP_INBOX_PREFIX +import org.session.libsession.utilities.GroupUtil.OPEN_GROUP_PREFIX +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper + +class ExpirationConfigurationDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) { + + companion object { + const val TABLE_NAME = "expiration_configuration" + const val THREAD_ID = "thread_id" + const val UPDATED_TIMESTAMP_MS = "updated_timestamp_ms" + + @JvmField + val CREATE_EXPIRATION_CONFIGURATION_TABLE_COMMAND = """ + CREATE TABLE $TABLE_NAME ( + $THREAD_ID INTEGER NOT NULL PRIMARY KEY ON CONFLICT REPLACE, + $UPDATED_TIMESTAMP_MS INTEGER DEFAULT NULL + ) + """.trimIndent() + + @JvmField + val MIGRATE_GROUP_CONVERSATION_EXPIRY_TYPE_COMMAND = """ + INSERT INTO $TABLE_NAME ($THREAD_ID) SELECT ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ID} + FROM ${ThreadDatabase.TABLE_NAME}, ${RecipientDatabase.TABLE_NAME} + WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} LIKE '$CLOSED_GROUP_PREFIX%' + AND EXISTS (SELECT ${RecipientDatabase.EXPIRE_MESSAGES} FROM ${RecipientDatabase.TABLE_NAME} WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} = ${RecipientDatabase.TABLE_NAME}.${RecipientDatabase.ADDRESS} AND ${RecipientDatabase.EXPIRE_MESSAGES} > 0) + """.trimIndent() + + @JvmField + val MIGRATE_ONE_TO_ONE_CONVERSATION_EXPIRY_TYPE_COMMAND = """ + INSERT INTO $TABLE_NAME ($THREAD_ID) SELECT ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ID} + FROM ${ThreadDatabase.TABLE_NAME}, ${RecipientDatabase.TABLE_NAME} + WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$CLOSED_GROUP_PREFIX%' + AND ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$OPEN_GROUP_PREFIX%' + AND ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$OPEN_GROUP_INBOX_PREFIX%' + AND EXISTS (SELECT ${RecipientDatabase.EXPIRE_MESSAGES} FROM ${RecipientDatabase.TABLE_NAME} WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} = ${RecipientDatabase.TABLE_NAME}.${RecipientDatabase.ADDRESS} AND ${RecipientDatabase.EXPIRE_MESSAGES} > 0) + """.trimIndent() + + private fun readExpirationConfiguration(cursor: Cursor): ExpirationDatabaseMetadata { + return ExpirationDatabaseMetadata( + threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID)), + updatedTimestampMs = cursor.getLong(cursor.getColumnIndexOrThrow(UPDATED_TIMESTAMP_MS)) + ) + } + } + + fun getExpirationConfiguration(threadId: Long): ExpirationDatabaseMetadata? { + val query = "$THREAD_ID = ?" + val args = arrayOf("$threadId") + + val configurations: MutableList = mutableListOf() + + readableDatabase.query(TABLE_NAME, null, query, args, null, null, null).use { cursor -> + while (cursor.moveToNext()) { + configurations += readExpirationConfiguration(cursor) + } + } + + return configurations.firstOrNull() + } + + fun setExpirationConfiguration(configuration: ExpirationConfiguration) { + writableDatabase.beginTransaction() + try { + val values = ContentValues().apply { + put(THREAD_ID, configuration.threadId) + put(UPDATED_TIMESTAMP_MS, configuration.updatedTimestampMs) + } + + writableDatabase.insert(TABLE_NAME, null, values) + writableDatabase.setTransactionSuccessful() + notifyConversationListeners(configuration.threadId) + } finally { + writableDatabase.endTransaction() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationInfo.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationInfo.kt new file mode 100644 index 0000000000..40d38e8b70 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationInfo.kt @@ -0,0 +1,12 @@ +package org.thoughtcrime.securesms.database + +data class ExpirationInfo( + val id: Long, + val timestamp: Long, + val expiresIn: Long, + val expireStarted: Long, + val isMms: Boolean +) { + private fun isDisappearAfterSend() = timestamp == expireStarted + fun isDisappearAfterRead() = expiresIn > 0 && !isDisappearAfterSend() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt index 53f4ea3196..f60c53bbe3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt @@ -97,6 +97,16 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( public val groupPublicKey = "group_public_key" @JvmStatic val createClosedGroupPublicKeysTable = "CREATE TABLE $closedGroupPublicKeysTable ($groupPublicKey STRING PRIMARY KEY)" + + private const val LAST_LEGACY_MESSAGE_TABLE = "last_legacy_messages" + // The overall "thread recipient + private const val LAST_LEGACY_THREAD_RECIPIENT = "last_legacy_thread_recipient" + // The individual 'last' person who sent the message with legacy expiration attached + private const val LAST_LEGACY_SENDER_RECIPIENT = "last_legacy_sender_recipient" + private const val LEGACY_THREAD_RECIPIENT_QUERY = "$LAST_LEGACY_THREAD_RECIPIENT = ?" + + const val CREATE_LAST_LEGACY_MESSAGE_TABLE = "CREATE TABLE $LAST_LEGACY_MESSAGE_TABLE ($LAST_LEGACY_THREAD_RECIPIENT STRING PRIMARY KEY, $LAST_LEGACY_SENDER_RECIPIENT STRING NOT NULL);" + // Hard fork service node info const val FORK_INFO_TABLE = "fork_info" const val DUMMY_KEY = "dummy_key" @@ -415,6 +425,31 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( database.endTransaction() } + override fun getLastLegacySenderAddress(threadRecipientAddress: String): String? = + databaseHelper.readableDatabase.get(LAST_LEGACY_MESSAGE_TABLE, LEGACY_THREAD_RECIPIENT_QUERY, wrap(threadRecipientAddress)) { cursor -> + cursor.getString(LAST_LEGACY_SENDER_RECIPIENT) + } + + override fun setLastLegacySenderAddress( + threadRecipientAddress: String, + senderRecipientAddress: String? + ) { + val database = databaseHelper.writableDatabase + if (senderRecipientAddress == null) { + // delete + database.delete(LAST_LEGACY_MESSAGE_TABLE, LEGACY_THREAD_RECIPIENT_QUERY, wrap(threadRecipientAddress)) + } else { + // just update the value to a new one + val values = wrap( + mapOf( + LAST_LEGACY_THREAD_RECIPIENT to threadRecipientAddress, + LAST_LEGACY_SENDER_RECIPIENT to senderRecipientAddress + ) + ) + database.insertOrUpdate(LAST_LEGACY_MESSAGE_TABLE, values, LEGACY_THREAD_RECIPIENT_QUERY, wrap(threadRecipientAddress)) + } + } + fun getUserCount(room: String, server: String): Int? { val database = databaseHelper.readableDatabase val index = "$server.$room" diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt index 45184c2d23..441c979108 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt @@ -13,6 +13,8 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab private val messageThreadMappingTable = "loki_message_thread_mapping_database" private val errorMessageTable = "loki_error_message_database" private val messageHashTable = "loki_message_hash_database" + private val smsHashTable = "loki_sms_hash_database" + private val mmsHashTable = "loki_mms_hash_database" private val messageID = "message_id" private val serverID = "server_id" private val friendRequestStatus = "friend_request_status" @@ -32,6 +34,10 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab val updateMessageMappingTable = "ALTER TABLE $messageThreadMappingTable ADD COLUMN $serverID INTEGER DEFAULT 0; ALTER TABLE $messageThreadMappingTable ADD CONSTRAINT PK_$messageThreadMappingTable PRIMARY KEY ($messageID, $serverID);" @JvmStatic val createMessageHashTableCommand = "CREATE TABLE IF NOT EXISTS $messageHashTable ($messageID INTEGER PRIMARY KEY, $serverHash STRING);" + @JvmStatic + val createMmsHashTableCommand = "CREATE TABLE IF NOT EXISTS $mmsHashTable ($messageID INTEGER PRIMARY KEY, $serverHash STRING);" + @JvmStatic + val createSmsHashTableCommand = "CREATE TABLE IF NOT EXISTS $smsHashTable ($messageID INTEGER PRIMARY KEY, $serverHash STRING);" const val SMS_TYPE = 0 const val MMS_TYPE = 1 @@ -201,52 +207,52 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab messages.add(cursor.getLong(messageID) to cursor.getLong(serverID)) } } - var deletedCount = 0L database.beginTransaction() messages.forEach { (messageId, serverId) -> - deletedCount += database.delete(messageIDTable, "$messageID = ? AND $serverID = ?", arrayOf(messageId.toString(), serverId.toString())) + database.delete(messageIDTable, "$messageID = ? AND $serverID = ?", arrayOf(messageId.toString(), serverId.toString())) } - val mappingDeleted = database.delete(messageThreadMappingTable, "$threadID = ?", arrayOf(threadId.toString())) + database.delete(messageThreadMappingTable, "$threadID = ?", arrayOf(threadId.toString())) database.setTransactionSuccessful() } finally { database.endTransaction() } } - fun getMessageServerHash(messageID: Long): String? { - val database = databaseHelper.readableDatabase - return database.get(messageHashTable, "${Companion.messageID} = ?", arrayOf(messageID.toString())) { cursor -> + fun getMessageServerHash(messageID: Long, mms: Boolean): String? = getMessageTables(mms).firstNotNullOfOrNull { + databaseHelper.readableDatabase.get(it, "${Companion.messageID} = ?", arrayOf(messageID.toString())) { cursor -> cursor.getString(serverHash) } } - fun setMessageServerHash(messageID: Long, serverHash: String) { - val database = databaseHelper.writableDatabase - val contentValues = ContentValues(2) - contentValues.put(Companion.messageID, messageID) - contentValues.put(Companion.serverHash, serverHash) - database.insertOrUpdate(messageHashTable, contentValues, "${Companion.messageID} = ?", arrayOf(messageID.toString())) + fun setMessageServerHash(messageID: Long, mms: Boolean, serverHash: String) { + val contentValues = ContentValues(2).apply { + put(Companion.messageID, messageID) + put(Companion.serverHash, serverHash) + } + + databaseHelper.writableDatabase.apply { + insertOrUpdate(getMessageTable(mms), contentValues, "${Companion.messageID} = ?", arrayOf(messageID.toString())) + } } - fun deleteMessageServerHash(messageID: Long) { - val database = databaseHelper.writableDatabase - database.delete(messageHashTable, "${Companion.messageID} = ?", arrayOf(messageID.toString())) + fun deleteMessageServerHash(messageID: Long, mms: Boolean) { + getMessageTables(mms).firstOrNull { + databaseHelper.writableDatabase.delete(it, "${Companion.messageID} = ?", arrayOf(messageID.toString())) > 0 + } } - fun deleteMessageServerHashes(messageIDs: List) { - val database = databaseHelper.writableDatabase - database.delete( - messageHashTable, - "${Companion.messageID} IN (${messageIDs.map { "?" }.joinToString(",")})", + fun deleteMessageServerHashes(messageIDs: List, mms: Boolean) { + databaseHelper.writableDatabase.delete( + getMessageTable(mms), + "${Companion.messageID} IN (${messageIDs.joinToString(",") { "?" }})", messageIDs.map { "$it" }.toTypedArray() ) } - fun migrateThreadId(legacyThreadId: Long, newThreadId: Long) { - val database = databaseHelper.writableDatabase - val contentValues = ContentValues(1) - contentValues.put(threadID, newThreadId) - database.update(messageThreadMappingTable, contentValues, "$threadID = ?", arrayOf(legacyThreadId.toString())) - } + private fun getMessageTables(mms: Boolean) = sequenceOf( + getMessageTable(mms), + messageHashTable + ) + private fun getMessageTable(mms: Boolean) = if (mms) mmsHashTable else smsHashTable } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MarkedMessageInfo.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MarkedMessageInfo.kt new file mode 100644 index 0000000000..9de3dac695 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MarkedMessageInfo.kt @@ -0,0 +1,14 @@ +package org.thoughtcrime.securesms.database + +import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType +import org.thoughtcrime.securesms.database.MessagingDatabase.SyncMessageId + +data class MarkedMessageInfo(val syncMessageId: SyncMessageId, val expirationInfo: ExpirationInfo) { + val expiryType get() = when { + syncMessageId.timetamp == expirationInfo.expireStarted -> ExpiryType.AFTER_SEND + expirationInfo.expiresIn > 0 -> ExpiryType.AFTER_READ + else -> ExpiryType.NONE + } + + val expiryMode get() = expiryType.mode(expirationInfo.expiresIn) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java index edc6bc1a6f..bc74496dda 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java @@ -14,6 +14,7 @@ import org.session.libsession.utilities.IdentityKeyMismatchList; import org.session.libsignal.crypto.IdentityKey; import org.session.libsignal.utilities.JsonUtil; import org.session.libsignal.utilities.Log; +import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.util.SqlUtil; @@ -33,7 +34,6 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn protected abstract String getTableName(); - public abstract void markExpireStarted(long messageId); public abstract void markExpireStarted(long messageId, long startTime); public abstract void markAsSent(long messageId, boolean secure); @@ -225,56 +225,6 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn } } - public static class ExpirationInfo { - - private final long id; - private final long expiresIn; - private final long expireStarted; - private final boolean mms; - - public ExpirationInfo(long id, long expiresIn, long expireStarted, boolean mms) { - this.id = id; - this.expiresIn = expiresIn; - this.expireStarted = expireStarted; - this.mms = mms; - } - - public long getId() { - return id; - } - - public long getExpiresIn() { - return expiresIn; - } - - public long getExpireStarted() { - return expireStarted; - } - - public boolean isMms() { - return mms; - } - } - - public static class MarkedMessageInfo { - - private final SyncMessageId syncMessageId; - private final ExpirationInfo expirationInfo; - - public MarkedMessageInfo(SyncMessageId syncMessageId, ExpirationInfo expirationInfo) { - this.syncMessageId = syncMessageId; - this.expirationInfo = expirationInfo; - } - - public SyncMessageId getSyncMessageId() { - return syncMessageId; - } - - public ExpirationInfo getExpirationInfo() { - return expirationInfo; - } - } - public static class InsertResult { private final long messageId; private final long threadId; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt index 111b6d5365..62db50a1ba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt @@ -19,11 +19,13 @@ package org.thoughtcrime.securesms.database import android.content.ContentValues import android.content.Context import android.database.Cursor +import android.provider.ContactsContract.CommonDataKinds.BaseTypes import com.annimon.stream.Stream import com.google.android.mms.pdu_alt.PduHeaders import org.json.JSONArray import org.json.JSONException import org.json.JSONObject +import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.signal.IncomingMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingGroupMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage @@ -222,6 +224,12 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa return readerFor(rawQuery(where, null))!! } + val expireNotStartedMessages: Reader + get() { + val where = "$EXPIRES_IN > 0 AND $EXPIRE_STARTED = 0" + return readerFor(rawQuery(where, null))!! + } + private fun updateMailboxBitmask( id: Long, maskOff: Long, @@ -296,10 +304,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa markAs(messageId, MmsSmsColumns.Types.BASE_DELETED_TYPE, threadId) } - override fun markExpireStarted(messageId: Long) { - markExpireStarted(messageId, SnodeAPI.nowWithOffset) - } - override fun markExpireStarted(messageId: Long, startedTimestamp: Long) { val contentValues = ContentValues() contentValues.put(EXPIRE_STARTED, startedTimestamp) @@ -347,13 +351,14 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa ) while (cursor != null && cursor.moveToNext()) { if (MmsSmsColumns.Types.isSecureType(cursor.getLong(3))) { - val syncMessageId = - SyncMessageId(fromSerialized(cursor.getString(1)), cursor.getLong(2)) + val timestamp = cursor.getLong(2) + val syncMessageId = SyncMessageId(fromSerialized(cursor.getString(1)), timestamp) val expirationInfo = ExpirationInfo( - cursor.getLong(0), - cursor.getLong(4), - cursor.getLong(5), - true + id = cursor.getLong(0), + timestamp = timestamp, + expiresIn = cursor.getLong(4), + expireStarted = cursor.getLong(5), + isMms = true ) result.add(MarkedMessageInfo(syncMessageId, expirationInfo)) } @@ -383,6 +388,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa val timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(NORMALIZED_DATE_SENT)) val subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(SUBSCRIPTION_ID)) val expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRES_IN)) + val expireStartedAt = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRE_STARTED)) val address = cursor.getString(cursor.getColumnIndexOrThrow(ADDRESS)) val threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID)) val distributionType = get(context).threadDatabase().getDistributionType(threadId) @@ -451,6 +457,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa timestamp, subscriptionId, expiresIn, + expireStartedAt, distributionType, quote, contacts, @@ -550,6 +557,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa runThreadUpdate: Boolean ): Optional { if (threadId < 0 ) throw MmsException("No thread ID supplied!") + if (retrieved.isExpirationUpdate) deleteExpirationTimerMessages(threadId, false.takeUnless { retrieved.groupId != null }) val contentValues = ContentValues() contentValues.put(DATE_SENT, retrieved.sentTimeMillis) contentValues.put(ADDRESS, retrieved.from.serialize()) @@ -570,7 +578,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa contentValues.put(PART_COUNT, retrieved.attachments.size) contentValues.put(SUBSCRIPTION_ID, retrieved.subscriptionId) contentValues.put(EXPIRES_IN, retrieved.expiresIn) - contentValues.put(READ, if (retrieved.isExpirationUpdate) 1 else 0) + contentValues.put(EXPIRE_STARTED, retrieved.expireStartedAt) contentValues.put(UNIDENTIFIED, retrieved.isUnidentified) contentValues.put(HAS_MENTION, retrieved.hasMention()) contentValues.put(MESSAGE_REQUEST_RESPONSE, retrieved.isMessageRequestResponse) @@ -619,6 +627,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa runThreadUpdate: Boolean ): Optional { if (threadId < 0 ) throw MmsException("No thread ID supplied!") + if (retrieved.isExpirationUpdate) deleteExpirationTimerMessages(threadId, true.takeUnless { retrieved.isGroup }) val messageId = insertMessageOutbox(retrieved, threadId, false, null, serverTimestamp, runThreadUpdate) if (messageId == -1L) { return Optional.absent() @@ -689,6 +698,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa contentValues.put(DATE_RECEIVED, receivedTimestamp) contentValues.put(SUBSCRIPTION_ID, message.subscriptionId) contentValues.put(EXPIRES_IN, message.expiresIn) + contentValues.put(EXPIRE_STARTED, message.expireStartedAt) contentValues.put(ADDRESS, message.recipient.address.serialize()) contentValues.put( DELIVERY_RECEIPT_COUNT, @@ -1152,6 +1162,20 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa ) } + /** + * @param outgoing if true only delete outgoing messages, if false only delete incoming messages, if null delete both. + */ + private fun deleteExpirationTimerMessages(threadId: Long, outgoing: Boolean? = null) { + val outgoingClause = outgoing?.takeIf { ExpirationConfiguration.isNewConfigEnabled }?.let { + val comparison = if (it) "IN" else "NOT IN" + " AND $MESSAGE_BOX & ${MmsSmsColumns.Types.BASE_TYPE_MASK} $comparison (${MmsSmsColumns.Types.OUTGOING_MESSAGE_TYPES.joinToString()})" + } ?: "" + + val where = "$THREAD_ID = ? AND ($MESSAGE_BOX & ${MmsSmsColumns.Types.EXPIRATION_TIMER_UPDATE_BIT}) <> 0" + outgoingClause + writableDatabase.delete(TABLE_NAME, where, arrayOf("$threadId")) + notifyConversationListeners(threadId) + } + object Status { const val DOWNLOAD_INITIALIZED = 1 const val DOWNLOAD_NO_CONNECTIVITY = 2 @@ -1398,7 +1422,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa const val SHARED_CONTACTS: String = "shared_contacts" const val LINK_PREVIEWS: String = "previews" const val CREATE_TABLE: String = - "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY, " + + "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + THREAD_ID + " INTEGER, " + DATE_SENT + " INTEGER, " + DATE_RECEIVED + " INTEGER, " + MESSAGE_BOX + " INTEGER, " + READ + " INTEGER DEFAULT 0, " + "m_id" + " TEXT, " + "sub" + " TEXT, " + "sub_cs" + " INTEGER, " + BODY + " TEXT, " + PART_COUNT + " INTEGER, " + @@ -1503,5 +1527,21 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa const val CREATE_REACTIONS_UNREAD_COMMAND = "ALTER TABLE $TABLE_NAME ADD COLUMN $REACTIONS_UNREAD INTEGER DEFAULT 0;" const val CREATE_REACTIONS_LAST_SEEN_COMMAND = "ALTER TABLE $TABLE_NAME ADD COLUMN $REACTIONS_LAST_SEEN INTEGER DEFAULT 0;" const val CREATE_HAS_MENTION_COMMAND = "ALTER TABLE $TABLE_NAME ADD COLUMN $HAS_MENTION INTEGER DEFAULT 0;" + + private const val TEMP_TABLE_NAME = "TEMP_TABLE_NAME" + + const val COMMA_SEPARATED_COLUMNS = "$ID, $THREAD_ID, $DATE_SENT, $DATE_RECEIVED, $MESSAGE_BOX, $READ, m_id, sub, sub_cs, $BODY, $PART_COUNT, ct_t, $CONTENT_LOCATION, $ADDRESS, $ADDRESS_DEVICE_ID, $EXPIRY, m_cls, $MESSAGE_TYPE, v, $MESSAGE_SIZE, pri, rr,rpt_a, resp_st, $STATUS, $TRANSACTION_ID, retr_st, retr_txt, retr_txt_cs, read_status, ct_cls, resp_txt, d_tm, $DELIVERY_RECEIPT_COUNT, $MISMATCHED_IDENTITIES, $NETWORK_FAILURE, d_rpt, $SUBSCRIPTION_ID, $EXPIRES_IN, $EXPIRE_STARTED, $NOTIFIED, $READ_RECEIPT_COUNT, $QUOTE_ID, $QUOTE_AUTHOR, $QUOTE_BODY, $QUOTE_ATTACHMENT, $QUOTE_MISSING, $SHARED_CONTACTS, $UNIDENTIFIED, $LINK_PREVIEWS, $MESSAGE_REQUEST_RESPONSE, $REACTIONS_UNREAD, $REACTIONS_LAST_SEEN, $HAS_MENTION" + + @JvmField + val ADD_AUTOINCREMENT = arrayOf( + "ALTER TABLE $TABLE_NAME RENAME TO $TEMP_TABLE_NAME", + CREATE_TABLE, + CREATE_MESSAGE_REQUEST_RESPONSE_COMMAND, + CREATE_REACTIONS_UNREAD_COMMAND, + CREATE_REACTIONS_LAST_SEEN_COMMAND, + CREATE_HAS_MENTION_COMMAND, + "INSERT INTO $TABLE_NAME ($COMMA_SEPARATED_COLUMNS) SELECT $COMMA_SEPARATED_COLUMNS FROM $TEMP_TABLE_NAME", + "DROP TABLE $TEMP_TABLE_NAME" + ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java index dde5847ffe..69c9fa87f1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java @@ -46,7 +46,8 @@ public class RecipientDatabase extends Database { private static final String COLOR = "color"; private static final String SEEN_INVITE_REMINDER = "seen_invite_reminder"; private static final String DEFAULT_SUBSCRIPTION_ID = "default_subscription_id"; - private static final String EXPIRE_MESSAGES = "expire_messages"; + static final String EXPIRE_MESSAGES = "expire_messages"; + private static final String DISAPPEARING_STATE = "disappearing_state"; private static final String REGISTERED = "registered"; private static final String PROFILE_KEY = "profile_key"; private static final String SYSTEM_DISPLAY_NAME = "system_display_name"; @@ -70,7 +71,7 @@ public class RecipientDatabase extends Database { PROFILE_KEY, SYSTEM_DISPLAY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_CONTACT_URI, SIGNAL_PROFILE_NAME, SIGNAL_PROFILE_AVATAR, PROFILE_SHARING, NOTIFICATION_CHANNEL, UNIDENTIFIED_ACCESS_MODE, - FORCE_SMS_SELECTION, NOTIFY_TYPE, WRAPPER_HASH, BLOCKS_COMMUNITY_MESSAGE_REQUESTS + FORCE_SMS_SELECTION, NOTIFY_TYPE, DISAPPEARING_STATE, WRAPPER_HASH, BLOCKS_COMMUNITY_MESSAGE_REQUESTS }; static final List TYPED_RECIPIENT_PROJECTION = Stream.of(RECIPIENT_PROJECTION) @@ -138,6 +139,11 @@ public class RecipientDatabase extends Database { "OR "+ADDRESS+" IN (SELECT "+GroupDatabase.TABLE_NAME+"."+GroupDatabase.ADMINS+" FROM "+GroupDatabase.TABLE_NAME+")))"; } + public static String getCreateDisappearingStateCommand() { + return "ALTER TABLE "+ TABLE_NAME + " " + + "ADD COLUMN " + DISAPPEARING_STATE + " INTEGER DEFAULT 0;"; + } + public static String getAddWrapperHash() { return "ALTER TABLE "+TABLE_NAME+" "+ "ADD COLUMN "+WRAPPER_HASH+" TEXT DEFAULT NULL;"; @@ -183,6 +189,7 @@ public class RecipientDatabase extends Database { boolean approvedMe = cursor.getInt(cursor.getColumnIndexOrThrow(APPROVED_ME)) == 1; String messageRingtone = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION)); String callRingtone = cursor.getString(cursor.getColumnIndexOrThrow(CALL_RINGTONE)); + int disappearingState = cursor.getInt(cursor.getColumnIndexOrThrow(DISAPPEARING_STATE)); int messageVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(VIBRATE)); int callVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(CALL_VIBRATE)); long muteUntil = cursor.getLong(cursor.getColumnIndexOrThrow(MUTE_UNTIL)); @@ -226,6 +233,7 @@ public class RecipientDatabase extends Database { return Optional.of(new RecipientSettings(blocked, approved, approvedMe, muteUntil, notifyType, + Recipient.DisappearingState.fromId(disappearingState), Recipient.VibrateState.fromId(messageVibrateState), Recipient.VibrateState.fromId(callVibrateState), Util.uri(messageRingtone), Util.uri(callRingtone), @@ -335,16 +343,6 @@ public class RecipientDatabase extends Database { notifyRecipientListeners(); } - public void setExpireMessages(@NonNull Recipient recipient, int expiration) { - recipient.setExpireMessages(expiration); - - ContentValues values = new ContentValues(1); - values.put(EXPIRE_MESSAGES, expiration); - updateOrInsert(recipient.getAddress(), values); - recipient.resolve().setExpireMessages(expiration); - notifyRecipientListeners(); - } - public void setUnidentifiedAccessMode(@NonNull Recipient recipient, @NonNull UnidentifiedAccessMode unidentifiedAccessMode) { ContentValues values = new ContentValues(1); values.put(UNIDENTIFIED_ACCESS_MODE, unidentifiedAccessMode.getMode()); @@ -443,6 +441,14 @@ public class RecipientDatabase extends Database { return returnList; } + public void setDisappearingState(@NonNull Recipient recipient, @NonNull Recipient.DisappearingState disappearingState) { + ContentValues values = new ContentValues(); + values.put(DISAPPEARING_STATE, disappearingState.getId()); + updateOrInsert(recipient.getAddress(), values); + recipient.resolve().setDisappearingState(disappearingState); + notifyRecipientListeners(); + } + public static class RecipientReader implements Closeable { private final Context context; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index 4ef576f404..2c0c33dda8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -52,6 +52,7 @@ import org.thoughtcrime.securesms.database.model.ReactionRecord; import org.thoughtcrime.securesms.database.model.SmsMessageRecord; import org.thoughtcrime.securesms.dependencies.DatabaseComponent; +import java.io.Closeable; import java.io.IOException; import java.security.SecureRandom; import java.util.Arrays; @@ -90,6 +91,7 @@ public class SmsDatabase extends MessagingDatabase { EXPIRES_IN + " INTEGER DEFAULT 0, " + EXPIRE_STARTED + " INTEGER DEFAULT 0, " + NOTIFIED + " DEFAULT 0, " + READ_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + UNIDENTIFIED + " INTEGER DEFAULT 0);"; + public static final String[] CREATE_INDEXS = { "CREATE INDEX IF NOT EXISTS sms_thread_id_index ON " + TABLE_NAME + " (" + THREAD_ID + ");", "CREATE INDEX IF NOT EXISTS sms_read_index ON " + TABLE_NAME + " (" + READ + ");", @@ -127,6 +129,18 @@ public class SmsDatabase extends MessagingDatabase { public static String CREATE_HAS_MENTION_COMMAND = "ALTER TABLE "+ TABLE_NAME + " " + "ADD COLUMN " + HAS_MENTION + " INTEGER DEFAULT 0;"; + private static String COMMA_SEPARATED_COLUMNS = ID + ", " + THREAD_ID + ", " + ADDRESS + ", " + ADDRESS_DEVICE_ID + ", " + PERSON + ", " + DATE_RECEIVED + ", " + DATE_SENT + ", " + PROTOCOL + ", " + READ + ", " + STATUS + ", " + TYPE + ", " + REPLY_PATH_PRESENT + ", " + DELIVERY_RECEIPT_COUNT + ", " + SUBJECT + ", " + BODY + ", " + MISMATCHED_IDENTITIES + ", " + SERVICE_CENTER + ", " + SUBSCRIPTION_ID + ", " + EXPIRES_IN + ", " + EXPIRE_STARTED + ", " + NOTIFIED + ", " + READ_RECEIPT_COUNT + ", " + UNIDENTIFIED + ", " + REACTIONS_UNREAD + ", " + HAS_MENTION; + private static String TEMP_TABLE_NAME = "TEMP_TABLE_NAME"; + + public static final String[] ADD_AUTOINCREMENT = new String[]{ + "ALTER TABLE " + TABLE_NAME + " RENAME TO " + TEMP_TABLE_NAME, + CREATE_TABLE, + CREATE_REACTIONS_UNREAD_COMMAND, + CREATE_HAS_MENTION_COMMAND, + "INSERT INTO " + TABLE_NAME + " (" + COMMA_SEPARATED_COLUMNS + ") SELECT " + COMMA_SEPARATED_COLUMNS + " FROM " + TEMP_TABLE_NAME, + "DROP TABLE " + TEMP_TABLE_NAME + }; + private static final EarlyReceiptCache earlyDeliveryReceiptCache = new EarlyReceiptCache(); private static final EarlyReceiptCache earlyReadReceiptCache = new EarlyReceiptCache(); @@ -237,11 +251,6 @@ public class SmsDatabase extends MessagingDatabase { updateTypeBitmask(messageId, Types.BASE_TYPE_MASK, Types.BASE_DELETED_TYPE); } - @Override - public void markExpireStarted(long id) { - markExpireStarted(id, SnodeAPI.getNowWithOffset()); - } - @Override public void markExpireStarted(long id, long startedAtTimestamp) { ContentValues contentValues = new ContentValues(); @@ -354,12 +363,11 @@ public class SmsDatabase extends MessagingDatabase { cursor = database.query(TABLE_NAME, new String[] {ID, ADDRESS, DATE_SENT, TYPE, EXPIRES_IN, EXPIRE_STARTED}, where, arguments, null, null, null); while (cursor != null && cursor.moveToNext()) { - if (Types.isSecureType(cursor.getLong(3))) { - SyncMessageId syncMessageId = new SyncMessageId(Address.fromSerialized(cursor.getString(1)), cursor.getLong(2)); - ExpirationInfo expirationInfo = new ExpirationInfo(cursor.getLong(0), cursor.getLong(4), cursor.getLong(5), false); + long timestamp = cursor.getLong(2); + SyncMessageId syncMessageId = new SyncMessageId(Address.fromSerialized(cursor.getString(1)), timestamp); + ExpirationInfo expirationInfo = new ExpirationInfo(cursor.getLong(0), timestamp, cursor.getLong(4), cursor.getLong(5), false); - results.add(new MarkedMessageInfo(syncMessageId, expirationInfo)); - } + results.add(new MarkedMessageInfo(syncMessageId, expirationInfo)); } ContentValues contentValues = new ContentValues(); @@ -407,6 +415,24 @@ public class SmsDatabase extends MessagingDatabase { } protected Optional insertMessageInbox(IncomingTextMessage message, long type, long serverTimestamp, boolean runThreadUpdate) { + Recipient recipient = Recipient.from(context, message.getSender(), true); + + Recipient groupRecipient; + + if (message.getGroupId() == null) { + groupRecipient = null; + } else { + groupRecipient = Recipient.from(context, message.getGroupId(), true); + } + + boolean unread = (Util.isDefaultSmsProvider(context) || + message.isSecureMessage() || message.isGroup() || message.isCallInfo()); + + long threadId; + + if (groupRecipient == null) threadId = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient); + else threadId = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(groupRecipient); + if (message.isSecureMessage()) { type |= Types.SECURE_MESSAGE_BIT; } else if (message.isGroup()) { @@ -420,40 +446,9 @@ public class SmsDatabase extends MessagingDatabase { CallMessageType callMessageType = message.getCallType(); if (callMessageType != null) { - switch (callMessageType) { - case CALL_OUTGOING: - type |= Types.OUTGOING_CALL_TYPE; - break; - case CALL_INCOMING: - type |= Types.INCOMING_CALL_TYPE; - break; - case CALL_MISSED: - type |= Types.MISSED_CALL_TYPE; - break; - case CALL_FIRST_MISSED: - type |= Types.FIRST_MISSED_CALL_TYPE; - break; - } + type |= getCallMessageTypeMask(callMessageType); } - Recipient recipient = Recipient.from(context, message.getSender(), true); - - Recipient groupRecipient; - - if (message.getGroupId() == null) { - groupRecipient = null; - } else { - groupRecipient = Recipient.from(context, message.getGroupId(), true); - } - - boolean unread = (Util.isDefaultSmsProvider(context) || - message.isSecureMessage() || message.isGroup() || message.isCallInfo()); - - long threadId; - - if (groupRecipient == null) threadId = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient); - else threadId = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(groupRecipient); - ContentValues values = new ContentValues(6); values.put(ADDRESS, message.getSender().serialize()); values.put(ADDRESS_DEVICE_ID, message.getSenderDeviceId()); @@ -466,6 +461,7 @@ public class SmsDatabase extends MessagingDatabase { values.put(READ, unread ? 0 : 1); values.put(SUBSCRIPTION_ID, message.getSubscriptionId()); values.put(EXPIRES_IN, message.getExpiresIn()); + values.put(EXPIRE_STARTED, message.getExpireStartedAt()); values.put(UNIDENTIFIED, message.isUnidentified()); values.put(HAS_MENTION, message.hasMention()); @@ -499,6 +495,21 @@ public class SmsDatabase extends MessagingDatabase { } } + private long getCallMessageTypeMask(CallMessageType callMessageType) { + switch (callMessageType) { + case CALL_OUTGOING: + return Types.OUTGOING_CALL_TYPE; + case CALL_INCOMING: + return Types.INCOMING_CALL_TYPE; + case CALL_MISSED: + return Types.MISSED_CALL_TYPE; + case CALL_FIRST_MISSED: + return Types.FIRST_MISSED_CALL_TYPE; + default: + return 0; + } + } + public Optional insertMessageInbox(IncomingTextMessage message, boolean runThreadUpdate) { return insertMessageInbox(message, Types.BASE_INBOX_TYPE, 0, runThreadUpdate); } @@ -547,6 +558,7 @@ public class SmsDatabase extends MessagingDatabase { contentValues.put(TYPE, type); contentValues.put(SUBSCRIPTION_ID, message.getSubscriptionId()); contentValues.put(EXPIRES_IN, message.getExpiresIn()); + contentValues.put(EXPIRE_STARTED, message.getExpireStartedAt()); contentValues.put(DELIVERY_RECEIPT_COUNT, Stream.of(earlyDeliveryReceipts.values()).mapToLong(Long::longValue).sum()); contentValues.put(READ_RECEIPT_COUNT, Stream.of(earlyReadReceipts.values()).mapToLong(Long::longValue).sum()); @@ -590,6 +602,11 @@ public class SmsDatabase extends MessagingDatabase { return rawQuery(where, null); } + public Cursor getExpirationNotStartedMessages() { + String where = EXPIRES_IN + " > 0 AND " + EXPIRE_STARTED + " = 0"; + return rawQuery(where, null); + } + public SmsMessageRecord getMessage(long messageId) throws NoSuchMessageException { Cursor cursor = rawQuery(ID_WHERE, new String[]{messageId + ""}); Reader reader = new Reader(cursor); @@ -615,7 +632,6 @@ public class SmsDatabase extends MessagingDatabase { long threadId = getThreadIdForMessage(messageId); db.delete(TABLE_NAME, ID_WHERE, new String[] {messageId+""}); boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false, true); - notifyConversationListeners(threadId); return threadDeleted; } @@ -787,7 +803,7 @@ public class SmsDatabase extends MessagingDatabase { } } - public class Reader { + public class Reader implements Closeable { private final Cursor cursor; @@ -853,8 +869,11 @@ public class SmsDatabase extends MessagingDatabase { return new LinkedList<>(); } + @Override public void close() { - cursor.close(); + if (cursor != null) { + cursor.close(); + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index 481a4fa06e..2760ee302f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -14,6 +14,7 @@ import network.loki.messenger.libsession_util.util.Conversation import network.loki.messenger.libsession_util.util.ExpiryMode import network.loki.messenger.libsession_util.util.GroupInfo import network.loki.messenger.libsession_util.util.UserPic +import network.loki.messenger.libsession_util.util.afterSend import org.session.libsession.avatars.AvatarHelper import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.BlindedIdMapping @@ -29,6 +30,7 @@ import org.session.libsession.messaging.jobs.MessageReceiveJob import org.session.libsession.messaging.jobs.MessageSendJob import org.session.libsession.messaging.jobs.RetrieveProfileAvatarJob import org.session.libsession.messaging.messages.Destination +import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.control.ConfigurationMessage import org.session.libsession.messaging.messages.control.MessageRequestResponse @@ -66,6 +68,7 @@ import org.session.libsession.utilities.ProfileKeyUtil import org.session.libsession.utilities.SSKEnvironment import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.recipients.Recipient.DisappearingState import org.session.libsignal.crypto.ecc.DjbECPrivateKey import org.session.libsignal.crypto.ecc.DjbECPublicKey import org.session.libsignal.crypto.ecc.ECKeyPair @@ -89,10 +92,16 @@ import org.thoughtcrime.securesms.mms.PartAuthority import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.SessionMetaProtocol import java.security.MessageDigest +import kotlin.time.Duration.Companion.days import network.loki.messenger.libsession_util.util.Contact as LibSessionContact -open class Storage(context: Context, helper: SQLCipherOpenHelper, private val configFactory: ConfigFactory) : Database(context, helper), StorageProtocol, - ThreadDatabase.ConversationThreadUpdateListener { +private const val TAG = "Storage" + +open class Storage( + context: Context, + helper: SQLCipherOpenHelper, + private val configFactory: ConfigFactory +) : Database(context, helper), StorageProtocol, ThreadDatabase.ConversationThreadUpdateListener { override fun threadCreated(address: Address, threadId: Long) { val localUserAddress = getUserPublicKey() ?: return @@ -322,19 +331,30 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co // open group recipients should explicitly create threads message.threadID = getOrCreateThreadIdFor(targetAddress) } + val expiryMode = message.expiryMode + val expiresInMillis = expiryMode.expiryMillis + val expireStartedAt = if (expiryMode is ExpiryMode.AfterSend) message.sentTimestamp!! else 0 if (message.isMediaMessage() || attachments.isNotEmpty()) { val quote: Optional = if (quotes != null) Optional.of(quotes) else Optional.absent() val linkPreviews: Optional> = if (linkPreview.isEmpty()) Optional.absent() else Optional.of(linkPreview.mapNotNull { it!! }) val mmsDatabase = DatabaseComponent.get(context).mmsDatabase() val insertResult = if (isUserSender || isUserBlindedSender) { - val mediaMessage = OutgoingMediaMessage.from(message, targetRecipient, pointers, quote.orNull(), linkPreviews.orNull()?.firstOrNull()) + val mediaMessage = OutgoingMediaMessage.from( + message, + targetRecipient, + pointers, + quote.orNull(), + linkPreviews.orNull()?.firstOrNull(), + expiresInMillis, + expireStartedAt + ) mmsDatabase.insertSecureDecryptedMessageOutbox(mediaMessage, message.threadID ?: -1, message.sentTimestamp!!, runThreadUpdate) } else { // It seems like we have replaced SignalServiceAttachment with SessionServiceAttachment val signalServiceAttachments = attachments.mapNotNull { it.toSignalPointer() } - val mediaMessage = IncomingMediaMessage.from(message, senderAddress, targetRecipient.expireMessages * 1000L, group, signalServiceAttachments, quote, linkPreviews) + val mediaMessage = IncomingMediaMessage.from(message, senderAddress, expiresInMillis, expireStartedAt, group, signalServiceAttachments, quote, linkPreviews) mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, message.threadID!!, message.receivedTimestamp ?: 0, runThreadUpdate) } if (insertResult.isPresent) { @@ -345,12 +365,12 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co val isOpenGroupInvitation = (message.openGroupInvitation != null) val insertResult = if (isUserSender || isUserBlindedSender) { - val textMessage = if (isOpenGroupInvitation) OutgoingTextMessage.fromOpenGroupInvitation(message.openGroupInvitation, targetRecipient, message.sentTimestamp) - else OutgoingTextMessage.from(message, targetRecipient) + val textMessage = if (isOpenGroupInvitation) OutgoingTextMessage.fromOpenGroupInvitation(message.openGroupInvitation, targetRecipient, message.sentTimestamp, expiresInMillis, expireStartedAt) + else OutgoingTextMessage.from(message, targetRecipient, expiresInMillis, expireStartedAt) smsDatabase.insertMessageOutbox(message.threadID ?: -1, textMessage, message.sentTimestamp!!, runThreadUpdate) } else { - val textMessage = if (isOpenGroupInvitation) IncomingTextMessage.fromOpenGroupInvitation(message.openGroupInvitation, senderAddress, message.sentTimestamp) - else IncomingTextMessage.from(message, senderAddress, group, targetRecipient.expireMessages * 1000L) + val textMessage = if (isOpenGroupInvitation) IncomingTextMessage.fromOpenGroupInvitation(message.openGroupInvitation, senderAddress, message.sentTimestamp, expiresInMillis, expireStartedAt) + else IncomingTextMessage.from(message, senderAddress, group, expiresInMillis, expireStartedAt) val encrypted = IncomingEncryptedMessage(textMessage, textMessage.messageBody) smsDatabase.insertMessageInbox(encrypted, message.receivedTimestamp ?: 0, runThreadUpdate) } @@ -360,7 +380,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co } message.serverHash?.let { serverHash -> messageID?.let { id -> - DatabaseComponent.get(context).lokiMessageDatabase().setMessageServerHash(id, serverHash) + DatabaseComponent.get(context).lokiMessageDatabase().setMessageServerHash(id, message.isMediaMessage(), serverHash) } } return messageID @@ -423,8 +443,8 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co return DatabaseComponent.get(context).lokiAPIDatabase().getAuthToken(id) } - override fun notifyConfigUpdates(forConfigObject: ConfigBase) { - notifyUpdates(forConfigObject) + override fun notifyConfigUpdates(forConfigObject: ConfigBase, messageTimestamp: Long) { + notifyUpdates(forConfigObject, messageTimestamp) } override fun conversationInConfig(publicKey: String?, groupPublicKey: String?, openGroupId: String?, visibleOnly: Boolean): Boolean { @@ -439,16 +459,16 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co return configFactory.user?.getCommunityMessageRequests() == true } - fun notifyUpdates(forConfigObject: ConfigBase) { + private fun notifyUpdates(forConfigObject: ConfigBase, messageTimestamp: Long) { when (forConfigObject) { - is UserProfile -> updateUser(forConfigObject) - is Contacts -> updateContacts(forConfigObject) - is ConversationVolatileConfig -> updateConvoVolatile(forConfigObject) - is UserGroupsConfig -> updateUserGroups(forConfigObject) + is UserProfile -> updateUser(forConfigObject, messageTimestamp) + is Contacts -> updateContacts(forConfigObject, messageTimestamp) + is ConversationVolatileConfig -> updateConvoVolatile(forConfigObject, messageTimestamp) + is UserGroupsConfig -> updateUserGroups(forConfigObject, messageTimestamp) } } - private fun updateUser(userProfile: UserProfile) { + private fun updateUser(userProfile: UserProfile, messageTimestamp: Long) { val userPublicKey = getUserPublicKey() ?: return // would love to get rid of recipient and context from this val recipient = Recipient.from(context, fromSerialized(userPublicKey), false) @@ -474,16 +494,25 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co deleteConversation(ourThread) } else { // create note to self thread if needed (?) - val ourThread = getOrCreateThreadIdFor(recipient.address) + val address = recipient.address + val ourThread = getThreadId(address) ?: getOrCreateThreadIdFor(address).also { + setThreadDate(it, 0) + } DatabaseComponent.get(context).threadDatabase().setHasSent(ourThread, true) setPinned(ourThread, userProfile.getNtsPriority() > 0) } + // Set or reset the shared library to use latest expiration config + getThreadId(recipient)?.let { + setExpirationConfiguration( + getExpirationConfiguration(it)?.takeIf { it.updatedTimestampMs > messageTimestamp } ?: ExpirationConfiguration(it, userProfile.getNtsExpiry(), messageTimestamp) + ) + } } - private fun updateContacts(contacts: Contacts) { + private fun updateContacts(contacts: Contacts, messageTimestamp: Long) { val extracted = contacts.all().toList() - addLibSessionContacts(extracted) + addLibSessionContacts(extracted, messageTimestamp) } override fun clearUserPic() { @@ -503,7 +532,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) } - private fun updateConvoVolatile(convos: ConversationVolatileConfig) { + private fun updateConvoVolatile(convos: ConversationVolatileConfig, messageTimestamp: Long) { val extracted = convos.all() for (conversation in extracted) { val threadId = when (conversation) { @@ -520,7 +549,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co } } - private fun updateUserGroups(userGroups: UserGroupsConfig) { + private fun updateUserGroups(userGroups: UserGroupsConfig, messageTimestamp: Long) { val threadDb = DatabaseComponent.get(context).threadDatabase() val localUserPublicKey = getUserPublicKey() ?: return Log.w( "Loki", @@ -572,6 +601,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co } for (group in lgc) { + val groupId = GroupUtil.doubleEncodeGroupID(group.sessionId) val existingGroup = existingClosedGroups.firstOrNull { GroupUtil.doubleDecodeGroupId(it.encodedId) == group.sessionId } val existingThread = existingGroup?.let { getThreadId(existingGroup.encodedId) } if (existingGroup != null) { @@ -586,7 +616,6 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co } else { val members = group.members.keys.map { Address.fromSerialized(it) } val admins = group.members.filter { it.value /*admin = true*/ }.keys.map { Address.fromSerialized(it) } - val groupId = GroupUtil.doubleEncodeGroupID(group.sessionId) val title = group.name val formationTimestamp = (group.joinedAt * 1000L) createGroup(groupId, title, admins + members, null, null, admins, formationTimestamp) @@ -596,9 +625,6 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co // Store the encryption key pair val keyPair = ECKeyPair(DjbECPublicKey(group.encPubKey), DjbECPrivateKey(group.encSecKey)) addClosedGroupEncryptionKeyPair(keyPair, group.sessionId, SnodeAPI.nowWithOffset) - // Set expiration timer - val expireTimer = group.disappearingTimer - setExpirationTimer(groupId, expireTimer.toInt()) // Notify the PN server PushRegistryV1.subscribeGroup(group.sessionId, publicKey = localUserPublicKey) // Notify the user @@ -609,6 +635,12 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co // Start polling ClosedGroupPollerV2.shared.startPolling(group.sessionId) } + getThreadId(Address.fromSerialized(groupId))?.let { + setExpirationConfiguration( + getExpirationConfiguration(it)?.takeIf { it.updatedTimestampMs > messageTimestamp } + ?: ExpirationConfiguration(it, afterSend(group.disappearingTimer), messageTimestamp) + ) + } } } @@ -712,10 +744,10 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co SessionMetaProtocol.removeTimestamps(timestamps) } - override fun getMessageIdInDatabase(timestamp: Long, author: String): Long? { + override fun getMessageIdInDatabase(timestamp: Long, author: String): Pair? { val database = DatabaseComponent.get(context).mmsSmsDatabase() val address = fromSerialized(author) - return database.getMessageFor(timestamp, address)?.getId() + return database.getMessageFor(timestamp, address)?.run { getId() to isMms } } override fun updateSentTimestamp( @@ -834,8 +866,8 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co db.clearErrorMessage(messageID) } - override fun setMessageServerHash(messageID: Long, serverHash: String) { - DatabaseComponent.get(context).lokiMessageDatabase().setMessageServerHash(messageID, serverHash) + override fun setMessageServerHash(messageID: Long, mms: Boolean, serverHash: String) { + DatabaseComponent.get(context).lokiMessageDatabase().setMessageServerHash(messageID, mms, serverHash) } override fun getGroup(groupID: String): GroupRecord? { @@ -847,9 +879,10 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co DatabaseComponent.get(context).groupDatabase().create(groupId, title, members, avatar, relay, admins, formationTimestamp) } - override fun createInitialConfigGroup(groupPublicKey: String, name: String, members: Map, formationTimestamp: Long, encryptionKeyPair: ECKeyPair) { + override fun createInitialConfigGroup(groupPublicKey: String, name: String, members: Map, formationTimestamp: Long, encryptionKeyPair: ECKeyPair, expirationTimer: Int) { val volatiles = configFactory.convoVolatile ?: return val userGroups = configFactory.userGroups ?: return + if (volatiles.getLegacyClosedGroup(groupPublicKey) != null && userGroups.getLegacyGroupInfo(groupPublicKey) != null) return val groupVolatileConfig = volatiles.getOrConstructLegacyGroup(groupPublicKey) groupVolatileConfig.lastRead = formationTimestamp volatiles.set(groupVolatileConfig) @@ -860,7 +893,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co priority = ConfigBase.PRIORITY_VISIBLE, encPubKey = (encryptionKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte encSecKey = encryptionKeyPair.privateKey.serialize(), - disappearingTimer = 0L, + disappearingTimer = expirationTimer.toLong(), joinedAt = (formationTimestamp / 1000L) ) // shouldn't exist, don't use getOrConstruct + copy @@ -871,8 +904,6 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co override fun updateGroupConfig(groupPublicKey: String) { val groupID = GroupUtil.doubleEncodeGroupID(groupPublicKey) val groupAddress = fromSerialized(groupID) - // TODO: probably add a check in here for isActive? - // TODO: also check if local user is a member / maybe run delete otherwise? val existingGroup = getGroup(groupID) ?: return Log.w("Loki-DBG", "No existing group for ${groupPublicKey.take(4)}} when updating group config") val userGroups = configFactory.userGroups ?: return @@ -886,7 +917,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co val membersMap = GroupUtil.createConfigMemberMap(admins = admins, members = members) val latestKeyPair = getLatestClosedGroupEncryptionKeyPair(groupPublicKey) ?: return Log.w("Loki-DBG", "No latest closed group encryption key pair for ${groupPublicKey.take(4)}} when updating group config") - val recipientSettings = getRecipientSettings(groupAddress) ?: return + val threadID = getThreadId(groupAddress) ?: return val groupInfo = userGroups.getOrConstructLegacyGroupInfo(groupPublicKey).copy( name = name, @@ -894,7 +925,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co encPubKey = (latestKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte encSecKey = latestKeyPair.privateKey.serialize(), priority = if (isPinned(threadID)) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE, - disappearingTimer = recipientSettings.expireMessages.toLong(), + disappearingTimer = getExpirationConfiguration(threadID)?.expiryMode?.expirySeconds ?: 0L, joinedAt = (existingGroup.formationTimestamp / 1000L) ) userGroups.set(groupInfo) @@ -926,7 +957,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co override fun insertIncomingInfoMessage(context: Context, senderPublicKey: String, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection, admins: Collection, sentTimestamp: Long) { val group = SignalServiceGroup(type, GroupUtil.getDecodedGroupIDAsData(groupID), SignalServiceGroup.GroupType.SIGNAL, name, members.toList(), null, admins.toList()) - val m = IncomingTextMessage(fromSerialized(senderPublicKey), 1, sentTimestamp, "", Optional.of(group), 0, true, false) + val m = IncomingTextMessage(fromSerialized(senderPublicKey), 1, sentTimestamp, "", Optional.of(group), 0, 0, true, false) val updateData = UpdateMessageData.buildGroupUpdate(type, name, members)?.toJSON() val infoMessage = IncomingGroupMessage(m, groupID, updateData, true) val smsDB = DatabaseComponent.get(context).smsDatabase() @@ -934,11 +965,10 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co } override fun insertOutgoingInfoMessage(context: Context, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection, admins: Collection, threadID: Long, sentTimestamp: Long) { - val userPublicKey = getUserPublicKey() + val userPublicKey = getUserPublicKey()!! val recipient = Recipient.from(context, fromSerialized(groupID), false) - val updateData = UpdateMessageData.buildGroupUpdate(type, name, members)?.toJSON() ?: "" - val infoMessage = OutgoingGroupMediaMessage(recipient, updateData, groupID, null, sentTimestamp, 0, true, null, listOf(), listOf()) + val infoMessage = OutgoingGroupMediaMessage(recipient, updateData, groupID, null, sentTimestamp, 0, 0, true, null, listOf(), listOf()) val mmsDB = DatabaseComponent.get(context).mmsDatabase() val mmsSmsDB = DatabaseComponent.get(context).mmsSmsDatabase() if (mmsSmsDB.getMessageFor(sentTimestamp, userPublicKey) != null) return @@ -996,23 +1026,6 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co .updateTimestampUpdated(groupID, updatedTimestamp) } - override fun setExpirationTimer(address: String, duration: Int) { - val recipient = Recipient.from(context, fromSerialized(address), false) - DatabaseComponent.get(context).recipientDatabase().setExpireMessages(recipient, duration) - if (recipient.isContactRecipient && !recipient.isLocalNumber) { - configFactory.contacts?.upsertContact(address) { - this.expiryMode = if (duration != 0) { - ExpiryMode.AfterRead(duration.toLong()) - } else { // = 0 / delete - ExpiryMode.NONE - } - } - if (configFactory.contacts?.needsPush() == true) { - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) - } - } - } - override fun setServerCapabilities(server: String, capabilities: List) { return DatabaseComponent.get(context).lokiAPIDatabase().setServerCapabilities(server, capabilities) } @@ -1135,11 +1148,10 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co } override fun getRecipientSettings(address: Address): Recipient.RecipientSettings? { - val recipientSettings = DatabaseComponent.get(context).recipientDatabase().getRecipientSettings(address) - return if (recipientSettings.isPresent) { recipientSettings.get() } else null + return DatabaseComponent.get(context).recipientDatabase().getRecipientSettings(address).orNull() } - override fun addLibSessionContacts(contacts: List) { + override fun addLibSessionContacts(contacts: List, timestamp: Long) { val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase() val moreContacts = contacts.filter { contact -> val id = SessionId(contact.id) @@ -1172,13 +1184,19 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co profileManager.setProfilePicture(context, recipient, null, null) } if (contact.priority == PRIORITY_HIDDEN) { - getThreadId(fromSerialized(contact.id))?.let { conversationThreadId -> - deleteConversation(conversationThreadId) - } + getThreadId(fromSerialized(contact.id))?.let(::deleteConversation) } else { - getThreadId(fromSerialized(contact.id))?.let { conversationThreadId -> - setPinned(conversationThreadId, contact.priority == PRIORITY_PINNED) - } + ( + getThreadId(address) ?: getOrCreateThreadIdFor(address).also { + setThreadDate(it, 0) + } + ).also { setPinned(it, contact.priority == PRIORITY_PINNED) } + } + getThreadId(recipient)?.let { + setExpirationConfiguration( + getExpirationConfiguration(it)?.takeIf { it.updatedTimestampMs > timestamp } + ?: ExpirationConfiguration(it, contact.expiryMode, timestamp) + ) } setRecipientHash(recipient, contact.hashCode().toString()) } @@ -1293,20 +1311,26 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co threadDb.setDate(threadId, newDate) } + override fun getLastLegacyRecipient(threadRecipient: String): String? = + DatabaseComponent.get(context).lokiAPIDatabase().getLastLegacySenderAddress(threadRecipient) + + override fun setLastLegacyRecipient(threadRecipient: String, senderRecipient: String?) { + DatabaseComponent.get(context).lokiAPIDatabase().setLastLegacySenderAddress(threadRecipient, senderRecipient) + } + override fun deleteConversation(threadID: Long) { - val recipient = getRecipientForThread(threadID) val threadDB = DatabaseComponent.get(context).threadDatabase() val groupDB = DatabaseComponent.get(context).groupDatabase() threadDB.deleteConversation(threadID) - if (recipient != null) { - if (recipient.isContactRecipient) { + val recipient = getRecipientForThread(threadID) ?: return + when { + recipient.isContactRecipient -> { if (recipient.isLocalNumber) return val contacts = configFactory.contacts ?: return - contacts.upsertContact(recipient.address.serialize()) { - this.priority = PRIORITY_HIDDEN - } + contacts.upsertContact(recipient.address.serialize()) { priority = PRIORITY_HIDDEN } ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) - } else if (recipient.isClosedGroupRecipient) { + } + recipient.isClosedGroupRecipient -> { // TODO: handle closed group val volatile = configFactory.convoVolatile ?: return val groups = configFactory.userGroups ?: return @@ -1338,14 +1362,17 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co val recipient = Recipient.from(context, address, false) if (recipient.isBlocked) return - val threadId = getThreadId(recipient) ?: return - + val expirationConfig = getExpirationConfiguration(threadId) + val expiryMode = expirationConfig?.expiryMode ?: ExpiryMode.NONE + val expiresInMillis = expiryMode.expiryMillis + val expireStartedAt = if (expiryMode is ExpiryMode.AfterSend) sentTimestamp else 0 val mediaMessage = IncomingMediaMessage( address, sentTimestamp, -1, - 0, + expiresInMillis, + expireStartedAt, false, false, false, @@ -1360,6 +1387,8 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co ) database.insertSecureDecryptedMessageInbox(mediaMessage, threadId, runThreadUpdate = true) + + SSKEnvironment.shared.messageExpirationManager.maybeStartExpiration(sentTimestamp, senderPublicKey, expiryMode) } override fun insertMessageRequestResponse(response: MessageRequestResponse) { @@ -1440,12 +1469,12 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co } recipientDb.setApproved(sender, true) recipientDb.setApprovedMe(sender, true) - val message = IncomingMediaMessage( sender.address, response.sentTimestamp!!, -1, 0, + 0, false, false, true, @@ -1485,8 +1514,15 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co override fun insertCallMessage(senderPublicKey: String, callMessageType: CallMessageType, sentTimestamp: Long) { val database = DatabaseComponent.get(context).smsDatabase() val address = fromSerialized(senderPublicKey) - val callMessage = IncomingTextMessage.fromCallInfo(callMessageType, address, Optional.absent(), sentTimestamp) + val recipient = Recipient.from(context, address, false) + val threadId = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient) + val expirationConfig = getExpirationConfiguration(threadId) + val expiryMode = expirationConfig?.expiryMode?.coerceSendToRead() ?: ExpiryMode.NONE + val expiresInMillis = expiryMode.expiryMillis + val expireStartedAt = if (expiryMode is ExpiryMode.AfterSend) sentTimestamp else 0 + val callMessage = IncomingTextMessage.fromCallInfo(callMessageType, address, Optional.absent(), sentTimestamp, expiresInMillis, expireStartedAt) database.insertCallMessage(callMessage) + SSKEnvironment.shared.messageExpirationManager.maybeStartExpiration(sentTimestamp, senderPublicKey, expiryMode) } override fun conversationHasOutgoing(userPublicKey: String): Boolean { @@ -1623,4 +1659,100 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co val recipientDb = DatabaseComponent.get(context).recipientDatabase() return recipientDb.blockedContacts } + + override fun getExpirationConfiguration(threadId: Long): ExpirationConfiguration? { + val recipient = getRecipientForThread(threadId) ?: return null + val dbExpirationMetadata = DatabaseComponent.get(context).expirationConfigurationDatabase().getExpirationConfiguration(threadId) ?: return null + return when { + recipient.isLocalNumber -> configFactory.user?.getNtsExpiry() + recipient.isContactRecipient -> { + // read it from contacts config if exists + recipient.address.serialize().takeIf { it.startsWith(IdPrefix.STANDARD.value) } + ?.let { configFactory.contacts?.get(it)?.expiryMode } + } + recipient.isClosedGroupRecipient -> { + // read it from group config if exists + GroupUtil.doubleDecodeGroupId(recipient.address.serialize()) + .let { configFactory.userGroups?.getLegacyGroupInfo(it) } + ?.run { disappearingTimer.takeIf { it != 0L }?.let(ExpiryMode::AfterSend) ?: ExpiryMode.NONE } + } + else -> null + }?.let { ExpirationConfiguration(threadId, it, dbExpirationMetadata.updatedTimestampMs) } + } + + override fun setExpirationConfiguration(config: ExpirationConfiguration) { + val recipient = getRecipientForThread(config.threadId) ?: return + + val expirationDb = DatabaseComponent.get(context).expirationConfigurationDatabase() + val currentConfig = expirationDb.getExpirationConfiguration(config.threadId) + if (currentConfig != null && currentConfig.updatedTimestampMs >= config.updatedTimestampMs) return + val expiryMode = config.expiryMode + + if (expiryMode == ExpiryMode.NONE) { + // Clear the legacy recipients on updating config to be none + DatabaseComponent.get(context).lokiAPIDatabase().setLastLegacySenderAddress(recipient.address.serialize(), null) + } + + if (recipient.isClosedGroupRecipient) { + val userGroups = configFactory.userGroups ?: return + val groupPublicKey = GroupUtil.addressToGroupSessionId(recipient.address) + val groupInfo = userGroups.getLegacyGroupInfo(groupPublicKey) + ?.copy(disappearingTimer = expiryMode.expirySeconds) ?: return + userGroups.set(groupInfo) + } else if (recipient.isLocalNumber) { + val user = configFactory.user ?: return + user.setNtsExpiry(expiryMode) + } else if (recipient.isContactRecipient) { + val contacts = configFactory.contacts ?: return + + val contact = contacts.get(recipient.address.serialize())?.copy(expiryMode = expiryMode) ?: return + contacts.set(contact) + } + expirationDb.setExpirationConfiguration( + config.run { copy(expiryMode = expiryMode) } + ) + } + + override fun getExpiringMessages(messageIds: List): List> { + val expiringMessages = mutableListOf>() + val smsDb = DatabaseComponent.get(context).smsDatabase() + smsDb.readerFor(smsDb.expirationNotStartedMessages).use { reader -> + while (reader.next != null) { + if (messageIds.isEmpty() || reader.current.id in messageIds) { + expiringMessages.add(reader.current.id to reader.current.expiresIn) + } + } + } + val mmsDb = DatabaseComponent.get(context).mmsDatabase() + mmsDb.expireNotStartedMessages.use { reader -> + while (reader.next != null) { + if (messageIds.isEmpty() || reader.current.id in messageIds) { + expiringMessages.add(reader.current.id to reader.current.expiresIn) + } + } + } + return expiringMessages + } + + override fun updateDisappearingState( + messageSender: String, + threadID: Long, + disappearingState: Recipient.DisappearingState + ) { + val threadDb = DatabaseComponent.get(context).threadDatabase() + val lokiDb = DatabaseComponent.get(context).lokiAPIDatabase() + val recipient = threadDb.getRecipientForThreadId(threadID) ?: return + val recipientAddress = recipient.address.serialize() + DatabaseComponent.get(context).recipientDatabase() + .setDisappearingState(recipient, disappearingState); + val currentLegacyRecipient = lokiDb.getLastLegacySenderAddress(recipientAddress) + val currentExpiry = getExpirationConfiguration(threadID) + if (disappearingState == DisappearingState.LEGACY + && currentExpiry?.isEnabled == true + && ExpirationConfiguration.isNewConfigEnabled) { // only set "this person is legacy" if new config enabled + lokiDb.setLastLegacySenderAddress(recipientAddress, messageSender) + } else if (messageSender == currentLegacyRecipient) { + lokiDb.setLastLegacySenderAddress(recipientAddress, null) + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index 921b1c06b4..fd5042086c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -51,7 +51,6 @@ import org.session.libsignal.utilities.Pair; import org.session.libsignal.utilities.guava.Optional; import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.contacts.ContactUtil; -import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord; @@ -816,13 +815,7 @@ public class ThreadDatabase extends Database { MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase(); if (mmsSmsDatabase.getConversationCount(threadId) <= 0 && !force) return false; List messages = setRead(threadId, lastSeenTime); - if (isGroupRecipient) { - for (MarkedMessageInfo message: messages) { - MarkReadReceiver.scheduleDeletion(context, message.getExpirationInfo()); - } - } else { - MarkReadReceiver.process(context, messages); - } + MarkReadReceiver.process(context, messages); ApplicationContext.getInstance(context).messageNotifier.updateNotification(context, threadId); return setLastSeen(threadId, lastSeenTime); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index 3a3347a209..7713043c2c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -21,6 +21,7 @@ import org.thoughtcrime.securesms.database.BlindedIdMappingDatabase; import org.thoughtcrime.securesms.database.ConfigDatabase; import org.thoughtcrime.securesms.database.DraftDatabase; import org.thoughtcrime.securesms.database.EmojiSearchDatabase; +import org.thoughtcrime.securesms.database.ExpirationConfigurationDatabase; import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.database.GroupMemberDatabase; import org.thoughtcrime.securesms.database.GroupReceiptDatabase; @@ -89,11 +90,12 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int lokiV41 = 62; private static final int lokiV42 = 63; private static final int lokiV43 = 64; - private static final int lokiV44 = 65; + private static final int lokiV45 = 66; + private static final int lokiV46 = 67; // Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes - private static final int DATABASE_VERSION = lokiV44; + private static final int DATABASE_VERSION = lokiV46; private static final int MIN_DATABASE_VERSION = lokiV7; private static final String CIPHER3_DATABASE_NAME = "signal.db"; public static final String DATABASE_NAME = "signal_v4.db"; @@ -313,6 +315,8 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL(LokiMessageDatabase.getCreateMessageToThreadMappingTableCommand()); db.execSQL(LokiMessageDatabase.getCreateErrorMessageTableCommand()); db.execSQL(LokiMessageDatabase.getCreateMessageHashTableCommand()); + db.execSQL(LokiMessageDatabase.getCreateSmsHashTableCommand()); + db.execSQL(LokiMessageDatabase.getCreateMmsHashTableCommand()); db.execSQL(LokiThreadDatabase.getCreateSessionResetTableCommand()); db.execSQL(LokiThreadDatabase.getCreatePublicChatTableCommand()); db.execSQL(LokiUserDatabase.getCreateDisplayNameTableCommand()); @@ -326,6 +330,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL(GroupDatabase.getCreateUpdatedTimestampCommand()); db.execSQL(RecipientDatabase.getCreateApprovedCommand()); db.execSQL(RecipientDatabase.getCreateApprovedMeCommand()); + db.execSQL(RecipientDatabase.getCreateDisappearingStateCommand()); db.execSQL(MmsDatabase.CREATE_MESSAGE_REQUEST_RESPONSE_COMMAND); db.execSQL(MmsDatabase.CREATE_REACTIONS_UNREAD_COMMAND); db.execSQL(SmsDatabase.CREATE_REACTIONS_UNREAD_COMMAND); @@ -347,6 +352,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL(SmsDatabase.CREATE_HAS_MENTION_COMMAND); db.execSQL(MmsDatabase.CREATE_HAS_MENTION_COMMAND); db.execSQL(ConfigDatabase.CREATE_CONFIG_TABLE_COMMAND); + db.execSQL(ExpirationConfigurationDatabase.CREATE_EXPIRATION_CONFIGURATION_TABLE_COMMAND); executeStatements(db, SmsDatabase.CREATE_INDEXS); executeStatements(db, MmsDatabase.CREATE_INDEXS); @@ -360,6 +366,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { executeStatements(db, ReactionDatabase.CREATE_REACTION_TRIGGERS); db.execSQL(RecipientDatabase.getAddWrapperHash()); db.execSQL(RecipientDatabase.getAddBlocksCommunityMessageRequests()); + db.execSQL(LokiAPIDatabase.CREATE_LAST_LEGACY_MESSAGE_TABLE); } @Override @@ -610,6 +617,22 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL(SessionJobDatabase.dropAttachmentDownloadJobs); } + if (oldVersion < lokiV45) { + db.execSQL(RecipientDatabase.getCreateDisappearingStateCommand()); + db.execSQL(ExpirationConfigurationDatabase.CREATE_EXPIRATION_CONFIGURATION_TABLE_COMMAND); + db.execSQL(ExpirationConfigurationDatabase.MIGRATE_GROUP_CONVERSATION_EXPIRY_TYPE_COMMAND); + db.execSQL(ExpirationConfigurationDatabase.MIGRATE_ONE_TO_ONE_CONVERSATION_EXPIRY_TYPE_COMMAND); + + db.execSQL(LokiMessageDatabase.getCreateSmsHashTableCommand()); + db.execSQL(LokiMessageDatabase.getCreateMmsHashTableCommand()); + } + + if (oldVersion < lokiV46) { + executeStatements(db, SmsDatabase.ADD_AUTOINCREMENT); + executeStatements(db, MmsDatabase.ADD_AUTOINCREMENT); + db.execSQL(LokiAPIDatabase.CREATE_LAST_LEGACY_MESSAGE_TABLE); + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java index ba01ffd9c5..b5b0aea20c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -54,6 +54,10 @@ public abstract class MessageRecord extends DisplayRecord { private final List reactions; private final boolean hasMention; + public final boolean isNotDisappearAfterRead() { + return expireStarted == getTimestamp(); + } + public abstract boolean isMms(); public abstract boolean isMmsNotification(); @@ -116,7 +120,7 @@ public abstract class MessageRecord extends DisplayRecord { return new SpannableString(UpdateMessageBuilder.INSTANCE.buildGroupUpdateMessage(context, updateMessageData, getIndividualRecipient().getAddress().serialize(), isOutgoing())); } else if (isExpirationTimerUpdate()) { int seconds = (int) (getExpiresIn() / 1000); - return new SpannableString(UpdateMessageBuilder.INSTANCE.buildExpirationTimerMessage(context, seconds, getIndividualRecipient().getAddress().serialize(), isOutgoing())); + return new SpannableString(UpdateMessageBuilder.INSTANCE.buildExpirationTimerMessage(context, seconds, getRecipient(), getIndividualRecipient().getAddress().serialize(), isOutgoing(), getTimestamp(), expireStarted)); } else if (isDataExtractionNotification()) { if (isScreenshotNotification()) return new SpannableString((UpdateMessageBuilder.INSTANCE.buildDataExtractionMessage(context, DataExtractionNotificationInfoMessage.Kind.SCREENSHOT, getIndividualRecipient().getAddress().serialize()))); else if (isMediaSavedNotification()) return new SpannableString((UpdateMessageBuilder.INSTANCE.buildDataExtractionMessage(context, DataExtractionNotificationInfoMessage.Kind.MEDIA_SAVED, getIndividualRecipient().getAddress().serialize()))); diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt index d664ffedb2..8379e1a23b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt @@ -187,7 +187,7 @@ class ConfigFactory( override fun persist(forConfigObject: ConfigBase, timestamp: Long) { try { listeners.forEach { listener -> - listener.notifyUpdates(forConfigObject) + listener.notifyUpdates(forConfigObject, timestamp) } when (forConfigObject) { is UserProfile -> persistUserConfigDump(timestamp) diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt index f2c046e0aa..c037f3b27a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt @@ -7,6 +7,7 @@ import dagger.hilt.components.SingletonComponent import org.session.libsession.database.MessageDataProvider import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.database.* +import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper @EntryPoint @@ -45,5 +46,6 @@ interface DatabaseComponent { fun attachmentProvider(): MessageDataProvider fun blindedIdMappingDatabase(): BlindedIdMappingDatabase fun groupMemberDatabase(): GroupMemberDatabase + fun expirationConfigurationDatabase(): ExpirationConfigurationDatabase fun configDatabase(): ConfigDatabase } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt index 524100190e..30fb40d89a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt @@ -7,12 +7,14 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import org.session.libsession.database.MessageDataProvider +import org.session.libsession.utilities.SSKEnvironment import org.thoughtcrime.securesms.attachments.DatabaseAttachmentProvider import org.thoughtcrime.securesms.crypto.AttachmentSecret import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider import org.thoughtcrime.securesms.database.* import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper +import org.thoughtcrime.securesms.service.ExpiringMessageManager import javax.inject.Singleton @Module @@ -24,6 +26,10 @@ object DatabaseModule { System.loadLibrary("sqlcipher") } + @Provides + @Singleton + fun provideMessageExpirationManagerProtocol(@ApplicationContext context: Context): SSKEnvironment.MessageExpirationManagerProtocol = ExpiringMessageManager(context) + @Provides @Singleton fun provideAttachmentSecret(@ApplicationContext context: Context) = AttachmentSecretProvider.getInstance(context).orCreateAttachmentSecret @@ -129,6 +135,10 @@ object DatabaseModule { @Singleton fun provideEmojiSearchDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = EmojiSearchDatabase(context, openHelper) + @Provides + @Singleton + fun provideExpirationConfigurationDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = ExpirationConfigurationDatabase(context, openHelper) + @Provides @Singleton fun provideStorage(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper, configFactory: ConfigFactory, threadDatabase: ThreadDatabase): Storage { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt index f8e64dd381..adeeeb91fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt @@ -8,7 +8,6 @@ import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPol import org.session.libsession.utilities.Address import org.session.libsession.utilities.GroupRecord import org.session.libsession.utilities.GroupUtil -import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.crypto.ecc.DjbECPublicKey import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.dependencies.ConfigFactory @@ -41,7 +40,7 @@ object ClosedGroupManager { return groups.eraseLegacyGroup(groupPublicKey) } - fun ConfigFactory.updateLegacyGroup(groupRecipientSettings: Recipient.RecipientSettings, group: GroupRecord) { + fun ConfigFactory.updateLegacyGroup(group: GroupRecord) { val groups = userGroups ?: return if (!group.isClosedGroup) return val storage = MessagingModuleConfiguration.shared.storage @@ -53,7 +52,6 @@ object ClosedGroupManager { val toSet = legacyInfo.copy( members = latestMemberMap, name = group.title, - disappearingTimer = groupRecipientSettings.expireMessages.toLong(), priority = if (storage.isPinned(threadId)) ConfigBase.PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE, encPubKey = (latestKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte encSecKey = latestKeyPair.privateKey.serialize() diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt index 9fee8adafc..da982589c2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt @@ -176,6 +176,7 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { // endregion // region Updating + @Deprecated("Deprecated in Java") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) when (requestCode) { @@ -335,7 +336,7 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { ?: return Log.w("Loki", "No recipient settings when trying to update group config") val latestGroup = storage.getGroup(groupID) ?: return Log.w("Loki", "No group record when trying to update group config") - groupConfigFactory.updateLegacyGroup(latestRecipient, latestGroup) + groupConfigFactory.updateLegacyGroup(latestGroup) } class GroupMembers(val members: List, val zombieMembers: List) 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 31b281c6de..c876edb822 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt @@ -92,7 +92,7 @@ class ConversationView : LinearLayout { val senderDisplayName = getUserDisplayName(thread.recipient) ?: thread.recipient.address.toString() binding.conversationViewDisplayNameTextView.text = senderDisplayName - binding.timestampTextView.text = DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), thread.date) + binding.timestampTextView.text = thread.date.takeIf { it != 0L }?.let { DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), it) } val recipient = thread.recipient binding.muteIndicatorImageView.isVisible = recipient.isMuted || recipient.notifyType != NOTIFY_TYPE_ALL val drawableRes = if (recipient.isMuted || recipient.notifyType == NOTIFY_TYPE_NONE) { 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 f255455727..850f065728 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -455,6 +455,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), // endregion // region Interaction + @Deprecated("Deprecated in Java") override fun onBackPressed() { if (binding.globalSearchRecycler.isVisible) { binding.globalSearchInputLayout.clearSearch(true) diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoHeardReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoHeardReceiver.java index 21157d0f51..88f92ecb48 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoHeardReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoHeardReceiver.java @@ -27,7 +27,7 @@ import androidx.core.app.NotificationManagerCompat; import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.ApplicationContext; -import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo; +import org.thoughtcrime.securesms.database.MarkedMessageInfo; import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import java.util.LinkedList; diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java index ddff9f52b6..0bfa2b0899 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java @@ -26,6 +26,7 @@ import android.os.Bundle; import androidx.core.app.RemoteInput; +import org.session.libsession.messaging.messages.ExpirationConfiguration; import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage; import org.session.libsession.messaging.messages.signal.OutgoingTextMessage; import org.session.libsession.messaging.messages.visible.VisibleMessage; @@ -35,13 +36,15 @@ import org.session.libsession.utilities.Address; import org.session.libsession.utilities.recipients.Recipient; import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.ApplicationContext; -import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo; +import org.thoughtcrime.securesms.database.MarkedMessageInfo; import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import org.thoughtcrime.securesms.mms.MmsException; import java.util.Collections; import java.util.List; +import network.loki.messenger.libsession_util.util.ExpiryMode; + /** * Get the response text from the Android Auto and sends an message as a reply */ @@ -85,10 +88,14 @@ public class AndroidAutoReplyReceiver extends BroadcastReceiver { message.setText(responseText.toString()); message.setSentTimestamp(SnodeAPI.getNowWithOffset()); MessageSender.send(message, recipient.getAddress()); + ExpirationConfiguration config = DatabaseComponent.get(context).storage().getExpirationConfiguration(threadId); + ExpiryMode expiryMode = config == null ? null : config.getExpiryMode(); + long expiresInMillis = expiryMode == null ? 0 : expiryMode.getExpiryMillis(); + long expireStartedAt = expiryMode instanceof ExpiryMode.AfterSend ? message.getSentTimestamp() : 0L; if (recipient.isGroupRecipient()) { Log.w("AndroidAutoReplyReceiver", "GroupRecipient, Sending media message"); - OutgoingMediaMessage reply = OutgoingMediaMessage.from(message, recipient, Collections.emptyList(), null, null); + OutgoingMediaMessage reply = OutgoingMediaMessage.from(message, recipient, Collections.emptyList(), null, null, expiresInMillis, 0); try { DatabaseComponent.get(context).mmsDatabase().insertMessageOutbox(reply, replyThreadId, false, null, true); } catch (MmsException e) { @@ -96,7 +103,7 @@ public class AndroidAutoReplyReceiver extends BroadcastReceiver { } } else { Log.w("AndroidAutoReplyReceiver", "Sending regular message "); - OutgoingTextMessage reply = OutgoingTextMessage.from(message, recipient); + OutgoingTextMessage reply = OutgoingTextMessage.from(message, recipient, expiresInMillis, expireStartedAt); DatabaseComponent.get(context).smsDatabase().insertMessageOutbox(replyThreadId, reply, false, SnodeAPI.getNowWithOffset(), null, true); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java deleted file mode 100644 index 309f2732f8..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java +++ /dev/null @@ -1,100 +0,0 @@ -package org.thoughtcrime.securesms.notifications; - -import android.annotation.SuppressLint; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.os.AsyncTask; - -import androidx.annotation.NonNull; -import androidx.core.app.NotificationManagerCompat; - -import com.annimon.stream.Collectors; -import com.annimon.stream.Stream; - -import org.session.libsession.database.StorageProtocol; -import org.session.libsession.messaging.MessagingModuleConfiguration; -import org.session.libsession.messaging.messages.control.ReadReceipt; -import org.session.libsession.messaging.sending_receiving.MessageSender; -import org.session.libsession.snode.SnodeAPI; -import org.session.libsession.utilities.Address; -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsession.utilities.recipients.Recipient; -import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.ApplicationContext; -import org.thoughtcrime.securesms.database.MessagingDatabase.ExpirationInfo; -import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo; -import org.thoughtcrime.securesms.database.MessagingDatabase.SyncMessageId; -import org.thoughtcrime.securesms.dependencies.DatabaseComponent; -import org.thoughtcrime.securesms.service.ExpiringMessageManager; -import org.thoughtcrime.securesms.util.SessionMetaProtocol; - -import java.util.List; -import java.util.Map; - -public class MarkReadReceiver extends BroadcastReceiver { - - private static final String TAG = MarkReadReceiver.class.getSimpleName(); - public static final String CLEAR_ACTION = "network.loki.securesms.notifications.CLEAR"; - public static final String THREAD_IDS_EXTRA = "thread_ids"; - public static final String NOTIFICATION_ID_EXTRA = "notification_id"; - - @SuppressLint("StaticFieldLeak") - @Override - public void onReceive(final Context context, Intent intent) { - if (!CLEAR_ACTION.equals(intent.getAction())) - return; - - final long[] threadIds = intent.getLongArrayExtra(THREAD_IDS_EXTRA); - - if (threadIds != null) { - NotificationManagerCompat.from(context).cancel(intent.getIntExtra(NOTIFICATION_ID_EXTRA, -1)); - - new AsyncTask() { - @Override - protected Void doInBackground(Void... params) { - long currentTime = SnodeAPI.getNowWithOffset(); - for (long threadId : threadIds) { - Log.i(TAG, "Marking as read: " + threadId); - StorageProtocol storage = MessagingModuleConfiguration.getShared().getStorage(); - storage.markConversationAsRead(threadId,currentTime, true); - } - return null; - } - }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - } - - public static void process(@NonNull Context context, @NonNull List markedReadMessages) { - if (markedReadMessages.isEmpty()) return; - - for (MarkedMessageInfo messageInfo : markedReadMessages) { - scheduleDeletion(context, messageInfo.getExpirationInfo()); - } - - if (!TextSecurePreferences.isReadReceiptsEnabled(context)) return; - - Map> addressMap = Stream.of(markedReadMessages) - .map(MarkedMessageInfo::getSyncMessageId) - .collect(Collectors.groupingBy(SyncMessageId::getAddress)); - - for (Address address : addressMap.keySet()) { - List timestamps = Stream.of(addressMap.get(address)).map(SyncMessageId::getTimetamp).toList(); - if (!SessionMetaProtocol.shouldSendReadReceipt(Recipient.from(context, address, false))) { continue; } - ReadReceipt readReceipt = new ReadReceipt(timestamps); - readReceipt.setSentTimestamp(SnodeAPI.getNowWithOffset()); - MessageSender.send(readReceipt, address); - } - } - - public static void scheduleDeletion(Context context, ExpirationInfo expirationInfo) { - if (expirationInfo.getExpiresIn() > 0 && expirationInfo.getExpireStarted() <= 0) { - ExpiringMessageManager expirationManager = ApplicationContext.getInstance(context).getExpiringMessageManager(); - - if (expirationInfo.isMms()) DatabaseComponent.get(context).mmsDatabase().markExpireStarted(expirationInfo.getId()); - else DatabaseComponent.get(context).smsDatabase().markExpireStarted(expirationInfo.getId()); - - expirationManager.scheduleDeletion(expirationInfo.getId(), expirationInfo.isMms(), expirationInfo.getExpiresIn()); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt new file mode 100644 index 0000000000..9f83726f46 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt @@ -0,0 +1,157 @@ +package org.thoughtcrime.securesms.notifications + +import android.annotation.SuppressLint +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.AsyncTask +import androidx.core.app.NotificationManagerCompat +import org.session.libsession.messaging.MessagingModuleConfiguration.Companion.shared +import org.session.libsession.messaging.messages.control.ReadReceipt +import org.session.libsession.messaging.sending_receiving.MessageSender.send +import org.session.libsession.snode.SnodeAPI +import org.session.libsession.snode.SnodeAPI.nowWithOffset +import org.session.libsession.utilities.SSKEnvironment +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.TextSecurePreferences.Companion.isReadReceiptsEnabled +import org.session.libsession.utilities.associateByNotNull +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.ApplicationContext +import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType +import org.thoughtcrime.securesms.database.ExpirationInfo +import org.thoughtcrime.securesms.database.MarkedMessageInfo +import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import org.thoughtcrime.securesms.util.SessionMetaProtocol.shouldSendReadReceipt + +class MarkReadReceiver : BroadcastReceiver() { + @SuppressLint("StaticFieldLeak") + override fun onReceive(context: Context, intent: Intent) { + if (CLEAR_ACTION != intent.action) return + val threadIds = intent.getLongArrayExtra(THREAD_IDS_EXTRA) ?: return + NotificationManagerCompat.from(context).cancel(intent.getIntExtra(NOTIFICATION_ID_EXTRA, -1)) + object : AsyncTask() { + override fun doInBackground(vararg params: Void?): Void? { + val currentTime = nowWithOffset + threadIds.forEach { + Log.i(TAG, "Marking as read: $it") + shared.storage.markConversationAsRead(it, currentTime, true) + } + return null + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR) + } + + companion object { + private val TAG = MarkReadReceiver::class.java.simpleName + const val CLEAR_ACTION = "network.loki.securesms.notifications.CLEAR" + const val THREAD_IDS_EXTRA = "thread_ids" + const val NOTIFICATION_ID_EXTRA = "notification_id" + + val messageExpirationManager = SSKEnvironment.shared.messageExpirationManager + + @JvmStatic + fun process( + context: Context, + markedReadMessages: List + ) { + if (markedReadMessages.isEmpty()) return + + sendReadReceipts(context, markedReadMessages) + + val mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase() + + // start disappear after read messages except TimerUpdates in groups. + markedReadMessages + .filter { it.expiryType == ExpiryType.AFTER_READ } + .map { it.syncMessageId } + .filter { mmsSmsDatabase.getMessageForTimestamp(it.timetamp)?.run { isExpirationTimerUpdate && recipient.isClosedGroupRecipient } == false } + .forEach { messageExpirationManager.startDisappearAfterRead(it.timetamp, it.address.serialize()) } + + hashToDisappearAfterReadMessage(context, markedReadMessages)?.let { + fetchUpdatedExpiriesAndScheduleDeletion(context, it) + shortenExpiryOfDisappearingAfterRead(context, it) + } + } + + private fun hashToDisappearAfterReadMessage( + context: Context, + markedReadMessages: List + ): Map? { + val loki = DatabaseComponent.get(context).lokiMessageDatabase() + + return markedReadMessages + .filter { it.expiryType == ExpiryType.AFTER_READ } + .associateByNotNull { it.expirationInfo.run { loki.getMessageServerHash(id, isMms) } } + .takeIf { it.isNotEmpty() } + } + + private fun shortenExpiryOfDisappearingAfterRead( + context: Context, + hashToMessage: Map + ) { + hashToMessage.entries + .groupBy( + keySelector = { it.value.expirationInfo.expiresIn }, + valueTransform = { it.key } + ).forEach { (expiresIn, hashes) -> + SnodeAPI.alterTtl( + messageHashes = hashes, + newExpiry = nowWithOffset + expiresIn, + publicKey = TextSecurePreferences.getLocalNumber(context)!!, + shorten = true + ) + } + } + + private fun sendReadReceipts( + context: Context, + markedReadMessages: List + ) { + if (!isReadReceiptsEnabled(context)) return + + markedReadMessages.map { it.syncMessageId } + .filter { shouldSendReadReceipt(Recipient.from(context, it.address, false)) } + .groupBy { it.address } + .forEach { (address, messages) -> + messages.map { it.timetamp } + .let(::ReadReceipt) + .apply { sentTimestamp = nowWithOffset } + .let { send(it, address) } + } + } + + private fun fetchUpdatedExpiriesAndScheduleDeletion( + context: Context, + hashToMessage: Map + ) { + @Suppress("UNCHECKED_CAST") + val expiries = SnodeAPI.getExpiries(hashToMessage.keys.toList(), TextSecurePreferences.getLocalNumber(context)!!).get()["expiries"] as Map + hashToMessage.forEach { (hash, info) -> expiries[hash]?.let { scheduleDeletion(context, info.expirationInfo, it - info.expirationInfo.expireStarted) } } + } + + private fun scheduleDeletion( + context: Context?, + expirationInfo: ExpirationInfo, + expiresIn: Long = expirationInfo.expiresIn + ) { + if (expiresIn == 0L) return + + val now = nowWithOffset + + val expireStarted = expirationInfo.expireStarted + + if (expirationInfo.isDisappearAfterRead() && expireStarted == 0L || now < expireStarted) { + val db = DatabaseComponent.get(context!!).run { if (expirationInfo.isMms) mmsDatabase() else smsDatabase() } + db.markExpireStarted(expirationInfo.id, now) + } + + ApplicationContext.getInstance(context).expiringMessageManager.scheduleDeletion( + expirationInfo.id, + expirationInfo.isMms, + now, + expiresIn + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java index 06ea38b79b..cf0e04ddf4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java @@ -26,25 +26,35 @@ import android.os.Bundle; import androidx.core.app.RemoteInput; +import org.session.libsession.messaging.messages.ExpirationConfiguration; import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage; import org.session.libsession.messaging.messages.signal.OutgoingTextMessage; import org.session.libsession.messaging.messages.visible.VisibleMessage; import org.session.libsession.messaging.sending_receiving.MessageSender; +import org.session.libsession.snode.SnodeAPI; import org.session.libsession.utilities.Address; import org.session.libsession.utilities.recipients.Recipient; import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.ApplicationContext; -import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo; +import org.thoughtcrime.securesms.database.MarkedMessageInfo; +import org.thoughtcrime.securesms.database.MmsDatabase; +import org.thoughtcrime.securesms.database.SmsDatabase; +import org.thoughtcrime.securesms.database.Storage; import org.thoughtcrime.securesms.database.ThreadDatabase; -import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import org.thoughtcrime.securesms.mms.MmsException; import java.util.Collections; import java.util.List; +import javax.inject.Inject; + +import dagger.hilt.android.AndroidEntryPoint; +import network.loki.messenger.libsession_util.util.ExpiryMode; + /** * Get the response text from the Wearable Device and sends an message as a reply */ +@AndroidEntryPoint public class RemoteReplyReceiver extends BroadcastReceiver { public static final String TAG = RemoteReplyReceiver.class.getSimpleName(); @@ -52,6 +62,15 @@ public class RemoteReplyReceiver extends BroadcastReceiver { public static final String ADDRESS_EXTRA = "address"; public static final String REPLY_METHOD = "reply_method"; + @Inject + ThreadDatabase threadDatabase; + @Inject + MmsDatabase mmsDatabase; + @Inject + SmsDatabase smsDatabase; + @Inject + Storage storage; + @SuppressLint("StaticFieldLeak") @Override public void onReceive(final Context context, Intent intent) { @@ -73,17 +92,20 @@ public class RemoteReplyReceiver extends BroadcastReceiver { @Override protected Void doInBackground(Void... params) { Recipient recipient = Recipient.from(context, address, false); - ThreadDatabase threadDatabase = DatabaseComponent.get(context).threadDatabase(); long threadId = threadDatabase.getOrCreateThreadIdFor(recipient); VisibleMessage message = new VisibleMessage(); - message.setSentTimestamp(System.currentTimeMillis()); + message.setSentTimestamp(SnodeAPI.getNowWithOffset()); message.setText(responseText.toString()); + ExpirationConfiguration config = storage.getExpirationConfiguration(threadId); + ExpiryMode expiryMode = config == null ? null : config.getExpiryMode(); + long expiresInMillis = expiryMode == null ? 0 : expiryMode.getExpiryMillis(); + long expireStartedAt = expiryMode instanceof ExpiryMode.AfterSend ? message.getSentTimestamp() : 0L; switch (replyMethod) { case GroupMessage: { - OutgoingMediaMessage reply = OutgoingMediaMessage.from(message, recipient, Collections.emptyList(), null, null); + OutgoingMediaMessage reply = OutgoingMediaMessage.from(message, recipient, Collections.emptyList(), null, null, expiresInMillis, 0); try { - DatabaseComponent.get(context).mmsDatabase().insertMessageOutbox(reply, threadId, false, null, true); + mmsDatabase.insertMessageOutbox(reply, threadId, false, null, true); MessageSender.send(message, address); } catch (MmsException e) { Log.w(TAG, e); @@ -91,8 +113,8 @@ public class RemoteReplyReceiver extends BroadcastReceiver { break; } case SecureMessage: { - OutgoingTextMessage reply = OutgoingTextMessage.from(message, recipient); - DatabaseComponent.get(context).smsDatabase().insertMessageOutbox(threadId, reply, false, System.currentTimeMillis(), null, true); + OutgoingTextMessage reply = OutgoingTextMessage.from(message, recipient, expiresInMillis, expireStartedAt); + smsDatabase.insertMessageOutbox(threadId, reply, false, System.currentTimeMillis(), null, true); MessageSender.send(message, address); break; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt index edd1bc274a..1c10571dbd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt @@ -54,6 +54,7 @@ class LinkDeviceActivity : BaseActionBarActivity(), ScanQRCodeWrapperFragmentDel private val adapter = LinkDeviceActivityAdapter(this) private var restoreJob: Job? = null + @Deprecated("Deprecated in Java") override fun onBackPressed() { if (restoreJob?.isActive == true) return // Don't allow going back with a pending job super.onBackPressed() diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt index 37a54a4afc..31f2782f8f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt @@ -15,10 +15,12 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.databinding.DialogClearAllDataBinding +import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.snode.SnodeAPI import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.createSessionDialog +import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities class ClearAllDataDialog : DialogFragment() { @@ -44,9 +46,9 @@ class ClearAllDataDialog : DialogFragment() { private fun createView(): View { binding = DialogClearAllDataBinding.inflate(LayoutInflater.from(requireContext())) - val device = RadioOption("deviceOnly", requireContext().getString(R.string.dialog_clear_all_data_clear_device_only)) - val network = RadioOption("deviceAndNetwork", requireContext().getString(R.string.dialog_clear_all_data_clear_device_and_network)) - var selectedOption = device + val device = radioOption("deviceOnly", R.string.dialog_clear_all_data_clear_device_only) + val network = radioOption("deviceAndNetwork", R.string.dialog_clear_all_data_clear_device_and_network) + var selectedOption: RadioOption = device val optionAdapter = RadioOptionAdapter { selectedOption = it } binding.recyclerView.apply { itemAnimator = null @@ -115,6 +117,10 @@ class ClearAllDataDialog : DialogFragment() { } else { // finish val result = try { + val openGroups = DatabaseComponent.get(requireContext()).lokiThreadDatabase().getAllOpenGroups() + openGroups.map { it.value.server }.toSet().forEach { server -> + OpenGroupApi.deleteAllInboxMessages(server).get() + } SnodeAPI.deleteAllMessages().get() } catch (e: Exception) { null diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt index a04e71ddf1..21b12496bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt @@ -95,6 +95,7 @@ class PrivacySettingsPreferenceFragment : ListSummaryPreferenceFragment() { } } + @Deprecated("Deprecated in Java") override fun onRequestPermissionsResult( requestCode: Int, permissions: Array, diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/RadioOptionAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/RadioOptionAdapter.kt index 4bb69c4c14..f80acee64e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/RadioOptionAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/RadioOptionAdapter.kt @@ -3,53 +3,120 @@ package org.thoughtcrime.securesms.preferences import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.annotation.StringRes +import androidx.core.view.isVisible import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import network.loki.messenger.R import network.loki.messenger.databinding.ItemSelectableBinding +import network.loki.messenger.libsession_util.util.ExpiryMode import org.thoughtcrime.securesms.mms.GlideApp +import org.thoughtcrime.securesms.ui.GetString +import java.util.Objects -class RadioOptionAdapter( - var selectedOptionPosition: Int = 0, - private val onClickListener: (RadioOption) -> Unit -) : ListAdapter(RadioOptionDiffer()) { +class RadioOptionAdapter( + private var selectedOptionPosition: Int = 0, + private val onClickListener: (RadioOption) -> Unit +) : ListAdapter, RadioOptionAdapter.ViewHolder>(RadioOptionDiffer()) { - class RadioOptionDiffer: DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: RadioOption, newItem: RadioOption) = oldItem.title == newItem.title - override fun areContentsTheSame(oldItem: RadioOption, newItem: RadioOption) = oldItem.value == newItem.value + class RadioOptionDiffer: DiffUtil.ItemCallback>() { + override fun areItemsTheSame(oldItem: RadioOption, newItem: RadioOption) = oldItem.title == newItem.title + override fun areContentsTheSame(oldItem: RadioOption, newItem: RadioOption) = Objects.equals(oldItem.value,newItem.value) } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val itemView = LayoutInflater.from(parent.context).inflate(R.layout.item_selectable, parent, false) - return ViewHolder(itemView) - } + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder = + LayoutInflater.from(parent.context).inflate(R.layout.item_selectable, parent, false) + .let(::ViewHolder) - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - val option = getItem(position) - val isSelected = position == selectedOptionPosition - holder.bind(option, isSelected) { + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind( + option = getItem(position), + isSelected = position == selectedOptionPosition + ) { onClickListener(it) - selectedOptionPosition = position - notifyItemRangeChanged(0, itemCount) + setSelectedPosition(position) } } - class ViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) { + fun setSelectedPosition(selectedPosition: Int) { + notifyItemChanged(selectedOptionPosition) + selectedOptionPosition = selectedPosition + notifyItemChanged(selectedOptionPosition) + } + class ViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) { val glide = GlideApp.with(itemView) val binding = ItemSelectableBinding.bind(itemView) - fun bind(option: RadioOption, isSelected: Boolean, toggleSelection: (RadioOption) -> Unit) { - binding.titleTextView.text = option.title - binding.root.setOnClickListener { toggleSelection(option) } + fun bind(option: RadioOption, isSelected: Boolean, toggleSelection: (RadioOption) -> Unit) { + val alpha = if (option.enabled) 1f else 0.5f + binding.root.isEnabled = option.enabled + binding.root.contentDescription = option.contentDescription?.string(itemView.context) + binding.titleTextView.alpha = alpha + binding.subtitleTextView.alpha = alpha + binding.selectButton.alpha = alpha + + binding.titleTextView.text = option.title.string(itemView.context) + binding.subtitleTextView.text = option.subtitle?.string(itemView.context).also { + binding.subtitleTextView.isVisible = !it.isNullOrBlank() + } + binding.selectButton.isSelected = isSelected + if (option.enabled) { + binding.root.setOnClickListener { toggleSelection(option) } + } } } } -data class RadioOption( - val value: String, - val title: String +data class RadioOption( + val value: T, + val title: GetString, + val subtitle: GetString? = null, + val enabled: Boolean = true, + val contentDescription: GetString? = null ) + +fun radioOption(value: T, @StringRes title: Int, configure: RadioOptionBuilder.() -> Unit = {}) = + radioOption(value, GetString(title), configure) + +fun radioOption(value: T, title: String, configure: RadioOptionBuilder.() -> Unit = {}) = + radioOption(value, GetString(title), configure) + +fun radioOption(value: T, title: GetString, configure: RadioOptionBuilder.() -> Unit = {}) = + RadioOptionBuilder(value, title).also { it.configure() }.build() + +class RadioOptionBuilder( + val value: T, + val title: GetString +) { + var subtitle: GetString? = null + var enabled: Boolean = true + var contentDescription: GetString? = null + + fun subtitle(string: String) { + subtitle = GetString(string) + } + + fun subtitle(@StringRes stringRes: Int) { + subtitle = GetString(stringRes) + } + + fun contentDescription(string: String) { + contentDescription = GetString(string) + } + + fun contentDescription(@StringRes stringRes: Int) { + contentDescription = GetString(stringRes) + } + + fun build() = RadioOption( + value, + title, + subtitle, + enabled, + contentDescription + ) +} 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 5f24855760..062a8d44dd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt @@ -30,9 +30,9 @@ import nl.komponents.kovenant.all import nl.komponents.kovenant.ui.alwaysUi import nl.komponents.kovenant.ui.successUi import org.session.libsession.avatars.AvatarHelper +import org.session.libsession.avatars.ProfileContactPhoto import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.snode.SnodeAPI -import org.session.libsession.avatars.ProfileContactPhoto import org.session.libsession.utilities.* import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol import org.session.libsession.utilities.recipients.Recipient @@ -151,6 +151,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { } } + @Deprecated("Deprecated in Java") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) when (requestCode) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt index 2c0b39d69d..f6277d1a4a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -1,7 +1,9 @@ package org.thoughtcrime.securesms.repository +import network.loki.messenger.libsession_util.util.ExpiryMode import android.content.ContentResolver import android.content.Context +import app.cash.copper.Query import app.cash.copper.flow.observeQuery import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow @@ -23,6 +25,7 @@ import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.toHexString import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.database.DraftDatabase +import org.thoughtcrime.securesms.database.ExpirationConfigurationDatabase import org.thoughtcrime.securesms.database.LokiMessageDatabase import org.thoughtcrime.securesms.database.LokiThreadDatabase import org.thoughtcrime.securesms.database.MmsDatabase @@ -35,6 +38,7 @@ import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.dependencies.DatabaseComponent import javax.inject.Inject import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -43,6 +47,7 @@ import kotlin.coroutines.suspendCoroutine interface ConversationRepository { fun maybeGetRecipientForThreadId(threadId: Long): Recipient? fun maybeGetBlindedRecipient(recipient: Recipient): Recipient? + fun changes(threadId: Long): Flow fun recipientUpdateFlow(threadId: Long): Flow fun saveDraft(threadId: Long, text: String) fun getDraft(threadId: Long): String? @@ -97,6 +102,7 @@ class DefaultConversationRepository @Inject constructor( private val storage: Storage, private val lokiMessageDb: LokiMessageDatabase, private val sessionJobDb: SessionJobDatabase, + private val configDb: ExpirationConfigurationDatabase, private val configFactory: ConfigFactory, private val contentResolver: ContentResolver, ) : ConversationRepository { @@ -114,6 +120,9 @@ class DefaultConversationRepository @Inject constructor( ) } + override fun changes(threadId: Long): Flow = + contentResolver.observeQuery(DatabaseContentProviders.Conversation.getUriForThread(threadId)) + override fun recipientUpdateFlow(threadId: Long): Flow { return contentResolver.observeQuery(DatabaseContentProviders.Conversation.getUriForThread(threadId)).map { maybeGetRecipientForThreadId(threadId) @@ -141,14 +150,20 @@ class DefaultConversationRepository @Inject constructor( for (contact in contacts) { val message = VisibleMessage() message.sentTimestamp = SnodeAPI.nowWithOffset - val openGroupInvitation = OpenGroupInvitation() - openGroupInvitation.name = openGroup.name - openGroupInvitation.url = openGroup.joinURL + val openGroupInvitation = OpenGroupInvitation().apply { + name = openGroup.name + url = openGroup.joinURL + } message.openGroupInvitation = openGroupInvitation + val expirationConfig = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(contact).let(storage::getExpirationConfiguration) + val expiresInMillis = expirationConfig?.expiryMode?.expiryMillis ?: 0 + val expireStartedAt = if (expirationConfig?.expiryMode is ExpiryMode.AfterSend) message.sentTimestamp!! else 0 val outgoingTextMessage = OutgoingTextMessage.fromOpenGroupInvitation( openGroupInvitation, contact, - message.sentTimestamp + message.sentTimestamp, + expiresInMillis, + expireStartedAt ) smsDb.insertMessageOutbox(-1, outgoingTextMessage, message.sentTimestamp!!, true) MessageSender.send(message, contact.address) @@ -194,7 +209,7 @@ class DefaultConversationRepository @Inject constructor( } } else { messageDataProvider.deleteMessage(message.id, !message.isMms) - messageDataProvider.getServerHashForMessage(message.id)?.let { serverHash -> + messageDataProvider.getServerHashForMessage(message.id, message.isMms)?.let { serverHash -> var publicKey = recipient.address.serialize() if (recipient.isClosedGroupRecipient) { publicKey = GroupUtil.doubleDecodeGroupID(publicKey).toHexString() @@ -211,16 +226,11 @@ class DefaultConversationRepository @Inject constructor( override fun buildUnsendRequest(recipient: Recipient, message: MessageRecord): UnsendRequest? { if (recipient.isOpenGroupRecipient) return null - messageDataProvider.getServerHashForMessage(message.id) ?: return null - val unsendRequest = UnsendRequest() - if (message.isOutgoing) { - unsendRequest.author = textSecurePreferences.getLocalNumber() - } else { - unsendRequest.author = message.individualRecipient.address.contactIdentifier() - } - unsendRequest.timestamp = message.timestamp - - return unsendRequest + messageDataProvider.getServerHashForMessage(message.id, message.isMms) ?: return null + return UnsendRequest( + author = message.takeUnless { it.isOutgoing }?.run { individualRecipient.address.contactIdentifier() } ?: textSecurePreferences.getLocalNumber(), + timestamp = message.timestamp + ) } override suspend fun deleteMessageWithoutUnsendRequest( @@ -235,7 +245,7 @@ class DefaultConversationRepository @Inject constructor( lokiMessageDb.getServerID(message.id, !message.isMms) ?: continue messageServerIDs[messageServerID] = message } - for ((messageServerID, message) in messageServerIDs) { + messageServerIDs.forEach { (messageServerID, message) -> OpenGroupApi.deleteMessage(messageServerID, openGroup.room, openGroup.server) .success { messageDataProvider.deleteMessage(message.id, !message.isMms) diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.java b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.java deleted file mode 100644 index 85d8c8f436..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.java +++ /dev/null @@ -1,288 +0,0 @@ -package org.thoughtcrime.securesms.service; - -import android.content.Context; - -import org.jetbrains.annotations.NotNull; -import org.session.libsession.database.StorageProtocol; -import org.session.libsession.messaging.MessagingModuleConfiguration; -import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate; -import org.session.libsession.messaging.messages.signal.IncomingMediaMessage; -import org.session.libsession.messaging.messages.signal.OutgoingExpirationUpdateMessage; -import org.session.libsession.utilities.Address; -import org.session.libsession.utilities.GroupUtil; -import org.session.libsession.utilities.SSKEnvironment; -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsession.utilities.recipients.Recipient; -import org.session.libsignal.messages.SignalServiceGroup; -import org.session.libsignal.utilities.Log; -import org.session.libsignal.utilities.guava.Optional; -import org.thoughtcrime.securesms.database.MmsDatabase; -import org.thoughtcrime.securesms.database.MmsSmsDatabase; -import org.thoughtcrime.securesms.database.SmsDatabase; -import org.thoughtcrime.securesms.database.model.MessageRecord; -import org.thoughtcrime.securesms.dependencies.DatabaseComponent; -import org.thoughtcrime.securesms.mms.MmsException; - -import java.io.IOException; -import java.util.Comparator; -import java.util.TreeSet; -import java.util.concurrent.Executor; -import java.util.concurrent.Executors; - -public class ExpiringMessageManager implements SSKEnvironment.MessageExpirationManagerProtocol { - - private static final String TAG = ExpiringMessageManager.class.getSimpleName(); - - private final TreeSet expiringMessageReferences = new TreeSet<>(new ExpiringMessageComparator()); - private final Executor executor = Executors.newSingleThreadExecutor(); - - private final SmsDatabase smsDatabase; - private final MmsDatabase mmsDatabase; - private final MmsSmsDatabase mmsSmsDatabase; - private final Context context; - - public ExpiringMessageManager(Context context) { - this.context = context.getApplicationContext(); - this.smsDatabase = DatabaseComponent.get(context).smsDatabase(); - this.mmsDatabase = DatabaseComponent.get(context).mmsDatabase(); - this.mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase(); - - executor.execute(new LoadTask()); - executor.execute(new ProcessTask()); - } - - public void scheduleDeletion(long id, boolean mms, long expiresInMillis) { - scheduleDeletion(id, mms, System.currentTimeMillis(), expiresInMillis); - } - - public void scheduleDeletion(long id, boolean mms, long startedAtTimestamp, long expiresInMillis) { - long expiresAtMillis = startedAtTimestamp + expiresInMillis; - - synchronized (expiringMessageReferences) { - expiringMessageReferences.add(new ExpiringMessageReference(id, mms, expiresAtMillis)); - expiringMessageReferences.notifyAll(); - } - } - - public void checkSchedule() { - synchronized (expiringMessageReferences) { - expiringMessageReferences.notifyAll(); - } - } - - @Override - public void setExpirationTimer(@NotNull ExpirationTimerUpdate message) { - String userPublicKey = TextSecurePreferences.getLocalNumber(context); - String senderPublicKey = message.getSender(); - - // Notify the user - if (senderPublicKey == null || userPublicKey.equals(senderPublicKey)) { - // sender is self or a linked device - insertOutgoingExpirationTimerMessage(message); - } else { - insertIncomingExpirationTimerMessage(message); - } - - if (message.getId() != null) { - smsDatabase.deleteMessage(message.getId()); - } - } - - private void insertIncomingExpirationTimerMessage(ExpirationTimerUpdate message) { - - String senderPublicKey = message.getSender(); - Long sentTimestamp = message.getSentTimestamp(); - String groupId = message.getGroupPublicKey(); - int duration = message.getDuration(); - - Optional groupInfo = Optional.absent(); - Address address = Address.fromSerialized(senderPublicKey); - Recipient recipient = Recipient.from(context, address, false); - - // if the sender is blocked, we don't display the update, except if it's in a closed group - if (recipient.isBlocked() && groupId == null) return; - - try { - if (groupId != null) { - String groupID = GroupUtil.doubleEncodeGroupID(groupId); - groupInfo = Optional.of(new SignalServiceGroup(GroupUtil.getDecodedGroupIDAsData(groupID), SignalServiceGroup.GroupType.SIGNAL)); - - Address groupAddress = Address.fromSerialized(groupID); - recipient = Recipient.from(context, groupAddress, false); - } - Long threadId = MessagingModuleConfiguration.getShared().getStorage().getThreadId(recipient); - if (threadId == null) { - return; - } - - IncomingMediaMessage mediaMessage = new IncomingMediaMessage(address, sentTimestamp, -1, - duration * 1000L, true, - false, - false, - false, - Optional.absent(), - groupInfo, - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent()); - //insert the timer update message - mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, threadId, true); - - //set the timer to the conversation - MessagingModuleConfiguration.getShared().getStorage().setExpirationTimer(recipient.getAddress().serialize(), duration); - - } catch (IOException | MmsException ioe) { - Log.e("Loki", "Failed to insert expiration update message."); - } - } - - private void insertOutgoingExpirationTimerMessage(ExpirationTimerUpdate message) { - - Long sentTimestamp = message.getSentTimestamp(); - String groupId = message.getGroupPublicKey(); - int duration = message.getDuration(); - - Address address; - - try { - if (groupId != null) { - address = Address.fromSerialized(GroupUtil.doubleEncodeGroupID(groupId)); - } else { - address = Address.fromSerialized((message.getSyncTarget() != null && !message.getSyncTarget().isEmpty()) ? message.getSyncTarget() : message.getRecipient()); - } - - Recipient recipient = Recipient.from(context, address, false); - StorageProtocol storage = MessagingModuleConfiguration.getShared().getStorage(); - message.setThreadID(storage.getOrCreateThreadIdFor(address)); - - OutgoingExpirationUpdateMessage timerUpdateMessage = new OutgoingExpirationUpdateMessage(recipient, sentTimestamp, duration * 1000L, groupId); - mmsDatabase.insertSecureDecryptedMessageOutbox(timerUpdateMessage, message.getThreadID(), sentTimestamp, true); - //set the timer to the conversation - MessagingModuleConfiguration.getShared().getStorage().setExpirationTimer(recipient.getAddress().serialize(), duration); - } catch (MmsException | IOException ioe) { - Log.e("Loki", "Failed to insert expiration update message.", ioe); - } - } - - @Override - public void disableExpirationTimer(@NotNull ExpirationTimerUpdate message) { - setExpirationTimer(message); - } - - @Override - public void startAnyExpiration(long timestamp, @NotNull String author) { - MessageRecord messageRecord = mmsSmsDatabase.getMessageFor(timestamp, author); - if (messageRecord != null) { - boolean mms = messageRecord.isMms(); - Recipient recipient = messageRecord.getRecipient(); - if (recipient.getExpireMessages() <= 0) return; - if (mms) { - mmsDatabase.markExpireStarted(messageRecord.getId()); - } else { - smsDatabase.markExpireStarted(messageRecord.getId()); - } - scheduleDeletion(messageRecord.getId(), mms, recipient.getExpireMessages() * 1000); - } - } - - private class LoadTask implements Runnable { - - public void run() { - SmsDatabase.Reader smsReader = smsDatabase.readerFor(smsDatabase.getExpirationStartedMessages()); - MmsDatabase.Reader mmsReader = mmsDatabase.getExpireStartedMessages(); - - MessageRecord messageRecord; - - while ((messageRecord = smsReader.getNext()) != null) { - expiringMessageReferences.add(new ExpiringMessageReference(messageRecord.getId(), - messageRecord.isMms(), - messageRecord.getExpireStarted() + messageRecord.getExpiresIn())); - } - - while ((messageRecord = mmsReader.getNext()) != null) { - expiringMessageReferences.add(new ExpiringMessageReference(messageRecord.getId(), - messageRecord.isMms(), - messageRecord.getExpireStarted() + messageRecord.getExpiresIn())); - } - - smsReader.close(); - mmsReader.close(); - } - } - - @SuppressWarnings("InfiniteLoopStatement") - private class ProcessTask implements Runnable { - public void run() { - while (true) { - ExpiringMessageReference expiredMessage = null; - - synchronized (expiringMessageReferences) { - try { - while (expiringMessageReferences.isEmpty()) expiringMessageReferences.wait(); - - ExpiringMessageReference nextReference = expiringMessageReferences.first(); - long waitTime = nextReference.expiresAtMillis - System.currentTimeMillis(); - - if (waitTime > 0) { - ExpirationListener.setAlarm(context, waitTime); - expiringMessageReferences.wait(waitTime); - } else { - expiredMessage = nextReference; - expiringMessageReferences.remove(nextReference); - } - - } catch (InterruptedException e) { - Log.w(TAG, e); - } - } - - if (expiredMessage != null) { - if (expiredMessage.mms) mmsDatabase.deleteMessage(expiredMessage.id); - else smsDatabase.deleteMessage(expiredMessage.id); - } - } - } - } - - private static class ExpiringMessageReference { - private final long id; - private final boolean mms; - private final long expiresAtMillis; - - private ExpiringMessageReference(long id, boolean mms, long expiresAtMillis) { - this.id = id; - this.mms = mms; - this.expiresAtMillis = expiresAtMillis; - } - - @Override - public boolean equals(Object other) { - if (other == null) return false; - if (!(other instanceof ExpiringMessageReference)) return false; - - ExpiringMessageReference that = (ExpiringMessageReference)other; - return this.id == that.id && this.mms == that.mms && this.expiresAtMillis == that.expiresAtMillis; - } - - @Override - public int hashCode() { - return (int)this.id ^ (mms ? 1 : 0) ^ (int)expiresAtMillis; - } - } - - private static class ExpiringMessageComparator implements Comparator { - @Override - public int compare(ExpiringMessageReference lhs, ExpiringMessageReference rhs) { - if (lhs.expiresAtMillis < rhs.expiresAtMillis) return -1; - else if (lhs.expiresAtMillis > rhs.expiresAtMillis) return 1; - else if (lhs.id < rhs.id) return -1; - else if (lhs.id > rhs.id) return 1; - else if (!lhs.mms && rhs.mms) return -1; - else if (lhs.mms && !rhs.mms) return 1; - else return 0; - } - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt new file mode 100644 index 0000000000..c1dff74333 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt @@ -0,0 +1,225 @@ +package org.thoughtcrime.securesms.service + +import android.content.Context +import network.loki.messenger.libsession_util.util.ExpiryMode +import network.loki.messenger.libsession_util.util.ExpiryMode.AfterSend +import org.session.libsession.messaging.MessagingModuleConfiguration.Companion.shared +import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate +import org.session.libsession.messaging.messages.signal.IncomingMediaMessage +import org.session.libsession.messaging.messages.signal.OutgoingExpirationUpdateMessage +import org.session.libsession.snode.SnodeAPI.nowWithOffset +import org.session.libsession.utilities.Address.Companion.fromSerialized +import org.session.libsession.utilities.GroupUtil.doubleEncodeGroupID +import org.session.libsession.utilities.GroupUtil.getDecodedGroupIDAsData +import org.session.libsession.utilities.SSKEnvironment.MessageExpirationManagerProtocol +import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.messages.SignalServiceGroup +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.guava.Optional +import org.thoughtcrime.securesms.database.MmsDatabase +import org.thoughtcrime.securesms.database.MmsSmsDatabase +import org.thoughtcrime.securesms.database.SmsDatabase +import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get +import org.thoughtcrime.securesms.mms.MmsException +import java.io.IOException +import java.util.TreeSet +import java.util.concurrent.Executor +import java.util.concurrent.Executors + +private val TAG = ExpiringMessageManager::class.java.simpleName +class ExpiringMessageManager(context: Context) : MessageExpirationManagerProtocol { + private val expiringMessageReferences = TreeSet() + private val executor: Executor = Executors.newSingleThreadExecutor() + private val smsDatabase: SmsDatabase + private val mmsDatabase: MmsDatabase + private val mmsSmsDatabase: MmsSmsDatabase + private val context: Context + + init { + this.context = context.applicationContext + smsDatabase = get(context).smsDatabase() + mmsDatabase = get(context).mmsDatabase() + mmsSmsDatabase = get(context).mmsSmsDatabase() + executor.execute(LoadTask()) + executor.execute(ProcessTask()) + } + + private fun getDatabase(mms: Boolean) = if (mms) mmsDatabase else smsDatabase + + fun scheduleDeletion(id: Long, mms: Boolean, startedAtTimestamp: Long, expiresInMillis: Long) { + if (startedAtTimestamp <= 0) return + + val expiresAtMillis = startedAtTimestamp + expiresInMillis + synchronized(expiringMessageReferences) { + expiringMessageReferences += ExpiringMessageReference(id, mms, expiresAtMillis) + (expiringMessageReferences as Object).notifyAll() + } + } + + fun checkSchedule() { + synchronized(expiringMessageReferences) { (expiringMessageReferences as Object).notifyAll() } + } + + private fun insertIncomingExpirationTimerMessage( + message: ExpirationTimerUpdate, + expireStartedAt: Long + ) { + val senderPublicKey = message.sender + val sentTimestamp = message.sentTimestamp + val groupId = message.groupPublicKey + val expiresInMillis = message.expiryMode.expiryMillis + var groupInfo = Optional.absent() + val address = fromSerialized(senderPublicKey!!) + var recipient = Recipient.from(context, address, false) + + // if the sender is blocked, we don't display the update, except if it's in a closed group + if (recipient.isBlocked && groupId == null) return + try { + if (groupId != null) { + val groupID = doubleEncodeGroupID(groupId) + groupInfo = Optional.of( + SignalServiceGroup( + getDecodedGroupIDAsData(groupID), + SignalServiceGroup.GroupType.SIGNAL + ) + ) + val groupAddress = fromSerialized(groupID) + recipient = Recipient.from(context, groupAddress, false) + } + val threadId = shared.storage.getThreadId(recipient) ?: return + val mediaMessage = IncomingMediaMessage( + address, sentTimestamp!!, -1, + expiresInMillis, expireStartedAt, true, + false, + false, + false, + Optional.absent(), + groupInfo, + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent() + ) + //insert the timer update message + mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, threadId, runThreadUpdate = true) + } catch (ioe: IOException) { + Log.e("Loki", "Failed to insert expiration update message.") + } catch (ioe: MmsException) { + Log.e("Loki", "Failed to insert expiration update message.") + } + } + + private fun insertOutgoingExpirationTimerMessage( + message: ExpirationTimerUpdate, + expireStartedAt: Long + ) { + val sentTimestamp = message.sentTimestamp + val groupId = message.groupPublicKey + val duration = message.expiryMode.expiryMillis + try { + val serializedAddress = groupId?.let(::doubleEncodeGroupID) + ?: message.syncTarget?.takeIf { it.isNotEmpty() } + ?: message.recipient!! + val address = fromSerialized(serializedAddress) + val recipient = Recipient.from(context, address, false) + + message.threadID = shared.storage.getOrCreateThreadIdFor(address) + val timerUpdateMessage = OutgoingExpirationUpdateMessage( + recipient, + sentTimestamp!!, + duration, + expireStartedAt, + groupId + ) + mmsDatabase.insertSecureDecryptedMessageOutbox( + timerUpdateMessage, + message.threadID!!, + sentTimestamp, + true + ) + } catch (ioe: MmsException) { + Log.e("Loki", "Failed to insert expiration update message.", ioe) + } catch (ioe: IOException) { + Log.e("Loki", "Failed to insert expiration update message.", ioe) + } + } + + override fun insertExpirationTimerMessage(message: ExpirationTimerUpdate) { + val expiryMode: ExpiryMode = message.expiryMode + + val userPublicKey = getLocalNumber(context) + val senderPublicKey = message.sender + val sentTimestamp = if (message.sentTimestamp == null) 0 else message.sentTimestamp!! + val expireStartedAt = if (expiryMode is AfterSend || message.isSenderSelf) sentTimestamp else 0 + + // Notify the user + if (senderPublicKey == null || userPublicKey == senderPublicKey) { + // sender is self or a linked device + insertOutgoingExpirationTimerMessage(message, expireStartedAt) + } else { + insertIncomingExpirationTimerMessage(message, expireStartedAt) + } + + maybeStartExpiration(message) + } + + override fun startAnyExpiration(timestamp: Long, author: String, expireStartedAt: Long) { + mmsSmsDatabase.getMessageFor(timestamp, author)?.run { + getDatabase(isMms()).markExpireStarted(getId(), expireStartedAt) + scheduleDeletion(getId(), isMms(), expireStartedAt, expiresIn) + } ?: Log.e(TAG, "no message record!") + } + + private inner class LoadTask : Runnable { + override fun run() { + val smsReader = smsDatabase.readerFor(smsDatabase.getExpirationStartedMessages()) + val mmsReader = mmsDatabase.expireStartedMessages + + val smsMessages = smsReader.use { generateSequence { it.next }.toList() } + val mmsMessages = mmsReader.use { generateSequence { it.next }.toList() } + + (smsMessages + mmsMessages).forEach { messageRecord -> + expiringMessageReferences += ExpiringMessageReference( + messageRecord.getId(), + messageRecord.isMms, + messageRecord.expireStarted + messageRecord.expiresIn + ) + } + } + } + + private inner class ProcessTask : Runnable { + override fun run() { + while (true) { + synchronized(expiringMessageReferences) { + try { + while (expiringMessageReferences.isEmpty()) (expiringMessageReferences as Object).wait() + val nextReference = expiringMessageReferences.first() + val waitTime = nextReference.expiresAtMillis - nowWithOffset + if (waitTime > 0) { + ExpirationListener.setAlarm(context, waitTime) + (expiringMessageReferences as Object).wait(waitTime) + null + } else { + expiringMessageReferences -= nextReference + nextReference + } + } catch (e: InterruptedException) { + Log.w(TAG, e) + null + } + }?.run { getDatabase(mms).deleteMessage(id) } + } + } + } + + private data class ExpiringMessageReference( + val id: Long, + val mms: Boolean, + val expiresAtMillis: Long + ): Comparable { + override fun compareTo(other: ExpiringMessageReference) = compareValuesBy(this, other, { it.expiresAtMillis }, { it.id }, { it.mms }) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Carousel.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Carousel.kt new file mode 100644 index 0000000000..0b7b6d6b4c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Carousel.kt @@ -0,0 +1,81 @@ +package org.thoughtcrime.securesms.ui + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import network.loki.messenger.R + +@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)) { + com.google.accompanist.pager.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 = null, + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index 1724bde8a6..6c223a45f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -1,42 +1,93 @@ package org.thoughtcrime.securesms.ui import androidx.annotation.DrawableRes -import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.ButtonColors +import androidx.compose.material.ButtonDefaults import androidx.compose.material.Card import androidx.compose.material.Colors import androidx.compose.material.Icon -import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.material.RadioButton import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView -import com.google.accompanist.pager.HorizontalPagerIndicator -import kotlinx.coroutines.launch -import network.loki.messenger.R import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.runIf import org.thoughtcrime.securesms.components.ProfilePictureView +import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.OptionsCard +import kotlin.math.min + +interface Callbacks { + fun onSetClick(): Any? + fun setValue(value: T) +} + +object NoOpCallbacks: Callbacks { + override fun onSetClick() {} + override fun setValue(value: Any) {} +} + +data class RadioOption( + val value: T, + val title: GetString, + val subtitle: GetString? = null, + val contentDescription: GetString = title, + val selected: Boolean = false, + val enabled: Boolean = true, +) + +@Composable +fun OptionsCard(card: OptionsCard, callbacks: Callbacks) { + Text(text = card.title()) + CellNoMargin { + LazyColumn( + modifier = Modifier.heightIn(max = 5000.dp) + ) { + itemsIndexed(card.options) { i, it -> + if (i != 0) Divider() + TitledRadioButton(it) { callbacks.setValue(it.value) } + } + } + } +} + @Composable fun ItemButton( @@ -95,66 +146,109 @@ fun CellWithPaddingAndMargin( } } +@Composable +fun TitledRadioButton(option: RadioOption, onClick: () -> Unit) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .runIf(option.enabled) { clickable { if (!option.selected) onClick() } } + .heightIn(min = 60.dp) + .padding(horizontal = 32.dp) + .contentDescription(option.contentDescription) + ) { + Column(modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically)) { + Column { + Text( + text = option.title(), + fontSize = 16.sp, + modifier = Modifier.alpha(if (option.enabled) 1f else 0.5f) + ) + option.subtitle?.let { + Text( + text = it(), + fontSize = 11.sp, + modifier = Modifier.alpha(if (option.enabled) 1f else 0.5f) + ) + } + } + } + RadioButton( + selected = option.selected, + onClick = null, + enabled = option.enabled, + modifier = Modifier + .height(26.dp) + .align(Alignment.CenterVertically) + ) + } +} + +@Composable +fun Modifier.contentDescription(text: GetString?): Modifier { + val context = LocalContext.current + return text?.let { semantics { contentDescription = it(context) } } ?: this +} + +@Composable +fun OutlineButton(text: GetString, contentDescription: GetString? = text, modifier: Modifier = Modifier, onClick: () -> Unit) { + OutlinedButton( + modifier = modifier.size(108.dp, 34.dp) + .contentDescription(contentDescription), + onClick = onClick, + border = BorderStroke(1.dp, LocalExtraColors.current.prominentButtonColor), + shape = RoundedCornerShape(50), // = 50% percent + colors = ButtonDefaults.outlinedButtonColors( + contentColor = LocalExtraColors.current.prominentButtonColor, + backgroundColor = MaterialTheme.colors.background + ) + ){ + Text(text = text()) + } +} + 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]) - } - } -} +fun Modifier.fadingEdges( + scrollState: ScrollState, + topEdgeHeight: Dp = 0.dp, + bottomEdgeHeight: Dp = 20.dp +): Modifier = this.then( + Modifier + // adding layer fixes issue with blending gradient and content + .graphicsLayer { alpha = 0.99F } + .drawWithContent { + drawContent() -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun RowScope.CarouselPrevButton(pagerState: PagerState) { - CarouselButton(pagerState, pagerState.canScrollBackward, R.drawable.ic_prev, -1) -} + val topColors = listOf(Color.Transparent, Color.Black) + val topStartY = scrollState.value.toFloat() + val topGradientHeight = min(topEdgeHeight.toPx(), topStartY) + if (topGradientHeight > 0f) drawRect( + brush = Brush.verticalGradient( + colors = topColors, + startY = topStartY, + endY = topStartY + topGradientHeight + ), + blendMode = BlendMode.DstIn + ) -@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 = "", + val bottomColors = listOf(Color.Black, Color.Transparent) + val bottomEndY = size.height - scrollState.maxValue + scrollState.value + val bottomGradientHeight = + min(bottomEdgeHeight.toPx(), scrollState.maxValue.toFloat() - scrollState.value) + if (bottomGradientHeight > 0f) drawRect( + brush = Brush.verticalGradient( + colors = bottomColors, + startY = bottomEndY - bottomGradientHeight, + endY = bottomEndY + ), + blendMode = BlendMode.DstIn ) } - } -} +) @Composable fun Divider() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Data.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Data.kt index 44ff4a42d8..e472209005 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Data.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Data.kt @@ -1,28 +1,55 @@ package org.thoughtcrime.securesms.ui +import android.content.Context import androidx.annotation.StringRes import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import org.session.libsession.utilities.ExpirationUtil +import kotlin.time.Duration /** * Compatibility class to allow ViewModels to use strings and string resources interchangeably. */ sealed class GetString { + + @Composable + operator fun invoke() = string() + operator fun invoke(context: Context) = string(context) + @Composable abstract fun string(): String + + abstract fun string(context: Context): String data class FromString(val string: String): GetString() { @Composable override fun string(): String = string + override fun string(context: Context): String = string } data class FromResId(@StringRes val resId: Int): GetString() { @Composable override fun string(): String = stringResource(resId) + override fun string(context: Context): String = context.getString(resId) + } + data class FromFun(val function: (Context) -> String): GetString() { + @Composable + override fun string(): String = function(LocalContext.current) + override fun string(context: Context): String = function(context) + } + data class FromMap(val value: T, val function: (Context, T) -> String): GetString() { + @Composable + override fun string(): String = function(LocalContext.current, value) + + override fun string(context: Context): String = function(context, value) } } fun GetString(@StringRes resId: Int) = GetString.FromResId(resId) fun GetString(string: String) = GetString.FromString(string) +fun GetString(function: (Context) -> String) = GetString.FromFun(function) +fun GetString(value: T, function: (Context, T) -> String) = GetString.FromMap(value, function) +fun GetString(duration: Duration) = GetString.FromMap(duration, ExpirationUtil::getExpirationDisplayValue) /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt index 64bbd21d8d..3fa861fb71 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt @@ -22,6 +22,7 @@ val LocalExtraColors = staticCompositionLocalOf { error("No Custom data class ExtraColors( val settingsBackground: Color, + val prominentButtonColor: Color ) /** @@ -34,6 +35,7 @@ fun AppTheme( val extraColors = LocalContext.current.run { ExtraColors( settingsBackground = getColorFromTheme(R.attr.colorSettingsBackground), + prominentButtonColor = getColorFromTheme(R.attr.prominentButtonColor), ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt b/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt index 06fda29306..4d00da8f96 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt @@ -131,6 +131,7 @@ object MockDataGenerator { .joinToString(), Optional.absent(), 0, + 0, false, -1, false @@ -148,6 +149,7 @@ object MockDataGenerator { .map { wordContent.random(dmThreadRandomGenerator.asKotlinRandom()) } .joinToString(), 0, + 0, -1, (timestampNow - (index * 5000)) ), @@ -232,14 +234,12 @@ object MockDataGenerator { // Add the group to the user's set of public keys to poll for and store the key pair val encryptionKeyPair = Curve.generateKeyPair() storage.addClosedGroupEncryptionKeyPair(encryptionKeyPair, randomGroupPublicKey, System.currentTimeMillis()) - storage.setExpirationTimer(groupId, 0) - storage.createInitialConfigGroup(randomGroupPublicKey, groupName, GroupUtil.createConfigMemberMap(members, setOf(adminUserId)), System.currentTimeMillis(), encryptionKeyPair) + storage.createInitialConfigGroup(randomGroupPublicKey, groupName, GroupUtil.createConfigMemberMap(members, setOf(adminUserId)), System.currentTimeMillis(), encryptionKeyPair, 0) // Add the group created message if (userSessionId == adminUserId) { storage.insertOutgoingInfoMessage(context, groupId, SignalServiceGroup.Type.CREATION, groupName, members, listOf(adminUserId), threadId, (timestampNow - (numMessages * 5000))) - } - else { + } else { storage.insertIncomingInfoMessage(context, adminUserId, groupId, SignalServiceGroup.Type.CREATION, groupName, members, listOf(adminUserId), (timestampNow - (numMessages * 5000))) } @@ -261,6 +261,7 @@ object MockDataGenerator { .joinToString(), Optional.absent(), 0, + 0, false, -1, false @@ -278,6 +279,7 @@ object MockDataGenerator { .map { wordContent.random(cgThreadRandomGenerator.asKotlinRandom()) } .joinToString(), 0, + 0, -1, (timestampNow - (index * 5000)) ), @@ -386,6 +388,7 @@ object MockDataGenerator { .joinToString(), Optional.absent(), 0, + 0, false, -1, false @@ -402,6 +405,7 @@ object MockDataGenerator { .map { wordContent.random(ogThreadRandomGenerator.asKotlinRandom()) } .joinToString(), 0, + 0, -1, (timestampNow - (index * 5000)) ), diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.kt b/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.kt index 8b219849a0..8df2a7cd2b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.kt @@ -66,6 +66,7 @@ class SaveAttachmentTask @JvmOverloads constructor(context: Context, count: Int this.contextReference = WeakReference(context) } + @Deprecated("Deprecated in Java") override fun doInBackground(vararg attachments: Attachment?): Pair { if (attachments.isEmpty()) { throw IllegalArgumentException("Must pass in at least one attachment") @@ -227,6 +228,7 @@ class SaveAttachmentTask @JvmOverloads constructor(context: Context, count: Int return File(fileName).name } + @Deprecated("Deprecated in Java") override fun onPostExecute(result: Pair) { super.onPostExecute(result) val context = contextReference.get() ?: return diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodeWrapperFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodeWrapperFragment.kt index ea1a90d19a..e5de4c36d9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodeWrapperFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodeWrapperFragment.kt @@ -29,6 +29,7 @@ class ScanQRCodeWrapperFragment : Fragment(), ScanQRCodePlaceholderFragmentDeleg } } + @Deprecated("Deprecated in Java") override fun setUserVisibleHint(isVisibleToUser: Boolean) { super.setUserVisibleHint(isVisibleToUser) enabled = isVisibleToUser diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt index 894de9de64..272f2c12db 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt @@ -16,6 +16,7 @@ import nl.komponents.kovenant.Promise import nl.komponents.kovenant.functional.bind import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.calls.CallMessageType +import org.session.libsession.messaging.messages.applyExpiryMode import org.session.libsession.messaging.messages.control.CallMessage import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.snode.SnodeAPI @@ -25,6 +26,7 @@ import org.session.libsession.utilities.Util import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.ICE_CANDIDATES import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.AudioDeviceUpdate import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.AudioEnabled import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.RecipientUpdate @@ -57,8 +59,12 @@ import java.util.ArrayDeque import java.util.UUID import org.thoughtcrime.securesms.webrtc.data.State as CallState -class CallManager(context: Context, audioManager: AudioManagerCompat, private val storage: StorageProtocol): PeerConnection.Observer, - SignalAudioManager.EventListener, CameraEventListener, DataChannel.Observer { +class CallManager( + private val context: Context, + audioManager: AudioManagerCompat, + private val storage: StorageProtocol +): PeerConnection.Observer, + SignalAudioManager.EventListener, CameraEventListener, DataChannel.Observer { sealed class StateEvent { data class AudioEnabled(val isEnabled: Boolean): StateEvent() @@ -293,17 +299,17 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va while (pendingOutgoingIceUpdates.isNotEmpty()) { currentPendings.add(pendingOutgoingIceUpdates.pop()) } - val sdps = currentPendings.map { it.sdp } - val sdpMLineIndexes = currentPendings.map { it.sdpMLineIndex } - val sdpMids = currentPendings.map { it.sdpMid } - MessageSender.sendNonDurably(CallMessage( - ICE_CANDIDATES, - sdps = sdps, - sdpMLineIndexes = sdpMLineIndexes, - sdpMids = sdpMids, - currentCallId - ), currentRecipient.address, isSyncMessage = currentRecipient.isLocalNumber) + val thread = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(expectedRecipient) + CallMessage( + ICE_CANDIDATES, + sdps = currentPendings.map(IceCandidate::sdp), + sdpMLineIndexes = currentPendings.map(IceCandidate::sdpMLineIndex), + sdpMids = currentPendings.map(IceCandidate::sdpMid), + currentCallId + ) + .applyExpiryMode(thread) + .also { MessageSender.sendNonDurably(it, currentRecipient.address, isSyncMessage = currentRecipient.isLocalNumber) } } } } @@ -419,6 +425,7 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va fun onNewOffer(offer: String, callId: UUID, recipient: Recipient): Promise { if (callId != this.callId) return Promise.ofFail(NullPointerException("No callId")) if (recipient != this.recipient) return Promise.ofFail(NullPointerException("No recipient")) + val thread = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient) val connection = peerConnection ?: return Promise.ofFail(NullPointerException("No peer connection wrapper")) @@ -431,11 +438,9 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va mandatory.add(MediaConstraints.KeyValuePair("IceRestart", "true")) }) connection.setLocalDescription(answer) - pendingIncomingIceUpdates.toList().forEach { update -> - connection.addIceCandidate(update) - } + pendingIncomingIceUpdates.toList().forEach(connection::addIceCandidate) pendingIncomingIceUpdates.clear() - val answerMessage = CallMessage.answer(answer.description, callId) + val answerMessage = CallMessage.answer(answer.description, callId).applyExpiryMode(thread) Log.i("Loki", "Posting new answer") MessageSender.sendNonDurably(answerMessage, recipient.address, isSyncMessage = recipient.isLocalNumber) } else { @@ -479,13 +484,14 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va connection.setRemoteDescription(SessionDescription(SessionDescription.Type.OFFER, offer)) val answer = connection.createAnswer(MediaConstraints()) connection.setLocalDescription(answer) - val answerMessage = CallMessage.answer(answer.description, callId) + val thread = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient) + val answerMessage = CallMessage.answer(answer.description, callId).applyExpiryMode(thread) val userAddress = storage.getUserPublicKey() ?: return Promise.ofFail(NullPointerException("No user public key")) MessageSender.sendNonDurably(answerMessage, Address.fromSerialized(userAddress), isSyncMessage = true) val sendAnswerMessage = MessageSender.sendNonDurably(CallMessage.answer( answer.description, callId - ), recipient.address, isSyncMessage = recipient.isLocalNumber) + ).applyExpiryMode(thread), recipient.address, isSyncMessage = recipient.isLocalNumber) insertCallMessage(recipient.address.serialize(), CallMessageType.CALL_INCOMING, false) @@ -533,15 +539,16 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va connection.setLocalDescription(offer) Log.d("Loki", "Sending pre-offer") + val thread = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient) return MessageSender.sendNonDurably(CallMessage.preOffer( callId - ), recipient.address, isSyncMessage = recipient.isLocalNumber).bind { + ).applyExpiryMode(thread), recipient.address, isSyncMessage = recipient.isLocalNumber).bind { Log.d("Loki", "Sent pre-offer") Log.d("Loki", "Sending offer") MessageSender.sendNonDurably(CallMessage.offer( offer.description, callId - ), recipient.address, isSyncMessage = recipient.isLocalNumber).success { + ).applyExpiryMode(thread), recipient.address, isSyncMessage = recipient.isLocalNumber).success { Log.d("Loki", "Sent offer") }.fail { Log.e("Loki", "Failed to send offer", it) @@ -555,8 +562,9 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va val recipient = recipient ?: return val userAddress = storage.getUserPublicKey() ?: return stateProcessor.processEvent(Event.DeclineCall) { - MessageSender.sendNonDurably(CallMessage.endCall(callId), Address.fromSerialized(userAddress), isSyncMessage = true) - MessageSender.sendNonDurably(CallMessage.endCall(callId), recipient.address, isSyncMessage = recipient.isLocalNumber) + val thread = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient) + MessageSender.sendNonDurably(CallMessage.endCall(callId).applyExpiryMode(thread), Address.fromSerialized(userAddress), isSyncMessage = true) + MessageSender.sendNonDurably(CallMessage.endCall(callId).applyExpiryMode(thread), recipient.address, isSyncMessage = recipient.isLocalNumber) insertCallMessage(recipient.address.serialize(), CallMessageType.CALL_MISSED) } } @@ -575,7 +583,9 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va val buffer = DataChannel.Buffer(ByteBuffer.wrap(HANGUP_JSON.toString().encodeToByteArray()), false) channel.send(buffer) } - MessageSender.sendNonDurably(CallMessage.endCall(callId), recipient.address, isSyncMessage = recipient.isLocalNumber) + + val thread = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient) + MessageSender.sendNonDurably(CallMessage.endCall(callId).applyExpiryMode(thread), recipient.address, isSyncMessage = recipient.isLocalNumber) } } @@ -725,8 +735,8 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va mandatory.add(MediaConstraints.KeyValuePair("IceRestart", "true")) }) connection.setLocalDescription(offer) - - MessageSender.sendNonDurably(CallMessage.offer(offer.description, callId), recipient.address, isSyncMessage = recipient.isLocalNumber) + val thread = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient) + MessageSender.sendNonDurably(CallMessage.offer(offer.description, callId).applyExpiryMode(thread), recipient.address, isSyncMessage = recipient.isLocalNumber) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallServiceReceivers.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallServiceReceivers.kt index 09db0022d8..dbbbffc3e8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallServiceReceivers.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallServiceReceivers.kt @@ -19,6 +19,7 @@ class HangUpRtcOnPstnCallAnsweredListener(private val hangupListener: ()->Unit): private val TAG = Log.tag(HangUpRtcOnPstnCallAnsweredListener::class.java) } + @Deprecated("Deprecated in Java") override fun onCallStateChanged(state: Int, phoneNumber: String?) { super.onCallStateChanged(state, phoneNumber) if (state == TelephonyManager.CALL_STATE_OFFHOOK) { diff --git a/app/src/main/res/drawable/call_message_background.xml b/app/src/main/res/drawable/call_message_background.xml new file mode 100644 index 0000000000..7713909167 --- /dev/null +++ b/app/src/main/res/drawable/call_message_background.xml @@ -0,0 +1,9 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_baseline_keyboard_arrow_left_24dp.xml b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_left_24dp.xml new file mode 100644 index 0000000000..f3dfdd0bdb --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_left_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_keyboard_arrow_right_24dp.xml b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_right_24dp.xml new file mode 100644 index 0000000000..19ec5a5eb8 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_right_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_timer_off_24.xml b/app/src/main/res/drawable/ic_baseline_timer_off_24.xml deleted file mode 100644 index 9216da7aff..0000000000 --- a/app/src/main/res/drawable/ic_baseline_timer_off_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_incoming_call.xml b/app/src/main/res/drawable/ic_incoming_call.xml index da1a78fe50..f9149818c5 100644 --- a/app/src/main/res/drawable/ic_incoming_call.xml +++ b/app/src/main/res/drawable/ic_incoming_call.xml @@ -1,6 +1,6 @@ + + diff --git a/app/src/main/res/drawable/tab_indicator_dot.xml b/app/src/main/res/drawable/tab_indicator_dot.xml new file mode 100644 index 0000000000..72f57dc8bc --- /dev/null +++ b/app/src/main/res/drawable/tab_indicator_dot.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_conversation_v2.xml b/app/src/main/res/layout/activity_conversation_v2.xml index 5afde1e296..000b860841 100644 --- a/app/src/main/res/layout/activity_conversation_v2.xml +++ b/app/src/main/res/layout/activity_conversation_v2.xml @@ -16,8 +16,10 @@ android:background="?colorPrimary" app:contentInsetStart="0dp"> - + @@ -216,6 +218,29 @@ + + + + + + - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_disappearing_messages.xml b/app/src/main/res/layout/activity_disappearing_messages.xml new file mode 100644 index 0000000000..8101297732 --- /dev/null +++ b/app/src/main/res/layout/activity_disappearing_messages.xml @@ -0,0 +1,24 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/context_menu_item.xml b/app/src/main/res/layout/context_menu_item.xml index 04354f5b10..83d43d82d7 100644 --- a/app/src/main/res/layout/context_menu_item.xml +++ b/app/src/main/res/layout/context_menu_item.xml @@ -18,13 +18,28 @@ android:layout_height="24dp" tools:src="@drawable/ic_message"/> - + android:layout_height="wrap_content" + android:orientation="vertical"> + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/conversation_item_footer.xml b/app/src/main/res/layout/conversation_item_footer.xml deleted file mode 100644 index 3aefb39444..0000000000 --- a/app/src/main/res/layout/conversation_item_footer.xml +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/expiration_dialog.xml b/app/src/main/res/layout/expiration_dialog.xml deleted file mode 100644 index 75f5988cab..0000000000 --- a/app/src/main/res/layout/expiration_dialog.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/expiration_timer_menu.xml b/app/src/main/res/layout/expiration_timer_menu.xml deleted file mode 100644 index 5139045021..0000000000 --- a/app/src/main/res/layout/expiration_timer_menu.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_enter_community_url.xml b/app/src/main/res/layout/fragment_enter_community_url.xml index 6edf8dfbd5..1fcfecd654 100644 --- a/app/src/main/res/layout/fragment_enter_community_url.xml +++ b/app/src/main/res/layout/fragment_enter_community_url.xml @@ -18,6 +18,7 @@ - @@ -14,18 +12,44 @@ android:id="@+id/titleTextView" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_margin="@dimen/medium_spacing" - android:layout_weight="1" + android:layout_marginTop="@dimen/small_spacing" android:ellipsize="end" android:lines="1" android:textSize="@dimen/text_size" - tools:text="@tools:sample/full_names" /> + android:textStyle="bold" + app:layout_goneMarginBottom="@dimen/small_spacing" + app:layout_constraintEnd_toStartOf="@id/selectButton" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toTopOf="@id/subtitleTextView" + tools:text="@tools:sample/cities" /> + + + android:foreground="@drawable/radial_multi_select" + app:layout_constraintHorizontal_bias="1" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toEndOf="@id/titleTextView" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintEnd_toEndOf="parent"/> - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/view_control_message.xml b/app/src/main/res/layout/view_control_message.xml index 03923677db..3cbe26048e 100644 --- a/app/src/main/res/layout/view_control_message.xml +++ b/app/src/main/res/layout/view_control_message.xml @@ -29,6 +29,16 @@ tools:src="@drawable/ic_timer" tools:visibility="visible"/> + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_conversation_action_bar.xml b/app/src/main/res/layout/view_conversation_action_bar.xml new file mode 100644 index 0000000000..db51410b86 --- /dev/null +++ b/app/src/main/res/layout/view_conversation_action_bar.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_conversation_setting.xml b/app/src/main/res/layout/view_conversation_setting.xml new file mode 100644 index 0000000000..688aa01f37 --- /dev/null +++ b/app/src/main/res/layout/view_conversation_setting.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_profile_overflow.xml b/app/src/main/res/layout/view_profile_overflow.xml new file mode 100644 index 0000000000..4214c76548 --- /dev/null +++ b/app/src/main/res/layout/view_profile_overflow.xml @@ -0,0 +1,18 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_visible_message.xml b/app/src/main/res/layout/view_visible_message.xml index c9badb415d..19f9b4f9ad 100644 --- a/app/src/main/res/layout/view_visible_message.xml +++ b/app/src/main/res/layout/view_visible_message.xml @@ -126,18 +126,6 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" /> - - @@ -150,28 +138,41 @@ app:layout_constraintStart_toStartOf="@+id/messageInnerContainer" app:layout_constraintTop_toBottomOf="@id/messageInnerContainer" /> - - - + app:layout_constraintStart_toStartOf="@id/messageInnerContainer" + app:layout_constraintHorizontal_bias="1" + app:layout_constraintEnd_toEndOf="parent"> + + + + + + + + diff --git a/app/src/main/res/menu/menu_conversation_expiration_off.xml b/app/src/main/res/menu/menu_conversation_expiration.xml similarity index 67% rename from app/src/main/res/menu/menu_conversation_expiration_off.xml rename to app/src/main/res/menu/menu_conversation_expiration.xml index 939220009c..e589252237 100644 --- a/app/src/main/res/menu/menu_conversation_expiration_off.xml +++ b/app/src/main/res/menu/menu_conversation_expiration.xml @@ -4,8 +4,7 @@ + android:id="@+id/menu_expiring_messages" + android:contentDescription="@string/AccessibilityId_disappearing_messages" /> \ No newline at end of file diff --git a/app/src/main/res/menu/menu_conversation_expiration_on.xml b/app/src/main/res/menu/menu_conversation_expiration_on.xml deleted file mode 100644 index 88775cf71d..0000000000 --- a/app/src/main/res/menu/menu_conversation_expiration_on.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index d8aedf3dea..ae38d0c057 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -319,11 +319,6 @@ - - - - - diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 196e587cdc..eae5a2b167 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -165,4 +165,6 @@ #FF3A3A #E12D19 + #00F782 + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 99e0aa197f..4eb1d37fd1 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -2,6 +2,7 @@ + 9sp 12sp 15sp 17sp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 87df82adf3..640b9f005d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -56,7 +56,7 @@ New conversation button New direct message Create group - Join community + Join community button Community input Join community button @@ -85,7 +85,7 @@ Call button Settings - Disappearing messages timer + Confirm Time selector Accept message request Decline message request @@ -117,9 +117,7 @@ Download media Don\'t download media - Message sent status: Sent - Message sent status pending - Message sent status syncing + Message sent status: Sent Message request has been accepted Message body Voice message @@ -156,6 +154,17 @@ Enable Cancel + + + Disappear after read option + Disappear after send option + Disappearing messages timer + Set button + Time option + Disable disappearing messages + Configuration message + Disappearing messages type and time + Conversation header name New message @@ -560,6 +569,12 @@ Default High Max + 5 Minutes + 1 Hour + 12 Hours + 1 Day + 1 Week + 2 Weeks %d hour @@ -894,6 +909,11 @@ Are you sure you want to join the %s open group? Open URL? Are you sure you want to open %s? + Follow Setting + Messages you send will no longer disappear. Are you sure you want to turn off disappearing messages? + Set your messages to disappear %1$s after they have been %2$s? + Set + Confirm Open Copy URL Enable Link Previews? @@ -1027,6 +1047,21 @@ Join Navigate Back Close Dialog + Disappearing Messages + This setting applies to messages you send in this conversation. + Original version of disappearing messages. + Legacy + Messages disappear after they have been sent. + Disappear After Read + Messages delete after they have been read. + Disappear After Send + Messages delete after they have been sent. + Set + Delete Type + Timer + This setting applies to everyone in this conversation.\nOnly group admins can change this setting. + %s is using an outdated client. Disappearing messages may not work as expected. + Settings not updated and please try again Database Upgrade Failed Please contact support to report the error. Syncing @@ -1045,5 +1080,6 @@ You have no messages from %s.\nSend a message to start the conversation! Unread Messages + Auto-deletes in %1$s diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 2928fc7718..72dcacf284 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -69,6 +69,10 @@ @color/emoji_tab_text_color + + + +