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.
| 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 |
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)
BleConfig.matchProduct(scanRecord) walks the advertisement AD structures and accepts a device when an AD structure has:
0xFF (manufacturer specific), and54 52 00 41.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):
matchProduct, and show the device in a list. The user taps it, then connectGatt(autoConnect=false) runs with a 6 s connect timeout.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.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).App.onCreate calls aes.keyExpansionDefault() once. aes.keyExpansion(byte[]) (custom key) is never called, so the key is static for every device.
…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).
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.
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.
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.
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.
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.
These results come from a real test on the glasses, not static analysis:
DATSOK, then 14 × REOK, then DATCPOK. A 4-image playlist took 3.16 s and ended with MANCPOK. Every chunk was acknowledged, with no errors.…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.
…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.
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.
PLAY for the whole list).Design recommendation (my inference, not observed behaviour):
bleak that the web UI talks to over WebSocket. The Python route is more robust for write-without-response and reconnection.SMVEW 01 (AES) to …9600 to clear the screen and enter live paint.LIGHT command for brightness. On exit, send SMVEW 00 so the glasses don’t save the drawing.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).
| # | 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. |
| 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.