aboutsummaryrefslogtreecommitdiff
path: root/mobile
diff options
context:
space:
mode:
Diffstat (limited to 'mobile')
-rw-r--r--mobile/mobile.go229
-rw-r--r--mobile/mobile_test.go211
2 files changed, 440 insertions, 0 deletions
diff --git a/mobile/mobile.go b/mobile/mobile.go
new file mode 100644
index 0000000..7e4c109
--- /dev/null
+++ b/mobile/mobile.go
@@ -0,0 +1,229 @@
+// Package mobile is lectio's engine facade for the dlectio Android app. It is
+// bound to a native Android library with gomobile:
+//
+// gomobile bind -target=android/arm64,android/amd64 -androidapi 26 \
+// -javapkg=xyz.labunix.dlectio.engine -tags fullbible \
+// -o dlectio-engine.aar github.com/lukaszkasprzak/lectio/mobile
+//
+// -javapkg gives the bound Mobile class its xyz.labunix.dlectio.engine.mobile
+// package (the app imports it from there); the two -target ABIs match the
+// app's arm64-v8a and x86_64 device/emulator targets; -androidapi 26 matches
+// the app's minSdk. Omitting any of the three still produces an .aar, just
+// one the app cannot import or that lacks an ABI it needs.
+//
+// The app calls Day() with a plain date/form/version and receives one JSON
+// document with the day's identity and rendered Mass readings. All liturgical
+// computation and text resolution happens here, in the exact validated lectio
+// engine — the app does none of it. Keep every exported signature to types
+// gomobile can marshal -- strings and ints only -- with JSON as the payload
+// format for everything structured. A Go int parameter (e.g. Days' count)
+// surfaces on the Kotlin side as a long, not an Int.
+package mobile
+
+import (
+ "encoding/json"
+ "strings"
+ "time"
+
+ "github.com/lukaszkasprzak/lectio/internal/config"
+ "github.com/lukaszkasprzak/lectio/internal/i18n"
+ "github.com/lukaszkasprzak/lectio/internal/readings"
+ "github.com/lukaszkasprzak/lectio/internal/render"
+)
+
+// reading is one Mass reading, rendered in the requested corpus.
+type reading struct {
+ Ord int `json:"ord"`
+ Part string `json:"part"` // machine key, e.g. "ewangelium" / "psalm"
+ Heading string `json:"heading"` // human label in the interface language, e.g. "Gospel"
+ Citation string `json:"citation"`
+ Text string `json:"text"`
+}
+
+// dayDoc is the JSON returned by Day.
+type dayDoc struct {
+ Date string `json:"date"`
+ Form string `json:"form"`
+ Lang string `json:"lang"`
+ Version string `json:"version"`
+ Name string `json:"name"`
+ Season string `json:"season"`
+ Colour string `json:"colour"`
+ Rank string `json:"rank"`
+ RankLabel string `json:"rank_label"`
+ Readings []reading `json:"readings"`
+ Error string `json:"error,omitempty"`
+}
+
+// lectByForm maps the app's form code to lectio's lectionary value.
+func lectByForm(form string) string {
+ if form == "ef" {
+ return "traditional"
+ }
+ return "new"
+}
+
+// Day computes one day's identity and Mass readings and returns them as JSON.
+//
+// date - "YYYY-MM-DD"
+// form - "of" (Ordinary) or "ef" (1962)
+// version - corpus: "drb" | "wuj" | "vul" | "grb"
+// lang - interface language for the celebration name: "en" | "pl"
+//
+// It never panics: any engine error is reported in the JSON "error" field so the
+// JNI boundary always returns a well-formed string.
+func Day(date, form, version, lang string) string {
+ lect := lectByForm(form)
+ out := dayDoc{Date: date, Form: form, Lang: lang, Version: version}
+
+ // Day identity (name/season/colour) in the interface language.
+ cfgID := config.Config{UILanguage: lang, Lectionary: lect, All: true}
+ if _, info, err := readings.Load(cfgID, readings.Options{Date: date, All: true}); err == nil {
+ out.Name = info.Name
+ out.Season = info.Season
+ out.Colour = info.Colour
+ out.Rank = info.Rank
+ out.RankLabel = i18n.Get(lang).Rank[info.Rank]
+ } else {
+ out.Error = err.Error()
+ }
+
+ // Readings, resolved and rendered in the requested corpus. The interface
+ // language localizes the structural labels (Heading) and citation dialect;
+ // the corpus (version) alone decides the scripture text.
+ cfgR := config.Config{UILanguage: lang, Lectionary: lect, ReadingVersion: version, All: true}
+ secs, _, err := readings.Load(cfgR, readings.Options{Date: date, All: true})
+ if err != nil {
+ if out.Error == "" {
+ out.Error = err.Error()
+ }
+ } else {
+ for i, sec := range secs {
+ // GatherVersion's lang localizes only the label/error wording, never
+ // the verse text (which the corpus alone decides), so the interface
+ // language is correct here. HeadingWithRef gives the same localized
+ // "label (citation)" heading lectio shows in its cli/tui/web.
+ _, blocks := render.GatherVersion(version, sec, lect, lang)
+ out.Readings = append(out.Readings, reading{
+ Ord: i,
+ Part: sec.PartID,
+ Heading: render.HeadingWithRef(sec, lang),
+ Citation: sec.Citation,
+ Text: strings.Join(blocks, "\n"),
+ })
+ }
+ }
+
+ b, err := json.Marshal(out)
+ if err != nil {
+ // Should be impossible with these types; degrade gracefully.
+ return `{"error":"marshal failed"}`
+ }
+ return string(b)
+}
+
+// summaryPart is one section of a day, citation only -- no rendered text.
+type summaryPart struct {
+ Part string `json:"part"`
+ Citation string `json:"citation"`
+}
+
+// daySummary is one day's identity and its reading citations. It is Day's
+// cheaper projection: the calendar view needs the day's shape, not its text.
+type daySummary struct {
+ Date string `json:"date"`
+ Name string `json:"name"`
+ Rank string `json:"rank"`
+ RankLabel string `json:"rank_label"`
+ Colour string `json:"colour"`
+ Parts []summaryPart `json:"parts"`
+ Error string `json:"error,omitempty"`
+}
+
+// Days returns count consecutive days from start as a JSON array, each with its
+// identity and reading citations but no reading text. It is the calendar view's
+// source.
+//
+// start - "YYYY-MM-DD", taken literally: aligning to a week is the caller's job
+// count - 1..366; anything else yields "[]"
+// form - "of" or "ef"
+// lang - interface language for the name, rank word and citation dialect
+//
+// No version parameter: the corpus decides only scripture text, which this never
+// renders, while the citation's sigla dialect follows lang.
+//
+// A day that fails to compute carries its own "error" and does not abort the
+// rest, so one bad date cannot blank a whole week. Malformed input yields "[]"
+// rather than an error string, so the caller always parses an array.
+func Days(start string, count int, form, lang string) string {
+ if count < 1 || count > 366 {
+ return "[]"
+ }
+ d, err := time.Parse("2006-01-02", start)
+ if err != nil {
+ return "[]"
+ }
+ lect := lectByForm(form)
+ ui := i18n.Get(lang)
+ cfg := config.Config{UILanguage: lang, Lectionary: lect, All: true}
+
+ out := make([]daySummary, 0, count)
+ for i := 0; i < count; i++ {
+ date := d.AddDate(0, 0, i).Format("2006-01-02")
+ row := daySummary{Date: date, Parts: []summaryPart{}}
+ secs, info, err := readings.Load(cfg, readings.Options{Date: date, All: true})
+ if err != nil {
+ row.Error = err.Error()
+ } else {
+ row.Name = info.Name
+ row.Colour = info.Colour
+ row.Rank = info.Rank
+ row.RankLabel = ui.Rank[info.Rank]
+ for _, sec := range secs {
+ row.Parts = append(row.Parts, summaryPart{Part: sec.PartID, Citation: sec.Citation})
+ }
+ }
+ out = append(out, row)
+ }
+
+ b, err := json.Marshal(out)
+ if err != nil {
+ return "[]"
+ }
+ return string(b)
+}
+
+// partLabel is one section's machine ID and its human label.
+type partLabel struct {
+ Part string `json:"part"`
+ Label string `json:"label"`
+}
+
+// PartLabels returns the form's section IDs and their labels in lang, in display
+// order, as a JSON array. The app builds its reading filters from this instead
+// of hardcoding part IDs -- which is how it came to ship seven 1962 checkboxes
+// the engine never emits.
+//
+// An array, not an object: JSON object key order is not guaranteed and the app
+// renders these in order.
+func PartLabels(form, lang string) string {
+ ids := readings.PartIDs(lectByForm(form))
+ ui := i18n.Get(lang)
+ out := make([]partLabel, 0, len(ids))
+ for _, id := range ids {
+ label := ui.PartLabel[id]
+ if label == "" {
+ label = id
+ }
+ out = append(out, partLabel{Part: id, Label: label})
+ }
+ b, err := json.Marshal(out)
+ if err != nil {
+ return "[]"
+ }
+ return string(b)
+}
+
+// Ping is a trivial JNI smoke test: it returns "pong" so the app can confirm the
+// native engine loaded before issuing a real query.
+func Ping() string { return "pong" }
diff --git a/mobile/mobile_test.go b/mobile/mobile_test.go
new file mode 100644
index 0000000..85de500
--- /dev/null
+++ b/mobile/mobile_test.go
@@ -0,0 +1,211 @@
+package mobile
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+)
+
+func decodeDay(t *testing.T, s string) map[string]any {
+ t.Helper()
+ var m map[string]any
+ if err := json.Unmarshal([]byte(s), &m); err != nil {
+ t.Fatalf("Day returned invalid JSON: %v\n%s", err, s)
+ }
+ return m
+}
+
+func TestDayCarriesLocalisedRank(t *testing.T) {
+ pl := decodeDay(t, Day("2026-08-01", "of", "vul", "pl"))
+ if pl["rank"] != "memorial" {
+ t.Errorf("pl rank = %v, want memorial", pl["rank"])
+ }
+ if pl["rank_label"] != "wspomnienie obowiązkowe" {
+ t.Errorf("pl rank_label = %v", pl["rank_label"])
+ }
+ en := decodeDay(t, Day("2026-08-01", "of", "vul", "en"))
+ if en["rank_label"] != "memorial" {
+ t.Errorf("en rank_label = %v", en["rank_label"])
+ }
+}
+
+// The app shows the colour as a swatch and never as a word, so the raw key must
+// be present and no localised label may be. This test exists so the app cannot
+// quietly start depending on a colour word.
+func TestDayHasColourKeyButNoColourLabel(t *testing.T) {
+ m := decodeDay(t, Day("2026-08-01", "of", "vul", "pl"))
+ if m["colour"] != "white" {
+ t.Errorf("colour = %v, want white", m["colour"])
+ }
+ if _, present := m["colour_label"]; present {
+ t.Error("colour_label must not be returned: the app draws a swatch")
+ }
+}
+
+func decodeDays(t *testing.T, s string) []map[string]any {
+ t.Helper()
+ var a []map[string]any
+ if err := json.Unmarshal([]byte(s), &a); err != nil {
+ t.Fatalf("Days returned invalid JSON: %v\n%s", err, s)
+ }
+ return a
+}
+
+func TestDaysReturnsConsecutiveDates(t *testing.T) {
+ a := decodeDays(t, Days("2026-07-27", 7, "of", "pl"))
+ if len(a) != 7 {
+ t.Fatalf("got %d elements, want 7", len(a))
+ }
+ want := []string{"2026-07-27", "2026-07-28", "2026-07-29", "2026-07-30",
+ "2026-07-31", "2026-08-01", "2026-08-02"}
+ for i, w := range want {
+ if a[i]["date"] != w {
+ t.Errorf("element %d date = %v, want %v", i, a[i]["date"], w)
+ }
+ }
+}
+
+// Days must agree with Day about the same date -- it is a cheaper projection of
+// the same computation, not a second implementation.
+func TestDaysAgreesWithDay(t *testing.T) {
+ one := decodeDay(t, Day("2026-08-01", "of", "vul", "pl"))
+ week := decodeDays(t, Days("2026-08-01", 1, "of", "pl"))
+ if len(week) != 1 {
+ t.Fatalf("got %d elements, want 1", len(week))
+ }
+ row := week[0]
+ for _, k := range []string{"name", "colour", "rank", "rank_label"} {
+ if row[k] != one[k] {
+ t.Errorf("%s: Days = %v, Day = %v", k, row[k], one[k])
+ }
+ }
+ parts := row["parts"].([]any)
+ readings := one["readings"].([]any)
+ if len(parts) != len(readings) {
+ t.Fatalf("parts = %d, readings = %d", len(parts), len(readings))
+ }
+ for i := range parts {
+ p := parts[i].(map[string]any)
+ r := readings[i].(map[string]any)
+ if p["part"] != r["part"] || p["citation"] != r["citation"] {
+ t.Errorf("element %d: Days %v/%v vs Day %v/%v",
+ i, p["part"], p["citation"], r["part"], r["citation"])
+ }
+ }
+}
+
+// No reading text may cross the boundary: that is the whole point of Days.
+func TestDaysCarriesNoReadingText(t *testing.T) {
+ s := Days("2026-08-01", 7, "of", "pl")
+ if strings.Contains(s, `"text"`) {
+ t.Error("Days must not return reading text")
+ }
+ if strings.Contains(s, "colour_label") {
+ t.Error("Days must not return a colour label")
+ }
+}
+
+func TestDaysRejectsBadInput(t *testing.T) {
+ for _, c := range []struct {
+ start string
+ count int
+ }{
+ {"not-a-date", 7}, {"2026-08-01", 0}, {"2026-08-01", -1}, {"2026-08-01", 400},
+ {"2026-08-01", 367}, // one past the documented upper bound
+ } {
+ if got := Days(c.start, c.count, "of", "pl"); got != "[]" {
+ t.Errorf("Days(%q, %d) = %s, want []", c.start, c.count, got)
+ }
+ }
+}
+
+// count's documented range is 1..366; 367 (above) is the first invalid value
+// and 366 (here) is the last valid one -- the boundary an off-by-one would
+// actually live on.
+func TestDaysAcceptsUpperBoundCount(t *testing.T) {
+ a := decodeDays(t, Days("2026-01-01", 366, "of", "en"))
+ if len(a) != 366 {
+ t.Errorf("got %d elements, want 366", len(a))
+ }
+}
+
+func BenchmarkDaysWeek(b *testing.B) {
+ for i := 0; i < b.N; i++ {
+ Days("2026-07-27", 7, "of", "pl")
+ }
+}
+
+// This is the assertion that would have caught the app's dead 1962 checkboxes:
+// it listed nine part IDs where the engine emits two.
+func TestPartLabelsMatchesWhatTheEngineEmits(t *testing.T) {
+ var ef []map[string]any
+ if err := json.Unmarshal([]byte(PartLabels("ef", "pl")), &ef); err != nil {
+ t.Fatalf("invalid JSON: %v", err)
+ }
+ if len(ef) != 2 {
+ t.Fatalf("ef: got %d labels, want 2: %v", len(ef), ef)
+ }
+ if ef[0]["part"] != "epistola" || ef[0]["label"] != "Lekcja" {
+ t.Errorf("ef[0] = %v, want epistola/Lekcja", ef[0])
+ }
+ if ef[1]["part"] != "evangelium" || ef[1]["label"] != "Ewangelia" {
+ t.Errorf("ef[1] = %v, want evangelium/Ewangelia", ef[1])
+ }
+
+ // OF: derive the expected set from what Days actually emits over a full
+ // year (2026 -- a full Sunday cycle, so second readings appear too),
+ // rather than hand-copying the production list, which asserts a
+ // declaration against itself and can never fail for this class of bug
+ // (that is exactly how the app came to render a checkbox -- aklamacja --
+ // that filters an ID the engine never emits).
+ observed := map[string]bool{}
+ s := Days("2026-01-01", 365, "of", "pl")
+ var days []map[string]any
+ if err := json.Unmarshal([]byte(s), &days); err != nil {
+ t.Fatalf("Days returned invalid JSON: %v", err)
+ }
+ for _, d := range days {
+ parts, _ := d["parts"].([]any)
+ for _, p := range parts {
+ part, _ := p.(map[string]any)
+ if id, ok := part["part"].(string); ok {
+ observed[id] = true
+ }
+ }
+ }
+ if len(observed) == 0 {
+ t.Fatal("swept zero part IDs from Days over 2026 -- the sweep is broken, not necessarily the engine")
+ }
+
+ var of []map[string]any
+ if err := json.Unmarshal([]byte(PartLabels("of", "pl")), &of); err != nil {
+ t.Fatalf("invalid JSON: %v", err)
+ }
+ got := map[string]bool{}
+ for _, e := range of {
+ got[e["part"].(string)] = true
+ }
+ if len(got) != len(of) {
+ t.Fatalf("PartLabels(of) lists a part ID more than once: %v", of)
+ }
+ for id := range observed {
+ if !got[id] {
+ t.Errorf("Days emits part %q somewhere in 2026 but PartLabels(of) does not list it: %v", id, of)
+ }
+ }
+ for id := range got {
+ if !observed[id] {
+ t.Errorf("PartLabels(of) lists part %q but Days never emits it anywhere in 2026: %v", id, of)
+ }
+ }
+}
+
+func TestPartLabelsUnknownForm(t *testing.T) {
+ // An unknown form is treated as the modern one, matching lectByForm: the
+ // fallback must be byte-identical to "of", not merely non-empty.
+ got := PartLabels("nonsense", "en")
+ want := PartLabels("of", "en")
+ if got != want {
+ t.Errorf("PartLabels(\"nonsense\", \"en\") = %s, want %s (same as \"of\")", got, want)
+ }
+}