Skip to content

Android Client Guide

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

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.

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.

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