001// SPDX-License-Identifier: GPL-3.0-or-later
002// Copyright © 2026 Aurélien Pierre
003
004package com.aurelienpierre.sioul;
005
006import android.content.ActivityNotFoundException;
007import android.content.Context;
008import android.content.Intent;
009import android.database.Cursor;
010import android.net.Uri;
011import android.provider.DocumentsContract;
012import android.provider.OpenableColumns;
013import android.util.Log;
014import android.webkit.MimeTypeMap;
015
016import org.json.JSONException;
017import org.json.JSONObject;
018
019import java.io.ByteArrayOutputStream;
020import java.io.File;
021import java.io.FileInputStream;
022import java.io.IOException;
023import java.io.InputStream;
024import java.io.OutputStream;
025import java.nio.charset.StandardCharsets;
026import java.util.Locale;
027
028/**
029 * A mail attachment opened in the app you choose, or saved where you choose
030 * (docs/client.md, "Antivirus"; crates/sioul-app/src/attachments.rs). Rust
031 * writes the attachment in Sioul's cache,
032 * {@code cache/sioul/attachments/<message>/<name>}, after Sioul's own
033 * checks: mail set aside keeps its attachments closed, and programs and
034 * installers are never opened. A phone has no antivirus Sioul can call, and
035 * nothing here claims one.
036 *
037 * <p>Since Android 7 a file goes to another app by a {@code content://}
038 * address, never by its path. Here it is an address of Qt's FileProvider
039 * (authority {@code <package>.qtprovider}), whose root "attachments"
040 * (res/xml/qtprovider_paths.xml) is that folder of the cache alone. Android
041 * lends the address, to read only, to the one app that opens it: that app
042 * reads this file and nothing else of Sioul's. When no app is set for its
043 * type, Android asks which one ("Open with").
044 *
045 * <p>Saving asks Android where ({@code ACTION_CREATE_DOCUMENT}, in
046 * AttachmentSave), then copies the file there; the copy in the cache goes,
047 * saved or not.
048 *
049 * <p>The pure parts (the address, the type, what an app is handed, what
050 * Android is asked) are checked on a computer: android/jvm-checks,
051 * AttachmentsCheck.
052 */
053public final class Attachments
054{
055    static final String TAG = DoseAlarms.TAG;
056    /** Qt's FileProvider, the one Sioul declares: its authority, after the package's name. */
057    static final String AUTHORITY = ".qtprovider";
058    /** The provider's root for attachments, by its name in res/xml/qtprovider_paths.xml. */
059    static final String ROOT = "attachments";
060    /** That root's folder, in the app's cache, where Rust writes attachments (XDG_CACHE_HOME/sioul/attachments). */
061    static final String FOLDER = "sioul/attachments";
062    /** Android's installer's type: never handed to open, whatever the name says. */
063    static final String INSTALLER = "application/vnd.android.package-archive";
064    /** A type nothing more is known of. */
065    static final String BYTES = "application/octet-stream";
066    /**
067     * A file handed to open: to read, and nothing more, in a task of its own
068     * (Rust asks from Sioul's application, not from its window).
069     */
070    static final int VIEW_FLAGS = Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK;
071
072    private static final char[] HEX = "0123456789ABCDEF".toCharArray();
073
074    private Attachments()
075    {
076    }
077
078    /** What another app is handed to open a file: an action, an address, a type, flags. */
079    static final class View
080    {
081        final String action;
082        final String uri;
083        final String type;
084        final int flags;
085
086        View(String action, String uri, String type, int flags)
087        {
088            this.action = action;
089            this.uri = uri;
090            this.type = type;
091            this.flags = flags;
092        }
093    }
094
095    /** Android's question "where?" for a file to save: an action, a category, a type, the name offered. */
096    static final class SaveAs
097    {
098        final String action;
099        final String category;
100        final String type;
101        final String title;
102
103        SaveAs(String action, String category, String type, String title)
104        {
105            this.action = action;
106            this.category = category;
107            this.type = type;
108            this.title = title;
109        }
110    }
111
112    /**
113     * A part of an address as Android's Uri.encode writes it, as FileProvider
114     * does: letters, digits and {@code _-!.~'()*} kept, every other byte of
115     * its UTF-8 as {@code %XX}.
116     */
117    static String encode(String text)
118    {
119        StringBuilder out = new StringBuilder();
120        for (byte b : text.getBytes(StandardCharsets.UTF_8)) {
121            int c = b & 0xff;
122            if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || "_-!.~'()*".indexOf(c) >= 0)
123                out.append((char) c);
124            else
125                out.append('%').append(HEX[c >> 4]).append(HEX[c & 15]);
126        }
127        return out.toString();
128    }
129
130    /** Uri.decode's way back: each {@code %XX} a byte, the bytes read as UTF-8. */
131    static String decode(String text)
132    {
133        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
134        int i = 0;
135        while (i < text.length()) {
136            int high = i + 2 < text.length() ? Character.digit(text.charAt(i + 1), 16) : -1;
137            int low = i + 2 < text.length() ? Character.digit(text.charAt(i + 2), 16) : -1;
138            if (text.charAt(i) == '%' && high >= 0 && low >= 0) {
139                bytes.write(high * 16 + low);
140                i += 3;
141            } else {
142                int point = text.codePointAt(i);
143                byte[] own = new String(Character.toChars(point)).getBytes(StandardCharsets.UTF_8);
144                bytes.write(own, 0, own.length);
145                i += Character.charCount(point);
146            }
147        }
148        return new String(bytes.toByteArray(), StandardCharsets.UTF_8);
149    }
150
151    /**
152     * The address FileProvider gives {@code file} in the root "attachments",
153     * whose folder is {@code folder} (both canonical paths, as FileProvider
154     * compares them): {@code content://<package>.qtprovider/attachments/<the
155     * rest, encoded part by part>}. Null for a file outside the folder, the
156     * folder itself, or a path with an empty, "." or ".." part.
157     */
158    static String contentUri(String packageName, String folder, String file)
159    {
160        return contentUri(packageName, folder, file, ROOT);
161    }
162
163    /**
164     * The address FileProvider gives {@code file} in its root {@code root}
165     * (res/xml/qtprovider_paths.xml), whose folder is {@code folder}, as
166     * {@link #contentUri(String, String, String)} says it for the
167     * attachments: a multimedia message's file, for Android to read it
168     * (TextSend.sendMms, root "files_path").
169     */
170    static String contentUri(String packageName, String folder, String file, String root)
171    {
172        if (packageName == null || packageName.isEmpty() || folder == null || folder.isEmpty() || file == null || root == null || root.isEmpty())
173            return null;
174        String within = folder.endsWith("/") ? folder : folder + "/";
175        if (!file.startsWith(within) || file.length() == within.length())
176            return null;
177        StringBuilder path = new StringBuilder();
178        for (String part : file.substring(within.length()).split("/", -1)) {
179            if (part.isEmpty() || part.equals(".") || part.equals(".."))
180                return null;
181            if (path.length() > 0)
182                path.append('/');
183            path.append(encode(part));
184        }
185        return "content://" + packageName + AUTHORITY + "/" + encode(root) + "/" + path;
186    }
187
188    /**
189     * FileProvider's way back (its getFileForUri), for the checks: the file
190     * an address names in the root "attachments", whose folder is
191     * {@code folder}; null when it names no file there.
192     */
193    static String fileOf(String packageName, String folder, String uri)
194    {
195        String start = "content://" + packageName + AUTHORITY + "/";
196        if (uri == null || !uri.startsWith(start))
197            return null;
198        String path = uri.substring(start.length());
199        int split = path.indexOf('/');
200        if (split <= 0 || !decode(path.substring(0, split)).equals(ROOT))
201            return null;
202        String inside = decode(path.substring(split + 1));
203        for (String part : inside.split("/", -1)) {
204            if (part.isEmpty() || part.equals(".") || part.equals(".."))
205                return null;
206        }
207        return (folder.endsWith("/") ? folder : folder + "/") + inside;
208    }
209
210    /**
211     * The type an app is asked to open: Rust's, from the name's extension
212     * (sioul_core::compose::mime_of), else Android's own for that extension,
213     * else plain bytes; in lower case. Null for Android's installer, whatever
214     * gave it: an installer is never handed to open.
215     */
216    static String type(String given, String android)
217    {
218        String type = given == null || given.isEmpty() || given.equalsIgnoreCase(BYTES) ? android : given;
219        if (type == null || type.isEmpty())
220            type = BYTES;
221        type = type.toLowerCase(Locale.ROOT);
222        return type.equals(INSTALLER) ? null : type;
223    }
224
225    /**
226     * What is handed to open {@code file} (a canonical path) of type
227     * {@code type} (from type()): ACTION_VIEW, its content address, the type,
228     * read only; null when the file is not in the attachments' folder, or for
229     * an installer.
230     */
231    static View view(String packageName, String folder, String file, String type)
232    {
233        String uri = contentUri(packageName, folder, file);
234        if (uri == null || type == null || type.equals(INSTALLER))
235            return null;
236        return new View(Intent.ACTION_VIEW, uri, type, VIEW_FLAGS);
237    }
238
239    /**
240     * What Android is asked to save a file: ACTION_CREATE_DOCUMENT, among
241     * the files that open (CATEGORY_OPENABLE), of its type (plain bytes when
242     * none), under its name (the attachment's when none).
243     */
244    static SaveAs saveAs(String name, String type)
245    {
246        String title = name == null || name.isEmpty() ? "attachment" : name;
247        String kind = type == null || type.isEmpty() ? BYTES : type.toLowerCase(Locale.ROOT);
248        return new SaveAs(Intent.ACTION_CREATE_DOCUMENT, Intent.CATEGORY_OPENABLE, kind, title);
249    }
250
251    // ---------------------------------------------------------------- on the phone
252
253    /**
254     * Rust's question ({@link StepService#call}, verbs "attachment-…"):
255     * "attachment-open" {path, type}, answered {opened} or {problem};
256     * "attachment-save" {path, name, type}, answered {asked} or {problem},
257     * then AttachmentSave tells Rust what became of it.
258     */
259    static String call(Context context, String verb, JSONObject asked)
260    {
261        switch (verb) {
262        case "attachment-open":
263            return open(context, asked.optString("path", ""), asked.optString("type", "")).toString();
264        case "attachment-save":
265            return save(context, asked.optString("path", ""), asked.optString("name", ""), asked.optString("type", "")).toString();
266        default:
267            return null;
268        }
269    }
270
271    /** A JSON answer from pairs of names and values; what cannot be written is left out. */
272    static JSONObject said(Object... pairs)
273    {
274        JSONObject said = new JSONObject();
275        for (int i = 0; i + 1 < pairs.length; i += 2) {
276            try {
277                said.put((String) pairs[i], pairs[i + 1]);
278            } catch (JSONException e) {
279                // A name or a value JSON cannot hold: left out.
280            }
281        }
282        return said;
283    }
284
285    /** Android's own type for a file's extension; null when it has none. */
286    private static String androidType(String file)
287    {
288        int dot = file.lastIndexOf('.');
289        if (dot < 0 || dot < file.lastIndexOf('/'))
290            return null;
291        return MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.substring(dot + 1).toLowerCase(Locale.ROOT));
292    }
293
294    /** The attachments' folder in this app's cache, as FileProvider reads it (canonical). */
295    private static String folder(Context context) throws IOException
296    {
297        return new File(context.getCacheDir(), FOLDER).getCanonicalPath();
298    }
299
300    /**
301     * The attachment at {@code path} handed to the app you choose:
302     * {opened: true}, else {problem: "refused" | "gone" | "no-app" | "failed"}.
303     * Nothing of the file's name goes to the log.
304     */
305    static JSONObject open(Context context, String path, String given)
306    {
307        try {
308            String file = new File(path).getCanonicalPath();
309            View view = view(context.getPackageName(), folder(context), file, type(given, androidType(file)));
310            if (view == null)
311                return said("problem", "refused");
312            if (!new File(file).isFile())
313                return said("problem", "gone");
314            context.startActivity(new Intent(view.action).setDataAndType(Uri.parse(view.uri), view.type).addFlags(view.flags));
315            Log.i(TAG, "Attachments: one handed to open, as " + view.type + ".");
316            return said("opened", true);
317        } catch (ActivityNotFoundException e) {
318            Log.i(TAG, "Attachments: no app opens this type.");
319            return said("problem", "no-app");
320        } catch (IOException | RuntimeException e) {
321            Log.w(TAG, "Attachments: not handed to open: " + e.getClass().getSimpleName());
322            return said("problem", "failed", "detail", e.getClass().getSimpleName());
323        }
324    }
325
326    /**
327     * Android asked where to save the attachment at {@code path}
328     * (AttachmentSave, never shown itself): {asked: true}, else
329     * {problem: "refused" | "gone" | "failed"}.
330     */
331    static JSONObject save(Context context, String path, String name, String type)
332    {
333        try {
334            String file = new File(path).getCanonicalPath();
335            if (contentUri(context.getPackageName(), folder(context), file) == null)
336                return said("problem", "refused");
337            if (!new File(file).isFile())
338                return said("problem", "gone");
339            context.startActivity(new Intent(context, AttachmentSave.class).putExtra(AttachmentSave.PATH, file)
340                .putExtra(AttachmentSave.NAME, name).putExtra(AttachmentSave.TYPE, type).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
341            return said("asked", true);
342        } catch (IOException | RuntimeException e) {
343            Log.w(TAG, "Attachments: Android's question where to save not asked: " + e.getClass().getSimpleName());
344            return said("problem", "failed", "detail", e.getClass().getSimpleName());
345        }
346    }
347
348    /**
349     * The attachment at {@code path} copied where Android's question
350     * answered ({@code target}, null when you closed it), then taken out of
351     * the cache, saved or not: {path, name, saved, shown (the name it has
352     * there)} or {path, name, saved: false, problem}. A copy that fails
353     * midway is removed there. On a thread of its own.
354     */
355    static JSONObject write(Context context, String path, Uri target, String name)
356    {
357        File source = path == null || path.isEmpty() ? null : new File(path);
358        try {
359            if (target == null)
360                return said("path", path, "name", name, "saved", false);
361            if (source == null || !source.isFile())
362                return said("path", path, "name", name, "saved", false, "problem", "gone");
363            try (InputStream in = new FileInputStream(source); OutputStream out = context.getContentResolver().openOutputStream(target, "w")) {
364                if (out == null)
365                    throw new IOException("no stream");
366                byte[] buffer = new byte[64 * 1024];
367                for (int n = in.read(buffer); n > 0; n = in.read(buffer))
368                    out.write(buffer, 0, n);
369            }
370            return said("path", path, "name", name, "saved", true, "shown", shownName(context, target, name));
371        } catch (IOException | RuntimeException e) {
372            Log.w(TAG, "Attachments: not saved: " + e.getClass().getSimpleName());
373            try {
374                DocumentsContract.deleteDocument(context.getContentResolver(), target);
375            } catch (Exception gone) {
376                // Nothing written there, or the place keeps it: left.
377            }
378            return said("path", path, "name", name, "saved", false, "problem", e.getClass().getSimpleName());
379        } finally {
380            if (source != null && !source.delete() && source.exists())
381                Log.w(TAG, "Attachments: the copy in the cache stays until tomorrow.");
382        }
383    }
384
385    /** The name the saved file has where it went (Android may add " (1)"); {@code name} when it does not say. */
386    private static String shownName(Context context, Uri target, String name)
387    {
388        try (Cursor row = context.getContentResolver().query(target, new String[] {OpenableColumns.DISPLAY_NAME}, null, null, null)) {
389            if (row != null && row.moveToFirst() && !row.isNull(0))
390                return row.getString(0);
391        } catch (RuntimeException e) {
392            // The place does not say: the name asked.
393        }
394        return name;
395    }
396}