| title | Agent |
|---|---|
| nav_order | 7 |
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).
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.
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).
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.
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/.MdmDeviceAdminReceiverUntil 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.
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.
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.
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.
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.
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).