Technical assessment Confidential Day 0 → v1.0

In-place reskin of a sealed protein dispenser

A vending appliance arrived with no source, no documentation, no vendor contact, and a hardware plane that could not be allowed to move. The engagement recovered the specification from the running unit — 33 MB of Kotlin compiled to 28,317 smali files — then shipped a bilingual Anytime Fitness front end and a new creatine product ladder while holding YS_CONFIG.db md5-identical across every deploy.

Smali recovered
28,317 files
Resources decoded
2,336 / 398 layouts
Vendor smali rewritten
6 files
Owned logic
classes5.dex · 970 LOC
Hardware drift
0 bytes
§00

Executive summary

Assessment scope

The appliance is a Chinese-market coffee/protein dispenser rebadged for a fitness operator. There is no vendor source tree, no API documentation, no upstream security stream, and the recipe/calibration plane is safety-relevant. The only workable path was to treat the running appliance as the specification: acquire everything over ADB, rebuild the APK through apktool, move product behaviour into data wherever possible, and confine new code to an owned extension dex that reaches the vendor app only through reflection.

7.1.2
OS, EOL since 2020
28,317
Smali files recovered
6
Vendor smali touched
7
Reflective hook points
0
YS_CONFIG byte drift

Findings

  • Platform-signature requirement was a documentation artefact, not a real constraint.
  • UART ownership survives re-signing under a project key.
  • Product behaviour is largely data-driven and reachable without touching vendor logic.

Delivered

  • Bilingual EN/FR front end including language-filtered attract and banner media.
  • True 0 / 5 / 10 g creatine ladder priced as real SKUs.
  • Own-mug path, redesigned detail and dispense screens, deploy and rollback tooling.

Explicitly out of scope

  • Serial protocol, MDB payment, hopper calibration, recipe physics.
  • OS hardening, removal of the OEM remote-support agent.
  • Any mutation of /sdcard/YSConfig/YS_CONFIG.db.
Governing constraint

This was never a rewrite. Recipes, calibration, MDB payment, and the serial framing stay bit-for-bit intact. Every deploy fingerprints /sdcard/YSConfig/YS_CONFIG.db before and after, and treats any difference as a hard stop rather than a warning.

§01

Specimen identification

Provenance & signing

Before any change could be justified, the unit had to be identified precisely: which binary is installed, who signed it, under what UID it runs, and which entry point the kiosk boots into.

PropertyObserved valueSource of truth
Installed APKcom.ys.vending.apk · 37,647,400 Badb pull of base.apk
VersionV1.1.5-20260731.01 · versionCode 6manifest / filename
Application classcom.ys.coffeenew.CoffeeAppAndroidManifest
Boot entryui.activity.SplashActivitylauncher intent filter
Active skinskin.proteindispenser.ProteinActivityruntime mCurrentFocus
Original signerCN=Jiancheng Song, OU=TCN, O=TCN, L=Changsha, ST=Hunan, C=CNapksigner verify
Original cert SHA-2564d5c6950…09375a7eapksigner verify
Signature schemev1 + v2apksigner verify
Reskin signerCN=Anytime Fitness Vending, OU=Kiosk Reskinproject JKS
Runtime UIDu0_a54ps / /proc
Install location/data/app/pm path

Table 1 — Specimen provenance. Certificate digest abbreviated.

Critical unlock

The manifest carries android:sharedUserId="android.uid.system" on the <application> element. Android only honours that attribute on <manifest>, so it is silently ignored: the app is an ordinary /data/app install running as u0_a54. That single observation converted the project from “requires the OEM platform key” to “re-signable with our own keystore” — and it was confirmed empirically, because the re-signed build still opens both UART nodes.

§02

Platform risk surface

Day-zero threat model

Day-zero findings defined what “safe change” could mean for the rest of the engagement. Two of them — the EOL runtime and the frozen hardware plane — constrained every later decision.

Obsolete runtime API 25

Android 7.1.2 with no public security stream since 2020. No modern permission model, no scoped storage — the latter ironically required, because the entire catalog, recipe and media tree sits on world-readable /sdcard/coffee.

Any emulation target must stay API 25 at 1080×1920 / 160 dpi, because the skin is laid out in mdpi where dp and px coincide; a higher-density AVD silently rescales every asset.

Rockchip RK3288 / K518 userdebug

Vendor image rk3288_k518_zhonggu_v15, rooted userdebug build signed with test-keys, TeamViewer Host preinstalled, brittle USB ADB and TCP 5555 frequently refused.

The BSP is opaque with no upstream patches. Root is what made forensics possible at all, and is simultaneously the largest item on the risk register.

Risk classObservationOperational consequenceStatus
OS end-of-lifeAPI 25, no vendor security streamAssume full-disk and ADB compromise in the threat modelAccepted
Root / test-keysadbd as root, remount possibleEnables forensics; equally expands attacker surfaceInherited
Remote supportTeamViewer Host installed by OEMThird-party channel entirely outside the app sandboxNot removed
World-readable dataRecipes, prices, media on /sdcardAny app or ADB session can rewrite the price of a drinkInherited
Platform-key mythsharedUserId on the wrong XML elementInitially appeared to block rebuild entirely; disprovedResolved
Supply chainTCN certificate, APK Signature Scheme v2Re-sign under project JKS viable; OEM trust not replacedResolved
No sourceKotlin/Java binary only, 4 vendor dexapktool + jadx + extension-dex hooks as the only routeMitigated
Raw UARTUnauthenticated framing to drink and MDB boardsPhysical-trust assumption; deliberately left untouchedOut of scope

Table 2 — Risk register as assessed on day 0 and its state at v1.0.

§03

Acquisition & forensics

Live unit as specification

No vendor VPN, no Git history, no protocol book. Everything below had to be recovered from a running kiosk over a connection that dropped often enough to make each pull a discrete, verified step.

ArtefactDevice pathWhy it was requiredHandling
Installed binary/data/app/…/base.apkOnly existing copy of the programPull, archive, never modify in place
Hardware config/sdcard/YSConfig/YS_CONFIG.dbPorts, calibration, recipe physicsRead + md5 only
Catalog/sdcard/coffee/database/coffee_db.dbSKUs, prices, formulas, ad rows, ordersEdited offline, pushed atomically
Translations/sdcard/coffee/database/language_db.dbEvery runtime UI stringPull → SQL → push
DataStore protos/sdcard/coffee/datastore/*.pbGrid layout, own-cup, serial, calibrationByte-level patch where unavoidable
Media trees/sdcard/coffee/ads/ · productImage/Attract, half banners, product heroesReplaced wholesale per language
Glide cache/data/data/…/cache/ad_image_cacheStale art survives file replacementCleared on every deploy
Runtime proof/proc/<pid>/fdShows which UID actually owns the UARTCaptured before and after re-sign
App log/sdcard/coffee/log/coffee-core-<date>.logTimber log, not visible in logcatPulled for failure analysis

Table 3 — Acquisition ledger. Handling column records the policy, not just the action.

Evidence that mattered most
runtime fd table
# the question: does an ordinary re-signed app still own the boards?
$ adb shell pidof com.ys.vending
4127
$ adb shell ls -l /proc/4127/fd | grep ttyS
lrwx------ 1 u0_a54 u0_a54 64 74 -> /dev/ttyS3   # drink board
lrwx------ 1 u0_a54 u0_a54 64 75 -> /dev/ttyS2   # MDB payment

Why this is the pivotal observation. The OEM narrative implied the app needed system privileges to reach the boards. Two file descriptors held by u0_a54 after a re-signed install disproved it, and that is what made the entire reskin legitimate rather than speculative.

§04

Binary topology

Decompilation surface

The app is modern Kotlin MVVM compiled across four dex files. jadx recovers intent well enough to reason about; it does not round-trip, so every rebuild goes through apktool smali. The scale below is what any change had to be located inside.

classes.dex — 7,736 smali classes2.dex — 6,933 smali classes3.dex — 7,142 smali · active skin classes4.dex — 6,506 smali classes5.dex — owned, 1 class, 0.03 MB
Vendor smali files28,317
Vendor dex payload33.11 MB
Resource files decoded2,336
Resource buckets150
Layout XMLs398
Vendor smali rewritten6 · 0.021%
Owned dexclasses5.dex · 30 KB
Owned source970 LOC Java 8
ToolVersionRole in the chainRound-trips?
apktool3.0.3Decode and rebuild resources + smaliYes
jadx1.5.6Java-level reading and control-flow analysisNo
d834.0.0Compile owned classes to classes5.dex, --min-api 24n/a
Temurin JDK17.0.20Host for apktool, javac, signern/a
android.jarandroid-25Sole compile classpath for owned coden/a
uber-apk-signer1.3.0zipalign + v2/v3 signingn/a
platform-tools37.0.1All device I/On/a
sqlite3scoopOffline catalog and translation editsn/a

Table 4 — Pinned toolchain. Versions are fixed because apktool output is not stable across releases.

§05

Runtime architecture

Process, storage, UART

A single process owns the UI, the catalog reads, and both serial links. The reskin operates entirely in the upper two planes; the kernel UART plane is frozen by policy and verified by fingerprint on every deploy.

PROCESS PLANE com.ys.vending (u0_a54) · SplashActivity → ProteinActivity Kotlin MVVM · Flow · Room · DataStore · Glide · Netty  |  + CreatineFlow (classes5.dex) owned extension · reflection only STORAGE PLANE — MUTABLE /sdcard/coffee/database/coffee_db.db product · drink_stock · ads · orders /sdcard/coffee/database/language_db.db translation · language_config /sdcard/coffee/datastore/*.pb · ads/ · productImage/ /sdcard/YSConfig/YS_CONFIG.db — fingerprint only, never written KERNEL UART PLANE — FROZEN /dev/ttyS3 · 9600 · drink board hoppers · valves · cup drop · blender · door /dev/ttyS2 · 9600 · MDB peripheral coin / card / cashless payment SerialPortManager · MdbManager · ShipAndOrderManager no reskin code opens or reconfigures these nodes formula
Fig. 1Process, storage and UART planes. Dashed edge is the recipe blob crossing from catalog to drink board.
Vendor components and their responsibilities
ComponentPlaneResponsibilityReskin relationship
SerialPortManagerUARTOpens tty nodes, protocol select (COFFEE / MDB / 240131 / 171111)Untouched
MdbManagerUARTMDB payment state machine over serial clientUntouched
ShipAndOrderManagerUARTOrder fulfilment; sends product.formula to the boardUntouched
MachineStatusManagerUARTMachine state, own-cup wait, fault surfacingRead only
AdPlayManagerStorageAttract and half-banner rotation from the ads table1 hook
LanguageSDKStorageRuntime strings; exposes current skin language as a FlowLabel edit
ProductRepoStorageRoom access, getProductsByCabinetIndex(int)Reflected
ProductCustomVMUIDetail screen state, own-cup and sugar-box selection2 hooks
SkinActivityVMUISelected product; the actual billing source of truthReflected
DataStoreManagerStorageProtobuf settings read/writeRead only

Table 5 — Vendor component map with the reskin's relationship to each.

§06

Data layer & schema

Where behaviour actually lives

The single most valuable finding of the engagement is that most product behaviour is configuration, not code. Recovering the schema turned features that looked like app changes into SQL and protobuf edits.

Ownership of each concern
ConcernAuthoritative storeConsumer pathChange vector
Product grid and pricescoffee_db.productShop VM via RoomSQL
Hopper recipeproduct.formula (JSON)ShipAndOrderManager → ttyS3SQL, derived only
Creatine ladderproduct.remarkCreatineFlow.loadVariantsSQL
Bilingual copyproduct.description (EN||FR)CreatineFlow.descriptionForSQL
UI stringslanguage_db.translationLanguageSDKSQL
Enabled languageslanguage_config.supportLanguagesLanguageSDKSQL
Attract / banner mediaads table + ads/local/*AdPlayManager + language filterFiles + hook
2-up grid layoutoperational_config.pb #26Shop layout selectionByte patch
Own-mug availabilitydevice_setting_config.pb #3CreatineFlow + detail VMRead only
Serial path / baudproto_serial_port_config.pbSerialPortManagerFrozen
Calibrationproto_calibration_value_config.pbBoard calibrationFrozen

Table 6 — Concern-to-store ownership, with the change vector actually used.

Catalog schema — product
sqlite3 .schema product
id, type, slot, layer, isCombinationSlot, slotType, name, capacity, stock, error,
error_at, price, discount_price, useDiscount, image, formula, spec, sku, enable,
cold, age_can_buy_enable, age_can_buy, sell_time, remark, create_at, update_at,
description, detail_image, video, with_hot_water_enable, hot_water_cost, sort,
translation_name, cabinet_index, sale_status, testStatus, showFormula,
showPrice, showDiscountPrice

-- indexes: index_product_sku, index_product_enable_type_sort
-- 22 rows total, 7 with enable = 1
Encoding trap

price is an integer in thousandths of a currency unit and the UI appends C$. 6490 renders as 6.49 C$. Assuming cents instead of mils turns a $6.49 shake into $64.90 on a live payment terminal, which is why every price edit is asserted against a rendered screenshot rather than the database alone.

DataStore protobuf inventory
FileMessageFields of interestReskin action
operational_config.pbOperationalConfig#26 showType, #1 loginPassword, #4 currencySymbolsAppend D0 01 01 if absent
device_setting_config.pbDeviceSettingConfig#3 selfCupOption, #1 cupCoverOptionRead to gate own-mug UI
base_setting_config.pbBaseSettingConfig#1 shoppingCart, #5/#6 display rows/columnsNone
proto_serial_port_config.pbCoffeeSerialPortConfignested address, baudrate, boardId, protocolFingerprint only
proto_calibration_value_config.pbCalibrationValueConfigcoffeeBean, water, boxList, perishablesListFingerprint only
proto_server_config.pbServerConfigip, port, signKey, machineId, machineSerialNone
fault_log.pbFaultLogfaultCode, faultTime, cabinetNo, isClearedRead for diagnosis
wash_record.pbWashRecordbrewer / blender / box wash countersNone

Table 7 — Protobuf configuration files. Field numbers were recovered by decoding live bytes, not from a schema.

The one byte-level edit
before… 20 01 28 00  <end of message, 65 B>
after… 20 01 28 00 D0 01 01  <68 B>
meaningfield 26, varint, value 1showType = true → 2-up grid + category rail

Why bytes and not an API. Jetpack DataStore serialises through generated classes that the reskin does not have. Appending a well-formed varint field is idempotent, verifiable by length, and reversible — the deploy script checks for the D0 01 tag first and leaves the file alone if the operator already enabled the grid.

§07

Catalog & creatine ladder

Product design in data

The operator wanted a real dosing choice at 0, 5 and 10 g, priced independently. An earlier approach that remapped the vendor “half sugar” control was abandoned: it inherited sweetness semantics and could not carry its own price. The shipped design encodes the ladder as ordinary SKUs and stores the relationship in a field the vendor app ignores.

product.remark grammar
CREA|<sku of 5 g variant>|<sku of 10 g variant>

-- parsed by CreatineFlow.loadVariants():
--   remark.startsWith("CREA|")  →  split on \|  →  requires length >= 3
--   row 0 is the visible tile (0 g); parts[1] and parts[2] are hidden SKUs
--   variant rows stay enable = 0, so they never appear on the grid

VANILLA        CREA|VANILLA+CREA|VANILLA+CREA+CREA
PVL H20        CREA|PVL H20+CREA|PVL H20+CREA+CREA
Shipped menu and price ladder
#SKUName (EN)Name (FR) 0 g5 g10 gHopper
1COOKIES + CREAMCookies & Cream ProteinProtéine Biscuits & Crème6.496.997.492
2CHOCOLATE CAKEChocolate Cake ProteinProtéine Gâteau Chocolat6.496.997.492
3VANILLAVanilla ProteinProtéine Vanille6.496.997.492
4ICED CAPPUCINOIced Cappuccino ProteinProtéine Cappuccino Glacé6.496.997.492
5PINEAPPLEPre-Workout PineapplePre-Workout Ananas3.994.494.993
6BLUE RASBERRYPre-Workout Blue RaspberryPre-Workout Framboise Bleue3.994.494.995
7PVL H20ElectrolytesÉlectrolytes2.993.493.997

Table 8 — Seven enabled tiles × three doses = 21 addressable SKUs, of which 14 are hidden rows. Prices in C$; stored as mils.

Recipe derivation

A 10 g row is generated from its 5 g row rather than authored: copy the row, then rewrite one field inside the formula JSON — "materialCost":4 becomes "materialCost":8 — and set the SKU to TRIM(sku) || '+CREA'.

The creatine step is identifiable because it is the only step with a cost of 4 or 8; the protein step uses 38. Water volume is inherited untouched: 275 ml for proteins, 300 ml for pre-workout and electrolytes.

Hopper reality

Creatine always dispenses from hopper index: 1, which the vendor firmware treats as the adjustable “sugar” box. Enabling it is a JSON flag flip inside drink_stock.material_box_stock, not a code change.

Field note The 10 g recipe has never been physically dispensed on the board — it exists as a derived recipe and a priced SKU. That is recorded as an open verification item rather than a shipped guarantee.

delivered customer flow
attract        ad_full_attract_{en|fr}.jpg          // language-filtered by hook
  → shop      2-up grid, half banner _{en|fr}, 7 tiles
      → CreatineFlow chooser
             · 0 / 5 / 10 g          // when remark starts with CREA|
             · own-mug option        // when device_setting.selfCupOption
          → detail   hero + creatine badge + nutrition card
              → payment  // vendor MDB path, untouched
                  → shipment  hero + progress arc
§08

Extension dex

classes5.dex · CreatineFlow

All new behaviour lives in one owned class compiled against android.jar alone — zero vendor classpath. Every integration point is reflection, and every entry point catches Throwable and falls back to vendor behaviour. A failure in the reskin degrades to the original app rather than crashing the kiosk.

VENDOR CALL SITES (smali) OWNED (classes5.dex) REFLECTIVE TARGETS ProteinShopFragment$onProductSelected$1 ProteinProductCustomFragment.onCreate ProteinProductCustomFragment.initView AdPlayManager.submitList ShopFragment$subscribeData$1$1$6$1 ProteinShipmentFragment.initView ShipmentFragment.handleMachineStatus CreatineFlow onProductSelected() applyChosenVariant() decorateDetail() filterAdsByLanguage() onSkinLanguageChanged() decorateShipment() statusCaption() 970 LOC · javac 8 → d8 --min-api 24 every entry: try { } catch (Throwable) sqlite coffee_db.db → remark / price SkinActivityVM.getSelectedProduct() ProductRepo.getProductsByCabinetIndex(0) ProductCustomVM.updateChoiceSelfCup() LanguageSDK.getCurrentSkinLanguage() AdEntity.getLocalPath() / getUrl() device_setting_config.pb field 3
Fig. 2Seven call sites in vendor smali, one owned class, all downstream access by reflection.
Entry points
MethodWhat it doesFailure mode
onProductSelected Reads sku from the navigation bundle, queries remark, presents the dose chooser, then rewrites sku, price, haveSugarBox and the own-cup keys before calling the vendor navigate. Falls through to vendor navigation with the original bundle.
applyChosenVariant Reconciles the chosen variant with SkinActivityVM.getSelectedProduct() so billing follows the dose, loading rows off the UI thread with a 2,000 ms join. Detail screen shows the base SKU; price stays consistent with the tile.
decorateDetail Populates the creatine badge and nutrition card, drives own-cup state, hides the vendor cup-choice container. Vendor detail layout renders unmodified.
filterAdsByLanguage Filters the ad list on _en. / _fr. in the local path or URL, so attract and banner art follow the selected language. Full unfiltered playlist plays.
onSkinLanguageChanged Re-submits the cached full ad pool through AdPlayManager when the language Flow emits, so switching language swaps art immediately. Art updates on the next natural rotation.
decorateShipment Replaces the vendor coffee animation with a circle-cropped product hero and a rotating progress arc (level 2800/10000, 1,100 ms). Vendor dispense animation plays.
statusCaption Rewrites empty and INVALID ID machine-status strings to bilingual “Preparing your drink”. Raw vendor status string is shown.

Table 9 — Extension entry points. The failure-mode column is the design contract, not a disclaimer.

Design rule

The badge does not pattern-match a suffix; it counts occurrences of CREA within the SKU, so VANILLA+CREA+CREA resolves to 10 g without a second parsing rule. Small choices like this are what keep 970 lines from needing a parser.

§09

Intervention ledger

6 of 28,317 files

Vendor smali edits are recorded individually because each one is a liability at the next vendor update. The ratio is the point: six files rewritten out of 28,317, and five of those six insert a single static call.

FileDexEditClass
…/fragment/ProteinShopFragment$onProductSelected$1.smali3Route selection through CreatineFlow.onProductSelected instead of navigating directlyHook
…/fragment/ProteinProductCustomFragment.smali3Two hooks in onCreate / initView, plus a genuine vendor bug fix: read haveSugarBox from the bundle and forward it to the VMHook + fix
…/manager/AdPlayManager.smali3Language filter at the head of submitListHook
…/fragment/ProteinShopFragment$subscribeData$1$1$6$1.smali3Notify the extension after the language label updatesHook
…/fragment/ProteinShipmentFragment.smali3Decorate the dispense screen and rewrite the status captionHook
…/tanslation/LanguageSDK.smali2Language picker reads English / Français without the vendor glossCosmetic

Table 10 — Every vendor smali file modified, with intent. Vendor package spelling tanslation is reproduced as found.

Resource additions
fragment_protein_product_custom.xmlaf_creatine_badge, af_info_card
fragment_protein_shipment.xmlaf_making_ring, af_making_hero
drawable/bg_af_making_hero.xmlhero mask
drawable/protein_making_arc.xmlprogress arc
values/ids.xmlnew view ids
Naming conventionaf_ prefix, always additive

Update strategy. New views carry an af_ prefix and are appended rather than replacing vendor ids, so a future vendor APK can be re-decoded and the same six patches reapplied mechanically instead of merged by hand.

§10

Build & signing chain

Reproducible artefact

Two independent chains converge: the owned Java compiles to a dex that is dropped into the apktool tree as an additional multi-dex member, then the whole app is rebuilt, aligned and signed under a project key.

CreatineFlow.java970 LOC javac -source 8cp: android-25 only d8 --min-api 24build-tools 34.0.0 unknown/classes5.dex30 KB apktool treesmali + res + xml apktool b 3.0.3pinned version zipalignuber-apk-signer sign v2 / v3project JKS anytime-reskinaligned-signed.apk
Fig. 3Owned-code chain (top) merges into the rebuild chain (bottom). Dashed edge is the dex drop into the apktool tree.
Original APK37,647,400 B
Rebuilt + signed35.92 MiB
Dex members4 vendor + 1 owned
Compile classpathandroid.jar only
Signature schemesv2 + v3
Keystoregit-ignored
§11

Deploy pipeline

14 ordered steps · 6 fingerprints

Deployment is a single scripted transaction, not a sequence of ad-hoc adb commands. It refuses to run against the wrong target, takes a restorable backup first, fingerprints the hardware plane on both sides of the change, and ends by proving the app is alive and still holding both serial ports.

  1. Preflight

    Enumerate devices, require exactly one target or an explicit serial, and refuse any emulator-* target outright. Require the staged catalog and the signed APK to exist.

  2. Backup

    Pull catalog plus its WAL and SHM sidecars, operational_config.pb, and the entire ads tree into backups/v2-<timestamp>/.

  3. Fingerprint — before

    md5 of YS_CONFIG.db (and its WAL), catalog, serial config, calibration, operational and device settings. This is the reference the deploy is judged against.

  4. Quiesce

    am force-stop com.ys.vending, then wait for the process to release its database handles.

  5. Replace attract and banner art

    Clear ads/local/full and ads/local/half, then push language-suffixed artwork routed by filename prefix.

  6. Push product renders

    Local hero PNGs into /sdcard/coffee/productImage/.

  7. Invalidate image cache

    Remove the Glide ad_image_cache directory — without this, replaced artwork keeps rendering from cache.

  8. Push catalog

    Copy the staged coffee_db.db over the live file and delete stale WAL/SHM so SQLite cannot resurrect the previous content.

  9. Apply translations

    Pull language_db.db, apply the creatine label and language-support SQL locally, push it back, drop sidecars, and archive a copy into the backup folder.

  10. Conditional protobuf patch

    Append D0 01 01 to operational_config.pb only when field 26 is absent; otherwise leave the operator's setting untouched.

  11. Install

    adb install -g -r of the aligned, signed APK.

  12. Launcher handoff

    Hand the HOME role back to the device's own Launcher3 so a failed kiosk app cannot lock the unit into an unusable state.

  13. Cold start

    Launch SplashActivity explicitly and allow 30 s for first-run initialisation and board handshake.

  14. Fingerprint — after, and verify

    Re-hash all six files, classify each as unchanged / expected / unexpected, confirm focus, PID and both tty descriptors, grep logcat for fatals, and capture a screenshot as evidence.

Fingerprint policy
FileExpected verdictOn mismatch
/sdcard/YSConfig/YS_CONFIG.dbUNCHANGEDStop. Investigate before any further action.
proto_serial_port_config.pbUNCHANGEDStop — serial plane must not move.
proto_calibration_value_config.pbUNCHANGEDStop — calibration is safety-relevant.
device_setting_config.pbUNCHANGEDInvestigate; reskin only reads it.
operational_config.pbMAY CHANGEOnly by exactly three appended bytes.
coffee_db.dbCHANGED (expected)An unchanged hash means the push silently failed.

Table 11 — Fingerprint verdicts. Both directions are failures: unexpected change and unexpected sameness.

§12

Verification & rollback

Post-deploy invariants

A deploy is only accepted when the appliance proves four things about itself. Anything less is treated as a failed deploy and rolled back from the backup taken minutes earlier.

Acceptance invariants

  • Foreground window is the expected skin activity, not a crash dialog or the launcher.
  • The process exists and its PID resolves.
  • That PID holds descriptors on both /dev/ttyS3 and /dev/ttyS2.
  • No fatal exception in the recent logcat tail, and a screenshot shows the reskinned grid.

Rollback path

  • Restore the catalog and its sidecars from the timestamped backup.
  • Restore operational_config.pb and the full ads tree.
  • Optionally reinstall the archived original vendor APK.
  • Nothing in the rollback path touches the hardware plane either.
Known-benign noise
logcat, expected
ClassNotFoundException: org.apache.logging.log4j.spi.ExtendedLoggerWrapper
  // Netty probing for an optional log4j backend that this build does not ship.
  // Present on the untouched vendor APK as well - documented so it is never
  // mistaken for reskin damage during a live deploy.

Control experiment. A rebuild of the untouched vendor APK through the same apktool version produced no com/ys/ smali differences attributable to the toolchain. That baseline is what allows the six-file ledger in §09 to be stated as complete rather than approximate.

§13

Iteration record

Day 0 → v1.0

The working loop was: pull the current truth, form a hypothesis about which store or class owns a behaviour, patch, deploy to the board, screenshot, compare. Relative effort by workstream:

Forensics & signing
18
Creatine chooser
20
Catalog & CREA ladder
16
Visual reskin v1–v2
14
Attract & banner i18n
12
Dispense UX
10
Own-cup & language polish
10
Phase 01 day 0

Acquisition

Full device dump, apktool and jadx trees, first identification pass. Network ADB proved unreliable; USB became the only supported path.

Phase 02 signing

Rebuild legitimacy

The sharedUserId trap disproved, project keystore established, and UART ownership confirmed on a re-signed build.

Phase 03 brand

Reskin v1

Seven-item menu, brand palette, 2-up grid via the protobuf field, product renders and attract art, plus the deploy and rollback tooling.

Phase 04 product

True dose ladder

The sweetness-remap approach abandoned in favour of real SKUs; chooser inserted between grid and detail; detail screen rebuilt around a centred hero.

Phase 05 bilingual

EN / FR and own mug

Language-suffixed media with a filter hook, translation overrides, own-mug gating on the device setting, and a lower call to action.

Phase 06 freeze

v1.0

Installable build, archived original APK, artwork and documentation frozen; HOME handed back to Launcher3; deploy target made explicit so the wrong device is refused.

Tracked revisions16
Released tagv1.0
Deploy / rollback scripts12 · 1,746 lines
Data migrations7 SQL · 346 lines
Owned application code970 lines
Restorable backup setstimestamped per deploy
§14

Security posture

Stated plainly
Honest

This engagement did not harden the appliance. The reskin inherits the OEM threat model in full: an end-of-life OS, a rooted test-keys image, a preinstalled third-party remote support agent, world-readable recipe and price data, and unauthenticated UART to both the drink board and the payment peripheral.

What was added is change control, not security control. Treating the kiosk as a high-trust physical object remains a business decision, and it should be made knowingly.

Control addedWhat it doesWhat it explicitly does not do
Hardware fingerprint gateDetects any drift in YS_CONFIG.db, serial and calibration config across a deployEncrypt, ACL or sign those files
Timestamped backup setsPoint-in-time restore of catalog, settings and mediaSecure wipe, integrity attestation or off-device escrow
Explicit deploy targetRefuses emulators and ambiguous device listsAuthenticate the operator or the workstation
Launcher handoffPrevents a broken kiosk app from locking the unitRemove the OEM boot receiver
Project signing keyReproducible, attributable installsReplace OEM supply-chain trust or enable verified boot
Fail-open extension codeReskin failures degrade to vendor behaviourProtect against a hostile process on the same device

Table 12 — Controls introduced, and the boundary of each.

Recommended if the fleet grows

Remove remote support

The OEM support agent is the shortest path to a compromised kiosk and is not required by any shipped feature.

Move data off /sdcard

Prices and recipes on world-readable storage means any local process can retail a drink for zero.

Sign the hardware config

The md5 gate detects drift after the fact; a signature would prevent an unauthorised write in the first place.

§15

Appendix & inventory

Reproducibility

A — Device command inventory

  • adb devices -l
  • adb shell md5sum '/sdcard/YSConfig/YS_CONFIG.db'
  • adb shell am force-stop com.ys.vending
  • adb pull /sdcard/coffee/database/coffee_db.db
  • adb push work\dbedit\coffee_db.db /sdcard/coffee/database/coffee_db.db
  • adb shell rm -f /sdcard/coffee/database/coffee_db.db-wal /sdcard/coffee/database/coffee_db.db-shm
  • adb shell rm -f /sdcard/coffee/ads/local/full/* /sdcard/coffee/ads/local/half/*
  • adb push assets\ads\ad_full_attract_en.jpg /sdcard/coffee/ads/local/full/
  • adb shell rm -rf /data/data/com.ys.vending/cache/ad_image_cache
  • adb install -g -r build\signed\anytime-reskin-aligned-signed.apk
  • adb shell cmd package set-home-activity 'com.android.launcher3/com.android.launcher3.Launcher'
  • adb shell am start -n com.ys.vending/com.ys.coffeenew.ui.activity.SplashActivity
  • adb shell pidof com.ys.vending
  • adb shell "ls -l /proc/<pid>/fd | grep ttyS"
  • adb shell "dumpsys window | grep mCurrentFocus"
  • adb shell "logcat -d -t 400 | grep -iE 'FATAL|AndroidRuntime|NotFoundException'"
  • adb shell screencap -p /sdcard/shot.png

B — Data migrations

ScriptLinesTargetEffect
set-creatine-variants.sql160coffee_dbCREA maps, ladder prices, hidden variant rows, derived 10 g recipes
set-creatine-menu.sql52coffee_dbEarlier tile strategy; hopper sugar flag
set-menu-copy.sql45coffee_dbNames, French display names, bilingual descriptions
set-creatine-labels.sql40language_dbSweetness strings rewritten as creatine doses, EN + FR
set-product-descriptions.sql25coffee_dbSuperseded French-only copy
set-local-product-images.sql18coffee_dbPoint rows at local hero renders
set-languages-en-fr.sql6language_dbSupport list reduced to ["en","fr"]

C — Repository layout

decompiled/apktool/rebuildable tree
tools/java/…/CreatineFlow.javaowned logic
tools/*.sqldata migrations
scripts/deploy, rollback, artwork
assets/approved artwork
device-dump/acquisition evidence
backups/v2-<ts>/restore points
docs/findings, deployment, toolchain

D — Open items

ItemStatusNote
10 g creatine physical dispenseUnverifiedRecipe derived and priced; never dispensed on hardware
Serial framing documentationNot attemptedDeliberately out of scope; the plane is frozen
Vendor update re-applicationDesigned forSix patches are mechanical; af_ resources are additive
Platform hardeningNot in scopeRequires an OEM image decision, not an app change