frider
______ _ _
| ___| (_) | |
| |_ _ __ _ __| | ___ _ __
| _| | '__|| | / _` | / _ \| '__|
| | | | | || (_| || __/| |
\_| |_| |_| \__,_| \___||_|
Android app framework detector · v0.4.0
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 (
hermesvsjsc), - 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 Androidadbbinary on yourPATHand 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
.dllentries. Release builds default toAndroidUseAssemblyStore, which packs them intoassemblies/*.blob— the names are inside the blob, so those apps reportxamarin(accurate: they are .NET) rather thanmaui. - Inside a container,
matched_filesrecords the innermost path. A match found inapp.apk!lib/.../libflutter.sois reported aslib/.../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: truemeans the Kotlin runtime is packaged, not that the app's own code is Kotlin.kotlin/*.kotlin_builtinsand thekotlinx *.versionstamps ship with the Kotlin standard library, so an app whose own sources are Java but which depends on one Kotlin library reportstrue. One such app turned up in the same sweep. Only.kotlin_moduleimplies 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
.sofiles inside a compressed blob — Meta's Superpack (libsuperpack-jni.so,libhelium.so) is the case seen in the wild — exposes nolibhermes.so/libjsc.soentry to match, so it can reportnativewhile 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) leavelib/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.yarwith 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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6f33e8f8b014854656b952cfee5e4e3877c0148cece12802b9eb645435e591f5
|
|
| MD5 |
017a0607b2f0292c461e0b3e85cba2c1
|
|
| BLAKE2b-256 |
3a3c4fa1ec2e67c47e6b2fc8a893fd5f88c206671fb7269314d4df345f79e00d
|
Provenance
The following attestation bundles were made for frider-0.4.0.tar.gz:
Publisher:
publish.yml on hdyrawan/frider
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
frider-0.4.0.tar.gz -
Subject digest:
6f33e8f8b014854656b952cfee5e4e3877c0148cece12802b9eb645435e591f5 - Sigstore transparency entry: 2452772564
- Sigstore integration time:
-
Permalink:
hdyrawan/frider@4c30ab85dc0adc456bed18da048dee5a9a70e014 -
Branch / Tag:
refs/tags/v0.4.0 - Owner: https://github.com/hdyrawan
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@4c30ab85dc0adc456bed18da048dee5a9a70e014 -
Trigger Event:
release
-
Statement type:
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
277433ce55ef7e5e28fc1893dbed3585555951553dfac20ef8d20f14b1d83af9
|
|
| MD5 |
9011565f2ba5cebad1028908cc9df379
|
|
| BLAKE2b-256 |
239ee8106d3b16e1cc9e3aec55d7b0f4b6c43097639cc869f5095b6b52eb4572
|
Provenance
The following attestation bundles were made for frider-0.4.0-py3-none-any.whl:
Publisher:
publish.yml on hdyrawan/frider
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
frider-0.4.0-py3-none-any.whl -
Subject digest:
277433ce55ef7e5e28fc1893dbed3585555951553dfac20ef8d20f14b1d83af9 - Sigstore transparency entry: 2452773025
- Sigstore integration time:
-
Permalink:
hdyrawan/frider@4c30ab85dc0adc456bed18da048dee5a9a70e014 -
Branch / Tag:
refs/tags/v0.4.0 - Owner: https://github.com/hdyrawan
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@4c30ab85dc0adc456bed18da048dee5a9a70e014 -
Trigger Event:
release
-
Statement type: