Activity Trace
One tile. One bounded span.
$ composer require all1web/nativephp-activity-trace
A stateful Quick Settings tile starts and stops a foreground recording that samples motion and location, and hands PHP one buffered span it can turn into calendar history.
- Stateful Quick Settings tile starts and stops the recording
- Foreground service samples motion and location
- PHP receives one buffered span, not a firehose
Documentation
🧭 nativephp-activity-trace
One tap before, one tap after. Your app gets the afternoon back as history.
The user flips a Quick Settings tile on the way into the shop and flips it off when they get home. Your Laravel app receives one recorded span — motion and location, sampled adaptively — and turns it into calendar history: "Shopping, 3:00–3:50", then "Driving, 3:50–4:15". From one recording, with the app closed the whole time.
This is a retrospective recorder, not a live tracker. Nothing is streamed, nothing is uploaded, nothing runs unless the user opened a bracket.
Status — Android complete, compile-checked and unit-tested, and NOT YET DEVICE-VALIDATED. The PHP API, the bridge contract, the configuration and the whole Android layer (tile, foreground service, activity-recognition governor, sample buffer, the four-gate permission door, the battery-exemption ask, the tile-label manifest patch) are written. Kotlin cannot be compiled in the environment that produced it, but every native change since has been checked against a real host Gradle project (
:app:compileDebugKotlinexit 0) and the PHP surface carries 60 Pest cases. None of it has run on a phone: nothing here is described as working until someone has walked throughdocs/DEVICE-VALIDATION.mdwith a real device. iOS is not implemented and is not claimed to work — see iOS. Readdocs/DESIGN.mdbefore shipping this to production.
✨ What you get
- 🎚️ A stateful Quick Settings tile. ON while recording, OFF while not — and honest about it. The tile and the recorder are the same component, so a killed service, a reboot, or a revoked permission flips the tile back to OFF instead of lying to your user.
- 🛡️ A foreground service with a real indicator. Android requires it for background location; this plugin makes it useful. Elapsed time, the detected activity, and a Stop button, without opening the app.
- 🔋 Adaptive sampling, not fixed-rate GPS. Activity recognition decides the cadence: standing still backs location off to one cheap fix every two minutes with the GPS chip switched off entirely; driving goes to one every 15 seconds. Fixed-rate GPS for an hour is what makes people disable a feature.
- 📦 Buffered natively, drained once. Samples accumulate on the device and cross into PHP in a single call after the bracket closes. No round trip per fix — the round trip would cost more than the fix.
- 🔒 Nothing leaves the device on its own. No endpoint, no vendor service, no background upload. Your app reads the buffer and decides what to do with it.
- 🐘 Plain Laravel. A facade, a config file, three events, and a JS wrapper for web-view front-ends.
📦 Install
After purchasing, connect Composer to the NativePHP plugin marketplace (your credentials are on your Purchased Plugins dashboard), then:
composer config repositories.nativephp-plugins composer https://plugins.nativephp.com
composer config http-basic.plugins.nativephp.com your-email@example.com your-license-key
composer require all1web/nativephp-activity-trace
NativePHP plugins are opt-in for security, so requiring the package is not enough — register the provider, then compile it into the native project:
php artisan native:plugin:register all1web/nativephp-activity-trace
php artisan native:install android --force
php artisan native:run android
The rebuild matters: the tile service, the foreground service, and the permissions are written into the Android manifest at install time. They exist only after this rebuild, not after re-running an old build.
php artisan vendor:publish --tag=activitytrace-config
# Local checkout (plugin development):
composer config repositories.activity-trace path ../nativephp-activity-trace
composer require "all1web/nativephp-activity-trace:*@dev"
🧑💻 Use it
use All1web\ActivityTrace\Facades\ActivityTrace;
// Offer the tile once, during onboarding (Android 13+; false elsewhere).
ActivityTrace::requestTile();
// Open a bracket from a foregrounded screen.
$trace = ActivityTrace::start(); // ['traceId' => 'atr_…', 'startedAt' => 1755300000000]
// …later, from your UI, the tile, or the notification's Stop button.
ActivityTrace::stop(); // ['traceId' => 'atr_…', 'endedAt' => …, 'sampleCount' => 96]
A start that is refused says why. start() carries an error key, and any
call's failure is readable afterwards through lastError():
$trace = ActivityTrace::start();
// ['traceId' => null, 'startedAt' => null,
// 'error' => ['code' => 'LOCATION_SERVICES_OFF', 'message' => 'Location is switched off for this device; …']]
ActivityTrace::lastError(); // the same {code, message}, or null after a success
The codes are the native ones, verbatim: PERMISSION_REQUIRED (fine location
not held), LOCATION_SERVICES_OFF (the device-wide toggle — a bracket opened
now would hold no fixes at all, so it is not opened), SERVICE_START_REFUSED
(Android would not let the foreground service start), FUNCTION_NOT_FOUND (the
plugin's native half is not in this build), INVALID_RESPONSE, UNKNOWN_ERROR.
Off device there is no error: every method degrades to its documented
empty shape by design, and "the plugin is missing" is the wrong sentence to put
in front of someone looking at a browser.
The important part is the resume path. The tile can start and stop a trace while your app isn't running, so ask the recorder what happened instead of remembering:
$state = ActivityTrace::status();
// ['recording' => false, 'traceId' => null, 'startedAt' => null, 'samples' => 0,
// 'activity' => null, 'label' => null, 'pendingTraces' => 1, 'tileAdded' => true]
if ($state['pendingTraces'] > 0) {
foreach (ActivityTrace::drainAll() as $trace) {
// $trace['startedAt'], $trace['endedAt']
// $trace['samples'] => [
// ['t' => 1755300000000, 'lat' => 26.1224, 'lng' => -80.1373,
// 'acc' => 12.5, 'speed' => 1.3, 'activity' => 'walking', 'conf' => 82],
// …
// ]
StoreTrace::dispatch($trace);
}
}
Draining is destructive — once a trace crosses into PHP the device no longer has it. Persist first, segment into stops and journeys afterwards.
Every drained trace also carries outcome (completed or interrupted) and,
when it holds no samples, a reason for that: no_fixes (the bracket ran
and the phone never produced a usable fix — a desk test does this),
location_disabled, permission_revoked, or foreground_refused (Android
would not let the recorder into the foreground). An empty bracket is delivered,
never dropped: "that recording captured nothing, because …" is a sentence your
app can say, and silence is the one thing this recorder refuses to be.
Prefer objects to arrays?
use All1web\ActivityTrace\Support\TraceSample;
$samples = TraceSample::collection($trace['samples']);
$samples[0]->activity; // 'walking'
$samples[0]->isMoving(); // true
Ask for the permissions first — the tile cannot
A Quick Settings tile can show no dialog at all, so a bracket opened from the shade either holds the grants already or records nothing. Your app has to ask, and only this package knows which permission each capability needs, so the door is here:
ActivityTrace::permissionStatus();
// ['fine' => 'granted', 'background' => 'denied', 'activity' => 'granted',
// 'notifications' => 'not_applicable', 'locationServicesOn' => true, 'batteryOptimised' => false]
Each gate is granted, denied, not_applicable (the running Android has no
such permission — nothing to ask for, not a denial) or, off device, unknown,
which is never treated as permission to record. batteryOptimised is true
when Android may put the app to sleep mid-trace — and true on any failure to
read it, so a bad read can never claim an exemption.
ActivityTrace::requestPermissions();
// ['requested' => ['background', 'activity'], 'status' => [… the same shape …]]
requestPermissions() raises the missing dialogs one at a time, in order:
fine, then background — only once fine is held, because Android auto-denies it
otherwise and Play wants it asked separately and later — then activity, then
notifications. It returns the queue it is about to walk; the answers arrive
later as the PermissionsAnswered event, and permissionStatus() is the truth
at any moment. Call it from a foregrounded screen.
ActivityTrace::requestBatteryExemption(); // ['requested' => true, 'exempt' => false]
Battery optimisation is the fifth thing a long trace needs and it is not a
permission: this opens Android's direct "let this app run in the background"
dialog, falling back to the battery-optimisation settings list when the direct
dialog is hidden. There is no result callback for that trip — the next
permissionStatus() read is the truth. Make it an explicit tap, never part of
a start: a Settings trip steals the screen.
Events (best-effort)
use All1web\ActivityTrace\Events\TraceStarted;
use All1web\ActivityTrace\Events\TraceStopped;
use All1web\ActivityTrace\Events\TraceInterrupted;
use All1web\ActivityTrace\Events\PermissionsAnswered;
These fire only when your app is foregrounded at that moment — which is
not the common case here, since the point of the plugin is recording while it
isn't. Use them as a convenience; use status() + drainAll() on resume as
the delivery path. TraceInterrupted tells you the system ended a trace the
user didn't (service killed, location switched off, or the foreground refused
after a restart — the samples recorded up to then still arrive), so you can
label that history as partial rather than pretend it's complete.
PermissionsAnswered carries the four gates after a requestPermissions()
chain has run its course.
🌐 Use it from JavaScript
For Livewire / Inertia / plain web-view front-ends:
import { activityTrace } from '@all1web/nativephp-activity-trace';
await activityTrace.requestTile();
const { traceId, startedAt } = await activityTrace.start();
await activityTrace.stop();
// On resume:
const state = await activityTrace.status();
if (state.pendingTraces > 0) {
const trace = await activityTrace.drain();
// trace.samples -> [{ t, lat, lng, acc, speed, activity, conf }, …]
}
One exported function per bridge function: start, stop, status, drain,
requestTile. drain() passes outcome and reason through, and a refused
call throws an Error whose code is the native code named above (err.code),
so a front-end can tell "location is off" from "the plugin is not in this
build".
🎛️ Tune it
Everything lives in config/activitytrace.php and is forwarded to native code
on every start — change the cadence without rebuilding the app.
'sampling' => [
'still' => ['interval_ms' => 120_000, 'min_distance_m' => 25, 'priority' => 'low_power'],
'walking' => ['interval_ms' => 20_000, 'min_distance_m' => 10, 'priority' => 'high'],
'in_vehicle' => ['interval_ms' => 15_000, 'min_distance_m' => 50, 'priority' => 'high'],
// …
],
Also configurable: recognition interval and confidence floor, accuracy filter,
sample and duration caps, notification copy ({elapsed}, {activity}), the
tile label, and whether the tile is allowed to start a trace or only stop one.
Two of those deserve a sentence. limits.stale_fix_minutes (default 3 — above
the still profile's own 120 s cadence, so a stationary user is not "stale")
is how long the recorder may go without a usable fix before it says so: the
notification switches to notification.stale_text ({elapsed} · no location for {stale} min) and the tile's subtitle to 12 min · no location, because an
elapsed clock climbing over an empty trace is the most convincing lie this
feature could tell. Recording never stops for it. And the tile label is
stamped onto the built manifest at build time by the post_compile hook
(php artisan nativephp:activitytrace:patch-manifest), so One UI's edit tray
lists the tile by its name rather than under the app's — with your icon too, if
the host ships a drawable named activitytrace_ic_tile.
🔐 Permissions and the Play Store
The plugin declares ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION,
ACCESS_BACKGROUND_LOCATION, ACTIVITY_RECOGNITION, FOREGROUND_SERVICE,
FOREGROUND_SERVICE_LOCATION and POST_NOTIFICATIONS, and registers the
foreground service with foregroundServiceType="location".
ACCESS_BACKGROUND_LOCATION carries a Play Store declaration requirement.
Before your app can ship with it you must file a declaration in Play Console:
what the feature is, why it cannot work in the foreground only, a prominent
in-app disclosure shown before the first request, and usually a demonstration
video. Expect review time.
This plugin's shape is the straightforward case to justify — recording is
user-initiated, bounded, visible in both a persistent notification and a
Quick Settings tile, and the samples never leave the device on their own — but
the declaration is yours to file, and no package can do it for you. Ask
for ACCESS_FINE_LOCATION first and escalate to background in a separate,
later prompt with a clear explanation; Android and Play both expect that
order — which is exactly the order requestPermissions() uses.
REQUEST_IGNORE_BATTERY_OPTIMIZATIONS is declared too. Without it the
direct exemption dialog is a flash-and-close. It is a Play acceptable-use flag
for a listed app: a sideloaded app is unaffected, and the fallback path (the
settings list) needs no permission at all. On Samsung One UI note that
isIgnoringBatteryOptimizations can read exempt while Samsung's own "Put app
to sleep" setting still sleeps the app — the probe is in
docs/PREFLIGHT-2026-09-11.md.
🍎 iOS
Not implemented. iOS has no Quick Settings analogue at all, so the plugin's
primary control does not exist there; requestTile() returns false. The
sampling half has a real counterpart — Core Motion's
CMMotionActivityManager plus Core Location with background updates — and the
bridge contract is already shaped for it, but no iOS implementation has been
written or tested. Nothing about iOS is claimed to work. Build for Android.
📚 Docs
docs/DESIGN.md— why the tile and the service are one component, the full sampling table, the buffer-and-drain contract, the battery reasoning, and an explicit list of what is unverified.docs/PREFLIGHT-2026-09-11.md— the pre-flight audit before the first device run: eight ordered fixes, what has landed, and the adb probes that prove each one on a phone.CHANGELOG.md