Skip to content

Latest commit

 

History

43 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

frider

 ______        _      _
|  ___|      (_)    | |
| |_    _ __  _   __| |  ___  _ __
|  _|  | '__|| | / _` | / _ \| '__|
| |    | |   | || (_| ||  __/| |
\_|    |_|   |_| \__,_| \___||_|
 Android app framework detector · v0.4.0

tests pypi python license

Android app framework ider — classifies the UI framework of an Android app from its APK entry names: Flutter / Dart, React Native (reporting the Hermes vs JavaScriptCore engine split most detectors collapse), .NET MAUI, Xamarin, Apache Cordova, Capacitor, Ionic, Kony (Temenos), Lynx (ByteDance), NativeScript, Qt, Titanium, Unity, or native Java/Kotlin. Zero runtime dependencies — pure Python standard library.

Fingerprints are data, not code: rules live in frider/rules.json, so anyone can add a detector without touching Python.

Why this exists

Framework knowledge decides which toolchain applies to an app (Dart cert-verify hooks vs Hermes JS surface vs a native host). Naive detectors — including the android-framework-detector project this is modelled on — classify anything with libreactnative* as "React Native" and stop there. That is wrong half the time in practice: JavaScriptCore and Hermes have different runtime surfaces, and apps built on Cordova or Kony look like "native" to a Flutter/RN-only scan (three real apps were mislabelled that way the first time this tool ran).

frider therefore reports:

  • the framework verdict,
  • the React Native JS engine (hermes vs jsc),
  • whether the Kotlin runtime is packaged (see Known limits for what that does and does not imply),
  • embedded JS runtimes that are not the framework (Duktape, J2V8),
  • notable third-party native libs (RootBeer, AppGuard, RiskStub, SecNeo DexShield, PairIP, EverSafe, DataVisor, Vkey) as an informational side channel.

Against a device it also lists what is installed (--adb --list) without pulling anything, so you can see the scope of a scan before paying for it.

Install

Requirements: Python 3.9–3.14 (python3 --version to check) and nothing else — frider has zero runtime dependencies, so installs are instant and there is no dependency hell. Every release is tested on 3.9 through 3.14, on Linux, macOS and Windows.

Option 1 — pipx (recommended, isolated CLI install)

pipx installs the command into its own environment so it never touches your other Python packages:

pipx install frider
frider --version

Option 2 — uv tool

uv tool install frider
frider --version

Option 3 — plain pip

Prefer a virtual environment so the frider script lands on your PATH:

python3 -m venv .venv && source .venv/bin/activate   # Windows: .venv\Scripts\activate
pip install frider
frider --version

Option 4 — unreleased main, or a clone

To run ahead of the latest release, install from git:

pipx install git+https://github.com/hdyrawan/frider.git   # or uv tool / pip

Or from a clone of this repo:

git clone https://github.com/hdyrawan/frider.git && cd frider
pip install .
frider --version

Option 5 — run without installing

From a clone you can also run it directly, no install step at all:

git clone https://github.com/hdyrawan/frider.git && cd frider
python3 -m frider app.apk

For development

git clone https://github.com/hdyrawan/frider.git && cd frider
python3 -m venv .venv && source .venv/bin/activate
pip install -e .           # editable: code changes apply immediately
python3 -m pytest tests/   # run the test suite

Verify & uninstall

frider --version        # e.g. "frider 0.4.0"
frider --help           # full usage
pipx uninstall frider   # or: uv tool uninstall frider / pip uninstall frider

adb mode (--adb) additionally needs the Android adb binary on your PATH and a reachable device — the APK-file modes need nothing but Python.

Usage

# a single APK
frider app.apk

# a directory / split-APK pull (every apk_*.apk is scanned as one set)
frider pulled-apks/

# an XAPK/APKS container (nested APKs are surfaced automatically)
frider app.xapk

# machine-readable
frider app.apk --json

# what is installed on a device, without pulling anything
frider --adb --serial <serial> --list

# custom rules
frider --rules /path/to/rules.json app.apk

Input modes

A directory is one app, not many. Every APK inside is unioned, which is what makes a split install classify correctly — the manifest lives in the base APK and the framework libraries in the config splits:

$ ls pulled/
apk_0.apk   apk_1.apk   apk_2.apk

$ frider pulled/
| pulled | Flutter / Dart | High | flutter:lib/arm64-v8a/libflutter.so (+2) |
1 source(s): 1 flutter / dart

Containers are walked into. APKs nested in an XAPK/APKS are surfaced with a container!inner path:

com.example.app.apk!AndroidManifest.xml
com.example.app.apk!lib/arm64-v8a/libflutter.so

Rules match the part after the last !, so a nested APK is fingerprinted exactly like a flat one.

adb mode — classify installed packages

# classify specific installed packages (pulls via `pm path`, needs adb + device)
frider --adb --serial <serial> com.example.app com.example.other

# serial from the environment
export ANDROID_PROBE_SERIAL=<serial>
frider --adb com.example.app

# every third-party package on the device
frider --adb --all

See what is installed before scanning it with --list, which costs one adb call and pulls nothing — where --all pulls every APK on the device:

frider --adb --list           # third-party packages: exactly what --all would scan
frider --adb --list-all       # including system packages
frider --adb --list --json    # {"schema_version": 1, ..., "packages": [...]}
$ frider --adb --list
+------------------------------+
| package                      |
+------------------------------+
| com.example.app              |
| com.example.other            |
+------------------------------+
2 third-party package(s)

Progress goes to stderr, the table to stdout, so --adb ... > report.txt keeps the two apart. The banner prints on stderr too, on every run, so --json stays pipeable into jq and a table stays parseable — banner or not.

A package that cannot be pulled becomes its own ERROR row — one failure never aborts the batch, and never silently reports as native:

$ frider --adb --serial emulator-5554 --all
pulling com.bank.mobile ...
  ok (2 apks)
pulling com.old.legacy ...
  not installed
pulling com.shop.app ...
  error: command failed: adb -s emulator-5554 pull ... Permission denied

| com.bank.mobile | Flutter / Dart | High | flutter:lib/arm64-v8a/libflutter.so (+1)     |
| com.old.legacy  | ERROR          | -    | errors=not installed                         |
| com.shop.app    | ERROR          | -    | errors=command failed: ... Permission denied |
3 source(s): 1 flutter / dart, 2 error(s)

Pulled APKs are cached under ~/.cache/frider/ (or $XDG_CACHE_HOME/frider), so a second run over the same packages does not re-pull. The package directory is cleared before each pull, so a stale split from an older version of an app can never be classified as part of the current one. Pass --cache-dir DIR to point elsewhere or --no-cache for a throwaway pull.

Example output

+------------------------+-------------------------------+------------+-------------------------------------------------------------------------------------+
| source                 | verdict                       | confidence | markers                                                                             |
+------------------------+-------------------------------+------------+-------------------------------------------------------------------------------------+
| banking-flutter.apk    | Flutter / Dart                | High       | flutter:lib/arm64-v8a/libflutter.so (+2)                                            |
| shop-rn-hermes.apk     | React Native (hermes)         | High       | react-native:assets/index.android.bundle (+3)                                       |
| legacy-rn-jsc.apk      | React Native (jsc)            | High       | react-native:assets/index.android.bundle (+2)                                       |
| crm-maui.apk           | .NET MAUI                     | High       | maui:assemblies/Microsoft.Maui.dll (+1); xamarin:lib/arm64-v8a/libmonodroid.so (+2) |
| kiosk-cordova.apk      | Apache Cordova                | High       | cordova:assets/www/index.html (+2); ionic:assets/www/build/vendor/ionic.bundle.js   |
| field-nativescript.apk | NativeScript                  | High       | nativescript:lib/arm64-v8a/libNativeScript.so (+2)                                  |
| teller-kony.apk        | Kony (Temenos)                | Medium     | kony:lib/arm64-v8a/libkony.so                                                       |
| social-lynx.apk        | Lynx (ByteDance)              | High       | lynx:lib/arm64-v8a/liblynx.so (+2)                                                  |
| plain-native.apk       | Native (no framework markers) | High       | -                                                                                   |
| split-fragment.apk     | Native (no framework markers) | Low        | -                                                                                   |
| truncated.apk          | ERROR                         | -          | -                                                                                   |
+------------------------+-------------------------------+------------+-------------------------------------------------------------------------------------+
11 source(s): 2 react native, 2 native, 1 flutter / dart, 1 .net maui, 1 apache cordova, 1 nativescript, 1 kony, 1 lynx, 1 error(s)

A notes column follows markers (elided above for width) carrying the JS engine, Kotlin metadata, embedded JS runtimes, notable native libraries and any error text.

The markers column shows the actual APK entries that matched (up to 8 per framework, with a (+N) overflow count) — not the rule regexes. Colors are enabled automatically when stdout is a TTY; disable with --no-color or the NO_COLOR env var. A one-line tally follows the table.

Confidence

Confidence describes how sure frider is of the verdict it reported — never which answer it happened to reach.

meaning
High two or more markers for the winning framework — or, for a native verdict, a complete APK (manifest and dex) in which no marker appeared at all
Medium exactly one marker for the winning framework
Low could not tell. The input was too thin to settle the question — a fragment, or a resource-only split with no code in it

Low never means "the answer was native". Absence of every marker across a package that frider fully read is real evidence, and reports High.

Known limits

Classification reads entry names only, never file contents, which bounds what can be distinguished:

  • .NET MAUI vs plain .NET Android. MAUI is named only when its assemblies ship as individual .dll entries. Release builds default to AndroidUseAssemblyStore, which packs them into assemblies/*.blob — the names are inside the blob, so those apps report xamarin (accurate: they are .NET) rather than maui.
  • Inside a container, matched_files records the innermost path. A match found in app.apk!lib/.../libflutter.so is reported as lib/.../libflutter.so, so for a multi-split XAPK the result does not say which nested APK it came from.
  • Kotlin Multiplatform and Compose Multiplatform are not detected, and deliberately have no rules. They compile to ordinary Android code with no distinguishing entry names; any marker specific enough to be safe would miss most builds, and anything broader would fire on plain Kotlin apps. Adding a guess here would be worse than reporting native.
  • A build that excludes Kotlin's packaged resources reads kotlin: false. Every Kotlin marker is a resource entry, and a build can drop all of them (packagingOptions { resources.excludes += ["kotlin/**", "META-INF/*.version"] }, common in size-tuned apps). The Kotlin code is then visible only inside the dex, which classification never reads. Measured on a 141-app device sweep: 8 of 132 apps with readable dex were Kotlin apps shipping no Kotlin resource at all — every one of them a large first-party app from a platform vendor.
  • kotlin: true means the Kotlin runtime is packaged, not that the app's own code is Kotlin. kotlin/*.kotlin_builtins and the kotlinx *.version stamps ship with the Kotlin standard library, so an app whose own sources are Java but which depends on one Kotlin library reports true. One such app turned up in the same sweep. Only .kotlin_module implies Kotlin compilation of a module, and R8 strips it.
  • Native libraries packed into a container hide the engine's name. An app that ships its .so files inside a compressed blob — Meta's Superpack (libsuperpack-jni.so, libhelium.so) is the case seen in the wild — exposes no libhermes.so/libjsc.so entry to match, so it can report native while embedding a framework. Instagram is the worked example: its dex carries the full React Native class tree, but no engine name appears in any entry. Detecting this needs the blob's contents, which is out of scope by design. Note the opposite case is fine: dex-encrypting packers (SecNeo DexHelper, SecIron AppGuard) leave lib/ untouched, so verdicts stay correct on protected apps even when the dex is unreadable.

JSON output

--json emits a versioned envelope. Branch on framework, which is a stable id (flutter, react-native, maui, xamarin, cordova, capacitor, ionic, kony, lynx, nativescript, qt, titanium, unity, plus native, hybrid and error). verdict is prose for humans and may be reworded between releases; matched_files names the real APK entries behind the call, so a verdict can be audited rather than trusted.

{
  "schema_version": 1,
  "tool": "frider",
  "tool_version": "0.4.0",
  "results": [
    {
      "source": "rn.apk",
      "verdict": "React Native (hermes)",
      "framework": "react-native",
      "frameworks": ["react-native"],
      "confidence": "High",
      "engines": ["hermes"],
      "kotlin": false,
      "embedded_js": [],
      "notable_libs": [],
      "matched_files": { "react-native": ["assets/index.android.bundle"] },
      "errors": []
    }
  ]
}

schema_version is bumped whenever a field changes meaning or is removed, so a caller can refuse input it does not understand instead of misreading it.

--adb --list --json uses the same envelope and carries packages in place of results, so a caller checks schema_version one way whatever it asked for:

{
  "schema_version": 1,
  "tool": "frider",
  "tool_version": "0.4.0",
  "packages": ["com.example.app", "com.example.other"]
}

Scripting

# every React Native app and which JS engine it ships
frider *.apk --json |
  jq -r '.results[] | select(.framework=="react-native") | "\(.source)\t\(.engines[0])"'

# anything shipping a root checker or RASP library
frider *.apk --json |
  jq -r '.results[] | select(.notable_libs != []) | "\(.source)\t\(.notable_libs | join(", "))"'

# fail a pipeline if any app is still on JavaScriptCore
frider *.apk --json | jq -e '[.results[] | select(.engines[]? == "jsc")] | length == 0' > /dev/null

# count the estate by framework (ERROR rows show up as `error`)
frider *.apk --json | jq -r '.results[].framework' | sort | uniq -c | sort -rn

# refuse a payload written by a future version
frider app.apk --json | jq -e '.schema_version == 1' > /dev/null

# pick from the device listing, then classify only what you picked
frider --adb --list --json | jq -r '.packages[]' | grep bank | xargs frider --adb

Branch on framework, not on verdict — the prose wording may change between releases, the id will not.

Exit codes

code meaning
0 everything classified
1 at least one source errored — missing file, unreadable APK, adb pull failure, package not installed
2 usage error — bad --rules file, --adb without a serial, no arguments

Errors are rows, not exceptions: a bad input never aborts the run or produces a traceback, so a batch over a hundred APKs always finishes and always reports.

Rules format

frider/rules.json is the whole detection surface:

{
  "apk_structure": {
    "manifest": "^AndroidManifest\\.xml$",
    "code": "^classes[0-9]*\\.dex$"
  },
  "kotlin": {
    "markers": [
      "^META-INF/.*\\.kotlin_module$",
      "^kotlin/.*\\.kotlin_builtins$",
      "^META-INF/kotlin(x)?[-_].*\\.version$"
    ]
  },
  "frameworks": [
    {
      "id": "react-native",
      "name": "React Native",
      "weight": 100,
      "markers": [
        "^assets/index\\.android\\.bundle$",
        "^lib/[^/]+/libreactnative[^/]*\\.so$",
        "^lib/[^/]+/libhermes[^/]*\\.so$",
        "^lib/[^/]+/libjsc\\.so$",
        "^lib/[^/]+/libjsi\\.so$"
      ],
      "requires": [
        "^lib/[^/]+/libreactnative[^/]*\\.so$",
        "^lib/[^/]+/libhermes[^/]*\\.so$",
        "^lib/[^/]+/libjsc\\.so$",
        "^lib/[^/]+/libjsi\\.so$"
      ],
      "engines": {
        "hermes": "^lib/[^/]+/libhermes[^/]*\\.so$",
        "jsc": "^(?:lib/[^/]+/libjsc\\.so|lib/[^/]+/libjscexecutor\\.so)$"
      }
    }
  ],
  "embedded_js": [ { "id": "duktape", "name": "Duktape (embedded JS)", "marker": "^lib/[^/]+/libduktape\\.so$" } ],
  "notable_libs": [ { "regex": "^lib/[^/]+/libtoolChecker\\.so$", "label": "RootBeer (root checker)" } ]
}

Markers are regular expressions matched (case-insensitive) against every APK entry path, anchored to the whole path^lib/[^/]+/libflutter\.so$, not a substring search. Android only loads lib/<abi>/*.so from the archive root, so a bundled copy under assets/ or a renamed .so.bak is a payload rather than a framework, and must not match. A marker ending in / is a directory prefix and is anchored only at the start.

requires separates runtime from payload. A framework can declare that at least one of its requires markers must match before it is claimed; the remaining markers then corroborate but cannot fire alone. This matters for asset markers: a shipped assets/index.android.bundle (React Native) or flutter_assets/ dir (Flutter) is only evidence if the APK also contains an engine to execute it. Android loads nothing from assets/, so a bundle with no libhermes/libjsc/libreactnative is a dead copy, not React Native — found on a real banking app that shipped a vestigial RN bundle on a Flutter host and was misreported hybrid until requires was added. Asset-only markers without requires risk the same false positive.

The kotlin block takes a markers list — several markers because no single one survives every build. .kotlin_module is stripped by R8, while kotlin/*.kotlin_builtins and the kotlinx *.version stamps usually survive minification. A legacy single "marker": "..." string is still accepted so an older custom rules file keeps loading.

Inside a container, rules match the inner APK's own path, so container.xapk!app.apk!lib/... matches the same rules as a flat APK. A framework wins by marker presence; ties break on weight, then on the number of distinct entries matched — so a more specific rule outranks a general one it overlaps with (maui above xamarin). An app with both Flutter and React Native markers is reported as hybrid.

apk_structure is what separates "no framework markers" from "could not tell": a native verdict is only confident when both patterns matched, meaning a whole APK was read rather than a fragment.

Measuring accuracy against real APKs

Every fixture in tests/ is a synthetic zip built from the same assumptions the rules were written from, so the suite proves the matcher works — not that the fingerprints are right. Only real APKs answer that, and the answer is a number.

Measured accuracy

The most recent figures come from a sweep of 141 third-party packages pulled from one Android 13 device — banking, messaging, social, vendor system apps — with every verdict checked by hand against evidence frider is not allowed to use: the class references inside classes*.dex, plus the lib/ layout.

signal result
framework verdict 140 / 141 confirmed
framework, remaining miss 1 app embedding React Native behind packed .so blobs (see Known limits)
kotlin flag 123 / 132 (93.2%) on apps with readable dex — 8 false negatives, 1 false positive
kotlin, before the multi-marker rule 56 / 132 (42.4%)

Nine apps are excluded from the kotlin figure because a packer encrypts their dex, leaving no independent evidence to compare against; their framework verdicts are still confirmed, since packers leave lib/ intact.

Two rule changes came out of that sweep: the Kotlin marker list (a .kotlin_module-only rule missed every minified app) and the Lynx fingerprint (one app in the set ships liblynx.so and had been reading native).

Cross-checking a sub-signal like kotlin needs the dex, so corpus_check.py cannot do it — that harness scores framework only. Verify sub-signals with a throwaway script that reads dex bytes directly, and keep it out of the package.

Start with real APKs you can fetch

Several open-source Android tools ship real, compiled APKs inside their PyPI sdists and npm tarballs. Nobody built those to match frider's rules, so they make a reproducible starting corpus with no binaries committed here:

python3 tools/fetch_sample_corpus.py corpus/
python3 tools/corpus_check.py corpus/
expected     n    ok      acc  confusions
----------------------------------------
native     5     5   100.0%  -

5/5 correct — 100.0% accuracy

Every APK reachable this way is a native app, so this tests the direction frider has historically got wrong — a real native app misreported as a framework, which is exactly what the res/xml/config.xml and libfbjni.so bugs did. It runs thousands of real entry names past every marker.

It does not validate any framework fingerprint. Nothing fetched is built with Flutter, React Native, MAUI, NativeScript, Qt or Titanium, so a broken marker for those sails straight through. For that, add apps by hand.

Labelling your own

Label a corpus by dropping each APK into a directory named after its expected framework id:

corpus/
  flutter/        com.example.a.apk  com.example.b.xapk
  react-native/   ...
  maui/           ...
  native/         ...     # apps built with no cross-platform framework
  _ignore/        ...     # skipped: staging, unlabelled, licence-bound

Then measure:

python3 tools/corpus_check.py corpus/
python3 tools/corpus_check.py corpus/ --json accuracy.json --min-accuracy 95
expected      n    ok      acc  confusions
------------------------------------------
flutter      12    12   100.0%  -
native        9     8    88.9%  unityx1
react-native  7     7   100.0%  -

27/28 correct — 96.4% accuracy

It exits non-zero below --min-accuracy (default: any mismatch fails), so the same command works as a release gate. The suite picks it up too:

FRIDER_CORPUS=corpus/ python3 -m pytest tests/           # floor of 100%
FRIDER_CORPUS=corpus/ FRIDER_CORPUS_MIN_ACCURACY=95 python3 -m pytest tests/

Without FRIDER_CORPUS that test skips, so CI stays green without shipping APKs. A mislabelled directory is rejected rather than silently scored zero, and an empty corpus fails rather than reporting success.

Development

python3 -m pytest tests/ -q          # unit + fixture tests
ruff check frider tools tests        # lint (config in pyproject.toml)

Fixtures are synthetic zips built from the same assumptions the rules were written from, so a green suite says the matcher works — not that a fingerprint is right. New or changed markers stay provisional until tools/corpus_check.py has run over real APKs.

Conventions for contributors — layout, invariants, why a green suite does not validate a fingerprint, and the release process — are in AGENTS.md. Changes are recorded in CHANGELOG.md; the threat model and reporting process are in SECURITY.md.

Roadmap

  • a harness for validating against real APK sets — tools/corpus_check.py
  • a published accuracy figure — see Measured accuracy, from a 141-package device sweep verified against dex contents
  • the same figure from a labelled, redistributable corpus that CI can rerun
  • framework version extraction (Flutter engine, Hermes, RN) — deliberately not in v1: it needs per-framework verification before shipping numbers
  • optional YARA-rule backend (yara-python) so rules can be shared as .yar with the rest of the ecosystem
  • protector/RASP vendor scoring as a first-class output

License

MIT — see LICENSE.

About

Android app framework detector — classifies Flutter / Dart, React Native (Hermes vs JavaScriptCore), .NET MAUI, Xamarin, Apache Cordova, Capacitor, Ionic, Kony, NativeScript, Qt, Titanium, Unity, or native Java/Kotlin from APK entry names. Zero runtime dependencies.

Topics

Resources

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages