Leaving Wi-Fi hands the phone's job back, and the runner serves again when charge and Wi-Fi return (refs #8) 41e2e862abce
Off Wi-Fi or on a metered network, the runner now leaves (flow::runner::leave, oxygenflow#76): it stops its job and hands it back to the hub, which gives it to another runner ahead of new work, rather than finishing it over the network the owner ruled out. Off charge it still drains. While its owner wants it, the service then waits in the foreground, saying why, and serves again once the phone is back on charge and Wi-Fi; Stop is offered while it waits. Pins OxygenFlow's main with leave.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 792780caa8e8
- When
- · 2026-10-25 17:54 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
41e2e862abce78d1a655c6012586b2535e1f9088bdadb201b2e68d594499c069- Depends on
- 114865a07877 4aee5293461d 57c0824300db ff65ea456ff1
- Depended on by
- aa627197ec18 0b152017dee3 8440d51a2275 ed6c64d23fca
- In bookmarks
- main
- In tags
- v0.1.0 v0.1.1 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
- 27 atoms, 0 tree operations, 5 files
Browse the tree at this change
modified app/src/main/java/io/oxygenhub/android/runner/PhoneRunner.kt +28 −2
| … | 14 unchanged lines |
||
| 15 | 15 | import kotlinx.coroutines.flow.asStateFlow |
|
| 16 | 16 | import kotlinx.coroutines.withContext |
|
| 17 | 17 | import uniffi.rust_core.runnerIsRegistered |
|
| 18 | + | import uniffi.rust_core.runnerLeave |
|
| 18 | 19 | import uniffi.rust_core.runnerRegister |
|
| 19 | 20 | import uniffi.rust_core.runnerStop |
|
| 20 | 21 | import java.io.File |
|
| … | 20 unchanged lines |
||
| 41 | 42 | /** Told to stop: finishing what it holds. */ |
|
| 42 | 43 | data class Draining(val why: String) : State |
|
| 43 | 44 | ||
| 45 | + | /** Told to leave: handing its job back to the hub for another runner. */ |
|
| 46 | + | data class Leaving(val why: String) : State |
|
| 47 | + | ||
| 44 | 48 | data class Failed(val message: String) : State |
|
| 45 | 49 | } |
|
| 46 | 50 | ||
| … | 10 unchanged lines |
||
| 57 | 61 | /** Called when the app starts and the service ends: what the runner is, as far as this process knows. */ |
|
| 58 | 62 | fun refresh(context: Context) { |
|
| 59 | 63 | val now = _state.value |
|
| 60 | - | if (now is State.Running || now is State.Draining) return |
|
| 64 | + | if (now is State.Running || now is State.Draining || now is State.Leaving) return |
|
| 61 | 65 | _state.value = if (runnerIsRegistered(home(context).path)) { |
|
| 62 | 66 | if (now is State.Waiting || now is State.Failed) now else State.Stopped |
|
| 63 | 67 | } else { |
|
| … | 34 unchanged lines |
||
| 98 | 102 | /** The owner stopped it: drain, and stay stopped after an update or restart. */ |
|
| 99 | 103 | fun stopByOwner(context: Context) { |
|
| 100 | 104 | prefs(context).edit { putBoolean(WANTED, false) } |
|
| 101 | - | drain("stopped by you") |
|
| 105 | + | if (_state.value is State.Waiting) { |
|
| 106 | + | // Not serving: only the service waiting for the charger or Wi-Fi. |
|
| 107 | + | context.stopService(Intent(context, RunnerService::class.java)) |
|
| 108 | + | _state.value = State.Stopped |
|
| 109 | + | } else { |
|
| 110 | + | drain("stopped by you") |
|
| 111 | + | } |
|
| 102 | 112 | } |
|
| 113 | + | ||
| 114 | + | /** Whether its owner wants it offered (started and not stopped by them). */ |
|
| 115 | + | fun wanted(context: Context): Boolean = prefs(context).getBoolean(WANTED, false) |
|
| 103 | 116 | ||
| 104 | 117 | /** After a restart or an app update ([RunnerResume]): offer the phone again if it was offered. */ |
|
| 105 | 118 | fun resume(context: Context) { |
|
| … | 9 unchanged lines |
||
| 115 | 128 | if (_state.value == State.Running) { |
|
| 116 | 129 | _state.value = State.Draining(why) |
|
| 117 | 130 | runnerStop() |
|
| 131 | + | } |
|
| 132 | + | } |
|
| 133 | + | ||
| 134 | + | /** |
|
| 135 | + | * Leave now (oxygenflow#76): stop what it holds and hand it back to the |
|
| 136 | + | * hub, which gives it to another runner ahead of new work. For leaving |
|
| 137 | + | * the allowed network: finishing would use the network the owner ruled out. |
|
| 138 | + | */ |
|
| 139 | + | fun leave(why: String) { |
|
| 140 | + | val now = _state.value |
|
| 141 | + | if (now == State.Running || now is State.Draining) { |
|
| 142 | + | _state.value = State.Leaving(why) |
|
| 143 | + | runnerLeave(why) |
|
| 118 | 144 | } |
|
| 119 | 145 | } |
|
| 120 | 146 | } |
modified app/src/main/java/io/oxygenhub/android/runner/RunnerCard.kt +3 −2
| … | 62 unchanged lines |
||
| 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 |
|
| 65 | 65 | is PhoneRunner.State.Draining -> "Draining (${s.why}): finishing its job, then stopping." to status.warn |
|
| 66 | + | is PhoneRunner.State.Leaving -> "Leaving (${s.why}): handing its job back for another runner, then stopping." to status.warn |
|
| 66 | 67 | is PhoneRunner.State.Failed -> "Stopped: ${s.message}" to status.bad |
|
| 67 | 68 | } |
|
| 68 | 69 | Text(line, style = MaterialTheme.typography.bodyMedium, color = colour) |
|
| … | 19 unchanged lines |
||
| 88 | 89 | } |
|
| 89 | 90 | }, |
|
| 90 | 91 | ) { Text("Offer this phone as a runner") } |
|
| 91 | - | PhoneRunner.State.Running -> OutlinedButton(onClick = { PhoneRunner.stopByOwner(context) }) { Text("Stop") } |
|
| 92 | - | is PhoneRunner.State.Draining -> {} |
|
| 92 | + | PhoneRunner.State.Running, is PhoneRunner.State.Waiting -> OutlinedButton(onClick = { PhoneRunner.stopByOwner(context) }) { Text("Stop") } |
|
| 93 | + | is PhoneRunner.State.Draining, is PhoneRunner.State.Leaving -> {} |
|
| 93 | 94 | else -> Button(onClick = { start() }) { Text("Start") } |
|
| 94 | 95 | } |
|
| 95 | 96 | } |
|
| … | 6 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/runner/RunnerService.kt +67 −22
| … | 22 unchanged lines |
||
| 23 | 23 | /** |
|
| 24 | 24 | * Runs the phone's OxygenFlow runner (#8) in the foreground, with a |
|
| 25 | 25 | * notification saying so, while the phone is on charge and on Wi-Fi. |
|
| 26 | - | * Leaving the charger or the network drains it: it claims nothing more, |
|
| 27 | - | * finishes and reports the job it holds, and the service stops. |
|
| 26 | + | * Leaving the charger drains it (it claims nothing more, finishes and |
|
| 27 | + | * reports the job it holds); leaving Wi-Fi hands the job back to the hub |
|
| 28 | + | * for another runner (oxygenflow#76). While its owner wants it, the service |
|
| 29 | + | * then waits, and serves again once the phone is back on charge and Wi-Fi. |
|
| 28 | 30 | */ |
|
| 29 | 31 | class RunnerService : Service() { |
|
| 30 | 32 | private var serving: Thread? = null |
|
| 31 | 33 | private var connectivity: ConnectivityManager.NetworkCallback? = null |
|
| 32 | 34 | ||
| 35 | + | private var watching = false |
|
| 36 | + | ||
| 33 | 37 | private val power = object : BroadcastReceiver() { |
|
| 34 | 38 | override fun onReceive(context: Context, intent: Intent) { |
|
| 35 | - | PhoneRunner.drain("taken off charge") |
|
| 36 | - | notify("Draining: taken off charge") |
|
| 39 | + | when (intent.action) { |
|
| 40 | + | Intent.ACTION_POWER_DISCONNECTED -> { |
|
| 41 | + | PhoneRunner.drain("taken off charge") |
|
| 42 | + | notify("Draining: taken off charge") |
|
| 43 | + | } |
|
| 44 | + | Intent.ACTION_POWER_CONNECTED -> serveIfAllowed() |
|
| 45 | + | } |
|
| 37 | 46 | } |
|
| 38 | 47 | } |
|
| 39 | 48 | ||
| … | 5 unchanged lines |
||
| 45 | 54 | startForeground(NOTIFICATION, notification("Starting"), ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE) |
|
| 46 | 55 | } else { |
|
| 47 | 56 | startForeground(NOTIFICATION, notification("Starting")) |
|
| 57 | + | } |
|
| 58 | + | if (!watching) { |
|
| 59 | + | watching = true |
|
| 60 | + | registerReceiver( |
|
| 61 | + | power, |
|
| 62 | + | IntentFilter().apply { |
|
| 63 | + | addAction(Intent.ACTION_POWER_DISCONNECTED) |
|
| 64 | + | addAction(Intent.ACTION_POWER_CONNECTED) |
|
| 65 | + | }, |
|
| 66 | + | ) |
|
| 67 | + | watchNetwork() |
|
| 48 | 68 | } |
|
| 49 | - | if (serving != null) return START_NOT_STICKY |
|
| 69 | + | serveIfAllowed() |
|
| 70 | + | return START_NOT_STICKY |
|
| 71 | + | } |
|
| 72 | + | ||
| 73 | + | /** |
|
| 74 | + | * Serve while the policy allows (on charge, on Wi-Fi), else wait for it |
|
| 75 | + | * in the foreground, saying why. Called again when the charger or the |
|
| 76 | + | * network comes back, and when serving ends while the owner still wants it. |
|
| 77 | + | */ |
|
| 78 | + | @Synchronized |
|
| 79 | + | private fun serveIfAllowed() { |
|
| 80 | + | if (serving != null) return |
|
| 50 | 81 | val blocked = PhoneRunner.blockedBy(this) |
|
| 51 | 82 | if (blocked != null) { |
|
| 52 | 83 | PhoneRunner.set(PhoneRunner.State.Waiting(blocked)) |
|
| 53 | - | stopSelf() |
|
| 54 | - | return START_NOT_STICKY |
|
| 84 | + | notify("Waiting: $blocked") |
|
| 85 | + | return |
|
| 55 | 86 | } |
|
| 56 | - | registerReceiver(power, IntentFilter(Intent.ACTION_POWER_DISCONNECTED)) |
|
| 57 | - | watchNetwork() |
|
| 58 | 87 | val home = PhoneRunner.home(this).path |
|
| 59 | 88 | PhoneRunner.set(PhoneRunner.State.Running) |
|
| 60 | 89 | notify("Waiting for WASM jobs, on charge and Wi-Fi") |
|
| 61 | 90 | serving = Thread { |
|
| 62 | - | try { |
|
| 91 | + | val failed = try { |
|
| 63 | 92 | runnerServe(home) |
|
| 64 | - | if (PhoneRunner.state.value !is PhoneRunner.State.Waiting) PhoneRunner.set(PhoneRunner.State.Stopped) |
|
| 93 | + | null |
|
| 65 | 94 | } catch (e: Exception) { |
|
| 66 | - | PhoneRunner.set(PhoneRunner.State.Failed(e.message ?: "the runner stopped")) |
|
| 67 | - | } finally { |
|
| 68 | - | serving = null |
|
| 69 | - | stopSelf() |
|
| 95 | + | e.message ?: "the runner stopped" |
|
| 70 | 96 | } |
|
| 97 | + | served(failed) |
|
| 71 | 98 | }.apply { |
|
| 72 | 99 | name = "oxygenflow-runner" |
|
| 73 | 100 | start() |
|
| 74 | 101 | } |
|
| 75 | - | return START_NOT_STICKY |
|
| 102 | + | } |
|
| 103 | + | ||
| 104 | + | /** Serving ended: wait for the charger and Wi-Fi again while the owner wants it, else stop. */ |
|
| 105 | + | @Synchronized |
|
| 106 | + | private fun served(failed: String?) { |
|
| 107 | + | serving = null |
|
| 108 | + | when { |
|
| 109 | + | failed != null -> { |
|
| 110 | + | PhoneRunner.set(PhoneRunner.State.Failed(failed)) |
|
| 111 | + | stopSelf() |
|
| 112 | + | } |
|
| 113 | + | PhoneRunner.wanted(this) -> serveIfAllowed() |
|
| 114 | + | else -> { |
|
| 115 | + | PhoneRunner.set(PhoneRunner.State.Stopped) |
|
| 116 | + | stopSelf() |
|
| 117 | + | } |
|
| 118 | + | } |
|
| 76 | 119 | } |
|
| 77 | 120 | ||
| 78 | - | /** Off Wi-Fi (or onto a metered network): drain. */ |
|
| 121 | + | /** Off Wi-Fi (or onto a metered network): leave, handing its job back to another runner (oxygenflow#76). */ |
|
| 79 | 122 | private fun watchNetwork() { |
|
| 80 | 123 | val net = getSystemService(ConnectivityManager::class.java) ?: return |
|
| 81 | 124 | val callback = object : ConnectivityManager.NetworkCallback() { |
|
| 82 | 125 | override fun onLost(network: Network) { |
|
| 83 | 126 | if (PhoneRunner.blockedBy(this@RunnerService) != null) { |
|
| 84 | - | PhoneRunner.drain("off Wi-Fi") |
|
| 85 | - | notify("Draining: off Wi-Fi") |
|
| 127 | + | PhoneRunner.leave("off Wi-Fi") |
|
| 128 | + | notify("Off Wi-Fi: handing its job back") |
|
| 86 | 129 | } |
|
| 87 | 130 | } |
|
| 88 | 131 | ||
| 89 | 132 | override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) { |
|
| 90 | - | if (!caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)) { |
|
| 91 | - | PhoneRunner.drain("on a metered network") |
|
| 92 | - | notify("Draining: on a metered network") |
|
| 133 | + | if (caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)) { |
|
| 134 | + | serveIfAllowed() |
|
| 135 | + | } else { |
|
| 136 | + | PhoneRunner.leave("on a metered network") |
|
| 137 | + | notify("On a metered network: handing its job back") |
|
| 93 | 138 | } |
|
| 94 | 139 | } |
|
| 95 | 140 | } |
|
| … | 38 unchanged lines |
replaced oxygenflow +0 −0
No line changes.
modified rust-core/src/lib.rs +9 −0
| … | 61 unchanged lines |
||
| 62 | 62 | flow::runner::stop(); |
|
| 63 | 63 | } |
|
| 64 | 64 | ||
| 65 | + | /// Leave now (oxygenflow#76): claim nothing more, stop the jobs it holds |
|
| 66 | + | /// and hand them back to the hub, which gives them to another runner ahead |
|
| 67 | + | /// of new work; [`runner_serve`] then returns. For leaving the allowed |
|
| 68 | + | /// network, where finishing would use the network the owner ruled out. |
|
| 69 | + | #[uniffi::export] |
|
| 70 | + | pub fn runner_leave(reason: String) { |
|
| 71 | + | flow::runner::leave(&reason); |
|
| 72 | + | } |
|
| 73 | + | ||
| 65 | 74 | /// The tiers this phone offers (the WASM tier), and why not the others. |
|
| 66 | 75 | #[uniffi::export] |
|
| 67 | 76 | pub fn runner_tiers(home: String) -> Vec<String> { |
|
| … | 6 unchanged lines |