NativePHP Widgets#
Cross-platform home screen widgets for NativePHP Mobile, defined as PHP classes and rendered natively with WidgetKit on iOS and Jetpack Glance on Android.
| Platform | Status |
|---|---|
| Android 12+ (API 31+) | Supported and verified on physical hardware |
| iOS 18+ | Supported through a generated WidgetKit extension; static checks and the shared Swift core are automated |
What it will not do is written down plainly, with the measurement behind every entry.
Before you install: a fresh clone does not build with push enabled, because of five unreleased defects in
nativephp/mobile. One of them stops the Gradle build outright. See Installation — it is fivepatchcommands and it is not optional.
Quickstart#
Ten lines, and they run as written.
namespace App\Widgets; use RubenVdB\Widgets\Widget;use RubenVdB\Widgets\WidgetContext; class SalesWidget extends Widget{ public string $key = 'sales'; public function data(?WidgetContext $context): array { return ['title' => 'Revenue today', 'value' => '€ 1,240']; }}
php artisan native:run android or php artisan native:run ios and that class
is in the platform widget picker, named "Sales", placeable, and rendering those two strings. Everything
else — the name, the description, the sizes, the refresh interval, the layout —
has a default, and every default is overridable.
Pushing new values into it later is one call, from anywhere in the application:
use RubenVdB\Widgets\Facades\Widget; Widget::update('sales', ['title' => 'Revenue today', 'value' => '€ 9,999']);
Or skip the writing entirely and generate a complete, working widget:
php artisan native:widget:make Water --recipe=counter
That one is a daily counter with a goal ring, step buttons, a destructive reset
with a confirmation and an undo window, and a midnight reset that needs no
server and no PHP process. Seventeen recipes ship;
php artisan native:widget:make --recipes lists them.
What it does, and what it does not#
It does:
| Render a widget from a PHP class | 48 layout components, a typed fluent API, a five-layer theme system, charts and gradients rasterised on device |
| Keep working with the app closed | Countdowns, streaks, "days since", greetings, daily resets are compiled expressions the device re-evaluates every repaint. Force-stop the app for a week and the numbers are still right |
| Answer a tap in ~200 ms with no PHP alive | Optimistic local state, redraw, queue, reconcile. Measured at 187 ms with the process dead |
| Run PHP in the background | An ephemeral interpreter from a WorkManager worker, ~1.2 s cold on a release build |
| Repaint from a server push | FCM, measured with the process dead and the device in forced deep Doze |
| Let the user theme a placed widget | An on-device configuration screen writing per-instance overrides |
It does not:
| Target an individual iOS placement | WidgetKit exposes kind/family but no stable per-placement ID; shared updates work and responses report instance_targeting: false |
| Show a Quick Settings tile on Samsung | One UI never binds a third-party tile service. Measured, and no app can change it |
| Use a custom font | Glance's TextStyle has no fontFamily, and the alternative costs the launcher's own day/night text colour. Refused rather than faked |
Fade text with opacity |
Glance has no alpha modifier. It scales background alpha and composites into bitmaps; on text it logs a warning |
| Repaint at an exact moment | A booked repaint is an inexact alarm and lands late — 50 s and 93 s, measured |
| Show live data in the picker preview | Glance 1.1.1 has no providePreview; the preview is a build-time snapshot |
The full list, with the measurement behind each one, is in docs/LIMITATIONS.md.
The idea#
A widget spends its life on a home screen with the app closed, and gets a tiny refresh budget. So the design principle behind everything here is:
Whatever the device can work out for itself, it does.
A countdown, a streak, a "days since", a greeting that changes at noon, a daily counter resetting at midnight — all of these are compiled expressions the device re-evaluates at every repaint. The app can be force-stopped for a week and the numbers are still right.
And when the widget is tapped, it does not wait either:
tap → local state changes → widget redraws (187 ms, measured, process dead) → queued → PHP reconciles later → corrected or rolled back
Documentation#
| components.md | The 48 components and their attributes |
| fluent-api.md | Typed layouts, appearance, bindings, placeholders and deep links |
| streaks.md | Timezone-aware streak state, snapshots, freezes and milestones |
| expressions.md | The language the device evaluates |
| theming.md | Tokens, presets, light and dark |
| actions.md | Taps, the optimistic layer, the queue |
| refresh.md | Data sources, triggers, push, the budget |
| notifications.md | Reminders, ProgressStyle, Quick Settings tiles |
| challenges.md | Challenges, seasons, deadlines, widget groups, quick-add |
| recipes.md | The seventeen ready-made widgets |
| BACKGROUND-EXECUTION.md | What can run with the app closed, measured |
| configuration.md | The on-device screen: elements, placement, the logo |
| LIMITATIONS.md | Every sharp edge, with the measurement behind it |
| UPSTREAM-PATCH.md | Eight core defects: four PR-backed patches, two additional patches, two worked around |
| FIREBASE.md | Setting up push, step by step |
| WIDGET-STANDARD.md | NativePHP contract, quality checklist and review evidence |
| ARCHITECTURE-iOS.md | The process, App Group, timeline and interaction architecture behind the iOS implementation |
Working with an AI assistant:
php artisan native:widget:boost
Installation#
composer require rubenvdb/nativephp-widgetsphp artisan native:plugin:register rubenvdb/nativephp-widgetsphp artisan native:install android # once for Androidphp artisan native:install ios # once for iOS, on macOSphp artisan native:run android # or: native:run ios
native:install scaffolds the requested native project and downloads the PHP runtime.
It is core's command rather than this package's, it only has to be run once, and
native:run fails without it because there is no native project to build.
During an iOS build the plugin's documented post_compile hook idempotently
adds a Widgets.appex target, links the local Swift package, and grants both
the app and extension the derived group.<app-id>.widgets App Group. Automatic
signing must be enabled for that App Group capability. WidgetKit controls the
actual refresh budget; refreshEvery() supplies a requested timeline date,
while Widget::update() stores through the App Group and requests an immediate
timeline reload.
native:plugin:register adds the plugin to App\Providers\NativeServiceProvider.
NativePHP blocks every plugin that is not listed there, so without this step
nothing is generated and nothing is reported — publish the provider first if the
command says it is missing:
php artisan vendor:publish --tag=nativephp-plugins-provider
Optionally publish the config:
php artisan vendor:publish --tag=nativephp-widgets-config
Authenticating to a private repository#
This package is not on Packagist. Depending on where you bought it, add one of
these to your application's composer.json before composer require, or
the install stops at a 403.
From a licensed Composer repository — a licence key acts as the password:
{ "repositories": [ { "type": "composer", "url": "https://<the-url-you-were-given>" } ]}
composer config --global --auth http-basic.<the-host> "<your-email>" "<your-licence-key>"
From a private Git repository, if you were granted access to one directly:
{ "repositories": [ { "type": "vcs", "url": "https://github.com/<owner>/nativephp-widgets" } ]}
composer config --global --auth github-oauth.github.com "<a-token-with-repo-read-access>"
Then composer require rubenvdb/nativephp-widgets:^0.9 as usual. Composer
caches credentials in ~/.composer/auth.json, so this is once per machine and
not once per project — and in CI, set COMPOSER_AUTH as a secret instead of
writing that file.
The five patches nativephp/mobile still needs#
This is not a footnote. Five defects in nativephp/mobile cannot be fixed
from this package's side. Four have a pull request open against
NativePHP/mobile-air; all five have a
patch here, and none is released, so a fresh clone does not have them.
| Patch | Skip it and |
|---|---|
google-services-classpath (#274) |
the Gradle build fails — Plugin with id 'com.google.gms.google-services' not found — as soon as push is on |
php-embed-init-race (#272) |
a widget tap that opens the app can SIGSEGV the process and lose the deeplink |
gradle-dependencies-marker-block (#271) |
build.gradle.kts grows a comment on every build and is never the same file twice |
ios-plugin-source-copying (#262) |
your app ships this package's whole source tree, its tests, and the absolute paths of the machine that built it, inside the .app — with a green build. Android is unaffected |
ios26-sdk-compile-gating |
no iOS build works at all on Xcode 16.x. Core's project template calls six iOS-26-only SwiftUI symbols behind #available, which is a runtime check and does not stop the compiler. Unnecessary, and harmless, on Xcode 26 |
Apply them from your application root:
PATCHES=vendor/rubenvdb/nativephp-widgets/docs/upstream for p in \ google-services-classpath \ php-embed-init-race \ gradle-dependencies-marker-block \ ios-plugin-source-copying \ ios26-sdk-compile-gatingdo patch -p1 -N -r /dev/null -d vendor/nativephp/mobile < "$PATCHES/$p.patch"done
-N -r /dev/null makes that safe to re-run: an already-applied patch is
skipped rather than prompting, and leaves no .rej behind. It exits 1 when it
skips one, so do not put that loop under set -e.
vendor/ is not committed, so this has to be re-applied after every
composer install. The durable version is
cweagans/composer-patches;
docs/UPSTREAM-PATCH.md has the extra.patches block to paste, the
reproduction for each defect, and what is measured either side of the fix.
Two more core defects are worked around here rather than patched, because
there is no implementation to patch: PushNotification.* has no Android bridge
at all, and Runtime::artisan() silently drops every positional argument in the
background lane. Both are in docs/UPSTREAM-PATCH.md.
Defining a widget#
php artisan native:widget:make Sales
This writes app/Widgets/SalesWidget.php. Every non-abstract class in that
directory extending Widget is registered automatically; you can also list
classes explicitly under nativephp-widgets.widgets.
The same definition can be adjusted fluently from an application service
provider. make() resolves the widget through Laravel's container, so
constructor injection keeps working:
use App\Widgets\SalesWidget;use RubenVdB\Widgets\Facades\Widget; Widget::register( SalesWidget::make() ->named('Live sales') ->describedAs('Revenue from the latest sync') ->supporting('small', 'medium') ->refreshEvery(30) ->groupedAs('sales') ->transparent(),);
refreshEvery() rejects values below Android's thirty-minute provider floor;
use withoutPeriodicRefresh() for push-driven widgets and ticking layouts that
book their own repaint boundaries. backgroundRefreshEvery(60) makes the
intent explicit and refreshEveryHours(6) avoids minute arithmetic.
withTheme([...]) validates theme tokens at
the call site. Properties remain the concise default for ordinary widget
classes, and both forms produce the same generated native contract.
The common title/value card can be customised without writing a template:
SalesWidget::make() ->supporting('small', 'medium', 'large') ->withColors( background: ['light' => '#FFFFFF', 'dark' => '#101418'], text: ['light' => '#111827', 'dark' => '#F9FAFB'], accent: '#2563EB', ) ->withDynamicColors() ->withLayout(padding: 18, spacing: 6, alignment: 'center', cornerRadius: 22) ->withTypography(title: 11, value: 30, body: 14, scale: 105) ->bind(fn () => ['title' => 'Revenue', 'value' => Revenue::today()]) ->stateWhen('warning', fn (array $data) => ($data['value'] ?? 0) < 0) ->onTap('myapp://sales');
For configuration-driven applications, configure() accepts the same choices
as an array. It applies each section independently: an invalid value keeps the
last valid value or default, and configurationWarnings() explains what was
ignored. The strict fluent methods still throw immediately on developer errors.
$widget->configure([ 'sizes' => ['small', 'medium'], 'refresh_minutes' => 30, // zero disables periodic refresh 'dynamic_colors' => true, 'colors' => ['accent' => '#2563EB'], 'typography' => ['value' => 30, 'scale' => 105], 'layout' => ['padding' => 18, 'corner_radius' => 22], 'interaction' => ['deeplink' => 'myapp://sales'], 'data' => ['title' => 'Revenue', 'value' => 1250],]);
| Property | Meaning | Default |
|---|---|---|
$key |
identifier used in storage keys, generated class names and Android resource names | snake_cased class name without the Widget suffix |
$name |
shown in the widget picker | humanised class name |
$description |
shown under the name in the picker | empty |
$sizes |
keys of the size_map config |
['small', 'medium'] |
$refreshMinutes |
how often Android should recompute data() through the durable background lane |
30 |
Keys must be lowercase snake_case (/^[a-z][a-z0-9]*(_[a-z0-9]+)*$/), at most
40 characters. That restriction is what makes key → KotlinClassName and
key → resource name collision free.
What data() may return#
The array is the render contract. The default renderer draws these keys:
| Key | Rendered as |
|---|---|
title |
small label above the value |
value |
the prominent value |
subtitle |
smaller line below the value |
footer |
muted line pinned to the bottom |
lines |
up to four extra rows; each entry a string, or ['label' => ..., 'value' => ...] |
Anything else is stored and ignored. Extra rows only appear on widgets at least 140dp tall, so a 2x2 widget stays readable.
Sizes#
Named sizes map to home screen cells. Android 12 and up place widgets by cells;
older launchers use the dp fallback, derived with the platform formula
70 * cells - 30.
| Name | Cells | dp |
|---|---|---|
small |
2 x 2 | 110 x 110 |
medium |
4 x 2 | 250 x 110 |
large |
4 x 4 | 250 x 250 |
accessory-circular |
iOS only | WidgetKit circular accessory |
accessory-rectangular |
iOS only | WidgetKit rectangular accessory |
accessory-inline |
iOS only | WidgetKit inline accessory |
The smallest declared size becomes the widget's default placement size and its
minimum; the largest becomes its maximum resize bound. Declaring more than one
home-screen size makes the widget resizable. Accessory families target the iOS
Lock Screen and StandBy; Android code generation deliberately filters them out
because AppWidgetProvider has no equivalent category. Prefer the typed
supportingFamilies(WidgetFamily::Small, WidgetFamily::AccessoryCircular, …)
when autocomplete is useful.
The API#
use RubenVdB\Widgets\Facades\Widget; Widget::update('sales', ['value' => '€ 9.999']); // store data and repaintWidget::prepare('sales') // the fluent form ->title('Revenue today') ->value('€ 9.999') ->subtitle('+12%') ->toInstance(7) ->because('order-import') ->send();Widget::refresh('sales'); // re-run data() and push itWidget::refreshSafely('sales'); // log + repaint stale data on failureWidget::refreshAll(); // ... for every widgetWidget::refreshAllSafely(); // isolate and log failures per widgetWidget::rerender('sales'); // repaint from stored dataWidget::rerenderAll();Widget::instances('sales'); // [['app_widget_id' => 7, 'width_dp' => 250, 'height_dp' => 110]]Widget::isPinned('sales'); // boolWidget::requestPin('sales'); // ask the launcher to add itWidget::isNative(); // is there a device to talk toWidget::all(); // every registered widget
Facade reference#
| Method | Purpose |
|---|---|
prepare($key, $data = []) |
Start a fluent WidgetUpdate builder |
update($key, $data, $instanceId = null, $reason = 'manual', $context = null) |
Store a payload and repaint the shared widget or one instance |
refresh($key, $context = null) |
Recompute data and push it |
refreshInstance($key, $instanceId) |
Recompute exactly one placed instance |
refreshSafely($key, $context = null) |
Refresh with logging and stale-payload fallback |
refreshAll($context = null) |
Refresh every registered widget |
refreshAllSafely($context = null) |
Refresh every widget while isolating failures |
refreshGroup($group) |
Resolve shared group data and update its members |
rerender($key) / rerenderAll() |
Repaint stored payloads without recomputing data |
refreshCounts() |
Return refresh-budget usage per widget |
instances($key) / instancesOrNull($key) |
Read placed Android instances |
contexts($key) / contextFor($key, $instanceId) |
Read full render contexts |
instance($key, $instanceId) |
Create a fluent placed-instance handle |
clearInstance($key, $instanceId) |
Remove instance-specific payload state |
isPinned($key) / requestPin($key) |
Check placement or show Android's pin dialog |
theme($key, $context = null) |
Resolve effective theme tokens |
setInstanceTheme($key, $instanceId, $tokens, $replace = false) |
Merge or replace per-instance theme tokens |
clearInstanceTheme($key, $instanceId) |
Restore the shared theme |
themeFromLogo($key, $logo, $instanceId = null) |
Derive and optionally apply a theme from a logo |
overrides($key, $instanceId) |
Read all per-instance edits |
setText(...) / setVisible(...) |
Edit text or visibility for one instance |
setLogo(...) / setLogoStyle(...) |
Configure an instance logo |
setAnchor(...) / setOffset(...) / setZIndex(...) |
Adjust element placement |
resetPlacement($key, $instanceId, $elementId = null) |
Clear one or all placement overrides |
openConfiguration($key, $instanceId) |
Open the native configuration activity |
all() / register($widget) / registry() |
Inspect or extend the widget registry |
selfTest() |
Run the native expression parity probe |
isNative() |
Report whether a native bridge is reachable |
Every method is safe to call anywhere. Outside the native runtime — a web request, a queue worker, a test — the native call is skipped and a neutral value is returned instead of an exception:
| Method | Off device |
|---|---|
update, refresh, rerender, rerenderAll, requestPin, isPinned |
false |
refreshAll |
['sales' => false, ...] |
instances |
[] |
Programming errors still throw, because rebuilding the app would not fix them:
an unregistered key throws UnknownWidget, an oversized payload throws
WidgetPayloadTooLarge, and a genuine native failure throws BridgeCallFailed.
Note that on a developer machine nativephp_call() exists as a relay to a
device running Jump. Its NO_DEVICE, SEND_FAILED and TIMEOUT responses are
treated as "not native", which is why a plain php artisan tinker on your
laptop returns false rather than blowing up.
Background entry points use refreshSafely() and refreshAllSafely(). They
contain a failing data() or bridge call to the affected widget group, log the
exception with its instance ID, and ask Android to repaint the last stored
payload. Set NATIVEPHP_WIDGETS_STALE_FALLBACK=false to disable that final
repaint. The methods still return false, so stale content cannot be mistaken
for a successful refresh.
Commands#
| Command | Purpose |
|---|---|
native:widget:make {Name} [--recipe=] |
scaffold a widget class, blank or complete from a recipe |
native:widget:make --recipes |
list the seventeen recipes |
native:widget:list [--json] |
key, name, sizes, refresh interval, generated receiver and placement status |
native:widget:validate |
keys, sizes, data(), payload size, and whether the generated manifest entries exist |
native:widget:preview |
render every widget at every size and theme on one page, with a time-travel slider |
native:widget:verify-expressions [--php-only] |
run the shared fixtures in PHP and, with a device connected, on the device |
native:widget:refresh |
recompute a widget and push it |
native:widget:boost |
write AI-assistant guidelines from the package's own registries |
native:widget:work |
process the taps a widget queued while the app was closed |
native:widget:probe |
record that PHP reached the device, for the background-execution spike |
nativephp:widgets:post-compile |
the build hook; invoked by NativePHP, not by hand |
The last three run on the device, from a WorkManager worker with no
Activity. They are registered unconditionally rather than behind
runningInConsole(), because the embedded interpreter sets
APP_RUNNING_IN_CONSOLE only for the duration of a call.
From JavaScript#
An Inertia + Vue/React front end can reach the same bridge without a Laravel route of its own:
import { widgets } from '../../vendor/rubenvdb/nativephp-widgets/resources/js/index.js'; await widgets.update({ key: 'sales', data: JSON.stringify({ value: '€ 9.999' }) });const instances = await widgets.getInstances({ key: 'sales' });
One export per bridge function, twenty-eight of them, over
POST /_native/api/call. A test asserts the bindings and the manifest cannot
drift apart.
Two error shapes are surfaced as thrown Errors, and they are genuinely
different: the transport failing or the method not being registered, and the
bridge function running and declining. The second exists because NativePHP's
router wraps whatever a bridge function returns in success(), so a plugin
cannot return a transport-level error from inside one.
Events#
Device-originated events are drained the next time PHP runs. Their at value is
Unix time in milliseconds; it records when the device observed the event, not
when Laravel later dispatched it.
use Illuminate\Support\Facades\Event;use RubenVdB\Widgets\Events\WidgetTapped; Event::listen(WidgetTapped::class, function (WidgetTapped $event): void { logger()->info('Widget action', ['key' => $event->key, 'action' => $event->action]);});
| Event | Public payload |
|---|---|
WidgetAdded, WidgetRemoved |
key, instanceId, at |
WidgetResized |
key, instanceId, size, widthDp, heightDp, at |
WidgetTapped |
key, instanceId, action, params, at |
WidgetConfigured |
key, instanceId, changes, at |
WidgetRefreshed |
key, instanceId, reason, bytes |
WidgetActionExecuted |
request, result |
WidgetActionFailed |
request, message, willRetry |
StreakContinued |
key, current, longest |
StreakBroken |
key, length, daysMissed |
MilestoneReached |
key, milestone, value |
TimerStarted |
key, startedAt, mode |
TimerStopped |
key, elapsedSeconds, phase |
CounterIncremented |
key, by, value, goalReached |
PointsAwarded |
key, points, balance, reason |
ChallengeCompleted |
key, challenge, goal, reward |
ChallengeExpired |
key, challenge, progress, goal |
How it works#
Android will not let an app declare a home screen widget at runtime. Every
widget must be a <receiver> in AndroidManifest.xml pointing at an
appwidget-provider resource, both fixed at build time. Applications define
their widgets in PHP long after this package was installed, so the missing
native pieces are generated during the build.
The generator runs as the post_compile lifecycle hook, which is the only
slot that works: NativePHP's plugin compiler wipes its generated directory,
rewrites AndroidManifest.xml and injects Gradle dependencies after
pre_compile fires. post_compile is the last thing to touch the project
before Gradle runs.
Per widget it writes, into nativephp/android/:
app/src/main/java/com/rubenvdb/widgets/generated/SalesWidget.kt receiver + Glance widget subclassapp/src/main/java/com/rubenvdb/widgets/generated/GeneratedWidgetCatalog.ktapp/src/main/res/xml/nativephp_widget_sales_info.xml appwidget-providerapp/src/main/res/layout/nativephp_widget_sales_preview.xml widget picker previewapp/src/main/res/values/nativephp_widgets.xml label + description stringsapp/src/main/AndroidManifest.xml <receiver> inside a marker block
The generated subclass carries exactly one thing — the widget key:
class SalesWidget : NativePhpGlanceWidget( widgetKey = "sales", supportedSizes = setOf(DpSize(110.dp, 110.dp), DpSize(250.dp, 110.dp)),) class SalesWidgetReceiver : NativePhpWidgetReceiver() { override val glanceAppWidget: NativePhpGlanceWidget = SalesWidget()}
Everything else lives in hand written base classes shipped with the package, so a rebuild never regenerates logic.
Idempotency#
The generator is designed to run any number of times in a row. It computes every file in memory first and only writes once all widgets validate, so a bad widget class can never leave a half generated tree that fails to compile. Then:
- the generated Kotlin package is deleted and rewritten wholesale;
res/xmlandres/layoutare pruned by filename prefix, since those directories are shared with the core project;res/values/nativephp_widgets.xmlis overwritten outright;- the manifest block between the
nativephp-widgetsmarkers is stripped and re-emitted, and the result is parsed withDOMDocumentbefore it is written.
Deleting a widget class removes every trace of it on the next build. Full block
regeneration is also what keeps widget labels correct: NativePHP rewrites
android:label across the entire manifest with an unbounded regex at the start
of every run, and our hook restores it at the end of the same run.
Storage#
Payloads live in one app-wide DataStore addressed by widget key, not in Glance's
default per-instance state. A payload therefore already exists when the user
places a new instance, and PHP never has to know which appWidgetIds are live.
A custom GlanceStateDefinition feeds that store into Glance so a push
recomposes a widget that is already on screen.
Payload keys are identical on both sides:
nativephp.widget.<key>.payload the JSON documentnativephp.widget.<key>.revision monotonic write counternativephp.widget.<key>.updated_at epoch millis
RubenVdB\Widgets\Support\StorageKeys and com.rubenvdb.widgets.WidgetPrefKeys
are asserted against each other in the test suite.
Payloads are capped at 32 KB by default (storage.max_payload_bytes). PHP
rejects an oversized payload before it reaches the bridge; the native side
re-checks and refuses independently.
Debugging#
adb logcat -s NativePhpWidgets
The plugin logs its compiled widget list at boot, every receiver callback and every repaint. In debug builds the generated receivers also carry Glance's debug broadcast, so a widget can be forced to repaint from the shell:
adb shell am broadcast -a androidx.glance.appwidget.action.DEBUG_UPDATE \ -n <app-id>/com.rubenvdb.widgets.generated.SalesWidgetReceiver
Set NATIVEPHP_WIDGETS_DEBUG=true to have the build hook print every file it
writes and removes.
If a widget does not appear at all, check in this order:
php artisan native:plugin:list— is the plugin registered?php artisan native:widget:validate— are the manifest entries generated?grep "Skipping plugin"over thenative:runoutput — a malformednativephp.jsonmakes NativePHP drop the whole plugin with only anerror_logline.
Requirements#
- PHP 8.4+. Not a preference:
nativephp/mobileitself requires^8.4, so this package cannot resolve on 8.3. - Laravel 11, 12 or 13. Both ends are tested — the suite is green against
laravel/framework11.44 as the lowest resolvable set and against 13.x. nativephp/mobile~4.0.0, deliberately narrow. This package's Kotlin compiles into core's own Gradle module and takes core'sinternalextractionLock(see docs/UPSTREAM-PATCH.md #2), so it is coupled to core's internals rather than only to its public API. 4.0.0 and 4.0.1 are both verified; a wider range would be a guess.- Android
minSdk31 or higher - Jetpack Glance 1.1.1, DataStore 1.1.1 (declared by the plugin, injected into the Gradle build automatically)
Android permissions#
| Permission | Why it is declared |
|---|---|
RECEIVE_BOOT_COMPLETED |
Restore scheduled background work and notifications after reboot or time changes |
VIBRATE |
Optional haptic feedback for widget actions |
POST_NOTIFICATIONS |
Widget notifications on Android 13+; requested only when that feature is used |
One thing that does not work on Samsung#
A Quick Settings tile cannot be placed on One UI. One UI 8 renders its panel
from a Samsung-private setting rather than from AOSP's sysui_qs_tiles, and
never binds a third-party TileService at all — requestAddTileService answers
"already added" about a store the panel does not read. The declaration here
follows the platform contract and works where the contract is honoured; on a
Samsung device the tile simply will not appear, and no app can change that.
Everything else in the plugin is Samsung-verified. The full measurement is in docs/LIMITATIONS.md.
License#
Commercial/proprietary. See LICENSE. A purchased licence covers one named developer and any number of applications; redistribution of the source is not permitted.
Support#
Use GitHub Issues for bug reports, compatibility questions and feature requests.