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 & 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}