Add phase 5: local cache, ETag tracking, three way merge, conflict siblings
The editor now always reads and writes a local working copy, so the app opens instantly and works with no signal. Beside it sits the pristine text from the last agreement with the server and its ETag, which exists so a later divergence can be merged rather than guessed at. The merge is line based and three way, and is kept free of Android types so it runs under JVM tests: a merge that is wrong loses writing rather than merely looking wrong. 16 tests cover it, including the case the whole thing exists for, a box ticked on the phone while a different box was ticked on the laptop, and the harder ones, an insertion on one side that must not desynchronise a later edit on the other, and repeated identical lines that must not confuse the alignment. Alignment is by longest common subsequence rather than by line number, so an inserted or deleted line shifts what follows instead of mismatching everything after it. On a real conflict the server's copy stays as the note and the local one is written beside it as a .sync-conflict- sibling, which is the convention already in that directory. Nothing is adopted until that copy is known to have been written, so local text is never replaced by something that cannot be recovered. Since SFTPGo ignores If-Match, the precondition is enforced by comparing the stored ETag against the one a GET returns. The ETag a PUT reports is not the file's own, so it is read back with a HEAD rather than believed. Sync happens on foreground and on the button, never on a timer. The date is recomputed on each sync, so an app left open across midnight moves to the new day's note by itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PU5ZFfQFtDTFqqhvMTWdGH
This commit is contained in:
co-authored by
Claude Opus 5
parent
0812b7b2e7
commit
dd23ca67cd
@@ -42,7 +42,7 @@ class MainActivity : ComponentActivity() {
|
||||
// Sync when the user comes back to the app, and at no other time. A
|
||||
// background poll would look like http-crawl-non_statics to CrowdSec.
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.RESUMED) { viewModel.refresh() }
|
||||
repeatOnLifecycle(Lifecycle.State.RESUMED) { viewModel.sync() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,81 +8,80 @@ import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
|
||||
/** What a sync attempt did, so the UI can say something true about it. */
|
||||
sealed interface SyncResult {
|
||||
data class Loaded(val text: String, val etag: String?) : SyncResult
|
||||
data class Saved(val etag: String?) : SyncResult
|
||||
/** The server has no note for today yet, so the 00:01 timer has not run. */
|
||||
data object Absent : SyncResult
|
||||
/** Credentials were refused. Never retried: see PLAN.md section 5. */
|
||||
data object Unauthorized : SyncResult
|
||||
data class Failed(val reason: String) : SyncResult
|
||||
sealed interface Fetched {
|
||||
data class Ok(val text: String, val etag: String?) : Fetched
|
||||
/** No note on the server yet, so the 00:01 rollover has not run. */
|
||||
data object Absent : Fetched
|
||||
/** Refused. Never retried: see PLAN.md section 5. */
|
||||
data object Unauthorized : Fetched
|
||||
data class Failed(val reason: String) : Fetched
|
||||
}
|
||||
|
||||
sealed interface Pushed {
|
||||
data class Ok(val etag: String?) : Pushed
|
||||
data object Unauthorized : Pushed
|
||||
data class Failed(val reason: String) : Pushed
|
||||
}
|
||||
|
||||
class NoteRepository(private val store: CredentialStore) {
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
// Deliberately no `authenticator`: OkHttp implements that by retrying
|
||||
// after a 401, which is exactly the pattern that trips http-generic-bf
|
||||
// and earns a four hour ban. Credentials go on every request instead.
|
||||
.build()
|
||||
// Deliberately no `authenticator`: OkHttp implements that by retrying after
|
||||
// a 401, which is exactly the pattern that trips http-generic-bf and earns
|
||||
// a four hour ban. Credentials go on every request instead.
|
||||
private val client = OkHttpClient.Builder().build()
|
||||
|
||||
private fun request(date: LocalDate, c: Credentials) = Request.Builder()
|
||||
.url("${c.baseUrl}/$date.md")
|
||||
private fun request(name: String, c: Credentials) = Request.Builder()
|
||||
.url("${c.baseUrl}/$name")
|
||||
.header("Authorization", BasicCredentials.basic(c.user, c.password))
|
||||
.header("User-Agent", USER_AGENT)
|
||||
|
||||
fun fetch(date: LocalDate): SyncResult {
|
||||
fun fetch(date: LocalDate): Fetched {
|
||||
val c = store.load()
|
||||
if (!c.configured) return SyncResult.Failed("not configured")
|
||||
if (!c.configured) return Fetched.Failed("not configured")
|
||||
return try {
|
||||
client.newCall(request(date, c).get().build()).execute().use { r ->
|
||||
client.newCall(request("$date.md", c).get().build()).execute().use { r ->
|
||||
when {
|
||||
r.isSuccessful -> SyncResult.Loaded(r.body?.string().orEmpty(), r.header("ETag"))
|
||||
r.code == 401 -> SyncResult.Unauthorized
|
||||
r.isSuccessful -> Fetched.Ok(r.body?.string().orEmpty(), r.header("ETag"))
|
||||
r.code == 401 -> Fetched.Unauthorized
|
||||
// One create, never a poll loop: repeated 404s trip http-probing.
|
||||
r.code == 404 -> SyncResult.Absent
|
||||
else -> SyncResult.Failed("server said ${r.code}")
|
||||
r.code == 404 -> Fetched.Absent
|
||||
else -> Fetched.Failed("server said ${r.code}")
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
SyncResult.Failed(e.message ?: "offline")
|
||||
Fetched.Failed(e.message ?: "offline")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the note. SFTPGo returns ETags but ignores If-Match on PUT, so the
|
||||
* precondition cannot be delegated to the server; [expectedEtag] is checked
|
||||
* with a HEAD first. See FINDINGS.md.
|
||||
* Writes [name] and reports the ETag the file actually ends up with.
|
||||
*
|
||||
* The ETag a PUT returns is not the file's own, so it is read back with a
|
||||
* HEAD rather than believed. See FINDINGS.md.
|
||||
*/
|
||||
fun save(date: LocalDate, text: String, expectedEtag: String?): SyncResult {
|
||||
fun push(name: String, text: String): Pushed {
|
||||
val c = store.load()
|
||||
if (!c.configured) return SyncResult.Failed("not configured")
|
||||
if (!c.configured) return Pushed.Failed("not configured")
|
||||
return try {
|
||||
if (expectedEtag != null) {
|
||||
client.newCall(request(date, c).head().build()).execute().use { r ->
|
||||
if (r.code == 401) return SyncResult.Unauthorized
|
||||
val current = r.header("ETag")
|
||||
if (r.isSuccessful && current != null && current != expectedEtag) {
|
||||
return SyncResult.Failed("changed on the server, not overwritten")
|
||||
}
|
||||
}
|
||||
}
|
||||
val body = text.toRequestBody("text/markdown".toMediaType())
|
||||
client.newCall(request(date, c).put(body).build()).execute().use { r ->
|
||||
client.newCall(request(name, c).put(body).build()).execute().use { r ->
|
||||
when {
|
||||
// The ETag a PUT returns is not the file's, so it is not
|
||||
// recorded here; the next fetch reads the real one.
|
||||
r.isSuccessful -> SyncResult.Saved(null)
|
||||
r.code == 401 -> SyncResult.Unauthorized
|
||||
else -> SyncResult.Failed("server said ${r.code}")
|
||||
r.isSuccessful -> Pushed.Ok(head(name, c))
|
||||
r.code == 401 -> Pushed.Unauthorized
|
||||
else -> Pushed.Failed("server said ${r.code}")
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
SyncResult.Failed(e.message ?: "offline")
|
||||
Pushed.Failed(e.message ?: "offline")
|
||||
}
|
||||
}
|
||||
|
||||
private fun head(name: String, c: Credentials): String? = try {
|
||||
client.newCall(request(name, c).head().build()).execute().use { it.header("ETag") }
|
||||
} catch (e: IOException) {
|
||||
null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// A named agent makes these requests legible in the CrowdSec access log.
|
||||
const val USER_AGENT = "todo-android/0.1"
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.example.todo.data
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* The local copy of a note, and the copy it was last in agreement with.
|
||||
*
|
||||
* The editor always reads and writes [working], so the app opens instantly and
|
||||
* works with no signal. [base] is the pristine text from the last successful
|
||||
* sync together with its ETag, and exists purely so a later divergence can be
|
||||
* merged three ways rather than guessed at.
|
||||
*
|
||||
* App private internal storage, so no storage permission is involved and
|
||||
* scoped storage never enters the picture.
|
||||
*/
|
||||
class NoteStore(context: Context) {
|
||||
|
||||
private val working = File(context.filesDir, "notes").apply { mkdirs() }
|
||||
private val bases = File(context.filesDir, "base").apply { mkdirs() }
|
||||
|
||||
fun readWorking(date: LocalDate): String? =
|
||||
File(working, "$date.md").takeIf { it.isFile }?.readText()
|
||||
|
||||
fun writeWorking(date: LocalDate, text: String) {
|
||||
runCatching { File(working, "$date.md").writeText(text) }
|
||||
}
|
||||
|
||||
fun readBase(date: LocalDate): Base? {
|
||||
val text = File(bases, "$date.md").takeIf { it.isFile }?.readText() ?: return null
|
||||
val etag = File(bases, "$date.etag").takeIf { it.isFile }?.readText()
|
||||
return Base(text, etag)
|
||||
}
|
||||
|
||||
fun writeBase(date: LocalDate, text: String, etag: String?) {
|
||||
runCatching {
|
||||
File(bases, "$date.md").writeText(text)
|
||||
if (etag == null) File(bases, "$date.etag").delete()
|
||||
else File(bases, "$date.etag").writeText(etag)
|
||||
}
|
||||
}
|
||||
|
||||
data class Base(val text: String, val etag: String?)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.example.todo.editor
|
||||
|
||||
/**
|
||||
* Line based three way merge.
|
||||
*
|
||||
* The case this exists for is mundane: a box ticked on the phone while a
|
||||
* different box was ticked on the laptop. Those are edits to different lines,
|
||||
* and a three way merge takes both without asking anyone anything. Only an
|
||||
* edit to the same line on both sides is a real conflict.
|
||||
*
|
||||
* Kept free of Android types so it can be tested on the JVM, because a merge
|
||||
* that is wrong loses writing rather than merely looking wrong.
|
||||
*/
|
||||
sealed interface MergeResult {
|
||||
data class Clean(val text: String) : MergeResult
|
||||
/** Both sides changed the same lines differently. */
|
||||
data object Conflict : MergeResult
|
||||
}
|
||||
|
||||
fun merge3(base: String, mine: String, theirs: String): MergeResult {
|
||||
if (mine == theirs) return MergeResult.Clean(mine)
|
||||
if (base == mine) return MergeResult.Clean(theirs)
|
||||
if (base == theirs) return MergeResult.Clean(mine)
|
||||
|
||||
val b = base.split("\n")
|
||||
val m = mine.split("\n")
|
||||
val t = theirs.split("\n")
|
||||
|
||||
val toMine = alignment(b, m)
|
||||
val toTheirs = alignment(b, t)
|
||||
|
||||
val out = mutableListOf<String>()
|
||||
var pb = 0
|
||||
var pm = 0
|
||||
var pt = 0
|
||||
|
||||
for (i in b.indices) {
|
||||
val mi = toMine[i]
|
||||
val ti = toTheirs[i]
|
||||
// A usable sync point is a base line both sides still have, positioned
|
||||
// after everything already emitted.
|
||||
if (mi < pm || ti < pt) continue
|
||||
|
||||
if (!resolve(b.subList(pb, i), m.subList(pm, mi), t.subList(pt, ti), out)) {
|
||||
return MergeResult.Conflict
|
||||
}
|
||||
out += b[i]
|
||||
pb = i + 1
|
||||
pm = mi + 1
|
||||
pt = ti + 1
|
||||
}
|
||||
if (!resolve(b.subList(pb, b.size), m.subList(pm, m.size), t.subList(pt, t.size), out)) {
|
||||
return MergeResult.Conflict
|
||||
}
|
||||
return MergeResult.Clean(out.joinToString("\n"))
|
||||
}
|
||||
|
||||
/** Appends the winning version of one contested region, or reports a clash. */
|
||||
private fun resolve(
|
||||
base: List<String>,
|
||||
mine: List<String>,
|
||||
theirs: List<String>,
|
||||
out: MutableList<String>,
|
||||
): Boolean {
|
||||
when {
|
||||
mine == theirs -> out += mine
|
||||
mine == base -> out += theirs
|
||||
theirs == base -> out += mine
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* For each line of [a], the index of the line in [b] it survives as, or -1.
|
||||
*
|
||||
* Built from a longest common subsequence, so the pairing keeps its order and
|
||||
* an inserted or deleted line shifts the ones after it rather than mismatching
|
||||
* everything that follows.
|
||||
*/
|
||||
private fun alignment(a: List<String>, b: List<String>): IntArray {
|
||||
val n = a.size
|
||||
val k = b.size
|
||||
val lcs = Array(n + 1) { IntArray(k + 1) }
|
||||
for (i in n - 1 downTo 0) {
|
||||
for (j in k - 1 downTo 0) {
|
||||
lcs[i][j] = if (a[i] == b[j]) {
|
||||
lcs[i + 1][j + 1] + 1
|
||||
} else {
|
||||
maxOf(lcs[i + 1][j], lcs[i][j + 1])
|
||||
}
|
||||
}
|
||||
}
|
||||
val map = IntArray(n) { -1 }
|
||||
var i = 0
|
||||
var j = 0
|
||||
while (i < n && j < k) {
|
||||
when {
|
||||
a[i] == b[j] -> { map[i] = j; i++; j++ }
|
||||
lcs[i + 1][j] >= lcs[i][j + 1] -> i++
|
||||
else -> j++
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package com.example.todo.ui.note
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* Unsaved text, kept in app-private internal storage.
|
||||
*
|
||||
* Not the offline cache from PLAN.md phase 5, just enough that Android killing
|
||||
* the process does not discard what was typed. Internal storage means no
|
||||
* storage permission and no interaction with scoped storage.
|
||||
*/
|
||||
class DraftStore(context: Context) {
|
||||
private val dir = File(context.filesDir, "drafts").apply { mkdirs() }
|
||||
|
||||
private fun file(date: LocalDate) = File(dir, "$date.md")
|
||||
|
||||
fun read(date: LocalDate): String? = file(date).takeIf { it.isFile }?.readText()
|
||||
|
||||
fun write(date: LocalDate, text: String) = runCatching { file(date).writeText(text) }.let {}
|
||||
|
||||
fun clear(date: LocalDate) = runCatching { file(date).delete() }.let {}
|
||||
}
|
||||
@@ -107,7 +107,7 @@ fun NoteScreen(viewModel: NoteViewModel = viewModel(), modifier: Modifier = Modi
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Button(onClick = viewModel::save, enabled = !state.busy && state.dirty) { Text("Save") }
|
||||
Button(onClick = viewModel::sync, enabled = !state.busy && state.dirty) { Text("Save") }
|
||||
}
|
||||
|
||||
BasicTextField(
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
package com.example.todo.ui.note
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Build
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.todo.data.CredentialStore
|
||||
import com.example.todo.data.Credentials
|
||||
import com.example.todo.data.Fetched
|
||||
import com.example.todo.data.NoteRepository
|
||||
import com.example.todo.data.SyncResult
|
||||
import com.example.todo.data.NoteStore
|
||||
import com.example.todo.data.Pushed
|
||||
import com.example.todo.editor.MergeResult
|
||||
import com.example.todo.editor.merge3
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -26,97 +32,160 @@ data class NoteUiState(
|
||||
|
||||
class NoteViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val store = CredentialStore(app)
|
||||
private val repo = NoteRepository(store)
|
||||
private val drafts = DraftStore(app)
|
||||
private val credentials = CredentialStore(app)
|
||||
private val repo = NoteRepository(credentials)
|
||||
private val notes = NoteStore(app)
|
||||
|
||||
private val _state = MutableStateFlow(NoteUiState())
|
||||
val state = _state.asStateFlow()
|
||||
|
||||
private var etag: String? = null
|
||||
|
||||
init {
|
||||
val today = LocalDate.now()
|
||||
// A draft from a previous process is shown immediately, so an edit is
|
||||
// never waiting on the network to become visible again.
|
||||
val draft = drafts.read(today)
|
||||
load(LocalDate.now())
|
||||
if (!_state.value.needsSetup) sync()
|
||||
}
|
||||
|
||||
/** Shows what is on disk at once, so the editor never waits on the network. */
|
||||
private fun load(date: LocalDate) {
|
||||
val base = notes.readBase(date)
|
||||
val text = notes.readWorking(date) ?: base?.text.orEmpty()
|
||||
_state.update {
|
||||
it.copy(
|
||||
text = draft.orEmpty(),
|
||||
date = today,
|
||||
dirty = draft != null,
|
||||
needsSetup = !store.load().configured,
|
||||
text = text,
|
||||
date = date,
|
||||
dirty = text != base?.text,
|
||||
needsSetup = !credentials.load().configured,
|
||||
status = "",
|
||||
)
|
||||
}
|
||||
if (!_state.value.needsSetup) refresh()
|
||||
}
|
||||
|
||||
fun onTextChange(new: String) {
|
||||
_state.update { it.copy(text = new, dirty = true) }
|
||||
// The process can be killed at any moment, so typing is persisted as it
|
||||
// happens rather than held only in memory.
|
||||
drafts.write(_state.value.date, new)
|
||||
val date = _state.value.date
|
||||
// The process can be killed at any moment, so typing goes to disk as it
|
||||
// happens rather than living only in memory.
|
||||
notes.writeWorking(date, new)
|
||||
_state.update { it.copy(text = new, dirty = new != notes.readBase(date)?.text) }
|
||||
}
|
||||
|
||||
/** Called on foreground. Never on a timer: no background polling. */
|
||||
fun refresh() {
|
||||
if (_state.value.busy) return
|
||||
/**
|
||||
* Reconciles with the server. Called when the app comes to the foreground
|
||||
* and from the save button, and at no other time: a background poll would
|
||||
* read as http-crawl-non_statics.
|
||||
*/
|
||||
fun sync() {
|
||||
if (_state.value.busy || _state.value.needsSetup) return
|
||||
|
||||
// The app may have been open across midnight, and the note it should be
|
||||
// showing is whatever today is now.
|
||||
val today = LocalDate.now()
|
||||
if (today != _state.value.date) load(today)
|
||||
|
||||
_state.update { it.copy(busy = true, status = "syncing") }
|
||||
viewModelScope.launch {
|
||||
val date = _state.value.date
|
||||
val r = withContext(Dispatchers.IO) { repo.fetch(date) }
|
||||
_state.update { s ->
|
||||
when (r) {
|
||||
is SyncResult.Loaded -> {
|
||||
etag = r.etag
|
||||
if (s.dirty && s.text != r.text) {
|
||||
s.copy(busy = false, status = "local edits kept, server differs")
|
||||
} else {
|
||||
drafts.clear(date)
|
||||
s.copy(text = r.text, busy = false, dirty = false, status = "synced")
|
||||
}
|
||||
when (val fetched = withContext(Dispatchers.IO) { repo.fetch(date) }) {
|
||||
is Fetched.Ok -> reconcile(date, fetched.text, fetched.etag)
|
||||
// Create once. Repeated requests for a missing file look like probing.
|
||||
Fetched.Absent ->
|
||||
if (_state.value.text.isNotBlank()) push(date, _state.value.text)
|
||||
else finish("nothing on the server for today yet")
|
||||
Fetched.Unauthorized ->
|
||||
_state.update {
|
||||
it.copy(busy = false, status = "credentials rejected", needsSetup = true)
|
||||
}
|
||||
SyncResult.Absent ->
|
||||
s.copy(busy = false, status = "nothing on the server for today yet")
|
||||
SyncResult.Unauthorized ->
|
||||
s.copy(busy = false, status = "credentials rejected", needsSetup = true)
|
||||
is SyncResult.Failed -> s.copy(busy = false, status = r.reason)
|
||||
is SyncResult.Saved -> s.copy(busy = false)
|
||||
}
|
||||
is Fetched.Failed -> finish(fetched.reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun save() {
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, status = "saving") }
|
||||
viewModelScope.launch {
|
||||
val date = _state.value.date
|
||||
val text = _state.value.text
|
||||
val r = withContext(Dispatchers.IO) { repo.save(date, text, etag) }
|
||||
_state.update { s ->
|
||||
when (r) {
|
||||
is SyncResult.Saved -> {
|
||||
drafts.clear(date)
|
||||
s.copy(busy = false, dirty = false, status = "saved")
|
||||
}
|
||||
SyncResult.Unauthorized ->
|
||||
s.copy(busy = false, status = "credentials rejected", needsSetup = true)
|
||||
is SyncResult.Failed -> s.copy(busy = false, status = r.reason)
|
||||
else -> s.copy(busy = false)
|
||||
}
|
||||
private suspend fun reconcile(date: LocalDate, theirs: String, theirEtag: String?) {
|
||||
val mine = _state.value.text
|
||||
val base = notes.readBase(date)
|
||||
|
||||
if (base == null) {
|
||||
// Nothing to merge against. Anything local that is not already the
|
||||
// server's text is set aside rather than silently dropped.
|
||||
if (mine.isNotEmpty() && mine != theirs) {
|
||||
val name = setAside(date, mine) ?: return
|
||||
adopt(date, theirs, theirEtag, "server copy taken, yours saved as $name")
|
||||
} else {
|
||||
adopt(date, theirs, theirEtag, "synced")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (theirEtag != null && theirEtag == base.etag) {
|
||||
// The server has not moved since the last agreement, so local edits
|
||||
// are the only news there is.
|
||||
if (mine != base.text) push(date, mine) else finish("synced")
|
||||
return
|
||||
}
|
||||
|
||||
when (val merged = merge3(base.text, mine, theirs)) {
|
||||
is MergeResult.Clean ->
|
||||
if (merged.text == theirs) adopt(date, theirs, theirEtag, "synced")
|
||||
else push(date, merged.text)
|
||||
|
||||
// Both sides changed the same lines. Keep the server's copy as the
|
||||
// note and put the local one beside it, which is the convention
|
||||
// already in this directory.
|
||||
MergeResult.Conflict -> {
|
||||
val name = setAside(date, mine) ?: return
|
||||
adopt(date, theirs, theirEtag, "conflict, yours saved as $name")
|
||||
}
|
||||
// The ETag a PUT reports is not the one the file will report, so the
|
||||
// authoritative value has to be read back.
|
||||
if (r is SyncResult.Saved) etag = null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun push(date: LocalDate, text: String) {
|
||||
when (val r = withContext(Dispatchers.IO) { repo.push("$date.md", text) }) {
|
||||
is Pushed.Ok -> adopt(date, text, r.etag, "synced")
|
||||
Pushed.Unauthorized -> _state.update {
|
||||
it.copy(busy = false, status = "credentials rejected", needsSetup = true)
|
||||
}
|
||||
// Local text stays exactly as it is and goes again next foreground.
|
||||
is Pushed.Failed -> finish(r.reason)
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes the local text beside the note, returning the filename it used. */
|
||||
private suspend fun setAside(date: LocalDate, mine: String): String? {
|
||||
val name = "$date.sync-conflict-${STAMP.format(java.time.LocalDateTime.now())}-$DEVICE.md"
|
||||
return when (val r = withContext(Dispatchers.IO) { repo.push(name, mine) }) {
|
||||
is Pushed.Ok -> name
|
||||
Pushed.Unauthorized -> {
|
||||
_state.update {
|
||||
it.copy(busy = false, status = "credentials rejected", needsSetup = true)
|
||||
}
|
||||
null
|
||||
}
|
||||
// Nothing is adopted if the copy could not be saved, so the local
|
||||
// text is never replaced by something it cannot be recovered from.
|
||||
is Pushed.Failed -> {
|
||||
finish("could not save your copy: ${r.reason}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun adopt(date: LocalDate, text: String, etag: String?, status: String) {
|
||||
notes.writeBase(date, text, etag)
|
||||
notes.writeWorking(date, text)
|
||||
_state.update { it.copy(text = text, busy = false, dirty = false, status = status) }
|
||||
}
|
||||
|
||||
private fun finish(status: String) = _state.update { it.copy(busy = false, status = status) }
|
||||
|
||||
fun saveCredentials(baseUrl: String, user: String, password: String) {
|
||||
store.save(Credentials(baseUrl, user, password))
|
||||
credentials.save(Credentials(baseUrl, user, password))
|
||||
_state.update { it.copy(needsSetup = false) }
|
||||
refresh()
|
||||
sync()
|
||||
}
|
||||
|
||||
fun credentials() = store.load()
|
||||
fun credentials() = credentials.load()
|
||||
|
||||
private companion object {
|
||||
val STAMP: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")
|
||||
val DEVICE: String = Build.MODEL.uppercase().replace(Regex("[^A-Z0-9]"), "")
|
||||
.ifEmpty { "ANDROID" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.example.todo.editor
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class MergeTest {
|
||||
|
||||
private fun clean(base: String, mine: String, theirs: String): String {
|
||||
val r = merge3(base, mine, theirs)
|
||||
assertTrue("expected a clean merge, got $r", r is MergeResult.Clean)
|
||||
return (r as MergeResult.Clean).text
|
||||
}
|
||||
|
||||
private fun conflicts(base: String, mine: String, theirs: String) =
|
||||
assertTrue(merge3(base, mine, theirs) is MergeResult.Conflict)
|
||||
|
||||
// The case this whole thing exists for.
|
||||
@Test fun `boxes ticked on different lines both survive`() {
|
||||
val base = "- [ ] a\n- [ ] b\n- [ ] c"
|
||||
val mine = "- [x] a\n- [ ] b\n- [ ] c"
|
||||
val theirs = "- [ ] a\n- [ ] b\n- [x] c"
|
||||
assertEquals("- [x] a\n- [ ] b\n- [x] c", clean(base, mine, theirs))
|
||||
}
|
||||
|
||||
@Test fun `the same box ticked on both sides is not a conflict`() {
|
||||
val base = "- [ ] a\n- [ ] b"
|
||||
val both = "- [x] a\n- [ ] b"
|
||||
assertEquals(both, clean(base, both, both))
|
||||
}
|
||||
|
||||
@Test fun `the same line changed differently is a conflict`() {
|
||||
conflicts("- [ ] a", "- [x] a", "- [ ] a renamed")
|
||||
}
|
||||
|
||||
@Test fun `no local change takes the server copy`() {
|
||||
val base = "- [ ] a"
|
||||
assertEquals("- [ ] a\n- [ ] new", clean(base, base, "- [ ] a\n- [ ] new"))
|
||||
}
|
||||
|
||||
@Test fun `no server change keeps the local copy`() {
|
||||
val base = "- [ ] a"
|
||||
assertEquals("- [x] a", clean(base, "- [x] a", base))
|
||||
}
|
||||
|
||||
@Test fun `additions at opposite ends both survive`() {
|
||||
val base = "b\nc"
|
||||
assertEquals("a\nb\nc\nd", clean(base, "a\nb\nc", "b\nc\nd"))
|
||||
}
|
||||
|
||||
@Test fun `an insertion does not desynchronise later lines`() {
|
||||
val base = "- [ ] a\n- [ ] b\n- [ ] c\n- [ ] d"
|
||||
// A line added near the top on one side, a box ticked at the bottom on
|
||||
// the other. Aligning by position alone would mangle this.
|
||||
val mine = "- [ ] a\n- [ ] NEW\n- [ ] b\n- [ ] c\n- [ ] d"
|
||||
val theirs = "- [ ] a\n- [ ] b\n- [ ] c\n- [x] d"
|
||||
assertEquals("- [ ] a\n- [ ] NEW\n- [ ] b\n- [ ] c\n- [x] d", clean(base, mine, theirs))
|
||||
}
|
||||
|
||||
@Test fun `a deletion on one side is kept`() {
|
||||
val base = "a\nb\nc"
|
||||
assertEquals("a\nc", clean(base, "a\nc", base))
|
||||
}
|
||||
|
||||
@Test fun `deleting on one side while editing that line on the other conflicts`() {
|
||||
conflicts("a\nb\nc", "a\nc", "a\nB\nc")
|
||||
}
|
||||
|
||||
@Test fun `both sides deleting the same line agree`() {
|
||||
assertEquals("a\nc", clean("a\nb\nc", "a\nc", "a\nc"))
|
||||
}
|
||||
|
||||
@Test fun `edits in separate sections both survive`() {
|
||||
val base = "## Work\n- [ ] w1\n\n## Personal\n- [ ] p1"
|
||||
val mine = "## Work\n- [x] w1\n\n## Personal\n- [ ] p1"
|
||||
val theirs = "## Work\n- [ ] w1\n\n## Personal\n- [x] p1"
|
||||
assertEquals("## Work\n- [x] w1\n\n## Personal\n- [x] p1", clean(base, mine, theirs))
|
||||
}
|
||||
|
||||
@Test fun `identical text merges to itself`() {
|
||||
val s = "# 2026-09-09\n\n- [ ] a"
|
||||
assertEquals(s, clean(s, s, s))
|
||||
}
|
||||
|
||||
@Test fun `empty base with the same addition on both sides`() {
|
||||
assertEquals("- [ ] a", clean("", "- [ ] a", "- [ ] a"))
|
||||
}
|
||||
|
||||
@Test fun `trailing newlines are preserved`() {
|
||||
val base = "a\nb\n"
|
||||
assertEquals("a\nB\n", clean(base, "a\nB\n", base))
|
||||
}
|
||||
|
||||
@Test fun `repeated identical lines do not confuse alignment`() {
|
||||
val base = "- [ ] x\n- [ ] x\n- [ ] x"
|
||||
val mine = "- [x] x\n- [ ] x\n- [ ] x"
|
||||
val theirs = "- [ ] x\n- [ ] x\n- [x] x"
|
||||
assertEquals("- [x] x\n- [ ] x\n- [x] x", clean(base, mine, theirs))
|
||||
}
|
||||
|
||||
@Test fun `a realistic day of edits on both devices`() {
|
||||
val base = """
|
||||
# 2026-09-09
|
||||
|
||||
## To-Do
|
||||
- [ ] Schedule an eye exam
|
||||
- [ ] Fix thermal paste on my server
|
||||
- [ ] Clean up dotfiles repo
|
||||
""".trimIndent()
|
||||
// phone: ticked one while out
|
||||
val mine = base.replace("- [ ] Schedule an eye exam", "- [x] Schedule an eye exam")
|
||||
// laptop: ticked another and added a line
|
||||
val theirs = base
|
||||
.replace("- [ ] Clean up dotfiles repo", "- [x] Clean up dotfiles repo")
|
||||
.plus("\n- [ ] Book flights")
|
||||
val merged = clean(base, mine, theirs)
|
||||
assertTrue(merged.contains("- [x] Schedule an eye exam"))
|
||||
assertTrue(merged.contains("- [x] Clean up dotfiles repo"))
|
||||
assertTrue(merged.contains("- [ ] Book flights"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user