OXYGENHUB

The hub's API and device sign-in, the token in the Keystore (refs #2) 5ae4af48ddd2

A hand-written Kotlin client over the hub's JSON (kotlinx.serialization
and OkHttp): the signed-in user, repositories, a repository's issues, the
inbox and marking it read, and revoking the app's own token. Errors come
back as the hub's problem documents; a refused token ends the session.

Device sign-in as oh auth login does it: a code from /device/code, the
user approves it on the hub's /device page (password and second factor
there, never in the app), polling /device/token at the interval, slower on
slow_down, until approved, denied or expired.

The session is kept in private preferences with the token sealed by an
AES-GCM key in the Android Keystore (StrongBox where present), and kept
out of backups and device transfer. Unit tests against fixtures written
from hub-api's types, with MockWebServer.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key f4ec0f2e7863
When
· 2026-10-24 22:23 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
5ae4af48ddd2b09236aef2440eb460a0cd5e4cedc7d57e922c974667459d8d47
Depends on
57c0824300db 7045183e3d2c e0e4a1c48a77 e955ae0c1342
Depended on by
263c6cad5e51 aa627197ec18 b610bc6eeab7 821035db5a1c 6e2af63ae616 1dfa631436b6 0ca942a9a1e4 2a15621eca66 ca672a3ada1f af9f2e514c72 40ab4df121e0 aa21eb5e0e5b 62429abe5869 0414607973cb dfbc3e972872 1c42a70fcba3 e833eb0a9e7f 31e4f3755627 98dbd1220929 ff65ea456ff1 4aee5293461d d1f635be9442 e2df27a81e5d db00632a61d6 3f3508bd5f16 0a2858c0dad3
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
53 atoms, 33 tree operations, 33 files

Browse the tree at this change

added app/src/main/java/io/oxygenhub/android/OxygenHubApp.kt +38 −0
1 +
package io.oxygenhub.android
2 +
3 +
import android.app.Application
4 +
import android.os.Build
5 +
import io.oxygenhub.android.api.DeviceAuth
6 +
import io.oxygenhub.android.api.HubClient
7 +
import io.oxygenhub.android.api.HubDeviceAuth
8 +
import io.oxygenhub.android.api.hubHttpClient
9 +
import io.oxygenhub.android.auth.KeystoreSessionStore
10 +
import io.oxygenhub.android.auth.SessionManager
11 +
import okhttp3.OkHttpClient
12 +
13 +
/** The hub the app signs in to unless told otherwise. */
14 +
const val DEFAULT_HUB = "https://oxygenhub.io"
15 +
16 +
/** The app's shared objects, made once. A small hand-made container: no DI framework yet. */
17 +
class AppContainer(app: Application) {
18 +
    val http: OkHttpClient = hubHttpClient()
19 +
20 +
    val sessions = SessionManager(KeystoreSessionStore(app)) { session, onUnauthorized ->
21 +
        HubClient(session.hub, session.token, http, onUnauthorized)
22 +
    }
23 +
24 +
    fun deviceAuth(hub: String): DeviceAuth = HubDeviceAuth(hub, http)
25 +
26 +
    /** What the app calls itself on the hub's /device page and token list. */
27 +
    val clientName: String = "OxygenHub for Android on ${Build.MODEL}".take(80)
28 +
}
29 +
30 +
class OxygenHubApp : Application() {
31 +
    lateinit var container: AppContainer
32 +
        private set
33 +
34 +
    override fun onCreate() {
35 +
        super.onCreate()
36 +
        container = AppContainer(this)
37 +
    }
38 +
}
added app/src/main/java/io/oxygenhub/android/api/DeviceFlow.kt +106 −0
1 +
package io.oxygenhub.android.api
2 +
3 +
import kotlinx.coroutines.delay
4 +
import kotlinx.serialization.SerialName
5 +
import kotlinx.serialization.Serializable
6 +
import okhttp3.HttpUrl.Companion.toHttpUrl
7 +
import okhttp3.OkHttpClient
8 +
import okhttp3.Request
9 +
import okhttp3.RequestBody.Companion.toRequestBody
10 +
import java.io.IOException
11 +
12 +
/** The scopes the app asks for. The user may untick any of them on the web. */
13 +
val AppScopes: List<String> = listOf("repo:read", "user:read")
14 +
15 +
/** Why a device sign-in ended without a token. */
16 +
sealed class DeviceFlowException(message: String) : Exception(message) {
17 +
    class Denied : DeviceFlowException("The sign-in was denied on the web.")
18 +
    class Expired : DeviceFlowException("The code expired before it was approved. Start again for a new one.")
19 +
    class Refused(val status: Int, val detail: String) : DeviceFlowException(detail)
20 +
}
21 +
22 +
/**
23 +
 * Device sign-in (oxygenhub#67, RFC 8628's shape), as `oh auth login` does
24 +
 * it: ask the hub for a code, show it, the user approves it on the hub's
25 +
 * `/device` page (signed in there with their password and, if they use one,
26 +
 * their second factor), and the app's next poll gets a token. The app never
27 +
 * sees a password or a second-factor code.
28 +
 */
29 +
interface DeviceAuth {
30 +
    /** `POST /device/code`: a code to show, and one to poll with. */
31 +
    suspend fun requestCode(client: String, scopes: List<String> = AppScopes): DeviceCode
32 +
33 +
    /**
34 +
     * Polls `POST /device/token` every `interval` seconds (five more after a
35 +
     * `slow_down`) until the user answers or the code expires.
36 +
     */
37 +
    suspend fun awaitToken(code: DeviceCode): TokenGrant
38 +
39 +
    /** The account a new token belongs to, checking that the token works. */
40 +
    suspend fun whoAmI(token: String): User
41 +
}
42 +
43 +
class HubDeviceAuth(
44 +
    private val hub: String,
45 +
    private val http: OkHttpClient,
46 +
    /** How the poll waits; tests pass a recorder. */
47 +
    private val sleep: suspend (Long) -> Unit = { delay(it) },
48 +
) : DeviceAuth {
49 +
    private val base = "${hub.trimEnd('/')}/api/v1/".toHttpUrl()
50 +
51 +
    @Serializable
52 +
    private data class CodeRequest(val client: String, val scopes: List<String>)
53 +
54 +
    @Serializable
55 +
    private data class TokenRequest(@SerialName("device_code") val deviceCode: String)
56 +
57 +
    override suspend fun requestCode(client: String, scopes: List<String>): DeviceCode {
58 +
        val (status, body) = post("device/code", HubJson.encodeToString(CodeRequest.serializer(), CodeRequest(client.take(80), scopes)))
59 +
        if (status != 200) throw DeviceFlowException.Refused(status, "$hub cannot sign in a device: ${errorDetail(body)}")
60 +
        return decode(body, DeviceCode.serializer(), "device/code")
61 +
    }
62 +
63 +
    override suspend fun awaitToken(code: DeviceCode): TokenGrant {
64 +
        var interval = code.interval.coerceAtLeast(1)
65 +
        val life = code.expiresIn.coerceAtLeast(1) * 1000
66 +
        var waited = 0L
67 +
        val request = HubJson.encodeToString(TokenRequest.serializer(), TokenRequest(code.deviceCode))
68 +
        while (true) {
69 +
            sleep(interval * 1000)
70 +
            waited += interval * 1000
71 +
            if (waited > life) throw DeviceFlowException.Expired()
72 +
            val (status, body) = post("device/token", request)
73 +
            if (status == 200) return decode(body, TokenGrant.serializer(), "device/token")
74 +
            val error = runCatching { HubJson.decodeFromString(OAuthError.serializer(), body) }.getOrNull()
75 +
            when (error?.error) {
76 +
                "authorization_pending" -> {}
77 +
                "slow_down" -> interval += 5
78 +
                "access_denied" -> throw DeviceFlowException.Denied()
79 +
                "expired_token" -> throw DeviceFlowException.Expired()
80 +
                else -> throw DeviceFlowException.Refused(status, "Signing in failed: HTTP $status: ${errorDetail(body)}")
81 +
            }
82 +
        }
83 +
    }
84 +
85 +
    override suspend fun whoAmI(token: String): User = HubClient(hub, token, http).user()
86 +
87 +
    /** POSTs JSON, returning the status and body whatever the status: the poll's waits are errors by design. */
88 +
    private suspend fun post(path: String, json: String): Pair<Int, String> {
89 +
        val request = Request.Builder()
90 +
            .url(base.resolve(path)!!)
91 +
            .header("Accept", "application/json")
92 +
            .post(json.toRequestBody(JsonMedia))
93 +
            .build()
94 +
        return try {
95 +
            http.newCall(request).await().use { r -> r.code to r.body?.string().orEmpty() }
96 +
        } catch (e: IOException) {
97 +
            throw HubException.Network(e)
98 +
        }
99 +
    }
100 +
101 +
    private fun <T> decode(body: String, serializer: kotlinx.serialization.KSerializer<T>, what: String): T = try {
102 +
        HubJson.decodeFromString(serializer, body)
103 +
    } catch (e: IllegalArgumentException) {
104 +
        throw HubException.Unreadable(what, e)
105 +
    }
106 +
}
added app/src/main/java/io/oxygenhub/android/api/Http.kt +89 −0
1 +
package io.oxygenhub.android.api
2 +
3 +
import kotlinx.coroutines.suspendCancellableCoroutine
4 +
import kotlinx.serialization.json.Json
5 +
import okhttp3.Call
6 +
import okhttp3.Callback
7 +
import okhttp3.MediaType.Companion.toMediaType
8 +
import okhttp3.OkHttpClient
9 +
import okhttp3.Response
10 +
import java.io.IOException
11 +
import java.util.concurrent.TimeUnit
12 +
import kotlin.coroutines.resume
13 +
import kotlin.coroutines.resumeWithException
14 +
15 +
/** The JSON settings every hub answer is read with. */
16 +
val HubJson: Json = Json {
17 +
    ignoreUnknownKeys = true
18 +
    explicitNulls = false
19 +
    coerceInputValues = true
20 +
}
21 +
22 +
internal val JsonMedia = "application/json".toMediaType()
23 +
24 +
/** One HTTP client for the whole app (connection pool, TLS sessions). */
25 +
fun hubHttpClient(): OkHttpClient = OkHttpClient.Builder()
26 +
    .connectTimeout(15, TimeUnit.SECONDS)
27 +
    .readTimeout(60, TimeUnit.SECONDS)
28 +
    .build()
29 +
30 +
/** A hub address as typed, made canonical: `https://host[:port][/path]`, no trailing slash. */
31 +
fun normalizeHubUrl(input: String): String? {
32 +
    var s = input.trim()
33 +
    if (s.isEmpty()) return null
34 +
    if (!s.contains("://")) s = "https://$s"
35 +
    s = s.trimEnd('/')
36 +
    val url = runCatching { java.net.URI(s) }.getOrNull() ?: return null
37 +
    if (url.scheme?.lowercase() != "https" || url.host.isNullOrBlank()) return null
38 +
    if (url.query != null || url.fragment != null) return null
39 +
    return s
40 +
}
41 +
42 +
/** Runs the call without blocking, cancelling it when the coroutine is. */
43 +
internal suspend fun Call.await(): Response = suspendCancellableCoroutine { cont ->
44 +
    cont.invokeOnCancellation { runCatching { cancel() } }
45 +
    enqueue(object : Callback {
46 +
        override fun onResponse(call: Call, response: Response) {
47 +
            cont.resume(response)
48 +
        }
49 +
50 +
        override fun onFailure(call: Call, e: IOException) {
51 +
            if (!cont.isCancelled) cont.resumeWithException(e)
52 +
        }
53 +
    })
54 +
}
55 +
56 +
/** Why a call to the hub failed. */
57 +
sealed class HubException(message: String, cause: Throwable? = null) : Exception(message, cause) {
58 +
    /** The hub answered with an error; [detail] is its problem document's reason. */
59 +
    class Http(val status: Int, val detail: String) : HubException("HTTP $status: $detail")
60 +
61 +
    /** The hub could not be reached, or the connection broke. */
62 +
    class Network(cause: IOException) : HubException(cause.message ?: "network error", cause)
63 +
64 +
    /** The hub answered with something this app cannot read. */
65 +
    class Unreadable(what: String, cause: Throwable) : HubException("unexpected answer to $what", cause)
66 +
}
67 +
68 +
/** The reason in an error body: a problem's `detail`, an OAuth `error_description`, or the text itself. */
69 +
internal fun errorDetail(body: String): String {
70 +
    val problem = runCatching { HubJson.decodeFromString<Problem>(body) }.getOrNull()
71 +
    if (problem != null && problem.detail.isNotBlank()) return problem.detail
72 +
    val oauth = runCatching { HubJson.decodeFromString<OAuthError>(body) }.getOrNull()
73 +
    if (oauth != null && oauth.description.isNotBlank()) return oauth.description
74 +
    return body.trim().take(200)
75 +
}
76 +
77 +
/** A sentence for the user, for any failure the API layer throws. */
78 +
fun Throwable.userMessage(): String = when (this) {
79 +
    is HubException.Http -> when (status) {
80 +
        401 -> "Your sign-in has ended. Sign in again."
81 +
        403 -> "Not allowed: $detail"
82 +
        404 -> "Not found: $detail"
83 +
        in 500..599 -> "The hub had a problem ($status). Try again in a moment."
84 +
        else -> detail.ifBlank { "The hub said HTTP $status." }
85 +
    }
86 +
    is HubException.Network -> "Can't reach the hub. Check your connection and try again."
87 +
    is HubException.Unreadable -> "The hub's answer couldn't be read. The app may need an update."
88 +
    else -> message ?: "Something went wrong."
89 +
}
added app/src/main/java/io/oxygenhub/android/api/HubClient.kt +127 −0
1 +
package io.oxygenhub.android.api
2 +
3 +
import kotlinx.serialization.KSerializer
4 +
import kotlinx.serialization.builtins.ListSerializer
5 +
import okhttp3.HttpUrl
6 +
import okhttp3.HttpUrl.Companion.toHttpUrl
7 +
import okhttp3.OkHttpClient
8 +
import okhttp3.Request
9 +
import okhttp3.RequestBody.Companion.toRequestBody
10 +
import java.io.IOException
11 +
12 +
/**
13 +
 * What the app asks of a hub, signed in. The view models see only this, so
14 +
 * their tests give them a fake. Later screens (oxygenhub-android#3–#5) add
15 +
 * to it: a repository's tree, history, proposals, runs.
16 +
 */
17 +
interface HubApi {
18 +
    /** The signed-in account (`GET /user`, scope `user:read`). */
19 +
    suspend fun user(): User
20 +
21 +
    /** Every repository the caller can read (`GET /repos`). */
22 +
    suspend fun repositories(): List<Repository>
23 +
24 +
    /** A repository's issues (`GET /repos/{owner}/{name}/issues?state=`). */
25 +
    suspend fun issues(owner: String, name: String, state: IssueState = IssueState.Open): List<Issue>
26 +
27 +
    /** The inbox: the unread count and the newest entries (`GET /notifications`). */
28 +
    suspend fun inbox(limit: Int = 50): Inbox
29 +
30 +
    /** Marks one notification read (`POST /notifications/{id}/read`). */
31 +
    suspend fun markRead(id: Long)
32 +
33 +
    /** Marks the whole inbox read (`POST /notifications/read`). */
34 +
    suspend fun markAllRead()
35 +
36 +
    /** Revokes the token this client signs in with (`DELETE /user/token`). */
37 +
    suspend fun revokeToken()
38 +
}
39 +
40 +
/**
41 +
 * The hub's JSON API over OkHttp. [onUnauthorized] runs when the hub
42 +
 * refuses the token (revoked on the web, or 90 days unused), so the app
43 +
 * can drop the session and ask for a new sign-in.
44 +
 */
45 +
class HubClient(
46 +
    hub: String,
47 +
    private val token: String,
48 +
    private val http: OkHttpClient,
49 +
    private val onUnauthorized: () -> Unit = {},
50 +
) : HubApi {
51 +
    private val base: HttpUrl = "${hub.trimEnd('/')}/api/v1/".toHttpUrl()
52 +
53 +
    override suspend fun user(): User = get("user", User.serializer())
54 +
55 +
    override suspend fun repositories(): List<Repository> = get("repos", ListSerializer(Repository.serializer()))
56 +
57 +
    override suspend fun issues(owner: String, name: String, state: IssueState): List<Issue> =
58 +
        get(url("repos", owner, name, "issues") { addQueryParameter("state", state.query) }, ListSerializer(Issue.serializer()))
59 +
60 +
    override suspend fun inbox(limit: Int): Inbox =
61 +
        get(url("notifications") { addQueryParameter("limit", limit.coerceIn(1, 200).toString()) }, Inbox.serializer())
62 +
63 +
    override suspend fun markRead(id: Long) {
64 +
        send("POST", url("notifications", id.toString(), "read"))
65 +
    }
66 +
67 +
    override suspend fun markAllRead() {
68 +
        send("POST", url("notifications", "read"))
69 +
    }
70 +
71 +
    override suspend fun revokeToken() {
72 +
        send("DELETE", url("user", "token"), notifyUnauthorized = false)
73 +
    }
74 +
75 +
    // The bearer token is never part of a string anything prints.
76 +
    override fun toString(): String = "HubClient(${base})"
77 +
78 +
    private fun url(vararg segments: String, query: HttpUrl.Builder.() -> Unit = {}): HttpUrl {
79 +
        val b = base.newBuilder()
80 +
        // Each segment is encoded on its own: an owner or name never adds a path level.
81 +
        segments.forEach { b.addPathSegment(it) }
82 +
        b.query()
83 +
        return b.build()
84 +
    }
85 +
86 +
    private suspend fun <T> get(path: String, serializer: KSerializer<T>): T = get(url(*path.split('/').toTypedArray()), serializer)
87 +
88 +
    private suspend fun <T> get(url: HttpUrl, serializer: KSerializer<T>): T {
89 +
        val body = call(request(url).get().build(), notifyUnauthorized = true)
90 +
        return try {
91 +
            HubJson.decodeFromString(serializer, body)
92 +
        } catch (e: IllegalArgumentException) {
93 +
            // kotlinx.serialization's errors are IllegalArgumentExceptions.
94 +
            throw HubException.Unreadable("GET ${url.encodedPath}", e)
95 +
        }
96 +
    }
97 +
98 +
    private suspend fun send(method: String, url: HttpUrl, notifyUnauthorized: Boolean = true) {
99 +
        val body = if (method == "DELETE") null else "{}".toRequestBody(JsonMedia)
100 +
        call(request(url).method(method, body).build(), notifyUnauthorized)
101 +
    }
102 +
103 +
    private fun request(url: HttpUrl): Request.Builder = Request.Builder()
104 +
        .url(url)
105 +
        .header("Accept", "application/json")
106 +
        .header("Authorization", "Bearer $token")
107 +
108 +
    private suspend fun call(request: Request, notifyUnauthorized: Boolean): String {
109 +
        val response = try {
110 +
            http.newCall(request).await()
111 +
        } catch (e: IOException) {
112 +
            throw HubException.Network(e)
113 +
        }
114 +
        response.use { r ->
115 +
            val text = try {
116 +
                r.body?.string().orEmpty()
117 +
            } catch (e: IOException) {
118 +
                throw HubException.Network(e)
119 +
            }
120 +
            if (!r.isSuccessful) {
121 +
                if (r.code == 401 && notifyUnauthorized) onUnauthorized()
122 +
                throw HubException.Http(r.code, errorDetail(text))
123 +
            }
124 +
            return text
125 +
        }
126 +
    }
127 +
}
added app/src/main/java/io/oxygenhub/android/api/Models.kt +149 −0
1 +
package io.oxygenhub.android.api
2 +
3 +
import kotlinx.serialization.SerialName
4 +
import kotlinx.serialization.Serializable
5 +
6 +
// The hub's JSON, by hand from oxygenhub's crates/hub-api/src/lib.rs (and
7 +
// the device flow's answers in crates/hub/src/api/device.rs). Field names
8 +
// are the hub's own, snake case; times are milliseconds since the epoch.
9 +
// Every field has a default, so an added or missing field never breaks the
10 +
// app: the hub's types may still change before 1.0.
11 +
12 +
/** `GET /api/v1/user`. */
13 +
@Serializable
14 +
data class User(
15 +
    val username: String,
16 +
    @SerialName("display_name") val displayName: String = "",
17 +
    @SerialName("is_admin") val isAdmin: Boolean = false,
18 +
    val keys: List<String> = emptyList(),
19 +
    @SerialName("created_ms") val createdMs: Long = 0,
20 +
) {
21 +
    /** The name to greet: the display name, or the username without one. */
22 +
    val shownName: String get() = displayName.ifBlank { username }
23 +
}
24 +
25 +
@Serializable
26 +
enum class Visibility {
27 +
    @SerialName("public") Public,
28 +
    @SerialName("internal") Internal,
29 +
    @SerialName("private") Private,
30 +
}
31 +
32 +
/** A repository as `GET /api/v1/repos` lists it. */
33 +
@Serializable
34 +
data class Repository(
35 +
    val owner: String,
36 +
    val name: String,
37 +
    val description: String = "",
38 +
    @SerialName("default_bookmark") val defaultBookmark: String = "main",
39 +
    val visibility: Visibility = Visibility.Public,
40 +
    @SerialName("created_ms") val createdMs: Long = 0,
41 +
    @SerialName("updated_ms") val updatedMs: Long = 0,
42 +
    @SerialName("clone_url") val cloneUrl: String = "",
43 +
) {
44 +
    val slug: String get() = "$owner/$name"
45 +
}
46 +
47 +
@Serializable
48 +
data class Label(
49 +
    val name: String,
50 +
    /** `#rrggbb`. */
51 +
    val colour: String = "",
52 +
    val description: String = "",
53 +
)
54 +
55 +
/** `GET /api/v1/repos/{owner}/{name}/issues`. */
56 +
@Serializable
57 +
data class Issue(
58 +
    val number: Long,
59 +
    val title: String,
60 +
    val body: String = "",
61 +
    val author: String = "",
62 +
    /** `open` or `closed`. */
63 +
    val state: String = "open",
64 +
    /** Why it closed: `completed`, `not_planned`, … (empty while open). */
65 +
    val reason: String = "",
66 +
    val labels: List<Label> = emptyList(),
67 +
    val assignees: List<String> = emptyList(),
68 +
    val milestone: String? = null,
69 +
    val comments: Long = 0,
70 +
    val locked: Boolean = false,
71 +
    val pinned: Boolean = false,
72 +
    @SerialName("created_ms") val createdMs: Long = 0,
73 +
    @SerialName("updated_ms") val updatedMs: Long = 0,
74 +
    @SerialName("closed_ms") val closedMs: Long? = null,
75 +
    val url: String = "",
76 +
) {
77 +
    val isOpen: Boolean get() = state == "open"
78 +
}
79 +
80 +
/** Which issues to list: the hub's `?state=`. */
81 +
enum class IssueState(val query: String) { Open("open"), Closed("closed"), All("all") }
82 +
83 +
/** One entry in the caller's inbox. */
84 +
@Serializable
85 +
data class Notification(
86 +
    val id: Long,
87 +
    @SerialName("at_ms") val atMs: Long = 0,
88 +
    /** `issue.opened`, `proposal.comment`, `mention`, `review`, … */
89 +
    val kind: String = "",
90 +
    val title: String = "",
91 +
    /** The page on the hub, absolute. */
92 +
    val url: String = "",
93 +
    val actor: String = "",
94 +
    val read: Boolean = false,
95 +
)
96 +
97 +
/** `GET /api/v1/notifications`: the unread count and the newest entries, unread first. */
98 +
@Serializable
99 +
data class Inbox(
100 +
    val unread: Long = 0,
101 +
    val items: List<Notification> = emptyList(),
102 +
)
103 +
104 +
/** An RFC 7807 problem: what the API answers with on an error. */
105 +
@Serializable
106 +
data class Problem(
107 +
    val status: Int = 0,
108 +
    val title: String = "",
109 +
    val detail: String = "",
110 +
)
111 +
112 +
/** The device flow's errors, RFC 8628's shape: `{"error": …, "error_description": …}`. */
113 +
@Serializable
114 +
data class OAuthError(
115 +
    val error: String = "",
116 +
    @SerialName("error_description") val description: String = "",
117 +
)
118 +
119 +
/** `POST /api/v1/device/code`'s answer. */
120 +
@Serializable
121 +
data class DeviceCode(
122 +
    @SerialName("device_code") val deviceCode: String,
123 +
    @SerialName("user_code") val userCode: String,
124 +
    @SerialName("verification_uri") val verificationUri: String,
125 +
    @SerialName("verification_uri_complete") val verificationUriComplete: String = "",
126 +
    @SerialName("expires_in") val expiresIn: Long = 900,
127 +
    val interval: Long = 5,
128 +
) {
129 +
    /** The page to open: with the code filled in when the hub offers it. */
130 +
    val pageToOpen: String get() = verificationUriComplete.ifBlank { verificationUri }
131 +
132 +
    // The device code is a secret until it is used: never in logs.
133 +
    override fun toString(): String = "DeviceCode(userCode=$userCode, verificationUri=$verificationUri)"
134 +
}
135 +
136 +
/** `POST /api/v1/device/token`'s answer once the user approved. */
137 +
@Serializable
138 +
data class TokenGrant(
139 +
    @SerialName("access_token") val accessToken: String,
140 +
    @SerialName("token_type") val tokenType: String = "bearer",
141 +
    /** The scopes the user allowed, space separated (they may untick some). */
142 +
    val scope: String = "",
143 +
    val username: String = "",
144 +
    @SerialName("expires_in") val expiresIn: Long = 0,
145 +
) {
146 +
    val scopes: List<String> get() = scope.split(' ').filter { it.isNotBlank() }
147 +
148 +
    override fun toString(): String = "TokenGrant(username=$username, scope=$scope, accessToken=<redacted>)"
149 +
}
added app/src/main/java/io/oxygenhub/android/auth/KeystoreSessionStore.kt +129 −0
1 +
package io.oxygenhub.android.auth
2 +
3 +
import android.content.Context
4 +
import android.content.SharedPreferences
5 +
import android.security.keystore.KeyGenParameterSpec
6 +
import android.security.keystore.KeyProperties
7 +
import android.security.keystore.StrongBoxUnavailableException
8 +
import android.util.Base64
9 +
import androidx.core.content.edit
10 +
import java.security.GeneralSecurityException
11 +
import java.security.KeyStore
12 +
import javax.crypto.Cipher
13 +
import javax.crypto.KeyGenerator
14 +
import javax.crypto.SecretKey
15 +
import javax.crypto.spec.GCMParameterSpec
16 +
17 +
/**
18 +
 * The session in app-private preferences, its token sealed with an AES-GCM
19 +
 * key that lives in the Android Keystore (StrongBox where the phone has
20 +
 * one) and can never leave it. The preferences hold only the ciphertext;
21 +
 * they are kept out of backups (res/xml/backup_rules.xml and
22 +
 * data_extraction_rules.xml), since a restored copy could not be opened
23 +
 * without the key anyway.
24 +
 *
25 +
 * Jetpack Security's EncryptedSharedPreferences does the same, but is
26 +
 * deprecated; this is the part of it the app needs.
27 +
 */
28 +
class KeystoreSessionStore(context: Context) : SessionStore {
29 +
    private val prefs: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
30 +
31 +
    override var lastHub: String?
32 +
        get() = prefs.getString(LAST_HUB, null)
33 +
        set(value) = prefs.edit { putString(LAST_HUB, value) }
34 +
35 +
    override fun load(): Session? {
36 +
        val hub = prefs.getString(HUB, null) ?: return null
37 +
        val username = prefs.getString(USERNAME, null) ?: return null
38 +
        val sealed = prefs.getString(TOKEN, null) ?: return null
39 +
        val token = try {
40 +
            open(sealed)
41 +
        } catch (_: GeneralSecurityException) {
42 +
            // The key is gone (the lock screen was reset, or the data was
43 +
            // restored to another phone): the token cannot be read, so the
44 +
            // user signs in again.
45 +
            clear()
46 +
            return null
47 +
        } catch (_: IllegalArgumentException) {
48 +
            clear()
49 +
            return null
50 +
        }
51 +
        val scopes = prefs.getString(SCOPES, "").orEmpty().split(' ').filter { it.isNotBlank() }
52 +
        return Session(hub, username, token, scopes)
53 +
    }
54 +
55 +
    override fun save(session: Session) {
56 +
        prefs.edit {
57 +
            putString(HUB, session.hub)
58 +
            putString(USERNAME, session.username)
59 +
            putString(SCOPES, session.scopes.joinToString(" "))
60 +
            putString(TOKEN, seal(session.token))
61 +
            putString(LAST_HUB, session.hub)
62 +
        }
63 +
    }
64 +
65 +
    override fun clear() {
66 +
        prefs.edit {
67 +
            remove(HUB)
68 +
            remove(USERNAME)
69 +
            remove(SCOPES)
70 +
            remove(TOKEN)
71 +
        }
72 +
    }
73 +
74 +
    private fun seal(plain: String): String {
75 +
        val cipher = Cipher.getInstance(TRANSFORMATION)
76 +
        cipher.init(Cipher.ENCRYPT_MODE, key())
77 +
        val iv = cipher.iv
78 +
        val out = cipher.doFinal(plain.toByteArray(Charsets.UTF_8))
79 +
        return b64(iv) + ":" + b64(out)
80 +
    }
81 +
82 +
    private fun open(sealed: String): String {
83 +
        val (iv, data) = sealed.split(':', limit = 2).let { parts ->
84 +
            require(parts.size == 2) { "not a sealed token" }
85 +
            Base64.decode(parts[0], Base64.NO_WRAP) to Base64.decode(parts[1], Base64.NO_WRAP)
86 +
        }
87 +
        val key = existingKey() ?: throw GeneralSecurityException("no key")
88 +
        val cipher = Cipher.getInstance(TRANSFORMATION)
89 +
        cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, iv))
90 +
        return String(cipher.doFinal(data), Charsets.UTF_8)
91 +
    }
92 +
93 +
    private fun b64(bytes: ByteArray): String = Base64.encodeToString(bytes, Base64.NO_WRAP)
94 +
95 +
    private fun existingKey(): SecretKey? {
96 +
        val ks = KeyStore.getInstance(KEYSTORE).apply { load(null) }
97 +
        return (ks.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.secretKey
98 +
    }
99 +
100 +
    private fun key(): SecretKey = existingKey() ?: try {
101 +
        generate(strongBox = true)
102 +
    } catch (_: StrongBoxUnavailableException) {
103 +
        generate(strongBox = false)
104 +
    }
105 +
106 +
    private fun generate(strongBox: Boolean): SecretKey {
107 +
        val spec = KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
108 +
            .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
109 +
            .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
110 +
            .setKeySize(256)
111 +
            .setIsStrongBoxBacked(strongBox)
112 +
            .build()
113 +
        val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)
114 +
        generator.init(spec)
115 +
        return generator.generateKey()
116 +
    }
117 +
118 +
    private companion object {
119 +
        const val PREFS = "session"
120 +
        const val KEYSTORE = "AndroidKeyStore"
121 +
        const val ALIAS = "io.oxygenhub.android.session"
122 +
        const val TRANSFORMATION = "AES/GCM/NoPadding"
123 +
        const val HUB = "hub"
124 +
        const val USERNAME = "username"
125 +
        const val SCOPES = "scopes"
126 +
        const val TOKEN = "token.sealed"
127 +
        const val LAST_HUB = "last_hub"
128 +
    }
129 +
}
added app/src/main/java/io/oxygenhub/android/auth/Session.kt +36 −0
1 +
package io.oxygenhub.android.auth
2 +
3 +
/** A signed-in hub: its address, the account, and the device token. */
4 +
data class Session(
5 +
    /** `https://oxygenhub.io`, no trailing slash. */
6 +
    val hub: String,
7 +
    val username: String,
8 +
    val token: String,
9 +
    /** The scopes the user allowed when approving the device. */
10 +
    val scopes: List<String> = emptyList(),
11 +
) {
12 +
    // The token never reaches a log through toString.
13 +
    override fun toString(): String = "Session(hub=$hub, username=$username, scopes=$scopes, token=<redacted>)"
14 +
}
15 +
16 +
/** Where the session is kept between launches. */
17 +
interface SessionStore {
18 +
    fun load(): Session?
19 +
    fun save(session: Session)
20 +
    fun clear()
21 +
22 +
    /** The hub last signed in to (or typed), so the sign-in screen offers it again. */
23 +
    var lastHub: String?
24 +
}
25 +
26 +
/** A store in memory, for tests and previews. */
27 +
class MemorySessionStore(private var session: Session? = null, override var lastHub: String? = null) : SessionStore {
28 +
    override fun load(): Session? = session
29 +
    override fun save(session: Session) {
30 +
        this.session = session
31 +
    }
32 +
33 +
    override fun clear() {
34 +
        session = null
35 +
    }
36 +
}
added app/src/main/java/io/oxygenhub/android/auth/SessionManager.kt +96 −0
1 +
package io.oxygenhub.android.auth
2 +
3 +
import io.oxygenhub.android.api.HubApi
4 +
import io.oxygenhub.android.api.HubException
5 +
import kotlinx.coroutines.flow.MutableStateFlow
6 +
import kotlinx.coroutines.flow.StateFlow
7 +
import kotlinx.coroutines.flow.asStateFlow
8 +
9 +
/** What signing out did on the hub. */
10 +
enum class SignOutResult {
11 +
    /** The token is revoked on the hub. */
12 +
    Revoked,
13 +
14 +
    /** The hub no longer knew the token (revoked or expired already). */
15 +
    AlreadyGone,
16 +
17 +
    /** Signed out here, but the hub could not be told: revoke it under Settings, Tokens. */
18 +
    NotRevoked,
19 +
}
20 +
21 +
/**
22 +
 * The app's one session: who is signed in, the API client for them, and
23 +
 * signing in and out. [apiFactory] makes the client for a session; it is
24 +
 * given a callback to run when the hub refuses the token.
25 +
 */
26 +
class SessionManager(
27 +
    private val store: SessionStore,
28 +
    private val apiFactory: (Session, onUnauthorized: () -> Unit) -> HubApi,
29 +
) {
30 +
    private val _session = MutableStateFlow(store.load())
31 +
    val session: StateFlow<Session?> = _session.asStateFlow()
32 +
33 +
    /** Why the last session ended without the user signing out, for the sign-in screen to say. */
34 +
    private val _ended = MutableStateFlow<String?>(null)
35 +
    val ended: StateFlow<String?> = _ended.asStateFlow()
36 +
37 +
    private var api: Pair<Session, HubApi>? = null
38 +
39 +
    val lastHub: String? get() = store.lastHub
40 +
41 +
    /** The client for the signed-in session, or null when signed out. */
42 +
    fun api(): HubApi? {
43 +
        val s = _session.value ?: return null
44 +
        api?.let { (owner, client) -> if (owner == s) return client }
45 +
        return apiFactory(s) { expired(s) }.also { api = s to it }
46 +
    }
47 +
48 +
    fun signedIn(session: Session) {
49 +
        store.save(session)
50 +
        _ended.value = null
51 +
        _session.value = session
52 +
    }
53 +
54 +
    fun rememberHub(hub: String) {
55 +
        store.lastHub = hub
56 +
    }
57 +
58 +
    /** Revokes the token on the hub, then forgets it here whatever the hub said. */
59 +
    suspend fun signOut(): SignOutResult {
60 +
        val client = api()
61 +
        val result = when {
62 +
            client == null -> SignOutResult.AlreadyGone
63 +
            else -> try {
64 +
                client.revokeToken()
65 +
                SignOutResult.Revoked
66 +
            } catch (e: HubException.Http) {
67 +
                if (e.status == 401) SignOutResult.AlreadyGone else SignOutResult.NotRevoked
68 +
            } catch (_: HubException) {
69 +
                SignOutResult.NotRevoked
70 +
            }
71 +
        }
72 +
        forget()
73 +
        if (result == SignOutResult.NotRevoked) {
74 +
            _ended.value = "Signed out here, but the hub couldn't be reached to revoke the token. " +
75 +
                "Revoke it on the hub under Settings, Tokens."
76 +
        }
77 +
        return result
78 +
    }
79 +
80 +
    /** The hub refused the token: forget it and ask for a new sign-in. */
81 +
    private fun expired(session: Session) {
82 +
        if (_session.value != session) return
83 +
        forget()
84 +
        _ended.value = "Your sign-in to ${session.hub} has ended (the token was revoked or not used for 90 days). Sign in again."
85 +
    }
86 +
87 +
    private fun forget() {
88 +
        store.clear()
89 +
        api = null
90 +
        _session.value = null
91 +
    }
92 +
93 +
    fun dismissEnded() {
94 +
        _ended.value = null
95 +
    }
96 +
}
added app/src/test/java/io/oxygenhub/android/FakeHubApi.kt +41 −0
1 +
package io.oxygenhub.android
2 +
3 +
import io.oxygenhub.android.api.HubApi
4 +
import io.oxygenhub.android.api.Inbox
5 +
import io.oxygenhub.android.api.Issue
6 +
import io.oxygenhub.android.api.IssueState
7 +
import io.oxygenhub.android.api.Repository
8 +
import io.oxygenhub.android.api.User
9 +
10 +
/** A hub in memory: answers from the fixtures, or the failure set for a call. */
11 +
class FakeHubApi : HubApi {
12 +
    var user: () -> User = { Fixtures.user }
13 +
    var repositories: () -> List<Repository> = { Fixtures.repos }
14 +
    var issues: (IssueState) -> List<Issue> = { state ->
15 +
        Fixtures.issues.filter { state == IssueState.All || it.state == state.query }
16 +
    }
17 +
    var inbox: () -> Inbox = { Fixtures.inbox }
18 +
    var markRead: (Long) -> Unit = {}
19 +
    var markAllRead: () -> Unit = {}
20 +
    var revoke: () -> Unit = {}
21 +
22 +
    val calls = mutableListOf<String>()
23 +
24 +
    override suspend fun user(): User = user.invoke().also { calls += "user" }
25 +
    override suspend fun repositories(): List<Repository> = repositories.invoke().also { calls += "repos" }
26 +
    override suspend fun issues(owner: String, name: String, state: IssueState): List<Issue> =
27 +
        issues.invoke(state).also { calls += "issues $owner/$name ${state.query}" }
28 +
    override suspend fun inbox(limit: Int): Inbox = inbox.invoke().also { calls += "inbox" }
29 +
    override suspend fun markRead(id: Long) {
30 +
        calls += "read $id"
31 +
        markRead.invoke(id)
32 +
    }
33 +
    override suspend fun markAllRead() {
34 +
        calls += "read all"
35 +
        markAllRead.invoke()
36 +
    }
37 +
    override suspend fun revokeToken() {
38 +
        calls += "revoke"
39 +
        revoke.invoke()
40 +
    }
41 +
}
added app/src/test/java/io/oxygenhub/android/Fixtures.kt +19 −0
1 +
package io.oxygenhub.android
2 +
3 +
import io.oxygenhub.android.api.HubJson
4 +
import io.oxygenhub.android.api.Inbox
5 +
import io.oxygenhub.android.api.Issue
6 +
import io.oxygenhub.android.api.Repository
7 +
import io.oxygenhub.android.api.User
8 +
import kotlinx.serialization.builtins.ListSerializer
9 +
10 +
/** The JSON fixtures under src/test/resources/fixtures, written from oxygenhub's hub-api types. */
11 +
object Fixtures {
12 +
    fun text(name: String): String =
13 +
        requireNotNull(Fixtures::class.java.classLoader!!.getResource("fixtures/$name")) { "no fixture $name" }.readText()
14 +
15 +
    val user: User get() = HubJson.decodeFromString(User.serializer(), text("user.json"))
16 +
    val repos: List<Repository> get() = HubJson.decodeFromString(ListSerializer(Repository.serializer()), text("repos.json"))
17 +
    val issues: List<Issue> get() = HubJson.decodeFromString(ListSerializer(Issue.serializer()), text("issues.json"))
18 +
    val inbox: Inbox get() = HubJson.decodeFromString(Inbox.serializer(), text("inbox.json"))
19 +
}
added app/src/test/java/io/oxygenhub/android/api/DeviceFlowTest.kt +127 −0
1 +
package io.oxygenhub.android.api
2 +
3 +
import io.oxygenhub.android.Fixtures
4 +
import kotlinx.coroutines.test.runTest
5 +
import kotlinx.serialization.json.jsonArray
6 +
import kotlinx.serialization.json.jsonObject
7 +
import kotlinx.serialization.json.jsonPrimitive
8 +
import okhttp3.OkHttpClient
9 +
import okhttp3.mockwebserver.MockResponse
10 +
import okhttp3.mockwebserver.MockWebServer
11 +
import org.junit.After
12 +
import org.junit.Assert.assertEquals
13 +
import org.junit.Assert.assertTrue
14 +
import org.junit.Assert.fail
15 +
import org.junit.Before
16 +
import org.junit.Test
17 +
18 +
class DeviceFlowTest {
19 +
    private lateinit var server: MockWebServer
20 +
    private val sleeps = mutableListOf<Long>()
21 +
22 +
    @Before
23 +
    fun setUp() {
24 +
        server = MockWebServer()
25 +
        server.start()
26 +
        sleeps.clear()
27 +
    }
28 +
29 +
    @After
30 +
    fun tearDown() {
31 +
        server.shutdown()
32 +
    }
33 +
34 +
    private fun auth() = HubDeviceAuth(server.url("/").toString().trimEnd('/'), OkHttpClient()) { sleeps += it }
35 +
36 +
    private fun answer(name: String, code: Int) = MockResponse().setResponseCode(code).setBody(Fixtures.text(name))
37 +
38 +
    private fun code() = HubJson.decodeFromString(DeviceCode.serializer(), Fixtures.text("device_code.json"))
39 +
40 +
    @Test
41 +
    fun requestingACodeNamesTheAppAndItsScopes() = runTest {
42 +
        server.enqueue(answer("device_code.json", 200))
43 +
        val code = auth().requestCode("OxygenHub for Android on Pixel 10 Pro Fold")
44 +
        assertEquals("BCDF-GHJK", code.userCode)
45 +
        val req = server.takeRequest()
46 +
        assertEquals("POST", req.method)
47 +
        assertEquals("/api/v1/device/code", req.path)
48 +
        assertEquals(null, req.getHeader("Authorization"))
49 +
        val body = HubJson.parseToJsonElement(req.body.readUtf8()).jsonObject
50 +
        assertEquals("OxygenHub for Android on Pixel 10 Pro Fold", body["client"]!!.jsonPrimitive.content)
51 +
        assertEquals(AppScopes, body["scopes"]!!.jsonArray.map { it.jsonPrimitive.content })
52 +
    }
53 +
54 +
    @Test
55 +
    fun aHubThatCannotSignInDevices() = runTest {
56 +
        server.enqueue(MockResponse().setResponseCode(400).setBody("""{"error":"invalid_scope","error_description":"unknown scope"}"""))
57 +
        try {
58 +
            auth().requestCode("app")
59 +
            fail("expected a refusal")
60 +
        } catch (e: DeviceFlowException.Refused) {
61 +
            assertEquals(400, e.status)
62 +
            assertTrue(e.message!!.endsWith("unknown scope"))
63 +
        }
64 +
    }
65 +
66 +
    @Test
67 +
    fun pollsUntilApprovedSlowingDownWhenTold() = runTest {
68 +
        server.enqueue(answer("device_authorization_pending.json", 400))
69 +
        server.enqueue(answer("device_slow_down.json", 400))
70 +
        server.enqueue(answer("device_authorization_pending.json", 400))
71 +
        server.enqueue(answer("device_token.json", 200))
72 +
        val grant = auth().awaitToken(code())
73 +
        assertEquals("oxy_test_not_a_real_token", grant.accessToken)
74 +
        assertEquals(listOf(5000L, 5000L, 10000L, 10000L), sleeps)
75 +
        repeat(4) {
76 +
            val req = server.takeRequest()
77 +
            assertEquals("/api/v1/device/token", req.path)
78 +
            val body = HubJson.parseToJsonElement(req.body.readUtf8()).jsonObject
79 +
            assertEquals(code().deviceCode, body["device_code"]!!.jsonPrimitive.content)
80 +
        }
81 +
    }
82 +
83 +
    @Test(expected = DeviceFlowException.Denied::class)
84 +
    fun denied() = runTest {
85 +
        server.enqueue(answer("device_access_denied.json", 400))
86 +
        auth().awaitToken(code())
87 +
    }
88 +
89 +
    @Test(expected = DeviceFlowException.Expired::class)
90 +
    fun expiredOnTheHub() = runTest {
91 +
        server.enqueue(answer("device_expired_token.json", 400))
92 +
        auth().awaitToken(code())
93 +
    }
94 +
95 +
    @Test
96 +
    fun expiresHereWhenTheHubNeverAnswers() = runTest {
97 +
        // A code with ten seconds to live, polled every five: two polls, then give up.
98 +
        repeat(5) { server.enqueue(answer("device_authorization_pending.json", 400)) }
99 +
        try {
100 +
            auth().awaitToken(code().copy(expiresIn = 10))
101 +
            fail("expected expiry")
102 +
        } catch (_: DeviceFlowException.Expired) {
103 +
        }
104 +
        assertEquals(2, server.requestCount)
105 +
    }
106 +
107 +
    @Test
108 +
    fun anUnexpectedAnswerStops() = runTest {
109 +
        server.enqueue(MockResponse().setResponseCode(500).setBody("""{"error":"server_error","error_description":"db down"}"""))
110 +
        try {
111 +
            auth().awaitToken(code())
112 +
            fail("expected a refusal")
113 +
        } catch (e: DeviceFlowException.Refused) {
114 +
            assertEquals(500, e.status)
115 +
            assertTrue(e.message!!.contains("db down"))
116 +
        }
117 +
    }
118 +
119 +
    @Test
120 +
    fun whoAmIUsesTheNewToken() = runTest {
121 +
        server.enqueue(answer("user.json", 200))
122 +
        assertEquals("andymccall", auth().whoAmI("oxy_new").username)
123 +
        val req = server.takeRequest()
124 +
        assertEquals("/api/v1/user", req.path)
125 +
        assertEquals("Bearer oxy_new", req.getHeader("Authorization"))
126 +
    }
127 +
}
added app/src/test/java/io/oxygenhub/android/api/HubClientTest.kt +169 −0
1 +
package io.oxygenhub.android.api
2 +
3 +
import io.oxygenhub.android.Fixtures
4 +
import kotlinx.coroutines.test.runTest
5 +
import okhttp3.OkHttpClient
6 +
import okhttp3.mockwebserver.MockResponse
7 +
import okhttp3.mockwebserver.MockWebServer
8 +
import okhttp3.mockwebserver.SocketPolicy
9 +
import org.junit.After
10 +
import org.junit.Assert.assertEquals
11 +
import org.junit.Assert.assertFalse
12 +
import org.junit.Assert.assertTrue
13 +
import org.junit.Assert.fail
14 +
import org.junit.Before
15 +
import org.junit.Test
16 +
17 +
class HubClientTest {
18 +
    private lateinit var server: MockWebServer
19 +
    private val http = OkHttpClient()
20 +
    private var unauthorized = 0
21 +
22 +
    @Before
23 +
    fun setUp() {
24 +
        server = MockWebServer()
25 +
        server.start()
26 +
        unauthorized = 0
27 +
    }
28 +
29 +
    @After
30 +
    fun tearDown() {
31 +
        server.shutdown()
32 +
    }
33 +
34 +
    // MockWebServer speaks plain HTTP; the client takes any base it is given.
35 +
    private fun client() = HubClient(server.url("/").toString().trimEnd('/'), "oxy_secret", http) { unauthorized++ }
36 +
37 +
    private fun json(name: String, code: Int = 200) =
38 +
        MockResponse().setResponseCode(code).setHeader("Content-Type", "application/json").setBody(Fixtures.text(name))
39 +
40 +
    @Test
41 +
    fun userSendsTheBearerToken() = runTest {
42 +
        server.enqueue(json("user.json"))
43 +
        val u = client().user()
44 +
        assertEquals("andymccall", u.username)
45 +
        val req = server.takeRequest()
46 +
        assertEquals("GET", req.method)
47 +
        assertEquals("/api/v1/user", req.path)
48 +
        assertEquals("Bearer oxy_secret", req.getHeader("Authorization"))
49 +
        assertEquals("application/json", req.getHeader("Accept"))
50 +
    }
51 +
52 +
    @Test
53 +
    fun repositories() = runTest {
54 +
        server.enqueue(json("repos.json"))
55 +
        assertEquals(3, client().repositories().size)
56 +
        assertEquals("/api/v1/repos", server.takeRequest().path)
57 +
    }
58 +
59 +
    @Test
60 +
    fun issuesAskForTheState() = runTest {
61 +
        server.enqueue(json("issues.json"))
62 +
        val issues = client().issues("andymccall", "oxygenhub-android", IssueState.All)
63 +
        assertEquals(3, issues.size)
64 +
        assertEquals("/api/v1/repos/andymccall/oxygenhub-android/issues?state=all", server.takeRequest().path)
65 +
    }
66 +
67 +
    @Test
68 +
    fun pathSegmentsAreEncoded() = runTest {
69 +
        server.enqueue(MockResponse().setBody("[]"))
70 +
        client().issues("a b", "c/d")
71 +
        assertEquals("/api/v1/repos/a%20b/c%2Fd/issues?state=open", server.takeRequest().path)
72 +
    }
73 +
74 +
    @Test
75 +
    fun inboxWithALimit() = runTest {
76 +
        server.enqueue(json("inbox.json"))
77 +
        assertEquals(2, client().inbox(500).unread)
78 +
        assertEquals("/api/v1/notifications?limit=200", server.takeRequest().path)
79 +
    }
80 +
81 +
    @Test
82 +
    fun markingRead() = runTest {
83 +
        server.enqueue(MockResponse().setResponseCode(204))
84 +
        server.enqueue(MockResponse().setResponseCode(204))
85 +
        client().markRead(41)
86 +
        client().markAllRead()
87 +
        val one = server.takeRequest()
88 +
        assertEquals("POST", one.method)
89 +
        assertEquals("/api/v1/notifications/41/read", one.path)
90 +
        val all = server.takeRequest()
91 +
        assertEquals("POST", all.method)
92 +
        assertEquals("/api/v1/notifications/read", all.path)
93 +
    }
94 +
95 +
    @Test
96 +
    fun revokingDeletesTheOwnToken() = runTest {
97 +
        server.enqueue(MockResponse().setResponseCode(204))
98 +
        client().revokeToken()
99 +
        val req = server.takeRequest()
100 +
        assertEquals("DELETE", req.method)
101 +
        assertEquals("/api/v1/user/token", req.path)
102 +
        assertEquals("Bearer oxy_secret", req.getHeader("Authorization"))
103 +
    }
104 +
105 +
    @Test
106 +
    fun aProblemBecomesAnHttpError() = runTest {
107 +
        server.enqueue(json("problem_403.json", 403).setHeader("Content-Type", "application/problem+json"))
108 +
        try {
109 +
            client().user()
110 +
            fail("expected an error")
111 +
        } catch (e: HubException.Http) {
112 +
            assertEquals(403, e.status)
113 +
            assertEquals("this token lacks the user:read scope", e.detail)
114 +
            assertEquals("Not allowed: this token lacks the user:read scope", e.userMessage())
115 +
        }
116 +
        assertEquals(0, unauthorized)
117 +
    }
118 +
119 +
    @Test
120 +
    fun aRefusedTokenEndsTheSession() = runTest {
121 +
        server.enqueue(json("problem_401.json", 401))
122 +
        try {
123 +
            client().repositories()
124 +
            fail("expected an error")
125 +
        } catch (e: HubException.Http) {
126 +
            assertEquals(401, e.status)
127 +
        }
128 +
        assertEquals(1, unauthorized)
129 +
    }
130 +
131 +
    @Test
132 +
    fun revokingARefusedTokenDoesNotCallBack() = runTest {
133 +
        server.enqueue(json("problem_401.json", 401))
134 +
        try {
135 +
            client().revokeToken()
136 +
            fail("expected an error")
137 +
        } catch (e: HubException.Http) {
138 +
            assertEquals(401, e.status)
139 +
        }
140 +
        assertEquals(0, unauthorized)
141 +
    }
142 +
143 +
    @Test
144 +
    fun anUnreadableAnswer() = runTest {
145 +
        server.enqueue(MockResponse().setBody("<html>a proxy's page</html>"))
146 +
        try {
147 +
            client().repositories()
148 +
            fail("expected an error")
149 +
        } catch (e: HubException.Unreadable) {
150 +
            assertTrue(e.message!!.contains("/api/v1/repos"))
151 +
        }
152 +
    }
153 +
154 +
    @Test
155 +
    fun aBrokenConnection() = runTest {
156 +
        server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START))
157 +
        try {
158 +
            client().user()
159 +
            fail("expected an error")
160 +
        } catch (e: HubException.Network) {
161 +
            assertEquals("Can't reach the hub. Check your connection and try again.", e.userMessage())
162 +
        }
163 +
    }
164 +
165 +
    @Test
166 +
    fun theTokenIsNotInToString() {
167 +
        assertFalse(client().toString().contains("oxy_secret"))
168 +
    }
169 +
}
added app/src/test/java/io/oxygenhub/android/api/ModelsTest.kt +112 −0
1 +
package io.oxygenhub.android.api
2 +
3 +
import io.oxygenhub.android.Fixtures
4 +
import org.junit.Assert.assertEquals
5 +
import org.junit.Assert.assertFalse
6 +
import org.junit.Assert.assertNull
7 +
import org.junit.Assert.assertTrue
8 +
import org.junit.Test
9 +
10 +
class ModelsTest {
11 +
    @Test
12 +
    fun user() {
13 +
        val u = Fixtures.user
14 +
        assertEquals("andymccall", u.username)
15 +
        assertEquals("Andy McCall", u.displayName)
16 +
        assertEquals("Andy McCall", u.shownName)
17 +
        assertTrue(u.isAdmin)
18 +
        assertEquals(1, u.keys.size)
19 +
        assertEquals(1790530000000, u.createdMs)
20 +
    }
21 +
22 +
    @Test
23 +
    fun shownNameFallsBackToUsername() {
24 +
        assertEquals("claude", User(username = "claude").shownName)
25 +
    }
26 +
27 +
    @Test
28 +
    fun repositoriesWithEveryVisibilityAndAnUnknownField() {
29 +
        val r = Fixtures.repos
30 +
        assertEquals(3, r.size)
31 +
        assertEquals("andymccall/oxygenflow", r[0].slug)
32 +
        assertEquals(Visibility.Public, r[0].visibility)
33 +
        assertEquals(Visibility.Private, r[1].visibility)
34 +
        assertEquals(Visibility.Internal, r[2].visibility)
35 +
        assertEquals("trunk", r[2].defaultBookmark)
36 +
        assertEquals(1792878439999, r[0].updatedMs)
37 +
        assertEquals("https://oxygenhub.io/andymccall/oxygenflow", r[0].cloneUrl)
38 +
    }
39 +
40 +
    @Test
41 +
    fun issues() {
42 +
        val i = Fixtures.issues
43 +
        assertEquals(listOf(3L, 2L, 1L), i.map { it.number })
44 +
        val two = i[1]
45 +
        assertTrue(two.isOpen)
46 +
        assertTrue(two.pinned)
47 +
        assertEquals("0.1", two.milestone)
48 +
        assertEquals(listOf("claude"), two.assignees)
49 +
        assertEquals(Label("feature", "#0064e0", "Something new"), two.labels.single())
50 +
        assertNull(two.closedMs)
51 +
        val one = i[2]
52 +
        assertFalse(one.isOpen)
53 +
        assertEquals("completed", one.reason)
54 +
        assertEquals(1792875000000, one.closedMs)
55 +
        assertNull(one.milestone)
56 +
    }
57 +
58 +
    @Test
59 +
    fun inbox() {
60 +
        val inbox = Fixtures.inbox
61 +
        assertEquals(2, inbox.unread)
62 +
        assertEquals(listOf(41L, 40L, 12L), inbox.items.map { it.id })
63 +
        assertEquals("issue.opened", inbox.items[0].kind)
64 +
        assertFalse(inbox.items[0].read)
65 +
        assertEquals("", inbox.items[1].actor)
66 +
        assertTrue(inbox.items[2].read)
67 +
    }
68 +
69 +
    @Test
70 +
    fun deviceCodeAndToken() {
71 +
        val code = HubJson.decodeFromString(DeviceCode.serializer(), Fixtures.text("device_code.json"))
72 +
        assertEquals("BCDF-GHJK", code.userCode)
73 +
        assertEquals("https://oxygenhub.io/device?code=BCDF-GHJK", code.pageToOpen)
74 +
        assertEquals(900, code.expiresIn)
75 +
        assertEquals(5, code.interval)
76 +
        val grant = HubJson.decodeFromString(TokenGrant.serializer(), Fixtures.text("device_token.json"))
77 +
        assertEquals(listOf("repo:read", "user:read"), grant.scopes)
78 +
        assertEquals("andymccall", grant.username)
79 +
    }
80 +
81 +
    @Test
82 +
    fun pageToOpenWithoutTheCompleteUri() {
83 +
        val code = DeviceCode("d", "BCDF-GHJK", "https://hub.example/device")
84 +
        assertEquals("https://hub.example/device", code.pageToOpen)
85 +
    }
86 +
87 +
    @Test
88 +
    fun secretsStayOutOfToString() {
89 +
        val code = HubJson.decodeFromString(DeviceCode.serializer(), Fixtures.text("device_code.json"))
90 +
        assertFalse(code.toString().contains("oxydev_"))
91 +
        val grant = HubJson.decodeFromString(TokenGrant.serializer(), Fixtures.text("device_token.json"))
92 +
        assertFalse(grant.toString().contains("oxy_test_not_a_real_token"))
93 +
    }
94 +
95 +
    @Test
96 +
    fun errorDetails() {
97 +
        assertEquals("this token lacks the user:read scope", errorDetail(Fixtures.text("problem_403.json")))
98 +
        assertEquals("not yet approved", errorDetail(Fixtures.text("device_authorization_pending.json")))
99 +
        assertEquals("plain text", errorDetail("plain text\n"))
100 +
    }
101 +
102 +
    @Test
103 +
    fun hubUrls() {
104 +
        assertEquals("https://oxygenhub.io", normalizeHubUrl("https://oxygenhub.io/"))
105 +
        assertEquals("https://oxygenhub.io", normalizeHubUrl("  oxygenhub.io "))
106 +
        assertEquals("https://hub.example:7343/forge", normalizeHubUrl("hub.example:7343/forge/"))
107 +
        assertNull(normalizeHubUrl("http://oxygenhub.io"))
108 +
        assertNull(normalizeHubUrl(""))
109 +
        assertNull(normalizeHubUrl("https://"))
110 +
        assertNull(normalizeHubUrl("https://oxygenhub.io/?x=1"))
111 +
    }
112 +
}
added app/src/test/java/io/oxygenhub/android/auth/SessionManagerTest.kt +98 −0
1 +
package io.oxygenhub.android.auth
2 +
3 +
import io.oxygenhub.android.FakeHubApi
4 +
import io.oxygenhub.android.api.HubException
5 +
import kotlinx.coroutines.test.runTest
6 +
import org.junit.Assert.assertEquals
7 +
import org.junit.Assert.assertFalse
8 +
import org.junit.Assert.assertNotNull
9 +
import org.junit.Assert.assertNull
10 +
import org.junit.Assert.assertSame
11 +
import org.junit.Test
12 +
import java.io.IOException
13 +
14 +
class SessionManagerTest {
15 +
    private val session = Session("https://oxygenhub.io", "andymccall", "oxy_secret", listOf("repo:read", "user:read"))
16 +
    private val fake = FakeHubApi()
17 +
    private var onUnauthorized: () -> Unit = {}
18 +
19 +
    private fun manager(store: SessionStore = MemorySessionStore()) = SessionManager(store) { _, cb ->
20 +
        onUnauthorized = cb
21 +
        fake
22 +
    }
23 +
24 +
    @Test
25 +
    fun signingInSavesTheSession() {
26 +
        val store = MemorySessionStore()
27 +
        val m = manager(store)
28 +
        assertNull(m.session.value)
29 +
        assertNull(m.api())
30 +
        m.signedIn(session)
31 +
        assertEquals(session, store.load())
32 +
        assertEquals(session, m.session.value)
33 +
        assertSame(m.api(), m.api())
34 +
    }
35 +
36 +
    @Test
37 +
    fun aSavedSessionIsThereAtStart() {
38 +
        val m = manager(MemorySessionStore(session))
39 +
        assertEquals(session, m.session.value)
40 +
        assertNotNull(m.api())
41 +
    }
42 +
43 +
    @Test
44 +
    fun signingOutRevokesTheToken() = runTest {
45 +
        val store = MemorySessionStore(session)
46 +
        val m = manager(store)
47 +
        assertEquals(SignOutResult.Revoked, m.signOut())
48 +
        assertEquals(listOf("revoke"), fake.calls)
49 +
        assertNull(store.load())
50 +
        assertNull(m.session.value)
51 +
        assertNull(m.ended.value)
52 +
    }
53 +
54 +
    @Test
55 +
    fun aTokenTheHubNoLongerKnows() = runTest {
56 +
        fake.revoke = { throw HubException.Http(401, "unknown or expired token") }
57 +
        val m = manager(MemorySessionStore(session))
58 +
        assertEquals(SignOutResult.AlreadyGone, m.signOut())
59 +
        assertNull(m.session.value)
60 +
    }
61 +
62 +
    @Test
63 +
    fun signingOutOfflineStillForgetsTheTokenAndSaysSo() = runTest {
64 +
        fake.revoke = { throw HubException.Network(IOException("no route")) }
65 +
        val store = MemorySessionStore(session)
66 +
        val m = manager(store)
67 +
        assertEquals(SignOutResult.NotRevoked, m.signOut())
68 +
        assertNull(store.load())
69 +
        assertNull(m.session.value)
70 +
        assertNotNull(m.ended.value)
71 +
    }
72 +
73 +
    @Test
74 +
    fun aRefusedTokenEndsTheSession() {
75 +
        val store = MemorySessionStore(session)
76 +
        val m = manager(store)
77 +
        m.api()
78 +
        onUnauthorized()
79 +
        assertNull(m.session.value)
80 +
        assertNull(store.load())
81 +
        assertEquals(true, m.ended.value?.contains("has ended"))
82 +
        m.dismissEnded()
83 +
        assertNull(m.ended.value)
84 +
    }
85 +
86 +
    @Test
87 +
    fun theLastHubIsRemembered() {
88 +
        val store = MemorySessionStore()
89 +
        val m = manager(store)
90 +
        m.rememberHub("https://hub.example")
91 +
        assertEquals("https://hub.example", m.lastHub)
92 +
    }
93 +
94 +
    @Test
95 +
    fun theTokenIsNotInToString() {
96 +
        assertFalse(session.toString().contains("oxy_secret"))
97 +
    }
98 +
}
added app/src/test/resources/fixtures/device_access_denied.json +1 −0
1 +
{"error":"access_denied","error_description":"the user denied the request"}
added app/src/test/resources/fixtures/device_authorization_pending.json +1 −0
1 +
{"error":"authorization_pending","error_description":"not yet approved"}
added app/src/test/resources/fixtures/device_code.json +1 −0
1 +
{"device_code":"oxydev_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","user_code":"BCDF-GHJK","verification_uri":"https://oxygenhub.io/device","verification_uri_complete":"https://oxygenhub.io/device?code=BCDF-GHJK","expires_in":900,"interval":5}
added app/src/test/resources/fixtures/device_expired_token.json +1 −0
1 +
{"error":"expired_token","error_description":"the code expired; ask for a new one"}
added app/src/test/resources/fixtures/device_slow_down.json +1 −0
1 +
{"error":"slow_down","error_description":"poll no more often than the interval"}
added app/src/test/resources/fixtures/device_token.json +1 −0
1 +
{"access_token":"oxy_test_not_a_real_token","token_type":"bearer","scope":"repo:read user:read","username":"andymccall","expires_in":7776000}
added app/src/test/resources/fixtures/inbox.json +5 −0
1 +
{"unread":2,"items":[
2 +
  {"id":41,"at_ms":1792880000000,"kind":"issue.opened","title":"App foundation: sign in, the hub's API, navigation, foldable layouts","url":"https://oxygenhub.io/andymccall/oxygenhub-android/issue/2","actor":"andymccall","read":false},
3 +
  {"id":40,"at_ms":1792870000000,"kind":"flow.failed","title":"oxygenhub-android main: lint failed","url":"https://oxygenhub.io/andymccall/oxygenhub-android/flow/runs/7","actor":"","read":false},
4 +
  {"id":12,"at_ms":1792000000000,"kind":"proposal.comment","title":"Pins for oxy 0.5.1","url":"https://oxygenhub.io/andymccall/oxygenhub/proposal/9#c2","actor":"antigravity","read":true}
5 +
]}
added app/src/test/resources/fixtures/issues.json +5 −0
1 +
[
2 +
  {"number":3,"title":"Browse: dashboard, repositories, code, history, drops","body":"The dashboard and pulse.","author":"andymccall","state":"open","reason":"","labels":[{"name":"feature","colour":"#0064e0","description":"Something new"}],"assignees":[],"milestone":null,"comments":0,"locked":false,"pinned":false,"created_ms":1792870000000,"updated_ms":1792870000000,"closed_ms":null,"url":"https://oxygenhub.io/andymccall/oxygenhub-android/issue/3"},
3 +
  {"number":2,"title":"App foundation: sign in, the hub's API, navigation, foldable layouts","body":"Sign in with the hub's device flow.","author":"andymccall","state":"open","reason":"","labels":[{"name":"feature","colour":"#0064e0","description":"Something new"}],"assignees":["claude"],"milestone":"0.1","comments":2,"locked":false,"pinned":true,"created_ms":1792869000000,"updated_ms":1792879000000,"closed_ms":null,"url":"https://oxygenhub.io/andymccall/oxygenhub-android/issue/2"},
4 +
  {"number":1,"title":"Set the project up properly before building on it","body":"","author":"andymccall","state":"closed","reason":"completed","labels":[{"name":"bug","colour":"#c62d47","description":""}],"assignees":[],"milestone":null,"comments":1,"locked":true,"pinned":false,"created_ms":1792860000000,"updated_ms":1792875000000,"closed_ms":1792875000000,"url":"https://oxygenhub.io/andymccall/oxygenhub-android/issue/1"}
5 +
]
added app/src/test/resources/fixtures/problem_401.json +1 −0
1 +
{"status":401,"title":"Unauthorized","detail":"unknown or expired token"}
added app/src/test/resources/fixtures/problem_403.json +1 −0
1 +
{"status":403,"title":"Forbidden","detail":"this token lacks the user:read scope"}
added app/src/test/resources/fixtures/repos.json +5 −0
1 +
[
2 +
  {"owner":"andymccall","name":"oxygenflow","description":"Oxygen Flow (flow) is a content-addressed, hermetic execution engine.","default_bookmark":"main","visibility":"public","created_ms":1790593221503,"updated_ms":1792878439999,"clone_url":"https://oxygenhub.io/andymccall/oxygenflow"},
3 +
  {"owner":"andymccall","name":"secret-plans","description":"","default_bookmark":"main","visibility":"private","created_ms":1790000000000,"updated_ms":1792000000000,"clone_url":"https://oxygenhub.io/andymccall/secret-plans","stars":3},
4 +
  {"owner":"doomsdayone","name":"tetris","description":"Falling blocks","default_bookmark":"trunk","visibility":"internal","created_ms":1791000000000,"updated_ms":1792900000000,"clone_url":"https://oxygenhub.io/doomsdayone/tetris"}
5 +
]
added app/src/test/resources/fixtures/user.json +1 −0
1 +
{"username":"andymccall","display_name":"Andy McCall","is_admin":true,"keys":["d89664ce0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5"],"created_ms":1790530000000}
removed app/src/test/java/io/oxygenhub/android/ExampleUnitTest.kt +0 −0
…
17 unchanged lines
modified app/build.gradle.kts +16 −3
…
3 unchanged lines
4 4
plugins {
5 5
    alias(libs.plugins.android.application)
6 6
    alias(libs.plugins.kotlin.compose)
7 +
    alias(libs.plugins.kotlin.serialization)
7 8
}
8 9
9 10
// The Rust core (rust-core/, over oxy-core) is built for the app's ABIs by
…
79 unchanged lines
89 90
}
90 91
91 92
dependencies {
92 -
    implementation("net.java.dev.jna:jna:5.17.0@aar")
93 +
    implementation(libs.jna) { artifact { type = "aar" } }
93 94
    implementation(platform(libs.androidx.compose.bom))
94 95
    implementation(libs.androidx.activity.compose)
95 96
    implementation(libs.androidx.compose.material3)
96 -
    implementation("androidx.compose.material:material-icons-extended")
97 +
    implementation(libs.androidx.compose.material3.navigation.suite)
98 +
    implementation(libs.androidx.compose.material3.adaptive)
99 +
    implementation(libs.androidx.compose.material3.adaptive.layout)
100 +
    implementation(libs.androidx.compose.material3.adaptive.navigation)
101 +
    implementation(libs.androidx.compose.material.icons.extended)
97 102
    implementation(libs.androidx.compose.ui)
98 103
    implementation(libs.androidx.compose.ui.graphics)
99 104
    implementation(libs.androidx.compose.ui.tooling.preview)
100 105
    implementation(libs.androidx.core.ktx)
101 106
    implementation(libs.androidx.lifecycle.runtime.ktx)
107 +
    implementation(libs.androidx.lifecycle.runtime.compose)
108 +
    implementation(libs.androidx.lifecycle.viewmodel.compose)
109 +
    implementation(libs.androidx.navigation.compose)
110 +
    implementation(libs.kotlinx.serialization.json)
111 +
    implementation(libs.kotlinx.coroutines.android)
112 +
    implementation(libs.okhttp)
102 113
    testImplementation(libs.junit)
114 +
    testImplementation(libs.kotlinx.coroutines.test)
115 +
    testImplementation(libs.okhttp.mockwebserver)
103 116
    androidTestImplementation(platform(libs.androidx.compose.bom))
104 117
    androidTestImplementation(libs.androidx.compose.ui.test.junit4)
105 118
    androidTestImplementation(libs.androidx.espresso.core)
106 119
    androidTestImplementation(libs.androidx.junit)
107 120
    debugImplementation(libs.androidx.compose.ui.test.manifest)
108 121
    debugImplementation(libs.androidx.compose.ui.tooling)
109 -
}
122 +
}
modified app/src/main/AndroidManifest.xml +3 −1
…
1 unchanged lines
2 2
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
3 3
    xmlns:tools="http://schemas.android.com/tools">
4 4
5 +
    <uses-permission android:name="android.permission.INTERNET" />
6 +
5 7
    <application
8 +
        android:name=".OxygenHubApp"
6 9
        android:allowBackup="true"
7 10
        android:dataExtractionRules="@xml/data_extraction_rules"
8 11
        android:fullBackupContent="@xml/backup_rules"
…
5 unchanged lines
14 17
        <activity
15 18
            android:name=".MainActivity"
16 19
            android:exported="true"
17 -
            android:label="@string/app_name"
18 20
            android:theme="@style/Theme.OxygenHub"
19 21
            android:windowSoftInputMode="adjustResize">
20 22
            <intent-filter>
…
8 unchanged lines
modified app/src/main/res/xml/backup_rules.xml +7 −12
1 -
<?xml version="1.0" encoding="utf-8"?><!--
2 -
   Sample backup rules file; uncomment and customize as necessary.
3 -
   See https://developer.android.com/guide/topics/data/autobackup
4 -
   for details.
5 -
   Note: This file is ignored for devices older than API 31
6 -
   See https://developer.android.com/about/versions/12/backup-restore
7 -
-->
1 +
<?xml version="1.0" encoding="utf-8"?>
2 +
<!-- Backups (Android 11 and older read this file; the app's minimum is 12,
3 +
     so it mirrors data_extraction_rules.xml). The session's preferences hold
4 +
     a token sealed with a Keystore key that never leaves the phone: a copy
5 +
     could not be opened elsewhere, so it is not backed up. -->
8 6
<full-backup-content>
9 -
    <!--
10 -
   <include domain="sharedpref" path="."/>
11 -
   <exclude domain="sharedpref" path="device.xml"/>
12 -
-->
13 -
</full-backup-content>
7 +
    <exclude domain="sharedpref" path="session.xml" />
8 +
</full-backup-content>
modified app/src/main/res/xml/data_extraction_rules.xml +7 −14
1 -
<?xml version="1.0" encoding="utf-8"?><!--
2 -
   Sample data extraction rules file; uncomment and customize as necessary.
3 -
   See https://developer.android.com/about/versions/12/backup-restore#xml-changes
4 -
   for details.
5 -
-->
1 +
<?xml version="1.0" encoding="utf-8"?>
2 +
<!-- What cloud backup and device transfer may copy. The session (a token
3 +
     sealed with a Keystore key that never leaves the phone) stays behind:
4 +
     on another phone the user signs in again. -->
6 5
<data-extraction-rules>
7 6
    <cloud-backup>
8 -
        <!-- TODO: Use <include> and <exclude> to control what is backed up.
9 -
        <include .../>
10 -
        <exclude .../>
11 -
        -->
7 +
        <exclude domain="sharedpref" path="session.xml" />
12 8
    </cloud-backup>
13 -
    <!--
14 9
    <device-transfer>
15 -
        <include .../>
16 -
        <exclude .../>
10 +
        <exclude domain="sharedpref" path="session.xml" />
17 11
    </device-transfer>
18 -
    -->
19 -
</data-extraction-rules>
12 +
</data-extraction-rules>
modified build.gradle.kts +1 −0
…
1 unchanged lines
2 2
plugins {
3 3
    alias(libs.plugins.android.application) apply false
4 4
    alias(libs.plugins.kotlin.compose) apply false
5 +
    alias(libs.plugins.kotlin.serialization) apply false
5 6
}
modified gradle/libs.versions.toml +25 −5
1 1
[versions]
2 2
agp = "9.4.1"
3 -
coreKtx = "1.10.1"
3 +
coreKtx = "1.16.0"
4 4
junit = "4.13.2"
5 5
junitVersion = "1.1.5"
6 6
espressoCore = "3.5.1"
7 -
lifecycleRuntimeKtx = "2.6.1"
8 -
activityCompose = "1.8.0"
7 +
lifecycle = "2.9.4"
8 +
activityCompose = "1.10.1"
9 9
kotlin = "2.2.10"
10 10
composeBom = "2026.02.01"
11 +
navigation = "2.9.5"
12 +
adaptive = "1.2.0"
13 +
kotlinxSerialization = "1.9.0"
14 +
coroutines = "1.10.2"
15 +
okhttp = "4.12.0"
16 +
jna = "5.17.0"
11 17
12 18
[libraries]
13 19
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
14 20
junit = { group = "junit", name = "junit", version.ref = "junit" }
15 21
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
16 22
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
17 -
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
23 +
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
24 +
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
25 +
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
18 26
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
27 +
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
19 28
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
20 29
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
21 30
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
…
2 unchanged lines
24 33
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
25 34
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
26 35
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
36 +
androidx-compose-material3-navigation-suite = { group = "androidx.compose.material3", name = "material3-adaptive-navigation-suite" }
37 +
androidx-compose-material3-adaptive = { group = "androidx.compose.material3.adaptive", name = "adaptive", version.ref = "adaptive" }
38 +
androidx-compose-material3-adaptive-layout = { group = "androidx.compose.material3.adaptive", name = "adaptive-layout", version.ref = "adaptive" }
39 +
androidx-compose-material3-adaptive-navigation = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation", version.ref = "adaptive" }
40 +
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
41 +
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
42 +
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
43 +
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
44 +
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
45 +
jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" }
46 +
okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" }
27 47
28 48
[plugins]
29 49
android-application = { id = "com.android.application", version.ref = "agp" }
30 50
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
31 -
51 +
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }