The one app this phone will install
The TCL Flip Go lets you install exactly one app — and only if you know its secret name. A deep dive into the OEM sideload gate I found by decompiling the phone's own PackageManager, and the disguise dumbsms wears to slip through it.
In the last post I won an argument with my
flip phone about whether it was running Android (it is) and got adb talking to
it. That was supposed to be the hard part. dumbsms needs a small companion app
on the phone to send picture and group messages — and installing an app on
Android is a one-line command:
adb install app-debug.apk
That command failed. And the way it failed sent me decompiling the phone’s operating system at 11pm.
A lie dressed up as an error
Here’s what the phone told me:
adb: failed to install app-debug.apk:
Failure [INSTALL_FAILED_INSUFFICIENT_STORAGE: Failed rename]
Insufficient storage. Except the phone had 4.3 GB free. I tried the usual incantations anyone would: push the APK to a temp directory and install from there, disable incremental install, flip on “unknown sources,” attribute the install to the Play Store. Every one returned the same misleading storage error. The message was a lie. Something was refusing my app and reporting the wrong reason on the way out.
The only way to know what was really happening was to read the code doing the refusing.
Reading the phone’s own PackageManager
PackageManagerService is the part of Android that installs apps. It lives in
/system/framework/services.jar on the device. So I pulled that file off the
phone and ran it through jadx, a decompiler
that turns compiled Android bytecode back into readable Java.
What I found inside the install routine was not stock Android. TCL had added a custom gate — roughly this:
// (decompiled, simplified) inside installPackageLI
int v = Settings.System.getInt(cr, "install_enable", 1); // default 1 = blocked
if (!mIsAllowInstall) mIsAllowInstall = (v == 0);
boolean whitelisted = Arrays.asList(mWhiteArray).contains(pkg);
if (mIsMPBranch && !whitelisted) throw forbidden; // gate (1): the whitelist
if (!mIsAllowInstall) throw forbidden; // gate (2): the flag
Two independent locks, and on my unit both were engaged.
Gate 1 — the whitelist of one
mWhiteArray — the list of package names this phone is willing to install — has
exactly one entry:
com.debug.loggerui
That’s it. On a “mass production” build (which mIsMPBranch says mine is), the
phone will install an app if, and only if, its package name is
com.debug.loggerui. Any other name is forbidden, dressed up as a storage
error. There’s no setting for this and no menu — it’s compiled into the firmware.
Gate 2 — the hidden flag
Even the whitelisted name needs a second lock opened: a system setting called
install_enable has to be 0. It defaults to 1 (blocked). You can flip it
over ADB:
adb shell settings put system install_enable 0
It turns out this is exactly what the phone’s secret service code *#*#2880#*#*
does internally — there’s a hidden receiver that maps that dialer code straight
to this setting. I’d found the back door the manufacturer uses for its own
servicing.
Why I couldn’t just turn the gates off
The obvious move is to make the phone stop thinking it’s a locked-down
production unit — flip mIsMPBranch to false. But that value is read once, at
boot, from a system property I can’t write from the shell. The clean escape
hatches all require root, and root requires an unlocked bootloader —
which this phone reports as permanently disabled (sys.oem_unlock_allowed=0).
So the locks can’t be removed. Which leaves exactly one move: stop fighting the whitelist and join it.
The disguise
If the only name the phone trusts is com.debug.loggerui, then that’s the name
my app wears to the front door. Android lets you set the manifest package name
(the identity the installer checks) independently from where your actual code
lives, so dumbsms’s companion ships like this:
<manifest package="com.debug.loggerui">
<application>
<receiver android:name="com.dumbsms.companion.ControlReceiver"
android:exported="true"> ... </receiver>
</application>
</manifest>
To the gate, it’s the blessed com.debug.loggerui. Inside, it’s my code in its
own com.dumbsms.companion namespace. Conveniently, the real com.debug.loggerui
isn’t actually installed on this phone, so there’s no system app to collide with.
With the name borrowed and install_enable=0 set, the install finally takes:
adb shell settings put system install_enable 0 # open gate 2
adb install -r app-debug.apk # name opens gate 1 -> Success
adb shell pm grant com.debug.loggerui android.permission.SEND_SMS
adb shell pm grant com.debug.loggerui android.permission.READ_SMS
Success. dumbsms now automates this whole sequence end to end, so from my Mac
it’s a single dumb-sms app install with the APK baked right into the binary.
A bonus trick for reading results back
There’s a nice side effect of installing a debug-signed app: it’s marked
debuggable, which means ADB can reach into its private data directory with
run-as. Android 11’s scoped storage makes the usual /sdcard/... paths
annoying to read from a shell, so the companion just writes its results into its
own sandbox and the Mac reads them back like this:
adb shell run-as com.debug.loggerui cat files/result-<id>.json
That’s the channel every dumbsms command’s reply flows through.
The catch
This works, but it’s the most fragile thing in the whole project. The disguise depends on a hardcoded list and a magic flag inside a specific firmware build. A software update from TCL could rename the whitelisted package, change the gate, or close the back door entirely — and because I can’t root the phone, I’d have no way around it. For now, the one app this phone will install happens to be mine. It just had to learn the password first.