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}