OXYGENHUB

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