OXYGENHUB

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

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