OXYGENHUB
# 8440d51a2275 oxygenhub-android / app / src / main / java / io / oxygenhub / android / runner / PhoneRunner.kt
7.3 KiB · last changed The phone runner rests when the phone is warm (refs #8) 7 days ago source blame history raw
1
package io.oxygenhub.android.runner
2
3
import android.content.Context
4
import android.content.Intent
5
import android.content.IntentFilter
6
import android.net.ConnectivityManager
7
import android.net.NetworkCapabilities
8
import android.os.BatteryManager
9
import android.os.Build
10
import android.os.PowerManager
11
import androidx.core.content.ContextCompat
12
import androidx.core.content.edit
13
import io.oxygenhub.android.api.HubApi
14
import kotlinx.coroutines.Dispatchers
15
import kotlinx.coroutines.flow.MutableStateFlow
16
import kotlinx.coroutines.flow.StateFlow
17
import kotlinx.coroutines.flow.asStateFlow
18
import kotlinx.coroutines.withContext
19
import uniffi.rust_core.runnerIsRegistered
20
import uniffi.rust_core.runnerLeave
21
import uniffi.rust_core.runnerRegister
22
import uniffi.rust_core.runnerStop
23
import java.io.File
24
25
/**
26
 * The phone as an OxygenFlow runner (#8): OxygenFlow's own runner, built
27
 * into the app through rust-core, offering the WASM tier. It runs in
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.
30
 */
31
object PhoneRunner {
32
    sealed interface State {
33
        data object Unregistered : State
34
35
        /** Registered, not running. */
36
        data object Stopped : State
37
38
        /** Not allowed to run now, and why (off charge, off Wi-Fi). */
39
        data class Waiting(val why: String) : State
40
41
        data object Running : State
42
43
        /** Told to stop: finishing what it holds. */
44
        data class Draining(val why: String) : State
45
46
        /** Told to leave: handing its job back to the hub for another runner, unless due within [noticeSecs]. */
47
        data class Leaving(val why: String, val noticeSecs: Long = 0) : State
48
49
        data class Failed(val message: String) : State
50
    }
51
52
    private val _state = MutableStateFlow<State>(State.Unregistered)
53
    val state: StateFlow<State> = _state.asStateFlow()
54
55
    internal fun set(state: State) {
56
        _state.value = state
57
    }
58
59
    /** Where the runner keeps its credential and its jobs' files. */
60
    fun home(context: Context): File = File(context.filesDir, "flow").also { it.mkdirs() }
61
62
    /** Called when the app starts and the service ends: what the runner is, as far as this process knows. */
63
    fun refresh(context: Context) {
64
        val now = _state.value
65
        if (now is State.Running || now is State.Draining || now is State.Leaving) return
66
        _state.value = if (runnerIsRegistered(home(context).path)) {
67
            if (now is State.Waiting || now is State.Failed) now else State.Stopped
68
        } else {
69
            State.Unregistered
70
        }
71
    }
72
73
    /** The runner's name on the hub: the phone's model, as runner names are written. */
74
    fun name(): String =
75
        "android-" + Build.MODEL.lowercase().replace(Regex("[^a-z0-9]+"), "-").trim('-').ifEmpty { "phone" }
76
77
    /** Ask the hub for a token and register this phone as a runner for the account's repositories. */
78
    suspend fun register(context: Context, api: HubApi, hub: String): String {
79
        val token = api.runnerToken()
80
        val name = withContext(Dispatchers.IO) { runnerRegister(home(context).path, hub, token.token, name()) }
81
        _state.value = State.Stopped
82
        return name
83
    }
84
85
    /** Why the runner may not run now, or null when it may: on charge, on Wi-Fi, and cool. */
86
    fun blockedBy(context: Context): String? {
87
        val battery = context.getSystemService(BatteryManager::class.java)
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 }
93
        val net = context.getSystemService(ConnectivityManager::class.java)
94
        val caps = net?.activeNetwork?.let { net.getNetworkCapabilities(it) }
95
        if (caps == null || !caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)) {
96
            return "the phone is not on Wi-Fi (or another unmetered network)"
97
        }
98
        return null
99
    }
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
117
    /** Start the runner, and keep it offered across app updates and restarts until the owner stops it. */
118
    fun start(context: Context) {
119
        prefs(context).edit { putBoolean(WANTED, true) }
120
        ContextCompat.startForegroundService(context, Intent(context, RunnerService::class.java))
121
    }
122
123
    /** The owner stopped it: drain, and stay stopped after an update or restart. */
124
    fun stopByOwner(context: Context) {
125
        prefs(context).edit { putBoolean(WANTED, false) }
126
        if (_state.value is State.Waiting) {
127
            // Not serving: only the service waiting for the charger or Wi-Fi.
128
            context.stopService(Intent(context, RunnerService::class.java))
129
            _state.value = State.Stopped
130
        } else {
131
            drain("stopped by you")
132
        }
133
    }
134
135
    /** Whether its owner wants it offered (started and not stopped by them). */
136
    fun wanted(context: Context): Boolean = prefs(context).getBoolean(WANTED, false)
137
138
    /** After a restart or an app update ([RunnerResume]): offer the phone again if it was offered. */
139
    fun resume(context: Context) {
140
        if (prefs(context).getBoolean(WANTED, false) && runnerIsRegistered(home(context).path)) start(context)
141
    }
142
143
    private const val WANTED = "runner.wanted"
144
145
    private fun prefs(context: Context) = context.getSharedPreferences("runner", Context.MODE_PRIVATE)
146
147
    /** Drain: claim nothing more, finish and report what it holds, then stop. */
148
    fun drain(why: String) {
149
        if (_state.value == State.Running) {
150
            _state.value = State.Draining(why)
151
            runnerStop()
152
        }
153
    }
154
155
    /**
156
     * Leave (oxygenflow#76): claim nothing more and hand what it holds back
157
     * to the hub, which gives it to another runner ahead of new work. With
158
     * [noticeSecs], a job the hub expects to finish within it finishes
159
     * first: a minute off charge. None off the allowed network, where
160
     * finishing would use the network the owner ruled out.
161
     */
162
    fun leave(why: String, noticeSecs: Long = 0) {
163
        val now = _state.value
164
        if (now == State.Running || now is State.Draining) {
165
            _state.value = State.Leaving(why, noticeSecs)
166
            runnerLeave(why, noticeSecs.toULong())
167
        }
168
    }
169
}