001// SPDX-License-Identifier: GPL-3.0-or-later
002// Copyright © 2026 Aurélien Pierre
003
004package com.aurelienpierre.sioul;
005
006import android.app.AlarmManager;
007import android.app.PendingIntent;
008import android.appwidget.AppWidgetManager;
009import android.appwidget.AppWidgetProvider;
010import android.content.ComponentName;
011import android.content.Context;
012import android.content.Intent;
013import android.net.Uri;
014import android.os.Build;
015import android.os.Bundle;
016import android.util.Log;
017import android.widget.RemoteViews;
018
019import org.json.JSONArray;
020import org.json.JSONException;
021import org.json.JSONObject;
022
023import java.io.File;
024import java.io.FileOutputStream;
025import java.io.IOException;
026import java.nio.charset.StandardCharsets;
027import java.nio.file.Files;
028import java.util.Locale;
029
030/**
031 * Sioul's cards on the home screen (docs/android.md, "The card on the home
032 * screen"). The full card (HomeCardToday): today first, the date and the
033 * weather; then what the Porch has for you, a dose due, a code you asked for,
034 * reminders, the calls declined, two events at once; then Now, what now is
035 * for and the next step. The mail card (HomeCardMail) and the agenda card
036 * (HomeCardAgenda): a list that scrolls (HomeCardRows, through
037 * HomeCardService), the Porch's latest messages, or the coming events, day by
038 * day. Rust writes what they say (crates/sioul-app/src/homecard.rs) in
039 * Sioul's state folder, ahead, in frames: one per change of time until the
040 * end of tomorrow, since Android freezes Sioul in the background; the weather
041 * hour by hour. Here: the frame and the hour for now, drawn again at the next
042 * change (a frame's end, an hour, a code expiring, a dose, a reminder, an
043 * event over, midnight) by an alarm that wakes nothing, and whenever Rust
044 * says so (HOME_CARD, sent through android/main.cpp's
045 * sioul_android_broadcast). No Rust, no network: cards drawn from a file. A
046 * tap opens Sioul: as by its icon on the card itself and on the weather; on
047 * a code, a dose, a call, the Porch; on a reminder, what it is about; on the
048 * step, Now; in a list, the Porch, a message on it, the Agenda or an event in
049 * it (HomeCardOpener). No button does anything else. This class draws all three.
050 */
051public class HomeCard extends AppWidgetProvider
052{
053    static final String DRAW = "com.aurelienpierre.sioul.action.HOME_CARD";
054    /** What a card shows: everything, its messages alone, its events alone. */
055    static final String ALL = "all", MAIL = "mail", AGENDA = "agenda";
056    /** Rust's state folder (android/main.cpp: XDG_STATE_HOME is files/state). */
057    private static final String FOLDER = "state/sioul";
058    private static final String FILE = "home-card.json";
059    private static final String OPENED = "home-card-opened";
060    /** A tap's address: {@code "sioul-card:porch"}, {@code "sioul-card:mail/<its file>"}… */
061    static final String SCHEME = "sioul-card";
062    /** The three kinds of cards, by their providers. */
063    private static final Class<?>[] PROVIDERS = { HomeCard.class, HomeCardMail.class, HomeCardAgenda.class };
064    private static final String[] KINDS = { ALL, MAIL, AGENDA };
065
066    /** What this card shows; the mail and agenda cards say theirs. */
067    String kind()
068    {
069        return ALL;
070    }
071
072    @Override
073    public void onReceive(Context context, Intent intent)
074    {
075        String action = intent.getAction();
076        if (DRAW.equals(action) || Intent.ACTION_BOOT_COMPLETED.equals(action) || Intent.ACTION_MY_PACKAGE_REPLACED.equals(action)) {
077            drawAll(context.getApplicationContext());
078            return;
079        }
080        super.onReceive(context, intent);
081    }
082
083    @Override
084    public void onUpdate(Context context, AppWidgetManager manager, int[] ids)
085    {
086        draw(context.getApplicationContext(), manager, ids, kind());
087    }
088
089    @Override
090    public void onAppWidgetOptionsChanged(Context context, AppWidgetManager manager, int id, Bundle options)
091    {
092        draw(context.getApplicationContext(), manager, new int[] { id }, kind());
093    }
094
095    @Override
096    public void onDisabled(Context context)
097    {
098        // This kind's last card gone: the next drawing only while a card of another kind is left.
099        drawAll(context.getApplicationContext());
100    }
101
102    /**
103     * The card before Sioul wrote one, in Sioul's two languages by the phone's
104     * (as {@link DoseAlarms#words}); every other word is Rust's.
105     */
106    static String empty()
107    {
108        return "fr".equals(Locale.getDefault().getLanguage()) ? "Ouvrez Sioul pour remplir cette carte." : "Open Sioul to fill this card.";
109    }
110
111    /** Every card on the home screens drawn again, of every kind; none, the next drawing let go. */
112    static void drawAll(Context context)
113    {
114        AppWidgetManager manager = AppWidgetManager.getInstance(context);
115        boolean any = false;
116        for (int i = 0; i < PROVIDERS.length; i++) {
117            int[] ids = manager.getAppWidgetIds(new ComponentName(context, PROVIDERS[i]));
118            if (ids == null || ids.length == 0)
119                continue;
120            any = true;
121            draw(context, manager, ids, KINDS[i]);
122        }
123        if (!any)
124            schedule(context, Long.MAX_VALUE);
125    }
126
127    /**
128     * Cards of one kind drawn, the lists read again (HomeCardRows), and the
129     * next drawing set: the same for every kind, made from the same file.
130     */
131    @SuppressWarnings("deprecation") // notifyAppWidgetViewDataChanged: a list's factory, the only way before Android 12.
132    private static void draw(Context context, AppWidgetManager manager, int[] ids, String kind)
133    {
134        long now = System.currentTimeMillis();
135        JSONObject card = read(context);
136        HomeCardToday.Lines lines = HomeCardToday.Lines.of(card, now);
137        for (int id : ids) {
138            try {
139                Bundle options = manager.getAppWidgetOptions(id);
140                manager.updateAppWidget(id, ALL.equals(kind) ? HomeCardToday.views(context, lines, options) : views(context, kind, id));
141            } catch (RuntimeException e) {
142                Log.e(DoseAlarms.TAG, "Home card: not drawn", e);
143            }
144        }
145        if (!ALL.equals(kind)) {
146            try {
147                manager.notifyAppWidgetViewDataChanged(ids, R.id.sioul_home_card_list);
148            } catch (RuntimeException e) {
149                Log.e(DoseAlarms.TAG, "Home card: its list not read again", e);
150            }
151        }
152        schedule(context, Math.min(lines.next, HomeCardRows.nextChange(card, now)));
153    }
154
155    // ---------------------------------------------------------------- what Rust wrote
156
157    /** The card Rust wrote last; none before Sioul's first start, or one that does not read. */
158    static JSONObject read(Context context)
159    {
160        File file = new File(new File(context.getFilesDir(), FOLDER), FILE);
161        try {
162            return new JSONObject(new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8));
163        } catch (IOException | JSONException e) {
164            return null;
165        }
166    }
167
168    /**
169     * A card's frames: "times" from version 3, "frames" before (an older
170     * Sioul's Java reads "frames" alone, finds none in a card of version 3,
171     * and says its "beyond": "Open Sioul to bring this card up to date.").
172     */
173    static JSONArray frames(JSONObject card)
174    {
175        return card == null ? null : card.optJSONArray(card.optInt("v") >= 3 ? "times" : "frames");
176    }
177
178    /** The frame for now; before the first (the clock set back), the first; past the last, none. */
179    static JSONObject frame(JSONArray frames, long now)
180    {
181        for (int i = 0; frames != null && i < frames.length(); i++) {
182            JSONObject frame = frames.optJSONObject(i);
183            if (frame == null)
184                continue;
185            if (i == 0 && now < frame.optLong("from"))
186                return frame;
187            if (frame.optLong("from") <= now && now < frame.optLong("until"))
188                return frame;
189        }
190        return null;
191    }
192
193    // ---------------------------------------------------------------- drawn
194
195    /**
196     * A mail card or an agenda card: its list (HomeCardRows, one factory per
197     * card, its address naming the card and its kind).
198     */
199    @SuppressWarnings("deprecation") // setRemoteAdapter(int, Intent): the only way before Android 12.
200    private static RemoteViews views(Context context, String kind, int id)
201    {
202        RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.sioul_home_card);
203        views.setOnClickPendingIntent(android.R.id.background, launch(context));
204        Intent rows = new Intent(context, HomeCardService.class).putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, id).putExtra(HomeCardService.KIND, kind);
205        // Its own address, so that Android keeps one factory per card.
206        rows.setData(Uri.parse(rows.toUri(Intent.URI_INTENT_SCHEME)));
207        views.setRemoteAdapter(R.id.sioul_home_card_list, rows);
208        // A row tapped: its address filled into this one (HomeCardRows), for HomeCardOpener.
209        views.setPendingIntentTemplate(R.id.sioul_home_card_list, template(context));
210        return views;
211    }
212
213    // ---------------------------------------------------------------- taps
214
215    /** Sioul, as by its icon. */
216    static PendingIntent launch(Context context)
217    {
218        Intent sioul = context.getPackageManager().getLaunchIntentForPackage(context.getPackageName());
219        if (sioul == null)
220            return null;
221        return PendingIntent.getActivity(context, 0, sioul.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED),
222                                         PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
223    }
224
225    /**
226     * Sioul on the Porch, on Now, on what a reminder is about: through
227     * HomeCardOpener, which keeps the tap for Rust. Each address its own
228     * pending intent (Android tells them apart by their data).
229     */
230    static PendingIntent opener(Context context, String where)
231    {
232        Intent open = new Intent(context, HomeCardOpener.class).setData(Uri.fromParts(SCHEME, where, null)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
233        return PendingIntent.getActivity(context, where.startsWith("now") ? 2 : 1, open, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
234    }
235
236    /**
237     * The list's taps: HomeCardOpener, each row's address filled in
238     * (HomeCardRows). Mutable for that alone, from Android 12 where it must be
239     * said; its class is fixed, so a row can only say where Sioul opens.
240     */
241    private static PendingIntent template(Context context)
242    {
243        Intent open = new Intent(context, HomeCardOpener.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
244        int mutable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_MUTABLE : 0;
245        return PendingIntent.getActivity(context, 3, open, mutable | PendingIntent.FLAG_UPDATE_CURRENT);
246    }
247
248    /**
249     * A tap kept for Rust (homecard.rs, `opened`), which takes it when the
250     * window comes up: {"open": "porch" | "now" | "mail" | "agenda" | "event"
251     * | "reminder", "uid" (a step's), "key" (a message's or an event's file, a
252     * reminder's address: "sioul:task/…"), "at"}.
253     */
254    static void opened(Context context, Intent intent)
255    {
256        Uri address = intent == null ? null : intent.getData();
257        if (address == null || !SCHEME.equals(address.getScheme()))
258            return;
259        String where = address.getSchemeSpecificPart();
260        if (where == null)
261            where = "";
262        String open = "porch", uid = "", key = "";
263        if (where.startsWith("now")) {
264            open = "now";
265            uid = where.startsWith("now/") ? where.substring(4) : "";
266        } else if (where.startsWith("mail/")) {
267            open = "mail";
268            key = where.substring(5);
269        } else if (where.startsWith("event/")) {
270            open = "event";
271            key = where.substring(6);
272        } else if (where.startsWith("reminder/")) {
273            open = "reminder";
274            key = where.substring(9);
275        } else if (where.equals("agenda")) {
276            open = "agenda";
277        }
278        JSONObject tap = new JSONObject();
279        try {
280            tap.put("open", open);
281            tap.put("uid", uid);
282            tap.put("key", key);
283            tap.put("at", System.currentTimeMillis());
284        } catch (JSONException e) {
285            return;
286        }
287        File folder = new File(context.getFilesDir(), FOLDER);
288        if (!folder.isDirectory() && !folder.mkdirs())
289            return;
290        try (FileOutputStream out = new FileOutputStream(new File(folder, OPENED))) {
291            out.write(tap.toString().getBytes(StandardCharsets.UTF_8));
292        } catch (IOException e) {
293            Log.w(DoseAlarms.TAG, "Home card: the tap could not be kept: " + e);
294        }
295    }
296
297    // ---------------------------------------------------------------- drawn again
298
299    /**
300     * The cards drawn again at `at`, when a frame ends, a code expires, a dose
301     * comes or goes, the age is to be said, an event is over, or a day's or a
302     * message's name changes at midnight: one alarm for every card, that wakes
303     * nothing (the cards are drawn when the phone is next awake), on time when
304     * Android allows Sioul exact alarms (the doses' "Alarms &amp; reminders"),
305     * else within a few minutes. Long.MAX_VALUE: none.
306     */
307    private static void schedule(Context context, long at)
308    {
309        AlarmManager alarms = context.getSystemService(AlarmManager.class);
310        PendingIntent redraw = PendingIntent.getBroadcast(context, 0, new Intent(context, HomeCard.class).setAction(DRAW),
311                                                          PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
312        if (at == Long.MAX_VALUE) {
313            alarms.cancel(redraw);
314            return;
315        }
316        try {
317            if (DoseAlarms.exact(context)) {
318                alarms.setExact(AlarmManager.RTC, at, redraw);
319                return;
320            }
321        } catch (SecurityException e) {
322            // Taken back between the question and the alarm.
323        }
324        alarms.setWindow(AlarmManager.RTC, at, 5 * 60 * 1000L, redraw);
325    }
326}