Monitoring the launched navigation app

Bug: 119258294
Test: Manual on device
Change-Id: I04cc2f1d4b606b2596a44151ba600fd855bf6141
(cherry picked from commit 72c5b124aa655ff7547cc890dfb9c4f270d4c507)
diff --git a/service/src/com/android/car/cluster/InstrumentClusterService.java b/service/src/com/android/car/cluster/InstrumentClusterService.java
index beb4427..360f6b4 100644
--- a/service/src/com/android/car/cluster/InstrumentClusterService.java
+++ b/service/src/com/android/car/cluster/InstrumentClusterService.java
@@ -122,6 +122,7 @@
         @Override
         public void onServiceDisconnected(ComponentName name) {
             Log.d(TAG, "onServiceDisconnected, name: " + name);
+            mContext.unbindService(this);
             mRendererBound = false;
 
             synchronized (mSync) {
diff --git a/tests/DirectRenderingClusterSample/Android.mk b/tests/DirectRenderingClusterSample/Android.mk
index 62918d5..4408158 100644
--- a/tests/DirectRenderingClusterSample/Android.mk
+++ b/tests/DirectRenderingClusterSample/Android.mk
@@ -37,6 +37,8 @@
 LOCAL_JAVA_LIBRARIES += android.car
 LOCAL_STATIC_ANDROID_LIBRARIES += \
     androidx.legacy_legacy-support-v4 \
-    androidx.car_car-cluster
+    androidx-constraintlayout_constraintlayout \
+    androidx.car_car-cluster \
+    car-arch-common
 
 include $(BUILD_PACKAGE)
diff --git a/tests/DirectRenderingClusterSample/AndroidManifest.xml b/tests/DirectRenderingClusterSample/AndroidManifest.xml
index a5f117c..4a44d73 100644
--- a/tests/DirectRenderingClusterSample/AndroidManifest.xml
+++ b/tests/DirectRenderingClusterSample/AndroidManifest.xml
@@ -39,6 +39,8 @@
     <uses-permission android:name="android.permission.MANAGE_USERS" />
     <!-- Required to launch navigation apps -->
     <uses-permission android:name="android.car.permission.CAR_INSTRUMENT_CLUSTER_CONTROL"/>
+    <!-- Required to watch activities running on the cluster -->
+    <uses-permission android:name="android.permission.SET_ACTIVITY_WATCHER"/>
 
     <application android:label="@string/app_name"
                  android:icon="@mipmap/ic_launcher"
diff --git a/tests/DirectRenderingClusterSample/res/drawable/gradient_bottom.xml b/tests/DirectRenderingClusterSample/res/drawable/gradient_bottom.xml
new file mode 100644
index 0000000..ddafcf9
--- /dev/null
+++ b/tests/DirectRenderingClusterSample/res/drawable/gradient_bottom.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android">
+  <gradient
+      android:startColor="#00000000"
+      android:endColor="#FF000000"
+      android:angle="270"
+      android:dither="true"
+      />
+</shape>
\ No newline at end of file
diff --git a/tests/DirectRenderingClusterSample/res/drawable/gradient_top.xml b/tests/DirectRenderingClusterSample/res/drawable/gradient_top.xml
new file mode 100644
index 0000000..35c8497
--- /dev/null
+++ b/tests/DirectRenderingClusterSample/res/drawable/gradient_top.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android">
+  <gradient
+      android:startColor="#00000000"
+      android:endColor="#FF000000"
+      android:angle="90"
+      android:dither="true"
+      />
+</shape>
\ No newline at end of file
diff --git a/tests/DirectRenderingClusterSample/res/layout/fragment_navigation.xml b/tests/DirectRenderingClusterSample/res/layout/fragment_navigation.xml
index c0fb4b3..865a3b5 100644
--- a/tests/DirectRenderingClusterSample/res/layout/fragment_navigation.xml
+++ b/tests/DirectRenderingClusterSample/res/layout/fragment_navigation.xml
@@ -1,22 +1,47 @@
-<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
     xmlns:tools="http://schemas.android.com/tools"
     android:layout_width="match_parent"
     android:layout_height="match_parent"
-    android:background="@color/darkBlue"
     tools:context=".NavigationFragment">
 
-    <FrameLayout
-        android:id="@+id/nav_frame_layout"
+    <SurfaceView
+        android:id="@+id/nav_surface"
         android:layout_width="match_parent"
-        android:layout_height="match_parent">
+        android:layout_height="match_parent"/>
 
-        <SurfaceView
-            android:id="@+id/nav_surface"
-            android:layout_width="match_parent"
-            android:layout_height="match_parent"
-            android:layout_margin="20dp"/>
+    <ProgressBar
+        android:id="@+id/progress_bar"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        app:layout_constraintVertical_chainStyle="packed"
+        app:layout_constraintLeft_toLeftOf="parent"
+        app:layout_constraintRight_toRightOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        app:layout_constraintBottom_toTopOf="@+id/message"/>
 
-    </FrameLayout>
+    <TextView
+        android:id="@+id/message"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="@string/select_nav_app"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintLeft_toLeftOf="parent"
+        app:layout_constraintRight_toRightOf="parent"
+        app:layout_constraintTop_toBottomOf="@+id/progress_bar"/>
 
+    <ImageView
+        android:layout_width="match_parent"
+        android:layout_height="30dp"
+        android:src="@drawable/gradient_top"
+        app:layout_constraintTop_toTopOf="parent"/>
 
-</FrameLayout>
+    <ImageView
+        android:layout_width="match_parent"
+        android:layout_height="30dp"
+        android:src="@drawable/gradient_bottom"
+        app:layout_constraintBottom_toBottomOf="parent"/>
+
+</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
diff --git a/tests/DirectRenderingClusterSample/res/values/strings.xml b/tests/DirectRenderingClusterSample/res/values/strings.xml
index 778ecf6..e86cf60 100644
--- a/tests/DirectRenderingClusterSample/res/values/strings.xml
+++ b/tests/DirectRenderingClusterSample/res/values/strings.xml
@@ -3,4 +3,7 @@
 
     <!-- TODO: Remove or change this placeholder text -->
     <string name="hello_blank_fragment">Hello blank fragment</string>
+
+    <!-- Message to show when a navigation app hasn't been selected yet. [CHAR LIMIT=100]-->
+    <string name="select_nav_app">Select a navigation app on the main unit.</string>
 </resources>
diff --git a/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/ActivityMonitor.java b/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/ActivityMonitor.java
new file mode 100644
index 0000000..8b69e65
--- /dev/null
+++ b/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/ActivityMonitor.java
@@ -0,0 +1,138 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package android.car.cluster.sample;
+
+import android.annotation.Nullable;
+import android.app.ActivityManager;
+import android.app.ActivityManager.StackInfo;
+import android.app.IActivityManager;
+import android.app.IProcessObserver;
+import android.app.TaskStackListener;
+import android.content.ComponentName;
+import android.os.RemoteException;
+import android.util.Log;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Top activity monitor, allows listeners to be notified when a new activity comes to the foreground
+ * on a particular device.
+ */
+public class ActivityMonitor {
+    private static final String TAG = "Cluster.ActivityMonitor";
+
+    /**
+     * Listener of activity changes
+     */
+    public interface ActivityListener {
+        /**
+         * Invoked when a new activity becomes the top activity on a particular display.
+         */
+        void onTopActivityChanged(int displayId, @Nullable ComponentName activity);
+    }
+
+    private IActivityManager mActivityManager;
+    // Listeners of top activity changes, indexed by the displayId they are interested on.
+    private final Map<Integer, Set<ActivityListener>> mListeners = new HashMap<>();
+    private final IProcessObserver.Stub mProcessObserver = new IProcessObserver.Stub() {
+        @Override
+        public void onForegroundActivitiesChanged(int pid, int uid, boolean foregroundActivities) {
+            notifyTopActivities();
+        }
+
+        @Override
+        public void onProcessDied(int pid, int uid) {
+            notifyTopActivities();
+        }
+    };
+    private final TaskStackListener mTaskStackListener = new TaskStackListener() {
+        @Override
+        public void onTaskStackChanged() {
+            Log.i(TAG, "onTaskStackChanged");
+            notifyTopActivities();
+        }
+    };
+
+    /**
+     * Registers a new listener to receive activity updates on a particular display
+     *
+     * @param displayId identifier of the display to monitor
+     * @param listener listener to be notified
+     */
+    public void addListener(int displayId, ActivityListener listener) {
+        mListeners.computeIfAbsent(displayId, k -> new HashSet<>()).add(listener);
+    }
+
+    /**
+     * Unregisters a listener previously registered with {@link #addListener(int, ActivityListener)}
+     */
+    public void removeListener(int displayId, ActivityListener listener) {
+        mListeners.computeIfAbsent(displayId, k -> new HashSet<>()).remove(listener);
+    }
+
+    /**
+     * Starts monitoring activity changes. {@link #stop()} should be invoked to release resources.
+     */
+    public void start() {
+        mActivityManager = ActivityManager.getService();
+        // Monitoring both listeners are necessary as there are cases where one listener cannot
+        // monitor activity change.
+        try {
+            mActivityManager.registerProcessObserver(mProcessObserver);
+            mActivityManager.registerTaskStackListener(mTaskStackListener);
+        } catch (RemoteException e) {
+            Log.e(TAG, "Cannot register activity monitoring", e);
+            throw new RuntimeException(e);
+        }
+        notifyTopActivities();
+    }
+
+    /**
+     * Stops monitoring activity changes. Should be invoked when this monitor is not longer used.
+     */
+    public void stop() {
+        if (mActivityManager == null) {
+            return;
+        }
+        try {
+            mActivityManager.unregisterProcessObserver(mProcessObserver);
+            mActivityManager.unregisterTaskStackListener(mTaskStackListener);
+        } catch (RemoteException e) {
+            Log.e(TAG, "Cannot unregister activity monitoring. Ignoring", e);
+        }
+        mActivityManager = null;
+    }
+
+    private void notifyTopActivities() {
+        try {
+            List<StackInfo> infos = mActivityManager.getAllStackInfos();
+            for (StackInfo info : infos) {
+                Set<ActivityListener> listeners = mListeners.get(info.displayId);
+                if (listeners != null && !listeners.isEmpty()) {
+                    for (ActivityListener listener : listeners) {
+                        listener.onTopActivityChanged(info.displayId, info.topActivity);
+                    }
+                }
+            }
+        } catch (RemoteException e) {
+            Log.e(TAG, "Cannot getTasks", e);
+        }
+    }
+}
diff --git a/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/ClusterRenderingServiceImpl.java b/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/ClusterRenderingServiceImpl.java
index 66d2d58..74125c3 100644
--- a/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/ClusterRenderingServiceImpl.java
+++ b/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/ClusterRenderingServiceImpl.java
@@ -64,6 +64,7 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
+import java.util.Objects;
 
 /**
  * Implementation of {@link InstrumentClusterRenderingService} which renders an activity on a
@@ -82,10 +83,14 @@
     static final int MSG_ON_KEY_EVENT = 3;
     static final int MSG_REGISTER_CLIENT = 4;
     static final int MSG_UNREGISTER_CLIENT = 5;
+    static final int MSG_ON_FREE_NAVIGATION_ACTIVITY_STATE_CHANGED = 6;
     static final String MSG_KEY_CATEGORY = "category";
     static final String MSG_KEY_ACTIVITY_DISPLAY_ID = "activity_display_id";
     static final String MSG_KEY_ACTIVITY_STATE = "activity_state";
     static final String MSG_KEY_KEY_EVENT = "key_event";
+    static final String MSG_KEY_FREE_NAVIGATION_ACTIVITY_NAME = "free_navigation_activity_name";
+    static final String MSG_KEY_FREE_NAVIGATION_ACTIVITY_VISIBLE =
+            "free_navigation_activity_visible";
 
     private static final int NAVIGATION_ACTIVITY_MAX_RETRIES = 10;
     private static final int NAVIGATION_ACTIVITY_RETRY_INTERVAL_MS = 1000;
@@ -95,8 +100,12 @@
     private int mDisplayId = NO_DISPLAY;
     private UserReceiver mUserReceiver;
     private final Handler mHandler = new Handler();
+    private final IBinder mLocalBinder = new Messenger(new MessageHandler(this)).getBinder();
     private final Runnable mRetryLaunchNavigationActivity = this::tryLaunchNavigationActivity;
     private int mNavigationDisplayId = NO_DISPLAY;
+    private ComponentName mFreeNavigationActivity;
+    private ActivityMonitor mActivityMonitor = new ActivityMonitor();
+    private boolean mFreeNavigationActivityVisible;
 
     private final DisplayListener mDisplayListener = new DisplayListener() {
         @Override
@@ -174,6 +183,7 @@
                     }
                     case MSG_REGISTER_CLIENT:
                         mService.get().mClients.add(msg.replyTo);
+                        mService.get().notifyFreeNavigationActivityChange();
                         break;
                     case MSG_UNREGISTER_CLIENT:
                         mService.get().mClients.remove(msg.replyTo);
@@ -187,11 +197,22 @@
         }
     }
 
+    private ActivityMonitor.ActivityListener mNavigationActivityMonitor = (displayId, activity) -> {
+        if (displayId != mNavigationDisplayId) {
+            return;
+        }
+        boolean activityVisible = activity != null && activity.equals(mFreeNavigationActivity);
+        if (activityVisible != mFreeNavigationActivityVisible) {
+            mFreeNavigationActivityVisible = activityVisible;
+            notifyFreeNavigationActivityChange();
+        }
+    };
+
     @Override
     public IBinder onBind(Intent intent) {
         Log.d(TAG, "onBind, intent: " + intent);
-        return (LOCAL_BINDING_ACTION.equals(intent.getAction()))
-                ? new Messenger(new MessageHandler(this)).getBinder()
+        return LOCAL_BINDING_ACTION.equals(intent.getAction())
+                ? mLocalBinder
                 : super.onBind(intent);
     }
 
@@ -202,6 +223,7 @@
         mDisplayProvider = new ClusterDisplayProvider(this, mDisplayListener);
         mUserReceiver = new UserReceiver(this);
         mUserReceiver.register(this);
+        mActivityMonitor.start();
     }
 
     private void launchMainActivity() {
@@ -247,6 +269,7 @@
         super.onDestroy();
         Log.w(TAG, "onDestroy");
         mUserReceiver.unregister(this);
+        mActivityMonitor.stop();
     }
 
     @Override
@@ -299,11 +322,8 @@
         if (args != null && args.length > 0) {
             execShellCommand(args);
         } else {
-
-            if (args == null || args.length == 0) {
-                writer.println("* dump " + getClass().getCanonicalName() + " *");
-                writer.println("DisplayProvider: " + mDisplayProvider);
-            }
+            writer.println("* dump " + getClass().getCanonicalName() + " *");
+            writer.println("DisplayProvider: " + mDisplayProvider);
         }
     }
 
@@ -388,6 +408,8 @@
     }
 
     private void startNavigationActivity(int displayId) {
+        mActivityMonitor.removeListener(mNavigationDisplayId, mNavigationActivityMonitor);
+        mActivityMonitor.addListener(displayId, mNavigationActivityMonitor);
         mNavigationDisplayId = displayId;
         tryLaunchNavigationActivity();
     }
@@ -410,12 +432,23 @@
         }
         mHandler.removeCallbacks(mRetryLaunchNavigationActivity);
 
-        Intent intent = getNavigationActivityIntent();
+        ComponentName navigationActivity = getNavigationActivity();
+        if (!Objects.equals(navigationActivity, mFreeNavigationActivity)) {
+            if (Log.isLoggable(TAG, Log.DEBUG)) {
+                Log.d(TAG, "Navigation activity change detected: " + navigationActivity);
+            }
+            mFreeNavigationActivity = navigationActivity;
+            notifyFreeNavigationActivityChange();
+        }
 
         try {
-            if (intent == null) {
+            if (navigationActivity == null) {
                 throw new ActivityNotFoundException();
             }
+            Intent intent = new Intent(Intent.ACTION_MAIN).addCategory(CATEGORY_NAVIGATION)
+                    .setPackage(navigationActivity.getPackageName())
+                    .setComponent(navigationActivity)
+                    .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
             Log.d(TAG, "Launching: " + intent + " on display: " + mNavigationDisplayId);
             Bundle activityOptions = ActivityOptions.makeBasic()
                     .setLaunchDisplayId(mNavigationDisplayId)
@@ -427,22 +460,29 @@
             mHandler.postDelayed(mRetryLaunchNavigationActivity,
                     NAVIGATION_ACTIVITY_RETRY_INTERVAL_MS);
         } catch (Exception ex) {
-            Log.e(TAG, "Unable to start navigation activity: " + intent, ex);
+            Log.e(TAG, "Unable to start navigation activity: " + navigationActivity, ex);
         }
     }
 
+    private void notifyFreeNavigationActivityChange() {
+        Bundle bundle = new Bundle();
+        bundle.putParcelable(MSG_KEY_FREE_NAVIGATION_ACTIVITY_NAME, mFreeNavigationActivity);
+        bundle.putBoolean(MSG_KEY_FREE_NAVIGATION_ACTIVITY_VISIBLE, mFreeNavigationActivityVisible);
+        broadcastClientMessage(MSG_ON_FREE_NAVIGATION_ACTIVITY_STATE_CHANGED, bundle);
+    }
+
     /**
      * Returns a default navigation activity to show in the cluster.
      * In the current implementation we search for an activity with the
      * {@link CarInstrumentClusterManager#CATEGORY_NAVIGATION} category from the same navigation app
      * selected from CarLauncher (see CarLauncher#getMapsIntent()).
-     * Alternatively, other implementations could
+     * Alternatively, other implementations could:
      * <ul>
      * <li>Read this package from a resource (having a OEM default activity to show)
      * <li>Let the user select one from settings.
      * </ul>
      */
-    private Intent getNavigationActivityIntent() {
+    private ComponentName getNavigationActivity() {
         PackageManager pm = getPackageManager();
         int userId = ActivityManager.getCurrentUser();
 
@@ -464,11 +504,8 @@
                 if (candidate.activityInfo.packageName.equals(navigationApp.activityInfo
                         .packageName)) {
                     Log.d(TAG, "Found activity: " + candidate);
-                    intent.setPackage(navigationApp.activityInfo.packageName);
-                    intent.setComponent(new ComponentName(candidate.activityInfo.packageName,
-                            candidate.activityInfo.name));
-                    intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
-                    return intent;
+                    return new ComponentName(candidate.activityInfo.packageName,
+                            candidate.activityInfo.name);
                 }
             }
         } else {
diff --git a/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/ClusterViewModel.java b/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/ClusterViewModel.java
new file mode 100644
index 0000000..55c9e54
--- /dev/null
+++ b/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/ClusterViewModel.java
@@ -0,0 +1,118 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package android.car.cluster.sample;
+
+import android.app.Application;
+import android.content.ComponentName;
+
+import androidx.annotation.NonNull;
+import androidx.lifecycle.AndroidViewModel;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.MutableLiveData;
+
+import java.util.Objects;
+
+/**
+ * {@link AndroidViewModel} for cluster information.
+ */
+public class ClusterViewModel extends AndroidViewModel {
+    /**
+     * Reference to a component (e.g.: an activity) and whether such component is visible or not.
+     */
+    public static class ComponentVisibility {
+        /**
+         * Application component name
+         */
+        public final ComponentName mComponent;
+        /**
+         * Whether the component is currently visible to the user or not.
+         */
+        public final boolean mIsVisible;
+
+        /**
+         * Creates a new component visibility reference
+         */
+        private ComponentVisibility(ComponentName component, boolean isVisible) {
+            mComponent = component;
+            mIsVisible = isVisible;
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) return true;
+            if (o == null || getClass() != o.getClass()) return false;
+            ComponentVisibility that = (ComponentVisibility) o;
+            return mIsVisible == that.mIsVisible
+                    && Objects.equals(mComponent, that.mComponent);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(mComponent, mIsVisible);
+        }
+    }
+
+    private final MutableLiveData<ComponentVisibility> mFreeNavigationActivity =
+            new MutableLiveData<>(new ComponentVisibility(null, false));
+    private final MutableLiveData<Boolean> mNavigationFocus = new MutableLiveData<>(false);
+
+    /**
+     * New {@link ClusterViewModel} instance
+     */
+    public ClusterViewModel(@NonNull Application application) {
+        super(application);
+    }
+
+    /**
+     * Returns a {@link LiveData} providing the activity selected to be displayed on the cluster
+     * when navigation focus is not granted (a.k.a.: free navigation). It also indicates whether
+     * such activity is currently visible to the user or not.
+     */
+    public LiveData<ComponentVisibility> getFreeNavigationActivity() {
+        return mFreeNavigationActivity;
+    }
+
+    /**
+     * Returns a {@link LiveData} indicating whether navigation focus is currently being granted
+     * or not. This indicates whether a navigation application is currently providing driving
+     * directions. Instrument cluster can use this signal to show/hide turn-by-turn
+     * directions UI, and hide/show the free navigation activity
+     * (see {@link #getFreeNavigationActivity()}).
+     */
+    public LiveData<Boolean> getNavigationFocus() {
+        return mNavigationFocus;
+    }
+
+    /**
+     * Sets the activity selected to be displayed on the cluster when no driving directions are
+     * being provided, and whether such activity is currently visible to the user or not
+     */
+    public void setFreeNavigationActivity(ComponentName application, boolean isVisible) {
+        ComponentVisibility newValue = new ComponentVisibility(application, isVisible);
+        if (!Objects.equals(mFreeNavigationActivity.getValue(), newValue)) {
+            mFreeNavigationActivity.setValue(new ComponentVisibility(application, isVisible));
+        }
+    }
+
+    /**
+     * Sets whether navigation focus is currently being granted or not.
+     */
+    public void setNavigationFocus(boolean navigationFocus) {
+        if (mNavigationFocus.getValue() == null || mNavigationFocus.getValue() != navigationFocus) {
+            mNavigationFocus.setValue(navigationFocus);
+        }
+    }
+}
diff --git a/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/MainClusterActivity.java b/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/MainClusterActivity.java
index 7464552..2a2ac73 100644
--- a/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/MainClusterActivity.java
+++ b/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/MainClusterActivity.java
@@ -20,11 +20,18 @@
 import static android.car.cluster.sample.ClusterRenderingServiceImpl.MSG_KEY_ACTIVITY_DISPLAY_ID;
 import static android.car.cluster.sample.ClusterRenderingServiceImpl.MSG_KEY_ACTIVITY_STATE;
 import static android.car.cluster.sample.ClusterRenderingServiceImpl.MSG_KEY_CATEGORY;
+import static android.car.cluster.sample.ClusterRenderingServiceImpl.MSG_KEY_FREE_NAVIGATION_ACTIVITY_NAME;
+import static android.car.cluster.sample.ClusterRenderingServiceImpl
+        .MSG_KEY_FREE_NAVIGATION_ACTIVITY_VISIBLE;
 import static android.car.cluster.sample.ClusterRenderingServiceImpl.MSG_KEY_KEY_EVENT;
+import static android.car.cluster.sample.ClusterRenderingServiceImpl
+        .MSG_ON_FREE_NAVIGATION_ACTIVITY_STATE_CHANGED;
 import static android.car.cluster.sample.ClusterRenderingServiceImpl.MSG_ON_KEY_EVENT;
-import static android.car.cluster.sample.ClusterRenderingServiceImpl.MSG_ON_NAVIGATION_STATE_CHANGED;
+import static android.car.cluster.sample.ClusterRenderingServiceImpl
+        .MSG_ON_NAVIGATION_STATE_CHANGED;
 import static android.car.cluster.sample.ClusterRenderingServiceImpl.MSG_REGISTER_CLIENT;
-import static android.car.cluster.sample.ClusterRenderingServiceImpl.MSG_SET_ACTIVITY_LAUNCH_OPTIONS;
+import static android.car.cluster.sample.ClusterRenderingServiceImpl
+        .MSG_SET_ACTIVITY_LAUNCH_OPTIONS;
 import static android.car.cluster.sample.ClusterRenderingServiceImpl.MSG_UNREGISTER_CLIENT;
 
 import android.car.Car;
@@ -55,6 +62,7 @@
 import androidx.fragment.app.FragmentActivity;
 import androidx.fragment.app.FragmentManager;
 import androidx.fragment.app.FragmentPagerAdapter;
+import androidx.lifecycle.ViewModelProviders;
 import androidx.versionedparcelable.ParcelUtils;
 import androidx.viewpager.widget.ViewPager;
 
@@ -69,6 +77,7 @@
 
     private ViewPager mPager;
     private NavStateController mNavStateController;
+    private ClusterViewModel mClusterViewModel;
 
     private HashMap<Button, Facet<?>> mButtonToFacet = new HashMap<>();
     private SparseArray<Facet<?>> mOrderToFacet = new SparseArray<>();
@@ -132,9 +141,9 @@
                     Log.e(TAG, "onServiceConnected: unable to obtain CarAppFocusManager");
                     return;
                 }
-                mCarAppFocusManager.addFocusListener((appType, active) -> {
-                    onNavigationFocusChanged(active);
-                }, CarAppFocusManager.APP_FOCUS_TYPE_NAVIGATION);
+                mCarAppFocusManager.addFocusListener(
+                        (appType, active) -> mClusterViewModel.setNavigationFocus(active),
+                        CarAppFocusManager.APP_FOCUS_TYPE_NAVIGATION);
             } catch (CarNotConnectedException e) {
                 Log.e(TAG, "onServiceConnected: error obtaining manager", e);
             }
@@ -159,7 +168,10 @@
             Bundle data = msg.getData();
             switch (msg.what) {
                 case MSG_ON_KEY_EVENT:
-                    mActivity.get().onKeyEvent(data.getParcelable(MSG_KEY_KEY_EVENT));
+                    KeyEvent event = data.getParcelable(MSG_KEY_KEY_EVENT);
+                    if (event != null) {
+                        mActivity.get().onKeyEvent(event);
+                    }
                     break;
                 case MSG_ON_NAVIGATION_STATE_CHANGED:
                     if (data == null) {
@@ -172,6 +184,13 @@
                         mActivity.get().onNavigationStateChange(navState);
                     }
                     break;
+                case MSG_ON_FREE_NAVIGATION_ACTIVITY_STATE_CHANGED:
+                    ComponentName activity = data.getParcelable(
+                            MSG_KEY_FREE_NAVIGATION_ACTIVITY_NAME);
+                    boolean isVisible = data.getBoolean(MSG_KEY_FREE_NAVIGATION_ACTIVITY_VISIBLE);
+                    mActivity.get().mClusterViewModel.setFreeNavigationActivity(activity,
+                            isVisible);
+                    break;
                 default:
                     super.handleMessage(msg);
             }
@@ -201,6 +220,10 @@
         mOrderToFacet.get(0).button.requestFocus();
         mNavStateController = new NavStateController(findViewById(R.id.navigation_state));
 
+        mClusterViewModel = ViewModelProviders.of(this).get(ClusterViewModel.class);
+        mClusterViewModel.getNavigationFocus().observe(this, active ->
+                mNavStateController.setActive(active));
+
         mCar = Car.createCar(this, mCarServiceConnection);
         mCar.connect();
     }
@@ -235,12 +258,6 @@
         }
     }
 
-    private void onNavigationFocusChanged(boolean active) {
-        if (mNavStateController != null) {
-            mNavStateController.setActive(active);
-        }
-    }
-
     public void updateNavDisplay(VirtualDisplay virtualDisplay) {
         if (mService == null) {
             // Service is not bound yet. Hold the information and notify when the service is bound.
diff --git a/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/NavStateController.java b/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/NavStateController.java
index 44b6268..f31c090 100644
--- a/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/NavStateController.java
+++ b/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/NavStateController.java
@@ -58,7 +58,9 @@
      * Updates views to reflect the provided navigation state
      */
     public void update(@Nullable NavigationState state) {
-        Log.i(TAG, "Updating nav state: " + state);
+        if (Log.isLoggable(TAG, Log.DEBUG)) {
+            Log.d(TAG, "Updating nav state: " + state);
+        }
         Step step = getImmediateStep(state);
         mManeuver.setImageDrawable(getManeuverIcon(step != null ? step.getManeuver() : null));
         mDistance.setText(formatDistance(step != null ? step.getDistance() : null));
@@ -69,7 +71,9 @@
      * a navigation application has focus.
      */
     public void setActive(boolean active) {
-        Log.i(TAG, "Navigation status active: " + active);
+        if (Log.isLoggable(TAG, Log.DEBUG)) {
+            Log.d(TAG, "Navigation status active: " + active);
+        }
         if (!active) {
             mManeuver.setImageDrawable(null);
             mDistance.setText(null);
diff --git a/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/NavigationFragment.java b/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/NavigationFragment.java
index 1b61658..407e13a 100644
--- a/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/NavigationFragment.java
+++ b/tests/DirectRenderingClusterSample/src/android/car/cluster/sample/NavigationFragment.java
@@ -31,8 +31,12 @@
 import android.view.SurfaceView;
 import android.view.View;
 import android.view.ViewGroup;
+import android.widget.ProgressBar;
+import android.widget.TextView;
 
 import androidx.fragment.app.Fragment;
+import androidx.lifecycle.ViewModelProvider;
+import androidx.lifecycle.ViewModelProviders;
 
 public class NavigationFragment extends Fragment {
     private static final String TAG = "Cluster.NavFragment";
@@ -41,6 +45,10 @@
     private DisplayManager mDisplayManager;
     private Rect mUnobscuredBounds;
     private MainClusterActivity mMainClusterActivity;
+    private ClusterViewModel mViewModel;
+    private ProgressBar mProgressBar;
+    private TextView mMessage;
+
 
     // Static because we want to keep alive this virtual display when navigating through
     // ViewPager (this fragment gets dynamically destroyed and created)
@@ -109,6 +117,9 @@
     public View onCreateView(LayoutInflater inflater, ViewGroup container,
                              Bundle savedInstanceState) {
         Log.i(TAG, "onCreateView");
+        ViewModelProvider provider = ViewModelProviders.of(requireActivity());
+        mViewModel = provider.get(ClusterViewModel.class);
+
         mDisplayManager = getActivity().getSystemService(DisplayManager.class);
         mDisplayManager.registerDisplayListener(mDisplayListener, new Handler());
 
@@ -145,6 +156,14 @@
                 mVirtualDisplay.setSurface(null);
             }
         });
+        mProgressBar = root.findViewById(R.id.progress_bar);
+        mMessage = root.findViewById(R.id.message);
+
+        mViewModel.getFreeNavigationActivity().observe(this, app -> {
+            mProgressBar.setVisibility(app.mComponent != null && !app.mIsVisible ? View.VISIBLE
+                    : View.GONE);
+            mMessage.setVisibility(app.mComponent == null ? View.VISIBLE : View.GONE);
+        });
 
         return root;
     }