OXYGENHUB
# 8440d51a2275 oxygenhub-android / app / src / main / java / io / oxygenhub / android / runner / RunnerService.kt
8.1 KiB · last changed The phone runner rests when the phone is warm (refs #8) 7 days ago source blame history raw
1
package io.oxygenhub.android.runner
2
3
import android.app.Notification
4
import android.app.NotificationChannel
5
import android.app.NotificationManager
6
import android.app.PendingIntent
7
import android.app.Service
8
import android.content.BroadcastReceiver
9
import android.content.Context
10
import android.content.Intent
11
import android.content.IntentFilter
12
import android.content.pm.ServiceInfo
13
import android.net.ConnectivityManager
14
import android.net.Network
15
import android.net.NetworkCapabilities
16
import android.os.BatteryManager
17
import android.os.Build
18
import android.os.IBinder
19
import android.os.PowerManager
20
import io.oxygenhub.android.MainActivity
21
import io.oxygenhub.android.R
22
import uniffi.rust_core.runnerServe
23
import uniffi.rust_core.runnerStop
24
25
/**
26
 * Runs the phone's OxygenFlow runner (#8) in the foreground, with a
27
 * notification saying so, while the phone is on charge, on Wi-Fi and cool.
28
 * Leaving the charger gives it a minute: it claims nothing more, finishes
29
 * a job the hub expects to end within it and hands back any other, its
30
 * checkpoint saved; leaving Wi-Fi hands the job back at once
31
 * (oxygenflow#76). While its owner wants it, the service
32
 * then waits, and serves again once the phone is back on charge and Wi-Fi.
33
 */
34
class RunnerService : Service() {
35
    private var serving: Thread? = null
36
    private var connectivity: ConnectivityManager.NetworkCallback? = null
37
38
    private var watching = false
39
40
    private val power = object : BroadcastReceiver() {
41
        override fun onReceive(context: Context, intent: Intent) {
42
            when (intent.action) {
43
                Intent.ACTION_POWER_DISCONNECTED -> {
44
                    PhoneRunner.leave("taken off charge", noticeSecs = 60)
45
                    notify("Leaving: taken off charge")
46
                }
47
                Intent.ACTION_POWER_CONNECTED -> serveIfAllowed()
48
                // The battery's temperature comes with its state (#8).
49
                Intent.ACTION_BATTERY_CHANGED -> {
50
                    val tenths = intent.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, Int.MIN_VALUE).takeIf { it != Int.MIN_VALUE }
51
                    val hot = PhoneRunner.tooWarm(PowerManager.THERMAL_STATUS_NONE, tenths)
52
                    if (hot != null && PhoneRunner.state.value == PhoneRunner.State.Running) {
53
                        PhoneRunner.leave(hot, noticeSecs = 60)
54
                        notify("Resting: $hot")
55
                    } else if (hot == null && PhoneRunner.state.value is PhoneRunner.State.Waiting) {
56
                        serveIfAllowed()
57
                    }
58
                }
59
            }
60
        }
61
    }
62
63
    /** Warming up (#8): leave with a minute's notice, as off charge; cooled down: serve again. */
64
    private val thermal = PowerManager.OnThermalStatusChangedListener { status ->
65
        val hot = PhoneRunner.tooWarm(status, null)
66
        if (hot != null) {
67
            PhoneRunner.leave(hot, noticeSecs = 60)
68
            notify("Resting: $hot")
69
        } else {
70
            serveIfAllowed()
71
        }
72
    }
73
74
    override fun onBind(intent: Intent?): IBinder? = null
75
76
    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
77
        // The special-use type is Android 14's; before it, the manifest's type stands.
78
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
79
            startForeground(NOTIFICATION, notification("Starting"), ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
80
        } else {
81
            startForeground(NOTIFICATION, notification("Starting"))
82
        }
83
        if (!watching) {
84
            watching = true
85
            registerReceiver(
86
                power,
87
                IntentFilter().apply {
88
                    addAction(Intent.ACTION_POWER_DISCONNECTED)
89
                    addAction(Intent.ACTION_POWER_CONNECTED)
90
                    addAction(Intent.ACTION_BATTERY_CHANGED)
91
                },
92
            )
93
            watchNetwork()
94
            getSystemService(PowerManager::class.java)?.addThermalStatusListener(thermal)
95
        }
96
        serveIfAllowed()
97
        return START_NOT_STICKY
98
    }
99
100
    /**
101
     * Serve while the policy allows (on charge, on Wi-Fi), else wait for it
102
     * in the foreground, saying why. Called again when the charger or the
103
     * network comes back, and when serving ends while the owner still wants it.
104
     */
105
    @Synchronized
106
    private fun serveIfAllowed() {
107
        if (serving != null) return
108
        val blocked = PhoneRunner.blockedBy(this)
109
        if (blocked != null) {
110
            PhoneRunner.set(PhoneRunner.State.Waiting(blocked))
111
            notify("Waiting: $blocked")
112
            return
113
        }
114
        val home = PhoneRunner.home(this).path
115
        PhoneRunner.set(PhoneRunner.State.Running)
116
        notify("Waiting for WASM jobs, on charge and Wi-Fi")
117
        serving = Thread {
118
            val failed = try {
119
                runnerServe(home)
120
                null
121
            } catch (e: Exception) {
122
                e.message ?: "the runner stopped"
123
            }
124
            served(failed)
125
        }.apply {
126
            name = "oxygenflow-runner"
127
            start()
128
        }
129
    }
130
131
    /** Serving ended: wait for the charger and Wi-Fi again while the owner wants it, else stop. */
132
    @Synchronized
133
    private fun served(failed: String?) {
134
        serving = null
135
        when {
136
            failed != null -> {
137
                PhoneRunner.set(PhoneRunner.State.Failed(failed))
138
                stopSelf()
139
            }
140
            PhoneRunner.wanted(this) -> serveIfAllowed()
141
            else -> {
142
                PhoneRunner.set(PhoneRunner.State.Stopped)
143
                stopSelf()
144
            }
145
        }
146
    }
147
148
    /** Off Wi-Fi (or onto a metered network): leave, handing its job back to another runner (oxygenflow#76). */
149
    private fun watchNetwork() {
150
        val net = getSystemService(ConnectivityManager::class.java) ?: return
151
        val callback = object : ConnectivityManager.NetworkCallback() {
152
            override fun onLost(network: Network) {
153
                if (PhoneRunner.blockedBy(this@RunnerService) != null) {
154
                    PhoneRunner.leave("off Wi-Fi")
155
                    notify("Off Wi-Fi: handing its job back")
156
                }
157
            }
158
159
            override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
160
                if (caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)) {
161
                    serveIfAllowed()
162
                } else {
163
                    PhoneRunner.leave("on a metered network")
164
                    notify("On a metered network: handing its job back")
165
                }
166
            }
167
        }
168
        net.registerDefaultNetworkCallback(callback)
169
        connectivity = callback
170
    }
171
172
    override fun onDestroy() {
173
        runCatching { unregisterReceiver(power) }
174
        runCatching { getSystemService(PowerManager::class.java)?.removeThermalStatusListener(thermal) }
175
        connectivity?.let { cb -> runCatching { getSystemService(ConnectivityManager::class.java)?.unregisterNetworkCallback(cb) } }
176
        // Stopped from outside (the app, the system): drain rather than leave a job behind.
177
        if (serving != null) runnerStop()
178
        super.onDestroy()
179
    }
180
181
    private fun notify(text: String) {
182
        getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION, notification(text))
183
    }
184
185
    private fun notification(text: String): Notification {
186
        val manager = getSystemService(NotificationManager::class.java)
187
        manager?.createNotificationChannel(
188
            NotificationChannel(CHANNEL, "OxygenFlow runner", NotificationManager.IMPORTANCE_LOW).apply {
189
                description = "While this phone runs OxygenFlow jobs"
190
            },
191
        )
192
        val open = PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE)
193
        return Notification.Builder(this, CHANNEL)
194
            .setSmallIcon(R.drawable.ic_launcher_monochrome)
195
            .setContentTitle("OxygenFlow runner")
196
            .setContentText(text)
197
            .setContentIntent(open)
198
            .setOngoing(true)
199
            .build()
200
    }
201
202
    private companion object {
203
        const val CHANNEL = "runner"
204
        const val NOTIFICATION = 8
205
    }
206
}