001// SPDX-License-Identifier: GPL-3.0-or-later
002// Copyright © 2026 Aurélien Pierre
003
004package com.aurelienpierre.sioul;
005
006import android.app.Activity;
007import android.content.ContentResolver;
008import android.content.Context;
009import android.content.Intent;
010import android.content.pm.ProviderInfo;
011import android.content.res.AssetFileDescriptor;
012import android.database.Cursor;
013import android.net.Uri;
014import android.os.Bundle;
015import android.os.Parcelable;
016import android.os.Process;
017import android.provider.OpenableColumns;
018import android.content.pm.ShortcutManager;
019import android.util.Log;
020import android.webkit.MimeTypeMap;
021import android.widget.Toast;
022
023import org.json.JSONArray;
024import org.json.JSONException;
025import org.json.JSONObject;
026
027import java.io.File;
028import java.io.FileNotFoundException;
029import java.io.FileOutputStream;
030import java.io.IOException;
031import java.io.InputStream;
032import java.io.OutputStream;
033import java.nio.charset.StandardCharsets;
034import java.util.ArrayList;
035import java.util.Collection;
036import java.util.HashSet;
037import java.util.LinkedHashSet;
038import java.util.List;
039import java.util.Locale;
040import java.util.Set;
041
042/**
043 * What other apps hand Sioul to write (docs/android.md, "Sharing"): files and
044 * text shared to it, or to one of your addresses straight from the share
045 * sheet (MailShortcuts), and mailto: links. Each becomes a request in Sioul's
046 * data folder ({@code handed/incoming/<id>.json}), which Rust makes a draft of as
047 * its window comes up (crates/sioul-app/src/outside.rs); Sioul's window is
048 * brought up at once.
049 *
050 * Shared files come as content:// addresses that Sioul may read only while
051 * this activity lives. They are opened on a thread of their own (a cloud's
052 * app may fetch a file before it opens), then this activity ends, and the copy
053 * goes on from the open files into {@code handed/files/<id>/}, under their own names,
054 * while you write. When all are copied, {@code handed/incoming/<id>.done.json} says
055 * where, and Rust is told (nativeHanded) to attach them. Never shown itself.
056 *
057 * Not taken: a file named by its place (file://), and Sioul's own files,
058 * which any app could otherwise ask Sioul to attach. Nothing is sent until
059 * you press Send.
060 */
061public final class ShareActivity extends Activity
062{
063    /** The account a launcher's shortcut writes from (MailShortcuts). */
064    static final String ACCOUNT = "com.aurelienpierre.sioul.extra.ACCOUNT";
065    /** Sioul's shortcuts for your addresses: "mail:" and the account's id. */
066    static final String PREFIX = "mail:";
067    /** More files than this in one share are not taken. */
068    private static final int MOST = 100;
069    /** Names kept under this many bytes: Android's file names hold 255. */
070    private static final int LONGEST = 200;
071
072    /** Rust told that something was handed (android/main.cpp, outside.rs). */
073    static native void nativeHanded();
074
075    /** A shared file, open, or why not. */
076    private static final class Opened
077    {
078        final String name;
079        final String type;
080        AssetFileDescriptor file;
081        String error;
082
083        Opened(String name, String type)
084        {
085            this.name = name;
086            this.type = type;
087        }
088    }
089
090    @Override
091    protected void onCreate(Bundle state)
092    {
093        super.onCreate(state);
094        // Made again (the manifest keeps it through a turn of the screen): the
095        // share was taken the first time.
096        if (state != null) {
097            finish();
098            return;
099        }
100        final Context app = getApplicationContext();
101        final Intent intent = getIntent();
102        final File handed = handed(app);
103        final String id = Long.toHexString(System.currentTimeMillis()) + "-" + Long.toHexString(System.nanoTime() & 0xffffffL) + "-" + Integer.toHexString(Process.myPid());
104        final List<Uri> files = new ArrayList<>();
105        final JSONArray refused = new JSONArray();
106        try {
107            for (Uri uri : streams(intent)) {
108                if (files.size() < MOST && taken(uri))
109                    files.add(uri);
110                else
111                    refused.put(lastPart(uri));
112            }
113            JSONObject request = request(intent, id, files.size(), refused);
114            write(new File(handed, "incoming/" + id + ".json"), request.toString());
115        } catch (JSONException | IOException | RuntimeException e) {
116            Log.e(DoseAlarms.TAG, "Sharing: what was shared could not be kept for Sioul: " + e);
117            Toast.makeText(app, failed(e), Toast.LENGTH_LONG).show();
118            finish();
119            return;
120        }
121        used(app, intent);
122        open();
123        if (files.isEmpty()) {
124            // Sioul's window, already up (split screen), takes it now; else when it comes up.
125            new Thread(ShareActivity::tell, "sioul-share").start();
126            finish();
127            return;
128        }
129        new Thread(() -> copy(app, handed, id, files), "sioul-share").start();
130    }
131
132    /**
133     * Where Rust takes what is handed (sioul_core::handed): XDG_DATA_HOME/sioul/handed
134     * (android/main.cpp). Not the drafts' folder: the sharing carries that one to
135     * your other devices, and a request is this phone's.
136     */
137    static File handed(Context context)
138    {
139        return new File(context.getFilesDir(), "data/sioul/handed");
140    }
141
142    /**
143     * "Sioul could not take what you shared: …", in the phone's language,
144     * as {@link DoseAlarms#words} picks it: Rust's words are not loaded yet.
145     */
146    private static String failed(Exception e)
147    {
148        String why = why(e);
149        boolean french = "fr".equals(Locale.getDefault().getLanguage());
150        return french ? "Sioul n\u2019a pas pu prendre ce que vous avez partag\u00e9\u202f: " + why : "Sioul could not take what you shared: " + why;
151    }
152
153    /** Sioul's window brought up, as by its icon. */
154    private void open()
155    {
156        Intent sioul = getPackageManager().getLaunchIntentForPackage(getPackageName());
157        if (sioul != null)
158            startActivity(sioul.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED));
159    }
160
161    /** Rust told, when Sioul's library is loaded (its window up); else it takes the request as the window comes up. */
162    private static void tell()
163    {
164        try {
165            nativeHanded();
166        } catch (UnsatisfiedLinkError e) {
167            // Not loaded yet: the window's start takes it.
168        }
169    }
170
171    /** An address of yours chosen in the share sheet: Android ranks it higher next time. */
172    private static void used(Context app, Intent intent)
173    {
174        try {
175            String shortcut = intent.getStringExtra(Intent.EXTRA_SHORTCUT_ID);
176            if (shortcut == null || !shortcut.startsWith(PREFIX))
177                return;
178            ShortcutManager manager = app.getSystemService(ShortcutManager.class);
179            if (manager != null)
180                manager.reportShortcutUsed(shortcut);
181        } catch (RuntimeException e) {
182            // Only a ranking lost.
183        }
184    }
185
186    // ---------------------------------------------------------------- the request
187
188    /** What Rust reads (sioul_core::handed::Handed). */
189    private static JSONObject request(Intent intent, String id, int files, JSONArray refused) throws JSONException
190    {
191        JSONObject request = new JSONObject();
192        request.put("id", id);
193        request.put("account", account(intent));
194        Uri data = intent.getData();
195        if (data != null && "mailto".equalsIgnoreCase(data.getScheme()))
196            request.put("mailto", data.toString());
197        Bundle extras = intent.getExtras();
198        request.put("to", strings(extras, Intent.EXTRA_EMAIL));
199        request.put("cc", strings(extras, Intent.EXTRA_CC));
200        request.put("bcc", strings(extras, Intent.EXTRA_BCC));
201        request.put("subject", text(extras, Intent.EXTRA_SUBJECT));
202        request.put("text", text(extras, Intent.EXTRA_TEXT));
203        request.put("files", files);
204        request.put("copier", Process.myPid());
205        request.put("refused", refused);
206        return request;
207    }
208
209    /** The account chosen: an address of yours in the share sheet, or a launcher's shortcut; "" for Sioul's usual one. */
210    private static String account(Intent intent)
211    {
212        String shortcut = intent.getStringExtra(Intent.EXTRA_SHORTCUT_ID);
213        if (shortcut != null && shortcut.startsWith(PREFIX))
214            return shortcut.substring(PREFIX.length());
215        String chosen = intent.getStringExtra(ACCOUNT);
216        return chosen == null ? "" : chosen;
217    }
218
219    /** Addresses, however the app gave them: one, an array, a list. */
220    @SuppressWarnings("deprecation")
221    private static JSONArray strings(Bundle extras, String key)
222    {
223        JSONArray out = new JSONArray();
224        Object value = extras == null ? null : extras.get(key);
225        if (value instanceof CharSequence) {
226            out.put(value.toString());
227        } else if (value instanceof String[]) {
228            for (String one : (String[]) value) {
229                if (one != null)
230                    out.put(one);
231            }
232        } else if (value instanceof Collection) {
233            for (Object one : (Collection<?>) value) {
234                if (one != null)
235                    out.put(one.toString());
236            }
237        }
238        return out;
239    }
240
241    /** A text, however the app gave it: a string, styled text, a list of them (one per paragraph). */
242    @SuppressWarnings("deprecation")
243    private static String text(Bundle extras, String key)
244    {
245        Object value = extras == null ? null : extras.get(key);
246        if (value instanceof CharSequence)
247            return value.toString();
248        if (value instanceof Collection) {
249            StringBuilder joined = new StringBuilder();
250            for (Object one : (Collection<?>) value) {
251                if (one == null)
252                    continue;
253                if (joined.length() > 0)
254                    joined.append("\n\n");
255                joined.append(one);
256            }
257            return joined.toString();
258        }
259        return "";
260    }
261
262    // ---------------------------------------------------------------- the files
263
264    /**
265     * The files shared: EXTRA_STREAM, one or a list, each once. Not the
266     * clip's addresses: beside a text, an app may put there the picture the
267     * share sheet shows, which is no attachment.
268     */
269    @SuppressWarnings("deprecation")
270    private static List<Uri> streams(Intent intent)
271    {
272        Set<Uri> found = new LinkedHashSet<>();
273        String action = intent.getAction();
274        if (Intent.ACTION_SEND.equals(action)) {
275            Object one = intent.getParcelableExtra(Intent.EXTRA_STREAM);
276            if (one instanceof Uri)
277                found.add((Uri) one);
278        } else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) {
279            ArrayList<Parcelable> many = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
280            if (many != null) {
281                for (Object one : many) {
282                    if (one instanceof Uri)
283                        found.add((Uri) one);
284                }
285            }
286        }
287        return new ArrayList<>(found);
288    }
289
290    /**
291     * Whether a shared address is taken: a content:// one, of another app's
292     * provider. Not a file:// one, nor one of Sioul's own providers: any app
293     * could name Sioul's private files that way, to have them attached.
294     */
295    private boolean taken(Uri uri)
296    {
297        if (!ContentResolver.SCHEME_CONTENT.equals(uri.getScheme()) || uri.getAuthority() == null)
298            return false;
299        ProviderInfo provider = getPackageManager().resolveContentProvider(uri.getAuthority(), 0);
300        return provider == null || !getPackageName().equals(provider.packageName);
301    }
302
303    private static String lastPart(Uri uri)
304    {
305        String last = uri.getLastPathSegment();
306        return last == null || last.isEmpty() ? "?" : last;
307    }
308
309    /**
310     * On a thread of its own: each file opened, then this activity let go (its
311     * permission with it: the files stay open), then each copied, then Rust told.
312     */
313    private void copy(Context app, File handed, String id, List<Uri> uris)
314    {
315        ContentResolver resolver = app.getContentResolver();
316        List<Opened> opened = new ArrayList<>();
317        for (Uri uri : uris) {
318            String type = null;
319            try {
320                type = resolver.getType(uri);
321            } catch (RuntimeException e) {
322                // No type said: the name's extension decides.
323            }
324            Opened file = new Opened(name(resolver, uri), type);
325            try {
326                file.file = resolver.openAssetFileDescriptor(uri, "r");
327                if (file.file == null)
328                    file.error = "no file";
329            } catch (FileNotFoundException | RuntimeException e) {
330                file.error = why(e);
331            }
332            opened.add(file);
333        }
334        runOnUiThread(this::finish);
335        File folder = new File(handed, "files/" + id);
336        JSONArray done = new JSONArray();
337        Set<String> names = new HashSet<>();
338        for (Opened file : opened) {
339            JSONObject said = new JSONObject();
340            try {
341                said.put("name", file.name);
342                if (file.error == null) {
343                    String name = unique(names, cleaned(file.name, file.type));
344                    File copy = new File(folder, name);
345                    try {
346                        store(file.file, folder, copy);
347                        said.put("name", name);
348                        said.put("path", copy.getAbsolutePath());
349                    } catch (IOException | RuntimeException e) {
350                        said.put("error", why(e));
351                    }
352                } else {
353                    said.put("error", file.error);
354                }
355            } catch (JSONException e) {
356                Log.e(DoseAlarms.TAG, "Sharing: " + e);
357            }
358            done.put(said);
359        }
360        try {
361            write(new File(handed, "incoming/" + id + ".done.json"), new JSONObject().put("files", done).toString());
362        } catch (JSONException | IOException e) {
363            Log.e(DoseAlarms.TAG, "Sharing: the copied files could not be told to Sioul: " + e);
364        }
365        Log.i(DoseAlarms.TAG, "Sharing: " + done.length() + " files copied for a draft.");
366        tell();
367    }
368
369    /** A file copied whole, or not at all: under ".part" until it is. The open file closed after. */
370    private static void store(AssetFileDescriptor file, File folder, File copy) throws IOException
371    {
372        if (!folder.isDirectory() && !folder.mkdirs())
373            throw new IOException("cannot make " + folder);
374        File part = new File(folder, copy.getName() + ".part");
375        try (InputStream in = file.createInputStream(); OutputStream out = new FileOutputStream(part)) {
376            byte[] buffer = new byte[64 * 1024];
377            int read;
378            while ((read = in.read(buffer)) > 0)
379                out.write(buffer, 0, read);
380        } catch (IOException e) {
381            part.delete();
382            throw e;
383        } finally {
384            try {
385                file.close();
386            } catch (IOException e) {
387                // Closed with its stream already.
388            }
389        }
390        if (!part.renameTo(copy)) {
391            part.delete();
392            throw new IOException("cannot name " + copy.getName());
393        }
394    }
395
396    /** A file's name as its app gives it, else the end of its address. */
397    private static String name(ContentResolver resolver, Uri uri)
398    {
399        try (Cursor row = resolver.query(uri, new String[] { OpenableColumns.DISPLAY_NAME }, null, null, null)) {
400            if (row != null && row.moveToFirst()) {
401                int column = row.getColumnIndex(OpenableColumns.DISPLAY_NAME);
402                if (column >= 0 && !row.isNull(column)) {
403                    String name = row.getString(column);
404                    if (name != null && !name.trim().isEmpty())
405                        return name.trim();
406                }
407            }
408        } catch (RuntimeException e) {
409            // No name said: the address's.
410        }
411        return lastPart(uri);
412    }
413
414    /**
415     * A name a file can have in Sioul's folder, as close to the app's as
416     * can be: no "/" nor hidden dot, not too long, and an extension from its
417     * type when it has none (Sioul tells the recipient a file's type by it).
418     */
419    static String cleaned(String name, String type)
420    {
421        String clean = name.replace('/', '_').replace('\\', '_').replace('\0', '_').trim();
422        while (clean.startsWith("."))
423            clean = clean.substring(1);
424        if (clean.isEmpty() || clean.endsWith(".part"))
425            clean = clean.isEmpty() ? "attachment" : clean + "_";
426        int dot = clean.lastIndexOf('.');
427        if (dot <= 0 && type != null) {
428            String extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(type.toLowerCase(Locale.ROOT));
429            if (extension != null)
430                clean = clean + "." + extension;
431        }
432        while (clean.getBytes(StandardCharsets.UTF_8).length > LONGEST) {
433            dot = clean.lastIndexOf('.');
434            String extension = dot > 0 && clean.length() - dot <= 10 ? clean.substring(dot) : "";
435            String stem = clean.substring(0, clean.length() - extension.length());
436            clean = stem.substring(0, stem.offsetByCodePoints(stem.length(), -1)) + extension;
437        }
438        return clean;
439    }
440
441    /** "photo.jpg", then "photo (2).jpg" for a second of the same name. */
442    static String unique(Set<String> taken, String name)
443    {
444        String candidate = name;
445        int dot = name.lastIndexOf('.');
446        String stem = dot > 0 ? name.substring(0, dot) : name;
447        String extension = dot > 0 ? name.substring(dot) : "";
448        for (int n = 2; taken.contains(candidate.toLowerCase(Locale.ROOT)); n++)
449            candidate = stem + " (" + n + ")" + extension;
450        taken.add(candidate.toLowerCase(Locale.ROOT));
451        return candidate;
452    }
453
454    /** Why it failed, in the system's words, without Java's class names. */
455    private static String why(Exception e)
456    {
457        String message = e.getMessage();
458        return message == null || message.trim().isEmpty() ? e.getClass().getSimpleName() : message.trim();
459    }
460
461    /** A text written whole: under another name, then renamed. */
462    static void write(File file, String text) throws IOException
463    {
464        File folder = file.getParentFile();
465        if (folder != null && !folder.isDirectory() && !folder.mkdirs())
466            throw new IOException("cannot make " + folder);
467        File temporary = new File(folder, file.getName() + ".new");
468        try (FileOutputStream out = new FileOutputStream(temporary)) {
469            out.write(text.getBytes(StandardCharsets.UTF_8));
470            out.getFD().sync();
471        }
472        if (!temporary.renameTo(file)) {
473            temporary.delete();
474            throw new IOException("cannot write " + file);
475        }
476    }
477}