The phone runner rests when the phone is warm (refs #8) 8440d51a2275
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 54501be0fb66
- When
- · 2026-10-28 21:33 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
8440d51a22759bee70927c1a9cb9cb4f2428583d4a74d640c44a5def98d3aa3e- Depends on
- 41e2e862abce 4aee5293461d 57c0824300db c53e328b2db4 ed6c64d23fca ff65ea456ff1
- Depended on by
- aa627197ec18 0b152017dee3
- In bookmarks
- main
- In tags
- v0.1.2 v0.1.3 v0.1.4 v0.1.5 v0.1.6 v0.1.7 v0.1.8 v0.1.9
- Edits
- 20 atoms, 2 tree operations, 5 files
Browse the tree at this change
added app/src/test/java/io/oxygenhub/android/runner/PhoneRunnerTest.kt +20 −0
| 1 | + | package io.oxygenhub.android.runner |
|
| 2 | + | ||
| 3 | + | import android.os.PowerManager |
|
| 4 | + | import org.junit.Assert.assertEquals |
|
| 5 | + | import org.junit.Assert.assertNull |
|
| 6 | + | import org.junit.Test |
|
| 7 | + | ||
| 8 | + | class PhoneRunnerTest { |
|
| 9 | + | @Test |
|
| 10 | + | fun aWarmPhoneRests() { |
|
| 11 | + | assertNull(PhoneRunner.tooWarm(PowerManager.THERMAL_STATUS_NONE, 312)) |
|
| 12 | + | assertNull(PhoneRunner.tooWarm(PowerManager.THERMAL_STATUS_LIGHT, null)) |
|
| 13 | + | assertNull(PhoneRunner.tooWarm(PowerManager.THERMAL_STATUS_NONE, PhoneRunner.MAX_BATTERY_TENTHS)) |
|
| 14 | + | assertEquals("the phone is warm (the system is throttling it)", PhoneRunner.tooWarm(PowerManager.THERMAL_STATUS_MODERATE, 300)) |
|
| 15 | + | assertEquals( |
|
| 16 | + | "the battery is warm (41.5 °C, above 40 °C)", |
|
| 17 | + | PhoneRunner.tooWarm(PowerManager.THERMAL_STATUS_NONE, 415), |
|
| 18 | + | ) |
|
| 19 | + | } |
|
| 20 | + | } |
modified CHANGELOG.md +1 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **The phone runner rests when warm (#8):** besides charging and Wi-Fi, the runner now needs the phone cool: when the system says it is throttling (moderate or worse) or the battery is above 40 °C it takes nothing more, finishes a job due within a minute and hands back any other, as off charge, and serves again once it has cooled. The card and the notification say why it waits. |
|
| 5 | 6 | - **A proposal's description is Markdown:** it showed as plain text, `##`, `**`, backticks and table pipes included, with the source's line breaks; it now renders as an issue's does (headings, emphasis, code, lists and tables, lines joined into paragraphs). |
|
| 6 | 7 | ||
| 7 | 8 | ## 0.1.1 (2026-10-28) |
|
| … | 30 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/runner/PhoneRunner.kt +25 −4
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | import android.content.Context |
|
| 4 | 4 | import android.content.Intent |
|
| 5 | + | import android.content.IntentFilter |
|
| 5 | 6 | import android.net.ConnectivityManager |
|
| 6 | 7 | import android.net.NetworkCapabilities |
|
| 7 | 8 | import android.os.BatteryManager |
|
| 8 | 9 | import android.os.Build |
|
| 10 | + | import android.os.PowerManager |
|
| 9 | 11 | import androidx.core.content.ContextCompat |
|
| 10 | 12 | import androidx.core.content.edit |
|
| 11 | 13 | import io.oxygenhub.android.api.HubApi |
|
| … | 11 unchanged lines |
||
| 23 | 25 | /** |
|
| 24 | 26 | * The phone as an OxygenFlow runner (#8): OxygenFlow's own runner, built |
|
| 25 | 27 | * into the app through rust-core, offering the WASM tier. It runs in |
|
| 26 | - | * [RunnerService] only while the phone is on charge and on an unmetered |
|
| 27 | - | * network (Wi-Fi), and drains when either stops: it claims nothing more |
|
| 28 | - | * and finishes what it holds. |
|
| 28 | + | * [RunnerService] only while the phone is on charge, on an unmetered |
|
| 29 | + | * network (Wi-Fi) and cool, and leaves when any of those stops. |
|
| 29 | 30 | */ |
|
| 30 | 31 | object PhoneRunner { |
|
| 31 | 32 | sealed interface State { |
|
| … | 49 unchanged lines |
||
| 81 | 82 | return name |
|
| 82 | 83 | } |
|
| 83 | 84 | ||
| 84 | - | /** Why the runner may not run now, or null when it may: on charge and on Wi-Fi. */ |
|
| 85 | + | /** Why the runner may not run now, or null when it may: on charge, on Wi-Fi, and cool. */ |
|
| 85 | 86 | fun blockedBy(context: Context): String? { |
|
| 86 | 87 | val battery = context.getSystemService(BatteryManager::class.java) |
|
| 87 | 88 | if (battery != null && !battery.isCharging) return "the phone is not on charge" |
|
| 89 | + | val sticky = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) |
|
| 90 | + | val tenths = sticky?.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, Int.MIN_VALUE)?.takeIf { it != Int.MIN_VALUE } |
|
| 91 | + | val thermal = context.getSystemService(PowerManager::class.java)?.currentThermalStatus ?: PowerManager.THERMAL_STATUS_NONE |
|
| 92 | + | tooWarm(thermal, tenths)?.let { return it } |
|
| 88 | 93 | val net = context.getSystemService(ConnectivityManager::class.java) |
|
| 89 | 94 | val caps = net?.activeNetwork?.let { net.getNetworkCapabilities(it) } |
|
| 90 | 95 | if (caps == null || !caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)) { |
|
| … | 2 unchanged lines |
||
| 93 | 98 | return null |
|
| 94 | 99 | } |
|
| 95 | 100 | ||
| 101 | + | /** The battery temperature, in tenths of a degree, above which the runner rests: 40 °C. */ |
|
| 102 | + | const val MAX_BATTERY_TENTHS = 400 |
|
| 103 | + | ||
| 104 | + | /** |
|
| 105 | + | * Why the phone is too warm to run jobs (#8), or null: the system says |
|
| 106 | + | * it is throttling (moderate or worse), or the battery is above |
|
| 107 | + | * [MAX_BATTERY_TENTHS]. Charging and a busy CPU together are what a |
|
| 108 | + | * phone handles worst; a job waits for it to cool rather than cook it. |
|
| 109 | + | */ |
|
| 110 | + | fun tooWarm(thermalStatus: Int, batteryTenths: Int?): String? = when { |
|
| 111 | + | thermalStatus >= PowerManager.THERMAL_STATUS_MODERATE -> "the phone is warm (the system is throttling it)" |
|
| 112 | + | batteryTenths != null && batteryTenths > MAX_BATTERY_TENTHS -> |
|
| 113 | + | "the battery is warm (${batteryTenths / 10}.${batteryTenths % 10} °C, above ${MAX_BATTERY_TENTHS / 10} °C)" |
|
| 114 | + | else -> null |
|
| 115 | + | } |
|
| 116 | + | ||
| 96 | 117 | /** Start the runner, and keep it offered across app updates and restarts until the owner stops it. */ |
|
| 97 | 118 | fun start(context: Context) { |
|
| 98 | 119 | prefs(context).edit { putBoolean(WANTED, true) } |
|
| … | 50 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/runner/RunnerCard.kt +1 −1
| … | 57 unchanged lines |
||
| 58 | 58 | Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { |
|
| 59 | 59 | Text("OxygenFlow runner", style = MaterialTheme.typography.titleMedium) |
|
| 60 | 60 | val (line, colour) = when (val s = state) { |
|
| 61 | - | PhoneRunner.State.Unregistered -> "Run your repositories' WASM jobs on this phone, while it is charging on Wi-Fi." to MaterialTheme.colorScheme.onSurfaceVariant |
|
| 61 | + | PhoneRunner.State.Unregistered -> "Run your repositories' WASM jobs on this phone, while it is charging on Wi-Fi and cool." to MaterialTheme.colorScheme.onSurfaceVariant |
|
| 62 | 62 | PhoneRunner.State.Stopped -> "Registered as ${PhoneRunner.name()}; not running." to MaterialTheme.colorScheme.onSurfaceVariant |
|
| 63 | 63 | is PhoneRunner.State.Waiting -> "Not running: ${s.why}." to MaterialTheme.colorScheme.onSurfaceVariant |
|
| 64 | 64 | PhoneRunner.State.Running -> "Running as ${PhoneRunner.name()}: taking WASM jobs." to status.ok |
|
| … | 42 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/runner/RunnerService.kt +28 −1
| … | 12 unchanged lines |
||
| 13 | 13 | import android.net.ConnectivityManager |
|
| 14 | 14 | import android.net.Network |
|
| 15 | 15 | import android.net.NetworkCapabilities |
|
| 16 | + | import android.os.BatteryManager |
|
| 16 | 17 | import android.os.Build |
|
| 17 | 18 | import android.os.IBinder |
|
| 19 | + | import android.os.PowerManager |
|
| 18 | 20 | import io.oxygenhub.android.MainActivity |
|
| 19 | 21 | import io.oxygenhub.android.R |
|
| 20 | 22 | import uniffi.rust_core.runnerServe |
|
| … | 1 unchanged lines |
||
| 22 | 24 | ||
| 23 | 25 | /** |
|
| 24 | 26 | * Runs the phone's OxygenFlow runner (#8) in the foreground, with a |
|
| 25 | - | * notification saying so, while the phone is on charge and on Wi-Fi. |
|
| 27 | + | * notification saying so, while the phone is on charge, on Wi-Fi and cool. |
|
| 26 | 28 | * Leaving the charger gives it a minute: it claims nothing more, finishes |
|
| 27 | 29 | * a job the hub expects to end within it and hands back any other, its |
|
| 28 | 30 | * checkpoint saved; leaving Wi-Fi hands the job back at once |
|
| … | 14 unchanged lines |
||
| 43 | 45 | notify("Leaving: taken off charge") |
|
| 44 | 46 | } |
|
| 45 | 47 | Intent.ACTION_POWER_CONNECTED -> serveIfAllowed() |
|
| 48 | + | // The battery's temperature comes with its state (#8). |
|
| 49 | + | Intent.ACTION_BATTERY_CHANGED -> { |
|
| 50 | + | val tenths = intent.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, Int.MIN_VALUE).takeIf { it != Int.MIN_VALUE } |
|
| 51 | + | val hot = PhoneRunner.tooWarm(PowerManager.THERMAL_STATUS_NONE, tenths) |
|
| 52 | + | if (hot != null && PhoneRunner.state.value == PhoneRunner.State.Running) { |
|
| 53 | + | PhoneRunner.leave(hot, noticeSecs = 60) |
|
| 54 | + | notify("Resting: $hot") |
|
| 55 | + | } else if (hot == null && PhoneRunner.state.value is PhoneRunner.State.Waiting) { |
|
| 56 | + | serveIfAllowed() |
|
| 57 | + | } |
|
| 58 | + | } |
|
| 46 | 59 | } |
|
| 60 | + | } |
|
| 61 | + | } |
|
| 62 | + | ||
| 63 | + | /** Warming up (#8): leave with a minute's notice, as off charge; cooled down: serve again. */ |
|
| 64 | + | private val thermal = PowerManager.OnThermalStatusChangedListener { status -> |
|
| 65 | + | val hot = PhoneRunner.tooWarm(status, null) |
|
| 66 | + | if (hot != null) { |
|
| 67 | + | PhoneRunner.leave(hot, noticeSecs = 60) |
|
| 68 | + | notify("Resting: $hot") |
|
| 69 | + | } else { |
|
| 70 | + | serveIfAllowed() |
|
| 47 | 71 | } |
|
| 48 | 72 | } |
|
| 49 | 73 | ||
| … | 13 unchanged lines |
||
| 63 | 87 | IntentFilter().apply { |
|
| 64 | 88 | addAction(Intent.ACTION_POWER_DISCONNECTED) |
|
| 65 | 89 | addAction(Intent.ACTION_POWER_CONNECTED) |
|
| 90 | + | addAction(Intent.ACTION_BATTERY_CHANGED) |
|
| 66 | 91 | }, |
|
| 67 | 92 | ) |
|
| 68 | 93 | watchNetwork() |
|
| 94 | + | getSystemService(PowerManager::class.java)?.addThermalStatusListener(thermal) |
|
| 69 | 95 | } |
|
| 70 | 96 | serveIfAllowed() |
|
| 71 | 97 | return START_NOT_STICKY |
|
| … | 73 unchanged lines |
||
| 145 | 171 | ||
| 146 | 172 | override fun onDestroy() { |
|
| 147 | 173 | runCatching { unregisterReceiver(power) } |
|
| 174 | + | runCatching { getSystemService(PowerManager::class.java)?.removeThermalStatusListener(thermal) } |
|
| 148 | 175 | connectivity?.let { cb -> runCatching { getSystemService(ConnectivityManager::class.java)?.unregisterNetworkCallback(cb) } } |
|
| 149 | 176 | // Stopped from outside (the app, the system): drain rather than leave a job behind. |
|
| 150 | 177 | if (serving != null) runnerStop() |
|
| … | 29 unchanged lines |