Snap for 10453938 from efd17e939480612943ec8ed4373c15258b0a02ff to mainline-odp-release

Change-Id: Id9ddeeedfd00b2f9a6dbedd80c4e4142cf07a779
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index cdae76d..030c955 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -67,6 +67,7 @@
                 <action android:name="android.media.browse.MediaBrowserService" />
                 <action android:name="android.car.intent.action.PLAY_BROADCASTRADIO" />
             </intent-filter>
+            <meta-data android:name="androidx.car.app.launchable" android:value="false"/>
         </service>
     </application>
 </manifest>
diff --git a/OWNERS b/OWNERS
index 679960d..f0d59d4 100644
--- a/OWNERS
+++ b/OWNERS
@@ -1,6 +1,7 @@
 # Default code reviewers picked from top 3 or more developers.
 # Please update this list if you find better candidates.
-twasilczyk@google.com
-sdai@google.com
+xuweilin@google.com
+oscarazu@google.com
 ajchen@google.com
 arnaudberry@google.com
+keunyoung@google.com
diff --git a/res/layout/radio_activity.xml b/res/layout/radio_activity.xml
index bd92a9a..cf2b4df 100644
--- a/res/layout/radio_activity.xml
+++ b/res/layout/radio_activity.xml
@@ -55,5 +55,6 @@
          android:layout_marginEnd="@dimen/playback_card_margin_horizontal"
          app:layout_constraintBottom_toBottomOf="parent"
          app:layout_constraintEnd_toEndOf="parent"
-         app:layout_constraintStart_toStartOf="parent" />
+         app:layout_constraintStart_toStartOf="parent"
+         style="@style/RadioPlaybackCard"/>
 </androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/res/values/overlayable.xml b/res/values/overlayable.xml
new file mode 100644
index 0000000..66d082f
--- /dev/null
+++ b/res/values/overlayable.xml
@@ -0,0 +1,50 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<!-- Copyright (C) 2022 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.-->
+<!--
+THIS FILE WAS AUTO GENERATED, DO NOT EDIT MANUALLY.
+REGENERATE USING packages/apps/Car/libs/tools/rro/generate-overlayable.py
+-->
+<resources>
+  <overlayable name="CarRadio">
+    <policy type="system|product|signature">
+      <item type="color" name="accent_color"/>
+      <item type="color" name="band_selector_flat_text_color"/>
+      <item type="color" name="band_selector_flat_text_color_selected"/>
+      <item type="color" name="browse_fragment_list_color"/>
+      <item type="color" name="browse_item_text_color"/>
+      <item type="color" name="control_button_color"/>
+      <item type="color" name="control_button_disabled_color"/>
+      <item type="color" name="control_button_ripple_color"/>
+      <item type="color" name="manual_tuner_button_background_color"/>
+      <item type="color" name="manual_tuner_button_ripple_background_color"/>
+      <item type="color" name="manual_tuner_button_ripple_fill_color"/>
+      <item type="color" name="manual_tuner_button_text_color"/>
+      <item type="color" name="manual_tuner_digit_color"/>
+      <item type="color" name="manual_tuner_done_disabled_color"/>
+      <item type="color" name="pause_button_fill_color"/>
+      <item type="color" name="play_arrow_fill_color"/>
+      <item type="color" name="play_arrow_off_fill_color"/>
+      <item type="color" name="playback_button_ripple_background_color"/>
+      <item type="color" name="playback_button_ripple_fill_color"/>
+      <item type="color" name="playback_card_background_color"/>
+      <item type="color" name="playback_controls_play_button_color"/>
+      <item type="color" name="radio_am_fill_color"/>
+      <item type="color" name="radio_card_color"/>
+      <item type="color" name="radio_fm_fill_color"/>
+      <item type="dimen" name="playback_card_height"/>
+      <item type="dimen" name="playback_card_margin_bottom"/>
+      <item type="dimen" name="playback_card_margin_horizontal"/>
+      <item type="dimen" name="radio_activity_playback_width"/>
+      <item type="style" name="RadioPlaybackCard"/>
+    </policy>
+  </overlayable>
+</resources>
diff --git a/res/values/styles.xml b/res/values/styles.xml
index 20001dc..ffef549 100644
--- a/res/values/styles.xml
+++ b/res/values/styles.xml
@@ -55,4 +55,7 @@
         <item name="android:background">@drawable/radio_control_background</item>
         <item name="android:textAppearance">?android:attr/textAppearanceLarge</item>
     </style>
+
+    <style name="RadioPlaybackCard" />
+
 </resources>
diff --git a/src/com/android/car/radio/RadioActivity.java b/src/com/android/car/radio/RadioActivity.java
index da8eb30..a9da2be 100644
--- a/src/com/android/car/radio/RadioActivity.java
+++ b/src/com/android/car/radio/RadioActivity.java
@@ -32,6 +32,7 @@
 import com.android.car.media.common.source.MediaTrampolineHelper;
 import com.android.car.radio.bands.ProgramType;
 import com.android.car.radio.service.RadioAppService;
+import com.android.car.radio.service.RadioAppServiceWrapper;
 import com.android.car.radio.util.Log;
 import com.android.car.ui.baselayout.Insets;
 import com.android.car.ui.baselayout.InsetsChangedListener;
@@ -39,7 +40,7 @@
 import com.android.car.ui.toolbar.TabLayout;
 import com.android.car.ui.toolbar.ToolbarController;
 
-import java.util.Arrays;
+import java.util.ArrayList;
 import java.util.List;
 
 /**
@@ -60,9 +61,11 @@
     private static final String EXTRA_RADIO_APP_FOREGROUND =
             "android.intent.action.RADIO_APP_STATE";
 
+    private boolean mIsConnected;
     private RadioController mRadioController;
     private BandController mBandController = new BandController();
     private ToolbarController mToolbar;
+    private ViewPager mViewPager;
     private RadioPagerAdapter mRadioPagerAdapter;
 
     private boolean mUseSourceLogoForAppSelector;
@@ -79,13 +82,16 @@
     protected void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
 
-        Log.d(TAG, "Radio app main activity created");
+        Log.i(TAG, "Radio app main activity created");
 
         setContentView(R.layout.radio_activity);
 
         mMediaTrampoline = new MediaTrampolineHelper(this);
 
         mRadioController = new RadioController(this);
+
+        mRadioController.getConnectionState().observe(this, this::onConnectionStateChanged);
+
         mRadioController.getCurrentProgram().observe(this, info -> {
             ProgramType programType = ProgramType.fromSelector(info.getSelector());
             if (programType != null) {
@@ -96,8 +102,7 @@
 
         mRadioPagerAdapter =
                 new RadioPagerAdapter(this, getSupportFragmentManager(), mRadioController);
-        ViewPager viewPager = findViewById(R.id.viewpager);
-        viewPager.setAdapter(mRadioPagerAdapter);
+        mViewPager = findViewById(R.id.viewpager);
 
         mUseSourceLogoForAppSelector =
                 getResources().getBoolean(R.bool.use_media_source_logo_for_app_selector);
@@ -108,11 +113,18 @@
         if (!mUseSourceLogoForAppSelector) {
             mToolbar.setLogo(R.drawable.logo_fm_radio);
         }
-        mToolbar.registerOnTabSelectedListener(t ->
-                viewPager.setCurrentItem(mToolbar.getTabPosition(t)));
+        mToolbar.registerOnTabSelectedListener(t -> {
+            if (mIsConnected) {
+                mViewPager.setCurrentItem(mToolbar.getTabPosition(t));
+            }
+        });
 
-        updateMenuItems();
-        setupTabsWithViewPager(viewPager);
+        mViewPager.addOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() {
+            @Override
+            public void onPageSelected(int position) {
+                mToolbar.selectTab(position);
+            }
+        });
     }
 
     @Override
@@ -202,13 +214,16 @@
         mBandController.setSupportedProgramTypes(supported);
     }
 
-    private void setupTabsWithViewPager(ViewPager viewPager) {
-        viewPager.addOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() {
-            @Override
-            public void onPageSelected(int position) {
-                mToolbar.selectTab(position);
-            }
-        });
+    private void onConnectionStateChanged(@RadioAppServiceWrapper.ConnectionState int state) {
+        if (state == RadioAppServiceWrapper.STATE_CONNECTED) {
+            mIsConnected = true;
+            mViewPager.setAdapter(mRadioPagerAdapter);
+        } else {
+            mIsConnected = false;
+            mViewPager.setAdapter(null);
+        }
+        Log.i(TAG, "onConnectionStateChanged connected: " + mIsConnected);
+        updateMenuItems();
         updateTabs();
     }
 
@@ -230,15 +245,22 @@
                 .setOnClickListener(m -> startActivity(appSelectorIntent))
                 .build();
 
-        mToolbar.setMenuItems(Arrays.asList(bandSelectorMenuItem, appSelectorMenuItem));
+        ArrayList<MenuItem> menuItems = new ArrayList<>(2);
+        if (mIsConnected) {
+            menuItems.add(bandSelectorMenuItem);
+        }
+        menuItems.add(appSelectorMenuItem);
+        mToolbar.setMenuItems(menuItems);
     }
 
     private void updateTabs() {
         mToolbar.clearAllTabs();
-        for (int i = 0; i < mRadioPagerAdapter.getCount(); i++) {
-            Drawable icon = mRadioPagerAdapter.getPageIcon(i);
-            CharSequence title = mRadioPagerAdapter.getPageTitle(i);
-            mToolbar.addTab(new TabLayout.Tab(icon, title));
+        if (mIsConnected) {
+            for (int i = 0; i < mRadioPagerAdapter.getCount(); i++) {
+                Drawable icon = mRadioPagerAdapter.getPageIcon(i);
+                CharSequence title = mRadioPagerAdapter.getPageTitle(i);
+                mToolbar.addTab(new TabLayout.Tab(icon, title));
+            }
         }
     }
 }
diff --git a/src/com/android/car/radio/RadioController.java b/src/com/android/car/radio/RadioController.java
index 6244847..a034aaa 100644
--- a/src/com/android/car/radio/RadioController.java
+++ b/src/com/android/car/radio/RadioController.java
@@ -28,8 +28,10 @@
 
 import com.android.car.broadcastradio.support.Program;
 import com.android.car.broadcastradio.support.platform.ProgramInfoExt;
+import com.android.car.broadcastradio.support.platform.ProgramSelectorExt;
 import com.android.car.radio.bands.ProgramType;
 import com.android.car.radio.bands.RegionConfig;
+import com.android.car.radio.media.TunerSession;
 import com.android.car.radio.service.RadioAppService;
 import com.android.car.radio.service.RadioAppServiceWrapper;
 import com.android.car.radio.service.RadioAppServiceWrapper.ConnectionState;
@@ -94,6 +96,14 @@
     }
 
     /**
+     * See {@link RadioAppServiceWrapper#getConnectionState}.
+     */
+    @NonNull
+    public LiveData<Integer> getConnectionState() {
+        return mAppService.getConnectionState();
+    }
+
+    /**
      * See {@link RadioAppServiceWrapper#getPlaybackState}.
      */
     @NonNull
@@ -169,7 +179,7 @@
             mDisplayController.setChannel(sel);
 
             mDisplayController.setStationName(
-                    ProgramInfoExt.getProgramName(info, ProgramInfoExt.NAME_NO_CHANNEL_FALLBACK));
+                    ProgramSelectorExt.getDisplayName(sel, info.getChannel()));
 
             if (meta.containsKey(RadioMetadata.METADATA_KEY_TITLE)
                     || meta.containsKey(RadioMetadata.METADATA_KEY_ARTIST)) {
@@ -177,7 +187,8 @@
                         meta.getString(RadioMetadata.METADATA_KEY_TITLE),
                         meta.getString(RadioMetadata.METADATA_KEY_ARTIST));
             } else {
-                mDisplayController.setDetails(meta.getString(RadioMetadata.METADATA_KEY_RDS_RT));
+                mDisplayController.setDetails(ProgramInfoExt.getProgramName(info, /* flags= */ 0,
+                        TunerSession.PROGRAM_NAME_ORDER));
             }
 
             mDisplayController.setCurrentIsFavorite(mRadioStorage.isFavorite(sel));
diff --git a/src/com/android/car/radio/bands/AMFMProgramType.java b/src/com/android/car/radio/bands/AMFMProgramType.java
index 4b9f202..46a4ff3 100644
--- a/src/com/android/car/radio/bands/AMFMProgramType.java
+++ b/src/com/android/car/radio/bands/AMFMProgramType.java
@@ -59,17 +59,19 @@
          */
         Random rnd = new Random();
         BandDescriptor band = bands.get(rnd.nextInt(bands.size()));
-        int freq = rnd.nextInt(band.getUpperLimit() - band.getLowerLimit()) + band.getLowerLimit();
-        freq /= band.getSpacing();
-        freq *= band.getSpacing();
+        int freq = rnd.nextInt(band.getUpperLimit() - band.getLowerLimit()) / band.getSpacing()
+                * band.getSpacing() + band.getLowerLimit();
+        Log.i(TAG, "Tuning to default frequency: " + freq);
 
         // tune to that frequency and seek forward, to find any station
         tuner.tune(ProgramSelectorExt.createAmFmSelector(freq), succeeded -> {
             if (!succeeded) {
                 result.onFinished(false);
+                Log.w(TAG, "Cannot tune to default frequency" + freq);
                 return;
             }
             tuner.seek(true, result);
+            Log.i(TAG, "Tuned to default frequency " + freq + " successfully");
         });
     }
 
diff --git a/src/com/android/car/radio/media/TunerSession.java b/src/com/android/car/radio/media/TunerSession.java
index 3d9c70c..438fd3f 100644
--- a/src/com/android/car/radio/media/TunerSession.java
+++ b/src/com/android/car/radio/media/TunerSession.java
@@ -19,6 +19,8 @@
 import android.content.Context;
 import android.hardware.radio.ProgramSelector;
 import android.hardware.radio.RadioManager.ProgramInfo;
+import android.hardware.radio.RadioMetadata;
+import android.media.MediaMetadata;
 import android.media.Rating;
 import android.media.session.MediaController;
 import android.media.session.MediaSession;
@@ -62,6 +64,20 @@
             new PlaybackState.Builder();
     @Nullable private ProgramInfo mCurrentProgram;
 
+    /**
+     * Custom order that puts RDS_RT ahead of RDS_PS.
+     * RDS_PS is often used to scroll data in RDS_RT in some regions, and for the interests of
+     * driver distration we want to prevent RDS_PS scrolling from updating UI so frequently.
+     */
+    public static final String[] PROGRAM_NAME_ORDER = new String[] {
+            RadioMetadata.METADATA_KEY_PROGRAM_NAME,
+            RadioMetadata.METADATA_KEY_DAB_COMPONENT_NAME,
+            RadioMetadata.METADATA_KEY_DAB_SERVICE_NAME,
+            RadioMetadata.METADATA_KEY_DAB_ENSEMBLE_NAME,
+            RadioMetadata.METADATA_KEY_RDS_RT,
+            RadioMetadata.METADATA_KEY_RDS_PS
+    };
+
     public TunerSession(@NonNull Context context, @NonNull BrowseTree browseTree,
             @NonNull RadioAppServiceWrapper appService, @Nullable ImageResolver imageResolver) {
         mSession = new MediaSession(context, TAG);
@@ -107,7 +123,12 @@
         synchronized (mLock) {
             if (info == null) return;
             boolean fav = mRadioStorage.isFavorite(info.getSelector());
-            mSession.setMetadata(ProgramInfoExt.toMediaMetadata(info, fav, mImageResolver));
+            MediaMetadata currMetaData = mSession.getController().getMetadata();
+            MediaMetadata newMetaData = ProgramInfoExt.toMediaDisplayMetadata(info, fav,
+                    mImageResolver, PROGRAM_NAME_ORDER);
+            if (!Objects.equals(currMetaData, newMetaData)) {
+                mSession.setMetadata(newMetaData);
+            }
         }
     }
 
diff --git a/src/com/android/car/radio/platform/ImageMemoryCache.java b/src/com/android/car/radio/platform/ImageMemoryCache.java
index 145e340..9e4bcda 100644
--- a/src/com/android/car/radio/platform/ImageMemoryCache.java
+++ b/src/com/android/car/radio/platform/ImageMemoryCache.java
@@ -18,27 +18,43 @@
 
 import android.graphics.Bitmap;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 
 import com.android.car.broadcastradio.support.platform.ImageResolver;
+import com.android.internal.annotations.GuardedBy;
 
 import java.util.LinkedHashMap;
 import java.util.Map;
 import java.util.Objects;
 
-public class ImageMemoryCache implements ImageResolver {
+/**
+ * Resolves metadata image cache for specific {@link RadioManagerExt}
+ */
+public final class ImageMemoryCache implements ImageResolver {
+    private final Object mLock = new Object();
+
     private final RadioManagerExt mRadioManager;
+
+    @GuardedBy("mLock")
     private final Map<Long, Bitmap> mCache;
 
-    public ImageMemoryCache(@NonNull RadioManagerExt radioManager, int cacheSize) {
-        mRadioManager = Objects.requireNonNull(radioManager);
+    public ImageMemoryCache(RadioManagerExt radioManager, int cacheSize) {
+        mRadioManager = Objects.requireNonNull(radioManager, "RadioManager cannot be null");
         mCache = new CacheMap<>(cacheSize);
     }
 
-    public @Nullable Bitmap resolve(long globalId) {
-        synchronized (mCache) {
-            if (mCache.containsKey(globalId)) return mCache.get(globalId);
+    /**
+     * Gets metadata image cache
+     *
+     * @param globalId Metadata image id
+     * @return Metadata image
+     */
+    @Nullable
+    public Bitmap resolve(long globalId) {
+        synchronized (mLock) {
+            if (mCache.containsKey(globalId)) {
+                return mCache.get(globalId);
+            }
 
             Bitmap bm = mRadioManager.getMetadataImage(globalId);
             mCache.put(globalId, bm);
@@ -50,7 +66,9 @@
         private final int mMaxSize;
 
         public CacheMap(int maxSize) {
-            if (maxSize < 0) throw new IllegalArgumentException("maxSize must not be negative");
+            if (maxSize < 0) {
+                throw new IllegalArgumentException("maxSize must not be negative");
+            }
             mMaxSize = maxSize;
         }
 
diff --git a/src/com/android/car/radio/platform/RadioManagerExt.java b/src/com/android/car/radio/platform/RadioManagerExt.java
index 01125f8..32d854e 100644
--- a/src/com/android/car/radio/platform/RadioManagerExt.java
+++ b/src/com/android/car/radio/platform/RadioManagerExt.java
@@ -23,16 +23,16 @@
 import android.hardware.radio.RadioTuner;
 import android.os.Handler;
 import android.os.HandlerThread;
+import android.util.ArrayMap;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 
 import com.android.car.broadcastradio.support.platform.RadioMetadataExt;
 import com.android.car.radio.util.Log;
+import com.android.internal.annotations.GuardedBy;
 
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
@@ -43,33 +43,46 @@
  *
  * They might eventually get pushed to the framework.
  */
-public class RadioManagerExt {
+public final class RadioManagerExt {
     private static final String TAG = "BcRadioApp.mgrext";
 
     // For now, we open first radio module only.
     private static final int HARDCODED_MODULE_INDEX = 0;
 
+    private static final long SHIFT_FOR_MODULE_ID = 32;
+    private static final long MASK_FOR_LOCAL_ID = 0xFFFFFFFF;
+
+    // This won't be necessary when we push this code to the framework,
+    // as we really need only module references.
+    private static Map<Integer, RadioTuner> sSessions = new ArrayMap<>();
+
     private final Object mLock = new Object();
     private final Context mContext;
 
     private final HandlerThread mCallbackHandlerThread = new HandlerThread("BcRadioApp.cbhandler");
 
-    private final @NonNull RadioManager mRadioManager;
-    private List<RadioManager.ModuleProperties> mModules;
-    private @Nullable List<BandDescriptor> mAmFmRegionConfig;
+    private final RadioManager mRadioManager;
 
-    public RadioManagerExt(@NonNull Context ctx) {
-        mContext = Objects.requireNonNull(ctx);
-        mRadioManager = (RadioManager)ctx.getSystemService(Context.RADIO_SERVICE);
+    @GuardedBy("mLock")
+    private List<RadioManager.ModuleProperties> mModules;
+
+    @GuardedBy("mLock")
+    @Nullable private List<BandDescriptor> mAmFmRegionConfig;
+
+    public RadioManagerExt(Context ctx) {
+        mContext = Objects.requireNonNull(ctx, "Context cannot be null");
+        mRadioManager = ctx.getSystemService(RadioManager.class);
         Objects.requireNonNull(mRadioManager, "RadioManager could not be loaded");
         mCallbackHandlerThread.start();
     }
 
-    /* Select only one region. HAL 2.x moves region selection responsibility from the app to the
-     * Broadcast Radio service, so we won't implement region selection based on bands in the app.
-     */
-    private @Nullable List<BandDescriptor> reduceAmFmBands(@Nullable BandDescriptor[] bands) {
-        if (bands == null || bands.length == 0) return null;
+    // Select only one region. HAL 2.x moves region selection responsibility from the app to the
+    // Broadcast Radio service, so we won't implement region selection based on bands in the app.
+    @Nullable
+    private List<BandDescriptor> reduceAmFmBands(@Nullable BandDescriptor[] bands) {
+        if (bands == null || bands.length == 0) {
+            return null;
+        }
         int region = bands[0].getRegion();
         Log.d(TAG, "Auto-selecting region " + region);
 
@@ -77,25 +90,26 @@
                 collect(Collectors.toList());
     }
 
-    private void initModules() {
-        synchronized (mLock) {
-            if (mModules != null) return;
-
-            mModules = new ArrayList<>();
-            int status = mRadioManager.listModules(mModules);
-            if (status != RadioManager.STATUS_OK) {
-                Log.w(TAG, "Couldn't get radio module list: " + status);
-                return;
-            }
-
-            if (mModules.size() == 0) {
-                Log.i(TAG, "No radio modules on this device");
-                return;
-            }
-
-            RadioManager.ModuleProperties module = mModules.get(HARDCODED_MODULE_INDEX);
-            mAmFmRegionConfig = reduceAmFmBands(module.getBands());
+    @GuardedBy("mLock")
+    private void initModulesLocked() {
+        if (mModules != null) {
+            return;
         }
+
+        mModules = new ArrayList<>();
+        int status = mRadioManager.listModules(mModules);
+        if (status != RadioManager.STATUS_OK) {
+            Log.w(TAG, "Couldn't get radio module list: " + status);
+            return;
+        }
+
+        if (mModules.size() == 0) {
+            Log.i(TAG, "No radio modules on this device");
+            return;
+        }
+
+        RadioManager.ModuleProperties moduleProperties = mModules.get(HARDCODED_MODULE_INDEX);
+        mAmFmRegionConfig = reduceAmFmBands(moduleProperties.getBands());
     }
 
     /**
@@ -105,31 +119,38 @@
      * @param handler The Handler on which the callbacks will be received,
      *        {@code null} for default handler.
      */
-    public @Nullable RadioTunerExt openSession(RadioTuner.Callback callback, Handler handler) {
+    @Nullable
+    public RadioTunerExt openSession(RadioTuner.Callback callback, Handler handler) {
         Log.i(TAG, "Opening broadcast radio session...");
 
-        initModules();
-        if (mModules.size() == 0) return null;
+        RadioManager.ModuleProperties moduleProperties;
+        synchronized (mLock) {
+            initModulesLocked();
+            if (mModules.size() == 0) {
+                return null;
+            }
+            moduleProperties = mModules.get(HARDCODED_MODULE_INDEX);
+        }
 
-        /* We won't need custom default wrapper when we push these proposed extensions to the
-         * framework; this is solely to avoid deadlock on onConfigurationChanged callback versus
-         * waitForInitialization.
-         */
+        // We won't need custom default wrapper when we push these proposed extensions to the
+        // framework; this is solely to avoid deadlock on onConfigurationChanged callback versus
+        // waitForInitialization.
         Handler hwHandler = new Handler(mCallbackHandlerThread.getLooper());
 
-        RadioManager.ModuleProperties module = mModules.get(HARDCODED_MODULE_INDEX);
         TunerCallbackAdapterExt cbExt = new TunerCallbackAdapterExt(callback, handler);
 
         RadioTuner tuner = mRadioManager.openTuner(
-                module.getId(),
-                null,  // BandConfig - let the service automatically select one.
-                true,  // withAudio
+                moduleProperties.getId(),
+                /* config= */ null,
+                /* withAudio= */ true,
                 cbExt, hwHandler);
-        mSessions.put(module.getId(), tuner);
-        if (tuner == null) return null;
-        RadioMetadataExt.setModuleId(module.getId());
+        sSessions.put(moduleProperties.getId(), tuner);
+        if (tuner == null) {
+            return null;
+        }
+        RadioMetadataExt.setModuleId(moduleProperties.getId());
 
-        if (module.isInitializationRequired()) {
+        if (moduleProperties.isInitializationRequired()) {
             if (!cbExt.waitForInitialization()) {
                 Log.w(TAG, "Timed out waiting for tuner initialization");
                 tuner.close();
@@ -140,23 +161,40 @@
         return new RadioTunerExt(mContext, tuner, cbExt);
     }
 
-    public @Nullable List<BandDescriptor> getAmFmRegionConfig() {
-        initModules();
-        return mAmFmRegionConfig;
+    /**
+     * Gets AM/FM region configuration
+     *
+     * @return AM/FM region configuration
+     */
+    @Nullable
+    public List<BandDescriptor> getAmFmRegionConfig() {
+        List<BandDescriptor> amFmRegionConfig;
+        synchronized (mLock) {
+            initModulesLocked();
+            amFmRegionConfig = mAmFmRegionConfig;
+        }
+        return amFmRegionConfig;
     }
 
-    /* This won't be necessary when we push this code to the framework,
-     * as we really need only module references. */
-    private static Map<Integer, RadioTuner> mSessions = new HashMap<>();
+    /**
+     * Gets metadata image
+     *
+     * @param globalId Global id of the metadata image
+     * @return Metadata image
+     */
+    @Nullable
+    public Bitmap getMetadataImage(long globalId) {
+        if (globalId == 0) {
+            return null;
+        }
 
-    public @Nullable Bitmap getMetadataImage(long globalId) {
-        if (globalId == 0) return null;
+        int moduleId = (int) (globalId >>> SHIFT_FOR_MODULE_ID);
+        int localId = (int) (globalId & MASK_FOR_LOCAL_ID);
 
-        int moduleId = (int)(globalId >>> 32);
-        int localId = (int)(globalId & 0xFFFFFFFF);
-
-        RadioTuner tuner = mSessions.get(moduleId);
-        if (tuner == null) return null;
+        RadioTuner tuner = sSessions.get(moduleId);
+        if (tuner == null) {
+            return null;
+        }
 
         return tuner.getMetadataImage(localId);
     }
diff --git a/src/com/android/car/radio/platform/RadioTunerExt.java b/src/com/android/car/radio/platform/RadioTunerExt.java
index bee6e92..d081d5e 100644
--- a/src/com/android/car/radio/platform/RadioTunerExt.java
+++ b/src/com/android/car/radio/platform/RadioTunerExt.java
@@ -27,10 +27,10 @@
 import android.media.HwAudioSource;
 import android.text.TextUtils;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 
 import com.android.car.radio.util.Log;
+import com.android.internal.annotations.GuardedBy;
 
 import java.util.Objects;
 import java.util.stream.Stream;
@@ -40,16 +40,19 @@
  *
  * They might eventually get pushed to the framework.
  */
-public class RadioTunerExt {
+public final class RadioTunerExt {
     private static final String TAG = "BcRadioApp.tunerext";
 
     private final Object mLock = new Object();
-    private final Object mTuneLock = new Object();
     private final RadioTuner mTuner;
 
+    @GuardedBy("mLock")
     private HwAudioSource mHwAudioSource;
 
+    @GuardedBy("mLock")
     @Nullable private ProgramSelector mOperationSelector;  // null for seek operations
+
+    @GuardedBy("mLock")
     @Nullable private TuneCallback mOperationResultCb;
 
     /**
@@ -66,7 +69,7 @@
         /**
          * Chains other result callbacks.
          */
-        default TuneCallback alsoCall(@NonNull TuneCallback other) {
+        default TuneCallback alsoCall(TuneCallback other) {
             return succeeded -> {
                 onFinished(succeeded);
                 other.onFinished(succeeded);
@@ -74,13 +77,12 @@
         }
     }
 
-    RadioTunerExt(@NonNull Context context, @NonNull RadioTuner tuner,
-            @NonNull TunerCallbackAdapterExt cbExt) {
-        mTuner = Objects.requireNonNull(tuner);
+    RadioTunerExt(Context context, RadioTuner tuner, TunerCallbackAdapterExt cbExt) {
+        mTuner = Objects.requireNonNull(tuner, "Tuner cannot be null");
         cbExt.setTuneFailedCallback(this::onTuneFailed);
         cbExt.setProgramInfoCallback(this::onProgramInfoChanged);
 
-        final AudioDeviceInfo tunerDevice = findTunerDevice(context, null);
+        AudioDeviceInfo tunerDevice = findTunerDevice(context, /* address= */ null);
         if (tunerDevice == null) {
             Log.e(TAG, "No TUNER_DEVICE found on board");
         } else {
@@ -94,11 +96,11 @@
     }
 
     public boolean setMuted(boolean muted) {
-        if (mHwAudioSource == null) {
-            Log.e(TAG, "No TUNER_DEVICE found on board");
-            return false;
-        }
         synchronized (mLock) {
+            if (mHwAudioSource == null) {
+                Log.e(TAG, "No TUNER_DEVICE found on board when setting muted");
+                return false;
+            }
             if (muted) {
                 mHwAudioSource.stop();
             } else {
@@ -112,18 +114,16 @@
      * See {@link RadioTuner#scan}.
      */
     public void seek(boolean forward, @Nullable TuneCallback resultCb) {
-        synchronized (mTuneLock) {
-            synchronized (mLock) {
-                markOperationFinishedLocked(false);
-                mOperationResultCb = resultCb;
-            }
+        synchronized (mLock) {
+            markOperationFinishedLocked(/* succeeded= */ false);
+            mOperationResultCb = resultCb;
+        }
 
-            mTuner.cancel();
-            int res = mTuner.scan(
-                    forward ? RadioTuner.DIRECTION_UP : RadioTuner.DIRECTION_DOWN, false);
-            if (res != RadioManager.STATUS_OK) {
-                throw new RuntimeException("Seek failed with result of " + res);
-            }
+        int res = mTuner.scan(
+                forward ? RadioTuner.DIRECTION_UP : RadioTuner.DIRECTION_DOWN,
+                /* skipSubChannel= */ false);
+        if (res != RadioManager.STATUS_OK) {
+            throw new RuntimeException("Seek failed with result of " + res);
         }
     }
 
@@ -131,13 +131,14 @@
      * See {@link RadioTuner#step}.
      */
     public void step(boolean forward, @Nullable TuneCallback resultCb) {
-        synchronized (mTuneLock) {
-            markOperationFinishedLocked(false);
+        synchronized (mLock) {
+            markOperationFinishedLocked(/* succeeded= */ false);
             mOperationResultCb = resultCb;
         }
-        mTuner.cancel();
+
         int res =
-                mTuner.step(forward ? RadioTuner.DIRECTION_UP : RadioTuner.DIRECTION_DOWN, false);
+                mTuner.step(forward ? RadioTuner.DIRECTION_UP : RadioTuner.DIRECTION_DOWN,
+                        /* skipSubChannel= */ false);
         if (res != RadioManager.STATUS_OK) {
             throw new RuntimeException("Step failed with result of " + res);
         }
@@ -146,17 +147,14 @@
     /**
      * See {@link RadioTuner#tune}.
      */
-    public void tune(@NonNull ProgramSelector selector, @Nullable TuneCallback resultCb) {
-        synchronized (mTuneLock) {
-            synchronized (mLock) {
-                markOperationFinishedLocked(false);
-                mOperationSelector = selector;
-                mOperationResultCb = resultCb;
-            }
-
-            mTuner.cancel();
-            mTuner.tune(selector);
+    public void tune(ProgramSelector selector, @Nullable TuneCallback resultCb) {
+        synchronized (mLock) {
+            markOperationFinishedLocked(/* succeeded= */ false);
+            mOperationSelector = selector;
+            mOperationResultCb = resultCb;
         }
+
+        mTuner.tune(selector);
     }
 
     /**
@@ -176,8 +174,11 @@
         return null;
     }
 
+    @GuardedBy("mLock")
     private void markOperationFinishedLocked(boolean succeeded) {
-        if (mOperationResultCb == null) return;
+        if (mOperationResultCb == null) {
+            return;
+        }
 
         if (Log.isLoggable(TAG, Log.DEBUG)) {
             Log.d(TAG, "Tune operation for " + mOperationSelector
@@ -196,32 +197,41 @@
         }
     }
 
-    private boolean isMatching(@NonNull ProgramSelector currentOperation,
-            @NonNull ProgramSelector event) {
+    private boolean isMatching(ProgramSelector currentOperation, ProgramSelector event) {
         ProgramSelector.Identifier pri = currentOperation.getPrimaryId();
         return Stream.of(event.getAllIds(pri.getType())).anyMatch(id -> pri.equals(id));
     }
 
     private void onProgramInfoChanged(RadioManager.ProgramInfo info) {
         synchronized (mLock) {
-            if (mOperationResultCb == null) return;
+            if (mOperationResultCb == null) {
+                return;
+            }
             // if we're seeking, all program info chanes does match
             if (mOperationSelector != null) {
-                if (!isMatching(mOperationSelector, info.getSelector())) return;
+                if (!isMatching(mOperationSelector, info.getSelector())) {
+                    return;
+                }
             }
-            markOperationFinishedLocked(true);
+            markOperationFinishedLocked(/* succeeded= */ true);
         }
     }
 
     private void onTuneFailed(int result, @Nullable ProgramSelector selector) {
         synchronized (mLock) {
-            if (mOperationResultCb == null) return;
-            // if we're seeking and got a failed tune (or vice versa), that's a mismatch
-            if ((mOperationSelector == null) != (selector == null)) return;
-            if (mOperationSelector != null) {
-                if (!isMatching(mOperationSelector, selector)) return;
+            if (mOperationResultCb == null) {
+                return;
             }
-            markOperationFinishedLocked(false);
+            // if we're seeking and got a failed tune (or vice versa), that's a mismatch
+            if ((mOperationSelector == null) != (selector == null)) {
+                return;
+            }
+            if (mOperationSelector != null) {
+                if (!isMatching(mOperationSelector, selector)) {
+                    return;
+                }
+            }
+            markOperationFinishedLocked(/* succeeded= */ false);
         }
     }
 
@@ -229,34 +239,33 @@
      * See {@link RadioTuner#cancel}.
      */
     public void cancel() {
-        synchronized (mTuneLock) {
-            synchronized (mLock) {
-                markOperationFinishedLocked(false);
-            }
+        synchronized (mLock) {
+            markOperationFinishedLocked(/* succeeded= */ false);
+        }
 
-            int res = mTuner.cancel();
-            if (res != RadioManager.STATUS_OK) {
-                Log.e(TAG, "Cancel failed with result of " + res);
-            }
+        int res = mTuner.cancel();
+        if (res != RadioManager.STATUS_OK) {
+            Log.e(TAG, "Cancel failed with result of " + res);
         }
     }
 
     /**
      * See {@link RadioTuner#getDynamicProgramList}.
      */
-    public @Nullable ProgramList getDynamicProgramList(@Nullable ProgramList.Filter filter) {
+    @Nullable
+    public ProgramList getDynamicProgramList(@Nullable ProgramList.Filter filter) {
         return mTuner.getDynamicProgramList(filter);
     }
 
     public void close() {
         synchronized (mLock) {
-            markOperationFinishedLocked(false);
+            markOperationFinishedLocked(/* succeeded= */ false);
+            if (mHwAudioSource != null) {
+                mHwAudioSource.stop();
+                mHwAudioSource = null;
+            }
         }
 
-        if (mHwAudioSource != null) {
-            mHwAudioSource.stop();
-            mHwAudioSource = null;
-        }
         mTuner.close();
     }
 }
diff --git a/src/com/android/car/radio/platform/TunerCallbackAdapterExt.java b/src/com/android/car/radio/platform/TunerCallbackAdapterExt.java
index cdf8748..d04991e 100644
--- a/src/com/android/car/radio/platform/TunerCallbackAdapterExt.java
+++ b/src/com/android/car/radio/platform/TunerCallbackAdapterExt.java
@@ -23,7 +23,6 @@
 import android.os.Handler;
 import android.os.Looper;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 
 import com.android.car.radio.util.Log;
@@ -42,7 +41,7 @@
  *
  * They might eventually get pushed to the framework.
  */
-class TunerCallbackAdapterExt extends RadioTuner.Callback {
+final class TunerCallbackAdapterExt extends RadioTuner.Callback {
     private static final String TAG = "BcRadioApp.tunerext";
     private static final int INIT_TIMEOUT_MS = 10000;  // 10s
 
@@ -67,8 +66,8 @@
         void onProgramInfoChanged(RadioManager.ProgramInfo info);
     }
 
-    TunerCallbackAdapterExt(@NonNull RadioTuner.Callback callback, @Nullable Handler handler) {
-        mCallback = Objects.requireNonNull(callback);
+    TunerCallbackAdapterExt(RadioTuner.Callback callback, @Nullable Handler handler) {
+        mCallback = Objects.requireNonNull(callback, "Callback cannot be null");
         if (handler == null) {
             mHandler = new Handler(Looper.getMainLooper());
         } else {
@@ -175,7 +174,7 @@
         mHandler.post(() -> mCallback.onProgramListChanged());
     }
 
-    public void onParametersUpdated(@NonNull Map<String, String> parameters) {
+    public void onParametersUpdated(Map<String, String> parameters) {
         mHandler.post(() -> mCallback.onParametersUpdated(parameters));
     }
 }
diff --git a/src/com/android/car/radio/service/RadioAppService.java b/src/com/android/car/radio/service/RadioAppService.java
index e906bcd..89d0b69 100644
--- a/src/com/android/car/radio/service/RadioAppService.java
+++ b/src/com/android/car/radio/service/RadioAppService.java
@@ -102,6 +102,8 @@
     private long mLastProgramListPush;
     @GuardedBy("mLock")
     private RegionConfig mRegionConfigCache;
+    @GuardedBy("mLock")
+    private boolean mCanUpdateCurrentProgram;
 
     private SkipController mSkipController;
 
@@ -197,6 +199,19 @@
         return mLifecycleRegistry;
     }
 
+    @GuardedBy("mLock")
+    private void setCanUpdateCurrentProgramLocked() {
+        if (mCanUpdateCurrentProgram) {
+            return;
+        }
+        mCanUpdateCurrentProgram = true;
+    }
+
+    @GuardedBy("mLock")
+    private boolean canUpdateCurrentProgramLocked() {
+        return mCanUpdateCurrentProgram;
+    }
+
     private void onPlaybackStateChanged(int newState) {
         Log.d(TAG, "onPlaybackStateChanged new state [%d]", newState);
         synchronized (mLock) {
@@ -243,6 +258,7 @@
                 } catch (IllegalArgumentException | UnsupportedOperationException e) {
                     Log.e(TAG, "Can't restore recently selected program: " + sel, e);
                 }
+                setCanUpdateCurrentProgramLocked();
                 return;
             }
 
@@ -345,6 +361,7 @@
                 if (tuneCb == null) return;
                 mRadioTuner.tune(sel, tuneCb.alsoCall(
                         succ -> tryExec(() -> callback.onFinished(succ))));
+                setCanUpdateCurrentProgramLocked();
             }
         }
 
@@ -359,6 +376,7 @@
                 if (tuneCb == null) return;
                 mRadioTuner.seek(forward, tuneCb.alsoCall(
                         succ -> tryExec(() -> callback.onFinished(succ))));
+                setCanUpdateCurrentProgramLocked();
             }
         }
 
@@ -391,6 +409,7 @@
                 if (tuneCb == null) return;
                 mRadioTuner.step(forward, tuneCb.alsoCall(
                         succ -> tryExec(() -> callback.onFinished(succ))));
+                setCanUpdateCurrentProgramLocked();
             }
         }
 
@@ -445,6 +464,10 @@
             Log.d(TAG, "Program info changed: %s", info);
 
             synchronized (mLock) {
+                if (!canUpdateCurrentProgramLocked()) {
+                    return;
+                }
+
                 mCurrentProgram = info;
 
                 /* Storing recently selected program might be limited to explicit tune calls only
diff --git a/src/com/android/car/radio/storage/RadioStorage.java b/src/com/android/car/radio/storage/RadioStorage.java
index 632d453..4211b78 100644
--- a/src/com/android/car/radio/storage/RadioStorage.java
+++ b/src/com/android/car/radio/storage/RadioStorage.java
@@ -21,7 +21,9 @@
 import android.hardware.radio.ProgramSelector;
 import android.net.Uri;
 import android.os.AsyncTask;
+import android.util.ArrayMap;
 
+import androidx.annotation.GuardedBy;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.lifecycle.LiveData;
@@ -33,6 +35,7 @@
 import com.android.car.radio.util.Log;
 
 import java.util.List;
+import java.util.Map;
 import java.util.Objects;
 
 /**
@@ -47,7 +50,10 @@
 
     private static final String PREF_KEY_SKIP_MODE = "smartSeekMode";
 
-    private static RadioStorage sInstance;
+    private static final Object sLock = new Object();
+
+    @GuardedBy("sLock")
+    private static Map<Integer, RadioStorage> sInstances = new ArrayMap<>();
 
     private final SharedPreferences mPrefs;
     private final RadioDatabase mDatabase;
@@ -61,14 +67,20 @@
     }
 
     /**
-     * Returns singleton instance of {@link RadioStorage}.
+     * Returns singleton instance of {@link RadioStorage} for the user in context.
+     *
+     * @param context Application context
      */
-    public static @NonNull RadioStorage getInstance(Context context) {
-        if (sInstance != null) return sInstance;
-        synchronized (RadioStorage.class) {
-            if (sInstance != null) return sInstance;
-            sInstance = new RadioStorage(context.getApplicationContext());
-            return sInstance;
+    @NonNull
+    public static RadioStorage getInstance(Context context) {
+        int userId = context.getUserId();
+        synchronized (sLock) {
+            if (sInstances.containsKey(userId)) {
+                return sInstances.get(userId);
+            }
+            RadioStorage newInstance = new RadioStorage(context.getApplicationContext());
+            sInstances.put(userId, newInstance);
+            return newInstance;
         }
     }
 
diff --git a/tools/generate-overlayable.sh b/tools/generate-overlayable.sh
new file mode 100755
index 0000000..7a2e140
--- /dev/null
+++ b/tools/generate-overlayable.sh
@@ -0,0 +1,25 @@
+#!/bin/bash
+#  Copyright (C) 2021 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.
+# Run this script to regenerate the overlayable.xml file.
+if [[ -z "$ANDROID_BUILD_TOP" ]]; then
+  echo 'ANDROID_BUILD_TOP environment variable is empty; did you forget to run `lunch`?'
+  exit 1
+fi
+PROJECT_TOP=$ANDROID_BUILD_TOP/packages/apps/Car/Radio
+python3 $ANDROID_BUILD_TOP/packages/apps/Car/systemlibs/tools/rro/generate-overlayable.py \
+    -n CarRadio \
+    -r $PROJECT_TOP/res \
+    -e $PROJECT_TOP/res/values/overlayable.xml \
+    -o $PROJECT_TOP/res/values/overlayable.xml