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}