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
36 changes: 10 additions & 26 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion SHIPPING-CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<canvas>` / `<shapes>` (engine)
Expand Down
7 changes: 6 additions & 1 deletion resources/android/ImageRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,14 +23,17 @@ 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))
} else modifier

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 },
Expand Down
11 changes: 9 additions & 2 deletions resources/android/ListItemRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
35 changes: 35 additions & 0 deletions resources/android/NativeUIImageSource.kt
Original file line number Diff line number Diff line change
@@ -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
* `<native:image>` 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` → `<laravel>/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+.-]*:")
8 changes: 8 additions & 0 deletions resources/boost/guidelines/core.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 — `<native:image src>`, and `leadingAvatar` / `leadingImage`
on `<native:list-item>` — 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
Expand Down
142 changes: 142 additions & 0 deletions resources/ios/NativeUIImageSource.swift
Original file line number Diff line number Diff line change
@@ -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 `<native:image>` 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<NSString, UIImage> = {
let cache = NSCache<NSString, UIImage>()
// ~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<Placeholder: View>: 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()
}
}
}
8 changes: 2 additions & 6 deletions resources/ios/NativeUIListItemRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
31 changes: 9 additions & 22 deletions resources/ios/NativeUISimpleRenderers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -181,7 +181,7 @@ struct NativeUIImageRenderer: View {
Color.clear
}
}
} else {
case .unresolvable:
Color.clear
}
}
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading