Notifications when a proposal can be landed; the runner comes back after an update or restart (closes #20, refs #8) ff65ea456ff1
Ready to land, on the Account screen: every 15 minutes, on any network and across reboots (JobScheduler, no new dependency), the app asks the hub what the account can land now (oxygenhub#311) and posts a notification for each proposal that has become landable since the last check; tapping it opens the proposal, where it can be landed. One that stops being landable and becomes so again is said again. The runner remembers that its owner offered it: after a restart or an app update it is offered again, until the owner taps Stop. Leaving the charger or Wi-Fi only drains it, as before.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key b10dc3fed50b
- When
- · 2026-10-25 16:43 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
ff65ea456ff1a8749dcc5c122f429f399f43d4fc4b56c366532a71d28dd23d34- Depends on
- 0a2858c0dad3 144cbf7bf2b5 4aee5293461d 57c0824300db 5ae4af48ddd2 d1f635be9442 db00632a61d6 e0e4a1c48a77 e2df27a81e5d
- Depended on by
- cccf06468345 263c6cad5e51 aa627197ec18 8440d51a2275 b610bc6eeab7 840c75813f86 6e2af63ae616 1dfa631436b6 aa21eb5e0e5b 98dbd1220929 41e2e862abce
- In bookmarks
- main
- In tags
- v0.1.0 v0.1.1 v0.1.2 v0.1.3 v0.1.4 v0.1.5 v0.1.6 v0.1.7 v0.1.8 v0.1.9
- Edits
- 30 atoms, 7 tree operations, 14 files
Browse the tree at this change
added app/src/main/java/io/oxygenhub/android/alerts/LandAlerts.kt +118 −0
| 1 | + | package io.oxygenhub.android.alerts |
|
| 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.job.JobInfo |
|
| 8 | + | import android.app.job.JobScheduler |
|
| 9 | + | import android.content.ComponentName |
|
| 10 | + | import android.content.Context |
|
| 11 | + | import android.content.Intent |
|
| 12 | + | import androidx.core.content.edit |
|
| 13 | + | import io.oxygenhub.android.MainActivity |
|
| 14 | + | import io.oxygenhub.android.R |
|
| 15 | + | import io.oxygenhub.android.api.ProposalBrief |
|
| 16 | + | import kotlinx.coroutines.flow.MutableStateFlow |
|
| 17 | + | import kotlinx.coroutines.flow.StateFlow |
|
| 18 | + | import kotlinx.coroutines.flow.asStateFlow |
|
| 19 | + | ||
| 20 | + | /** |
|
| 21 | + | * Notifications when a proposal can be landed (#20): every 15 minutes the |
|
| 22 | + | * phone asks the hub what the account can land now (oxygenhub#311) and |
|
| 23 | + | * says which proposals have become landable since it last asked. Polling |
|
| 24 | + | * until FCM (#16) can push the same. |
|
| 25 | + | */ |
|
| 26 | + | object LandAlerts { |
|
| 27 | + | private const val PREFS = "alerts" |
|
| 28 | + | private const val ON = "land.on" |
|
| 29 | + | private const val SEEN = "land.seen" |
|
| 30 | + | private const val JOB = 20 |
|
| 31 | + | private const val CHANNEL = "landable" |
|
| 32 | + | private const val EVERY_MS = 15 * 60 * 1000L |
|
| 33 | + | ||
| 34 | + | /** Extras on the intent a notification opens: the proposal to show. */ |
|
| 35 | + | const val EXTRA_REPOSITORY = "io.oxygenhub.android.proposal.repository" |
|
| 36 | + | const val EXTRA_NUMBER = "io.oxygenhub.android.proposal.number" |
|
| 37 | + | ||
| 38 | + | private val _on = MutableStateFlow<Boolean?>(null) |
|
| 39 | + | ||
| 40 | + | /** Whether the alerts are on, once [refresh] has read it. */ |
|
| 41 | + | val on: StateFlow<Boolean?> = _on.asStateFlow() |
|
| 42 | + | ||
| 43 | + | fun refresh(context: Context) { |
|
| 44 | + | _on.value = prefs(context).getBoolean(ON, false) |
|
| 45 | + | } |
|
| 46 | + | ||
| 47 | + | /** |
|
| 48 | + | * Turn the alerts on: the check runs every 15 minutes on any network, |
|
| 49 | + | * across reboots. What is landable already is said at the first check. |
|
| 50 | + | */ |
|
| 51 | + | fun enable(context: Context) { |
|
| 52 | + | prefs(context).edit { |
|
| 53 | + | putBoolean(ON, true) |
|
| 54 | + | remove(SEEN) |
|
| 55 | + | } |
|
| 56 | + | val job = JobInfo.Builder(JOB, ComponentName(context, LandCheckJob::class.java)) |
|
| 57 | + | .setPeriodic(EVERY_MS) |
|
| 58 | + | .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) |
|
| 59 | + | .setPersisted(true) |
|
| 60 | + | .build() |
|
| 61 | + | context.getSystemService(JobScheduler::class.java)?.schedule(job) |
|
| 62 | + | _on.value = true |
|
| 63 | + | } |
|
| 64 | + | ||
| 65 | + | fun disable(context: Context) { |
|
| 66 | + | context.getSystemService(JobScheduler::class.java)?.cancel(JOB) |
|
| 67 | + | prefs(context).edit { |
|
| 68 | + | putBoolean(ON, false) |
|
| 69 | + | remove(SEEN) |
|
| 70 | + | } |
|
| 71 | + | _on.value = false |
|
| 72 | + | } |
|
| 73 | + | ||
| 74 | + | /** The proposals to say now, and what to remember for the next check. */ |
|
| 75 | + | fun fresh(seen: Set<String>, landable: List<ProposalBrief>): Pair<List<ProposalBrief>, Set<String>> { |
|
| 76 | + | val now = landable.associateBy(::key) |
|
| 77 | + | // Only what is landable now is remembered, so one that stops being |
|
| 78 | + | // landable and becomes so again is said again. |
|
| 79 | + | return now.filterKeys { it !in seen }.values.toList() to now.keys |
|
| 80 | + | } |
|
| 81 | + | ||
| 82 | + | /** Say what has become landable since the last check, and remember what is landable now. */ |
|
| 83 | + | fun check(context: Context, landable: List<ProposalBrief>) { |
|
| 84 | + | val prefs = prefs(context) |
|
| 85 | + | if (!prefs.getBoolean(ON, false)) return |
|
| 86 | + | val (say, keep) = fresh(prefs.getStringSet(SEEN, emptySet()).orEmpty(), landable) |
|
| 87 | + | say.forEach { notify(context, it) } |
|
| 88 | + | prefs.edit { putStringSet(SEEN, keep) } |
|
| 89 | + | } |
|
| 90 | + | ||
| 91 | + | private fun key(p: ProposalBrief) = "${p.repository}#${p.number}" |
|
| 92 | + | ||
| 93 | + | private fun notify(context: Context, p: ProposalBrief) { |
|
| 94 | + | val manager = context.getSystemService(NotificationManager::class.java) ?: return |
|
| 95 | + | manager.createNotificationChannel( |
|
| 96 | + | NotificationChannel(CHANNEL, "Ready to land", NotificationManager.IMPORTANCE_DEFAULT).apply { |
|
| 97 | + | description = "A proposal you can land: its checks and reviews are settled" |
|
| 98 | + | }, |
|
| 99 | + | ) |
|
| 100 | + | val open = Intent(context, MainActivity::class.java) |
|
| 101 | + | .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) |
|
| 102 | + | .putExtra(EXTRA_REPOSITORY, p.repository) |
|
| 103 | + | .putExtra(EXTRA_NUMBER, p.number) |
|
| 104 | + | val id = key(p).hashCode() |
|
| 105 | + | val tap = PendingIntent.getActivity(context, id, open, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) |
|
| 106 | + | val notification = Notification.Builder(context, CHANNEL) |
|
| 107 | + | .setSmallIcon(R.drawable.ic_launcher_monochrome) |
|
| 108 | + | .setContentTitle("Ready to land: ${p.repository}#${p.number}") |
|
| 109 | + | .setContentText(p.title) |
|
| 110 | + | .setStyle(Notification.BigTextStyle().bigText("${p.title}\nby ${p.author}")) |
|
| 111 | + | .setContentIntent(tap) |
|
| 112 | + | .setAutoCancel(true) |
|
| 113 | + | .build() |
|
| 114 | + | manager.notify(id, notification) |
|
| 115 | + | } |
|
| 116 | + | ||
| 117 | + | private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) |
|
| 118 | + | } |
added app/src/main/java/io/oxygenhub/android/alerts/LandAlertsCard.kt +59 −0
| 1 | + | package io.oxygenhub.android.alerts |
|
| 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.ElevatedCard |
|
| 14 | + | import androidx.compose.material3.MaterialTheme |
|
| 15 | + | import androidx.compose.material3.Switch |
|
| 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.ui.Alignment |
|
| 21 | + | import androidx.compose.ui.Modifier |
|
| 22 | + | import androidx.compose.ui.platform.LocalContext |
|
| 23 | + | import androidx.compose.ui.unit.dp |
|
| 24 | + | import androidx.core.content.ContextCompat |
|
| 25 | + | import androidx.lifecycle.compose.collectAsStateWithLifecycle |
|
| 26 | + | ||
| 27 | + | /** On the Account screen: notifications when a proposal can be landed (#20), on or off. */ |
|
| 28 | + | @Composable |
|
| 29 | + | fun LandAlertsCard() { |
|
| 30 | + | val context = LocalContext.current |
|
| 31 | + | val on by LandAlerts.on.collectAsStateWithLifecycle() |
|
| 32 | + | LaunchedEffect(Unit) { LandAlerts.refresh(context) } |
|
| 33 | + | // Without the permission there is nothing to show, so it stays off. |
|
| 34 | + | val ask = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> |
|
| 35 | + | if (granted) LandAlerts.enable(context) |
|
| 36 | + | } |
|
| 37 | + | val turnOn = { |
|
| 38 | + | val granted = Build.VERSION.SDK_INT < 33 || |
|
| 39 | + | ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED |
|
| 40 | + | if (granted) LandAlerts.enable(context) else ask.launch(Manifest.permission.POST_NOTIFICATIONS) |
|
| 41 | + | } |
|
| 42 | + | ElevatedCard(Modifier.fillMaxWidth()) { |
|
| 43 | + | Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) { |
|
| 44 | + | Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { |
|
| 45 | + | Text("Ready to land", style = MaterialTheme.typography.titleMedium) |
|
| 46 | + | Text( |
|
| 47 | + | "Tell me when a proposal I can land has its checks and reviews settled. Checked every 15 minutes.", |
|
| 48 | + | style = MaterialTheme.typography.bodyMedium, |
|
| 49 | + | color = MaterialTheme.colorScheme.onSurfaceVariant, |
|
| 50 | + | ) |
|
| 51 | + | } |
|
| 52 | + | Switch( |
|
| 53 | + | checked = on == true, |
|
| 54 | + | enabled = on != null, |
|
| 55 | + | onCheckedChange = { if (it) turnOn() else LandAlerts.disable(context) }, |
|
| 56 | + | ) |
|
| 57 | + | } |
|
| 58 | + | } |
|
| 59 | + | } |
added app/src/main/java/io/oxygenhub/android/alerts/LandCheckJob.kt +37 −0
| 1 | + | package io.oxygenhub.android.alerts |
|
| 2 | + | ||
| 3 | + | import android.app.job.JobParameters |
|
| 4 | + | import android.app.job.JobService |
|
| 5 | + | import io.oxygenhub.android.OxygenHubApp |
|
| 6 | + | import io.oxygenhub.android.api.HubException |
|
| 7 | + | import kotlinx.coroutines.CoroutineScope |
|
| 8 | + | import kotlinx.coroutines.Dispatchers |
|
| 9 | + | import kotlinx.coroutines.SupervisorJob |
|
| 10 | + | import kotlinx.coroutines.cancel |
|
| 11 | + | import kotlinx.coroutines.launch |
|
| 12 | + | ||
| 13 | + | /** The periodic check behind [LandAlerts]: what the account can land now. */ |
|
| 14 | + | class LandCheckJob : JobService() { |
|
| 15 | + | private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) |
|
| 16 | + | ||
| 17 | + | override fun onStartJob(params: JobParameters): Boolean { |
|
| 18 | + | // Signed out: nothing to ask, and the next sign-in finds the alerts as they were. |
|
| 19 | + | val api = (application as OxygenHubApp).container.sessions.api() ?: return false |
|
| 20 | + | scope.launch { |
|
| 21 | + | try { |
|
| 22 | + | LandAlerts.check(this@LandCheckJob, api.userProposals(landable = true).landable) |
|
| 23 | + | } catch (_: HubException) { |
|
| 24 | + | // Unreachable or refused: the next period asks again. |
|
| 25 | + | } |
|
| 26 | + | jobFinished(params, false) |
|
| 27 | + | } |
|
| 28 | + | return true |
|
| 29 | + | } |
|
| 30 | + | ||
| 31 | + | override fun onStopJob(params: JobParameters): Boolean = false |
|
| 32 | + | ||
| 33 | + | override fun onDestroy() { |
|
| 34 | + | scope.cancel() |
|
| 35 | + | super.onDestroy() |
|
| 36 | + | } |
|
| 37 | + | } |
added app/src/main/java/io/oxygenhub/android/runner/RunnerResume.kt +18 −0
| 1 | + | package io.oxygenhub.android.runner |
|
| 2 | + | ||
| 3 | + | import android.content.BroadcastReceiver |
|
| 4 | + | import android.content.Context |
|
| 5 | + | import android.content.Intent |
|
| 6 | + | ||
| 7 | + | /** |
|
| 8 | + | * Offers the phone as a runner again after it restarts or the app is |
|
| 9 | + | * updated, if its owner had it offered: an update stops the app, and with |
|
| 10 | + | * it the runner. |
|
| 11 | + | */ |
|
| 12 | + | class RunnerResume : BroadcastReceiver() { |
|
| 13 | + | override fun onReceive(context: Context, intent: Intent) { |
|
| 14 | + | if (intent.action == Intent.ACTION_BOOT_COMPLETED || intent.action == Intent.ACTION_MY_PACKAGE_REPLACED) { |
|
| 15 | + | PhoneRunner.resume(context) |
|
| 16 | + | } |
|
| 17 | + | } |
|
| 18 | + | } |
added app/src/test/java/io/oxygenhub/android/alerts/LandAlertsTest.kt +36 −0
| 1 | + | package io.oxygenhub.android.alerts |
|
| 2 | + | ||
| 3 | + | import io.oxygenhub.android.api.ProposalBrief |
|
| 4 | + | import org.junit.Assert.assertEquals |
|
| 5 | + | import org.junit.Test |
|
| 6 | + | ||
| 7 | + | class LandAlertsTest { |
|
| 8 | + | private fun p(repository: String, number: Long) = ProposalBrief(repository, number, title = "P$number") |
|
| 9 | + | ||
| 10 | + | @Test |
|
| 11 | + | fun a_proposal_is_said_once_while_it_stays_landable() { |
|
| 12 | + | val (first, seen) = LandAlerts.fresh(emptySet(), listOf(p("andy/demo", 2), p("andy/web", 7))) |
|
| 13 | + | assertEquals(listOf(2L, 7L), first.map { it.number }) |
|
| 14 | + | val (second, still) = LandAlerts.fresh(seen, listOf(p("andy/demo", 2), p("andy/web", 7))) |
|
| 15 | + | assertEquals(emptyList<ProposalBrief>(), second) |
|
| 16 | + | assertEquals(seen, still) |
|
| 17 | + | } |
|
| 18 | + | ||
| 19 | + | @Test |
|
| 20 | + | fun a_proposal_that_becomes_landable_again_is_said_again() { |
|
| 21 | + | val (_, seen) = LandAlerts.fresh(emptySet(), listOf(p("andy/demo", 2))) |
|
| 22 | + | // A new commit made it wait for its checks again: no longer landable. |
|
| 23 | + | val (none, waiting) = LandAlerts.fresh(seen, emptyList()) |
|
| 24 | + | assertEquals(emptyList<ProposalBrief>(), none) |
|
| 25 | + | assertEquals(emptySet<String>(), waiting) |
|
| 26 | + | val (again, _) = LandAlerts.fresh(waiting, listOf(p("andy/demo", 2))) |
|
| 27 | + | assertEquals(listOf("andy/demo#2"), again.map { "${it.repository}#${it.number}" }) |
|
| 28 | + | } |
|
| 29 | + | ||
| 30 | + | @Test |
|
| 31 | + | fun the_same_number_in_two_repositories_is_two_proposals() { |
|
| 32 | + | val (_, seen) = LandAlerts.fresh(emptySet(), listOf(p("andy/demo", 2))) |
|
| 33 | + | val (said, _) = LandAlerts.fresh(seen, listOf(p("andy/demo", 2), p("andy/web", 2))) |
|
| 34 | + | assertEquals(listOf("andy/web"), said.map { it.repository }) |
|
| 35 | + | } |
|
| 36 | + | } |
modified app/src/main/AndroidManifest.xml +14 −0
| … | 8 unchanged lines |
||
| 9 | 9 | <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> |
|
| 10 | 10 | <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" /> |
|
| 11 | 11 | <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> |
|
| 12 | + | <!-- The check for proposals ready to land (#20) is kept across reboots, and the runner (#8) offered again. --> |
|
| 13 | + | <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> |
|
| 12 | 14 | ||
| 13 | 15 | <application |
|
| 14 | 16 | android:name=".OxygenHubApp" |
|
| … | 24 unchanged lines |
||
| 39 | 41 | android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" |
|
| 40 | 42 | android:value="Runs OxygenFlow CI jobs (WebAssembly) for the signed-in account while the phone is charging on Wi-Fi" /> |
|
| 41 | 43 | </service> |
|
| 44 | + | <receiver |
|
| 45 | + | android:name=".runner.RunnerResume" |
|
| 46 | + | android:exported="false"> |
|
| 47 | + | <intent-filter> |
|
| 48 | + | <action android:name="android.intent.action.BOOT_COMPLETED" /> |
|
| 49 | + | <action android:name="android.intent.action.MY_PACKAGE_REPLACED" /> |
|
| 50 | + | </intent-filter> |
|
| 51 | + | </receiver> |
|
| 52 | + | <service |
|
| 53 | + | android:name=".alerts.LandCheckJob" |
|
| 54 | + | android:exported="false" |
|
| 55 | + | android:permission="android.permission.BIND_JOB_SERVICE" /> |
|
| 42 | 56 | </application> |
|
| 43 | 57 | ||
| 44 | 58 | </manifest> |
modified app/src/main/java/io/oxygenhub/android/MainActivity.kt +22 −0
| 1 | 1 | package io.oxygenhub.android |
|
| 2 | 2 | ||
| 3 | + | import android.content.Intent |
|
| 3 | 4 | import android.os.Bundle |
|
| 4 | 5 | import androidx.activity.ComponentActivity |
|
| 5 | 6 | import androidx.activity.compose.setContent |
|
| 6 | 7 | import androidx.activity.enableEdgeToEdge |
|
| 7 | 8 | import androidx.compose.runtime.getValue |
|
| 8 | 9 | import androidx.compose.runtime.key |
|
| 10 | + | import androidx.compose.runtime.mutableStateOf |
|
| 11 | + | import io.oxygenhub.android.alerts.LandAlerts |
|
| 12 | + | import io.oxygenhub.android.ui.nav.ProposalRoute |
|
| 9 | 13 | import androidx.lifecycle.compose.collectAsStateWithLifecycle |
|
| 10 | 14 | import io.oxygenhub.android.ui.nav.SignedInApp |
|
| 11 | 15 | import io.oxygenhub.android.ui.nav.rememberViewModel |
|
| … | 3 unchanged lines |
||
| 15 | 19 | import uniffi.rust_core.oxygenGreeting |
|
| 16 | 20 | ||
| 17 | 21 | class MainActivity : ComponentActivity() { |
|
| 22 | + | /** A proposal a notification asked to open (#20), until it is shown. */ |
|
| 23 | + | private val opening = mutableStateOf<ProposalRoute?>(null) |
|
| 24 | + | ||
| 18 | 25 | override fun onCreate(savedInstanceState: Bundle?) { |
|
| 19 | 26 | super.onCreate(savedInstanceState) |
|
| 27 | + | if (savedInstanceState == null) opening.value = proposalIn(intent) |
|
| 20 | 28 | enableEdgeToEdge() |
|
| 21 | 29 | val container = (application as OxygenHubApp).container |
|
| 22 | 30 | val sessions = container.sessions |
|
| … | 14 unchanged lines |
||
| 37 | 45 | api = api, |
|
| 38 | 46 | nativeCore = { oxygenGreeting(signedIn.username) }, |
|
| 39 | 47 | onSignOut = { sessions.signOut() }, |
|
| 48 | + | open = opening.value, |
|
| 49 | + | onOpened = { opening.value = null }, |
|
| 40 | 50 | ) |
|
| 41 | 51 | } |
|
| 42 | 52 | } |
|
| 43 | 53 | } |
|
| 44 | 54 | } |
|
| 55 | + | } |
|
| 56 | + | ||
| 57 | + | override fun onNewIntent(intent: Intent) { |
|
| 58 | + | super.onNewIntent(intent) |
|
| 59 | + | proposalIn(intent)?.let { opening.value = it } |
|
| 60 | + | } |
|
| 61 | + | ||
| 62 | + | private fun proposalIn(intent: Intent?): ProposalRoute? { |
|
| 63 | + | val repository = intent?.getStringExtra(LandAlerts.EXTRA_REPOSITORY) ?: return null |
|
| 64 | + | val number = intent.getLongExtra(LandAlerts.EXTRA_NUMBER, 0) |
|
| 65 | + | if ('/' !in repository || number <= 0) return null |
|
| 66 | + | return ProposalRoute(repository.substringBefore('/'), repository.substringAfter('/'), number) |
|
| 45 | 67 | } |
|
| 46 | 68 | } |
modified app/src/main/java/io/oxygenhub/android/api/HubClient.kt +7 −3
| … | 35 unchanged lines |
||
| 36 | 36 | /** Revokes the token this client signs in with (`DELETE /user/token`). */ |
|
| 37 | 37 | suspend fun revokeToken() |
|
| 38 | 38 | ||
| 39 | - | /** The proposals the caller should act on, across repositories (the hub's dashboard). */ |
|
| 40 | - | suspend fun userProposals(): UserProposals |
|
| 39 | + | /** |
|
| 40 | + | * The proposals the caller should act on, across repositories (the hub's |
|
| 41 | + | * dashboard); with [landable], also those it can land now (#20, oxygenhub#311). |
|
| 42 | + | */ |
|
| 43 | + | suspend fun userProposals(landable: Boolean = false): UserProposals |
|
| 41 | 44 | ||
| 42 | 45 | /** A repository's proposals: open (with drafts), landed, closed or all. */ |
|
| 43 | 46 | suspend fun proposals(owner: String, name: String, state: ProposalState = ProposalState.Open): List<Proposal> |
|
| … | 45 unchanged lines |
||
| 89 | 92 | ||
| 90 | 93 | override suspend fun runnerToken(): RunnerToken = send("POST", url("user", "runner-token"), RunnerToken.serializer()) |
|
| 91 | 94 | ||
| 92 | - | override suspend fun userProposals(): UserProposals = get("user/proposals", UserProposals.serializer()) |
|
| 95 | + | override suspend fun userProposals(landable: Boolean): UserProposals = |
|
| 96 | + | get(url("user", "proposals") { if (landable) addQueryParameter("landable", "true") }, UserProposals.serializer()) |
|
| 93 | 97 | ||
| 94 | 98 | override suspend fun proposals(owner: String, name: String, state: ProposalState): List<Proposal> = |
|
| 95 | 99 | get(url("repos", owner, name, "proposals") { addQueryParameter("state", state.query) }, ListSerializer(Proposal.serializer())) |
|
| … | 107 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/api/Models.kt +2 −0
| … | 120 unchanged lines |
||
| 121 | 121 | val review: List<ProposalBrief> = emptyList(), |
|
| 122 | 122 | /** The caller's own open ones and drafts. */ |
|
| 123 | 123 | val authored: List<ProposalBrief> = emptyList(), |
|
| 124 | + | /** Asked for with `landable=true`: open ones the caller can land now. */ |
|
| 125 | + | val landable: List<ProposalBrief> = emptyList(), |
|
| 124 | 126 | ) |
|
| 125 | 127 | ||
| 126 | 128 | /** A proposal in a list across repositories. */ |
|
| … | 125 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/runner/PhoneRunner.kt +18 −0
| … | 6 unchanged lines |
||
| 7 | 7 | import android.os.BatteryManager |
|
| 8 | 8 | import android.os.Build |
|
| 9 | 9 | import androidx.core.content.ContextCompat |
|
| 10 | + | import androidx.core.content.edit |
|
| 10 | 11 | import io.oxygenhub.android.api.HubApi |
|
| 11 | 12 | import kotlinx.coroutines.Dispatchers |
|
| 12 | 13 | import kotlinx.coroutines.flow.MutableStateFlow |
|
| … | 75 unchanged lines |
||
| 88 | 89 | return null |
|
| 89 | 90 | } |
|
| 90 | 91 | ||
| 92 | + | /** Start the runner, and keep it offered across app updates and restarts until the owner stops it. */ |
|
| 91 | 93 | fun start(context: Context) { |
|
| 94 | + | prefs(context).edit { putBoolean(WANTED, true) } |
|
| 92 | 95 | ContextCompat.startForegroundService(context, Intent(context, RunnerService::class.java)) |
|
| 93 | 96 | } |
|
| 94 | 97 | ||
| 98 | + | /** The owner stopped it: drain, and stay stopped after an update or restart. */ |
|
| 99 | + | fun stopByOwner(context: Context) { |
|
| 100 | + | prefs(context).edit { putBoolean(WANTED, false) } |
|
| 101 | + | drain("stopped by you") |
|
| 102 | + | } |
|
| 103 | + | ||
| 104 | + | /** After a restart or an app update ([RunnerResume]): offer the phone again if it was offered. */ |
|
| 105 | + | fun resume(context: Context) { |
|
| 106 | + | if (prefs(context).getBoolean(WANTED, false) && runnerIsRegistered(home(context).path)) start(context) |
|
| 107 | + | } |
|
| 108 | + | ||
| 109 | + | private const val WANTED = "runner.wanted" |
|
| 110 | + | ||
| 111 | + | private fun prefs(context: Context) = context.getSharedPreferences("runner", Context.MODE_PRIVATE) |
|
| 112 | + | ||
| 95 | 113 | /** Drain: claim nothing more, finish and report what it holds, then stop. */ |
|
| 96 | 114 | fun drain(why: String) { |
|
| 97 | 115 | if (_state.value == State.Running) { |
|
| … | 5 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/runner/RunnerCard.kt +1 −1
| … | 87 unchanged lines |
||
| 88 | 88 | } |
|
| 89 | 89 | }, |
|
| 90 | 90 | ) { Text("Offer this phone as a runner") } |
|
| 91 | - | PhoneRunner.State.Running -> OutlinedButton(onClick = { PhoneRunner.drain("stopped by you") }) { Text("Stop") } |
|
| 91 | + | PhoneRunner.State.Running -> OutlinedButton(onClick = { PhoneRunner.stopByOwner(context) }) { Text("Stop") } |
|
| 92 | 92 | is PhoneRunner.State.Draining -> {} |
|
| 93 | 93 | else -> Button(onClick = { start() }) { Text("Start") } |
|
| 94 | 94 | } |
|
| … | 7 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/ui/account/AccountScreen.kt +2 −0
| … | 37 unchanged lines |
||
| 38 | 38 | import io.oxygenhub.android.auth.Session |
|
| 39 | 39 | import io.oxygenhub.android.api.Avatar |
|
| 40 | 40 | import io.oxygenhub.android.api.HubApi |
|
| 41 | + | import io.oxygenhub.android.alerts.LandAlertsCard |
|
| 41 | 42 | import io.oxygenhub.android.runner.RunnerCard |
|
| 42 | 43 | import io.oxygenhub.android.ui.common.AppScreen |
|
| 43 | 44 | import io.oxygenhub.android.ui.common.AvatarImage |
|
| … | 18 unchanged lines |
||
| 62 | 63 | Spacer(Modifier.height(8.dp)) |
|
| 63 | 64 | Text(session.username, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) |
|
| 64 | 65 | } |
|
| 66 | + | LandAlertsCard() |
|
| 65 | 67 | RunnerCard(api, session.hub, session.canWrite) |
|
| 66 | 68 | Card(Modifier.fillMaxWidth()) { |
|
| 67 | 69 | ListItem( |
|
| … | 50 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/ui/nav/AppNavigation.kt +10 −0
| … | 14 unchanged lines |
||
| 15 | 15 | import androidx.compose.material3.Text |
|
| 16 | 16 | import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold |
|
| 17 | 17 | import androidx.compose.runtime.Composable |
|
| 18 | + | import androidx.compose.runtime.LaunchedEffect |
|
| 18 | 19 | import androidx.compose.runtime.getValue |
|
| 19 | 20 | import androidx.compose.ui.graphics.vector.ImageVector |
|
| 20 | 21 | import androidx.lifecycle.ViewModel |
|
| … | 71 unchanged lines |
||
| 92 | 93 | start: TopLevel = TopLevel.Dashboard, |
|
| 93 | 94 | /** A repository to show beside the list from the start (`owner/name`). */ |
|
| 94 | 95 | selectedRepository: String? = null, |
|
| 96 | + | /** A proposal to open, as a notification asks (#20); [onOpened] once it is. */ |
|
| 97 | + | open: ProposalRoute? = null, |
|
| 98 | + | onOpened: () -> Unit = {}, |
|
| 95 | 99 | ) { |
|
| 96 | 100 | val nav = rememberNavController() |
|
| 97 | 101 | // One inbox for the badge, the dashboard's count and the inbox screen. |
|
| … | 72 unchanged lines |
||
| 170 | 174 | composable<InboxRoute> { InboxScreen(inbox) } |
|
| 171 | 175 | composable<AccountRoute> { AccountScreen(session, rememberAvatar(api, session.username), api, nativeCore, onSignOut) } |
|
| 172 | 176 | } |
|
| 177 | + | LaunchedEffect(open) { |
|
| 178 | + | open?.let { |
|
| 179 | + | nav.navigate(it) |
|
| 180 | + | onOpened() |
|
| 181 | + | } |
|
| 182 | + | } |
|
| 173 | 183 | } |
|
| 174 | 184 | } |
|
| 175 | 185 | ||
| … | 8 unchanged lines |
modified app/src/test/java/io/oxygenhub/android/FakeHubApi.kt +2 −1
| … | 58 unchanged lines |
||
| 59 | 59 | } |
|
| 60 | 60 | var runnerToken: () -> io.oxygenhub.android.api.RunnerToken = { io.oxygenhub.android.api.RunnerToken("flowreg_test", "andymccall") } |
|
| 61 | 61 | override suspend fun runnerToken() = runnerToken.invoke().also { calls += "runner token" } |
|
| 62 | - | override suspend fun userProposals() = userProposals.invoke().also { calls += "user proposals" } |
|
| 62 | + | override suspend fun userProposals(landable: Boolean) = |
|
| 63 | + | userProposals.invoke().also { calls += if (landable) "user proposals, landable" else "user proposals" } |
|
| 63 | 64 | override suspend fun proposals(owner: String, name: String, state: ProposalState): List<Proposal> = |
|
| 64 | 65 | proposals.invoke(state).also { calls += "proposals $owner/$name ${state.query}" } |
|
| 65 | 66 | override suspend fun proposal(owner: String, name: String, number: Long): Proposal = |
|
| … | 17 unchanged lines |