diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..f0a5c08 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,99 @@ +name: Android + +on: + # Only on main: a branch pushed to this repo would otherwise also run every + # job a second time through its pull request. + push: + branches: [main] + paths: + - crates/** + - Cargo.toml + - Cargo.lock + - .github/workflows/android.yml + pull_request: + paths: + - crates/** + - Cargo.toml + - Cargo.lock + - .github/workflows/android.yml + +jobs: + check: + # Both Android ABIs that apps ship: arm64-v8a and 32-bit armeabi-v7a. + # Checking the 32-bit one catches code that only works with 64-bit pointers. + name: Android (${{ matrix.target }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + target: [aarch64-linux-android, armv7-linux-androideabi] + env: + RUSTFLAGS: -D warnings + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} + # The build scripts compile Java with javac and dex it with d8 from the + # Android SDK. Pin JDK 17, the version cargo-makepad bundles, rather than + # the runner's default JDK, which is moving to 25 on newer images. + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + - name: Select Android SDK platform and build-tools + run: | + set -euo pipefail + sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}" + if [ -z "$sdk" ] || [ ! -d "$sdk" ]; then + echo "::error::No Android SDK found (ANDROID_HOME and ANDROID_SDK_ROOT are unset or missing)" + exit 1 + fi + # Find the newest stable platform. Leave out -ext and preview (-beta) ones: + # left to themselves, the build scripts would pick a preview such as + # android-37.2-beta3. Prefer a plain API level (android-36), then a + # minor release (android-37.1). + newest() { { ls -1 "$1" 2>/dev/null || true; } | { grep -E "$2" || true; } | sort -V | tail -n 1; } + platform=$(newest "$sdk/platforms" '^android-[0-9]+$') + if [ -z "$platform" ]; then + platform=$(newest "$sdk/platforms" '^android-[0-9]+\.[0-9]+$') + fi + build_tools=$(newest "$sdk/build-tools" '^[0-9]+\.[0-9]+\.[0-9]+$') + android_jar="$sdk/platforms/$platform/android.jar" + d8_jar="$sdk/build-tools/$build_tools/lib/d8.jar" + if [ -z "$platform" ] || [ ! -f "$android_jar" ]; then + echo "::error::No stable platform with android.jar under $sdk/platforms"; ls "$sdk/platforms"; exit 1 + fi + if [ -z "$build_tools" ] || [ ! -f "$d8_jar" ]; then + echo "::error::No build-tools with lib/d8.jar under $sdk/build-tools"; ls "$sdk/build-tools"; exit 1 + fi + echo "Using $platform, build-tools $build_tools, JAVA_HOME=$JAVA_HOME" + { + # robius-speech reads ANDROID_HOME, ANDROID_PLATFORM and ANDROID_BUILD_TOOLS_VERSION + # as directory names, so ANDROID_PLATFORM must be the full "android-NN" form. + echo "ANDROID_HOME=$sdk" + echo "ANDROID_SDK_ROOT=$sdk" + echo "ANDROID_PLATFORM=$platform" + echo "ANDROID_BUILD_TOOLS_VERSION=$build_tools" + # The `android-build` crate checks these two first. + echo "ANDROID_JAR=$android_jar" + echo "ANDROID_D8_JAR=$d8_jar" + } >> "$GITHUB_ENV" + # Checks every workspace crate: crates without Android code compile their + # no-op backends. Benches are left out because the robius-directories + # bench doesn't build on any platform. No NDK is needed, because + # `cargo check` never links. + - name: cargo check + run: | + set -o pipefail + cargo check --locked --workspace --lib --examples --tests --target ${{ matrix.target }} 2>&1 | tee check.log + # robius-speech doesn't fail when it can't find the SDK or JDK. It + # quietly builds without its Android backend, and then this job would + # skip that code. + if grep -q "Android native dictation disabled" check.log; then + echo "::error::robius-speech built without its Android backend; see the warning above" + exit 1 + fi diff --git a/crates/location/src/sys/android/callback.rs b/crates/location/src/sys/android/callback.rs index 3d65b98..ceaa8af 100644 --- a/crates/location/src/sys/android/callback.rs +++ b/crates/location/src/sys/android/callback.rs @@ -36,9 +36,6 @@ unsafe extern "C" fn rust_callback<'a>( location: JObject<'a>, cached: jboolean, ) { - #[cfg(not(target_pointer_width = "64"))] - compile_error!("non-64-bit Android targets are not supported"); - if shared_ptr == 0 { return; } diff --git a/crates/share/README.md b/crates/share/README.md index 6ac566e..ae932af 100644 --- a/crates/share/README.md +++ b/crates/share/README.md @@ -30,8 +30,10 @@ ShareSheet::new() Android `content://` attachments are shared directly. Filesystem path attachments are copied to a shareable MediaStore item on Android 10 and newer. -On Android 8 and 9, API 26 through 28, text files can be shared as text -content; arbitrary binary path attachments require a host-app `ContentProvider`. +On Android 8 and 9, API 26 through 28, they are copied into the app's cache and +shared through the host app's `ContentProvider`, if it declares one (see +below). Without one, text files are shared as text content, and arbitrary binary +path attachments can't be shared. The **minimum supported Android API level is 26 (Android 8.0)**: the bundled Java helper is loaded via `InMemoryDexClassLoader`, which requires API 26, so set @@ -53,25 +55,36 @@ That authority must be unique per installed app. `robius-share` cannot use one global authority like `robius.share`, because two different apps that depend on this crate would then declare the same provider authority and conflict at install time. The authority also has to appear in the final Android manifest; -it cannot be registered after the app starts. - -This provider setup is only required by `robius-share` when all of these are -true: - -- the app is running on Android 8 or 9, API 26 through 28; -- the payload is an app-private filesystem path; -- the file must be shared as a real attachment rather than as text content; -- the file is an arbitrary binary file, such as a PDF, image, video, archive, or - custom document. - -It is not needed for `content://` URIs returned by `robius-file-picker`, because -those URIs already come from a provider. It is also not needed for -`add_file(path)` on Android 10 and newer, because `robius-share` copies the file -to a shareable MediaStore item before opening the Android Sharesheet. If your -app already has a `FileProvider` or custom `ContentProvider`, you can reuse that -instead of adding a separate Robius-specific provider. - -The normal app integration is: +it cannot be registered after the app starts, so the host app has to declare +the provider. + +A provider is only needed on Android 8 and 9, API 26 through 28, to share +filesystem path attachments (`add_file(path)`). It is not needed for +`content://` URIs, such as those returned by `robius-file-picker`, because those +URIs already come from a provider. It is also not needed for `add_file(path)` on +Android 10 and newer, because `robius-share` copies the file to a shareable +MediaStore item before opening the Android Sharesheet. + +On Android 8 and 9, `add_file(path)` uses the provider whose authority is the +app's package name followed by `.robius.share`, if the app declares one: + +- it copies the file to `/robius-share//`, where + `` is the app's `Context.getCacheDir()` and `` is a new + random UUID, in lowercase; +- it shares that copy as + `content://.robius.share/robius-share//`, + so the provider has to serve the `robius-share` directory of the cache under + the URI path `robius-share`; +- it only uses a provider that belongs to the app itself and lets it grant + access to that URI (`android:grantUriPermissions="true"`, or a matching + ``), and it checks that the provider serves the copy. + Otherwise it falls back to the behavior without a provider; +- on a later share, it deletes the directories in `/robius-share` + whose names are lowercase UUIDs and that haven't changed for a day, without + following symbolic links. The app may keep its own files in that directory, + as long as their names aren't UUIDs. + +With androidx's `FileProvider`, that's: ```xml ``` -with a matching `res/xml/robius_share_paths.xml`, for example: +with a matching `res/xml/robius_share_paths.xml`: ```xml - - + + ``` -For Makepad apps, the same idea can be expressed in -`resources/android/AndroidManifest.xml.template` using Makepad's package token: +Makepad apps don't bundle androidx, so they can't use `FileProvider`. cargo-makepad +builds that include `dev.makepad.android.MakepadFileProvider` can declare that +instead, in `resources/android/AndroidManifest.xml.template`, using Makepad's +package token: ```xml -android:authorities="{package_id}.robius.share" + + + ``` -Once your app has converted a file path into a provider-backed `content://` URI, -pass that URI to `robius-share`: +Only declare a provider class that your APK actually contains: Android starts +an app's providers when the app starts, so a missing class crashes the app at +launch on every Android version. + +An app can also make its own `content://` URIs, with this provider or any other +one it already has, and pass them to `robius-share`: ```rust use robius_share::ShareSheet; -let uri = "content://dev.example.notes.robius.share/robius-share-cache/report.pdf"; +let uri = "content://dev.example.notes.robius.share/robius-share/report.pdf"; ShareSheet::new() .set_title("Share report") @@ -134,9 +161,10 @@ ShareSheet::new() ``` That `add_file(...)` example is a true file attachment on iOS, macOS, Windows, -Linux, and Android 10 or newer. On Android 8 and 9, the crate can share small -text files as text content, but arbitrary binary paths such as PDFs, images, or -videos need the host app to provide a `content://` URI and call `add_file_uri`. +Linux, and Android 10 or newer. On Android 8 and 9 it is one only if the app +declares the provider described above; without one, small text files are shared +as text content, and arbitrary binary paths such as PDFs, images, or videos +can't be shared. Android's FileProvider setup is documented here: . diff --git a/crates/share/src/lib.rs b/crates/share/src/lib.rs index 256f7e7..f19e37f 100644 --- a/crates/share/src/lib.rs +++ b/crates/share/src/lib.rs @@ -8,9 +8,10 @@ //! * Text, URLs, `content://` attachments, and filesystem path attachments //! are supported. //! * Filesystem path attachments are copied to a shareable MediaStore item on -//! Android 10 and newer. On older Android versions, text files can be shared -//! as text content, but arbitrary binary path attachments require an app -//! `ContentProvider`. +//! Android 10 and newer. On older Android versions, they are shared through +//! the app's `.robius.share` `ContentProvider` if it declares +//! one (see the crate README). Without one, text files are shared as text +//! content, and arbitrary binary path attachments can't be shared. //! * **Minimum API level: 26 (Android 8.0).** The bundled Java helper is loaded //! via `InMemoryDexClassLoader`, which requires API 26. Set `minSdk` to at //! least 26 in your app. @@ -108,9 +109,11 @@ impl ShareSheet { /// Adds a filesystem path attachment. /// /// On Android 10 and newer, this copies the file to a shareable MediaStore - /// item before launching the chooser. On older Android versions, text files - /// can be shared as text content, while arbitrary binary path attachments - /// require a host app `ContentProvider`. + /// item before launching the chooser. On older Android versions, it copies + /// the file into the app's cache and shares it through the host app's + /// `.robius.share` `ContentProvider`, if the app declares one + /// (see the crate README). Without one, text files are shared as text + /// content, while arbitrary binary path attachments can't be shared. #[must_use] pub fn add_file>(mut self, path: P) -> Self { self.options.items.push(ShareItem::File(SharedFile::from_path(path))); diff --git a/crates/share/src/sys/android/ShareSheet.java b/crates/share/src/sys/android/ShareSheet.java index 88af338..6f5dff8 100644 --- a/crates/share/src/sys/android/ShareSheet.java +++ b/crates/share/src/sys/android/ShareSheet.java @@ -8,21 +8,29 @@ import android.content.ContentValues; import android.content.ClipData; import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ProviderInfo; import android.net.Uri; import android.os.Build; import android.os.Environment; import android.os.Looper; +import android.os.PatternMatcher; import android.provider.MediaStore; import android.webkit.MimeTypeMap; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; +import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; +import java.util.UUID; import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicInteger; @@ -31,6 +39,15 @@ public class ShareSheet { private static final int RESULT_NO_HANDLER = 1; private static final int RESULT_ERROR = 2; + // The host app's provider authority is `` plus this suffix (see the README). + private static final String PROVIDER_AUTHORITY_SUFFIX = ".robius.share"; + // Copies shared through that provider live in this subdirectory of the app's cache + // directory, and the provider serves it under this same first URI path segment. + private static final String PROVIDER_CACHE_DIR = "robius-share"; + // Receivers may read a shared file well after the share sheet closes, so old copies + // are only deleted on a later share. + private static final long PROVIDER_CACHE_MAX_AGE_MILLIS = 24L * 60 * 60 * 1000; + public static int share( Activity activity, String title, @@ -266,14 +283,21 @@ private static ShareableFile resolveShareableFile(Activity activity, String loca return null; } + String normalizedMimeType = normalizedMimeType(file, mimeType); if (Build.VERSION.SDK_INT >= 29) { - String normalizedMimeType = normalizedMimeType(file, mimeType); Uri uri = copyFileToMediaStore(activity, file, normalizedMimeType); if (uri != null) { return new ShareableFile(uri, normalizedMimeType); } } + // Before Android 10 there is no MediaStore Downloads collection to copy into, so a + // private file can only be shared through a provider that the host app declares. + Uri uri = copyFileToHostProvider(activity, file); + if (uri != null) { + return new ShareableFile(uri, normalizedMimeType); + } + return null; } @@ -330,6 +354,125 @@ private static Uri copyFileToMediaStore(Activity activity, File file, String mim } } + /** + * Copies the file into `/robius-share/` and returns its URI from the host app's + * `.robius.share` provider, or null if the app has no such provider, or the + * provider can't grant access to the copy or doesn't serve it. + */ + @SuppressWarnings("deprecation") // `resolveContentProvider(String, int)`, for API < 33. + private static Uri copyFileToHostProvider(Activity activity, File file) { + File copyDir = null; + try { + String authority = activity.getPackageName() + PROVIDER_AUTHORITY_SUFFIX; + // A directory per share keeps the original file name, which receivers show. + String copyDirName = UUID.randomUUID().toString(); + Uri uri = new Uri.Builder() + .scheme(ContentResolver.SCHEME_CONTENT) + .authority(authority) + .appendPath(PROVIDER_CACHE_DIR) + .appendPath(copyDirName) + .appendPath(file.getName()) + .build(); + ProviderInfo provider = activity.getPackageManager().resolveContentProvider( + authority, + PackageManager.GET_URI_PERMISSION_PATTERNS); + if (!canGrantReadAccess(activity, provider, uri)) { + return null; + } + + File cacheDir = new File(activity.getCacheDir(), PROVIDER_CACHE_DIR); + deleteOldCopies(cacheDir); + + copyDir = new File(cacheDir, copyDirName); + if (!copyDir.mkdirs()) { + return null; + } + try (InputStream input = new FileInputStream(file); + OutputStream output = new FileOutputStream(new File(copyDir, file.getName()))) { + copy(input, output); + } + + // A provider that maps the URI elsewhere would only fail later, in the receiving + // app, so check that it serves the copy. A provider can always be read by its app. + activity.getContentResolver().openFileDescriptor(uri, "r").close(); + return uri; + } catch (Throwable e) { + if (copyDir != null) { + deleteRecursively(copyDir); + } + return null; + } + } + + /** + * Whether the provider is the app's own, and lets the app grant other apps read access to + * the URI. The system checks the latter when the share sheet starts, and fails the share. + */ + private static boolean canGrantReadAccess(Activity activity, ProviderInfo provider, Uri uri) { + // Any app can declare an authority that no installed app has, so a provider with this + // app's authority may belong to another app. + if (provider == null + || !activity.getPackageName().equals(provider.packageName) + || !provider.grantUriPermissions) { + return false; + } + if (provider.uriPermissionPatterns == null) { + return true; + } + for (PatternMatcher pattern : provider.uriPermissionPatterns) { + if (pattern != null && pattern.match(uri.getPath())) { + return true; + } + } + return false; + } + + private static void deleteOldCopies(File cacheDir) { + File[] entries = cacheDir.listFiles(); + if (entries == null) { + return; + } + long cutoff = System.currentTimeMillis() - PROVIDER_CACHE_MAX_AGE_MILLIS; + for (File entry : entries) { + // The app may keep its own files here too, so only delete the per-share directories. + if (!isCopyDirName(entry.getName())) { + continue; + } + try { + BasicFileAttributes attributes = Files.readAttributes( + entry.toPath(), + BasicFileAttributes.class, + LinkOption.NOFOLLOW_LINKS); + if (attributes.isDirectory() && attributes.lastModifiedTime().toMillis() < cutoff) { + deleteRecursively(entry); + } + } catch (IOException ignored) { + // Try again on a later share. + } + } + } + + private static boolean isCopyDirName(String name) { + try { + return UUID.fromString(name).toString().equals(name); + } catch (IllegalArgumentException e) { + return false; + } + } + + /** Deletes the file or directory tree, without following symbolic links out of it. */ + private static void deleteRecursively(File file) { + if (!Files.isSymbolicLink(file.toPath())) { + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + deleteRecursively(child); + } + } + } + file.delete(); + } + private static Uri downloadsExternalContentUri() throws IOException { try { // Keep API 29 symbols behind the SDK_INT guard so this class can