Agent — the Android Device Owner
The agent is the Android app that turns desired policy into reality. It runs as a Device Owner (a Device Policy Controller, DPC), so it can call DevicePolicyManager to actually enforce settings, not just ask the user nicely. It shares only :protocol with the rest of the system.
Source: agent/src/main/kotlin/com/arganaemre/mdmcore/.
Grounding: the
DevicePolicyManager/ device-admin claims below are grounded against the Android Device Administration API doc (kb://android/work/device-admin, https://developer.android.com/guide/topics/admin/device-admin).
The sync routine: runMdmSync
One suspend function (MdmSync.kt) is the only place data moves. It’s shared by every trigger — app launch, the WorkManager job, and the FCM doorbell — so there’s a single code path to reason about. In order, it:
- Establishes identity. Reads the persisted
DeviceIdfromDeviceIdStore. If there’s none (or a later call 404s), itPOST /enrolls and stores the assigned id. - Registers the FCM token.
POST /devices/{id}/tokenwith the current token, so the server can ring this device. Best-effort: a failure here doesn’t abort the sync. - Pulls desired policy.
GET /devices/{id}/policy. A404means the server forgot this device → re-enroll. - Enforces, then observes. Only if
isDeviceOwner(): apply the policy viaPolicyEnforcer, then read the actual state back. - Reports.
POST /devices/{id}/reportwith the observed camera state and theappliedPolicyVersion.
See the loop for the end-to-end picture and http-api.md for the request/response shapes.
Identity persistence
DeviceIdStore (DeviceIdStore.kt) keeps the DeviceId in SharedPreferences, so a device keeps its server-assigned identity across restarts and background runs. It re-enrolls only when it has no id, or when the server returns 404 (the in-memory server was restarted and lost it — see server.md).
Enforcement: PolicyEnforcer
PolicyEnforcer.kt wraps DevicePolicyManager. It holds the admin ComponentName pointing at MdmDeviceAdminReceiver.
| Method | What it does |
|---|---|
isDeviceOwner() | DevicePolicyManager.isDeviceOwnerApp(packageName) — true only after the device-owner command below has run. |
apply(policy) | Guards on isDeviceOwner() (a no-op otherwise, since the DPM call would throw), then setCameraDisabled(admin, policy.cameraDisabled). |
cameraDisabled() | getCameraDisabled(null) — reads the effective camera state. Passing null asks “is the camera disabled by any admin”, which is what we want to observe. |
setCameraDisabled(ComponentName, boolean) has existed since Android 4.0, well under this app’s minSdk 24, so it needs no @RequiresApi guard. It does require the app to be a Device Owner (or Profile Owner) and the <disable-camera/> policy to be declared (below) — otherwise it throws SecurityException.
Device Owner setup (development)
A Device Owner can only be set on a device with no accounts added. The standard dev path is a fresh emulator (a Google APIs image — which also satisfies FCM’s need for Google Play services; see firebase-setup.md), with no Google account signed in.
# install the agent first (see development.md), then:
adb shell dpm set-device-owner com.arganaemre.mdmcore/.MdmDeviceAdminReceiver
# to undo (or just wipe the emulator):
adb shell dpm remove-active-admin com.arganaemre.mdmcore/.MdmDeviceAdminReceiver
Until this succeeds, the agent runs but can’t enforce: isDeviceOwner() is false, step 4 is skipped, and the UI shows the observed state as “n/a” along with the command to run.
The device-admin declaration
Every policy the DPC invokes must be declared in agent/src/main/res/xml/device_admin.xml, or the DevicePolicyManager call throws SecurityException at runtime:
<device-admin xmlns:android="http://schemas.android.com/apk/res/android">
<uses-policies>
<disable-camera />
<limit-password />
</uses-policies>
</device-admin>
Camera is the only policy actually enforced. <disable-camera/> backs setCameraDisabled. The <limit-password/> line is a leftover from a removed password policy — declaring a policy you never call is harmless (the platform’s own sample declares several unused ones), but nothing in the code, in DevicePolicy, or in the compliance rule touches passwords today. It can be safely deleted.
Manifest
AndroidManifest.xml wires three things:
- The device-admin receiver — matches the platform’s required shape exactly:
<receiver android:name=".MdmDeviceAdminReceiver" android:permission="android.permission.BIND_DEVICE_ADMIN" android:exported="true"> <meta-data android:name="android.app.device_admin" android:resource="@xml/device_admin" /> <intent-filter> <action android:name="android.app.action.DEVICE_ADMIN_ENABLED" /> </intent-filter> </receiver>BIND_DEVICE_ADMINensures only the system can bind the receiver (no app can hold that permission);exported="true"lets the system discover it; the meta-data points at the policy declaration.MdmDeviceAdminReceiveritself is an emptyDeviceAdminReceiversubclass — its job is to exist and be declared. - The FCM service —
MdmMessagingServicewith an intent filter forcom.google.firebase.MESSAGING_EVENT. See fcm-doorbell.md. - Permissions — only
android.permission.INTERNET. Camera control comes from the Device Owner privilege, not a manifest permission. Data-only FCM means noPOST_NOTIFICATIONSis needed.
Networking
The agent’s MdmClient (MdmClient.kt) is a Ktor client on the OkHttp engine, pointed at http://10.0.2.2:8081 — the alias an Android emulator uses to reach the host machine.
targetSdk 36 blocks cleartext HTTP by default, and Ktor does not enforce HTTPS for you, so cleartext is explicitly re-permitted for just the dev hosts in res/xml/network_security_config.xml:
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">10.0.2.2</domain>
<domain includeSubdomains="true">localhost</domain>
</domain-config>
Every other host still requires HTTPS.
The UI
MainActivity + AgentViewModel (AgentViewModel.kt) show a small status screen: the device id, the desired policy (camera + version), the observed camera state (or “n/a” when not Device Owner), the computed compliance (compliant ✓ / DRIFT ✗), and whether the last report was sent. The view model kicks off runMdmSync in init, so the agent syncs on launch.
Adding a new policy
Camera is the only enforced policy. Adding another is a coordinated change across three places:
- Contract (
:protocol): add the field toDevicePolicy(with a default) andPolicyUpdate, and extendDevicePolicy.isSatisfiedByso both sides judge it identically. - Declaration: add the matching
<uses-policies>tag todevice_admin.xml. - Enforcement: extend
PolicyEnforcer. Check the DPM setter’s shape first — some setters take no adminComponentName(unlikesetCameraDisabled) — and check its@RequiresApiagainstminSdk 24. Verify the exact signature via theandroidCLI docs before writing it.
The console picks up a new editable field automatically once it’s in PolicyUpdate and the UI is extended (see console.md).