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}. */