001// SPDX-License-Identifier: GPL-3.0-or-later 002// Copyright © 2026 Aurélien Pierre 003 004package com.aurelienpierre.sioul; 005 006import android.content.BroadcastReceiver; 007import android.content.Context; 008import android.content.Intent; 009import android.os.Handler; 010import android.os.Looper; 011import android.util.Log; 012 013import org.json.JSONException; 014import org.json.JSONObject; 015 016/** 017 * A dose's alarm, and the "Taken" of its reminder (DoseAlarms): each asks 018 * Rust's side (crates/sioul-app/src/alarms.rs) on a thread of its own, the 019 * broadcast held open meanwhile (goAsync). At an alarm, Rust first gives the 020 * sync app twenty seconds to bring your other devices' marks; Android gives 021 * the alarm's broadcast a minute (it comes from the background), the 022 * button's ten seconds (from the foreground). An expedited JobScheduler job 023 * would get longer, but waits for its turn, its quota and the phone's sleep, 024 * where a broadcast runs at once. Past the deadline the broadcast is let go, 025 * before Android takes Sioul for hung and stops it: an alarm's reminder then 026 * says to check in Sioul rather than nothing, and Rust's answer, if it comes, 027 * replaces it. 028 */ 029public final class DoseReceiver extends BroadcastReceiver 030{ 031 private static final long DEADLINE_MS = 45_000; 032 private static final long FOREGROUND_DEADLINE_MS = 8_000; 033 034 @Override 035 public void onReceive(Context context, Intent intent) 036 { 037 final String action = intent.getAction(); 038 final String key = DoseAlarms.key(intent); 039 final boolean alarm = DoseAlarms.ALARM.equals(action); 040 if (key.isEmpty() || !(alarm || DoseAlarms.TAKEN.equals(action))) 041 return; 042 final Context app = context.getApplicationContext(); 043 final Asked asked = new Asked(goAsync()); 044 final Handler main = new Handler(Looper.getMainLooper()); 045 final Runnable deadline = () -> asked.letGo(alarm ? app : null, key); 046 final boolean foreground = (intent.getFlags() & Intent.FLAG_RECEIVER_FOREGROUND) != 0; 047 main.postDelayed(deadline, foreground ? FOREGROUND_DEADLINE_MS : DEADLINE_MS); 048 new Thread(() -> { 049 String answer = null; 050 try { 051 DoseAlarms.load(app); 052 answer = alarm ? DoseAlarms.nativeDecide(key) : DoseAlarms.nativeTaken(key); 053 } catch (Throwable e) { 054 // Sioul's library that does not load, among others: said all the same. 055 Log.e(DoseAlarms.TAG, "Doses: no answer about " + key, e); 056 } 057 main.removeCallbacks(deadline); 058 asked.answer(app, intent, key, alarm, answer); 059 }, "sioul-dose").start(); 060 } 061 062 /** One broadcast: answered in time, or let go at the deadline and answered after. */ 063 private static final class Asked 064 { 065 private final PendingResult broadcast; 066 private boolean late; 067 private boolean finished; 068 069 Asked(PendingResult broadcast) 070 { 071 this.broadcast = broadcast; 072 } 073 074 /** The deadline: an alarm not answered yet says to check in Sioul; the broadcast goes. */ 075 synchronized void letGo(Context alarm, String key) 076 { 077 if (finished) 078 return; 079 late = true; 080 if (alarm != null) 081 DoseAlarms.fallback(alarm, key); 082 finish(); 083 } 084 085 synchronized void answer(Context context, Intent intent, String key, boolean alarm, String answer) 086 { 087 try { 088 if (alarm) 089 decided(context, intent, key, answer, late); 090 else 091 taken(context, intent, key, answer); 092 } catch (RuntimeException e) { 093 Log.e(DoseAlarms.TAG, "Doses: the answer about " + key + " could not be shown", e); 094 } 095 finish(); 096 } 097 098 private void finish() 099 { 100 if (finished) 101 return; 102 finished = true; 103 broadcast.finish(); 104 } 105 } 106 107 /** 108 * Rust's decision at a dose's time: the reminder, as Rust says it; or 109 * nothing, maybe asked again later (news from another device awaited). 110 */ 111 private static void decided(Context context, Intent intent, String key, String answer, boolean late) 112 { 113 String name = text(intent.getStringExtra(DoseAlarms.NAME)); 114 JSONObject said = parse(answer); 115 if (said == null) { 116 DoseAlarms.fallback(context, key); 117 return; 118 } 119 if (said.optBoolean("show")) { 120 String title = said.optString("title"); 121 DoseAlarms.remind(context, key, title.isEmpty() ? "Sioul" : title, said.optString("body"), name, said.optString("taken")); 122 DoseAlarms.reminded(context, key); 123 } else if (late) { 124 // The reminder to check in Sioul, said for nothing. 125 DoseAlarms.dismiss(context, key); 126 } 127 long again = said.optLong("again_at"); 128 if (again > 0) 129 DoseAlarms.again(context, key, again, name); 130 } 131 132 /** 133 * Rust's answer to "Taken": recorded, said quietly; more than half an hour 134 * late, when it was taken is asked in Sioul. Else the reminder stays as it 135 * was, its button still there. 136 */ 137 private static void taken(Context context, Intent intent, String key, String answer) 138 { 139 String title = text(intent.getStringExtra(DoseAlarms.TITLE)); 140 String name = text(intent.getStringExtra(DoseAlarms.NAME)); 141 JSONObject said = parse(answer); 142 if (said != null && said.optBoolean("done")) 143 DoseAlarms.confirm(context, key, name.isEmpty() ? title : name, said.optString("line")); 144 else if (said != null && said.optBoolean("open")) 145 DoseAlarms.askWhen(context, key, title, said.optString("line")); 146 else 147 Log.w(DoseAlarms.TAG, "Doses: \"Taken\" not recorded for " + key + ": " + answer); 148 } 149 150 private static JSONObject parse(String answer) 151 { 152 if (answer == null) 153 return null; 154 try { 155 return new JSONObject(answer); 156 } catch (JSONException e) { 157 Log.e(DoseAlarms.TAG, "Doses: an answer that does not read: " + answer); 158 return null; 159 } 160 } 161 162 private static String text(String text) 163 { 164 return text == null ? "" : text; 165 } 166}