Hoist IPFS state to IpfsManager

and observe that state in UI. Also move all action code into the manager.
This commit is contained in:
Torsten Grote
2026-02-20 14:01:49 -03:00
parent 7a45ea3571
commit 4936a01af7
5 changed files with 113 additions and 150 deletions

View File

@@ -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<String>
var ipfsGwUserList: List<String>
}

View File

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

View File

@@ -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<String>
private var disabledDefaultGateways: List<String>
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<String>
private var userGateways: List<String>
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<String> {
try {
val jsonArray = JSONArray(json)

View File

@@ -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<String>,
val userGateways: List<String>,
)

View File

@@ -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<String>
get() = listOf("https://4everland.io/ipfs/")
set(_) {}
override var ipfsGwUserList: List<String>
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 = {},
)
}