diff --git a/app/src/full/kotlin/org/fdroid/ui/ipfs/IPreferencesIpfs.kt b/app/src/full/kotlin/org/fdroid/ui/ipfs/IPreferencesIpfs.kt deleted file mode 100644 index e8cb8ff65..000000000 --- a/app/src/full/kotlin/org/fdroid/ui/ipfs/IPreferencesIpfs.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.fdroid.ui.ipfs - -/** - * Interface for accessing IPFS related preferences. - * - * Splitting this into a separate interfaces allows for easily injecting data into Compose UI - * previews. (and maybe unit-tests too) - */ -interface IPreferencesIpfs { - var isIpfsEnabled: Boolean - var ipfsGwDisabledDefaults: List - var ipfsGwUserList: List -} diff --git a/app/src/full/kotlin/org/fdroid/ui/ipfs/IpfsGatewaySettingsActivity.kt b/app/src/full/kotlin/org/fdroid/ui/ipfs/IpfsGatewaySettingsActivity.kt index ae6f0cff9..314ccd62b 100644 --- a/app/src/full/kotlin/org/fdroid/ui/ipfs/IpfsGatewaySettingsActivity.kt +++ b/app/src/full/kotlin/org/fdroid/ui/ipfs/IpfsGatewaySettingsActivity.kt @@ -4,9 +4,9 @@ import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.compose.collectAsStateWithLifecycle import dagger.hilt.android.AndroidEntryPoint import org.fdroid.ui.FDroidContent -import org.fdroid.ui.ipfs.IpfsManager.Companion.DEFAULT_GATEWAYS import javax.inject.Inject @AndroidEntryPoint @@ -21,18 +21,8 @@ class IpfsGatewaySettingsActivity : AppCompatActivity() { setContent { FDroidContent { SettingsScreen( - prefs = manager, - onAddUserGateway = { url -> - // don't allow adding default gateways to the user gateways list - if (!DEFAULT_GATEWAYS.contains(url)) { - val updatedUserGwList = manager.ipfsGwUserList.toMutableList() - // don't allow double adding gateways - if (!updatedUserGwList.contains(url)) { - updatedUserGwList.add(url) - manager.ipfsGwUserList = updatedUserGwList - } - } - }, + prefs = manager.preferences.collectAsStateWithLifecycle().value, + actions = manager, onBackClicked = { onBackPressedDispatcher.onBackPressed() }, ) } diff --git a/app/src/full/kotlin/org/fdroid/ui/ipfs/IpfsManager.kt b/app/src/full/kotlin/org/fdroid/ui/ipfs/IpfsManager.kt index c23c40bdb..5ebea57cb 100644 --- a/app/src/full/kotlin/org/fdroid/ui/ipfs/IpfsManager.kt +++ b/app/src/full/kotlin/org/fdroid/ui/ipfs/IpfsManager.kt @@ -1,6 +1,9 @@ package org.fdroid.ui.ipfs import androidx.core.content.edit +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import org.fdroid.settings.SettingsManager import org.json.JSONArray import org.json.JSONException @@ -10,7 +13,7 @@ import javax.inject.Singleton @Singleton class IpfsManager @Inject constructor( settingsManager: SettingsManager, -) : IPreferencesIpfs { +) : IpfsActions { companion object { val DEFAULT_GATEWAYS = listOf( @@ -23,14 +26,17 @@ class IpfsManager @Inject constructor( private val prefs = settingsManager.prefs - override var isIpfsEnabled: Boolean + private val _preferences = MutableStateFlow(loadPreferences()) + val preferences = _preferences.asStateFlow() + + private var enabled: Boolean get() = prefs.getBoolean(PREF_USE_IPFS_GATEWAYS, false) set(value) { prefs.edit { putBoolean(PREF_USE_IPFS_GATEWAYS, value) } } - override var ipfsGwDisabledDefaults: List + private var disabledDefaultGateways: List get() { val list = prefs.getString(PREF_IPFSGW_DISABLED_DEFAULTS_LIST, "[]") ?: "[]" return parseJsonStringArray(list) @@ -40,7 +46,7 @@ class IpfsManager @Inject constructor( putString(PREF_IPFSGW_DISABLED_DEFAULTS_LIST, toJsonStringArray(value)) } } - override var ipfsGwUserList: List + private var userGateways: List get() = parseJsonStringArray(prefs.getString(PREF_IPFSGW_USER_LIST, "[]") ?: "[]") set(value) { prefs.edit { @@ -48,6 +54,55 @@ class IpfsManager @Inject constructor( } } + private fun loadPreferences() = IpfsPreferences( + isIpfsEnabled = prefs.getBoolean(PREF_USE_IPFS_GATEWAYS, false), + disabledDefaultGateways = parseJsonStringArray( + prefs.getString(PREF_IPFSGW_DISABLED_DEFAULTS_LIST, "[]") ?: "[]" + ), + userGateways = parseJsonStringArray(prefs.getString(PREF_IPFSGW_USER_LIST, "[]") ?: "[]"), + ) + + override fun setIpfsEnabled(enabled: Boolean) { + _preferences.update { + it.copy(isIpfsEnabled = enabled) + } + } + + override fun setDefaultGatewayEnabled(url: String, enabled: Boolean) { + _preferences.update { + val newList = it.disabledDefaultGateways.toMutableList() + if (!enabled) { + newList.add(url) + } else { + newList.remove(url) + } + disabledDefaultGateways = newList + it.copy(disabledDefaultGateways = newList) + } + } + + override fun addUserGateway(url: String) { + // don't allow adding default gateways to the user gateways list + if (!DEFAULT_GATEWAYS.contains(url)) _preferences.update { + if (it.userGateways.contains(url)) { + it // already has URL in list + } else { + val newList = it.userGateways.toMutableList().apply { + add(url) + } + userGateways = newList + it.copy(userGateways = newList) + } + } + } + + override fun removeUserGateway(url: String) = _preferences.update { + val newGateways = it.userGateways.toMutableList() + newGateways.remove(url) + userGateways = newGateways + it.copy(userGateways = newGateways) + } + private fun parseJsonStringArray(json: String): List { try { val jsonArray = JSONArray(json) diff --git a/app/src/full/kotlin/org/fdroid/ui/ipfs/IpfsPreferences.kt b/app/src/full/kotlin/org/fdroid/ui/ipfs/IpfsPreferences.kt new file mode 100644 index 000000000..824a83728 --- /dev/null +++ b/app/src/full/kotlin/org/fdroid/ui/ipfs/IpfsPreferences.kt @@ -0,0 +1,14 @@ +package org.fdroid.ui.ipfs + +interface IpfsActions { + fun setIpfsEnabled(enabled: Boolean) + fun setDefaultGatewayEnabled(url: String, enabled: Boolean) + fun addUserGateway(url: String) + fun removeUserGateway(url: String) +} + +data class IpfsPreferences( + val isIpfsEnabled: Boolean, + val disabledDefaultGateways: List, + val userGateways: List, +) diff --git a/app/src/full/kotlin/org/fdroid/ui/ipfs/SettingsScreen.kt b/app/src/full/kotlin/org/fdroid/ui/ipfs/SettingsScreen.kt index 10e4dc5d7..bc835e956 100644 --- a/app/src/full/kotlin/org/fdroid/ui/ipfs/SettingsScreen.kt +++ b/app/src/full/kotlin/org/fdroid/ui/ipfs/SettingsScreen.kt @@ -1,6 +1,5 @@ package org.fdroid.ui.ipfs -import android.annotation.SuppressLint import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -24,23 +23,16 @@ import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.compose.LocalLifecycleOwner import org.fdroid.R import org.fdroid.ui.FDroidContent import org.fdroid.ui.ipfs.IpfsManager.Companion.DEFAULT_GATEWAYS @@ -48,14 +40,11 @@ import org.fdroid.ui.ipfs.IpfsManager.Companion.DEFAULT_GATEWAYS @Composable @OptIn(ExperimentalMaterial3Api::class) fun SettingsScreen( - onAddUserGateway: (url: String) -> Unit, + prefs: IpfsPreferences, + actions: IpfsActions, onBackClicked: () -> Unit, - prefs: IPreferencesIpfs, ) { - val context = LocalContext.current var showAddDialog by remember { mutableStateOf(false) } - var ipfsEnabled by remember { mutableStateOf(prefs.isIpfsEnabled) } - Scaffold( topBar = { TopAppBar( @@ -73,7 +62,7 @@ fun SettingsScreen( }, floatingActionButton = { // it doesn't seam to be supported to disable FABs, so just hide it for now. - if (ipfsEnabled) { + if (prefs.isIpfsEnabled) { FloatingActionButton( onClick = { showAddDialog = true @@ -100,32 +89,25 @@ fun SettingsScreen( style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f) ) - Switch(checked = ipfsEnabled, onCheckedChange = { checked -> - ipfsEnabled = checked - prefs.isIpfsEnabled = checked - }) + Switch(checked = prefs.isIpfsEnabled, onCheckedChange = actions::setIpfsEnabled) } - DefaultGatewaysSettings(prefs = prefs, ipfsEnabled = ipfsEnabled) - UserGatewaysSettings(prefs = prefs, ipfsEnabled = ipfsEnabled) + DefaultGatewaysSettings(prefs, actions) + UserGatewaysSettings(prefs, actions) // make sure FAB doesn't occlude the delete button of the last user gateway Spacer(modifier = Modifier.height(64.dp)) } } - if (showAddDialog) AddGatewaysDialog(onAddUserGateway) { showAddDialog = false } + if (showAddDialog) AddGatewaysDialog(actions::addUserGateway) { showAddDialog = false } } } -@SuppressLint("MutableCollectionMutableState") @Composable -fun DefaultGatewaysSettings( - prefs: IPreferencesIpfs, - ipfsEnabled: Boolean, -) { - var disabledDefaultGateways by remember { mutableStateOf(prefs.ipfsGwDisabledDefaults) } - +fun DefaultGatewaysSettings(prefs: IpfsPreferences, actions: IpfsActions) { Column { - CaptionText( + Text( text = stringResource(id = R.string.ipfsgw_caption_official_gateways), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(0.dp, 16.dp, 0.dp, 4.dp) ) for (gatewayUrl in DEFAULT_GATEWAYS) { Row( @@ -139,21 +121,14 @@ fun DefaultGatewaysSettings( modifier = Modifier .weight(1f) .align(Alignment.CenterVertically) - .alpha(if (ipfsEnabled) 1f else 0.5f) + .alpha(if (prefs.isIpfsEnabled) 1f else 0.5f) ) Switch( - checked = !disabledDefaultGateways.contains(gatewayUrl), + checked = !prefs.disabledDefaultGateways.contains(gatewayUrl), onCheckedChange = { checked -> - val newList = disabledDefaultGateways.toMutableList() - if (!checked) { - newList.add(gatewayUrl) - } else { - newList.remove(gatewayUrl) - } - disabledDefaultGateways = newList - prefs.ipfsGwDisabledDefaults = newList + actions.setDefaultGatewayEnabled(gatewayUrl, checked) }, - enabled = ipfsEnabled, + enabled = prefs.isIpfsEnabled, modifier = Modifier.align(Alignment.CenterVertically) ) } @@ -161,31 +136,21 @@ fun DefaultGatewaysSettings( } } -@SuppressLint("MutableCollectionMutableState") @Composable -fun UserGatewaysSettings( - prefs: IPreferencesIpfs, - ipfsEnabled: Boolean, -) { - var userGateways by remember { mutableStateOf(prefs.ipfsGwUserList) } - - // Make sure list get updated when user returns from IpfsGatewayAddActivity - LifecycleEventListener { _, event -> - if (Lifecycle.Event.ON_RESUME == event) { - userGateways = prefs.ipfsGwUserList - } - } - +fun UserGatewaysSettings(prefs: IpfsPreferences, actions: IpfsActions) { Column { - if (userGateways.isNotEmpty()) { - CaptionText(text = stringResource(id = R.string.ipfsgw_caption_custom_gateways)) + if (prefs.userGateways.isNotEmpty()) { + Text( + text = stringResource(id = R.string.ipfsgw_caption_custom_gateways), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(0.dp, 16.dp, 0.dp, 4.dp) + ) } - for (gatewayUrl in userGateways) { + for (gatewayUrl in prefs.userGateways) { Row( modifier = Modifier .fillMaxWidth() .padding(48.dp, 4.dp, 0.dp, 4.dp) - ) { Text( text = gatewayUrl, @@ -193,17 +158,11 @@ fun UserGatewaysSettings( modifier = Modifier .weight(1f) .align(Alignment.CenterVertically) - .alpha(if (ipfsEnabled) 1f else 0.5f) + .alpha(if (prefs.isIpfsEnabled) 1f else 0.5f) ) IconButton( - onClick = { - val newGateways = userGateways.toMutableList() - newGateways.remove(gatewayUrl) - - userGateways = newGateways - prefs.ipfsGwUserList = newGateways - }, - enabled = ipfsEnabled, + onClick = { actions.removeUserGateway(gatewayUrl) }, + enabled = prefs.isIpfsEnabled, modifier = Modifier.align(Alignment.CenterVertically), ) { Icon( @@ -216,64 +175,22 @@ fun UserGatewaysSettings( } } -/** - * Composable that mimics MDC TextView with `@style/CaptionText` - */ -@Composable -private fun CaptionText(text: String) { - Text( - text = text, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(0.dp, 16.dp, 0.dp, 4.dp) - ) -} - -/** - * A tiny helper for consuming Activity lifecycle events. - * - * copied from https://stackoverflow.com/a/66807899 - * - * There is also an official API for consuming lifecycle events. However, at the time of writing - * it's not stable and I also couldn't find any actually working code snippets demonstrating - * its use. "androidx.lifecycle:lifecycle-runtime-compose" - */ -@Composable -fun LifecycleEventListener(onEvent: (owner: LifecycleOwner, event: Lifecycle.Event) -> Unit) { - val eventHandler = rememberUpdatedState(onEvent) - val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current) - - DisposableEffect(lifecycleOwner.value) { - val lifecycle = lifecycleOwner.value.lifecycle - val observer = LifecycleEventObserver { owner, event -> - eventHandler.value(owner, event) - } - - lifecycle.addObserver(observer) - onDispose { - lifecycle.removeObserver(observer) - } - } -} - @Composable @Preview fun SettingsScreenPreview() { - val prefs = object : IPreferencesIpfs { - override var isIpfsEnabled: Boolean - get() = true - set(_) {} - override var ipfsGwDisabledDefaults: List - get() = listOf("https://4everland.io/ipfs/") - set(_) {} - override var ipfsGwUserList: List - get() = listOf("https://my.imaginary.gateway/ifps/") - set(_) {} - } - FDroidContent { SettingsScreen( - prefs = prefs, - onAddUserGateway = {}, + prefs = IpfsPreferences( + isIpfsEnabled = true, + disabledDefaultGateways = listOf("https://4everland.io/ipfs/"), + userGateways = listOf("https://my.imaginary.gateway/ifps/") + ), + actions = object : IpfsActions { + override fun setIpfsEnabled(enabled: Boolean) {} + override fun setDefaultGatewayEnabled(url: String, enabled: Boolean) {} + override fun addUserGateway(url: String) {} + override fun removeUserGateway(url: String) {} + }, onBackClicked = {}, ) }