Skip to content

Commit 49a70e1

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 49a70e1

File tree

2 files changed

+128
-21
lines changed

2 files changed

+128
-21
lines changed

app/src/processing/app/Preferences.kt

Lines changed: 99 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,29 +3,30 @@ package processing.app
33
import androidx.compose.runtime.*
44
import kotlinx.coroutines.Dispatchers
55
import kotlinx.coroutines.FlowPreview
6+
import kotlinx.coroutines.delay
67
import kotlinx.coroutines.flow.debounce
78
import kotlinx.coroutines.flow.dropWhile
89
import kotlinx.coroutines.launch
910
import java.io.File
1011
import java.io.InputStream
11-
import java.io.OutputStream
1212
import java.nio.file.*
1313
import java.util.Properties
1414

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

2223
override fun setProperty(key: String, value: String) {
2324
super.setProperty(key, value)
24-
_stateMap[key] = value
25+
snapshotStateMap[key] = value
2526
}
2627

2728
override fun getProperty(key: String): String? {
28-
return _stateMap[key] ?: super.getProperty(key)
29+
return snapshotStateMap[key] ?: super.getProperty(key)
2930
}
3031

3132
operator fun get(key: String): String? = getProperty(key)
@@ -34,34 +35,84 @@ class ReactiveProperties: Properties() {
3435
setProperty(key, value)
3536
}
3637
}
38+
3739
val LocalPreferences = compositionLocalOf<ReactiveProperties> { error("No preferences provided") }
40+
41+
const val PREFERENCES_FILE_NAME = "preferences.txt"
42+
const val DEFAULTS_FILE_NAME = "defaults.txt"
43+
44+
/*
45+
This composable function sets up a preferences provider that manages application settings.
46+
It initializes the preferences from a file, watches for changes to that file, and saves
47+
any updates back to the file. It uses a ReactiveProperties class to allow for reactive
48+
updates in the UI when preferences change.
49+
50+
usage:
51+
PreferencesProvider {
52+
// Your app content here
53+
}
54+
55+
to access preferences:
56+
val preferences = LocalPreferences.current
57+
val someSetting = preferences["someKey"] ?: "defaultValue"
58+
preferences["someKey"] = "newValue"
59+
60+
This will automatically save to the preferences file and update any UI components
61+
that are observing that key.
62+
63+
to override the preferences file (for testing, etc)
64+
System.setProperty("processing.app.preferences.file", "/path/to/your/preferences.txt")
65+
to override the debounce time (in milliseconds)
66+
System.setProperty("processing.app.preferences.debounce", "200")
67+
68+
*/
3869
@OptIn(FlowPreview::class)
3970
@Composable
4071
fun PreferencesProvider(content: @Composable () -> Unit){
72+
val preferencesFileOverride: File? = System.getProperty("processing.app.preferences.file")?.let { File(it) }
73+
val preferencesDebounceOverride: Long? = System.getProperty("processing.app.preferences.debounce")?.toLongOrNull()
74+
75+
// Initialize the platform (if not already done) to ensure we have access to the settings folder
4176
remember {
4277
Platform.init()
4378
}
4479

80+
// Grab the preferences file, creating it if it doesn't exist
81+
// TODO: This functionality should be separated from the `Preferences` class itself
4582
val settingsFolder = Platform.getSettingsFolder()
46-
val preferencesFile = settingsFolder.resolve(PREFERENCES_FILE_NAME)
83+
val preferencesFile = preferencesFileOverride ?: settingsFolder.resolve(PREFERENCES_FILE_NAME)
4784
if(!preferencesFile.exists()){
4885
preferencesFile.mkdirs()
4986
preferencesFile.createNewFile()
5087
}
5188

5289
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-
}}
5790

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

92+
val properties = remember(preferencesFile, update) {
93+
ReactiveProperties().apply {
94+
val defaultsStream = ClassLoader.getSystemResourceAsStream(DEFAULTS_FILE_NAME)
95+
?: InputStream.nullInputStream()
96+
load(defaultsStream
97+
.reader(Charsets.UTF_8)
98+
)
99+
load(preferencesFile
100+
.inputStream()
101+
.reader(Charsets.UTF_8)
102+
)
103+
}
104+
}
105+
106+
val initialState = remember(properties) { properties.snapshotStateMap.toMap() }
107+
108+
// Listen for changes to the preferences and save them to file
60109
LaunchedEffect(properties) {
61-
snapshotFlow { properties._stateMap.toMap() }
110+
snapshotFlow { properties.snapshotStateMap.toMap() }
62111
.dropWhile { it == initialState }
63-
.debounce(100)
112+
.debounce(preferencesDebounceOverride ?: 100)
64113
.collect {
114+
115+
// Save the preferences to file, sorted alphabetically
65116
preferencesFile.outputStream().use { output ->
66117
output.write(
67118
properties.entries
@@ -78,24 +129,53 @@ fun PreferencesProvider(content: @Composable () -> Unit){
78129
}
79130

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

86148
DisposableEffect(file){
87149
val fileSystem = FileSystems.getDefault()
88150
val watcher = fileSystem.newWatchService()
151+
89152
var active = true
90153

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

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)