Skip to the content.

TT Festival App — “Bierroute” / Gamification & QR-scanning Deep-dive

Scope: com.appmiral.ttfestival v2.0.1, module com.appmiral.gamification. The festival’s bierroute (“beer route” — visit participating cafés/locations and collect stamps/badges) is implemented by the generic Appmiral gamification module: Games that contain Badges.


1. Data model: Games → Badges

Entity Key fields
Game (model/Game.java) id, name, showScan (show_scan) — a route/collection (e.g. the bierroute)
Badge (model/Badge.java) id, gameId, trigger, requiresValidation, showScan (show_scan), eventIdentifier (event_identifier), isUnlocked, locked/unlocked name+icon+detail image+summary+description, priority

A Badge has one of three triggers (Badge.java lines 84–86):

trigger value Constant Meaning
link TRIGGER_LINK Unlocked when the app opens a matching deep link (e.g. a QR at a café)
game_completed TRIGGER_GAME_COMPLETED Unlocked automatically when all other badges in the game are collected
app_event TRIGGER_APP_EVENT Unlocked when an in-app event fires (eventIdentifier), routed via gamification/ModuleTracker.java

Plus a boolean requiresValidation that decides local vs server unlock.


2. How a badge is unlocked (provider/GamesDataProvider.java)

public final void unlockBadge(Badge badge) {
    if (badge.getRequiresValidation()) {
        // SERVER unlock — validated remotely
        unlockBadgeViaAPI(badge.getGameId(),
            MapsKt.hashMapOf(TuplesKt.to("id", String.valueOf(badge.getId()))),
            null);
        return;
    }
    // LOCAL unlock — no network round-trip
    Analytics.getAnalytics().trackGameBadgeUnlock(gameId, badgeId);
    updateBadgeState(...);              // mark isUnlocked in local DB
    sendBroadCastForUnlockingGame(...); // refresh UI
}

2.1 The server unlock carries your real identity

unlockBadgeViaAPI() reads IdentityProviderManager.getCurrentIdentity() and calls BadgeService.unlockBadge(...):

POST  events/{festival}/editions/{edition}/games/{gameId}/badges/unlock
      (base URL = app.appmiral.com/function/ — AWS Lambda)
Headers:
  x-account-token         x-account-access-token   x-account-id-token
  x-app-user-id           x-account-id
  x-account-email         x-account-first-name     x-account-last-name
QueryMap:
  id = <badgeId>          (+ any scanned code/params)

(Source: api/BadgeService.java line 28–29; provider/GamesDataProvider.java lines 278–297.)

🔴 Privacy implication. For any badge with requiresValidation = true, the unlock POST sends the user’s email + first name + last name + account id + app user id to the Appmiral Lambda backend, tied to which badge (i.e. which physical café/location on the bierroute) and when. The server can therefore reconstruct who visited which venue at what time — server-side location-history-by-identity, built from QR scans, independent of the Crowd Connected passive tracking documented elsewhere.

For requiresValidation = false badges the unlock is purely local; only an analytics event (trackGameBadgeUnlock, consent-gated) is emitted.


3. The “QR scanning piece” — what actually scans

This is the part most people assume is an in-app QR scanner. It mostly isn’t.

3.1 Gamification “scan” button → opens the system camera

When a Game has show_scan = true, the badge-overview toolbar inflates R.menu.gamification_scan_button_menu (action_camera). Tapping it does not open an in-app ZXing scanner — it fires the OS camera intent (view/BadgeOverviewFragment.java lines 121–157):

if (item.getItemId() == R.id.action_camera) {
    requireContext().startActivity(
        new Intent("android.media.action.STILL_IMAGE_CAMERA"));
}

So the in-app “scan” button is just a shortcut to the phone’s camera app. The actual mechanism for collecting a bierroute stamp is a deep link: each café’s QR code encodes an Appmiral deep link (https://ttfestival2026.deeplink.appmiral.com/… or appmiral-ttfestival://…). Scanning it with the camera opens the app, which resolves to the relevant trigger = link badge and unlocks it (locally, or via the validated API path in §2.1). There is no ScanContract/BarcodeCallback/ DecoratedBarcodeView code anywhere in the gamification package — confirming the badge flow is deep-link-driven, not ZXing-driven.

3.2 The four distinct scan/credential surfaces in the app

# Surface Engine Direction Purpose
1 Gamification / bierroute System camera + deep link Inbound link Collect badges/stamps along a route
2 Ticket scanner (ticketscanner/TicketScanActivity) ZXing com.journeyapps.barcodescanner (decodeContinuous) + CaptureActivity Inbound scan Scan a ticket QR/barcode to add a ticket to the app
3 Wallet / cashless (wallet) NFC wristband + QR Inbound/tap Cashless payments / wristband linking
4 Loyalty (loyalty/LoyaltyFragment) Renders a barcode/QR on screen Outbound (display) Shows your code for staff to scan — it does not scan anything

Only surface #2 (and parts of #3) is a genuine in-app camera scanner. The ZXing CaptureActivity is declared in the manifest for the ticket scanner.


4. End-to-end bierroute flow (typical config)

sequenceDiagram
    participant U as User
    participant C as Café QR (sticker)
    participant Cam as Phone camera
    participant App as TT Festival app
    participant API as app.appmiral.com/function (Lambda)

    U->>C: walk into café
    U->>Cam: scan QR sticker
    Cam->>App: open deep link (…deeplink.appmiral.com/…)
    App->>App: resolve trigger=link badge
    alt requiresValidation == false
        App->>App: local unlock + analytics event (consent-gated)
    else requiresValidation == true
        App->>API: POST …/badges/unlock + identity headers (email, name, ids)
        API-->>App: ApiBadge (validated)
        App->>App: mark unlocked, show reward
    end
    Note over App,API: When all badges collected →<br/>game_completed badge auto-unlocks

5. Summary


6. Live TT Bierroute configuration (2026-06-08)

The public Appmiral games endpoint was queried with the same mobile-app headers used by the APK (x-protect, platform/version headers). It returned one game:

Field Value
Game id 54
Name TT Bierroute
Type badge_collection
State ongoing
show_scan true
Badge count 10

The app’s universal-link landing page turns:

https://ttfestival2026.deeplink.appmiral.com/<path>?<query>

into:

appmiral-ttfestival://<path>?<query>

For the bierroute unlock flow, the app module accepts:

https://ttfestival2026.deeplink.appmiral.com/games/<gameId>/badges/unlock?id=<BADGE_ID>&code=<CODE>&secret=<SECRET>
appmiral-ttfestival://games/<gameId>/badges/unlock?id=<BADGE_ID>&code=<CODE>&secret=<SECRET>

The query parameters are forwarded into GamesDataProvider.unlockBadgeViaAPI(gameId, queryMap=...). All returned bierroute badges have trigger=link, requires_validation=true, and show_scan=true, so unlock validation happens server-side.

6.1.1 The full unlock parameter set — id, code, and secret

The real unlock URL embedded in a physical QR is not ?code=… alone. It carries three parameters, e.g.:

…/games/55/badges/unlock?id=567&code=<12-char code>&secret=<32-hex-char token>
Param Source In public game/badge JSON? Role
id badge id ✅ yes (badges[].id) which badge
code badge code ✅ yes (badges[].code) per-badge code (effectively public config)
secret QR only ❌ no 128-bit (32 hex) per-badge validation token

🔑 The secret is server-side, never client-side — verified. The gamification deeplink router does not parse, generate, derive, store, or even reference a parameter named secret. Module.open() enumerates whatever query keys happen to be in the scanned URI and copies them verbatim into the unlock POST:

// com.appmiral.gamification.Module.open()
Set<String> queryParameterNames = uri.getQueryParameterNames();
for (String key : queryParameterNames) {
    arrayList.add(TuplesKt.to(key, uri.getQueryParameter(key)));   // id, code, secret …
}
// → queryMap → POST events/{…}/games/{gameId}/badges/unlock

The string "secret" appears nowhere in the decompiled gamification package, nor as a code literal in the dex. The client is a dumb pass-through: it relays every ?key=value from the QR to app.appmiral.com/function/…, and only the Appmiral backend knows the valid (id, code, secret) tuples and validates them. Consequently the secret cannot be extracted from the APK, app config, /games response, or badge JSON — it exists only on the physical QR (e.g. a TT beer coaster) and in the server’s database. This is what makes the badge codes alone insufficient to unlock: knowing id+code (both public) without the matching secret yields a server-side rejection.

Abuse note: the full unlock codes are deliberately not published in this repository. They are venue/QR unlock secrets and could be used to bypass physically visiting the participating cafés. The private analysis artifact files/proof/bierroute_badges.tsv contains the raw response for local verification.

6.2 Bierroute badges / QR locations

Badge id Badge / poster year Participating venue links in the badge text Redacted deeplink
558 1991 De laatste chicane Restaurant & Grandcafé LIFF; Café 1809 Lodewijk Napoleon /games/54/badges/unlock?code=h7wA…UaC
559 1986 Volle toeren Brink 14; De Pendance /games/54/badges/unlock?code=qAmD…WwF
560 1933 De pioniersjaren Autentieq; Dapper; Brouwerij H.E.F.T. /games/54/badges/unlock?code=zHvC…FnY
561 1936 De grote doorbraak De Drie Esschen; New Café Image /games/54/badges/unlock?code=gRzK…5pM
562 1962 Wereldsterren op wielen Zusjes de Boer; Brownies & downieS /games/54/badges/unlock?code=lZyP…Qu2
563 1952 De volgende versnelling Hotel de Jonge; Café de Koppelpaarden /games/54/badges/unlock?code=zAxY…UnW
564 1951 Waar traditie begint De Witte Bal; Café Cours /games/54/badges/unlock?code=zSy5…I99
565 1931 Over land en weg Grand Café Goud; Stadscafé De Pelikaan /games/54/badges/unlock?code=mPfJ…JiW
566 1929 Heldere start Café de Oude Eik; Plein 10; Brouwerij H.E.F.T. /games/54/badges/unlock?code=mJcR…0oY
575 Game completed Auto-completion badge, no venue links /games/54/badges/unlock?code=eCtN…BgW

The badge descriptions say the user can order a special beer at one of the listed venues and receive a unique TT beer coaster for that badge/year. Because all badges require server validation, scanning a QR code sends the code plus the identity headers described in §2.1.

Full codes & Appmiral API: The complete unlock codes and additional Appmiral API endpoints are documented in private research artifacts (files/appmiral_api_research.md, files/bierroute_badges.tsv) — not committed to git. These contain the full deeplink URLs, unlock mechanism, available endpoints (games, tickets, wallet, location services), and security notes. The codes are intentionally kept private to prevent bypassing the physical check-in requirement.