OXYGENHUB
# 8a657957d5a0 oxygenhub-android / app / src / main / java / io / oxygenhub / android / runner / PhoneRunner.kt
6.0 KiB · last changed Off charge, the runner leaves with a minute's notice 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.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, unless due within [noticeSecs]. */
46
        data class Leaving(val why: String, val noticeSecs: Long = 0) : 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 (oxygenflow#76): claim nothing more and hand what it holds back
136
     * to the hub, which gives it to another runner ahead of new work. With
137
     * [noticeSecs], a job the hub expects to finish within it finishes
138
     * first: a minute off charge. None off the allowed network, where
139
     * finishing would use the network the owner ruled out.
140
     */
141
    fun leave(why: String, noticeSecs: Long = 0) {
142
        val now = _state.value
143
        if (now == State.Running || now is State.Draining) {
144
            _state.value = State.Leaving(why, noticeSecs)
145
            runnerLeave(why, noticeSecs.toULong())
146
        }
147
    }
148
}