| 1 |
package io.oxygenhub.android.runner |
| 2 |
|
| 3 |
import android.Manifest |
| 4 |
import android.content.pm.PackageManager |
| 5 |
import android.os.Build |
| 6 |
import androidx.activity.compose.rememberLauncherForActivityResult |
| 7 |
import androidx.activity.result.contract.ActivityResultContracts |
| 8 |
import androidx.compose.foundation.layout.Arrangement |
| 9 |
import androidx.compose.foundation.layout.Column |
| 10 |
import androidx.compose.foundation.layout.Row |
| 11 |
import androidx.compose.foundation.layout.fillMaxWidth |
| 12 |
import androidx.compose.foundation.layout.padding |
| 13 |
import androidx.compose.material3.Button |
| 14 |
import androidx.compose.material3.MaterialTheme |
| 15 |
import androidx.compose.material3.OutlinedButton |
| 16 |
import androidx.compose.material3.Text |
| 17 |
import androidx.compose.runtime.Composable |
| 18 |
import androidx.compose.runtime.LaunchedEffect |
| 19 |
import androidx.compose.runtime.getValue |
| 20 |
import androidx.compose.runtime.mutableStateOf |
| 21 |
import androidx.compose.runtime.remember |
| 22 |
import androidx.compose.runtime.rememberCoroutineScope |
| 23 |
import androidx.compose.runtime.setValue |
| 24 |
import androidx.compose.ui.Modifier |
| 25 |
import androidx.compose.ui.platform.LocalContext |
| 26 |
import androidx.compose.ui.unit.dp |
| 27 |
import androidx.core.content.ContextCompat |
| 28 |
import androidx.lifecycle.compose.collectAsStateWithLifecycle |
| 29 |
import io.oxygenhub.android.api.HubApi |
| 30 |
import io.oxygenhub.android.api.userMessage |
| 31 |
import io.oxygenhub.android.ui.common.Panel |
| 32 |
import io.oxygenhub.android.ui.theme.LocalStatusColors |
| 33 |
import kotlinx.coroutines.CancellationException |
| 34 |
import kotlinx.coroutines.launch |
| 35 |
|
| 36 |
/** |
| 37 |
* The phone as an OxygenFlow runner (#8), on the Account screen: offer it |
| 38 |
* (one tap: a registration token from the hub, then the runner starts), |
| 39 |
* see what it is doing, stop it. |
| 40 |
*/ |
| 41 |
@Composable |
| 42 |
fun RunnerCard(api: HubApi, hub: String, canWrite: Boolean) { |
| 43 |
val context = LocalContext.current |
| 44 |
val state by PhoneRunner.state.collectAsStateWithLifecycle() |
| 45 |
var busy by remember { mutableStateOf(false) } |
| 46 |
var error by remember { mutableStateOf<String?>(null) } |
| 47 |
val scope = rememberCoroutineScope() |
| 48 |
LaunchedEffect(Unit) { PhoneRunner.refresh(context) } |
| 49 |
// The notification that says the runner works; the runner runs without it too. |
| 50 |
val ask = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { PhoneRunner.start(context) } |
| 51 |
val start = { |
| 52 |
val granted = Build.VERSION.SDK_INT < 33 || |
| 53 |
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED |
| 54 |
if (granted) PhoneRunner.start(context) else ask.launch(Manifest.permission.POST_NOTIFICATIONS) |
| 55 |
} |
| 56 |
val status = LocalStatusColors.current |
| 57 |
Panel(Modifier.fillMaxWidth()) { |
| 58 |
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { |
| 59 |
Text("OxygenFlow runner", style = MaterialTheme.typography.titleMedium) |
| 60 |
val (line, colour) = when (val s = state) { |
| 61 |
PhoneRunner.State.Unregistered -> "Run your repositories' WASM jobs on this phone, while it is charging on Wi-Fi." to MaterialTheme.colorScheme.onSurfaceVariant |
| 62 |
PhoneRunner.State.Stopped -> "Registered as ${PhoneRunner.name()}; not running." to MaterialTheme.colorScheme.onSurfaceVariant |
| 63 |
is PhoneRunner.State.Waiting -> "Not running: ${s.why}." to MaterialTheme.colorScheme.onSurfaceVariant |
| 64 |
PhoneRunner.State.Running -> "Running as ${PhoneRunner.name()}: taking WASM jobs." to status.ok |
| 65 |
is PhoneRunner.State.Draining -> "Draining (${s.why}): finishing its job, then stopping." to status.warn |
| 66 |
is PhoneRunner.State.Leaving -> if (s.noticeSecs > 0) { |
| 67 |
"Leaving (${s.why}): finishing a job due within ${s.noticeSecs} s, handing back any other, then stopping." to status.warn |
| 68 |
} else { |
| 69 |
"Leaving (${s.why}): handing its job back for another runner, then stopping." to status.warn |
| 70 |
} |
| 71 |
is PhoneRunner.State.Failed -> "Stopped: ${s.message}" to status.bad |
| 72 |
} |
| 73 |
Text(line, style = MaterialTheme.typography.bodyMedium, color = colour) |
| 74 |
error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = status.bad) } |
| 75 |
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { |
| 76 |
when (state) { |
| 77 |
PhoneRunner.State.Unregistered -> Button( |
| 78 |
enabled = canWrite && !busy, |
| 79 |
onClick = { |
| 80 |
busy = true |
| 81 |
error = null |
| 82 |
scope.launch { |
| 83 |
try { |
| 84 |
PhoneRunner.register(context, api, hub) |
| 85 |
start() |
| 86 |
} catch (e: CancellationException) { |
| 87 |
throw e |
| 88 |
} catch (e: Exception) { |
| 89 |
error = e.userMessage() |
| 90 |
} finally { |
| 91 |
busy = false |
| 92 |
} |
| 93 |
} |
| 94 |
}, |
| 95 |
) { Text("Offer this phone as a runner") } |
| 96 |
PhoneRunner.State.Running, is PhoneRunner.State.Waiting -> OutlinedButton(onClick = { PhoneRunner.stopByOwner(context) }) { Text("Stop") } |
| 97 |
is PhoneRunner.State.Draining, is PhoneRunner.State.Leaving -> {} |
| 98 |
else -> Button(onClick = { start() }) { Text("Start") } |
| 99 |
} |
| 100 |
} |
| 101 |
if (!canWrite && state == PhoneRunner.State.Unregistered) { |
| 102 |
Text("Sign in again to allow it: offering a runner needs repo:write.", style = MaterialTheme.typography.bodySmall) |
| 103 |
} |
| 104 |
} |
| 105 |
} |
| 106 |
} |