From c2978d47a0f9362c985991821e1635d3b32c5d11 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 14:19:44 +0000 Subject: [PATCH 1/6] DrawVars: pad so instance fields follow `dyn_instances` on 32-bit targets Port of the `DrawVars` part of upstream commit 100b70cd. On 32-bit targets (e.g., armv7 Android) the 8-byte-aligned struct ended 4 bytes past `dyn_instances`, so every draw call's `#[live]` instance fields were uploaded one f32 lane off. A const assert now keeps that gap at zero. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Va5dxPCRyW8YN5b1Yt1Awy --- platform/src/draw_list.rs | 1 + platform/src/draw_vars.rs | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/platform/src/draw_list.rs b/platform/src/draw_list.rs index 1b62087357..67d97d88cc 100644 --- a/platform/src/draw_list.rs +++ b/platform/src/draw_list.rs @@ -3602,6 +3602,7 @@ mod uniform_generation_tests { dyn_uniforms: call.dyn_uniforms, texture_slots: call.texture_slots.clone(), uniform_buffer_slots: call.uniform_buffer_slots.clone(), + dyn_instances_pad: Default::default(), dyn_instances: [0.0; crate::draw_vars::DRAW_CALL_DYN_INSTANCES], }; vars.dyn_uniforms[0] += 1.0; diff --git a/platform/src/draw_vars.rs b/platform/src/draw_vars.rs index e1845b9981..92398d5de1 100644 --- a/platform/src/draw_vars.rs +++ b/platform/src/draw_vars.rs @@ -30,6 +30,18 @@ pub const DRAW_CALL_TEXTURE_SLOTS: usize = 17; pub const DRAW_CALL_UNIFORM_BUFFER_SLOTS: usize = 2; pub const DRAW_CALL_DYN_INSTANCES: usize = 32; +/// f32 lanes placed before `dyn_instances` so the array ends exactly where +/// `DrawVars` ends. The instance data a draw call uploads is the tail of +/// `dyn_instances` followed directly by the `#[live]` fields of the shader +/// struct that embeds `DrawVars` (`as_slice`), so any padding after the array +/// shifts every one of those fields by a lane. On wasm32 (4-byte pointers and +/// usize) the 8-byte-aligned struct ended 4 bytes past the array: the shader +/// read `char_index` where `texture_index` was, and web text drew as boxes. +#[cfg(target_pointer_width = "32")] +const DRAW_VARS_TAIL_PAD: usize = 1; +#[cfg(not(target_pointer_width = "32"))] +const DRAW_VARS_TAIL_PAD: usize = 0; + #[derive(Clone, Script, Debug)] #[repr(C)] pub struct DrawVars { @@ -53,10 +65,21 @@ pub struct DrawVars { pub texture_slots: [Option; DRAW_CALL_TEXTURE_SLOTS], #[rust] pub uniform_buffer_slots: [Option; DRAW_CALL_UNIFORM_BUFFER_SLOTS], + #[rust([0f32; DRAW_VARS_TAIL_PAD])] + pub dyn_instances_pad: [f32; DRAW_VARS_TAIL_PAD], #[rust([0f32; DRAW_CALL_DYN_INSTANCES])] pub dyn_instances: [f32; DRAW_CALL_DYN_INSTANCES], } +// See DRAW_VARS_TAIL_PAD: the instance fields must follow `dyn_instances` +// with no gap, on every target. +const _: () = assert!( + core::mem::offset_of!(DrawVars, dyn_instances) + + core::mem::size_of::<[f32; DRAW_CALL_DYN_INSTANCES]>() + == core::mem::size_of::(), + "DrawVars must end with dyn_instances (adjust DRAW_VARS_TAIL_PAD)" +); + impl ScriptHook for DrawVars { fn on_after_apply( &mut self, From ffe358666413b775ee6805f352dfb15bc837f36e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 14:19:44 +0000 Subject: [PATCH 2/6] Android: use the right `gettid` syscall number on 32-bit ARM and x86 These use the legacy syscall table, where 178 is `rt_sigqueueinfo`. `android_entry` made that call with garbage arguments on every launch on armv7. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Va5dxPCRyW8YN5b1Yt1Awy --- platform/src/os/linux/libc_sys.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/platform/src/os/linux/libc_sys.rs b/platform/src/os/linux/libc_sys.rs index 7317914675..ff7f931c6b 100644 --- a/platform/src/os/linux/libc_sys.rs +++ b/platform/src/os/linux/libc_sys.rs @@ -54,8 +54,11 @@ pub const RTLD_LOCAL: c_int = 0; pub const SYS_GETTID: c_long = 186; #[cfg(target_arch = "aarch64")] pub const SYS_GETTID: c_long = 178; -#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] -pub const SYS_GETTID: c_long = 178; // fallback, may need updating for other arches +// 32-bit ARM (EABI) and x86 use the legacy syscall table, where 178 is `rt_sigqueueinfo`. +#[cfg(any(target_arch = "arm", target_arch = "x86"))] +pub const SYS_GETTID: c_long = 224; +#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64", target_arch = "arm", target_arch = "x86")))] +pub const SYS_GETTID: c_long = 178; // fallback (the asm-generic table, e.g. riscv64), may need updating for other arches extern "C" { pub fn dlopen(filename: *const c_char, flag: c_int) -> *mut c_void; From adde46f040d99461392836950054e7dce3b7d33a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 14:19:44 +0000 Subject: [PATCH 3/6] cargo-makepad: look for NDK shared libs in the armv7 sysroot dir The NDK keeps armv7 libraries under `sysroot/usr/lib/arm-linux-androideabi`, not under the clang wrapper prefix `armv7a-linux-androideabi`, so an armv7 APK/AAB never bundled a needed NDK library such as `libc++_shared.so`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Va5dxPCRyW8YN5b1Yt1Awy --- tools/cargo_makepad/src/android/compile.rs | 6 ++---- tools/cargo_makepad/src/android/mod.rs | 16 ++++++++++++++-- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/tools/cargo_makepad/src/android/compile.rs b/tools/cargo_makepad/src/android/compile.rs index 8ad97a935e..7760a43922 100644 --- a/tools/cargo_makepad/src/android/compile.rs +++ b/tools/cargo_makepad/src/android/compile.rs @@ -1422,8 +1422,7 @@ fn bundle_ndk_shared_deps( // The NDK sysroot lib directory for this target triple (base dir, NOT the // API-level subdirectory). Files here that are real .so's (not linker // scripts / stubs) are NDK-provided and must be shipped inside the APK. - let clang_triple = android_target.clang(); - let sysroot_lib_dir = ndk_prebuilt_root.join("sysroot/usr/lib").join(clang_triple); + let sysroot_lib_dir = ndk_prebuilt_root.join("sysroot/usr/lib").join(android_target.sys_dir()); // Parse NEEDED entries from readelf output. Each relevant line looks like: // 0x0000000000000001 (NEEDED) Shared library: [libc++_shared.so] @@ -2100,8 +2099,7 @@ fn stage_ndk_shared_deps_for_so( readelf_path.to_str().unwrap(), &["-d", so_path.to_str().unwrap()], )?; - let clang_triple = android_target.clang(); - let sysroot_lib_dir = ndk_prebuilt_root.join("sysroot/usr/lib").join(clang_triple); + let sysroot_lib_dir = ndk_prebuilt_root.join("sysroot/usr/lib").join(android_target.sys_dir()); for line in output.lines() { if !line.contains("(NEEDED)") { continue; diff --git a/tools/cargo_makepad/src/android/mod.rs b/tools/cargo_makepad/src/android/mod.rs index 37c83af0ef..910e1aeffe 100644 --- a/tools/cargo_makepad/src/android/mod.rs +++ b/tools/cargo_makepad/src/android/mod.rs @@ -367,7 +367,10 @@ impl AndroidTarget { } return Ok(out); } - fn _sys_dir(&self) -> &'static str { + /// The triple the NDK sysroot uses for this ABI's library directory + /// (`sysroot/usr/lib/`). It differs from [`Self::clang`] on armv7, + /// whose clang wrappers are `armv7a-linux-androideabi-clang`. + fn sys_dir(&self) -> &'static str { match self { Self::aarch64 => "aarch64-linux-android", Self::x86_64 => "x86_64-linux-android", @@ -925,7 +928,7 @@ pub fn handle_android(mut args: &[String]) -> Result<(), String> { #[cfg(test)] mod tests { - use super::{AndroidVariant, ManifestArgs}; + use super::{AndroidTarget, AndroidVariant, ManifestArgs}; fn test_args<'a>(label: &'a str, url: &'a str) -> ManifestArgs<'a> { ManifestArgs { @@ -1020,6 +1023,15 @@ mod tests { assert!(xml.contains("android:debuggable=\"false\"")); } + #[test] + fn armv7_ndk_sysroot_dir_is_not_the_clang_prefix() { + assert_eq!(AndroidTarget::armv7.clang(), "armv7a-linux-androideabi"); + assert_eq!(AndroidTarget::armv7.sys_dir(), "arm-linux-androideabi"); + for target in [AndroidTarget::aarch64, AndroidTarget::x86_64, AndroidTarget::i686] { + assert_eq!(target.sys_dir(), target.clang()); + } + } + #[test] fn manifests_opt_into_predictive_back() { for variant in [AndroidVariant::Default, AndroidVariant::Quest] { From 52cee3fecee30c62f8cc6cfef0ba6995367e4b0a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 15:43:07 +0000 Subject: [PATCH 4/6] Images: cap decoded images at 128 MiB on 32-bit native targets Decoding holds a few buffers of up to the cap at once (the decoder output, the RGBA buffer and an EXIF-rotated copy), and on a 32-bit target (e.g., armv7 Android) several 1 GiB ones don't fit in the address space. A large or crafted image (~1 MB on the wire) then aborted the whole app on an infallible allocation, instead of failing with `DimensionsTooLarge`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Va5dxPCRyW8YN5b1Yt1Awy --- draw/src/image_cache.rs | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/draw/src/image_cache.rs b/draw/src/image_cache.rs index 82a1abaec7..f3d9f94e7a 100644 --- a/draw/src/image_cache.rs +++ b/draw/src/image_cache.rs @@ -198,11 +198,17 @@ impl PendingDecode { const MAX_IMAGE_DIMENSION: usize = 16384; /// Hard upper bound on a decoded image or animation atlas in bytes. Browser /// textures have a tighter admission limit; native retains the existing 1 GiB -/// cap (the RGBA size of a 16384x16384 image). +/// cap (the RGBA size of a 16384x16384 image). Other 32-bit targets (e.g., +/// armv7 Android) get 128 MiB (32 MP): decoding holds a few buffers of up to +/// this size at once, and several 1 GiB ones don't fit in a 32-bit address +/// space, so an oversized image would abort the app instead of failing. const WEB_MAX_IMAGE_DECODED_BYTES: usize = 64 * 1024 * 1024; +const NATIVE_32BIT_MAX_IMAGE_DECODED_BYTES: usize = 128 * 1024 * 1024; const NATIVE_MAX_IMAGE_DECODED_BYTES: usize = 1024 * 1024 * 1024; const MAX_IMAGE_DECODED_BYTES: usize = if cfg!(target_arch = "wasm32") { WEB_MAX_IMAGE_DECODED_BYTES +} else if cfg!(target_pointer_width = "32") { + NATIVE_32BIT_MAX_IMAGE_DECODED_BYTES } else { NATIVE_MAX_IMAGE_DECODED_BYTES }; @@ -1556,11 +1562,30 @@ mod tests { } #[test] - #[cfg(not(target_arch = "wasm32"))] + #[cfg(all(not(target_arch = "wasm32"), target_pointer_width = "64"))] fn native_decoded_image_policy_remains_one_gibibyte() { assert_eq!(MAX_IMAGE_DECODED_BYTES, NATIVE_MAX_IMAGE_DECODED_BYTES); } + #[test] + #[cfg(all(not(target_arch = "wasm32"), target_pointer_width = "32"))] + fn native_32bit_decoded_image_policy_is_128_mebibytes() { + assert_eq!(MAX_IMAGE_DECODED_BYTES, NATIVE_32BIT_MAX_IMAGE_DECODED_BYTES); + } + + #[test] + fn native_32bit_decoded_image_policy_boundaries() { + let limit = NATIVE_32BIT_MAX_IMAGE_DECODED_BYTES; + assert_eq!( + checked_pixel_count_with_limit(8192, 4096, limit).unwrap(), + 8192 * 4096 + ); + assert!(checked_pixel_count_with_limit(8192, 4097, limit).is_err()); + assert!(checked_pixel_count_with_limit(16384, 16384, limit).is_err()); + assert!(animation_atlas_layout_with_limit(32, 1024, 1024, limit).is_ok()); + assert!(animation_atlas_layout_with_limit(33, 1024, 1024, limit).is_err()); + } + fn single_frame_gif() -> Vec { let palette = [0x00, 0x00, 0x00, 0xff, 0x00, 0x00]; let pixels = [0, 1, 1, 0]; From 75f4b1d07202d38f06728cf0f02f3b42f7c2fd6d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 15:43:07 +0000 Subject: [PATCH 5/6] SharedBytes: link `mmap64` on Android and glibc, whose `mmap` takes a 32-bit `off_t` on 32-bit The 64-bit offset we pass otherwise lands where the 32-bit ABI doesn't look, so the kernel got a garbage offset on armv7. Not reached on Android today (resource paths don't exist on a device). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Va5dxPCRyW8YN5b1Yt1Awy --- libs/shared_bytes/src/lib.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/libs/shared_bytes/src/lib.rs b/libs/shared_bytes/src/lib.rs index ec5f294ffa..5e95f6c054 100644 --- a/libs/shared_bytes/src/lib.rs +++ b/libs/shared_bytes/src/lib.rs @@ -209,6 +209,12 @@ mod os { const MAP_FAILED: *mut c_void = !0usize as *mut c_void; unsafe extern "C" { + // On 32-bit bionic and glibc, the plain `mmap` symbol takes a 32-bit + // `off_t`; `mmap64` takes 64 bits on every ABI (on 64-bit, it's an alias). + #[cfg_attr( + any(target_os = "android", all(target_os = "linux", target_env = "gnu")), + link_name = "mmap64" + )] fn mmap( addr: *mut c_void, length: usize, From b6588efca1e2a18bcc6100bce54107ed88549627 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 03:21:05 +0000 Subject: [PATCH 6/6] cargo-makepad: add MakepadFileProvider, a read-only file provider for apps To share a private file with another app, an app gives it a `content://` URI from one of its own providers. Android 10+ apps can copy the file to MediaStore instead. On Android 8 and 9, without storage permissions, a provider is the only way. androidx's FileProvider does this for most apps, but Makepad apps don't bundle androidx. A provider must also be declared in the app's manifest, and its class compiled into the APK so the app's class loader can load it. So libraries whose Java is loaded at runtime, such as robius-share, can't ship one. MakepadFileProvider is compiled into every APK but does nothing until an app declares it in its manifest template. It serves files from the cache subdirectories named in its meta-data, read-only, to apps that were granted the URI. Like FileProvider, it refuses to run if it is exported or doesn't grant URI permissions. It resolves `..` and symlinks before checking that a file is inside a listed directory. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Va5dxPCRyW8YN5b1Yt1Awy --- tools/cargo_makepad/src/android/compile.rs | 1 + .../makepad/android/MakepadFileProvider.java | 183 ++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 tools/cargo_makepad/src/android/java/dev/makepad/android/MakepadFileProvider.java diff --git a/tools/cargo_makepad/src/android/compile.rs b/tools/cargo_makepad/src/android/compile.rs index 7760a43922..aab1fc4869 100644 --- a/tools/cargo_makepad/src/android/compile.rs +++ b/tools/cargo_makepad/src/android/compile.rs @@ -1137,6 +1137,7 @@ fn compile_java( makepad_java_classes_dir.join("VideoPlayerRunnable.java"), makepad_java_classes_dir.join("OesDecodeSurface.java"), makepad_java_classes_dir.join("H264Encoder.java"), + makepad_java_classes_dir.join("MakepadFileProvider.java"), build_paths.java_file.clone(), build_paths.xr_file.clone(), ]; diff --git a/tools/cargo_makepad/src/android/java/dev/makepad/android/MakepadFileProvider.java b/tools/cargo_makepad/src/android/java/dev/makepad/android/MakepadFileProvider.java new file mode 100644 index 0000000000..0e9e4a6523 --- /dev/null +++ b/tools/cargo_makepad/src/android/java/dev/makepad/android/MakepadFileProvider.java @@ -0,0 +1,183 @@ +package dev.makepad.android; + +import android.content.ContentProvider; +import android.content.ContentValues; +import android.content.Context; +import android.content.pm.PackageManager; +import android.content.pm.ProviderInfo; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.net.Uri; +import android.os.Bundle; +import android.os.ParcelFileDescriptor; +import android.provider.OpenableColumns; +import android.webkit.MimeTypeMap; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; + +/** + * A read-only provider that hands other apps `content://` URIs for files in chosen + * subdirectories of the app's cache directory, like androidx's FileProvider but without + * the library. An app opts in by declaring it in its `AndroidManifest.xml.template`: + * + *
+ * <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>
+ * 
+ * + * `cacheDirs` is a comma-separated list of subdirectory names. The file + * `//` is served as `content:////`, and only to + * apps that were granted that URI (e.g., with `Intent.FLAG_GRANT_READ_URI_PERMISSION`). + */ +public class MakepadFileProvider extends ContentProvider { + private static final String CACHE_DIRS_META_DATA = + "dev.makepad.android.MakepadFileProvider.cacheDirs"; + private static final String[] DEFAULT_PROJECTION = { + OpenableColumns.DISPLAY_NAME, + OpenableColumns.SIZE, + }; + + private final HashSet cacheDirs = new HashSet<>(); + + @Override + public boolean onCreate() { + return true; + } + + // `resolveContentProvider(String, int)` is deprecated in API 33, but its replacement isn't + // available before that. + @SuppressWarnings("deprecation") + @Override + public void attachInfo(Context context, ProviderInfo info) { + super.attachInfo(context, info); + // As with FileProvider: exported, any app could read these files without a grant. + if (info.exported) { + throw new SecurityException("MakepadFileProvider must not be exported"); + } + if (!info.grantUriPermissions) { + throw new SecurityException("MakepadFileProvider must grant URI permissions"); + } + + // The ProviderInfo given here usually lacks meta-data, so look it up again. + Bundle metaData = info.metaData; + if (metaData == null) { + ProviderInfo withMetaData = context.getPackageManager().resolveContentProvider( + info.authority.split(";")[0], + PackageManager.GET_META_DATA); + metaData = withMetaData == null ? null : withMetaData.metaData; + } + String dirs = metaData == null ? null : metaData.getString(CACHE_DIRS_META_DATA); + if (dirs == null) { + return; + } + for (String dir : dirs.split(",")) { + dir = dir.trim(); + if (!dir.isEmpty() && !dir.equals(".") && !dir.equals("..") && dir.indexOf('/') < 0) { + cacheDirs.add(dir); + } + } + } + + @Override + public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException { + if (!"r".equals(mode)) { + throw new SecurityException("MakepadFileProvider only opens files for reading"); + } + return ParcelFileDescriptor.open(fileForUri(uri), ParcelFileDescriptor.MODE_READ_ONLY); + } + + @Override + public Cursor query( + Uri uri, + String[] projection, + String selection, + String[] selectionArgs, + String sortOrder) { + File file; + try { + file = fileForUri(uri); + } catch (FileNotFoundException e) { + return null; + } + + // Receivers ask for the name and size of a shared file; nothing else is known. + ArrayList columns = new ArrayList<>(); + ArrayList values = new ArrayList<>(); + for (String column : projection != null ? projection : DEFAULT_PROJECTION) { + if (OpenableColumns.DISPLAY_NAME.equals(column)) { + columns.add(column); + values.add(file.getName()); + } else if (OpenableColumns.SIZE.equals(column)) { + columns.add(column); + values.add(file.length()); + } + } + MatrixCursor cursor = new MatrixCursor(columns.toArray(new String[0]), 1); + cursor.addRow(values.toArray()); + return cursor; + } + + @Override + public String getType(Uri uri) { + String name = uri.getLastPathSegment(); + int dot = name == null ? -1 : name.lastIndexOf('.'); + if (dot >= 0) { + String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension( + name.substring(dot + 1).toLowerCase(Locale.ROOT)); + if (mimeType != null) { + return mimeType; + } + } + return "application/octet-stream"; + } + + @Override + public Uri insert(Uri uri, ContentValues values) { + throw new UnsupportedOperationException("MakepadFileProvider is read-only"); + } + + @Override + public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { + throw new UnsupportedOperationException("MakepadFileProvider is read-only"); + } + + @Override + public int delete(Uri uri, String selection, String[] selectionArgs) { + throw new UnsupportedOperationException("MakepadFileProvider is read-only"); + } + + private File fileForUri(Uri uri) throws FileNotFoundException { + List segments = uri.getPathSegments(); + if (segments.size() < 2 || !cacheDirs.contains(segments.get(0))) { + throw new FileNotFoundException("Not a shared file: " + uri); + } + try { + File root = new File(getContext().getCacheDir(), segments.get(0)).getCanonicalFile(); + File file = root; + for (String segment : segments.subList(1, segments.size())) { + file = new File(file, segment); + } + // Resolving `..` and symlinks must not lead out of the shared directory. + file = file.getCanonicalFile(); + if (!file.getPath().startsWith(root.getPath() + File.separator) || !file.isFile()) { + throw new FileNotFoundException("Not a shared file: " + uri); + } + return file; + } catch (IOException e) { + throw new FileNotFoundException("Not a shared file: " + uri); + } + } +}