OXYGENHUB

Coverage / app/src/main/java/io/oxygenhub/android/runner/PhoneRunner.kt 21.4%

15 of 70 lines run, 3 of 27 functions, 9 of 59 branches · run #1659 on main · run missed

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
    /** The jobs it is running now, "owner/name #run job" (#8), while it serves. */
56
    private val _jobs = MutableStateFlow<List<String>>(emptyList())
57
    val jobs: StateFlow<List<String>> = _jobs.asStateFlow()
58
59
    internal fun setJobs(jobs: List<String>) {
60
        _jobs.value = jobs
61
    }
62
63
    /** The notification's line for [jobs]: what it runs, or that it waits for work. */
64
    fun jobsLine(jobs: List<String>): String = when (jobs.size) {
65
        0 -> "Waiting for WASM jobs, on charge and Wi-Fi"
66
        1 -> "Running ${jobs[0]}"
67
        else -> "Running ${jobs.size} jobs: ${jobs.joinToString(", ")}"
68
    }
69
70
    internal fun set(state: State) {
71
        _state.value = state
72
    }
73
74
    /** Where the runner keeps its credential and its jobs' files. */
75
    fun home(context: Context): File = File(context.filesDir, "flow").also { it.mkdirs() }
76
77
    /** Called when the app starts and the service ends: what the runner is, as far as this process knows. */
78
    fun refresh(context: Context) {
79
        val now = _state.value
80
        if (now is State.Running || now is State.Draining || now is State.Leaving) return
81
        _state.value = if (runnerIsRegistered(home(context).path)) {
82
            if (now is State.Waiting || now is State.Failed) now else State.Stopped
83
        } else {
84
            State.Unregistered
85
        }
86
    }
87
88
    /** The runner's name on the hub: the phone's model, as runner names are written. */
89
    fun name(): String =
90
        "android-" + Build.MODEL.lowercase().replace(Regex("[^a-z0-9]+"), "-").trim('-').ifEmpty { "phone" }
91
92
    /** Ask the hub for a token and register this phone as a runner for the account's repositories. */
93
    suspend fun register(context: Context, api: HubApi, hub: String): String {
94
        val token = api.runnerToken()
95
        val name = withContext(Dispatchers.IO) { runnerRegister(home(context).path, hub, token.token, name()) }
96
        _state.value = State.Stopped
97
        return name
98
    }
99
100
    /** Why the runner may not run now, or null when it may: on charge, on Wi-Fi, and cool. */
101
    fun blockedBy(context: Context): String? {
102
        val battery = context.getSystemService(BatteryManager::class.java)
103
        if (battery != null && !battery.isCharging) return "the phone is not on charge"
104
        val sticky = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
105
        val tenths = sticky?.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, Int.MIN_VALUE)?.takeIf { it != Int.MIN_VALUE }
106
        val thermal = context.getSystemService(PowerManager::class.java)?.currentThermalStatus ?: PowerManager.THERMAL_STATUS_NONE
107
        tooWarm(thermal, tenths)?.let { return it }
108
        val net = context.getSystemService(ConnectivityManager::class.java)
109
        val caps = net?.activeNetwork?.let { net.getNetworkCapabilities(it) }
110
        if (caps == null || !caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)) {
111
            return "the phone is not on Wi-Fi (or another unmetered network)"
112
        }
113
        return null
114
    }
115
116
    /** The battery temperature, in tenths of a degree, above which the runner rests: 40 °C. */
117
    const val MAX_BATTERY_TENTHS = 400
118
119
    /**
120
     * Why the phone is too warm to run jobs (#8), or null: the system says
121
     * it is throttling (moderate or worse), or the battery is above
122
     * [MAX_BATTERY_TENTHS]. Charging and a busy CPU together are what a
123
     * phone handles worst; a job waits for it to cool rather than cook it.
124
     */
125
    fun tooWarm(thermalStatus: Int, batteryTenths: Int?): String? = when {
126
        thermalStatus >= PowerManager.THERMAL_STATUS_MODERATE -> "the phone is warm (the system is throttling it)"
127
        batteryTenths != null && batteryTenths > MAX_BATTERY_TENTHS ->
128
            "the battery is warm (${batteryTenths / 10}.${batteryTenths % 10} °C, above ${MAX_BATTERY_TENTHS / 10} °C)"
129
        else -> null
130
    }
131
132
    /** Start the runner, and keep it offered across app updates and restarts until the owner stops it. */
133
    fun start(context: Context) {
134
        prefs(context).edit { putBoolean(WANTED, true) }
135
        ContextCompat.startForegroundService(context, Intent(context, RunnerService::class.java))
136
    }
137
138
    /** The owner stopped it: drain, and stay stopped after an update or restart. */
139
    fun stopByOwner(context: Context) {
140
        prefs(context).edit { putBoolean(WANTED, false) }
141
        if (_state.value is State.Waiting) {
142
            // Not serving: only the service waiting for the charger or Wi-Fi.
143
            context.stopService(Intent(context, RunnerService::class.java))
144
            _state.value = State.Stopped
145
        } else {
146
            drain("stopped by you")
147
        }
148
    }
149
150
    /** Whether its owner wants it offered (started and not stopped by them). */
151
    fun wanted(context: Context): Boolean = prefs(context).getBoolean(WANTED, false)
152
153
    /** After a restart or an app update ([RunnerResume]): offer the phone again if it was offered. */
154
    fun resume(context: Context) {
155
        if (prefs(context).getBoolean(WANTED, false) && runnerIsRegistered(home(context).path)) start(context)
156
    }
157
158
    private const val WANTED = "runner.wanted"
159
160
    private fun prefs(context: Context) = context.getSharedPreferences("runner", Context.MODE_PRIVATE)
161
162
    /** Drain: claim nothing more, finish and report what it holds, then stop. */
163
    fun drain(why: String) {
164
        if (_state.value == State.Running) {
165
            _state.value = State.Draining(why)
166
            runnerStop()
167
        }
168
    }
169
170
    /**
171
     * Leave (oxygenflow#76): claim nothing more and hand what it holds back
172
     * to the hub, which gives it to another runner ahead of new work. With
173
     * [noticeSecs], a job the hub expects to finish within it finishes
174
     * first: a minute off charge. None off the allowed network, where
175
     * finishing would use the network the owner ruled out.
176
     */
177
    fun leave(why: String, noticeSecs: Long = 0) {
178
        val now = _state.value
179
        if (now == State.Running || now is State.Draining) {
180
            _state.value = State.Leaving(why, noticeSecs)
181
            runnerLeave(why, noticeSecs.toULong())
182
        }
183
    }
184
}