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