18 KiB
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://<server-host>/api - JSON header:
Content-Type: application/json; charset=UTF-8 - Auth header after login:
Authorization: Bearer <token> - All timestamps are integer Unix timestamps. Use seconds unless the server contract is changed.
- Successful front-controller responses use:
{
"ok": true,
"data": {}
}
- Error responses use:
{
"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:
{
"username": "coach1",
"password": "secret"
}
Success:
{
"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:
Authorization: Bearer <token>
The token may be a normal session token or the temporary token returned when mustChangePassword is true.
Request:
{
"username": "coach1",
"old_password": "temporary-or-current-password",
"new_password": "new-secret"
}
Success:
{
"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:
Authorization: Bearer <token>
Success:
{
"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; labels come from app UI strings (e.g.questionnaire_group_health_interview).
Only questionnaires with state = "active" are returned, ordered by server orderIndex.
Fetch One Questionnaire
GET /api/app_questionnaires?id=<questionnaireID>
Example:
GET /api/app_questionnaires?id=questionnaire_1_demographic_information
Success:
{
"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 exampleq1.layout: question renderer type, for exampleradio_question,multi_check_box_question,string_spinner,value_spinner,client_coach_code_question, orlast_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 asanswerOptionKeyduring 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=<code>&answers=1
Allowed roles: coach, supervisor, admin (coach must be assigned to the client).
Headers:
Authorization: Bearer <token>
Success (encrypted envelope, same as ?clients=1):
{
"ok": true,
"data": {
"encrypted": true,
"payload": { "...": "..." }
}
}
Decrypted data:
{
"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_questionnairerows for the client. answersmirrors the POST submit shape (shortquestionID, glass-scale symptoms as separate entries, multi-checkbox as multiple rows with the samequestionID).- On the server, multi-checkbox answers are stored in one
client_answerrow (freeTextValueJSON 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:
Authorization: Bearer <token>
Success:
{
"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 byclientCode.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, ornullif not recorded.
Common failures:
401: missing or invalid token.403: token belongs to a role other thancoach(supervisor and admin must use the web/api/clientsendpoint 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 <token>.
Success:
{
"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:
- Load
translations[activeLanguage]. - Look up the string key (e.g.
save,client_code). - If missing, fall back to embedded defaults in the app.
Questionnaire detail translations
GET /api/app_questionnaires?id=<questionnaireID> includes a sibling translations object with the same shape (language code → key → text), scoped to that questionnaire only:
- question default text (
questionfield 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
- On app start (login screen), fetch app UI translations with
GET /api/app_questionnaires?translations=1(no token) and cache locally. - Login with
/api/auth/loginand store the returned token securely (EncryptedSharedPreferences on Android). - Fetch the assigned client list with
GET /api/app_questionnaires?clients=1(coach role only). - Re-fetch app UI translations optionally after login (same public endpoint, or authenticated refresh).
- For each list item, fetch details with
GET /api/app_questionnaires?id=<id>(includes per-questionnairetranslations). - Cache the client list, app translations, the questionnaire list, and questionnaire details (with embedded translations) locally for offline use.
- When a questionnaire is completed, upload answers with
POST /api/app_questionnairesusing 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:
- HTTPS (TLS) for transport.
- 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):
{
"encrypted": true,
"payload": "<base64( IV16 || ciphertext )>"
}
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:
{
"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. Useoptions[].keyfrom the detail endpoint.freeTextValue: for text answers.numericValue: for numeric/range answers.answeredAt: timestamp for this answer.
Current upload behavior:
- Unknown or empty
questionIDvalues are skipped. - Unknown
answerOptionKeyvalues are stored as no selected option for that question. - Re-uploading the same
clientCodeandquestionIDoverwrites the previous answer. - Re-uploading the same
clientCodeandquestionnaireIDoverwrites the completed questionnaire status and point sum (supports correction / re-edit workflows). sumPointsis calculated from recognizedanswerOptionKeyvalues.- 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:
{
"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:answersis 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:
data class ApiEnvelope<T>(
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<String, Any?> = emptyMap()
)
data class QuestionnaireDetail(
val meta: QuestionnaireMeta,
val questions: List<Question>
)
data class QuestionnaireMeta(
val id: String
)
data class Question(
val id: String,
val layout: String,
val question: String,
val options: List<QuestionOption>? = null,
val pointsMap: Map<String, Int>? = 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<String>? = null,
val range: Map<String, Double>? = null,
val constraints: Map<String, Any?>? = null,
val minSelection: Int? = null
)
data class QuestionOption(
val key: String,
val nextQuestionId: String? = null
)
data class TranslationsResponse(
val translations: Map<String, Map<String, String>>
)
data class SubmitQuestionnaireRequest(
val questionnaireID: String,
val clientCode: String,
val startedAt: Long? = null,
val completedAt: Long? = null,
val answers: List<SubmitAnswer>
)
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<ClientInfo>
)
data class ClientInfo(
val clientCode: String,
val completedQuestionnaires: List<CompletedQuestionnaire> = emptyList()
)
data class CompletedQuestionnaire(
val questionnaireID: String,
val sumPoints: Int,
val completedAt: Long?
)
Example Retrofit service:
interface QuestionnaireApi {
@POST("auth/login")
suspend fun login(@Body body: LoginRequest): ApiEnvelope<LoginResponse>
@GET("app_questionnaires")
suspend fun getAssignedClients(
@Header("Authorization") bearerToken: String,
@Query("clients") clients: Int = 1
): ApiEnvelope<ClientsResponse>
@GET("app_questionnaires")
suspend fun getQuestionnaires(
@Header("Authorization") bearerToken: String
): ApiEnvelope<List<QuestionnaireListItem>>
@GET("app_questionnaires")
suspend fun getQuestionnaire(
@Header("Authorization") bearerToken: String,
@Query("id") questionnaireId: String
): ApiEnvelope<QuestionnaireDetail>
@GET("app_questionnaires")
suspend fun getTranslations(
@Header("Authorization") bearerToken: String,
@Query("translations") translations: Int = 1
): ApiEnvelope<TranslationsResponse>
@POST("app_questionnaires")
suspend fun submitQuestionnaire(
@Header("Authorization") bearerToken: String,
@Body body: SubmitQuestionnaireRequest
): ApiEnvelope<SubmitQuestionnaireResponse>
}
Pass the token as Bearer $token.