Document Android file framing and error status contracts

This commit is contained in:
evgeny committed 2026-10-28 11:36:13 +01:00
1 parent 5290a2868d
commit 0aa63c98ee
2 files changed
+29 -2

No files matched your search

@@ -2,7 +2,8 @@ package com.utun.chat.data
import com.utun.chat.R
/** UI получает ID ресурса, поэтому ошибки переводятся при каждом отображении. */
/** ID строки для MF-ошибки 1..14; IO_ERROR=9 и неизвестное значение дают files_error_io.
* Успех 0 не предназначен для этого API. Перевод выполняется UI при отображении, а не сохраняется в состоянии. */
fun fileStatusResource(status: Int): Int = when (status) {
1 -> R.string.files_error_access
2 -> R.string.files_error_missing
@@ -5,7 +5,13 @@ import java.nio.ByteOrder
import java.nio.charset.CodingErrorAction
import java.util.concurrent.atomic.AtomicLong
/** Общий wire-кодек файлов и аватарок. UID хранится как исходные 64 бита. */
/**
* Kotlin-кодек member_files v4, общий для файлового браузера и аватаров.
* Числа LE; UID/ID хранят исходные 64 бита в Long, размеры/счётчики в проверяемых payload должны быть неотрицательными.
* request кодирует только LIST/STAT/MKDIR/RENAME/REMOVE/QUOTA, без group/server-префикса JNI-события 49.
* response принимает событие 49 с этим префиксом, progress — локальное событие 50, не сетевой STREAM_DATA.
* Неверные данные бросают исключение; caller логирует отказ с контекстом запроса. Проверки ACL и смысла команд остаются в core.
*/
object MemberFilesProtocol {
const val LIST = 1
const val STAT = 2
@@ -20,11 +26,14 @@ object MemberFilesProtocol {
const val NOT_FOUND = 2
const val CANCELLED = 12
private val ids = AtomicLong(System.nanoTime())
/** Общий потокобезопасный счётчик запросов браузера/аватаров; нулевой ID пропускается. */
fun nextId(): Long { var id = ids.incrementAndGet(); if (id == 0L) id = ids.incrementAndGet(); return id }
private fun buffer(data: ByteArray) = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
private fun utf8(data: ByteArray): String = Charsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT).decode(ByteBuffer.wrap(data)).toString()
/** Относительный UTF-8 путь до 1024 байт/60 компонентов по 1..255 байт; root разрешает пустой путь.
* Отклоняет leading '.', trailing '.'/' ', C0/DEL, Windows device names и запрещённые символы; путь не нормализуется. */
fun validPath(path: String, root: Boolean = true): Boolean {
if (path.isEmpty()) return root
if (path.toByteArray().size > 1024 || path.any { it.isSurrogate() } &&
@@ -36,20 +45,31 @@ object MemberFilesProtocol {
!Regex("(?i)(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])").matches(name.substringBefore('.'))
}
}
/** Первый компонент avatars без учёта регистра; это признак служебного дерева, не проверка валидности пути. */
fun servicePath(path: String) = path.substringBefore('/').equals("avatars", true)
/** Добавить одно имя к path и проверить итог; имя со '/' или неверный итог бросают IllegalArgumentException. */
fun child(path: String, name: String): String {
require(!name.contains('/') && validPath(name, false)) { "invalid file name" }
return (if (path.isEmpty()) name else "$path/$name").also { require(validPath(it, false)) { "invalid path" } }
}
/** Ответ 49: status=0 — успех, 1..14 — MF-ошибка; mtime в Unix seconds, size в байтах.
* version — непрозрачные 16 байт снимка, hash — 32 байта; STAT не вычисляет хеш содержимого.
* cursor/more/payload зависят от команды; контекст id/group/server/owner/command/path сверяет репозиторий. */
data class Reply(val id: Long, val group: Long, val server: Long, val owner: Long, val command: Int, val status: Int,
val size: Long, val mtime: Long, val hash: ByteArray, val type: Int, val cursor: Long,
val version: ByteArray, val more: Boolean, val path: String, val payload: ByteArray)
/** Одна запись LIST: имя без '/', FILE/DIRECTORY, байты/Unix seconds и 16-байтная версия для операций. */
data class Entry(val name: String, val type: Int, val size: Long, val mtime: Long, val version: ByteArray)
/** Событие 50: done/total — байты; окончательный status действителен только при finished. */
data class Progress(val id: Long, val group: Long, val server: Long, val owner: Long, val done: Long,
val total: Long, val status: Int, val finished: Boolean)
/** Общий учёт UID по группам сервера: первые три поля в байтах, последние два — числа опубликованных объектов. */
data class Quota(val used: Long, val limit: Long, val reserved: Long, val files: Long, val directories: Long)
/** Заголовок 125 байт и UTF-8 path/target. LIST начинает с cursor=0/version=0, продолжает снимком ответа.
* RENAME/REMOVE используют версию entry, target допустим только RENAME; прочие wire-поля остаются нулевыми.
* id ненулевой; допустимость owner=0 и полномочия вызывающего дополнительно проверяются JNI/core. */
fun request(id: Long, owner: Long, command: Int, path: String, target: String = "", cursor: Long = 0,
version: ByteArray = ByteArray(16)): ByteArray {
require(id != 0L && command in listOf(LIST, STAT, MKDIR, RENAME, REMOVE, QUOTA))
@@ -63,6 +83,8 @@ object MemberFilesProtocol {
}.array()
}
/** Разобрать group/server:u64 и полный v4 RESPONSE с точными длинами и строгим UTF-8 путей.
* target проверяется, но не возвращается; семантика size/type/version и корректность страниц проверяются caller. */
fun response(data: ByteArray): Reply {
require(data.size >= 141) { "file response length=${data.size}" }
val b = buffer(data); val group = b.long; val server = b.long
@@ -82,6 +104,8 @@ object MemberFilesProtocol {
path, ByteArray(length).also { b.get(it) })
}
/** Полный payload страницы LIST: записи с заголовком 35 байт и UTF-8 name; пустая страница допустима.
* Дубликат имени внутри страницы/усечённая запись/неверное имя отклоняют всю страницу. */
fun entries(data: ByteArray): List<Entry> {
val b = buffer(data); val entries = mutableListOf<Entry>(); val names = mutableSetOf<String>()
while (b.hasRemaining()) {
@@ -96,6 +120,7 @@ object MemberFilesProtocol {
return entries
}
/** Ровно 51 байт: id/group/server/owner/done/total:u64, status:u16, state:u8 (1 progress, 2 finished). */
fun progress(data: ByteArray): Progress {
require(data.size == 51) { "transfer length=${data.size}" }
val b = buffer(data); val id = b.long; val group = b.long; val server = b.long; val owner = b.long
@@ -103,6 +128,7 @@ object MemberFilesProtocol {
require(id != 0L && state in 1..2 && status in 0..14 && done >= 0 && total >= 0 && done <= total)
return Progress(id, group, server, owner, done, total, status, state == 2)
}
/** QUOTA payload: ровно пять u64 LE, limit > 0; used может превышать limit после снижения квоты. */
fun quota(data: ByteArray): Quota {
require(data.size == 40) { "quota length=${data.size}" }
val b = buffer(data); val q = Quota(b.long, b.long, b.long, b.long, b.long)