Skip to content

Commit d44ec69

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 d44ec69

File tree

2 files changed

+127
-21
lines changed

2 files changed

+127
-21
lines changed

app/src/processing/app/Preferences.kt

Lines changed: 98 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,84 @@ class ReactiveProperties: Properties() {
3434
setProperty(key, value)
3535
}
3636
}
37+
3738
val LocalPreferences = compositionLocalOf<ReactiveProperties> { error("No preferences provided") }
39+
40+
const val PREFERENCES_FILE_NAME = "preferences.txt"
41+
const val DEFAULTS_FILE_NAME = "defaults.txt"
42+
43+
/*
44+
This composable function sets up a preferences provider that manages application settings.
45+
It initializes the preferences from a file, watches for changes to that file, and saves
46+
any updates back to the file. It uses a ReactiveProperties class to allow for reactive
47+
updates in the UI when preferences change.
48+
49+
usage:
50+
PreferencesProvider {
51+
// Your app content here
52+
}
53+
54+
to access preferences:
55+
val preferences = LocalPreferences.current
56+
val someSetting = preferences["someKey"] ?: "defaultValue"
57+
preferences["someKey"] = "newValue"
58+
59+
This will automatically save to the preferences file and update any UI components
60+
that are observing that key.
61+
62+
to override the preferences file (for testing, etc)
63+
System.setProperty("processing.app.preferences.file", "/path/to/your/preferences.txt")
64+
to override the debounce time (in milliseconds)
65+
System.setProperty("processing.app.preferences.debounce", "200")
66+
67+
*/
3868
@OptIn(FlowPreview::class)
3969
@Composable
4070
fun PreferencesProvider(content: @Composable () -> Unit){
71+
val preferencesFileOverride: File? = System.getProperty("processing.app.preferences.file")?.let { File(it) }
72+
val preferencesDebounceOverride: Long? = System.getProperty("processing.app.preferences.debounce")?.toLongOrNull()
73+
74+
// Initialize the platform (if not already done) to ensure we have access to the settings folder
4175
remember {
4276
Platform.init()
4377
}
4478

79+
// Grab the preferences file, creating it if it doesn't exist
80+
// TODO: This functionality should be separated from the `Preferences` class itself
4581
val settingsFolder = Platform.getSettingsFolder()
46-
val preferencesFile = settingsFolder.resolve(PREFERENCES_FILE_NAME)
82+
val preferencesFile = preferencesFileOverride ?: settingsFolder.resolve(PREFERENCES_FILE_NAME)
4783
if(!preferencesFile.exists()){
4884
preferencesFile.mkdirs()
4985
preferencesFile.createNewFile()
5086
}
5187

5288
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-
}}
5789

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

91+
val properties = remember(preferencesFile, update) {
92+
ReactiveProperties().apply {
93+
val defaultsStream = ClassLoader.getSystemResourceAsStream(DEFAULTS_FILE_NAME)
94+
?: InputStream.nullInputStream()
95+
load(defaultsStream
96+
.reader(Charsets.UTF_8)
97+
)
98+
load(preferencesFile
99+
.inputStream()
100+
.reader(Charsets.UTF_8)
101+
)
102+
}
103+
}
104+
105+
val initialState = remember(properties) { properties.snapshotStateMap.toMap() }
106+
107+
// Listen for changes to the preferences and save them to file
60108
LaunchedEffect(properties) {
61-
snapshotFlow { properties._stateMap.toMap() }
109+
snapshotFlow { properties.snapshotStateMap.toMap() }
62110
.dropWhile { it == initialState }
63-
.debounce(100)
111+
.debounce(preferencesDebounceOverride ?: 100)
64112
.collect {
113+
114+
// Save the preferences to file, sorted alphabetically
65115
preferencesFile.outputStream().use { output ->
66116
output.write(
67117
properties.entries
@@ -78,24 +128,53 @@ fun PreferencesProvider(content: @Composable () -> Unit){
78128
}
79129

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

86147
DisposableEffect(file){
87148
val fileSystem = FileSystems.getDefault()
88149
val watcher = fileSystem.newWatchService()
150+
89151
var active = true
90152

153+
// In forced mode we just poll the last modified time of the file
154+
// This is not efficient but works better for testing with temp files
155+
val toWatch = { file.lastModified() }
156+
var state = toWatch()
157+
91158
val path = file.toPath()
92159
val parent = path.parent
93160
val key = parent.register(watcher, StandardWatchEventKinds.ENTRY_MODIFY)
94161
scope.launch(Dispatchers.IO) {
95162
while (active) {
96-
for (modified in key.pollEvents()) {
97-
if (modified.context() != path.fileName) continue
98-
event = modified
163+
if(forcedWatch) {
164+
if(toWatch() == state) continue
165+
state = toWatch()
166+
event = object : WatchEvent<Path> {
167+
override fun count(): Int = 1
168+
override fun context(): Path = file.toPath().fileName
169+
override fun kind(): WatchEvent.Kind<Path> = StandardWatchEventKinds.ENTRY_MODIFY
170+
override fun toString(): String = "ForcedEvent(${context()})"
171+
}
172+
continue
173+
}else{
174+
for (modified in key.pollEvents()) {
175+
if (modified.context() != path.fileName) continue
176+
event = modified
177+
}
99178
}
100179
}
101180
}

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)