| 1 |
package io.oxygenhub.android.runner |
| 2 |
|
| 3 |
import android.content.Context |
| 4 |
import android.content.Intent |
| 5 |
import android.net.ConnectivityManager |
| 6 |
import android.net.NetworkCapabilities |
| 7 |
import android.os.BatteryManager |
| 8 |
import android.os.Build |
| 9 |
import androidx.core.content.ContextCompat |
| 10 |
import androidx.core.content.edit |
| 11 |
import io.oxygenhub.android.api.HubApi |
| 12 |
import kotlinx.coroutines.Dispatchers |
| 13 |
import kotlinx.coroutines.flow.MutableStateFlow |
| 14 |
import kotlinx.coroutines.flow.StateFlow |
| 15 |
import kotlinx.coroutines.flow.asStateFlow |
| 16 |
import kotlinx.coroutines.withContext |
| 17 |
import uniffi.rust_core.runnerIsRegistered |
| 18 |
import uniffi.rust_core.runnerLeave |
| 19 |
import uniffi.rust_core.runnerRegister |
| 20 |
import uniffi.rust_core.runnerStop |
| 21 |
import java.io.File |
| 22 |
|
| 23 |
/** |
| 24 |
* The phone as an OxygenFlow runner (#8): OxygenFlow's own runner, built |
| 25 |
* into the app through rust-core, offering the WASM tier. It runs in |
| 26 |
* [RunnerService] only while the phone is on charge and on an unmetered |
| 27 |
* network (Wi-Fi), and drains when either stops: it claims nothing more |
| 28 |
* and finishes what it holds. |
| 29 |
*/ |
| 30 |
object PhoneRunner { |
| 31 |
sealed interface State { |
| 32 |
data object Unregistered : State |
| 33 |
|
| 34 |
/** Registered, not running. */ |
| 35 |
data object Stopped : State |
| 36 |
|
| 37 |
/** Not allowed to run now, and why (off charge, off Wi-Fi). */ |
| 38 |
data class Waiting(val why: String) : State |
| 39 |
|
| 40 |
data object Running : State |
| 41 |
|
| 42 |
/** Told to stop: finishing what it holds. */ |
| 43 |
data class Draining(val why: String) : State |
| 44 |
|
| 45 |
/** Told to leave: handing its job back to the hub for another runner. */ |
| 46 |
data class Leaving(val why: String) : State |
| 47 |
|
| 48 |
data class Failed(val message: String) : State |
| 49 |
} |
| 50 |
|
| 51 |
private val _state = MutableStateFlow<State>(State.Unregistered) |
| 52 |
val state: StateFlow<State> = _state.asStateFlow() |
| 53 |
|
| 54 |
internal fun set(state: State) { |
| 55 |
_state.value = state |
| 56 |
} |
| 57 |
|
| 58 |
/** Where the runner keeps its credential and its jobs' files. */ |
| 59 |
fun home(context: Context): File = File(context.filesDir, "flow").also { it.mkdirs() } |
| 60 |
|
| 61 |
/** Called when the app starts and the service ends: what the runner is, as far as this process knows. */ |
| 62 |
fun refresh(context: Context) { |
| 63 |
val now = _state.value |
| 64 |
if (now is State.Running || now is State.Draining || now is State.Leaving) return |
| 65 |
_state.value = if (runnerIsRegistered(home(context).path)) { |
| 66 |
if (now is State.Waiting || now is State.Failed) now else State.Stopped |
| 67 |
} else { |
| 68 |
State.Unregistered |
| 69 |
} |
| 70 |
} |
| 71 |
|
| 72 |
/** The runner's name on the hub: the phone's model, as runner names are written. */ |
| 73 |
fun name(): String = |
| 74 |
"android-" + Build.MODEL.lowercase().replace(Regex("[^a-z0-9]+"), "-").trim('-').ifEmpty { "phone" } |
| 75 |
|
| 76 |
/** Ask the hub for a token and register this phone as a runner for the account's repositories. */ |
| 77 |
suspend fun register(context: Context, api: HubApi, hub: String): String { |
| 78 |
val token = api.runnerToken() |
| 79 |
val name = withContext(Dispatchers.IO) { runnerRegister(home(context).path, hub, token.token, name()) } |
| 80 |
_state.value = State.Stopped |
| 81 |
return name |
| 82 |
} |
| 83 |
|
| 84 |
/** Why the runner may not run now, or null when it may: on charge and on Wi-Fi. */ |
| 85 |
fun blockedBy(context: Context): String? { |
| 86 |
val battery = context.getSystemService(BatteryManager::class.java) |
| 87 |
if (battery != null && !battery.isCharging) return "the phone is not on charge" |
| 88 |
val net = context.getSystemService(ConnectivityManager::class.java) |
| 89 |
val caps = net?.activeNetwork?.let { net.getNetworkCapabilities(it) } |
| 90 |
if (caps == null || !caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)) { |
| 91 |
return "the phone is not on Wi-Fi (or another unmetered network)" |
| 92 |
} |
| 93 |
return null |
| 94 |
} |
| 95 |
|
| 96 |
/** Start the runner, and keep it offered across app updates and restarts until the owner stops it. */ |
| 97 |
fun start(context: Context) { |
| 98 |
prefs(context).edit { putBoolean(WANTED, true) } |
| 99 |
ContextCompat.startForegroundService(context, Intent(context, RunnerService::class.java)) |
| 100 |
} |
| 101 |
|
| 102 |
/** The owner stopped it: drain, and stay stopped after an update or restart. */ |
| 103 |
fun stopByOwner(context: Context) { |
| 104 |
prefs(context).edit { putBoolean(WANTED, false) } |
| 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 |
} |
| 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) |
| 116 |
|
| 117 |
/** After a restart or an app update ([RunnerResume]): offer the phone again if it was offered. */ |
| 118 |
fun resume(context: Context) { |
| 119 |
if (prefs(context).getBoolean(WANTED, false) && runnerIsRegistered(home(context).path)) start(context) |
| 120 |
} |
| 121 |
|
| 122 |
private const val WANTED = "runner.wanted" |
| 123 |
|
| 124 |
private fun prefs(context: Context) = context.getSharedPreferences("runner", Context.MODE_PRIVATE) |
| 125 |
|
| 126 |
/** Drain: claim nothing more, finish and report what it holds, then stop. */ |
| 127 |
fun drain(why: String) { |
| 128 |
if (_state.value == State.Running) { |
| 129 |
_state.value = State.Draining(why) |
| 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) |
| 144 |
} |
| 145 |
} |
| 146 |
} |