Skip to content

Android Client Guide

Sia edited this page Jun 9, 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.

Two-factor authentication on the Android side

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

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

ClaudeStatusDto 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

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 changes a wire shape, 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

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>

    /** 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.

Prompt templates

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 the minimal Android client — let users manage the library in the browser at /prompts.

General Chat — project-less conversation

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; an Android "history" tab uses the same conversation_turns endpoint (/api/projects/__scratch__/history).

Memos — global / per-project (v1.91.0+)

Free-form memos, separate from the turn-inline userMemo field on HistoryTurnDto. projectId == null → global (show on every project screen); non-null → that project only. The web UI shows them in a sidebar /memos card grid and in the project console rail (prompt-history bottom). On Android, list with ?projectId= to mirror the rail set.

interface MemoApi {
    // all memos (or global + a project's with ?projectId=)
    @GET(ApiPath.MEMOS)
    suspend fun list(@Query("projectId") projectId: String? = null): MemoListResponseDto

    @POST(ApiPath.MEMOS)
    suspend fun create(@Body body: MemoCreateRequestDto): MemoDto

    @PUT  // ApiPath.memo(memoId) is dynamic
    suspend fun update(@Url url: String, @Body body: MemoUpdateRequestDto): MemoDto

    @DELETE
    suspend fun delete(@Url url: String): MemoMutationAckDto
}

MemoUpdateRequestDto.keepScope = true (default) updates content only; set false to move the memo to projectId (null = global). All four DTOs (MemoDto, MemoCreateRequestDto, MemoUpdateRequestDto, MemoListResponseDto, MemoMutationAckDto) and the ApiPath.MEMOS / ApiPath.memo(id) constants ship in shared/ — copy the same file into the Android repo's shared/ (see the CHANGELOG sync note below).

File tree + viewer

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")
    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 — The file tree / viewer is primarily a browser SSR feature; the JSON variants (/api/projects/{id}/files/tree, /api/projects/{id}/files/view) back the read-only mobile viewer.

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)

    // 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

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

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

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

RegisterProjectRequestDto.templateId: String? — 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 default empty scaffold.

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

Release/AAB build, Quality (Lint), Archive (v1.118.0 / v1.119.0)

interface BuildApi {
    @POST  // ApiPath.buildDebug(id)
    suspend fun buildDebug(@Url url: String): BuildDto
    @POST  // ApiPath.buildRelease(id) — assembleRelease, keystore-signed; 409 keystore_required
    suspend fun buildRelease(@Url url: String): BuildDto
    @POST  // ApiPath.buildBundle(id) — bundleRelease (.aab); 409 keystore_required
    suspend fun buildBundle(@Url url: String): BuildDto
    // ApiPath.playUpload(id) — trigger Google Play upload (sends a console prompt).
    // body PlayUploadRequestDto {aabPath?, track?, releaseNotes?} → StoreUploadResponseDto. (v1.121.0)
    @POST
    suspend fun playUpload(@Url url: String, @Body req: PlayUploadRequestDto): StoreUploadResponseDto
}

interface QualityApi {
    // ApiPath.qualityLint(id) — runs :module:lintDebug, returns LintResultDto. Synchronous
    // (tens of seconds–minutes) — show a loading state. Emulator NOT required.
    @POST
    suspend fun lint(@Url url: String, @Query("module") module: String = "app"): LintResultDto
    // ApiPath.qualityFix(id) — send selected lint issues to the console (Claude) to fix.
    @POST
    suspend fun fix(@Url url: String, @Body req: QualityFixRequestDto): QualityFixResponseDto
}

interface ArchiveApi {
    @GET(ApiPath.ARCHIVES)
    suspend fun list(): List<ArchivedProjectDto>
    @POST  // ApiPath.projectArchive(id) — idle-guarded; 409 project_busy while running
    suspend fun archive(@Url url: String): ArchivedProjectDto
    @POST  // ApiPath.archiveRestore(aid)
    suspend fun restore(@Url url: String)
    @DELETE  // ApiPath.archiveDelete(aid)
    suspend fun delete(@Url url: String)
}
  • Quality exposes Lint only — the instrumented-test (emulator) half of the server's /projects/{id}/quality tab is server-side and not on the client.
  • quality/lint is a long synchronous call; wrap the UI in a loading state (the server runs it on Dispatchers.IO). quality/fix results flow over the console WebSocket, so route the user to the console after a fix request.
  • Archive create is reachable from a per-project overflow; restore/delete from a dedicated "Archived projects" screen. 409 project_busy → surface a friendly "project is busy" message (use the shared error-mapping helper).

Keeping shared/ in sync

To pick up server wire changes:

  1. Copy shared/ from this repo over your shared/ module.
  2. Build the Android module — any wire incompatibility surfaces as a missing class / field.
  3. Add the new screens / API calls for any new features.

CHANGELOG sync checklist

  • v1.118.0 / v1.119.0 — new JSON endpoints (additive): ApiPath.buildRelease/ buildBundle; ApiPath.ARCHIVES + projectArchive/archiveRestore/archiveDelete/ archiveDownload; ApiPath.qualityLint/qualityFix. New DTOs ArchivedProjectDto, LintIssueDto/LintResultDto/QualityFixRequestDto/QualityFixResponseDto. Also wire for console interrupt (ApiPath.claudeConsoleInterrupt) + WsFrame.ConsoleContextUsage
    • busy state vocabulary "waiting"/"error" (v1.106.x/v1.112.0). Mirrored in vibe-coder-android v0.36.0–v0.38.0.
  • v1.88.0NotificationKind gained CLAUDE_STOPPED = "claude.stopped" and CLAUDE_ERROR = "claude.error" (additive constants — unknown kinds should render generically, no breaking change). ApiPath.NOTIFICATIONS_ACK_ALL = "/api/notifications/ack-all" was added (POST, acknowledges all unread). The server now also emits claude.turn_done for completed console turns.

Clone this wiki locally