Skip to main content

frider

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

tests 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),
  • Kotlin metadata presence,
  • embedded JS runtimes that are not the framework (Duktape, J2V8),
  • notable third-party native libs (RootBeer, AppGuard, RiskStub, PairIP, EverSafe, DataVisor, Vkey) as an informational side channel.

Install

Requirements: Python 3.9+ (python3 --version to check) and nothing else — frider has zero runtime dependencies, so installs are instant and there is no dependency hell. It runs 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 git+https://github.com/hdyrawan/frider.git
frider --version

Option 2 — uv tool

uv tool install git+https://github.com/hdyrawan/frider.git
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 git+https://github.com/hdyrawan/frider.git
frider --version

Or, from a clone of this repo:

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

Option 4 — 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

# 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                                                       |
| plain-native.apk       | Native (no framework markers) | High       | -                                                                                   |
| split-fragment.apk     | Native (no framework markers) | Low        | -                                                                                   |
| truncated.apk          | ERROR                         | -          | -                                                                                   |
+------------------------+-------------------------------+------------+-------------------------------------------------------------------------------------+
10 source(s): 2 react native, 2 native, 1 flutter / dart, 1 .net maui, 1 apache cordova, 1 nativescript, 1 kony, 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.

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

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.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

frider-0.4.0.tar.gz (58.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

frider-0.4.0-py3-none-any.whl (31.3 kB view details)

Uploaded Python 3

File details

Details for the file frider-0.4.0.tar.gz.

File metadata

  • Download URL: frider-0.4.0.tar.gz
  • Upload date:
  • Size: 58.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for frider-0.4.0.tar.gz
Algorithm Hash digest
SHA256 6f33e8f8b014854656b952cfee5e4e3877c0148cece12802b9eb645435e591f5
MD5 017a0607b2f0292c461e0b3e85cba2c1
BLAKE2b-256 3a3c4fa1ec2e67c47e6b2fc8a893fd5f88c206671fb7269314d4df345f79e00d

See more details on using hashes here.

Provenance

The following attestation bundles were made for frider-0.4.0.tar.gz:

Publisher: publish.yml on hdyrawan/frider

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file frider-0.4.0-py3-none-any.whl.

File metadata

  • Download URL: frider-0.4.0-py3-none-any.whl
  • Upload date:
  • Size: 31.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for frider-0.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 277433ce55ef7e5e28fc1893dbed3585555951553dfac20ef8d20f14b1d83af9
MD5 9011565f2ba5cebad1028908cc9df379
BLAKE2b-256 239ee8106d3b16e1cc9e3aec55d7b0f4b6c43097639cc869f5095b6b52eb4572

See more details on using hashes here.

Provenance

The following attestation bundles were made for frider-0.4.0-py3-none-any.whl:

Publisher: publish.yml on hdyrawan/frider

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.4.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page