Skip to content

Android Client Guide

siamakerlab edited this page May 23, 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.10.0.

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"))

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
}

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.

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