diff --git a/feature/notes/build.gradle b/feature/notes/build.gradle index a9fd309..df95e80 100644 --- a/feature/notes/build.gradle +++ b/feature/notes/build.gradle @@ -37,4 +37,6 @@ dependencies { implementation libs.coil.compose implementation libs.coil.svg testImplementation libs.junit + testImplementation libs.okhttp + testImplementation libs.okhttp.mockwebserver } diff --git a/feature/notes/src/main/java/ru/forbion/f7cloud/feature/notes/Note.kt b/feature/notes/src/main/java/ru/forbion/f7cloud/feature/notes/Note.kt new file mode 100644 index 0000000..473f7c8 --- /dev/null +++ b/feature/notes/src/main/java/ru/forbion/f7cloud/feature/notes/Note.kt @@ -0,0 +1,45 @@ +package ru.forbion.f7cloud.feature.notes + +import org.json.JSONArray +import org.json.JSONObject + +/** + * Заметка (Notes API v1). Поля контракта: id, etag, readonly, content, title, category, + * favorite, modified. Источник модели — `nextcloud/notes` v5.0.0 (карта app-map/notes.md). + */ +data class Note( + val id: Long, + val etag: String = "", + val readonly: Boolean = false, + val title: String = "", + val category: String = "", + val content: String = "", + val favorite: Boolean = false, + val modified: Long = 0L, +) { + companion object { + /** Разбор одной заметки из JSON ответа API v1. */ + fun fromJson(obj: JSONObject): Note = Note( + id = obj.optLong("id", 0L), + etag = obj.optString("etag"), + readonly = obj.optBoolean("readonly", false), + title = obj.optString("title"), + category = obj.optString("category"), + content = obj.optString("content"), + favorite = obj.optBoolean("favorite", false), + modified = obj.optLong("modified", 0L), + ) + + /** Разбор списка заметок (GET /notes, в т.ч. с exclude=content). */ + fun listFromJson(array: JSONArray): List { + val out = ArrayList(array.length()) + for (i in 0 until array.length()) { + val obj = array.optJSONObject(i) + if (obj != null && obj.optLong("id", 0L) > 0L) { + out += fromJson(obj) + } + } + return out + } + } +} diff --git a/feature/notes/src/main/java/ru/forbion/f7cloud/feature/notes/NoteConflictException.kt b/feature/notes/src/main/java/ru/forbion/f7cloud/feature/notes/NoteConflictException.kt new file mode 100644 index 0000000..82cb146 --- /dev/null +++ b/feature/notes/src/main/java/ru/forbion/f7cloud/feature/notes/NoteConflictException.kt @@ -0,0 +1,14 @@ +package ru.forbion.f7cloud.feature.notes + +/** + * Конфликт версий заметки: сервер вернул 412 Precondition Failed на PUT с `If-Match` + * (заметка изменена в другой сессии). Разрешается ЯВНЫМ выбором пользователя (версия сервера + * или текущая), НЕ молчаливой перезаписью — согласовано с лидом (mail/071 п.2, диалог + * ConflictSolution в карте app-map/notes.md). + * + * @param serverNote версия с сервера (если её удалось перечитать) — для показа в диалоге. + */ +class NoteConflictException( + val noteId: Long, + val serverNote: Note? = null, +) : RuntimeException("Note $noteId changed on server (412)") diff --git a/feature/notes/src/main/java/ru/forbion/f7cloud/feature/notes/NotesApiClient.kt b/feature/notes/src/main/java/ru/forbion/f7cloud/feature/notes/NotesApiClient.kt new file mode 100644 index 0000000..3daba98 --- /dev/null +++ b/feature/notes/src/main/java/ru/forbion/f7cloud/feature/notes/NotesApiClient.kt @@ -0,0 +1,142 @@ +package ru.forbion.f7cloud.feature.notes + +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONArray +import org.json.JSONObject + +/** + * Клиент публичного Notes API v1 (`/index.php/apps/notes/api/v1`). Стабильный контракт для + * сторонних клиентов (его же использует официальный Nextcloud Notes Android): ETag на + * коллекцию и заметку, `If-None-Match` для дешёвого рефреша, `exclude=content` для лёгкого + * списка, `If-Match` для конфликтов при записи (mail/071, карта app-map/notes.md). + * + * `client` инъектируется (в проде — [ru.forbion.f7cloud.core.network.NetworkFactory] + * .newAuthedClient), что делает клиент юнит-тестируемым через MockWebServer — пишущие + * сценарии проверяются юнитами, НЕ на боевом forbion (mail/073). + */ +class NotesApiClient( + private val client: OkHttpClient, + serverUrl: String, +) { + private val apiBase = serverUrl.trimEnd('/') + "/index.php/apps/notes/api/v1" + + /** Результат листинга: заметки + ETag коллекции (для последующего If-None-Match). */ + data class ListResult(val notes: List, val etag: String, val notModified: Boolean = false) + + /** + * GET /notes — список. [excludeContent] → `exclude=content` (лёгкий список). + * [pruneBefore] → инкрементальный синк (только изменённые после метки). [ifNoneMatch] — + * ETag коллекции; при 304 возвращается [ListResult.notModified] = true с пустым списком. + */ + fun list( + excludeContent: Boolean = true, + pruneBefore: Long? = null, + ifNoneMatch: String? = null, + ): ListResult { + val params = buildList { + if (excludeContent) add("exclude=content") + if (pruneBefore != null) add("pruneBefore=$pruneBefore") + } + val url = apiBase + "/notes" + if (params.isEmpty()) "" else "?" + params.joinToString("&") + val req = Request.Builder() + .url(url) + .header("Accept", "application/json") + .apply { if (!ifNoneMatch.isNullOrBlank()) header("If-None-Match", ifNoneMatch) } + .build() + client.newCall(req).execute().use { resp -> + if (resp.code == HTTP_NOT_MODIFIED) { + return ListResult(emptyList(), ifNoneMatch.orEmpty(), notModified = true) + } + if (!resp.isSuccessful) error("Notes list HTTP ${resp.code}") + val body = resp.body?.string().orEmpty() + val notes = Note.listFromJson(JSONArray(if (body.isBlank()) "[]" else body)) + return ListResult(notes, resp.header("ETag").orEmpty()) + } + } + + /** GET /notes/{id} — одна заметка (с содержимым). */ + fun get(id: Long): Note { + val req = Request.Builder() + .url("$apiBase/notes/$id") + .header("Accept", "application/json") + .build() + client.newCall(req).execute().use { resp -> + if (!resp.isSuccessful) error("Notes get HTTP ${resp.code}") + return Note.fromJson(JSONObject(resp.body?.string().orEmpty())) + } + } + + /** POST /notes — создать заметку. */ + fun create(content: String, category: String = ""): Note { + val payload = JSONObject().put("content", content) + if (category.isNotEmpty()) payload.put("category", category) + val req = Request.Builder() + .url("$apiBase/notes") + .header("Accept", "application/json") + .post(payload.toString().toRequestBody(JSON)) + .build() + client.newCall(req).execute().use { resp -> + if (!resp.isSuccessful) error("Notes create HTTP ${resp.code}") + return Note.fromJson(JSONObject(resp.body?.string().orEmpty())) + } + } + + /** + * PUT /notes/{id} с телом `content` и заголовком `If-Match: ""`. На 412 — конфликт + * версий: перечитываем серверную версию и бросаем [NoteConflictException] (НЕ молчаливо + * перезаписываем — mail/071 п.2). + */ + fun saveContent(note: Note): Note { + val payload = JSONObject().put("content", note.content) + val builder = Request.Builder() + .url("$apiBase/notes/${note.id}") + .header("Accept", "application/json") + .put(payload.toString().toRequestBody(JSON)) + if (note.etag.isNotBlank()) builder.header("If-Match", "\"${note.etag}\"") + client.newCall(builder.build()).execute().use { resp -> + if (resp.code == HTTP_PRECONDITION_FAILED) { + val server = runCatching { get(note.id) }.getOrNull() + throw NoteConflictException(note.id, server) + } + if (!resp.isSuccessful) error("Notes save HTTP ${resp.code}") + return Note.fromJson(JSONObject(resp.body?.string().orEmpty())) + } + } + + /** PUT /notes/{id} — обновить метаданные (favorite/category/title) без If-Match. */ + private fun patchMeta(id: Long, field: String, value: Any): Note { + val payload = JSONObject().put(field, value) + val req = Request.Builder() + .url("$apiBase/notes/$id") + .header("Accept", "application/json") + .put(payload.toString().toRequestBody(JSON)) + .build() + client.newCall(req).execute().use { resp -> + if (!resp.isSuccessful) error("Notes patch HTTP ${resp.code}") + return Note.fromJson(JSONObject(resp.body?.string().orEmpty())) + } + } + + fun setFavorite(id: Long, favorite: Boolean): Note = patchMeta(id, "favorite", favorite) + + fun setCategory(id: Long, category: String): Note = patchMeta(id, "category", category) + + fun rename(id: Long, title: String): Note = patchMeta(id, "title", title) + + /** DELETE /notes/{id}. */ + fun delete(id: Long) { + val req = Request.Builder().url("$apiBase/notes/$id").delete().build() + client.newCall(req).execute().use { resp -> + if (!resp.isSuccessful) error("Notes delete HTTP ${resp.code}") + } + } + + private companion object { + val JSON = "application/json; charset=utf-8".toMediaType() + const val HTTP_NOT_MODIFIED = 304 + const val HTTP_PRECONDITION_FAILED = 412 + } +} diff --git a/feature/notes/src/main/java/ru/forbion/f7cloud/feature/notes/NotesCache.kt b/feature/notes/src/main/java/ru/forbion/f7cloud/feature/notes/NotesCache.kt new file mode 100644 index 0000000..d76e886 --- /dev/null +++ b/feature/notes/src/main/java/ru/forbion/f7cloud/feature/notes/NotesCache.kt @@ -0,0 +1,49 @@ +package ru.forbion.f7cloud.feature.notes + +import android.content.Context +import java.io.File +import java.security.MessageDigest + +/** + * Дисковый кэш списка заметок, ключуемый ETag коллекции (образец — CalendarEventsCache). + * Формат файла: первая строка — ETag на момент загрузки, дальше сырой JSON массива заметок + * (переиспользуем боевой парсер [Note.listFromJson], без хрупкого промежуточного маппинга). + * + * Даёт: (1) offline-отдачу списка при отсутствии сети; (2) быстрый рефреш через + * `If-None-Match` — при 304 сеть не качает тело, читаем диск. + */ +class NotesCache(private val dir: File) { + + constructor(context: Context) : this(File(context.cacheDir, "notes_list")) + + data class Entry(val etag: String, val json: String) + + /** Кэш списка для аккаунта, если есть; иначе null. */ + fun get(accountKey: String): Entry? { + val f = entryFile(accountKey) + if (!f.isFile) return null + return runCatching { + val text = f.readText() + val nl = text.indexOf('\n') + if (nl <= 0) null else Entry(text.substring(0, nl), text.substring(nl + 1)) + }.getOrNull() + } + + fun put(accountKey: String, etag: String, json: String) { + if (etag.isBlank()) return + runCatching { + dir.mkdirs() + entryFile(accountKey).writeText("$etag\n$json") + } + } + + fun clear(accountKey: String) { + runCatching { entryFile(accountKey).delete() } + } + + private fun entryFile(accountKey: String): File = File(dir, md5(accountKey) + ".json") + + private fun md5(s: String): String = + MessageDigest.getInstance("MD5").digest(s.toByteArray()) + .joinToString("") { "%02x".format(it) } +} diff --git a/feature/notes/src/main/java/ru/forbion/f7cloud/feature/notes/NotesRepository.kt b/feature/notes/src/main/java/ru/forbion/f7cloud/feature/notes/NotesRepository.kt new file mode 100644 index 0000000..3059017 --- /dev/null +++ b/feature/notes/src/main/java/ru/forbion/f7cloud/feature/notes/NotesRepository.kt @@ -0,0 +1,98 @@ +package ru.forbion.f7cloud.feature.notes + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import ru.forbion.f7cloud.core.auth.AuthSession +import ru.forbion.f7cloud.core.network.NetworkFactory + +/** + * Репозиторий Заметок: связывает сессию ([AuthSession]) → [NotesApiClient] → [NotesCache]. + * Сеть — на IO. Списки отдаются из ETag-кэша при 304 или отсутствии сети (offline-first + * read). Пишущие операции идут напрямую в API (проверяются юнит-тестами, не на forbion — + * mail/073). + */ +class NotesRepository( + private val cache: NotesCache? = null, + private val apiFactory: (AuthSession) -> NotesApiClient = ::defaultApi, +) { + private fun accountKey(s: AuthSession) = "${s.serverUrl}|${s.davUserId ?: s.username}" + + /** + * Список заметок (лёгкий, exclude=content). Пытается сеть с `If-None-Match` по кэш-ETag; + * при 304 или сетевой ошибке возвращает кэш (если он есть). Свежий ответ кладётся в кэш. + */ + suspend fun listNotes(session: AuthSession): List = withContext(Dispatchers.IO) { + val api = apiFactory(session) + val key = accountKey(session) + val cached = cache?.get(key) + try { + val res = api.list(excludeContent = true, ifNoneMatch = cached?.etag) + if (res.notModified) { + cached?.let { return@withContext Note.listFromJson(JSONArray(it.json)) } + return@withContext emptyList() + } + cache?.put(key, res.etag, notesToJson(res.notes)) + res.notes + } catch (e: Exception) { + // offline / сеть недоступна → отдаём кэш, если он есть + cached?.let { return@withContext Note.listFromJson(JSONArray(it.json)) } + throw e + } + } + + suspend fun getNote(session: AuthSession, id: Long): Note = withContext(Dispatchers.IO) { + apiFactory(session).get(id) + } + + suspend fun createNote(session: AuthSession, content: String, category: String = ""): Note = + withContext(Dispatchers.IO) { apiFactory(session).create(content, category) } + + /** Сохранение содержимого с If-Match; [NoteConflictException] на 412 (обрабатывает UI). */ + suspend fun saveNote(session: AuthSession, note: Note): Note = withContext(Dispatchers.IO) { + apiFactory(session).saveContent(note) + } + + suspend fun setFavorite(session: AuthSession, id: Long, favorite: Boolean): Note = + withContext(Dispatchers.IO) { apiFactory(session).setFavorite(id, favorite) } + + suspend fun setCategory(session: AuthSession, id: Long, category: String): Note = + withContext(Dispatchers.IO) { apiFactory(session).setCategory(id, category) } + + suspend fun renameNote(session: AuthSession, id: Long, title: String): Note = + withContext(Dispatchers.IO) { apiFactory(session).rename(id, title) } + + suspend fun deleteNote(session: AuthSession, id: Long) = withContext(Dispatchers.IO) { + apiFactory(session).delete(id) + } + + private fun notesToJson(notes: List): String { + val arr = JSONArray() + notes.forEach { n -> + arr.put( + JSONObject() + .put("id", n.id) + .put("etag", n.etag) + .put("readonly", n.readonly) + .put("title", n.title) + .put("category", n.category) + .put("content", n.content) + .put("favorite", n.favorite) + .put("modified", n.modified), + ) + } + return arr.toString() + } + + private companion object { + fun defaultApi(session: AuthSession): NotesApiClient { + val client = NetworkFactory.newAuthedClient( + username = session.username, + appPassword = session.appPassword, + trustAllCerts = session.trustAllCerts, + ) + return NotesApiClient(client, session.serverUrl) + } + } +} diff --git a/feature/notes/src/test/java/ru/forbion/f7cloud/feature/notes/NotesApiClientTest.kt b/feature/notes/src/test/java/ru/forbion/f7cloud/feature/notes/NotesApiClientTest.kt new file mode 100644 index 0000000..99e0f45 --- /dev/null +++ b/feature/notes/src/test/java/ru/forbion/f7cloud/feature/notes/NotesApiClientTest.kt @@ -0,0 +1,134 @@ +package ru.forbion.f7cloud.feature.notes + +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Юнит-тесты Notes API v1 через MockWebServer — пишущие сценарии (create/save/If-Match/412) + * проверяются здесь, НЕ на боевом forbion (mail/073). Покрывают контракт из карты + * app-map/notes.md: exclude=content, ETag/If-None-Match, If-Match→412 конфликт. + */ +class NotesApiClientTest { + + private lateinit var server: MockWebServer + private lateinit var api: NotesApiClient + + @Before + fun setUp() { + server = MockWebServer() + server.start() + api = NotesApiClient(OkHttpClient(), server.url("/").toString().trimEnd('/')) + } + + @After + fun tearDown() { + server.shutdown() + } + + @Test + fun `list parses notes and sends exclude=content plus ETag`() { + val body = """[{"id":7,"etag":"e7","title":"Заметка",""" + + """"category":"Работа","favorite":true,"modified":100}]""" + server.enqueue(MockResponse().setHeader("ETag", "coll-1").setBody(body)) + + val res = api.list(excludeContent = true, ifNoneMatch = "prev-etag") + + assertFalse(res.notModified) + assertEquals("coll-1", res.etag) + assertEquals(1, res.notes.size) + assertEquals(7L, res.notes[0].id) + assertEquals("Заметка", res.notes[0].title) + assertTrue(res.notes[0].favorite) + + val recorded = server.takeRequest() + assertEquals("/index.php/apps/notes/api/v1/notes?exclude=content", recorded.path) + assertEquals("prev-etag", recorded.getHeader("If-None-Match")) + } + + @Test + fun `list returns notModified on 304`() { + server.enqueue(MockResponse().setResponseCode(304)) + + val res = api.list(ifNoneMatch = "coll-1") + + assertTrue(res.notModified) + assertTrue(res.notes.isEmpty()) + assertEquals("coll-1", res.etag) + } + + @Test + fun `saveContent sends If-Match with quoted etag`() { + server.enqueue(MockResponse().setBody("""{"id":7,"etag":"e8","content":"new"}""")) + + val saved = api.saveContent(Note(id = 7, etag = "e7", content = "new")) + + assertEquals("e8", saved.etag) + val recorded = server.takeRequest() + assertEquals("PUT", recorded.method) + assertEquals("/index.php/apps/notes/api/v1/notes/7", recorded.path) + assertEquals("\"e7\"", recorded.getHeader("If-Match")) + assertTrue(recorded.body.readUtf8().contains("\"content\"")) + } + + @Test + fun `saveContent on 412 rereads server version and throws conflict`() { + server.enqueue(MockResponse().setResponseCode(412)) // PUT → конфликт + server.enqueue(MockResponse().setBody("""{"id":7,"etag":"server","content":"srv"}""")) // GET перечит + + val ex = assertThrows(NoteConflictException::class.java) { + api.saveContent(Note(id = 7, etag = "stale", content = "mine")) + } + + assertEquals(7L, ex.noteId) + assertEquals("server", ex.serverNote?.etag) + assertEquals("srv", ex.serverNote?.content) + } + + @Test + fun `create posts content and category`() { + server.enqueue(MockResponse().setBody("""{"id":9,"etag":"e9","content":"c","category":"Личное"}""")) + + val note = api.create(content = "c", category = "Личное") + + assertEquals(9L, note.id) + val recorded = server.takeRequest() + assertEquals("POST", recorded.method) + assertEquals("/index.php/apps/notes/api/v1/notes", recorded.path) + val body = recorded.body.readUtf8() + assertTrue(body.contains("\"content\"")) + assertTrue(body.contains("Личное")) + } + + @Test + fun `setFavorite puts favorite field without If-Match`() { + server.enqueue(MockResponse().setBody("""{"id":7,"favorite":true}""")) + + val note = api.setFavorite(7, true) + + assertTrue(note.favorite) + val recorded = server.takeRequest() + assertEquals("PUT", recorded.method) + assertNull(recorded.getHeader("If-Match")) + assertTrue(recorded.body.readUtf8().contains("\"favorite\"")) + } + + @Test + fun `delete issues DELETE on note id`() { + server.enqueue(MockResponse().setResponseCode(200)) + + api.delete(7) + + val recorded = server.takeRequest() + assertEquals("DELETE", recorded.method) + assertEquals("/index.php/apps/notes/api/v1/notes/7", recorded.path) + } +}