返回 CodeWhale
PetViewModel.kt
根目录 / pet / android / src / main / kotlin / codewhale / pet / PetViewModel.kt
1 package codewhale.pet
2
3 import android.app.Application
4 import android.content.Intent
5 import android.net.Uri
6 import android.os.SystemClock
7 import java.io.File
8 import org.json.JSONObject
9 import androidx.lifecycle.AndroidViewModel
10 import androidx.lifecycle.viewModelScope
11 import java.util.concurrent.Executors
12 import java.util.concurrent.atomic.AtomicReference
13 import kotlinx.coroutines.CancellationException
14 import kotlinx.coroutines.NonCancellable
15 import kotlinx.coroutines.Dispatchers
16 import kotlinx.coroutines.Job
17 import kotlinx.coroutines.asCoroutineDispatcher
18 import kotlinx.coroutines.channels.Channel
19 import kotlinx.coroutines.delay
20 import kotlinx.coroutines.isActive
21 import kotlinx.coroutines.launch
22 import kotlinx.coroutines.flow.MutableStateFlow
23 import kotlinx.coroutines.flow.asStateFlow
24 import kotlinx.coroutines.flow.update
25 import kotlinx.coroutines.withContext
26
27 enum class PetMode(val key: String, val label: String, val detail: String) {
28 WILD("wild", "Wild", "Simulated creature · no session attached"),
29 DEMO("demo", "Event demo", "Synthetic telemetry"),
30 RECORDING("recording", "Recording", "Saved telemetry · replay"),
31 LIVE("live", "File study", "Isolated local telemetry study"),
32 SHARED("shared", "Shared", "One pet owned by your local companion"),
33 }
34 data class PetUiState(val scene: PetScene? = null, val mode: PetMode = PetMode.WILD,
35 val paused: Boolean = false, val sound: Boolean = false, val still: Boolean = false,
36 val systemStill: Boolean = false, val message: String? = null, val savedAtMs: Double? = null,
37 val archives: List<String> = emptyList(), val canExportRecovery: Boolean = false, val running: Boolean = false,
38 val canFollowLive: Boolean = false, val identity: String = "")
39
40 /** A single actor owns core, audio cursor and save revision. Compose receives
41 * immutable projections and never reads a particle while it is being stepped. */
42 class PetViewModel(application: Application) : AndroidViewModel(application) {
43 private sealed interface Command {
44 data class Mode(val mode: PetMode) : Command
45 data class Interact(val food: Boolean) : Command
46 data class Import(val uri: Uri) : Command
47 data class Follow(val uri: Uri) : Command
48 data class Join(val uri: Uri) : Command
49 data class Export(val uri: Uri) : Command
50 data class ExportRecovery(val uri: Uri) : Command
51 data class ExportArchive(val uri: Uri, val name: String) : Command
52 data object Restart : Command
53 data object Reload : Command
54 }
55 private val prefs = application.getSharedPreferences("pet", 0)
56 private var liveUri = prefs.getString("live-uri", null)?.let(Uri::parse)?.takeIf { it.scheme == "content" }
57 private val initialMode = PetMode.entries.firstOrNull { it.key == prefs.getString("mode", "shared") } ?: PetMode.SHARED
58 private val mutable = MutableStateFlow(PetUiState(mode = initialMode, still = prefs.getBoolean("still", false), canFollowLive = liveUri != null))
59 val ui = mutable.asStateFlow()
60 private val commands = Channel<Command>(64)
61 private val worker = Executors.newSingleThreadExecutor { Thread(it, "codewhale-pet-world") }.asCoroutineDispatcher()
62 @Volatile private var active = false
63 @Volatile private var paused = false
64 @Volatile private var sound = false
65 @Volatile private var still = prefs.getBoolean("still", false)
66 @Volatile private var systemStill = false
67 private val connectionFile = File(application.filesDir, "pet-connection.json")
68 private val shared = PetSharedClient(connectionFile)
69 private var core: PetNativeCore? = null
70 private var store: PetHabitatStore? = null
71 private var saveBlocked = false
72 private var mode = initialMode
73 private var output: PetAudioOutput? = null
74 private var cursor: PetAudioCursor? = null
75 private var lastSave = 0L
76 private data class LiveSample(val reader: PetLiveFile, val text: String?, val receivedAt: Long)
77 private val pendingLive = AtomicReference<LiveSample?>()
78 private var liveReader: PetLiveFile? = null
79 private var liveTask: Job? = null
80 private val assets = application.assets
81 private val bundle by lazy { assets.open("pet-native.js").bufferedReader().use { it.readText() } }
82 private val points by lazy { assets.open("whale-points.tsv").bufferedReader().use { it.readText() } }
83 private fun tape(mode: PetMode) = if (mode == PetMode.DEMO) assets.open("demo.jsonl").bufferedReader().use { it.readText() } else ""
84
85 init {
86 viewModelScope.launch {
87 try {
88 withContext(worker) {
89 try { open(initialMode) } catch (e: Exception) { setPaused(true); message(e) }
90 }
91 var wasPlaying = false
92 while (isActive) {
93 val began = SystemClock.elapsedRealtime()
94 try {
95 withContext(worker) {
96 while (true) {
97 val command = commands.tryReceive().getOrNull() ?: break
98 try { handle(command) } catch (e: Exception) { message(e) }
99 }
100 val framePaused = paused
101 val frameStill = still
102 val frameSystemStill = systemStill
103 val playing = active && !framePaused
104 if (!playing) {
105 stopAudio(); stopLive(); shared.detach()
106 if (wasPlaying) save()
107 // Acknowledge Pause only after the in-flight frame,
108 // output and save have settled on their owning worker.
109 mutable.update { it.copy(paused = framePaused, running = false,
110 still = frameStill, systemStill = frameSystemStill) }
111 } else if (mode == PetMode.SHARED) {
112 stopAudio(); stopLive()
113 try {
114 val scene = shared.poll(frameStill || frameSystemStill, sound)
115 mutable.update { it.copy(scene = scene, paused = false, running = true, still = frameStill, systemStill = frameSystemStill,
116 identity = shared.identity, message = shared.message, sound = shared.granted) }
117 } catch (e: Exception) {
118 shared.detach(); mutable.update { it.copy(scene = null, running = false, message = e.message, sound = false) }
119 }
120 } else {
121 val pet = checkNotNull(core)
122 if (mode == PetMode.LIVE) { startLive(); acceptLive(pet, began) }
123 if (!sound) stopAudio()
124 if (sound && output == null) {
125 try {
126 output = PetAudioOutput(application) { setSound(false) }
127 cursor = PetAudioCursor(pet.timeMs)
128 } catch (e: Exception) { audioFailed(e) }
129 }
130 val scene = pet.tick(!frameStill && !frameSystemStill)
131 if (!active || paused || !sound) stopAudio()
132 if (output != null) {
133 try { cursor?.next(pet)?.let { output?.offer(it) } }
134 catch (e: Exception) { audioFailed(e) }
135 }
136 mutable.update { it.copy(scene = scene, paused = false, running = true,
137 still = frameStill, systemStill = frameSystemStill) }
138 if (began - lastSave >= 5_000) save()
139 }
140 wasPlaying = playing
141 }
142 } catch (e: CancellationException) { throw e }
143 catch (e: Exception) {
144 setPaused(true); setSound(false)
145 withContext(worker) { stopAudio() }
146 message(e)
147 }
148 delay(if (active && !paused) maxOf(1, 33 - (SystemClock.elapsedRealtime() - began)) else 80)
149 }
150 } catch (e: CancellationException) { throw e }
151 catch (e: Exception) { message(e) }
152 finally {
153 withContext(NonCancellable + worker) {
154 stopAudio(); stopLive(); shared.detach(); runCatching { save() }; core?.close(); core = null
155 }
156 commands.close(); worker.close()
157 }
158 }
159 }
160
161 fun setActive(value: Boolean) { active = value }
162 fun setPaused(value: Boolean) { paused = value }
163 fun setSound(value: Boolean) { sound = value; mutable.update { it.copy(sound = value) } }
164 fun setStill(value: Boolean) { still = value; prefs.edit().putBoolean("still", value).apply() }
165 fun setSystemStill(value: Boolean) { systemStill = value }
166 fun mode(value: PetMode) = enqueue(Command.Mode(value))
167 fun interact(food: Boolean) = enqueue(Command.Interact(food))
168 fun importRecording(uri: Uri) = enqueue(Command.Import(uri))
169 fun joinShared(uri: Uri) = enqueue(Command.Join(uri))
170 fun followLocalTape(uri: Uri) = enqueue(Command.Follow(uri))
171 fun exportRecording(uri: Uri) = enqueue(Command.Export(uri))
172 fun exportRecovery(uri: Uri) = enqueue(Command.ExportRecovery(uri))
173 fun exportArchive(uri: Uri, name: String) = enqueue(Command.ExportArchive(uri, name))
174 fun restart() = enqueue(Command.Restart)
175 fun reload() = enqueue(Command.Reload)
176 fun dismissMessage() { mutable.update { it.copy(message = null) } }
177 private fun enqueue(command: Command) {
178 if (!commands.trySend(command).isSuccess) mutable.update { it.copy(message = "The habitat is busy. Try again.") }
179 }
180 private fun message(e: Exception) { mutable.update { it.copy(message = e.message?.take(400) ?: "The habitat could not continue.") } }
181 private fun stopAudio() { output?.close(); output = null; cursor = null }
182 private fun audioFailed(e: Exception) { stopAudio(); setSound(false); mutable.update { it.copy(message = "Sound paused: ${e.message ?: "output unavailable"}") } }
183 private fun stopLive() {
184 liveTask?.cancel(); liveTask = null
185 liveReader?.close(); liveReader = null; pendingLive.set(null)
186 }
187 private fun startLive() {
188 if (liveReader != null) return
189 val uri = liveUri ?: return
190 checkNotNull(core).resumeLiveInput()
191 val reader = PetLiveFile(getApplication<Application>().contentResolver, uri)
192 liveReader = reader
193 liveTask = viewModelScope.launch(Dispatchers.IO) {
194 while (isActive) {
195 val text = try { reader.readTail() } catch (e: Exception) { if (!isActive) break; null }
196 if (isActive) pendingLive.set(LiveSample(reader, text, SystemClock.elapsedRealtime()))
197 delay(400)
198 }
199 }
200 }
201 private fun acceptLive(pet: PetNativeCore, now: Long) {
202 val sample = pendingLive.getAndSet(null) ?: return
203 if (sample.reader !== liveReader) return
204 try {
205 if (now - sample.receivedAt > 800 || sample.text == null) {
206 pet.acceptLiveTail("")
207 mutable.update { it.copy(message = "Local tape unavailable or delayed. Use Follow local tape to choose a local, seekable file.") }
208 } else if (pet.acceptLiveTail(sample.text)) {
209 mutable.update { it.copy(message = "Following local telemetry. Missing input stays unobserved.") }
210 }
211 } catch (e: Exception) {
212 runCatching { pet.acceptLiveTail("") }
213 mutable.update { it.copy(message = "Invalid local telemetry · unobserved. The current world is kept.") }
214 }
215 }
216 private fun save(): Boolean {
217 lastSave = SystemClock.elapsedRealtime()
218 if (saveBlocked) return false
219 val pet = core ?: return true
220 try {
221 val files = checkNotNull(store) { "Habitat storage is unavailable." }
222 val next = pet.prepareSegment()
223 if (next != null) {
224 val staged = File.createTempFile("pet-segment-", ".json", getApplication<Application>().cacheDir)
225 try {
226 staged.outputStream().buffered().use { pet.exportRecording(it, completed = true) }
227 files.save(next, staged, kotlin.math.round(pet.timeMs * 30 / 1000).toLong())
228 pet.commitSegment()
229 } finally { staged.delete() }
230 } else files.save(pet.recording())
231 mutable.update { it.copy(savedAtMs = pet.timeMs, archives = if (next != null) files.archives() else it.archives) }
232 return true
233 } catch (e: Exception) {
234 saveBlocked = true
235 mutable.update { it.copy(message = "Saving paused; the previous file is kept. ${e.message}") }
236 return false
237 }
238 }
239 private fun open(nextMode: PetMode) {
240 shared.detach()
241 if (nextMode == PetMode.SHARED) {
242 stopAudio(); stopLive(); core?.close(); core = null; store = null; mode = nextMode; saveBlocked = false
243 prefs.edit().putString("mode", nextMode.key).apply()
244 mutable.update { it.copy(scene = null, mode = nextMode, savedAtMs = null, archives = emptyList(), canExportRecovery = false, message = "Connecting to the shared pet…") }
245 return
246 }
247 val nextStore = PetHabitatStore(getApplication<Application>().filesDir, nextMode.key)
248 var blocked = false
249 val saved = try { nextStore.read() } catch (e: Exception) { blocked = true; message(e); null }
250 val next = try { PetNativeCore(bundle, points, tape(nextMode), saved, live = nextMode == PetMode.LIVE) } catch (e: Exception) {
251 blocked = true
252 mutable.update { it.copy(message = "Saved habitat could not be opened and is kept intact. ${e.message}") }
253 PetNativeCore(bundle, points, tape(nextMode), live = nextMode == PetMode.LIVE)
254 }
255 stopAudio(); stopLive(); core?.close(); core = next; store = nextStore; saveBlocked = blocked; mode = nextMode
256 prefs.edit().putString("mode", nextMode.key).apply()
257 mutable.update { it.copy(scene = next.scene(), mode = nextMode, savedAtMs = if (saved != null && !blocked) next.timeMs else null,
258 canExportRecovery = recoveryFile() != null, archives = nextStore.archives()) }
259 lastSave = SystemClock.elapsedRealtime()
260 }
261 private fun handle(command: Command) {
262 when (command) {
263 is Command.Mode -> if (command.mode != mode) {
264 check(command.mode != PetMode.LIVE || liveUri != null) { "Choose Follow local tape from More first." }
265 check(save()) { "This world could not be saved. Export it or reopen the saved habitat before switching." }
266 open(command.mode)
267 }
268 is Command.Join -> {
269 val data = getApplication<Application>().contentResolver.openInputStream(command.uri)?.use { it.readPetBytes(4097) } ?: error("Could not read connection.")
270 require(data.size <= 4096); PetSharedClient.validateConnection(JSONObject(String(data, Charsets.UTF_8)))
271 check(save()) { "Export this world before leaving it." }
272 val file = android.util.AtomicFile(connectionFile); val out = file.startWrite()
273 try { out.write(data); file.finishWrite(out) } catch (e: Exception) { file.failWrite(out); throw e }
274 open(PetMode.SHARED); setPaused(false)
275 }
276 is Command.Follow -> {
277 require(command.uri.scheme == "content") { "Choose a local document through the file picker." }
278 check(save()) { "This world could not be saved. Export it before changing sources." }
279 open(PetMode.LIVE)
280 liveUri = command.uri
281 runCatching { getApplication<Application>().contentResolver.takePersistableUriPermission(command.uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) }
282 prefs.edit().putString("live-uri", command.uri.toString()).apply()
283 mutable.update { it.copy(canFollowLive = true, message = "Waiting for fresh local telemetry. Existing file contents are not replayed as live work.") }
284 }
285 is Command.Interact -> if (mode == PetMode.SHARED) shared.interact(command.food) else core?.interact(command.food)
286 is Command.Reload -> { open(mode); setPaused(false) }
287 is Command.Restart -> {
288 check(mode != PetMode.SHARED) { "A view cannot reset the shared pet. Close or reopen this view." }
289 save()
290 val replay = if (mode == PetMode.RECORDING) core?.recording()?.let { JSONObject(it).apply { remove("checkpoint") }.toString() } else null
291 val next = PetNativeCore(bundle, points, tape(mode), replay, live = mode == PetMode.LIVE)
292 try {
293 val backup = checkNotNull(store).restart(next.recording())
294 if (backup != null) prefs.edit().putString("recovery-${mode.key}", backup.name).apply()
295 stopAudio(); stopLive(); core?.close(); core = next; saveBlocked = false
296 mutable.update { it.copy(scene = next.scene(), savedAtMs = next.timeMs, canExportRecovery = recoveryFile() != null,
297 message = "Fresh habitat started. You can export the previous saved world from More.") }
298 lastSave = SystemClock.elapsedRealtime()
299 } catch (e: Exception) { next.close(); throw e }
300 }
301 is Command.Import -> {
302 val resolver = getApplication<Application>().contentResolver
303 val saved = resolver.openInputStream(command.uri)?.use(PetHabitatStore::boundedRead) ?: error("Could not open the selected recording.")
304 val next = PetNativeCore(bundle, points, saved = saved)
305 try {
306 check(save()) { "This world could not be saved. Export it before importing another recording." }
307 val scene = next.scene()
308 val nextStore = PetHabitatStore(getApplication<Application>().filesDir, PetMode.RECORDING.key)
309 nextStore.read(); nextStore.save(next.recording())
310 stopAudio(); stopLive(); core?.close(); core = next; store = nextStore; saveBlocked = false; mode = PetMode.RECORDING
311 prefs.edit().putString("mode", mode.key).apply()
312 mutable.update { it.copy(scene = scene, mode = mode, savedAtMs = next.timeMs) }
313 lastSave = SystemClock.elapsedRealtime()
314 } catch (e: Exception) { next.close(); throw e }
315 }
316 is Command.Export -> {
317 val app = getApplication<Application>()
318 val staged = File.createTempFile("pet-export-", ".json", app.cacheDir)
319 try {
320 val bytes = staged.outputStream().buffered().use { out -> if (mode == PetMode.SHARED) { val data = shared.export(); out.write(data); data.size.toLong() } else checkNotNull(core).exportRecording(out) }
321 // Finish and validate the snapshot before opening the user's
322 // destination. A core/size failure cannot truncate that file.
323 val out = app.contentResolver.openOutputStream(command.uri, "wt") ?: error("Could not open the selected export file.")
324 out.use { output -> staged.inputStream().use { it.copyTo(output) } }
325 mutable.update { it.copy(message = if (bytes > PetNativeCore.MAX_HABITAT_BYTES)
326 "Recording exported. Open files over 8 MiB in the browser."
327 else "Recording exported, including the current world state.") }
328 } finally { staged.delete() }
329 }
330 is Command.ExportArchive -> {
331 val file = checkNotNull(store).archive(command.name)
332 val out = getApplication<Application>().contentResolver.openOutputStream(command.uri, "wt")
333 ?: error("Could not open the selected export file.")
334 out.use { output -> file.inputStream().use { it.copyTo(output) } }
335 mutable.update { it.copy(message = "Earlier recording exported.") }
336 }
337 is Command.ExportRecovery -> {
338 val file = recoveryFile() ?: error("No previous world is available.")
339 val out = getApplication<Application>().contentResolver.openOutputStream(command.uri, "wt")
340 ?: error("Could not open the selected export file.")
341 out.use { output -> file.inputStream().use { it.copyTo(output) } }
342 mutable.update { it.copy(message = "Previous saved world exported.") }
343 }
344 }
345 }
346 private fun recoveryFile(): File? {
347 val name = prefs.getString("recovery-${mode.key}", null) ?: return null
348 if (!name.matches(Regex("pet-${mode.key}-recovery-[0-9a-f-]+\\.json"))) return null
349 return File(getApplication<Application>().filesDir, name).takeIf { it.isFile }
350 }
351 }
352
352 lines Plain Text