001// SPDX-License-Identifier: GPL-3.0-or-later
002// Copyright © 2026 Aurélien Pierre
003
004package com.aurelienpierre.sioul;
005
006import android.app.Notification;
007import android.app.NotificationManager;
008import android.app.PendingIntent;
009import android.content.Context;
010import android.content.Intent;
011import android.graphics.drawable.Icon;
012import android.net.Uri;
013import android.util.Log;
014
015import org.json.JSONArray;
016import org.json.JSONException;
017import org.json.JSONObject;
018
019import java.util.ArrayList;
020import java.util.List;
021
022/**
023 * The end of work's notice and the night's (docs/reviews.md), as on a
024 * computer: "Work hours are over" with Close the work day; as the night's
025 * wind-down begins, its own notice with Close the whole day while that
026 * night's review is to give, and Options…. Rust decides what and when, at
027 * their alarms, given with the events' (crates/sioul-app/src/eventalarms.rs,
028 * reviews.rs, health.rs), and shows them here. Each button opens Sioul on
029 * its sheet or its question (ReminderOpener), the notice then taken away; a
030 * tap on the notice is its first button. The "End of the day" channel, at
031 * low importance: no sound, no banner, yours to change in Android's
032 * settings, made as Sioul's other quiet ones (Channels.quiet). Never said
033 * from words kept with the list: Rust alone says them, or nothing
034 * (EventAlarms.fallback).
035 */
036final class DayNotes
037{
038    /** The "End of the day" channel. */
039    static final String CHANNEL = "day";
040    /** A notice is known by its key (its tag: "day:work:2026-10-14") and this number. */
041    static final int NOTE = 5;
042    /** Android shows three buttons at most. */
043    static final int AT_MOST = 3;
044
045    private DayNotes() {}
046
047    /** A button: its words, and what it opens (ReminderOpener.opens). */
048    static final class Button
049    {
050        final String label;
051        final String kind;
052        final String key;
053
054        Button(String label, String kind, String key)
055        {
056            this.label = label;
057            this.kind = kind;
058            this.key = key;
059        }
060    }
061
062    /**
063     * The buttons Rust gave ({buttons: [{label, kind, key}]}), as Android
064     * shows them: in their order, three at most, each with its words and
065     * opening what a notice of Sioul's may open (ReminderOpener.opens).
066     * Pure, checked on a JVM (android/jvm-checks/DayNotesCheck.java).
067     *
068     * @param said Rust's notice, or null
069     * @return the buttons to show, perhaps none
070     */
071    static List<Button> buttons(JSONObject said)
072    {
073        List<Button> out = new ArrayList<>();
074        JSONArray given = said == null ? null : said.optJSONArray("buttons");
075        if (given == null)
076            return out;
077        for (int i = 0; i < given.length() && out.size() < AT_MOST; i++) {
078            JSONObject button = given.optJSONObject(i);
079            if (button == null)
080                continue;
081            String label = button.optString("label");
082            String kind = button.optString("kind");
083            String key = button.optString("key");
084            if (!label.isEmpty() && ReminderOpener.opens(kind, key))
085                out.add(new Button(label, kind, key));
086        }
087        return out;
088    }
089
090    /**
091     * Each press's own request code, so that Android keeps their intents
092     * apart: the tap on the notice (`index` -1) and each button.
093     *
094     * @param note the notice's key
095     * @param index the button's place, -1 for the tap on the notice
096     * @return the request code
097     */
098    static int requestCode(String note, int index)
099    {
100        return (note + "#" + index).hashCode();
101    }
102
103    /**
104     * Rust's notice (eventalarms.rs, `day_note`): JSON {key, title, body,
105     * buttons, until (Unix ms), words {day}}, shown in its place by its key;
106     * gone from the shade at `until`. Any thread.
107     *
108     * @param context the application's
109     * @param json Rust's words
110     */
111    static void show(Context context, String json)
112    {
113        final JSONObject said;
114        try {
115            said = new JSONObject(json);
116        } catch (JSONException e) {
117            Log.e(DoseAlarms.TAG, "End of the day: a notice that does not read. " + e);
118            return;
119        }
120        String note = said.optString("key");
121        List<Button> buttons = buttons(said);
122        if (note.isEmpty() || buttons.isEmpty())
123            return;
124        JSONObject words = said.optJSONObject("words");
125        String name = words == null ? "" : words.optString("day");
126        NotificationManager notifications = context.getSystemService(NotificationManager.class);
127        notifications.createNotificationChannel(Channels.quiet(CHANNEL, name.isEmpty() ? EventAlarms.word(context, "day") : name, NotificationManager.IMPORTANCE_LOW));
128        String title = said.optString("title");
129        String body = said.optString("body");
130        Notification.Builder builder = new Notification.Builder(context, CHANNEL)
131            .setSmallIcon(R.drawable.sioul_notification)
132            .setColor(context.getColor(R.color.sioul_green))
133            .setContentTitle(title.isEmpty() ? "Sioul" : title)
134            .setContentText(body)
135            .setStyle(new Notification.BigTextStyle().bigText(body))
136            .setCategory(Notification.CATEGORY_REMINDER)
137            .setContentIntent(open(context, note, -1, buttons.get(0)))
138            .setAutoCancel(true)
139            .setOnlyAlertOnce(true);
140        for (int i = 0; i < buttons.size(); i++) {
141            Button button = buttons.get(i);
142            builder.addAction(new Notification.Action.Builder(Icon.createWithResource(context, R.drawable.sioul_notification), button.label, open(context, note, i, button)).build());
143        }
144        long left = said.optLong("until") - System.currentTimeMillis();
145        if (left > 0)
146            builder.setTimeoutAfter(left);
147        notifications.notify(note, NOTE, builder.build());
148        // Said: its alarm has nothing left to do, after a restart neither.
149        EventAlarms.done(context, note);
150    }
151
152    /** A press: Sioul brought up on what the button offers (ReminderOpener), this notice named to go. */
153    private static PendingIntent open(Context context, String note, int index, Button button)
154    {
155        Intent sioul = new Intent(context, ReminderOpener.class).setData(Uri.fromParts(ReminderOpener.SCHEME, button.kind, null))
156                           .putExtra(ReminderOpener.KEY, button.key).putExtra(ReminderOpener.NOTE, note).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
157        return PendingIntent.getActivity(context, requestCode(note, index), sioul, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
158    }
159
160    /**
161     * A notice pressed, taken away from the shade: a button's press leaves
162     * it there by itself.
163     *
164     * @param context any of Sioul's
165     * @param note the notice's key; nothing when null or empty
166     */
167    static void dismiss(Context context, String note)
168    {
169        if (note != null && !note.isEmpty())
170            context.getSystemService(NotificationManager.class).cancel(note, NOTE);
171    }
172}