001// SPDX-License-Identifier: GPL-3.0-or-later
002// Copyright © 2026 Aurélien Pierre
003
004package com.aurelienpierre.sioul;
005
006import android.content.Context;
007import android.content.Intent;
008import android.graphics.Typeface;
009import android.net.Uri;
010import android.text.SpannableString;
011import android.text.Spanned;
012import android.text.style.StyleSpan;
013import android.view.View;
014import android.widget.RemoteViews;
015import android.widget.RemoteViewsService;
016
017import org.json.JSONArray;
018import org.json.JSONObject;
019
020import java.util.ArrayList;
021import java.util.List;
022
023/**
024 * The rows of a home-screen card's list (HomeCard, through HomeCardService),
025 * as a mail card and an agenda card show them, from what Rust wrote (crates/
026 * sioul-app/src/homecard.rs). First "Mail": the messages the Porch shows in the
027 * frame for now, newest first, each with a thin bar in its account's colour,
028 * its sender (bold while not read), when it came, its subject and a line of
029 * its text; or the Porch's sentence instead ("The Porch opens tomorrow at
030 * 09:00.", "Nothing waits on the Porch."). Then "Agenda": the events not over
031 * yet, each under its day ("Today, Wed 7 Oct", "Tomorrow, Thu 8 Oct", "Mon 12
032 * Oct": each name as it holds now, Rust having said until when), as a block
033 * in its calendar's colour with its title and its time. Never a count.
034 *
035 * A row tapped fills its address into the list's template (HomeCard), for
036 * HomeCardOpener: the Porch, a message ("mail/" and its file), the Agenda, an
037 * event ("event/" and its file).
038 */
039final class HomeCardRows implements RemoteViewsService.RemoteViewsFactory
040{
041    /** The rows' kinds, each with its layout. */
042    static final int SECTION = 0, LINE = 1, MAIL = 2, DAY = 3, EVENT = 4;
043    private static final int[] LAYOUTS = {
044        R.layout.sioul_home_card_section, R.layout.sioul_home_card_line, R.layout.sioul_home_card_mail,
045        R.layout.sioul_home_card_day, R.layout.sioul_home_card_event,
046    };
047    /**
048     * The accounts' colours, by their place among your accounts: Theme.qml's
049     * chart colours, taken between their light and dark tones so that each
050     * reads on either ground (the host draws the card in the phone's theme,
051     * these are fixed here).
052     */
053    private static final int[] ACCOUNTS = { 0xff5f8572, 0xffb07d3f, 0xff5b7ba3, 0xffa86464, 0xff8571ad, 0xff7d8c3a, 0xff4b8f8f };
054    /** An event whose calendar gives no colour: Sioul's green. */
055    private static final int OWN = 0xff5f8572;
056    /** The text on a light block; on a dark one, white. */
057    private static final int DARK = 0xff1f1f1c;
058
059    /** One row: its kind, the message or event it shows, its words, where a tap leads. */
060    static final class Row
061    {
062        final int type;
063        final JSONObject item;
064        final String text;
065        final String open;
066
067        Row(int type, JSONObject item, String text, String open)
068        {
069            this.type = type;
070            this.item = item;
071            this.text = text;
072            this.open = open;
073        }
074    }
075
076    private final Context context;
077    private final String kind;
078    private List<Row> rows = new ArrayList<>();
079    /** When the rows were made: the dates and days said as at then. */
080    private long now = System.currentTimeMillis();
081
082    HomeCardRows(Context context, String kind)
083    {
084        this.context = context;
085        this.kind = kind;
086    }
087
088    @Override
089    public void onCreate()
090    {
091    }
092
093    /** The file read again (HomeCard asks it at each drawing): the rows for now. */
094    @Override
095    public void onDataSetChanged()
096    {
097        now = System.currentTimeMillis();
098        rows = rows(HomeCard.read(context), now, kind);
099    }
100
101    @Override
102    public void onDestroy()
103    {
104        rows = new ArrayList<>();
105    }
106
107    @Override
108    public int getCount()
109    {
110        return rows.size();
111    }
112
113    @Override
114    public RemoteViews getViewAt(int position)
115    {
116        if (position < 0 || position >= rows.size())
117            return getLoadingView();
118        Row row = rows.get(position);
119        RemoteViews views = new RemoteViews(context.getPackageName(), LAYOUTS[row.type]);
120        if (row.type == MAIL)
121            message(views, row.item);
122        else if (row.type == EVENT)
123            event(views, row.item);
124        else
125            views.setTextViewText(R.id.sioul_home_card_row, row.text);
126        views.setOnClickFillInIntent(R.id.sioul_home_card_row, new Intent().setData(Uri.fromParts(HomeCard.SCHEME, row.open, null)));
127        return views;
128    }
129
130    /** A blank line while a row comes, rather than Android's "Loading…". */
131    @Override
132    public RemoteViews getLoadingView()
133    {
134        RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.sioul_home_card_line);
135        views.setTextViewText(R.id.sioul_home_card_row, "");
136        return views;
137    }
138
139    @Override
140    public int getViewTypeCount()
141    {
142        return LAYOUTS.length;
143    }
144
145    @Override
146    public long getItemId(int position)
147    {
148        return position;
149    }
150
151    @Override
152    public boolean hasStableIds()
153    {
154        return false;
155    }
156
157    // ---------------------------------------------------------------- what the list says
158
159    /**
160     * The rows for `now`, for a card of `kind` (HomeCard.ALL, MAIL, AGENDA):
161     * the Porch's part of the frame for now, then the agenda's. None before
162     * Sioul wrote a card with lists (an older Sioul's card, or none: the lines
163     * on top say what there is).
164     */
165    static List<Row> rows(JSONObject card, long now, String kind)
166    {
167        List<Row> rows = new ArrayList<>();
168        if (card == null || card.optInt("v") < 2)
169            return rows;
170        JSONObject words = card.optJSONObject("words");
171        JSONObject frame = HomeCard.frame(HomeCard.frames(card), now);
172        if (!HomeCard.AGENDA.equals(kind))
173            mail(rows, card, frame, words == null ? "" : words.optString("mail"), kind);
174        if (!HomeCard.MAIL.equals(kind))
175            agenda(rows, card, frame, words == null ? "" : words.optString("agenda"), now, kind);
176        return rows;
177    }
178
179    /**
180     * "Mail", then the messages the frame lists, or its sentence. Asleep, in a
181     * pause, in free time, the Porch says nothing: on the full card the status
182     * line says why; the mail card says it under its heading.
183     */
184    private static void mail(List<Row> rows, JSONObject card, JSONObject frame, String heading, String kind)
185    {
186        JSONObject porch = frame == null ? null : frame.optJSONObject("porch");
187        if (porch == null) {
188            if (HomeCard.MAIL.equals(kind)) {
189                rows.add(new Row(SECTION, null, heading, "porch"));
190                rows.add(new Row(LINE, null, frame == null ? card.optString("beyond") : frame.optString("status"), "porch"));
191            }
192            return;
193        }
194        rows.add(new Row(SECTION, null, heading, "porch"));
195        String line = porch.optString("line");
196        if (!line.isEmpty())
197            rows.add(new Row(LINE, null, line, "porch"));
198        JSONArray all = card.optJSONArray("mail");
199        JSONArray listed = porch.optJSONArray("mail");
200        for (int i = 0; all != null && listed != null && i < listed.length(); i++) {
201            JSONObject message = all.optJSONObject(listed.optInt(i, -1));
202            if (message == null)
203                continue;
204            String key = message.optString("key");
205            rows.add(new Row(MAIL, message, "", key.isEmpty() ? "porch" : "mail/" + key));
206        }
207    }
208
209    /**
210     * "Agenda", then each event not over yet under its day: the day it starts,
211     * or today while it runs. Rust gave them nearest first, so the days only
212     * go forward. In a pause the full card shows the pause alone; the agenda
213     * card says it under its heading. Without a calendar, nothing.
214     */
215    private static void agenda(List<Row> rows, JSONObject card, JSONObject frame, String heading, long now, String kind)
216    {
217        JSONObject agenda = card.optJSONObject("agenda");
218        if (agenda == null)
219            return;
220        if (frame != null && !frame.optBoolean("events", true)) {
221            if (HomeCard.AGENDA.equals(kind)) {
222                rows.add(new Row(SECTION, null, heading, "agenda"));
223                rows.add(new Row(LINE, null, frame.optString("status"), "agenda"));
224            }
225            return;
226        }
227        rows.add(new Row(SECTION, null, heading, "agenda"));
228        JSONArray days = agenda.optJSONArray("days");
229        JSONArray events = agenda.optJSONArray("events");
230        int shownDay = -1;
231        boolean any = false;
232        for (int i = 0; events != null && i < events.length(); i++) {
233            JSONObject event = events.optJSONObject(i);
234            if (event == null || event.optLong("end") <= now)
235                continue;
236            int day = dayOf(days, Math.max(event.optLong("start"), now));
237            if (day < 0)
238                continue;
239            if (day != shownDay) {
240                JSONObject named = days.optJSONObject(day);
241                rows.add(new Row(DAY, null, label(named == null ? null : named.optJSONArray("label"), now), "agenda"));
242                shownDay = day;
243            }
244            String key = event.optString("key");
245            rows.add(new Row(EVENT, event, "", key.isEmpty() ? "agenda" : "event/" + key));
246            any = true;
247        }
248        if (!any)
249            rows.add(new Row(LINE, null, agenda.optString("empty"), "agenda"));
250    }
251
252    /** The day `at` falls in, by its place in `days`; -1 outside them. */
253    private static int dayOf(JSONArray days, long at)
254    {
255        for (int i = 0; days != null && i < days.length(); i++) {
256            JSONObject day = days.optJSONObject(i);
257            if (day != null && day.optLong("from") <= at && at < day.optLong("until"))
258                return i;
259        }
260        return -1;
261    }
262
263    /** The wording that holds at `now`: the first whose `until` is ahead, or 0 (for good). */
264    static String label(JSONArray labels, long now)
265    {
266        for (int i = 0; labels != null && i < labels.length(); i++) {
267            JSONObject label = labels.optJSONObject(i);
268            if (label == null)
269                continue;
270            long until = label.optLong("until");
271            if (until == 0 || until > now)
272                return label.optString("text");
273        }
274        return "";
275    }
276
277    /** The first `until` ahead of `now` among some wordings; none, Long.MAX_VALUE. */
278    static long ahead(JSONArray labels, long now)
279    {
280        for (int i = 0; labels != null && i < labels.length(); i++) {
281            JSONObject label = labels.optJSONObject(i);
282            long until = label == null ? 0 : label.optLong("until");
283            if (until > now)
284                return until;
285        }
286        return Long.MAX_VALUE;
287    }
288
289    /**
290     * When the list says something else (Unix ms), for HomeCard's next
291     * drawing: an event over, a message's or a day's name changing at
292     * midnight. Long.MAX_VALUE: never.
293     */
294    static long nextChange(JSONObject card, long now)
295    {
296        long next = Long.MAX_VALUE;
297        if (card == null)
298            return next;
299        JSONArray mail = card.optJSONArray("mail");
300        for (int i = 0; mail != null && i < mail.length(); i++) {
301            JSONObject message = mail.optJSONObject(i);
302            if (message != null)
303                next = Math.min(next, ahead(message.optJSONArray("when"), now));
304        }
305        JSONObject agenda = card.optJSONObject("agenda");
306        JSONArray days = agenda == null ? null : agenda.optJSONArray("days");
307        for (int i = 0; days != null && i < days.length(); i++) {
308            JSONObject day = days.optJSONObject(i);
309            if (day != null)
310                next = Math.min(next, ahead(day.optJSONArray("label"), now));
311        }
312        JSONArray events = agenda == null ? null : agenda.optJSONArray("events");
313        for (int i = 0; events != null && i < events.length(); i++) {
314            JSONObject event = events.optJSONObject(i);
315            long end = event == null ? 0 : event.optLong("end");
316            if (end > now)
317                next = Math.min(next, end);
318        }
319        return next;
320    }
321
322    // ---------------------------------------------------------------- drawn
323
324    /** A message, as a mail card shows it; sender and subject in bold while it is not read. */
325    private void message(RemoteViews views, JSONObject message)
326    {
327        boolean unread = message.optBoolean("unread");
328        views.setTextViewText(R.id.sioul_home_card_sender, bold(message.optString("sender"), unread));
329        views.setTextViewText(R.id.sioul_home_card_date, label(message.optJSONArray("when"), now));
330        views.setTextViewText(R.id.sioul_home_card_subject, bold(message.optString("subject"), unread));
331        String text = message.optString("text");
332        views.setTextViewText(R.id.sioul_home_card_text, text);
333        views.setViewVisibility(R.id.sioul_home_card_text, text.isEmpty() ? View.GONE : View.VISIBLE);
334        int account = message.optInt("account", -1);
335        if (account < 0) {
336            views.setViewVisibility(R.id.sioul_home_card_bar, View.INVISIBLE);
337            return;
338        }
339        views.setViewVisibility(R.id.sioul_home_card_bar, View.VISIBLE);
340        views.setInt(R.id.sioul_home_card_bar, "setColorFilter", ACCOUNTS[account % ACCOUNTS.length]);
341    }
342
343    /** An event, as an agenda card shows it: a block in its calendar's colour, the text as that colour asks. */
344    private void event(RemoteViews views, JSONObject event)
345    {
346        int color = colour(event.optString("color"));
347        int text = textOn(color);
348        views.setInt(R.id.sioul_home_card_block, "setColorFilter", color);
349        views.setTextViewText(R.id.sioul_home_card_title, event.optString("title"));
350        views.setTextColor(R.id.sioul_home_card_title, text);
351        String time = event.optString("time");
352        views.setTextViewText(R.id.sioul_home_card_time, time);
353        // The time a little softer than the title, as the window's agenda has it.
354        views.setTextColor(R.id.sioul_home_card_time, (text & 0x00ffffff) | 0xd9000000);
355        views.setViewVisibility(R.id.sioul_home_card_time, time.isEmpty() ? View.GONE : View.VISIBLE);
356    }
357
358    private static CharSequence bold(String text, boolean bold)
359    {
360        if (!bold || text.isEmpty())
361            return text;
362        SpannableString styled = new SpannableString(text);
363        styled.setSpan(new StyleSpan(Typeface.BOLD), 0, styled.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
364        return styled;
365    }
366
367    /** "#4c6b5c" as a colour; anything else, Sioul's own. */
368    private static int colour(String hex)
369    {
370        if (hex != null && hex.length() == 7 && hex.charAt(0) == '#') {
371            try {
372                return 0xff000000 | Integer.parseInt(hex.substring(1), 16);
373            } catch (NumberFormatException e) {
374                // Not a colour: Sioul's own.
375            }
376        }
377        return OWN;
378    }
379
380    /** Dark text on a light block, white on a dark one: whichever contrasts more (WCAG 2's ratio). */
381    static int textOn(int color)
382    {
383        double ground = luminance(color);
384        double white = 1.05 / (ground + 0.05);
385        double dark = (ground + 0.05) / (luminance(DARK) + 0.05);
386        return white >= dark ? 0xffffffff : DARK;
387    }
388
389    /** WCAG 2's relative luminance of an sRGB colour. */
390    private static double luminance(int color)
391    {
392        return 0.2126 * linear((color >> 16) & 0xff) + 0.7152 * linear((color >> 8) & 0xff) + 0.0722 * linear(color & 0xff);
393    }
394
395    private static double linear(int value)
396    {
397        double c = value / 255.0;
398        return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
399    }
400}