Use compose

This commit is contained in:
andrew 2023-08-24 22:37:25 +09:30
parent 5142c45643
commit 71b2544c31
10 changed files with 414 additions and 442 deletions

View File

@ -166,14 +166,14 @@ dependencies {
testImplementation 'org.robolectric:shadows-multidex:4.4' testImplementation 'org.robolectric:shadows-multidex:4.4'
implementation 'com.github.bumptech.glide:compose:1.0.0-alpha.1' implementation 'com.github.bumptech.glide:compose:1.0.0-alpha.1'
implementation 'androidx.compose.ui:ui:1.4.3' implementation 'androidx.compose.ui:ui:1.5.0'
implementation 'androidx.compose.ui:ui-tooling:1.4.3' implementation 'androidx.compose.ui:ui-tooling:1.5.0'
implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.31.5-beta" implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.31.5-beta"
implementation "com.google.accompanist:accompanist-pager-indicators:0.31.5-beta" implementation "com.google.accompanist:accompanist-pager-indicators:0.31.5-beta"
implementation "androidx.compose.runtime:runtime-livedata:1.4.3" implementation "androidx.compose.runtime:runtime-livedata:1.5.0"
implementation 'androidx.compose.foundation:foundation-layout:1.5.0-alpha02' implementation 'androidx.compose.foundation:foundation-layout:1.5.0'
implementation 'androidx.compose.material:material:1.5.0-alpha02' implementation 'androidx.compose.material:material:1.5.0'
} }
def canonicalVersionCode = 354 def canonicalVersionCode = 354

View File

@ -1,33 +1,58 @@
package org.thoughtcrime.securesms.conversation.expiration package org.thoughtcrime.securesms.conversation.expiration
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable
import android.util.SparseArray
import android.widget.Toast import android.widget.Toast
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.core.text.HtmlCompat import androidx.compose.foundation.BorderStroke
import androidx.core.view.isVisible import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton
import androidx.compose.material.RadioButton
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator
import com.google.android.material.divider.MaterialDividerItemDecoration
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import network.loki.messenger.BuildConfig
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityExpirationSettingsBinding import network.loki.messenger.databinding.ActivityExpirationSettingsBinding
import network.loki.messenger.libsession_util.util.ExpiryMode
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.database.RecipientDatabase import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.preferences.ExpirationRadioOption import org.thoughtcrime.securesms.ui.AppTheme
import org.thoughtcrime.securesms.preferences.RadioOptionAdapter import org.thoughtcrime.securesms.ui.CellNoMargin
import org.thoughtcrime.securesms.preferences.radioOption import org.thoughtcrime.securesms.ui.GetString
import org.thoughtcrime.securesms.ui.PreviewTheme
import org.thoughtcrime.securesms.ui.ThemeResPreviewParameterProvider
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.max
@AndroidEntryPoint @AndroidEntryPoint
class ExpirationSettingsActivity: PassphraseRequiredActionBarActivity() { class ExpirationSettingsActivity: PassphraseRequiredActionBarActivity() {
@ -43,34 +68,16 @@ class ExpirationSettingsActivity: PassphraseRequiredActionBarActivity() {
} }
private val viewModel: ExpirationSettingsViewModel by viewModels { private val viewModel: ExpirationSettingsViewModel by viewModels {
val afterReadOptions = resources.getIntArray(R.array.read_expiration_time_values) viewModelFactory.create(threadId)
.zip(resources.getStringArray(R.array.read_expiration_time_names)) { value, name ->
radioOption(ExpiryMode.AfterRead(value.toLong()), name) { contentDescription(R.string.AccessibilityId_time_option) }
}
val afterSendOptions = resources.getIntArray(R.array.send_expiration_time_values)
.zip(resources.getStringArray(R.array.send_expiration_time_names)) { value, name ->
radioOption(ExpiryMode.AfterSend(value.toLong()), name) { contentDescription(R.string.AccessibilityId_time_option) }
}
viewModelFactory.create(threadId, mayAddTestExpiryOption(afterReadOptions), mayAddTestExpiryOption(afterSendOptions))
} }
private fun mayAddTestExpiryOption(expiryOptions: List<ExpirationRadioOption>): List<ExpirationRadioOption> =
if (BuildConfig.DEBUG) {
when (expiryOptions.first().value) {
is ExpiryMode.AfterRead -> ExpiryMode.AfterRead(60)
is ExpiryMode.AfterSend -> ExpiryMode.AfterSend(60)
is ExpiryMode.Legacy -> ExpiryMode.Legacy(60)
ExpiryMode.NONE -> ExpiryMode.NONE // shouldn't happen
}.let { radioOption(it, "1 Minute (for testing purposes)") }
.let { expiryOptions.toMutableList().apply { add(1, it) } }
} else expiryOptions
override fun onSaveInstanceState(outState: Bundle) { // override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState) // super.onSaveInstanceState(outState)
val scrollParcelArray = SparseArray<Parcelable>() // val scrollParcelArray = SparseArray<Parcelable>()
binding.scrollView.saveHierarchyState(scrollParcelArray) // binding.scrollView.saveHierarchyState(scrollParcelArray)
outState.putSparseParcelableArray(SCROLL_PARCEL, scrollParcelArray) // outState.putSparseParcelableArray(SCROLL_PARCEL, scrollParcelArray)
} // }
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
super.onCreate(savedInstanceState, ready) super.onCreate(savedInstanceState, ready)
@ -79,45 +86,35 @@ class ExpirationSettingsActivity: PassphraseRequiredActionBarActivity() {
setUpToolbar() setUpToolbar()
savedInstanceState?.let { bundle -> binding.container.setContent { DisappearingMessagesScreen() }
val scrollStateParcel = bundle.getSparseParcelableArray<Parcelable>(SCROLL_PARCEL)
if (scrollStateParcel != null) {
binding.scrollView.restoreHierarchyState(scrollStateParcel)
}
}
val deleteTypeOptions = viewModel.getDeleteOptions() // savedInstanceState?.let { bundle ->
val deleteTypeOptionAdapter = RadioOptionAdapter { // val scrollStateParcel = bundle.getSparseParcelableArray<Parcelable>(SCROLL_PARCEL)
viewModel.onExpirationTypeSelected(it) // if (scrollStateParcel != null) {
} // binding.scrollView.restoreHierarchyState(scrollStateParcel)
binding.recyclerViewDeleteTypes.apply { // }
(itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false // }
adapter = deleteTypeOptionAdapter
addDividers()
setHasFixedSize(true)
}
deleteTypeOptionAdapter.submitList(deleteTypeOptions)
val timerOptionAdapter = RadioOptionAdapter { // val deleteTypeOptions = viewModel.getDeleteOptions()
viewModel.onExpirationTimerSelected(it)
} // binding.buttonSet.setOnClickListener {
binding.recyclerViewTimerOptions.apply { // viewModel.onSetClick()
(itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false // }
adapter = timerOptionAdapter
addDividers()
}
binding.buttonSet.setOnClickListener {
viewModel.onSetClick()
}
lifecycleScope.launch { lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) { repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { uiState -> viewModel.state.collect { state ->
binding.textViewDeleteType.isVisible = uiState.showExpirationTypeSelector // actionBar?.subtitle = if (state.selectedExpirationType.value is ExpiryMode.AfterSend) {
binding.layoutDeleteTypes.isVisible = uiState.showExpirationTypeSelector // getString(R.string.activity_expiration_settings_subtitle_sent)
binding.textViewFooter.isVisible = uiState.recipient?.isClosedGroupRecipient == true // } else {
binding.textViewFooter.text = HtmlCompat.fromHtml(getString(R.string.activity_expiration_settings_group_footer), HtmlCompat.FROM_HTML_MODE_COMPACT) // getString(R.string.activity_expiration_settings_subtitle)
// }
when (uiState.settingsSaved) { // binding.textViewDeleteType.isVisible = state.showExpirationTypeSelector
// binding.layoutDeleteTypes.isVisible = state.showExpirationTypeSelector
// binding.textViewFooter.isVisible = state.recipient?.isClosedGroupRecipient == true
// binding.textViewFooter.text = HtmlCompat.fromHtml(getString(R.string.activity_expiration_settings_group_footer), HtmlCompat.FROM_HTML_MODE_COMPACT)
when (state.settingsSaved) {
true -> { true -> {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@ExpirationSettingsActivity) ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@ExpirationSettingsActivity)
finish() finish()
@ -125,47 +122,39 @@ class ExpirationSettingsActivity: PassphraseRequiredActionBarActivity() {
false -> showToast(getString(R.string.ExpirationSettingsActivity_settings_not_updated)) false -> showToast(getString(R.string.ExpirationSettingsActivity_settings_not_updated))
else -> {} else -> {}
} }
}
}
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.selectedExpirationType.collect { type ->
val position = deleteTypeOptions.indexOfFirst { it.value == type }
deleteTypeOptionAdapter.setSelectedPosition(max(0, position))
}
}
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.selectedExpirationTimer.collect { option ->
val position =
viewModel.expirationTimerOptions.value.indexOfFirst { it.value == option?.value }
timerOptionAdapter.setSelectedPosition(max(0, position))
}
}
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.expirationTimerOptions.collect { options ->
binding.textViewTimer.isVisible =
options.isNotEmpty() && viewModel.uiState.value.showExpirationTypeSelector
binding.layoutTimer.isVisible = options.isNotEmpty()
timerOptionAdapter.submitList(options)
}
}
}
}
private fun RecyclerView.addDividers() { // val position = deleteTypeOptions.indexOfFirst { it.value == state.selectedExpirationType }
addItemDecoration( // deleteTypeOptionAdapter.setSelectedPosition(max(0, position))
MaterialDividerItemDecoration( }
this@ExpirationSettingsActivity,
RecyclerView.VERTICAL
).apply {
isLastItemDecorated = false
} }
) }
// lifecycleScope.launch {
// repeatOnLifecycle(Lifecycle.State.STARTED) {
// viewModel.selectedExpirationType.collect { type ->
// val position = deleteTypeOptions.indexOfFirst { it.value == type }
// deleteTypeOptionAdapter.setSelectedPosition(max(0, position))
// }
// }
// }
// lifecycleScope.launch {
// repeatOnLifecycle(Lifecycle.State.STARTED) {
// viewModel.selectedExpirationTimer.collect { option ->
// val position =
// viewModel.expirationTimerOptions.value.indexOfFirst { it.value == option?.value }
// timerOptionAdapter.setSelectedPosition(max(0, position))
// }
// }
// }
// lifecycleScope.launch {
// repeatOnLifecycle(Lifecycle.State.STARTED) {
// viewModel.expirationTimerOptions.collect { options ->
// binding.textViewTimer.isVisible =
// options.isNotEmpty() && viewModel.uiState.value.showExpirationTypeSelector
// binding.layoutTimer.isVisible = options.isNotEmpty()
// timerOptionAdapter.submitList(options)
// }
// }
// }
} }
private fun showToast(message: String) { private fun showToast(message: String) {
@ -176,11 +165,6 @@ class ExpirationSettingsActivity: PassphraseRequiredActionBarActivity() {
setSupportActionBar(binding.toolbar) setSupportActionBar(binding.toolbar)
val actionBar = supportActionBar ?: return val actionBar = supportActionBar ?: return
actionBar.title = getString(R.string.activity_expiration_settings_title) actionBar.title = getString(R.string.activity_expiration_settings_title)
actionBar.subtitle = if (viewModel.selectedExpirationType.value is ExpiryMode.AfterSend) {
getString(R.string.activity_expiration_settings_subtitle_sent)
} else {
getString(R.string.activity_expiration_settings_subtitle)
}
actionBar.setDisplayHomeAsUpEnabled(true) actionBar.setDisplayHomeAsUpEnabled(true)
actionBar.setHomeButtonEnabled(true) actionBar.setHomeButtonEnabled(true)
} }
@ -190,4 +174,139 @@ class ExpirationSettingsActivity: PassphraseRequiredActionBarActivity() {
const val THREAD_ID = "thread_id" const val THREAD_ID = "thread_id"
} }
} @Composable
fun DisappearingMessagesScreen() {
val uiState by viewModel.uiState.collectAsState(UiState())
AppTheme {
DisappearingMessages(uiState, onSetClick = viewModel::onSetClick)
}
}
}
@Composable
fun DisappearingMessages(
state: UiState,
modifier: Modifier = Modifier,
onSetClick: () -> Unit = {}
) {
Column(modifier = modifier) {
Box(modifier = Modifier.weight(1f)) {
Column(
modifier = Modifier
.padding(horizontal = 32.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
state.cards.filter { it.options.isNotEmpty() }.forEach { OptionsCard(it) }
}
Gradient(100.dp, modifier = Modifier.align(Alignment.BottomCenter))
}
OutlineButton(
stringResource(R.string.expiration_settings_set_button_title),
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(bottom = 20.dp),
onClick = onSetClick
)
}
}
@Composable
fun OptionsCard(card: CardModel) {
Text(text = card.title())
CellNoMargin {
LazyColumn(
modifier = Modifier.heightIn(max = 5000.dp)
) {
items(card.options) {
TitledRadioButton(it)
}
}
}
}
@Composable
fun Gradient(height: Dp, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxWidth()
.height(height)
.background(
brush = Brush.verticalGradient(
colors = listOf(Color.Transparent, MaterialTheme.colors.primary),
startY = 0f,
endY = height.value
)
)
)
}
@Composable
fun TitledRadioButton(option: OptionModel) {
Row(modifier = Modifier
.heightIn(min = 60.dp)
.padding(horizontal = 34.dp)) {
Column(modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically)) {
Column {
Text(text = option.title())
option.subtitle?.let { Text(text = it()) }
}
}
RadioButton(
selected = option.selected,
onClick = option.onClick,
modifier = Modifier
.height(26.dp)
.align(Alignment.CenterVertically)
)
}
}
@Composable
fun OutlineButton(text: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
OutlinedButton(
modifier = modifier.size(108.dp, 34.dp),
onClick = onClick,
border = BorderStroke(1.dp, MaterialTheme.colors.secondary),
shape = RoundedCornerShape(50), // = 50% percent
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colors.secondary)
){
Text(text = text)
}
}
@Preview
@Composable
fun PreviewMessageDetails(
@PreviewParameter(ThemeResPreviewParameterProvider::class) themeResId: Int
) {
PreviewTheme(themeResId) {
DisappearingMessages(
UiState(
cards = listOf(
CardModel(GetString(R.string.activity_expiration_settings_delete_type), typeOptions()),
CardModel(GetString(R.string.activity_expiration_settings_timer), timeOptions())
)
),
modifier = Modifier.size(400.dp, 600.dp)
)
}
}
fun typeOptions() = listOf(
OptionModel(GetString(R.string.expiration_off)),
OptionModel(GetString(R.string.expiration_type_disappear_legacy)),
OptionModel(GetString(R.string.expiration_type_disappear_after_read)),
OptionModel(GetString(R.string.expiration_type_disappear_after_send))
)
fun timeOptions() = listOf(
OptionModel(GetString("1 Minute")),
OptionModel(GetString("5 Minutes")),
OptionModel(GetString("1 Week")),
OptionModel(GetString("2 Weeks")),
)

View File

@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.conversation.expiration package org.thoughtcrime.securesms.conversation.expiration
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@ -7,36 +8,31 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import network.loki.messenger.BuildConfig
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.libsession_util.util.ExpiryMode import network.loki.messenger.libsession_util.util.ExpiryMode
import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate import org.session.libsession.utilities.ExpirationUtil
import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.GroupRecord
import org.session.libsession.utilities.SSKEnvironment.MessageExpirationManagerProtocol import org.session.libsession.utilities.SSKEnvironment.MessageExpirationManagerProtocol
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.guava.Optional
import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.GroupDatabase
import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.preferences.ExpirationRadioOption import org.thoughtcrime.securesms.ui.GetString
import org.thoughtcrime.securesms.preferences.RadioOption import kotlin.time.Duration
import org.thoughtcrime.securesms.preferences.radioOption import kotlin.time.Duration.Companion.days
import kotlin.reflect.KClass import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class ExpirationSettingsViewModel( class ExpirationSettingsViewModel(
private val threadId: Long, private val threadId: Long,
private val afterReadOptions: List<ExpirationRadioOption>,
private val afterSendOptions: List<ExpirationRadioOption>,
private val textSecurePreferences: TextSecurePreferences, private val textSecurePreferences: TextSecurePreferences,
private val messageExpirationManager: MessageExpirationManagerProtocol, private val messageExpirationManager: MessageExpirationManagerProtocol,
private val threadDb: ThreadDatabase, private val threadDb: ThreadDatabase,
@ -44,47 +40,38 @@ class ExpirationSettingsViewModel(
private val storage: Storage private val storage: Storage
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(ExpirationSettingsUiState()) private val _state = MutableStateFlow(State())
val uiState: StateFlow<ExpirationSettingsUiState> = _uiState val state = _state.asStateFlow()
val uiState = _state.map {
UiState(
cards = listOf(
CardModel(GetString(R.string.activity_expiration_settings_delete_type), typeOptions()),
CardModel(GetString(R.string.activity_expiration_settings_timer), timeOptions(it))
)
)
}
private var expirationConfig: ExpirationConfiguration? = null private var expirationConfig: ExpirationConfiguration? = null
private val _selectedExpirationType: MutableStateFlow<ExpiryMode> = MutableStateFlow(ExpiryMode.NONE)
val selectedExpirationType: StateFlow<ExpiryMode> = _selectedExpirationType
private val _selectedExpirationTimer = MutableStateFlow(afterSendOptions.firstOrNull())
val selectedExpirationTimer: StateFlow<RadioOption<ExpiryMode>?> = _selectedExpirationTimer
private val _expirationTimerOptions = MutableStateFlow<List<RadioOption<ExpiryMode>>>(emptyList())
val expirationTimerOptions: StateFlow<List<RadioOption<ExpiryMode>>> = _expirationTimerOptions
init { init {
// SETUP // SETUP
viewModelScope.launch { viewModelScope.launch {
expirationConfig = storage.getExpirationConfiguration(threadId) expirationConfig = storage.getExpirationConfiguration(threadId)
val expirationType = expirationConfig?.expiryMode val expiryMode = expirationConfig?.expiryMode ?: ExpiryMode.NONE
val recipient = threadDb.getRecipientForThreadId(threadId) val recipient = threadDb.getRecipientForThreadId(threadId)
val groupInfo = recipient?.takeIf { it.isClosedGroupRecipient } val groupInfo = recipient?.takeIf { it.isClosedGroupRecipient }
?.run { address.toGroupString().let(groupDb::getGroup).orNull() } ?.run { address.toGroupString().let(groupDb::getGroup).orNull() }
_uiState.update { currentUiState -> _state.update { state ->
currentUiState.copy( state.copy(
isSelfAdmin = groupInfo == null || groupInfo.admins.any{ it.serialize() == textSecurePreferences.getLocalNumber() }, isSelfAdmin = groupInfo == null || groupInfo.admins.any{ it.serialize() == textSecurePreferences.getLocalNumber() },
showExpirationTypeSelector = true, recipient = recipient,
recipient = recipient expiryType = expiryMode.type,
expiryMode = expiryMode
) )
} }
_selectedExpirationType.value = if (ExpirationConfiguration.isNewConfigEnabled) {
expirationType ?: ExpiryMode.NONE
} else {
if (expirationType != null && expirationType != ExpiryMode.NONE)
ExpiryMode.Legacy(expirationType.expirySeconds)
else ExpiryMode.NONE
}
_selectedExpirationTimer.value = when(expirationType) {
is ExpiryMode.AfterSend -> afterSendOptions.find { it.value == expirationType }
is ExpiryMode.AfterRead -> afterReadOptions.find { it.value == expirationType }
else -> afterSendOptions.firstOrNull()
}
} }
// selectedExpirationType.mapLatest { // selectedExpirationType.mapLatest {
// when (it) { // when (it) {
@ -100,146 +87,99 @@ class ExpirationSettingsViewModel(
// }.launchIn(viewModelScope) // }.launchIn(viewModelScope)
} }
fun onExpirationTypeSelected(option: RadioOption<ExpiryMode>) { fun typeOption(
_selectedExpirationType.value = option.value type: ExpiryType,
_selectedExpirationTimer.value = _expirationTimerOptions.value.firstOrNull() @StringRes title: Int,
@StringRes subtitle: Int,
// @StringRes contentDescription: Int
) = OptionModel(GetString(title), GetString(subtitle)) { setType(type) }
private fun typeOptions() = listOf(
typeOption(ExpiryType.NONE, R.string.expiration_off, R.string.AccessibilityId_disable_disappearing_messages),
typeOption(ExpiryType.LEGACY, R.string.expiration_type_disappear_legacy, R.string.expiration_type_disappear_legacy_description),
typeOption(ExpiryType.AFTER_READ, R.string.expiration_type_disappear_after_read, R.string.expiration_type_disappear_after_read_description),
typeOption(ExpiryType.AFTER_SEND, R.string.expiration_type_disappear_after_send, R.string.expiration_type_disappear_after_send_description),
)
private fun setType(type: ExpiryType) {
_state.update { it.copy(expiryType = type, expiryMode = type.mode(0)) }
} }
fun onExpirationTimerSelected(option: RadioOption<ExpiryMode>) { private fun setTime(seconds: Long) {
_selectedExpirationTimer.value = option _state.update { it.copy(
expiryMode = it.expiryType.mode(seconds)
) }
} }
private fun KClass<out ExpiryMode>?.withTime(expirationTimer: Long) = when(this) { private fun setMode(mode: ExpiryMode) {
ExpiryMode.AfterRead::class -> ExpiryMode.AfterRead(expirationTimer) _state.update { it.copy(
ExpiryMode.AfterSend::class -> ExpiryMode.AfterSend(expirationTimer) expiryMode = mode
else -> ExpiryMode.NONE ) }
} }
fun timeOption(seconds: Long, @StringRes id: Int) = OptionModel(GetString(id), selected = false, onClick = { setTime(seconds) })
fun timeOption(seconds: Long, title: String, subtitle: String) = OptionModel(GetString(title), GetString(subtitle), selected = false, onClick = { setTime(seconds) })
// private fun timeOptions(state: State) = timeOptions(state.types.isEmpty(), state.expiryType == ExpiryType.AFTER_SEND)
private fun timeOptions(state: State) = noteToSelfOptions()
val afterReadTimes = listOf(12.hours, 1.days, 7.days, 14.days)
val afterSendTimes = listOf(5.minutes, 1.hours) + afterReadTimes
private fun noteToSelfOptions() = listOfNotNull(
typeOption(ExpiryType.NONE, R.string.arrays__off, R.string.arrays__off),
noteToSelfOption(1.minutes, subtitle = "for testing purposes").takeIf { BuildConfig.DEBUG },
) + afterSendTimes.map(::noteToSelfOption)
private fun noteToSelfOption(
duration: Duration,
title: GetString = GetString { ExpirationUtil.getExpirationDisplayValue(it, duration.inWholeSeconds.toInt()) },
subtitle: String? = null
) = OptionModel(
title = title,
subtitle = subtitle?.let(::GetString),
selected = false,
onClick = { setMode(ExpiryMode.AfterSend(duration.inWholeSeconds)) }
)
fun onSetClick() = viewModelScope.launch { fun onSetClick() = viewModelScope.launch {
val state = uiState.value val state = _state.value
val expiryMode = _selectedExpirationTimer.value?.value ?: ExpiryMode.NONE // val expiryMode = _selectedExpirationTimer.value?.value ?: ExpiryMode.NONE
val typeValue = expiryMode.let { // val typeValue = expiryMode.let {
if (it is ExpiryMode.Legacy) ExpiryMode.AfterRead(it.expirySeconds) // if (it is ExpiryMode.Legacy) ExpiryMode.AfterRead(it.expirySeconds)
else it // else it
} // }
val address = state.recipient?.address val address = state.recipient?.address
if (address == null || expirationConfig?.expiryMode == typeValue) { // if (address == null || expirationConfig?.expiryMode == typeValue) {
_uiState.update { // _state.update {
it.copy(settingsSaved = false) // it.copy(settingsSaved = false)
} // }
return@launch // return@launch
} // }
val expiryChangeTimestampMs = SnodeAPI.nowWithOffset // val expiryChangeTimestampMs = SnodeAPI.nowWithOffset
storage.setExpirationConfiguration(ExpirationConfiguration(threadId, typeValue, expiryChangeTimestampMs)) // storage.setExpirationConfiguration(ExpirationConfiguration(threadId, typeValue, expiryChangeTimestampMs))
//
val message = ExpirationTimerUpdate(typeValue.expirySeconds.toInt()) // val message = ExpirationTimerUpdate(typeValue.expirySeconds.toInt())
message.sender = textSecurePreferences.getLocalNumber() // message.sender = textSecurePreferences.getLocalNumber()
message.recipient = address.serialize() // message.recipient = address.serialize()
message.sentTimestamp = expiryChangeTimestampMs // message.sentTimestamp = expiryChangeTimestampMs
messageExpirationManager.setExpirationTimer(message, typeValue) // messageExpirationManager.setExpirationTimer(message, typeValue)
//
MessageSender.send(message, address) // MessageSender.send(message, address)
_uiState.update { // state.update {
it.copy(settingsSaved = true) // it.copy(settingsSaved = true)
} // }
} }
fun getDeleteOptions(): List<ExpirationRadioOption> {
if (!uiState.value.showExpirationTypeSelector) return emptyList()
val recipient = uiState.value.recipient ?: return emptyList()
return if (ExpirationConfiguration.isNewConfigEnabled) when {
recipient.isLocalNumber -> noteToSelfOptions()
recipient.isContactRecipient -> contactRecipientOptions()
recipient.isClosedGroupRecipient -> closedGroupRecipientOptions()
else -> emptyList()
} else when {
recipient.isContactRecipient && !recipient.isLocalNumber -> oldConfigContactRecipientOptions()
else -> oldConfigDefaultOptions()
}
}
private fun oldConfigDefaultOptions() = listOf(
radioOption(ExpiryMode.NONE, R.string.expiration_off),
radioOption(ExpiryMode.Legacy(0), R.string.expiration_type_disappear_legacy) {
subtitle(R.string.expiration_type_disappear_legacy_description)
},
radioOption(ExpiryMode.AfterSend(0), R.string.expiration_type_disappear_after_send) {
subtitle(R.string.expiration_type_disappear_after_send_description)
enabled = false
contentDescription(R.string.AccessibilityId_disappear_after_send_option)
}
)
private fun oldConfigContactRecipientOptions() = listOf(
radioOption(ExpiryMode.NONE, R.string.expiration_off) {
contentDescription(R.string.AccessibilityId_disable_disappearing_messages)
},
radioOption(ExpiryMode.Legacy(0), R.string.expiration_type_disappear_legacy) {
subtitle(R.string.expiration_type_disappear_legacy_description)
},
radioOption(ExpiryMode.AfterRead(0), R.string.expiration_type_disappear_after_read) {
subtitle(R.string.expiration_type_disappear_after_read_description)
enabled = false
contentDescription(R.string.AccessibilityId_disappear_after_read_option)
},
radioOption(ExpiryMode.AfterSend(0), R.string.expiration_type_disappear_after_send) {
subtitle(R.string.expiration_type_disappear_after_send_description)
enabled = false
contentDescription(R.string.AccessibilityId_disappear_after_send_option)
}
)
private fun contactRecipientOptions() = listOf(
radioOption(ExpiryMode.NONE, R.string.expiration_off) {
contentDescription(R.string.AccessibilityId_disable_disappearing_messages)
},
radioOption(ExpiryMode.AfterRead(0), R.string.expiration_type_disappear_after_read) {
subtitle(R.string.expiration_type_disappear_after_read_description)
contentDescription(R.string.AccessibilityId_disappear_after_read_option)
},
radioOption(ExpiryMode.AfterSend(0), R.string.expiration_type_disappear_after_send) {
subtitle(R.string.expiration_type_disappear_after_send_description)
contentDescription(R.string.AccessibilityId_disappear_after_send_option)
}
)
private fun closedGroupRecipientOptions() = listOf(
radioOption(ExpiryMode.NONE, R.string.expiration_off) {
contentDescription(R.string.AccessibilityId_disable_disappearing_messages)
},
radioOption(ExpiryMode.AfterSend(0), R.string.expiration_type_disappear_after_send) {
subtitle(R.string.expiration_type_disappear_after_send_description)
contentDescription(R.string.AccessibilityId_disappear_after_send_option)
}
)
private fun noteToSelfOptions() = listOf(
radioOption(ExpiryMode.NONE, R.string.expiration_off) {
contentDescription(R.string.AccessibilityId_disable_disappearing_messages)
},
radioOption(ExpiryMode.AfterSend(0), R.string.expiration_type_disappear_after_send) {
subtitle(R.string.expiration_type_disappear_after_send_description)
contentDescription(R.string.AccessibilityId_disappear_after_send_option)
}
)
@dagger.assisted.AssistedFactory @dagger.assisted.AssistedFactory
interface AssistedFactory { interface AssistedFactory {
fun create( fun create(threadId: Long): Factory
threadId: Long,
@Assisted("afterRead") afterReadOptions: List<ExpirationRadioOption>,
@Assisted("afterSend") afterSendOptions: List<ExpirationRadioOption>
): Factory
} }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
class Factory @AssistedInject constructor( class Factory @AssistedInject constructor(
@Assisted private val threadId: Long, @Assisted private val threadId: Long,
@Assisted("afterRead") private val afterReadOptions: List<ExpirationRadioOption>,
@Assisted("afterSend") private val afterSendOptions: List<ExpirationRadioOption>,
private val textSecurePreferences: TextSecurePreferences, private val textSecurePreferences: TextSecurePreferences,
private val messageExpirationManager: MessageExpirationManagerProtocol, private val messageExpirationManager: MessageExpirationManagerProtocol,
private val threadDb: ThreadDatabase, private val threadDb: ThreadDatabase,
@ -247,24 +187,54 @@ class ExpirationSettingsViewModel(
private val storage: Storage private val storage: Storage
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T = ExpirationSettingsViewModel(
return ExpirationSettingsViewModel( threadId,
threadId, textSecurePreferences,
afterReadOptions, messageExpirationManager,
afterSendOptions, threadDb,
textSecurePreferences, groupDb,
messageExpirationManager, storage
threadDb, ) as T
groupDb,
storage
) as T
}
} }
} }
data class ExpirationSettingsUiState( data class State(
val isSelfAdmin: Boolean = false, val isSelfAdmin: Boolean = false,
val showExpirationTypeSelector: Boolean = false,
val settingsSaved: Boolean? = null, val settingsSaved: Boolean? = null,
val recipient: Recipient? = null val recipient: Recipient? = null,
val expiryType: ExpiryType = ExpiryType.NONE,
val expiryMode: ExpiryMode? = null,
val types: List<ExpiryType> = emptyList()
) {
val isSelf = recipient?.isLocalNumber == true
}
data class UiState(
val cards: List<CardModel> = emptyList()
) )
data class CardModel(
val title: GetString,
val options: List<OptionModel>
)
data class OptionModel(
val title: GetString,
val subtitle: GetString? = null,
val selected: Boolean = false,
val onClick: () -> Unit = {}
)
enum class ExpiryType(val mode: (Long) -> ExpiryMode) {
NONE({ ExpiryMode.NONE }),
LEGACY(ExpiryMode::Legacy),
AFTER_SEND(ExpiryMode::AfterSend),
AFTER_READ(ExpiryMode::AfterRead)
}
private val ExpiryMode.type: ExpiryType get() = when(this) {
is ExpiryMode.Legacy -> ExpiryType.LEGACY
is ExpiryMode.AfterSend -> ExpiryType.AFTER_SEND
is ExpiryMode.AfterRead -> ExpiryType.AFTER_READ
else -> ExpiryType.NONE
}

View File

@ -41,7 +41,7 @@ class AlbumThumbnailView : RelativeLayout {
private var slides: List<Slide> = listOf() private var slides: List<Slide> = listOf()
private var slideSize: Int = 0 private var slideSize: Int = 0
override fun dispatchDraw(canvas: Canvas?) { override fun dispatchDraw(canvas: Canvas) {
super.dispatchDraw(canvas) super.dispatchDraw(canvas)
cornerMask.mask(canvas) cornerMask.mask(canvas)
} }

View File

@ -30,9 +30,7 @@ class ThumbnailProgressBar: View {
private val objectRect = Rect() private val objectRect = Rect()
private val drawingRect = Rect() private val drawingRect = Rect()
override fun dispatchDraw(canvas: Canvas?) { override fun dispatchDraw(canvas: Canvas) {
if (canvas == null) return
getDrawingRect(objectRect) getDrawingRect(objectRect)
drawingRect.set(objectRect) drawingRect.set(objectRect)

View File

@ -3,12 +3,18 @@ package org.thoughtcrime.securesms.ui
import android.content.Context import android.content.Context
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
/** /**
* Compatibility class to allow ViewModels to use strings and string resources interchangeably. * Compatibility class to allow ViewModels to use strings and string resources interchangeably.
*/ */
sealed class GetString { sealed class GetString {
@Composable
operator fun invoke() = string()
operator fun invoke(context: Context) = string(context)
@Composable @Composable
abstract fun string(): String abstract fun string(): String
@ -22,12 +28,17 @@ sealed class GetString {
@Composable @Composable
override fun string(): String = stringResource(resId) override fun string(): String = stringResource(resId)
override fun string(context: Context): String = context.getString(resId) override fun string(context: Context): String = context.getString(resId)
}
data class FromFun(val function: (Context) -> String): GetString() {
@Composable
override fun string(): String = function(LocalContext.current)
override fun string(context: Context): String = function(context)
} }
} }
fun GetString(@StringRes resId: Int) = GetString.FromResId(resId) fun GetString(@StringRes resId: Int) = GetString.FromResId(resId)
fun GetString(string: String) = GetString.FromString(string) fun GetString(string: String) = GetString.FromString(string)
fun GetString(function: (Context) -> String) = GetString.FromFun(function)
/** /**

View File

@ -22,101 +22,11 @@
app:subtitleTextAppearance="@style/TextAppearance.Session.ToolbarSubtitle" app:subtitleTextAppearance="@style/TextAppearance.Session.ToolbarSubtitle"
app:title="@string/activity_expiration_settings_title" /> app:title="@string/activity_expiration_settings_title" />
<LinearLayout <androidx.compose.ui.platform.ComposeView
android:id="@+id/layout_container" android:id="@+id/container"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:layout_below="@id/toolbar" android:layout_below="@id/toolbar"/>
android:layout_marginBottom="@dimen/massive_spacing"
android:orientation="vertical">
<TextView
android:id="@+id/text_view_delete_type"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="@dimen/very_large_spacing"
android:paddingVertical="@dimen/small_spacing"
android:text="@string/activity_expiration_settings_delete_type"
android:textColor="?android:textColorTertiary"
android:textSize="@dimen/medium_font_size" />
<LinearLayout
android:id="@+id/layout_delete_types"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/small_spacing"
android:background="@drawable/preference_single"
android:paddingHorizontal="@dimen/large_spacing"
android:paddingVertical="@dimen/medium_spacing">
<androidx.recyclerview.widget.RecyclerView
android:overScrollMode="never"
android:id="@+id/recycler_view_delete_types"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:itemCount="3"
tools:listitem="@layout/item_selectable" />
</LinearLayout>
<TextView
android:id="@+id/text_view_timer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="@dimen/very_large_spacing"
android:paddingVertical="@dimen/small_spacing"
android:text="@string/activity_expiration_settings_timer"
android:textColor="?android:textColorTertiary"
android:textSize="@dimen/medium_font_size" />
<LinearLayout
android:id="@+id/layout_timer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:contentDescription="@string/AccessibilityId_disappearing_messages_timer"
android:layout_marginHorizontal="@dimen/small_spacing"
android:background="@drawable/preference_single"
android:paddingHorizontal="@dimen/large_spacing"
android:paddingVertical="@dimen/medium_spacing">
<androidx.recyclerview.widget.RecyclerView
android:overScrollMode="never"
android:id="@+id/recycler_view_timer_options"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:itemCount="3"
tools:listitem="@layout/item_selectable" />
</LinearLayout>
<TextView
android:id="@+id/text_view_footer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="@dimen/very_large_spacing"
android:paddingVertical="@dimen/small_spacing"
android:gravity="center_horizontal"
android:text="@string/activity_expiration_settings_group_footer"
android:textColor="?android:textColorTertiary"
android:textSize="@dimen/very_small_font_size"
android:visibility="gone"
tools:visibility="visible"/>
</LinearLayout>
<Button
android:id="@+id/button_set"
style="@style/Widget.Session.Button.Common.ProminentOutline"
android:layout_width="196dp"
android:layout_height="@dimen/medium_button_height"
android:contentDescription="@string/AccessibilityId_set_button"
android:layout_alignParentBottom="true"
android:layout_centerHorizontal="true"
android:layout_marginTop="@dimen/medium_spacing"
android:layout_marginBottom="@dimen/very_large_spacing"
android:text="@string/expiration_settings_set_button_title" />
</RelativeLayout> </RelativeLayout>

View File

@ -228,40 +228,4 @@
<item>@string/notify_type_mentions</item> <item>@string/notify_type_mentions</item>
</string-array> </string-array>
<integer-array name="read_expiration_time_names">
<item>@string/arrays__off</item>
<item>@string/arrays__five_minutes</item>
<item>@string/arrays__one_hour</item>
<item>@string/arrays__twelve_hours</item>
<item>@string/arrays__one_day</item>
<item>@string/arrays__one_week</item>
<item>@string/arrays__two_weeks</item>
</integer-array>
<integer-array name="read_expiration_time_values">
<item>0</item>
<item>300</item>
<item>3600</item>
<item>43200</item>
<item>86400</item>
<item>604800</item>
<item>1209600</item>
</integer-array>
<integer-array name="send_expiration_time_names">
<item>@string/arrays__off</item>
<item>@string/arrays__twelve_hours</item>
<item>@string/arrays__one_day</item>
<item>@string/arrays__one_week</item>
<item>@string/arrays__two_weeks</item>
</integer-array>
<integer-array name="send_expiration_time_values">
<item>0</item>
<item>43200</item>
<item>86400</item>
<item>604800</item>
<item>1209600</item>
</integer-array>
</resources> </resources>

View File

@ -57,6 +57,6 @@ allprojects {
project.ext { project.ext {
androidMinimumSdkVersion = 23 androidMinimumSdkVersion = 23
androidTargetSdkVersion = 33 androidTargetSdkVersion = 33
androidCompileSdkVersion = 33 androidCompileSdkVersion = 34
} }
} }

View File

@ -161,7 +161,7 @@ private fun MessageReceiver.handleExpirationTimerUpdate(message: ExpirationTimer
val type = when { val type = when {
recipient.isContactRecipient -> ExpiryMode.AfterRead(message.duration!!.toLong()) recipient.isContactRecipient -> ExpiryMode.AfterRead(message.duration!!.toLong())
recipient.isGroupRecipient -> ExpiryMode.AfterSend(message.duration!!.toLong()) recipient.isGroupRecipient -> ExpiryMode.AfterSend(message.duration!!.toLong())
else -> null else -> ExpiryMode.NONE
} }
try { try {
var threadId: Long = module.storage.getOrCreateThreadIdFor(fromSerialized(message.sender!!)) var threadId: Long = module.storage.getOrCreateThreadIdFor(fromSerialized(message.sender!!))
@ -295,7 +295,7 @@ fun MessageReceiver.updateExpiryIfNeeded(
val durationSeconds = if (proto.hasExpirationTimer()) proto.expirationTimer else 0 val durationSeconds = if (proto.hasExpirationTimer()) proto.expirationTimer else 0
val type = if (proto.hasExpirationType()) proto.expirationType else null val type = if (proto.hasExpirationType()) proto.expirationType else null
val expiryMode = type?.expiryMode(durationSeconds.toLong()) val expiryMode = type?.expiryMode(durationSeconds.toLong()) ?: ExpiryMode.NONE
val remoteConfig = ExpirationConfiguration( val remoteConfig = ExpirationConfiguration(
threadID, threadID,
@ -325,12 +325,12 @@ fun MessageReceiver.updateExpiryIfNeeded(
// handle a delete after send expired fetch // handle a delete after send expired fetch
if (type == ExpirationType.DELETE_AFTER_SEND if (type == ExpirationType.DELETE_AFTER_SEND
&& sentTime + (configToUse.expiryMode?.expirySeconds ?: 0) <= SnodeAPI.nowWithOffset) { && sentTime + configToUse.expiryMode.expirySeconds <= SnodeAPI.nowWithOffset) {
throw MessageReceiver.Error.ExpiredMessage throw MessageReceiver.Error.ExpiredMessage
} }
// handle a delete after read last known config value // handle a delete after read last known config value
if (type == ExpirationType.DELETE_AFTER_READ if (type == ExpirationType.DELETE_AFTER_READ
&& sentTime + (configToUse.expiryMode?.expirySeconds ?: 0) <= storage.getLastSeen(threadID)) { && sentTime + configToUse.expiryMode.expirySeconds <= storage.getLastSeen(threadID)) {
throw MessageReceiver.Error.ExpiredMessage throw MessageReceiver.Error.ExpiredMessage
} }