Add simulate-device-(dis)appeared CDM shell commands

Add 'companiondevice simulate-device-appeared' and
'companiondevice simulate-device-disappeared' Shell commands to be used
in CompanionDeviceManager tests.

Bug: 218615198
Test: atest CtsCompanionDeviceManagerCoreTestCases
Test: atest
  CtsCompanionDeviceManagerCoreTestCases:ObservingDevicePresenceTest
Change-Id: I5dad0b1859f5292fc0db68ca6ce2114eaa889d4e
Merged-In: I5dad0b1859f5292fc0db68ca6ce2114eaa889d4e
diff --git a/services/companion/java/com/android/server/companion/CompanionDeviceManagerService.java b/services/companion/java/com/android/server/companion/CompanionDeviceManagerService.java
index 62bb9f1..3b11038 100644
--- a/services/companion/java/com/android/server/companion/CompanionDeviceManagerService.java
+++ b/services/companion/java/com/android/server/companion/CompanionDeviceManagerService.java
@@ -732,9 +732,12 @@
                 String[] args, ShellCallback callback, ResultReceiver resultReceiver)
                 throws RemoteException {
             enforceCallerCanManageCompanionDevice(getContext(), "onShellCommand");
-            new CompanionDeviceShellCommand(
-                    CompanionDeviceManagerService.this, mAssociationStore)
-                    .exec(this, in, out, err, args, callback, resultReceiver);
+
+            final CompanionDeviceShellCommand cmd = new CompanionDeviceShellCommand(
+                    CompanionDeviceManagerService.this,
+                    mAssociationStore,
+                    mDevicePresenceMonitor);
+            cmd.exec(this, in, out, err, args, callback, resultReceiver);
         }
 
         @Override
diff --git a/services/companion/java/com/android/server/companion/CompanionDeviceShellCommand.java b/services/companion/java/com/android/server/companion/CompanionDeviceShellCommand.java
index fd13085..6a19a42 100644
--- a/services/companion/java/com/android/server/companion/CompanionDeviceShellCommand.java
+++ b/services/companion/java/com/android/server/companion/CompanionDeviceShellCommand.java
@@ -21,6 +21,8 @@
 import android.util.Log;
 import android.util.Slog;
 
+import com.android.server.companion.presence.CompanionDevicePresenceMonitor;
+
 import java.io.PrintWriter;
 import java.util.List;
 
@@ -29,20 +31,24 @@
 
     private final CompanionDeviceManagerService mService;
     private final AssociationStore mAssociationStore;
+    private final CompanionDevicePresenceMonitor mDevicePresenceMonitor;
 
     CompanionDeviceShellCommand(CompanionDeviceManagerService service,
-            AssociationStore associationStore) {
+            AssociationStore associationStore,
+            CompanionDevicePresenceMonitor devicePresenceMonitor) {
         mService = service;
         mAssociationStore = associationStore;
+        mDevicePresenceMonitor = devicePresenceMonitor;
     }
 
     @Override
     public int onCommand(String cmd) {
         final PrintWriter out = getOutPrintWriter();
+        final int associationId;
         try {
             switch (cmd) {
                 case "list": {
-                    final int userId = getNextArgInt();
+                    final int userId = getNextIntArgRequired();
                     final List<AssociationInfo> associationsForUser =
                             mAssociationStore.getAssociationsForUser(userId);
                     for (AssociationInfo association : associationsForUser) {
@@ -55,7 +61,7 @@
                 break;
 
                 case "associate": {
-                    int userId = getNextArgInt();
+                    int userId = getNextIntArgRequired();
                     String packageName = getNextArgRequired();
                     String address = getNextArgRequired();
                     mService.legacyCreateAssociation(userId, address, packageName, null);
@@ -63,7 +69,7 @@
                 break;
 
                 case "disassociate": {
-                    final int userId = getNextArgInt();
+                    final int userId = getNextIntArgRequired();
                     final String packageName = getNextArgRequired();
                     final String address = getNextArgRequired();
                     final AssociationInfo association =
@@ -80,6 +86,16 @@
                 }
                 break;
 
+                case "simulate-device-appeared":
+                    associationId = getNextIntArgRequired();
+                    mDevicePresenceMonitor.simulateDeviceAppeared(associationId);
+                    break;
+
+                case "simulate-device-disappeared":
+                    associationId = getNextIntArgRequired();
+                    mDevicePresenceMonitor.simulateDeviceDisappeared(associationId);
+                    break;
+
                 default:
                     return handleDefaultCommands(cmd);
             }
@@ -91,10 +107,6 @@
         }
     }
 
-    private int getNextArgInt() {
-        return Integer.parseInt(getNextArgRequired());
-    }
-
     @Override
     public void onHelp() {
         PrintWriter pw = getOutPrintWriter();
@@ -108,7 +120,31 @@
         pw.println("  disassociate USER_ID PACKAGE MAC_ADDRESS");
         pw.println("      Remove an existing Association.");
         pw.println("  clear-association-memory-cache");
-        pw.println("      Clear the in-memory association cache and reload all association "
-                + "information from persistent storage. USE FOR DEBUGGING PURPOSES ONLY.");
+        pw.println("      Clear the in-memory association cache and reload all association ");
+        pw.println("      information from persistent storage. USE FOR DEBUGGING PURPOSES ONLY.");
+        pw.println("      USE FOR DEBUGGING AND/OR TESTING PURPOSES ONLY.");
+
+        pw.println("  simulate-device-appeared ASSOCIATION_ID");
+        pw.println("      Make CDM act as if the given companion device has appeared.");
+        pw.println("      I.e. bind the associated companion application's");
+        pw.println("      CompanionDeviceService(s) and trigger onDeviceAppeared() callback.");
+        pw.println("      The CDM will consider the devices as present for 60 seconds and then");
+        pw.println("      will act as if device disappeared, unless 'simulate-device-disappeared'");
+        pw.println("      or 'simulate-device-appeared' is called again before 60 seconds run out"
+                + ".");
+        pw.println("      USE FOR DEBUGGING AND/OR TESTING PURPOSES ONLY.");
+
+        pw.println("  simulate-device-disappeared ASSOCIATION_ID");
+        pw.println("      Make CDM act as if the given companion device has disappeared.");
+        pw.println("      I.e. unbind the associated companion application's");
+        pw.println("      CompanionDeviceService(s) and trigger onDeviceDisappeared() callback.");
+        pw.println("      NOTE: This will only have effect if 'simulate-device-appeared' was");
+        pw.println("      invoked for the same device (same ASSOCIATION_ID) no longer than");
+        pw.println("      60 seconds ago.");
+        pw.println("      USE FOR DEBUGGING AND/OR TESTING PURPOSES ONLY.");
+    }
+
+    private int getNextIntArgRequired() {
+        return Integer.parseInt(getNextArgRequired());
     }
 }
diff --git a/services/companion/java/com/android/server/companion/presence/CompanionDevicePresenceMonitor.java b/services/companion/java/com/android/server/companion/presence/CompanionDevicePresenceMonitor.java
index 24be1b6..37e8369 100644
--- a/services/companion/java/com/android/server/companion/presence/CompanionDevicePresenceMonitor.java
+++ b/services/companion/java/com/android/server/companion/presence/CompanionDevicePresenceMonitor.java
@@ -16,11 +16,19 @@
 
 package com.android.server.companion.presence;
 
+import static android.os.Process.ROOT_UID;
+import static android.os.Process.SHELL_UID;
+
 import android.annotation.NonNull;
 import android.annotation.SuppressLint;
+import android.annotation.TestApi;
 import android.bluetooth.BluetoothAdapter;
 import android.companion.AssociationInfo;
 import android.content.Context;
+import android.os.Binder;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
 import android.util.Log;
 
 import com.android.server.companion.AssociationStore;
@@ -72,6 +80,11 @@
     private final @NonNull Set<Integer> mNearbyBleDevices = new HashSet<>();
     private final @NonNull Set<Integer> mReportedSelfManagedDevices = new HashSet<>();
 
+    // Tracking "simulated" presence. Used for debugging and testing only.
+    private final @NonNull Set<Integer> mSimulated = new HashSet<>();
+    private final SimulatedDevicePresenceSchedulerHelper mSchedulerHelper =
+            new SimulatedDevicePresenceSchedulerHelper();
+
     public CompanionDevicePresenceMonitor(@NonNull AssociationStore associationStore,
             @NonNull Callback callback) {
         mAssociationStore = associationStore;
@@ -106,7 +119,8 @@
     public boolean isDevicePresent(int associationId) {
         return mReportedSelfManagedDevices.contains(associationId)
                 || mConnectedBtDevices.contains(associationId)
-                || mNearbyBleDevices.contains(associationId);
+                || mNearbyBleDevices.contains(associationId)
+                || mSimulated.contains(associationId);
     }
 
     /**
@@ -155,6 +169,45 @@
         onDeviceGone(mNearbyBleDevices, associationId, /* sourceLoggingTag */ "ble");
     }
 
+    /** FOR DEBUGGING AND/OR TESTING PURPOSES ONLY. */
+    @TestApi
+    public void simulateDeviceAppeared(int associationId) {
+        // IMPORTANT: this API should only be invoked via the
+        // 'companiondevice simulate-device-appeared' Shell command, so the only uid-s allowed to
+        // make this call are SHELL and ROOT.
+        // No other caller (including SYSTEM!) should be allowed.
+        enforceCallerShellOrRoot();
+        // Make sure the association exists.
+        enforceAssociationExists(associationId);
+
+        onDevicePresent(mSimulated, associationId, /* sourceLoggingTag */ "simulated");
+
+        mSchedulerHelper.scheduleOnDeviceGoneCallForSimulatedDevicePresence(associationId);
+    }
+
+    /** FOR DEBUGGING AND/OR TESTING PURPOSES ONLY. */
+    @TestApi
+    public void simulateDeviceDisappeared(int associationId) {
+        // IMPORTANT: this API should only be invoked via the
+        // 'companiondevice simulate-device-appeared' Shell command, so the only uid-s allowed to
+        // make this call are SHELL and ROOT.
+        // No other caller (including SYSTEM!) should be allowed.
+        enforceCallerShellOrRoot();
+        // Make sure the association exists.
+        enforceAssociationExists(associationId);
+
+        mSchedulerHelper.unscheduleOnDeviceGoneCallForSimulatedDevicePresence(associationId);
+
+        onDeviceGone(mSimulated, associationId, /* sourceLoggingTag */ "simulated");
+    }
+
+    private void enforceAssociationExists(int associationId) {
+        if (mAssociationStore.getAssociationById(associationId) == null) {
+            throw new IllegalArgumentException(
+                    "Association with id " + associationId + " does not exist.");
+        }
+    }
+
     private void onDevicePresent(@NonNull Set<Integer> presentDevicesForSource,
             int newDeviceAssociationId, @NonNull String sourceLoggingTag) {
         if (DEBUG) {
@@ -212,6 +265,7 @@
         mConnectedBtDevices.remove(associationId);
         mNearbyBleDevices.remove(associationId);
         mReportedSelfManagedDevices.remove(associationId);
+        mSimulated.remove(associationId);
     }
 
     /**
@@ -232,4 +286,36 @@
         // CompanionDeviceManagerService will know that the association is removed, and will do
         // what's needed.
     }
+
+    private static void enforceCallerShellOrRoot() {
+        final int callingUid = Binder.getCallingUid();
+        if (callingUid == SHELL_UID || callingUid == ROOT_UID) return;
+
+        throw new SecurityException("Caller is neither Shell nor Root");
+    }
+
+    private class SimulatedDevicePresenceSchedulerHelper extends Handler {
+        SimulatedDevicePresenceSchedulerHelper() {
+            super(Looper.getMainLooper());
+        }
+
+        void scheduleOnDeviceGoneCallForSimulatedDevicePresence(int associationId) {
+            // First, unschedule if it was scheduled previously.
+            if (hasMessages(/* what */ associationId)) {
+                removeMessages(/* what */ associationId);
+            }
+
+            sendEmptyMessageDelayed(/* what */ associationId, 60 * 1000 /* 60 seconds */);
+        }
+
+        void unscheduleOnDeviceGoneCallForSimulatedDevicePresence(int associationId) {
+            removeMessages(/* what */ associationId);
+        }
+
+        @Override
+        public void handleMessage(@NonNull Message msg) {
+            final int associationId = msg.what;
+            onDeviceGone(mSimulated, associationId, /* sourceLoggingTag */ "simulated");
+        }
+    }
 }