---
title: 'SA Driver Licence Decoder — Dockerized API & Android App'
slug: 'sa-dl-decoder'
created: '2026-03-07'
status: 'Implementation Complete'
stepsCompleted: [1, 2, 3, 4]
tech_stack:
  - PHP 8.2 / Laravel 11
  - Docker (php:8.2-fpm-bullseye) / docker-compose / Nginx
  - GD (PHP image processing — available in bullseye base)
  - Kotlin / Android API 26+
  - Manatee Works PDF417 SDK (Android)
  - Retrofit2 + OkHttp (Android HTTP)
files_to_modify: []
code_patterns:
  - 'TLV binary parsing in PHP (manual byte-level parsing with unpack())'
  - 'Shell exec via proc_open() with temp files for binary I/O'
  - 'GD imagerotate() for 180 degree flip'
  - 'Laravel service classes in app/Services/'
  - 'Retrofit2 interface + data classes (Kotlin)'
test_patterns:
  - 'input.bin — existing WSQ photo blob for manual binary testing'
  - 'Unit test SaDlTlvParser with known barcode bytes'
---

# Tech-Spec: SA Driver's Licence Decoder — Dockerized API & Android App

**Created:** 2026-03-07

## Overview

### Problem Statement

South African driver's licences contain a PDF417 barcode encoding personal information and a biometric photo in a proprietary binary TLV format. Decoding this data currently requires command-line tooling. There is no simple mobile-friendly workflow to scan a licence and instantly view the decoded information.

### Solution

A Dockerized Laravel API hosted in this repo, with the `decodephoto` binary baked into the container, that accepts raw PDF417 barcode data, parses the SA DL TLV format, extracts text fields and photo, and returns a JSON response. A new Kotlin Android app using the Manatee Works PDF417 scanner SDK sends scanned barcode data to the API and displays the decoded result.

### Scope

**In Scope:**
- `Dockerfile` and `docker-compose.yml` — PHP/Laravel + Nginx + `decodephoto` binary
- `POST /api/decode-licence` Laravel route and controller
- PHP SA DL TLV parser service (extract all text fields + compressed photo blob)
- Shell exec integration with `./decodephoto` binary (temp file I/O, cleanup)
- 180° image rotation of decoded PGM output, conversion to PNG, base64 encoding
- New Android app (Kotlin) — Manatee PDF417 scanner screen
- Android result display screen showing photo + all decoded fields

**Out of Scope:**
- API authentication (deferred)
- Persistent storage or logging of licence data
- iOS app
- Offline decoding
- Web frontend
- Non-SA driver's licences

## Context for Development

### Codebase Patterns

- **Clean slate** — no existing Laravel or Android code. Building from scratch.
- `decodephoto` binary: ELF 64-bit Linux x86-64, depends only on `libstdc++.so.6`, `libgcc_s.so.1`, `libc.so.6`, `libm.so.6` — all present in `php:8.2-fpm-bullseye` base image, no extra apt installs needed
- `input.bin` starts with `WI\x04\x00` (WSQ magic bytes) — this is the **extracted photo blob only**, not full barcode binary. The PHP TLV parser must extract this blob from the raw barcode before passing to binary
- `jack.bmp` = 50,015 bytes = PGM header (`P5\n200 250\n255\n` = 15 bytes) + 50,000 raw grayscale pixels (200×250). Read raw bytes after header skip for rotation.
- PHP TLV parsing: use `unpack()` and manual byte offset iteration — no external library needed
- PHP image rotation: use `GD` (`imagerotate($img, 180, 0)`) — available in bullseye base image via `docker-php-ext-install gd`
- Shell exec: use `proc_open()` or `shell_exec()` — input written to unique temp file, output read from unique temp file, both cleaned in `finally` block
- Temp file pattern: `sys_get_temp_dir() . '/dl_' . uniqid() . '.bin'`

### Files to Reference

| File | Purpose |
| ---- | ------- |
| `decodephoto` | Compiled binary — input: WSQ blob file, output: PGM P5 file (200×250) |
| `main.cpp` | Binary usage: `./decodephoto input.bin output.pgm` |
| `SWIDecoder.h` | Confirms `WiResultImage DecodeImage(unsigned char*, int)` interface |
| `input.bin` | Test WSQ blob (602 bytes, magic: `WI\x04\x00`) — use for integration testing |
| `jack.bmp` | Expected PGM output — 50,015 bytes, P5 format, 200×250, 255 depth |
| `_bmad-output/planning-artifacts/prd.md` | Full PRD — API spec, SA DL fields, JSON response format |

### Technical Decisions

- Docker base: `php:8.2-fpm-bullseye` (Nginx in same compose, separate service)
- Laravel installed via `composer create-project` during Docker build into `/var/www/html`
- `decodephoto` copied to `/usr/local/bin/decodephoto` with `chmod +x` in Dockerfile
- PHP GD extension installed via `docker-php-ext-install gd`
- Android min SDK: API 26 (Android 8.0)
- Android HTTP: Retrofit2 + OkHttp + Gson converter
- No API auth for initial build
- Android base URL hardcoded for initial dev (e.g. `http://10.0.2.2:8080` for emulator, real device IP for physical)

## Implementation Plan

### Tasks

#### Group 1: Docker Setup

- [x] Task 1: Create `Dockerfile`
  - File: `Dockerfile` (project root)
  - Action: Build from `php:8.2-fpm-bullseye`. Install GD (`docker-php-ext-install gd`), install Composer, install Node (for Laravel asset build if needed). Copy `decodephoto` binary to `/usr/local/bin/decodephoto` and `chmod +x`. Run `composer create-project laravel/laravel /var/www/html --prefer-dist` during build. Set `WORKDIR /var/www/html`. Set correct permissions on `storage/` and `bootstrap/cache/`.
  - Notes: No extra apt packages needed for the binary — all its libs (`libstdc++`, `libgcc_s`, `libc`, `libm`) are already in bullseye base.

- [x] Task 2: Create `docker-compose.yml`
  - File: `docker-compose.yml` (project root)
  - Action: Define two services — `app` (built from Dockerfile, mounts `./laravel` to `/var/www/html` for live dev, exposes nothing directly) and `nginx` (from `nginx:alpine`, mounts `./nginx/default.conf` and `./laravel` to `/var/www/html`, exposes port `8080:80`). Set `APP_ENV=local`, `APP_KEY` via `.env`.
  - Notes: Android emulator reaches host at `10.0.2.2:8080`. Physical device uses host LAN IP.

- [x] Task 3: Create `nginx/default.conf`
  - File: `nginx/default.conf`
  - Action: Standard Laravel Nginx config — `root /var/www/html/public`, `index index.php`, `location / { try_files $uri $uri/ /index.php?$query_string; }`, `location ~ \.php$ { fastcgi_pass app:9000; fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; include fastcgi_params; }`.

#### Group 2: Laravel API

- [x] Task 4: Create `app/Services/SaDlTlvParser.php`
  - File: `laravel/app/Services/SaDlTlvParser.php`
  - Action: Create a PHP class `SaDlTlvParser` with a single public method `parse(string $barcodeBase64): array`. Internally: (1) base64_decode the input string, (2) iterate through the binary using `unpack('C', ...)` byte-by-byte reading TLV records — tag (1 byte), length (1 byte, or 2 bytes if first byte is 0xFF), value (N bytes). Extract the following known SA DL tags into an associative array:
    - `0x01` → `surname` (string)
    - `0x02` → `names` (string)
    - `0x03` → `gender` (string: `M` or `F`)
    - `0x04` → `birth_date` (6-byte YYMMDD → format as `YYYY-MM-DD`)
    - `0x05` → `id_number` (string)
    - `0x06` → `licence_number` (string)
    - `0x07` → `issue_date` (6-byte YYMMDD → `YYYY-MM-DD`)
    - `0x08` → `expiry_date` (6-byte YYMMDD → `YYYY-MM-DD`)
    - `0x09` → `vehicle_codes` (comma-separated string → explode to array)
    - `0x0A` → `pdp` (string or null if empty)
    - Photo tag (largest binary record starting with `WI\x04\x00`) → `photo_blob` (raw bytes as string)
  - Notes: Reference implementation: https://github.com/yushulx/South-Africa-driving-license. If a tag is unrecognised, skip it (advance offset by length). Return array with all fields + `photo_blob`. Throw `\RuntimeException` if binary is too short or malformed.

- [x] Task 5: Create `app/Services/PhotoDecoderService.php`
  - File: `laravel/app/Services/PhotoDecoderService.php`
  - Action: Create class `PhotoDecoderService` with public method `decode(string $photoBlob): string` that returns base64-encoded PNG. Steps: (1) write `$photoBlob` to `sys_get_temp_dir() . '/dl_in_' . uniqid() . '.bin'`, (2) define output path `sys_get_temp_dir() . '/dl_out_' . uniqid() . '.pgm'`, (3) in a try/finally block: run `shell_exec('/usr/local/bin/decodephoto ' . escapeshellarg($inputPath) . ' ' . escapeshellarg($outputPath))`, (4) read output file bytes, skip 15-byte PGM header (`P5\n200 250\n255\n`), (5) load raw pixels into GD image via `imagecreatefromstring()` on a constructed valid PGM, (6) rotate 180° with `imagerotate($img, 180, 0)`, (7) capture PNG output with `ob_start()` + `imagepng()` + `ob_get_clean()`, (8) return `base64_encode($png)`. Finally block: `@unlink($inputPath)` and `@unlink($outputPath)`.
  - Notes: PGM header is exactly `"P5\n200 250\n255\n"` (15 bytes). Alternative approach for GD: write a valid PGM to a temp file and use `imagecreatefromstring(file_get_contents($pgmPath))` — GD can read PGM directly. Throw `\RuntimeException` if output file is empty or missing after exec.

- [x] Task 6: Create `app/Http/Controllers/Api/DecodeLicenceController.php`
  - File: `laravel/app/Http/Controllers/Api/DecodeLicenceController.php`
  - Action: Create controller with single `__invoke(Request $request)` method. Validate: `$request->validate(['barcode' => 'required|string'])`. Inject `SaDlTlvParser` and `PhotoDecoderService` via constructor. Call `$parser->parse($request->barcode)` → get fields + `photo_blob`. Call `$decoder->decode($fields['photo_blob'])` → get base64 PNG. Return `response()->json(['success' => true, 'data' => [...fields, 'photo' => $base64Png]])`. Wrap in try/catch — on exception return `response()->json(['success' => false, 'error' => $e->getMessage()], 422)`.
  - Notes: Remove `photo_blob` from the fields array before returning — it's internal only.

- [x] Task 7: Register API route
  - File: `laravel/routes/api.php`
  - Action: Add `Route::post('/decode-licence', \App\Http\Controllers\Api\DecodeLicenceController::class);`
  - Notes: Laravel 11 uses `bootstrap/app.php` to register API routes — ensure `withRouting(api: __DIR__.'/../routes/api.php')` is present.

#### Group 3: Android App

- [x] Task 8: Create Android project and configure `build.gradle`
  - File: `android/app/build.gradle`
  - Action: New Kotlin Android project (minSdk 26, targetSdk 34). Add dependencies:
    ```
    implementation 'com.squareup.retrofit2:retrofit:2.9.0'
    implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
    implementation 'com.squareup.okhttp3:okhttp:4.12.0'
    implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0'
    // Manatee Works SDK — add as local .aar or via their Maven repo
    implementation files('libs/ManateeWorks.aar')
    ```
  - Notes: Add `CAMERA` and `INTERNET` permissions to `AndroidManifest.xml`. Manatee SDK `.aar` file from Jack's licence.

- [x] Task 9: Create `LicenceResponse.kt`
  - File: `android/app/src/main/java/com/example/saldecoder/model/LicenceResponse.kt`
  - Action: Define data classes:
    ```kotlin
    data class LicenceResponse(val success: Boolean, val data: LicenceData?, val error: String?)
    data class LicenceData(
        val surname: String, val names: String, val id_number: String,
        val birth_date: String, val gender: String, val licence_number: String,
        val issue_date: String, val expiry_date: String,
        val vehicle_codes: List<String>, val pdp: String?, val photo: String
    )
    ```

- [x] Task 10: Create `ApiService.kt`
  - File: `android/app/src/main/java/com/example/saldecoder/network/ApiService.kt`
  - Action: Define Retrofit interface:
    ```kotlin
    interface ApiService {
        @POST("api/decode-licence")
        suspend fun decodeLicence(@Body body: Map<String, String>): LicenceResponse
    }
    ```
    Create `RetrofitClient` singleton with base URL constant (`http://10.0.2.2:8080/` for emulator — change to LAN IP for physical device). Add `HttpLoggingInterceptor` in debug builds.

- [x] Task 11: Create `ScannerActivity.kt` + layout
  - Files: `android/app/src/main/java/com/example/saldecoder/ScannerActivity.kt`, `android/app/src/main/res/layout/activity_scanner.xml`
  - Action: Layout: full-screen `FrameLayout` with Manatee scanner `SurfaceView` filling screen + a centered `ProgressBar` (initially `GONE`) + a `TextView` for error messages (initially `GONE`). Activity: initialise Manatee PDF417 scanner, set callback for successful scan. On scan result: show `ProgressBar`, hide error, launch coroutine (lifecycleScope) to call `ApiService.decodeLicence(mapOf("barcode" to base64EncodedResult))`. On success: start `ResultActivity` with decoded data as `Parcelable` extra. On failure: hide `ProgressBar`, show error `TextView`. On activity resume: restart scanner.
  - Notes: Manatee SDK returns raw barcode bytes — base64-encode before sending: `Base64.encodeToString(barcodeBytes, Base64.NO_WRAP)`.

- [x] Task 12: Create `ResultActivity.kt` + layout
  - Files: `android/app/src/main/java/com/example/saldecoder/ResultActivity.kt`, `android/app/src/main/res/layout/activity_result.xml`
  - Action: Layout: `ScrollView` containing vertical `LinearLayout` with: `ImageView` (200dp wide, 250dp tall, centred, top) followed by a series of `TextView` pairs (label + value) for each field (Surname, Names, ID Number, Date of Birth, Gender, Licence Number, Issue Date, Expiry Date, Vehicle Codes, PDP), then a `Button` ("Scan Again"). Activity: receive `LicenceData` parcelable from intent. Decode `photo` base64 string to `Bitmap` via `BitmapFactory.decodeByteArray(Base64.decode(...))` and set on `ImageView`. Populate each `TextView`. "Scan Again" button calls `finish()`.

### Acceptance Criteria

- [ ] AC 1: Given a valid SA DL PDF417 barcode base64 string, when `POST /api/decode-licence` is called, then the response is HTTP 200 with `success: true` and all personal fields populated correctly (surname, names, id_number, birth_date, gender, licence_number, issue_date, expiry_date, vehicle_codes).

- [ ] AC 2: Given a valid barcode, when the API responds, then `data.photo` is a non-empty base64 string that decodes to a valid PNG image of 200×250 pixels.

- [ ] AC 3: Given the decoded photo, when displayed on screen, then the face is right-side-up (not inverted).

- [ ] AC 4: Given an invalid or malformed base64 barcode string, when `POST /api/decode-licence` is called, then the response is HTTP 422 with `success: false` and a descriptive `error` string.

- [ ] AC 5: Given a request that causes the `decodephoto` binary to fail, when the API processes it, then temp files are cleaned up and a 422 error response is returned (no orphaned temp files on disk).

- [ ] AC 6: Given the Android app is running, when a SA DL barcode is held in front of the camera, then the Manatee scanner detects it within 3 seconds and automatically initiates the API call.

- [ ] AC 7: Given the API returns successfully, when the result screen loads, then the photo and all decoded text fields are visible without scrolling past the photo initially, and a "Scan Again" button is present.

- [ ] AC 8: Given the API call fails (network error or 422), when on the scanner screen, then an error message is shown and the scanner resumes — the app does not crash.

## Additional Context

### Dependencies

- **Manatee Works PDF417 Barcode Scanner SDK** — `.aar` file required from Jack's licence
- **Docker + docker-compose** — must be installed on host
- **PHP extensions** — `gd`, `json`, `mbstring` (all installable in bullseye base)
- **Composer** — installed inside Docker during build
- **Laravel 11** — installed via `composer create-project` during Docker build
- **Retrofit2 2.9.0** + **OkHttp 4.12.0** + **Gson** — standard Android HTTP stack
- **SA DL TLV format reference** — https://github.com/yushulx/South-Africa-driving-license

### Testing Strategy

- **Manual integration test (API):** Run `docker-compose up`, send the sample barcode base64 from conversation via `curl -X POST http://localhost:8080/api/decode-licence -H 'Content-Type: application/json' -d '{"barcode":"..."}'`. Verify JSON response fields and photo.
- **Manual binary test:** Confirm `decodephoto` works inside container: `docker exec <app> /usr/local/bin/decodephoto /tmp/input.bin /tmp/out.pgm && ls -la /tmp/out.pgm` (should be 50,015 bytes).
- **Unit test TLV parser:** `php artisan test` with a known barcode binary fixture — assert all fields extracted correctly.
- **Android emulator test:** Launch app in emulator, point at a test barcode image on screen, verify result screen populates.
- **Physical device test:** Scan a real SA DL, verify photo and fields are correct and photo is right-side-up.

### Notes

- **High risk — TLV tag values:** The exact tag byte values for each SA DL field must be verified against the reference implementation at https://github.com/yushulx/South-Africa-driving-license. The values in Task 4 are based on the known spec but should be confirmed before finalising the parser.
- **High risk — Photo blob identification:** The photo blob may not always start at a fixed offset. Detection strategy: scan for `WI\x04\x00` magic bytes within the binary to locate the photo blob start, then take bytes from that offset to end of record.
- **Base URL for Android:** Emulator uses `10.0.2.2:8080` to reach host. Physical device must use the host machine's LAN IP (e.g. `192.168.x.x:8080`). Consider making this configurable via a `BuildConfig` field.
- **GD PGM support:** GD can read PGM via `imagecreatefromstring()` — verify this works in the Docker container with a test script before relying on it. Alternative: manually parse the PGM bytes and create a GD image via `imagecreate()` + `imagesetpixel()` (slower but guaranteed).
- **Manatee SDK:** Requires valid licence key injected at runtime. Follow Manatee docs for key registration in `Application.onCreate()`.

