# Android Questionnaire API Integration This document describes the API contract the Android app can use to fetch questionnaires, fetch translations, and upload completed questionnaire answers. Use the front-controller routes under `/api/...`. The clean route `/api/app_questionnaires` is preferred because it returns the unified response envelope used by the web app and supports `POST` uploads. ## Base Requirements - Base URL: `https:///api` - JSON header: `Content-Type: application/json; charset=UTF-8` - Auth header after login: `Authorization: Bearer ` - All timestamps are integer Unix timestamps. Use seconds unless the server contract is changed. - Successful front-controller responses use: ```json { "ok": true, "data": {} } ``` - Error responses use: ```json { "ok": false, "error": { "code": "ERROR_CODE", "message": "Human readable message" } } ``` Some low-level auth failures can still return a legacy body such as `{"error":"Missing Bearer token"}` or `{"error":"Invalid or expired token"}`. ## Authentication ### Login `POST /api/auth/login` Request: ```json { "username": "coach1", "password": "secret" } ``` Success: ```json { "ok": true, "data": { "token": "64-character-hex-token", "user": "coach1", "role": "coach", "clients": [ { "clientCode": "CLIENT-001" }, { "clientCode": "CLIENT-002" } ] } } ``` For `coach` accounts, `clients` lists client codes currently assigned to that coach (`client.coachID`). Other roles omit this field or return an empty array. If the account must change its password first, the response contains `mustChangePassword: true` and a temporary token. That temporary token cannot access questionnaire endpoints until the password is changed. ### Change password `POST /api/auth/change-password` Headers: ```http Authorization: Bearer ``` The token may be a normal session token or the temporary token returned when `mustChangePassword` is true. Request: ```json { "username": "coach1", "old_password": "temporary-or-current-password", "new_password": "new-secret" } ``` Success: ```json { "ok": true, "data": { "token": "64-character-hex-token", "user": "coach1", "role": "coach" } } ``` The server revokes the previous token and returns a new full session token. `new_password` must be at least 6 characters. Common failures: - `400 MISSING_FIELDS`: required fields missing. - `400 PASSWORD_TOO_SHORT`: new password too short. - `401 INVALID_CREDENTIALS`: old password incorrect. - `403`: invalid, expired, or mismatched token. There is no self-service signup endpoint. Coach accounts are created on the web by administrators. ## Fetch Questionnaires ### Fetch Active Questionnaire List `GET /api/app_questionnaires` Headers: ```http Authorization: Bearer ``` Success: ```json { "ok": true, "data": [ { "id": "questionnaire_1_demographic_information", "name": "demographic information", "showPoints": false, "condition": {} } ] } ``` Fields: - `id`: stable questionnaire identifier. Use this when fetching the questionnaire details and uploading answers. - `name`: display/admin name. - `showPoints`: whether the app may show the points result. - `condition`: JSON condition object. Empty conditions are returned as `{}`. - `categoryKey` (optional): grouping key for the opening screen. The app resolves the section title as `questionnaire_group_` (global app UI strings). Keys in use appear automatically on the website **Translations** page; default German text may come from `data/app_ui_strings.json` (`germanDefaults`). Unused translation rows can be removed from the questionnaires dashboard. Only questionnaires with `state = "active"` are returned, ordered by server `orderIndex`. ### Fetch One Questionnaire `GET /api/app_questionnaires?id=` Example: `GET /api/app_questionnaires?id=questionnaire_1_demographic_information` Success: ```json { "ok": true, "data": { "meta": { "id": "questionnaire_1_demographic_information" }, "questions": [ { "id": "q1", "layout": "radio_question", "question": "consent_instruction", "options": [ { "key": "consent_signed", "nextQuestionId": "q6" }, { "key": "consent_not_signed", "nextQuestionId": "q2" } ], "pointsMap": { "consent_signed": 0, "consent_not_signed": 0 } } ] } } ``` Question fields: - `id`: short question ID used by the Android app and upload payload, for example `q1`. - `layout`: question renderer type, for example `radio_question`, `multi_check_box_question`, `string_spinner`, `value_spinner`, `client_coach_code_question`, or `last_page`. - `question`: translation key or default text for the question. - `options`: answer choices for choice questions and spinner options. - `options[].key`: answer option key. Send this back as `answerOptionKey` during upload. - `options[].nextQuestionId`: optional conditional navigation target. - `pointsMap`: optional map from answer option key to point value. - `textKey`, `textKey1`, `textKey2`, `hint`, `hint1`, `hint2`, `symptoms`, `range`, `constraints`, `minSelection`: optional layout-specific config fields. Important ID rule: the fetch response returns short question IDs such as `q1`. The server resolves these back to full database IDs during upload, so the Android app should upload the same short IDs it received. ## Download Client Answers (restore on device) `GET /api/app_questionnaires?clientCode=&answers=1` Allowed roles: `coach`, `supervisor`, `admin` (coach must be assigned to the client). Headers: ```http Authorization: Bearer ``` Success (encrypted envelope, same as `?clients=1`): ```json { "ok": true, "data": { "encrypted": true, "payload": { "...": "..." } } } ``` Decrypted `data`: ```json { "clientCode": "K008", "questionnaires": [ { "questionnaireID": "questionnaire_2_rhs", "completedAt": 1710000000, "sumPoints": 12, "answers": [ { "questionID": "q1", "answerOptionKey": "consent_signed" }, { "questionID": "languages_spoken", "answerOptionKey": "language_arabic" }, { "questionID": "languages_spoken", "answerOptionKey": "language_english" } ] } ] } ``` Notes: - Returns all `completed_questionnaire` rows for the client. - `answers` mirrors the POST submit shape (short `questionID`, glass-scale symptoms as separate entries, multi-checkbox as multiple rows with the same `questionID`). - On the server, multi-checkbox answers are stored in one `client_answer` row (`freeTextValue` JSON array); export expands them for the app importer. - Use on **client load** to restore answers after upload or on a fresh install. ## Fetch Assigned Clients `GET /api/app_questionnaires?clients=1` Allowed roles: `coach` only. Returns the list of client codes assigned to the authenticated coach. Headers: ```http Authorization: Bearer ``` Success: ```json { "ok": true, "data": { "clients": [ { "clientCode": "CLIENT-001", "completedQuestionnaires": [ { "questionnaireID": "questionnaire_1_demographic_information", "sumPoints": 42, "completedAt": 1714471800 } ] }, { "clientCode": "CLIENT-002", "completedQuestionnaires": [] } ] } } ``` Fields: - `clients`: array of client objects assigned to the coach, ordered alphabetically by `clientCode`. - `clients[].clientCode`: the client's code string. - `clients[].completedQuestionnaires`: array of questionnaires already completed by this client. Empty array if none. - `clients[].completedQuestionnaires[].questionnaireID`: the questionnaire ID, matching the IDs from the questionnaire list endpoint. - `clients[].completedQuestionnaires[].sumPoints`: total points from the last upload for this questionnaire. - `clients[].completedQuestionnaires[].completedAt`: Unix timestamp (seconds) of when the questionnaire was completed, or `null` if not recorded. Common failures: - `401`: missing or invalid token. - `403`: token belongs to a role other than `coach` (supervisor and admin must use the web `/api/clients` endpoint instead). ## Fetch Translations `GET /api/app_questionnaires?translations=1` **Authentication:** not required. The Android app calls this on the login screen before the coach signs in. All other `app_questionnaires` routes still require `Authorization: Bearer `. Success: ```json { "ok": true, "data": { "translations": { "en": { "consent_instruction": "Consent instruction text", "consent_signed": "Consent signed", "client_code": "Client code" }, "de": { "consent_instruction": "Einwilligungstext", "consent_signed": "Einwilligung unterschrieben", "client_code": "Klientencode" } } } } ``` The translation map is grouped by language code. Each language contains a flat key-value map of **app UI strings only** (screens, toasts, auth labels, etc.). Questionnaire question/option text is **not** included here. Android lookup for app UI: 1. Load `translations[activeLanguage]`. 2. Look up the string key (e.g. `save`, `client_code`). 3. If missing, fall back to embedded defaults in the app. ## Questionnaire detail translations `GET /api/app_questionnaires?id=` includes a sibling `translations` object with the same shape (language code → key → text), scoped to that questionnaire only: - question default text (`question` field values) - answer option default text (`options[].key`) - questionnaire config string keys (`textKey`, hints, symptoms, etc.), excluding global app UI catalog keys Android should apply this map while a questionnaire is open (overlay on top of app UI translations), then clear it when leaving the questionnaire. ## Recommended Android Sync Flow 1. On app start (login screen), fetch app UI translations with `GET /api/app_questionnaires?translations=1` (no token) and cache locally. 2. Login with `/api/auth/login` and store the returned token securely (EncryptedSharedPreferences on Android). 3. Fetch the assigned client list with `GET /api/app_questionnaires?clients=1` (coach role only). 4. Re-fetch app UI translations optionally after login (same public endpoint, or authenticated refresh). 6. For each list item, fetch details with `GET /api/app_questionnaires?id=` (includes per-questionnaire `translations`). 7. Cache the client list, app translations, the questionnaire list, and questionnaire details (with embedded translations) locally for offline use. 8. When a questionnaire is completed, upload answers with `POST /api/app_questionnaires` using an **encrypted** JSON envelope (see above). There is currently no version or `updatedAt` field in the app fetch response, so the safest refresh strategy is to re-fetch translations, list, and details at login or app start when network is available. ## Android local data security The Android app encrypts sensitive data **at rest on the device**: - **Answer text** and **client codes** in the local Room database use AES-256-GCM with keys in Android Keystore (ciphertext on disk; plaintext only inside the running app). - **Assigned client list** cache and **session token** use the same encryption / EncryptedSharedPreferences respectively. - **Questionnaire definitions** and UI translations cached from this API are not encrypted (no user answers). Sensitive mobile traffic uses **two layers**: 1. **HTTPS** (TLS) for transport. 2. **Application payload encryption** for patient-related fields: AES-256-CBC with a per-session key derived from the Bearer token via HKDF-SHA256 (`info` = `qdb-aes`), matching the legacy QDB mobile crypto helpers. Encrypted wire format (request or response `data`): ```json { "encrypted": true, "payload": "" } ``` Endpoints using encrypted payloads: | Endpoint | Direction | |----------|-----------| | `POST /api/auth/login` | Response: `clientsPayload` (when coach has clients) | | `GET /api/app_questionnaires?clients=1` | Response: entire `data` object encrypted | | `POST /api/app_questionnaires` | Request body must be encrypted | Plain JSON (no app-layer encryption): `GET ?translations=1`, questionnaire list, questionnaire detail. The server decrypts uploads before writing to the database. The web dashboard reads **server-side** plaintext (RBAC). The Android app also keeps **AES-GCM at rest** on device via Android Keystore (separate from wire encryption). Re-uploading the same `clientCode` + `questionnaireID` updates server rows in place (see upload behavior below). This matches upcoming editable flows on web and mobile. Legacy full-database download endpoints (`downloadFull.php`, encrypted SQLite master file) are **not** used by the current Android app. ## Upload Questionnaire Data `POST /api/app_questionnaires` Allowed roles: `admin`, `supervisor`, `coach`. The authenticated user must be authorized for the submitted `clientCode`. A coach can upload for their own clients. A supervisor can upload for clients of their coaches. An admin can upload for any client. Request: ```json { "questionnaireID": "questionnaire_1_demographic_information", "clientCode": "CLIENT-001", "startedAt": 1714471200, "completedAt": 1714471800, "answers": [ { "questionID": "q1", "answerOptionKey": "consent_signed", "answeredAt": 1714471210 }, { "questionID": "q9", "numericValue": 34, "answeredAt": 1714471300 }, { "questionID": "q12", "freeTextValue": "Free text answer", "answeredAt": 1714471400 } ] } ``` Required top-level fields: - `questionnaireID`: questionnaire ID from the list/detail endpoints. - `clientCode`: existing client code. - `answers`: array of answer objects. Optional top-level fields: - `startedAt`: questionnaire start timestamp. - `completedAt`: questionnaire completion timestamp. Answer fields: - `questionID`: required per useful answer. Use the short question ID returned by the detail endpoint. - `answerOptionKey`: for choice answers. Use `options[].key` from the detail endpoint. - `freeTextValue`: for text answers. - `numericValue`: for numeric/range answers. - `answeredAt`: timestamp for this answer. Current upload behavior: - Unknown or empty `questionID` values are skipped. - Unknown `answerOptionKey` values are stored as no selected option for that question. - Re-uploading the same `clientCode` and `questionID` overwrites the previous answer. - Re-uploading the same `clientCode` and `questionnaireID` overwrites the completed questionnaire status and point sum (supports correction / re-edit workflows). - `sumPoints` is calculated from recognized `answerOptionKey` values. - The current database stores one selected answer option per question. If the Android UI allows multiple selections for a layout, coordinate a server change before uploading multiple option keys for one question. Success: ```json { "ok": true, "data": { "submitted": true, "sumPoints": 0 } } ``` Common failures: - `400 INVALID_BODY`: request body is not valid JSON. - `400 MISSING_FIELDS`: one or more required fields are missing. - `400 INVALID_FIELD`: `answers` is not an array. - `401`: missing token or invalid credentials. - `403`: expired token, password change required, or insufficient role. - `404 NOT_FOUND`: questionnaire does not exist, client does not exist, or user is not authorized for that client. - `405 METHOD_NOT_ALLOWED`: wrong HTTP method. ## Retrofit Shape Example Kotlin data shapes: ```kotlin data class ApiEnvelope( val ok: Boolean, val data: T? = null, val error: ApiError? = null ) data class ApiError( val code: String? = null, val message: String ) data class QuestionnaireListItem( val id: String, val name: String, val showPoints: Boolean, val condition: Map = emptyMap() ) data class QuestionnaireDetail( val meta: QuestionnaireMeta, val questions: List ) data class QuestionnaireMeta( val id: String ) data class Question( val id: String, val layout: String, val question: String, val options: List? = null, val pointsMap: Map? = null, val textKey: String? = null, val textKey1: String? = null, val textKey2: String? = null, val hint: String? = null, val hint1: String? = null, val hint2: String? = null, val symptoms: List? = null, val range: Map? = null, val constraints: Map? = null, val minSelection: Int? = null ) data class QuestionOption( val key: String, val nextQuestionId: String? = null ) data class TranslationsResponse( val translations: Map> ) data class SubmitQuestionnaireRequest( val questionnaireID: String, val clientCode: String, val startedAt: Long? = null, val completedAt: Long? = null, val answers: List ) data class SubmitAnswer( val questionID: String, val answerOptionKey: String? = null, val freeTextValue: String? = null, val numericValue: Double? = null, val answeredAt: Long? = null ) data class SubmitQuestionnaireResponse( val submitted: Boolean, val sumPoints: Int ) data class ClientsResponse( val clients: List ) data class ClientInfo( val clientCode: String, val completedQuestionnaires: List = emptyList() ) data class CompletedQuestionnaire( val questionnaireID: String, val sumPoints: Int, val completedAt: Long? ) ``` Example Retrofit service: ```kotlin interface QuestionnaireApi { @POST("auth/login") suspend fun login(@Body body: LoginRequest): ApiEnvelope @GET("app_questionnaires") suspend fun getAssignedClients( @Header("Authorization") bearerToken: String, @Query("clients") clients: Int = 1 ): ApiEnvelope @GET("app_questionnaires") suspend fun getQuestionnaires( @Header("Authorization") bearerToken: String ): ApiEnvelope> @GET("app_questionnaires") suspend fun getQuestionnaire( @Header("Authorization") bearerToken: String, @Query("id") questionnaireId: String ): ApiEnvelope @GET("app_questionnaires") suspend fun getTranslations( @Header("Authorization") bearerToken: String, @Query("translations") translations: Int = 1 ): ApiEnvelope @POST("app_questionnaires") suspend fun submitQuestionnaire( @Header("Authorization") bearerToken: String, @Body body: SubmitQuestionnaireRequest ): ApiEnvelope } ``` Pass the token as `Bearer $token`.