TT Festival App — “Bierroute” / Gamification & QR-scanning Deep-dive
Scope:
com.appmiral.ttfestivalv2.0.1, modulecom.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 = falsebadges 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
- The bierroute is the gamification module (Games→Badges), not a bespoke feature; the same code powers any “collect stamps” route.
- The in-app “scan” button just launches the system camera; stamps are
collected via deep links encoded in QR stickers (
trigger = link). requiresValidation = truebadges phone home with your real identity (email + name + account ids) tied to the venue and timestamp — a server-side visited-venues record.falsebadges stay local.- Genuine in-app QR scanning (ZXing) is used by the ticket scanner, not the bierroute. Loyalty displays a code; wallet uses NFC.
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 |
6.1 Deeplink format
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
secretis server-side, never client-side — verified. The gamification deeplink router does not parse, generate, derive, store, or even reference a parameter namedsecret.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/unlockThe 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=valuefrom the QR toapp.appmiral.com/function/…, and only the Appmiral backend knows the valid(id, code, secret)tuples and validates them. Consequently thesecretcannot be extracted from the APK, app config,/gamesresponse, 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: knowingid+code(both public) without the matchingsecretyields 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.tsvcontains 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.