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}