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}