blob: e906bcd49642bfacd593a638bd2a9b5545fd5d42 [file] [log] [blame]
/**
* Copyright (C) 2018 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.car.radio.service;
import static com.android.car.radio.util.Remote.tryExec;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.hardware.radio.ProgramList;
import android.hardware.radio.ProgramSelector;
import android.hardware.radio.RadioManager.ProgramInfo;
import android.hardware.radio.RadioTuner;
import android.media.browse.MediaBrowser.MediaItem;
import android.media.session.PlaybackState;
import android.os.Bundle;
import android.os.IBinder;
import android.os.RemoteException;
import android.os.SystemClock;
import android.service.media.MediaBrowserService;
import android.util.IndentingPrintWriter;
import androidx.annotation.GuardedBy;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
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;
import com.android.car.radio.media.TunerSession;
import com.android.car.radio.platform.ImageMemoryCache;
import com.android.car.radio.platform.RadioManagerExt;
import com.android.car.radio.platform.RadioTunerExt;
import com.android.car.radio.platform.RadioTunerExt.TuneCallback;
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;
import java.util.Objects;
/**
* A service handling hardware tuner session and audio streaming.
*/
public class RadioAppService extends MediaBrowserService implements LifecycleOwner {
private static final String TAG = "BcRadioApp.service";
public static String ACTION_APP_SERVICE = "com.android.car.radio.ACTION_APP_SERVICE";
private static final long PROGRAM_LIST_RATE_LIMITING = 1000;
/** Returns the {@link ComponentName} that represents this {@link MediaBrowserService}. */
public static @NonNull ComponentName getMediaSourceComp(Context context) {
return new ComponentName(context, RadioAppService.class);
}
private final Object mLock = new Object();
private final LifecycleRegistry mLifecycleRegistry = new LifecycleRegistry(this);
private final List<IRadioAppCallback> mRadioAppCallbacks = new ArrayList<>();
private RadioAppServiceWrapper mWrapper;
private RadioManagerExt mRadioManager;
@Nullable private RadioTunerExt mRadioTuner;
@Nullable private ProgramList mProgramList;
private RadioStorage mRadioStorage;
private ImageMemoryCache mImageCache;
@Nullable private AudioStreamController mAudioStreamController;
private BrowseTree mBrowseTree;
private TunerSession mMediaSession;
// current observables state for newly bound IRadioAppCallbacks
@GuardedBy("mLock")
private ProgramInfo mCurrentProgram = null;
@GuardedBy("mLock")
private int mCurrentPlaybackState = PlaybackState.STATE_NONE;
@GuardedBy("mLock")
private long mLastProgramListPush;
@GuardedBy("mLock")
private RegionConfig mRegionConfigCache;
private SkipController mSkipController;
@Override
public void onCreate() {
super.onCreate();
Log.i(TAG, "Starting RadioAppService...");
mWrapper = new RadioAppServiceWrapper(mBinder);
mRadioManager = new RadioManagerExt(this);
mRadioStorage = RadioStorage.getInstance(this);
mImageCache = new ImageMemoryCache(mRadioManager, 1000);
mRadioTuner = mRadioManager.openSession(mHardwareCallback, null);
if (mRadioTuner == null) {
Log.e(TAG, "Couldn't open tuner session");
return;
}
mAudioStreamController = new AudioStreamController(this, mRadioTuner,
this::onPlaybackStateChanged);
mBrowseTree = new BrowseTree(this, mImageCache);
mMediaSession = new TunerSession(this, mBrowseTree, mWrapper, mImageCache);
setSessionToken(mMediaSession.getSessionToken());
mBrowseTree.setAmFmRegionConfig(mRadioManager.getAmFmRegionConfig());
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) {
mBrowseTree.setProgramList(mProgramList);
mProgramList.registerListCallback(new ProgramList.ListCallback() {
@Override
public void onItemChanged(@NonNull ProgramSelector.Identifier id) {
onProgramListChanged();
}
});
mProgramList.addOnCompleteListener(this::pushProgramListUpdate);
}
mLifecycleRegistry.markState(Lifecycle.State.CREATED);
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
Log.d(TAG, "onStartCommand intent [%s] flags[%d] startId[%d]",
intent.toString(), flags, startId);
mLifecycleRegistry.markState(Lifecycle.State.STARTED);
if (BrowseTree.ACTION_PLAY_BROADCASTRADIO.equals(intent.getAction())) {
Log.i(TAG, "Executing general play radio intent");
mMediaSession.getController().getTransportControls().playFromMediaId(
mBrowseTree.getRoot().getRootId(), null);
return START_NOT_STICKY;
}
return super.onStartCommand(intent, flags, startId);
}
@Override
public IBinder onBind(Intent intent) {
Log.i(TAG, "onBind intent[" + intent + "]");
mLifecycleRegistry.markState(Lifecycle.State.STARTED);
if (mRadioTuner == null) return null;
if (ACTION_APP_SERVICE.equals(intent.getAction())) {
return mBinder;
}
return super.onBind(intent);
}
@Override
public boolean onUnbind(Intent intent) {
mLifecycleRegistry.markState(Lifecycle.State.CREATED);
return false;
}
@Override
public void onDestroy() {
Log.i(TAG, "Shutting down RadioAppService...");
mLifecycleRegistry.markState(Lifecycle.State.DESTROYED);
if (mMediaSession != null) mMediaSession.release();
close();
super.onDestroy();
}
@NonNull
@Override
public Lifecycle getLifecycle() {
return mLifecycleRegistry;
}
private void onPlaybackStateChanged(int newState) {
Log.d(TAG, "onPlaybackStateChanged new state [%d]", newState);
synchronized (mLock) {
mCurrentPlaybackState = newState;
for (IRadioAppCallback callback : mRadioAppCallbacks) {
tryExec(() -> callback.onPlaybackStateChanged(newState));
}
}
}
private void onProgramListChanged() {
if (mProgramList == null) return;
synchronized (mLock) {
if (SystemClock.elapsedRealtime() - mLastProgramListPush > PROGRAM_LIST_RATE_LIMITING) {
pushProgramListUpdate();
}
}
}
private void pushProgramListUpdate() {
if (mProgramList == null) return;
List<ProgramInfo> plist = mProgramList.toList();
synchronized (mLock) {
mLastProgramListPush = SystemClock.elapsedRealtime();
for (IRadioAppCallback callback : mRadioAppCallbacks) {
tryExec(() -> callback.onProgramListChanged(plist));
}
}
}
private void tuneToDefault(@Nullable ProgramType pt) {
synchronized (mLock) {
if (mRadioTuner == null) throw new IllegalStateException("Tuner session is closed");
TuneCallback tuneCb = mAudioStreamController.preparePlayback(
AudioStreamController.OPERATION_TUNE);
if (tuneCb == null) return;
ProgramSelector sel = mRadioStorage.getRecentlySelected(pt);
if (sel != null) {
Log.i(TAG, "Restoring recently selected program: " + sel);
try {
mRadioTuner.tune(sel, tuneCb);
} catch (IllegalArgumentException | UnsupportedOperationException e) {
Log.e(TAG, "Can't restore recently selected program: " + sel, e);
}
return;
}
if (pt == null) pt = ProgramType.FM;
Log.i(TAG, "No recently selected program set, selecting default channel for " + pt);
pt.tuneToDefault(mRadioTuner, mWrapper.getRegionConfig(), tuneCb);
}
}
private void close() {
synchronized (mLock) {
if (mAudioStreamController != null) {
mAudioStreamController.requestMuted(true);
mAudioStreamController = null;
}
if (mProgramList != null) {
ProgramList oldList = mProgramList;
mProgramList = null;
oldList.close();
}
if (mRadioTuner != null) {
mRadioTuner.close();
mRadioTuner = null;
}
}
}
@Override
public BrowserRoot onGetRoot(String clientPackageName, int clientUid, Bundle rootHints) {
/* Radio application may restrict who can read its MediaBrowser tree.
* Our implementation doesn't.
*/
return mBrowseTree.getRoot();
}
@Override
public void onLoadChildren(final String parentMediaId, final Result<List<MediaItem>> result) {
mBrowseTree.loadChildren(parentMediaId, result);
}
private void onHardwareError() {
close();
stopSelf();
synchronized (mLock) {
for (IRadioAppCallback callback : mRadioAppCallbacks) {
tryExec(() -> callback.onHardwareError());
}
}
}
@Override
public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
try (IndentingPrintWriter writer = new IndentingPrintWriter(pw)) {
pw.println("RadioAppService:");
writer.increaseIndent();
if (mSkipController != null) {
writer.increaseIndent();
mSkipController.dump(writer);
writer.decreaseIndent();
} else {
pw.println("No SkipController");
}
if (mAudioStreamController != null) {
writer.increaseIndent();
mAudioStreamController.dump(writer);
writer.decreaseIndent();
} else {
pw.println("No AudioStreamController");
}
writer.decreaseIndent();
}
}
private final IRadioAppService.Stub mBinder = new IRadioAppService.Stub() {
@Override
public void addCallback(IRadioAppCallback callback) throws RemoteException {
synchronized (mLock) {
if (mCurrentProgram != null) callback.onCurrentProgramChanged(mCurrentProgram);
callback.onPlaybackStateChanged(mCurrentPlaybackState);
if (mProgramList != null) callback.onProgramListChanged(mProgramList.toList());
mRadioAppCallbacks.add(callback);
}
}
@Override
public void removeCallback(IRadioAppCallback callback) {
synchronized (mLock) {
mRadioAppCallbacks.remove(callback);
}
}
@Override
public void tune(ProgramSelector sel, ITuneCallback callback) {
Objects.requireNonNull(callback);
synchronized (mLock) {
if (mRadioTuner == null) throw new IllegalStateException("Tuner session is closed");
TuneCallback tuneCb = mAudioStreamController.preparePlayback(
AudioStreamController.OPERATION_TUNE);
if (tuneCb == null) return;
mRadioTuner.tune(sel, tuneCb.alsoCall(
succ -> tryExec(() -> callback.onFinished(succ))));
}
}
@Override
public void seek(boolean forward, ITuneCallback callback) {
Objects.requireNonNull(callback);
synchronized (mLock) {
if (mRadioTuner == null) throw new IllegalStateException("Tuner session is closed");
TuneCallback tuneCb = mAudioStreamController.preparePlayback(forward
? AudioStreamController.OPERATION_SEEK_FWD
: AudioStreamController.OPERATION_SEEK_BKW);
if (tuneCb == null) return;
mRadioTuner.seek(forward, tuneCb.alsoCall(
succ -> tryExec(() -> callback.onFinished(succ))));
}
}
@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) {
if (mRadioTuner == null) throw new IllegalStateException("Tuner session is closed");
TuneCallback tuneCb = mAudioStreamController.preparePlayback(forward
? AudioStreamController.OPERATION_STEP_FWD
: AudioStreamController.OPERATION_STEP_BKW);
if (tuneCb == null) return;
mRadioTuner.step(forward, tuneCb.alsoCall(
succ -> tryExec(() -> callback.onFinished(succ))));
}
}
@Override
public void setMuted(boolean muted) {
if (mAudioStreamController == null) return;
if (muted) mRadioTuner.cancel();
mAudioStreamController.requestMuted(muted);
}
@Override
public void tuneToDefaultIfNeeded() {
synchronized (mLock) {
if (mRadioTuner == null) {
throw new IllegalStateException("Tuner session is closed");
}
if (mCurrentPlaybackState != PlaybackState.STATE_NONE) {
return;
}
}
tuneToDefault(null);
}
@Override
public void switchBand(ProgramType band) {
tuneToDefault(band);
}
@Override
public boolean isProgramListSupported() {
return mProgramList != null;
}
@Override
public RegionConfig getRegionConfig() {
synchronized (mLock) {
if (mRegionConfigCache == null) {
mRegionConfigCache = new RegionConfig(mRadioManager.getAmFmRegionConfig());
}
return mRegionConfigCache;
}
}
};
private RadioTuner.Callback mHardwareCallback = new RadioTuner.Callback() {
@Override
public void onProgramInfoChanged(ProgramInfo info) {
Objects.requireNonNull(info);
Log.d(TAG, "Program info changed: %s", info);
synchronized (mLock) {
mCurrentProgram = info;
/* Storing recently selected program might be limited to explicit tune calls only
* (including next/prev seek), but the implementation would be nontrivial with the
* current API. For now, let's make it simple and make it react to all program
* selector changes. */
mRadioStorage.setRecentlySelected(info.getSelector());
for (IRadioAppCallback callback : mRadioAppCallbacks) {
tryExec(() -> callback.onCurrentProgramChanged(info));
}
}
}
@Override
public void onError(int status) {
switch (status) {
case RadioTuner.ERROR_HARDWARE_FAILURE:
case RadioTuner.ERROR_SERVER_DIED:
Log.e(TAG, "Fatal hardware error: " + status);
onHardwareError();
break;
default:
Log.w(TAG, "Hardware error: " + status);
}
}
@Override
public void onControlChanged(boolean control) {
if (!control) onHardwareError();
}
};
}