← All posts

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.


Enjoyed this? There's a tip jar if you're feeling generous, or just say hi.