Merge branch 'dev' into on

This commit is contained in:
Andrew 2024-03-15 10:24:52 +10:30
commit 4279c4b456
160 changed files with 5862 additions and 3243 deletions

View File

@ -321,9 +321,9 @@ dependencies {
implementation "com.opencsv:opencsv:4.6" implementation "com.opencsv:opencsv:4.6"
testImplementation "junit:junit:$junitVersion" testImplementation "junit:junit:$junitVersion"
testImplementation 'org.assertj:assertj-core:3.11.1' 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" 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" androidTestImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion"
testImplementation "androidx.test:core:$testCoreVersion" testImplementation "androidx.test:core:$testCoreVersion"
testImplementation "androidx.arch.core:core-testing:2.2.0" testImplementation "androidx.arch.core:core-testing:2.2.0"
@ -343,6 +343,7 @@ dependencies {
// Assertions // Assertions
androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.ext:truth:1.5.0' androidTestImplementation 'androidx.test.ext:truth:1.5.0'
testImplementation 'com.google.truth:truth:1.1.3'
androidTestImplementation 'com.google.truth:truth:1.1.3' androidTestImplementation 'com.google.truth:truth:1.1.3'
// Espresso dependencies // Espresso dependencies

View File

@ -7,16 +7,25 @@ import androidx.test.filters.SmallTest
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import network.loki.messenger.libsession_util.ConfigBase import network.loki.messenger.libsession_util.ConfigBase
import network.loki.messenger.libsession_util.Contacts 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.Contact
import network.loki.messenger.libsession_util.util.Conversation
import network.loki.messenger.libsession_util.util.ExpiryMode 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.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.argThat import org.mockito.kotlin.argThat
import org.mockito.kotlin.argWhere
import org.mockito.kotlin.eq import org.mockito.kotlin.eq
import org.mockito.kotlin.spy import org.mockito.kotlin.spy
import org.mockito.kotlin.verify import org.mockito.kotlin.verify
import org.session.libsession.messaging.MessagingModuleConfiguration 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.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.KeyHelper import org.session.libsignal.utilities.KeyHelper
import org.session.libsignal.utilities.hexEncodedPublicKey import org.session.libsignal.utilities.hexEncodedPublicKey
@ -50,13 +59,22 @@ class LibSessionTests {
private fun buildContactMessage(contactList: List<Contact>): ByteArray { private fun buildContactMessage(contactList: List<Contact>): ByteArray {
val (key,_) = maybeGetUserInfo()!! val (key,_) = maybeGetUserInfo()!!
val contacts = Contacts.Companion.newInstance(key) val contacts = Contacts.newInstance(key)
contactList.forEach { contact -> contactList.forEach { contact ->
contacts.set(contact) contacts.set(contact)
} }
return contacts.push().config return contacts.push().config
} }
private fun buildVolatileMessage(conversations: List<Conversation>): 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) { private fun fakePollNewConfig(configBase: ConfigBase, toMerge: ByteArray) {
configBase.merge(nextFakeHash to toMerge) configBase.merge(nextFakeHash to toMerge)
MessagingModuleConfiguration.shared.configFactory.persist(configBase, System.currentTimeMillis()) MessagingModuleConfiguration.shared.configFactory.persist(configBase, System.currentTimeMillis())
@ -95,8 +113,83 @@ class LibSessionTests {
fakePollNewConfig(contacts, newContactMerge) fakePollNewConfig(contacts, newContactMerge)
verify(storageSpy).addLibSessionContacts(argThat { verify(storageSpy).addLibSessionContacts(argThat {
first().let { it.id == newContactId && it.approved } && size == 1 first().let { it.id == newContactId && it.approved } && size == 1
}) }, any())
verify(storageSpy).setRecipientApproved(argThat { address.serialize() == newContactId }, eq(true)) 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))
}
} }

View File

@ -178,6 +178,9 @@
android:screenOrientation="portrait" /> android:screenOrientation="portrait" />
<activity android:name="org.thoughtcrime.securesms.preferences.appearance.AppearanceSettingsActivity" <activity android:name="org.thoughtcrime.securesms.preferences.appearance.AppearanceSettingsActivity"
android:screenOrientation="portrait"/> android:screenOrientation="portrait"/>
<activity android:name="org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.Session.DayNight.NoActionBar" />
<activity <activity
android:exported="true" android:exported="true"

View File

@ -197,12 +197,12 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
} }
@Override @Override
public void notifyUpdates(@NonNull ConfigBase forConfigObject) { public void notifyUpdates(@NonNull ConfigBase forConfigObject, long messageTimestamp) {
// forward to the config factory / storage ig // forward to the config factory / storage ig
if (forConfigObject instanceof UserProfile && !textSecurePreferences.getConfigurationMessageSynced()) { if (forConfigObject instanceof UserProfile && !textSecurePreferences.getConfigurationMessageSynced()) {
textSecurePreferences.setConfigurationMessageSynced(true); textSecurePreferences.setConfigurationMessageSynced(true);
} }
storage.notifyConfigUpdates(forConfigObject); storage.notifyConfigUpdates(forConfigObject, messageTimestamp);
} }
@Override @Override

View File

@ -1,51 +0,0 @@
package org.thoughtcrime.securesms
import android.content.Context
import android.view.LayoutInflater
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import cn.carbswang.android.numberpickerview.library.NumberPickerView
import network.loki.messenger.R
import org.session.libsession.utilities.ExpirationUtil
fun Context.showExpirationDialog(
expiration: Int,
onExpirationTime: (Int) -> Unit
): AlertDialog {
val view = LayoutInflater.from(this).inflate(R.layout.expiration_dialog, null)
val numberPickerView = view.findViewById<NumberPickerView>(R.id.expiration_number_picker)
fun updateText(index: Int) {
view.findViewById<TextView>(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()
}
}

View File

@ -186,7 +186,7 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper)
else DatabaseComponent.get(context).mmsDatabase() else DatabaseComponent.get(context).mmsDatabase()
messagingDatabase.deleteMessage(messageID) messagingDatabase.deleteMessage(messageID)
DatabaseComponent.get(context).lokiMessageDatabase().deleteMessage(messageID, isSms) 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<Long>, threadId: Long, isSms: Boolean) { override fun deleteMessages(messageIDs: List<Long>, threadId: Long, isSms: Boolean) {
@ -195,7 +195,7 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper)
messagingDatabase.deleteMessages(messageIDs.toLongArray(), threadId) messagingDatabase.deleteMessages(messageIDs.toLongArray(), threadId)
DatabaseComponent.get(context).lokiMessageDatabase().deleteMessages(messageIDs) 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? { override fun updateMessageAsDeleted(timestamp: Long, author: String): Long? {
@ -212,15 +212,12 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper)
return message.id return message.id
} }
override fun getServerHashForMessage(messageID: Long): String? { override fun getServerHashForMessage(messageID: Long, mms: Boolean): String? =
val messageDB = DatabaseComponent.get(context).lokiMessageDatabase() DatabaseComponent.get(context).lokiMessageDatabase().getMessageServerHash(messageID, mms)
return messageDB.getMessageServerHash(messageID)
}
override fun getDatabaseAttachment(attachmentId: Long): DatabaseAttachment? { override fun getDatabaseAttachment(attachmentId: Long): DatabaseAttachment? =
val attachmentDatabase = DatabaseComponent.get(context).attachmentDatabase() DatabaseComponent.get(context).attachmentDatabase()
return attachmentDatabase.getAttachment(AttachmentId(attachmentId, 0)) .getAttachment(AttachmentId(attachmentId, 0))
}
private fun scaleAndStripExif(attachmentDatabase: AttachmentDatabase, constraints: MediaConstraints, attachment: Attachment): Attachment? { private fun scaleAndStripExif(attachmentDatabase: AttachmentDatabase, constraints: MediaConstraints, attachment: Attachment): Attachment? {
return try { return try {

View File

@ -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<Void, Void, Void>() {
@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();
}
}
}

View File

@ -1,13 +1,17 @@
package org.thoughtcrime.securesms.components.menu package org.thoughtcrime.securesms.components.menu
import android.content.Context
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.annotation.ColorRes
/** /**
* Represents an action to be rendered * Represents an action to be rendered
*/ */
data class ActionItem @JvmOverloads constructor( data class ActionItem(
@AttrRes val iconRes: Int, @AttrRes val iconRes: Int,
val title: CharSequence, val title: Int,
val action: Runnable, val action: Runnable,
val contentDescription: String? = null val contentDescription: Int? = null,
val subtitle: ((Context) -> CharSequence?)? = null,
@ColorRes val color: Int? = null,
) )

View File

@ -1,12 +1,21 @@
package org.thoughtcrime.securesms.components.menu package org.thoughtcrime.securesms.components.menu
import android.content.Context
import android.content.res.ColorStateList
import android.util.TypedValue import android.util.TypedValue
import android.view.View import android.view.View
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.isGone
import androidx.core.widget.ImageViewCompat
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView 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 network.loki.messenger.R
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
@ -34,30 +43,23 @@ class ContextMenuList(recyclerView: RecyclerView, onItemClick: () -> Unit) {
mappingAdapter.submitList(items.toAdapterItems()) mappingAdapter.submitList(items.toAdapterItems())
} }
private fun List<ActionItem>.toAdapterItems(): List<DisplayItem> { private fun List<ActionItem>.toAdapterItems(): List<DisplayItem> =
return this.mapIndexed { index, item -> mapIndexed { index, item ->
val displayType: DisplayType = when { when {
this.size == 1 -> DisplayType.ONLY size == 1 -> DisplayType.ONLY
index == 0 -> DisplayType.TOP index == 0 -> DisplayType.TOP
index == this.size - 1 -> DisplayType.BOTTOM index == size - 1 -> DisplayType.BOTTOM
else -> DisplayType.MIDDLE else -> DisplayType.MIDDLE
} }.let { DisplayItem(item, it) }
DisplayItem(item, displayType)
} }
}
private data class DisplayItem( private data class DisplayItem(
val item: ActionItem, val item: ActionItem,
val displayType: DisplayType val displayType: DisplayType
) : MappingModel<DisplayItem> { ) : MappingModel<DisplayItem> {
override fun areItemsTheSame(newItem: DisplayItem): Boolean { override fun areItemsTheSame(newItem: DisplayItem): Boolean = this == newItem
return this == newItem
}
override fun areContentsTheSame(newItem: DisplayItem): Boolean { override fun areContentsTheSame(newItem: DisplayItem): Boolean = this == newItem
return this == newItem
}
} }
private enum class DisplayType { private enum class DisplayType {
@ -68,28 +70,61 @@ class ContextMenuList(recyclerView: RecyclerView, onItemClick: () -> Unit) {
itemView: View, itemView: View,
private val onItemClick: () -> Unit, private val onItemClick: () -> Unit,
) : MappingViewHolder<DisplayItem>(itemView) { ) : MappingViewHolder<DisplayItem>(itemView) {
private var subtitleJob: Job? = null
val icon: ImageView = itemView.findViewById(R.id.context_menu_item_icon) val icon: ImageView = itemView.findViewById(R.id.context_menu_item_icon)
val title: TextView = itemView.findViewById(R.id.context_menu_item_title) 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) { 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() 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.setImageDrawable(ContextCompat.getDrawable(context, typedValue.resourceId))
icon.imageTintList = color?.let(ColorStateList::valueOf)
} }
itemView.contentDescription = model.item.contentDescription item.contentDescription?.let(context.resources::getString)?.let { itemView.contentDescription = it }
title.text = model.item.title title.setText(item.title)
color?.let(title::setTextColor)
color?.let(subtitle::setTextColor)
subtitle.isGone = true
item.subtitle?.let { startSubtitleJob(subtitle, it) }
itemView.setOnClickListener { itemView.setOnClickListener {
model.item.action.run() item.action.run()
onItemClick() onItemClick()
} }
when (model.displayType) { when (model.displayType) {
DisplayType.TOP -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_top) DisplayType.TOP -> R.drawable.context_menu_item_background_top
DisplayType.BOTTOM -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_bottom) DisplayType.BOTTOM -> R.drawable.context_menu_item_background_bottom
DisplayType.MIDDLE -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_middle) DisplayType.MIDDLE -> R.drawable.context_menu_item_background_middle
DisplayType.ONLY -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_only) 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()
} }
} }
} }

View File

@ -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<MarginLayoutParams> {
marginEnd = if (recipient.showCallMenu()) 0 else binding.profilePictureView.width
}
}
fun updateSubtitle(recipient: Recipient, openGroup: OpenGroup? = null, config: ExpirationConfiguration? = null) {
val settings = mutableListOf<ConversationSetting>()
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<ConversationSetting, ConversationSettingsAdapter.SettingViewHolder>(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<ConversationSetting>() {
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
}

View File

@ -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)

View File

@ -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)
}
}
}

View File

@ -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<Event>()
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 <T : ViewModel> create(modelClass: Class<T>): 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)

View File

@ -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
}

View File

@ -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<ExpiryRadioOption>? = if (typeOptionsHidden) null else {
buildList {
add(offTypeOption())
if (!isNewConfigEnabled) add(legacyTypeOption())
if (!isGroup) add(afterReadTypeOption())
add(afterSendTypeOption())
}
}
private fun State.timeOptions(): List<ExpiryRadioOption>? {
// 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<ExpiryRadioOption> =
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
)

View File

@ -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<ExpiryMode>
typealias ExpiryRadioOption = RadioOption<ExpiryMode>
@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
)
}
}

View File

@ -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<State> {
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)
)
}
}

View File

@ -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<ExpiryMode>
data class UiState(
val cards: List<ExpiryOptionsCard> = 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<T>(
val title: GetString,
val options: List<RadioOption<T>>
) {
constructor(title: GetString, vararg options: RadioOption<T>): this(title, options.asList())
constructor(@StringRes title: Int, vararg options: RadioOption<T>): this(GetString(title), options.asList())
}

View File

@ -30,13 +30,11 @@ import android.view.MenuItem
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.WindowManager import android.view.WindowManager
import android.widget.LinearLayout
import android.widget.RelativeLayout import android.widget.RelativeLayout
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.annotation.DimenRes
import androidx.core.text.set import androidx.core.text.set
import androidx.core.text.toSpannable import androidx.core.text.toSpannable
import androidx.core.view.drawToBitmap import androidx.core.view.drawToBitmap
@ -46,6 +44,7 @@ import androidx.fragment.app.DialogFragment
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import androidx.loader.app.LoaderManager import androidx.loader.app.LoaderManager
@ -60,11 +59,13 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityConversationV2Binding import network.loki.messenger.databinding.ActivityConversationV2Binding
import network.loki.messenger.databinding.ViewVisibleMessageBinding import network.loki.messenger.databinding.ViewVisibleMessageBinding
import network.loki.messenger.libsession_util.util.ExpiryMode
import nl.komponents.kovenant.ui.successUi import nl.komponents.kovenant.ui.successUi
import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
@ -72,8 +73,9 @@ import org.session.libsession.messaging.jobs.AttachmentDownloadJob
import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.mentions.Mention import org.session.libsession.messaging.mentions.Mention
import org.session.libsession.messaging.mentions.MentionsManager 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.DataExtractionNotification
import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate
import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage
import org.session.libsession.messaging.messages.signal.OutgoingTextMessage import org.session.libsession.messaging.messages.signal.OutgoingTextMessage
import org.session.libsession.messaging.messages.visible.Reaction 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.attachments.ScreenshotObserver
import org.thoughtcrime.securesms.audio.AudioRecorder import org.thoughtcrime.securesms.audio.AudioRecorder
import org.thoughtcrime.securesms.contacts.SelectContactsActivity.Companion.selectedContactsKey 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.OnActionSelectedListener
import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnReactionSelectedListener import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnReactionSelectedListener
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.MESSAGE_TIMESTAMP import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.MESSAGE_TIMESTAMP
@ -125,19 +129,16 @@ import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDel
import org.thoughtcrime.securesms.conversation.v2.search.SearchBottomBar import org.thoughtcrime.securesms.conversation.v2.search.SearchBottomBar
import org.thoughtcrime.securesms.conversation.v2.search.SearchViewModel import org.thoughtcrime.securesms.conversation.v2.search.SearchViewModel
import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager 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.MentionUtilities
import org.thoughtcrime.securesms.conversation.v2.utilities.ResendMessageUtilities import org.thoughtcrime.securesms.conversation.v2.utilities.ResendMessageUtilities
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.crypto.MnemonicUtilities import org.thoughtcrime.securesms.crypto.MnemonicUtilities
import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.GroupDatabase
import org.thoughtcrime.securesms.database.LokiAPIDatabase
import org.thoughtcrime.securesms.database.LokiMessageDatabase import org.thoughtcrime.securesms.database.LokiMessageDatabase
import org.thoughtcrime.securesms.database.LokiThreadDatabase import org.thoughtcrime.securesms.database.LokiThreadDatabase
import org.thoughtcrime.securesms.database.MmsDatabase import org.thoughtcrime.securesms.database.MmsDatabase
import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase
import org.thoughtcrime.securesms.database.ReactionDatabase import org.thoughtcrime.securesms.database.ReactionDatabase
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.database.SessionContactDatabase import org.thoughtcrime.securesms.database.SessionContactDatabase
import org.thoughtcrime.securesms.database.SmsDatabase import org.thoughtcrime.securesms.database.SmsDatabase
import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.Storage
@ -166,7 +167,6 @@ import org.thoughtcrime.securesms.onboarding.recoverypassword.startRecoveryPassw
import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.reactions.ReactionsDialogFragment import org.thoughtcrime.securesms.reactions.ReactionsDialogFragment
import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiDialogFragment import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiDialogFragment
import org.thoughtcrime.securesms.showExpirationDialog
import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.util.ActivityDispatcher import org.thoughtcrime.securesms.util.ActivityDispatcher
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities 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.SimpleTextWatcher
import org.thoughtcrime.securesms.util.isScrolledToBottom import org.thoughtcrime.securesms.util.isScrolledToBottom
import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.show
import org.thoughtcrime.securesms.util.toPx import org.thoughtcrime.securesms.util.toPx
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.time.Instant
import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.concurrent.ExecutionException import java.util.concurrent.ExecutionException
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
@ -188,6 +191,10 @@ import kotlin.math.abs
import kotlin.math.min import kotlin.math.min
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.math.sqrt 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 // 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 // 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, class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDelegate,
InputBarRecordingViewDelegate, AttachmentManager.AttachmentListener, ActivityDispatcher, InputBarRecordingViewDelegate, AttachmentManager.AttachmentListener, ActivityDispatcher,
ConversationActionModeCallbackDelegate, VisibleMessageViewDelegate, RecipientModifiedListener, ConversationActionModeCallbackDelegate, VisibleMessageViewDelegate, RecipientModifiedListener,
SearchBottomBar.EventListener, LoaderManager.LoaderCallbacks<Cursor>, SearchBottomBar.EventListener, LoaderManager.LoaderCallbacks<Cursor>, ConversationActionBarDelegate,
OnReactionSelectedListener, ReactWithAnyEmojiDialogFragment.Callback, ReactionsDialogFragment.Callback, OnReactionSelectedListener, ReactWithAnyEmojiDialogFragment.Callback, ReactionsDialogFragment.Callback,
ConversationMenuHelper.ConversationMenuListener { ConversationMenuHelper.ConversationMenuListener {
@ -208,8 +215,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
@Inject lateinit var lokiThreadDb: LokiThreadDatabase @Inject lateinit var lokiThreadDb: LokiThreadDatabase
@Inject lateinit var sessionContactDb: SessionContactDatabase @Inject lateinit var sessionContactDb: SessionContactDatabase
@Inject lateinit var groupDb: GroupDatabase @Inject lateinit var groupDb: GroupDatabase
@Inject lateinit var recipientDb: RecipientDatabase
@Inject lateinit var lokiApiDb: LokiAPIDatabase
@Inject lateinit var smsDb: SmsDatabase @Inject lateinit var smsDb: SmsDatabase
@Inject lateinit var mmsDb: MmsDatabase @Inject lateinit var mmsDb: MmsDatabase
@Inject lateinit var lokiMessageDb: LokiMessageDatabase @Inject lateinit var lokiMessageDb: LokiMessageDatabase
@ -410,7 +415,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
updateUnreadCountIndicator() updateUnreadCountIndicator()
updateSubtitle()
updatePlaceholder() updatePlaceholder()
setUpBlockedBanner() setUpBlockedBanner()
binding!!.searchBottomBar.setEventListener(this) binding!!.searchBottomBar.setEventListener(this)
@ -438,6 +442,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
setUpRecipientObserver() setUpRecipientObserver()
getLatestOpenGroupInfoIfNeeded() getLatestOpenGroupInfoIfNeeded()
setUpSearchResultObserver() setUpSearchResultObserver()
scrollToFirstUnreadMessageIfNeeded()
setUpOutdatedClientBanner()
if (author != null && messageTimestamp >= 0 && targetPosition >= 0) { if (author != null && messageTimestamp >= 0 && targetPosition >= 0) {
binding?.conversationRecyclerView?.scrollToPosition(targetPosition) binding?.conversationRecyclerView?.scrollToPosition(targetPosition)
@ -453,18 +459,21 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
reactionDelegate = ConversationReactionDelegate(reactionOverlayStub) reactionDelegate = ConversationReactionDelegate(reactionOverlayStub)
reactionDelegate.setOnReactionSelectedListener(this) reactionDelegate.setOnReactionSelectedListener(this)
lifecycleScope.launch { lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
// only update the conversation every 3 seconds maximum // only update the conversation every 3 seconds maximum
// channel is rendezvous and shouldn't block on try send calls as often as we want // channel is rendezvous and shouldn't block on try send calls as often as we want
val bufferedFlow = bufferedLastSeenChannel.consumeAsFlow() bufferedLastSeenChannel.receiveAsFlow()
bufferedFlow.filter { .flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED)
it > storage.getLastSeen(viewModel.threadId) .collectLatest {
}.collectLatest { latestMessageRead -> withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { try {
storage.markConversationAsRead(viewModel.threadId, latestMessageRead) 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, true,
screenshotObserver screenshotObserver
) )
viewModel.run {
binding?.toolbarContent?.update(recipient ?: return, openGroup, expirationConfiguration)
}
} }
override fun onPause() { override fun onPause() {
@ -493,8 +505,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
override fun dispatchIntent(body: (Context) -> Intent?) { override fun dispatchIntent(body: (Context) -> Intent?) {
val intent = body(this) ?: return body(this)?.let { push(it, false) }
push(intent, false)
} }
override fun showDialog(dialogFragment: DialogFragment, tag: String?) { override fun showDialog(dialogFragment: DialogFragment, tag: String?) {
@ -526,16 +537,16 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
if (author != null && messageTimestamp >= 0) { if (author != null && messageTimestamp >= 0) {
jumpToMessage(author, messageTimestamp, firstLoad.get(), null) jumpToMessage(author, messageTimestamp, firstLoad.get(), null)
} } else {
else if (firstLoad.getAndSet(false)) { if (firstLoad.getAndSet(false)) scrollToFirstUnreadMessageIfNeeded(true)
scrollToFirstUnreadMessageIfNeeded(true)
handleRecyclerViewScrolled()
}
else if (oldCount != newCount) {
handleRecyclerViewScrolled() handleRecyclerViewScrolled()
} }
} }
updatePlaceholder() updatePlaceholder()
viewModel.recipient?.let {
maybeUpdateToolbar(recipient = it)
setUpOutdatedClientBanner()
}
} }
override fun onLoaderReset(cursor: Loader<Cursor>) { override fun onLoaderReset(cursor: Loader<Cursor>) {
@ -574,20 +585,14 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
actionBar.title = "" actionBar.title = ""
actionBar.setDisplayHomeAsUpEnabled(true) actionBar.setDisplayHomeAsUpEnabled(true)
actionBar.setHomeButtonEnabled(true) actionBar.setHomeButtonEnabled(true)
binding.toolbarContent.conversationTitleView.text = when { binding!!.toolbarContent.bind(
recipient.isLocalNumber -> getString(R.string.note_to_self) this,
else -> recipient.toShortString() viewModel.threadId,
} recipient,
@DimenRes val sizeID: Int = if (viewModel.recipient?.isClosedGroupRecipient == true) { viewModel.expirationConfiguration,
R.dimen.medium_profile_picture_size viewModel.openGroup
} else { )
R.dimen.small_profile_picture_size maybeUpdateToolbar(recipient)
}
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)
} }
// called from onCreate // called from onCreate
@ -679,23 +684,36 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
private fun getLatestOpenGroupInfoIfNeeded() { private fun getLatestOpenGroupInfoIfNeeded() {
viewModel.openGroup?.let { val openGroup = viewModel.openGroup ?: return
OpenGroupApi.getMemberCount(it.room, it.server).successUi { updateSubtitle() } OpenGroupApi.getMemberCount(openGroup.room, openGroup.server) successUi {
binding?.toolbarContent?.updateSubtitle(viewModel.recipient!!, openGroup, viewModel.expirationConfiguration)
maybeUpdateToolbar(viewModel.recipient!!)
} }
} }
// called from onCreate // called from onCreate
private fun setUpBlockedBanner() { private fun setUpBlockedBanner() {
val recipient = viewModel.recipient ?: return val recipient = viewModel.recipient?.takeUnless { it.isGroupRecipient } ?: return
if (recipient.isGroupRecipient) { return }
val sessionID = recipient.address.toString() val sessionID = recipient.address.toString()
val contact = sessionContactDb.getContactWithSessionID(sessionID) val name = sessionContactDb.getContactWithSessionID(sessionID)?.displayName(Contact.ContactContext.REGULAR) ?: sessionID
val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID
binding?.blockedBannerTextView?.text = resources.getString(R.string.activity_conversation_blocked_banner_text, name) binding?.blockedBannerTextView?.text = resources.getString(R.string.activity_conversation_blocked_banner_text, name)
binding?.blockedBanner?.isVisible = recipient.isBlocked binding?.blockedBanner?.isVisible = recipient.isBlocked
binding?.blockedBanner?.setOnClickListener { viewModel.unblock() } 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() { private fun setUpLinkPreviewObserver() {
if (!textSecurePreferences.isLinkPreviewsEnabled()) { if (!textSecurePreferences.isLinkPreviewsEnabled()) {
linkPreviewViewModel.onUserCancel(); return linkPreviewViewModel.onUserCancel(); return
@ -766,10 +784,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
menu, menu,
menuInflater, menuInflater,
recipient, recipient,
viewModel.threadId,
this this
) { onOptionsItemSelected(it) } )
} }
maybeUpdateToolbar(recipient)
return true return true
} }
@ -778,7 +796,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
tearDownRecipientObserver() tearDownRecipientObserver()
super.onDestroy() super.onDestroy()
binding = null binding = null
// actionBarBinding = null
} }
// endregion // endregion
@ -793,31 +810,25 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
setUpMessageRequestsBar() setUpMessageRequestsBar()
invalidateOptionsMenu() invalidateOptionsMenu()
updateSubtitle()
updateSendAfterApprovalText() updateSendAfterApprovalText()
showOrHideInputIfNeeded() showOrHideInputIfNeeded()
binding?.toolbarContent?.profilePictureView?.update(threadRecipient) maybeUpdateToolbar(threadRecipient)
binding?.toolbarContent?.conversationTitleView?.text = when {
threadRecipient.isLocalNumber -> getString(R.string.note_to_self)
else -> threadRecipient.toShortString()
}
} }
} }
private fun maybeUpdateToolbar(recipient: Recipient) {
binding?.toolbarContent?.update(recipient, viewModel.openGroup, viewModel.expirationConfiguration)
}
private fun updateSendAfterApprovalText() { private fun updateSendAfterApprovalText() {
binding?.textSendAfterApproval?.isVisible = viewModel.showSendAfterApprovalText binding?.textSendAfterApproval?.isVisible = viewModel.showSendAfterApprovalText
} }
private fun showOrHideInputIfNeeded() { private fun showOrHideInputIfNeeded() {
val recipient = viewModel.recipient binding?.inputBar?.showInput = viewModel.recipient?.takeIf { it.isClosedGroupRecipient }
if (recipient != null && recipient.isClosedGroupRecipient) { ?.run { address.toGroupString().let(groupDb::getGroup).orNull()?.isActive == true }
val group = groupDb.getGroup(recipient.address.toGroupString()).orNull() ?: true
val isActive = (group?.isActive == true)
binding?.inputBar?.showInput = isActive
} else {
binding?.inputBar?.showInput = true
}
} }
private fun setUpMessageRequestsBar() { private fun setUpMessageRequestsBar() {
@ -847,21 +858,15 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
} }
private fun isOutgoingMessageRequestThread(): Boolean { private fun isOutgoingMessageRequestThread(): Boolean = viewModel.recipient?.run {
val recipient = viewModel.recipient ?: return false !isGroupRecipient && !isLocalNumber &&
return !recipient.isGroupRecipient && !(hasApprovedMe() || viewModel.hasReceived())
!recipient.isLocalNumber && } ?: false
!(recipient.hasApprovedMe() || viewModel.hasReceived())
}
private fun isIncomingMessageRequestThread(): Boolean { private fun isIncomingMessageRequestThread(): Boolean = viewModel.recipient?.run {
val recipient = viewModel.recipient ?: return false !isGroupRecipient && !isApproved && !isLocalNumber &&
return !recipient.isGroupRecipient && !threadDb.getLastSeenAndHasSent(viewModel.threadId).second() && threadDb.getMessageCount(viewModel.threadId) > 0
!recipient.isApproved && } ?: false
!recipient.isLocalNumber &&
!threadDb.getLastSeenAndHasSent(viewModel.threadId).second() &&
threadDb.getMessageCount(viewModel.threadId) > 0
}
override fun inputBarEditTextContentChanged(newContent: CharSequence) { override fun inputBarEditTextContentChanged(newContent: CharSequence) {
val inputBarText = binding?.inputBar?.text ?: return // TODO check if we should be referencing newContent here instead 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 maybeTargetVisiblePosition = if (reverseMessageList) layoutManager?.findFirstVisibleItemPosition() else layoutManager?.findLastVisibleItemPosition()
val targetVisiblePosition = maybeTargetVisiblePosition ?: RecyclerView.NO_POSITION val targetVisiblePosition = maybeTargetVisiblePosition ?: RecyclerView.NO_POSITION
if (!firstLoad.get() && targetVisiblePosition != RecyclerView.NO_POSITION) { if (!firstLoad.get() && targetVisiblePosition != RecyclerView.NO_POSITION) {
val visibleItemTimestamp = adapter.getTimestampForItemAt(targetVisiblePosition) adapter.getTimestampForItemAt(targetVisiblePosition)?.let { visibleItemTimestamp ->
if (visibleItemTimestamp != null) { bufferedLastSeenChannel.trySend(visibleItemTimestamp).apply {
bufferedLastSeenChannel.trySend(visibleItemTimestamp) if (isFailure) Log.e(TAG, "trySend failed", exceptionOrNull())
}
} }
} }
if (reverseMessageList) { if (reverseMessageList) {
unreadCount = min(unreadCount, targetVisiblePosition).coerceAtLeast(0) unreadCount = min(unreadCount, targetVisiblePosition).coerceAtLeast(0)
} } else {
else {
val layoutUnreadCount = layoutManager?.let { (it.itemCount - 1) - it.findLastVisibleItemPosition() } val layoutUnreadCount = layoutManager?.let { (it.itemCount - 1) - it.findLastVisibleItemPosition() }
?: RecyclerView.NO_POSITION ?: RecyclerView.NO_POSITION
unreadCount = min(unreadCount, layoutUnreadCount).coerceAtLeast(0) unreadCount = min(unreadCount, layoutUnreadCount).coerceAtLeast(0)
@ -1104,33 +1109,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
binding.unreadCountIndicator.isVisible = (unreadCount != 0) 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 // endregion
// region Interaction // region Interaction
override fun onDisappearingMessagesClicked() {
viewModel.recipient?.let { showDisappearingMessages(it) }
}
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) { if (item.itemId == android.R.id.home) {
return false return false
@ -1174,20 +1159,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() 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) { if (thread.isClosedGroupRecipient) {
val group = groupDb.getGroup(thread.address.toGroupString()).orNull() groupDb.getGroup(thread.address.toGroupString()).orNull()?.run { if (!isActive) return }
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()
} }
Intent(this, DisappearingMessagesActivity::class.java)
.apply { putExtra(DisappearingMessagesActivity.THREAD_ID, viewModel.threadId) }
.also { show(it, true) }
} }
override fun unblock() { override fun unblock() {
@ -1582,10 +1560,14 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
startRecoveryPasswordActivity() startRecoveryPasswordActivity()
} }
// Create the message // Create the message
val message = VisibleMessage() val message = VisibleMessage().applyExpiryMode(viewModel.threadId)
message.sentTimestamp = sentTimestamp message.sentTimestamp = sentTimestamp
message.text = text 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 // Clear the input bar
binding?.inputBar?.text = "" binding?.inputBar?.text = ""
binding?.inputBar?.cancelQuoteDraft() binding?.inputBar?.cancelQuoteDraft()
@ -1613,7 +1595,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val sentTimestamp = SnodeAPI.nowWithOffset val sentTimestamp = SnodeAPI.nowWithOffset
processMessageRequestApproval() processMessageRequestApproval()
// Create the message // Create the message
val message = VisibleMessage() val message = VisibleMessage().applyExpiryMode(viewModel.threadId)
message.sentTimestamp = sentTimestamp message.sentTimestamp = sentTimestamp
message.text = body message.text = body
val quote = quotedMessage?.let { val quote = quotedMessage?.let {
@ -1629,7 +1611,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
else it.individualRecipient.address else it.individualRecipient.address
quote?.copy(author = sender) 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 // Clear the input bar
binding?.inputBar?.text = "" binding?.inputBar?.text = ""
binding?.inputBar?.cancelQuoteDraft() binding?.inputBar?.cancelQuoteDraft()
@ -1694,6 +1680,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults) Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults)
} }
@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) {
super.onActivityResult(requestCode, resultCode, intent) super.onActivityResult(requestCode, resultCode, intent)
val mediaPreppedListener = object : ListenableFuture.Listener<Boolean> { val mediaPreppedListener = object : ListenableFuture.Listener<Boolean> {
@ -1813,7 +1800,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
override fun deleteMessages(messages: Set<MessageRecord>) { override fun deleteMessages(messages: Set<MessageRecord>) {
val recipient = viewModel.recipient ?: return val recipient = viewModel.recipient ?: return
val allSentByCurrentUser = messages.all { it.isOutgoing } 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) { if (recipient.isOpenGroupRecipient) {
val messageCount = 1 val messageCount = 1

View File

@ -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<String> 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<ActionItem> getMenuActionItems(@NonNull MessageRecord message) {
List<ActionItem> 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<Animator> 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<Animator> newHideAnimators() {
int duration = getContext().getResources().getInteger(R.integer.reaction_scrubber_hide_duration);
List<Animator> 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,
}
}

View File

@ -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<EmojiImageView>
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<View>(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<ActionItem> {
val items: MutableList<ActionItem> = 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<Animator> {
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) }
}

View File

@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.conversation.v2 package org.thoughtcrime.securesms.conversation.v2
import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@ -12,10 +13,12 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.messaging.utilities.SessionId import org.session.libsession.messaging.utilities.SessionId
import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
@ -40,6 +43,9 @@ class ConversationViewModel(
private var _recipient: RetrieveOnce<Recipient> = RetrieveOnce { private var _recipient: RetrieveOnce<Recipient> = RetrieveOnce {
repository.maybeGetRecipientForThreadId(threadId) repository.maybeGetRecipientForThreadId(threadId)
} }
val expirationConfiguration: ExpirationConfiguration?
get() = storage.getExpirationConfiguration(threadId)
val recipient: Recipient? val recipient: Recipient?
get() = _recipient.value get() = _recipient.value
@ -215,8 +221,11 @@ class ConversationViewModel(
} }
fun hidesInputBar(): Boolean = openGroup?.canWrite != true && 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 @dagger.assisted.AssistedFactory
interface AssistedFactory { interface AssistedFactory {

View File

@ -5,9 +5,11 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import network.loki.messenger.R 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.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.mms.ImageSlide import org.thoughtcrime.securesms.mms.ImageSlide
import org.thoughtcrime.securesms.mms.Slide import org.thoughtcrime.securesms.mms.Slide
import org.thoughtcrime.securesms.repository.ConversationRepository
import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.GetString
import org.thoughtcrime.securesms.ui.TitledText import org.thoughtcrime.securesms.ui.TitledText
import java.util.Date import java.util.Date
@ -38,8 +41,11 @@ class MessageDetailsViewModel @Inject constructor(
private val lokiMessageDatabase: LokiMessageDatabase, private val lokiMessageDatabase: LokiMessageDatabase,
private val mmsSmsDatabase: MmsSmsDatabase, private val mmsSmsDatabase: MmsSmsDatabase,
private val threadDb: ThreadDatabase, private val threadDb: ThreadDatabase,
private val repository: ConversationRepository,
) : ViewModel() { ) : ViewModel() {
private var job: Job? = null
private val state = MutableStateFlow(MessageDetailsState()) private val state = MutableStateFlow(MessageDetailsState())
val stateFlow = state.asStateFlow() val stateFlow = state.asStateFlow()
@ -48,6 +54,8 @@ class MessageDetailsViewModel @Inject constructor(
var timestamp: Long = 0L var timestamp: Long = 0L
set(value) { set(value) {
job?.cancel()
field = value field = value
val record = mmsSmsDatabase.getMessageForTimestamp(timestamp) val record = mmsSmsDatabase.getMessageForTimestamp(timestamp)
@ -58,6 +66,12 @@ class MessageDetailsViewModel @Inject constructor(
val mmsRecord = record as? MmsMessageRecord 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 { state.value = record.run {
val slides = mmsRecord?.slideDeck?.slides ?: emptyList() val slides = mmsRecord?.slideDeck?.slides ?: emptyList()

View File

@ -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<ExpirationTimerView> 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);
}
}
}

View File

@ -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)
}
}

View File

@ -4,16 +4,11 @@ import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.os.AsyncTask import android.os.AsyncTask
import android.view.Menu import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.annotation.ColorInt
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ContextThemeWrapper import androidx.appcompat.view.ContextThemeWrapper
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
@ -24,10 +19,8 @@ import androidx.core.graphics.drawable.IconCompat
import network.loki.messenger.R import network.loki.messenger.R
import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.messaging.sending_receiving.leave 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.GroupUtil.doubleDecodeGroupID
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.getColorFromAttr
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.guava.Optional
import org.session.libsignal.utilities.toHexString 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.groups.EditClosedGroupActivity.Companion.groupIDKey
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
import org.thoughtcrime.securesms.service.WebRtcCallService import org.thoughtcrime.securesms.service.WebRtcCallService
import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.showMuteDialog import org.thoughtcrime.securesms.showMuteDialog
import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.util.BitmapUtil import org.thoughtcrime.securesms.util.BitmapUtil
import java.io.IOException import java.io.IOException
@ -53,9 +46,7 @@ object ConversationMenuHelper {
menu: Menu, menu: Menu,
inflater: MenuInflater, inflater: MenuInflater,
thread: Recipient, thread: Recipient,
threadId: Long, context: Context
context: Context,
onOptionsItemSelected: (MenuItem) -> Unit
) { ) {
// Prepare // Prepare
menu.clear() menu.clear()
@ -63,21 +54,8 @@ object ConversationMenuHelper {
// Base menu (options that should always be present) // Base menu (options that should always be present)
inflater.inflate(R.menu.menu_conversation, menu) inflater.inflate(R.menu.menu_conversation, menu)
// Expiring messages // Expiring messages
if (!isOpenGroup && (thread.hasApprovedMe() || thread.isClosedGroupRecipient) && !thread.isBlocked) { if (!isOpenGroup && (thread.hasApprovedMe() || thread.isClosedGroupRecipient || thread.isLocalNumber)) {
if (thread.expireMessages > 0) { inflater.inflate(R.menu.menu_conversation_expiration, menu)
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<ImageView>(R.id.menu_badge_icon)
val badgeView = actionView.findViewById<TextView>(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)
}
} }
// One-on-one chat menu allows copying the session id // One-on-one chat menu allows copying the session id
if (thread.isContactRecipient) { if (thread.isContactRecipient) {
@ -110,7 +88,7 @@ object ConversationMenuHelper {
inflater.inflate(R.menu.menu_conversation_notification_settings, menu) 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) 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_view_all_media -> { showAllMedia(context, thread) }
R.id.menu_search -> { search(context) } R.id.menu_search -> { search(context) }
R.id.menu_add_shortcut -> { addShortcut(context, thread) } R.id.menu_add_shortcut -> { addShortcut(context, thread) }
R.id.menu_expiring_messages -> { showExpiringMessagesDialog(context, thread) } R.id.menu_expiring_messages -> { showDisappearingMessages(context, thread) }
R.id.menu_expiring_messages_off -> { showExpiringMessagesDialog(context, thread) }
R.id.menu_unblock -> { unblock(context, thread) } R.id.menu_unblock -> { unblock(context, thread) }
R.id.menu_block -> { block(context, thread, deleteThread = false) } R.id.menu_block -> { block(context, thread, deleteThread = false) }
R.id.menu_block_delete -> { blockAndDelete(context, thread) } R.id.menu_block_delete -> { blockAndDelete(context, thread) }
@ -210,6 +187,7 @@ object ConversationMenuHelper {
private fun addShortcut(context: Context, thread: Recipient) { private fun addShortcut(context: Context, thread: Recipient) {
object : AsyncTask<Void?, Void?, IconCompat?>() { object : AsyncTask<Void?, Void?, IconCompat?>() {
@Deprecated("Deprecated in Java")
override fun doInBackground(vararg params: Void?): IconCompat? { override fun doInBackground(vararg params: Void?): IconCompat? {
var icon: IconCompat? = null var icon: IconCompat? = null
val contactPhoto = thread.contactPhoto val contactPhoto = thread.contactPhoto
@ -228,6 +206,7 @@ object ConversationMenuHelper {
return icon return icon
} }
@Deprecated("Deprecated in Java")
override fun onPostExecute(icon: IconCompat?) { override fun onPostExecute(icon: IconCompat?) {
val name = Optional.fromNullable<String>(thread.name) val name = Optional.fromNullable<String>(thread.name)
.or(Optional.fromNullable<String>(thread.profileName)) .or(Optional.fromNullable<String>(thread.profileName))
@ -244,9 +223,9 @@ object ConversationMenuHelper {
}.execute() }.execute()
} }
private fun showExpiringMessagesDialog(context: Context, thread: Recipient) { private fun showDisappearingMessages(context: Context, thread: Recipient) {
val listener = context as? ConversationMenuListener ?: return val listener = context as? ConversationMenuListener ?: return
listener.showExpiringMessagesDialog(thread) listener.showDisappearingMessages(thread)
} }
private fun unblock(context: Context, thread: Recipient) { private fun unblock(context: Context, thread: Recipient) {
@ -348,7 +327,7 @@ object ConversationMenuHelper {
fun unblock() fun unblock()
fun copySessionID(sessionId: String) fun copySessionID(sessionId: String)
fun copyOpenGroupUrl(thread: Recipient) fun copyOpenGroupUrl(thread: Recipient)
fun showExpiringMessagesDialog(thread: Recipient) fun showDisappearingMessages(thread: Recipient)
} }
} }

View File

@ -3,50 +3,80 @@ package org.thoughtcrime.securesms.conversation.v2.messages
import android.content.Context import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.core.content.res.ResourcesCompat import androidx.core.content.res.ResourcesCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ViewControlMessageBinding 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.database.model.MessageRecord
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import javax.inject.Inject
@AndroidEntryPoint
class ControlMessageView : LinearLayout { class ControlMessageView : LinearLayout {
private val TAG = "ControlMessageView"
private lateinit var binding: ViewControlMessageBinding private lateinit var binding: ViewControlMessageBinding
// region Lifecycle
constructor(context: Context) : super(context) { initialize() } constructor(context: Context) : super(context) { initialize() }
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() }
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() }
@Inject lateinit var disappearingMessages: DisappearingMessages
private fun initialize() { private fun initialize() {
binding = ViewControlMessageBinding.inflate(LayoutInflater.from(context), this, true) binding = ViewControlMessageBinding.inflate(LayoutInflater.from(context), this, true)
layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT) layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT)
} }
// endregion
// region Updating
fun bind(message: MessageRecord, previous: MessageRecord?) { fun bind(message: MessageRecord, previous: MessageRecord?) {
binding.dateBreakTextView.showDateBreak(message, previous) 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) var messageBody: CharSequence = message.getDisplayBody(context)
binding.root.contentDescription= null binding.root.contentDescription = null
binding.textView.text = messageBody
when { when {
message.isExpirationTimerUpdate -> { message.isExpirationTimerUpdate -> {
binding.iconImageView.setImageDrawable( binding.apply {
ResourcesCompat.getDrawable(resources, R.drawable.ic_timer, context.theme) expirationTimerView.isVisible = true
)
binding.iconImageView.visibility = View.VISIBLE 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 -> { message.isMediaSavedNotification -> {
binding.iconImageView.setImageDrawable( binding.iconImageView.apply {
ResourcesCompat.getDrawable(resources, R.drawable.ic_file_download_white_36dp, context.theme) setImageDrawable(
) ResourcesCompat.getDrawable(resources, R.drawable.ic_file_download_white_36dp, context.theme)
binding.iconImageView.visibility = View.VISIBLE )
isVisible = true
}
} }
message.isMessageRequestResponse -> { 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) binding.root.contentDescription=context.getString(R.string.AccessibilityId_message_request_config_message)
} }
message.isCallLog -> { message.isCallLog -> {
@ -56,16 +86,22 @@ class ControlMessageView : LinearLayout {
message.isFirstMissedCall -> R.drawable.ic_info_outline_light message.isFirstMissedCall -> R.drawable.ic_info_outline_light
else -> R.drawable.ic_missed_call else -> R.drawable.ic_missed_call
} }
binding.iconImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, drawable, context.theme)) binding.textView.isVisible = false
binding.iconImageView.visibility = View.VISIBLE 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() { fun recycle() {
} }
// endregion
} }

View File

@ -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.messaging.sending_receiving.attachments.DatabaseAttachment
import org.session.libsession.utilities.ThemeUtil import org.session.libsession.utilities.ThemeUtil
import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.getColorFromAttr
import org.session.libsession.utilities.modifyLayoutParams
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.conversation.v2.ModalUrlBottomSheet import org.thoughtcrime.securesms.conversation.v2.ModalUrlBottomSheet
@ -198,9 +199,9 @@ class VisibleMessageContentView : ConstraintLayout {
isStart = isStartOfMessageCluster, isStart = isStartOfMessageCluster,
isEnd = isEndOfMessageCluster isEnd = isEndOfMessageCluster
) )
val layoutParams = binding.albumThumbnailView.root.layoutParams as ConstraintLayout.LayoutParams binding.albumThumbnailView.root.modifyLayoutParams<ConstraintLayout.LayoutParams> {
layoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f horizontalBias = if (message.isOutgoing) 1f else 0f
binding.albumThumbnailView.root.layoutParams = layoutParams }
onContentClick.add { event -> onContentClick.add { event ->
binding.albumThumbnailView.root.calculateHitObject(event, message, thread, onAttachmentNeedsDownload) binding.albumThumbnailView.root.calculateHitObject(event, message, thread, onAttachmentNeedsDownload)
} }
@ -233,9 +234,9 @@ class VisibleMessageContentView : ConstraintLayout {
} }
} }
} }
val layoutParams = binding.contentParent.layoutParams as ConstraintLayout.LayoutParams binding.contentParent.modifyLayoutParams<ConstraintLayout.LayoutParams> {
layoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f horizontalBias = if (message.isOutgoing) 1f else 0f
binding.contentParent.layoutParams = layoutParams }
} }
private val onContentClick: MutableList<((event: MotionEvent) -> Unit)> = mutableListOf() private val onContentClick: MutableList<((event: MotionEvent) -> Unit)> = mutableListOf()
@ -306,17 +307,9 @@ class VisibleMessageContentView : ConstraintLayout {
} }
@ColorInt @ColorInt
fun getTextColor(context: Context, message: MessageRecord): Int { fun getTextColor(context: Context, message: MessageRecord): Int = context.getColorFromAttr(
val colorAttribute = if (message.isOutgoing) { if (message.isOutgoing) R.attr.message_sent_text_color else R.attr.message_received_text_color
// sent )
R.attr.message_sent_text_color
} else {
// received
R.attr.message_received_text_color
}
return context.getColorFromAttr(colorAttribute)
}
} }
// endregion // endregion
} }

View File

@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.conversation.v2.messages package org.thoughtcrime.securesms.conversation.v2.messages
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Canvas import android.graphics.Canvas
@ -21,7 +22,7 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.core.view.isInvisible import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.marginBottom import androidx.core.view.marginBottom
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@ -30,13 +31,11 @@ import network.loki.messenger.databinding.ViewVisibleMessageBinding
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.contacts.Contact.ContactContext import org.session.libsession.messaging.contacts.Contact.ContactContext
import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address
import org.session.libsession.utilities.ViewUtil import org.session.libsession.utilities.ViewUtil
import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.getColorFromAttr
import org.session.libsession.utilities.modifyLayoutParams
import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.ThreadUtils
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.database.LokiAPIDatabase import org.thoughtcrime.securesms.database.LokiAPIDatabase
import org.thoughtcrime.securesms.database.LokiThreadDatabase import org.thoughtcrime.securesms.database.LokiThreadDatabase
@ -61,6 +60,8 @@ import kotlin.math.min
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.math.sqrt import kotlin.math.sqrt
private const val TAG = "VisibleMessageView"
@AndroidEntryPoint @AndroidEntryPoint
class VisibleMessageView : LinearLayout { class VisibleMessageView : LinearLayout {
@Inject lateinit var threadDb: ThreadDatabase @Inject lateinit var threadDb: ThreadDatabase
@ -201,43 +202,7 @@ class VisibleMessageView : LinearLayout {
binding.dateBreakTextView.text = if (showDateBreak) DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), message.timestamp) else null binding.dateBreakTextView.text = if (showDateBreak) DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), message.timestamp) else null
binding.dateBreakTextView.isVisible = showDateBreak binding.dateBreakTextView.isVisible = showDateBreak
// Message status indicator // Message status indicator
if (message.isOutgoing) { showStatusMessage(message)
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)
// Emoji Reactions // Emoji Reactions
val emojiLayoutParams = binding.emojiReactionsView.root.layoutParams as ConstraintLayout.LayoutParams val emojiLayoutParams = binding.emojiReactionsView.root.layoutParams as ConstraintLayout.LayoutParams
emojiLayoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f emojiLayoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f
@ -272,122 +237,106 @@ class VisibleMessageView : LinearLayout {
onDoubleTap = { binding.messageContentView.root.onContentDoubleTap?.invoke() } onDoubleTap = { binding.messageContentView.root.onContentDoubleTap?.invoke() }
} }
private fun isStartOfMessageCluster(current: MessageRecord, previous: MessageRecord?, isGroupThread: Boolean): Boolean { private fun showStatusMessage(message: MessageRecord) {
return if (isGroupThread) { val disappearing = message.expiresIn > 0
previous == null || previous.isUpdate || !DateUtils.isSameHour(current.timestamp, previous.timestamp)
|| current.recipient.address != previous.recipient.address binding.messageInnerLayout.modifyLayoutParams<FrameLayout.LayoutParams> {
gravity = if (message.isOutgoing) Gravity.END else Gravity.START
}
binding.statusContainer.modifyLayoutParams<ConstraintLayout.LayoutParams> {
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 { } else {
previous == null || previous.isUpdate || !DateUtils.isSameHour(current.timestamp, previous.timestamp) binding.messageStatusTextView.isVisible = false
|| current.isOutgoing != previous.isOutgoing binding.messageStatusImageView.isVisible = false
} }
} }
private fun isEndOfMessageCluster(current: MessageRecord, next: MessageRecord?, isGroupThread: Boolean): Boolean { private fun isStartOfMessageCluster(current: MessageRecord, previous: MessageRecord?, isGroupThread: Boolean): Boolean =
return if (isGroupThread) { previous == null || previous.isUpdate || !DateUtils.isSameHour(current.timestamp, previous.timestamp) || if (isGroupThread) {
next == null || next.isUpdate || !DateUtils.isSameHour(current.timestamp, next.timestamp) current.recipient.address != previous.recipient.address
|| current.recipient.address != next.recipient.address
} else { } else {
next == null || next.isUpdate || !DateUtils.isSameHour(current.timestamp, next.timestamp) current.isOutgoing != previous.isOutgoing
|| current.isOutgoing != next.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?, data class MessageStatusInfo(@DrawableRes val iconId: Int?,
@ColorInt val iconTint: Int?, @ColorInt val iconTint: Int?,
@StringRes val messageText: Int?, @StringRes val messageText: Int?)
val contentDescription: String?)
private fun getMessageStatusImage(message: MessageRecord): MessageStatusInfo = when { private fun getMessageStatusImage(message: MessageRecord): MessageStatusInfo = when {
message.isFailed -> message.isFailed ->
MessageStatusInfo( MessageStatusInfo(
R.drawable.ic_delivery_status_failed, R.drawable.ic_delivery_status_failed,
resources.getColor(R.color.destructive, context.theme), resources.getColor(R.color.destructive, context.theme),
R.string.delivery_status_failed, R.string.delivery_status_failed
null
) )
message.isSyncFailed -> message.isSyncFailed ->
MessageStatusInfo( MessageStatusInfo(
R.drawable.ic_delivery_status_failed, R.drawable.ic_delivery_status_failed,
context.getColor(R.color.accent_orange), context.getColor(R.color.accent_orange),
R.string.delivery_status_sync_failed, R.string.delivery_status_sync_failed
null
) )
message.isPending -> message.isPending ->
MessageStatusInfo( MessageStatusInfo(
R.drawable.ic_delivery_status_sending, R.drawable.ic_delivery_status_sending,
context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_sending, context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_sending
context.getString(R.string.AccessibilityId_message_sent_status_pending)
) )
message.isResyncing -> message.isResyncing ->
MessageStatusInfo( MessageStatusInfo(
R.drawable.ic_delivery_status_sending, R.drawable.ic_delivery_status_sending,
context.getColor(R.color.accent_orange), R.string.delivery_status_syncing, context.getColor(R.color.accent_orange), R.string.delivery_status_syncing
context.getString(R.string.AccessibilityId_message_sent_status_syncing)
) )
message.isRead -> message.isRead || !message.isOutgoing ->
MessageStatusInfo( MessageStatusInfo(
R.drawable.ic_delivery_status_read, R.drawable.ic_delivery_status_read,
context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_read, context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_read
null
) )
else -> else ->
MessageStatusInfo( MessageStatusInfo(
R.drawable.ic_delivery_status_sent, R.drawable.ic_delivery_status_sent,
context.getColorFromAttr(R.attr.message_status_color), context.getColorFromAttr(R.attr.message_status_color),
R.string.delivery_status_sent, R.string.delivery_status_sent
context.getString(R.string.AccessibilityId_message_sent_status_tick)
) )
} }
private fun updateExpirationTimer(message: MessageRecord) { private fun updateExpirationTimer(message: MessageRecord) {
val container = binding.messageInnerContainer if (!message.isOutgoing) binding.messageStatusTextView.bringToFront()
val layout = binding.messageInnerLayout binding.expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn)
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()
} }
private fun handleIsSelectedChanged() { private fun handleIsSelectedChanged() {
background = if (snIsSelected) { background = if (snIsSelected) ColorDrawable(context.getColorFromAttr(R.attr.message_selected)) else null
ColorDrawable(context.getColorFromAttr(R.attr.message_selected))
} else {
null
}
} }
override fun onDraw(canvas: Canvas) { override fun onDraw(canvas: Canvas) {
@ -424,6 +373,7 @@ class VisibleMessageView : LinearLayout {
// endregion // endregion
// region Interaction // region Interaction
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean { override fun onTouchEvent(event: MotionEvent): Boolean {
if (onPress == null || onSwipeToReply == null || onLongPress == null) { return false } if (onPress == null || onSwipeToReply == null || onLongPress == null) { return false }
when (event.action) { when (event.action) {
@ -524,14 +474,13 @@ class VisibleMessageView : LinearLayout {
} }
private fun maybeShowUserDetails(publicKey: String, threadID: Long) { private fun maybeShowUserDetails(publicKey: String, threadID: Long) {
val userDetailsBottomSheet = UserDetailsBottomSheet() UserDetailsBottomSheet().apply {
val bundle = bundleOf( arguments = bundleOf(
UserDetailsBottomSheet.ARGUMENT_PUBLIC_KEY to publicKey, UserDetailsBottomSheet.ARGUMENT_PUBLIC_KEY to publicKey,
UserDetailsBottomSheet.ARGUMENT_THREAD_ID to threadID UserDetailsBottomSheet.ARGUMENT_THREAD_ID to threadID
) )
userDetailsBottomSheet.arguments = bundle show((this@VisibleMessageView.context as AppCompatActivity).supportFragmentManager, tag)
val activity = context as AppCompatActivity }
userDetailsBottomSheet.show(activity.supportFragmentManager, userDetailsBottomSheet.tag)
} }
fun playVoiceMessage() { fun playVoiceMessage() {

View File

@ -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<ExpirationDatabaseMetadata> = 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()
}
}
}

View File

@ -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()
}

View File

@ -97,6 +97,16 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
public val groupPublicKey = "group_public_key" public val groupPublicKey = "group_public_key"
@JvmStatic @JvmStatic
val createClosedGroupPublicKeysTable = "CREATE TABLE $closedGroupPublicKeysTable ($groupPublicKey STRING PRIMARY KEY)" 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 // Hard fork service node info
const val FORK_INFO_TABLE = "fork_info" const val FORK_INFO_TABLE = "fork_info"
const val DUMMY_KEY = "dummy_key" const val DUMMY_KEY = "dummy_key"
@ -415,6 +425,31 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
database.endTransaction() 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? { fun getUserCount(room: String, server: String): Int? {
val database = databaseHelper.readableDatabase val database = databaseHelper.readableDatabase
val index = "$server.$room" val index = "$server.$room"

View File

@ -13,6 +13,8 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
private val messageThreadMappingTable = "loki_message_thread_mapping_database" private val messageThreadMappingTable = "loki_message_thread_mapping_database"
private val errorMessageTable = "loki_error_message_database" private val errorMessageTable = "loki_error_message_database"
private val messageHashTable = "loki_message_hash_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 messageID = "message_id"
private val serverID = "server_id" private val serverID = "server_id"
private val friendRequestStatus = "friend_request_status" 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);" val updateMessageMappingTable = "ALTER TABLE $messageThreadMappingTable ADD COLUMN $serverID INTEGER DEFAULT 0; ALTER TABLE $messageThreadMappingTable ADD CONSTRAINT PK_$messageThreadMappingTable PRIMARY KEY ($messageID, $serverID);"
@JvmStatic @JvmStatic
val createMessageHashTableCommand = "CREATE TABLE IF NOT EXISTS $messageHashTable ($messageID INTEGER PRIMARY KEY, $serverHash STRING);" 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 SMS_TYPE = 0
const val MMS_TYPE = 1 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)) messages.add(cursor.getLong(messageID) to cursor.getLong(serverID))
} }
} }
var deletedCount = 0L
database.beginTransaction() database.beginTransaction()
messages.forEach { (messageId, serverId) -> 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() database.setTransactionSuccessful()
} finally { } finally {
database.endTransaction() database.endTransaction()
} }
} }
fun getMessageServerHash(messageID: Long): String? { fun getMessageServerHash(messageID: Long, mms: Boolean): String? = getMessageTables(mms).firstNotNullOfOrNull {
val database = databaseHelper.readableDatabase databaseHelper.readableDatabase.get(it, "${Companion.messageID} = ?", arrayOf(messageID.toString())) { cursor ->
return database.get(messageHashTable, "${Companion.messageID} = ?", arrayOf(messageID.toString())) { cursor ->
cursor.getString(serverHash) cursor.getString(serverHash)
} }
} }
fun setMessageServerHash(messageID: Long, serverHash: String) { fun setMessageServerHash(messageID: Long, mms: Boolean, serverHash: String) {
val database = databaseHelper.writableDatabase val contentValues = ContentValues(2).apply {
val contentValues = ContentValues(2) put(Companion.messageID, messageID)
contentValues.put(Companion.messageID, messageID) put(Companion.serverHash, serverHash)
contentValues.put(Companion.serverHash, serverHash) }
database.insertOrUpdate(messageHashTable, contentValues, "${Companion.messageID} = ?", arrayOf(messageID.toString()))
databaseHelper.writableDatabase.apply {
insertOrUpdate(getMessageTable(mms), contentValues, "${Companion.messageID} = ?", arrayOf(messageID.toString()))
}
} }
fun deleteMessageServerHash(messageID: Long) { fun deleteMessageServerHash(messageID: Long, mms: Boolean) {
val database = databaseHelper.writableDatabase getMessageTables(mms).firstOrNull {
database.delete(messageHashTable, "${Companion.messageID} = ?", arrayOf(messageID.toString())) databaseHelper.writableDatabase.delete(it, "${Companion.messageID} = ?", arrayOf(messageID.toString())) > 0
}
} }
fun deleteMessageServerHashes(messageIDs: List<Long>) { fun deleteMessageServerHashes(messageIDs: List<Long>, mms: Boolean) {
val database = databaseHelper.writableDatabase databaseHelper.writableDatabase.delete(
database.delete( getMessageTable(mms),
messageHashTable, "${Companion.messageID} IN (${messageIDs.joinToString(",") { "?" }})",
"${Companion.messageID} IN (${messageIDs.map { "?" }.joinToString(",")})",
messageIDs.map { "$it" }.toTypedArray() messageIDs.map { "$it" }.toTypedArray()
) )
} }
fun migrateThreadId(legacyThreadId: Long, newThreadId: Long) { private fun getMessageTables(mms: Boolean) = sequenceOf(
val database = databaseHelper.writableDatabase getMessageTable(mms),
val contentValues = ContentValues(1) messageHashTable
contentValues.put(threadID, newThreadId) )
database.update(messageThreadMappingTable, contentValues, "$threadID = ?", arrayOf(legacyThreadId.toString()))
}
private fun getMessageTable(mms: Boolean) = if (mms) mmsHashTable else smsHashTable
} }

View File

@ -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)
}

View File

@ -14,6 +14,7 @@ import org.session.libsession.utilities.IdentityKeyMismatchList;
import org.session.libsignal.crypto.IdentityKey; import org.session.libsignal.crypto.IdentityKey;
import org.session.libsignal.utilities.JsonUtil; import org.session.libsignal.utilities.JsonUtil;
import org.session.libsignal.utilities.Log; 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.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.util.SqlUtil; import org.thoughtcrime.securesms.util.SqlUtil;
@ -33,7 +34,6 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
protected abstract String getTableName(); protected abstract String getTableName();
public abstract void markExpireStarted(long messageId);
public abstract void markExpireStarted(long messageId, long startTime); public abstract void markExpireStarted(long messageId, long startTime);
public abstract void markAsSent(long messageId, boolean secure); 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 { public static class InsertResult {
private final long messageId; private final long messageId;
private final long threadId; private final long threadId;

View File

@ -19,11 +19,13 @@ package org.thoughtcrime.securesms.database
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import android.provider.ContactsContract.CommonDataKinds.BaseTypes
import com.annimon.stream.Stream import com.annimon.stream.Stream
import com.google.android.mms.pdu_alt.PduHeaders import com.google.android.mms.pdu_alt.PduHeaders
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONException import org.json.JSONException
import org.json.JSONObject 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.IncomingMediaMessage
import org.session.libsession.messaging.messages.signal.OutgoingGroupMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingGroupMediaMessage
import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage
@ -222,6 +224,12 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
return readerFor(rawQuery(where, null))!! 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( private fun updateMailboxBitmask(
id: Long, id: Long,
maskOff: Long, maskOff: Long,
@ -296,10 +304,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
markAs(messageId, MmsSmsColumns.Types.BASE_DELETED_TYPE, threadId) markAs(messageId, MmsSmsColumns.Types.BASE_DELETED_TYPE, threadId)
} }
override fun markExpireStarted(messageId: Long) {
markExpireStarted(messageId, SnodeAPI.nowWithOffset)
}
override fun markExpireStarted(messageId: Long, startedTimestamp: Long) { override fun markExpireStarted(messageId: Long, startedTimestamp: Long) {
val contentValues = ContentValues() val contentValues = ContentValues()
contentValues.put(EXPIRE_STARTED, startedTimestamp) contentValues.put(EXPIRE_STARTED, startedTimestamp)
@ -347,13 +351,14 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
) )
while (cursor != null && cursor.moveToNext()) { while (cursor != null && cursor.moveToNext()) {
if (MmsSmsColumns.Types.isSecureType(cursor.getLong(3))) { if (MmsSmsColumns.Types.isSecureType(cursor.getLong(3))) {
val syncMessageId = val timestamp = cursor.getLong(2)
SyncMessageId(fromSerialized(cursor.getString(1)), cursor.getLong(2)) val syncMessageId = SyncMessageId(fromSerialized(cursor.getString(1)), timestamp)
val expirationInfo = ExpirationInfo( val expirationInfo = ExpirationInfo(
cursor.getLong(0), id = cursor.getLong(0),
cursor.getLong(4), timestamp = timestamp,
cursor.getLong(5), expiresIn = cursor.getLong(4),
true expireStarted = cursor.getLong(5),
isMms = true
) )
result.add(MarkedMessageInfo(syncMessageId, expirationInfo)) 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 timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(NORMALIZED_DATE_SENT))
val subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(SUBSCRIPTION_ID)) val subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(SUBSCRIPTION_ID))
val expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRES_IN)) val expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRES_IN))
val expireStartedAt = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRE_STARTED))
val address = cursor.getString(cursor.getColumnIndexOrThrow(ADDRESS)) val address = cursor.getString(cursor.getColumnIndexOrThrow(ADDRESS))
val threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID)) val threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID))
val distributionType = get(context).threadDatabase().getDistributionType(threadId) val distributionType = get(context).threadDatabase().getDistributionType(threadId)
@ -451,6 +457,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
timestamp, timestamp,
subscriptionId, subscriptionId,
expiresIn, expiresIn,
expireStartedAt,
distributionType, distributionType,
quote, quote,
contacts, contacts,
@ -550,6 +557,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
runThreadUpdate: Boolean runThreadUpdate: Boolean
): Optional<InsertResult> { ): Optional<InsertResult> {
if (threadId < 0 ) throw MmsException("No thread ID supplied!") if (threadId < 0 ) throw MmsException("No thread ID supplied!")
if (retrieved.isExpirationUpdate) deleteExpirationTimerMessages(threadId, false.takeUnless { retrieved.groupId != null })
val contentValues = ContentValues() val contentValues = ContentValues()
contentValues.put(DATE_SENT, retrieved.sentTimeMillis) contentValues.put(DATE_SENT, retrieved.sentTimeMillis)
contentValues.put(ADDRESS, retrieved.from.serialize()) 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(PART_COUNT, retrieved.attachments.size)
contentValues.put(SUBSCRIPTION_ID, retrieved.subscriptionId) contentValues.put(SUBSCRIPTION_ID, retrieved.subscriptionId)
contentValues.put(EXPIRES_IN, retrieved.expiresIn) 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(UNIDENTIFIED, retrieved.isUnidentified)
contentValues.put(HAS_MENTION, retrieved.hasMention()) contentValues.put(HAS_MENTION, retrieved.hasMention())
contentValues.put(MESSAGE_REQUEST_RESPONSE, retrieved.isMessageRequestResponse) contentValues.put(MESSAGE_REQUEST_RESPONSE, retrieved.isMessageRequestResponse)
@ -619,6 +627,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
runThreadUpdate: Boolean runThreadUpdate: Boolean
): Optional<InsertResult> { ): Optional<InsertResult> {
if (threadId < 0 ) throw MmsException("No thread ID supplied!") 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) val messageId = insertMessageOutbox(retrieved, threadId, false, null, serverTimestamp, runThreadUpdate)
if (messageId == -1L) { if (messageId == -1L) {
return Optional.absent() return Optional.absent()
@ -689,6 +698,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
contentValues.put(DATE_RECEIVED, receivedTimestamp) contentValues.put(DATE_RECEIVED, receivedTimestamp)
contentValues.put(SUBSCRIPTION_ID, message.subscriptionId) contentValues.put(SUBSCRIPTION_ID, message.subscriptionId)
contentValues.put(EXPIRES_IN, message.expiresIn) contentValues.put(EXPIRES_IN, message.expiresIn)
contentValues.put(EXPIRE_STARTED, message.expireStartedAt)
contentValues.put(ADDRESS, message.recipient.address.serialize()) contentValues.put(ADDRESS, message.recipient.address.serialize())
contentValues.put( contentValues.put(
DELIVERY_RECEIPT_COUNT, 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 { object Status {
const val DOWNLOAD_INITIALIZED = 1 const val DOWNLOAD_INITIALIZED = 1
const val DOWNLOAD_NO_CONNECTIVITY = 2 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 SHARED_CONTACTS: String = "shared_contacts"
const val LINK_PREVIEWS: String = "previews" const val LINK_PREVIEWS: String = "previews"
const val CREATE_TABLE: String = 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, " + THREAD_ID + " INTEGER, " + DATE_SENT + " INTEGER, " + DATE_RECEIVED + " INTEGER, " + MESSAGE_BOX + " INTEGER, " +
READ + " INTEGER DEFAULT 0, " + "m_id" + " TEXT, " + "sub" + " TEXT, " + READ + " INTEGER DEFAULT 0, " + "m_id" + " TEXT, " + "sub" + " TEXT, " +
"sub_cs" + " INTEGER, " + BODY + " TEXT, " + PART_COUNT + " INTEGER, " + "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_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_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;" 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"
)
} }
} }

View File

@ -46,7 +46,8 @@ public class RecipientDatabase extends Database {
private static final String COLOR = "color"; private static final String COLOR = "color";
private static final String SEEN_INVITE_REMINDER = "seen_invite_reminder"; private static final String SEEN_INVITE_REMINDER = "seen_invite_reminder";
private static final String DEFAULT_SUBSCRIPTION_ID = "default_subscription_id"; 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 REGISTERED = "registered";
private static final String PROFILE_KEY = "profile_key"; private static final String PROFILE_KEY = "profile_key";
private static final String SYSTEM_DISPLAY_NAME = "system_display_name"; 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, PROFILE_KEY, SYSTEM_DISPLAY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_CONTACT_URI,
SIGNAL_PROFILE_NAME, SIGNAL_PROFILE_AVATAR, PROFILE_SHARING, NOTIFICATION_CHANNEL, SIGNAL_PROFILE_NAME, SIGNAL_PROFILE_AVATAR, PROFILE_SHARING, NOTIFICATION_CHANNEL,
UNIDENTIFIED_ACCESS_MODE, 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<String> TYPED_RECIPIENT_PROJECTION = Stream.of(RECIPIENT_PROJECTION) static final List<String> 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+")))"; "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() { public static String getAddWrapperHash() {
return "ALTER TABLE "+TABLE_NAME+" "+ return "ALTER TABLE "+TABLE_NAME+" "+
"ADD COLUMN "+WRAPPER_HASH+" TEXT DEFAULT NULL;"; "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; boolean approvedMe = cursor.getInt(cursor.getColumnIndexOrThrow(APPROVED_ME)) == 1;
String messageRingtone = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION)); String messageRingtone = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION));
String callRingtone = cursor.getString(cursor.getColumnIndexOrThrow(CALL_RINGTONE)); String callRingtone = cursor.getString(cursor.getColumnIndexOrThrow(CALL_RINGTONE));
int disappearingState = cursor.getInt(cursor.getColumnIndexOrThrow(DISAPPEARING_STATE));
int messageVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(VIBRATE)); int messageVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(VIBRATE));
int callVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(CALL_VIBRATE)); int callVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(CALL_VIBRATE));
long muteUntil = cursor.getLong(cursor.getColumnIndexOrThrow(MUTE_UNTIL)); 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, return Optional.of(new RecipientSettings(blocked, approved, approvedMe, muteUntil,
notifyType, notifyType,
Recipient.DisappearingState.fromId(disappearingState),
Recipient.VibrateState.fromId(messageVibrateState), Recipient.VibrateState.fromId(messageVibrateState),
Recipient.VibrateState.fromId(callVibrateState), Recipient.VibrateState.fromId(callVibrateState),
Util.uri(messageRingtone), Util.uri(callRingtone), Util.uri(messageRingtone), Util.uri(callRingtone),
@ -335,16 +343,6 @@ public class RecipientDatabase extends Database {
notifyRecipientListeners(); 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) { public void setUnidentifiedAccessMode(@NonNull Recipient recipient, @NonNull UnidentifiedAccessMode unidentifiedAccessMode) {
ContentValues values = new ContentValues(1); ContentValues values = new ContentValues(1);
values.put(UNIDENTIFIED_ACCESS_MODE, unidentifiedAccessMode.getMode()); values.put(UNIDENTIFIED_ACCESS_MODE, unidentifiedAccessMode.getMode());
@ -443,6 +441,14 @@ public class RecipientDatabase extends Database {
return returnList; 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 { public static class RecipientReader implements Closeable {
private final Context context; private final Context context;

View File

@ -52,6 +52,7 @@ import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.database.model.SmsMessageRecord; import org.thoughtcrime.securesms.database.model.SmsMessageRecord;
import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
import java.io.Closeable;
import java.io.IOException; import java.io.IOException;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.Arrays; 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, " + EXPIRES_IN + " INTEGER DEFAULT 0, " + EXPIRE_STARTED + " INTEGER DEFAULT 0, " + NOTIFIED + " DEFAULT 0, " +
READ_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + UNIDENTIFIED + " INTEGER DEFAULT 0);"; READ_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + UNIDENTIFIED + " INTEGER DEFAULT 0);";
public static final String[] CREATE_INDEXS = { 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_thread_id_index ON " + TABLE_NAME + " (" + THREAD_ID + ");",
"CREATE INDEX IF NOT EXISTS sms_read_index ON " + TABLE_NAME + " (" + READ + ");", "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 + " " + public static String CREATE_HAS_MENTION_COMMAND = "ALTER TABLE "+ TABLE_NAME + " " +
"ADD COLUMN " + HAS_MENTION + " INTEGER DEFAULT 0;"; "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 earlyDeliveryReceiptCache = new EarlyReceiptCache();
private static final EarlyReceiptCache earlyReadReceiptCache = 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); updateTypeBitmask(messageId, Types.BASE_TYPE_MASK, Types.BASE_DELETED_TYPE);
} }
@Override
public void markExpireStarted(long id) {
markExpireStarted(id, SnodeAPI.getNowWithOffset());
}
@Override @Override
public void markExpireStarted(long id, long startedAtTimestamp) { public void markExpireStarted(long id, long startedAtTimestamp) {
ContentValues contentValues = new ContentValues(); 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); 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()) { while (cursor != null && cursor.moveToNext()) {
if (Types.isSecureType(cursor.getLong(3))) { long timestamp = cursor.getLong(2);
SyncMessageId syncMessageId = new SyncMessageId(Address.fromSerialized(cursor.getString(1)), cursor.getLong(2)); SyncMessageId syncMessageId = new SyncMessageId(Address.fromSerialized(cursor.getString(1)), timestamp);
ExpirationInfo expirationInfo = new ExpirationInfo(cursor.getLong(0), cursor.getLong(4), cursor.getLong(5), false); 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(); ContentValues contentValues = new ContentValues();
@ -407,6 +415,24 @@ public class SmsDatabase extends MessagingDatabase {
} }
protected Optional<InsertResult> insertMessageInbox(IncomingTextMessage message, long type, long serverTimestamp, boolean runThreadUpdate) { protected Optional<InsertResult> 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()) { if (message.isSecureMessage()) {
type |= Types.SECURE_MESSAGE_BIT; type |= Types.SECURE_MESSAGE_BIT;
} else if (message.isGroup()) { } else if (message.isGroup()) {
@ -420,40 +446,9 @@ public class SmsDatabase extends MessagingDatabase {
CallMessageType callMessageType = message.getCallType(); CallMessageType callMessageType = message.getCallType();
if (callMessageType != null) { if (callMessageType != null) {
switch (callMessageType) { type |= getCallMessageTypeMask(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;
}
} }
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); ContentValues values = new ContentValues(6);
values.put(ADDRESS, message.getSender().serialize()); values.put(ADDRESS, message.getSender().serialize());
values.put(ADDRESS_DEVICE_ID, message.getSenderDeviceId()); values.put(ADDRESS_DEVICE_ID, message.getSenderDeviceId());
@ -466,6 +461,7 @@ public class SmsDatabase extends MessagingDatabase {
values.put(READ, unread ? 0 : 1); values.put(READ, unread ? 0 : 1);
values.put(SUBSCRIPTION_ID, message.getSubscriptionId()); values.put(SUBSCRIPTION_ID, message.getSubscriptionId());
values.put(EXPIRES_IN, message.getExpiresIn()); values.put(EXPIRES_IN, message.getExpiresIn());
values.put(EXPIRE_STARTED, message.getExpireStartedAt());
values.put(UNIDENTIFIED, message.isUnidentified()); values.put(UNIDENTIFIED, message.isUnidentified());
values.put(HAS_MENTION, message.hasMention()); 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<InsertResult> insertMessageInbox(IncomingTextMessage message, boolean runThreadUpdate) { public Optional<InsertResult> insertMessageInbox(IncomingTextMessage message, boolean runThreadUpdate) {
return insertMessageInbox(message, Types.BASE_INBOX_TYPE, 0, 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(TYPE, type);
contentValues.put(SUBSCRIPTION_ID, message.getSubscriptionId()); contentValues.put(SUBSCRIPTION_ID, message.getSubscriptionId());
contentValues.put(EXPIRES_IN, message.getExpiresIn()); 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(DELIVERY_RECEIPT_COUNT, Stream.of(earlyDeliveryReceipts.values()).mapToLong(Long::longValue).sum());
contentValues.put(READ_RECEIPT_COUNT, Stream.of(earlyReadReceipts.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); 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 { public SmsMessageRecord getMessage(long messageId) throws NoSuchMessageException {
Cursor cursor = rawQuery(ID_WHERE, new String[]{messageId + ""}); Cursor cursor = rawQuery(ID_WHERE, new String[]{messageId + ""});
Reader reader = new Reader(cursor); Reader reader = new Reader(cursor);
@ -615,7 +632,6 @@ public class SmsDatabase extends MessagingDatabase {
long threadId = getThreadIdForMessage(messageId); long threadId = getThreadIdForMessage(messageId);
db.delete(TABLE_NAME, ID_WHERE, new String[] {messageId+""}); db.delete(TABLE_NAME, ID_WHERE, new String[] {messageId+""});
boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false, true); boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false, true);
notifyConversationListeners(threadId);
return threadDeleted; return threadDeleted;
} }
@ -787,7 +803,7 @@ public class SmsDatabase extends MessagingDatabase {
} }
} }
public class Reader { public class Reader implements Closeable {
private final Cursor cursor; private final Cursor cursor;
@ -853,8 +869,11 @@ public class SmsDatabase extends MessagingDatabase {
return new LinkedList<>(); return new LinkedList<>();
} }
@Override
public void close() { public void close() {
cursor.close(); if (cursor != null) {
cursor.close();
}
} }
} }

View File

@ -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.ExpiryMode
import network.loki.messenger.libsession_util.util.GroupInfo import network.loki.messenger.libsession_util.util.GroupInfo
import network.loki.messenger.libsession_util.util.UserPic 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.avatars.AvatarHelper
import org.session.libsession.database.StorageProtocol import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.BlindedIdMapping 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.MessageSendJob
import org.session.libsession.messaging.jobs.RetrieveProfileAvatarJob import org.session.libsession.messaging.jobs.RetrieveProfileAvatarJob
import org.session.libsession.messaging.messages.Destination 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.Message
import org.session.libsession.messaging.messages.control.ConfigurationMessage import org.session.libsession.messaging.messages.control.ConfigurationMessage
import org.session.libsession.messaging.messages.control.MessageRequestResponse 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.SSKEnvironment
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.recipients.Recipient 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.DjbECPrivateKey
import org.session.libsignal.crypto.ecc.DjbECPublicKey import org.session.libsignal.crypto.ecc.DjbECPublicKey
import org.session.libsignal.crypto.ecc.ECKeyPair 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.ConfigurationMessageUtilities
import org.thoughtcrime.securesms.util.SessionMetaProtocol import org.thoughtcrime.securesms.util.SessionMetaProtocol
import java.security.MessageDigest import java.security.MessageDigest
import kotlin.time.Duration.Companion.days
import network.loki.messenger.libsession_util.util.Contact as LibSessionContact 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, private const val TAG = "Storage"
ThreadDatabase.ConversationThreadUpdateListener {
open class Storage(
context: Context,
helper: SQLCipherOpenHelper,
private val configFactory: ConfigFactory
) : Database(context, helper), StorageProtocol, ThreadDatabase.ConversationThreadUpdateListener {
override fun threadCreated(address: Address, threadId: Long) { override fun threadCreated(address: Address, threadId: Long) {
val localUserAddress = getUserPublicKey() ?: return 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 // open group recipients should explicitly create threads
message.threadID = getOrCreateThreadIdFor(targetAddress) 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()) { if (message.isMediaMessage() || attachments.isNotEmpty()) {
val quote: Optional<QuoteModel> = if (quotes != null) Optional.of(quotes) else Optional.absent() val quote: Optional<QuoteModel> = if (quotes != null) Optional.of(quotes) else Optional.absent()
val linkPreviews: Optional<List<LinkPreview>> = if (linkPreview.isEmpty()) Optional.absent() else Optional.of(linkPreview.mapNotNull { it!! }) val linkPreviews: Optional<List<LinkPreview>> = if (linkPreview.isEmpty()) Optional.absent() else Optional.of(linkPreview.mapNotNull { it!! })
val mmsDatabase = DatabaseComponent.get(context).mmsDatabase() val mmsDatabase = DatabaseComponent.get(context).mmsDatabase()
val insertResult = if (isUserSender || isUserBlindedSender) { 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) mmsDatabase.insertSecureDecryptedMessageOutbox(mediaMessage, message.threadID ?: -1, message.sentTimestamp!!, runThreadUpdate)
} else { } else {
// It seems like we have replaced SignalServiceAttachment with SessionServiceAttachment // It seems like we have replaced SignalServiceAttachment with SessionServiceAttachment
val signalServiceAttachments = attachments.mapNotNull { val signalServiceAttachments = attachments.mapNotNull {
it.toSignalPointer() 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) mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, message.threadID!!, message.receivedTimestamp ?: 0, runThreadUpdate)
} }
if (insertResult.isPresent) { if (insertResult.isPresent) {
@ -345,12 +365,12 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
val isOpenGroupInvitation = (message.openGroupInvitation != null) val isOpenGroupInvitation = (message.openGroupInvitation != null)
val insertResult = if (isUserSender || isUserBlindedSender) { val insertResult = if (isUserSender || isUserBlindedSender) {
val textMessage = if (isOpenGroupInvitation) OutgoingTextMessage.fromOpenGroupInvitation(message.openGroupInvitation, targetRecipient, message.sentTimestamp) val textMessage = if (isOpenGroupInvitation) OutgoingTextMessage.fromOpenGroupInvitation(message.openGroupInvitation, targetRecipient, message.sentTimestamp, expiresInMillis, expireStartedAt)
else OutgoingTextMessage.from(message, targetRecipient) else OutgoingTextMessage.from(message, targetRecipient, expiresInMillis, expireStartedAt)
smsDatabase.insertMessageOutbox(message.threadID ?: -1, textMessage, message.sentTimestamp!!, runThreadUpdate) smsDatabase.insertMessageOutbox(message.threadID ?: -1, textMessage, message.sentTimestamp!!, runThreadUpdate)
} else { } else {
val textMessage = if (isOpenGroupInvitation) IncomingTextMessage.fromOpenGroupInvitation(message.openGroupInvitation, senderAddress, message.sentTimestamp) val textMessage = if (isOpenGroupInvitation) IncomingTextMessage.fromOpenGroupInvitation(message.openGroupInvitation, senderAddress, message.sentTimestamp, expiresInMillis, expireStartedAt)
else IncomingTextMessage.from(message, senderAddress, group, targetRecipient.expireMessages * 1000L) else IncomingTextMessage.from(message, senderAddress, group, expiresInMillis, expireStartedAt)
val encrypted = IncomingEncryptedMessage(textMessage, textMessage.messageBody) val encrypted = IncomingEncryptedMessage(textMessage, textMessage.messageBody)
smsDatabase.insertMessageInbox(encrypted, message.receivedTimestamp ?: 0, runThreadUpdate) 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 -> message.serverHash?.let { serverHash ->
messageID?.let { id -> messageID?.let { id ->
DatabaseComponent.get(context).lokiMessageDatabase().setMessageServerHash(id, serverHash) DatabaseComponent.get(context).lokiMessageDatabase().setMessageServerHash(id, message.isMediaMessage(), serverHash)
} }
} }
return messageID return messageID
@ -423,8 +443,8 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
return DatabaseComponent.get(context).lokiAPIDatabase().getAuthToken(id) return DatabaseComponent.get(context).lokiAPIDatabase().getAuthToken(id)
} }
override fun notifyConfigUpdates(forConfigObject: ConfigBase) { override fun notifyConfigUpdates(forConfigObject: ConfigBase, messageTimestamp: Long) {
notifyUpdates(forConfigObject) notifyUpdates(forConfigObject, messageTimestamp)
} }
override fun conversationInConfig(publicKey: String?, groupPublicKey: String?, openGroupId: String?, visibleOnly: Boolean): Boolean { 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 return configFactory.user?.getCommunityMessageRequests() == true
} }
fun notifyUpdates(forConfigObject: ConfigBase) { private fun notifyUpdates(forConfigObject: ConfigBase, messageTimestamp: Long) {
when (forConfigObject) { when (forConfigObject) {
is UserProfile -> updateUser(forConfigObject) is UserProfile -> updateUser(forConfigObject, messageTimestamp)
is Contacts -> updateContacts(forConfigObject) is Contacts -> updateContacts(forConfigObject, messageTimestamp)
is ConversationVolatileConfig -> updateConvoVolatile(forConfigObject) is ConversationVolatileConfig -> updateConvoVolatile(forConfigObject, messageTimestamp)
is UserGroupsConfig -> updateUserGroups(forConfigObject) is UserGroupsConfig -> updateUserGroups(forConfigObject, messageTimestamp)
} }
} }
private fun updateUser(userProfile: UserProfile) { private fun updateUser(userProfile: UserProfile, messageTimestamp: Long) {
val userPublicKey = getUserPublicKey() ?: return val userPublicKey = getUserPublicKey() ?: return
// would love to get rid of recipient and context from this // would love to get rid of recipient and context from this
val recipient = Recipient.from(context, fromSerialized(userPublicKey), false) val recipient = Recipient.from(context, fromSerialized(userPublicKey), false)
@ -474,16 +494,25 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
deleteConversation(ourThread) deleteConversation(ourThread)
} else { } else {
// create note to self thread if needed (?) // 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) DatabaseComponent.get(context).threadDatabase().setHasSent(ourThread, true)
setPinned(ourThread, userProfile.getNtsPriority() > 0) 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() val extracted = contacts.all().toList()
addLibSessionContacts(extracted) addLibSessionContacts(extracted, messageTimestamp)
} }
override fun clearUserPic() { override fun clearUserPic() {
@ -503,7 +532,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
} }
private fun updateConvoVolatile(convos: ConversationVolatileConfig) { private fun updateConvoVolatile(convos: ConversationVolatileConfig, messageTimestamp: Long) {
val extracted = convos.all() val extracted = convos.all()
for (conversation in extracted) { for (conversation in extracted) {
val threadId = when (conversation) { 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 threadDb = DatabaseComponent.get(context).threadDatabase()
val localUserPublicKey = getUserPublicKey() ?: return Log.w( val localUserPublicKey = getUserPublicKey() ?: return Log.w(
"Loki", "Loki",
@ -572,6 +601,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
} }
for (group in lgc) { for (group in lgc) {
val groupId = GroupUtil.doubleEncodeGroupID(group.sessionId)
val existingGroup = existingClosedGroups.firstOrNull { GroupUtil.doubleDecodeGroupId(it.encodedId) == group.sessionId } val existingGroup = existingClosedGroups.firstOrNull { GroupUtil.doubleDecodeGroupId(it.encodedId) == group.sessionId }
val existingThread = existingGroup?.let { getThreadId(existingGroup.encodedId) } val existingThread = existingGroup?.let { getThreadId(existingGroup.encodedId) }
if (existingGroup != null) { if (existingGroup != null) {
@ -586,7 +616,6 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
} else { } else {
val members = group.members.keys.map { Address.fromSerialized(it) } val members = group.members.keys.map { Address.fromSerialized(it) }
val admins = group.members.filter { it.value /*admin = true*/ }.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 title = group.name
val formationTimestamp = (group.joinedAt * 1000L) val formationTimestamp = (group.joinedAt * 1000L)
createGroup(groupId, title, admins + members, null, null, admins, formationTimestamp) 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 // Store the encryption key pair
val keyPair = ECKeyPair(DjbECPublicKey(group.encPubKey), DjbECPrivateKey(group.encSecKey)) val keyPair = ECKeyPair(DjbECPublicKey(group.encPubKey), DjbECPrivateKey(group.encSecKey))
addClosedGroupEncryptionKeyPair(keyPair, group.sessionId, SnodeAPI.nowWithOffset) addClosedGroupEncryptionKeyPair(keyPair, group.sessionId, SnodeAPI.nowWithOffset)
// Set expiration timer
val expireTimer = group.disappearingTimer
setExpirationTimer(groupId, expireTimer.toInt())
// Notify the PN server // Notify the PN server
PushRegistryV1.subscribeGroup(group.sessionId, publicKey = localUserPublicKey) PushRegistryV1.subscribeGroup(group.sessionId, publicKey = localUserPublicKey)
// Notify the user // Notify the user
@ -609,6 +635,12 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
// Start polling // Start polling
ClosedGroupPollerV2.shared.startPolling(group.sessionId) 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) SessionMetaProtocol.removeTimestamps(timestamps)
} }
override fun getMessageIdInDatabase(timestamp: Long, author: String): Long? { override fun getMessageIdInDatabase(timestamp: Long, author: String): Pair<Long, Boolean>? {
val database = DatabaseComponent.get(context).mmsSmsDatabase() val database = DatabaseComponent.get(context).mmsSmsDatabase()
val address = fromSerialized(author) val address = fromSerialized(author)
return database.getMessageFor(timestamp, address)?.getId() return database.getMessageFor(timestamp, address)?.run { getId() to isMms }
} }
override fun updateSentTimestamp( override fun updateSentTimestamp(
@ -834,8 +866,8 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
db.clearErrorMessage(messageID) db.clearErrorMessage(messageID)
} }
override fun setMessageServerHash(messageID: Long, serverHash: String) { override fun setMessageServerHash(messageID: Long, mms: Boolean, serverHash: String) {
DatabaseComponent.get(context).lokiMessageDatabase().setMessageServerHash(messageID, serverHash) DatabaseComponent.get(context).lokiMessageDatabase().setMessageServerHash(messageID, mms, serverHash)
} }
override fun getGroup(groupID: String): GroupRecord? { 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) DatabaseComponent.get(context).groupDatabase().create(groupId, title, members, avatar, relay, admins, formationTimestamp)
} }
override fun createInitialConfigGroup(groupPublicKey: String, name: String, members: Map<String, Boolean>, formationTimestamp: Long, encryptionKeyPair: ECKeyPair) { override fun createInitialConfigGroup(groupPublicKey: String, name: String, members: Map<String, Boolean>, formationTimestamp: Long, encryptionKeyPair: ECKeyPair, expirationTimer: Int) {
val volatiles = configFactory.convoVolatile ?: return val volatiles = configFactory.convoVolatile ?: return
val userGroups = configFactory.userGroups ?: return val userGroups = configFactory.userGroups ?: return
if (volatiles.getLegacyClosedGroup(groupPublicKey) != null && userGroups.getLegacyGroupInfo(groupPublicKey) != null) return
val groupVolatileConfig = volatiles.getOrConstructLegacyGroup(groupPublicKey) val groupVolatileConfig = volatiles.getOrConstructLegacyGroup(groupPublicKey)
groupVolatileConfig.lastRead = formationTimestamp groupVolatileConfig.lastRead = formationTimestamp
volatiles.set(groupVolatileConfig) volatiles.set(groupVolatileConfig)
@ -860,7 +893,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
priority = ConfigBase.PRIORITY_VISIBLE, priority = ConfigBase.PRIORITY_VISIBLE,
encPubKey = (encryptionKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte encPubKey = (encryptionKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte
encSecKey = encryptionKeyPair.privateKey.serialize(), encSecKey = encryptionKeyPair.privateKey.serialize(),
disappearingTimer = 0L, disappearingTimer = expirationTimer.toLong(),
joinedAt = (formationTimestamp / 1000L) joinedAt = (formationTimestamp / 1000L)
) )
// shouldn't exist, don't use getOrConstruct + copy // 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) { override fun updateGroupConfig(groupPublicKey: String) {
val groupID = GroupUtil.doubleEncodeGroupID(groupPublicKey) val groupID = GroupUtil.doubleEncodeGroupID(groupPublicKey)
val groupAddress = fromSerialized(groupID) 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) val existingGroup = getGroup(groupID)
?: return Log.w("Loki-DBG", "No existing group for ${groupPublicKey.take(4)}} when updating group config") ?: return Log.w("Loki-DBG", "No existing group for ${groupPublicKey.take(4)}} when updating group config")
val userGroups = configFactory.userGroups ?: return 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 membersMap = GroupUtil.createConfigMemberMap(admins = admins, members = members)
val latestKeyPair = getLatestClosedGroupEncryptionKeyPair(groupPublicKey) val latestKeyPair = getLatestClosedGroupEncryptionKeyPair(groupPublicKey)
?: return Log.w("Loki-DBG", "No latest closed group encryption key pair for ${groupPublicKey.take(4)}} when updating group config") ?: 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 threadID = getThreadId(groupAddress) ?: return
val groupInfo = userGroups.getOrConstructLegacyGroupInfo(groupPublicKey).copy( val groupInfo = userGroups.getOrConstructLegacyGroupInfo(groupPublicKey).copy(
name = name, 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 encPubKey = (latestKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte
encSecKey = latestKeyPair.privateKey.serialize(), encSecKey = latestKeyPair.privateKey.serialize(),
priority = if (isPinned(threadID)) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE, priority = if (isPinned(threadID)) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE,
disappearingTimer = recipientSettings.expireMessages.toLong(), disappearingTimer = getExpirationConfiguration(threadID)?.expiryMode?.expirySeconds ?: 0L,
joinedAt = (existingGroup.formationTimestamp / 1000L) joinedAt = (existingGroup.formationTimestamp / 1000L)
) )
userGroups.set(groupInfo) 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<String>, admins: Collection<String>, sentTimestamp: Long) { override fun insertIncomingInfoMessage(context: Context, senderPublicKey: String, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection<String>, admins: Collection<String>, sentTimestamp: Long) {
val group = SignalServiceGroup(type, GroupUtil.getDecodedGroupIDAsData(groupID), SignalServiceGroup.GroupType.SIGNAL, name, members.toList(), null, admins.toList()) 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 updateData = UpdateMessageData.buildGroupUpdate(type, name, members)?.toJSON()
val infoMessage = IncomingGroupMessage(m, groupID, updateData, true) val infoMessage = IncomingGroupMessage(m, groupID, updateData, true)
val smsDB = DatabaseComponent.get(context).smsDatabase() 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<String>, admins: Collection<String>, threadID: Long, sentTimestamp: Long) { override fun insertOutgoingInfoMessage(context: Context, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection<String>, admins: Collection<String>, threadID: Long, sentTimestamp: Long) {
val userPublicKey = getUserPublicKey() val userPublicKey = getUserPublicKey()!!
val recipient = Recipient.from(context, fromSerialized(groupID), false) val recipient = Recipient.from(context, fromSerialized(groupID), false)
val updateData = UpdateMessageData.buildGroupUpdate(type, name, members)?.toJSON() ?: "" 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 mmsDB = DatabaseComponent.get(context).mmsDatabase()
val mmsSmsDB = DatabaseComponent.get(context).mmsSmsDatabase() val mmsSmsDB = DatabaseComponent.get(context).mmsSmsDatabase()
if (mmsSmsDB.getMessageFor(sentTimestamp, userPublicKey) != null) return if (mmsSmsDB.getMessageFor(sentTimestamp, userPublicKey) != null) return
@ -996,23 +1026,6 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
.updateTimestampUpdated(groupID, updatedTimestamp) .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<String>) { override fun setServerCapabilities(server: String, capabilities: List<String>) {
return DatabaseComponent.get(context).lokiAPIDatabase().setServerCapabilities(server, capabilities) 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? { override fun getRecipientSettings(address: Address): Recipient.RecipientSettings? {
val recipientSettings = DatabaseComponent.get(context).recipientDatabase().getRecipientSettings(address) return DatabaseComponent.get(context).recipientDatabase().getRecipientSettings(address).orNull()
return if (recipientSettings.isPresent) { recipientSettings.get() } else null
} }
override fun addLibSessionContacts(contacts: List<LibSessionContact>) { override fun addLibSessionContacts(contacts: List<LibSessionContact>, timestamp: Long) {
val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase() val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase()
val moreContacts = contacts.filter { contact -> val moreContacts = contacts.filter { contact ->
val id = SessionId(contact.id) 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) profileManager.setProfilePicture(context, recipient, null, null)
} }
if (contact.priority == PRIORITY_HIDDEN) { if (contact.priority == PRIORITY_HIDDEN) {
getThreadId(fromSerialized(contact.id))?.let { conversationThreadId -> getThreadId(fromSerialized(contact.id))?.let(::deleteConversation)
deleteConversation(conversationThreadId)
}
} else { } 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()) setRecipientHash(recipient, contact.hashCode().toString())
} }
@ -1293,20 +1311,26 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
threadDb.setDate(threadId, newDate) 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) { override fun deleteConversation(threadID: Long) {
val recipient = getRecipientForThread(threadID)
val threadDB = DatabaseComponent.get(context).threadDatabase() val threadDB = DatabaseComponent.get(context).threadDatabase()
val groupDB = DatabaseComponent.get(context).groupDatabase() val groupDB = DatabaseComponent.get(context).groupDatabase()
threadDB.deleteConversation(threadID) threadDB.deleteConversation(threadID)
if (recipient != null) { val recipient = getRecipientForThread(threadID) ?: return
if (recipient.isContactRecipient) { when {
recipient.isContactRecipient -> {
if (recipient.isLocalNumber) return if (recipient.isLocalNumber) return
val contacts = configFactory.contacts ?: return val contacts = configFactory.contacts ?: return
contacts.upsertContact(recipient.address.serialize()) { contacts.upsertContact(recipient.address.serialize()) { priority = PRIORITY_HIDDEN }
this.priority = PRIORITY_HIDDEN
}
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
} else if (recipient.isClosedGroupRecipient) { }
recipient.isClosedGroupRecipient -> {
// TODO: handle closed group // TODO: handle closed group
val volatile = configFactory.convoVolatile ?: return val volatile = configFactory.convoVolatile ?: return
val groups = configFactory.userGroups ?: 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) val recipient = Recipient.from(context, address, false)
if (recipient.isBlocked) return if (recipient.isBlocked) return
val threadId = getThreadId(recipient) ?: 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( val mediaMessage = IncomingMediaMessage(
address, address,
sentTimestamp, sentTimestamp,
-1, -1,
0, expiresInMillis,
expireStartedAt,
false, false,
false, false,
false, false,
@ -1360,6 +1387,8 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
) )
database.insertSecureDecryptedMessageInbox(mediaMessage, threadId, runThreadUpdate = true) database.insertSecureDecryptedMessageInbox(mediaMessage, threadId, runThreadUpdate = true)
SSKEnvironment.shared.messageExpirationManager.maybeStartExpiration(sentTimestamp, senderPublicKey, expiryMode)
} }
override fun insertMessageRequestResponse(response: MessageRequestResponse) { override fun insertMessageRequestResponse(response: MessageRequestResponse) {
@ -1440,12 +1469,12 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
} }
recipientDb.setApproved(sender, true) recipientDb.setApproved(sender, true)
recipientDb.setApprovedMe(sender, true) recipientDb.setApprovedMe(sender, true)
val message = IncomingMediaMessage( val message = IncomingMediaMessage(
sender.address, sender.address,
response.sentTimestamp!!, response.sentTimestamp!!,
-1, -1,
0, 0,
0,
false, false,
false, false,
true, true,
@ -1485,8 +1514,15 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
override fun insertCallMessage(senderPublicKey: String, callMessageType: CallMessageType, sentTimestamp: Long) { override fun insertCallMessage(senderPublicKey: String, callMessageType: CallMessageType, sentTimestamp: Long) {
val database = DatabaseComponent.get(context).smsDatabase() val database = DatabaseComponent.get(context).smsDatabase()
val address = fromSerialized(senderPublicKey) 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) database.insertCallMessage(callMessage)
SSKEnvironment.shared.messageExpirationManager.maybeStartExpiration(sentTimestamp, senderPublicKey, expiryMode)
} }
override fun conversationHasOutgoing(userPublicKey: String): Boolean { 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() val recipientDb = DatabaseComponent.get(context).recipientDatabase()
return recipientDb.blockedContacts 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<Long>): List<Pair<Long, Long>> {
val expiringMessages = mutableListOf<Pair<Long, Long>>()
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)
}
}
} }

View File

@ -51,7 +51,6 @@ import org.session.libsignal.utilities.Pair;
import org.session.libsignal.utilities.guava.Optional; import org.session.libsignal.utilities.guava.Optional;
import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.contacts.ContactUtil; import org.thoughtcrime.securesms.contacts.ContactUtil;
import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord;
@ -816,13 +815,7 @@ public class ThreadDatabase extends Database {
MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase(); MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase();
if (mmsSmsDatabase.getConversationCount(threadId) <= 0 && !force) return false; if (mmsSmsDatabase.getConversationCount(threadId) <= 0 && !force) return false;
List<MarkedMessageInfo> messages = setRead(threadId, lastSeenTime); List<MarkedMessageInfo> messages = setRead(threadId, lastSeenTime);
if (isGroupRecipient) { MarkReadReceiver.process(context, messages);
for (MarkedMessageInfo message: messages) {
MarkReadReceiver.scheduleDeletion(context, message.getExpirationInfo());
}
} else {
MarkReadReceiver.process(context, messages);
}
ApplicationContext.getInstance(context).messageNotifier.updateNotification(context, threadId); ApplicationContext.getInstance(context).messageNotifier.updateNotification(context, threadId);
return setLastSeen(threadId, lastSeenTime); return setLastSeen(threadId, lastSeenTime);
} }

View File

@ -21,6 +21,7 @@ import org.thoughtcrime.securesms.database.BlindedIdMappingDatabase;
import org.thoughtcrime.securesms.database.ConfigDatabase; import org.thoughtcrime.securesms.database.ConfigDatabase;
import org.thoughtcrime.securesms.database.DraftDatabase; import org.thoughtcrime.securesms.database.DraftDatabase;
import org.thoughtcrime.securesms.database.EmojiSearchDatabase; import org.thoughtcrime.securesms.database.EmojiSearchDatabase;
import org.thoughtcrime.securesms.database.ExpirationConfigurationDatabase;
import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.GroupMemberDatabase; import org.thoughtcrime.securesms.database.GroupMemberDatabase;
import org.thoughtcrime.securesms.database.GroupReceiptDatabase; 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 lokiV41 = 62;
private static final int lokiV42 = 63; private static final int lokiV42 = 63;
private static final int lokiV43 = 64; private static final int lokiV43 = 64;
private static final int lokiV44 = 65; 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 // 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 int MIN_DATABASE_VERSION = lokiV7;
private static final String CIPHER3_DATABASE_NAME = "signal.db"; private static final String CIPHER3_DATABASE_NAME = "signal.db";
public static final String DATABASE_NAME = "signal_v4.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.getCreateMessageToThreadMappingTableCommand());
db.execSQL(LokiMessageDatabase.getCreateErrorMessageTableCommand()); db.execSQL(LokiMessageDatabase.getCreateErrorMessageTableCommand());
db.execSQL(LokiMessageDatabase.getCreateMessageHashTableCommand()); db.execSQL(LokiMessageDatabase.getCreateMessageHashTableCommand());
db.execSQL(LokiMessageDatabase.getCreateSmsHashTableCommand());
db.execSQL(LokiMessageDatabase.getCreateMmsHashTableCommand());
db.execSQL(LokiThreadDatabase.getCreateSessionResetTableCommand()); db.execSQL(LokiThreadDatabase.getCreateSessionResetTableCommand());
db.execSQL(LokiThreadDatabase.getCreatePublicChatTableCommand()); db.execSQL(LokiThreadDatabase.getCreatePublicChatTableCommand());
db.execSQL(LokiUserDatabase.getCreateDisplayNameTableCommand()); db.execSQL(LokiUserDatabase.getCreateDisplayNameTableCommand());
@ -326,6 +330,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(GroupDatabase.getCreateUpdatedTimestampCommand()); db.execSQL(GroupDatabase.getCreateUpdatedTimestampCommand());
db.execSQL(RecipientDatabase.getCreateApprovedCommand()); db.execSQL(RecipientDatabase.getCreateApprovedCommand());
db.execSQL(RecipientDatabase.getCreateApprovedMeCommand()); db.execSQL(RecipientDatabase.getCreateApprovedMeCommand());
db.execSQL(RecipientDatabase.getCreateDisappearingStateCommand());
db.execSQL(MmsDatabase.CREATE_MESSAGE_REQUEST_RESPONSE_COMMAND); db.execSQL(MmsDatabase.CREATE_MESSAGE_REQUEST_RESPONSE_COMMAND);
db.execSQL(MmsDatabase.CREATE_REACTIONS_UNREAD_COMMAND); db.execSQL(MmsDatabase.CREATE_REACTIONS_UNREAD_COMMAND);
db.execSQL(SmsDatabase.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(SmsDatabase.CREATE_HAS_MENTION_COMMAND);
db.execSQL(MmsDatabase.CREATE_HAS_MENTION_COMMAND); db.execSQL(MmsDatabase.CREATE_HAS_MENTION_COMMAND);
db.execSQL(ConfigDatabase.CREATE_CONFIG_TABLE_COMMAND); db.execSQL(ConfigDatabase.CREATE_CONFIG_TABLE_COMMAND);
db.execSQL(ExpirationConfigurationDatabase.CREATE_EXPIRATION_CONFIGURATION_TABLE_COMMAND);
executeStatements(db, SmsDatabase.CREATE_INDEXS); executeStatements(db, SmsDatabase.CREATE_INDEXS);
executeStatements(db, MmsDatabase.CREATE_INDEXS); executeStatements(db, MmsDatabase.CREATE_INDEXS);
@ -360,6 +366,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
executeStatements(db, ReactionDatabase.CREATE_REACTION_TRIGGERS); executeStatements(db, ReactionDatabase.CREATE_REACTION_TRIGGERS);
db.execSQL(RecipientDatabase.getAddWrapperHash()); db.execSQL(RecipientDatabase.getAddWrapperHash());
db.execSQL(RecipientDatabase.getAddBlocksCommunityMessageRequests()); db.execSQL(RecipientDatabase.getAddBlocksCommunityMessageRequests());
db.execSQL(LokiAPIDatabase.CREATE_LAST_LEGACY_MESSAGE_TABLE);
} }
@Override @Override
@ -610,6 +617,22 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(SessionJobDatabase.dropAttachmentDownloadJobs); 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(); db.setTransactionSuccessful();
} finally { } finally {
db.endTransaction(); db.endTransaction();

View File

@ -54,6 +54,10 @@ public abstract class MessageRecord extends DisplayRecord {
private final List<ReactionRecord> reactions; private final List<ReactionRecord> reactions;
private final boolean hasMention; private final boolean hasMention;
public final boolean isNotDisappearAfterRead() {
return expireStarted == getTimestamp();
}
public abstract boolean isMms(); public abstract boolean isMms();
public abstract boolean isMmsNotification(); 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())); return new SpannableString(UpdateMessageBuilder.INSTANCE.buildGroupUpdateMessage(context, updateMessageData, getIndividualRecipient().getAddress().serialize(), isOutgoing()));
} else if (isExpirationTimerUpdate()) { } else if (isExpirationTimerUpdate()) {
int seconds = (int) (getExpiresIn() / 1000); 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()) { } else if (isDataExtractionNotification()) {
if (isScreenshotNotification()) return new SpannableString((UpdateMessageBuilder.INSTANCE.buildDataExtractionMessage(context, DataExtractionNotificationInfoMessage.Kind.SCREENSHOT, getIndividualRecipient().getAddress().serialize()))); 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()))); else if (isMediaSavedNotification()) return new SpannableString((UpdateMessageBuilder.INSTANCE.buildDataExtractionMessage(context, DataExtractionNotificationInfoMessage.Kind.MEDIA_SAVED, getIndividualRecipient().getAddress().serialize())));

View File

@ -187,7 +187,7 @@ class ConfigFactory(
override fun persist(forConfigObject: ConfigBase, timestamp: Long) { override fun persist(forConfigObject: ConfigBase, timestamp: Long) {
try { try {
listeners.forEach { listener -> listeners.forEach { listener ->
listener.notifyUpdates(forConfigObject) listener.notifyUpdates(forConfigObject, timestamp)
} }
when (forConfigObject) { when (forConfigObject) {
is UserProfile -> persistUserConfigDump(timestamp) is UserProfile -> persistUserConfigDump(timestamp)

View File

@ -7,6 +7,7 @@ import dagger.hilt.components.SingletonComponent
import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.MessageDataProvider
import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.database.* import org.thoughtcrime.securesms.database.*
import org.thoughtcrime.securesms.database.MmsSmsDatabase
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
@EntryPoint @EntryPoint
@ -45,5 +46,6 @@ interface DatabaseComponent {
fun attachmentProvider(): MessageDataProvider fun attachmentProvider(): MessageDataProvider
fun blindedIdMappingDatabase(): BlindedIdMappingDatabase fun blindedIdMappingDatabase(): BlindedIdMappingDatabase
fun groupMemberDatabase(): GroupMemberDatabase fun groupMemberDatabase(): GroupMemberDatabase
fun expirationConfigurationDatabase(): ExpirationConfigurationDatabase
fun configDatabase(): ConfigDatabase fun configDatabase(): ConfigDatabase
} }

View File

@ -7,12 +7,14 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.MessageDataProvider
import org.session.libsession.utilities.SSKEnvironment
import org.thoughtcrime.securesms.attachments.DatabaseAttachmentProvider import org.thoughtcrime.securesms.attachments.DatabaseAttachmentProvider
import org.thoughtcrime.securesms.crypto.AttachmentSecret import org.thoughtcrime.securesms.crypto.AttachmentSecret
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider
import org.thoughtcrime.securesms.database.* import org.thoughtcrime.securesms.database.*
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
import org.thoughtcrime.securesms.service.ExpiringMessageManager
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@ -24,6 +26,10 @@ object DatabaseModule {
System.loadLibrary("sqlcipher") System.loadLibrary("sqlcipher")
} }
@Provides
@Singleton
fun provideMessageExpirationManagerProtocol(@ApplicationContext context: Context): SSKEnvironment.MessageExpirationManagerProtocol = ExpiringMessageManager(context)
@Provides @Provides
@Singleton @Singleton
fun provideAttachmentSecret(@ApplicationContext context: Context) = AttachmentSecretProvider.getInstance(context).orCreateAttachmentSecret fun provideAttachmentSecret(@ApplicationContext context: Context) = AttachmentSecretProvider.getInstance(context).orCreateAttachmentSecret
@ -129,6 +135,10 @@ object DatabaseModule {
@Singleton @Singleton
fun provideEmojiSearchDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = EmojiSearchDatabase(context, openHelper) fun provideEmojiSearchDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = EmojiSearchDatabase(context, openHelper)
@Provides
@Singleton
fun provideExpirationConfigurationDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = ExpirationConfigurationDatabase(context, openHelper)
@Provides @Provides
@Singleton @Singleton
fun provideStorage(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper, configFactory: ConfigFactory, threadDatabase: ThreadDatabase): Storage { fun provideStorage(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper, configFactory: ConfigFactory, threadDatabase: ThreadDatabase): Storage {

View File

@ -8,7 +8,6 @@ import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPol
import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address
import org.session.libsession.utilities.GroupRecord import org.session.libsession.utilities.GroupRecord
import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.crypto.ecc.DjbECPublicKey import org.session.libsignal.crypto.ecc.DjbECPublicKey
import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.ConfigFactory
@ -41,7 +40,7 @@ object ClosedGroupManager {
return groups.eraseLegacyGroup(groupPublicKey) return groups.eraseLegacyGroup(groupPublicKey)
} }
fun ConfigFactory.updateLegacyGroup(groupRecipientSettings: Recipient.RecipientSettings, group: GroupRecord) { fun ConfigFactory.updateLegacyGroup(group: GroupRecord) {
val groups = userGroups ?: return val groups = userGroups ?: return
if (!group.isClosedGroup) return if (!group.isClosedGroup) return
val storage = MessagingModuleConfiguration.shared.storage val storage = MessagingModuleConfiguration.shared.storage
@ -53,7 +52,6 @@ object ClosedGroupManager {
val toSet = legacyInfo.copy( val toSet = legacyInfo.copy(
members = latestMemberMap, members = latestMemberMap,
name = group.title, name = group.title,
disappearingTimer = groupRecipientSettings.expireMessages.toLong(),
priority = if (storage.isPinned(threadId)) ConfigBase.PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE, priority = if (storage.isPinned(threadId)) ConfigBase.PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE,
encPubKey = (latestKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte encPubKey = (latestKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte
encSecKey = latestKeyPair.privateKey.serialize() encSecKey = latestKeyPair.privateKey.serialize()

View File

@ -176,6 +176,7 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
// endregion // endregion
// region Updating // region Updating
@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data) super.onActivityResult(requestCode, resultCode, data)
when (requestCode) { when (requestCode) {
@ -335,7 +336,7 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
?: return Log.w("Loki", "No recipient settings when trying to update group config") ?: return Log.w("Loki", "No recipient settings when trying to update group config")
val latestGroup = storage.getGroup(groupID) val latestGroup = storage.getGroup(groupID)
?: return Log.w("Loki", "No group record when trying to update group config") ?: 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<String>, val zombieMembers: List<String>) class GroupMembers(val members: List<String>, val zombieMembers: List<String>)

View File

@ -92,7 +92,7 @@ class ConversationView : LinearLayout {
val senderDisplayName = getUserDisplayName(thread.recipient) val senderDisplayName = getUserDisplayName(thread.recipient)
?: thread.recipient.address.toString() ?: thread.recipient.address.toString()
binding.conversationViewDisplayNameTextView.text = senderDisplayName 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 val recipient = thread.recipient
binding.muteIndicatorImageView.isVisible = recipient.isMuted || recipient.notifyType != NOTIFY_TYPE_ALL binding.muteIndicatorImageView.isVisible = recipient.isMuted || recipient.notifyType != NOTIFY_TYPE_ALL
val drawableRes = if (recipient.isMuted || recipient.notifyType == NOTIFY_TYPE_NONE) { val drawableRes = if (recipient.isMuted || recipient.notifyType == NOTIFY_TYPE_NONE) {

View File

@ -548,6 +548,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
// endregion // endregion
// region Interaction // region Interaction
@Deprecated("Deprecated in Java")
override fun onBackPressed() { override fun onBackPressed() {
if (binding.globalSearchRecycler.isVisible) { if (binding.globalSearchRecycler.isVisible) {
binding.globalSearchInputLayout.clearSearch(true) binding.globalSearchInputLayout.clearSearch(true)

View File

@ -27,7 +27,7 @@ import androidx.core.app.NotificationManagerCompat;
import org.session.libsignal.utilities.Log; import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.ApplicationContext; 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.dependencies.DatabaseComponent;
import java.util.LinkedList; import java.util.LinkedList;

View File

@ -26,6 +26,7 @@ import android.os.Bundle;
import androidx.core.app.RemoteInput; 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.OutgoingMediaMessage;
import org.session.libsession.messaging.messages.signal.OutgoingTextMessage; import org.session.libsession.messaging.messages.signal.OutgoingTextMessage;
import org.session.libsession.messaging.messages.visible.VisibleMessage; 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.libsession.utilities.recipients.Recipient;
import org.session.libsignal.utilities.Log; import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.ApplicationContext; 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.dependencies.DatabaseComponent;
import org.thoughtcrime.securesms.mms.MmsException; import org.thoughtcrime.securesms.mms.MmsException;
import java.util.Collections; import java.util.Collections;
import java.util.List; 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 * 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.setText(responseText.toString());
message.setSentTimestamp(SnodeAPI.getNowWithOffset()); message.setSentTimestamp(SnodeAPI.getNowWithOffset());
MessageSender.send(message, recipient.getAddress()); 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()) { if (recipient.isGroupRecipient()) {
Log.w("AndroidAutoReplyReceiver", "GroupRecipient, Sending media message"); 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 { try {
DatabaseComponent.get(context).mmsDatabase().insertMessageOutbox(reply, replyThreadId, false, null, true); DatabaseComponent.get(context).mmsDatabase().insertMessageOutbox(reply, replyThreadId, false, null, true);
} catch (MmsException e) { } catch (MmsException e) {
@ -96,7 +103,7 @@ public class AndroidAutoReplyReceiver extends BroadcastReceiver {
} }
} else { } else {
Log.w("AndroidAutoReplyReceiver", "Sending regular message "); 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); DatabaseComponent.get(context).smsDatabase().insertMessageOutbox(replyThreadId, reply, false, SnodeAPI.getNowWithOffset(), null, true);
} }

View File

@ -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<Void, Void, Void>() {
@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<MarkedMessageInfo> markedReadMessages) {
if (markedReadMessages.isEmpty()) return;
for (MarkedMessageInfo messageInfo : markedReadMessages) {
scheduleDeletion(context, messageInfo.getExpirationInfo());
}
if (!TextSecurePreferences.isReadReceiptsEnabled(context)) return;
Map<Address, List<SyncMessageId>> addressMap = Stream.of(markedReadMessages)
.map(MarkedMessageInfo::getSyncMessageId)
.collect(Collectors.groupingBy(SyncMessageId::getAddress));
for (Address address : addressMap.keySet()) {
List<Long> 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());
}
}
}

View File

@ -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<Void?, Void?, Void?>() {
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<MarkedMessageInfo>
) {
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<MarkedMessageInfo>
): Map<String, MarkedMessageInfo>? {
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<String, MarkedMessageInfo>
) {
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<MarkedMessageInfo>
) {
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<String, MarkedMessageInfo>
) {
@Suppress("UNCHECKED_CAST")
val expiries = SnodeAPI.getExpiries(hashToMessage.keys.toList(), TextSecurePreferences.getLocalNumber(context)!!).get()["expiries"] as Map<String, Long>
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
)
}
}
}

View File

@ -26,25 +26,35 @@ import android.os.Bundle;
import androidx.core.app.RemoteInput; 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.OutgoingMediaMessage;
import org.session.libsession.messaging.messages.signal.OutgoingTextMessage; import org.session.libsession.messaging.messages.signal.OutgoingTextMessage;
import org.session.libsession.messaging.messages.visible.VisibleMessage; import org.session.libsession.messaging.messages.visible.VisibleMessage;
import org.session.libsession.messaging.sending_receiving.MessageSender; import org.session.libsession.messaging.sending_receiving.MessageSender;
import org.session.libsession.snode.SnodeAPI;
import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Address;
import org.session.libsession.utilities.recipients.Recipient; import org.session.libsession.utilities.recipients.Recipient;
import org.session.libsignal.utilities.Log; import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.ApplicationContext; 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.database.ThreadDatabase;
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
import org.thoughtcrime.securesms.mms.MmsException; import org.thoughtcrime.securesms.mms.MmsException;
import java.util.Collections; import java.util.Collections;
import java.util.List; 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 * Get the response text from the Wearable Device and sends an message as a reply
*/ */
@AndroidEntryPoint
public class RemoteReplyReceiver extends BroadcastReceiver { public class RemoteReplyReceiver extends BroadcastReceiver {
public static final String TAG = RemoteReplyReceiver.class.getSimpleName(); 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 ADDRESS_EXTRA = "address";
public static final String REPLY_METHOD = "reply_method"; public static final String REPLY_METHOD = "reply_method";
@Inject
ThreadDatabase threadDatabase;
@Inject
MmsDatabase mmsDatabase;
@Inject
SmsDatabase smsDatabase;
@Inject
Storage storage;
@SuppressLint("StaticFieldLeak") @SuppressLint("StaticFieldLeak")
@Override @Override
public void onReceive(final Context context, Intent intent) { public void onReceive(final Context context, Intent intent) {
@ -73,17 +92,20 @@ public class RemoteReplyReceiver extends BroadcastReceiver {
@Override @Override
protected Void doInBackground(Void... params) { protected Void doInBackground(Void... params) {
Recipient recipient = Recipient.from(context, address, false); Recipient recipient = Recipient.from(context, address, false);
ThreadDatabase threadDatabase = DatabaseComponent.get(context).threadDatabase();
long threadId = threadDatabase.getOrCreateThreadIdFor(recipient); long threadId = threadDatabase.getOrCreateThreadIdFor(recipient);
VisibleMessage message = new VisibleMessage(); VisibleMessage message = new VisibleMessage();
message.setSentTimestamp(System.currentTimeMillis()); message.setSentTimestamp(SnodeAPI.getNowWithOffset());
message.setText(responseText.toString()); 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) { switch (replyMethod) {
case GroupMessage: { 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 { try {
DatabaseComponent.get(context).mmsDatabase().insertMessageOutbox(reply, threadId, false, null, true); mmsDatabase.insertMessageOutbox(reply, threadId, false, null, true);
MessageSender.send(message, address); MessageSender.send(message, address);
} catch (MmsException e) { } catch (MmsException e) {
Log.w(TAG, e); Log.w(TAG, e);
@ -91,8 +113,8 @@ public class RemoteReplyReceiver extends BroadcastReceiver {
break; break;
} }
case SecureMessage: { case SecureMessage: {
OutgoingTextMessage reply = OutgoingTextMessage.from(message, recipient); OutgoingTextMessage reply = OutgoingTextMessage.from(message, recipient, expiresInMillis, expireStartedAt);
DatabaseComponent.get(context).smsDatabase().insertMessageOutbox(threadId, reply, false, System.currentTimeMillis(), null, true); smsDatabase.insertMessageOutbox(threadId, reply, false, System.currentTimeMillis(), null, true);
MessageSender.send(message, address); MessageSender.send(message, address);
break; break;
} }

View File

@ -15,10 +15,12 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.DialogClearAllDataBinding import network.loki.messenger.databinding.DialogClearAllDataBinding
import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeAPI
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.createSessionDialog import org.thoughtcrime.securesms.createSessionDialog
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
class ClearAllDataDialog : DialogFragment() { class ClearAllDataDialog : DialogFragment() {
@ -44,9 +46,9 @@ class ClearAllDataDialog : DialogFragment() {
private fun createView(): View { private fun createView(): View {
binding = DialogClearAllDataBinding.inflate(LayoutInflater.from(requireContext())) binding = DialogClearAllDataBinding.inflate(LayoutInflater.from(requireContext()))
val device = RadioOption("deviceOnly", requireContext().getString(R.string.dialog_clear_all_data_clear_device_only)) val device = radioOption("deviceOnly", 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)) val network = radioOption("deviceAndNetwork", R.string.dialog_clear_all_data_clear_device_and_network)
var selectedOption = device var selectedOption: RadioOption<String> = device
val optionAdapter = RadioOptionAdapter { selectedOption = it } val optionAdapter = RadioOptionAdapter { selectedOption = it }
binding.recyclerView.apply { binding.recyclerView.apply {
itemAnimator = null itemAnimator = null
@ -115,6 +117,10 @@ class ClearAllDataDialog : DialogFragment() {
} else { } else {
// finish // finish
val result = try { 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() SnodeAPI.deleteAllMessages().get()
} catch (e: Exception) { } catch (e: Exception) {
null null

View File

@ -95,6 +95,7 @@ class PrivacySettingsPreferenceFragment : ListSummaryPreferenceFragment() {
} }
} }
@Deprecated("Deprecated in Java")
override fun onRequestPermissionsResult( override fun onRequestPermissionsResult(
requestCode: Int, requestCode: Int,
permissions: Array<String>, permissions: Array<String>,

View File

@ -3,53 +3,120 @@ package org.thoughtcrime.securesms.preferences
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.StringRes
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ItemSelectableBinding import network.loki.messenger.databinding.ItemSelectableBinding
import network.loki.messenger.libsession_util.util.ExpiryMode
import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.ui.GetString
import java.util.Objects
class RadioOptionAdapter( class RadioOptionAdapter<T>(
var selectedOptionPosition: Int = 0, private var selectedOptionPosition: Int = 0,
private val onClickListener: (RadioOption) -> Unit private val onClickListener: (RadioOption<T>) -> Unit
) : ListAdapter<RadioOption, RadioOptionAdapter.ViewHolder>(RadioOptionDiffer()) { ) : ListAdapter<RadioOption<T>, RadioOptionAdapter.ViewHolder<T>>(RadioOptionDiffer()) {
class RadioOptionDiffer: DiffUtil.ItemCallback<RadioOption>() { class RadioOptionDiffer<T>: DiffUtil.ItemCallback<RadioOption<T>>() {
override fun areItemsTheSame(oldItem: RadioOption, newItem: RadioOption) = oldItem.title == newItem.title override fun areItemsTheSame(oldItem: RadioOption<T>, newItem: RadioOption<T>) = oldItem.title == newItem.title
override fun areContentsTheSame(oldItem: RadioOption, newItem: RadioOption) = oldItem.value == newItem.value override fun areContentsTheSame(oldItem: RadioOption<T>, newItem: RadioOption<T>) = Objects.equals(oldItem.value,newItem.value)
} }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder<T> =
val itemView = LayoutInflater.from(parent.context).inflate(R.layout.item_selectable, parent, false) LayoutInflater.from(parent.context).inflate(R.layout.item_selectable, parent, false)
return ViewHolder(itemView) .let(::ViewHolder)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) { override fun onBindViewHolder(holder: ViewHolder<T>, position: Int) {
val option = getItem(position) holder.bind(
val isSelected = position == selectedOptionPosition option = getItem(position),
holder.bind(option, isSelected) { isSelected = position == selectedOptionPosition
) {
onClickListener(it) onClickListener(it)
selectedOptionPosition = position setSelectedPosition(position)
notifyItemRangeChanged(0, itemCount)
} }
} }
class ViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) { fun setSelectedPosition(selectedPosition: Int) {
notifyItemChanged(selectedOptionPosition)
selectedOptionPosition = selectedPosition
notifyItemChanged(selectedOptionPosition)
}
class ViewHolder<T>(itemView: View): RecyclerView.ViewHolder(itemView) {
val glide = GlideApp.with(itemView) val glide = GlideApp.with(itemView)
val binding = ItemSelectableBinding.bind(itemView) val binding = ItemSelectableBinding.bind(itemView)
fun bind(option: RadioOption, isSelected: Boolean, toggleSelection: (RadioOption) -> Unit) { fun bind(option: RadioOption<T>, isSelected: Boolean, toggleSelection: (RadioOption<T>) -> Unit) {
binding.titleTextView.text = option.title val alpha = if (option.enabled) 1f else 0.5f
binding.root.setOnClickListener { toggleSelection(option) } 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 binding.selectButton.isSelected = isSelected
if (option.enabled) {
binding.root.setOnClickListener { toggleSelection(option) }
}
} }
} }
} }
data class RadioOption( data class RadioOption<out T>(
val value: String, val value: T,
val title: String val title: GetString,
val subtitle: GetString? = null,
val enabled: Boolean = true,
val contentDescription: GetString? = null
) )
fun <T> radioOption(value: T, @StringRes title: Int, configure: RadioOptionBuilder<T>.() -> Unit = {}) =
radioOption(value, GetString(title), configure)
fun <T> radioOption(value: T, title: String, configure: RadioOptionBuilder<T>.() -> Unit = {}) =
radioOption(value, GetString(title), configure)
fun <T> radioOption(value: T, title: GetString, configure: RadioOptionBuilder<T>.() -> Unit = {}) =
RadioOptionBuilder(value, title).also { it.configure() }.build()
class RadioOptionBuilder<out T>(
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
)
}

View File

@ -31,9 +31,9 @@ import nl.komponents.kovenant.all
import nl.komponents.kovenant.ui.alwaysUi import nl.komponents.kovenant.ui.alwaysUi
import nl.komponents.kovenant.ui.successUi import nl.komponents.kovenant.ui.successUi
import org.session.libsession.avatars.AvatarHelper import org.session.libsession.avatars.AvatarHelper
import org.session.libsession.avatars.ProfileContactPhoto
import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeAPI
import org.session.libsession.avatars.ProfileContactPhoto
import org.session.libsession.utilities.* import org.session.libsession.utilities.*
import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
@ -163,6 +163,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
} }
} }
@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data) super.onActivityResult(requestCode, resultCode, data)
when (requestCode) { when (requestCode) {

View File

@ -1,7 +1,9 @@
package org.thoughtcrime.securesms.repository package org.thoughtcrime.securesms.repository
import network.loki.messenger.libsession_util.util.ExpiryMode
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context import android.content.Context
import app.cash.copper.Query
import app.cash.copper.flow.observeQuery import app.cash.copper.flow.observeQuery
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -23,6 +25,7 @@ import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.toHexString import org.session.libsignal.utilities.toHexString
import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.database.DatabaseContentProviders
import org.thoughtcrime.securesms.database.DraftDatabase import org.thoughtcrime.securesms.database.DraftDatabase
import org.thoughtcrime.securesms.database.ExpirationConfigurationDatabase
import org.thoughtcrime.securesms.database.LokiMessageDatabase import org.thoughtcrime.securesms.database.LokiMessageDatabase
import org.thoughtcrime.securesms.database.LokiThreadDatabase import org.thoughtcrime.securesms.database.LokiThreadDatabase
import org.thoughtcrime.securesms.database.MmsDatabase 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.MessageRecord
import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import javax.inject.Inject import javax.inject.Inject
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
@ -43,6 +47,7 @@ import kotlin.coroutines.suspendCoroutine
interface ConversationRepository { interface ConversationRepository {
fun maybeGetRecipientForThreadId(threadId: Long): Recipient? fun maybeGetRecipientForThreadId(threadId: Long): Recipient?
fun maybeGetBlindedRecipient(recipient: Recipient): Recipient? fun maybeGetBlindedRecipient(recipient: Recipient): Recipient?
fun changes(threadId: Long): Flow<Query>
fun recipientUpdateFlow(threadId: Long): Flow<Recipient?> fun recipientUpdateFlow(threadId: Long): Flow<Recipient?>
fun saveDraft(threadId: Long, text: String) fun saveDraft(threadId: Long, text: String)
fun getDraft(threadId: Long): String? fun getDraft(threadId: Long): String?
@ -97,6 +102,7 @@ class DefaultConversationRepository @Inject constructor(
private val storage: Storage, private val storage: Storage,
private val lokiMessageDb: LokiMessageDatabase, private val lokiMessageDb: LokiMessageDatabase,
private val sessionJobDb: SessionJobDatabase, private val sessionJobDb: SessionJobDatabase,
private val configDb: ExpirationConfigurationDatabase,
private val configFactory: ConfigFactory, private val configFactory: ConfigFactory,
private val contentResolver: ContentResolver, private val contentResolver: ContentResolver,
) : ConversationRepository { ) : ConversationRepository {
@ -114,6 +120,9 @@ class DefaultConversationRepository @Inject constructor(
) )
} }
override fun changes(threadId: Long): Flow<Query> =
contentResolver.observeQuery(DatabaseContentProviders.Conversation.getUriForThread(threadId))
override fun recipientUpdateFlow(threadId: Long): Flow<Recipient?> { override fun recipientUpdateFlow(threadId: Long): Flow<Recipient?> {
return contentResolver.observeQuery(DatabaseContentProviders.Conversation.getUriForThread(threadId)).map { return contentResolver.observeQuery(DatabaseContentProviders.Conversation.getUriForThread(threadId)).map {
maybeGetRecipientForThreadId(threadId) maybeGetRecipientForThreadId(threadId)
@ -141,14 +150,20 @@ class DefaultConversationRepository @Inject constructor(
for (contact in contacts) { for (contact in contacts) {
val message = VisibleMessage() val message = VisibleMessage()
message.sentTimestamp = SnodeAPI.nowWithOffset message.sentTimestamp = SnodeAPI.nowWithOffset
val openGroupInvitation = OpenGroupInvitation() val openGroupInvitation = OpenGroupInvitation().apply {
openGroupInvitation.name = openGroup.name name = openGroup.name
openGroupInvitation.url = openGroup.joinURL url = openGroup.joinURL
}
message.openGroupInvitation = openGroupInvitation 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( val outgoingTextMessage = OutgoingTextMessage.fromOpenGroupInvitation(
openGroupInvitation, openGroupInvitation,
contact, contact,
message.sentTimestamp message.sentTimestamp,
expiresInMillis,
expireStartedAt
) )
smsDb.insertMessageOutbox(-1, outgoingTextMessage, message.sentTimestamp!!, true) smsDb.insertMessageOutbox(-1, outgoingTextMessage, message.sentTimestamp!!, true)
MessageSender.send(message, contact.address) MessageSender.send(message, contact.address)
@ -194,7 +209,7 @@ class DefaultConversationRepository @Inject constructor(
} }
} else { } else {
messageDataProvider.deleteMessage(message.id, !message.isMms) 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() var publicKey = recipient.address.serialize()
if (recipient.isClosedGroupRecipient) { if (recipient.isClosedGroupRecipient) {
publicKey = GroupUtil.doubleDecodeGroupID(publicKey).toHexString() publicKey = GroupUtil.doubleDecodeGroupID(publicKey).toHexString()
@ -211,16 +226,11 @@ class DefaultConversationRepository @Inject constructor(
override fun buildUnsendRequest(recipient: Recipient, message: MessageRecord): UnsendRequest? { override fun buildUnsendRequest(recipient: Recipient, message: MessageRecord): UnsendRequest? {
if (recipient.isOpenGroupRecipient) return null if (recipient.isOpenGroupRecipient) return null
messageDataProvider.getServerHashForMessage(message.id) ?: return null messageDataProvider.getServerHashForMessage(message.id, message.isMms) ?: return null
val unsendRequest = UnsendRequest() return UnsendRequest(
if (message.isOutgoing) { author = message.takeUnless { it.isOutgoing }?.run { individualRecipient.address.contactIdentifier() } ?: textSecurePreferences.getLocalNumber(),
unsendRequest.author = textSecurePreferences.getLocalNumber() timestamp = message.timestamp
} else { )
unsendRequest.author = message.individualRecipient.address.contactIdentifier()
}
unsendRequest.timestamp = message.timestamp
return unsendRequest
} }
override suspend fun deleteMessageWithoutUnsendRequest( override suspend fun deleteMessageWithoutUnsendRequest(
@ -235,7 +245,7 @@ class DefaultConversationRepository @Inject constructor(
lokiMessageDb.getServerID(message.id, !message.isMms) ?: continue lokiMessageDb.getServerID(message.id, !message.isMms) ?: continue
messageServerIDs[messageServerID] = message messageServerIDs[messageServerID] = message
} }
for ((messageServerID, message) in messageServerIDs) { messageServerIDs.forEach { (messageServerID, message) ->
OpenGroupApi.deleteMessage(messageServerID, openGroup.room, openGroup.server) OpenGroupApi.deleteMessage(messageServerID, openGroup.room, openGroup.server)
.success { .success {
messageDataProvider.deleteMessage(message.id, !message.isMms) messageDataProvider.deleteMessage(message.id, !message.isMms)

View File

@ -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<ExpiringMessageReference> 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<SignalServiceGroup> 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<ExpiringMessageReference> {
@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;
}
}
}

View File

@ -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<ExpiringMessageReference>()
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<SignalServiceGroup?>()
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<ExpiringMessageReference> {
override fun compareTo(other: ExpiringMessageReference) = compareValuesBy(this, other, { it.expiresAtMillis }, { it.id }, { it.mms })
}
}

View File

@ -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,
)
}
}
}

View File

@ -3,56 +3,63 @@ package org.thoughtcrime.securesms.ui
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.layout.wrapContentSize
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.foundation.shape.RoundedCornerShape
import androidx.compose.material.ButtonColors import androidx.compose.material.ButtonColors
import androidx.compose.material.ButtonDefaults import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Card import androidx.compose.material.Card
import androidx.compose.material.Colors import androidx.compose.material.Colors
import androidx.compose.material.Icon import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton import androidx.compose.material.OutlinedButton
import androidx.compose.material.Tab import androidx.compose.material.RadioButton
import androidx.compose.material.TabRow
import androidx.compose.material.TabRowDefaults
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material.TextButton import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
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 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.recipients.Recipient
import org.session.libsession.utilities.runIf
import org.thoughtcrime.securesms.components.ProfilePictureView import org.thoughtcrime.securesms.components.ProfilePictureView
import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.OptionsCard
import kotlin.math.min
import kotlin.math.roundToInt import kotlin.math.roundToInt
@Composable @Composable
@ -117,6 +124,41 @@ fun BorderlessButton(
} }
} }
interface Callbacks<in T> {
fun onSetClick(): Any?
fun setValue(value: T)
}
object NoOpCallbacks: Callbacks<Any> {
override fun onSetClick() {}
override fun setValue(value: Any) {}
}
data class RadioOption<T>(
val value: T,
val title: GetString,
val subtitle: GetString? = null,
val contentDescription: GetString = title,
val selected: Boolean = false,
val enabled: Boolean = true,
)
@Composable
fun <T> OptionsCard(card: OptionsCard<T>, callbacks: Callbacks<T>) {
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 @Composable
fun ItemButton( fun ItemButton(
text: String, text: String,
@ -174,66 +216,109 @@ fun CellWithPaddingAndMargin(
} }
} }
@Composable
fun <T> TitledRadioButton(option: RadioOption<T>, 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 private val Colors.cellColor: Color
@Composable @Composable
get() = LocalExtraColors.current.settingsBackground get() = LocalExtraColors.current.settingsBackground
@OptIn(ExperimentalFoundationApi::class) fun Modifier.fadingEdges(
@Composable scrollState: ScrollState,
fun BoxScope.HorizontalPagerIndicator(pagerState: PagerState) { topEdgeHeight: Dp = 0.dp,
if (pagerState.pageCount >= 2) Card( bottomEdgeHeight: Dp = 20.dp
shape = RoundedCornerShape(50.dp), ): Modifier = this.then(
backgroundColor = Color.Black.copy(alpha = 0.4f), Modifier
modifier = Modifier // adding layer fixes issue with blending gradient and content
.align(Alignment.BottomCenter) .graphicsLayer { alpha = 0.99F }
.padding(8.dp) .drawWithContent {
) { drawContent()
Box(modifier = Modifier.padding(8.dp)) {
HorizontalPagerIndicator(
pagerState = pagerState,
pageCount = pagerState.pageCount,
activeColor = Color.White,
inactiveColor = classicDarkColors[5])
}
}
}
@OptIn(ExperimentalFoundationApi::class) val topColors = listOf(Color.Transparent, Color.Black)
@Composable val topStartY = scrollState.value.toFloat()
fun RowScope.CarouselPrevButton(pagerState: PagerState) { val topGradientHeight = min(topEdgeHeight.toPx(), topStartY)
CarouselButton(pagerState, pagerState.canScrollBackward, R.drawable.ic_prev, -1) if (topGradientHeight > 0f) drawRect(
} brush = Brush.verticalGradient(
colors = topColors,
startY = topStartY,
endY = topStartY + topGradientHeight
),
blendMode = BlendMode.DstIn
)
@OptIn(ExperimentalFoundationApi::class) val bottomColors = listOf(Color.Black, Color.Transparent)
@Composable val bottomEndY = size.height - scrollState.maxValue + scrollState.value
fun RowScope.CarouselNextButton(pagerState: PagerState) { val bottomGradientHeight =
CarouselButton(pagerState, pagerState.canScrollForward, R.drawable.ic_next, 1) min(bottomEdgeHeight.toPx(), scrollState.maxValue.toFloat() - scrollState.value)
} if (bottomGradientHeight > 0f) drawRect(
brush = Brush.verticalGradient(
@OptIn(ExperimentalFoundationApi::class) colors = bottomColors,
@Composable startY = bottomEndY - bottomGradientHeight,
fun RowScope.CarouselButton( endY = bottomEndY
pagerState: PagerState, ),
enabled: Boolean, blendMode = BlendMode.DstIn
@DrawableRes id: Int,
delta: Int
) {
if (pagerState.pageCount <= 1) Spacer(modifier = Modifier.width(32.dp))
else {
val animationScope = rememberCoroutineScope()
IconButton(
modifier = Modifier
.width(40.dp)
.align(Alignment.CenterVertically),
enabled = enabled,
onClick = { animationScope.launch { pagerState.animateScrollToPage(pagerState.currentPage + delta) } }) {
Icon(
painter = painterResource(id = id),
contentDescription = "",
) )
} }
} )
}
@Composable @Composable
fun Divider() { fun Divider() {

View File

@ -1,28 +1,55 @@
package org.thoughtcrime.securesms.ui package org.thoughtcrime.securesms.ui
import android.content.Context
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource 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. * Compatibility class to allow ViewModels to use strings and string resources interchangeably.
*/ */
sealed class GetString { sealed class GetString {
@Composable
operator fun invoke() = string()
operator fun invoke(context: Context) = string(context)
@Composable @Composable
abstract fun string(): String abstract fun string(): String
abstract fun string(context: Context): String
data class FromString(val string: String): GetString() { data class FromString(val string: String): GetString() {
@Composable @Composable
override fun string(): String = string override fun string(): String = string
override fun string(context: Context): String = string
} }
data class FromResId(@StringRes val resId: Int): GetString() { data class FromResId(@StringRes val resId: Int): GetString() {
@Composable @Composable
override fun string(): String = stringResource(resId) 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<T>(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(@StringRes resId: Int) = GetString.FromResId(resId)
fun GetString(string: String) = GetString.FromString(string) fun GetString(string: String) = GetString.FromString(string)
fun GetString(function: (Context) -> String) = GetString.FromFun(function)
fun <T> GetString(value: T, function: (Context, T) -> String) = GetString.FromMap(value, function)
fun GetString(duration: Duration) = GetString.FromMap(duration, ExpirationUtil::getExpirationDisplayValue)
/** /**

View File

@ -131,6 +131,7 @@ object MockDataGenerator {
.joinToString(), .joinToString(),
Optional.absent(), Optional.absent(),
0, 0,
0,
false, false,
-1, -1,
false false
@ -148,6 +149,7 @@ object MockDataGenerator {
.map { wordContent.random(dmThreadRandomGenerator.asKotlinRandom()) } .map { wordContent.random(dmThreadRandomGenerator.asKotlinRandom()) }
.joinToString(), .joinToString(),
0, 0,
0,
-1, -1,
(timestampNow - (index * 5000)) (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 // Add the group to the user's set of public keys to poll for and store the key pair
val encryptionKeyPair = Curve.generateKeyPair() val encryptionKeyPair = Curve.generateKeyPair()
storage.addClosedGroupEncryptionKeyPair(encryptionKeyPair, randomGroupPublicKey, System.currentTimeMillis()) storage.addClosedGroupEncryptionKeyPair(encryptionKeyPair, randomGroupPublicKey, System.currentTimeMillis())
storage.setExpirationTimer(groupId, 0) storage.createInitialConfigGroup(randomGroupPublicKey, groupName, GroupUtil.createConfigMemberMap(members, setOf(adminUserId)), System.currentTimeMillis(), encryptionKeyPair, 0)
storage.createInitialConfigGroup(randomGroupPublicKey, groupName, GroupUtil.createConfigMemberMap(members, setOf(adminUserId)), System.currentTimeMillis(), encryptionKeyPair)
// Add the group created message // Add the group created message
if (userSessionId == adminUserId) { if (userSessionId == adminUserId) {
storage.insertOutgoingInfoMessage(context, groupId, SignalServiceGroup.Type.CREATION, groupName, members, listOf(adminUserId), threadId, (timestampNow - (numMessages * 5000))) 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))) storage.insertIncomingInfoMessage(context, adminUserId, groupId, SignalServiceGroup.Type.CREATION, groupName, members, listOf(adminUserId), (timestampNow - (numMessages * 5000)))
} }
@ -261,6 +261,7 @@ object MockDataGenerator {
.joinToString(), .joinToString(),
Optional.absent(), Optional.absent(),
0, 0,
0,
false, false,
-1, -1,
false false
@ -278,6 +279,7 @@ object MockDataGenerator {
.map { wordContent.random(cgThreadRandomGenerator.asKotlinRandom()) } .map { wordContent.random(cgThreadRandomGenerator.asKotlinRandom()) }
.joinToString(), .joinToString(),
0, 0,
0,
-1, -1,
(timestampNow - (index * 5000)) (timestampNow - (index * 5000))
), ),
@ -386,6 +388,7 @@ object MockDataGenerator {
.joinToString(), .joinToString(),
Optional.absent(), Optional.absent(),
0, 0,
0,
false, false,
-1, -1,
false false
@ -402,6 +405,7 @@ object MockDataGenerator {
.map { wordContent.random(ogThreadRandomGenerator.asKotlinRandom()) } .map { wordContent.random(ogThreadRandomGenerator.asKotlinRandom()) }
.joinToString(), .joinToString(),
0, 0,
0,
-1, -1,
(timestampNow - (index * 5000)) (timestampNow - (index * 5000))
), ),

View File

@ -66,6 +66,7 @@ class SaveAttachmentTask @JvmOverloads constructor(context: Context, count: Int
this.contextReference = WeakReference(context) this.contextReference = WeakReference(context)
} }
@Deprecated("Deprecated in Java")
override fun doInBackground(vararg attachments: Attachment?): Pair<Int, String?> { override fun doInBackground(vararg attachments: Attachment?): Pair<Int, String?> {
if (attachments.isEmpty()) { if (attachments.isEmpty()) {
throw IllegalArgumentException("Must pass in at least one attachment") 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 return File(fileName).name
} }
@Deprecated("Deprecated in Java")
override fun onPostExecute(result: Pair<Int, String?>) { override fun onPostExecute(result: Pair<Int, String?>) {
super.onPostExecute(result) super.onPostExecute(result)
val context = contextReference.get() ?: return val context = contextReference.get() ?: return

View File

@ -29,6 +29,7 @@ class ScanQRCodeWrapperFragment : Fragment(), ScanQRCodePlaceholderFragmentDeleg
} }
} }
@Deprecated("Deprecated in Java")
override fun setUserVisibleHint(isVisibleToUser: Boolean) { override fun setUserVisibleHint(isVisibleToUser: Boolean) {
super.setUserVisibleHint(isVisibleToUser) super.setUserVisibleHint(isVisibleToUser)
enabled = isVisibleToUser enabled = isVisibleToUser

View File

@ -16,6 +16,7 @@ import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.functional.bind import nl.komponents.kovenant.functional.bind
import org.session.libsession.database.StorageProtocol import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.calls.CallMessageType 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.messages.control.CallMessage
import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.snode.SnodeAPI 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.libsession.utilities.recipients.Recipient
import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.ICE_CANDIDATES import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.ICE_CANDIDATES
import org.session.libsignal.utilities.Log 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.AudioDeviceUpdate
import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.AudioEnabled import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.AudioEnabled
import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.RecipientUpdate import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.RecipientUpdate
@ -57,8 +59,12 @@ import java.util.ArrayDeque
import java.util.UUID import java.util.UUID
import org.thoughtcrime.securesms.webrtc.data.State as CallState import org.thoughtcrime.securesms.webrtc.data.State as CallState
class CallManager(context: Context, audioManager: AudioManagerCompat, private val storage: StorageProtocol): PeerConnection.Observer, class CallManager(
SignalAudioManager.EventListener, CameraEventListener, DataChannel.Observer { private val context: Context,
audioManager: AudioManagerCompat,
private val storage: StorageProtocol
): PeerConnection.Observer,
SignalAudioManager.EventListener, CameraEventListener, DataChannel.Observer {
sealed class StateEvent { sealed class StateEvent {
data class AudioEnabled(val isEnabled: Boolean): StateEvent() data class AudioEnabled(val isEnabled: Boolean): StateEvent()
@ -293,17 +299,17 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va
while (pendingOutgoingIceUpdates.isNotEmpty()) { while (pendingOutgoingIceUpdates.isNotEmpty()) {
currentPendings.add(pendingOutgoingIceUpdates.pop()) 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( val thread = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(expectedRecipient)
ICE_CANDIDATES, CallMessage(
sdps = sdps, ICE_CANDIDATES,
sdpMLineIndexes = sdpMLineIndexes, sdps = currentPendings.map(IceCandidate::sdp),
sdpMids = sdpMids, sdpMLineIndexes = currentPendings.map(IceCandidate::sdpMLineIndex),
currentCallId sdpMids = currentPendings.map(IceCandidate::sdpMid),
), currentRecipient.address, isSyncMessage = currentRecipient.isLocalNumber) 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<Unit, Exception> { fun onNewOffer(offer: String, callId: UUID, recipient: Recipient): Promise<Unit, Exception> {
if (callId != this.callId) return Promise.ofFail(NullPointerException("No callId")) if (callId != this.callId) return Promise.ofFail(NullPointerException("No callId"))
if (recipient != this.recipient) return Promise.ofFail(NullPointerException("No recipient")) 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")) 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")) mandatory.add(MediaConstraints.KeyValuePair("IceRestart", "true"))
}) })
connection.setLocalDescription(answer) connection.setLocalDescription(answer)
pendingIncomingIceUpdates.toList().forEach { update -> pendingIncomingIceUpdates.toList().forEach(connection::addIceCandidate)
connection.addIceCandidate(update)
}
pendingIncomingIceUpdates.clear() pendingIncomingIceUpdates.clear()
val answerMessage = CallMessage.answer(answer.description, callId) val answerMessage = CallMessage.answer(answer.description, callId).applyExpiryMode(thread)
Log.i("Loki", "Posting new answer") Log.i("Loki", "Posting new answer")
MessageSender.sendNonDurably(answerMessage, recipient.address, isSyncMessage = recipient.isLocalNumber) MessageSender.sendNonDurably(answerMessage, recipient.address, isSyncMessage = recipient.isLocalNumber)
} else { } else {
@ -479,13 +484,14 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va
connection.setRemoteDescription(SessionDescription(SessionDescription.Type.OFFER, offer)) connection.setRemoteDescription(SessionDescription(SessionDescription.Type.OFFER, offer))
val answer = connection.createAnswer(MediaConstraints()) val answer = connection.createAnswer(MediaConstraints())
connection.setLocalDescription(answer) 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")) val userAddress = storage.getUserPublicKey() ?: return Promise.ofFail(NullPointerException("No user public key"))
MessageSender.sendNonDurably(answerMessage, Address.fromSerialized(userAddress), isSyncMessage = true) MessageSender.sendNonDurably(answerMessage, Address.fromSerialized(userAddress), isSyncMessage = true)
val sendAnswerMessage = MessageSender.sendNonDurably(CallMessage.answer( val sendAnswerMessage = MessageSender.sendNonDurably(CallMessage.answer(
answer.description, answer.description,
callId callId
), recipient.address, isSyncMessage = recipient.isLocalNumber) ).applyExpiryMode(thread), recipient.address, isSyncMessage = recipient.isLocalNumber)
insertCallMessage(recipient.address.serialize(), CallMessageType.CALL_INCOMING, false) insertCallMessage(recipient.address.serialize(), CallMessageType.CALL_INCOMING, false)
@ -533,15 +539,16 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va
connection.setLocalDescription(offer) connection.setLocalDescription(offer)
Log.d("Loki", "Sending pre-offer") Log.d("Loki", "Sending pre-offer")
val thread = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient)
return MessageSender.sendNonDurably(CallMessage.preOffer( return MessageSender.sendNonDurably(CallMessage.preOffer(
callId callId
), recipient.address, isSyncMessage = recipient.isLocalNumber).bind { ).applyExpiryMode(thread), recipient.address, isSyncMessage = recipient.isLocalNumber).bind {
Log.d("Loki", "Sent pre-offer") Log.d("Loki", "Sent pre-offer")
Log.d("Loki", "Sending offer") Log.d("Loki", "Sending offer")
MessageSender.sendNonDurably(CallMessage.offer( MessageSender.sendNonDurably(CallMessage.offer(
offer.description, offer.description,
callId callId
), recipient.address, isSyncMessage = recipient.isLocalNumber).success { ).applyExpiryMode(thread), recipient.address, isSyncMessage = recipient.isLocalNumber).success {
Log.d("Loki", "Sent offer") Log.d("Loki", "Sent offer")
}.fail { }.fail {
Log.e("Loki", "Failed to send offer", it) 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 recipient = recipient ?: return
val userAddress = storage.getUserPublicKey() ?: return val userAddress = storage.getUserPublicKey() ?: return
stateProcessor.processEvent(Event.DeclineCall) { stateProcessor.processEvent(Event.DeclineCall) {
MessageSender.sendNonDurably(CallMessage.endCall(callId), Address.fromSerialized(userAddress), isSyncMessage = true) val thread = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient)
MessageSender.sendNonDurably(CallMessage.endCall(callId), recipient.address, isSyncMessage = recipient.isLocalNumber) 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) 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) val buffer = DataChannel.Buffer(ByteBuffer.wrap(HANGUP_JSON.toString().encodeToByteArray()), false)
channel.send(buffer) 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")) mandatory.add(MediaConstraints.KeyValuePair("IceRestart", "true"))
}) })
connection.setLocalDescription(offer) connection.setLocalDescription(offer)
val thread = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient)
MessageSender.sendNonDurably(CallMessage.offer(offer.description, callId), recipient.address, isSyncMessage = recipient.isLocalNumber) MessageSender.sendNonDurably(CallMessage.offer(offer.description, callId).applyExpiryMode(thread), recipient.address, isSyncMessage = recipient.isLocalNumber)
} }
} }

View File

@ -19,6 +19,7 @@ class HangUpRtcOnPstnCallAnsweredListener(private val hangupListener: ()->Unit):
private val TAG = Log.tag(HangUpRtcOnPstnCallAnsweredListener::class.java) private val TAG = Log.tag(HangUpRtcOnPstnCallAnsweredListener::class.java)
} }
@Deprecated("Deprecated in Java")
override fun onCallStateChanged(state: Int, phoneNumber: String?) { override fun onCallStateChanged(state: Int, phoneNumber: String?) {
super.onCallStateChanged(state, phoneNumber) super.onCallStateChanged(state, phoneNumber)
if (state == TelephonyManager.CALL_STATE_OFFHOOK) { if (state == TelephonyManager.CALL_STATE_OFFHOOK) {

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="?message_received_background_color" />
<corners android:radius="@dimen/message_corner_radius" />
</shape>

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M15.41,16.59L10.83,12l4.58,-4.59L14,6l-6,6 6,6 1.41,-1.41z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M8.59,16.59L13.17,12 8.59,7.41 10,6l6,6 -6,6 -1.41,-1.41z"/>
</vector>

View File

@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M19.04,4.55l-1.42,1.42C16.07,4.74 14.12,4 12,4c-1.83,0 -3.53,0.55 -4.95,1.48l1.46,1.46C9.53,6.35 10.73,6 12,6c3.87,0 7,3.13 7,7 0,1.27 -0.35,2.47 -0.94,3.49l1.45,1.45C20.45,16.53 21,14.83 21,13c0,-2.12 -0.74,-4.07 -1.97,-5.61l1.42,-1.42 -1.41,-1.42zM15,1L9,1v2h6L15,1zM11,9.44l2,2L13,8h-2v1.44zM3.02,4L1.75,5.27 4.5,8.03C3.55,9.45 3,11.16 3,13c0,4.97 4.02,9 9,9 1.84,0 3.55,-0.55 4.98,-1.5l2.5,2.5 1.27,-1.27 -7.71,-7.71L3.02,4zM12,20c-3.87,0 -7,-3.13 -7,-7 0,-1.28 0.35,-2.48 0.95,-3.52l9.56,9.56c-1.03,0.61 -2.23,0.96 -3.51,0.96z"/>
</vector>

View File

@ -1,6 +1,6 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp" android:width="16dp"
android:height="20dp" android:height="16dp"
android:viewportWidth="20" android:viewportWidth="20"
android:viewportHeight="20"> android:viewportHeight="20">
<path <path

View File

@ -1,6 +1,6 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp" android:width="16dp"
android:height="20dp" android:height="16dp"
android:viewportWidth="20" android:viewportWidth="20"
android:viewportHeight="20"> android:viewportHeight="20">
<path <path

View File

@ -1,6 +1,6 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp" android:width="16dp"
android:height="20dp" android:height="16dp"
android:viewportWidth="20" android:viewportWidth="20"
android:viewportHeight="20"> android:viewportHeight="20">
<path <path

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M19.43,12.98c0.04,-0.32 0.07,-0.64 0.07,-0.98 0,-0.34 -0.03,-0.66 -0.07,-0.98l2.11,-1.65c0.19,-0.15 0.24,-0.42 0.12,-0.64l-2,-3.46c-0.09,-0.16 -0.26,-0.25 -0.44,-0.25 -0.06,0 -0.12,0.01 -0.17,0.03l-2.49,1c-0.52,-0.4 -1.08,-0.73 -1.69,-0.98l-0.38,-2.65C14.46,2.18 14.25,2 14,2h-4c-0.25,0 -0.46,0.18 -0.49,0.42l-0.38,2.65c-0.61,0.25 -1.17,0.59 -1.69,0.98l-2.49,-1c-0.06,-0.02 -0.12,-0.03 -0.18,-0.03 -0.17,0 -0.34,0.09 -0.43,0.25l-2,3.46c-0.13,0.22 -0.07,0.49 0.12,0.64l2.11,1.65c-0.04,0.32 -0.07,0.65 -0.07,0.98 0,0.33 0.03,0.66 0.07,0.98l-2.11,1.65c-0.19,0.15 -0.24,0.42 -0.12,0.64l2,3.46c0.09,0.16 0.26,0.25 0.44,0.25 0.06,0 0.12,-0.01 0.17,-0.03l2.49,-1c0.52,0.4 1.08,0.73 1.69,0.98l0.38,2.65c0.03,0.24 0.24,0.42 0.49,0.42h4c0.25,0 0.46,-0.18 0.49,-0.42l0.38,-2.65c0.61,-0.25 1.17,-0.59 1.69,-0.98l2.49,1c0.06,0.02 0.12,0.03 0.18,0.03 0.17,0 0.34,-0.09 0.43,-0.25l2,-3.46c0.12,-0.22 0.07,-0.49 -0.12,-0.64l-2.11,-1.65zM17.45,11.27c0.04,0.31 0.05,0.52 0.05,0.73 0,0.21 -0.02,0.43 -0.05,0.73l-0.14,1.13 0.89,0.7 1.08,0.84 -0.7,1.21 -1.27,-0.51 -1.04,-0.42 -0.9,0.68c-0.43,0.32 -0.84,0.56 -1.25,0.73l-1.06,0.43 -0.16,1.13 -0.2,1.35h-1.4l-0.19,-1.35 -0.16,-1.13 -1.06,-0.43c-0.43,-0.18 -0.83,-0.41 -1.23,-0.71l-0.91,-0.7 -1.06,0.43 -1.27,0.51 -0.7,-1.21 1.08,-0.84 0.89,-0.7 -0.14,-1.13c-0.03,-0.31 -0.05,-0.54 -0.05,-0.74s0.02,-0.43 0.05,-0.73l0.14,-1.13 -0.89,-0.7 -1.08,-0.84 0.7,-1.21 1.27,0.51 1.04,0.42 0.9,-0.68c0.43,-0.32 0.84,-0.56 1.25,-0.73l1.06,-0.43 0.16,-1.13 0.2,-1.35h1.39l0.19,1.35 0.16,1.13 1.06,0.43c0.43,0.18 0.83,0.41 1.23,0.71l0.91,0.7 1.06,-0.43 1.27,-0.51 0.7,1.21 -1.07,0.85 -0.89,0.7 0.14,1.13zM12,8c-2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4 -1.79,-4 -4,-4zM12,14c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2z" />
</vector>

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_selected="true">
<shape
android:shape="ring"
android:innerRadius="0dp"
android:thickness="2dp"
android:useLevel="false">
<solid android:color="?android:textColorPrimary"/>
</shape>
</item>
<item>
<shape
android:shape="ring"
android:innerRadius="0dp"
android:thickness="2dp"
android:useLevel="false">
<solid android:color="?android:textColorTertiary"/>
</shape>
</item>
</selector>

View File

@ -16,8 +16,10 @@
android:background="?colorPrimary" android:background="?colorPrimary"
app:contentInsetStart="0dp"> app:contentInsetStart="0dp">
<include android:id="@+id/toolbarContent" <org.thoughtcrime.securesms.conversation.ConversationActionBarView
layout="@layout/activity_conversation_v2_action_bar" /> android:id="@+id/toolbarContent"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</androidx.appcompat.widget.Toolbar> </androidx.appcompat.widget.Toolbar>
@ -216,6 +218,29 @@
</RelativeLayout> </RelativeLayout>
<RelativeLayout
android:id="@+id/outdatedBanner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/blockedBanner"
android:background="@color/outdated_client_banner_background_color"
android:visibility="gone"
tools:visibility="visible">
<TextView
android:id="@+id/outdatedBannerTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:layout_centerInParent="true"
android:layout_marginVertical="@dimen/very_small_spacing"
android:layout_marginHorizontal="@dimen/medium_spacing"
android:textColor="@color/black"
android:textSize="@dimen/tiny_font_size"
tools:text="This user's client is outdated, things may not work as expected" />
</RelativeLayout>
<TextView <TextView
android:padding="@dimen/medium_spacing" android:padding="@dimen/medium_spacing"
android:textSize="@dimen/small_font_size" android:textSize="@dimen/small_font_size"

View File

@ -1,68 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="horizontal"
android:gravity="center_vertical">
<org.thoughtcrime.securesms.components.ProfilePictureView
android:id="@+id/profilePictureView"
android:layout_width="@dimen/medium_profile_picture_size"
android:layout_height="@dimen/medium_profile_picture_size" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/conversationTitleView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:contentDescription="@string/AccessibilityId_username"
tools:text="@tools:sample/full_names"
android:textColor="?android:textColorPrimary"
android:textStyle="bold"
android:textSize="@dimen/very_large_font_size"
android:maxLines="1"
android:ellipsize="end" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:orientation="horizontal"
android:gravity="center_vertical">
<ImageView
android:id="@+id/muteIconImageView"
android:layout_width="14dp"
android:layout_height="14dp"
android:layout_marginEnd="4dp"
android:layout_gravity="center"
android:src="@drawable/ic_outline_notifications_off_24"
app:tint="?android:textColorPrimary"
android:alpha="0.6"
android:visibility="gone"
tools:visibility="visible"/>
<TextView
android:id="@+id/conversationSubtitleView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Muted"
android:textColor="?android:textColorPrimary"
android:alpha="0.6"
android:textSize="@dimen/very_small_font_size"
android:maxLines="1"
android:ellipsize="end" />
</LinearLayout>
</LinearLayout>
</LinearLayout>

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?colorPrimary"
app:contentInsetStart="0dp"
app:subtitle="@string/activity_disappearing_messages_subtitle"
app:subtitleTextAppearance="@style/TextAppearance.Session.ToolbarSubtitle"
app:title="@string/activity_disappearing_messages_title" />
<androidx.compose.ui.platform.ComposeView
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>

View File

@ -18,13 +18,28 @@
android:layout_height="24dp" android:layout_height="24dp"
tools:src="@drawable/ic_message"/> tools:src="@drawable/ic_message"/>
<TextView <LinearLayout
android:id="@+id/context_menu_item_title" android:layout_width="match_parent"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="16dp" android:layout_marginStart="16dp"
tools:text="Archive" /> android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/context_menu_item_title"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
tools:text="Archive" />
<TextView
android:id="@+id/context_menu_item_subtitle"
android:textSize="@dimen/tiny_font_size"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
tools:text="subtitle" />
</LinearLayout>
</LinearLayout> </LinearLayout>

View File

@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
tools:parentTag="org.thoughtcrime.securesms.components.ConversationItemFooter">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="6dp"
android:orientation="horizontal"
android:gravity="left|start|center_vertical">
<TextView
android:id="@+id/footer_date"
android:autoLink="none"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="@dimen/very_small_font_size"
android:linksClickable="false"
style="@style/Signal.Text.Caption.MessageSent"
android:textColor="?conversation_item_sent_text_secondary_color"
android:textAllCaps="true"
tools:text="30 mins"/>
<org.thoughtcrime.securesms.conversation.v2.components.ExpirationTimerView
android:id="@+id/footer_expiration_timer"
android:layout_gravity="center_vertical|end"
android:layout_marginStart="6dp"
android:layout_width="12dp"
android:layout_height="12dp"
android:contentDescription="@string/AccessibilityId_timer_icon"
android:visibility="gone"
tools:visibility="visible"/>
</LinearLayout>
<ImageView
android:id="@+id/footer_insecure_indicator"
android:layout_width="12dp"
android:layout_height="11dp"
android:src="@drawable/ic_unlocked_white_18dp"
android:visibility="gone"
android:layout_gravity="center_vertical|end"
android:contentDescription="@string/conversation_item__secure_message_description"
tools:visibility="visible"/>
<org.thoughtcrime.securesms.components.DeliveryStatusView
android:id="@+id/footer_delivery_status"
android:layout_width="20dp"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical" />
</merge>

View File

@ -1,37 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:background="?colorPrimary"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center">
<cn.carbswang.android.numberpickerview.library.NumberPickerView
android:id="@+id/expiration_number_picker"
android:contentDescription="@string/AccessibilityId_disappearing_messages_time_picker"
android:layout_alignParentTop="true"
app:npv_WrapSelectorWheel="false"
app:npv_DividerColor="#cbc8ea"
app:npv_TextColorNormal="?android:textColorPrimary"
app:npv_TextColorSelected="?android:textColorPrimary"
app:npv_ItemPaddingVertical="20dp"
app:npv_TextColorHint="@color/grey_600"
app:npv_TextSizeNormal="16sp"
app:npv_TextSizeSelected="16sp"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
<TextView
android:id="@+id/expiration_details"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/expiration_number_picker"
android:minLines="3"
android:padding="20dp"
tools:text="Your messages will not expire."/>
</RelativeLayout>

View File

@ -1,32 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
style="?android:attr/actionButtonStyle"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clickable="true"
android:focusable="true"
android:gravity="center">
<ImageView
android:id="@+id/menu_badge_icon"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_gravity="center"
android:src="@drawable/ic_timer"
android:background="@color/transparent"
android:scaleType="fitCenter"/>
<TextView
android:id="@+id/expiration_badge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|center_horizontal"
android:gravity="center_horizontal|bottom"
android:paddingBottom="3dp"
android:paddingTop="1dp"
android:background="@color/transparent"
android:textColor="?android:textColorPrimary"
android:textSize="10sp" />
</FrameLayout>

View File

@ -18,6 +18,7 @@
<EditText <EditText
android:id="@+id/communityUrlEditText" android:id="@+id/communityUrlEditText"
android:contentDescription="@string/AccessibilityId_community_input_box"
style="@style/SmallSessionEditText" style="@style/SmallSessionEditText"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="64dp" android:layout_height="64dp"
@ -25,7 +26,6 @@
android:layout_marginTop="@dimen/large_spacing" android:layout_marginTop="@dimen/large_spacing"
android:gravity="center_vertical" android:gravity="center_vertical"
android:hint="@string/fragment_enter_community_url_edit_text_hint" android:hint="@string/fragment_enter_community_url_edit_text_hint"
android:contentDescription="@string/AccessibilityId_community_input_box"
android:inputType="textUri" android:inputType="textUri"
android:maxLines="3" android:maxLines="3"
android:paddingTop="0dp" android:paddingTop="0dp"

View File

@ -1,12 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/backgroundContainer" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="?selectableItemBackground" android:background="?selectableItemBackground"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="@dimen/medium_spacing" android:paddingHorizontal="@dimen/medium_spacing"
android:paddingVertical="@dimen/small_spacing"> android:paddingVertical="@dimen/small_spacing">
@ -14,18 +12,44 @@
android:id="@+id/titleTextView" android:id="@+id/titleTextView"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_margin="@dimen/medium_spacing" android:layout_marginTop="@dimen/small_spacing"
android:layout_weight="1"
android:ellipsize="end" android:ellipsize="end"
android:lines="1" android:lines="1"
android:textSize="@dimen/text_size" 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" />
<TextView
android:id="@+id/subtitleTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/small_spacing"
android:ellipsize="end"
android:lines="1"
android:textSize="@dimen/very_small_font_size"
android:visibility="gone"
app:layout_constraintEnd_toStartOf="@id/selectButton"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/titleTextView"
tools:text="@tools:sample/full_names"
tools:visibility="visible"/>
<View <View
android:id="@+id/selectButton" android:id="@+id/selectButton"
android:layout_width="@dimen/small_radial_size" android:layout_width="@dimen/small_radial_size"
android:layout_height="@dimen/small_radial_size" android:layout_height="@dimen/small_radial_size"
android:background="@drawable/padded_circle_accent_select" android:background="@drawable/padded_circle_accent_select"
android:foreground="@drawable/radial_multi_select" /> 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"/>
</LinearLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -29,6 +29,16 @@
tools:src="@drawable/ic_timer" tools:src="@drawable/ic_timer"
tools:visibility="visible"/> tools:visibility="visible"/>
<org.thoughtcrime.securesms.conversation.v2.components.ExpirationTimerView
android:id="@+id/expirationTimerView"
android:layout_width="12dp"
android:layout_height="12dp"
android:layout_marginBottom="@dimen/small_spacing"
android:visibility="gone"
app:tint="?android:textColorPrimary"
tools:src="@drawable/ic_timer"
tools:visibility="visible"/>
<TextView <TextView
android:id="@+id/textView" android:id="@+id/textView"
android:contentDescription="@string/AccessibilityId_control_message" android:contentDescription="@string/AccessibilityId_control_message"
@ -40,4 +50,35 @@
android:textStyle="bold" android:textStyle="bold"
tools:text="@string/MessageRecord_you_disabled_disappearing_messages" /> tools:text="@string/MessageRecord_you_disabled_disappearing_messages" />
<FrameLayout
android:id="@+id/call_view"
style="@style/CallMessage"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/call_text_view"
android:textColor="?message_received_text_color"
android:textAlignment="center"
android:layout_gravity="center"
tools:text="You missed a call"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:drawableTint="?message_received_text_color"
app:drawableStartCompat="@drawable/ic_missed_call" />
</FrameLayout>
<TextView
android:id="@+id/followSetting"
style="@style/Widget.Session.Button.Common.Borderless"
android:layout_marginTop="4dp"
android:textColor="@color/accent_green"
android:textSize="@dimen/very_small_font_size"
android:text="@string/MessageRecord_follow_setting"
android:contentDescription="@string/AccessibilityId_follow_setting"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</LinearLayout> </LinearLayout>

View File

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:orientation="horizontal"
android:gravity="center_vertical">
<org.thoughtcrime.securesms.components.ProfilePictureView
android:id="@+id/profilePictureView"
android:layout_width="@dimen/medium_profile_picture_size"
android:layout_height="@dimen/medium_profile_picture_size"
android:layout_marginTop="@dimen/medium_spacing"
android:layout_marginStart="@dimen/medium_spacing"
android:layout_marginBottom="@dimen/medium_spacing" />
<LinearLayout
android:id="@+id/conversationTitleContainer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center_horizontal|center_vertical"
android:orientation="vertical">
<TextView
android:id="@+id/conversationTitleView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/AccessibilityId_conversation_header_name"
tools:text="@tools:sample/full_names"
android:textColor="?android:textColorPrimary"
android:textStyle="bold"
android:textSize="@dimen/large_font_size"
android:maxLines="1"
android:ellipsize="end" />
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/settings_pager"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/very_small_spacing"/>
<com.google.android.material.tabs.TabLayout
android:id="@+id/settings_tab_layout"
android:layout_width="wrap_content"
android:layout_height="@dimen/very_small_spacing"
app:tabBackground="@drawable/tab_indicator_dot"
app:tabGravity="center"
app:tabIndicator="@null"
app:tabPaddingStart="@dimen/very_small_spacing"
app:tabPaddingEnd="@dimen/very_small_spacing"/>
</LinearLayout>
</LinearLayout>

View File

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center_horizontal">
<ImageView
android:id="@+id/leftArrowImageView"
android:layout_width="14dp"
android:layout_height="14dp"
android:layout_gravity="center"
android:alpha="0.6"
android:visibility="gone"
android:src="@drawable/ic_baseline_keyboard_arrow_left_24dp"
app:tint="?android:textColorPrimary"
tools:visibility="visible" />
<ImageView
android:id="@+id/iconImageView"
android:layout_width="14dp"
android:layout_height="14dp"
android:layout_gravity="center"
android:layout_marginEnd="4dp"
android:alpha="0.6"
android:visibility="gone"
app:tint="?android:textColorPrimary"
tools:src="@drawable/ic_outline_notifications_off_24"
tools:visibility="visible" />
<TextView
android:id="@+id/titleView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:alpha="0.6"
android:ellipsize="end"
android:maxLines="1"
android:layout_gravity="center"
android:textColor="?android:textColorPrimary"
android:textSize="@dimen/very_small_font_size"
tools:text="Muted" />
<ImageView
android:id="@+id/rightArrowImageView"
android:layout_width="14dp"
android:layout_height="14dp"
android:layout_gravity="center"
android:alpha="0.6"
android:visibility="gone"
android:src="@drawable/ic_baseline_keyboard_arrow_right_24dp"
app:tint="?android:textColorPrimary"
tools:visibility="visible" />
</LinearLayout>

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="?android:attr/actionBarSize"
android:layout_height="?android:attr/actionBarSize">
<LinearLayout
android:layout_width="@dimen/small_profile_picture_size"
android:layout_height="@dimen/small_profile_picture_size"
android:layout_gravity="center"
android:background="?attr/selectableItemBackgroundBorderless">
<include
android:id="@+id/profilePictureView"
layout="@layout/view_profile_picture" />
</LinearLayout>
</LinearLayout>

View File

@ -126,18 +126,6 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" /> android:layout_height="wrap_content" />
<org.thoughtcrime.securesms.conversation.v2.components.ExpirationTimerView
android:id="@+id/expirationTimerView"
android:layout_width="12dp"
android:layout_height="12dp"
android:layout_gravity="center_vertical"
android:layout_marginHorizontal="@dimen/small_spacing"
android:contentDescription="@string/AccessibilityId_timer_icon"
android:visibility="invisible"
tools:visibility="visible"
tools:src="@drawable/timer60"
tools:tint="@color/black"/>
</LinearLayout> </LinearLayout>
</FrameLayout> </FrameLayout>
@ -150,28 +138,41 @@
app:layout_constraintStart_toStartOf="@+id/messageInnerContainer" app:layout_constraintStart_toStartOf="@+id/messageInnerContainer"
app:layout_constraintTop_toBottomOf="@id/messageInnerContainer" /> app:layout_constraintTop_toBottomOf="@id/messageInnerContainer" />
<TextView <LinearLayout
android:id="@+id/messageStatusTextView" android:id="@+id/statusContainer"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="2dp" android:baselineAligned="true"
android:layout_marginBottom="1dp"
app:layout_constraintTop_toTopOf="@id/messageStatusImageView"
app:layout_constraintBottom_toBottomOf="@id/messageStatusImageView"
app:layout_constraintEnd_toStartOf="@id/messageStatusImageView"
android:textSize="@dimen/very_small_font_size"
android:textColor="@color/classic_dark_1"
tools:text="Sent" />
<ImageView
android:id="@+id/messageStatusImageView"
android:layout_width="12dp"
android:layout_height="12dp"
android:layout_marginTop="5dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/emojiReactionsView" app:layout_constraintTop_toBottomOf="@+id/emojiReactionsView"
tools:tint="@color/classic_dark_1" app:layout_constraintStart_toStartOf="@id/messageInnerContainer"
android:src="@drawable/ic_delivery_status_sent" /> app:layout_constraintHorizontal_bias="1"
app:layout_constraintEnd_toEndOf="parent">
<TextView
android:id="@+id/messageStatusTextView"
android:contentDescription="@string/AccessibilityId_message_sent_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="2dp"
android:layout_gravity="center"
android:textSize="@dimen/very_small_font_size"
tools:text="Sent" />
<ImageView
android:id="@+id/messageStatusImageView"
android:layout_width="12dp"
android:layout_height="12dp"
android:layout_gravity="center"
android:src="@drawable/ic_delivery_status_sent" />
<org.thoughtcrime.securesms.conversation.v2.components.ExpirationTimerView
android:id="@+id/expirationTimerView"
android:layout_width="12dp"
android:layout_height="12dp"
android:layout_gravity="center"
android:tint="?message_status_color" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -4,8 +4,7 @@
<item <item
android:title="@string/conversation_expiring_off__disappearing_messages" android:title="@string/conversation_expiring_off__disappearing_messages"
android:contentDescription="@string/AccessibilityId_disappearing_messages" android:id="@+id/menu_expiring_messages"
android:id="@+id/menu_expiring_messages_off" android:contentDescription="@string/AccessibilityId_disappearing_messages" />
android:icon="@drawable/ic_baseline_timer_off_24" />
</menu> </menu>

View File

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/menu_expiring_messages"
android:contentDescription="@string/AccessibilityId_disappearing_messages_timer"
app:actionLayout="@layout/expiration_timer_menu"
app:showAsAction="always"
android:title="@string/menu_conversation_expiring_on__messages_expiring" />
</menu>

View File

@ -322,11 +322,6 @@
<attr name="doc_downloadButtonTint" format="color" /> <attr name="doc_downloadButtonTint" format="color" />
</declare-styleable> </declare-styleable>
<declare-styleable name="ConversationItemFooter">
<attr name="footer_text_color" format="color" />
<attr name="footer_icon_color" format="color" />
</declare-styleable>
<declare-styleable name="ConversationItemThumbnail"> <declare-styleable name="ConversationItemThumbnail">
<attr name="conversationThumbnail_minWidth" format="dimension" /> <attr name="conversationThumbnail_minWidth" format="dimension" />
<attr name="conversationThumbnail_maxWidth" format="dimension" /> <attr name="conversationThumbnail_maxWidth" format="dimension" />

View File

@ -165,4 +165,6 @@
<color name="danger_dark">#FF3A3A</color> <color name="danger_dark">#FF3A3A</color>
<color name="danger_light">#E12D19</color> <color name="danger_light">#E12D19</color>
<color name="outdated_client_banner_background_color">#00F782</color>
</resources> </resources>

View File

@ -2,6 +2,7 @@
<resources> <resources>
<!-- Font Sizes --> <!-- Font Sizes -->
<dimen name="tiny_font_size">9sp</dimen>
<dimen name="very_small_font_size">12sp</dimen> <dimen name="very_small_font_size">12sp</dimen>
<dimen name="small_font_size">15sp</dimen> <dimen name="small_font_size">15sp</dimen>
<dimen name="medium_font_size">17sp</dimen> <dimen name="medium_font_size">17sp</dimen>

View File

@ -52,7 +52,7 @@
<string name="AccessibilityId_new_conversation_button">New conversation button</string> <string name="AccessibilityId_new_conversation_button">New conversation button</string>
<string name="AccessibilityId_new_direct_message">New direct message</string> <string name="AccessibilityId_new_direct_message">New direct message</string>
<string name="AccessibilityId_create_group">Create group</string> <string name="AccessibilityId_create_group">Create group</string>
<string name="AccessibilityId_join_community">Join community</string> <string name="AccessibilityId_join_community">Join community button</string>
<!-- Join community pop up --> <!-- Join community pop up -->
<string name="AccessibilityId_community_input_box">Community input</string> <string name="AccessibilityId_community_input_box">Community input</string>
<string name="AccessibilityId_join_community_button">Join community button</string> <string name="AccessibilityId_join_community_button">Join community button</string>
@ -81,7 +81,7 @@
<!-- Conversation icons --> <!-- Conversation icons -->
<string name="AccessibilityId_call_button">Call button</string> <string name="AccessibilityId_call_button">Call button</string>
<string name="AccessibilityId_settings">Settings</string> <string name="AccessibilityId_settings">Settings</string>
<string name="AccessibilityId_disappearing_messages_timer">Disappearing messages timer</string> <string name="AccessibilityId_confirm">Confirm</string>
<string name="AccessibilityId_disappearing_messages_time_picker">Time selector</string> <string name="AccessibilityId_disappearing_messages_time_picker">Time selector</string>
<string name="AccessibilityId_accept_message_request_button">Accept message request</string> <string name="AccessibilityId_accept_message_request_button">Accept message request</string>
<string name="AccessibilityId_decline_message_request_button">Decline message request</string> <string name="AccessibilityId_decline_message_request_button">Decline message request</string>
@ -113,9 +113,7 @@
<string name="AccessibilityId_download_media">Download media</string> <string name="AccessibilityId_download_media">Download media</string>
<string name="AccessibilityId_dont_download_media">Don\'t download media</string> <string name="AccessibilityId_dont_download_media">Don\'t download media</string>
<!-- Conversation View--> <!-- Conversation View-->
<string name="AccessibilityId_message_sent_status_tick">Message sent status: Sent</string> <string name="AccessibilityId_message_sent_status">Message sent status: Sent</string>
<string name="AccessibilityId_message_sent_status_pending">Message sent status pending</string>
<string name="AccessibilityId_message_sent_status_syncing">Message sent status syncing</string>
<string name="AccessibilityId_message_request_config_message">Message request has been accepted</string> <string name="AccessibilityId_message_request_config_message">Message request has been accepted</string>
<string name="AccessibilityId_message_body">Message body</string> <string name="AccessibilityId_message_body">Message body</string>
<string name="AccessibilityId_voice_message">Voice message</string> <string name="AccessibilityId_voice_message">Voice message</string>
@ -152,6 +150,17 @@
<!-- Link preview Dialog--> <!-- Link preview Dialog-->
<string name="AccessibilityId_enable_link_preview_button">Enable</string> <string name="AccessibilityId_enable_link_preview_button">Enable</string>
<string name="AccessibilityId_cancel_link_preview_button">Cancel</string> <string name="AccessibilityId_cancel_link_preview_button">Cancel</string>
<!-- Disappearing messages -->
<string name="AccessibilityId_disappear_after_read_option">Disappear after read option</string>
<string name="AccessibilityId_disappear_after_send_option">Disappear after send option</string>
<string name="AccessibilityId_disappearing_messages_timer">Disappearing messages timer</string>
<string name="AccessibilityId_set_button">Set button</string>
<string name="AccessibilityId_time_option">Time option</string>
<string name="AccessibilityId_disable_disappearing_messages">Disable disappearing messages</string>
<string name="AccessibilityId_configuration_message">Configuration message</string>
<string name="AccessibilityId_disappearing_messages_type_and_time">Disappearing messages type and time</string>
<string name="AccessibilityId_conversation_header_name">Conversation header name</string>
<!-- AbstractNotificationBuilder --> <!-- AbstractNotificationBuilder -->
<string name="AbstractNotificationBuilder_new_message">New message</string> <string name="AbstractNotificationBuilder_new_message">New message</string>
<!-- AlbumThumbnailView --> <!-- AlbumThumbnailView -->
@ -556,6 +565,12 @@
<string name="arrays__default">Default</string> <string name="arrays__default">Default</string>
<string name="arrays__high">High</string> <string name="arrays__high">High</string>
<string name="arrays__max">Max</string> <string name="arrays__max">Max</string>
<string name="arrays__five_minutes">5 Minutes</string>
<string name="arrays__one_hour">1 Hour</string>
<string name="arrays__twelve_hours">12 Hours</string>
<string name="arrays__one_day">1 Day</string>
<string name="arrays__one_week">1 Week</string>
<string name="arrays__two_weeks">2 Weeks</string>
<!-- plurals.xml --> <!-- plurals.xml -->
<plurals name="hours_ago"> <plurals name="hours_ago">
<item quantity="one">%d hour</item> <item quantity="one">%d hour</item>
@ -888,6 +903,11 @@
<string name="dialog_join_open_group_explanation">Are you sure you want to join the %s open group?</string> <string name="dialog_join_open_group_explanation">Are you sure you want to join the %s open group?</string>
<string name="dialog_open_url_title">Open URL?</string> <string name="dialog_open_url_title">Open URL?</string>
<string name="dialog_open_url_explanation">Are you sure you want to open %s?</string> <string name="dialog_open_url_explanation">Are you sure you want to open %s?</string>
<string name="dialog_disappearing_messages_follow_setting_title">Follow Setting</string>
<string name="dialog_disappearing_messages_follow_setting_off_body">Messages you send will no longer disappear. Are you sure you want to turn off disappearing messages?</string>
<string name="dialog_disappearing_messages_follow_setting_on_body">Set your messages to disappear %1$s after they have been %2$s?</string>
<string name="dialog_disappearing_messages_follow_setting_set">Set</string>
<string name="dialog_disappearing_messages_follow_setting_confirm">Confirm</string>
<string name="open">Open</string> <string name="open">Open</string>
<string name="copy_url">Copy URL</string> <string name="copy_url">Copy URL</string>
<string name="dialog_link_preview_title">Enable Link Previews?</string> <string name="dialog_link_preview_title">Enable Link Previews?</string>
@ -1021,6 +1041,21 @@
<string name="fragment_enter_community_url_join_button_title">Join</string> <string name="fragment_enter_community_url_join_button_title">Join</string>
<string name="new_conversation_dialog_back_button_content_description">Navigate Back</string> <string name="new_conversation_dialog_back_button_content_description">Navigate Back</string>
<string name="new_conversation_dialog_close_button_content_description">Close Dialog</string> <string name="new_conversation_dialog_close_button_content_description">Close Dialog</string>
<string name="activity_disappearing_messages_title">Disappearing Messages</string>
<string name="activity_disappearing_messages_subtitle">This setting applies to messages you send in this conversation.</string>
<string name="expiration_type_disappear_legacy_description">Original version of disappearing messages.</string>
<string name="expiration_type_disappear_legacy">Legacy</string>
<string name="activity_disappearing_messages_subtitle_sent">Messages disappear after they have been sent.</string>
<string name="expiration_type_disappear_after_read">Disappear After Read</string>
<string name="expiration_type_disappear_after_read_description">Messages delete after they have been read.</string>
<string name="expiration_type_disappear_after_send">Disappear After Send</string>
<string name="expiration_type_disappear_after_send_description">Messages delete after they have been sent.</string>
<string name="disappearing_messages_set_button_title">Set</string>
<string name="activity_disappearing_messages_delete_type">Delete Type</string>
<string name="activity_disappearing_messages_timer">Timer</string>
<string name="activity_disappearing_messages_group_footer">This setting applies to everyone in this conversation.\nOnly group admins can change this setting.</string>
<string name="activity_conversation_outdated_client_banner_text">%s is using an outdated client. Disappearing messages may not work as expected.</string>
<string name="DisappearingMessagesActivity_settings_not_updated">Settings not updated and please try again</string>
<string name="ErrorNotifier_migration">Database Upgrade Failed</string> <string name="ErrorNotifier_migration">Database Upgrade Failed</string>
<string name="ErrorNotifier_migration_downgrade">Please contact support to report the error.</string> <string name="ErrorNotifier_migration_downgrade">Please contact support to report the error.</string>
<string name="delivery_status_syncing">Syncing</string> <string name="delivery_status_syncing">Syncing</string>
@ -1039,6 +1074,8 @@
<string name="activity_conversation_empty_state_default">You have no messages from <b>%s</b>.\nSend a message to start the conversation!</string> <string name="activity_conversation_empty_state_default">You have no messages from <b>%s</b>.\nSend a message to start the conversation!</string>
<string name="unread_marker">Unread Messages</string> <string name="unread_marker">Unread Messages</string>
<string name="auto_deletes_in">Auto-deletes in %1$s</string>
<string name="activity_recovery_password">Recovery Password</string> <string name="activity_recovery_password">Recovery Password</string>
<string name="onboarding_privacy_in_your_pocket">Privacy in your pocket.</string> <string name="onboarding_privacy_in_your_pocket">Privacy in your pocket.</string>

View File

@ -69,6 +69,10 @@
<item name="android:textColor">@color/emoji_tab_text_color</item> <item name="android:textColor">@color/emoji_tab_text_color</item>
</style> </style>
<style name="TextAppearance.Session.ToolbarSubtitle" parent="TextAppearance.MaterialComponents.Caption">
<item name="android:textSize">11dp</item>
</style>
<!-- TODO These button styles require proper background selectors for up/down visual state. --> <!-- TODO These button styles require proper background selectors for up/down visual state. -->
<style name="Widget.Session.Button.Common" parent=""> <style name="Widget.Session.Button.Common" parent="">
<item name="android:gravity">center</item> <item name="android:gravity">center</item>
@ -189,6 +193,17 @@
<item name="android:maxLines">1</item> <item name="android:maxLines">1</item>
</style> </style>
<style name="CallMessage">
<item name="android:background">@drawable/call_message_background</item>
<item name="android:textColor">?message_received_text_color</item>
<item name="android:paddingLeft">@dimen/medium_spacing</item>
<item name="android:paddingTop">12dp</item>
<item name="android:paddingRight">@dimen/medium_spacing</item>
<item name="android:paddingBottom">12dp</item>
<item name="android:textSize">15sp</item>
<item name="android:fontFamily">sans-serif-medium</item>
</style>
<!-- Session --> <!-- Session -->
<style name="NoAnimation.Theme.AppCompat.Light.DarkActionBar" parent="@style/Theme.AppCompat.Light.DarkActionBar"> <style name="NoAnimation.Theme.AppCompat.Light.DarkActionBar" parent="@style/Theme.AppCompat.Light.DarkActionBar">

View File

@ -0,0 +1,25 @@
package org.thoughtcrime.securesms
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestWatcher
import org.junit.runner.Description
@ExperimentalCoroutinesApi
class MainCoroutineRule(private val dispatcher: TestDispatcher = StandardTestDispatcher()) :
TestWatcher() {
override fun starting(description: Description) {
super.starting(description)
Dispatchers.setMain(dispatcher)
}
override fun finished(description: Description) {
super.finished(description)
Dispatchers.resetMain()
}
}

Some files were not shown because too many files have changed in this diff Show More