Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 0 additions & 3 deletions crates/location/src/sys/android/callback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
94 changes: 61 additions & 33 deletions crates/share/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 `<cache dir>/robius-share/<uuid>/<file name>`, where
`<cache dir>` is the app's `Context.getCacheDir()` and `<uuid>` is a new
random UUID, in lowercase;
- it shares that copy as
`content://<package name>.robius.share/robius-share/<uuid>/<file name>`,
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
`<grant-uri-permission>`), 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 `<cache dir>/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
<provider
Expand All @@ -85,29 +98,43 @@ The normal app integration is:
</provider>
```

with a matching `res/xml/robius_share_paths.xml`, for example:
with a matching `res/xml/robius_share_paths.xml`:

```xml
<paths>
<cache-path name="robius-share-cache" path="robius-share/" />
<files-path name="robius-share-files" path="robius-share/" />
<!-- The name must be "robius-share", the URI path robius-share uses. -->
<cache-path name="robius-share" path="robius-share/" />
</paths>
```

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"
<provider
android:name="dev.makepad.android.MakepadFileProvider"
android:authorities="{package_id}.robius.share"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="dev.makepad.android.MakepadFileProvider.cacheDirs"
android:value="robius-share" />
</provider>
```

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")
Expand All @@ -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:
<https://developer.android.com/training/secure-file-sharing/setup-sharing>.
Expand Down
15 changes: 9 additions & 6 deletions crates/share/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<package name>.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.
Expand Down Expand Up @@ -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
/// `<package name>.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<P: AsRef<Path>>(mut self, path: P) -> Self {
self.options.items.push(ShareItem::File(SharedFile::from_path(path)));
Expand Down
Loading
Loading