-
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 v0.10.0.
- 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 bumps a wire version, 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"))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
}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)
// 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
}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.
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
When the server publishes a new minor:
- Pull this repo, copy
shared/over yourshared/module. - Build the Android module — any wire incompatibility surfaces as a missing class / field.
- Inspect
CHANGELOG.mdfor the "Wire change:" line. IfNo, you can ship right away. IfYes, add the new screens / API calls for the new features.