Skip to content

feat: add device thermal state + extra getInfo telemetry - #359

Open
dr-codswallop wants to merge 4 commits into
NativePHP:mainfrom
dr-codswallop:feat/device-thermal-state
Open

feat: add device thermal state + extra getInfo telemetry#359
dr-codswallop wants to merge 4 commits into
NativePHP:mainfrom
dr-codswallop:feat/device-thermal-state

Conversation

@dr-codswallop

Copy link
Copy Markdown

Important note: currently untested on Android

Summary

  • Adds Device::thermalState() plus ThermalStateChanged so apps can throttle when the phone heats up (same query-plus-event pattern as System::appearance()).
  • Adds processorCount, activeProcessorCount, systemUptime, and memTotal to the existing Device::getInfo() JSON (alongside existing memUsed). Thermal is not on getInfo().
  • Minor — Kotlin/Swift changed. Requires php artisan native:install --force.
image

Query

use Native\Mobile\Facades\Device;
use Native\Mobile\ThermalState;

$state = Device::thermalState(); // ThermalState enum (Normal, Warm, Hot, Critical). Never null
$state->isHot();                 // Hot or Critical

$info = json_decode(Device::getInfo());
$info->processorCount;
$info->activeProcessorCount;
$info->systemUptime; // seconds awake since boot, excluding sleep
$info->memTotal;     // device RAM, bytes (pairs with existing memUsed)

Off-device: thermalState() is ThermalState::Normal. getInfo() is null. Android 8–9 still has getInfo(); only thermal is stubbed (normal, no event) — see Notes.

JS: device.thermalState(){ state }. New keys are inside the existing device.getInfo() info string.

Event

Native\Mobile\Events\Device\ThermalStateChanged (BroadcastsGlobally). Payload: state + previous. Cold start does not fire it.

use Native\Mobile\Attributes\On;
use Native\Mobile\Events\Device\ThermalStateChanged;
use Native\Mobile\ThermalState;

#[On(ThermalStateChanged::class)]
public function onThermal(ThermalState $state, ThermalState $previous): void
{
    if ($state->isWarmerThan($previous) && $state->isHot()) {
        $this->pauseHeavyWork = true;
    }
    if ($state->isCoolerThan($previous)) {
        $this->pauseHeavyWork = false;
    }
}

Helpers: isWarm(), isHot(), isCritical(), isWarmerThan(), isCoolerThan() on the enum; isWarming() / isCooling() on the event.

Normalized thermal buckets

iOS ProcessInfo.ThermalState maps 1:1. Android PowerManager (API 29+) collapses seven statuses onto the same four names. Unknown/null values are considered normal.

NativePHP iOS Android
normal .nominal NONE
warm .fair LIGHT, MODERATE
hot .serious SEVERE
critical .critical CRITICAL, EMERGENCY, SHUTDOWN

Buckets follow user-visible impact, not constant names: LIGHT/MODERATE are still fully usable (Warm), SEVERE is when UX is hit (Hot), and Android’s own CRITICAL maps to critical. SHUTDOWN folds into critical — iOS has no equivalent, and AOSP says apps often never receive that callback.

Bonus tip: monitor thermal state changes in the background

ThermalStateChanged does not fire while the app is in the background. Probe Device::thermalState() from a scheduled command instead, compare it to the previous state, and notify if it has changed since last probed.

Requires nativephp/mobile-background-tasks and nativephp/mobile-local-notifications.

// routes/console.php
Schedule::command('thermal:check')->everyFifteenMinutes();
// app/Console/Commands/ThermalCheckCommand.php
use Native\Mobile\Facades\Device;
use Native\Mobile\ThermalState;
use NativePHP\LocalNotifications\Facades\LocalNotifications;

public function handle(): int
{
    $currentState = Device::thermalState();

    // Get previous stored state
    $previousState = ThermalState::tryFrom((string) cache('thermal_state'));
    // Or fetch last recorded state from the database if the app persists thermal changes there.

    if ($previousState && ($currentState !== $previousState)) {
        LocalNotifications::send('thermal')
            ->title($currentState->value)
            ->body($currentState->isWarmerThan($previousState) 
                ? 'Your device is getting hotter' 
                : 'Your device is cooling down'
            );
    }

    // Update the state to current
    cache(['thermal_state' => $currentState->value]);
    // Or write the new state to the database instead of cache

    return self::SUCCESS;
}

Notes

  • Android 8–9 (API 26–28) has no thermal API. PowerManager.getCurrentThermalStatus() and OnThermalStatusChangedListener were added in Android 10 (API 29). On 8–9, thermalState() is always normal and ThermalStateChanged never fires.
  • iOS Simulator stays normal. Live events need a physical device. Android 10+ emulator: adb shell cmd thermalservice override-status <0-6> then reset.

Apps can throttle work from a normalized thermal bucket and event, and getInfo now reports processor counts, uptime, and total RAM so they can size work to the machine.
Align thermal state mapping by user-visible impact: Android `THERMAL_STATUS_LIGHT` now maps to `warm` alongside `MODERATE` instead of being treated as `normal`. Updated Kotlin/PHP docs to clarify the collapsed Android-to-shared mapping and why `SHUTDOWN` is grouped under `critical`.
Clarifies the NativePHP Mobile thermal-state documentation across iOS and Android. It explains the platform-specific mapping from raw values to Normal/Warm/Hot/Critical, notes Android 8-9 and simulator behavior, and aligns the docs in the PHP docs writer guidance, skill guide, and reference list.
@gwleuverink

Copy link
Copy Markdown
Contributor

Thanks for this Larry 🙏🏻 Nice feature, plenty of practical uses.

Verified! buckets map correctly on both platforms, events fire on real changes only, cold start seeds without firing.

What I ran into:

  • Android webview: the event reaches neither PHP nor JS, so thermalState() stays wrong for the life of the process
  • iOS webview: the event does arrive, then 500s on the enum constructor

Android webview

The monitor sends through NativeElementBridge.sendNativeEvent, which only feeds the EDGE element queue, and the only thing draining that is NativeComponent's render loop. Neither PHP nor the JS On() listener hears it, and thermalState() never re-probes. A webview screen polling both reads, after heating the device:

$ adb shell cmd thermalservice override-status 3     # SEVERE

logcat    ThermalStateMonitor: Thermal state normal → hot    (native saw it)
bridge    Device.GetThermalState => {"state":"hot"}          (bridge agrees)
PHP       Device::thermalState() => normal                   (stays normal, same process)

NativeActionCoordinator.dispatch has the full fan-out (CustomEvent, Livewire, POST to /_native/api/events, plus the element queue), so routing the monitor through it should cover both. Your iOS side is the proof that this is the right path: it sends through the general LaravelBridge.send, which ContentView upgrades to the coordinator once a WebView exists, and webview delivery works there without you doing anything.

iOS webview

The webview path delivers, and then the POST to /_native/api/events 500s. DispatchEventFromAppController builds the event with new $eventClass(...$payload) and the payload arrives as strings:

Argument #1 ($state) must be of type Native\Mobile\ThermalState, string given

The coercion you added to NativeComponent needs a twin there. Appearance survives this only because its constructor takes a string, which is why nobody hit it before.

Simulator

The simulator can be driven 🚀 If you want to speed up your testing:

xcrun simctl spawn <udid> notifyutil -s com.apple.system.thermalpressurelevel 20
xcrun simctl spawn <udid> notifyutil -p com.apple.system.thermalpressurelevel

The -p post is what delivers it, setting the value alone does nothing.


Are you up to crossing those T's? Let me know or I'll have a look myself

@gwleuverink

Copy link
Copy Markdown
Contributor

Correction on the Android half, hold off on that change for now.

Kept digging and the same bypass hits AppearanceChanged and ShakeDetected: appearance goes stale in webview apps and shake never arrives at all, in PHP or JS. Filed that separately (#360) and I'll pick up the delivery fix there, so you likely dont have to change anything in your monitor 🙌🏻

That leaves the iOS 500 as the one thing on your side. I'll approve this to be merged after I fix #360

@dr-codswallop dr-codswallop changed the title Add device thermal state + extra getInfo telemetry feat: add device thermal state + extra getInfo telemetry Aug 21, 2026
Updated `ThermalStateChanged` to accept either `ThermalState` enums or string values and normalize both constructor inputs to enums. This fixes event hydration when payloads come from webview/native JSON posts. Added unit and feature coverage to verify string payload handling, event dispatch hydration, and device thermal state updates via `_native/api/events`.

@dr-codswallop dr-codswallop left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for catching this Willem. I'd concentrated on SuperNative/Edge so forgot the webview.

This update should fix the iOS 500 in webview when ThermalStateChanged arrives as JSON. It should work on Android ... in theory.

Image

@gwleuverink

Copy link
Copy Markdown
Contributor

You're a wizard Larry. Thanks!

This looks great. I'll give it another test once #360 is merged 🙌

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants