Skip to content

Commit e03f9ae

Browse files
committed
fix KeeShare sync storm between devices
Auto-triggered syncs (ContentObserver/polling) now only import — no export. This matches KeePassXC's pattern where directory changes trigger import-only, and database saves trigger export-only. Also bumps ContentObserver debounce to 3s and adds 5s min-interval guard to prevent rapid-fire re-triggers from Syncthing writes.
1 parent bca671a commit e03f9ae

File tree

3 files changed

+221
-21
lines changed

3 files changed

+221
-21
lines changed

app/src/main/java/com/kunzisoft/keepass/database/action/KeeShareSyncRunnable.kt

Lines changed: 122 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ class KeeShareSyncRunnable(
4141
database: ContextualDatabase,
4242
saveDatabase: Boolean,
4343
challengeResponseRetriever: (HardwareKey, ByteArray?) -> ByteArray,
44-
private val progressTaskUpdater: ProgressTaskUpdater?
44+
private val progressTaskUpdater: ProgressTaskUpdater?,
45+
private val silentSync: Boolean = false,
46+
private val importOnly: Boolean = false
4547
) : SaveDatabaseRunnable(
4648
context,
4749
database,
@@ -55,23 +57,49 @@ class KeeShareSyncRunnable(
5557
var exportedEntries: Int = 0
5658

5759
override fun onStartRun() {
58-
database.wasReloaded = true
60+
// For silent background syncs, only reload UI later if entries actually changed
61+
if (!silentSync) {
62+
database.wasReloaded = true
63+
}
5964
super.onStartRun()
6065
}
6166

6267
override fun onActionRun() {
68+
Log.d(TAG, "=== KeeShare sync onActionRun() START ===")
6369
try {
6470
val kdbx = database.databaseKDBX
6571
if (kdbx == null) {
72+
Log.e(TAG, "databaseKDBX is null — not a KDBX database")
6673
setError("KeeShare sync requires a KDBX database")
6774
return
6875
}
76+
Log.d(TAG, "Database is KDBX, rootGroup=${kdbx.rootGroup?.title}")
6977

7078
val deviceId = resolveDeviceId(context)
79+
Log.d(TAG, "Device ID: $deviceId")
7180
val cacheDir = File(context.cacheDir, "keeshare")
7281
cacheDir.mkdirs()
7382

83+
// Auto-provision PerDeviceSync from preferences sync folder + classic reference
84+
val prefSyncFolder = PreferencesUtil.getKeeShareSyncFolderUri(context)
85+
Log.d(TAG, "Preferences sync folder URI: ${prefSyncFolder ?: "(not set)"}")
86+
if (!prefSyncFolder.isNullOrEmpty()) {
87+
provisionPerDeviceConfig(kdbx, prefSyncFolder)
88+
} else {
89+
Log.w(TAG, "No sync folder configured in preferences — auto-provisioning skipped")
90+
}
91+
92+
// Log KeeShare config presence per group
93+
kdbx.rootGroup?.let { root ->
94+
val hasClassic = root.customData.get(com.kunzisoft.keepass.database.keeshare.KeeShareReference.CLASSIC_KEY) != null
95+
val hasPerDev = root.customData.get(com.kunzisoft.keepass.database.keeshare.KeeShareReference.PER_DEVICE_KEY) != null
96+
if (hasClassic || hasPerDev) {
97+
Log.d(TAG, "Root '${root.title}': classic=$hasClassic, perDevice=$hasPerDev")
98+
}
99+
}
100+
74101
// 1. Import from all other device containers via SAF
102+
Log.d(TAG, "--- Starting import phase ---")
75103
val importResults = KeeShareImport.importAll(
76104
database = kdbx,
77105
ownDeviceId = deviceId,
@@ -87,28 +115,49 @@ class KeeShareSyncRunnable(
87115
importedEntries = importResults.filter { it.success }.sumOf { it.entriesImported }
88116
importedDevices = importResults.filter { it.success }
89117
.map { it.containerName }.distinct().size
118+
Log.d(TAG, "Import results: ${importResults.size} total, $importedEntries entries from $importedDevices devices")
119+
for (r in importResults) {
120+
Log.d(TAG, " Import: group='${r.groupName}' container='${r.containerName}' entries=${r.entriesImported} success=${r.success} error=${r.errorMessage}")
121+
}
122+
123+
// For silent syncs, only trigger UI reload if something was actually imported
124+
if (silentSync && importedEntries > 0) {
125+
database.wasReloaded = true
126+
}
90127

91128
// 2. Export own container for each shared group via SAF
92-
val exportResults = KeeShareExport.exportAll(
93-
database = kdbx,
94-
deviceId = deviceId,
95-
cacheDirectory = cacheDir,
96-
targetStreamProvider = { syncDirUri, devId ->
97-
openTargetOutputStream(context, syncDirUri, devId)
129+
// Skip export when triggered by auto-sync (importOnly) to prevent
130+
// feedback loops between devices via Syncthing
131+
if (!importOnly) {
132+
Log.d(TAG, "--- Starting export phase ---")
133+
val exportResults = KeeShareExport.exportAll(
134+
database = kdbx,
135+
deviceId = deviceId,
136+
cacheDirectory = cacheDir,
137+
targetStreamProvider = { syncDirUri, devId ->
138+
openTargetOutputStream(context, syncDirUri, devId)
139+
}
140+
)
141+
142+
exportedEntries = exportResults.filter { it.success }.sumOf { it.entriesExported }
143+
Log.d(TAG, "Export results: ${exportResults.size} total, $exportedEntries entries exported")
144+
for (r in exportResults) {
145+
Log.d(TAG, " Export: group='${r.groupName}' entries=${r.entriesExported} success=${r.success} error=${r.errorMessage}")
98146
}
99-
)
100147

101-
exportedEntries = exportResults.filter { it.success }.sumOf { it.entriesExported }
148+
val failedExports = exportResults.filter { !it.success }
149+
if (failedExports.isNotEmpty()) {
150+
Log.w(TAG, "Failed exports: ${failedExports.map { "${it.groupName}: ${it.errorMessage}" }}")
151+
}
152+
} else {
153+
Log.d(TAG, "--- Export phase SKIPPED (importOnly=true) ---")
154+
}
102155

103-
// Log results
156+
// Log failed imports
104157
val failedImports = importResults.filter { !it.success }
105-
val failedExports = exportResults.filter { !it.success }
106158
if (failedImports.isNotEmpty()) {
107159
Log.w(TAG, "Failed imports: ${failedImports.map { "${it.containerName}: ${it.errorMessage}" }}")
108160
}
109-
if (failedExports.isNotEmpty()) {
110-
Log.w(TAG, "Failed exports: ${failedExports.map { "${it.groupName}: ${it.errorMessage}" }}")
111-
}
112161

113162
// Update last sync time
114163
PreferencesUtil.setKeeShareLastSyncTime(context, System.currentTimeMillis())
@@ -134,6 +183,57 @@ class KeeShareSyncRunnable(
134183
const val RESULT_IMPORTED_DEVICES = "KEESHARE_IMPORTED_DEVICES"
135184
const val RESULT_EXPORTED_ENTRIES = "KEESHARE_EXPORTED_ENTRIES"
136185

186+
/**
187+
* For groups that have a classic KeeShare/Reference (from KeePassXC) with
188+
* per-device mode but no KeeShare/PerDeviceSync custom data, auto-create
189+
* the PerDeviceSync config using the sync folder URI from preferences.
190+
*/
191+
private fun provisionPerDeviceConfig(
192+
database: com.kunzisoft.keepass.database.element.database.DatabaseKDBX,
193+
syncFolderUri: String
194+
) {
195+
fun checkGroup(group: com.kunzisoft.keepass.database.element.group.GroupKDBX) {
196+
// Skip if already has PerDeviceSync
197+
if (group.customData.get(com.kunzisoft.keepass.database.keeshare.KeeShareReference.PER_DEVICE_KEY) != null) return
198+
199+
val classicData = group.customData.get(
200+
com.kunzisoft.keepass.database.keeshare.KeeShareReference.CLASSIC_KEY
201+
) ?: return
202+
val ref = com.kunzisoft.keepass.database.keeshare.KeeShareReference
203+
.fromClassicCustomData(classicData.value) ?: return
204+
205+
if (!ref.isPerDeviceMode()) return
206+
if (ref.type != com.kunzisoft.keepass.database.keeshare.KeeShareReference.Type.SYNCHRONIZE
207+
&& ref.type != com.kunzisoft.keepass.database.keeshare.KeeShareReference.Type.IMPORT) return
208+
209+
val config = PerDeviceSyncConfig(
210+
syncDir = syncFolderUri,
211+
password = ref.password,
212+
keepGroups = ref.keepGroups
213+
)
214+
val encoded = PerDeviceSyncConfig.toCustomData(config)
215+
group.customData.put(
216+
com.kunzisoft.keepass.database.element.CustomDataItem(
217+
com.kunzisoft.keepass.database.keeshare.KeeShareReference.PER_DEVICE_KEY,
218+
encoded,
219+
com.kunzisoft.keepass.database.element.DateInstant()
220+
)
221+
)
222+
Log.i(TAG, "Auto-provisioned PerDeviceSync for group '${group.title}' with folder: $syncFolderUri")
223+
}
224+
225+
database.rootGroup?.let { checkGroup(it) }
226+
database.rootGroup?.doForEachChild(
227+
null,
228+
object : com.kunzisoft.keepass.database.element.node.NodeHandler<com.kunzisoft.keepass.database.element.group.GroupKDBX>() {
229+
override fun operate(node: com.kunzisoft.keepass.database.element.group.GroupKDBX): Boolean {
230+
checkGroup(node)
231+
return true
232+
}
233+
}
234+
)
235+
}
236+
137237
/**
138238
* Resolve the device ID for this device.
139239
*
@@ -160,21 +260,27 @@ class KeeShareSyncRunnable(
160260
syncDirUri: String,
161261
ownDeviceId: String
162262
): List<Pair<String, InputStream>> {
263+
Log.d(TAG, "listOtherDeviceStreams: syncDirUri=$syncDirUri, ownDeviceId=$ownDeviceId")
163264
val treeUri = try {
164265
Uri.parse(syncDirUri)
165266
} catch (e: Exception) {
166267
Log.w(TAG, "Invalid sync dir URI: $syncDirUri", e)
167268
return emptyList()
168269
}
270+
Log.d(TAG, " Parsed URI: $treeUri (scheme=${treeUri.scheme})")
169271

170272
val dir = DocumentFile.fromTreeUri(context, treeUri)
273+
Log.d(TAG, " DocumentFile: exists=${dir?.exists()}, isDir=${dir?.isDirectory}, name=${dir?.name}")
171274
if (dir == null || !dir.exists()) {
172275
Log.d(TAG, "Sync dir not accessible: $syncDirUri")
173276
return emptyList()
174277
}
175278

279+
val allFiles = dir.listFiles()
280+
Log.d(TAG, " Directory contains ${allFiles.size} files: ${allFiles.map { it.name }}")
176281
val ownFileName = PerDeviceSyncConfig.containerFileName(ownDeviceId)
177-
return dir.listFiles()
282+
Log.d(TAG, " Own filename to skip: $ownFileName")
283+
return allFiles
178284
.filter { doc ->
179285
doc.isFile
180286
&& doc.name?.endsWith(".kdbx", ignoreCase = true) == true

app/src/main/java/com/kunzisoft/keepass/keeshare/KeeShareSyncManager.kt

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@ package com.kunzisoft.keepass.keeshare
2121

2222
import android.content.Context
2323
import android.content.Intent
24+
import android.database.ContentObserver
2425
import android.net.Uri
26+
import android.os.Handler
27+
import android.os.Looper
2528
import android.util.Log
2629
import androidx.documentfile.provider.DocumentFile
2730
import com.kunzisoft.keepass.database.ContextualDatabase
@@ -55,6 +58,8 @@ class KeeShareSyncManager(
5558
) {
5659

5760
private var periodicJob: Job? = null
61+
private var contentObservers: List<ContentObserver> = emptyList()
62+
private var debounceJob: Job? = null
5863

5964
fun startAutoSync(database: ContextualDatabase) {
6065
stopAutoSync()
@@ -79,15 +84,54 @@ class KeeShareSyncManager(
7984
kdbx.rootGroup?.doForEachChild(null, groupHandler)
8085
kdbx.rootGroup?.let { groupHandler.operate(it) }
8186

87+
// Also check preferences sync folder (may not yet be provisioned into groups)
88+
val prefSyncFolder = PreferencesUtil.getKeeShareSyncFolderUri(context)
89+
if (!prefSyncFolder.isNullOrEmpty()) {
90+
syncDirUris.add(prefSyncFolder)
91+
}
92+
8293
if (syncDirUris.isEmpty()) return
8394

84-
// Start periodic sync timer
95+
// Register ContentObservers on SAF tree URIs for near-real-time detection
96+
val observers = mutableListOf<ContentObserver>()
97+
val handler = Handler(Looper.getMainLooper())
98+
for (syncDirUri in syncDirUris) {
99+
val treeUri = try {
100+
Uri.parse(syncDirUri)
101+
} catch (e: Exception) {
102+
continue
103+
}
104+
val observer = object : ContentObserver(handler) {
105+
override fun onChange(selfChange: Boolean) {
106+
onChange(selfChange, null)
107+
}
108+
override fun onChange(selfChange: Boolean, uri: Uri?) {
109+
if (selfChange) return // Ignore self-triggered changes
110+
Log.d(TAG, "ContentObserver: change detected in $syncDirUri (uri=$uri)")
111+
onSyncDirChanged(syncDirUris)
112+
}
113+
}
114+
try {
115+
context.contentResolver.registerContentObserver(treeUri, true, observer)
116+
observers.add(observer)
117+
Log.d(TAG, "Registered ContentObserver on: $syncDirUri")
118+
} catch (e: Exception) {
119+
Log.w(TAG, "Failed to register ContentObserver on $syncDirUri", e)
120+
}
121+
}
122+
contentObservers = observers
123+
124+
// Start periodic sync timer as fallback (ContentObserver may not catch
125+
// all changes, e.g. files synced by external tools writing to filesystem)
85126
periodicJob = CoroutineScope(Dispatchers.IO).launch {
86127
while (isActive) {
87128
delay(PERIODIC_SYNC_INTERVAL_MS)
88129
if (!isActive) break
89130
val lastSyncTime = PreferencesUtil.getKeeShareLastSyncTime(context)
90-
if (hasNewerContainerFiles(context, syncDirUris, lastSyncTime)) {
131+
val elapsed = System.currentTimeMillis() - lastSyncTime
132+
if (elapsed >= MIN_SYNC_INTERVAL_MS
133+
&& hasNewerContainerFiles(context, syncDirUris, lastSyncTime)) {
134+
Log.d(TAG, "Periodic poll: newer container files detected")
91135
withContext(Dispatchers.Main) {
92136
if (!isActionRunning()) {
93137
startSyncService()
@@ -106,12 +150,47 @@ class KeeShareSyncManager(
106150
}
107151
}
108152

109-
Log.i(TAG, "KeeShare auto-sync started for ${syncDirUris.size} directories")
153+
Log.i(TAG, "KeeShare auto-sync started: ${syncDirUris.size} directories, ${observers.size} observers, polling every ${PERIODIC_SYNC_INTERVAL_MS / 1000}s")
154+
}
155+
156+
/**
157+
* Called by ContentObserver when a change is detected in a sync directory.
158+
* Debounces rapid changes (e.g. Syncthing writing in chunks) with a 2-second delay.
159+
*/
160+
private fun onSyncDirChanged(syncDirUris: Set<String>) {
161+
debounceJob?.cancel()
162+
debounceJob = mainScope.launch {
163+
delay(DEBOUNCE_MS)
164+
val lastSyncTime = PreferencesUtil.getKeeShareLastSyncTime(context)
165+
// Prevent rapid-fire re-triggers (e.g. our own export writing back)
166+
val elapsed = System.currentTimeMillis() - lastSyncTime
167+
if (elapsed < MIN_SYNC_INTERVAL_MS) {
168+
Log.d(TAG, "ContentObserver: skipping sync, only ${elapsed}ms since last sync")
169+
return@launch
170+
}
171+
val hasNewer = withContext(Dispatchers.IO) {
172+
hasNewerContainerFiles(context, syncDirUris, lastSyncTime)
173+
}
174+
if (hasNewer && !isActionRunning()) {
175+
Log.i(TAG, "ContentObserver triggered sync: newer files detected")
176+
startSyncService()
177+
}
178+
}
110179
}
111180

112181
fun stopAutoSync() {
113182
periodicJob?.cancel()
114183
periodicJob = null
184+
debounceJob?.cancel()
185+
debounceJob = null
186+
for (observer in contentObservers) {
187+
try {
188+
context.contentResolver.unregisterContentObserver(observer)
189+
} catch (e: Exception) {
190+
Log.w(TAG, "Failed to unregister ContentObserver", e)
191+
}
192+
}
193+
contentObservers = emptyList()
115194
}
116195

117196
/**
@@ -155,7 +234,9 @@ class KeeShareSyncManager(
155234
companion object {
156235
private val TAG = KeeShareSyncManager::class.java.simpleName
157236

158-
private const val PERIODIC_SYNC_INTERVAL_MS = 15 * 60 * 1000L // 15 minutes
237+
private const val PERIODIC_SYNC_INTERVAL_MS = 2 * 60 * 1000L // 2 minutes (fallback)
238+
private const val DEBOUNCE_MS = 3000L // 3-second debounce for ContentObserver (Syncthing writes in chunks)
239+
private const val MIN_SYNC_INTERVAL_MS = 5000L // minimum 5s between syncs to prevent rapid re-triggers
159240

160241
/**
161242
* Check if any container files in the sync directories have been

app/src/main/java/com/kunzisoft/keepass/services/DatabaseTaskNotificationService.kt

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,11 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress
525525
// Assign elements for updates
526526
val intentAction = intent?.action
527527

528+
// Skip notification update for silent background KeeShare syncs
529+
val isSilentSync = intentAction == ACTION_DATABASE_KEESHARE_SYNC_TASK
530+
&& intent?.getBooleanExtra(KEESHARE_SILENT_SYNC_KEY, false) == true
531+
if (isSilentSync) return
532+
528533
// Get icon depending action state
529534
val iconId = if (intentAction == null)
530535
R.drawable.notification_ic_database_open
@@ -702,6 +707,8 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress
702707
val syncIntent = Intent(applicationContext, DatabaseTaskNotificationService::class.java).apply {
703708
action = ACTION_DATABASE_KEESHARE_SYNC_TASK
704709
putExtra(SAVE_DATABASE_KEY, true)
710+
putExtra(KEESHARE_SILENT_SYNC_KEY, true)
711+
putExtra(KEESHARE_IMPORT_ONLY_KEY, true)
705712
}
706713
startService(syncIntent)
707714
} catch (e: Exception) {
@@ -942,14 +949,18 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress
942949
database: ContextualDatabase
943950
): ActionRunnable {
944951
val saveDatabase = intent.getBooleanExtra(SAVE_DATABASE_KEY, false)
952+
val silentSync = intent.getBooleanExtra(KEESHARE_SILENT_SYNC_KEY, false)
953+
val importOnly = intent.getBooleanExtra(KEESHARE_IMPORT_ONLY_KEY, false)
945954
return KeeShareSyncRunnable(
946955
this,
947956
database,
948957
!database.isReadOnly && saveDatabase,
949958
{ hardwareKey, seed ->
950959
retrieveResponseFromChallenge(hardwareKey, seed)
951960
},
952-
this
961+
this,
962+
silentSync,
963+
importOnly
953964
).apply {
954965
afterSaveDatabase = { result ->
955966
if (result.isSuccess) {
@@ -1439,6 +1450,8 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress
14391450
const val PARENT_ID_KEY = "PARENT_ID_KEY"
14401451
const val ENTRY_HISTORY_POSITION_KEY = "ENTRY_HISTORY_POSITION_KEY"
14411452
const val SAVE_DATABASE_KEY = "SAVE_DATABASE_KEY"
1453+
const val KEESHARE_SILENT_SYNC_KEY = "KEESHARE_SILENT_SYNC_KEY"
1454+
const val KEESHARE_IMPORT_ONLY_KEY = "KEESHARE_IMPORT_ONLY_KEY"
14421455
const val OLD_NODES_KEY = "OLD_NODES_KEY"
14431456
const val NEW_NODES_KEY = "NEW_NODES_KEY"
14441457
const val OLD_ELEMENT_KEY = "OLD_ELEMENT_KEY" // Warning type of this thing change every time

0 commit comments

Comments
 (0)