blog.ganska.latRSS

Reading Tasker's APK: 13 broadcasts in the manifest, 60 more in a service

What we’re building

$ python3 manifest-actions.py | tail -1
13 system broadcasts on manifest receivers

$ grep -ohP '"(android|com\.android)\.[a-zA-Z0-9_.]+"' MonitorService.java \
    | tr -d '"' | grep -vE '\.(extra|EXTRA)|EXTRA_|\.permission\.' | sort -u | wc -l
69

$ comm -13 manifest-actions.txt service-actions.txt | grep -E 'SCREEN|BATTERY|POWER'
android.intent.action.ACTION_POWER_CONNECTED
android.intent.action.ACTION_POWER_DISCONNECTED
android.intent.action.BATTERY_CHANGED
android.intent.action.SCREEN_OFF
android.intent.action.SCREEN_ON

A reproducible way to read a closed-source Android app’s trigger architecture out of its APK. The example here is Tasker 6.6.20, because I am building a replacement for it and needed to know how it does the one thing the documentation never explains: where the events actually come from.

The answer is the three commands above. Thirteen system broadcasts reach Tasker through its manifest. Sixty more are named only inside a foreground service, which means they arrive only while that service is alive. Screen on, screen off, power connected and battery changed are all in the second group.

Why it works this way

Two decoders, not one, because an APK is two different problems in one zip. The binary AndroidManifest.xml and the 10 MB resources.arsc need apktool, which knows the binary XML and resource-table formats. The four classes*.dex files need jadx, which lifts Dalvik bytecode back to Java. Run each with the other’s job switched off: apktool d -s skips smali disassembly, and jadx --no-res skips the resource table. The manifest then lands long before the decompiler finishes, which matters, because the manifest is what tells you where to look in the output.

The alternative was reading the documentation, and it loses for a specific reason. Tasker’s user docs describe what a trigger does, not how it is delivered, and the delivery is the entire question if you are writing your own. The community-maintained reference for its export format lags the current release. The APK is neither: it is exactly the shipped behaviour, and the manifest in particular cannot lie, because the system reads the same file to decide what to deliver.

One thing to be clear about, since this is someone else’s commercial product. Nothing below reproduces their code. What a manifest declares, which broadcasts a service names, and how many strings a resource table holds are observations about behaviour, and they are the only kind of finding worth writing down anyway.

What you need

Thing Version Where it comes from
nix 2.34.8 nix --version
jadx 1.5.6 nix shell nixpkgs#jadx -c jadx --version
apktool 3.0.3 nix shell nixpkgs#apktool -c apktool --version
python3 3.13.14 python3 --version

unzip and curl you already have. Disk is the one to watch: the decompiled output is several times the size of the APK, and this teardown produced 165 MB of Java from a 31 MB download.

How the pieces fit

One APK, three decoders, four questions. The manifest answers what the system delivers when nothing is running. The decompiled sources answer what needs a live process, and what the app documents about itself. The string table answers what the app thinks its own vocabulary is.

flowchart TD
    APK[Tasker.6.6.20.apk] --> UZ[unzip]
    APK --> AT["apktool d -s"]
    APK --> JX["jadx --no-res"]

    UZ --> ASSETS["assets/"]
    AT --> MAN[AndroidManifest.xml]
    AT --> STR["res/values/strings.xml"]
    JX --> SRC["sources/"]

    MAN --> Q1{"What arrives cold?<br/>step 2"}
    SRC --> Q2{"What needs a live process?<br/>step 3"}
    STR --> Q3{"What is the vocabulary?<br/>step 4"}
    SRC --> Q4{"What does it document?<br/>step 5"}
    ASSETS --> Q5{"What does it ship?<br/>step 6"}

Trace one question end to end. Ask whether a screen-off trigger can fire on a phone where nothing of Tasker’s is running. Grep the decoded manifest for SCREEN_OFF: no hit, so no manifest receiver is registered for it and the system will never start the app to deliver it. Grep the decompiled sources for the same string: it appears in MonitorService.java, the class the manifest declares as a foreground service with six service types. So the answer is no, and the mechanism is a runtime registerReceiver from a service that has to already be alive. That is one grep in each half of the extraction, and it is the shape of every other answer in this article.

Step 1: Fetch the APK and confirm what is inside it

The goal: a verified local copy, and a first read of its scale before spending 25 minutes decompiling it.

mkdir -p work && cd work
curl -sSL -o Tasker.6.6.20.apk \
  https://tasker.joaoapps.com/releases/playstore/Tasker.6.6.20.apk
sha256sum Tasker.6.6.20.apk
unzip -l Tasker.6.6.20.apk | grep -E '\.dex|lib/arm64|resources.arsc'

Check it:

$ sha256sum Tasker.6.6.20.apk
a2e7623f0adec61e6726dfc7c5c0389e7a0193066d95c627155a03d7b35042ea  Tasker.6.6.20.apk

$ unzip -l Tasker.6.6.20.apk | grep -E '\.dex|lib/arm64|resources.arsc'
  7969040  classes.dex
  7060676  classes2.dex
  8103620  classes3.dex
   477048  classes4.dex
 26543344  lib/arm64-v8a/libCHIPController.so
   340704  lib/arm64-v8a/libSetupPayloadParser.so
    10096  lib/arm64-v8a/libandroidx.graphics.path.so
  1021888  lib/arm64-v8a/libc++_shared.so
 10280964  resources.arsc

Four dex files totalling 23 MB is the number that sets your expectations for jadx. The 26 MB libCHIPController.so is Matter, the smart-home protocol: one feature, and the single largest entry in an archive whose contents come to 80,079,656 bytes across 4812 files.

Step 2: List what the manifest delivers cold

The goal: the exact set of system broadcasts that reach the app when none of its processes are running.

nix shell nixpkgs#apktool -c apktool d -s -f -o apktool_out Tasker.6.6.20.apk

-s is the load-bearing flag. Without it apktool also disassembles all four dex files to smali, which triples the runtime and produces something you are about to get in better form from jadx.

Then read the decoded manifest structurally rather than with grep, because intent filters nest:

import xml.etree.ElementTree as ET

NS = '{http://schemas.android.com/apk/res/android}'
app = ET.parse('apktool_out/AndroidManifest.xml').getroot().find('application')

actions = set()
for r in app.findall('receiver'):
    name = r.get(NS + 'name')
    if not name.startswith(('net.dinglisch', 'com.joaomgcd')):
        continue
    for f in r.findall('intent-filter'):
        for a in f.findall('action'):
            value = a.get(NS + 'name')
            if value.startswith('android.'):
                actions.add(value)

for a in sorted(actions):
    print(a)
print(len(actions), 'system broadcasts on manifest receivers')

The package-name filter matters. The manifest declares 30 receivers, but most belong to bundled libraries: WorkManager’s constraint proxies, Glance, Firebase, profileinstaller. Only the ones under the app’s own package tell you anything about its design.

Check it:

$ python3 manifest-actions.py
android.app.action.DEVICE_ADMIN_ENABLED
android.app.action.NEXT_ALARM_CLOCK_CHANGED
android.appwidget.action.APPWIDGET_UPDATE
android.intent.action.BOOT_COMPLETED
android.intent.action.DATE_CHANGED
android.intent.action.MY_PACKAGE_REPLACED
android.intent.action.NEW_OUTGOING_CALL
android.intent.action.PACKAGE_ADDED
android.intent.action.PACKAGE_REMOVED
android.intent.action.PACKAGE_REPLACED
android.intent.action.PHONE_STATE
android.intent.action.TIMEZONE_CHANGED
android.intent.action.TIME_SET
13 system broadcasts on manifest receivers

Strip the widget update and the device-admin handshake and you have eleven, which is close to the complete list of implicit broadcasts Android still delivers to a manifest receiver: boot, package changes, phone state, outgoing call, and the date and time family. There is no screen state here, no battery, no connectivity, no bluetooth. An app with 92 named events, which step 4 counts, gets thirteen of them delivered cold.

Step 3: Find what only arrives while a process is running

The goal: the other set, and the class that owns it.

Start jadx in the background. It has 23 MB of dex to get through, and produces 11710 Java files and 165 MB of source, with 241 classes it cannot fully decompile:

nix shell nixpkgs#jadx -c \
  jadx --no-res --no-imports -j 8 -d jadx_out Tasker.6.6.20.apk

While that runs, the manifest already told you where to look. The service declaration names the class and its six foreground service types, which is a strong hint that it is doing more than one job:

net.dinglisch.android.taskerm.MonitorService  exported=false
  fgType=mediaPlayback|location|mediaProjection|camera|microphone|specialUse

When jadx finishes, pull every action-shaped constant out of that one file:

cd jadx_out/sources/net/dinglisch/android/taskerm
grep -ohP '"(android|com\.android)\.[a-zA-Z0-9_.]+"' MonitorService.java \
  | tr -d '"' \
  | grep -vE '\.(extra|EXTRA)|EXTRA_|\.permission\.|^android\.intent\.category' \
  | sort -u > /tmp/service-actions.txt
wc -l < /tmp/service-actions.txt

The exclusion list is doing real work. Without it the same grep returns 90: string constants in a decompiled class include extras keys (android.bluetooth.adapter.extra.STATE), permission names and intent categories, none of which are broadcasts. What survives is action-shaped, and it includes both what the service registers for and what it sends, so treat it as an upper bound rather than a registration list.

Check it, and take the set difference against step 2:

$ wc -l < /tmp/service-actions.txt
69

$ LC_ALL=C comm -13 /tmp/manifest-actions.txt /tmp/service-actions.txt | wc -l
60

$ LC_ALL=C comm -13 /tmp/manifest-actions.txt /tmp/service-actions.txt \
    | grep -E 'SCREEN|BATTERY|POWER|HEADSET|RINGER|AIRPLANE|DOCK'
android.intent.action.ACTION_POWER_CONNECTED
android.intent.action.ACTION_POWER_DISCONNECTED
android.intent.action.AIRPLANE_MODE
android.intent.action.BATTERY_CHANGED
android.intent.action.DOCK_EVENT
android.intent.action.HEADSET_PLUG
android.intent.action.SCREEN_OFF
android.intent.action.SCREEN_ON
android.media.RINGER_MODE_CHANGED
android.os.action.POWER_SAVE_MODE_CHANGED

LC_ALL=C is not decoration. comm requires both inputs in the same collation order as its own comparison, and a locale-sorted file silently produces a wrong count with a warning you will read past.

Sixty actions named in a service and not in the manifest. This is not a design preference, it is the Android 8 implicit broadcast ban: those broadcasts stopped being deliverable to manifest receivers in 2017, and the only remaining path is registerReceiver from a live process. A permanent foreground service with a permanent notification is the price of a screen-off trigger, and Tasker pays it.

Step 4: Read the trigger vocabulary out of the string table

The goal: what the app thinks its own trigger taxonomy is, without opening the UI.

Resource strings are named by the developer, and the prefixes leak the internal model. Histogram them:

cd apktool_out
grep -oP '(?<=<string name=")[a-z_0-9]+(?=")' res/values/strings.xml \
  | sed 's/_.*//' | sort | uniq -c | sort -rn | head -6

Check it:

$ grep -oP '(?<=<string name=")[a-z_0-9]+(?=")' res/values/strings.xml \
    | sed 's/_.*//' | sort | uniq -c | sort -rn | head -6
    667 pl
    432 an
    363 ml
    252 dc
    205 settings
    173 word

Three of those prefixes are the model. an_ is action names, en_ is event names, sn_ is state names. Count them:

$ for p in en sn an; do
>   printf '%s %s\n' "$p" "$(grep -c "<string name=\"${p}_" res/values/strings.xml)"
> done
en 92
sn 51
an 432

Ninety-two events, fifty-one states, four hundred and thirty-two actions. The split between the first two is the finding. Events are edge-triggered and fire once: screen_on, received_sms, nfc_tag. States are level-triggered and hold over an interval: wifi_connect, battery_level, docked, headset. A profile with a state condition gets an exit task for free, because a state has an end and an event does not.

That is a modelling decision rather than a UI one, and it is legible from the resource table without opening the app.

Step 5: Extract the format documentation the app ships with

The goal: the app’s own description of its data model, if it has one.

Tasker 6.6 has an AI feature that generates automations, which means it has to explain its own export format to a language model, which means that explanation is compiled into the APK as string literals. Find the class:

cd jadx_out/sources
grep -rl 'json-schema.org' --include='*.java' .

Then pull every long literal out of it, unescaping as you go:

import codecs, os, re

src = open('com/joaomgcd/oldtaskercompat/aigenerator/a.java').read()
literals = re.findall(r'"((?:[^"\\]|\\.)*)"', src)

os.makedirs('extracted', exist_ok=True)
for i, lit in enumerate(l for l in literals if len(l) > 400):
    text = codecs.decode(lit, 'unicode_escape')
    open(f'extracted/aigen_{i:02d}.txt', 'w').write(text)
    print(i, len(text), repr(text[:60]))

Check it:

$ python3 extract-literals.py
0 26867 '## System Instructions for Tasker AI Generator\n\n**Core Goal:'
1 20750 '\n    {\n  "$schema": "http://json-schema.org/draft-07/schema#'
2 15329 '\n$$$------$$$$\n\n    When connected to home Wifi (in this cas'
...

Twelve files, 187,670 bytes by wc -c, including a complete JSON Schema of the export format and prose sections on condition operators, variable scoping and pattern matching semantics. It is better documentation of the format than anything published, and it is sitting in the string pool.

Step 6: Diff the findings against your own design

The goal: turn observations into decisions, which is the only reason to have done any of this.

I am building a replacement for one phone: automations written as typed Python, compiled to a validated JSON artifact, baked into an APK by Nix. Three of the findings landed directly on it.

The first was flattering. Tasker’s artifact identifies an action by a numeric code with positional arguments, arg0 through argN, and the argument names live only in a runtime table inside the app. Mine uses names, checked by the compiler before the artifact exists. The documentation blobs from step 5 exist partly because a positional numeric format has to be explained at length to anything that reads it.

The second was a straightforward gap. My build already computes the manifest’s permission block from the compiled artifact rather than trusting anyone to keep two files in sync:

jq -r '.permissions[] | "    <uses-permission android:name=\"" + . + "\" />"' \
  assets/config.json > perms.xml

Tasker does the same thing one level finer. Its app-export feature keeps one manifest fragment per component under assets/kid/manifest/, with placeholder tokens substituted per generated app. Same technique, applied to receivers and services rather than only permissions. My build ships an SMS receiver in every APK whether or not any automation uses SMS.

The third was the one worth the whole exercise, and it was in my own code rather than theirs. My design has always said that states and events are different types, with states usable as conditions. Reading Tasker’s 51 states sent me back to check, and the validator rejects exactly that:

if len(path) != 2 or path[0] != definition.ns or path[1] not in definition.event_fields:

A condition may only name fields of the trigger that fired. The engine agrees with it:

if (ns != event.ns) {
    return MatchResult.Failed(ns, "condition is on namespace '$ns', event is '${event.ns}'")
}

So the state column of my own design table is a build error today, and has been since the checks were written. Two one-line comparisons, plus the real work of having somewhere for the state to live. I would not have gone looking without a competitor’s string table telling me it had fifty-one of them.

File formats

The export format, as its own shipped schema describes it. This is a skeleton with the optional attributes elided, not a valid export. Every element carries sr, a source reference used as an index:

<TaskerData sr="" dvi="1" tv="6.6.20">
  <Profile sr="prof75" ve="2">
    <id>75</id>
    <nme>Arriving home</nme>
    <mid0>76</mid0>          <!-- entry task id -->
    <mid1>77</mid1>          <!-- exit task id, states only -->
    <State sr="con0" ve="2">
      <code>165</code>       <!-- which state, numerically -->
      <pin>true</pin>        <!-- present only when inverted -->
    </State>
  </Profile>
  <Task sr="task76">
    <id>76</id>
    <Action sr="act0" ve="7">
      <code>547</code>       <!-- Variable Set -->
      <se>false</se>         <!-- continue on error -->
      <Str sr="arg0" ve="3">%myvar</Str>
    </Action>
  </Task>
</TaskerData>

mid0 and mid1 are the mechanism behind the event and state split: one profile declaration, two task references, and the second only means anything for a condition that has an end.

The spec table, which is how the app knows what arg0 of a given action code means. Every action is one constructor call whose trailing varargs repeat in groups of five, one group per argument. Action 61 is Vibrate, and it takes exactly one:

new y0(61, R.string.an_vibrate, 10, 4, "vibrate",
       0, Integer.valueOf(R.string.pl_time), "1:1000:200", 0, 1);
//     ^  ^                                  ^             ^  ^
//     |  |                                  |             |  flags
//     |  |                                  |             flags
//     |  |                                  constraint
//     |  label, as a resource id
//     argument type

an_vibrate resolves to “Vibrate” and pl_time to “Time”, so the whole parameter dialog is reconstructable from this one line. The constraint is a mini-language: 1:1000:200 is minimum, maximum and default, so vibrate durations are clamped to 1 to 1000 ms and default to 200. Elsewhere 0:255 bounds a brightness, t:1:? is a one-line optional text field, uvar:1 means the value must name a user variable. It is the same idea as attaching bounds to a parameter declaration, expressed as an unparsed string and checked at runtime rather than at build time.

What bit us

grep '"$schema"' finds nothing in decompiled Java. The quotes inside a Java string literal are escaped in the source text, so the bytes on disk are \"$schema\". Grep for the unescaped token (json-schema.org) or for the escaped form, never for what you expect the value to look like. This cost a full pass over 165 MB before it was obvious.

Locale-sorted input makes comm lie. It warns on stderr and still prints a number. Both inputs need LC_ALL=C, and so does comm itself.

jadx renames integer literals after whatever constant happens to match. The spec table above reads cleanly, but a neighbouring entry decompiles as new y0(115, R.string.an_test, bsh.org.objectweb.asm.Constants.F2L, 4, ...). There is no bytecode manipulation in that line. The source had a plain integer, and jadx substituted a same-valued constant it found on the classpath, which here is BeanShell’s bundled ASM because the app embeds BeanShell for its Java-code action. Resolve the constant before believing a number in decompiled output.

R8 flattens package names, so the manifest is your index. Almost everything in the decompiled tree is ah.java, bk.java, c2.java. What survives with real names is precisely what the manifest references by name, because the system resolves those strings at runtime. Every useful entry point in this teardown came from reading the manifest first and grepping for that class second.

A generated block beats a transcribed one, and this is where I learned it. From the commit that introduced the jq line above: the manifest was hand-written while the permission list was already computed from the declared automations, so the two could disagree with nothing saying so, and a trigger whose permission nobody remembered to copy across produces an app that compiles, installs and never fires. The substitution uses --replace-fail rather than --replace so that editing the marker away stops the build instead of shipping an APK holding three permissions and no triggers. Finding the same pattern in a mature commercial app was the strongest signal in the teardown that it generalises.

References