Skip to content

Commit b3b69b3

Browse files
authored
fix: data sync always points to legacy database (#164)
* docs: plan * fix: phase 1 and 2 * feat: phase 3 and 4 * test: fix node tests * fix: use prefs because you can't import main app stuff in expo module * fix: build and bug * test: actual module * fix: correct default
1 parent aa239ff commit b3b69b3

11 files changed

Lines changed: 855 additions & 11 deletions

File tree

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
//
2+
// Calendar Notifications Plus
3+
// Copyright (C) 2025 William Harris (wharris+cnplus@upscalews.com)
4+
//
5+
// This program is free software; you can redistribute it and/or modify
6+
// it under the terms of the GNU General Public License as published by
7+
// the Free Software Foundation; either version 3 of the License, or
8+
// (at your option) any later version.
9+
//
10+
// This program is distributed in the hope that it will be useful,
11+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
// GNU General Public License for more details.
14+
//
15+
// You should have received a copy of the GNU General Public License
16+
// along with this program; if not, write to the Free Software Foundation,
17+
// Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
18+
//
19+
20+
package com.github.quarck.calnotify.eventsstorage
21+
22+
import android.content.Context
23+
import androidx.test.ext.junit.runners.AndroidJUnit4
24+
import androidx.test.platform.app.InstrumentationRegistry
25+
import com.github.quarck.calnotify.logs.DevLog
26+
import expo.modules.mymodule.MyModule
27+
import org.junit.After
28+
import org.junit.Assert.*
29+
import org.junit.Before
30+
import org.junit.Test
31+
import org.junit.runner.RunWith
32+
33+
/**
34+
* Contract tests for sync database consistency.
35+
*
36+
* These tests verify that the expo module (MyModule) and main app (EventsStorage)
37+
* agree on SharedPreferences keys and database names. This prevents the bug where
38+
* Room migration caused native code to write to "RoomEvents" while sync code
39+
* was hardcoded to read from "Events".
40+
*
41+
* The fix: Communication via SharedPreferences with matching constants.
42+
*
43+
* @see docs/dev_todo/sync_database_mismatch.md
44+
*/
45+
@RunWith(AndroidJUnit4::class)
46+
class SyncDatabaseContractTest {
47+
48+
companion object {
49+
private const val LOG_TAG = "SyncDatabaseContractTest"
50+
}
51+
52+
private lateinit var context: Context
53+
54+
@Before
55+
fun setup() {
56+
context = InstrumentationRegistry.getInstrumentation().targetContext
57+
DevLog.info(LOG_TAG, "Test setup complete")
58+
}
59+
60+
@After
61+
fun cleanup() {
62+
DevLog.info(LOG_TAG, "Test cleanup complete")
63+
}
64+
65+
/**
66+
* CRITICAL: Verifies that MyModule and EventsStorage use the same SharedPreferences file name.
67+
* If these don't match, the expo module won't read what the app writes.
68+
*/
69+
@Test
70+
fun sharedPreferencesFileNameMatches() {
71+
assertEquals(
72+
"MyModule and EventsStorage must use the same SharedPreferences file name",
73+
EventsStorage.STORAGE_PREFS_NAME,
74+
MyModule.STORAGE_PREFS_NAME
75+
)
76+
DevLog.info(LOG_TAG, "✅ Prefs file name matches: ${EventsStorage.STORAGE_PREFS_NAME}")
77+
}
78+
79+
/**
80+
* CRITICAL: Verifies that MyModule and EventsStorage use the same key for database name.
81+
*/
82+
@Test
83+
fun databaseNamePreferenceKeyMatches() {
84+
assertEquals(
85+
"MyModule and EventsStorage must use the same key for active_db_name",
86+
EventsStorage.PREF_ACTIVE_DB_NAME,
87+
MyModule.PREF_ACTIVE_DB_NAME
88+
)
89+
DevLog.info(LOG_TAG, "✅ DB name key matches: ${EventsStorage.PREF_ACTIVE_DB_NAME}")
90+
}
91+
92+
/**
93+
* CRITICAL: Verifies that MyModule and EventsStorage use the same key for is_using_room flag.
94+
*/
95+
@Test
96+
fun isUsingRoomPreferenceKeyMatches() {
97+
assertEquals(
98+
"MyModule and EventsStorage must use the same key for is_using_room",
99+
EventsStorage.PREF_IS_USING_ROOM,
100+
MyModule.PREF_IS_USING_ROOM
101+
)
102+
DevLog.info(LOG_TAG, "✅ isUsingRoom key matches: ${EventsStorage.PREF_IS_USING_ROOM}")
103+
}
104+
105+
/**
106+
* CRITICAL: Verifies that MyModule's Room database name constant matches EventsDatabase.
107+
*/
108+
@Test
109+
fun roomDatabaseNameConstantMatches() {
110+
assertEquals(
111+
"MyModule.ROOM_DATABASE_NAME must match EventsDatabase.DATABASE_NAME",
112+
EventsDatabase.DATABASE_NAME,
113+
MyModule.ROOM_DATABASE_NAME
114+
)
115+
DevLog.info(LOG_TAG, "✅ Room DB name matches: ${EventsDatabase.DATABASE_NAME}")
116+
}
117+
118+
/**
119+
* CRITICAL: Verifies that MyModule's legacy database name constant matches EventsDatabase.
120+
*/
121+
@Test
122+
fun legacyDatabaseNameConstantMatches() {
123+
assertEquals(
124+
"MyModule.LEGACY_DATABASE_NAME must match EventsDatabase.LEGACY_DATABASE_NAME",
125+
EventsDatabase.LEGACY_DATABASE_NAME,
126+
MyModule.LEGACY_DATABASE_NAME
127+
)
128+
DevLog.info(LOG_TAG, "✅ Legacy DB name matches: ${EventsDatabase.LEGACY_DATABASE_NAME}")
129+
}
130+
131+
/**
132+
* Integration test: Verifies that after EventsStorage writes to SharedPreferences,
133+
* reading with MyModule's keys returns the correct values.
134+
*
135+
* This is the actual contract - what EventsStorage writes, MyModule must be able to read.
136+
*/
137+
@Test
138+
fun myModuleCanReadWhatEventsStorageWrites() {
139+
// Create storage - this writes to SharedPreferences
140+
val storage = EventsStorage(context)
141+
142+
// Read using MyModule's constants (simulating what the expo module does)
143+
val prefs = context.getSharedPreferences(MyModule.STORAGE_PREFS_NAME, Context.MODE_PRIVATE)
144+
val dbNameFromPrefs = prefs.getString(MyModule.PREF_ACTIVE_DB_NAME, MyModule.LEGACY_DATABASE_NAME)
145+
val isRoomFromPrefs = prefs.getBoolean(MyModule.PREF_IS_USING_ROOM, false)
146+
147+
// Verify they match what EventsStorage reports
148+
val expectedDbName = if (storage.isUsingRoom) {
149+
EventsDatabase.DATABASE_NAME
150+
} else {
151+
EventsDatabase.LEGACY_DATABASE_NAME
152+
}
153+
154+
assertEquals(
155+
"DB name read via MyModule's keys should match EventsStorage state",
156+
expectedDbName,
157+
dbNameFromPrefs
158+
)
159+
160+
assertEquals(
161+
"isUsingRoom read via MyModule's keys should match EventsStorage state",
162+
storage.isUsingRoom,
163+
isRoomFromPrefs
164+
)
165+
166+
DevLog.info(LOG_TAG, "✅ MyModule can read EventsStorage's values: dbName=$dbNameFromPrefs, isRoom=$isRoomFromPrefs")
167+
168+
storage.close()
169+
}
170+
171+
/**
172+
* Default test: Verifies that when SharedPreferences is empty (before EventsStorage
173+
* has been created), MyModule defaults to Room database (the primary implementation).
174+
*
175+
* Room is the primary implementation; legacy is only used if Room migration fails.
176+
*/
177+
@Test
178+
fun defaultsToRoomWhenPrefsEmpty() {
179+
// Clear the SharedPreferences to simulate fresh state
180+
val prefs = context.getSharedPreferences(MyModule.STORAGE_PREFS_NAME, Context.MODE_PRIVATE)
181+
prefs.edit().clear().commit()
182+
183+
// Read with defaults (simulating what MyModule.getActiveEventsDbName does)
184+
// These defaults must match what's in MyModule.kt
185+
val dbNameFromPrefs = prefs.getString(MyModule.PREF_ACTIVE_DB_NAME, MyModule.ROOM_DATABASE_NAME)
186+
?: MyModule.ROOM_DATABASE_NAME
187+
val isRoomFromPrefs = prefs.getBoolean(MyModule.PREF_IS_USING_ROOM, true)
188+
189+
// Should default to Room (primary implementation)
190+
assertEquals(
191+
"When prefs are empty, should default to Room database name",
192+
MyModule.ROOM_DATABASE_NAME,
193+
dbNameFromPrefs
194+
)
195+
196+
assertEquals(
197+
"When prefs are empty, isUsingRoom should default to true",
198+
true,
199+
isRoomFromPrefs
200+
)
201+
202+
DevLog.info(LOG_TAG, "✅ Defaults to Room: dbName=$dbNameFromPrefs, isRoom=$isRoomFromPrefs")
203+
204+
// Now create storage to restore proper state for other tests
205+
val storage = EventsStorage(context)
206+
storage.close()
207+
}
208+
209+
/**
210+
* Verifies that MyModule's default values point to Room (the primary implementation).
211+
* Legacy is only used as fallback when Room migration explicitly fails.
212+
*/
213+
@Test
214+
fun defaultValuesPointToRoom() {
215+
// MyModule's Room database name should be correct
216+
assertEquals(
217+
"MyModule.ROOM_DATABASE_NAME should be 'RoomEvents'",
218+
"RoomEvents",
219+
MyModule.ROOM_DATABASE_NAME
220+
)
221+
222+
// MyModule's legacy database name should also be correct (for fallback)
223+
assertEquals(
224+
"MyModule.LEGACY_DATABASE_NAME should be 'Events'",
225+
"Events",
226+
MyModule.LEGACY_DATABASE_NAME
227+
)
228+
229+
// The Room database file path should be resolvable
230+
val roomDbPath = context.getDatabasePath(MyModule.ROOM_DATABASE_NAME)
231+
assertNotNull("Room database path should be resolvable", roomDbPath)
232+
233+
DevLog.info(LOG_TAG, "✅ Default values correct: room=${MyModule.ROOM_DATABASE_NAME}, legacy=${MyModule.LEGACY_DATABASE_NAME}")
234+
}
235+
}

android/app/src/main/java/com/github/quarck/calnotify/eventsstorage/EventsStorage.kt

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,20 +36,34 @@ import java.io.Closeable
3636
* - Legacy database is preserved untouched
3737
*/
3838
class EventsStorage private constructor(
39+
context: Context,
3940
result: Pair<EventsStorageInterface, Boolean>
4041
) : EventsStorageInterface by result.first, Closeable {
4142

4243
private val delegate: EventsStorageInterface = result.first
4344
val isUsingRoom: Boolean = result.second
4445

45-
constructor(context: Context) : this(createStorage(context))
46+
init {
47+
// Write storage state to SharedPreferences for cross-module communication
48+
// (expo native module reads this to know which database to use for sync)
49+
// This prefs file is NOT in backup_rules.xml, so it won't be backed up
50+
writeStorageState(context, isUsingRoom)
51+
}
52+
53+
constructor(context: Context) : this(context, createStorage(context))
4654

4755
override fun close() {
4856
(delegate as? Closeable)?.close()
4957
}
5058

5159
companion object {
5260
private const val LOG_TAG = "EventsStorage"
61+
62+
// SharedPreferences for cross-module communication (not backed up - see backup_rules.xml)
63+
// These constants must match MyModule.kt in the expo module
64+
const val STORAGE_PREFS_NAME = "events_storage_state"
65+
const val PREF_ACTIVE_DB_NAME = "active_db_name"
66+
const val PREF_IS_USING_ROOM = "is_using_room"
5367

5468
private fun createStorage(context: Context): Pair<EventsStorageInterface, Boolean> {
5569
return try {
@@ -62,5 +76,21 @@ class EventsStorage private constructor(
6276
Pair(LegacyEventsStorage(context), false)
6377
}
6478
}
79+
80+
private fun writeStorageState(context: Context, isUsingRoom: Boolean) {
81+
val dbName = if (isUsingRoom) {
82+
EventsDatabase.DATABASE_NAME
83+
} else {
84+
EventsDatabase.LEGACY_DATABASE_NAME
85+
}
86+
87+
context.getSharedPreferences(STORAGE_PREFS_NAME, Context.MODE_PRIVATE)
88+
.edit()
89+
.putString(PREF_ACTIVE_DB_NAME, dbName)
90+
.putBoolean(PREF_IS_USING_ROOM, isUsingRoom)
91+
.apply()
92+
93+
DevLog.info(LOG_TAG, "Wrote storage state to prefs: dbName=$dbName, isUsingRoom=$isUsingRoom")
94+
}
6595
}
6696
}

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ Historical decisions and completed work, kept for reference:
4747

4848
Features and changes under consideration:
4949

50+
- [Sync Database Mismatch Fix](dev_todo/sync_database_mismatch.md) - **Critical:** RN sync reads wrong DB after Room migration ⚠️
5051
- [Deprecated Features Removal](dev_todo/deprecated_features.md) - QuietHours, CalendarEditor
5152
- [Android Modernization](dev_todo/android_modernization.md) - Coroutines, Hilt DI opportunities
5253
- [Raise Min SDK](dev_todo/raise_min_sdk.md) - API 24 → 26+ considerations

0 commit comments

Comments
 (0)