OXYGENHUB
# 41e2e862abce oxygenhub-android / app / src / main / java / io / oxygenhub / android / runner / RunnerService.kt
6.6 KiB · last changed Leaving Wi-Fi hands the phone's job back, and the runner serves again when charge and Wi-Fi return (refs #8) 10 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.Build
17
import android.os.IBinder
18
import io.oxygenhub.android.MainActivity
19
import io.oxygenhub.android.R
20
import uniffi.rust_core.runnerServe
21
import uniffi.rust_core.runnerStop
22
23
/**
24
 * Runs the phone's OxygenFlow runner (#8) in the foreground, with a
25
 * notification saying so, while the phone is on charge and on Wi-Fi.
26
 * Leaving the charger drains it (it claims nothing more, finishes and
27
 * reports the job it holds); leaving Wi-Fi hands the job back to the hub
28
 * for another runner (oxygenflow#76). While its owner wants it, the service
29
 * then waits, and serves again once the phone is back on charge and Wi-Fi.
30
 */
31
class RunnerService : Service() {
32
    private var serving: Thread? = null
33
    private var connectivity: ConnectivityManager.NetworkCallback? = null
34
35
    private var watching = false
36
37
    private val power = object : BroadcastReceiver() {
38
        override fun onReceive(context: Context, intent: Intent) {
39
            when (intent.action) {
40
                Intent.ACTION_POWER_DISCONNECTED -> {
41
                    PhoneRunner.drain("taken off charge")
42
                    notify("Draining: taken off charge")
43
                }
44
                Intent.ACTION_POWER_CONNECTED -> serveIfAllowed()
45
            }
46
        }
47
    }
48
49
    override fun onBind(intent: Intent?): IBinder? = null
50
51
    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
52
        // The special-use type is Android 14's; before it, the manifest's type stands.
53
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
54
            startForeground(NOTIFICATION, notification("Starting"), ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
55
        } else {
56
            startForeground(NOTIFICATION, notification("Starting"))
57
        }
58
        if (!watching) {
59
            watching = true
60
            registerReceiver(
61
                power,
62
                IntentFilter().apply {
63
                    addAction(Intent.ACTION_POWER_DISCONNECTED)
64
                    addAction(Intent.ACTION_POWER_CONNECTED)
65
                },
66
            )
67
            watchNetwork()
68
        }
69
        serveIfAllowed()
70
        return START_NOT_STICKY
71
    }
72
73
    /**
74
     * Serve while the policy allows (on charge, on Wi-Fi), else wait for it
75
     * in the foreground, saying why. Called again when the charger or the
76
     * network comes back, and when serving ends while the owner still wants it.
77
     */
78
    @Synchronized
79
    private fun serveIfAllowed() {
80
        if (serving != null) return
81
        val blocked = PhoneRunner.blockedBy(this)
82
        if (blocked != null) {
83
            PhoneRunner.set(PhoneRunner.State.Waiting(blocked))
84
            notify("Waiting: $blocked")
85
            return
86
        }
87
        val home = PhoneRunner.home(this).path
88
        PhoneRunner.set(PhoneRunner.State.Running)
89
        notify("Waiting for WASM jobs, on charge and Wi-Fi")
90
        serving = Thread {
91
            val failed = try {
92
                runnerServe(home)
93
                null
94
            } catch (e: Exception) {
95
                e.message ?: "the runner stopped"
96
            }
97
            served(failed)
98
        }.apply {
99
            name = "oxygenflow-runner"
100
            start()
101
        }
102
    }
103
104
    /** Serving ended: wait for the charger and Wi-Fi again while the owner wants it, else stop. */
105
    @Synchronized
106
    private fun served(failed: String?) {
107
        serving = null
108
        when {
109
            failed != null -> {
110
                PhoneRunner.set(PhoneRunner.State.Failed(failed))
111
                stopSelf()
112
            }
113
            PhoneRunner.wanted(this) -> serveIfAllowed()
114
            else -> {
115
                PhoneRunner.set(PhoneRunner.State.Stopped)
116
                stopSelf()
117
            }
118
        }
119
    }
120
121
    /** Off Wi-Fi (or onto a metered network): leave, handing its job back to another runner (oxygenflow#76). */
122
    private fun watchNetwork() {
123
        val net = getSystemService(ConnectivityManager::class.java) ?: return
124
        val callback = object : ConnectivityManager.NetworkCallback() {
125
            override fun onLost(network: Network) {
126
                if (PhoneRunner.blockedBy(this@RunnerService) != null) {
127
                    PhoneRunner.leave("off Wi-Fi")
128
                    notify("Off Wi-Fi: handing its job back")
129
                }
130
            }
131
132
            override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
133
                if (caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)) {
134
                    serveIfAllowed()
135
                } else {
136
                    PhoneRunner.leave("on a metered network")
137
                    notify("On a metered network: handing its job back")
138
                }
139
            }
140
        }
141
        net.registerDefaultNetworkCallback(callback)
142
        connectivity = callback
143
    }
144
145
    override fun onDestroy() {
146
        runCatching { unregisterReceiver(power) }
147
        connectivity?.let { cb -> runCatching { getSystemService(ConnectivityManager::class.java)?.unregisterNetworkCallback(cb) } }
148
        // Stopped from outside (the app, the system): drain rather than leave a job behind.
149
        if (serving != null) runnerStop()
150
        super.onDestroy()
151
    }
152
153
    private fun notify(text: String) {
154
        getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION, notification(text))
155
    }
156
157
    private fun notification(text: String): Notification {
158
        val manager = getSystemService(NotificationManager::class.java)
159
        manager?.createNotificationChannel(
160
            NotificationChannel(CHANNEL, "OxygenFlow runner", NotificationManager.IMPORTANCE_LOW).apply {
161
                description = "While this phone runs OxygenFlow jobs"
162
            },
163
        )
164
        val open = PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE)
165
        return Notification.Builder(this, CHANNEL)
166
            .setSmallIcon(R.drawable.ic_launcher_monochrome)
167
            .setContentTitle("OxygenFlow runner")
168
            .setContentText(text)
169
            .setContentIntent(open)
170
            .setOngoing(true)
171
            .build()
172
    }
173
174
    private companion object {
175
        const val CHANNEL = "runner"
176
        const val NOTIFICATION = 8
177
    }
178
}