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