Convert AdvancedPrivacySettingsFragment to compose.

This commit is contained in:
Alex Hart
2025-08-20 14:23:18 -03:00
committed by Jeffrey Starke
parent 7d35cf1374
commit d92286297f
3 changed files with 299 additions and 159 deletions

View File

@@ -4,59 +4,65 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.net.ConnectivityManager
import android.text.SpannableStringBuilder
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModelProvider
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.preference.PreferenceManager
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.Dividers
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.ui.compose.Texts
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.SignalProgressDialog
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.viewModel
class AdvancedPrivacySettingsFragment : DSLSettingsFragment(R.string.preferences__advanced) {
/**
* Displays advanced privacy controls such as call relaying and
* censorship circumvention to the user.
*/
class AdvancedPrivacySettingsFragment : ComposeFragment() {
private lateinit var viewModel: AdvancedPrivacySettingsViewModel
private val viewModel: AdvancedPrivacySettingsViewModel by viewModel {
val repository = AdvancedPrivacySettingsRepository(requireContext())
val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
AdvancedPrivacySettingsViewModel(
preferences,
repository
)
}
private var networkReceiver: NetworkReceiver? = null
private val sealedSenderSummary: CharSequence by lazy {
SpanUtil.learnMore(
requireContext(),
ContextCompat.getColor(requireContext(), R.color.signal_text_primary)
) {
CommunicationActions.openBrowserLink(
requireContext(),
getString(R.string.AdvancedPrivacySettingsFragment__sealed_sender_link)
)
}
}
var progressDialog: SignalProgressDialog? = null
val statusIcon: CharSequence by lazy {
val unidentifiedDeliveryIcon = requireNotNull(
ContextCompat.getDrawable(
requireContext(),
R.drawable.ic_unidentified_delivery
)
)
unidentifiedDeliveryIcon.setBounds(0, 0, ViewUtil.dpToPx(20), ViewUtil.dpToPx(15))
val iconTint = ContextCompat.getColor(requireContext(), R.color.signal_text_primary_dialog)
unidentifiedDeliveryIcon.colorFilter = PorterDuffColorFilter(iconTint, PorterDuff.Mode.SRC_IN)
SpanUtil.buildImageSpan(unidentifiedDeliveryIcon)
}
override fun onResume() {
super.onResume()
viewModel.refresh()
@@ -68,96 +74,30 @@ class AdvancedPrivacySettingsFragment : DSLSettingsFragment(R.string.preferences
unregisterNetworkReceiver()
}
override fun bindAdapter(adapter: MappingAdapter) {
val repository = AdvancedPrivacySettingsRepository(requireContext())
val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
val factory = AdvancedPrivacySettingsViewModel.Factory(preferences, repository)
viewModel = ViewModelProvider(this, factory)[AdvancedPrivacySettingsViewModel::class.java]
viewModel.state.observe(viewLifecycleOwner) {
if (it.showProgressSpinner) {
if (progressDialog?.isShowing == false) {
progressDialog = SignalProgressDialog.show(requireContext(), null, null, true)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
viewModel.events.collect {
if (it == AdvancedPrivacySettingsViewModel.Event.DISABLE_PUSH_FAILED) {
Toast.makeText(
requireContext(),
R.string.ApplicationPreferencesActivity_error_connecting_to_server,
Toast.LENGTH_LONG
).show()
}
}
} else {
progressDialog?.hide()
}
adapter.submitList(getConfiguration(it).toMappingModelList())
}
viewModel.events.observe(viewLifecycleOwner) {
if (it == AdvancedPrivacySettingsViewModel.Event.DISABLE_PUSH_FAILED) {
Toast.makeText(
requireContext(),
R.string.ApplicationPreferencesActivity_error_connecting_to_server,
Toast.LENGTH_LONG
).show()
}
}
}
private fun getConfiguration(state: AdvancedPrivacySettingsState): DSLConfiguration {
return configure {
switchPref(
title = DSLSettingsText.from(R.string.preferences_advanced__always_relay_calls),
summary = DSLSettingsText.from(R.string.preferences_advanced__relay_all_calls_through_the_signal_server_to_avoid_revealing_your_ip_address),
isChecked = state.alwaysRelayCalls
) {
viewModel.setAlwaysRelayCalls(!state.alwaysRelayCalls)
}
@Composable
override fun FragmentContent() {
val state by viewModel.state.collectAsStateWithLifecycle()
dividerPref()
sectionHeaderPref(R.string.preferences_communication__category_censorship_circumvention)
val censorshipSummaryResId: Int = when (state.censorshipCircumventionState) {
CensorshipCircumventionState.AVAILABLE -> R.string.preferences_communication__censorship_circumvention_if_enabled_signal_will_attempt_to_circumvent_censorship
CensorshipCircumventionState.AVAILABLE_MANUALLY_DISABLED -> R.string.preferences_communication__censorship_circumvention_you_have_manually_disabled
CensorshipCircumventionState.AVAILABLE_AUTOMATICALLY_ENABLED -> R.string.preferences_communication__censorship_circumvention_has_been_activated_based_on_your_accounts_phone_number
CensorshipCircumventionState.UNAVAILABLE_CONNECTED -> R.string.preferences_communication__censorship_circumvention_is_not_necessary_you_are_already_connected
CensorshipCircumventionState.UNAVAILABLE_NO_INTERNET -> R.string.preferences_communication__censorship_circumvention_can_only_be_activated_when_connected_to_the_internet
}
switchPref(
title = DSLSettingsText.from(R.string.preferences_communication__censorship_circumvention),
summary = DSLSettingsText.from(censorshipSummaryResId),
isChecked = state.censorshipCircumventionEnabled,
isEnabled = state.censorshipCircumventionState.available,
onClick = {
viewModel.setCensorshipCircumventionEnabled(!state.censorshipCircumventionEnabled)
}
)
dividerPref()
sectionHeaderPref(R.string.preferences_communication__category_sealed_sender)
switchPref(
title = DSLSettingsText.from(
SpannableStringBuilder(getString(R.string.AdvancedPrivacySettingsFragment__show_status_icon))
.append(" ")
.append(statusIcon)
),
summary = DSLSettingsText.from(R.string.AdvancedPrivacySettingsFragment__show_an_icon),
isChecked = state.showSealedSenderStatusIcon
) {
viewModel.setShowStatusIconForSealedSender(!state.showSealedSenderStatusIcon)
}
switchPref(
title = DSLSettingsText.from(R.string.preferences_communication__sealed_sender_allow_from_anyone),
summary = DSLSettingsText.from(R.string.preferences_communication__sealed_sender_allow_from_anyone_description),
isChecked = state.allowSealedSenderFromAnyone
) {
viewModel.setAllowSealedSenderFromAnyone(!state.allowSealedSenderFromAnyone)
}
textPref(
summary = DSLSettingsText.from(sealedSenderSummary)
)
}
AdvancedPrivacySettingsScreen(
state = state,
callbacks = remember { Callbacks() }
)
}
@Suppress("DEPRECATION")
@@ -182,4 +122,183 @@ class AdvancedPrivacySettingsFragment : DSLSettingsFragment(R.string.preferences
viewModel.refresh()
}
}
private inner class Callbacks : AdvancedPrivacySettingsCallbacks {
override fun onNavigationClick() {
requireActivity().onBackPressedDispatcher.onBackPressed()
}
override fun onAlwaysRelayCallsChanged(enabled: Boolean) {
viewModel.setAlwaysRelayCalls(enabled)
}
override fun onCensorshipCircumventionChanged(enabled: Boolean) {
viewModel.setCensorshipCircumventionEnabled(enabled)
}
override fun onShowStatusIconForSealedSenderChanged(enabled: Boolean) {
viewModel.setShowStatusIconForSealedSender(enabled)
}
override fun onAllowSealedSenderFromAnyoneChanged(enabled: Boolean) {
viewModel.setAllowSealedSenderFromAnyone(enabled)
}
override fun onSealedSenderLearnMoreClick() {
CommunicationActions.openBrowserLink(
requireContext(),
getString(R.string.AdvancedPrivacySettingsFragment__sealed_sender_link)
)
}
}
}
private interface AdvancedPrivacySettingsCallbacks {
fun onNavigationClick() = Unit
fun onAlwaysRelayCallsChanged(enabled: Boolean) = Unit
fun onCensorshipCircumventionChanged(enabled: Boolean) = Unit
fun onShowStatusIconForSealedSenderChanged(enabled: Boolean) = Unit
fun onAllowSealedSenderFromAnyoneChanged(enabled: Boolean) = Unit
fun onSealedSenderLearnMoreClick() = Unit
object Empty : AdvancedPrivacySettingsCallbacks
}
@Composable
private fun AdvancedPrivacySettingsScreen(
state: AdvancedPrivacySettingsState,
callbacks: AdvancedPrivacySettingsCallbacks
) {
Scaffolds.Settings(
title = stringResource(R.string.preferences__advanced),
onNavigationClick = callbacks::onNavigationClick,
navigationIcon = ImageVector.vectorResource(R.drawable.symbol_arrow_start_24)
) { paddingValues ->
LazyColumn(
modifier = Modifier.padding(paddingValues)
) {
item {
Rows.ToggleRow(
checked = state.alwaysRelayCalls,
text = stringResource(R.string.preferences_advanced__always_relay_calls),
label = stringResource(R.string.preferences_advanced__relay_all_calls_through_the_signal_server_to_avoid_revealing_your_ip_address),
onCheckChanged = callbacks::onAlwaysRelayCallsChanged
)
}
item {
Dividers.Default()
}
item {
Texts.SectionHeader(text = stringResource(R.string.preferences_communication__category_censorship_circumvention))
}
item {
val censorshipSummaryResId: Int = when (state.censorshipCircumventionState) {
CensorshipCircumventionState.AVAILABLE -> R.string.preferences_communication__censorship_circumvention_if_enabled_signal_will_attempt_to_circumvent_censorship
CensorshipCircumventionState.AVAILABLE_MANUALLY_DISABLED -> R.string.preferences_communication__censorship_circumvention_you_have_manually_disabled
CensorshipCircumventionState.AVAILABLE_AUTOMATICALLY_ENABLED -> R.string.preferences_communication__censorship_circumvention_has_been_activated_based_on_your_accounts_phone_number
CensorshipCircumventionState.UNAVAILABLE_CONNECTED -> R.string.preferences_communication__censorship_circumvention_is_not_necessary_you_are_already_connected
CensorshipCircumventionState.UNAVAILABLE_NO_INTERNET -> R.string.preferences_communication__censorship_circumvention_can_only_be_activated_when_connected_to_the_internet
}
Rows.ToggleRow(
text = stringResource(R.string.preferences_communication__censorship_circumvention),
label = stringResource(censorshipSummaryResId),
checked = state.censorshipCircumventionEnabled,
enabled = state.censorshipCircumventionState.available,
onCheckChanged = callbacks::onCensorshipCircumventionChanged
)
}
item {
Dividers.Default()
}
item {
Texts.SectionHeader(
text = stringResource(R.string.preferences_communication__category_sealed_sender)
)
}
item {
val imageId = "sealed-sender-image"
val text = buildAnnotatedString {
append(stringResource(R.string.AdvancedPrivacySettingsFragment__show_status_icon))
append(" ")
appendInlineContent(imageId, "[image]")
}
val inlineContentMap = mapOf(
imageId to InlineTextContent(
placeholder = Placeholder(
width = 20.sp,
height = 15.sp,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
)
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_unidentified_delivery),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
)
Rows.ToggleRow(
text = text,
inlineContent = inlineContentMap,
label = AnnotatedString(stringResource(R.string.AdvancedPrivacySettingsFragment__show_an_icon)),
checked = state.showSealedSenderStatusIcon,
onCheckChanged = callbacks::onShowStatusIconForSealedSenderChanged
)
}
item {
Rows.ToggleRow(
checked = state.allowSealedSenderFromAnyone,
text = stringResource(R.string.preferences_communication__sealed_sender_allow_from_anyone),
label = stringResource(R.string.preferences_communication__sealed_sender_allow_from_anyone_description),
onCheckChanged = callbacks::onAllowSealedSenderFromAnyoneChanged
)
}
item {
val sealedSenderSummary = buildAnnotatedString {
withLink(
LinkAnnotation.Clickable("learn-more", linkInteractionListener = {
callbacks.onSealedSenderLearnMoreClick()
})
) {
append(stringResource(R.string.LearnMoreTextView_learn_more))
}
}
Rows.TextRow(
text = sealedSenderSummary
)
}
}
}
}
@SignalPreview
@Composable
private fun AdvancedPrivacySettingsScreenPreview() {
Previews.Preview {
AdvancedPrivacySettingsScreen(
state = AdvancedPrivacySettingsState(
isPushEnabled = true,
alwaysRelayCalls = false,
censorshipCircumventionState = CensorshipCircumventionState.UNAVAILABLE_CONNECTED,
censorshipCircumventionEnabled = false,
showSealedSenderStatusIcon = false,
allowSealedSenderFromAnyone = false,
showProgressSpinner = false
),
callbacks = AdvancedPrivacySettingsCallbacks.Empty
)
}
}

View File

@@ -1,11 +1,14 @@
package org.thoughtcrime.securesms.components.settings.app.privacy.advanced
import android.content.SharedPreferences
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.CompositeDisposable
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob
@@ -13,9 +16,7 @@ import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob
import org.thoughtcrime.securesms.keyvalue.SettingsValues
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.SignalE164Util
import org.thoughtcrime.securesms.util.SingleLiveEvent
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.livedata.Store
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
class AdvancedPrivacySettingsViewModel(
@@ -23,11 +24,11 @@ class AdvancedPrivacySettingsViewModel(
private val repository: AdvancedPrivacySettingsRepository
) : ViewModel() {
private val store = Store(getState())
private val singleEvents = SingleLiveEvent<Event>()
private val store = MutableStateFlow(getState())
private val singleEvents = MutableSharedFlow<Event>()
val state: LiveData<AdvancedPrivacySettingsState> = store.stateLiveData
val events: LiveData<Event> = singleEvents
val state: StateFlow<AdvancedPrivacySettingsState> = store
val events: SharedFlow<Event> = singleEvents
val disposables: CompositeDisposable = CompositeDisposable()
init {
@@ -136,20 +137,4 @@ class AdvancedPrivacySettingsViewModel(
enum class Event {
DISABLE_PUSH_FAILED
}
class Factory(
private val sharedPreferences: SharedPreferences,
private val repository: AdvancedPrivacySettingsRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(
modelClass.cast(
AdvancedPrivacySettingsViewModel(
sharedPreferences,
repository
)
)
)
}
}
}

View File

@@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -161,12 +162,44 @@ object Rows {
enabled: Boolean = true,
isLoading: Boolean = false
) {
val enabled = enabled && !isLoading
ToggleRow(
checked = checked,
text = AnnotatedString(text),
onCheckChanged = onCheckChanged,
modifier = modifier,
label = label?.let { AnnotatedString(it) },
icon = icon,
textColor = textColor,
enabled = enabled,
isLoading = isLoading
)
}
/**
* Row that positions [text] and optional [label] in a [TextAndLabel] to the side of a [Switch].
*
* Can display a circular loading indicator by setting isLoaded to true. Setting isLoading to true
* will disable the control by default.
*/
@Composable
fun ToggleRow(
checked: Boolean,
text: AnnotatedString,
onCheckChanged: (Boolean) -> Unit,
modifier: Modifier = Modifier,
label: AnnotatedString? = null,
icon: ImageVector? = null,
textColor: Color = MaterialTheme.colorScheme.onSurface,
enabled: Boolean = true,
isLoading: Boolean = false,
inlineContent: Map<String, InlineTextContent> = mapOf()
) {
val isEnabled = enabled && !isLoading
Row(
modifier = modifier
.fillMaxWidth()
.clickable(enabled = enabled) { onCheckChanged(!checked) }
.clickable(enabled = isEnabled) { onCheckChanged(!checked) }
.padding(defaultPadding()),
verticalAlignment = CenterVertically
) {
@@ -183,13 +216,14 @@ object Rows {
text = text,
label = label,
textColor = textColor,
enabled = enabled,
modifier = Modifier.padding(end = 16.dp)
enabled = isEnabled,
modifier = Modifier.padding(end = 16.dp),
inlineContent = inlineContent
)
val loadingContent by rememberDelayedState(isLoading)
val toggleState = remember(checked, loadingContent, enabled, onCheckChanged) {
ToggleState(checked, loadingContent, enabled, onCheckChanged)
val toggleState = remember(checked, loadingContent, isEnabled, onCheckChanged) {
ToggleState(checked, loadingContent, isEnabled, onCheckChanged)
}
AnimatedContent(
@@ -415,7 +449,8 @@ object Rows {
label: AnnotatedString? = null,
enabled: Boolean = true,
textColor: Color = MaterialTheme.colorScheme.onSurface,
textStyle: TextStyle = MaterialTheme.typography.bodyLarge
textStyle: TextStyle = MaterialTheme.typography.bodyLarge,
inlineContent: Map<String, InlineTextContent> = mapOf()
) {
Column(
modifier = modifier
@@ -426,7 +461,8 @@ object Rows {
Text(
text = text,
style = textStyle,
color = textColor
color = textColor,
inlineContent = inlineContent
)
}