Coverage Summary for Class: UserConfigSyncPlugin (com.georgeci.moneysurfer.data.sync.plugin)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| UserConfigSyncPlugin |
100%
(3/3)
|
100%
(14/14)
|
100%
(46/46)
|
100%
(324/324)
|
| UserConfigSyncPlugin$applyDoc$1 |
|
| UserConfigSyncPlugin$Companion |
|
| UserConfigSyncPlugin$push$1 |
|
| Total |
100%
(3/3)
|
100%
(14/14)
|
100%
(46/46)
|
100%
(324/324)
|
package com.georgeci.moneysurfer.data.sync.plugin
import co.touchlab.kermit.Logger
import com.georgeci.moneysurfer.data.db.dao.ConfigEntryDao
import com.georgeci.moneysurfer.data.db.entity.ConfigEntryEntity
import com.georgeci.moneysurfer.data.remote.UserConfigDoc
import com.georgeci.moneysurfer.data.sync.UserConfigRemoteSource
import com.georgeci.moneysurfer.domain.AppInfo
import com.georgeci.moneysurfer.domain.auth.SessionPointers
import com.georgeci.moneysurfer.domain.sync.SyncEntityTypes
import com.georgeci.moneysurfer.sync.api.SyncCollection
import com.georgeci.moneysurfer.sync.plugin.EntityApplyResult
import com.georgeci.moneysurfer.sync.plugin.PullScope
import com.georgeci.moneysurfer.sync.plugin.RemoteDocument
import com.georgeci.moneysurfer.sync.plugin.SyncEntityPlugin
import com.georgeci.moneysurfer.sync.repository.ConflictMetadata
import com.georgeci.moneysurfer.sync.repository.ConflictResolution
import com.georgeci.moneysurfer.sync.repository.ConflictResolver
import com.georgeci.moneysurfer.sync.repository.MutationOperation
import com.georgeci.moneysurfer.sync.repository.PendingMutation
import kotlinx.coroutines.flow.first
import org.koin.core.annotation.Single
import kotlin.time.Instant
/**
* Replicates the settings marked `sync = true` between a user's devices.
*
* **One key is one entity.** Outbox rows carry no payload — `push` re-reads current local state —
* so there is no field-merge concept to support and the mapping falls out naturally:
*
* | | |
* | --- | --- |
* | `entityType` | [SyncEntityTypes.USER_CONFIG] |
* | `entityId` | the key name, e.g. `ui.theme_mode` |
* | `scopeKey` | `null` — settings belong to the user, not to a workspace |
* | Firestore path | `users/{uid}/config/{keyName}` |
*
* Per-key LWW then needs no new primitives: two devices changing *different* settings touch
* different documents and both survive, and the same setting resolves by `updatedAt` through the
* shared [ConflictResolver].
*
* It lives here with the other entity plugins rather than in `app-config/remote` because it is a
* sync plugin first: it needs `PluginHelpers`, the resolver and the outbox, and it has nothing to
* do with the RemoteGlobal layer.
*/
@Single(binds = [SyncEntityPlugin::class])
class UserConfigSyncPlugin(
private val remote: UserConfigRemoteSource,
private val appInfo: AppInfo,
private val conflictResolver: ConflictResolver,
private val configEntryDao: ConfigEntryDao,
private val session: SessionPointers,
) : SyncEntityPlugin {
private val log = Logger.withTag(TAG)
override val entityType: String = SyncEntityTypes.USER_CONFIG
override val firestoreCollectionName: String = SyncCollection.USER_CONFIG
override val pullScope: PullScope = PullScope.User
override val pullPriority: Int = SyncPullPriorities.DEFAULT
/**
* Pushes whatever the row holds *now*, then records it as pushed — but only if the row still
* carries the `updatedAt` that was sent, so a write that landed between the read and the write
* stays pending instead of being marked as delivered.
*
* A missing row is a no-op rather than an error: the account wipe deletes `config_entry` while
* outbox rows for it may still be queued, and a wiped setting must not be resurrected on the
* server from a stale queue entry.
*
* A missing uid, in contrast, **throws**. `UploadPendingChangesUseCaseImpl` treats any push
* that returns normally as delivered and deletes the row, so returning quietly here would drop
* the queue entry for a setting that never reached the server — recovered only at the next
* session start, and silently unsynced until then. Throwing routes it to `markFailed`, which is
* what puts it back to `PENDING` for the next drain.
*/
override suspend fun push(mutation: PendingMutation) {
if (mutation.operation == MutationOperation.DELETE) {
// Nothing enqueues this: a setting is overwritten, never deleted. The account-deletion
// purge clears the collection directly, outside the outbox.
log.w { "[push] ignoring DELETE for ${mutation.entityId} — settings are not deletable" }
return
}
val uid = session.currentFirebaseUid.first()
check(!uid.isNullOrEmpty()) {
"no Firebase session — ${mutation.entityId} stays queued for the next drain"
}
val row = configEntryDao.getByKey(mutation.entityId) ?: return
remote.write(
uid = uid,
key = row.key,
doc = UserConfigDoc(
value = row.value,
updatedAt = row.updatedAt,
clientVersionCode = appInfo.versionCode,
),
)
configEntryDao.markPushed(key = row.key, pushedUpdatedAt = row.updatedAt)
}
/**
* [scopeKey] is the uid here, not a workspace id — this plugin is [PullScope.User].
*
* "Remote is not newer" is the *normal* outcome, not a conflict: the user-scoped phase has no
* cursor, so every pull re-reads every document, and reporting each unchanged one as a conflict
* would make the sync summary meaningless. Only a value this device actually adopts counts.
*
* A key this build does not know is stored anyway. It costs one row, it is what a mixed-version
* pair of devices needs in order not to lose each other's settings, and resolution never looks
* up a name that has no key object.
*/
override suspend fun applyDoc(doc: RemoteDocument, scopeKey: String): EntityApplyResult {
val dto = doc.decodeOrNull(UserConfigDoc.serializer()) ?: return SKIPPED_APPLY_RESULT
val local = configEntryDao.getByKey(doc.id)
val remoteRow = ConfigEntryEntity(
key = doc.id,
value = dto.value,
updatedAt = dto.updatedAt,
// The server is where this value came from, so it is by definition already pushed —
// otherwise the sign-in reconciliation would push it straight back.
lastPushedAt = dto.updatedAt,
)
val resolution = conflictResolver.resolve(
local = local,
remote = remoteRow,
metadata = ConflictMetadata(
entityType = SyncEntityTypes.USER_CONFIG,
entityId = doc.id,
localUpdatedAt = local?.updatedAt?.let(Instant::fromEpochMilliseconds),
remoteUpdatedAt = Instant.fromEpochMilliseconds(dto.updatedAt),
),
)
if (resolution !is ConflictResolution.TakeRemote) return SKIPPED_APPLY_RESULT
configEntryDao.applyRemote(
key = remoteRow.key,
value = remoteRow.value,
updatedAt = remoteRow.updatedAt,
)
return EntityApplyResult(applied = true, wasConflict = false)
}
private companion object {
const val TAG = "UserConfigSync"
}
}