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.os.Build; 013import android.util.Log; 014 015import org.json.JSONException; 016import org.json.JSONObject; 017 018/** 019 * The time running, in Android's notifications while a focus session runs 020 * (docs/android.md, "The time running"): its task, a chronometer counting 021 * from its start, the time paused left out, and Pause (Go on, while paused) 022 * and Stop. Rust says what to show (crates/sioul-app/src/timenote.rs, through 023 * android/main.cpp) whenever the session changes, here or on another 024 * device; a button reaches Rust through TimeReceiver, Sioul's window running 025 * or not; a tap opens Sioul. An ongoing notification the app posts stays 026 * while Sioul is away, or stopped by Android: no foreground service, which 027 * would keep Sioul running all day for a notification that needs nothing 028 * running. 029 */ 030final class TimeNote 031{ 032 static final String PAUSE = "com.aurelienpierre.sioul.action.TIME_PAUSE"; 033 static final String RESUME = "com.aurelienpierre.sioul.action.TIME_RESUME"; 034 static final String STOP = "com.aurelienpierre.sioul.action.TIME_STOP"; 035 036 private static final String CHANNEL = "time"; 037 /** The one note, by its number alone: the doses' reminders are known by their key and 1. */ 038 private static final int NOTE = 2; 039 040 private TimeNote() {} 041 042 /** A button's action ("pause", "resume", "stop") done by Rust: the note it then showed, "" when none is left. */ 043 static native String nativeAction(String action); 044 045 /** 046 * Rust's note, JSON as timenote.rs writes it: put up, or changed in 047 * place; "" takes it away. Any thread. 048 */ 049 static synchronized void show(Context context, String json) 050 { 051 NotificationManager notifications = context.getSystemService(NotificationManager.class); 052 if (json == null || json.isEmpty()) { 053 notifications.cancel(NOTE); 054 return; 055 } 056 final JSONObject note; 057 try { 058 note = new JSONObject(json); 059 } catch (JSONException e) { 060 Log.e(DoseAlarms.TAG, "Time: a note that does not read; the one shown stays. " + e); 061 return; 062 } 063 // Low: in the shade and the status bar, without sound or banner. 064 notifications.createNotificationChannel(Channels.quiet(CHANNEL, note.optString("channel", "Focus timer"), NotificationManager.IMPORTANCE_LOW)); 065 boolean paused = note.optBoolean("paused"); 066 Notification.Builder built = new Notification.Builder(context, CHANNEL) 067 .setSmallIcon(R.drawable.sioul_notification) 068 .setColor(context.getColor(R.color.sioul_green)) 069 .setContentTitle(note.optString("title")) 070 .setContentText(note.optString("text")) 071 .setCategory(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? Notification.CATEGORY_STOPWATCH : Notification.CATEGORY_STATUS) 072 .setOngoing(true) 073 .setOnlyAlertOnce(true) 074 .setContentIntent(open(context)) 075 .addAction(button(context, paused ? RESUME : PAUSE, note.optString("pause"))) 076 .addAction(button(context, STOP, note.optString("stop"))); 077 // Running, a chronometer from the start, the time paused left out; paused, none. 078 if (paused) 079 built.setShowWhen(false).setUsesChronometer(false); 080 else 081 built.setWhen(note.optLong("since")).setShowWhen(true).setUsesChronometer(true); 082 notifications.notify(NOTE, built.build()); 083 } 084 085 /** A button: its broadcast to TimeReceiver, by no other app. */ 086 private static Notification.Action button(Context context, String action, String label) 087 { 088 PendingIntent press = PendingIntent.getBroadcast(context, action.hashCode(), new Intent(context, TimeReceiver.class).setAction(action), 089 PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); 090 return new Notification.Action.Builder(Icon.createWithResource(context, R.drawable.sioul_notification), label, press).build(); 091 } 092 093 /** A tap: Sioul, as by its icon. */ 094 private static PendingIntent open(Context context) 095 { 096 Intent sioul = context.getPackageManager().getLaunchIntentForPackage(context.getPackageName()); 097 if (sioul == null) 098 return null; 099 return PendingIntent.getActivity(context, NOTE, sioul.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED), 100 PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); 101 } 102}