Imported from hdyrawan/frider (
AGENTS.md). Install upstream withnpx skills add hdyrawan/frider. Copyright stays with the author.
AGENTS.md
Conventions for anyone — human or agent — working on frider. This is the canonical file; assistant-specific files point here.
frider classifies the UI framework of an Android app from its APK contents.
Stdlib only, no runtime dependencies. That constraint is a feature, not an
accident: do not add a dependency without asking, and note that CI fails the
build if the wheel grows a Requires-Dist line.
Layout
| path | role |
|---|---|
frider/apk.py |
open APK/XAPK/APKS files or directories, expose entries |
frider/rules.py |
load and validate rules.json, classify entries |
frider/rules.json |
framework fingerprints as data, not code |
frider/report.py |
table and JSON rendering |
frider/ui.py |
banner, color, verdict styling, summaries |
frider/adb.py |
pull installed packages from a device |
frider/cli.py |
argument parsing and orchestration |
tools/corpus_check.py |
measure accuracy against real APKs (not packaged) |
Before you push
python3 -m pytest tests -q
ruff check frider tools tests
Authorship and tooling references
Commits, pull requests, issues, and code comments must not reference the tools used to write them. Do not add:
Co-Authored-By:trailers naming an AI assistant, or any*-Session:trailer- "Generated by" / "Co-authored with" footers on pull requests or comments
- links to AI chat transcripts, sessions, or generated artifacts
- the name of any assistant or AI vendor
Write commit messages and pull request descriptions in the project's own voice: what changed and why, nothing about how it was produced. Share generated reports or walkthroughs with the maintainer directly rather than linking them from the repository.
A green test suite does not mean a rule is correct
This is the single most important thing to understand here, and it has already shipped two bugs.
Every fixture in tests/ is a synthetic zip. Whoever writes a rule also writes
the fixture, so both encode the same belief. The test then asks "does the code
do what I told it to?" — never "was what I told it true?" A wrong marker and a
passing suite are entirely compatible.
res/xml/config.xml (Cordova) and libfbjni.so (React Native) both shipped
with green tests. Both matched ordinary native apps. They were caught by reading
the rules, not by running them.
So: a new or changed marker is provisional until tools/corpus_check.py has
run over real APKs. Say so in the pull request rather than implying the tests
validate it. See the README for the corpus layout.
tools/fetch_sample_corpus.py pulls real APKs from PyPI and npm and the
real apks workflow runs them monthly, which covers the false-positive
direction: no framework rule may fire on a real native app. Everything
reachable that way is native, so it proves nothing about a Flutter or React
Native marker — those still need apps added by hand.
Verifying against a device sweep
corpus_check.py scores framework and nothing else, so it cannot confirm a
sub-signal like kotlin. Verify those against the dex: a throwaway script
that opens each classes*.dex and looks for type descriptors the framework or
language leaves behind is evidence frider is not allowed to use, which is
exactly what makes it independent. Keep such a script out of the package.
Two traps that will otherwise produce confident nonsense, both hit in practice:
- Absence in the dex is not absence in the app. R8 renames
kotlin.jvm.internal.Intrinsicsand stripskotlin.Metadata, and a dex packer (SecNeo DexShield, SecIron AppGuard) encrypts the real dex entirely, leaving a stub. Probing for only those two names scored a set of plainly Kotlin apps as Kotlin-free. Probe several strings, and exclude packed apps from the comparison instead of counting them as negatives. - Presence in the dex is not use. A single vestigial
Lio/flutter/.../FlutterRenderer;reference, with nolibflutter.soand noflutter_assets/, is a dependency leftover —nativeis the right verdict, and the sweep must not be "corrected" toward the dex.
Rules are data
Prefer adding a fingerprint to rules.json over adding Python.
A marker must be specific enough to stand alone. Paths that ordinary native
apps also ship — generic resource paths, widely vendored .so files, anything
a popular SDK bundles — cause false positives and belong out of the marker
list. When a framework cannot be distinguished from entry names alone, say so
in the README's "Known limits" and add no rule: reporting native is better
than a guess. Kotlin/Compose Multiplatform are absent for exactly this reason.
Anchor every marker to the whole entry path (^...$, or ^... for a
directory prefix). Unanchored markers are substring searches, so a bundled copy
under assets/ or a renamed .so.bak matched a library Android would never
load. A test fails if an unanchored marker appears.
Asset markers must be backed by a runtime lib via requires. A marker
whose path lives under assets/ (a JS bundle, an asset directory) is a payload
Android never loads; it only means something if an engine is present to run it.
Give such frameworks a requires list (the runtime .so markers) and keep the
asset marker in markers — the framework is then claimed only when a requires
marker also matches, and the asset corroborates. Without this, a Flutter app
shipping a vestigial RN bundle was reported hybrid (real case, a banking app).
A new asset-only marker without requires is provisional the same way an
unanchored one is.
Match on Entry.match_path(), never on innermost(entry.path). The !
container boundary is display-only; ! is legal in a zip entry name, so parsing
it back out truncates real paths.
Ties break on weight, then on distinct entries matched, so a specific rule can
be weighted above a general one it overlaps with (maui above xamarin).
Invariants worth not breaking
- Classification reads entry names only, never file contents. Keep readers lazy, and stream nested archives rather than buffering them — a container can be several GB.
- Report cells must stay single-line. adb relays multi-line stderr, and a
newline inside a cell tears the table apart.
--jsoncarries the full text. - Pad table cells in terminal columns, via
ui.display_width— neverlen()and never after adding ANSI codes. CJK app names are double-width. - A package name from a device is untrusted input on a filesystem path. It
becomes a cache directory that gets deleted before each pull, so validate it
with
adb.check_package_namebefore it reachesos.path.join. - An input that cannot be read must never report as
native. A wrong answer is worse than an error. Errored sources useframework: "error". confidencedescribes the reported verdict, not which answer was reached.Lowmeans "could not tell", never "the answer was native".--jsonis a contract. BumpSCHEMA_VERSIONinreport.pywhenever a field changes meaning or disappears. Callers branch onframework;verdictis prose and may be reworded.
Writing tests
Confirm a regression test fails against the unfixed code before trusting it. A test written from the same mental model as the fix often passes either way, and then it protects nothing. Two real examples from this repository:
- A memory test asserting on
resource.ru_maxrsspassed against the buggy implementation, because that value is a process-lifetime peak whose reading depends on how the process was spawned. It was replaced with a structural assertion. Do not assert on RSS. - A
report()default argument ofstream=sys.stdoutbound stdout at import time and escapedcapsys. Resolve such defaults at call time.
Prefer asserting on mechanism over measurement when the measurement is environment-dependent.
Releasing
The version lives in frider/__init__.py and nowhere else — pyproject.toml
reads it from there, and a test fails if a hardcoded version reappears.
- Bump
__version__, updateCHANGELOG.md, merge tomain. - Wait for the test matrix.
- Run the
publishworkflow manually against testpypi, then install the result in a clean environment. This is also the only run that exercises the artifact round-trip between the build and publish jobs, so it is whereupload-artifact/download-artifactbumps get validated. - Tag
vX.Y.Zand publish a GitHub release; that uploads to PyPI.
Uploads use Trusted Publishing (OIDC) — there is no API token in this
repository. The workflow refuses to upload when the tag and __version__
disagree. PyPI never allows a version number to be reused, so a bad release
costs a patch bump: the TestPyPI step is not optional.
Note that publish.yml never runs on a pull request. Green checks on a PR
touching it prove nothing about that change, and its jobs must not be added as
required status checks or every PR blocks forever.
Do not commit
APKs or any corpus of them (licensing, and they are large), dist/, build/,
or anything under a corpus directory. tools/ and tests/ stay out of the
built wheel — keep it that way.
Nor the identity of a scanned banking app. This repository is public, and a package name paired with a framework, packer, or RASP vendor is a target list tied to a named financial institution. Findings from such an app go in generically — "a shipping banking app", "a minified Kotlin app" — with no package name, app name, or bank name, in code, tests, docs, CHANGELOG, commit messages, PRs, or issues. Per-app detail belongs in the private anti-tamper-probe repo, and sweep tables go to the maintainer directly. Naming an ordinary consumer app (a social or messaging app, a vendor system app) as the worked example for a rule is fine. Aggregate counts — "1 of 141 packages" — are fine.