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}