From 9ce239df7ca337d99c31f6e522202fb1905c4fea Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Thu, 20 Aug 2026 12:26:51 +0100 Subject: [PATCH 1/2] Resolve relative image paths against the app's public directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `src` implies a URL to anyone coming from the web, but the renderers wanted a real path on disk. A relative path now means "an asset in `public/`" — for `` and for the list-item avatar and image slots alike. A leading slash deliberately stays a device filesystem path, since that's how camera captures and gallery picks arrive. Local files on iOS now go through an NSCache keyed on path, mtime and size, so a list of local thumbnails doesn't re-read and re-decode per row. Android needs no counterpart — Coil already memory-caches. Co-Authored-By: Claude Opus 5 (1M context) --- SHIPPING-CHECKLIST.md | 2 +- resources/android/ImageRenderer.kt | 7 +- resources/android/ListItemRenderer.kt | 11 +- resources/android/NativeUIImageSource.kt | 35 +++++ resources/boost/guidelines/core.blade.php | 8 ++ resources/ios/NativeUIImageSource.swift | 142 +++++++++++++++++++ resources/ios/NativeUIListItemRenderer.swift | 8 +- resources/ios/NativeUISimpleRenderers.swift | 31 ++-- src/Elements/ListItem.php | 5 +- tests/ListItemImageSourceTest.php | 49 +++++++ 10 files changed, 264 insertions(+), 34 deletions(-) create mode 100644 resources/android/NativeUIImageSource.kt create mode 100644 resources/ios/NativeUIImageSource.swift create mode 100644 tests/ListItemImageSourceTest.php diff --git a/SHIPPING-CHECKLIST.md b/SHIPPING-CHECKLIST.md index d688772..c949ef9 100644 --- a/SHIPPING-CHECKLIST.md +++ b/SHIPPING-CHECKLIST.md @@ -459,7 +459,7 @@ Rationale: a Card component fights the "Tailwind classes only" principle (memory - [ ] placeholder - [ ] error fallback - [ ] async loading -- [ ] local asset references +- [x] local asset references (relative `src` → app `public/`, absolute → device path) - [ ] Docs: `image.md` ### 8.4 `` / `` (engine) diff --git a/resources/android/ImageRenderer.kt b/resources/android/ImageRenderer.kt index df312a2..b7bd99d 100644 --- a/resources/android/ImageRenderer.kt +++ b/resources/android/ImageRenderer.kt @@ -2,10 +2,12 @@ package com.nativephp.plugins.native_ui.ui import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.nativephp.mobile.ui.nativerender.NativeUINode @@ -21,6 +23,9 @@ object ImageRenderer { val tintArgb = p.getColor("tint_color", 0) val radius = node.style?.borderRadius ?: 0f + val context = LocalContext.current + val model = remember(src, context) { nuiResolveImageSrc(src, context) } + // Images need explicit clip for rounded corners (nodeStyle doesn't clip globally) val imgModifier = if (radius > 0f) { modifier.clip(RoundedCornerShape(radius.dp)) @@ -28,7 +33,7 @@ object ImageRenderer { if (src.isNotEmpty()) { AsyncImage( - model = src, + model = model, // `alt` marks the image as meaningful; without it the image // stays decorative (silent for TalkBack). contentDescription = alt.ifEmpty { null }, diff --git a/resources/android/ListItemRenderer.kt b/resources/android/ListItemRenderer.kt index 9fa3734..29dc703 100644 --- a/resources/android/ListItemRenderer.kt +++ b/resources/android/ListItemRenderer.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -227,8 +228,11 @@ object ListItemRenderer { } } "avatar" -> { + val context = LocalContext.current SubcomposeAsyncImage( - model = effectiveValue, + model = remember(effectiveValue, context) { + nuiResolveImageSrc(effectiveValue, context) + }, contentDescription = null, modifier = Modifier .size(40.dp) @@ -272,8 +276,11 @@ object ListItemRenderer { } } "image" -> { + val context = LocalContext.current SubcomposeAsyncImage( - model = effectiveValue, + model = remember(effectiveValue, context) { + nuiResolveImageSrc(effectiveValue, context) + }, contentDescription = null, modifier = Modifier .size(56.dp) diff --git a/resources/android/NativeUIImageSource.kt b/resources/android/NativeUIImageSource.kt new file mode 100644 index 0000000..5c79066 --- /dev/null +++ b/resources/android/NativeUIImageSource.kt @@ -0,0 +1,35 @@ +package com.nativephp.plugins.native_ui.ui + +import android.content.Context +import com.nativephp.mobile.bridge.PHPBridge + +/** + * Shared `src` resolution for every renderer that loads an image — the + * `` element and the list-item avatar / thumbnail slots — so a + * path means the same thing wherever it's written. + */ + +/** + * `src` reads like a web URL to whoever authored it, but Coil needs something + * it can actually fetch off this device. + * + * Left untouched: anything carrying a scheme (`https:`, `file:`, `content:`, + * `data:`, `android.resource:`) and anything absolute — camera captures and + * gallery picks arrive as absolute filesystem paths. + * + * A RELATIVE path is what a web developer writes for an asset shipped with the + * app, so it resolves against the Laravel app's `public/` directory on device: + * `img/logo.png` → `/public/img/logo.png`. Note the asymmetry with the + * web, where a leading `/` is document-root-relative — here it stays an + * absolute device path, since that's the only way to reach a captured photo. + */ +internal fun nuiResolveImageSrc(src: String, context: Context): String { + if (src.isEmpty() || src.startsWith("/") || SCHEME.containsMatchIn(src)) { + return src + } + + return "${PHPBridge(context).getLaravelPath()}/public/${src.removePrefix("./")}" +} + +/** Leading URI scheme per RFC 3986 — `https:`, `file:`, `android.resource:`. */ +private val SCHEME = Regex("^[a-zA-Z][a-zA-Z0-9+.-]*:") diff --git a/resources/boost/guidelines/core.blade.php b/resources/boost/guidelines/core.blade.php index c929e29..fcf43fb 100644 --- a/resources/boost/guidelines/core.blade.php +++ b/resources/boost/guidelines/core.blade.php @@ -18,6 +18,14 @@ Use `.live` / `.blur` / `.debounce.Xms` modifiers to control sync frequency. - Wire callbacks with event attributes (`@tap`, `@change`, `@submit`, `@dismiss`) pointing at public methods on the component. +- Image sources — ``, and `leadingAvatar` / `leadingImage` + on `` — all resolve the same way: a remote URL + (`https://…`), a device file path (`/var/mobile/…/photo.jpg`, what the + camera and gallery hand you), or a RELATIVE path, which resolves against the + app's `public/` directory on device — `src="img/logo.png"` renders + `public/img/logo.png`. Unlike the web, a leading slash means a device + filesystem path, NOT the public root: write `img/logo.png`, never + `/img/logo.png`. - Text inputs also take `@selectionChange` for caret / selection reporting: the handler is called as `method(string $text, int $selectionStart, int $selectionEnd)` with offsets in Unicode code points (`start === end` for a diff --git a/resources/ios/NativeUIImageSource.swift b/resources/ios/NativeUIImageSource.swift new file mode 100644 index 0000000..5703803 --- /dev/null +++ b/resources/ios/NativeUIImageSource.swift @@ -0,0 +1,142 @@ +import SwiftUI +import UIKit + +/// Where an image `src` actually points. +/// +/// `src` reads like a web URL to whoever authored it, but SwiftUI has to know +/// whether to decode a file off disk or go out to the network — `AsyncImage` / +/// `URLSession` can't load `file://` or bare filesystem paths at all. +/// +/// Shared by every renderer that loads an image (the `` element, +/// the list-item avatar / thumbnail slots) so a path means the same thing +/// wherever it's written. +enum NativeUIImageSource { + /// A file on this device — load it through `NativeUIImageCache`. + case local(path: String) + /// A remote URL — stream through `AsyncImage`. + case remote(url: URL) + /// Empty, or a string that isn't a usable URL. + case unresolvable + + /// Resolution rules, in order: + /// - `file://…` URLs and absolute `/…` paths are device files — camera + /// captures, gallery picks, anything the app already holds a real path + /// for. + /// - Anything else carrying a URL scheme (`https:`, `data:`) is remote. + /// - Whatever is left is RELATIVE, which is what a web developer writes for + /// an asset shipped with the app, so it resolves against the Laravel + /// app's `public/` directory on device. Note the asymmetry with the web, + /// where a leading `/` is document-root-relative — here it stays an + /// absolute device path, since that's the only way to reach a captured + /// photo. + static func resolve(_ src: String) -> NativeUIImageSource { + if src.isEmpty { + return .unresolvable + } + if src.hasPrefix("file://") { + return .local(path: URL(string: src)?.path ?? String(src.dropFirst("file://".count))) + } + if src.hasPrefix("/") { + return .local(path: src) + } + if let url = URL(string: src), url.scheme != nil { + return .remote(url: url) + } + + let relative = src.hasPrefix("./") ? String(src.dropFirst(2)) : src + + return .local(path: AppUpdateManager.shared.getAppPath() + "/public/" + relative) + } +} + +/// Decoded-image cache for local files. +/// +/// `UIImage(contentsOfFile:)` re-reads the file on every call and SwiftUI +/// re-evaluates `body` freely, so a list scrolling through local thumbnails +/// would pay that cost per visible row, per evaluation, on the main thread. +/// Remote images get `URLCache` for free via `AsyncImage`; this is the +/// local-file equivalent. (Android needs no counterpart — Coil memory-caches +/// by model already.) +/// +/// Keyed on path + modification date + size, so overwriting a file in place — +/// re-taking an avatar to the same path, an update replacing a `public/` +/// asset — serves the new bytes instead of a stale image. +enum NativeUIImageCache { + private static let cache: NSCache = { + let cache = NSCache() + // ~4 full-screen 3x images, or several hundred list thumbnails. + // NSCache also purges itself under memory pressure. + cache.totalCostLimit = 64 * 1024 * 1024 + + return cache + }() + + static func image(atPath path: String) -> UIImage? { + guard let key = cacheKey(for: path) else { + return nil + } + if let cached = cache.object(forKey: key) { + return cached + } + guard let image = UIImage(contentsOfFile: path) else { + return nil + } + cache.setObject(image, forKey: key, cost: cost(of: image)) + + return image + } + + /// nil when the file doesn't exist, which costs one `stat` and caches + /// nothing — an asset that only appears later still loads when it does. + private static func cacheKey(for path: String) -> NSString? { + guard let attributes = try? FileManager.default.attributesOfItem(atPath: path), + let modified = attributes[.modificationDate] as? Date, + let size = attributes[.size] as? Int else { + return nil + } + + return "\(path)|\(modified.timeIntervalSince1970)|\(size)" as NSString + } + + /// Decoded footprint from metadata alone — reaching for `cgImage` here + /// would defeat UIKit's lazy decode. + private static func cost(of image: UIImage) -> Int { + Int(image.size.width * image.scale * image.size.height * image.scale) * 4 + } +} + +/// Avatar / thumbnail image for list rows: fills its frame, showing +/// `placeholder` while a remote image loads and when the source doesn't +/// resolve. Callers supply their own frame and clip shape. +struct NativeUIRowImage: View { + private let src: String + private let placeholder: () -> Placeholder + + init(src: String, @ViewBuilder placeholder: @escaping () -> Placeholder) { + self.src = src + self.placeholder = placeholder + } + + var body: some View { + switch NativeUIImageSource.resolve(src) { + case .local(let path): + if let uiImage = NativeUIImageCache.image(atPath: path) { + Image(uiImage: uiImage) + .resizable() + .scaledToFill() + } else { + placeholder() + } + case .remote(let url): + AsyncImage(url: url) { image in + image + .resizable() + .scaledToFill() + } placeholder: { + placeholder() + } + case .unresolvable: + placeholder() + } + } +} diff --git a/resources/ios/NativeUIListItemRenderer.swift b/resources/ios/NativeUIListItemRenderer.swift index 8ff3a55..f7fb1a0 100644 --- a/resources/ios/NativeUIListItemRenderer.swift +++ b/resources/ios/NativeUIListItemRenderer.swift @@ -137,9 +137,7 @@ struct NativeUIListItemRenderer: View { } case "avatar": // Decorative — the row's text content carries the meaning. - AsyncImage(url: URL(string: value)) { image in - image.resizable().scaledToFill() - } placeholder: { + NativeUIRowImage(src: value) { Circle().fill(Color(.systemGray5)) } .frame(width: 40, height: 40) @@ -158,9 +156,7 @@ struct NativeUIListItemRenderer: View { .accessibilityHidden(true) case "image": // Decorative — the row's text content carries the meaning. - AsyncImage(url: URL(string: value)) { image in - image.resizable().scaledToFill() - } placeholder: { + NativeUIRowImage(src: value) { RoundedRectangle(cornerRadius: 4).fill(Color(.systemGray5)) } .frame(width: 56, height: 56) diff --git a/resources/ios/NativeUISimpleRenderers.swift b/resources/ios/NativeUISimpleRenderers.swift index ccfc093..6ff762a 100644 --- a/resources/ios/NativeUISimpleRenderers.swift +++ b/resources/ios/NativeUISimpleRenderers.swift @@ -155,19 +155,19 @@ struct NativeUIImageRenderer: View { @ViewBuilder private func imageContent(src: String, contentMode: ContentMode, tintArgb: Int, cornerRadius: CGFloat) -> some View { - if src.isEmpty { - Color.clear - } else if let path = Self.localFilePath(for: src) { - // Local device file — camera capture, gallery selection, etc. + switch NativeUIImageSource.resolve(src) { + case .local(let path): + // Local device file — camera capture, gallery selection, or an + // asset shipped in the app's `public/` directory. // `AsyncImage`/`URLSession` can't load `file://` or bare - // filesystem paths, so decode directly with UIImage. Handles - // HEIC/HEIF transparently (UIImage decodes them natively). - if let uiImage = UIImage(contentsOfFile: path) { + // filesystem paths, so decode directly with UIImage (cached, and + // HEIC/HEIF handled transparently — UIImage decodes them natively). + if let uiImage = NativeUIImageCache.image(atPath: path) { tinted(Image(uiImage: uiImage), contentMode: contentMode, tintArgb: tintArgb, cornerRadius: cornerRadius) } else { Color.clear } - } else if let url = URL(string: src) { + case .remote(let url): // Remote URL (http/https) — load asynchronously. AsyncImage(url: url) { phase in switch phase { @@ -181,7 +181,7 @@ struct NativeUIImageRenderer: View { Color.clear } } - } else { + case .unresolvable: Color.clear } } @@ -229,19 +229,6 @@ struct NativeUIImageRenderer: View { } } - /// Resolves `src` to a local filesystem path when it points at an - /// on-device file (`file://…` URL or an absolute `/…` path), or nil - /// when it's a remote URL that should go through AsyncImage. - private static func localFilePath(for src: String) -> String? { - if src.hasPrefix("file://") { - return URL(string: src)?.path ?? String(src.dropFirst("file://".count)) - } - if src.hasPrefix("/") { - return src - } - return nil - } - private func resolveContentMode(_ fit: Int) -> ContentMode { switch fit { case 2: return .fill diff --git a/src/Elements/ListItem.php b/src/Elements/ListItem.php index 1abdb94..e4bb74d 100644 --- a/src/Elements/ListItem.php +++ b/src/Elements/ListItem.php @@ -4,6 +4,7 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; +use Native\Mobile\Edge\ImageSource; use Native\Mobile\Edge\Layouts\Builders\NavAction; use Native\Mobile\Icon\AndroidSymbol; use Native\Mobile\Icon\IconResolver; @@ -310,7 +311,7 @@ public function leadingIcon( public function leadingAvatar(string $url): static { $this->listItemProps['leading_type'] = 'avatar'; - $this->listItemProps['leading_value'] = $url; + $this->listItemProps['leading_value'] = ImageSource::forDevice($url); return $this; } @@ -329,7 +330,7 @@ public function leadingMonogram(string $initials, ?string $color = null): static public function leadingImage(string $url): static { $this->listItemProps['leading_type'] = 'image'; - $this->listItemProps['leading_value'] = $url; + $this->listItemProps['leading_value'] = ImageSource::forDevice($url); return $this; } diff --git a/tests/ListItemImageSourceTest.php b/tests/ListItemImageSourceTest.php new file mode 100644 index 0000000..ae54155 --- /dev/null +++ b/tests/ListItemImageSourceTest.php @@ -0,0 +1,49 @@ +`: a relative path means an asset in `public/`. On device the + * native renderer resolves it; under Jump `public/` only exists on the dev + * machine, so core's ImageSource rewrites it to a URL the phone can fetch. + * + * The only file in this suite that needs a booted app — the Jump branch calls + * `asset()`, which needs a URL generator. + */ +uses(TestCase::class); + +afterEach(function () { + putenv('JUMP_BRIDGE_PORT'); +}); + +function leadingValue(ListItem $item): string +{ + return $item->toArray(new CallbackRegistry)['props']['leading_value']; +} + +it('leaves relative avatar and image paths for the renderer on device', function () { + putenv('JUMP_BRIDGE_PORT'); + + expect(leadingValue(ListItem::make()->leadingAvatar('img/ada.png')))->toBe('img/ada.png') + ->and(leadingValue(ListItem::make()->leadingImage('img/cover.png')))->toBe('img/cover.png'); +}); + +it('rewrites relative avatar and image paths to fetchable URLs under Jump', function () { + putenv('JUMP_BRIDGE_PORT=3002'); + + expect(leadingValue(ListItem::make()->leadingAvatar('img/ada.png')))->toBe(asset('img/ada.png')) + ->and(leadingValue(ListItem::make()->leadingImage('img/cover.png')))->toBe(asset('img/cover.png')); +}); + +it('leaves device paths and remote URLs alone under Jump', function () { + putenv('JUMP_BRIDGE_PORT=3002'); + + $captured = '/var/mobile/Containers/Data/Application/x/tmp/photo.jpg'; + + expect(leadingValue(ListItem::make()->leadingAvatar($captured)))->toBe($captured) + ->and(leadingValue(ListItem::make()->leadingImage('https://example.com/cover.png'))) + ->toBe('https://example.com/cover.png'); +}); From 3ee87bc73f531fddd0b9732c56b774046ba0b86a Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Thu, 20 Aug 2026 12:50:47 +0100 Subject: [PATCH 2/2] Install nativephp/mobile from Packagist now v4 has shipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the dev-element scaffolding: the MOBILE_AIR_TOKEN auth step, the VCS repository, and the stability tweaks. The committed composer.json was always clean — all of that was CI-only mutation — so the `^4.0` constraint now resolves 4.2.0 straight from Packagist, and forks build the same way we do. `update` rather than `install`: composer.lock is gitignored, so there is never a lock file to install from. The 8.4 pin stays (8.3 still fails to resolve) but its comment was stale — core declares `php ^8.4` itself now, rather than inheriting it from endroid/qr-code. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/tests.yml | 36 ++++++++++-------------------------- 1 file changed, 10 insertions(+), 26 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a4a849a..acbb045 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,38 +16,22 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - # 8.4+ is required: nativephp/mobile pulls in endroid/qr-code ^6.1.3, - # which needs PHP ^8.4, so the dependency chain won't resolve on 8.3. + # 8.4+ is required: nativephp/mobile v4 declares php ^8.4 itself, + # so the dependency chain won't resolve on 8.3. php-version: '8.4' coverage: none # Unlike the leaf plugins, this suite exercises nativephp/mobile at # runtime (the Edge element collector, elements, facades), so CI must - # actually install it. Until v4 is released we build against the - # mobile-air `element` branch (composer constraint `dev-element`); - # after release, replace the require below with a normal `^4.0` pin - # and drop the VCS repository + stability tweaks. + # actually install it. Now that v4 has shipped, the `^4.0` constraint + # in composer.json resolves straight from Packagist — no VCS + # repository, stability tweaks or access token involved, so forks + # build the same way we do. # - # NativePHP/mobile-air is private, so cloning is authenticated with - # the MOBILE_AIR_TOKEN repo secret (a PAT / fine-grained token with - # read access to NativePHP/mobile-air). If the secret is absent the - # step is skipped so public forks still get a clear failure. - - name: Configure private repo auth - env: - TOKEN: ${{ secrets.MOBILE_AIR_TOKEN }} - run: | - if [ -n "$TOKEN" ]; then - composer config --global github-oauth.github.com "$TOKEN" - else - echo "MOBILE_AIR_TOKEN not set — assuming public access to nativephp/mobile" - fi - - - name: Install dependencies (mobile-air @ dev-element) - run: | - composer config minimum-stability dev - composer config prefer-stable true - composer config repositories.mobile-air vcs https://github.com/NativePHP/mobile-air.git - composer require "nativephp/mobile:dev-element" --no-interaction --with-all-dependencies + # `update` rather than `install`: composer.lock is gitignored, so + # there is never a lock file to install from. + - name: Install dependencies + run: composer update --prefer-dist --no-interaction --no-progress - name: Run tests run: ./vendor/bin/pest --colors=always