Extension apps and local commands

  • The Android Management API SDK allows an extension app to communicate with Android Device Policy and execute commands, currently supporting only the ClearAppData command.

  • After integration, an extension app can send command requests, query their status, and receive status change notifications.

  • To issue a command, an extension app uses an IssueCommandRequest containing details about the command and its parameters.

  • The status of a previously issued command request can be queried using the command ID returned after successfully issuing the command.

  • Extension apps can optionally register a CommandListener through an extended NotificationReceiverService to receive updates for long-running command status changes.

  • Enabling direct communication between the extension app and ADP requires configuring an extensionConfig policy, including the app's signing key fingerprints and the optional notification receiver.

The Android Management API (AMAPI) SDK enables an EMM-specified extension app to communicate directly with Android Device Policy (ADP) and execute Commands on the device.

Integrate with the AMAPI SDK provides more information about this library and how to add it to your application.

Once integrated the SDK, your extension app can communicate with ADP to:

Issue Command

An extension app can request for commands to be issued using ADP. IssueCommandRequest contains the request object that will contain detail on the command to be issued and specific parameters.

Clear app data

The following snippet shows how to issue a request to clear the package's data:

import android.util.Log;
...
import com.google.android.managementapi.commands.LocalCommandClientFactory;
import com.google.android.managementapi.commands.model.Command;
import com.google.android.managementapi.commands.model.GetCommandRequest;
import com.google.android.managementapi.commands.model.IssueCommandRequest;
import com.google.android.managementapi.commands.model.IssueCommandRequest.ClearAppsData;
import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.MoreExecutors;

...
  void issueClearAppDataCommand(ImmutableList<String> packageNames) {
    Futures.addCallback(
        LocalCommandClientFactory.create(getContext())
            .issueCommand(createClearAppRequest(packageNames)),
        new FutureCallback<Command>() {
          @Override
          public void onSuccess(Command result) {
            // Process the returned command result here
            Log.i(TAG, "Successfully issued command");
          }

          @Override
          public void onFailure(Throwable t) {
            Log.e(TAG, "Failed to issue command", t);
          }
        },
        MoreExecutors.directExecutor());
  }

  IssueCommandRequest createClearAppRequest(ImmutableList<String> packageNames) {
    return IssueCommandRequest.builder()
        .setClearAppsData(
            ClearAppsData.builder()
                .setPackageNames(packageNames)
                .build()
        )
        .build();
  }
...

The earlier example shows issuing a clear app data request for specified packages and waiting until the command has been successfully issued. If successfully issued, a Command object will be returned with the current command status and the command ID which can later be used to query the status of any long running commands.

Wipe the device or work profile

A companion app can issue a local Wipe command when the IT admin assigns the COMPANION_APP role with the LOCAL_DEVICE_WIPE_COMMAND_ALLOWED extra capability. On company-owned devices, this factory-resets the device; on personally-owned devices with a work profile, it removes the work profile. If the app doesn't have this role and extra capability, the request fails. For details, see Policy Configuration.

Both Wipe parameters are optional. If you don't want to specify a value, omit the builder setter or pass an empty collection or empty string (don't pass null).

  • WipeDataFlag values control what data is wiped:
    • PRESERVE_RESET_PROTECTION_DATA: Keeps the factory reset protection data, which is wiped by default. Applies only to company-owned devices.
    • WIPE_EXTERNAL_STORAGE: Also wipes external storage, such as SD cards. Applies only to company-owned devices.
    • WIPE_ESIMS: Removes all eSIMs from company-owned devices. On personally-owned devices, removes only managed eSIMs (on Android 16 or higher, managed eSIMs are always removed with the work profile, whether this flag is set or not).
  • wipeReason: A short message, up to 200 characters, that's shown to the user when the work profile is removed from a personally-owned device. It has no effect on company-owned devices.

The following snippet shows how to issue a request to wipe the device or work profile:

Kotlin

import android.content.Context
import android.util.Log
...
import com.google.android.managementapi.commands.CommandException
import com.google.android.managementapi.commands.LocalCommandClientFactory
import com.google.android.managementapi.commands.model.IssueCommandRequest
import com.google.android.managementapi.commands.model.IssueCommandRequest.Wipe
import com.google.android.managementapi.commands.model.IssueCommandRequest.Wipe.WipeDataFlag

...
  suspend fun issueWipeCommand(
      context: Context,
      wipeDataFlags: List<WipeDataFlag>,
      wipeReason: String,
  ) {
    try {
      val command =
          LocalCommandClientFactory.create(context)
              .issueCommandAwait(createWipeRequest(wipeDataFlags, wipeReason))
      Log.i(TAG, "Successfully issued wipe command: ${command.commandId}")
    } catch (e: CommandException) {
      Log.e(TAG, "Failed to issue wipe command", e)
    }
  }

  fun createWipeRequest(
      wipeDataFlags: List<WipeDataFlag>,
      wipeReason: String,
  ): IssueCommandRequest =
      IssueCommandRequest.builder()
          .setWipe(
              Wipe.builder()
                  .setWipeDataFlags(wipeDataFlags)
                  .setWipeReason(wipeReason)
                  .build()
          )
          .build()
...

Java

import android.content.Context;
import android.util.Log;
...
import com.google.android.managementapi.commands.LocalCommandClientFactory;
import com.google.android.managementapi.commands.model.Command;
import com.google.android.managementapi.commands.model.IssueCommandRequest;
import com.google.android.managementapi.commands.model.IssueCommandRequest.Wipe;
import com.google.android.managementapi.commands.model.IssueCommandRequest.Wipe.WipeDataFlag;
import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.MoreExecutors;

...
  void issueWipeCommand(
      Context context,
      ImmutableList<WipeDataFlag> wipeDataFlags,
      String wipeReason) {
    Futures.addCallback(
        LocalCommandClientFactory.create(context)
            .issueCommand(createWipeRequest(wipeDataFlags, wipeReason)),
        new FutureCallback<Command>() {
          @Override
          public void onSuccess(Command result) {
            Log.i(TAG, "Successfully issued wipe command: " + result.getCommandId());
          }

          @Override
          public void onFailure(Throwable t) {
            Log.e(TAG, "Failed to issue wipe command", t);
          }
        },
        MoreExecutors.directExecutor());
  }

  IssueCommandRequest createWipeRequest(
      ImmutableList<WipeDataFlag> wipeDataFlags, String wipeReason) {
    return IssueCommandRequest.builder()
        .setWipe(
            Wipe.builder()
                .setWipeDataFlags(wipeDataFlags)
                .setWipeReason(wipeReason)
                .build())
        .build();
  }
...

Get Command

An extension app can query the status of previously issued command requests. To retrieve a command's status, you will need the command ID (available from the issue command request). The following snippet shows how to send a GetCommandRequest to ADP.

import android.util.Log;
...
import com.google.android.managementapi.commands.LocalCommandClientFactory;
...
import com.google.android.managementapi.commands.model.GetCommandRequest;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.MoreExecutors;

...
  void getCommand(String commandId) {
    Futures.addCallback(
        LocalCommandClientFactory.create(getApplication())
            .getCommand(GetCommandRequest.builder().setCommandId(commandId).build()),
        new FutureCallback<Command>() {
          @Override
          public void onSuccess(Command result) {
            // Process the returned command result here
            Log.i(Constants.TAG, "Successfully issued command");
          }

          @Override
          public void onFailure(Throwable t) {
            Log.e(Constants.TAG, "Failed to issue command", t);
          }
        },
        MoreExecutors.directExecutor());
  }
  ...

Listen to Command status change callbacks

An extension app can optionally register a callback to receive updates for status changes of long running commands following these steps:

  1. Command status changes are notified to CommandListener, implement this interface in your app and provide implementation on how to handle the received status updates.
  2. Extend NotificationReceiverService and provide a CommandListener instance through the getCommandListener method.
  3. Configure the application policy to assign the COMPANION_APP role to your app (see Policy Configuration).

    import com.google.android.managementapi.commands.CommandListener;
    import com.google.android.managementapi.notification.NotificationReceiverService;
    
    ...
    
    public class SampleCommandService extends NotificationReceiverService {
    
     @Override
     public CommandListener getCommandListener() {
       // return the concrete implementation from previous step
       return ...;
     }
    }
    

Policy Configuration

To enable the companion app to communicate directly with ADP, the EMM has to assign the COMPANION_APP role to the app using the roles field in the application policy:

 "applications": [{
   "packageName": "com.amapi.extensibility.demo",
   "installType": "FORCE_INSTALLED",
   "roles": [
     { "roleType": "COMPANION_APP" }
   ]
 }]

To allow the companion app to issue a local Wipe command, set extraCapabilities.localDeviceWipeCommand to LOCAL_DEVICE_WIPE_COMMAND_ALLOWED:

 "applications": [{
   "packageName": "com.amapi.extensibility.demo",
   "installType": "FORCE_INSTALLED",
   "roles": [
     {
       "roleType": "COMPANION_APP",
       "extraCapabilities": {
         "localDeviceWipeCommand": "LOCAL_DEVICE_WIPE_COMMAND_ALLOWED"
       }
     }
   ]
 }]

For additional options available see Provision the device with app roles policies.

The service that listens for command status changes is automatically discovered by ADP using metadata in the app's manifest, and no longer needs to be specified in the policy as notificationReceiver. For details on how to prepare your app and manifest, see Manage application roles.

Testing

Unit testing

LocalCommandClient is an interface and thus allows to provide a testable implementation.

Integration testing

The following information will be needed to test with ADP:

  1. Package name of the extension app.
  2. The base64-encoded SHA-256 hash of the Signature associated with the app package.
  3. Optionally, if testing callback - fully qualified name of the service from the newly introduced service to support callback. (Fully qualified name of CommandService in the example).