OXYGENHUB

Coverage / app/src/main/java/io/oxygenhub/android/runner/RunnerService.kt 0.0%

0 of 124 lines run, 0 of 19 functions, 0 of 66 branches · run #1660 on main · run missed

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