OXYGENHUB

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