| /** |
| * 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(); |
| } |
| }; |
| } |