Skip to content

Commit 9be0035

Browse files
Fix SharedPreferences no-op guard silently discarding writes
The no-op guard in Editor.commit() compares filtered_data against preferences.data and skips the file write if they match. But any code that got a mutable reference via get_dict()/get_list(), mutated it, then created an Editor had already mutated preferences.data in-place before the Editor's deep-copy — so the guard always saw equality and never wrote to disk. This affected ALL preferences, not just WiFi. Fixes: - config.py: get_dict() and get_list() now return copies so that accidental mutation doesn't corrupt prefs.data before the Editor snapshot. - wifi_service.py: use editor.put_dict_item() / remove_dict_item() to operate on the Editor's deep copy instead. - testing/mocks.py: add put_dict_item() / remove_dict_item() to MockEditor for test compatibility. - tests: add regression tests for the get_dict -> mutate -> edit -> commit pattern.
1 parent bfb17bf commit 9be0035

5 files changed

Lines changed: 91 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@ Future release (next version)
33

44
Put unreleased changes here!
55

6+
0.11.2
7+
======
8+
9+
Frameworks:
10+
- Fix SharedPreferences no-op guard silently discarding writes (affects all settings, not just WiFi)
11+
12+
613
0.11.1
714
======
815

internal_filesystem/lib/mpos/config.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ def get_bool(self, key, default=False):
123123
def get_list(self, key, default=None):
124124
"""Retrieve a list for the given key, with a default if not found."""
125125
if key in self.data:
126-
return self.data[key]
126+
return list(self.data[key]) # return a copy — callers must not mutate prefs.data directly
127127
# Key not in stored data, check defaults
128128
# Method default takes precedence if provided
129129
if default is not None:
@@ -137,7 +137,7 @@ def get_list(self, key, default=None):
137137
def get_dict(self, key, default=None):
138138
"""Retrieve a dictionary for the given key, with a default if not found."""
139139
if key in self.data:
140-
return self.data[key]
140+
return dict(self.data[key]) # return a copy — callers must not mutate prefs.data directly
141141
# Key not in stored data, check defaults
142142
# Method default takes precedence if provided
143143
if default is not None:

internal_filesystem/lib/mpos/net/wifi_service.py

Lines changed: 17 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -730,21 +730,20 @@ def save_network(ssid, password, hidden=False):
730730
"""
731731
# Load current saved networks
732732
prefs = mpos.config.SharedPreferences(WIFI_SERVICE_PREFS_KEY)
733-
access_points = prefs.get_dict("access_points")
734733

735-
# Add or update the network
734+
# Build the network config
736735
network_config = {"password": password}
737736
if hidden:
738737
network_config["hidden"] = True
739-
access_points[ssid] = network_config
740738

741-
# Save back to config
739+
# Modify the deep copy inside the editor, not prefs.data directly,
740+
# otherwise the no-op guard in commit() sees no change and skips writing.
742741
editor = prefs.edit()
743-
editor.put_dict("access_points", access_points)
742+
editor.put_dict_item("access_points", ssid, network_config)
744743
editor.commit()
745744

746745
# Update class-level cache
747-
WifiService.access_points = access_points
746+
WifiService.access_points = prefs.get_dict("access_points")
748747

749748
print(f"WifiService: Saved network '{ssid}' (hidden={hidden})")
750749

@@ -761,23 +760,20 @@ def forget_network(ssid):
761760
"""
762761
# Load current saved networks
763762
prefs = mpos.config.SharedPreferences(WIFI_SERVICE_PREFS_KEY)
764-
access_points = prefs.get_dict("access_points")
765763

766-
# Remove the network if it exists
767-
if ssid in access_points:
768-
del access_points[ssid]
764+
# Check if the network exists without mutating prefs.data
765+
if ssid not in prefs.get_dict("access_points"):
766+
return False
769767

770-
# Save back to config
771-
editor = prefs.edit()
772-
editor.put_dict("access_points", access_points)
773-
editor.commit()
768+
# Modify the deep copy inside the editor, not prefs.data directly,
769+
# otherwise the no-op guard in commit() sees no change and skips writing.
770+
editor = prefs.edit()
771+
editor.remove_dict_item("access_points", ssid)
772+
editor.commit()
774773

775-
# Update class-level cache
776-
WifiService.access_points = access_points
774+
# Update class-level cache
775+
WifiService.access_points = prefs.get_dict("access_points")
777776

778-
print(f"WifiService: Forgot network '{ssid}'")
779-
return True
780-
else:
781-
print(f"WifiService: Network '{ssid}' not found in saved networks")
782-
return False
777+
print(f"WifiService: Forgot network '{ssid}'")
778+
return True
783779

internal_filesystem/lib/mpos/testing/mocks.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -973,6 +973,19 @@ def __init__(self, prefs):
973973
def put_dict(self, key, value):
974974
self.pending[key] = value
975975

976+
def put_dict_item(self, dict_key, item_key, config):
977+
if dict_key not in self.pending:
978+
existing = self.prefs._get_value(dict_key, {})
979+
self.pending[dict_key] = dict(existing)
980+
if isinstance(config, dict):
981+
self.pending[dict_key][item_key] = config
982+
983+
def remove_dict_item(self, dict_key, item_key):
984+
if dict_key not in self.pending:
985+
existing = self.prefs._get_value(dict_key, {})
986+
self.pending[dict_key] = dict(existing)
987+
self.pending[dict_key].pop(item_key, None)
988+
976989
def put_list(self, key, value):
977990
self.pending[key] = value
978991

tests/test_shared_preferences.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,58 @@ def test_get_dict_keys_nonexistent(self):
308308
prefs = SharedPreferences(self.test_app_name)
309309
self.assertEqual(prefs.get_dict_keys("nonexistent"), [])
310310

311+
def test_get_dict_mutate_then_edit_persists(self):
312+
"""
313+
Test that mutating a dict returned by get_dict() before creating an
314+
editor does NOT prevent commit() from writing to disk.
315+
316+
Regression guard: get_dict() returns a direct reference to prefs.data.
317+
Mutating that reference modifies prefs.data in-place. If edit()
318+
then deep-copies the already-mutated data, commit()'s no-op guard
319+
sees filtered_data == preferences.data and skips the write.
320+
Editors must operate on their own deep copy.
321+
"""
322+
prefs = SharedPreferences(self.test_app_name)
323+
324+
# 1. Save initial data
325+
prefs.edit().put_dict("access_points", {"ExistingNet": {"password": "old"}}).commit()
326+
327+
# 2. Simulate the WifiService bug pattern: get_dict, mutate, edit, commit
328+
prefs2 = SharedPreferences(self.test_app_name)
329+
aps = prefs2.get_dict("access_points") # direct reference
330+
aps["NewNet"] = {"password": "secret"} # mutates prefs2.data in-place
331+
editor = prefs2.edit() # deep-copies already-mutated data
332+
editor.put_dict("access_points", aps)
333+
editor.commit()
334+
335+
# 3. Verify the change was persisted
336+
prefs3 = SharedPreferences(self.test_app_name)
337+
saved = prefs3.get_dict("access_points")
338+
self.assertIn("NewNet", saved)
339+
340+
def test_get_dict_mutate_remove_then_edit_persists(self):
341+
"""
342+
Same as test_get_dict_mutate_then_edit_persists but for deletion.
343+
"""
344+
prefs = SharedPreferences(self.test_app_name)
345+
346+
# 1. Save initial data
347+
prefs.edit().put_dict("access_points", {"NetA": {"password": "a"}, "NetB": {"password": "b"}}).commit()
348+
349+
# 2. Bug pattern: get_dict, delete from it, then edit + commit
350+
prefs2 = SharedPreferences(self.test_app_name)
351+
aps = prefs2.get_dict("access_points")
352+
del aps["NetA"]
353+
editor = prefs2.edit()
354+
editor.put_dict("access_points", aps)
355+
editor.commit()
356+
357+
# 3. Verify the change was persisted
358+
prefs3 = SharedPreferences(self.test_app_name)
359+
saved = prefs3.get_dict("access_points")
360+
self.assertFalse("NetA" in saved)
361+
self.assertTrue("NetB" in saved)
362+
311363
# ============================================================
312364
# Editor Operations
313365
# ============================================================

0 commit comments

Comments
 (0)