# X4.24 — Android Print Agent: Device API Contract Audit

Date: 2026-10-07 · Read-only audit against repository source (no server change made).

## Contract Table

| Area | Existing Contract | Android Reuse | Change Required |
|---|---|---|---|
| Authentication | Device bearer token (`Authorization: Bearer <raw token>`), token accepted ONLY via header; server stores SHA-256 hash only; AKTIF status required for claim; `DeviceService::authenticate` updates `last_seen_at` | YES — identical | NO |
| Device ID | `devices` row (code/name/branch/type); id returned by `/api/v1/device/auth` | YES | NO |
| Branch binding | Server derives branch from device registration; client-supplied branch ignored (`DeviceApiController::claimJob`) | YES | NO |
| Destination | Server maps device `type` → destination: `PRINTER_KITCHEN` → KITCHEN, `KITCHEN_PRINT_BRIDGE` → KITCHEN, `PRINTER_KASIR` → RECEIPT | YES — register Android as type `PRINTER_KITCHEN` | NO |
| Claim | `POST /api/v1/device/print-jobs/claim` (auth via token, no CSRF, 64KB body guard, 1s per-device rate limit on live leases) | YES | NO |
| Claim token | Server issues random `claim_token` per claim generation; result accepted ONLY with matching token; late result → `STALE_CLAIM` 409 | YES | NO |
| Lease | `lease_expires_at` = claim time + 90s (fixed); expired PROCESSING jobs become re-claimable (crash recovery) | YES | NO |
| Payload | `print_jobs.payload TEXT` = plain text UTF-8 lines joined `\n` (kitchen batch / voucher slip); renderer is client-side | YES | NO |
| ACK success | `POST /api/v1/device/print-jobs/{id}/result` body `{"claim_token":"…","result":"SUCCESS"}`; PROCESSING+token match → PRINTED (+printed_at); repeated ACK for PRINTED job → idempotent 200 | YES | NO |
| ACK failure | Same endpoint, `result=FAILURE`, `error_message` REQUIRED (≤255 chars) → status FAILED; re-claimable while `attempt_count < 3` | YES | NO |
| Retry | Server-bounded: eligibility PENDING / PROCESSING-lease-expired / FAILED with attempts < 3; attempt_count increments per claim generation | YES (client mirrors backoff) | NO |
| Reprint | `print_jobs.kind`/`reprint_of_job_id` (orders) — data model; voucher reprint = new job same payload; no reprint UI yet (X5 note) | YES | NO |

## Explicit Answer

> **Apakah Android Print Agent dapat menggunakan existing Device API tanpa perubahan server?**

**YA — tanpa perubahan server apa pun.** Evidence:
- Seluruh alur claim/lease/ACK adalah HTTP+JSON murni (`DeviceApiController::claimJob`/`reportJobResult`, `PrintJobService::claim`/`reportResult`) — tidak ada satu pun asumsi Windows di server.
- Satu-satunya lapisan Windows-specific berada di klien (PowerShell PrintDocument/DPAPI/scheduler) — tidak menyentuh kontrak.
- Tipe device `PRINTER_KITCHEN` sudah ada di ENUM `devices.type` (migration 001) dan dipetakan ke KITCHEN di `DeviceApiController::DESTINATION_BY_TYPE`.
- Register device baru (UI Perangkat existing, permission `device.create`/`device.update`) + generate token (`/devices/{id}/token`) — mekanisme operasional sudah dipakai untuk bridge KASIR (X4.18-F1-R1).

## Endpoint Reference (from source)

| Endpoint | Method | Auth | Body / Response |
|---|---|---|---|
| `/api/v1/device/auth` | POST | Bearer | `{device_id, device_code, device_name, device_type, branch_id, branch_code, branch_name}` |
| `/api/v1/device/heartbeat` | POST | Bearer | `{status:"ok", last_seen_at}` |
| `/api/v1/device/print-jobs/claim` | POST | Bearer (AKTIF only) | 204 = no job · 200 = `{job_id, claim_token, destination, payload, attempt_count, lease_expires_at, server_time}` · 401/403/429/5xx |
| `/api/v1/device/print-jobs/{id}/result` | POST | Bearer (resolveByToken — result allowed even if device deactivated mid-claim) | `{claim_token, result: SUCCESS\|FAILURE, error_message?}` → `{status, idempotent}` · 409 STALE_CLAIM / INVALID_STATE · 422 INVALID_RESULT |

## Windows Bridge Behavioral Reference (yang harus dipertahankan Android)

| Behavior | Value | Source |
|---|---|---|
| Polling interval | 3s (idle) | `poll_interval_seconds` |
| Request timeout | 10s | `request_timeout_seconds` |
| Backoff on error | ×2, cap 30s; reset to 3s setelah respons HTTP apa pun | `bridge-core.ps1` loop |
| Claim handling | 204/200-kosong = IDLE (silent); job harus punya `job_id/claim_token/destination/payload/lease_expires_at` | `Get-NextJob` |
| Lease awareness | Cetak + ACK sebelum 90s; TIDAK ada re-claim manual | server lease |
| Print success criteria | spooler menerima tanpa exception = transport-level success (BUKAN konfirmasi kertas) | README + `Send-BridgePrint` |
| Print failure | exception → ACK FAILURE + kategori error | catch block |
| ACK behavior | SUCCESS/FAILURE + retry ACK maks 3× (1s delay) dengan claim_token SAMA; STALE_CLAIM → berhenti tanpa cetak ulang | `Send-JobResult` |
| Restart | exit bersih (STOP_AUTH/STOP_TRANSPORT 6× http=0) → scheduler restart | loop |
| Token | DPAPI (CurrentUser) | `Get-BridgeToken` |
| Logging | satu baris per event, tanpa token/header | `Write-BridgeLog` |

## Encoding Evidence (payload)

Payload produksi terbukti berisi: ASCII, angka, `Rp`, `·` (middle dot — `Catatan: Pedas · Campur`), nama produk Indonesia (`Sate Gajah Gembul`, `Kambing Muda`, `Maranggi`), `-`/`×`. UTF-8 di sisi server; printer thermal generik umumnya CP437/CP850 → Android renderer WAJIB punya strategi encoding eksplisit (CP437 mapping default + mode UTF-8 opsional), tidak boleh diam-diam merusak karakter.

## Coexistence (Windows + Android)

Kedua tipe device (PRINTER_KITCHEN / KITCHEN_PRINT_BRIDGE) memetakan ke KITCHEN yang sama. Server menjamin **tanpa double-claim per job** (FIFO `FOR UPDATE`), tetapi **TIDAK** menjamin semua job ke satu printer — dua agent aktif akan MEMBAGI job. Prosedur switchover wajib: nonaktifkan device lawan (`status NONAKTIF` → 401 → agent lawan berhenti sendiri). Tidak ada perubahan server yang diperlukan.

## Server Change Gate

**SERVER CHANGE REQUIRED: NO** — kontrak existing mencukupi; tidak ada proposal perubahan server.
