| 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 |