Activity Trace

One tile. One bounded span.

Android
$ 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
Read the documentation Guides, changelog and the full README

Documentation

Synced from GitHub 1 month ago

🧭 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:compileDebugKotlin exit 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 through docs/DEVICE-VALIDATION.md with a real device. iOS is not implemented and is not claimed to work — see iOS. Read docs/DESIGN.md before 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

🆘 Support

plugins@all1.ai