-
Notifications
You must be signed in to change notification settings - Fork 1
Android Client Guide
Reference playbook for building the vibe-coder-android companion app
(or any third-party Kotlin/Java client) against vibe-coder-server.
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.
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.
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.
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.
- 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
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.
suspend fun isInitialSetup(): Boolean =
api.setupStatus().adminExists.not()If setup is required, show a setup form; otherwise login form.
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)
}
}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.
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"))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.
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.
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.
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/logsHistory page /chat/history exists in the browser; an Android "history"
tab uses the same conversation_turns endpoint
(/api/projects/__scratch__/history).
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).
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.
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
}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.
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()
}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>
}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
}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.
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.
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.
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.
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
}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
}
}
}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…") }
}
}
}// 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",
))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 inAWAITING_CODE -
too_large(413) — credentials file > 64 KB -
expired(400) — uploaded credentials already pastexpiresAt
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}/qualitytab is server-side and not on the client. -
quality/lintis a long synchronous call; wrap the UI in a loading state (the server runs it onDispatchers.IO).quality/fixresults 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).
To pick up server wire changes:
- Copy
shared/from this repo over yourshared/module. - Build the Android module — any wire incompatibility surfaces as a missing class / field.
- Add the new screens / API calls for any new features.
-
v1.118.0 / v1.119.0 — new JSON endpoints (additive):
ApiPath.buildRelease/buildBundle;ApiPath.ARCHIVES+projectArchive/archiveRestore/archiveDelete/archiveDownload;ApiPath.qualityLint/qualityFix. New DTOsArchivedProjectDto,LintIssueDto/LintResultDto/QualityFixRequestDto/QualityFixResponseDto. Also wire for console interrupt (ApiPath.claudeConsoleInterrupt) +WsFrame.ConsoleContextUsage- busy
statevocabulary"waiting"/"error"(v1.106.x/v1.112.0). Mirrored in vibe-coder-android v0.36.0–v0.38.0.
- busy
-
v1.88.0 —
NotificationKindgainedCLAUDE_STOPPED = "claude.stopped"andCLAUDE_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 emitsclaude.turn_donefor completed console turns.