glasses-eyes

Shining Glass LED glasses: reverse-engineering report

Target: com.icwork.shiningglass v1.2.7 (versionCode 127), from com.icwork.shiningglass_1.2.7.xapk Base APK SHA-256: e8d41528…5c6bd · Native split: config.armeabi_v7a.apk (libAES.so, libint16fft.so) Method: static analysis only. I used REA (artifact extraction, JADX class/method decompilation) and parsed the native ELF symbols directly. I did not run the app, connect to the glasses or capture any BLE traffic. Anything marked (inferred) or (unverified) needs a hardware test.


1. TL;DR

Item Value
Display 36 columns × 12 rows RGB LED matrix (init(36, 12) everywhere; the protocol calls it “1236”)
Radio Bluetooth Low Energy (GATT). Classic BT is not used for control.
Discovery Manufacturer-specific advert data, company ID 0x5254 ("TR"), followed by 00 41
Main service 0000fff0-0000-1000-8000-00805f9b34fb
Command crypto AES-128-ECB, one 16-byte block, key 32672f7974ad43451d9c6c894a0e8764 (hard-coded in libAES.so)
Bulk / live data Plain text (no encryption) on separate characteristics
Pairing / auth None found. No password or bonding step in the app’s connect flow.
Best path for “moving eyes” Live-paint DIY mode (…960b): send up to 8 pixel coordinates + 1 RGB colour per write, and the glasses update immediately

2. GATT map

From com.cdbwsoft.library.ble.BleManager (constructor) and BleDevice.onServicesDiscovered:

Role in app UUID Used for Payload
Service 0000fff0-0000-1000-8000-00805f9b34fb all control  
WRITE1 (writeCharacteristic) d44bc439-abfd-45a2-b575-925416129600 16-byte commands AES-encrypted
WRITE2 (writeCharacteristicBy2) d44bc439-abfd-45a2-b575-92541612960a bulk upload frames (DIY images, text) plain
WRITE3 (writeCharacteristicBy3) d44bc439-abfd-45a2-b575-92541612960b live pixel painting plain
NOTIFY1 / WRITE4 (writeCharacteristicBy4) d44bc439-abfd-45a2-b575-925416129601 notifications from glasses; also written for the music-rhythm stream notifications AES-encrypted; rhythm writes AES-encrypted
CCCD 00002902-0000-1000-8000-00805f9b34fb enable notify on …9601  
OTA service 0000fd00-… (data fd01, ctrl fd02) Panchip firmware update do not touch

BleDevice.onEncrypt() / onDecrypt() are identity functions. The app encrypts only by calling Agreement.getEncryptData() explicitly, and only for the command channel and the rhythm stream. Upload frames and live-paint frames go out raw.

MTU: after connecting, the app waits about 1.5 s and calls requestMtu(103) (PanchipOtaManager.setMtuSize1(dev, 100)). If that succeeds, upload chunks are 98 bytes; otherwise they are 18 bytes.

Write type: the app uses Android’s default for each characteristic. I could not determine whether the firmware requires write-with-response. (unverified)


3. Discovery and connection

BleConfig.matchProduct(scanRecord) walks the advertisement AD structures and accepts a device when an AD structure has:

In Web Bluetooth or bleak terms, the manufacturer data has company ID 0x5254 and its payload starts with 00 41. The app also requires a non-empty device name. It does not filter on the name itself. (C.Constance.BLE_AVERTISE_NAME = "BTCar-" is leftover code from another product and is never used for filtering.)

Connect flow (ConnectActivity.BleListenerImpl):

  1. Scan, filter with matchProduct, and show the device in a list. The user taps it, then connectGatt(autoConnect=false) runs with a 6 s connect timeout.
  2. discoverServices() runs with a 4 s timeout and up to one retry. The app records the characteristics listed above and enables notifications on …9601 by writing the CCCD.
  3. onReady fires, and 1 s later the device is added to the “connected” list. The app reads a characteristic (“ota/version”), and 500 ms after that it calls requestMtu(103).
  4. Nothing else is sent. There is no handshake or authentication. Every later action is an independent command.

App.onCreate calls aes.keyExpansionDefault() once. aes.keyExpansion(byte[]) (custom key) is never called, so the key is static for every device.


4. Command channel (…9600, AES-encrypted)

Every command is exactly 16 bytes and is encrypted with AES-128-ECB before writing:

byte 0      : N = number of meaningful bytes that follow (opcode + args)
bytes 1..N  : ASCII opcode + argument bytes
bytes N+1.. : padding (the app uses random bytes; DATS/DATCP use zeros, so zeros are fine)

Key, from the exported symbol key (16 bytes at VA 0x5004 in libAES.so, followed by a standard AES S-box 63 7c 77 7b…):

32 67 2f 79 74 ad 43 45 1d 9c 6c 89 4a 0e 87 64

Notifications from the glasses use the same format and are decrypted with the same key (Agreement.getDecodeData).

4.1 Command table (all from model/data/Agreement.java unless noted)

Command Plaintext bytes (before padding) Meaning / range App screen
Brightness 06 'L' 'I' 'G' 'H' 'T' v v = 1–100 (slider 0–100; 0 is sent as 1) Main
Speed 06 'S' 'P' 'E' 'E' 'D' v v = 1–100 (text scroll / animation speed) Text
Show built-in image 05 'I' 'M' 'A' 'G' n n = list position, 0-based (inferred); the app lists 20 images Image › Default
Play built-in animation 05 'A' 'N' 'I' 'M' n n = list position, about 22 animations Animation
Loop all animations 04 'L' 'O' 'O' 'A' cycle every animation Animation
Text effect mode 05 'M' 'O' 'D' 'E' m m: 1 = static, 3/4/2 = UI effects 1–3 (scroll/flash variants) Text
Preset colour mode 03 'M' on c c = 0–6 preset gradients; on = checkbox state Text
Text foreground colour 06 'F' 'C' on R G B   Text
Text background colour 06 'B' 'C' on R G B   Text
Enter DIY (blank) 06 'S' 'M' 'V' 'E' 'W' 01 clears the screen and enters live-paint mode DIY
Enter DIY (editing saved) 06 'S' 'M' 'V' 'E' 'W' 03 enter live-paint mode, keep content DIY
Exit DIY, discard 06 'S' 'M' 'V' 'E' 'W' 00   DIY
Exit DIY, save 06 'S' 'M' 'V' 'E' 'W' 02 the glasses keep the drawing DIY
Play saved DIY images 04 'P' 'L' 'A' 'Y' slideshow of the uploaded DIY images Image › DIY
Stop music rhythm 04 'S' 'O' 'U' 'T' leave rhythm mode Rhythm
Incoming-call alert 06 'C' 'A' 'L' 'L' status time defined but no call site (dead code) —
Upload start 09 'D' 'A' 'T' 'S' totalHi totalLo bmpHi bmpLo type type 0 = text, 1 = DIY image (see §5)
Upload finished 05 'D' 'A' 'T' 'C' 'P'   (see §5)
Multi-image start 06 'M' 'A' 'N' 'Y' count 01   (see §5.3)
Multi-image finished 07 'M' 'A' 'N' 'C' 'P' 'O' 'K'   (see §5.3)

The DIY opcode really is the five bytes 53 4D 56 45 57 (“SMVEW”). JADX resolves the second byte to the constant 77.

AppProtocol and Command (TimeCommand, PwdCommand, MODE_RED_FLASH…) have no references anywhere in the app. They are leftovers and not part of this product’s protocol.

Replies seen via notify, decrypted: DATSOK, REOK, DATCPOK, MANYOK, MANCPOK, ERROR. Byte 0 is a length byte; the ASCII starts at byte 1.

4.2 Example ciphertexts (computed with the recovered key, zero padding)

LIGHT 50 -> 9d d9 0e 66 e3 9a 3c c3 20 be 9e 45 d7 bc a9 f8
IMAG 1   -> 5e 98 f3 14 c6 12 e0 94 b6 d4 76 69 43 2a f7 fc

These are useful as a first smoke test: write one to …9600, and the brightness or image should change.


5. Bulk upload (DIY image or text)

5.1 Image payload format (DiyAgreement.getDiyBytes1236)

The editor grid is stored column-major. Pixel index i maps to col = i / 12 and row = i % 12.

bitmap: 72 bytes = 36 columns × 2 bytes
  byte[2*col]   bit7..bit0  = rows 0..7   (bit7 = row 0, top)
  byte[2*col+1] bit7..bit4  = rows 8..11  (low nibble unused)
colours: 432 × (R,G,B) = 1296 bytes, one per pixel, same column-major order
payload = bitmap ‖ colours  (1368 bytes total for a full DIY image)

Text uploads (TextAgreement.sendTextTo1236) use the same bitmap layout with any number of columns, but one colour per column instead of per pixel.

5.2 Upload handshake (single image)

App → …9600  AES{ 09 'DATS' len(2,BE) bmpLen(2,BE) type 00… }     type 1 = image, 0 = text
Glasses → notify  AES{ ..'DATSOK' }
loop k = 0..frames-1:
    App → …960a  PLAIN [ chunkLen+1 , k , chunk(≤98 or ≤18 bytes) , zero-pad ]
    Glasses → notify  AES{ ..'REOK' }           (stop-and-wait: one frame per ack)
App → …9600  AES{ 05 'DATCP' }
Glasses → notify  AES{ ..'DATCPOK' }    (or 'ERROR')

At MTU 103, a 1368-byte image is 14 frames, and each frame waits for its own REOK. Expect a few hundred milliseconds or more per image (unverified). The glasses store the result as a DIY image (it appears in the PLAY slideshow), which suggests it is written to flash.

5.3 Multi-image upload (DiyMutiAgreement, “Image select” screen)

MANY count 01 → MANYOK, then the §5.2 sequence once per image → MANCP OK → MANCPOK. This replaces the stored DIY playlist, which PLAY then cycles through. I found no command to show one stored DIY image by index.

5.4 Observed on hardware (GLASSES-03819C, Windows + bleak, MTU 252)

These results come from a real test on the glasses, not static analysis:


6. Live paint (…960b, plain, DIY mode only)

From DiyActivity.onItemSelect → LedViewDiy.getRealTime. Every pixel the user touches immediately sends one 20-byte write:

byte 0     : 2*n + 3          (n = number of points, 1..8)
byte 1..3  : R, G, B          (one colour for every point; 0,0,0 = erase)
byte 4..   : n × (col, row)   col 0–35 (x, left→right), row 0–11 (y, top→bottom)
rest       : zero

The app keeps a sliding window of the last 8 touched points and re-sends all of them in the current colour with every touch, which is probably redundancy against dropped writes. A controller can instead send any set of up to 8 points that share a colour.

Precondition: send SMVEW 01 (clear and enter) or SMVEW 03 (enter and keep) on …9600 first. To leave, send SMVEW 00 (discard) or SMVEW 02 (save).

This is the only fully arbitrary, real-time, per-pixel channel in the app, so it is the right basis for a gaze-controlled eye display.


7. Music / microphone rhythm stream (…9601, AES)

ConnectActivity.sendRhythmData / MicrophoneActivity.sendRhythmData push one packet per FFT callback through a background queue:

AES{ 0F , mode , 12 bytes of levels }   mode 0..5 (visualiser style)
levels: each byte = (bar 2i << 4) | bar 2i+1, each bar 0..9

The firmware draws its own bar or visualiser graphics from these levels. You can’t draw arbitrary pixels this way, but it does show the glasses accept a high-rate encrypted stream.


8. Step-by-step flows inside the app

A. Launch → connected SplashActivity → ConnectActivity.onCreate: start the rhythm writer thread, request permissions → startScan() → filterDevice() (matchProduct) → user taps → connectDevice → GATT connect → discover services → set CCCD on …9601 → onReady → +1 s add to device list → read char → +0.5 s requestMtu(103).

B. Brightness slider SeekBar.onProgressChanged(i) → sendLight(i) (0→1) → Agreement.getLight(i) → getEncryptData (AES) → BleManager.writeData → writeCharacteristic on …9600 for every connected device.

C. Built-in image / animation Tap item at position i → post STOP_RHY (stops music) → IMAG i or ANIM i → AES → …9600. The “loop” button sends LOOA.

D. DIY drawing (live) Open DiyActivity → SMVEW 01 (or 03 if editing) → each touched cell → 20-byte live-paint packet → …960b (plain). Back: SMVEW 02 if anything is drawn, else SMVEW 00. “Save” also stores the image locally in the app’s greenDAO SQLite DB.

E. DIY image send (from list) DiyImageFragment → DiyAgreement.sendDiy → bitmap + per-pixel RGB → DATS(type 1) → per-frame …960a + REOK → DATCP → DATCPOK.

F. Text Render glyphs into a column bitmap with per-column colour → DATS(type 0) → frames → DATCP → DATCPOK → then MODE m and, 150 ms later, SPEED v. The colour buttons send FC/BC/M.

G. Music / mic FFT → 12 packed level bytes → 0F mode … → AES → queue → …9601. Leave with SOUT.


9. What the glasses can and cannot do

Can (observed in app code)

Cannot / not exposed by the app


10. What this means for the eye controller

Design recommendation (my inference, not observed behaviour):

  1. Transport from the laptop: use either Chrome or Edge Web Bluetooth directly from the web page (works on Windows), or a small local Python server using bleak that the web UI talks to over WebSocket. The Python route is more robust for write-without-response and reconnection.
  2. On connect: write SMVEW 01 (AES) to …9600 to clear the screen and enter live paint.
  3. Draw the static parts once (eye whites or outlines) with live-paint packets in batches of ≤8 points.
  4. Each gaze update:
    • send the old pupil pixels in black (≤8 per packet), then
    • send the new pupil pixels in pupil colour. A 2×2 or 3×3 pupil per eye is 8–18 pixels, so about 2–5 small writes per frame. Only send when the gaze actually changes.
  5. Throttle to about 20–30 updates/s at first, and measure how quickly the glasses keep up.
  6. Use the LIGHT command for brightness. On exit, send SMVEW 00 so the glasses don’t save the drawing.
  7. As a fallback for complex frames, use the §5.2 upload (full 1368-byte image) with type 1.

Each eye gets an 18×12 half of the panel (columns 0–17 and 18–35). That assumes the physical lenses split the matrix down the middle (unverified; check on the device).


11. Open questions to check on hardware

# Question How to check
1 Does the firmware accept zero padding in commands (the app uses random)? Send LIGHT with zero padding. DATS/DATCP already use zeros, so it very likely works.
2 Write with vs. without response on …960b / …9600? Read the characteristic properties after connecting.
3 Maximum live-paint rate before packets drop Ramp the update rate and watch for missing pixels.
4 Do IMAG/ANIM indices start at 0 or 1? Send IMAG 0 and IMAG 1.
5 Are columns 0–17 the left lens? Is there a physical gap or dead pixels? Light column 0, 17, 18, 35.
6 Is per-pixel colour in an uploaded DIY image honoured, or reduced to per-column? Upload a gradient image.
7 Does live paint survive brightness changes and reconnects? Test.

12. Evidence index

Finding Source
Classes and opcodes com.icwork.shiningglass.model.data.Agreement (REA inspect_android_method: ev_61e12a24…, ev_e88a9c48…, ev_e2e47181…, ev_f8d69e9d…, ev_a2c242a8…, ev_cbeaebd6…, ev_fdc07077…)
AES is native csh.tiro.cc.aes → System.loadLibrary("AES") (ev_27f06c24…)
AES key libAES.so exported symbol key @ 0x5004, size 16; S-box follows. Parsed directly from ELF .dynsym (REA had no ARM-ELF decompiler on Windows)
Advert filter BleConfig.matchProduct and <clinit> bytes 54 52 00 41 (ev_798abf30…, ev_a55f9e7e…)
UUIDs / channel roles cdbwsoft.library.ble.BleManager ctor, BleDevice.onServicesDiscovered, writeCharacteristic*
Upload protocol DiyAgreement, DiyMutiAgreement, TextAgreement
Live paint ui.activity.DiyActivity.onItemSelect, ui.widget.LedViewDiy.getRealTime / init
Rhythm stream ConnectActivity.sendRhythmData, MicrophoneActivity, base.music.VisualizerUtil.getWaveFormData
MTU ConnectActivity.BleListenerImpl.onReady → PanchipOtaManager.setMtuSize1(…,100)
Full decompiled sources JADX 0.7.1 (the same engine REA uses) dumped to the session scratchpad for grep-level coverage

Limitations: this is static analysis only. Nothing here was observed on the wire, and firmware behaviour beyond what the app expects is unknown.