001// SPDX-License-Identifier: GPL-3.0-or-later
002// Copyright © 2026 Aurélien Pierre
003
004package com.aurelienpierre.sioul;
005
006import android.content.ComponentName;
007import android.content.Context;
008import android.content.Intent;
009import android.content.SharedPreferences;
010import android.content.pm.ShortcutInfo;
011import android.content.pm.ShortcutManager;
012import android.graphics.drawable.Icon;
013import android.net.Uri;
014import android.os.Build;
015import android.util.Log;
016
017import org.json.JSONArray;
018import org.json.JSONException;
019import org.json.JSONObject;
020
021import java.util.ArrayList;
022import java.util.Collections;
023import java.util.HashSet;
024import java.util.List;
025import java.util.Set;
026
027/**
028 * Sioul's addresses that can send, published to Android (docs/android.md,
029 * "Sharing"): one shortcut each, which the share sheet offers as a direct
030 * target from Android 10 (its {@code <share-target>} in res/xml/sioul_shortcuts.xml
031 * matches their category), and a long press on Sioul's icon as "Write from
032 * you@example.org". Chosen, ShareActivity makes the draft from that address.
033 *
034 * Rust gives the list whenever it changes (crates/sioul-app/src/outside.rs,
035 * android/main.cpp): JSON {accounts: [{id, short, long}], gone}, the most
036 * used first. Only Sioul's own "mail:" shortcuts are touched; the "Pause"
037 * one (the manifest's) stays. Only the address shows in them: nothing else
038 * of the account, and nothing is sent until you press Send.
039 */
040final class MailShortcuts
041{
042    /** The share sheet's category for them, as res/xml/sioul_shortcuts.xml names it. */
043    static final String CATEGORY = "com.aurelienpierre.sioul.category.MAIL";
044    private static final String KEPT = "sioul-mail-shortcuts";
045    private static final String GIVEN = "given";
046
047    private MailShortcuts() {}
048
049    /** Rust's list, published when it differs from the last one given. Any thread. */
050    static synchronized void set(Context context, String json)
051    {
052        ShortcutManager manager = context.getSystemService(ShortcutManager.class);
053        if (manager == null)
054            return;
055        SharedPreferences kept = context.getSharedPreferences(KEPT, Context.MODE_PRIVATE);
056        if (json.equals(kept.getString(GIVEN, "")) && !missing(manager, json))
057            return;
058        final JSONArray accounts;
059        final String gone;
060        try {
061            JSONObject list = new JSONObject(json);
062            accounts = list.optJSONArray("accounts") == null ? new JSONArray() : list.getJSONArray("accounts");
063            gone = list.optString("gone", "");
064        } catch (JSONException e) {
065            Log.e(DoseAlarms.TAG, "Sharing: the list of addresses does not read; the shortcuts stay as they were. " + e);
066            return;
067        }
068        // Never more than Android takes: its limit per activity, less the manifest's ("Pause").
069        int room = Math.max(0, manager.getMaxShortcutCountPerActivity() - manager.getManifestShortcuts().size());
070        List<ShortcutInfo> made = new ArrayList<>();
071        Set<String> ids = new HashSet<>();
072        for (int i = 0; i < accounts.length() && made.size() < room; i++) {
073            JSONObject account = accounts.optJSONObject(i);
074            String id = account == null ? "" : account.optString("id", "");
075            if (id.isEmpty() || !ids.add(ShareActivity.PREFIX + id))
076                continue;
077            made.add(shortcut(context, id, account.optString("short", id), account.optString("long", ""), made.size()));
078        }
079        try {
080            if (manager.isRateLimitingActive()) {
081                Log.i(DoseAlarms.TAG, "Sharing: Android asks to wait before the addresses change; next time.");
082                return;
083            }
084            // Those of addresses gone: off the share sheet and the long press;
085            // one pinned to the home screen says the address is no longer in Sioul.
086            List<String> stale = new ArrayList<>();
087            for (ShortcutInfo old : manager.getDynamicShortcuts())
088                if (old.getId().startsWith(ShareActivity.PREFIX) && !ids.contains(old.getId()))
089                    stale.add(old.getId());
090            if (!stale.isEmpty()) {
091                manager.removeDynamicShortcuts(stale);
092                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
093                    manager.removeLongLivedShortcuts(stale);
094            }
095            List<String> pinnedGone = new ArrayList<>();
096            List<String> pinnedBack = new ArrayList<>();
097            for (ShortcutInfo pinned : manager.getPinnedShortcuts()) {
098                if (!pinned.getId().startsWith(ShareActivity.PREFIX))
099                    continue;
100                if (!ids.contains(pinned.getId()))
101                    pinnedGone.add(pinned.getId());
102                else if (!pinned.isEnabled())
103                    pinnedBack.add(pinned.getId());
104            }
105            if (!pinnedGone.isEmpty())
106                manager.disableShortcuts(pinnedGone, gone);
107            if (!pinnedBack.isEmpty())
108                manager.enableShortcuts(pinnedBack);
109            // Those kept, renamed or new: made again with their rank.
110            List<ShortcutInfo> known = new ArrayList<>();
111            List<ShortcutInfo> fresh = new ArrayList<>();
112            Set<String> dynamic = new HashSet<>();
113            for (ShortcutInfo old : manager.getDynamicShortcuts())
114                dynamic.add(old.getId());
115            for (ShortcutInfo one : made)
116                (dynamic.contains(one.getId()) ? known : fresh).add(one);
117            if (!known.isEmpty())
118                manager.updateShortcuts(known);
119            if (!fresh.isEmpty())
120                manager.addDynamicShortcuts(fresh);
121            kept.edit().putString(GIVEN, json).apply();
122            Log.i(DoseAlarms.TAG, "Sharing: " + made.size() + " addresses offered in the share sheet.");
123        } catch (IllegalArgumentException | IllegalStateException e) {
124            Log.e(DoseAlarms.TAG, "Sharing: the addresses could not be offered: " + e);
125        }
126    }
127
128    /** Whether shortcuts given before are gone (Sioul's data cleared, a restore): then given again. */
129    private static boolean missing(ShortcutManager manager, String json)
130    {
131        try {
132            JSONArray accounts = new JSONObject(json).optJSONArray("accounts");
133            int wanted = accounts == null ? 0 : Math.min(accounts.length(), Math.max(0, manager.getMaxShortcutCountPerActivity() - manager.getManifestShortcuts().size()));
134            int have = 0;
135            for (ShortcutInfo one : manager.getDynamicShortcuts())
136                if (one.getId().startsWith(ShareActivity.PREFIX))
137                    have++;
138            return have != wanted;
139        } catch (JSONException e) {
140            return true;
141        }
142    }
143
144    /**
145     * One address's shortcut: under its icon in the share sheet, the address;
146     * at a long press on Sioul's icon, "Write from …", which opens a new
147     * message from it (ShareActivity, as a mailto: link would).
148     */
149    private static ShortcutInfo shortcut(Context context, String id, String label, String longLabel, int rank)
150    {
151        Intent write = new Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:"))
152            .setComponent(new ComponentName(context, ShareActivity.class))
153            .putExtra(ShareActivity.ACCOUNT, id);
154        ShortcutInfo.Builder builder = new ShortcutInfo.Builder(context, ShareActivity.PREFIX + id)
155            .setShortLabel(label.isEmpty() ? id : label)
156            .setIcon(Icon.createWithResource(context, R.mipmap.sioul_mail_shortcut))
157            .setIntent(write)
158            .setCategories(Collections.singleton(CATEGORY))
159            .setRank(rank);
160        if (!longLabel.isEmpty())
161            builder.setLongLabel(longLabel);
162        // Kept by the share sheet between its showings (Android 11 and later).
163        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
164            builder.setLongLived(true);
165        return builder.build();
166    }
167}