| 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 |
} |