Skip to content

Android Client Guide

wody edited this page May 24, 2026 · 12 revisions

Android Client Guide

Reference playbook for building the vibe-coder-android companion app (or any third-party Kotlin/Java client) against vibe-coder-server v0.29.0.

Wire deltas since v0.10.0 baseline

  • v0.13.0 — claudeConsoleCancel(projectId) + Prompt Template GET
  • v0.16.0 — Conversation history GET (per-project + scratch)
  • v0.18.0 — gitCommit(projectId) + templateId field on register
  • v0.19.0 — Emulator diagnostics (SSR only; JSON API on roadmap)
  • v0.20.0 — PROMPT_TEMPLATES constant + PromptTemplateDto promoted to shared/ (server response shape stable since v0.13.0)
  • v0.21.0 — ClaudeStatusDto gains usagePercent: Int? and resetAt: String? (default null, additive)
  • v0.26.0 — LoginRequestDto.totpCode: String? (additive). 2FA users get 401 totp_required until field is supplied. invalid_totp error code on bad code.
  • v0.22.0 / v0.23.0 / v0.24.0 / v0.28.0 / v0.29.0 are server-side SSR only — no client wire change.
  • v0.30.0 — global /history search, build history chart, keyboard shortcuts (all SSR/static)
  • v0.31.0 — GET /api/projects/{id}/claude/prompt-suggestions?prefix=… (server-only; no DTO in shared/)
  • v0.32.0 — /projects/{id}/deps, /env-files, /logs (all SSR)
  • v0.33.0 — POST /api/webhooks/build/{id} external trigger (admin-auth-free). Android client typically doesn't call this — it's meant for CI/CD systems.
  • v0.34.0 — /backup SSR; bundled cli/vibe bash MVP

Two-factor authentication on the Android side (v0.26.0+)

If the user has TOTP enabled, the login POST returns 401 {code:"totp_required"}. Treat it as a two-step flow:

suspend fun login(username: String, password: String): LoginStep {
    return try {
        val res = api.login(LoginRequestDto(username, password, deviceName = "android"))
        LoginStep.Success(res)
    } catch (e: HttpException) {
        val body = e.response()?.errorBody()?.string()
        val err = runCatching { Json.decodeFromString<ApiErrorDto>(body!!) }.getOrNull()
        when (err?.code) {
            "totp_required" -> LoginStep.NeedTotp(username, password)
            "invalid_totp"  -> LoginStep.WrongTotp(err.message)
            else            -> LoginStep.Failed(err?.message ?: "unknown")
        }
    }
}

suspend fun loginWithTotp(username: String, password: String, code: String): LoginStep =
    try {
        val res = api.login(LoginRequestDto(username, password, "android", totpCode = code))
        LoginStep.Success(res)
    } catch (e: HttpException) { /* same handling */ }

UI: on NeedTotp push a small screen with a 6-digit code field + hidden username/password preserved; on submit call loginWithTotp.

Session timeout (v0.26.0+)

Bearer tokens auto-expire after security.sessionIdleTimeoutMinutes (default 30) of inactivity. Any API call after that returns 401. Treat 401 the same as on app launch: drop the stored token + redirect to the Connect screen. No special wire signal — just 401.

Claude usage on the Android side (v0.21.0+)

ClaudeStatusDto now exposes usagePercent and resetAt. Surface them as a small chip in the console header (parallel to the existing model chip):

if (status.usagePercent != null) {
    val color = when {
        status.usagePercent!! >= 95 -> Color.Red
        status.usagePercent!! >= 80 -> Color.Yellow
        else -> Color.Green
    }
    AssistChip(
        onClick = {},
        label = { Text("${status.usagePercent}% used") },
        colors = AssistChipDefaults.assistChipColors(containerColor = color.copy(alpha = 0.15f)),
    )
}

Server already does the threshold alert via email + webhook — no need to duplicate that on the client.

Prompt suggestions (v0.31.0+)

interface PromptSuggestionApi {
    @GET("/api/projects/{id}/claude/prompt-suggestions")
    suspend fun suggest(
        @Path("id") id: String,
        @Query("prefix") prefix: String,
        @Query("limit") limit: Int = 8,
    ): SuggestionsResponse
}

@Serializable
data class SuggestionsResponse(val suggestions: List<String>)

UI: debounce 300 ms on the prompt textarea, call after the user typed ≥ 2 chars, show the result as an inline dropdown below the input. Tapping a suggestion replaces the textarea content. The server caches 60 s in-memory so rapid keystrokes don't hit the DB on every char.

Recommended stack

  • HTTP: Retrofit 2 + OkHttp 5
  • JSON: kotlinx-serialization (matches server @Serializable)
  • WebSocket: OkHttp 's built-in WebSocket
  • Coroutines + Flow for streaming
  • Hilt / Koin for DI

Shared wire module

Copy shared/ from this repo verbatim into your Android project. Both sides compile the same ApiPath constants and @Serializable DTOs, so wire changes are caught at compile time.

android-app/
└── shared/                  ← exact copy of server-side shared/
    └── src/main/kotlin/com/siamakerlab/vibecoder/shared/
        ├── ApiPath.kt
        ├── ws/WsFrame.kt
        └── dto/Dtos.kt

When the server bumps a wire version, sync this directory in lockstep.

Auth flow

First-launch detection

suspend fun isInitialSetup(): Boolean =
    api.setupStatus().adminExists.not()

If setup is required, show a setup form; otherwise login form.

Login + token persistence

class AuthRepository(
    private val api: AuthApi,
    private val tokenStore: TokenDataStore,   // DataStore preferences
) {
    suspend fun login(username: String, password: String): Result<Unit> = runCatching {
        val res = api.login(LoginRequestDto(username, password, deviceName = "android"))
        tokenStore.save(res.token, res.username, res.serverName)
    }
}

Bearer interceptor

class BearerInterceptor(private val store: TokenDataStore) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val req = chain.request()
        val token = runBlocking { store.token() }
        return if (token != null) {
            chain.proceed(req.newBuilder()
                .header(ApiHeader.AUTHORIZATION, ApiHeader.BEARER_PREFIX + token)
                .build())
        } else chain.proceed(req)
    }
}

Hook into OkHttp and Retrofit uses it for every call.

Retrofit service definitions

Projects

interface ProjectsApi {
    @GET(ApiPath.PROJECTS)
    suspend fun list(): List<ProjectDto>

    @POST(ApiPath.PROJECTS_REGISTER)
    suspend fun register(@Body body: RegisterProjectRequestDto): ProjectDto

    @GET  // ApiPath.project(id) is dynamic
    suspend fun get(@Url url: String): ProjectDto
}

// Caller usage
val p = api.get(ApiPath.project("my-app"))

Optional keystore on register

RegisterProjectRequestDto.keystore: KeystoreRequestDto? is the only path to keystore generation — the admin SSR project-create form omits the field entirely, so this is effectively a mobile-only / JSON-API-only feature.

projectsApi.register(RegisterProjectRequestDto(
    projectId   = "my-app",
    appName     = "My App",
    packageName = "com.siamakerlab.myapp",
    keystore    = KeystoreRequestDto(
        alias        = "myapp",
        password     = userInput,          // ≥ 6 chars; UI should validate stricter
        dname        = null,                // null → Sia Makerlab default DName
        validityDays = 36500,
    ),
))

Output files land in <workspace>/.vibecoder/keystores/<projectId>/{<projectId>.keystore, <projectId>-keystore.properties} (outside the project source folder).

Don't ship a real release keystore through this path. The current server invokes keytool -storepass <plain> so the password is briefly exposed in ps -ef. Acceptable for throwaway debug keystores; for release signing, have the user generate the keystore on their machine and drop the two files into the keystores dir via SSH / docker cp.

Claude console + turn cancel (v0.13.0+)

interface ConsoleApi {
    @POST("/api/projects/{id}/claude/console/prompt")
    suspend fun prompt(@Path("id") id: String, @Body req: PromptRequestDto): Response<PromptAcceptedDto>

    @POST("/api/projects/{id}/claude/console/new")
    suspend fun newSession(@Path("id") id: String): Response<Unit>

    /** v0.13.0+. Server SIGTERM-s the running Claude child; session-id is
     *  preserved so the next prompt resumes the same conversation via --resume. */
    @POST("/api/projects/{id}/claude/console/cancel")
    suspend fun cancelTurn(@Path("id") id: String): Response<Unit>

    @GET("/api/projects/{id}/claude/status")
    suspend fun status(@Path("id") id: String): ClaudeStatusDto
}

UI integration: while a turn is in flight (the prompt POST returned 202 Accepted), show a red ■ stop button next to the send button. On click call cancelTurn(id). Hide it as soon as you receive console_done or console_system{code = "turn_cancelled" | "process_crashed" | "idle_terminated"} over the WebSocket — the same logic the browser uses.

Use ApiPath.claudeConsoleCancel(projectId) from the shared module so the constant stays in lock-step with the server. The constant lives in vibe-coder-android shared/ since v0.6.11.

Prompt templates (v0.13.0+)

The server has a JSON-backed prompt template library; the browser console exposes a ▼ dropdown that lets the user paste a saved prompt into the input. Equivalent UX on Android: a QuickActionSheet (bottom sheet) or a "Templates" tab on the console screen.

interface PromptApi {
    @GET("/api/prompt-templates")
    suspend fun list(): PromptListDto
}

@Serializable
data class PromptListDto(val templates: List<PromptTemplate>)

@Serializable
data class PromptTemplate(
    val id: String,
    val title: String,
    val category: String,         // e.g. "Android", "Debug", "Refactor"
    val body: String,
    val createdAt: String,
    val updatedAt: String,
)

UI: group by category, sort by title. On select, prepend or append body to the prompt textarea (don't auto-send — let the user review/edit). CRUD UI is not in scope for v1 of the Android client — let users manage the library in the browser at /prompts.

General Chat — project-less conversation (v0.13.0+)

The server exposes /chat as a sibling to /projects/{id}/console. It runs a Claude session against a synthetic __scratch__ workspace (<workspace>/__scratch__/) so the user can ask quick questions without a project context. Multi-turn (--resume) just like a project console.

Android UX option A — 5th tab in the BottomNav: "Chat" sits next to Projects / Environment / etc. Tap → opens a console screen wired with projectId = "__scratch__". All existing console screens / WS code can be reused as-is — the project id is just __scratch__.

Android UX option B — Home entry: Dashboard tile "General Chat →" that launches the same console screen. Less prominent but doesn't add a tab.

// Reuse the existing console screen with this id:
const val SCRATCH_PROJECT_ID = "__scratch__"

// No /api/chat/... endpoints — all console traffic goes through the
// same project endpoints with id = __scratch__.
consoleApi.prompt(SCRATCH_PROJECT_ID, PromptRequestDto(text))
consoleApi.cancelTurn(SCRATCH_PROJECT_ID)
// WS:  wss://<host>/ws/projects/__scratch__/console/logs

History page /chat/history exists in the browser; if the Android client adds a "history" tab later it uses the same conversation_turns endpoint (planned /api/projects/__scratch__/history).

File tree + viewer (v0.13.0+)

Server side: /projects/{id}/tree lists files, /projects/{id}/view?path= opens read-only with highlight.js / edit toggle (textarea). The mobile form factor makes edit mode less valuable — recommend read-only viewer only for the Android client:

interface ProjectFilesApi {
    @GET("/api/projects/{id}/files/tree")           // planned — see note below
    suspend fun tree(@Path("id") id: String, @Query("path") path: String?): TreeListingDto

    @GET("/api/projects/{id}/files/view")
    suspend fun view(@Path("id") id: String, @Query("path") path: String): FileViewDto
}

Note (v0.16.0) — REST endpoints for the file tree / viewer are not yet exposed (browser-only). The browser hits the SSR routes directly. A JSON variant (/api/projects/{id}/files/tree, /api/projects/{id}/files/view) is on Roadmap for the next minor. For now, defer this Android feature or scrape the SSR HTML (not recommended).

UI: file tree as a LazyColumn with breadcrumbs. View screen renders the text in a Text(...) composable with a monospace font. Syntax highlighting on Android requires a 3rd-party library (e.g. com.github.amrdeveloper:codeview) — defer unless requested.

Safety guards (server-enforced): 1 MB cap, binary / symlink rejected with 415 / 403. Just surface the error toast.

Environment setup

interface EnvSetupApi {
    @GET(ApiPath.ENV_SETUP_COMPONENTS)
    suspend fun listComponents(): EnvSetupComponentsResponseDto

    @POST(ApiPath.ENV_SETUP_INSTALL_ALL)
    suspend fun installAll(): EnvSetupTaskDto

    @POST  // ApiPath.envSetupInstall(id) dynamic
    suspend fun installOne(@Url url: String): EnvSetupTaskDto
}

Tracking install progress per component

ComponentStateDto has no installingTaskId field — its full shape is id / displayName / description / sizeHint / status / message / installable. The mapping between a component and its in-flight install is client state, not server state.

Pattern:

class EnvSetupViewModel(
    private val api: EnvSetupApi,
    private val taskStream: TaskLogStream,
) : ViewModel() {

    // componentId -> in-flight taskId. UI reads this to show per-card spinners.
    private val inflight = MutableStateFlow<Map<String, String>>(emptyMap())

    fun install(componentId: String) {
        viewModelScope.launch {
            val res = api.installOne(ApiPath.envSetupInstall(componentId))
            inflight.update { it + (componentId to res.taskId) }
            taskStream.subscribe(res.taskId).collect { frame ->
                if (frame is WsFrame.Done) {
                    inflight.update { it - componentId }
                    refreshComponents()   // GET /components to pick up new status
                }
            }
        }
    }
}

If the user kills the app mid-install, the next launch has no way to re-attach to the running task — the server still tracks it internally for SSR but the JSON API gives you no handle. Refresh listComponents() on foreground to see the resulting status instead.

Claude auth (3 options)

interface ClaudeAuthApi {
    @Multipart
    @POST(ApiPath.CLAUDE_AUTH_UPLOAD)
    suspend fun upload(@Part part: MultipartBody.Part): ClaudeCredentialsUploadResponseDto

    @POST(ApiPath.CLAUDE_AUTH_API_KEY)
    suspend fun registerApiKey(@Body body: ClaudeApiKeyRequestDto)

    @DELETE(ApiPath.CLAUDE_AUTH_API_KEY_DELETE)
    suspend fun deleteApiKey()
}

Claude semi-automatic web OAuth

interface ClaudeLoginApi {
    @POST(ApiPath.CLAUDE_LOGIN_START)
    suspend fun start(): ClaudeLoginStateDto

    @GET(ApiPath.CLAUDE_LOGIN_STATUS)
    suspend fun status(): Response<ClaudeLoginStateDto>   // 204 if none

    @POST(ApiPath.CLAUDE_LOGIN_SUBMIT)
    suspend fun submit(@Body body: ClaudeLoginSubmitRequestDto): ClaudeLoginStateDto

    @POST(ApiPath.CLAUDE_LOGIN_CANCEL)
    suspend fun cancel(): Response<ClaudeLoginStateDto>
}

MCP catalog

interface McpApi {
    @GET(ApiPath.MCP_CATALOG)
    suspend fun catalog(): McpCatalogResponseDto

    @POST(ApiPath.MCP_INSTALL)
    suspend fun install(@Body body: McpInstallRequestDto): EnvSetupTaskDto

    @POST(ApiPath.MCP_UNREGISTER)
    suspend fun unregister(@Body body: McpUnregisterRequestDto)

    // v0.11.0 — secret file upload (Service Account JSON / Apple .p8 etc.)
    @Multipart
    @POST  // ApiPath.mcpUploadFile(mcpId, fieldKey) is dynamic
    suspend fun uploadConfigFile(
        @Url url: String,
        @Part part: MultipartBody.Part,
    ): McpFileUploadResponseDto
}

File-based MCP install flow (v0.11.0+)

For MCPs whose configFields[].isFile == true (Play Publisher, App Store Connect, Firebase, Google Drive), the user picks a file on the device, the client uploads it, then includes the returned path in the install request:

// 1. Pick a file (Storage Access Framework / content://)
val file: File = ...

// 2. Upload first — get back the absolute path on the server
val part = MultipartBody.Part.createFormData(
    "file", file.name,
    file.asRequestBody("application/octet-stream".toMediaType())
)
val res = mcpApi.uploadConfigFile(
    url = ApiPath.mcpUploadFile("google-play-publisher", "GOOGLE_PLAY_SERVICE_ACCOUNT_JSON"),
    part = part,
)

// 3. Include the path with other configValues
mcpApi.install(McpInstallRequestDto(selections = mapOf(
    "google-play-publisher" to mapOf(
        "GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" to res.path,
        "GOOGLE_PLAY_PACKAGE_NAME" to "com.example.app",
    )
)))

In Compose UI, render configFields with isFile == true as a file picker (rememberLauncherForActivityResult(ActivityResultContracts.GetContent())) rather than TextField. Disable the "Install" button until upload finishes and path is non-empty.

Conversation history (v0.16.0+)

GET /api/projects/{id}/history and GET /api/chat/history return the persisted conversation_turns rows for a project or for the General Chat scratch workspace.

interface HistoryApi {
    @GET("/api/projects/{id}/history")
    suspend fun project(
        @Path("id") id: String,
        @Query("limit") limit: Int = 100,
        @Query("sessionId") sessionId: String? = null,
        @Query("before") cursorTurnId: Long? = null,
    ): HistoryPageDto

    @GET("/api/chat/history")
    suspend fun chat(
        @Query("limit") limit: Int = 100,
        @Query("sessionId") sessionId: String? = null,
        @Query("before") cursorTurnId: Long? = null,
    ): HistoryPageDto
}

@Serializable
data class HistoryPageDto(
    val turns: List<HistoryTurnDto>,
    val nextCursor: Long? = null,
)

@Serializable
data class HistoryTurnDto(
    val id: Long,
    val sessionId: String? = null,
    val turnIdx: Int,
    val ts: String,
    val role: String,                 // "user" | "assistant" | "tool" | "system"
    val content: String,
    val toolName: String? = null,
    val toolUseId: String? = null,
    val tokensIn: Int? = null,
    val tokensOut: Int? = null,
)

UI: a "History" tab on the console screen. Render as a reversed LazyColumn (newest at bottom, paginate older into the top with before cursor). Distinguish role with leading chips / colors. Render role=tool rows collapsed with a "▶ tool_use: " header that expands to show JSON.

The shape matches what the WebSocket emits live — you can layer the same renderer over both replay-from-history and live frames.

Git commit + push (v0.18.0+)

POST /api/projects/{id}/git/commit is the one and only Git write API.

interface GitWriteApi {
    @POST(ApiPath.gitCommit(":id"))   // resolve dynamically with @Url
    suspend fun commit(
        @Url url: String,
        @Body body: GitCommitRequestDto,
    ): GitCommitResultDto
}

@Serializable
data class GitCommitRequestDto(
    val message: String,
    val push: Boolean = true,
    val onlyTracked: Boolean = false,
)

@Serializable
data class GitCommitResultDto(
    val committed: Boolean,
    val pushed: Boolean,
    val sha: String? = null,
    val branch: String? = null,
    val errorMessage: String? = null,
)

Caller:

gitApi.commit(
    url = ApiPath.gitCommit("my-app"),
    body = GitCommitRequestDto(message = "feat: settings screen", push = true),
)

UI: a "Commit & push" button on the project screen. Default the message to the project's last Claude turn summary. Disable while a commit is in flight. On committed=true, pushed=false, surface errorMessage and offer a "Retry push" action — re-running the same endpoint with an empty diff just returns committed=false, pushed=true after retrying the push.

Project templating (v0.18.0+)

RegisterProjectRequestDto.templateId: String? (v0.18.0+) — pass one of empty / compose-basic / compose-mvvm-hilt / compose-mvvm-room / wear-os / android-tv to scaffold from a built-in template. Each seeds a starterPrompt consumed by the project's first console turn.

projectsApi.register(RegisterProjectRequestDto(
    projectId = "my-wear-app",
    appName = "My Wear App",
    packageName = "com.siamakerlab.wear",
    templateId = "wear-os",
))

In the project-create UI, render a DropdownMenu or HorizontalUncontainedCarousel of template chips above the source-type radio group. null / "empty" is equivalent to the v0.17 default.

Git integrations

interface GitIntegrationsApi {
    @GET(ApiPath.GIT_INTEGRATIONS)
    suspend fun list(): GitIntegrationsResponseDto

    @POST(ApiPath.GIT_INTEGRATIONS)
    suspend fun register(@Body body: GitTokenRegisterRequestDto)

    @POST(ApiPath.GIT_INTEGRATIONS_DELETE)
    suspend fun delete(@Body body: GitTokenDeleteRequestDto)

    @POST(ApiPath.GIT_INTEGRATIONS_SSH_KEYGEN)
    suspend fun sshKeygen(): GitIntegrationsResponseDto
}

Live install progress (WebSocket Flow)

Wrap a WS subscription as a Flow<WsFrame>:

class TaskLogStream(
    private val client: OkHttpClient,
    private val baseUrl: HttpUrl,
    private val tokenStore: TokenDataStore,
) {
    fun subscribe(taskId: String): Flow<WsFrame> = callbackFlow {
        val url = baseUrl.newBuilder()
            .scheme(if (baseUrl.isHttps) "wss" else "ws")
            .encodedPath(ApiPath.wsEnvSetupLogs(taskId))
            .build()
        val req = Request.Builder().url(url).build()

        val ws = client.newWebSocket(req, object : WebSocketListener() {
            override fun onOpen(ws: WebSocket, resp: Response) {
                val token = runBlocking { tokenStore.token() } ?: error("no token")
                ws.send(Json.encodeToString(WsFrame.serializer(), WsFrame.Auth(token)))
            }
            override fun onMessage(ws: WebSocket, text: String) {
                runCatching { Json.decodeFromString<WsFrame>(text) }
                    .onSuccess { trySend(it) }
            }
            override fun onClosed(ws: WebSocket, code: Int, reason: String) { close() }
            override fun onFailure(ws: WebSocket, t: Throwable, resp: Response?) { close(t) }
        })
        awaitClose { ws.close(1000, "client closed") }
    }
}

UI consumer:

viewModelScope.launch {
    repo.subscribe(taskId).collect { f ->
        when (f) {
            is WsFrame.Log  -> _state.update { it.copy(lines = it.lines + f.message) }
            is WsFrame.Done -> _state.update { it.copy(status = f.status) }
            else -> Unit
        }
    }
}

Three-step Claude web OAuth UX

This is the canonical Compose flow for in-app login:

@Composable
fun ClaudeLoginScreen(vm: ClaudeLoginViewModel) {
    val state by vm.state.collectAsStateWithLifecycle()
    LaunchedEffect(Unit) { vm.refreshStatus() }
    LaunchedEffect(state.state) {
        // poll every second while in progress
        while (state.state in setOf("STARTING","AWAITING_CODE","VERIFYING")) {
            delay(1000); vm.refreshStatus()
        }
    }

    Column {
        when (state.state) {
            null, "IDLE", "DONE", "FAILED", "CANCELED" -> Button(onClick = vm::start) {
                Text("Login via web")
            }
            "STARTING" -> CircularProgressIndicator()
            "AWAITING_CODE" -> {
                Text("Open this URL in a browser, sign in, paste the code below.")
                ClickableUrl(state.url!!)
                var code by remember { mutableStateOf("") }
                OutlinedTextField(value = code, onValueChange = { code = it },
                                  label = { Text("Authorization code") })
                Row {
                    Button(onClick = { vm.submit(code) }) { Text("Submit") }
                    OutlinedButton(onClick = vm::cancel) { Text("Cancel") }
                }
            }
            "VERIFYING" -> { CircularProgressIndicator(); Text("Verifying…") }
        }
    }
}

SSH key + private clone

// 1) Ensure a key exists on the server.
val res = gitApi.sshKeygen()

// 2) Show res.sshPublicKey to the user, with a "Copy" button.
//    They paste it into GitHub/GitLab/Gitea SSH keys.

// 3) Now they can register a project with an SSH URL.
projectsApi.register(RegisterProjectRequestDto(
    projectId = "my-private-app",
    appName = "My Private App",
    packageName = "com.example.private",
    sourceType = "clone",
    cloneUrl = "git@github.com:owner/private-repo.git",
))

Error handling

ApiErrorDto is returned on any 4xx/5xx response. Wrap calls in a generic ApiResult<T>:

sealed class ApiResult<out T> {
    data class Success<T>(val value: T) : ApiResult<T>()
    data class Error(val code: String, val message: String, val httpStatus: Int) : ApiResult<Nothing>()
    data class NetworkFailure(val cause: Throwable) : ApiResult<Nothing>()
}

suspend inline fun <T> apiCall(block: () -> T): ApiResult<T> = try {
    ApiResult.Success(block())
} catch (e: HttpException) {
    val body = e.response()?.errorBody()?.string()
    val dto = runCatching { Json.decodeFromString<ApiErrorDto>(body!!) }.getOrNull()
    ApiResult.Error(dto?.code ?: "http_${e.code()}", dto?.message ?: e.message(), e.code())
} catch (e: IOException) {
    ApiResult.NetworkFailure(e)
}

Common error codes worth handling in UI:

  • manual_install_only — component (e.g. Claude OAuth) needs interactive install; route the user to the right screen.
  • missing_clone_url, bad_url_scheme, target_not_empty, clone_failed
  • in_progress (409) — already-running Claude login session
  • wrong_state (409) — submit code while not in AWAITING_CODE
  • too_large (413) — credentials file > 64 KB
  • expired (400) — uploaded credentials already past expiresAt

CHANGELOG sync checklist

When the server publishes a new minor:

  1. Pull this repo, copy shared/ over your shared/ module.
  2. Build the Android module — any wire incompatibility surfaces as a missing class / field.
  3. Inspect CHANGELOG.md for the "Wire change:" line. If No, you can ship right away. If Yes, add the new screens / API calls for the new features.

Clone this wiki locally