diff --git a/android/000_BEHAVIOR_CONTRACT_AND_ORACLE.md b/android/000_BEHAVIOR_CONTRACT_AND_ORACLE.md
new file mode 100644
index 00000000000..7d41e948649
--- /dev/null
+++ b/android/000_BEHAVIOR_CONTRACT_AND_ORACLE.md
@@ -0,0 +1,400 @@
+# Android Osquery: Behavior Contract and Oracle (Human Review)
+
+> Generated by the implementing AI agent as part of this implementation effort, then refined for human review.
+
+## Why this file exists
+This PR is large. The goal of this document is to give reviewers a stable behavior contract and a practical oracle, so humans review expected system behavior and safety invariants instead of diff volume.
+
+## Enrollment scenario (high-level)
+1. Device is enrolled into Android MDM and this app is installed via managed distribution policy.
+2. MDM provides managed app config with `server_url`, `enroll_secret`, and `host_uuid`.
+3. App starts (install/boot/worker wake), reads managed config, and sets enrollment credentials.
+4. App enrolls to Fleet Orbit via `POST /api/fleet/orbit/enroll` using managed credentials.
+5. Fleet returns `orbit_node_key`; app stores it and uses it for follow-up Fleet API calls.
+6. App runs distributed loop (`/api/v1/osquery/distributed/read` -> local execute -> `/api/v1/osquery/distributed/write`) and appears as an enrolled Android host.
+
+## Provenance and ordering note
+This document was created by AI, but it was created **after** the implementation (reverse-extracted from the code in this PR branch).
+So this is not the historical authoring order; it is a post-hoc contract/oracle reconstruction for human review and accountability.
+As part of this PR, the contracts/oracle approach from the book was adopted during the work: some sections were extracted from already-implemented code, and some sections were defined contract-first and then implemented according to that contract.
+
+## Compliance statement
+The implementation in this PR was checked against the currently implemented contract/oracle rules (C1-C14 and O1-O22), and it appears to respect them based on current unit tests and manual validation performed by this AI agent in this branch.
+This is an engineering confidence statement, not a formal proof of correctness.
+
+## System behavior contract
+
+### Scope
+This contract covers Android osquery behavior implemented in this PR branch:
+- enrollment and re-enrollment behavior
+- distributed query read/execute/write loop
+- SQL surface and table execution semantics
+- osquery table exposure for Android
+- security-sensitive behavior for transport and logging
+- cross-OS parity table behavior for `time` and `uptime`
+- cross-OS parity table behavior for `system_info`, `kernel_info`, and `memory_info`
+- cross-OS parity table behavior for `processes`, `interface_addresses`, `routes`, `users`, `mounts`, and `cpu_info`
+- management/visibility table behavior for `app_signatures`, `mdm_status`, and `startup_items`
+
+### Actors and interfaces
+- Fleet server endpoints:
+ - `POST /api/fleet/orbit/enroll`
+ - `POST /api/fleet/orbit/config`
+ - `POST /api/v1/osquery/distributed/read`
+ - `POST /api/v1/osquery/distributed/write`
+- Android agent components:
+ - `ApiClient`
+ - `DistributedCheckinWorker`
+ - `OsqueryQueryEngine`
+ - `TableRegistry` + registered Android tables
+
+### Contract rules (must remain true)
+
+#### C1. Enrollment identity ownership
+If enrollment identity changes (`enroll_secret`, `hardware_uuid`, or `server_url`), stored node key must be cleared before next check-in so host identity is not reused incorrectly.
+
+#### C2. 401 re-enrollment behavior
+On `HTTP 401` for Fleet/orbit calls, client clears node key and retries once via re-enrollment path. Non-401 failures must not trigger re-enrollment.
+
+#### C3. URL safety and normalization
+`server_url` must be validated before network requests:
+- scheme must be `http` or `https`
+- non-debug policy requires `https`
+- reject user-info, query, fragment, and non-root path
+- normalize to canonical origin (`scheme://authority`)
+
+#### C4. Request resiliency
+Failure to obtain optional device ID header must not crash requests; requests proceed without `X-Fleet-Device-Id`.
+
+#### C5. Distributed query loop semantics
+For each check-in cycle:
+1. read queries from Fleet
+2. execute each SQL query through osquery engine
+3. write result rows back to Fleet
+If query execution fails/unsupported, return empty rows for that query to clear retry churn.
+
+#### C6. SQL surface is intentionally narrow
+Supported SQL surface:
+- `SELECT *` or explicit column list
+- single `FROM
`
+- optional `WHERE` with `AND`-combined terms
+- operators: `=` and `LIKE`
+Out of scope: joins, aggregates, subqueries, group/order semantics.
+
+#### C7. Table strictness
+A table may only emit declared columns; unknown columns are contract violation. Missing columns are filled as empty string.
+
+#### C8. `android_logcat` privacy gate
+`android_logcat` must be disabled by default and only active with managed config key `enable_android_logcat_table=true`. When active, output is constrained:
+- Fleet agent tags only
+- capped row count
+- sensitive token redaction patterns
+- bounded execution timeout
+
+#### C9. Cross-OS parity tables `time` and `uptime`
+Android must expose `time` and `uptime` using osquery-style table names and deterministic one-row semantics.
+
+`time` contract:
+- exactly one row per query
+- required columns: `weekday`, `year`, `month`, `day`, `hour`, `minutes`, `seconds`, `timezone`, `local_timezone`, `unix_time`
+- values come from device local clock/timezone at query runtime
+- `unix_time` is epoch seconds
+
+`uptime` contract:
+- exactly one row per query
+- required columns: `days`, `hours`, `minutes`, `seconds`, `total_seconds`
+- values come from elapsed realtime since boot
+- all values are non-negative
+
+#### C10. Cross-OS parity tables `system_info`, `kernel_info`, `memory_info`
+Android must expose `system_info`, `kernel_info`, and `memory_info` using osquery-style names and deterministic snapshot semantics.
+
+`system_info` contract:
+- exactly one row per query
+- exposes stable host identity/profile snapshot fields
+- includes non-empty identity/model fields where Android provides them
+
+`kernel_info` contract:
+- exactly one row per query
+- exposes kernel/runtime version snapshot fields from local system properties
+- no network dependency
+
+`memory_info` contract:
+- exactly one row per query
+- exposes memory snapshot fields derived from Android memory APIs
+- total/available/threshold fields are non-negative numeric values
+
+#### C11. Security assessment baseline (enrollment + distributed query path)
+All security-relevant checks below must hold:
+- Transport policy: release builds enforce HTTPS and reject malformed origins. Status: good.
+- Re-enrollment safety: 401 clears node key and retries once; non-401 does not trigger re-enroll. Status: good.
+- Node key at rest: stored encrypted via Android Keystore-backed key. Status: good.
+- Distributed loop network path: both worker paths now use hardened `ApiClient` request policy (no legacy unsafe TLS path). Status: good.
+- Debug-only behaviors: debug fallback enrollment credentials and fast polling remain debug-gated. Status: good.
+- Log exposure control: `android_logcat` is opt-in, tag-filtered, bounded, and token-redacted. Status: good.
+- Identifier logging: full Device ID is not logged in non-debug builds. Status: good.
+- Backup exfiltration risk: app backup is disabled (`allowBackup=false`) to reduce managed-config/token leakage risk. Status: good.
+- Broadcast trigger abuse risk: boot receiver is non-exported to reduce external-trigger surface. Status: good.
+- Minor accepted risk: enrollment secret is stored in app-private DataStore (not Keystore-encrypted). Rationale: low practical risk after backup disable + app sandbox; full secret-encryption migration is possible but adds compatibility and rotation complexity. Status: accepted minor.
+
+#### C12. Additional core parity tables
+Android must expose additional osquery-style tables with stable snapshot behavior:
+- `processes`: best-effort process list snapshot for visible app processes.
+- `interface_addresses`: one row per discovered interface address.
+- `routes`: best-effort route snapshot from local system route output.
+- `users`: current app/profile identity snapshot (non-privileged view).
+- `mounts`: mount-point snapshot from `/proc/mounts`.
+- `cpu_info`: CPU capability snapshot (ABI/core count/model best effort).
+
+#### C13. Management and startup visibility tables
+Android must expose:
+- `app_signatures`: installed app signing-certificate fingerprint metadata (best effort, visibility-scoped).
+- `mdm_status`: one-row MDM/work-profile/restrictions presence snapshot.
+- `startup_items`: boot-receiver/launcher component visibility snapshot (best effort).
+
+#### C14. Quality gate process requirements
+PR quality must include:
+- oracle-to-evidence mapping table in PR description.
+- reviewer split: one behavioral/oracle reviewer and one implementation reviewer.
+- scope freeze after quality gate definition (no new feature without contract/oracle delta).
+- explicit list of environment-limited checks not executed (for example: second OEM pass, release signing when keystore absent).
+
+### Contracted Android table set
+Current registered tables:
+- `installed_apps`
+- `app_permissions`
+- `os_version`
+- `osquery_info`
+- `certificates`
+- `device_info`
+- `network_interfaces`
+- `battery`
+- `wifi_networks`
+- `system_properties`
+- `android_logcat`
+- `time`
+- `uptime`
+- `system_info`
+- `kernel_info`
+- `memory_info`
+- `processes`
+- `interface_addresses`
+- `routes`
+- `users`
+- `mounts`
+- `cpu_info`
+- `app_signatures`
+- `mdm_status`
+- `startup_items`
+
+## Oracle for reviewers
+An oracle is the set of observable truths used to decide whether implementation behavior is acceptable.
+In this PR, the oracle is used as a human review lens: reviewers can validate outcomes and invariants directly, without depending on full implementation-level understanding.
+This oracle was generated by AI from the implemented code and tests, then written for humans to quickly confirm or reject behavior.
+
+### Message from the AI that implemented/reviewed this code
+As far as I can tell, the current implementation respects all oracle rules listed below. This is a best-effort technical judgment, not a formal proof.
+
+### O1. Enrollment and node key lifecycle
+Human rule:
+- A device with no node key must auto-enroll before normal operations.
+- `HTTP 401` means key invalidation: clear key, re-enroll, retry once.
+- Non-401 failures must not trigger re-enrollment.
+- Enrollment identity changes (`enroll_secret`, `hardware_uuid`, `server_url`) must force new enrollment.
+How to verify:
+- unit suite: `ApiClientReenrollTest`
+
+### O2. Fleet URL and transport safety
+Human rule:
+- Reject unsafe/invalid Fleet origins before any request is sent.
+- Enforce HTTPS where non-debug policy requires it.
+- Accept only canonical origin format and normalize it for requests.
+How to verify:
+- unit suite: `ApiClientReenrollTest` URL validation cases
+
+### O3. Distributed query loop behavior
+Human rule:
+- Each check-in cycle must follow: read -> execute -> write.
+- If a query cannot run, return empty rows for that query to stop repeated retries.
+- Failures must be handled without crashing the worker loop.
+How to verify:
+- runtime logs + manual device run
+- code paths: `DistributedCheckinWorker`, `OsqueryQueryEngine`
+
+### O4. SQL capability boundary
+Human rule:
+- Only the documented SQL subset is supported.
+- Unknown table/column requests must fail clearly, without destabilizing execution.
+- Behavior must stay deterministic for supported syntax.
+How to verify:
+- `SqlParser` + `OsqueryQueryEngine` behavior
+- manual Fleet UI query checks
+
+### O5. `android_logcat` privacy and safety gate
+Human rule:
+- `android_logcat` is opt-in and off by default.
+- When enabled, output must be bounded, tag-filtered, and redacted for sensitive values.
+- Log collection failures must never crash the agent.
+How to verify:
+- code path: `AndroidLogcatTable`
+- manual managed-config validation (off vs on)
+
+### O6. `time` table behavior
+Human rule:
+- `SELECT * FROM time;` returns exactly one row.
+- Required columns are present and parseable where numeric semantics apply.
+- `unix_time` is a valid epoch-seconds value close to device current time.
+- `local_timezone` is non-empty.
+How to verify:
+- manual Fleet query on Android host
+- unit/integration assertion for one-row shape and parseability
+
+### O7. `uptime` table behavior
+Human rule:
+- `SELECT * FROM uptime;` returns exactly one row.
+- `total_seconds` is an integer >= 0.
+- `days/hours/minutes/seconds` are non-negative and consistent with `total_seconds` within integer rounding tolerance.
+How to verify:
+- manual Fleet query on Android host
+- unit/integration assertion for non-negative and consistency checks
+
+### O8. Process compliance oracle for AI-assisted development
+Human rule:
+- For any new requested change, AI must produce contract/oracle deltas before code edits.
+- AI must pause for human approval before implementation.
+- Final output must state what oracle items were tested and what remains untested.
+How to verify:
+- PR timeline and commit order show contract/oracle update commit before implementation commit.
+- Conversation history shows explicit approval checkpoint before code edits.
+
+### O9. `system_info` table behavior
+Human rule:
+- `SELECT * FROM system_info;` returns exactly one row.
+- Identity/model fields expected on Android are present and non-empty when available.
+- Output is deterministic for a single snapshot (no crashes, no multi-row behavior).
+How to verify:
+- manual Fleet query on Android host
+- unit assertion for one-row shape and required keys
+
+### O10. `kernel_info` table behavior
+Human rule:
+- `SELECT * FROM kernel_info;` returns exactly one row.
+- Kernel/runtime version fields are present and non-empty where available.
+- Table does not depend on network access.
+How to verify:
+- manual Fleet query on Android host
+- unit assertion for one-row shape and key presence
+
+### O11. `memory_info` table behavior
+Human rule:
+- `SELECT * FROM memory_info;` returns exactly one row.
+- memory totals/available values parse as non-negative integers.
+- low-memory indicator is stable and parseable.
+How to verify:
+- manual Fleet query on Android host
+- unit assertion for one-row shape and numeric parseability
+
+### O12. Security baseline regression checks
+Human rule:
+- Release path rejects non-HTTPS origins and malformed base URLs.
+- 401 behavior triggers controlled re-enrollment; non-401 does not.
+- Distributed query execution path does not use unsafe TLS bypass logic.
+- Non-debug logs do not expose full Device ID.
+- Backup remains disabled and boot receiver remains non-exported.
+How to verify:
+- unit suite: `ApiClientReenrollTest` (URL and re-enrollment invariants)
+- code inspection: `FleetDistributedQueryRunner` delegates network calls to `ApiClient`
+- manifest inspection: `android:allowBackup=\"false\"`, boot receiver `android:exported=\"false\"`
+- runtime/log review on release-like build
+
+### O13. `processes` table behavior
+Human rule:
+- `SELECT * FROM processes;` returns zero or more rows without crash.
+- Rows include stable process identity columns (`pid`, `name`) for visible processes.
+How to verify:
+- manual Fleet query on Android host
+- unit assertion for query success and required key presence on returned rows
+
+### O14. `interface_addresses` table behavior
+Human rule:
+- `SELECT * FROM interface_addresses;` returns zero or more rows without crash.
+- Rows include interface and address fields.
+How to verify:
+- manual Fleet query on Android host
+- unit assertion for query success and required keys on returned rows
+
+### O15. `routes` table behavior
+Human rule:
+- `SELECT * FROM routes;` returns zero or more rows without crash.
+- Route parsing is best effort; missing platform data must degrade safely to empty results.
+How to verify:
+- manual Fleet query on Android host
+- unit assertion for query success and safe empty behavior
+
+### O16. `users` table behavior
+Human rule:
+- `SELECT * FROM users;` returns exactly one row for current app/profile context.
+- Identity fields are present and parseable (`uid`, `username`).
+How to verify:
+- manual Fleet query on Android host
+- unit assertion for one-row shape and parseability
+
+### O17. `mounts` table behavior
+Human rule:
+- `SELECT * FROM mounts;` returns zero or more rows from `/proc/mounts` parsing.
+- Parser failures must not crash the table path.
+How to verify:
+- manual Fleet query on Android host
+- unit assertion for query success and key presence on returned rows
+
+### O18. `cpu_info` table behavior
+Human rule:
+- `SELECT * FROM cpu_info;` returns exactly one row.
+- Core count is parseable and non-negative; model/arch fields are present.
+How to verify:
+- manual Fleet query on Android host
+- unit assertion for one-row shape and numeric parseability
+
+### O19. `app_signatures` table behavior
+Human rule:
+- `SELECT * FROM app_signatures;` returns zero or more rows without crash.
+- If rows exist, `package_name` and `sha256` are present; `sha256` is hex-encoded digest.
+How to verify:
+- manual Fleet query on Android host
+- unit assertion for query success and key presence on returned rows
+
+### O20. `mdm_status` table behavior
+Human rule:
+- `SELECT * FROM mdm_status;` returns exactly one row.
+- Presence/state fields are parseable booleans (`0`/`1`) and reflect current local status best effort.
+How to verify:
+- manual Fleet query on Android host
+- unit assertion for one-row shape and boolean parseability
+
+### O21. `startup_items` table behavior
+Human rule:
+- `SELECT * FROM startup_items;` returns zero or more rows without crash.
+- Rows represent discovered startup-related components (boot receivers / launcher activities).
+How to verify:
+- manual Fleet query on Android host
+- unit assertion for query success and key presence on returned rows
+
+### O22. PR quality evidence and transparency
+Human rule:
+- PR description must include an oracle evidence table mapping each oracle group to concrete evidence.
+- PR description must include reviewer split and scope freeze notes.
+- PR description must explicitly call out high-value checks that were not executed in current environment.
+How to verify:
+- inspect PR sections: `Test Plan`, `QA`, `Oracle evidence table`, `Reviewer split`, `Scope freeze`, `Not tested`.
+
+## Reviewer checklist (fast path)
+1. Validate C1-C4 against unit test evidence (`ApiClientReenrollTest`).
+2. Validate C5-C7 by one end-to-end run: query from Fleet UI and inspect writeback results.
+3. Validate C8 by running `android_logcat` once with flag off (expect empty), then on (expect filtered/redacted rows).
+4. Validate C9/O6/O7 with `SELECT * FROM time;` and `SELECT * FROM uptime;` plus basic value sanity checks.
+5. Validate C10/O9/O10/O11 with `SELECT * FROM system_info;`, `SELECT * FROM kernel_info;`, and `SELECT * FROM memory_info;`.
+6. Validate C11/O12 security baseline checks (URL policy, manifest flags, no unsafe TLS path).
+7. Validate C12/O13-O18 with query success and shape checks for parity tables.
+8. Validate C13/O19-O21 with query success and shape checks for management/startup tables.
+9. Validate C14/O22 by checking PR evidence mapping, reviewer split, scope freeze, and explicit untested list.
+10. Approve only if behavior and safety expectations match this contract, even if implementation details change later.
diff --git a/android/000_PASTE_INTO_AI_TERMINAL_CONTRACT_ORACLE_WORKFLOW.md b/android/000_PASTE_INTO_AI_TERMINAL_CONTRACT_ORACLE_WORKFLOW.md
new file mode 100644
index 00000000000..244fefe78c9
--- /dev/null
+++ b/android/000_PASTE_INTO_AI_TERMINAL_CONTRACT_ORACLE_WORKFLOW.md
@@ -0,0 +1,65 @@
+# Contract-First AI Workflow (Paste Into Codex/Claude)
+
+Use this instruction at the start of every AI coding session.
+
+## Mission
+Work in a **contract-first** way.
+Before changing code, create a clear behavior contract and oracle, get human approval, and only then implement.
+
+## Core concepts (required)
+- **Behavior contract**:
+ The human-readable description of what the system must do and what must remain true.
+ It defines boundaries, invariants, and expected behavior.
+- **Oracle**:
+ The concrete, observable checks that decide whether implementation behavior is acceptable.
+ Oracles can be unit tests, integration tests, runtime checks, logs, and manual validation steps.
+
+## Mandatory workflow for every request
+1. Restate human intent in 1-3 clear lines.
+2. Propose contract changes first.
+3. Propose oracle changes first.
+4. Ask for human approval.
+5. Stop. Do not edit implementation code before explicit approval.
+6. After approval, implement exactly according to the approved contract/oracle.
+7. Run verification and map results to oracle items.
+8. Report what is tested vs not tested.
+
+## Required behavior rules
+- Always produce a contract/oracle delta before code changes.
+- Always request a clear approval checkpoint before implementation.
+- Keep contract/oracle human-readable, short, and reviewable.
+- If a requirement is ambiguous, ask a clarifying question before implementation.
+
+## Truthfulness and provenance rules
+- Never claim “contract came first” when it did not.
+- If contract was extracted from existing code, state that explicitly.
+- If part was contract-first and part was reverse-extracted, state both clearly.
+- Separate confidence from proof:
+ - acceptable: “appears compliant based on tests/manual validation”
+ - not acceptable: “proven correct” unless formal proof exists.
+- If manual validation was performed by AI, say so explicitly.
+
+## Oracle quality rules
+Each oracle section must include:
+- `Human rule`: plain-language expected behavior.
+- `How to verify`: exact checks (test command, query, runtime check, etc.).
+- `Coverage status`: tested / not tested.
+
+Prefer deterministic checks and avoid vague acceptance criteria.
+
+## PR reporting rules
+In PR description (or equivalent summary), include:
+- Link to contract/oracle file.
+- Short `Tested` section mapped to oracle IDs.
+- Short `Not tested` section mapped to oracle IDs.
+
+## Output style
+- Be concise and direct.
+- Use simple language.
+- Optimize for human review speed.
+
+## Start behavior (important)
+After receiving this instruction, your first action on any new task is:
+1. Draft contract/oracle changes.
+2. Ask for review/approval.
+3. Wait.
diff --git a/android/001_TEST_PLAN_ANDROID_OSQUERY.md b/android/001_TEST_PLAN_ANDROID_OSQUERY.md
new file mode 100644
index 00000000000..c43cdf0a7cd
--- /dev/null
+++ b/android/001_TEST_PLAN_ANDROID_OSQUERY.md
@@ -0,0 +1,256 @@
+# Android Osquery Test Plan
+
+## Purpose
+Provide a practical, repeatable test plan for validating Android osquery behavior, enrollment reliability, and security controls before merge/release.
+
+## Scope
+This plan covers:
+- Enrollment and re-enrollment behavior
+- Distributed query read/execute/write loop
+- SQL surface and table behavior
+- Security controls in enrollment/distributed paths
+- Real-device validation and regression checks
+
+Out of scope:
+- Full certificate/SCEP matrix (covered by dedicated certificate tests)
+- Non-Android platforms
+
+## Reference contract/oracle
+- Contract + oracle: `android/000_BEHAVIOR_CONTRACT_AND_ORACLE.md`
+- Key mappings used in this plan:
+ - Enrollment/transport: O1, O2
+ - Distributed loop: O3, O4
+ - Table oracles: O5, O6, O7, O9, O10, O11
+ - Security baseline: O12
+
+## Test environments
+- Local dev:
+ - macOS + Android SDK + JDK 17+
+ - Fleet server reachable at localhost/LAN
+ - Android device connected by ADB
+- CI/local unit:
+ - JVM tests via Gradle
+- Real device matrix (manual):
+ - At least 2 Android versions
+ - At least 2 OEMs if possible
+
+## Entry criteria
+- Branch builds locally
+- Contract/oracle updated and reviewed
+- Test device enrolled or ready to enroll
+
+## Exit criteria
+- All P0/P1 tests pass
+- No open high-severity security issues
+- Known minor risks documented in contract and PR QA
+
+## Priority levels
+- P0: Must pass for merge
+- P1: Should pass before review sign-off
+- P2: Nice-to-have confidence expansion
+
+## Automated test suite (P0)
+
+### A1. Enrollment + URL hardening
+Command:
+```bash
+./gradlew :app:testDebugUnitTest --tests com.fleetdm.agent.ApiClientReenrollTest --console=plain --no-daemon
+```
+Expected:
+- Re-enrollment on 401 works
+- Non-401 does not re-enroll
+- Identity change clears node key
+- URL validation rules enforced
+
+### A2. Time/uptime table oracle checks
+Command:
+```bash
+./gradlew :app:testDebugUnitTest --tests com.fleetdm.agent.osquery.TimeAndUptimeTableTest --console=plain --no-daemon
+```
+Expected:
+- `time` and `uptime` return one row
+- numeric/time sanity checks pass
+
+### A3. System/kernel/memory table oracle checks
+Command:
+```bash
+./gradlew :app:testDebugUnitTest --tests com.fleetdm.agent.osquery.SystemKernelMemoryTableTest --console=plain --no-daemon
+```
+Expected:
+- `system_info`, `kernel_info`, `memory_info` one-row shape and value parseability checks pass
+
+### A4. Combined gate
+Command:
+```bash
+./gradlew :app:compileDebugKotlin :app:testDebugUnitTest \
+ --tests com.fleetdm.agent.ApiClientReenrollTest \
+ --tests com.fleetdm.agent.osquery.TimeAndUptimeTableTest \
+ --tests com.fleetdm.agent.osquery.SystemKernelMemoryTableTest \
+ --tests com.fleetdm.agent.osquery.AdditionalParityTablesTest \
+ --tests com.fleetdm.agent.osquery.ManagementVisibilityTablesTest \
+ --console=plain --no-daemon
+```
+Expected:
+- Full targeted suite passes
+
+## Manual end-to-end validation (P0/P1)
+
+### M1. Device enrollment smoke (P0)
+Steps:
+1. Confirm device:
+```bash
+adb devices
+```
+2. Reverse localhost:
+```bash
+adb reverse tcp:8080 tcp:8080
+```
+3. Install debug app:
+```bash
+FLEET_SERVER_URL='http://127.0.0.1:8080' ./gradlew installDebug
+```
+4. Reset app state and launch:
+```bash
+adb shell pm clear com.fleetdm.agent
+adb shell monkey -p com.fleetdm.agent -c android.intent.category.LAUNCHER 1
+```
+Expected:
+- Host appears in Fleet within expected check-in window
+
+### M2. Distributed query loop smoke (P0)
+Run in Fleet Live Query:
+```sql
+SELECT name, version, platform, security_patch FROM os_version;
+```
+Expected:
+- Result returns from Android host
+- No repeated stuck query behavior
+
+### M3. Cross-OS table smoke (P1)
+Run in Fleet Live Query:
+```sql
+SELECT * FROM time;
+SELECT * FROM uptime;
+SELECT * FROM system_info;
+SELECT * FROM kernel_info;
+SELECT * FROM memory_info;
+SELECT * FROM processes LIMIT 20;
+SELECT * FROM interface_addresses LIMIT 20;
+SELECT * FROM routes LIMIT 20;
+SELECT * FROM users;
+SELECT * FROM mounts LIMIT 20;
+SELECT * FROM cpu_info;
+SELECT * FROM app_signatures LIMIT 20;
+SELECT * FROM mdm_status;
+SELECT * FROM startup_items LIMIT 20;
+```
+Expected:
+- Exactly one row per table
+- Key sanity:
+ - `time.unix_time` close to current epoch
+ - `uptime.total_seconds >= 0`
+ - `system_info.uuid` non-empty
+ - `kernel_info.platform='android'`
+ - memory numeric fields non-negative
+ - `users.uid` parseable and non-negative
+ - `cpu_info.cores` parseable and non-negative
+ - `mdm_status` boolean fields are parseable as `0`/`1`
+
+### M4. Logcat table gating (P1)
+1. Query with flag OFF:
+```sql
+SELECT * FROM android_logcat LIMIT 20;
+```
+Expected:
+- Empty result
+2. Enable managed config `enable_android_logcat_table=true` and retry.
+Expected:
+- Filtered Fleet-tag rows, no obvious secret leakage
+
+## Security-focused tests (P0/P1)
+
+### S1. URL policy (P0)
+Verify behavior from automated tests (A1) and code paths:
+- Non-debug requires HTTPS
+- Reject path/query/fragment/userinfo in base URL
+
+### S1b. Negative URL/config path (P0)
+Command:
+```bash
+./gradlew :app:testDebugUnitTest --tests com.fleetdm.agent.SecurityNegativePathsTest --console=plain --no-daemon
+```
+Expected:
+- Missing enrollment config fails closed (`Credentials not set`)
+
+### S2. Re-enrollment control (P0)
+Simulate/validate:
+- 401 -> clear key -> re-enroll -> retry
+- non-401 -> no re-enroll
+
+### S3. Manifest security controls (P0)
+Verify in manifest:
+- `android:allowBackup="false"`
+- `BootReceiver` exported false
+
+### S4. Distributed path hardening (P0)
+Code verification:
+- `FleetDistributedQueryRunner` uses `ApiClient.distributedRead/distributedWrite`
+- no custom unsafe TLS bypass path in active distributed loop
+
+### S5. Identifier logging control (P1)
+Release-like behavior:
+- full Device ID not logged in non-debug
+
+### S6. Accepted minor risk verification (P2)
+- Enrollment secret currently in app-private DataStore (not Keystore-encrypted)
+- Confirm risk remains documented in contract + PR QA
+
+### S7. Negative SQL parser path (P1)
+Command:
+```bash
+./gradlew :app:testDebugUnitTest --tests com.fleetdm.agent.SecurityNegativePathsTest --console=plain --no-daemon
+```
+Expected:
+- Malformed SQL is rejected and does not crash worker/query engine path
+
+## Lifecycle and corner-case tests (P1/P2)
+
+### L1. Host delete + re-enroll (P1)
+Flow:
+1. Enroll device
+2. Delete host in Fleet UI
+3. Trigger next check-in
+Expected:
+- 401 path re-enrolls and host recovers
+
+### L2. App uninstall/reinstall (P1)
+Expected:
+- Fresh enroll works, no stale-key failure
+
+### L3. Work profile reset (P2)
+Expected:
+- Re-enrollment and query loop recover after profile recreation
+
+### L4. Network disruption (P2)
+Cases:
+- captive portal
+- intermittent connectivity
+- TLS interception/proxy
+Expected:
+- Failures are controlled, no crash loops, recovery when network normalizes
+
+## Test reporting template
+For each run, record:
+- Build/commit
+- Device model + Android version
+- Tests run (IDs)
+- Result: pass/fail
+- Evidence: command output, screenshot, query result, log snippet
+- Follow-ups/issues
+
+## Recommended merge gate
+Minimum for merge:
+- A4 pass
+- M1 + M2 pass on at least one real device
+- S1/S2/S3/S4 pass
+- Open issues triaged, high severity resolved
diff --git a/android/README.md b/android/README.md
index d661cbb25b0..b98d72d5e99 100644
--- a/android/README.md
+++ b/android/README.md
@@ -291,3 +291,149 @@ See `gradle/libs.versions.toml` for complete list.
**Delete device from Android MDM:**
- Delete Work profile on Android device
- Using `tools/android/android.go`, delete the device and delete the associated policy (as of 2025/11/21, Fleet server does not do this)
+
+## Osquery on Android
+
+This project extends the Fleet Android agent toward an Android osquery runtime that executes distributed queries and reports results back to Fleet.
+The goal is to make Android hosts first-class query targets with osquery-style tables and behavior.
+
+### Enrollment summary
+
+The app reads `server_url`, `enroll_secret`, and `host_uuid` from Android managed configuration.
+It enrolls with Fleet Orbit and stores the node key securely on device for future API calls.
+In debug builds, if managed configuration is missing, it can fall back to debug-provided server URL and enroll secret.
+
+### Query polling cadence
+
+For distributed queries, the app checks in with Fleet on a loop using WorkManager.
+In the current implementation, the debug polling interval is hardcoded to **15 seconds** in `DistributedCheckinWorker`.
+This interval is **not configurable yet**; making it configurable (for example via managed config or a build setting) is a good next step.
+
+### Android table reference
+
+| Table | Summary | Quick query |
+| --- | --- | --- |
+| `installed_apps` | Installed app inventory and versions | `SELECT app_name, package_name, version_name FROM installed_apps LIMIT 25;` |
+| `app_permissions` | App permissions and grant state | `SELECT app_name, permission, granted FROM app_permissions LIMIT 50;` |
+| `os_version` | Android version/build/security patch info | `SELECT name, version, build, security_patch FROM os_version;` |
+| `osquery_info` | Agent runtime metadata | `SELECT uuid, instance_id, version FROM osquery_info;` |
+| `certificates` | Certificate records visible to the agent | `SELECT alias, subject, issuer, not_after FROM certificates LIMIT 50;` |
+| `device_info` | Device model/manufacturer/hardware metadata | `SELECT manufacturer, brand, model, device FROM device_info;` |
+| `network_interfaces` | Interface and addressing details | `SELECT name, mac, mtu, addresses FROM network_interfaces;` |
+| `battery` | Battery state and health | `SELECT percent_remaining, charging, health FROM battery;` |
+| `wifi_networks` | Wi-Fi connection/network details | `SELECT ssid, bssid, rssi, is_connected FROM wifi_networks;` |
+| `system_properties` | Android system property key/value pairs | `SELECT key, value FROM system_properties LIMIT 50;` |
+| `android_logcat` | Recent logcat entries | `SELECT timestamp, level, tag, message FROM android_logcat LIMIT 100;` |
+| `time` | Current local time/timezone snapshot | `SELECT weekday, hour, minutes, local_timezone, unix_time FROM time;` |
+| `uptime` | Device uptime duration snapshot | `SELECT days, hours, minutes, seconds, total_seconds FROM uptime;` |
+| `system_info` | Host identity, hardware, and memory summary snapshot | `SELECT hostname, uuid, hardware_vendor, hardware_model, physical_memory FROM system_info;` |
+| `kernel_info` | Kernel and runtime version snapshot | `SELECT version, release, build, platform FROM kernel_info;` |
+| `memory_info` | Current memory totals and low-memory state | `SELECT total_bytes, available_bytes, threshold_bytes, low_memory FROM memory_info;` |
+| `processes` | Visible process snapshot | `SELECT pid, name, uid, package_name, importance FROM processes LIMIT 50;` |
+| `interface_addresses` | Interface-to-address rows | `SELECT interface, address, family FROM interface_addresses LIMIT 50;` |
+| `routes` | Best-effort route snapshot | `SELECT destination, gateway, interface FROM routes LIMIT 50;` |
+| `users` | Current app/profile identity row | `SELECT uid, gid, username, directory FROM users;` |
+| `mounts` | Filesystem mount snapshot | `SELECT device, path, type, flags FROM mounts LIMIT 50;` |
+| `cpu_info` | CPU capability snapshot | `SELECT cores, arch, model, hardware, vendor FROM cpu_info;` |
+| `app_signatures` | Installed app signing certificate fingerprint metadata | `SELECT package_name, sha256, subject, issuer FROM app_signatures LIMIT 50;` |
+| `mdm_status` | MDM/restrictions presence snapshot | `SELECT has_device_owner, has_work_profile, restrictions_present, enroll_secret_present FROM mdm_status;` |
+| `startup_items` | Boot/launcher component visibility snapshot | `SELECT package_name, component, type, enabled, exported FROM startup_items LIMIT 50;` |
+
+### Quick start (5 minutes)
+
+Run these commands from `android/`:
+
+```bash
+adb devices
+adb reverse tcp:8080 tcp:8080
+export FLEET_ENROLL_SECRET='YOUR_ENROLL_SECRET'
+FLEET_SERVER_URL='http://127.0.0.1:8080' ./gradlew installDebug
+adb shell pm clear com.fleetdm.agent
+adb shell monkey -p com.fleetdm.agent -c android.intent.category.LAUNCHER 1
+```
+
+### Verify it works
+
+1. Open Fleet UI and check that the Android host appears in **Hosts**.
+2. Run a simple live query:
+
+```sql
+SELECT name, version, platform, security_patch FROM os_version;
+```
+
+3. Confirm logs show successful read/write loop:
+
+```bash
+adb logcat | rg "fleet-ApiClient|fleet-distributed|Successfully enrolled host|distributed/read"
+```
+
+### QA quick checks for new cross-OS tables
+
+Run these in Fleet live query and confirm exactly 1 row each:
+
+```sql
+SELECT * FROM time;
+SELECT * FROM uptime;
+SELECT * FROM system_info;
+SELECT * FROM kernel_info;
+SELECT * FROM memory_info;
+SELECT * FROM processes LIMIT 20;
+SELECT * FROM interface_addresses LIMIT 20;
+SELECT * FROM routes LIMIT 20;
+SELECT * FROM users;
+SELECT * FROM mounts LIMIT 20;
+SELECT * FROM cpu_info;
+SELECT * FROM app_signatures LIMIT 20;
+SELECT * FROM mdm_status;
+SELECT * FROM startup_items LIMIT 20;
+```
+
+Expected sanity checks:
+- `time.unix_time` is close to current epoch time, and `local_timezone` is non-empty.
+- `uptime.total_seconds` is non-negative and consistent with `days/hours/minutes/seconds`.
+- `system_info.uuid` is non-empty.
+- `kernel_info.platform` is `android`.
+- `memory_info.total_bytes`/`available_bytes`/`threshold_bytes` are non-negative.
+- `users.uid` is parseable and non-negative.
+- `cpu_info.cores` is parseable and non-negative.
+- `mdm_status` boolean fields are `0`/`1`.
+
+### Architecture at a glance
+
+```text
+Managed config (server_url + enroll_secret + host_uuid)
+ -> AgentApplication refreshes credentials
+ -> ApiClient enrolls via /api/fleet/orbit/enroll (node key persisted)
+ -> DistributedCheckinWorker polls /api/v1/osquery/distributed/read
+ -> OsqueryQueryEngine runs SQL on Android-backed tables
+ -> Results posted to /api/v1/osquery/distributed/write
+```
+
+### Current limitations
+
+- SQL support is intentionally limited (basic `SELECT` + simple `WHERE`).
+- Debug distributed polling interval is currently hardcoded to 15 seconds.
+- Debug builds allow cleartext HTTP for local development; production should use managed secure config.
+
+### Roadmap / next steps
+
+- Make distributed polling interval configurable (managed config or build config).
+- Add explicit platform validation updates for Android query targeting.
+- Add more Android-specific tables and improve schema/docs coverage.
+- Expand integration tests for distributed query loop behavior.
+
+### Troubleshooting by symptom
+
+| Symptom | Likely cause | Fix |
+| --- | --- | --- |
+| Host not appearing in Fleet | Wrong `server_url` or enroll secret | Use `adb reverse`, set `FLEET_SERVER_URL=http://127.0.0.1:8080`, reinstall debug build |
+| `localhost` does not work from phone | Phone resolves localhost to itself | Use `adb reverse tcp:8080 tcp:8080` or LAN IP |
+| Query returns no rows | Unsupported table/columns or no local data | Start with `os_version` and `device_info` queries |
+| No distributed logs | App not started after install/clear | Launch with `adb shell monkey -p com.fleetdm.agent -c android.intent.category.LAUNCHER 1` |
+| Build/install fails | Missing execute bit or SDK/JDK setup | Run `chmod +x ./gradlew` and verify SDK/JDK requirements |
+
+### Security model (summary)
+
+- Enrollment uses managed config (`server_url`, `enroll_secret`, `host_uuid`) or debug fallback in debug builds only.
+- Node key and API credentials are stored encrypted on-device using Android Keystore-backed encryption.
+- Network access is restricted by build type; debug permits local development workflows, while production is expected to use secure managed configuration.
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 5d8f4eae115..f95776a6f5d 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -2,6 +2,14 @@ import org.gradle.testing.jacoco.plugins.JacocoTaskExtension
import java.io.FileInputStream
import java.util.Properties
+
+
+fun fleetProp(name: String): String =
+ (project.findProperty(name) as String?)
+ ?: (System.getenv(name.replace('.', '_').uppercase()) ?: "")
+
+
+
// ==================== PLUGINS ====================
plugins {
@@ -101,6 +109,8 @@ android {
debug {
enableUnitTestCoverage = true
enableAndroidTestCoverage = true
+ buildConfigField("String", "DEBUG_FLEET_SERVER_URL", "\"${fleetProp("fleet.server_url")}\"")
+ buildConfigField("String", "DEBUG_FLEET_ENROLL_SECRET", "\"${fleetProp("fleet.enroll_secret")}\"")
}
release {
if (keystorePropertiesFile.exists()) {
@@ -255,6 +265,7 @@ dependencies {
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.amapi.sdk)
implementation(libs.kotlinx.serialization.json)
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
// SCEP (Simple Certificate Enrollment Protocol)
implementation(libs.jscep)
diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml
new file mode 100644
index 00000000000..f23f576df98
--- /dev/null
+++ b/android/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/debug/res/xml/network_security_config_debug.xml b/android/app/src/debug/res/xml/network_security_config_debug.xml
new file mode 100644
index 00000000000..e0ebce10d80
--- /dev/null
+++ b/android/app/src/debug/res/xml/network_security_config_debug.xml
@@ -0,0 +1,10 @@
+
+
+
+
+ localhost
+ 127.0.0.1
+ 10.0.2.2
+
+
+
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 8dff2518919..96bcaf54737 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -20,7 +20,7 @@
+ android:exported="false">
diff --git a/android/app/src/main/java/com/fleetdm/agent/AgentApplication.kt b/android/app/src/main/java/com/fleetdm/agent/AgentApplication.kt
index a822589fe7e..95445f38e91 100644
--- a/android/app/src/main/java/com/fleetdm/agent/AgentApplication.kt
+++ b/android/app/src/main/java/com/fleetdm/agent/AgentApplication.kt
@@ -12,11 +12,14 @@ import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkRequest
+import com.fleetdm.agent.device.DeviceIdManager
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
+import com.fleetdm.agent.device.Storage
+import com.fleetdm.agent.osquery.core.TableRegistry
/**
* Custom Application class for Fleet Agent.
@@ -30,11 +33,6 @@ class AgentApplication : Application() {
companion object {
private const val TAG = "fleet-app"
- /**
- * Gets the CertificateOrchestrator instance from the Application.
- * @param context Any context (will use applicationContext)
- * @return The shared CertificateOrchestrator instance
- */
fun getCertificateOrchestrator(context: Context): CertificateOrchestrator =
(context.applicationContext as AgentApplication).certificateOrchestrator
}
@@ -46,6 +44,13 @@ class AgentApplication : Application() {
Log.i(TAG, "Fleet agent process started")
FleetLog.initialize(this)
+ Storage.init(this)
+
+ // Device ID is useful for local debugging; avoid logging full identifier in non-debug builds.
+ val deviceId = DeviceIdManager.getOrCreateDeviceId()
+ if (BuildConfig.DEBUG) {
+ Log.i(TAG, "DeviceId=${deviceId.take(8)}...")
+ }
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
@@ -56,46 +61,80 @@ class AgentApplication : Application() {
android.os.Process.killProcess(android.os.Process.myPid())
}
}
-
// Initialize dependencies
ApiClient.initialize(this)
+
+ // Register osquery table plugins
+ com.fleetdm.agent.osquery.OsqueryTables.registerAll(this)
+
+ // Register core osquery tables (including android_logcat)
+ TableRegistry.ensureRegistered()
+
+ if (BuildConfig.DEBUG) {
+ DistributedCheckinWorker.scheduleNextDebug(this)
+ }
+
certificateOrchestrator = CertificateOrchestrator()
refreshEnrollmentCredentials()
schedulePeriodicCertificateEnrollment()
}
+ /**
+ * Production path: MDM managed configuration (RestrictionsManager).
+ * Debug-only fallback: BuildConfig.DEBUG_* values, ONLY if MDM values are missing.
+ */
private fun refreshEnrollmentCredentials() {
applicationScope.launch {
try {
- val restrictionsManager = getSystemService(Context.RESTRICTIONS_SERVICE)
- as? RestrictionsManager
- val appRestrictions = restrictionsManager?.applicationRestrictions ?: return@launch
-
- val enrollSecret = appRestrictions.getString("enroll_secret")
- val hostUUID = appRestrictions.getString("host_uuid")
- val serverURL = appRestrictions.getString("server_url")
-
- if (enrollSecret != null && hostUUID != null && serverURL != null) {
- Log.d(TAG, "Refreshing enrollment credentials from MDM config")
- ApiClient.setEnrollmentCredentials(
- enrollSecret = enrollSecret,
- hardwareUUID = hostUUID,
- serverUrl = serverURL,
- computerName = "${Build.BRAND} ${Build.MODEL}",
- )
-
- // Only enroll if not already enrolled
- if (ApiClient.getApiKey() == null) {
- val configResult = ApiClient.getOrbitConfig()
- configResult.onSuccess {
- Log.d(TAG, "Successfully enrolled host with Fleet server")
- }.onFailure { error ->
- Log.w(TAG, "Host enrollment failed: ${error.message}")
- }
+ val restrictionsManager =
+ getSystemService(Context.RESTRICTIONS_SERVICE) as? RestrictionsManager
+ val appRestrictions = restrictionsManager?.applicationRestrictions
+
+ val mdmEnrollSecret = appRestrictions?.getString("enroll_secret")
+ val mdmHostUUID = appRestrictions?.getString("host_uuid")
+ val mdmServerURL = appRestrictions?.getString("server_url")
+
+ val (enrollSecret, hostUUID, serverURL) = if (
+ !mdmEnrollSecret.isNullOrBlank() &&
+ !mdmHostUUID.isNullOrBlank() &&
+ !mdmServerURL.isNullOrBlank()
+ ) {
+ Log.d(TAG, "Using MDM enrollment credentials (managed config)")
+ Triple(mdmEnrollSecret, mdmHostUUID, mdmServerURL)
+ } else if (BuildConfig.DEBUG) {
+ val debugUrl = getOptionalBuildConfigString("DEBUG_FLEET_SERVER_URL")
+ val debugSecret = getOptionalBuildConfigString("DEBUG_FLEET_ENROLL_SECRET")
+
+ if (!debugUrl.isNullOrBlank() && !debugSecret.isNullOrBlank()) {
+ // Debug fallback host UUID: stable per app install (acceptable for dev)
+ val debugHostUUID = DeviceIdManager.getOrCreateDeviceId()
+
+ Log.w(TAG, "MDM config missing; using DEBUG enrollment credentials")
+ Triple(debugSecret, debugHostUUID, debugUrl)
+ } else { Log.d(TAG, "MDM config missing and DEBUG values not set")
+ return@launch
}
} else {
Log.d(TAG, "MDM enrollment credentials not available")
+ return@launch
+ }
+
+ ApiClient.setEnrollmentCredentials(
+ enrollSecret = enrollSecret,
+ hardwareUUID = hostUUID,
+ serverUrl = serverURL,
+ computerName = "${Build.BRAND} ${Build.MODEL}",
+ )
+
+ // Only enroll if not already enrolled
+ if (ApiClient.getApiKey() == null) {
+ val configResult = ApiClient.getOrbitConfig()
+ configResult.onSuccess {
+ Log.d(TAG, "Successfully enrolled host with Fleet server")
+ }.onFailure { error ->
+ Log.w(TAG, "Host enrollment failed: ${error.message}")
+ }
}
} catch (e: Exception) {
FleetLog.e(TAG, "Error refreshing enrollment credentials", e)
@@ -103,6 +142,17 @@ class AgentApplication : Application() {
}
}
+
+ private fun getOptionalBuildConfigString(fieldName: String): String? {
+ return try {
+ val clazz = Class.forName("${packageName}.BuildConfig")
+ val field = clazz.getField(fieldName)
+ (field.get(null) as? String)?.takeIf { it.isNotBlank() }
+ } catch (_: Throwable) {
+ null
+ }
+ }
+
private fun schedulePeriodicCertificateEnrollment() {
val workRequest = PeriodicWorkRequestBuilder(
15, // 15 minutes is the minimum
diff --git a/android/app/src/main/java/com/fleetdm/agent/ApiClient.kt b/android/app/src/main/java/com/fleetdm/agent/ApiClient.kt
index d85235f4a87..b262eaa2add 100644
--- a/android/app/src/main/java/com/fleetdm/agent/ApiClient.kt
+++ b/android/app/src/main/java/com/fleetdm/agent/ApiClient.kt
@@ -27,6 +27,10 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.builtins.ListSerializer
+import kotlinx.serialization.builtins.MapSerializer
+import kotlinx.serialization.builtins.serializer
+import com.fleetdm.agent.device.DeviceIdManager
/**
* Converts a java.util.Date to ISO8601 format string.
@@ -72,6 +76,34 @@ object ApiClient : CertificateApiClient {
private val enrollmentMutex = Mutex()
+ private fun shouldRequireHttps(): Boolean = !BuildConfig.DEBUG && !KeystoreManager.isTestModeEnabled()
+
+ internal fun validateBaseUrl(baseUrl: String, requireHttps: Boolean): Result {
+ return try {
+ val parsedUrl = URL(baseUrl)
+ if (parsedUrl.protocol !in listOf("https", "http")) {
+ return Result.failure(Exception("Base URL must use HTTP or HTTPS scheme"))
+ }
+ if (requireHttps && parsedUrl.protocol != "https") {
+ return Result.failure(Exception("Base URL must use HTTPS in non-debug builds"))
+ }
+ if (parsedUrl.userInfo != null) {
+ return Result.failure(Exception("Base URL must not include user info"))
+ }
+ if (parsedUrl.path.isNotEmpty() && parsedUrl.path != "/") {
+ return Result.failure(Exception("Base URL must not include a path"))
+ }
+ if (!parsedUrl.query.isNullOrEmpty() || !parsedUrl.ref.isNullOrEmpty()) {
+ return Result.failure(Exception("Base URL must not include query or fragment"))
+ }
+
+ val normalized = "${parsedUrl.protocol}://${parsedUrl.authority}".trimEnd('/')
+ Result.success(normalized)
+ } catch (e: Exception) {
+ Result.failure(Exception("Invalid base URL format: ${e.message}"))
+ }
+ }
+
fun initialize(context: Context) {
Log.d(TAG, "initializing api client")
if (!::dataStore.isInitialized) {
@@ -79,6 +111,46 @@ object ApiClient : CertificateApiClient {
}
}
+ suspend fun distributedRead(): Result = withReenrollOnUnauthorized {
+ val nodeKey = getNodeKeyOrEnroll().getOrElse { error ->
+ return@withReenrollOnUnauthorized Result.failure(error)
+ }
+
+ makeRequest(
+ endpoint = "/api/v1/osquery/distributed/read",
+ method = "POST",
+ body = DistributedReadRequest(nodeKey = nodeKey),
+ bodySerializer = DistributedReadRequest.serializer(),
+ responseSerializer = DistributedReadResponse.serializer(),
+ authorized = false,
+ )
+ }
+
+ suspend fun distributedWrite(
+ queryResults: Map>>,
+ ): Result = withReenrollOnUnauthorized {
+ val nodeKey = getNodeKeyOrEnroll().getOrElse { error ->
+ return@withReenrollOnUnauthorized Result.failure(error)
+ }
+
+ val req = DistributedWriteRequest(nodeKey = nodeKey, queries = queryResults)
+
+ // Fleet usually returns an empty JSON object; we don't care about the body.
+ val res = makeRequest(
+ endpoint = "/api/v1/osquery/distributed/write",
+ method = "POST",
+ body = req,
+ bodySerializer = DistributedWriteRequest.serializer(),
+ responseSerializer = JsonElement.serializer(),
+ authorized = false,
+ )
+
+ res.fold(
+ onSuccess = { Result.success(Unit) },
+ onFailure = { Result.failure(it) },
+ )
+ }
+
private suspend fun setApiKey(key: String) {
dataStore.edit { preferences ->
preferences[API_KEY] = KeystoreManager.encrypt(key)
@@ -122,21 +194,10 @@ object ApiClient : CertificateApiClient {
Exception("Base URL not configured"),
)
- // Validate base URL format and scheme
- try {
- val parsedUrl = URL(baseUrl)
- if (parsedUrl.protocol !in listOf("https", "http")) {
- return@withContext Result.failure(
- Exception("Base URL must use HTTP or HTTPS scheme"),
- )
- }
- } catch (e: Exception) {
- return@withContext Result.failure(
- Exception("Invalid base URL format: ${e.message}"),
- )
- }
+ val normalizedBaseUrl = validateBaseUrl(baseUrl, requireHttps = shouldRequireHttps())
+ .getOrElse { error -> return@withContext Result.failure(error) }
- val url = URL("$baseUrl$endpoint")
+ val url = URL("$normalizedBaseUrl$endpoint")
connection = url.openConnection() as HttpURLConnection
connection.apply {
@@ -144,6 +205,12 @@ object ApiClient : CertificateApiClient {
useCaches = false
doInput = true
setRequestProperty("Content-Type", "application/json")
+ val deviceId = runCatching { DeviceIdManager.getOrCreateDeviceId() }
+ .onFailure { FleetLog.e(TAG, "Failed to retrieve device id: ${it.message}") }
+ .getOrNull()
+ if (!deviceId.isNullOrBlank()) {
+ setRequestProperty("X-Fleet-Device-Id", deviceId)
+ }
if (authorized) {
getNodeKeyOrEnroll().fold(
onFailure = { throwable -> return@withContext Result.failure(throwable) },
@@ -164,13 +231,18 @@ object ApiClient : CertificateApiClient {
}
val responseCode = connection.responseCode
- val response = if (responseCode in 200..299) {
+ var response = if (responseCode in 200..299) {
connection.inputStream.bufferedReader().use { it.readText() }
} else {
connection.errorStream?.bufferedReader()?.use { it.readText() }
?: "HTTP $responseCode"
}
+ // Some Fleet endpoints may respond with an empty body on success.
+ if (responseCode in 200..299 && response.isBlank()) {
+ response = "{}"
+ }
+
Log.d(TAG, "server response from $method $endpoint ($responseCode)")
if (responseCode in 200..299) {
@@ -254,6 +326,20 @@ object ApiClient : CertificateApiClient {
suspend fun setEnrollmentCredentials(enrollSecret: String, hardwareUUID: String, computerName: String, serverUrl: String) {
dataStore.edit { preferences ->
+ val currentEnrollSecret = preferences[ENROLL_SECRET]
+ val currentHardwareUUID = preferences[HARDWARE_UUID]
+ val currentServerUrl = preferences[SERVER_URL_KEY]
+
+ val identityChanged = currentEnrollSecret != null &&
+ (currentEnrollSecret != enrollSecret ||
+ currentHardwareUUID != hardwareUUID ||
+ currentServerUrl != serverUrl)
+
+ if (identityChanged) {
+ preferences.remove(API_KEY)
+ Log.i(TAG, "Enrollment identity changed, cleared stored node key")
+ }
+
preferences[ENROLL_SECRET] = enrollSecret
preferences[HARDWARE_UUID] = hardwareUUID
preferences[COMPUTER_NAME] = computerName
@@ -386,6 +472,32 @@ object ApiClient : CertificateApiClient {
)
}
+@Serializable
+data class DistributedReadRequest(
+ @SerialName("node_key")
+ val nodeKey: String,
+ @SerialName("queries")
+ val queries: Map = emptyMap(),
+)
+
+@Serializable
+data class DistributedReadResponse(
+ @SerialName("queries")
+ val queries: Map = emptyMap(),
+)
+
+@Serializable
+data class DistributedWriteRequest(
+ @SerialName("node_key")
+ val nodeKey: String,
+
+ // Map: queryName -> rows[] where each row is {col: value}
+ @SerialName("queries")
+ val queries: Map>> = emptyMap(),
+)
+
+
+
@Serializable
data class EnrollRequest(
@SerialName("enroll_secret")
diff --git a/android/app/src/main/java/com/fleetdm/agent/DistributedCheckinWorker.kt b/android/app/src/main/java/com/fleetdm/agent/DistributedCheckinWorker.kt
new file mode 100644
index 00000000000..3c2baf95c5a
--- /dev/null
+++ b/android/app/src/main/java/com/fleetdm/agent/DistributedCheckinWorker.kt
@@ -0,0 +1,105 @@
+package com.fleetdm.agent
+
+import android.content.Context
+import android.util.Log
+import androidx.work.Constraints
+import androidx.work.CoroutineWorker
+import androidx.work.ExistingWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkManager
+import androidx.work.WorkerParameters
+import com.fleetdm.agent.osquery.OsqueryQueryEngine
+import java.util.concurrent.TimeUnit
+
+class DistributedCheckinWorker(
+ appContext: Context,
+ params: WorkerParameters,
+) : CoroutineWorker(appContext, params) {
+
+ override suspend fun doWork(): Result {
+ try {
+ Log.d(TAG, "Distributed check-in: starting")
+
+ val readResult = ApiClient.distributedRead()
+ readResult.fold(
+ onSuccess = { resp ->
+ val queries = resp.queries
+ Log.d(TAG, "Distributed check-in: received ${queries.size} query(ies)")
+
+ // 1) Log received queries
+ queries.forEach { (name, _) ->
+ Log.i(TAG, "Distributed query [$name] received")
+ }
+
+ // 2) Execute what we can, and write results back
+ val results = linkedMapOf>>()
+
+ for ((name, sql) in queries) {
+ if (sql.isBlank()) continue
+
+ try {
+ val rows = OsqueryQueryEngine.execute(sql)
+ results[name] = rows
+ } catch (e: Exception) {
+ val msg = e.message ?: e.javaClass.simpleName
+ Log.w(TAG, "Distributed query failed [$name]: $msg")
+
+ // "clear" unknown/unsupported queries so Fleet stops re-sending them
+ results[name] = emptyList()
+ }
+ }
+
+ if (results.isNotEmpty()) {
+ ApiClient.distributedWrite(results).fold(
+ onSuccess = {
+ Log.d(TAG, "Distributed check-in: wrote ${results.size} result set(s)")
+ },
+ onFailure = { err ->
+ Log.w(TAG, "Distributed check-in: write failed: ${err.message}")
+ },
+ )
+ }
+ },
+ onFailure = { err ->
+ Log.w(TAG, "Distributed check-in: read failed: ${err.message}")
+ },
+ )
+
+ return Result.success()
+ } finally {
+ // Debug-only fast polling (15s). Release scheduling handled elsewhere (15m).
+ if (BuildConfig.DEBUG) {
+ scheduleNextDebug(applicationContext)
+ Log.d(TAG, "Distributed check-in: scheduled next run in 15 seconds")
+ }
+ }
+ }
+
+ companion object {
+ private const val TAG = "fleet-distributed"
+
+ /** Single logical chain for debug polling */
+ private const val WORK_NAME_DEBUG = "fleet_distributed_checkin_debug"
+
+ fun scheduleNextDebug(context: Context) {
+ val request = OneTimeWorkRequestBuilder()
+ .setInitialDelay(15, TimeUnit.SECONDS)
+ .setConstraints(
+ Constraints.Builder()
+ .setRequiredNetworkType(NetworkType.CONNECTED)
+ .build()
+ )
+ .addTag(WORK_NAME_DEBUG)
+ .build()
+
+ WorkManager.getInstance(context)
+ .beginUniqueWork(
+ WORK_NAME_DEBUG,
+ ExistingWorkPolicy.APPEND, // do NOT cancel running work
+ request
+ )
+ .enqueue()
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/fleetdm/agent/KeystoreManager.kt b/android/app/src/main/java/com/fleetdm/agent/KeystoreManager.kt
index c37de9438cd..b484fd23041 100644
--- a/android/app/src/main/java/com/fleetdm/agent/KeystoreManager.kt
+++ b/android/app/src/main/java/com/fleetdm/agent/KeystoreManager.kt
@@ -36,6 +36,8 @@ object KeystoreManager {
testKey = null
}
+ internal fun isTestModeEnabled(): Boolean = testMode
+
private fun getOrCreateKey(): SecretKey {
if (testMode) {
return testKey ?: error("Test mode enabled but no test key available")
diff --git a/android/app/src/main/java/com/fleetdm/agent/MainActivity.kt b/android/app/src/main/java/com/fleetdm/agent/MainActivity.kt
index 9c93c4bc4b6..10c5e1b431b 100644
--- a/android/app/src/main/java/com/fleetdm/agent/MainActivity.kt
+++ b/android/app/src/main/java/com/fleetdm/agent/MainActivity.kt
@@ -30,12 +30,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@@ -49,10 +47,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.SpanStyle
-import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
@@ -62,18 +57,15 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.fleetdm.agent.ui.theme.FleetTextDark
import com.fleetdm.agent.ui.theme.MyApplicationTheme
+import com.fleetdm.agent.device.DeviceIdManager
+import com.fleetdm.agent.device.Storage
import kotlinx.serialization.Serializable
const val CLICKS_TO_DEBUG = 8
-@Serializable
-object MainDestination
-
-@Serializable
-object DebugDestination
-
-@Serializable
-object LogsDestination
+@Serializable object MainDestination
+@Serializable object DebugDestination
+@Serializable object LogsDestination
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -106,17 +98,6 @@ fun AppNavigation() {
onNavigateToDebug = { navController.navigate(DebugDestination) },
)
}
-
- composable {
- DebugScreen(
- onNavigateBack = { navController.navigateUp() },
- onNavigateToLogs = { navController.navigate(LogsDestination) },
- )
- }
-
- composable {
- LogsScreen(onNavigateBack = { navController.navigateUp() })
- }
}
}
@@ -125,90 +106,65 @@ fun MainScreen(onNavigateToDebug: () -> Unit) {
val context = LocalContext.current
val orchestrator = remember { AgentApplication.getCertificateOrchestrator(context) }
+ val deviceId = remember {
+ Storage.init(context)
+ DeviceIdManager.getOrCreateDeviceId()
+ }
+
var versionClicks by remember { mutableStateOf(0) }
- val installedCerts by orchestrator.installedCertsFlow(context).collectAsStateWithLifecycle(initialValue = emptyMap())
+ val installedCerts by orchestrator
+ .installedCertsFlow(context)
+ .collectAsStateWithLifecycle(initialValue = emptyMap())
Scaffold(
modifier = Modifier.fillMaxSize(),
content = { paddingValues ->
- Column(Modifier.padding(paddingValues = paddingValues)) {
+ Column(
+ Modifier
+ .padding(paddingValues)
+ .verticalScroll(rememberScrollState())
+ ) {
LogoHeader()
HorizontalDivider()
AboutFleet {
val intent = Intent(Intent.ACTION_VIEW, BuildConfig.INFO_URL.toUri())
- if (intent.resolveActivity(context.packageManager) != null) {
- // A browser is available, open the URL directly
- context.startActivity(intent)
- } else {
- // A browser is not available in the work profile, display a toast with the URL
- val toast = Toast.makeText(context, "Visit ${BuildConfig.INFO_URL}\nfor more information", Toast.LENGTH_LONG)
- toast.show()
- }
+ context.startActivity(intent)
}
HorizontalDivider()
CertificateList(certificates = installedCerts)
AppVersion {
if (++versionClicks >= CLICKS_TO_DEBUG) {
onNavigateToDebug()
- } else if (versionClicks == 1) {
- val clipboard = context.getSystemService(ClipboardManager::class.java)
- ?: error("ClipboardManager not available")
- clipboard.setPrimaryClip(ClipData.newPlainText("", "Fleet Android Agent: ${BuildConfig.VERSION_NAME}"))
- Toast.makeText(context, "Fleet Agent version copied", Toast.LENGTH_SHORT).show()
}
}
+ DeviceIdRow(deviceId = deviceId) {
+ val clipboard = context.getSystemService(ClipboardManager::class.java)
+ clipboard.setPrimaryClip(
+ ClipData.newPlainText("Device ID", deviceId)
+ )
+ Toast.makeText(context, "Device ID copied", Toast.LENGTH_SHORT).show()
+ }
}
},
)
}
@Composable
-fun AboutFleet(modifier: Modifier = Modifier, onLearnClick: () -> Unit = {}) {
- Column(modifier = modifier.padding(20.dp)) {
- Text(
- text = stringResource(R.string.app_description),
- )
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(4.dp),
- modifier = Modifier
- .padding(top = 10.dp)
- .clickable(onClick = onLearnClick),
+fun DeviceIdRow(deviceId: String, onClick: () -> Unit = {}) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick),
+ ) {
+ Column(
+ modifier = Modifier.padding(horizontal = 20.dp, vertical = 14.dp),
) {
Text(
- text = stringResource(R.string.learn_about_fleet),
- fontWeight = FontWeight.Bold,
+ text = "Device ID",
color = FleetTextDark,
+ fontWeight = FontWeight.Bold,
)
- Icon(imageVector = Icons.AutoMirrored.Default.ArrowForward, contentDescription = "forward arrow")
- }
- }
-}
-
-@Composable
-fun LogoHeader(modifier: Modifier = Modifier) {
- Image(
- modifier = modifier.padding(20.dp),
- painter = painterResource(R.drawable.fleet_logo),
- contentDescription = stringResource(R.string.fleet_logo),
- )
-}
-
-@Composable
-fun CertificateList(modifier: Modifier = Modifier, certificates: CertificateStateMap) {
- Column(modifier = modifier.padding(all = 20.dp)) {
- Text(
- text = stringResource(R.string.certificate_list_title),
- color = FleetTextDark,
- fontWeight = FontWeight.Bold,
- )
- certificates.ifEmpty {
- Text(text = stringResource(R.string.certificate_list_no_certificates))
- }
- certificates.forEach { (_, value) ->
- if (value.status == CertificateStatus.INSTALLED || value.status == CertificateStatus.INSTALLED_UNREPORTED) {
- Text(text = value.alias)
- }
+ Text(text = deviceId)
}
}
}
@@ -221,8 +177,7 @@ fun AppVersion(onClick: () -> Unit = {}) {
.clickable(onClick = onClick),
) {
Column(
- modifier = Modifier
- .padding(horizontal = 20.dp),
+ modifier = Modifier.padding(horizontal = 20.dp),
) {
Text(
text = stringResource(R.string.app_version_title),
@@ -234,30 +189,55 @@ fun AppVersion(onClick: () -> Unit = {}) {
}
}
-@Preview(showBackground = true)
@Composable
-fun FleetScreenPreview() {
- MyApplicationTheme {
- Column {
- LogoHeader()
- HorizontalDivider()
- AboutFleet()
- HorizontalDivider()
- CertificateList(
- certificates = mapOf(
- 1 to CertificateState(alias = "WIFI-1", status = CertificateStatus.INSTALLED),
- 2 to CertificateState(alias = "VPN-3", status = CertificateStatus.FAILED),
- ),
+fun AboutFleet(onLearnClick: () -> Unit = {}) {
+ Column(Modifier.padding(20.dp)) {
+ Text(text = stringResource(R.string.app_description))
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .padding(top = 10.dp)
+ .clickable(onClick = onLearnClick),
+ ) {
+ Text(
+ text = stringResource(R.string.learn_about_fleet),
+ fontWeight = FontWeight.Bold,
+ color = FleetTextDark,
+ )
+ Icon(
+ imageVector = Icons.AutoMirrored.Default.ArrowForward,
+ contentDescription = null,
)
- AppVersion(onClick = {})
}
}
}
-@Preview(showBackground = true)
@Composable
-fun AboutFleetPreview() {
- MyApplicationTheme {
- AboutFleet()
+fun LogoHeader() {
+ Image(
+ modifier = Modifier.padding(20.dp),
+ painter = painterResource(R.drawable.fleet_logo),
+ contentDescription = null,
+ )
+}
+
+@Composable
+fun CertificateList(certificates: CertificateStateMap) {
+ Column(Modifier.padding(20.dp)) {
+ Text(
+ text = stringResource(R.string.certificate_list_title),
+ color = FleetTextDark,
+ fontWeight = FontWeight.Bold,
+ )
+ certificates.ifEmpty {
+ Text(text = stringResource(R.string.certificate_list_no_certificates))
+ }
+ certificates.forEach { (_, value) ->
+ if (value.status == CertificateStatus.INSTALLED ||
+ value.status == CertificateStatus.INSTALLED_UNREPORTED
+ ) {
+ Text(text = value.alias)
+ }
+ }
}
}
diff --git a/android/app/src/main/java/com/fleetdm/agent/device/DeviceIdManager.kt b/android/app/src/main/java/com/fleetdm/agent/device/DeviceIdManager.kt
new file mode 100644
index 00000000000..efefb792d7e
--- /dev/null
+++ b/android/app/src/main/java/com/fleetdm/agent/device/DeviceIdManager.kt
@@ -0,0 +1,85 @@
+package com.fleetdm.agent.device
+
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyProperties
+import android.util.Base64
+import java.security.KeyStore
+import java.util.UUID
+import javax.crypto.Cipher
+import javax.crypto.KeyGenerator
+import javax.crypto.SecretKey
+import javax.crypto.spec.GCMParameterSpec
+
+object DeviceIdManager {
+
+ private const val KEY_ALIAS = "fleet_device_id_key"
+ private const val ANDROID_KEYSTORE = "AndroidKeyStore"
+ private const val TRANSFORMATION = "AES/GCM/NoPadding"
+ private const val IV_SIZE = 12
+ private const val TAG_SIZE = 128
+
+ fun getOrCreateDeviceId(): String {
+ val key = getOrCreateKey()
+ val stored = readEncrypted(key)
+ if (stored != null) return stored
+
+ val newId = UUID.randomUUID().toString()
+ writeEncrypted(key, newId)
+ return newId
+ }
+
+ private fun getOrCreateKey(): SecretKey {
+ val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
+
+ keyStore.getKey(KEY_ALIAS, null)?.let {
+ return it as SecretKey
+ }
+
+ val keyGenerator = KeyGenerator.getInstance(
+ KeyProperties.KEY_ALGORITHM_AES,
+ ANDROID_KEYSTORE
+ )
+
+ keyGenerator.init(
+ KeyGenParameterSpec.Builder(
+ KEY_ALIAS,
+ KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
+ )
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
+ .setKeySize(256)
+ .build()
+ )
+
+ return keyGenerator.generateKey()
+ }
+
+ private fun writeEncrypted(key: SecretKey, value: String) {
+ val cipher = Cipher.getInstance(TRANSFORMATION)
+ cipher.init(Cipher.ENCRYPT_MODE, key)
+
+ val encrypted = cipher.doFinal(value.toByteArray())
+ val combined = cipher.iv + encrypted
+
+ Storage.write(Base64.encodeToString(combined, Base64.NO_WRAP))
+ }
+
+ private fun readEncrypted(key: SecretKey): String? {
+ val stored = Storage.read() ?: return null
+ val data = Base64.decode(stored, Base64.NO_WRAP)
+
+ if (data.size <= IV_SIZE) return null
+
+ val iv = data.copyOfRange(0, IV_SIZE)
+ val encrypted = data.copyOfRange(IV_SIZE, data.size)
+
+ val cipher = Cipher.getInstance(TRANSFORMATION)
+ cipher.init(
+ Cipher.DECRYPT_MODE,
+ key,
+ GCMParameterSpec(TAG_SIZE, iv)
+ )
+
+ return String(cipher.doFinal(encrypted))
+ }
+}
diff --git a/android/app/src/main/java/com/fleetdm/agent/device/Storage.kt b/android/app/src/main/java/com/fleetdm/agent/device/Storage.kt
new file mode 100644
index 00000000000..5feca3c99a1
--- /dev/null
+++ b/android/app/src/main/java/com/fleetdm/agent/device/Storage.kt
@@ -0,0 +1,29 @@
+package com.fleetdm.agent.device
+
+import android.content.Context
+
+object Storage {
+
+ private lateinit var context: Context
+ private const val FILE = "fleet_device_id"
+
+ fun init(ctx: Context) {
+ context = ctx.applicationContext
+ }
+
+ fun write(value: String) {
+ context.openFileOutput(FILE, Context.MODE_PRIVATE).use {
+ it.write(value.toByteArray())
+ }
+ }
+
+ fun read(): String? {
+ return try {
+ context.openFileInput(FILE)
+ .bufferedReader()
+ .use { it.readText() }
+ } catch (_: Exception) {
+ null
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/fleetdm/agent/osquery/FleetDistributedQueryRunner.kt b/android/app/src/main/java/com/fleetdm/agent/osquery/FleetDistributedQueryRunner.kt
new file mode 100644
index 00000000000..c98d0c87ada
--- /dev/null
+++ b/android/app/src/main/java/com/fleetdm/agent/osquery/FleetDistributedQueryRunner.kt
@@ -0,0 +1,112 @@
+package com.fleetdm.agent.osquery
+
+import android.util.Log
+import com.fleetdm.agent.ApiClient
+import com.fleetdm.agent.osquery.core.TableQueryContext
+import com.fleetdm.agent.osquery.core.TableRegistry
+import com.fleetdm.agent.osquery.core.WhereCond
+import com.fleetdm.agent.osquery.core.WhereOp
+import com.fleetdm.agent.osquery.core.parseSelectSql
+
+object FleetDistributedQueryRunner {
+
+ private const val tag = "FleetOsquery"
+
+ // If true, when we cannot run a query, we still answer it with [] so Fleet stops sending it.
+ var clearUnknownQueries: Boolean = true
+
+ suspend fun runOnce() {
+ val startMs = System.currentTimeMillis()
+ val readResp = ApiClient.distributedRead()
+ .getOrElse { throw RuntimeException("distributed/read failed: ${it.message}") }
+ val queryNames = readResp.queries.keys.sorted()
+
+ val resultsToWrite = linkedMapOf>>()
+ var handled = 0
+
+ for (qName in queryNames) {
+ val sql = readResp.queries[qName].orEmpty()
+ if (sql.isBlank()) continue
+
+ try {
+ val rows = executeSqlViaTables(sql)
+ resultsToWrite[qName] = rows
+ handled++
+ } catch (e: Exception) {
+ val msg = e.message ?: e.javaClass.simpleName
+ Log.w(tag, "Query failed: $qName err=$msg")
+
+ if (clearUnknownQueries) {
+ resultsToWrite[qName] = emptyList()
+ handled++
+ }
+ }
+ }
+
+ if (resultsToWrite.isNotEmpty()) {
+ ApiClient.distributedWrite(resultsToWrite)
+ .getOrElse { throw RuntimeException("distributed/write failed: ${it.message}") }
+ }
+
+ val took = System.currentTimeMillis() - startMs
+ Log.i(tag, "runOnce handled=$handled wrote=${resultsToWrite.size} tookMs=$took")
+ }
+
+ private suspend fun executeSqlViaTables(sql: String): List