Merge branch 'dev' into SA1346_AppCrashOnNonAlphanumericFirstCharSearch
commit
73f11c5a4f
@ -0,0 +1,88 @@
|
||||
local docker_base = 'registry.oxen.rocks/lokinet-ci-';
|
||||
|
||||
// Log a bunch of version information to make it easier for debugging
|
||||
local version_info = {
|
||||
name: 'Version Information',
|
||||
image: docker_base + 'android',
|
||||
commands: [
|
||||
'cmake --version',
|
||||
'apt --installed list'
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
// Intentionally doing a depth of 2 as libSession-util has it's own submodules (and libLokinet likely will as well)
|
||||
local clone_submodules = {
|
||||
name: 'Clone Submodules',
|
||||
image: 'drone/git',
|
||||
commands: ['git fetch --tags', 'git submodule update --init --recursive --depth=2 --jobs=4']
|
||||
};
|
||||
|
||||
// cmake options for static deps mirror
|
||||
local ci_dep_mirror(want_mirror) = (if want_mirror then ' -DLOCAL_MIRROR=https://oxen.rocks/deps ' else '');
|
||||
|
||||
[
|
||||
// Unit tests (PRs only)
|
||||
{
|
||||
kind: 'pipeline',
|
||||
type: 'docker',
|
||||
name: 'Unit Tests',
|
||||
platform: { arch: 'amd64' },
|
||||
trigger: { event: { exclude: [ 'push' ] } },
|
||||
steps: [
|
||||
version_info,
|
||||
clone_submodules,
|
||||
{
|
||||
name: 'Run Unit Tests',
|
||||
image: docker_base + 'android',
|
||||
pull: 'always',
|
||||
environment: { ANDROID_HOME: '/usr/lib/android-sdk' },
|
||||
commands: [
|
||||
'apt-get install -y ninja-build',
|
||||
'./gradlew testPlayDebugUnitTestCoverageReport'
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
// Validate build artifact was created by the direct branch push (PRs only)
|
||||
{
|
||||
kind: 'pipeline',
|
||||
type: 'docker',
|
||||
name: 'Check Build Artifact Existence',
|
||||
platform: { arch: 'amd64' },
|
||||
trigger: { event: { exclude: [ 'push' ] } },
|
||||
steps: [
|
||||
{
|
||||
name: 'Poll for build artifact existence',
|
||||
image: docker_base + 'android',
|
||||
pull: 'always',
|
||||
commands: [
|
||||
'./scripts/drone-upload-exists.sh'
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
// Debug APK build (non-PRs only)
|
||||
{
|
||||
kind: 'pipeline',
|
||||
type: 'docker',
|
||||
name: 'Debug APK Build',
|
||||
platform: { arch: 'amd64' },
|
||||
trigger: { event: { exclude: [ 'pull_request' ] } },
|
||||
steps: [
|
||||
version_info,
|
||||
clone_submodules,
|
||||
{
|
||||
name: 'Build and upload',
|
||||
image: docker_base + 'android',
|
||||
pull: 'always',
|
||||
environment: { SSH_KEY: { from_secret: 'SSH_KEY' }, ANDROID_HOME: '/usr/lib/android-sdk' },
|
||||
commands: [
|
||||
'apt-get install -y ninja-build',
|
||||
'./gradlew assemblePlayDebug',
|
||||
'./scripts/drone-static-upload.sh'
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
@ -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()
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
@ -1,13 +1,17 @@
|
||||
package org.thoughtcrime.securesms.components.menu
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.ColorRes
|
||||
|
||||
/**
|
||||
* Represents an action to be rendered
|
||||
*/
|
||||
data class ActionItem @JvmOverloads constructor(
|
||||
data class ActionItem(
|
||||
@AttrRes val iconRes: Int,
|
||||
val title: CharSequence,
|
||||
val title: Int,
|
||||
val action: Runnable,
|
||||
val contentDescription: String? = null
|
||||
val contentDescription: Int? = null,
|
||||
val subtitle: ((Context) -> CharSequence?)? = null,
|
||||
@ColorRes val color: Int? = null,
|
||||
)
|
||||
|
@ -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
|
||||
}
|
@ -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)
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
@ -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
|
||||
}
|
@ -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
|
||||
)
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
@ -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())
|
||||
}
|
@ -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,
|
||||
}
|
||||
}
|
@ -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) }
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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()
|
||||
}
|
@ -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)
|
||||
}
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,114 +0,0 @@
|
||||
package org.thoughtcrime.securesms.onboarding
|
||||
|
||||
import android.content.Intent
|
||||
import android.graphics.Typeface
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.text.Spannable
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.text.style.ClickableSpan
|
||||
import android.text.style.StyleSpan
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import network.loki.messenger.R
|
||||
import network.loki.messenger.databinding.ActivityRecoveryPhraseRestoreBinding
|
||||
import org.session.libsession.snode.SnodeModule
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.session.libsignal.crypto.MnemonicCodec
|
||||
import org.session.libsignal.database.LokiAPIDatabaseProtocol
|
||||
import org.session.libsignal.utilities.Hex
|
||||
import org.session.libsignal.utilities.KeyHelper
|
||||
import org.session.libsignal.utilities.hexEncodedPublicKey
|
||||
import org.thoughtcrime.securesms.BaseActionBarActivity
|
||||
import org.thoughtcrime.securesms.crypto.KeyPairUtilities
|
||||
import org.thoughtcrime.securesms.crypto.MnemonicUtilities
|
||||
import org.thoughtcrime.securesms.dependencies.ConfigFactory
|
||||
import org.thoughtcrime.securesms.util.push
|
||||
import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class RecoveryPhraseRestoreActivity : BaseActionBarActivity() {
|
||||
|
||||
@Inject
|
||||
lateinit var configFactory: ConfigFactory
|
||||
|
||||
private lateinit var binding: ActivityRecoveryPhraseRestoreBinding
|
||||
internal val database: LokiAPIDatabaseProtocol
|
||||
get() = SnodeModule.shared.storage
|
||||
// region Lifecycle
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setUpActionBarSessionLogo()
|
||||
TextSecurePreferences.apply {
|
||||
setHasViewedSeed(this@RecoveryPhraseRestoreActivity, true)
|
||||
setConfigurationMessageSynced(this@RecoveryPhraseRestoreActivity, false)
|
||||
setRestorationTime(this@RecoveryPhraseRestoreActivity, System.currentTimeMillis())
|
||||
setLastProfileUpdateTime(this@RecoveryPhraseRestoreActivity, System.currentTimeMillis())
|
||||
}
|
||||
binding = ActivityRecoveryPhraseRestoreBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
binding.mnemonicEditText.imeOptions = binding.mnemonicEditText.imeOptions or 16777216 // Always use incognito keyboard
|
||||
binding.restoreButton.setOnClickListener { restore() }
|
||||
val termsExplanation = SpannableStringBuilder("By using this service, you agree to our Terms of Service and Privacy Policy")
|
||||
termsExplanation.setSpan(StyleSpan(Typeface.BOLD), 40, 56, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
termsExplanation.setSpan(object : ClickableSpan() {
|
||||
|
||||
override fun onClick(widget: View) {
|
||||
openURL("https://getsession.org/terms-of-service/")
|
||||
}
|
||||
}, 40, 56, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
termsExplanation.setSpan(StyleSpan(Typeface.BOLD), 61, 75, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
termsExplanation.setSpan(object : ClickableSpan() {
|
||||
|
||||
override fun onClick(widget: View) {
|
||||
openURL("https://getsession.org/privacy-policy/")
|
||||
}
|
||||
}, 61, 75, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
binding.termsTextView.movementMethod = LinkMovementMethod.getInstance()
|
||||
binding.termsTextView.text = termsExplanation
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region Interaction
|
||||
private fun restore() {
|
||||
val mnemonic = binding.mnemonicEditText.text.toString()
|
||||
try {
|
||||
// This is here to resolve a case where the app restarts before a user completes onboarding
|
||||
// which can result in an invalid database state
|
||||
database.clearAllLastMessageHashes()
|
||||
database.clearReceivedMessageHashValues()
|
||||
|
||||
val loadFileContents: (String) -> String = { fileName ->
|
||||
MnemonicUtilities.loadFileContents(this, fileName)
|
||||
}
|
||||
val hexEncodedSeed = MnemonicCodec(loadFileContents).decode(mnemonic)
|
||||
val seed = Hex.fromStringCondensed(hexEncodedSeed)
|
||||
val keyPairGenerationResult = KeyPairUtilities.generate(seed)
|
||||
val x25519KeyPair = keyPairGenerationResult.x25519KeyPair
|
||||
KeyPairUtilities.store(this, seed, keyPairGenerationResult.ed25519KeyPair, x25519KeyPair)
|
||||
configFactory.keyPairChanged()
|
||||
val userHexEncodedPublicKey = x25519KeyPair.hexEncodedPublicKey
|
||||
val registrationID = KeyHelper.generateRegistrationId(false)
|
||||
TextSecurePreferences.setLocalRegistrationId(this, registrationID)
|
||||
TextSecurePreferences.setLocalNumber(this, userHexEncodedPublicKey)
|
||||
val intent = Intent(this, DisplayNameActivity::class.java)
|
||||
push(intent)
|
||||
} catch (e: Exception) {
|
||||
val message = if (e is MnemonicCodec.DecodingError) e.description else MnemonicCodec.DecodingError.Generic.description
|
||||
return Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun openURL(url: String) {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(this, R.string.invalid_url, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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 })
|
||||
}
|
||||
}
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="100dp"
|
||||
android:height="100dp"
|
||||
android:viewportWidth="100"
|
||||
android:viewportHeight="100">
|
||||
|
||||
<path
|
||||
android:pathData="M0,0 L100,100 M0,100 L100,0"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="@android:color/white" />
|
||||
</vector>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -1,78 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"/>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="@dimen/very_large_spacing"
|
||||
android:layout_marginRight="@dimen/very_large_spacing"
|
||||
android:textSize="@dimen/very_large_font_size"
|
||||
android:textStyle="bold"
|
||||
android:textColor="?android:textColorPrimary"
|
||||
android:text="@string/activity_restore_title" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="@dimen/very_large_spacing"
|
||||
android:layout_marginTop="7dp"
|
||||
android:layout_marginRight="@dimen/very_large_spacing"
|
||||
android:textSize="@dimen/medium_font_size"
|
||||
android:textColor="?android:textColorPrimary"
|
||||
android:text="@string/activity_restore_explanation" />
|
||||
|
||||
<EditText
|
||||
style="@style/SessionEditText"
|
||||
android:id="@+id/mnemonicEditText"
|
||||
android:contentDescription="@string/AccessibilityId_enter_your_recovery_phrase"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="64dp"
|
||||
android:layout_marginLeft="@dimen/very_large_spacing"
|
||||
android:layout_marginTop="12dp"
|
||||
android:layout_marginRight="@dimen/very_large_spacing"
|
||||
android:paddingTop="0dp"
|
||||
android:paddingBottom="0dp"
|
||||
android:gravity="center_vertical"
|
||||
android:inputType="textMultiLine"
|
||||
android:maxLines="3"
|
||||
android:hint="@string/activity_restore_seed_edit_text_hint" />
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"/>
|
||||
|
||||
<Button
|
||||
style="@style/Widget.Session.Button.Common.ProminentFilled"
|
||||
android:id="@+id/restoreButton"
|
||||
android:contentDescription="@string/AccessibilityId_continue"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="@dimen/medium_button_height"
|
||||
android:layout_marginLeft="@dimen/massive_spacing"
|
||||
android:layout_marginRight="@dimen/massive_spacing"
|
||||
android:text="@string/continue_2" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/termsTextView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="@dimen/onboarding_button_bottom_offset"
|
||||
android:layout_marginLeft="@dimen/massive_spacing"
|
||||
android:layout_marginRight="@dimen/massive_spacing"
|
||||
android:gravity="center"
|
||||
android:textColor="?android:textColorTertiary"
|
||||
android:textColorLink="?colorAccent"
|
||||
android:textSize="@dimen/very_small_font_size"
|
||||
android:text="By using this service, you agree to our Terms of Service and Privacy Policy"
|
||||
tools:ignore="HardcodedText" /> <!-- Intentionally not yet translated -->
|
||||
|
||||
</LinearLayout>
|
@ -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>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue