Skip to content

Commit 38c8001

Browse files
committed
Enhance Preferences reactivity and test coverage
Refactored ReactiveProperties to use snapshotStateMap for Compose reactivity. Improved PreferencesProvider and watchFile composables with better file watching, override support via system properties, and added documentation. Updated PreferencesKtTest to use temporary files and verify file-to-UI reactivity.
1 parent 4135460 commit 38c8001

File tree

2 files changed

+131
-21
lines changed

2 files changed

+131
-21
lines changed

app/src/processing/app/Preferences.kt

Lines changed: 102 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,24 +8,24 @@ import kotlinx.coroutines.flow.dropWhile
88
import kotlinx.coroutines.launch
99
import java.io.File
1010
import java.io.InputStream
11-
import java.io.OutputStream
1211
import java.nio.file.*
1312
import java.util.Properties
1413

15-
16-
const val PREFERENCES_FILE_NAME = "preferences.txt"
17-
const val DEFAULTS_FILE_NAME = "defaults.txt"
18-
14+
/*
15+
The ReactiveProperties class extends the standard Java Properties class
16+
to provide reactive capabilities using Jetpack Compose's mutableStateMapOf.
17+
This allows UI components to automatically update when preference values change.
18+
*/
1919
class ReactiveProperties: Properties() {
20-
val _stateMap = mutableStateMapOf<String, String>()
20+
val snapshotStateMap = mutableStateMapOf<String, String>()
2121

2222
override fun setProperty(key: String, value: String) {
2323
super.setProperty(key, value)
24-
_stateMap[key] = value
24+
snapshotStateMap[key] = value
2525
}
2626

2727
override fun getProperty(key: String): String? {
28-
return _stateMap[key] ?: super.getProperty(key)
28+
return snapshotStateMap[key] ?: super.getProperty(key)
2929
}
3030

3131
operator fun get(key: String): String? = getProperty(key)
@@ -34,34 +34,88 @@ class ReactiveProperties: Properties() {
3434
setProperty(key, value)
3535
}
3636
}
37+
38+
/*
39+
A CompositionLocal to provide access to the ReactiveProperties instance
40+
throughout the composable hierarchy.
41+
*/
3742
val LocalPreferences = compositionLocalOf<ReactiveProperties> { error("No preferences provided") }
43+
44+
const val PREFERENCES_FILE_NAME = "preferences.txt"
45+
const val DEFAULTS_FILE_NAME = "defaults.txt"
46+
47+
/*
48+
This composable function sets up a preferences provider that manages application settings.
49+
It initializes the preferences from a file, watches for changes to that file, and saves
50+
any updates back to the file. It uses a ReactiveProperties class to allow for reactive
51+
updates in the UI when preferences change.
52+
53+
usage:
54+
PreferencesProvider {
55+
// Your app content here
56+
}
57+
58+
to access preferences:
59+
val preferences = LocalPreferences.current
60+
val someSetting = preferences["someKey"] ?: "defaultValue"
61+
preferences["someKey"] = "newValue"
62+
63+
This will automatically save to the preferences file and update any UI components
64+
that are observing that key.
65+
66+
to override the preferences file (for testing, etc)
67+
System.setProperty("processing.app.preferences.file", "/path/to/your/preferences.txt")
68+
to override the debounce time (in milliseconds)
69+
System.setProperty("processing.app.preferences.debounce", "200")
70+
71+
*/
3872
@OptIn(FlowPreview::class)
3973
@Composable
4074
fun PreferencesProvider(content: @Composable () -> Unit){
75+
val preferencesFileOverride: File? = System.getProperty("processing.app.preferences.file")?.let { File(it) }
76+
val preferencesDebounceOverride: Long? = System.getProperty("processing.app.preferences.debounce")?.toLongOrNull()
77+
78+
// Initialize the platform (if not already done) to ensure we have access to the settings folder
4179
remember {
4280
Platform.init()
4381
}
4482

83+
// Grab the preferences file, creating it if it doesn't exist
84+
// TODO: This functionality should be separated from the `Preferences` class itself
4585
val settingsFolder = Platform.getSettingsFolder()
46-
val preferencesFile = settingsFolder.resolve(PREFERENCES_FILE_NAME)
86+
val preferencesFile = preferencesFileOverride ?: settingsFolder.resolve(PREFERENCES_FILE_NAME)
4787
if(!preferencesFile.exists()){
4888
preferencesFile.mkdirs()
4989
preferencesFile.createNewFile()
5090
}
5191

5292
val update = watchFile(preferencesFile)
53-
val properties = remember(preferencesFile, update) { ReactiveProperties().apply {
54-
load((ClassLoader.getSystemResourceAsStream(DEFAULTS_FILE_NAME)?: InputStream.nullInputStream()).reader(Charsets.UTF_8))
55-
load(preferencesFile.inputStream().reader(Charsets.UTF_8))
56-
}}
5793

58-
val initialState = remember(properties) { properties._stateMap.toMap() }
5994

95+
val properties = remember(preferencesFile, update) {
96+
ReactiveProperties().apply {
97+
val defaultsStream = ClassLoader.getSystemResourceAsStream(DEFAULTS_FILE_NAME)
98+
?: InputStream.nullInputStream()
99+
load(defaultsStream
100+
.reader(Charsets.UTF_8)
101+
)
102+
load(preferencesFile
103+
.inputStream()
104+
.reader(Charsets.UTF_8)
105+
)
106+
}
107+
}
108+
109+
val initialState = remember(properties) { properties.snapshotStateMap.toMap() }
110+
111+
// Listen for changes to the preferences and save them to file
60112
LaunchedEffect(properties) {
61-
snapshotFlow { properties._stateMap.toMap() }
113+
snapshotFlow { properties.snapshotStateMap.toMap() }
62114
.dropWhile { it == initialState }
63-
.debounce(100)
115+
.debounce(preferencesDebounceOverride ?: 100)
64116
.collect {
117+
118+
// Save the preferences to file, sorted alphabetically
65119
preferencesFile.outputStream().use { output ->
66120
output.write(
67121
properties.entries
@@ -78,24 +132,53 @@ fun PreferencesProvider(content: @Composable () -> Unit){
78132
}
79133

80134
}
135+
136+
/*
137+
This composable function watches a specified file for modifications. When the file is modified,
138+
it updates a state variable with the latest WatchEvent. This can be useful for triggering UI updates
139+
or other actions in response to changes in the file.
140+
141+
To watch the file at the fasted speed (for testing) set the following system property:
142+
System.setProperty("processing.app.watchfile.forced", "true")
143+
*/
81144
@Composable
82145
fun watchFile(file: File): Any? {
146+
val forcedWatch: Boolean = System.getProperty("processing.app.watchfile.forced").toBoolean()
147+
83148
val scope = rememberCoroutineScope()
84149
var event by remember(file) { mutableStateOf<WatchEvent<*>?> (null) }
85150

86151
DisposableEffect(file){
87152
val fileSystem = FileSystems.getDefault()
88153
val watcher = fileSystem.newWatchService()
154+
89155
var active = true
90156

157+
// In forced mode we just poll the last modified time of the file
158+
// This is not efficient but works better for testing with temp files
159+
val toWatch = { file.lastModified() }
160+
var state = toWatch()
161+
91162
val path = file.toPath()
92163
val parent = path.parent
93164
val key = parent.register(watcher, StandardWatchEventKinds.ENTRY_MODIFY)
94165
scope.launch(Dispatchers.IO) {
95166
while (active) {
96-
for (modified in key.pollEvents()) {
97-
if (modified.context() != path.fileName) continue
98-
event = modified
167+
if(forcedWatch) {
168+
if(toWatch() == state) continue
169+
state = toWatch()
170+
event = object : WatchEvent<Path> {
171+
override fun count(): Int = 1
172+
override fun context(): Path = file.toPath().fileName
173+
override fun kind(): WatchEvent.Kind<Path> = StandardWatchEventKinds.ENTRY_MODIFY
174+
override fun toString(): String = "ForcedEvent(${context()})"
175+
}
176+
continue
177+
}else{
178+
for (modified in key.pollEvents()) {
179+
if (modified.context() != path.fileName) continue
180+
event = modified
181+
}
99182
}
100183
}
101184
}

app/test/processing/app/PreferencesKtTest.kt

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,29 @@ import androidx.compose.material.Text
55
import androidx.compose.ui.Modifier
66
import androidx.compose.ui.platform.testTag
77
import androidx.compose.ui.test.*
8+
import java.util.Properties
9+
import kotlin.io.path.createFile
10+
import kotlin.io.path.createTempDirectory
811
import kotlin.test.Test
912

1013
class PreferencesKtTest{
1114
@OptIn(ExperimentalTestApi::class)
1215
@Test
1316
fun testKeyReactivity() = runComposeUiTest {
17+
val directory = createTempDirectory("preferences")
18+
val tempPreferences = directory
19+
.resolve("preferences.txt")
20+
.createFile()
21+
.toFile()
22+
23+
// Set system properties for testing
24+
System.setProperty("processing.app.preferences.file", tempPreferences.absolutePath)
25+
System.setProperty("processing.app.preferences.debounce", "0")
26+
System.setProperty("processing.app.watchfile.forced", "true")
27+
1428
val newValue = (0..Int.MAX_VALUE).random().toString()
15-
val testKey = "test.preferences.reactivity.$newValue"
29+
val testKey = "test.preferences.reactivity"
30+
1631
setContent {
1732
PreferencesProvider {
1833
val preferences = LocalPreferences.current
@@ -29,6 +44,18 @@ class PreferencesKtTest{
2944
onNodeWithTag("text").assertTextEquals("default")
3045
onNodeWithTag("button").performClick()
3146
onNodeWithTag("text").assertTextEquals(newValue)
32-
}
3347

48+
val preferences = Properties()
49+
preferences.load(tempPreferences.inputStream().reader(Charsets.UTF_8))
50+
51+
// Check if the preference was saved to file
52+
assert(preferences[testKey] == newValue)
53+
54+
55+
val nextValue = (0..Int.MAX_VALUE).random().toString()
56+
// Overwrite the file to see if the UI updates
57+
tempPreferences.writeText("$testKey=${nextValue}")
58+
59+
onNodeWithTag("text").assertTextEquals(nextValue)
60+
}
3461
}

0 commit comments

Comments
 (0)