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 +15 −2
| … | 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) |
|
| … | 4 unchanged lines |
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" } |