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