Architecture¶
This page describes the real, end-to-end architecture of Echidna as it is
built in this repository — the companion app, the in-app control service, the
JNI bridge, the Zygisk native module, the DSP engine, and the LSPosed
compatibility shim. Rooted-emulator testing proves the service-side native DSP
path. A native AudioRecord.read interception slice passed before the current
explicit-contract redesign and is retained as historical evidence only. Current
capture routes, Magisk flashing, LSPosed injection, and physical-device
SELinux/HAL behavior remain device validation.
Component overview¶
Echidna ships a primary companion APK, an optional LSPosed shim APK, and a
flashable Magisk module that carries the Zygisk engine and DSP. There is no
separate com.echidna.control package — the control service is hosted inside
the companion app process.
flowchart TB
subgraph APK["Companion APK — com.echidna.app (single installable)"]
UI["Jetpack Compose UI<br/>Dashboard · Presets · Effects · Diagnostics<br/>Compatibility · Whitelist · Settings · QS Tile"]
REPO["ControlStateRepository<br/>(StateFlow singleton, persists presets)"]
CLIENT["ControlServiceClient<br/>binds by ComponentName(this, EchidnaControlService)"]
SVC["EchidnaControlService<br/>(privileged Service, exported=false)"]
REG[("PublishedPolicyRegistry<br/>strict policy v2 + generation")]
PROVIDER["PolicySnapshotService<br/>(read-only Binder, exported=true)"]
JNI["libechidna_control_jni.so<br/>(:service native lib, packaged in APK)"]
UI --> REPO --> CLIENT
CLIENT -->|"AIDL: IEchidnaControlService"| SVC
SVC --> JNI
SVC --> REG
REG --> PROVIDER
end
subgraph MAGISK["Magisk module — id: echidna (flashable, per-ABI)"]
ZY["libechidna.so<br/>Zygisk module"]
DSP["libech_dsp.so<br/>C++ DSP engine"]
ZY -->|"dlopen + dlsym"| DSP
end
subgraph TARGET["Target / media app processes (Discord, Telegram, …)"]
HOOKS["Normal-flow native candidates<br/>AAudio · OpenSL · tinyalsa"]
DEV["Developer-contract routes<br/>native AudioRecord · libc read"]
NO["Unsupported boundaries<br/>Audio HAL · AudioFlinger"]
SHIM["LSPosed Java shim<br/>(AudioRecord.read, fail-closed)"]
SHIM_NATIVE["libechidna_shim_jni.so<br/>+ packaged libech_dsp.so"]
SHIM --> SHIM_NATIVE
end
JNI -.->|"device-gated dlopen of module-provided libechidna.so"| ZY
REG -->|"UID-scoped v2 over abstract AF_UNIX"| ZY
PROVIDER -->|"caller-UID + process-scoped v2"| SHIM
ZY --> HOOKS
ZY --> DEV
ZY -.-> NO
HOOKS -->|"echidna_process_block()"| DSP
Rendered diagram (offline / in-app Help fidelity)
The web site renders the interactive Mermaid diagram above. The in-app Help tab decodes this exported raster twin instead, so the same big picture is visible offline.

The six runtime pieces¶
| Component | Artifact | Runs in | Role |
|---|---|---|---|
| Companion app + UI | app-debug.apk / app-release.apk |
its own process (com.echidna.app) |
Compose UI over a ControlStateRepository StateFlow singleton; binds the control service; persists presets. |
| Control service | EchidnaControlService plus PolicySnapshotService (in the same APK) |
companion app process | The private AIDL owns mutations. The exported read-only provider authenticates LSPosed callers and exposes only their scoped policy. |
| JNI bridge | libechidna_control_jni.so (in the APK) |
companion app process | App-side native glue; attempts to dlopen the Magisk-delivered engine for status/control and fails closed when unreachable. |
| Zygisk module | libechidna.so (Magisk zygisk/<abi>.so) |
every specialized app process | Registered Zygisk module; attempts eligible capture managers and routes captured PCM through the DSP. |
| DSP engine | libech_dsp.so (Magisk system/lib(64)) |
whichever process loaded the hooks | Real-time C++ effect chain; exposes the echidna_process_block C ABI. |
| LSPosed shim | shim-release.apk (com.echidna.lsposed) |
LSPosed-scoped app process | Optional Java AudioRecord fallback; bundles only libechidna_shim_jni.so plus libech_dsp.so and fetches authenticated, process-scoped policy over Binder. |
Control plane: app to service to native¶
The control plane was repackaged into a single-APK topology (t2-e6). The
older design bound to a phantom com.echidna.control package that had no
installable host; that has been removed.
ControlServiceClientbinds withComponentName(context, EchidnaControlService::class.java)— an in-package bind, so it resolves at runtime. The service is declaredexported="false".- The companion app's Gradle build folds the
:servicemodule in directly (include(":service")with aprojectDirredirect), so the service, the single canonical AIDL, and theechidna_control_jninative library are all bundled into the one APK. The duplicate app-side AIDL copy was deleted, so there is exactly oneIEchidnaControlServicecontract. - The AIDL surface (
IEchidnaControlService) carries the full control API: status/refresh (getModuleStatus,refreshStatus():String), whitelist and binding queries (getWhitelistBindings), global controls (setMasterEnabled,setBypass,triggerPanic,setSidetone,getControlState), plus profile push, telemetry streaming (RemoteCallbackList), andprocessBlock. getModuleStatus/refreshStatusreturn a combined status JSON assembled from the real module status, a human-readable SELinux state, and a liveAudioStackProbe(manufacturer,ro.board.platform, AAudio / low-latency / pro-audio features, output sample rate and frames-per-buffer). This replaced the previously hard-coded "Qualcomm QSSI / Enforcing" placeholder data.- Availability is not runtime proof.
policyToolAvailable,policyAppliedVerified,nativeRouteVerified, andjavaFallbackRecommendedare separate signals; neither Zygisk nor a policy tool being present proves that a transformed buffer was observed. - The exported
PolicySnapshotServiceis a deliberately narrow exception to the private control surface. It authenticates Binder's caller UID against the claimed process and cannot mutate policy. The privilegedIEchidnaControlServiceremains non-exported.
Data plane: audio capture to DSP¶
When the Zygisk module attaches inside a target process it attempts every eligible
capture manager. A manager that captures a PCM block calls
echidna_process_block(...), which lazily dlopens libech_dsp.so, resolves the
four DSP entrypoints, and calls dsp.process(...), then writes the processed
PCM back in place. All hooking is gated on hooksEnabled() and
isProcessWhitelisted() — the module never hooks unconditionally.
Capture-route support is a code-owned contract in
native/zygisk/src/hooks/capture_route_reachability.h:
| Route | Status | PCM metadata source / reason |
|---|---|---|
| AAudio | Operational candidate | Stable AAudio stream getters. |
| OpenSL ES | Operational candidate | Recorder sink PCM descriptor and tested wrapper lifecycle. |
| tinyalsa | Operational candidate | pcm_open configuration. |
LSPosed Java AudioRecord |
Operational candidate | Java sample-rate, channel-count, and format getters. |
| Legacy input preprocessor effect ABI | Experimental attachment candidate | ABI/lifecycle/audio/RT tests pass; eligible system/vendor HIDL configs can stage next-boot registration, and the default-off LSPosed path requests authorized per-session attachment. No device audio proof. |
Native AudioRecord |
Developer contract only | Requires ECHIDNA_AR_SR, ECHIDNA_AR_CH, and ECHIDNA_AR_FORMAT; normal app specialization does not provide them. |
| libc raw-device read | Developer contract only | Requires ECHIDNA_LIBC_SR, ECHIDNA_LIBC_CH, and ECHIDNA_LIBC_FORMAT. |
| Audio HAL | Unsupported | unsupported_injection_boundary; vendor stream objects live behind audioserver. |
| AudioFlinger | Unsupported | unsupported_injection_boundary; no stable app-process transform ABI. |
Operational means the route has a reachable code contract, not that it has passed on a physical
device. The orchestrator reports support, metadata source, and the exact unavailable reason in hook
telemetry. This matrix is ABI-qualified: on armeabi-v7a, AAudio, OpenSL ES, tinyalsa, native
AudioRecord, and libc-read are backed by a host-proven ARM32/Thumb-2 prologue relocator, with
on-device install/execution device-gated. The LSPosed Java/JNI route and the legacy input
preprocessor do not use Echidna's inline-symbol backend and remain eligible subject to their normal
policy and device gates.
The route decision is a fail-closed funnel: a capture site only ever reaches the DSP after it clears every gate below. Any "no" leaves the block untouched and emits telemetry rather than guessing.
flowchart TD
START(["Capture site touched<br/>in a specialized process"]) --> ELIG{"Route eligible?<br/>(operational candidate,<br/>not unsupported boundary)"}
ELIG -->|"Audio HAL / AudioFlinger"| BOUND["unsupported_injection_boundary<br/>fail closed + telemetry"]
ELIG -->|"native AudioRecord / libc<br/>without ECHIDNA_* env"| DEVC["developer-contract-only<br/>inert; no normal-flow producer"]
ELIG -->|"AAudio · OpenSL ES · tinyalsa ·<br/>LSPosed AudioRecord · legacy preproc"| META{"Trustworthy PCM metadata?<br/>(sample rate · channels · format ·<br/>direction · lifecycle)"}
META -->|"No / undecodable prologue"| DECLINE["hook declines / relocator<br/>fails closed per function"]
META -->|"Yes"| POLICY{"Authenticated v2 policy admits?<br/>global on · outside panic ·<br/>whitelisted true · owner matches"}
POLICY -->|"No policy / revoked / wrong owner"| INERT["installed hook stays inert<br/>original bytes preserved"]
POLICY -->|"Yes, current generation"| PROC["echidna_process_block()<br/>→ dlopen libech_dsp.so → dsp.process()"]
PROC --> WRITE["processed PCM written in place<br/>status flips to kHooked"]
classDef ok fill:#12492f,stroke:#1f8f5f,color:#eafff4;
classDef stop fill:#5b1f1f,stroke:#b5473f,color:#ffecec;
class PROC,WRITE ok;
class BOUND,DEVC,DECLINE,INERT stop;
flowchart TB
A["AAudio"] --> P["echidna_process_block()"]
B["OpenSL ES"] --> P
C["tinyalsa"] --> P
J["LSPosed Java AudioRecord"] --> Q["dedicated shim JNI + packaged DSP"]
L["legacy input preprocessor<br/>default-off permission"]
R["authorized LSPosed<br/>session attachment"]
L -.-> R
D["native AudioRecord / libc<br/>developer contract"] -.-> P
X["Audio HAL / AudioFlinger<br/>unsupported boundary"] -.-> N["fail closed + telemetry"]
P --> H["dlopen libech_dsp.so → dsp.process()"]
Any successful manager flips internal status to kHooked, which the control service surfaces via
getModuleStatus. Managers are not mutually exclusive: several candidates can be installed when an
app touches multiple APIs. Unsupported or unconfigured routes return false with explicit telemetry.
Zygisk module lifecycle¶
libechidna.so is a genuine Zygisk module (t2-e9). It registers via
REGISTER_ZYGISK_MODULE(EchidnaModule) against the compatibility-focused Zygisk API v3 header
(native/zygisk/include/zygisk.hpp):
sequenceDiagram
participant Z as Zygisk loader (Magisk)
participant M as EchidnaModule
participant S as authenticated policy publisher
participant O as AudioHookOrchestrator
participant D as libech_dsp.so
Z->>M: onLoad(Api*, JNIEnv*)
Note over M: stash handles and stay mapped without DLCLOSE_MODULE_LIBRARY
Z->>M: preAppSpecialize(args)
Note over M: cache target process and expected companion UID
Z->>M: postAppSpecialize(args)
M->>M: echidna_module_attach()
M->>S: connect, verify SO_PEERCRED, send v3 process hello
Note over M: stay inert and reconnect if publisher/policy is unavailable
S-->>M: process-scoped, monotonic v2 policy
M->>O: install once admitted by master/panic/whitelist/owner gates
Note over O,D: on captured PCM → echidna_process_block → dsp.process()
preAppSpecialize caches the target process and resolves the companion package UID while the
zygote-side package registry is still readable. postAppSpecialize starts the process-local reader
and activation worker only after sandboxing. A cold publisher does not permanently disable an
eligible process: it remains inert, reconnects with bounded backoff, and installs once a valid
current generation assigns that process to the zygisk capture owner. Disconnect, policy revoke,
master-off, bypass, or an active panic hold revoke processing immediately; rollback and conflicting
same-generation payloads are rejected. The module stays mapped so installed inline/PLT hooks can
remain resident while their processing gate is disabled. The earlier rooted x86_64 AudioRecord.read probe
predates the current explicit PCM contract and does not prove the current native
route is reachable. Full Magisk loader lifecycle, reboot survival, arbitrary
target-app specialization, and current capture routes require device validation.
Multi-ABI hooking¶
The native superbuild targets four shared objects per ABI — libechidna.so,
libech_dsp.so, libechidna_shim_jni.so, and libechidna_preproc.so — for arm64-v8a,
armeabi-v7a, and x86_64. That is 12 generated targets. Release tooling carries the engine,
DSP, and preprocessor through the Magisk module and shim JNI/DSP through the LSPosed APK. The
preprocessor remains in inert ABI staging until runtime evidence proves a legacy-HIDL system/vendor
registry. The generated next-boot overlay adds only library/effect registration. Separately, the
default-off companion setting can permit the LSPosed shim to request a short-lived capability and
attach the registered effect to one eligible AudioRecord session. It does not auto-apply the
effect or prove load, enablement, or mutation on a device. The inline-hook
trampoline support differs by ABI (t2-e11):
- arm64-v8a — full trampoline (LDR X16 / BR X16 with relocation fixups); the primary, most-tested path.
- x86_64 — full trampoline implemented (14-byte absolute
jmp [rip]patch with an allow-listed length decoder that relocates RIP-relative and rel32 operands, failing closed on anything unrecognized). Verified with a host decoder + end-to-end hook harness. The earlier rooted-emulatorAudioRecordprobe predates the current route contract. - armeabi-v7a — relocator implemented, host-proven, on-device device-gated: it
builds and loads, and a real ARM32/Thumb-2 prologue relocator
(
runtime/armv7_instruction.h, host-proven byarmv7_instruction_test.cpp) now backs the direct inline-symbol routes (AAudio, OpenSL ES, tinyalsa, nativeAudioRecord, libc-read).install()attempts the route and the relocator fails closed per function on any prologue it cannot provably relocate; on-hardware install/execution is device-gated (armv7_inline_relocation_host_proven_on_device_gated). Zygisk API v3 is still not a late-load substitute: its API ends after specialization, while its PLT commit applies to ELFs already loaded in memory and clears the registrations, and Echidna receives authenticated policy after that callback, so a complete, process-scoped PLT transaction cannot be installed safely. The LSPosed Java/JNI route and official legacy preprocessor remain eligible through their separate attachment boundaries.
Authenticated policy v2 delivery¶
ProfileStore persists and publishes one strict, bounded version-2 policy document. It contains
schemaVersion, a service-owned monotonic generation, profiles, defaultProfileId,
appBindings, whitelist, captureOwners, the complete control object, and an internal
appIdentities binding for each resolvable policy package. An identity records the full Android
UID/user and sorted current APK signing-certificate digests at publication time. Unknown or
duplicate keys, malformed Unicode, oversize documents, dangling defaults/bindings, invalid owners,
and incomplete controls fail closed. A pre-identity stored policy is rewritten inert and cannot
activate a route until the companion refreshes it. Process-scoped transport views deliberately omit
the private identity table.
PublishedPolicyRegistry is the read-only process-local source shared by the two transports:
- Zygisk:
ProfileSyncBridgeowns an Android-user-scoped abstractAF_UNIXsocket. User 0 keeps the compatibility nameechidna_profiles; other users useechidna_profiles_u<userId>so two companion instances cannot collide or accept each other's readers. A client must send the v3 Zygisk hello with its exact process name. The publisher binds the fullSO_PEERCREDUID to the current policy's published package/user/signing identity; PID identifies only that socket incarnation. The native reader independently accepts only the companion UID cached before specialization. Frames are bounded, length-prefixed UTF-8. Each reader receives only its exact/base process view; slow writers and handshake/client counts are bounded. A disconnect revokes admission while retaining the generation watermark for safe reconnect. - LSPosed:
PolicySnapshotServiceis an explicit, exported, read-only Binder component. It bindsBinder.getCallingUid()to the same current published identity and pins PID plus the live callback Binder to one registration incarnation. It returns only the exact/base process view. Bounded listeners receive generation invalidations, then fetch the newest scoped document. They never receive mutation authority. Provider API v7 uses synchronous Binder transactions for the PID-bound capability, telemetry, proof, and drain reports. Those transactions only capture and validate UID/PID and enqueue bounded work; signing and proof verification stay off Binder threads. The retained v2-v6 one-way transactions fail closed because Android does not provide a caller PID for one-way calls. - A socket LSPosed hello is closed, and an unnegotiated legacy socket reader receives one inert
fail-closed document before disconnect. The old filesystem endpoint
/data/local/tmp/echidna_profiles.sockis not used.
Android packages sharing one UID are one application-sandbox trust domain, so either sibling can
act for that UID; package/process policy keys still limit which scoped route exists. Full UIDs keep
work-profile users distinct. Resolution is intentionally limited to packages visible to the
companion's Android user (including its launcher-scoped <queries> declaration); missing
visibility, uninstall/reinstall UID drift, or signer drift revokes admission without adding
QUERY_ALL_PACKAGES, privileged permissions, /proc inspection, or SELinux exceptions.
The companion APK must be installed and started inside each Android user/profile that should receive
policy; cross-user service or policy access is never attempted.
Shared-memory fallback and telemetry¶
The config and telemetry helper regions use file-backed mappings under
/data/local/tmp/echidna via android_shared_memory.h. Versioned socket/Binder policy is
authoritative for admission; deprecated shared-file state cannot revive a denied process. The
telemetry region remains the transformed-buffer evidence path consumed by diagnostics.
LSPosed shim path (Java-API apps)¶
For apps that capture through Java AudioRecord, the LSPosed shim provides a fallback that does
not require an active Zygisk route. After the real target process identity is known:
ProfileSyncReceiverexplicitly binds the companion's read-onlyPolicySnapshotService, registers its listener before the first fetch, and reconnects on provider failure.ProfileSnapshotaccepts only the complete strict v2 schema.ProfileSnapshotStorepreserves a monotonic generation watermark and rejects rollback or conflicting same-generation bytes.- Policy resolution uses the exact process then base package, selects an app binding or the explicit
default profile, and requires
captureOwnersto assign the target tolsposed. - Fail-closed by construction: the default snapshot is
empty()(empty whitelist, global off). Before any policy fetch, or on any unreadable/unparseable snapshot, resolution denies processing. Processing is enabled only when globally enabled, outside the panic hold, explicitly whitelistedtrue, and assigned to the LSPosed owner. A policy change during a read invalidates the transaction so original bytes/results are preserved.
Multi-reader delivery without shared authority¶
The previous profile-sync contract was single-holder: each hooked process tried to own one filesystem socket. Current delivery has one service-owned policy registry, multiple authenticated native socket readers, and independently authenticated Binder views for LSPosed. Late readers get the persisted/current generation through their transport; no target process can publish policy.
Threading and latency¶
- The DSP runs synchronously inside the capture callback by default (in-place processing), which is the low-latency path. A hybrid mode copies into a lock-free ring buffer and lets a worker apply heavier transforms, with an overrun watchdog and xrun counting; this trades latency for quality. Latency modes are exposed per preset (Low-Latency / Balanced / High-Quality). See DSP & Effects.
- Policy is published on mutation and restored at service startup. Native readers receive scoped frames; Binder listeners receive only a generation invalidation and then re-fetch their scoped view. Late consumers obtain the current persisted/registry generation through their transport.
What is verified vs device-gated¶
Echidna's honesty model is a ladder of evidence. A route is not "working" because a library is present; each rung is a distinct, separately-provable claim, and the ladder is deliberately drawn so you can see exactly where the current build stands versus what still needs a physical device. The same model is used verbatim by the hardening evidence-state page and the on-device Diagnostics tab.
stateDiagram-v2
direction LR
[*] --> Installed
Installed --> Loaded: Zygisk/LSPosed places code in process
Loaded --> Hooked: capture symbol patched / shim attached
Hooked --> Processing: whitelisted PCM reaches echidna_process_block
Processing --> Mutating: non-neutral preset changes ≥1 output sample
note right of Installed
PROVEN here: APK install + launch,
Magisk zip builds/verifies
end note
note right of Loaded
PROVEN: rooted-emulator app instrumentation.
DEVICE-GATED: Magisk flash + arb. app specialize
end note
note right of Hooked
PROVEN: x86_64 host hook harness; native processBlock.
DEVICE-GATED: armv7 on-hardware, live capture routes
end note
note right of Processing
PROVEN: rooted-emulator processBlock through the DSP.
DEVICE-GATED: AAudio/OpenSL/tinyalsa/LSPosed live capture
end note
note right of Mutating
PROVEN: host DSP + rooted-emulator attenuation assert.
DEVICE-GATED: transformed buffer in a real target app
end note
How to read the ladder
Each rung is claimed only with recorded evidence for that specific rung. The current build
reaches Processing/Mutating on rooted emulators via processBlock, and every live capture
route (the normal-flow candidates a real app would use) remains device-gated. "Installed"
or "Zygisk available" is a capability signal, never proof of a higher rung. The historical
AudioRecord.read probe sat on the Hooked rung before the current PCM-contract redesign and is
retained as history only.
- Verified in this environment: the single-APK topology and AIDL unification
build and the debug/release APKs assemble; all 12 superbuild targets cross-compile, while release
delivery verifies the nine engine/DSP/shim-JNI artifacts; host DSP and preprocessor tests pass;
the x86_64 trampoline passes a host end-to-end hook harness; the app installs,
launches crash-free, and navigates on an unrooted emulator; rooted Android
13/14 emulators passed app instrumentation with native
processBlockcoverage. The recordedAudioRecord.readprobe is historical, pre-redesign evidence. - Still release-device validation: Magisk flashing/reboot/module-manager load, live LSPosed shim injection, physical-device Zygisk lifecycle on arm64, AAudio/OpenSL/tinyalsa capture, SELinux interaction, and multi-process profile-sync behavior. Native AudioRecord/libc need a normal-flow PCM contract; Audio HAL/AudioFlinger remain unsupported rather than merely unverified. See Verification for the full matrix and a reproduce-on-device procedure.