</>CodeWithKarani

The OEM autostart rabbit hole: deep-linking into a dozen undocumented settings screens

Karani GeoffreyKarani Geoffrey9 min read

Here is a sentence that is true and that no documentation will tell you: on a large share of the Android phones actually in use, your foreground service does not decide whether it keeps running. The manufacturer's battery manager does, and it has no API, no callback, and no way to ask it what it thinks.

The SMS gateway I have been writing about is exactly the kind of app these managers exist to kill. It is a background process. It runs forever. It talks to the network. From a battery manager's point of view it is indistinguishable from a badly written app leaking wakelocks, and the manager is not wrong to be suspicious, it is just wrong about this one.

So the app has a screen whose only job is to walk an operator through turning those protections off, and behind that screen is one of the least elegant pieces of code in the project. I am showing it because everybody writing this kind of app writes it, usually badly, and because the interesting part is not the list of component names. It is what you do when the list misses.

keep an ordered list of undocumented component names, test each one with resolveActivity before launching it, and fall back to the app details page.

  • There is no platform API for autostart. It is a manufacturer feature and it is invisible to you.
  • Battery optimisation and OEM autostart are two different protections. Winning one does not win the other.
  • Transsion (Tecno, Infinix), which is very large in Africa, exposes no component at all, so the fallback is not an edge case, it is a primary path.
  • You cannot query the autostart state, so the only honest signal is a watchdog that notices the service stopped.

Two protections, not one

The first thing to get straight is that there are two separate mechanisms with similar-sounding names, and they are handled by completely different code.

Battery optimisation (Doze)OEM autostart or protected apps
Who owns itAndroid platform, since MarshmallowThe manufacturer's own app manager
Can you query it?Yes: PowerManager.isIgnoringBatteryOptimizationsNo. Nothing to ask.
Can you request it?Yes: a documented intent with a documented permissionNo. You can only try to open a screen.
What it does when activeDefers your background work in idle windowsStops your app outright, often on a schedule

An app can be perfectly exempt from Doze and still be killed every night at 2am by a manufacturer's "phone manager" doing its cleanup round. Conversely, getting onto the OEM allowlist does not exempt you from Doze. You need both, and on several ROMs there is a third thing, pinning the app in the recents list, that has no API whatsoever.

The part that has an API

Battery optimisation is the well-behaved half. The app declares REQUEST_IGNORE_BATTERY_OPTIMIZATIONS in the manifest and then walks a three-step ladder, each rung less specific than the last:

@android.annotation.SuppressLint("BatteryLife")
private fun requestIgnoreBatteryOptimizations(): Boolean {
    return try {
        if (isIgnoringBatteryOptimizations()) return true
        val i = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
            data = Uri.parse("package:$packageName")
        }
        startActivity(i)
        true
    } catch (e: Exception) {
        openBatteryOptimizationSettings()
    }
}

private fun openBatteryOptimizationSettings(): Boolean {
    return try {
        startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
        true
    } catch (e: Exception) {
        openAppDetailsSettings()
    }
}

Three things in there are deliberate. The first line checks whether the exemption is already granted, because launching a settings dialog at somebody who has already granted it is how an app teaches its user to dismiss its dialogs. The lint suppression is honest: Google flags direct use of this intent because they do not want apps demanding it casually, and this is one of the cases where demanding it is the entire point. And each catch falls through to something less specific rather than reporting failure, so the worst case is that the operator lands on the app details page and can still get there manually.

The state is queryable, which means the dashboard can show it live rather than assuming:

private fun isIgnoringBatteryOptimizations(): Boolean {
    return try {
        val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
        pm.isIgnoringBatteryOptimizations(packageName)
    } catch (e: Exception) {
        false
    }
}

That single boolean sits on the gateway's dashboard next to the queue counts, because the operator needs to know it is still true. Users turn things off. Phones get reset. A setting granted in March is not a setting that is granted today.

The part that has no API

Now the other half. There is no Settings.ACTION_AUTOSTART. What exists instead is, on each ROM, an activity somewhere inside the manufacturer's security or system-manager package, with a name that is not documented, is not stable across versions, and is not the same on two brands that share an underlying ROM.

The only approach available is to hold a list, try each candidate, and check whether the device can actually resolve it before launching:

for (c in candidates) {
    try {
        val i = Intent().apply {
            component = c
            flags = Intent.FLAG_ACTIVITY_NEW_TASK
        }
        if (packageManager.resolveActivity(i, 0) != null) {
            startActivity(i)
            return true
        }
    } catch (e: Exception) {
        // try next
    }
}
// Fallback: at least open our app's settings so the user can find autostart.
return openAppDetailsSettings()

The resolveActivity check is what makes this tolerable rather than reckless. Without it you are firing explicit intents at components that may not exist, catching ActivityNotFoundException a dozen times, and hoping none of the ROMs decides to crash instead of throwing. With it, you ask first and only launch what the device says it has.

The real list in the shipped app:

BrandPackageActivity
Xiaomi, MIUI, Redmi, POCOcom.miui.securitycentercom.miui.permcenter.autostart.AutoStartManagementActivity
Oppo, ColorOS, Realmecom.coloros.safecenter...safecenter.permission.startup.StartupAppListActivity
Oppo, older ColorOScom.coloros.safecenter...safecenter.startupapp.StartupAppListActivity
Oppo, older stillcom.oppo.safe...safe.permission.startup.StartupAppListActivity
Vivocom.vivo.permissionmanager...permissionmanager.activity.BgStartUpManagerActivity
Vivo, iQOOcom.iqoo.secure...secure.ui.phoneoptimize.AddWhiteListActivity
Vivo, iQOO variantcom.iqoo.secure...secure.ui.phoneoptimize.BgStartUpManager
Huawei, Honorcom.huawei.systemmanager...systemmanager.startupmgr.ui.StartupNormalAppListActivity
Huawei, oldercom.huawei.systemmanager...systemmanager.optimize.process.ProtectActivity
Samsungcom.samsung.android.lool...sm.ui.battery.BatteryActivity
Samsung, China ROMcom.samsung.android.sm_cn...sm.ui.battery.BatteryActivity
Asuscom.asus.mobilemanager...mobilemanager.MainActivity
Letvcom.letv.android.letvsafe...letvsafe.AutobootManageActivity
Transsion: Tecno, Infinix, itelNo public component. Falls through.

Look at how many rows are the same brand at a different ROM generation. Oppo appears three times, Vivo three times, Huawei twice, Samsung twice. That is the shape of the problem: this is not thirteen manufacturers, it is a handful of manufacturers times however many times they have renamed the screen. The list is not something you write once. It is something that decays, and the only way you find out it has decayed is that a phone in a shop somewhere stops forwarding payments.

The fallback ladder for opening an OEM autostart screen A flow starting from the operator tapping the fix reliability button. The app walks an ordered list of candidate components, checking each with resolveActivity. If one resolves it is launched and the operator lands on the autostart screen. If none resolves, the app opens the application details settings page and shows a written walkthrough instead, which is what happens on Transsion devices. Operator taps "Fix reliability" Next candidate component resolveActivity(intent, 0) != null ? yes startActivity autostart screen opens no More candidates left? yes, loop no candidate resolved openAppDetailsSettings() plus the written walkthrough in the app On a Tecno or an Infinix this is not the exception path. It is the only path, so it has to be designed as carefully as the happy one. Nothing ever reports "could not open settings" and stops.
The measure of this code is not how many brands it deep-links. It is how good the bottom of the ladder is.

The brand that is not on the list

Transsion, the group behind Tecno, Infinix and itel, exposes nothing. There is no component to resolve, so every candidate fails and the ladder falls through to the app details page.

If you build for a European or North American market, that reads like a minor gap. If you build for Africa, it is not a gap, it is a large share of the phones your software will actually run on. Transsion brands are everywhere in the market this gateway serves, and they are exactly the price bracket a shop buys when it needs a second phone to sit on the counter. Which means the fallback path is not a courtesy for unusual devices. It is the main path.

That reframing changes what you build. If the fallback is an edge case, a toast saying "please enable autostart" is acceptable. If the fallback is the common case, it has to be a proper screen: the app's Reliability tab, with steps in plain language, in an order that matches what the operator will actually see, and a live indicator of the things that can be checked so at least part of the walkthrough verifies itself.

What the app can verify and what it has to ask the operator to confirm Two columns. On the left, the items the app can check itself: whether the foreground service is running, whether the battery optimisation exemption is granted, whether the last heartbeat succeeded, and how long ago the service last reported being alive. On the right, the items with no API at all: the OEM autostart allowlist, whether the app is pinned in recents, and whether the manufacturer's own cleanup schedule has been told to skip this app. The app can verify these Foreground service running Battery optimisation exemption Last heartbeat, and whether it was OK Service alive within 3 minutes No API exists for these On the OEM autostart allowlist Pinned in the recents list Excluded from the nightly cleanup Whether the app was ever force-stopped Because the right-hand column is unknowable, the watchdog on the left is the only evidence that any of it went wrong.
Half the reliability checklist is unobservable. The design answer is to observe the consequence instead.

Designing for the thing you cannot check

You cannot ask whether the app is on the autostart allowlist. You can, however, notice when it stops running, and that is what the gateway does instead.

The foreground service writes a timestamp to shared preferences every 60 seconds, and the dashboard treats the service as stale after three minutes of silence. Separately, the five-minute heartbeat means the backend also notices, which is the version that matters, because the person who can drive to the shop is not the person holding the phone.

This is the general shape of the answer whenever a platform hides state from you. Stop trying to read the setting. Measure the outcome the setting affects, and alarm on that. It is less satisfying, it catches every cause including the ones you have not enumerated, and it keeps working when the manufacturer renames the screen again.

The one case it does not fix is a force-stop, because after an OEM force-stop the broadcast receiver will not fire until a human opens the app. There is no code that works around that. What the app does instead is make reopening it do the right thing: the UI bootstrap calls startService on every launch, harmlessly if it is already running, so the single action an operator is most likely to take, opening the app to see whether it is working, is also the action that fixes it.

// Ensure the gateway service is running (also recovers after a force-stop,
// the only time the user reopens the app). Harmless if already running.
try {
  await NativeBridge().startService();
} catch (e) {
  AppLog.w('Bootstrap', 'startService failed: $e');
}

What I would do differently

I would report the manufacturer in the heartbeat and act on it centrally. The app already reads Build.MANUFACTURER and a full device fingerprint for its About screen, and the heartbeat already carries device health. Putting the manufacturer in that payload would mean the backend could answer a question no individual phone can: are Infinix devices in this fleet going quiet more often than Samsungs? That is the only realistic way to find out that a component name has gone stale, and it is a small change I have not made.

I would also stop treating the candidate list as code. It is data that decays independently of every release, and it is currently compiled into the APK, which means updating it requires shipping a new build to every phone in the field. Fetching it from the same backend that serves the version feed would let a new ROM be supported the day someone reports it. The trade is that you are then launching activities named by a remote server, which is a genuinely uncomfortable thing to do and would need the list signed and constrained to a known set of packages. I have not decided that the convenience is worth it, which is why the list is still hard-coded.

What I would not change is the bottom of the ladder. Every branch of that code ends somewhere the operator can act, and nothing ever tells them it failed and leaves them there. For the reliability work that all of this protects, see the six failure modes article, and for the wider picture of what this phone is actually doing, the gateway overview.

Frequently asked questions

Is there an official Android API to open the OEM autostart screen?
No. Autostart and protected-app allowlists are manufacturer additions with no entry in the platform settings API. The only way in is to construct an explicit intent for an undocumented component name that varies by brand and by ROM version, check whether it resolves on this device, and fall back when it does not.
What do I do about Tecno and Infinix phones?
Fall back to a walkthrough. Transsion brands expose no public component for their autostart screen, so there is nothing to deep-link to. The honest design is to detect that no candidate resolved, open the app details settings page, and put a plain-language instruction list in the app so the operator can find the setting themselves. This matters more in Africa than the market share tables from elsewhere would suggest.
Is REQUEST_IGNORE_BATTERY_OPTIMIZATIONS enough on its own?
No. The Doze exemption stops the platform's own battery optimisation from deferring your work. It has no effect on a manufacturer's separate app-management layer, which will still stop the app on its own schedule. You need both, and on some ROMs you also need the app pinned in the recents list.
How do I know whether the exemption actually worked?
Query it rather than trusting the intent result. PowerManager.isIgnoringBatteryOptimizations returns the real state, so the app can show a live indicator on its dashboard instead of assuming that launching a settings screen changed anything. For the OEM autostart allowlist there is no equivalent query, which is why the app has to watch for the service dying instead.
#Android#Kotlin#Reliability#UX#Background Jobs
Keep reading

Related articles