apps\online\src\main\java\cn\nfarms\online\OnlineViewModel.kt¶
源码路径:
apps\online\src\main\java\cn\nfarms\online\OnlineViewModel.kt· 338 行
package cn.nfarms.online
import android.app.Application
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import cn.nfarms.core.secure.Crypto
import cn.nfarms.core.store.AppFiles
import cn.nfarms.core.store.JsonStore
import cn.nfarms.online.data.Attachment
import cn.nfarms.online.data.AuditEntry
import cn.nfarms.online.data.ConflictException
import cn.nfarms.online.data.DemoTicketApi
import cn.nfarms.online.data.LocalTicket
import cn.nfarms.online.data.Merge
import cn.nfarms.online.data.MergeResult
import cn.nfarms.online.data.OnlineState
import cn.nfarms.online.data.SyncState
import cn.nfarms.online.data.Ticket
import cn.nfarms.online.data.TicketApi
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class OnlineApp : Application() {
lateinit var container: Container
private set
override fun onCreate() {
super.onCreate()
container = Container(this)
}
}
class Container(context: Context) {
val files = AppFiles.of(context, "online")
val store = JsonStore(File(files.root(), "tickets.json"))
val api: DemoTicketApi = DemoTicketApi()
val attachDir: File = files.media("attachments").also { it.mkdirs() }
fun apiAsTicketApi(): TicketApi = api
}
data class OnlineUi(
val state: OnlineState = OnlineState(),
/** 正在编辑的工单(详情页) */
val editing: LocalTicket? = null,
val conflict: MergeResult.Conflict? = null,
val syncing: Boolean = false,
val message: String? = null,
val attachments: List<AttachRow> = emptyList(),
)
data class AttachRow(
val name: String,
val sizeBytes: Long,
val sha256: String,
val ok: Boolean,
val why: String = "",
val uploaded: Boolean = false,
)
class OnlineViewModel(private val c: Container) : ViewModel() {
private val _ui = MutableStateFlow(OnlineUi())
val ui: StateFlow<OnlineUi> = _ui.asStateFlow()
init {
load()
// 首次进入自动拉一次:否则演示后端里那两张单要等用户手动点「同步」才出现,
// 看起来像"这个 App 是空的"。真实实现里这一步也是必要的(冷启动补数据)。
sync()
// 后台"别人"在改一单 —— 让冲突是**自然发生**的,而不是按钮伪造的
viewModelScope.launch {
kotlinx.coroutines.delay(8_000)
runCatching { c.api.simulateRemoteEdit("T-1001", "打印机卡纸(已派人)") }
}
}
// --------------------------------------------------------------- 存取
private fun load() {
val st = c.store.read(OnlineState.serializer()) ?: OnlineState()
_ui.value = _ui.value.copy(state = st)
}
private fun persist() {
c.store.write(OnlineState.serializer(), _ui.value.state)
}
private fun log(action: String, detail: String = "") {
val s = _ui.value.state
_ui.value = _ui.value.copy(
state = s.copy(audit = (s.audit + AuditEntry(action = action, detail = detail)).takeLast(300)),
)
persist()
}
fun dismissMessage() { _ui.value = _ui.value.copy(message = null) }
// --------------------------------------------------------------- 编辑
/**
* 新建草稿。
*
* **草稿先落本地**:没网、或者用户只是想先写两笔,都不该被"必须提交"卡住。
* 草稿有 `draft = true`,同步时会被识别为"要创建"。
*/
fun newDraft() {
val t = Ticket(
id = "D-" + System.currentTimeMillis().toString().takeLast(7),
title = "",
body = "",
status = "open",
updatedAt = System.currentTimeMillis(),
rev = 0,
)
val lt = LocalTicket(ticket = t, base = null, state = SyncState.Local, draft = true)
_ui.value = _ui.value.copy(state = _ui.value.state.copy(tickets = _ui.value.state.tickets + lt))
persist()
_ui.value = _ui.value.copy(editing = lt)
}
fun edit(ticketId: String) {
_ui.value = _ui.value.copy(
editing = _ui.value.state.tickets.firstOrNull { it.ticket.id == ticketId },
attachments = _ui.value.attachments,
)
}
fun closeEditor() { _ui.value = _ui.value.copy(editing = null) }
fun updateEditing(title: String? = null, body: String? = null, status: String? = null) {
val cur = _ui.value.editing ?: return
val t = cur.ticket.copy(
title = title ?: cur.ticket.title,
body = body ?: cur.ticket.body,
status = status ?: cur.ticket.status,
updatedAt = System.currentTimeMillis(),
)
val next = cur.copy(ticket = t, state = SyncState.Local)
replace(next)
_ui.value = _ui.value.copy(editing = next)
}
private fun replace(lt: LocalTicket) {
val list = _ui.value.state.tickets.map {
if (it.ticket.id == lt.ticket.id) lt else it
}
_ui.value = _ui.value.copy(state = _ui.value.state.copy(tickets = list))
persist()
}
// --------------------------------------------------------------- 附件
/**
* 校验一个附件:大小 + 是否存在 + 算 SHA-256。
*
* 为什么客户端也要算哈希:上传后服务端会算一遍,**两边不一致就说明传输中坏了**。
* 客户端先算一份,等于把"坏在哪一段"这件事提前到上传之前。
*/
fun addAttachment(file: File) {
viewModelScope.launch(Dispatchers.IO) {
val row = if (!file.isFile) {
AttachRow(file.name, 0, "", ok = false, why = "文件不存在")
} else if (file.length() > 10L * 1024 * 1024) {
AttachRow(file.name, file.length(), "", ok = false, why = "超过 10 MB 上限")
} else if (file.length() == 0L) {
AttachRow(file.name, 0, "", ok = false, why = "空文件")
} else {
val sha = Crypto.sha256File(file)
AttachRow(file.name, file.length(), sha, ok = true)
}
withContext(Dispatchers.Main) {
_ui.value = _ui.value.copy(attachments = _ui.value.attachments + row)
}
}
}
/** 造一个演示附件(用于在没有文件选择器的情况下也能验证链路)。 */
fun addDemoAttachment() {
viewModelScope.launch(Dispatchers.IO) {
val f = File(c.attachDir, "demo-${System.currentTimeMillis().toString().takeLast(6)}.txt")
f.writeText("演示附件内容 ${System.currentTimeMillis()}")
val sha = Crypto.sha256File(f)
withContext(Dispatchers.Main) {
_ui.value = _ui.value.copy(
attachments = _ui.value.attachments + AttachRow(f.name, f.length(), sha, ok = true),
)
}
}
}
fun attachToEditing() {
val cur = _ui.value.editing ?: return
val rows = _ui.value.attachments.filter { it.ok }
if (rows.isEmpty()) {
_ui.value = _ui.value.copy(message = "没有可用的附件")
return
}
val list = cur.ticket.attachments + rows.map {
Attachment(
id = "A" + it.sha256.take(8),
name = it.name,
sizeBytes = it.sizeBytes,
sha256 = it.sha256,
)
}
val next = cur.copy(ticket = cur.ticket.copy(attachments = list))
replace(next)
_ui.value = _ui.value.copy(editing = next, attachments = emptyList(), message = "已附加 ${rows.size} 个")
}
// --------------------------------------------------------------- 同步
/**
* 同步。
*
* 流程:**拉远端 → 逐个合并 → 有冲突的留给用户 → 没冲突的推上去**。
*
* 注意「有冲突」的单**不推**:推上去就是用我的覆盖别人的,
* 而用户还没做决定。宁可这一单停在 conflicted,也不能静默覆盖。
*/
fun sync() {
if (_ui.value.syncing) return
_ui.value = _ui.value.copy(syncing = true)
viewModelScope.launch {
var merged = 0
var conflicts = 0
var pushed = 0
runCatching {
val remote = c.api.list()
val current = _ui.value.state.tickets
val updated = current.map { lt ->
val r = remote.firstOrNull { it.id == lt.ticket.id } ?: return@map lt
when (val res = Merge.merge(lt.ticket, r, lt.base)) {
is MergeResult.TookRemote -> {
merged++
lt.copy(ticket = res.ticket, base = res.ticket, state = SyncState.Synced)
}
is MergeResult.KeptLocal -> {
lt.copy(state = SyncState.Local)
}
MergeResult.Identical -> {
lt.copy(ticket = r, base = r, state = SyncState.Synced)
}
is MergeResult.Conflict -> {
conflicts++
lt.copy(ticket = res.local, state = SyncState.Conflicted)
}
}
}
// **服务端有、本地没有的单也要拉下来** —— 这是之前漏掉的一步:
// 只遍历本地的话,别人新建的工单永远不会出现在这台设备上,
// 而「同步」的字面意思就是双向对齐。首次启动时这一条正是
// 把演示后端那两张单显示出来的唯一途径。
val localIds = current.map { it.ticket.id }.toSet()
val pulled = remote.filter { it.id !in localIds }.map { r ->
LocalTicket(ticket = r, base = r, state = SyncState.Synced)
}
_ui.value = _ui.value.copy(
state = _ui.value.state.copy(tickets = updated + pulled),
)
persist()
// 推没有冲突的
for (lt in _ui.value.state.tickets) {
if (lt.state == SyncState.Conflicted) continue
if (!lt.isDirty() && !lt.draft) continue
try {
val baseRev = if (lt.draft) 0 else (lt.base?.rev ?: 0)
val acked = c.api.push(lt.ticket, baseRev)
replace(lt.copy(ticket = acked, base = acked, state = SyncState.Synced, draft = false))
pushed++
} catch (e: ConflictException) {
conflicts++
replace(lt.copy(state = SyncState.Conflicted))
log("push-conflict", "${lt.ticket.id} 服务端 rev=${e.remote.rev}")
}
}
log("sync", "拉取 ${pulled.size} · 合并 $merged · 冲突 $conflicts · 推送 $pushed")
}.onFailure {
log("sync-fail", it.message ?: it.javaClass.simpleName)
_ui.value = _ui.value.copy(message = "同步失败:${it.message}")
}
_ui.value = _ui.value.copy(
syncing = false,
state = _ui.value.state.copy(lastSyncAt = System.currentTimeMillis()),
message = if (conflicts > 0) "有 $conflicts 处冲突需要你决定" else "同步完成",
)
persist()
}
}
// --------------------------------------------------------------- 冲突
/** 打开某一单的冲突对比。 */
fun openConflict(ticketId: String) {
val lt = _ui.value.state.tickets.firstOrNull { it.ticket.id == ticketId } ?: return
// 拉远端是 suspend:必须进协程。这里必须**真的去拉**而不是用本地缓存的
// 远端值 —— 冲突对比的意义就在于看到"现在"远端是什么。
viewModelScope.launch {
val remote = runCatching { c.api.list().firstOrNull { it.id == ticketId } }.getOrNull() ?: return@launch
val res = Merge.merge(lt.ticket, remote, lt.base)
if (res is MergeResult.Conflict) {
_ui.value = _ui.value.copy(conflict = res, editing = lt)
} else {
_ui.value = _ui.value.copy(message = "这一单已经没有冲突了")
}
}
}
fun resolveKeepMine() {
val c0 = _ui.value.conflict ?: return
val lt = _ui.value.state.tickets.first { it.ticket.id == c0.local.id }
val mine = Merge.takeMine(c0.local, c0.remote)
replace(lt.copy(ticket = mine, state = SyncState.Local))
_ui.value = _ui.value.copy(conflict = null, message = "已保留我的版本,下次同步会提交")
}
fun resolveTakeTheirs() {
val c0 = _ui.value.conflict ?: return
val lt = _ui.value.state.tickets.first { it.ticket.id == c0.local.id }
val theirs = Merge.takeTheirs(c0.remote)
replace(lt.copy(ticket = theirs, base = theirs, state = SyncState.Synced))
_ui.value = _ui.value.copy(conflict = null, message = "已采用远端版本")
}
fun closeConflict() { _ui.value = _ui.value.copy(conflict = null) }
fun serverRev(id: String): Int? = c.api.revOf(id)
}