Skip to content

Android commands (frontend + more backend) - #46174

Merged
getvictor merged 56 commits into
mainfrom
victor/41683-android-commands-frontend
May 28, 2026
Merged

Android commands (frontend + more backend)#46174
getvictor merged 56 commits into
mainfrom
victor/41683-android-commands-frontend

Conversation

@getvictor

@getvictor getvictor commented May 25, 2026

Copy link
Copy Markdown
Member

Related issue: Resolves #41683

Updated frontend for Android commands along with additional changes in the backend. Did full QA testing with test plan.

Checklist for submitter

  • Timeouts are implemented and retries are limited to avoid infinite loops

Testing

  • Added/updated automated tests
  • QA'd all new/changed functionality manually

Database migrations

  • Ensured the correct collation is explicitly set for character columns (COLLATE utf8mb4_unicode_ci).

Summary by CodeRabbit

  • New Features

    • Android MDM: added Clear passcode action, Unenroll behavior, and refined BYO vs COBO action visibility and confirmations.
    • Optimistic pending states and Android-specific success/error messages in Lock/Wipe/Clear flows; modals require confirmations for Android.
  • Bug Fixes

    • More robust clearing of stale Android device actions during re-enrollment and Pub/Sub flows to keep UI state accurate.
  • Tests

    • Expanded Android MDM tests for action visibility, pending states, and end-to-end state transitions.

Review Change Stack

getvictor added 30 commits May 21, 2026 17:36
Tracks AMAPI commands issued by Fleet via EnterprisesDevicesService.IssueCommand
(Lock, Wipe, Clear passcode for Android hosts). host_mdm_actions.{lock_ref, wipe_ref}
points into this table via command_uuid for Android hosts, mirroring how those
columns point into nano_commands for Apple and mdm_windows_commands for Windows.
MDMAndroidCommand maps to the mdm_android_commands table. Status and type values
mirror the strings AMAPI uses on the wire so the same enum doubles as the value we
send in IssueCommand and the value we read back from Pub/Sub COMMAND notifications.
Adds the four CRUD methods Fleet needs at AMAPI command-issue and
Pub/Sub COMMAND-notification time:

- NewMDMAndroidCommand: insert at issue time (auto-generates command_uuid
  if caller leaves it empty)
- GetMDMAndroidCommandByUUID: lookup by Fleet command_uuid (the value
  host_mdm_actions.{lock_ref, wipe_ref} points to)
- GetMDMAndroidCommandByOperationName: lookup by AMAPI operation name,
  used by the Pub/Sub handler to correlate a notification back to the
  originating Fleet command
- UpdateMDMAndroidCommandStatus: transition status (and optional error
  code/message) when the device acks or AMAPI rejects

Interface declared on fleet.Datastore (mirrors NewAndroidPolicyRequest /
GetAndroidPolicyRequestByUUID), with matching mock entries and an
integration test exercising all four methods plus NotFound paths.
Teaches HostLockWipeStatus to interpret AMAPI lock/wipe state stored in
mdm_android_commands, so the existing PendingAction / DeviceStatus /
IsLocked / IsWiped helpers work for android hosts the same way they do
for darwin, ios, ipados, windows, and linux.

- GetHostLockWipeStatus: new android case that maps lock_ref/wipe_ref
  through mdm_android_commands and populates Lock/WipeMDMCommand +
  Lock/WipeMDMCommandResult. Pending status leaves the result nil so
  IsPendingLock/IsPendingWipe report "pending". Orphan refs log and
  return zero-state (mirrors apple/windows).
- GetHostsLockWipeStatusBatch: matching android collection + batch
  query against mdm_android_commands.
- HostLockWipeStatus methods (server/fleet/scripts.go): android arms
  added to IsPendingLock, IsPendingWipe (covered by the existing fall
  through), IsLocked, and IsWiped. They compare against the literal
  "acknowledged" string (the value of
  android.MDMAndroidCommandStatusAcknowledged) to avoid pulling the
  android package into server/fleet.
Wires the Android Management API IssueCommand endpoint through Fleet's
three Client implementations so the service layer can dispatch LOCK,
RESET_PASSWORD, and WIPE commands to enrolled Android devices:

- Client interface (androidmgmt/client.go): add the method declaration
  with a docstring pointing at the AMAPI reference.
- GoogleClient: direct AMAPI passthrough with the standard error
  wrapping pattern used by neighbouring methods.
- ProxyClient: same passthrough, plus the Authorization: Bearer header
  the proxy needs for every call.
- mock/Client: hand-maintained mock additions (Func type, struct field
  + Invoked flag, method wrapper) so service-layer tests can swap in
  fakes without contacting real AMAPI.

Validated by porting the spike code that exercised this against real
BYO and COBO devices for all three command types.
Replaces the spike's log-only implementations with the real
issue-and-persist flow:

1. Service layer (server/mdm/android/service.go + service/service.go):
   - LockAndroidHost: AMAPI LOCK -> NewMDMAndroidCommand +
     host_mdm_actions.lock_ref (via LockHostViaAndroidMDM).
   - ClearAndroidPasscode: AMAPI RESET_PASSWORD with newPassword=""
     (clears, does not regenerate, per product); persists the row but
     does NOT touch host_mdm_actions (one-shot, no UI state).
   - WipeAndroidHost: AMAPI WIPE -> NewMDMAndroidCommand +
     host_mdm_actions.wipe_ref (via WipeHostViaAndroidMDM). WipeParams
     intentionally set to an empty struct -- the spike confirmed AMAPI
     rejects WIPE without it, despite the SDK marking it Optional.
   - resolveAndroidCommandTarget helper centralises the host/enterprise
     lookup + secret refresh shared by all three methods.
   - longCommandDuration = "315360000s" (10 years) on every command, so
     undelivered commands stay queued forever -- matches Apple/Windows
     MDM semantics. AMAPI default of 600s would silently expire.

2. Datastore (server/fleet/datastore.go + datastore/mysql/android.go):
   - LockHostViaAndroidMDM / WipeHostViaAndroidMDM: transactional
     two-write helpers that insert the mdm_android_commands row and
     upsert host_mdm_actions in a single retried tx. Mirrors
     WipeHostViaWindowsMDM. Generated mocks updated.
   - Integration tests cover the lock-pending path and the wipe-requeue
     path (later wipe_ref overwrites earlier, both command rows preserved
     for audit).

Pub/Sub COMMAND ack handling (Phase 9) and EE dispatch (Phase 10) are
follow-ups; this commit gets the command-issue side end-to-end.
Per product (2026-05-20): unenrolling a BYO Android host should run an
AMAPI WIPE command, which on a personal device only wipes the
work profile and leaves the personal side intact. The mdm_unenrolled
activity is still emitted on the subsequent Pub/Sub COMMAND path
(unchanged from the previous behavior). COBO unenroll keeps the existing
EnterprisesDevicesDelete call -- it terminates management without
factory-resetting the device.

UnenrollAndroidHost branches on host_mdm.is_personal_enrollment. The
BYO branch persists an mdm_android_commands row (audit trail) but does
NOT write host_mdm_actions.wipe_ref, because BYO unenroll surfaces in
the UI as "unenroll", not as a wipe.
AMAPI delivers a COMMAND notification when a device acks (or rejects) an
issued command. The notification is an Operation envelope whose Name
matches the operation_name we recorded at IssueCommand time. This commit
turns the spike's log-only handler into a real state machine:

- Decodes the payload as androidmanagement.Operation.
- Looks up the Fleet row by operation_name. NotFound (e.g. a command
  issued from a previous deployment) is acked without retry to keep
  Pub/Sub from looping.
- Empty op.Name is logged and acked (malformed/foreign payload).
- If op.Error is set, transitions the row to "error" with error_code
  (the int google.rpc.Code rendered as string) and error_message.
- Otherwise transitions to "acknowledged".
- Already-terminal rows are ignored -- AMAPI delivers at-least-once, so
  we must be idempotent.

host_mdm_actions does not need a separate update because the android
branch of GetHostLockWipeStatus reads the row status string directly,
and HostLockWipeStatus.IsLocked/IsWiped compare against "acknowledged".

Unit tests cover all five paths (ack, error, idempotent re-delivery,
unknown op, empty op.Name).
Threads the android.Service Lock/Wipe/ClearPasscode helpers (added in
the previous commit) into the EE Service layer so they're reachable from
the existing /hosts/:id/lock, /hosts/:id/wipe, /hosts/:id/mdm/passcode
API endpoints.

- LockHost case "android": validates AndroidEnabledAndConfigured +
  IsHostConnectedToFleetMDM. Lock is supported on both BYO and COBO.
- enqueueLockHostRequest case "android": dispatches to LockAndroidHost
  and explicitly resets activity.ViewPIN -- Android has no unlock PIN.
- WipeHost case "android": rejects BYO with a descriptive error
  (BYO unenroll already runs an AMAPI WIPE under the hood) and
  validates AndroidEnabledAndConfigured. requireMDM is set so the
  existing MDM-connection precondition runs.
- enqueueWipeHostRequest case "android": dispatches to WipeAndroidHost.
- ClearPasscode: new clearPasscodeAndroid branch mirroring
  clearPasscodeApple's shape. Validates AndroidEnabledAndConfigured,
  dispatches to ClearAndroidPasscode, emits the existing
  cleared_passcode activity (shared with Apple), returns a
  CommandEnqueueResult shaped for API compatibility. Error message
  updated to mention Android.
Mirrors the existing lock/unlock/wipe subcommands and exposes clear-
passcode at the CLI for iOS/iPadOS (existing) and Android (new in
this PR). The server endpoint at /hosts/:id/clear_passcode already
dispatches by platform, so the CLI doesn't need android-specific
branching.

- Promotes the previously-private clearPasscodeResponse to
  fleet.ClearPasscodeResponse, matching LockHostResponse /
  WipeHostResponse and letting client + server share the type.
- Adds Client.MDMClearPasscodeHost in server/service/client_mdm.go.
- Adds mdmClearPasscodeCommand in cmd/fleetctl/fleetctl/mdm.go and
  registers it under `fleetctl mdm clear-passcode` (alias
  clear_passcode).
- Unit test TestMDMClearPasscodeCommand exercises the failure paths
  (missing flag, unknown host, MDM-off). Happy paths against real
  iOS/Android hosts are covered by integration tests.
End-to-end coverage against the real Fleet HTTP handler stack with the
mock AMAPI client. Verifies that for each command the API endpoint
issues the right AMAPI call, persists the mdm_android_commands row,
updates host_mdm_actions where applicable, and surfaces the right
device/pending status on the host detail endpoint.

- TestAndroidHostUnenrollMDM: updated to match the new BYO behavior
  -- BYO unenroll now issues an AMAPI WIPE (work-profile only), not
  EnterprisesDevicesDelete. Added a COBO sub-case to assert the
  delete path is still used for company-owned hosts.
- TestAndroidLockWipeClearPasscode (new):
  - Lock COBO: AMAPI LOCK + Duration=315360000s; GET host returns
    PendingAction=lock + DeviceStatus=unlocked; Pub/Sub COMMAND ack
    transitions the row to acknowledged and the host page to locked.
  - Wipe BYO rejected with the "personally-owned" error message.
  - Wipe COBO: AMAPI WIPE with non-nil empty WipeParams + long
    duration; PendingAction=wipe surfaces.
  - Clear passcode: AMAPI RESET_PASSWORD with newPassword=""; no
    host_mdm_actions write.
  - Pub/Sub COMMAND with op.Error transitions the row to "error" and
    populates error_code (the int rpc code as string) and
    error_message.

To support both BYO and COBO test fixtures, factored
createAndroidHostWithStorage into createAndroidHostForTest taking an
explicit companyOwned bool; the existing helper now just wraps it for
BYO callers.

Also replaced the private clearPasscodeResponse reference in the
existing iOS clear-passcode integration test with the public
fleet.ClearPasscodeResponse promoted earlier in this branch.
Addresses the two failing CI checks on PR #46031:

test-mock-changes: server/mock/datastore_mock.go had manual edits from
the earlier commits adding android Lock/Wipe/Clear-passcode datastore
methods. CI runs `make mock` and fails on any diff. Regenerated via
`make mock`; the only change is field/type ordering.

lint-incremental:
- modernize: `ptr.Bool(true)` -> `new(true)` in the new clear-passcode
  CLI test fixture.
- modernize: `map[string]interface{}` -> `map[string]any` in the BYO-
  unenroll and Wipe payload-marshal helpers (4 sites).
- testifylint: `require.Equal(t, "", ...)` -> `require.Empty(t, ...)`
  in three places and `require.Equal(t, n, len(s))` -> `require.Len(t,
  s, n)` in one place within TestAndroidLockWipeClearPasscode.
…commands-backend

# Conflicts:
#	server/datastore/mysql/schema.sql
After merging main (#46079 renumbered 11 migrations to 20260522195224-..235),
our 20260521205417_AddMDMAndroidCommands ended up before them in the
migration timeline. Bump our timestamp to 20260522195236 so the migration
runs last, and regenerate schema.sql.
@getvictor
getvictor marked this pull request as ready for review May 28, 2026 14:10
@getvictor
getvictor requested review from a team as code owners May 28, 2026 14:10

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
server/mdm/android/service/pubsub.go (1)

548-583: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Suppress duplicate mdm_unenrolled activity in the ENROLLMENT DELETED path.

This branch still ignores the didUnenroll result from SetAndroidHostUnenrolled, so a redelivered ENROLLMENT notification—or one that arrives after the COMMAND/STATUS_REPORT path already flipped the host—will emit a second mdm_unenrolled activity for the same event. The STATUS_REPORT branch already has the right guard.

🔧 Minimal fix
-			if _, err := svc.ds.SetAndroidHostUnenrolled(ctx, host.Host.ID); err != nil {
+			didUnenroll, err := svc.ds.SetAndroidHostUnenrolled(ctx, host.Host.ID)
+			if err != nil {
 				return ctxerr.Wrap(ctx, err, "set android host unenrolled on DELETED state (ENROLLMENT)")
 			}
 
 			// cancel any apps pending install for this host
 			users, acts, err := svc.ds.MarkAllPendingVPPInstallsAsFailedForAndroidHost(ctx, host.Host.ID)
@@
 			for i, act := range acts {
 				user := users[i]
 				if err := svc.newActivity(ctx, user, act); err != nil {
 					return ctxerr.Wrap(ctx, err, "create failed app install activity")
 				}
 			}
+
+			if !didUnenroll {
+				return nil
+			}
 
 			var displayName, serial string
 			if hosts, herr := svc.fleetDS.ListHostsLiteByIDs(ctx, []uint{host.Host.ID}); herr == nil && len(hosts) == 1 && hosts[0] != nil {
 				displayName = hosts[0].DisplayName()
 				serial = hosts[0].HardwareSerial
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/mdm/android/service/pubsub.go` around lines 548 - 583, Capture the
boolean returned by svc.ds.SetAndroidHostUnenrolled (e.g., didUnenroll, err :=
svc.ds.SetAndroidHostUnenrolled(...)) and if didUnenroll is false return nil (or
otherwise skip the unenroll-specific work) so you do not emit a duplicate
mdm_unenrolled activity; only run clearAndroidBYOWipeRef,
svc.ds.MarkAllPendingVPPInstallsAsFailedForAndroidHost, and the
svc.newActivity(...) that emits fleet.ActivityTypeMDMUnenrolled when didUnenroll
is true (keep existing error handling for the DB calls).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@server/mdm/android/service/pubsub.go`:
- Around line 548-583: Capture the boolean returned by
svc.ds.SetAndroidHostUnenrolled (e.g., didUnenroll, err :=
svc.ds.SetAndroidHostUnenrolled(...)) and if didUnenroll is false return nil (or
otherwise skip the unenroll-specific work) so you do not emit a duplicate
mdm_unenrolled activity; only run clearAndroidBYOWipeRef,
svc.ds.MarkAllPendingVPPInstallsAsFailedForAndroidHost, and the
svc.newActivity(...) that emits fleet.ActivityTypeMDMUnenrolled when didUnenroll
is true (keep existing error handling for the DB calls).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d1f66774-9288-41a8-803e-1e467974fbae

📥 Commits

Reviewing files that changed from the base of the PR and between b7b6d7c and cf6e5ca.

📒 Files selected for processing (23)
  • frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tests.tsx
  • frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/helpers.tsx
  • frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx
  • frontend/pages/hosts/details/HostDetailsPage/modals/ClearPasscodeModal/ClearPasscodeModal.tsx
  • frontend/pages/hosts/details/HostDetailsPage/modals/LockModal/LockModal.tsx
  • frontend/pages/hosts/details/HostDetailsPage/modals/UnenrollMdmModal/UnenrollMdmModal.tsx
  • frontend/pages/hosts/details/HostDetailsPage/modals/WipeModal/WipeModal.tsx
  • frontend/pages/hosts/details/cards/HostHeader/HostHeader.tests.tsx
  • frontend/pages/hosts/details/cards/HostHeader/HostHeader.tsx
  • server/datastore/mysql/android.go
  • server/datastore/mysql/android_test.go
  • server/datastore/mysql/hosts_test.go
  • server/datastore/mysql/migrations/tables/20260526120000_AddClearPasscodeRefToHostMDMActions.go
  • server/datastore/mysql/schema.sql
  • server/datastore/mysql/scripts.go
  • server/fleet/datastore.go
  • server/fleet/scripts.go
  • server/mdm/android/service/pubsub.go
  • server/mdm/android/service/pubsub_test.go
  • server/mdm/android/service/reconcile_devices.go
  • server/mdm/android/service/service.go
  • server/mock/datastore_mock.go
  • server/service/integration_mdm_test.go
💤 Files with no reviewable changes (2)
  • frontend/pages/hosts/details/HostDetailsPage/modals/LockModal/LockModal.tsx
  • frontend/pages/hosts/details/HostDetailsPage/modals/WipeModal/WipeModal.tsx
🚧 Files skipped from review as they are similar to previous changes (8)
  • server/fleet/datastore.go
  • server/datastore/mysql/hosts_test.go
  • server/datastore/mysql/migrations/tables/20260526120000_AddClearPasscodeRefToHostMDMActions.go
  • server/fleet/scripts.go
  • server/datastore/mysql/scripts.go
  • frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/helpers.tsx
  • frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tests.tsx
  • server/mock/datastore_mock.go

const isAndroidBYOWipe =
isAndroid(platform) &&
hostMdmDeviceStatus === "wiping" &&
hostMdmEnrollmentStatus === "On (personal)";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use pending_action here like we do for location tracking?
https://github.com/fleetdm/fleet/blob/main/frontend/pages/hosts/details/helpers.ts#L114-L117

We could store something like pending_action="unenroll" in host_mdm_actions?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok so it's not as easy because there is no host_last_known_locations table to look at. I think it might not be worth changing this from what it is right now.

const isLockableAndroidDevice =
isAndroid(hostPlatform) &&
isAndroidMdmEnabledAndConfigured &&
isConnectedToFleetMdm &&

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isLockableMacOSDevice and isLockableIosOrIpadDevice don't check isConnectedToFleetMdm? I'm not sure which one is correct. My assumption is we might be missing isConnectedToFleetMdm for MacOS and iOS?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/helpers.tsx Outdated
@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown
Contributor

Actionable comments posted: 0

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@frontend/interfaces/mdm.ts`:
- Around line 330-332: The isAndroidCOBO function currently only checks for
enrollmentStatus === "On (automatic)" and will miss the alternate company-owned
string; update isAndroidCOBO (in frontend/interfaces/mdm.ts) to return true for
both "On (automatic)" and "On (company-owned)" (or otherwise normalize/enumerate
MdmEnrollmentStatus values) so COBO-specific actions are shown for either
string; ensure the comparison handles null safely (enrollmentStatus:
MdmEnrollmentStatus | null).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d93aa9ba-36e8-44d4-9563-f2d5a5bab657

📥 Commits

Reviewing files that changed from the base of the PR and between d1bce2d and 1d429fb.

📒 Files selected for processing (6)
  • frontend/interfaces/mdm.ts
  • frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx
  • frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/helpers.tsx
  • frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx
  • frontend/pages/hosts/details/cards/HostHeader/HostHeader.tests.tsx
  • frontend/pages/hosts/details/cards/HostHeader/HostHeader.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
  • frontend/pages/hosts/details/cards/HostHeader/HostHeader.tests.tsx
  • frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx

Comment thread frontend/interfaces/mdm.ts
@getvictor
getvictor requested a review from ksykulev May 28, 2026 18:47
@getvictor

Copy link
Copy Markdown
Member Author

@ksykulev Ready for re-review

ksykulev
ksykulev previously approved these changes May 28, 2026
@getvictor
getvictor merged commit 65708f9 into main May 28, 2026
42 checks passed
@getvictor
getvictor deleted the victor/41683-android-commands-frontend branch May 28, 2026 21:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Android commands: Lock, wipe, & clear passcode

3 participants