OXYGENHUB

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