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.Notification; 008import android.app.NotificationChannel; 009import android.app.NotificationManager; 010import android.app.PendingIntent; 011import android.content.ComponentName; 012import android.content.Context; 013import android.content.Intent; 014import android.content.SharedPreferences; 015import android.content.pm.ActivityInfo; 016import android.content.pm.PackageManager; 017import android.graphics.drawable.Icon; 018import android.net.Uri; 019import android.os.Build; 020import android.util.Log; 021 022import org.json.JSONArray; 023import org.json.JSONException; 024import org.json.JSONObject; 025 026import java.util.HashSet; 027import java.util.Locale; 028import java.util.Set; 029 030/** 031 * Doses reminded while Sioul is not on the screen (docs/android.md, "Doses 032 * while Sioul is away"). Android freezes an app it does not show: each coming 033 * dose is given to Android's alarm clock ahead, and at its time DoseReceiver 034 * asks Rust's side what to say (crates/sioul-app/src/alarms.rs), which reads 035 * first what your other devices marked. Here: the alarms, their list kept for 036 * Android's restarts, the reminders, the dose of a tapped one, and Sioul's 037 * library, loaded without its window when Android starts Sioul for an alarm 038 * alone (android/main.cpp). 039 */ 040final class DoseAlarms 041{ 042 static final String TAG = "sioul"; 043 static final String ALARM = "com.aurelienpierre.sioul.action.DOSE_ALARM"; 044 static final String TAKEN = "com.aurelienpierre.sioul.action.DOSE_TAKEN"; 045 /** The dose as the list names it: "Levothyroxine · 75 µg". */ 046 static final String NAME = "name"; 047 /** The reminder's title and text, as Rust said them. */ 048 static final String TITLE = "title"; 049 static final String BODY = "body"; 050 051 private static final String SCHEME = "sioul-dose"; 052 private static final String CHANNEL = "doses"; 053 private static final String KEPT = "sioul-doses"; 054 private static final String LIST = "alarms"; 055 private static final String OPENED = "opened"; 056 /** A reminder is known by its dose's key (its tag) and this number. */ 057 private static final int REMINDER = 1; 058 /** "Taken at 08:03" stays half a day in the shade. */ 059 private static final long CONFIRMED_MS = 12 * 60 * 60 * 1000L; 060 061 private static boolean loaded; 062 063 private DoseAlarms() {} 064 065 // ---------------------------------------------------------------- Sioul's library 066 067 static native void nativeInit(Context context); 068 static native String nativeDecide(String key); 069 static native String nativeTaken(String key); 070 071 /** 072 * Sioul's library, loaded and started once per process. Android may start 073 * Sioul for an alarm alone, without its window: then without Qt's Java 074 * side, which Qt's loader starts when the window opens. The library alone 075 * is loaded (Qt's libraries come as its own), by its name in the manifest 076 * and the processor, as Qt names it: libsioul_arm64-v8a.so. 077 */ 078 static synchronized void load(Context context) 079 { 080 if (loaded) 081 return; 082 String name = "sioul"; 083 try { 084 PackageManager packages = context.getPackageManager(); 085 Intent launcher = packages.getLaunchIntentForPackage(context.getPackageName()); 086 ComponentName window = launcher == null ? null : launcher.getComponent(); 087 ActivityInfo info = window == null ? null : packages.getActivityInfo(window, PackageManager.GET_META_DATA); 088 if (info != null && info.metaData != null) 089 name = info.metaData.getString("android.app.lib_name", name); 090 } catch (PackageManager.NameNotFoundException e) { 091 Log.w(TAG, "Doses: the library's name is not in the manifest, \"sioul\" taken."); 092 } 093 UnsatisfiedLinkError first = null; 094 for (String abi : Build.SUPPORTED_ABIS) { 095 try { 096 System.loadLibrary(name + "_" + abi); 097 nativeInit(context.getApplicationContext()); 098 loaded = true; 099 return; 100 } catch (UnsatisfiedLinkError e) { 101 if (first == null) 102 first = e; 103 } 104 } 105 throw first != null ? first : new UnsatisfiedLinkError("Doses: no library " + name); 106 } 107 108 // ---------------------------------------------------------------- the alarms 109 110 /** 111 * Rust's coming doses (android/main.cpp): JSON [{key, at (Unix 112 * milliseconds), title}]. Their alarms replace those given before, and 113 * the list is kept for Android's restarts. Any thread. 114 */ 115 static synchronized void set(Context context, String json) 116 { 117 final JSONArray doses; 118 try { 119 doses = new JSONArray(json); 120 } catch (JSONException e) { 121 Log.e(TAG, "Doses: the list of alarms does not read; those given before stay. " + e); 122 return; 123 } 124 Set<String> coming = new HashSet<>(); 125 for (int i = 0; i < doses.length(); i++) 126 coming.add(key(doses.optJSONObject(i))); 127 JSONArray before = list(context); 128 for (int i = 0; i < before.length(); i++) { 129 String key = key(before.optJSONObject(i)); 130 if (!key.isEmpty() && !coming.contains(key)) 131 cancel(context, key); 132 } 133 schedule(context, doses); 134 keep(context, doses); 135 channel(context); 136 Log.i(TAG, "Doses: " + doses.length() + " alarms given, " + (exact(context) ? "on time." : "inexact (no \"Alarms & reminders\").")); 137 } 138 139 /** 140 * The alarms given again from the list kept: Android forgets them when it 141 * restarts (DoseBootReceiver). A dose whose time went by rings at once: 142 * Rust says whether it is still to remind. 143 */ 144 static synchronized void restore(Context context, String why) 145 { 146 JSONArray doses = list(context); 147 schedule(context, doses); 148 Log.i(TAG, "Doses: " + doses.length() + " alarms given again (" + why + ")."); 149 } 150 151 /** A dose asked about again at `at`, Rust waiting for news from your other devices. */ 152 static synchronized void again(Context context, String key, long at, String name) 153 { 154 alarm(context, key, at, name); 155 JSONArray doses = list(context); 156 boolean found = false; 157 for (int i = 0; i < doses.length(); i++) { 158 JSONObject dose = doses.optJSONObject(i); 159 if (key.equals(key(dose))) { 160 put(dose, "at", at); 161 found = true; 162 } 163 } 164 if (!found) { 165 JSONObject dose = new JSONObject(); 166 put(dose, "key", key); 167 put(dose, "at", at); 168 put(dose, "title", name); 169 doses.put(dose); 170 } 171 keep(context, doses); 172 } 173 174 /** A dose reminded: out of the list, not to ring again after a restart. */ 175 static synchronized void reminded(Context context, String key) 176 { 177 JSONArray doses = list(context); 178 JSONArray left = new JSONArray(); 179 for (int i = 0; i < doses.length(); i++) 180 if (!key.equals(key(doses.optJSONObject(i)))) 181 left.put(doses.opt(i)); 182 keep(context, left); 183 } 184 185 /** 186 * Whether the alarms ring on time: "Alarms & reminders" allowed, which 187 * Android 12 lets you take back and gives from Android 13 (USE_EXACT_ALARM). 188 */ 189 static boolean exact(Context context) 190 { 191 return Build.VERSION.SDK_INT < Build.VERSION_CODES.S || context.getSystemService(AlarmManager.class).canScheduleExactAlarms(); 192 } 193 194 private static void schedule(Context context, JSONArray doses) 195 { 196 for (int i = 0; i < doses.length(); i++) { 197 JSONObject dose = doses.optJSONObject(i); 198 String key = key(dose); 199 long at = dose == null ? 0 : dose.optLong("at"); 200 if (!key.isEmpty() && at > 0) 201 alarm(context, key, at, dose.optString("title")); 202 } 203 } 204 205 /** 206 * A dose's alarm, at its time even while the phone sleeps (Doze). Not 207 * Android's alarm clock (setAlarmClock): it would show an alarm in the 208 * status bar for good, a dose always coming within two days, and put the 209 * doses before your own alarm on the lock screen. Without "Alarms & 210 * reminders", as close as Android allows: up to an hour late. 211 */ 212 private static void alarm(Context context, String key, long at, String name) 213 { 214 AlarmManager alarms = context.getSystemService(AlarmManager.class); 215 PendingIntent ring = PendingIntent.getBroadcast(context, key.hashCode(), alarmIntent(context, key).putExtra(NAME, name), 216 PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); 217 try { 218 if (exact(context)) { 219 alarms.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, ring); 220 return; 221 } 222 } catch (SecurityException e) { 223 // Taken back between the question and the alarm. 224 } 225 alarms.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, ring); 226 } 227 228 private static void cancel(Context context, String key) 229 { 230 PendingIntent ring = PendingIntent.getBroadcast(context, key.hashCode(), alarmIntent(context, key), 231 PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_NO_CREATE); 232 if (ring != null) { 233 context.getSystemService(AlarmManager.class).cancel(ring); 234 ring.cancel(); 235 } 236 } 237 238 private static Intent alarmIntent(Context context, String key) 239 { 240 return new Intent(context, DoseReceiver.class).setAction(ALARM).setData(address(key)); 241 } 242 243 /** A dose's intents differ by their address, its key: one alarm per dose, whatever the keys' hashes. */ 244 static Uri address(String key) 245 { 246 return Uri.fromParts(SCHEME, key, null); 247 } 248 249 /** The dose an intent is about; "" when none. */ 250 static String key(Intent intent) 251 { 252 Uri address = intent == null ? null : intent.getData(); 253 if (address == null || !SCHEME.equals(address.getScheme())) 254 return ""; 255 String key = address.getSchemeSpecificPart(); 256 return key == null ? "" : key; 257 } 258 259 private static String key(JSONObject dose) 260 { 261 return dose == null ? "" : dose.optString("key"); 262 } 263 264 private static void put(JSONObject dose, String name, Object value) 265 { 266 try { 267 dose.put(name, value); 268 } catch (JSONException e) { 269 // Only for a name that is null or a number that is not one. 270 } 271 } 272 273 // ---------------------------------------------------------------- the list kept 274 275 private static SharedPreferences kept(Context context) 276 { 277 return context.getSharedPreferences(KEPT, Context.MODE_PRIVATE); 278 } 279 280 private static JSONArray list(Context context) 281 { 282 try { 283 return new JSONArray(kept(context).getString(LIST, "[]")); 284 } catch (JSONException e) { 285 return new JSONArray(); 286 } 287 } 288 289 private static void keep(Context context, JSONArray doses) 290 { 291 kept(context).edit().putString(LIST, doses.toString()).commit(); 292 } 293 294 /** A tapped reminder's dose, kept until Sioul takes it (DoseOpener). */ 295 static synchronized void opened(Context context, String key) 296 { 297 kept(context).edit().putString(OPENED, key).apply(); 298 } 299 300 /** The dose of a tapped reminder, once; null when there is none (android/main.cpp, for Rust). */ 301 static synchronized String takeOpened(Context context) 302 { 303 SharedPreferences kept = kept(context); 304 String key = kept.getString(OPENED, null); 305 if (key != null) { 306 kept.edit().remove(OPENED).apply(); 307 Log.i(TAG, "Doses: a tapped reminder's dose handed to Sioul."); 308 } 309 return key; 310 } 311 312 // ---------------------------------------------------------------- the reminders 313 314 /** 315 * The "Doses" channel: Android's default importance (its sound, no banner 316 * over what you do), yours to change in Android's settings. 317 */ 318 static void channel(Context context) 319 { 320 context.getSystemService(NotificationManager.class).createNotificationChannel( 321 Channels.quiet(CHANNEL, "Doses", NotificationManager.IMPORTANCE_DEFAULT)); 322 } 323 324 /** Whether reminders can show: Sioul's notifications on (from Android 13, your yes), the "Doses" channel too. */ 325 static boolean notificationsAllowed(Context context) 326 { 327 NotificationManager notifications = context.getSystemService(NotificationManager.class); 328 if (!notifications.areNotificationsEnabled()) 329 return false; 330 NotificationChannel doses = notifications.getNotificationChannel(CHANNEL); 331 return doses == null || doses.getImportance() != NotificationManager.IMPORTANCE_NONE; 332 } 333 334 /** Rust's reminder, as Rust said it, with "Taken"; a tap opens Sioul on the dose's question. */ 335 static void remind(Context context, String key, String title, String body, String name, String taken) 336 { 337 Intent press = new Intent(context, DoseReceiver.class).setAction(TAKEN).setData(address(key)) 338 .putExtra(NAME, name).putExtra(TITLE, title).putExtra(BODY, body); 339 PendingIntent button = PendingIntent.getBroadcast(context, key.hashCode(), press, 340 PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); 341 Notification.Action action = new Notification.Action.Builder( 342 Icon.createWithResource(context, R.drawable.sioul_notification), 343 taken == null || taken.isEmpty() ? words(TAKEN_WORD) : taken, button).build(); 344 show(context, key, reminder(context, key, title, body).addAction(action)); 345 } 346 347 /** "Taken" recorded: the reminder becomes a quiet line, "Taken at 08:03", for half a day. */ 348 static void confirm(Context context, String key, String title, String line) 349 { 350 show(context, key, reminder(context, key, title, line).setTimeoutAfter(CONFIRMED_MS)); 351 } 352 353 /** 354 * "Taken" more than half an hour late: when, asked in Sioul, which a tap 355 * opens. Android opens no window from a button's broadcast (Android 12). 356 */ 357 static void askWhen(Context context, String key, String title, String line) 358 { 359 show(context, key, reminder(context, key, title, line == null || line.isEmpty() ? words(LATE_WORDS) : line)); 360 } 361 362 /** 363 * No answer from Rust, or none in time: never silence, never a claim. A 364 * dose to check in Sioul, as Rust says it then ("dose-alarm-fallback"). 365 */ 366 static void fallback(Context context, String key) 367 { 368 show(context, key, reminder(context, key, "Sioul", words(FALLBACK_WORDS))); 369 } 370 371 /** A dose's reminder taken away: marked meanwhile, or said for nothing. */ 372 static void dismiss(Context context, String key) 373 { 374 context.getSystemService(NotificationManager.class).cancel(key, REMINDER); 375 } 376 377 private static Notification.Builder reminder(Context context, String key, String title, String text) 378 { 379 // Sioul's window opened by DoseOpener, never with the dose in its own intent. 380 PendingIntent open = PendingIntent.getActivity(context, key.hashCode(), 381 new Intent(context, DoseOpener.class).setData(address(key)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), 382 PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); 383 // During a pause that lets doses through, their own channel, which 384 // passes the pause's do-not-disturb (PauseMode); "Doses" otherwise. 385 return new Notification.Builder(context, PauseMode.dosesChannelNow(context)) 386 .setSmallIcon(R.drawable.sioul_notification) 387 .setColor(context.getColor(R.color.sioul_green)) 388 .setContentTitle(title) 389 .setContentText(text) 390 .setStyle(new Notification.BigTextStyle().bigText(text)) 391 .setCategory(Notification.CATEGORY_REMINDER) 392 .setContentIntent(open) 393 .setAutoCancel(true) 394 // Replaced (by "Taken", by Rust's answer after the fallback): no second sound. 395 .setOnlyAlertOnce(true); 396 } 397 398 private static void show(Context context, String key, Notification.Builder reminder) 399 { 400 channel(context); 401 context.getSystemService(NotificationManager.class).notify(key, REMINDER, reminder.build()); 402 } 403 404 // ---------------------------------------------------------------- the words said here 405 406 private static final int TAKEN_WORD = 0; 407 private static final int LATE_WORDS = 1; 408 private static final int FALLBACK_WORDS = 2; 409 private static final String[][] WORDS = { 410 { "Taken", "Pris" }, 411 { "Taken late: tap to say when.", "Pris en retard : touchez pour dire à quelle heure." }, 412 { "A dose is due: open Sioul to check it.", "Une prise est prévue : ouvrez Sioul pour vérifier." }, 413 }; 414 415 /** 416 * The few words said here, in Sioul's two languages (crates/sioul-core/ 417 * locales), by the phone's language as Sioul takes it (android/main.cpp); 418 * all the others are Rust's. 419 */ 420 private static String words(int which) 421 { 422 return WORDS[which]["fr".equals(Locale.getDefault().getLanguage()) ? 1 : 0]; 423 } 424}