/* * Copyright (C) 2017 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; import static android.car.cluster.ClusterRenderingService.LOCAL_BINDING_ACTION; import static android.content.Intent.ACTION_SCREEN_OFF; import static android.content.Intent.ACTION_USER_PRESENT; import static android.content.Intent.ACTION_USER_SWITCHED; import static android.content.Intent.ACTION_USER_UNLOCKED; import static android.content.PermissionChecker.PERMISSION_GRANTED; import android.annotation.NonNull; import android.app.ActivityManager; import android.app.ActivityOptions; import android.car.Car; import android.car.cluster.navigation.NavigationState.NavigationStateProto; import android.car.cluster.sensors.Sensors; import android.content.ActivityNotFoundException; import android.content.BroadcastReceiver; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.ServiceConnection; import android.content.pm.ActivityInfo; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.graphics.Rect; import android.os.Bundle; import android.os.Handler; import android.os.IBinder; import android.os.UserHandle; import android.util.Log; import android.util.SparseArray; import android.view.Display; import android.view.InputDevice; import android.view.KeyEvent; import android.view.View; import android.view.inputmethod.InputMethodManager; import android.widget.Button; import android.widget.TextView; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentPagerAdapter; import androidx.lifecycle.LiveData; import androidx.lifecycle.ViewModelProvider; import androidx.lifecycle.ViewModelProviders; import androidx.viewpager.widget.ViewPager; import com.android.car.telephony.common.InMemoryPhoneBook; import java.lang.ref.WeakReference; import java.lang.reflect.InvocationTargetException; import java.net.URISyntaxException; import java.util.HashMap; import java.util.Map; /** * Main activity displayed on the instrument cluster. This activity contains fragments for each of * the cluster "facets" (e.g.: navigation, communication, media and car state). Users can navigate * to each facet by using the steering wheel buttons. *

* This activity runs on "system user" (see {@link UserHandle#USER_SYSTEM}) but it is visible on * all users (the same activity remains active even during user switch). *

* This activity also launches a default navigation app inside a virtual display (which is located * inside {@link NavigationFragment}). This navigation app is launched when: *

* This is necessary because the navigation app runs under a normal user, and different users will * see different instances of the same application, with their own personalized data. */ public class MainClusterActivity extends FragmentActivity implements ClusterRenderingService.ServiceClient { private static final String TAG = "Cluster.MainActivity"; private static final int NAV_FACET_ID = 0; private static final int COMMS_FACET_ID = 1; private static final int MEDIA_FACET_ID = 2; private static final int INFO_FACET_ID = 3; private static final NavigationStateProto NULL_NAV_STATE = NavigationStateProto.getDefaultInstance(); private static final int NO_DISPLAY = -1; private ViewPager mPager; private NavStateController mNavStateController; private ClusterViewModel mClusterViewModel; private Map> mButtonToFacet = new HashMap<>(); private SparseArray> mOrderToFacet = new SparseArray<>(); private Map mGearsToIcon = new HashMap<>(); private InputMethodManager mInputMethodManager; private ClusterRenderingService mService; private VirtualDisplay mPendingVirtualDisplay = null; private static final int NAVIGATION_ACTIVITY_RETRY_INTERVAL_MS = 1000; private static final int NAVIGATION_ACTIVITY_RELAUNCH_DELAY_MS = 5000; private final UserReceiver mUserReceiver = new UserReceiver(); private ActivityMonitor mActivityMonitor = new ActivityMonitor(); private final Handler mHandler = new Handler(); private final Runnable mRetryLaunchNavigationActivity = this::tryLaunchNavigationActivity; private VirtualDisplay mNavigationDisplay = new VirtualDisplay(NO_DISPLAY, null); private int mPreviousFacet = COMMS_FACET_ID; /** * Description of a virtual display */ public static class VirtualDisplay { /** Identifier of the display */ public final int mDisplayId; /** Rectangular area inside this display that can be viewed without obstructions */ public final Rect mUnobscuredBounds; public VirtualDisplay(int displayId, Rect unobscuredBounds) { mDisplayId = displayId; mUnobscuredBounds = unobscuredBounds; } } private final View.OnFocusChangeListener mFacetButtonFocusListener = new View.OnFocusChangeListener() { @Override public void onFocusChange(View v, boolean hasFocus) { if (hasFocus) { mPager.setCurrentItem(mButtonToFacet.get(v).mOrder); } } }; private ServiceConnection mClusterRenderingServiceConnection = new ServiceConnection() { @Override public void onServiceConnected(ComponentName name, IBinder service) { Log.i(TAG, "onServiceConnected, name: " + name + ", service: " + service); mService = ((ClusterRenderingService.LocalBinder) service).getService(); mService.registerClient(MainClusterActivity.this); mNavStateController.setImageResolver(mService.getImageResolver()); if (mPendingVirtualDisplay != null) { // If haven't reported the virtual display yet, do so on service connect. reportNavDisplay(mPendingVirtualDisplay); mPendingVirtualDisplay = null; } } @Override public void onServiceDisconnected(ComponentName name) { Log.i(TAG, "onServiceDisconnected, name: " + name); mService = null; mNavStateController.setImageResolver(null); onNavigationStateChange(NULL_NAV_STATE); } }; private ActivityMonitor.ActivityListener mNavigationActivityMonitor = (displayId, activity) -> { if (displayId != mNavigationDisplay.mDisplayId) { return; } mClusterViewModel.setCurrentNavigationActivity(activity); }; /** * On user switch the navigation application must be re-launched on the new user. Otherwise * the navigation fragment will keep showing the application on the previous user. * {@link MainClusterActivity} is shared between all users (it is not restarted on user switch) */ private class UserReceiver extends BroadcastReceiver { void register(Context context) { IntentFilter intentFilter = new IntentFilter(ACTION_USER_UNLOCKED); context.registerReceiverForAllUsers(this, intentFilter, null, null); } void unregister(Context context) { context.unregisterReceiver(this); } @Override public void onReceive(Context context, Intent intent) { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Broadcast received: " + intent); } tryLaunchNavigationActivity(); } } private final BroadcastReceiver mScreenOffReceiver = new BroadcastReceiver(){ @Override public void onReceive(final Context context, final Intent intent) { if (!intent.getAction().equals(Intent.ACTION_SCREEN_OFF)){ return; } if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "ACTION_SCREEN_OFF"); } mNavStateController.hideNavigationStateInfo(); } }; private final BroadcastReceiver mUserPresentReceiver = new BroadcastReceiver(){ @Override public void onReceive(final Context context, final Intent intent) { if (!intent.getAction().equals(Intent.ACTION_USER_PRESENT)) { return; } if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "ACTION_USER_PRESENT"); } mNavStateController.showNavigationStateInfo(); } }; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Log.d(TAG, "onCreate"); setContentView(R.layout.activity_main); mInputMethodManager = getSystemService(InputMethodManager.class); Intent intent = new Intent(this, ClusterRenderingService.class); intent.setAction(LOCAL_BINDING_ACTION); bindServiceAsUser(intent, mClusterRenderingServiceConnection, 0, UserHandle.SYSTEM); registerFacet(new Facet<>(findViewById(R.id.btn_nav), NAV_FACET_ID, NavigationFragment.class)); registerFacet(new Facet<>(findViewById(R.id.btn_phone), COMMS_FACET_ID, PhoneFragment.class)); registerFacet(new Facet<>(findViewById(R.id.btn_music), MEDIA_FACET_ID, MusicFragment.class)); registerFacet(new Facet<>(findViewById(R.id.btn_car_info), INFO_FACET_ID, CarInfoFragment.class)); registerGear(findViewById(R.id.gear_parked), Sensors.Gear.PARK); registerGear(findViewById(R.id.gear_reverse), Sensors.Gear.REVERSE); registerGear(findViewById(R.id.gear_neutral), Sensors.Gear.NEUTRAL); registerGear(findViewById(R.id.gear_drive), Sensors.Gear.DRIVE); mPager = findViewById(R.id.pager); mPager.setAdapter(new ClusterPageAdapter(getSupportFragmentManager())); mOrderToFacet.get(NAV_FACET_ID).mButton.requestFocus(); mNavStateController = new NavStateController(findViewById(R.id.navigation_state)); IntentFilter screenOffFilter = new IntentFilter(); screenOffFilter.addAction(ACTION_SCREEN_OFF); registerReceiver(mScreenOffReceiver, screenOffFilter); IntentFilter userPresentFilter = new IntentFilter(); userPresentFilter.addAction(ACTION_USER_PRESENT); registerReceiver(mUserPresentReceiver, userPresentFilter); mClusterViewModel = new ViewModelProvider(this).get(ClusterViewModel.class); mClusterViewModel.getNavigationFocus().observe(this, focus -> { if (!focus) { mNavStateController.update(null); } }); mClusterViewModel.getNavigationActivityState().observe(this, state -> { if (state == ClusterViewModel.NavigationActivityState.LOADING) { if (!mHandler.hasCallbacks(mRetryLaunchNavigationActivity)) { mHandler.postDelayed(mRetryLaunchNavigationActivity, NAVIGATION_ACTIVITY_RELAUNCH_DELAY_MS); } } else { mHandler.removeCallbacks(mRetryLaunchNavigationActivity); } }); mClusterViewModel.getSensor(Sensors.SENSOR_GEAR).observe(this, this::updateSelectedGear); registerSensor(findViewById(R.id.info_fuel), mClusterViewModel.getFuelLevel()); registerSensor(findViewById(R.id.info_speed), mClusterViewModel.getSpeed()); registerSensor(findViewById(R.id.info_range), mClusterViewModel.getRange()); registerSensor(findViewById(R.id.info_rpm), mClusterViewModel.getRPM()); mActivityMonitor.start(); mUserReceiver.register(this); InMemoryPhoneBook.init(this); PhoneFragmentViewModel phoneViewModel = new ViewModelProvider(this).get( PhoneFragmentViewModel.class); phoneViewModel.setPhoneStateCallback(new PhoneFragmentViewModel.PhoneStateCallback() { @Override public void onCall() { if (mPager.getCurrentItem() != COMMS_FACET_ID) { mPreviousFacet = mPager.getCurrentItem(); } mOrderToFacet.get(COMMS_FACET_ID).mButton.requestFocus(); } @Override public void onDisconnect() { if (mPreviousFacet != COMMS_FACET_ID) { mOrderToFacet.get(mPreviousFacet).mButton.requestFocus(); } } }); } private void registerSensor(TextView textView, LiveData source) { String emptyValue = getString(R.string.info_value_empty); source.observe(this, value -> { // Need to check that the text is actually different, or else // it will generate a bunch of CONTENT_CHANGE_TYPE_TEXT accessability // actions. This will cause cts tests to fail when they waitForIdle(), // and the system never idles because it's constantly updating these // TextViews if (value != null && !value.toString().contentEquals(textView.getText())) { textView.setText(value.toString()); } if (value == null && !emptyValue.contentEquals(textView.getText())) { textView.setText(emptyValue); } }); } @Override protected void onDestroy() { super.onDestroy(); Log.d(TAG, "onDestroy"); mUserReceiver.unregister(this); mActivityMonitor.stop(); if (mService != null) { mService.unregisterClient(this); mService = null; } unbindService(mClusterRenderingServiceConnection); unregisterReceiver(mScreenOffReceiver); unregisterReceiver(mUserPresentReceiver); } @Override public void onKeyEvent(KeyEvent event) { Log.i(TAG, "onKeyEvent, event: " + event); // This is a hack. We use SOURCE_CLASS_POINTER here because this type of input is associated // with the display. otherwise this event will be ignored in ViewRootImpl because injecting // KeyEvent w/o activity being focused is useless. event.setSource(event.getSource() | InputDevice.SOURCE_CLASS_POINTER); mInputMethodManager.dispatchKeyEventFromInputMethod(getCurrentFocus(), event); } @Override public void onNavigationStateChange(NavigationStateProto state) { Log.d(TAG, "onNavigationStateChange: " + state); if (mNavStateController != null) { mNavStateController.update(state); } } public void updateNavDisplay(VirtualDisplay virtualDisplay) { // Starting the default navigation activity. This activity will be shown when navigation // focus is not taken. startNavigationActivity(virtualDisplay); // Notify the service (so it updates display properties on car service) if (mService == null) { // Service is not bound yet. Hold the information and notify when the service is bound. mPendingVirtualDisplay = virtualDisplay; return; } else { reportNavDisplay(virtualDisplay); } } private void reportNavDisplay(VirtualDisplay virtualDisplay) { mService.setActivityLaunchOptions(virtualDisplay.mDisplayId, ClusterActivityState .create(virtualDisplay.mDisplayId != Display.INVALID_DISPLAY, virtualDisplay.mUnobscuredBounds)); } public class ClusterPageAdapter extends FragmentPagerAdapter { public ClusterPageAdapter(FragmentManager fm) { super(fm); } @Override public int getCount() { return mButtonToFacet.size(); } @Override public Fragment getItem(int position) { return mOrderToFacet.get(position).getOrCreateFragment(); } } private void registerFacet(Facet facet) { mOrderToFacet.append(facet.mOrder, facet); mButtonToFacet.put(facet.mButton, facet); facet.mButton.setOnFocusChangeListener(mFacetButtonFocusListener); } private static class Facet { Button mButton; Class mClazz; int mOrder; Facet(Button button, int order, Class clazz) { this.mButton = button; this.mOrder = order; this.mClazz = clazz; } private Fragment mFragment; Fragment getOrCreateFragment() { if (mFragment == null) { try { mFragment = (Fragment) mClazz.getConstructors()[0].newInstance(); } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) { throw new RuntimeException(e); } } return mFragment; } } private void startNavigationActivity(VirtualDisplay virtualDisplay) { mActivityMonitor.removeListener(mNavigationDisplay.mDisplayId, mNavigationActivityMonitor); mActivityMonitor.addListener(virtualDisplay.mDisplayId, mNavigationActivityMonitor); mNavigationDisplay = virtualDisplay; tryLaunchNavigationActivity(); } /** * Tries to start a default navigation activity in the cluster. During system initialization * launching user activities might fail due the system not being ready or {@link PackageManager} * not being able to resolve the implicit intent. It is also possible that the system doesn't * have a default navigation activity selected yet. */ private void tryLaunchNavigationActivity() { if (mNavigationDisplay.mDisplayId == NO_DISPLAY) { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, String.format("Launch activity ignored (no display yet)")); } // Not ready to launch yet. return; } mHandler.removeCallbacks(mRetryLaunchNavigationActivity); ActivityInfo activityInfo = getNavigationActivity(this); ComponentName navigationActivity = new ComponentName(activityInfo.packageName, activityInfo.name); int userId = (activityInfo.flags & ActivityInfo.FLAG_SHOW_FOR_ALL_USERS) != 0 ? UserHandle.USER_SYSTEM : ActivityManager.getCurrentUser(); mClusterViewModel.setFreeNavigationActivity(navigationActivity); try { ClusterActivityState activityState = ClusterActivityState .create(true, mNavigationDisplay.mUnobscuredBounds); Intent intent = new Intent(Intent.ACTION_MAIN) .setComponent(navigationActivity) .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) .putExtra(Car.CAR_EXTRA_CLUSTER_ACTIVITY_STATE, activityState.toBundle()); Log.d(TAG, "Launching: " + intent + " on display" + mNavigationDisplay.mDisplayId + " as user" + userId); ActivityOptions activityOptions = ActivityOptions.makeBasic() .setLaunchDisplayId(mNavigationDisplay.mDisplayId); mService.startFixedActivityModeForDisplayAndUser(intent, activityOptions, userId); } catch (ActivityNotFoundException ex) { // Some activities might not be available right on startup. We will retry. mHandler.postDelayed(mRetryLaunchNavigationActivity, NAVIGATION_ACTIVITY_RETRY_INTERVAL_MS); } catch (Exception ex) { Log.e(TAG, "Unable to start navigation activity: " + navigationActivity, ex); } } /** * Returns a default navigation activity to show in the cluster. * In the current implementation we obtain this activity from an intent defined in a resources * file (which OEMs can overlay). * When it fails to find, parse or resolve the activity, it'll throw ActivityNotFoundException. */ static @NonNull ActivityInfo getNavigationActivity(Context context) { PackageManager pm = context.getPackageManager(); String intentString = context.getString(R.string.freeNavigationIntent); if (intentString == null) { throw new ActivityNotFoundException("No free navigation activity defined"); } Log.i(TAG, "Free navigation intent: " + intentString); try { Intent intent = Intent.parseUri(intentString, Intent.URI_INTENT_SCHEME); ResolveInfo navigationApp = pm.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY); if (navigationApp == null) { throw new ActivityNotFoundException("Can't resolve freeNavigationIntent"); } return navigationApp.activityInfo; } catch (URISyntaxException ex) { throw new ActivityNotFoundException("Unable to parse freeNavigationIntent"); } } private void registerGear(View view, Sensors.Gear gear) { mGearsToIcon.put(gear, view); } private void updateSelectedGear(Sensors.Gear gear) { for (Map.Entry entry : mGearsToIcon.entrySet()) { entry.getValue().setSelected(entry.getKey() == gear); } } }