whiskers.git / android / app / src / main / kotlin / app / whiskers / EngineParents.kt
EngineParents.kt144 lines · 7.4 KB · raw
1package app.whiskers
2
3import uniffi.whiskers_ffi.Outcome
4import uniffi.whiskers_ffi.WhiskersEngine
5import java.io.File
6import java.time.Instant
7import java.time.LocalDate
8import java.time.ZoneId
9import java.time.format.DateTimeFormatter
10
11/** The real [Parents]: today's log, read back from the core. */
12class EngineParents(
13    private val engine: WhiskersEngine,
14    private val address: ServiceAddress,
15    private val zone: ZoneId = ZoneId.systemDefault(),
16) : Parents {
17    private fun bounds(): Pair<Long, Long> {
18        val d = LocalDate.now(zone)
19        return d.atStartOfDay(zone).toInstant().toEpochMilli() to d.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
20    }
21
22    override fun today(): ParentView {
23        val (from, to) = bounds()
24        val clock = DateTimeFormatter.ofPattern("h:mm a").withZone(zone)
25        val began = System.nanoTime()
26        return try {
27            val d = engine.digest(from.toULong(), to.toULong())
28            Wlog.d(Area.Parents, "today: ${d.exchanges.size} exchanges, ${d.answered} answered, ${d.stopped} stopped, ${d.unreadableLines} unreadable, ${d.factsLearned.size} facts learned, in ${(System.nanoTime() - began) / 1_000_000} ms")
29            ParentView(
30                day = DateTimeFormatter.ofPattern("EEEE, MMMM d").format(LocalDate.now(zone)),
31                answered = d.answered.toInt(),
32                stopped = d.stopped.toInt(),
33                lines = d.exchanges.map {
34                    Line(
35                        time = clock.format(Instant.ofEpochMilli(it.atMs.toLong())),
36                        heard = it.heard,
37                        said = it.said,
38                        urgent = it.needsAGrownUp,
39                        notes = it.notes,
40                        withheld = if (it.outcome == Outcome.ANSWERED) null else it.modelWrote,
41                        pictures = it.pictures.map { id -> File(engine.picturePath(id)) },
42                    )
43                },
44                factsLearned = d.factsLearned,
45                facts = engine.facts().map { FactLine(it.id.toLong(), it.text, it.kind, it.who, it.place, it.`when`, it.pictures.map { id -> File(engine.picturePath(id)) }, it.hiddenByChild, it.icon) },
46                unreadableLines = d.unreadableLines.toInt(),
47            )
48        } catch (e: Exception) {
49            Wlog.e(Area.Parents, "today: the log could not be read after ${(System.nanoTime() - began) / 1_000_000} ms", e)
50            // The log could not be read, but what she told it is a separate file and still there.
51            val facts = runCatching { engine.facts().map { FactLine(it.id.toLong(), it.text, it.kind, it.who, it.place, it.`when`, it.pictures.map { id -> File(engine.picturePath(id)) }, it.hiddenByChild, it.icon) } }
52                .onFailure { Wlog.e(Area.Parents, "today: the facts could not be read either", it) }
53                .getOrDefault(emptyList())
54            ParentView(day = "", answered = 0, stopped = 0, lines = emptyList(), factsLearned = emptyList(), facts = facts, error = e.message ?: "could not read the log")
55        }
56    }
57
58    override fun note(): String {
59        val (from, to) = bounds()
60        val began = System.nanoTime()
61        Wlog.d(Area.Parents, "note: summarising the day")
62        return try {
63            engine.summarize(from.toULong(), to.toULong()).also { Wlog.i(Area.Parents, "note: ${it.length} chars in ${(System.nanoTime() - began) / 1_000_000} ms") }
64        } catch (e: Exception) {
65            Wlog.e(Area.Parents, "note: summarising failed after ${(System.nanoTime() - began) / 1_000_000} ms", e)
66            throw e
67        }
68    }
69
70    override fun voiceUsage(): VoiceUsage? = fetchVoiceUsage(address)
71
72    private fun day() = LocalDate.now(zone).toEpochDay().toUInt()
73
74    override fun settings(): HouseholdSettings = engine.householdSettings().also { Wlog.v(Area.Parents, "settings read") }.let {
75        HouseholdSettings(
76            it.dailyMinutes?.toInt(), it.quietFrom?.toInt(), it.quietUntil?.toInt(),
77            it.tokensPerWindow?.toLong(), it.windowHours.toInt(), it.keepMicOpen, it.voiceDailyChars.toInt(), it.pinHash,
78            it.child?.let { c -> ChildProfile(c.name, c.ageYears.toInt()) },
79            it.catTheme.toApp(),
80        )
81    }
82
83    override fun setSettings(settings: HouseholdSettings) {
84        Wlog.i(Area.Parents, "setSettings: dailyMinutes=${settings.dailyMinutes}, quiet=${settings.quietFrom}-${settings.quietUntil}, tokens=${settings.tokensPerWindow}/${settings.windowHours}h, keepMicOpen=${settings.keepMicOpen}, voiceDailyChars=${settings.voiceDailyChars}, pinSet=${settings.pinHash != null}, childSet=${settings.child != null}, catTheme=${settings.catTheme}")
85        engine.setHouseholdSettings(
86            uniffi.whiskers_ffi.HouseholdSettings(
87                settings.dailyMinutes?.toUInt(), settings.quietFrom?.toUShort(), settings.quietUntil?.toUShort(),
88                settings.tokensPerWindow?.toULong(), settings.windowHours.toULong(), settings.keepMicOpen,
89                settings.voiceDailyChars.toUInt(), settings.pinHash,
90                settings.child?.let { c -> uniffi.whiskers_ffi.ChildProfile(c.name, c.ageYears.toUByte()) },
91                settings.catTheme.toCore(),
92            ),
93        )
94    }
95
96    override val childAges: IntRange = uniffi.whiskers_ffi.childAgeRange().let { it.youngest.toInt()..it.oldest.toInt() }
97
98    override fun checkChild(name: String, ageYears: Int): ChildCheck {
99        // Never log the name. The core's rejection says what is wrong without quoting it.
100        if (ageYears !in 0..255) return ChildCheck.Invalid("Choose an age.")
101        return try {
102            val c = uniffi.whiskers_ffi.validateChild(name, ageYears.toUByte())
103            Wlog.d(Area.Parents, "checkChild: accepted")
104            ChildCheck.Valid(ChildProfile(c.name, c.ageYears.toInt()))
105        } catch (e: uniffi.whiskers_ffi.EngineException) {
106            Wlog.d(Area.Parents, "checkChild: rejected")
107            ChildCheck.Invalid(e.message ?: "That cannot be used.")
108        }
109    }
110
111    override fun sync() {
112        val began = System.nanoTime()
113        runCatching { NetActivity.around { engine.sync() } }
114            .onSuccess { Wlog.d(Area.Sync, "parents sync ok: $it changes in ${(System.nanoTime() - began) / 1_000_000} ms") }
115            .onFailure { Wlog.w(Area.Sync, "parents sync failed after ${(System.nanoTime() - began) / 1_000_000} ms", it) }
116    }
117
118    override fun grantTime(minutes: Int) {
119        Wlog.i(Area.Parents, "grantTime: $minutes minutes")
120        engine.grantTime(day(), minutes.toUInt())
121    }
122
123    override fun timeUsedToday() = engine.timeUsedMinutes(day()).toInt()
124
125    override fun restore(id: Long): Boolean = engine.restore(id.toULong()).also {
126        Wlog.i(Area.Parents, "restore fact id=$id: restored=$it")
127        runCatching { engine.sync() }.onFailure { e -> Wlog.w(Area.Sync, "sync after restore failed", e) }
128    }
129
130    override fun forget(id: Long): Boolean = engine.forget(id.toULong()).also {
131        Wlog.i(Area.Parents, "forget fact id=$id: removed=$it")
132        runCatching { engine.sync() }.onFailure { e -> Wlog.w(Area.Sync, "sync after forget failed", e) }
133    }
134}
135
136private fun uniffi.whiskers_ffi.CatTheme.toApp(): CatTheme = when (this) {
137    uniffi.whiskers_ffi.CatTheme.GREY -> CatTheme.Grey
138    uniffi.whiskers_ffi.CatTheme.GINGER -> CatTheme.Ginger
139}
140
141private fun CatTheme.toCore(): uniffi.whiskers_ffi.CatTheme = when (this) {
142    CatTheme.Grey -> uniffi.whiskers_ffi.CatTheme.GREY
143    CatTheme.Ginger -> uniffi.whiskers_ffi.CatTheme.GINGER
144}