Changed Radio app to use a "skip" mode when prev/next is triggered.

This mode will change the station based on what the user used latest (favorites or tune).

Test: manual verification
Bug: 137647889

Change-Id: Ife470d4eeeb5f0d1a11682ab9f00b0b1a48255e4
diff --git a/src/com/android/car/radio/BrowseFragment.java b/src/com/android/car/radio/BrowseFragment.java
index b53563d..4661df3 100644
--- a/src/com/android/car/radio/BrowseFragment.java
+++ b/src/com/android/car/radio/BrowseFragment.java
@@ -68,6 +68,14 @@
         mRadioController.getProgramList().observe(this, mBrowseAdapter::setProgramList);
     }
 
+    @Override
+    public void setUserVisibleHint(boolean isVisibleToUser) {
+        super.setUserVisibleHint(isVisibleToUser);
+        if (isVisibleToUser) {
+            mRadioController.setSkipMode(SkipMode.BROWSE);
+        }
+    }
+
     private void handlePresetItemFavoriteChanged(Program program, boolean saveAsFavorite) {
         if (saveAsFavorite) {
             mRadioStorage.addFavorite(program);
diff --git a/src/com/android/car/radio/FavoritesFragment.java b/src/com/android/car/radio/FavoritesFragment.java
index ec2e1fa..3154dba 100644
--- a/src/com/android/car/radio/FavoritesFragment.java
+++ b/src/com/android/car/radio/FavoritesFragment.java
@@ -71,6 +71,9 @@
         if (!isVisibleToUser && mBrowseAdapter != null) {
             mBrowseAdapter.removeFormerFavorites();
         }
+        if (isVisibleToUser) {
+            mRadioController.setSkipMode(SkipMode.FAVORITES);
+        }
     }
 
     private void handlePresetItemFavoriteChanged(Program program, boolean saveAsFavorite) {
diff --git a/src/com/android/car/radio/ManualTunerFragment.java b/src/com/android/car/radio/ManualTunerFragment.java
index 39d930e..737edb9 100644
--- a/src/com/android/car/radio/ManualTunerFragment.java
+++ b/src/com/android/car/radio/ManualTunerFragment.java
@@ -52,6 +52,9 @@
         ProgramInfo current = mRadioController.getCurrentProgram().getValue();
         if (current == null) return;
         mController.switchProgramType(ProgramType.fromSelector(current.getSelector()));
+        if (isVisibleToUser) {
+            mRadioController.setSkipMode(SkipMode.TUNE);
+        }
     }
 
     static ManualTunerFragment newInstance(RadioController radioController) {
diff --git a/src/com/android/car/radio/RadioController.java b/src/com/android/car/radio/RadioController.java
index 3f9c09d..6244847 100644
--- a/src/com/android/car/radio/RadioController.java
+++ b/src/com/android/car/radio/RadioController.java
@@ -145,6 +145,13 @@
         return mAppService.getRegionConfig();
     }
 
+    /**
+     * Sets the service's {@link SkipMode}.
+     */
+    public void setSkipMode(@NonNull SkipMode mode) {
+        mAppService.setSkipMode(mode);
+    }
+
     private void onFavoritesChanged(List<Program> favorites) {
         synchronized (mLock) {
             if (mCurrentProgram == null) return;
@@ -179,12 +186,12 @@
 
     private void onBackwardSeekClick(View v) {
         mDisplayController.startSeekAnimation(false);
-        mAppService.seek(false);
+        mAppService.skip(false);
     }
 
     private void onForwardSeekClick(View v) {
         mDisplayController.startSeekAnimation(true);
-        mAppService.seek(true);
+        mAppService.skip(true);
     }
 
     private void onSwitchToPlayState(@PlaybackState.State int newPlayState) {
diff --git a/src/com/android/car/radio/SkipMode.java b/src/com/android/car/radio/SkipMode.java
new file mode 100644
index 0000000..484a8de
--- /dev/null
+++ b/src/com/android/car/radio/SkipMode.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright (C) 2019 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 com.android.car.radio;
+
+import androidx.annotation.Nullable;
+
+/**
+ * Enum used to define which Radio property should be affected by the media keys.
+ */
+public enum SkipMode {
+
+    FAVORITES, TUNE, BROWSE;
+
+    /**
+     * Gets the default mode.
+     */
+    public static final SkipMode DEFAULT_MODE = TUNE;
+
+    /**
+     * Converts an {@code int} value to a valid {@link SkipMode} {or {@code null} if invalid).
+     */
+    @Nullable
+    public static SkipMode valueOf(int mode) {
+        if (mode < 0 || mode > SkipMode.values().length - 1) {
+            return null;
+        }
+        return values()[mode];
+    }
+}
diff --git a/src/com/android/car/radio/media/TunerSession.java b/src/com/android/car/radio/media/TunerSession.java
index 279b760..0cda1f3 100644
--- a/src/com/android/car/radio/media/TunerSession.java
+++ b/src/com/android/car/radio/media/TunerSession.java
@@ -154,12 +154,12 @@
 
         @Override
         public void onSkipToNext() {
-            mAppService.seek(true);
+            mAppService.skip(true);
         }
 
         @Override
         public void onSkipToPrevious() {
-            mAppService.seek(false);
+            mAppService.skip(false);
         }
 
         @Override
diff --git a/src/com/android/car/radio/service/IRadioAppService.aidl b/src/com/android/car/radio/service/IRadioAppService.aidl
index cb8ed68..6ece15a 100644
--- a/src/com/android/car/radio/service/IRadioAppService.aidl
+++ b/src/com/android/car/radio/service/IRadioAppService.aidl
@@ -57,6 +57,18 @@
     void step(boolean forward, in ITuneCallback callback);
 
     /**
+     * Skips forward or backwards; the meaning of "skip" is defined by setSkipMode().
+     */
+    void skip(boolean forward, in ITuneCallback callback);
+
+    /**
+      * Sets the behavior of skip()
+      *
+      * @param mode must be a valid SkipMode enum value.
+      */
+    void setSkipMode(int mode);
+
+    /**
      * Mutes or resumes audio.
      *
      * @param muted {@code true} to mute, {@code false} to resume audio.
diff --git a/src/com/android/car/radio/service/RadioAppService.java b/src/com/android/car/radio/service/RadioAppService.java
index c313266..bb03488 100644
--- a/src/com/android/car/radio/service/RadioAppService.java
+++ b/src/com/android/car/radio/service/RadioAppService.java
@@ -36,8 +36,11 @@
 import androidx.lifecycle.Lifecycle;
 import androidx.lifecycle.LifecycleOwner;
 import androidx.lifecycle.LifecycleRegistry;
+import androidx.lifecycle.LiveData;
 
+import com.android.car.broadcastradio.support.Program;
 import com.android.car.broadcastradio.support.media.BrowseTree;
+import com.android.car.radio.SkipMode;
 import com.android.car.radio.audio.AudioStreamController;
 import com.android.car.radio.bands.ProgramType;
 import com.android.car.radio.bands.RegionConfig;
@@ -49,6 +52,8 @@
 import com.android.car.radio.storage.RadioStorage;
 import com.android.car.radio.util.Log;
 
+import java.io.FileDescriptor;
+import java.io.PrintWriter;
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
@@ -86,6 +91,8 @@
 
     private RegionConfig mRegionConfigCache;
 
+    private SkipController mSkipController;
+
     @Override
     public void onCreate() {
         super.onCreate();
@@ -108,8 +115,10 @@
         mMediaSession = new TunerSession(this, mBrowseTree, mWrapper, mImageCache);
         setSessionToken(mMediaSession.getSessionToken());
         mBrowseTree.setAmFmRegionConfig(mRadioManager.getAmFmRegionConfig());
-        mRadioStorage.getFavorites().observe(this,
-                favs -> mBrowseTree.setFavorites(new HashSet<>(favs)));
+        LiveData<List<Program>> favorites = mRadioStorage.getFavorites();
+        SkipMode skipMode = mRadioStorage.getSkipMode();
+        mSkipController = new SkipController(mBinder, favorites, skipMode);
+        favorites.observe(this, favs -> mBrowseTree.setFavorites(new HashSet<>(favs)));
 
         mProgramList = mRadioTuner.getDynamicProgramList(null);
         if (mProgramList != null) {
@@ -264,7 +273,16 @@
         }
     }
 
-    private IRadioAppService.Stub mBinder = new IRadioAppService.Stub() {
+    @Override
+    public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
+        if (mSkipController != null) {
+            pw.println("SkipController:"); mSkipController.dump(pw, "  ");
+        } else {
+            pw.println("no SkipController");
+        }
+    }
+
+    private final IRadioAppService.Stub mBinder = new IRadioAppService.Stub() {
         @Override
         public void addCallback(IRadioAppCallback callback) throws RemoteException {
             synchronized (mLock) {
@@ -310,6 +328,24 @@
         }
 
         @Override
+        public void skip(boolean forward, ITuneCallback callback) throws RemoteException {
+            Objects.requireNonNull(callback);
+
+            mSkipController.skip(forward, callback);
+        }
+
+        @Override
+        public void setSkipMode(int mode) {
+            SkipMode newMode = SkipMode.valueOf(mode);
+            if (newMode == null) {
+                Log.e(TAG, "setSkipMode(): invalid mode " + mode);
+                return;
+            }
+            mSkipController.setSkipMode(newMode);
+            mRadioStorage.setSkipMode(newMode);
+        }
+
+        @Override
         public void step(boolean forward, ITuneCallback callback) {
             Objects.requireNonNull(callback);
             synchronized (mLock) {
@@ -356,9 +392,7 @@
         public void onProgramInfoChanged(ProgramInfo info) {
             Objects.requireNonNull(info);
 
-            if (Log.isLoggable(TAG, Log.DEBUG)) {
-                Log.d(TAG, "Program info changed: " + info);
-            }
+            Log.d(TAG, "Program info changed: %s", info);
 
             synchronized (mLock) {
                 mCurrentProgram = info;
diff --git a/src/com/android/car/radio/service/RadioAppServiceWrapper.java b/src/com/android/car/radio/service/RadioAppServiceWrapper.java
index 90b9ac0..0ab882d 100644
--- a/src/com/android/car/radio/service/RadioAppServiceWrapper.java
+++ b/src/com/android/car/radio/service/RadioAppServiceWrapper.java
@@ -32,6 +32,7 @@
 import androidx.lifecycle.LiveData;
 import androidx.lifecycle.MutableLiveData;
 
+import com.android.car.radio.SkipMode;
 import com.android.car.radio.bands.ProgramType;
 import com.android.car.radio.bands.RegionConfig;
 import com.android.car.radio.platform.RadioTunerExt.TuneCallback;
@@ -325,6 +326,20 @@
     }
 
     /**
+     * Skips forward/backwards.
+     */
+    public void skip(boolean forward) {
+        callService(service -> service.skip(forward, new TuneCallbackAdapter(null)));
+    }
+
+    /**
+     * Sets the service's {@link SkipMode} mode.
+     */
+    public void setSkipMode(@NonNull SkipMode mode) {
+        callService(service -> service.setSkipMode(mode.ordinal()));
+    }
+
+    /**
      * Steps forward/backwards
      */
     public void step(boolean forward) {
diff --git a/src/com/android/car/radio/service/SkipController.java b/src/com/android/car/radio/service/SkipController.java
new file mode 100644
index 0000000..574399b
--- /dev/null
+++ b/src/com/android/car/radio/service/SkipController.java
@@ -0,0 +1,152 @@
+/*
+ * Copyright (C) 2019 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 com.android.car.radio.service;
+
+import android.os.RemoteException;
+
+import androidx.annotation.GuardedBy;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.lifecycle.LiveData;
+
+import com.android.car.broadcastradio.support.Program;
+import com.android.car.radio.SkipMode;
+import com.android.car.radio.util.Log;
+
+import java.io.PrintWriter;
+import java.util.List;
+
+/**
+ * Helper class used to keep track of which station should be toggled next (or prev), based on
+ * {@link SkipMode}.
+ */
+final class SkipController {
+
+    private static final String TAG = SkipController.class.getSimpleName();
+
+    private final Object mLock = new Object();
+
+    private final IRadioAppService.Stub mService;
+
+    @GuardedBy("mlock")
+    private List<Program> mFavorites;
+
+    @GuardedBy("mlock")
+    private int mCurrentIndex;
+
+    @GuardedBy("mlock")
+    private SkipMode mSkipMode;
+
+    SkipController(@NonNull IRadioAppService.Stub service,
+            @NonNull LiveData<List<Program>> favorites, @NonNull SkipMode initialMode) {
+        mService = service;
+        mSkipMode = initialMode;
+
+        Log.v(TAG, "Initial mode: %s", initialMode);
+
+        // TODO(b/137647889): not really working because they're changed in a different process.
+        // As such, the changes are only effective after the radio service restarts - that's
+        // not ideal, but it's better than nothing :-)
+        // Long term, we need to provide a way to sync them...
+        favorites.observeForever(this::onFavoritesChanged);
+    }
+
+    void setSkipMode(@NonNull SkipMode mode) {
+        Log.d(TAG, "setSkipMode(%s)", mode);
+        synchronized (mLock) {
+            this.mSkipMode = mode;
+        }
+    }
+
+    void skip(boolean forward, ITuneCallback callback) throws RemoteException {
+        Log.d(TAG, "skip(%s, %s)", mSkipMode, forward);
+
+        Program program = null;
+        synchronized (mLock) {
+            if (mSkipMode == SkipMode.FAVORITES || mSkipMode == SkipMode.BROWSE) {
+                program = getFavoriteLocked(forward);
+                if (program == null) {
+                    Log.d(TAG, "skip(%s): no favorites, seeking instead", forward);
+                }
+            }
+        }
+
+        if (program != null) {
+            Log.d(TAG, "skip(%s): changing to %s", forward, program.getName());
+            mService.tune(program.getSelector(), callback);
+        } else {
+            mService.seek(forward, callback);
+        }
+    }
+
+    private void onFavoritesChanged(List<Program> favorites) {
+        Log.v(TAG, "onFavoritesChanged(): %s", favorites);
+        synchronized (this) {
+            mFavorites = favorites;
+            // TODO(b/137647889): try to preserve currentIndex, either pointing to the same station,
+            // or the closest one
+            mCurrentIndex = 0;
+        }
+    }
+
+    @Nullable
+    private Program getFavoriteLocked(boolean next) {
+        if (mFavorites == null || mFavorites.isEmpty()) return null;
+
+        // TODO(b/137647889): to keep it simple, we're only interacting through explicit calls
+        // to prev/next, but ideally it should also take in account the current station.
+        // For example, say the favorites are 4, 8, 15, 16, 23, 42 and user skipped from
+        // 15 to 16 but later manually tuned to 5. In this case, if the user skips again we'll
+        // return 23 (next index), but ideally it would be 8 (i.e, next favorite whose value
+        // is higher than 5)
+        if (next) {
+            mCurrentIndex++;
+            if (mCurrentIndex >= mFavorites.size()) {
+                mCurrentIndex = 0;
+            }
+        } else {
+            mCurrentIndex--;
+            if (mCurrentIndex < 0) {
+                mCurrentIndex = mFavorites.size() - 1;
+            }
+        }
+        Program program = mFavorites.get(mCurrentIndex);
+        Log.v(TAG, "getting favorite #" + mCurrentIndex + ": " + program.getName());
+        return program;
+    }
+
+    void dump(@NonNull PrintWriter pw, @NonNull String prefix) {
+        synchronized (mLock) {
+            pw.print(prefix); pw.print("mode: "); pw.println(mSkipMode);
+            pw.print(prefix); pw.print("current index: "); pw.println(mCurrentIndex);
+            if (mFavorites == null || mFavorites.isEmpty()) {
+                pw.print(prefix); pw.println("no favorites");
+                return;
+            }
+            int size = mFavorites.size();
+            pw.print(prefix); pw.print(size); pw.println(" favorites: ");
+            String prefix2 = prefix + "  ";
+            for (int i = 0; i < size; i++) {
+                pw.print(prefix2);
+                pw.print(i); pw.print(": "); pw.print(mFavorites.get(i).getName());
+                if (i == mCurrentIndex) {
+                    pw.print(" (current)");
+                }
+                pw.println();
+            }
+        }
+    }
+}
diff --git a/src/com/android/car/radio/storage/RadioStorage.java b/src/com/android/car/radio/storage/RadioStorage.java
index 02fcd77..632d453 100644
--- a/src/com/android/car/radio/storage/RadioStorage.java
+++ b/src/com/android/car/radio/storage/RadioStorage.java
@@ -28,6 +28,7 @@
 
 import com.android.car.broadcastradio.support.Program;
 import com.android.car.broadcastradio.support.platform.ProgramSelectorExt;
+import com.android.car.radio.SkipMode;
 import com.android.car.radio.bands.ProgramType;
 import com.android.car.radio.util.Log;
 
@@ -44,6 +45,8 @@
     private static final String PREF_KEY_RECENT_TYPE = "recentProgramType";
     private static final String PREF_KEY_RECENT_PROGRAM_PREFIX = "recentProgram-";
 
+    private static final String PREF_KEY_SKIP_MODE = "smartSeekMode";
+
     private static RadioStorage sInstance;
 
     private final SharedPreferences mPrefs;
@@ -189,4 +192,33 @@
 
         return ProgramSelectorExt.fromUri(Uri.parse(selUriStr));
     }
+
+    /**
+     * Stores the last {@link SkipMode} set.
+     */
+    public void setSkipMode(@NonNull SkipMode mode) {
+        int value = mode.ordinal();
+        SharedPreferences.Editor editor = mPrefs.edit();
+
+        if (mPrefs.getInt(PREF_KEY_SKIP_MODE,
+                SkipMode.DEFAULT_MODE.ordinal()) != value) {
+            editor.putInt(PREF_KEY_SKIP_MODE, value);
+            editor.apply();
+        }
+    }
+
+    /**
+     * Gets the last {@link SkipMode} set.
+     */
+    @NonNull
+    public SkipMode getSkipMode() {
+        int value = mPrefs.getInt(PREF_KEY_SKIP_MODE, SkipMode.DEFAULT_MODE.ordinal());
+        SkipMode mode = SkipMode.valueOf(value);
+        if (mode == null) {
+            Log.e(TAG, "getSkipMode(): invalid pref value " + value + "; returning "
+                    + SkipMode.DEFAULT_MODE + " instead");
+            mode = SkipMode.DEFAULT_MODE;
+        }
+        return mode;
+    }
 }
diff --git a/src/com/android/car/radio/util/Log.java b/src/com/android/car/radio/util/Log.java
index 8bac78d..eaed7f9 100644
--- a/src/com/android/car/radio/util/Log.java
+++ b/src/com/android/car/radio/util/Log.java
@@ -38,15 +38,25 @@
     }
 
     /** See {@link android.util.Log#v}. */
-    public static int v(@Nullable String tag, @NonNull String msg) {
+    public static int v(@Nullable String tag, @NonNull String format, @Nullable Object...args) {
         if (!isLoggable(tag, VERBOSE)) return 0;
-        return android.util.Log.v(tag, msg);
+
+        if (args != null) {
+            return android.util.Log.v(tag, String.format(format, args));
+        } else {
+            return android.util.Log.v(tag, format);
+        }
     }
 
     /** See {@link android.util.Log#d}. */
-    public static int d(@Nullable String tag, @NonNull String msg) {
+    public static int d(@Nullable String tag, @NonNull String format, @Nullable Object...args) {
         if (!isLoggable(tag, DEBUG)) return 0;
-        return android.util.Log.d(tag, msg);
+
+        if (args != null) {
+            return android.util.Log.d(tag, String.format(format, args));
+        } else {
+            return android.util.Log.d(tag, format);
+        }
     }
 
     /** See {@link android.util.Log#i}. */