blutrixx / nativephp-device-utils
Native device utilities for NativePHP Mobile — system insets, file picking, and app storage access
Package info
github.com/joelnjoshkibona/nativephp-device-utils
Language:Kotlin
Type:nativephp-plugin
pkg:composer/blutrixx/nativephp-device-utils
Requires
- php: ^8.1
- nativephp/mobile: ^3.3 || ^4.0
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A NativePHP Mobile plugin exposing device-level primitives that don't belong to any one app: safe-area insets, the system file picker, app-private storage, runtime permission requests, and a camera overlay for photo capture or continuous barcode/QR scanning.
The routes, controller, and JS composables are all part of this package too (see below) — a consuming app needs zero PHP or JS code of its own to use scan, photo, warm, or requestPermissions. getInsets/pickFile/copyToStorage don't have a shipped route yet — call the facade directly from a route of your own (see the API reference).
Composer package: blutrixx/nativephp-device-utils
Repo: joelnjoshkibona/nativephp-device-utils
Current release: v1.3.0
Upgrading an existing consumer from before v1.3.0? Delete your app's own NativeDeviceController (or equivalent) and its /device/scan, /device/camera/warm, /device/photo, /device/photo/read, /device/request-permissions route lines — this package now registers those same paths itself. Leaving both in place means two controllers answer the same route; Laravel resolves to whichever registers first, which is confusing to debug and not a state you want to be in.
Photos that survive the app being killed (v1.5.0)
On a phone with little memory Android can kill the app while the system picker (gallery) is in front. The picked photo then reaches a page that has not loaded yet, and the PhotoCaptured event is lost. Now every photo, from the camera or the gallery, is saved natively (files/smart_camera_pending/) before the event is sent, and PhotoCaptured carries its id. A photo nobody confirms is deleted after 48 hours.
capturePhoto()acknowledges the photo for you once the page has received it. Nothing to change in an existing app.- New: call
recoverPhotos()when a page that uses the camera mounts. It returns the photos that never arrived ({ base64, mimeType, id, width, height }[]) and you save them like a normal capture; callackPhoto(id)after saving each one. - PHP:
SmartCamera::pending(),SmartCamera::take($id),SmartCamera::ack($id); routesPOST /device/photo/pending,/device/photo/take,/device/photo/ack. - Also fixed: when Android restored the camera fragment after killing the app, it opened the gallery a second time. It now relies on the result Android already holds.
- Also new:
SmartCamera.Openhides the soft keyboard and blurs the page before the camera or gallery appears.
Tested on a Telpo M1K (Android 12): gallery opened, app process killed with the picker open, photo picked, app restarted. The photo (73 KB) was found in the pending folder, collected by the page on load, shown in the editor, acknowledged, and the pending folder was empty. A normal pick without a kill arrived live and was acknowledged too. No second picker opened.
Voice notes, files and camera errors (v1.7.0)
-
DeviceUtils::voiceStart(),voiceStop(),voiceCancel(),voiceLevel(): record a voice note with the microphone into the app's privatehodie_importfolder (AAC in.m4a, mono, 64 kbit/s).voiceStart()answers the error codeNO_PERMISSIONuntilRECORD_AUDIOhas been granted (ask withrequestPermissions()first).voiceStop()returns{path, filename, seconds, size}. RoutesPOST /device/voice/{start,stop,cancel,level}. Android lets only an app in the foreground use the microphone, so a recording ends when the app is left. -
DeviceUtils::openFile($path, $mime, $name, $folder = 'Hodie'): open a file from the app's private storage with the app that handles its type. Other apps cannot read the private folder, so the file is copied once intoDownloads/<folder>(MediaStore, no storage permission) and opened from there. Needs Android 10 or newer; errorsNO_APP,UNSUPPORTED,BAD_PATH. -
File picker: the picked document is copied into the app's
pending_filesfolder the moment the picker returns, andFileSelectedcarries{id, path, filename, mimeType, size}(the real type). If the page was not there to receive it,DeviceUtils::filePending()lists it (POST /device/file/pending) andfileAck($id)(/device/file/ack) deletes the copy once the app has stored its own; copies nobody confirms go after 48 hours.
Tested on an Android 16 emulator and an Infinix (Android 10): permission dialog, a 12 second recording (101 KB), a PDF opened through the system "Open with" list.
Logs (v1.8.0)
The app's own log, kept on the phone and exportable, so a crash or an odd behaviour can be followed up afterwards. A text file in private storage (files/applog/app.log, three files of 400 KB) written by native code, so it works when PHP or the web view is dead. A content provider starts it when the process starts and installs a crash handler, so crashes (also early ones) are recorded and the next start can say so.
- PHP:
AppLog::info('places', 'tracking on'),warn,error,exception($e),read($limit, $level),clear(),crash()(the crash that ended the last run, once),setDetail(bool)andexport($share = true, $folder = null). Every exception Laravel reports is also written (class, file and line, a message of at most 200 characters with any SQL removed). Without the bridge (a development server, tests) the same format is written understorage/app/applog. - JS:
useAppLog()(resources/js/useAppLog.ts), or plainPOST /device/log,/device/log/read,/clear,/crash,/detail,/export. It records JavaScript errors and unhandled rejections by itself. - Export: saves a text file in Downloads/ (MediaStore, no permission) and opens Android's share sheet. Nothing leaves the phone unless the person chooses where it goes.
- Other plugins can write with
AppLog.write(level, tag, message)(a static Kotlin method; by reflection if they do not want a dependency).AppLog.detail()says whether the person switched on detailed place logging. - Rule: never write personal content (note text, amounts, names, PINs, coordinates). Times, counts, error codes and names of processes only.
Phone status (v1.9.0)
DeviceUtils::getStatus() (POST /device/status) is one reading of the phone: battery percent and charging (and how: usb, ac, wireless), network (wifi, mobile, ethernet, none), mobile generation (2G to 5G, left out when Android does not say: from Android 11 it needs a permission this plugin does not take), signal level 0 to 4 (the wifi joined, or the mobile radio), power saving, airplane mode, Android version and API level, model, and free and total storage. Nothing that identifies the person or the place: no wifi name, no phone number, no SIM or cell identifiers. A part the phone refuses is left out, never an error. Adds the permissions ACCESS_NETWORK_STATE and ACCESS_WIFI_STATE (both granted at install, no prompt).
com.blutrixx.device.utils.DeviceStatus.snapshot(context) is the short form {"b":85,"c":1,"n":"w","g":"4G","s":3,"p":0} (battery, charging, network w/m/e/x, generation, signal, power saving) that another plugin can attach to its own records by reflection; blutrixx/nativephp-geo-location 1.2.0 attaches it to each location fix, so a reading is kept with every fix, with the app closed too.
Area names (v1.10.0)
DeviceUtils::geocode($lat, $lng, $lang = null) (POST /device/geocode) asks Android's own Geocoder for the name of the area at a position: street, area (Android's sub-locality: a neighbourhood or ward where the data has one), town, district, region, country, countryCode, feature. Missing parts are left out; nothing found is {found: false}; a failure says why in error (NO_GEOCODER, NETWORK, TIMEOUT, SERVICE, BAD_POSITION). No key or account. It needs the internet and the position leaves the phone (Google's service on most phones), so name only what the person agreed to, and keep the answer instead of asking twice. It waits up to 10 seconds: call it from PHP, not in a loop.
The on-screen keyboard (v1.7.0)
On Android 10 the keyboard opens on top of the web view without resizing it, so it hides the lower part of a sheet or a form and the field you are typing in. DeviceUtils::getKeyboard() (POST /device/keyboard) says how much of the screen it covers, {visible, height} in dp. useKeyboard() (resources/js/useKeyboard.ts) uses it for you: it sets the CSS variable --kb on <html> (px, 0px while the keyboard is down) and --kb-vh (the height left above it), calls you back on every change, and scrolls the field being typed in to the middle of the visible part. Use the variable: .sheet { bottom: var(--kb, 0px) }, .page { padding-bottom: var(--kb, 0px) }. Where Android cannot answer it falls back to an estimate and says so (exact: false).
Requirements
- PHP ^8.1
- A Laravel app running under
nativephp/mobile^3.3 (enforced bycomposer.json; 4.x is not supported yet) - Android: adds the
android.permission.CAMERApermission and thecom.google.mlkit:barcode-scanning:17.2.0dependency to your app's build. Camera hardware is declaredrequired: false— the plugin degrades gracefully on devices without one, butSmartCameracalls will fail on those devices.
Installation
This package isn't on Packagist yet. Two ways to pull it in:
As a git submodule (how this repo itself consumes it):
git submodule add https://github.com/joelnjoshkibona/nativephp-device-utils.git packages/nativephp-device-utils
// composer.json { "repositories": [ {"type": "path", "url": "packages/nativephp-device-utils"} ], "require": { "blutrixx/nativephp-device-utils": "@dev" } }
Without a submodule, pointing straight at GitHub:
{
"repositories": [
{"type": "vcs", "url": "https://github.com/joelnjoshkibona/nativephp-device-utils"}
],
"require": {
"blutrixx/nativephp-device-utils": "^1.0"
}
}
Laravel auto-discovers DeviceUtilsServiceProvider — no manual registration needed. Its boot() loads this package's own routes/web.php (registered under the web middleware group), which wires /device/scan, /device/camera/warm, /device/photo, /device/photo/read, and /device/request-permissions straight to Http\Controllers\DeviceUtilsController — nothing to add to your own routes/web.php.
How the bridge works
Every call goes Vue → this package's own route → this package's facade → nativephp_call() → native Android code. Two shapes:
- Synchronous (
getInsets,copyToStorage): the native side does the work and returns a result immediately — a route can justreturnit. (These don't have a shipped route yet — call the facade directly from a route of your own, e.g.Route::get('/device/insets', fn () => DeviceUtils::getInsets());.) - Asynchronous (
pickFile,requestPermissions,SmartCamera::open): the call returns{launched: true}right away because the native side is opening a picker/overlay/dialog the user has to interact with. The actual result arrives later as anative-eventDOMCustomEventfired directly into your WebView — your frontend needs a listener for it, correlated where noted below.
This package ships the canonical JS-side listener composables for requestPermissions, SmartCamera::open('scan'), and SmartCamera::open('photo'|'gallery') directly, under resources/js/ — you don't need to hand-write the event-correlation logic yourself, and every consuming app stays on the same, race-free implementation instead of drifting apart. (pickFile/copyToStorage don't have shipped composables yet — the pattern below is a template for wiring those, or any future async bridge call, yourself.)
Wire up the alias (Vite resolves vendor/ paths fine — no build step or publish command needed; composer update alone keeps consuming apps current):
// vite.config.ts import path from 'node:path' export default defineConfig({ resolve: { alias: { '@blutrixx/device-utils': path.resolve(__dirname, './vendor/blutrixx/nativephp-device-utils/resources/js'), }, }, })
// your own resources/js/composables/useScanner.ts — a thin re-export, so // existing imports from '@/composables/useScanner' keep working unchanged export * from '@blutrixx/device-utils/useScanner'
import { useScanner } from '@/composables/useScanner' const { scan } = useScanner() const { data, format } = await scan()
If you consume this package via a composer "path" repository (local development — this shell's own composer.json does this), composer installs it as a symlink into vendor/, not a copy. Vite/Rollup then resolves this package's own imports (axios, inside useScanner.ts etc.) against the symlink's real target directory instead of your app's node_modules, and the build fails with Rollup failed to resolve import "axios". Fix: add resolve.preserveSymlinks: true to vite.config.ts. A real install (this package pulled via its GitHub VCS repo/tag, the normal case for a generated project) is a plain copy and needs no such flag.
The one rule every one of these composables follows, and yours should too if you add another: register the pending Promise in its correlation map before firing the axios.post(...) that starts the native call, not after await-ing its response. The native side can dispatch its result event in single-digit milliseconds — faster than the HTTP round-trip through the WebView resolves — and a listener that isn't registered yet silently drops the event. Confirmed live: this exact ordering bug made every scan()/capturePhoto() call eat the full 30-second requestPermissions() timeout before proceeding anyway, even when permission was already granted.
For requestPermissions specifically: generate a crypto.randomUUID() client-side, send it as id, and match it against the id field on the incoming PermissionsResult event — the native side supports multiple concurrent permission requests this way (already handled inside useDevicePermissions). pickFile and SmartCamera calls don't take an id; only one file-pick or one camera overlay can be open at a time.
API reference
Blutrixx\DeviceUtils\Facades\DeviceUtils
| Method | Sync? | Returns | Notes |
|---|---|---|---|
getInsets() |
Sync | array (status bar / nav bar heights in dp) |
|
geocode(float $lat, float $lng, ?string $lang = null) |
Sync (up to 10 s) | {found, street?, area?, town?, district?, region?, country?, countryCode?, feature?, error?} |
The area at a position through Android's Geocoder (v1.10.0); needs the internet, the position goes to the service; POST /device/geocode |
getStatus() |
Sync | {battery, charging, plug, batteryTemp, network, generation, signal, powerSave, airplane, android, sdk, model, manufacturer, storageFree, storageTotal} |
One reading of the phone's state (v1.9.0); POST /device/status; see Phone status below |
getKeyboard() |
Sync | {visible, height} (dp) |
How much of the screen the on-screen keyboard covers; see useKeyboard below |
pickFile(string $mimeType = 'application/pdf') |
Async | {launched: true} |
Fires FileSelected or FilePickCancelled |
copyToStorage(string $uri, string $filename, string $subfolder = 'books') |
Sync | {path, filename, size} |
Copies a SAF content URI into app-private storage |
requestPermissions(array $permissions, ?string $id = null) |
Async | {launched: true} |
Fires PermissionsResult, correlated by $id |
use Blutrixx\DeviceUtils\Facades\DeviceUtils; DeviceUtils::getInsets(); DeviceUtils::pickFile('application/pdf'); DeviceUtils::copyToStorage($uri, 'invoice.pdf', 'documents'); DeviceUtils::requestPermissions(['android.permission.CAMERA'], id: $requestId);
Blutrixx\DeviceUtils\Facades\SmartCamera
| Method | Sync? | Returns | Notes |
|---|---|---|---|
open(string $mode = 'scan', string $quality = 'high', bool $multiple = false, bool $autoClose = true) |
Async | {launched: true} |
mode: 'scan' decodes barcodes/QR and fires ScanResult per read (or ScanResults, plural, if $multiple, once the user taps Done); mode: 'photo' shows a shutter button and fires PhotoCaptured on capture. Either mode fires CaptureCancelled if the user backs out. quality ('high'|'medium'|'low') only affects 'photo' mode's JPEG output. $multiple/$autoClose apply to 'scan' mode only — see ScanOptions in useScanner.ts. |
warm() |
Async, fire-and-forget | {warming: true} |
Pre-binds CameraX in the background so a later open() shows its overlay faster — call once when a page that might scan/capture mounts. Fires no event; nothing to listen for. |
close() |
Sync | array |
Dismisses the overlay if one is open |
use Blutrixx\DeviceUtils\Facades\SmartCamera; SmartCamera::open(mode: 'scan'); // barcode/QR scanning SmartCamera::open(mode: 'photo', quality: 'medium'); SmartCamera::close();
Events
All under Blutrixx\DeviceUtils\Events\*:
| Event | Fired by | Payload |
|---|---|---|
FileSelected |
pickFile() |
file details from the SAF picker |
FilePickCancelled |
pickFile() |
— (user backed out) |
PermissionsResult |
requestPermissions() |
grant results, correlated by id |
PhotoCaptured |
SmartCamera::open(mode: 'photo') |
{path, base64, width, height, size} |
CaptureCancelled |
SmartCamera::open(...) |
— (user closed the overlay, or the camera could not be used: see CaptureFailed) |
CaptureFailed |
SmartCamera::open(...) |
{code, reason} — the camera opened and Android then refused it (camera_disabled, camera_in_use, max_cameras_in_use, camera_fatal, do_not_disturb, stream_config, bind_failed, camera_error). Sent once, just before CaptureCancelled, so a listener that only handles CaptureCancelled still stops waiting. The overlay closes itself instead of staying on a black screen. |
ScanResult |
SmartCamera::open(mode: 'scan') |
{value, format} — format is one of QR_CODE, EAN_13, EAN_8, UPC_A, UPC_E, CODE_128, CODE_39, DATA_MATRIX |
ScanResults |
SmartCamera::open(mode: 'scan', multiple: true), once the user taps Done |
{results: [{value, format}, ...]} |
Quick start: scan a barcode
No PHP to write — /device/scan already exists once this package is installed. Just wire the alias (see "Wire up the alias" above), add the one-line re-export composable file, and call it:
// Vue -- useScanner() is shipped by this package, see "Wire up the alias" above import { useScanner } from '@/composables/useScanner' const { scan, scanning } = useScanner() const { data, format } = await scan() console.log(`Scanned ${format}: ${data}`)