Quick Capture Widget
Capture before the thought is gone.
$ composer require all1web/nativephp-quick-capture-widget
A home-screen widget for quick note, photo and voice capture, plus a dynamic “Search” app shortcut. It piggybacks on the Share Target native inbox, so an app already using that pipeline needs zero changes.
- Note, photo and voice capture straight from the home screen
- Dynamic “Search” app shortcut
- Reuses the Share Target inbox — no new capture pipeline
Documentation
nativephp-quick-capture-widget
A home-screen widget for capturing a note, a photo, or a voice memo in one tap — before the app is even open. Plus a "Search" app shortcut reachable by long-pressing the launcher icon.
Three buttons, "Note", "Photo", and "Voice", live on the Android home screen. Tap one and you're either typing straight into the app's capture dialog or looking at the system camera / voice recorder; either way the result lands in the app's normal inbox with no extra plumbing.
Android only, v1. There is no iOS implementation —
nativephp.jsondeclares an empty"ios": {}block and noresources/ios/directory exists, so the NativePHP iOS compiler skips this plugin entirely.
How the piggyback works
This plugin does not implement its own capture pipeline. It writes
directly into the native inbox owned by
all1web/nativephp-share-target
— the same com.all1web.plugins.sharetarget.ShareTargetInbox object that plugin's own
ShareTargetActivity (the OS share-sheet trampoline) writes into.
- Tap Note →
QuickCaptureActivity(no UI) enqueues an item with empty text and markersubject = "widget-quick-note", then bringsMainActivityto the foreground exactly the wayShareTargetActivitydoes. - Tap Photo →
QuickCapturePhotoActivity(transparent) firesMediaStore.ACTION_IMAGE_CAPTUREinto aFileProviderURI, and onRESULT_OKcopies the result into the same inbox viaShareTargetInbox.copyUri()— markersubject = "widget-quick-photo"— then brings the app to the foreground the same way.RESULT_CANCELEDjust finishes; nothing is enqueued. - Tap Voice →
QuickCaptureVoiceActivity(transparent) firesMediaStore.Audio.Media.RECORD_SOUND_ACTION(noFileProvideroutput — the recorder app owns the file and hands back its own contentUri), and onRESULT_OKcopies the result into the same inbox the same way — markersubject = "widget-quick-voice",mimeTyperead viacontentResolver.getType()(falls back toaudio/mp4if a resolver ever declines to report one).RESULT_CANCELED(or no camera/recorder app installed at all) just finishes; nothing is enqueued.
Because all three paths write the exact same shape of item the share-target
plugin already produces for an OS share, your app's existing loader
fast-path → capture dialog → upload flow handles all three widget buttons
with zero app-side code changes (the server-side capture pipeline already
accepts audio/*, same as image/*). Drain them the normal way:
use All1web\ShareTarget\Facades\ShareTarget;
foreach (ShareTarget::pending() as $item) {
match ($item->subject) {
'widget-quick-note' => $capture->openBlank(),
'widget-quick-photo' => $capture->files($item->filePaths()),
'widget-quick-voice' => $capture->files($item->filePaths()),
default => $capture->handle($item), // a real OS share
};
}
If you don't care about distinguishing a widget tap from a real share, ignore
subject entirely and just branch on $item->type() as usual.
OEM quirk worth knowing at test time: unlike the camera (
ACTION_IMAGE_CAPTURE, which effectively every device answers), not every OEM ships an app that answersRECORD_SOUND_ACTION. Stock/AOSP-adjacent builds (Pixel, most Android-One devices) generally do; some other OEM skins don't ship a dedicated "sound recorder" app at all, or only expose recording from inside another app that doesn't register for this action. Per the "neverresolveActivity()" rule,QuickCaptureVoiceActivityjust fires the intent and catchesActivityNotFoundException— on an affected device, tapping Voice silently logs a warning and returns to the home screen with nothing enqueued (no crash, no dialog). Test the Voice button on a real stock-Android device, not just the emulator image you happen to have handy.
What you get
- A compact 3-button
AppWidgetProviderwidget (res/layout/widget_quick_capture.xml), registered as a system receiver withAPPWIDGET_UPDATE+ itsappwidget-providermetadata XML. - Three trampoline activities, Android-12-safe (
FLAG_IMMUTABLEPendingIntents), none exported (they're only ever launched by this app's own widget). - A scoped
FileProvider(cache-only, one subdirectory) for the camera output URI — noCAMERApermission requested;ACTION_IMAGE_CAPTUREis an implicit intent handled by whatever camera app is installed. The Voice path needs noFileProviderof its own — the recorder app owns its output file and hands back a contentUriwe're granted a one-shot read on. - A dynamic "Search" app shortcut (long-press the launcher icon, pinnable) — see The Search app shortcut below.
All1web\QuickCaptureWidget\Facades\QuickCaptureWidget::isAdded()/::count()— report whether the widget is on a home screen, for onboarding UX ("hey, did you know you can add our widget?").All1web\QuickCaptureWidget\Facades\QuickCaptureWidget::publishShortcuts()/::drainPendingRoute()— the two bridge calls behind the Search shortcut.
The Search app shortcut
Long-pressing the app's launcher icon shows a "Search" entry (a standard Android app shortcut, pinnable to the home screen by the user from there).
Why this is a ShortcutManager dynamic shortcut, not a classic
res/xml/shortcuts.xml + <meta-data> static one: a static shortcut is
declared via a <meta-data android:name="android.app.shortcuts"> element on
the app's own LAUNCHER-category main activity — MainActivity, which this
plugin doesn't own and can't patch (a plugin's manifest compiler only knows
how to emit its own new components, not attach children onto a component
declared elsewhere). Publishing a dynamic shortcut at runtime via
ShortcutManager needs no manifest ownership of MainActivity at all, and
dynamic shortcuts already show up in the long-press menu and are pinnable
from there — so this sidesteps the manifest-surgery problem entirely, at the
cost of the shortcut only existing once the app has run at least once after
install (a <meta-data> static shortcut would be visible immediately after
install, before first launch; a dynamic one needs the plugin's service
provider to have booted once, which in practice means the very first app
launch on the device).
How it's wired:
QuickCaptureWidget::publishShortcuts()— called automatically fromQuickCaptureWidgetServiceProvider::boot()on every request when running on-device (it no-ops off-device). It calls theQuickCaptureWidget.PublishShortcutsbridge function, whichShortcutManager.setDynamicShortcuts()s a single "Search" shortcut pointing at theQuickCaptureSearchActivitytrampoline. Idempotent — republishing the same shortcut id every boot is harmless.- Tapping the shortcut launches
QuickCaptureSearchActivity(no UI,exported="true"because the system launcher process — not this app — is what starts it). It writespending_route = "/search?focus=1"into a small SharedPreferences mailbox and bringsMainActivityto the foreground, exactly like the widget's own trampolines. QuickCaptureWidget::drainPendingRoute(): ?string— call this once during your app's normal boot / loader sequence (same place you'd drainShareTarget::pending()). It reads-and-clears the pending route atomically, so a stale route is never re-applied on a later cold start. Returnsnulloff-device or when nothing is waiting.
use All1web\QuickCaptureWidget\Facades\QuickCaptureWidget;
if ($route = QuickCaptureWidget::drainPendingRoute()) {
// e.g. $route === '/search?focus=1'
return redirect($route);
}
Install
This is a private, unpublished plugin (see License below) — there is no marketplace listing. Install from local path repositories.
Both this plugin and its dependency, all1web/nativephp-share-target,
must be registered as path (or VCS) repositories in the consuming app's own
composer.json — Composer does not inherit repositories entries declared
inside a dependency's composer.json, so declaring the share-target
repository here only helps when developing/testing this plugin standalone.
In the app's composer.json:
{
"repositories": [
{ "type": "path", "url": "../nativephp-share-target" },
{ "type": "path", "url": "../nativephp-quick-capture-widget" }
]
}
(Adjust the relative paths to wherever the two checkouts actually sit next to the app.)
Then, from the app:
composer require all1web/nativephp-share-target:"*@dev" all1web/nativephp-quick-capture-widget:"*@dev"
php artisan native:plugin:register all1web/nativephp-share-target
php artisan native:plugin:register all1web/nativephp-quick-capture-widget
php artisan native:install android --force
php artisan native:run android
The rebuild matters: Android decides which app owns a widget class, its manifest receiver/activities, and its FileProvider authority at install time — the widget only becomes pickable from the home screen's widget picker after this rebuild, not just a re-run of an old build.
Both native:plugin:register calls matter and in either order is fine, but
both plugins must be registered — this plugin's Kotlin trampolines import
com.all1web.plugins.sharetarget.ShareTargetInbox directly, so if share-target isn't
registered the Android build will fail to resolve that class.
Use it
Nothing to call to receive a capture — see How the piggyback
works above; drain ShareTarget::pending() as you
already do.
Check whether the widget has been added (e.g. to show a "add our widget!" onboarding tip only to users who haven't):
use All1web\QuickCaptureWidget\Facades\QuickCaptureWidget;
if (! QuickCaptureWidget::isAdded()) {
// show the tip
}
Requirements
- NativePHP Mobile
^4.0 all1web/nativephp-share-targetregistered in the same app- Android 8.0+ (minSdk 26)
- No runtime permissions needed — camera capture and voice recording both use
implicit intents (
ACTION_IMAGE_CAPTURE,RECORD_SOUND_ACTION) handled by whatever camera/recorder app is installed, not theCAMERAorRECORD_AUDIOpermission held by this app
Deviations from the nativephp-share-target reference conventions
Documented here because this plugin was built by mirroring that plugin's structure closely — these are the three places it intentionally diverges, and why:
- The FileProvider is declared in a legacy
resources/android/AndroidManifest.xmloverride, not innativephp.json'sandroid.providersarray.AndroidPluginCompiler::buildProviderEntry()(innativephp/mobile) only emitsandroid:name/android:authorities/android:exported/android:grantUriPermissionsfor JSON-declared providers — it does not support a nested<meta-data>child the way its receiver/service builders do. AFileProviderwithout itsFILE_PROVIDER_PATHSmeta-data throws at first use, so the whole<provider>block (meta-data included) lives in the legacy XML file instead, where the compiler'sextractManifestEntries()captures full component blocks verbatim. res/layout,res/xml, andres/drawablefiles are declared via the manifest's"assets"key, not via acopy_assetshook command (noCopyAssetsCommandexists in this plugin).PluginHookRunner::copyManifestAssets()already copies anyassets.androidentry whose destination starts withres/straight intoapp/src/main/res/...before hooks run, so a hook command would be redundant for plain, unconditional file copies.nativephp/mobileis pinned to^4.0only (share-target supports^3.0 || ^4.0) — this plugin was commissioned specifically as a v4 plugin.
Everything else — composer.json shape, PSR-4 namespace under All1web\
matching the all1web/ package vendor, src/Facades/, bridge-function/manifest
conventions, resources/boost/guidelines/core.blade.php — mirrors
nativephp-share-target directly. (Prior to v0.2.0 the PHP namespace and
Kotlin package predated that identity unification — see
UPGRADING.md if you're on an older checkout.)
License
Private / commercial line. Not published to Packagist or the NativePHP Plugin Marketplace. Source access is for internal development on this app only — do not publish, redistribute, or make this repository public.