Add ClusterHomeManager#sendHeartbeat

Bug: 285415531
Test: atest ClusterHomeServiceUnitTest ClusterHalServiceTest
Change-Id: I60ee9b8a489dfe1510787c0b9b24a7acd833dd6c
diff --git a/car-lib/src/android/car/cluster/ClusterHomeManager.java b/car-lib/src/android/car/cluster/ClusterHomeManager.java
index b1f309f..111da5a 100644
--- a/car-lib/src/android/car/cluster/ClusterHomeManager.java
+++ b/car-lib/src/android/car/cluster/ClusterHomeManager.java
@@ -367,6 +367,21 @@
         }
     }
 
+    /**
+     * Sends a heartbeat to ClusterOS.
+     * @param epochTimeNs the current time
+     * @param appMetadata the application specific metadata which will be delivered with
+     *                    the heartbeat.
+     */
+    @RequiresPermission(Car.PERMISSION_CAR_INSTRUMENT_CLUSTER_CONTROL)
+    public void sendHeartbeat(long epochTimeNs, @Nullable byte[] appMetadata) {
+        try {
+            mService.sendHeartbeat(epochTimeNs, appMetadata);
+        } catch (RemoteException e) {
+            handleRemoteExceptionFromCarService(e);
+        }
+    }
+
     @Override
     protected void onCarDisconnected() {
         mStateListeners.clear();
diff --git a/car-lib/src/android/car/cluster/IClusterHomeService.aidl b/car-lib/src/android/car/cluster/IClusterHomeService.aidl
index c5c589b..582eb27 100644
--- a/car-lib/src/android/car/cluster/IClusterHomeService.aidl
+++ b/car-lib/src/android/car/cluster/IClusterHomeService.aidl
@@ -61,4 +61,6 @@
     void registerClusterNavigationStateListener(in IClusterNavigationStateListener listener) = 9;
     /** Unregisters a cluster navigation state listener. */
     void unregisterClusterNavigationStateListener(in IClusterNavigationStateListener listener) = 10;
+    /** Sends a heartbeat. */
+    void sendHeartbeat(long epochTimeNs, in byte[] appMetadata) = 11;
 }
diff --git a/service/src/com/android/car/ICarImpl.java b/service/src/com/android/car/ICarImpl.java
index 331f397..c9794cd 100644
--- a/service/src/com/android/car/ICarImpl.java
+++ b/service/src/com/android/car/ICarImpl.java
@@ -437,7 +437,7 @@
                         t, ClusterHomeService.class,
                         () -> new ClusterHomeService(serviceContext, mHal.getClusterHal(),
                                 mClusterNavigationService, mCarOccupantZoneService,
-                                mFixedActivityService), allServices);
+                                mFixedActivityService, mCarActivityService), allServices);
             } else {
                 Slogf.w(TAG, "Can't init ClusterHomeService, since Old cluster service is running");
                 mClusterHomeService = null;
diff --git a/service/src/com/android/car/cluster/ClusterHomeService.java b/service/src/com/android/car/cluster/ClusterHomeService.java
index d4abe37..a9ef031 100644
--- a/service/src/com/android/car/cluster/ClusterHomeService.java
+++ b/service/src/com/android/car/cluster/ClusterHomeService.java
@@ -23,11 +23,13 @@
 import static com.android.car.hal.ClusterHalService.DISPLAY_ON;
 import static com.android.car.hal.ClusterHalService.DONT_CARE;
 import static com.android.car.internal.ExcludeFromCodeCoverageGeneratedReport.DUMP_INFO;
+import static com.android.car.internal.common.CommonConstants.EMPTY_BYTE_ARRAY;
 
 import android.app.ActivityOptions;
 import android.car.Car;
 import android.car.CarOccupantZoneManager;
 import android.car.ICarOccupantZoneCallback;
+import android.car.builtin.app.TaskInfoHelper;
 import android.car.builtin.os.UserManagerHelper;
 import android.car.builtin.util.Slogf;
 import android.car.cluster.ClusterHomeManager;
@@ -56,6 +58,7 @@
 import com.android.car.CarOccupantZoneService;
 import com.android.car.CarServiceBase;
 import com.android.car.R;
+import com.android.car.am.CarActivityService;
 import com.android.car.am.FixedActivityService;
 import com.android.car.hal.ClusterHalService;
 import com.android.car.internal.ExcludeFromCodeCoverageGeneratedReport;
@@ -76,6 +79,7 @@
     private final ClusterNavigationService mClusterNavigationService;
     private final CarOccupantZoneService mOccupantZoneService;
     private final FixedActivityService mFixedActivityService;
+    private final CarActivityService mCarActivityService;
     private final ComponentName mClusterHomeActivity;
 
     private boolean mServiceEnabled;
@@ -88,6 +92,7 @@
     private int mUiType = ClusterHomeManager.UI_TYPE_CLUSTER_HOME;
     private Intent mLastIntent;
     private int mLastIntentUserId = UserManagerHelper.USER_SYSTEM;
+    private volatile boolean mClusterActivityVisible;
 
     private final RemoteCallbackList<IClusterStateListener> mClientListeners =
             new RemoteCallbackList<>();
@@ -98,12 +103,14 @@
     public ClusterHomeService(Context context, ClusterHalService clusterHalService,
             ClusterNavigationService navigationService,
             CarOccupantZoneService occupantZoneService,
-            FixedActivityService fixedActivityService) {
+            FixedActivityService fixedActivityService,
+            CarActivityService carActivityService) {
         mContext = context;
         mClusterHalService = clusterHalService;
         mClusterNavigationService = navigationService;
         mOccupantZoneService = occupantZoneService;
         mFixedActivityService = fixedActivityService;
+        mCarActivityService = carActivityService;
         mClusterHomeActivity = ComponentName.unflattenFromString(
                 mContext.getString(R.string.config_clusterHomeActivity));
         mLastIntent = new Intent(ACTION_MAIN).setComponent(mClusterHomeActivity);
@@ -134,6 +141,10 @@
         mClusterNavigationService.setClusterServiceCallback(this);
 
         mOccupantZoneService.registerCallback(mOccupantZoneCallback);
+
+        if (mClusterHalService.isHeartbeatSupported()) {
+            mCarActivityService.registerActivityLaunchListener(mActivityLaunchListener);
+        }
         initClusterDisplay();
     }
 
@@ -183,6 +194,9 @@
     @Override
     public void release() {
         Slogf.d(TAG, "releaseClusterHomeService");
+        if (mClusterHalService.isHeartbeatSupported()) {
+            mCarActivityService.unregisterActivityLaunchListener(mActivityLaunchListener);
+        }
         mOccupantZoneService.unregisterCallback(mOccupantZoneCallback);
         mClusterHalService.setCallback(null);
         mClusterNavigationService.setClusterServiceCallback(null);
@@ -190,6 +204,18 @@
         mClientNavigationListeners.kill();
     }
 
+    private final CarActivityService.ActivityLaunchListener mActivityLaunchListener =
+            (topTask) -> {
+                if (TaskInfoHelper.getDisplayId(topTask) != mClusterDisplayId) return;
+                if (!mLastIntent.getComponent().equals(topTask.topActivity)) {
+                    mClusterActivityVisible = false;
+                    return;
+                }
+
+                // TODO: b/285415531 - Install TPL and TPL decides the visibility.
+                mClusterActivityVisible = true;
+            };
+
     @Override
     @ExcludeFromCodeCoverageGeneratedReport(reason = DUMP_INFO)
     public void dump(IndentingPrintWriter writer) {
@@ -383,6 +409,15 @@
         if (!mServiceEnabled) throw new IllegalStateException("Service is not enabled");
         return createClusterState();
     }
+
+    @Override
+    public void sendHeartbeat(long epochTimeNs, byte[] appMetadata) {
+        enforcePermission(Car.PERMISSION_CAR_INSTRUMENT_CLUSTER_CONTROL);
+        if (appMetadata == null) {
+            appMetadata = EMPTY_BYTE_ARRAY;
+        }
+        mClusterHalService.sendHeartbeat(epochTimeNs, mClusterActivityVisible ? 1 : 0, appMetadata);
+    }
     // IClusterHomeService ends
 
     private void enforcePermission(String permissionName) {
diff --git a/service/src/com/android/car/hal/ClusterHalService.java b/service/src/com/android/car/hal/ClusterHalService.java
index 17a9f84..3086e89 100644
--- a/service/src/com/android/car/hal/ClusterHalService.java
+++ b/service/src/com/android/car/hal/ClusterHalService.java
@@ -17,14 +17,16 @@
 package com.android.car.hal;
 
 import static android.car.VehiclePropertyIds.CLUSTER_DISPLAY_STATE;
+import static android.car.VehiclePropertyIds.CLUSTER_HEARTBEAT;
 import static android.car.VehiclePropertyIds.CLUSTER_NAVIGATION_STATE;
 import static android.car.VehiclePropertyIds.CLUSTER_REPORT_STATE;
 import static android.car.VehiclePropertyIds.CLUSTER_REQUEST_DISPLAY;
 import static android.car.VehiclePropertyIds.CLUSTER_SWITCH_UI;
 
-import static com.android.car.internal.common.CommonConstants.EMPTY_FLOAT_ARRAY;
-import static com.android.car.internal.common.CommonConstants.EMPTY_LONG_ARRAY;
 import static com.android.car.internal.ExcludeFromCodeCoverageGeneratedReport.DUMP_INFO;
+import static com.android.car.internal.common.CommonConstants.EMPTY_FLOAT_ARRAY;
+import static com.android.car.internal.common.CommonConstants.EMPTY_INT_ARRAY;
+import static com.android.car.internal.common.CommonConstants.EMPTY_LONG_ARRAY;
 
 import android.annotation.NonNull;
 import android.car.builtin.util.Slogf;
@@ -90,6 +92,7 @@
             CLUSTER_REPORT_STATE,
             CLUSTER_REQUEST_DISPLAY,
             CLUSTER_NAVIGATION_STATE,
+            CLUSTER_HEARTBEAT,
     };
 
     private static final int[] CORE_PROPERTIES = new int[]{
@@ -116,6 +119,7 @@
     // Whether all CORE_PROPERTIES are available.
     private volatile boolean mIsCoreSupported;
     private volatile boolean mIsNavigationStateSupported;
+    private volatile boolean mIsHeartbeatSupported;
 
     private final HalPropValueBuilder mPropValueBuilder;
 
@@ -179,8 +183,10 @@
             }
         }
         mIsNavigationStateSupported = supportedProperties.indexOf(CLUSTER_NAVIGATION_STATE) >= 0;
-        Slogf.d(TAG, "takeProperties: coreSupported=%s, navigationStateSupported=%s",
-                mIsCoreSupported, mIsNavigationStateSupported);
+        mIsHeartbeatSupported = supportedProperties.indexOf(CLUSTER_HEARTBEAT) >= 0;
+        Slogf.d(TAG, "takeProperties: coreSupported=%s, navigationStateSupported=%s, "
+                + "heartbeatSupported=%s",
+                mIsCoreSupported, mIsNavigationStateSupported, mIsHeartbeatSupported);
     }
 
     @VisibleForTesting
@@ -201,6 +207,10 @@
         return mIsNavigationStateSupported;
     }
 
+    public boolean isHeartbeatSupported() {
+        return mIsHeartbeatSupported;
+    }
+
     @Override
     public void onHalEvents(List<HalPropValue> values) {
         Slogf.d(TAG, "handleHalEvents(): %s", values);
@@ -339,6 +349,26 @@
         send(request);
     }
 
+    /**
+     * Sends a heartbeat to ClusterOS
+     * @param epochTimeNs the current time
+     * @param visibility 0 means invisible and 1 means visible.
+     * @param appMetadata the application specific metadata which will be delivered with
+     *                    the heartbeat.
+     */
+    public void sendHeartbeat(long epochTimeNs, long visibility, byte[] appMetadata) {
+        long[] longValues = new long[]{
+                epochTimeNs,
+                visibility
+        };
+        HalPropValue request = mPropValueBuilder.build(CLUSTER_HEARTBEAT,
+                /* areaId= */ 0, SystemClock.elapsedRealtime(), VehiclePropertyStatus.AVAILABLE,
+                /* int32Values= */ EMPTY_INT_ARRAY, /* floatValues= */ EMPTY_FLOAT_ARRAY,
+                /* int64Values= */ longValues, /* stringValue= */ "",
+                /* byteValues= */ appMetadata);
+        send(request);
+    }
+
     private void send(HalPropValue request) {
         try {
             mHal.set(request);
@@ -354,5 +384,6 @@
         writer.println("mServiceMode: " + mServiceMode);
         writer.println("mIsCoreSupported: " + mIsCoreSupported);
         writer.println("mIsNavigationStateSupported: " + mIsNavigationStateSupported);
+        writer.println("mIsHeartbeatSupported: " + mIsHeartbeatSupported);
     }
 }
diff --git a/tests/carservice_unit_test/res/raw/car_hidden_apis.txt b/tests/carservice_unit_test/res/raw/car_hidden_apis.txt
index 14e13a8..42561a4 100644
--- a/tests/carservice_unit_test/res/raw/car_hidden_apis.txt
+++ b/tests/carservice_unit_test/res/raw/car_hidden_apis.txt
@@ -230,6 +230,7 @@
 android.car.cluster ClusterHomeManager void registerClusterStateListener(Executor executor, ClusterStateListener callback)
 android.car.cluster ClusterHomeManager void reportState(int uiTypeMain, int uiTypeSub, byte[] uiAvailability)
 android.car.cluster ClusterHomeManager void requestDisplay(int uiType)
+android.car.cluster ClusterHomeManager void sendHeartbeat(long epochTimeNs, byte[] appMetadata)
 android.car.cluster ClusterHomeManager void stopFixedActivityMode()
 android.car.cluster ClusterHomeManager void unregisterClusterNavigationStateListener(ClusterNavigationStateListener callback)
 android.car.cluster ClusterHomeManager void unregisterClusterStateListener(ClusterStateListener callback)
diff --git a/tests/carservice_unit_test/src/com/android/car/cluster/ClusterHomeServiceUnitTest.java b/tests/carservice_unit_test/src/com/android/car/cluster/ClusterHomeServiceUnitTest.java
index 7c0f6f5..9240396 100644
--- a/tests/carservice_unit_test/src/com/android/car/cluster/ClusterHomeServiceUnitTest.java
+++ b/tests/carservice_unit_test/src/com/android/car/cluster/ClusterHomeServiceUnitTest.java
@@ -21,6 +21,8 @@
 import static android.car.cluster.ClusterHomeManager.UI_TYPE_CLUSTER_NONE;
 import static android.car.navigation.CarNavigationInstrumentCluster.CLUSTER_TYPE_IMAGE_CODES_ONLY;
 
+import static com.android.car.internal.common.CommonConstants.EMPTY_BYTE_ARRAY;
+
 import static com.google.common.truth.Truth.assertThat;
 
 import static org.mockito.ArgumentMatchers.any;
@@ -30,6 +32,7 @@
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
+import android.app.ActivityManager;
 import android.app.ActivityOptions;
 import android.car.CarOccupantZoneManager;
 import android.car.ICarOccupantZoneCallback;
@@ -51,6 +54,8 @@
 import android.view.Display;
 
 import com.android.car.CarOccupantZoneService;
+import com.android.car.am.CarActivityService;
+import com.android.car.am.CarActivityService.ActivityLaunchListener;
 import com.android.car.am.FixedActivityService;
 import com.android.car.cluster.ClusterNavigationService.ContextOwner;
 import com.android.car.hal.ClusterHalService;
@@ -88,6 +93,8 @@
     @Mock
     private FixedActivityService mFixedActivityService;
     @Mock
+    private CarActivityService mCarActivityService;
+    @Mock
     private DisplayManager mDisplayManager;
     @Mock
     private Display mClusterDisplay;
@@ -96,6 +103,7 @@
     private int mClusterStateChanges;
     private byte[] mNavigationState;
     private ICarOccupantZoneCallback mOccupantZoneCallback;
+    private ActivityLaunchListener mActivityLaunchListener;
 
     private IClusterStateListener mClusterStateListener;
     private IClusterNavigationStateListener mClusterNavigationStateListener;
@@ -134,15 +142,23 @@
         when(mClusterHalService.isServiceEnabled()).thenReturn(true);
         when(mClusterHalService.isLightMode()).thenReturn(false);
         when(mClusterHalService.isNavigationStateSupported()).thenReturn(true);
+        when(mClusterHalService.isHeartbeatSupported()).thenReturn(true);
         when(mDisplayManager.getDisplay(CLUSTER_DISPLAY_ID)).thenReturn(mClusterDisplay);
         doAnswer(invocation -> {
             Point size = (Point) invocation.getArgument(0);
             size.set(CLUSTER_WIDTH, CLUSTER_HEIGHT);
             return null;
         }).when(mClusterDisplay).getRealSize(any(Point.class));
+        doAnswer(invocation -> {
+            mActivityLaunchListener = invocation.getArgument(0);
+            assertThat(mActivityLaunchListener).isNotNull();
+            return null;
+        }).when(mCarActivityService).registerActivityLaunchListener(
+                any(ActivityLaunchListener.class));
 
         mClusterHomeService = new ClusterHomeService(mContext, mClusterHalService,
-                mNavigationService, mOccupantZoneService, mFixedActivityService);
+                mNavigationService, mOccupantZoneService, mFixedActivityService,
+                mCarActivityService);
         mClusterHomeService.init();
     }
 
@@ -350,4 +366,31 @@
 
         verify(mFixedActivityService).stopFixedActivityMode(eq(CLUSTER_DISPLAY_ID));
     }
+
+    @Test
+    public void sendHeartbeat_SimpleCase() {
+        byte[] appMetadata = null;
+        long epochTimeNs = 123456789;
+
+        mClusterHomeService.sendHeartbeat(epochTimeNs, appMetadata);
+
+        verify(mClusterHalService).sendHeartbeat(
+                eq(epochTimeNs), eq(/* visibility= */ 0L), eq(EMPTY_BYTE_ARRAY));
+    }
+
+    @Test
+    public void sendHeartbeat_ReflectsClusterAppVisibility() {
+        byte[] appMetadata = new byte[] {(byte) 3, (byte) 2, (byte) 0, (byte) 1};
+        long epochTimeNs = 123456789;
+        ActivityManager.RunningTaskInfo clusterTask = new ActivityManager.RunningTaskInfo();
+        clusterTask.displayId = CLUSTER_DISPLAY_ID;
+        clusterTask.topActivity = mClusterHomeActivity;
+        clusterTask.isVisible = true;
+        mActivityLaunchListener.onActivityLaunch(clusterTask);
+
+        mClusterHomeService.sendHeartbeat(epochTimeNs, appMetadata);
+
+        verify(mClusterHalService).sendHeartbeat(
+                eq(epochTimeNs), eq(/* visibility= */ 1L), eq(appMetadata));
+    }
 }
diff --git a/tests/carservice_unit_test/src/com/android/car/hal/ClusterHalServiceTest.java b/tests/carservice_unit_test/src/com/android/car/hal/ClusterHalServiceTest.java
index 7edf86f..47c7863 100644
--- a/tests/carservice_unit_test/src/com/android/car/hal/ClusterHalServiceTest.java
+++ b/tests/carservice_unit_test/src/com/android/car/hal/ClusterHalServiceTest.java
@@ -17,6 +17,7 @@
 package com.android.car.hal;
 
 import static android.car.VehiclePropertyIds.CLUSTER_DISPLAY_STATE;
+import static android.car.VehiclePropertyIds.CLUSTER_HEARTBEAT;
 import static android.car.VehiclePropertyIds.CLUSTER_NAVIGATION_STATE;
 import static android.car.VehiclePropertyIds.CLUSTER_REPORT_STATE;
 import static android.car.VehiclePropertyIds.CLUSTER_REQUEST_DISPLAY;
@@ -528,4 +529,21 @@
 
         verify(mVehicleHal, times(0)).set(mPropCaptor.capture());
     }
+
+    @Test
+    public void testSendHeartbeat() {
+        long epochTimeNs = 123456789;
+        long visibility = 1;
+        byte[] appMetadata = new byte[]{3, 2, 0, 1};
+        mClusterHalService.sendHeartbeat(epochTimeNs, visibility, appMetadata);
+
+        verify(mVehicleHal).set(mPropCaptor.capture());
+        HalPropValue prop = mPropCaptor.getValue();
+        assertThat(prop.getPropId()).isEqualTo(CLUSTER_HEARTBEAT);
+        assertThat(prop.getInt64ContainerArray()).asList()
+                .containsExactly((long) epochTimeNs, (long) visibility);
+        assertThat(prop.getByteArray()).asList()
+                .containsExactly((byte) 3, (byte) 2, (byte) 0, (byte) 1);
+    }
+
 }