1 /*
2  * Copyright (C) 2016 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package android.media.tv;
18 
19 import android.annotation.NonNull;
20 import android.annotation.Nullable;
21 import android.annotation.SystemApi;
22 import android.content.Context;
23 import android.media.tv.TvInputManager;
24 import android.net.Uri;
25 import android.os.Bundle;
26 import android.os.Handler;
27 import android.os.Looper;
28 import android.text.TextUtils;
29 import android.util.Log;
30 import android.util.Pair;
31 
32 import java.util.ArrayDeque;
33 import java.util.Objects;
34 import java.util.Queue;
35 
36 /**
37  * The public interface object used to interact with a specific TV input service for TV program
38  * recording.
39  */
40 public class TvRecordingClient {
41     private static final String TAG = "TvRecordingClient";
42     private static final boolean DEBUG = false;
43 
44     private final RecordingCallback mCallback;
45     private final Handler mHandler;
46 
47     private final TvInputManager mTvInputManager;
48     private TvInputManager.Session mSession;
49     private MySessionCallback mSessionCallback;
50 
51     private boolean mIsRecordingStarted;
52     private boolean mIsTuned;
53     private boolean mIsPaused;
54     private boolean mIsRecordingStopping;
55     private final Queue<Pair<String, Bundle>> mPendingAppPrivateCommands = new ArrayDeque<>();
56 
57     /**
58      * Creates a new TvRecordingClient object.
59      *
60      * @param context The application context to create a TvRecordingClient with.
61      * @param tag A short name for debugging purposes.
62      * @param callback The callback to receive recording status changes.
63      * @param handler The handler to invoke the callback on.
64      */
TvRecordingClient(Context context, String tag, @NonNull RecordingCallback callback, Handler handler)65     public TvRecordingClient(Context context, String tag, @NonNull RecordingCallback callback,
66             Handler handler) {
67         mCallback = callback;
68         mHandler = handler == null ? new Handler(Looper.getMainLooper()) : handler;
69         mTvInputManager = (TvInputManager) context.getSystemService(Context.TV_INPUT_SERVICE);
70     }
71 
72     /**
73      * Tunes to a given channel for TV program recording. The first tune request will create a new
74      * recording session for the corresponding TV input and establish a connection between the
75      * application and the session. If recording has already started in the current recording
76      * session, this method throws an exception.
77      *
78      * <p>The application may call this method before starting or after stopping recording, but not
79      * during recording.
80      *
81      * <p>The recording session will respond by calling
82      * {@link RecordingCallback#onTuned(Uri)} if the tune request was fulfilled, or
83      * {@link RecordingCallback#onError(int)} otherwise.
84      *
85      * @param inputId The ID of the TV input for the given channel.
86      * @param channelUri The URI of a channel.
87      * @throws IllegalStateException If recording is already started.
88      */
tune(String inputId, Uri channelUri)89     public void tune(String inputId, Uri channelUri) {
90         tune(inputId, channelUri, null);
91     }
92 
93     /**
94      * Tunes to a given channel for TV program recording. The first tune request will create a new
95      * recording session for the corresponding TV input and establish a connection between the
96      * application and the session. If recording has already started in the current recording
97      * session, this method throws an exception. This can be used to provide domain-specific
98      * features that are only known between certain client and their TV inputs.
99      *
100      * <p>The application may call this method before starting or after stopping recording, but not
101      * during recording.
102      *
103      * <p>The recording session will respond by calling
104      * {@link RecordingCallback#onTuned(Uri)} if the tune request was fulfilled, or
105      * {@link RecordingCallback#onError(int)} otherwise.
106      *
107      * @param inputId The ID of the TV input for the given channel.
108      * @param channelUri The URI of a channel.
109      * @param params Domain-specific data for this tune request. Keys <em>must</em> be a scoped
110      *            name, i.e. prefixed with a package name you own, so that different developers will
111      *            not create conflicting keys.
112      * @throws IllegalStateException If recording is already started.
113      */
tune(String inputId, Uri channelUri, Bundle params)114     public void tune(String inputId, Uri channelUri, Bundle params) {
115         if (DEBUG) Log.d(TAG, "tune(" + channelUri + ")");
116         if (TextUtils.isEmpty(inputId)) {
117             throw new IllegalArgumentException("inputId cannot be null or an empty string");
118         }
119         if (mIsRecordingStarted && !mIsPaused) {
120             throw new IllegalStateException("tune failed - recording already started");
121         }
122         if (mSessionCallback != null && TextUtils.equals(mSessionCallback.mInputId, inputId)) {
123             if (mSession != null) {
124                 mSessionCallback.mChannelUri = channelUri;
125                 mSession.tune(channelUri, params);
126             } else {
127                 mSessionCallback.mChannelUri = channelUri;
128                 mSessionCallback.mConnectionParams = params;
129             }
130             mIsTuned = false;
131         } else {
132             if (mIsPaused) {
133                 throw new IllegalStateException("tune failed - inputId is changed during pause");
134             }
135             resetInternal();
136             mSessionCallback = new MySessionCallback(inputId, channelUri, params);
137             if (mTvInputManager != null) {
138                 mTvInputManager.createRecordingSession(inputId, mSessionCallback, mHandler);
139             }
140         }
141     }
142 
143     /**
144      * Releases the resources in the current recording session immediately. This may be called at
145      * any time, however if the session is already released, it does nothing.
146      */
release()147     public void release() {
148         if (DEBUG) Log.d(TAG, "release()");
149         resetInternal();
150     }
151 
resetInternal()152     private void resetInternal() {
153         mSessionCallback = null;
154         mPendingAppPrivateCommands.clear();
155         if (mSession != null) {
156             mSession.release();
157             mIsTuned = false;
158             mIsRecordingStarted = false;
159             mIsPaused = false;
160             mIsRecordingStopping = false;
161             mSession = null;
162         }
163     }
164 
165     /**
166      * Starts TV program recording in the current recording session. Recording is expected to start
167      * immediately when this method is called. If the current recording session has not yet tuned to
168      * any channel, this method throws an exception.
169      *
170      * <p>The application may supply the URI for a TV program for filling in program specific data
171      * fields in the {@link android.media.tv.TvContract.RecordedPrograms} table.
172      * A non-null {@code programUri} implies the started recording should be of that specific
173      * program, whereas null {@code programUri} does not impose such a requirement and the
174      * recording can span across multiple TV programs. In either case, the application must call
175      * {@link TvRecordingClient#stopRecording()} to stop the recording.
176      *
177      * <p>The recording session will respond by calling {@link RecordingCallback#onError(int)} if
178      * the start request cannot be fulfilled.
179      *
180      * @param programUri The URI for the TV program to record, built by
181      *            {@link TvContract#buildProgramUri(long)}. Can be {@code null}.
182      * @throws IllegalStateException If {@link #tune} request hasn't been handled yet or during
183      *            pause.
184      */
startRecording(@ullable Uri programUri)185     public void startRecording(@Nullable Uri programUri) {
186         startRecording(programUri, Bundle.EMPTY);
187     }
188 
189     /**
190      * Starts TV program recording in the current recording session. Recording is expected to start
191      * immediately when this method is called. If the current recording session has not yet tuned to
192      * any channel, this method throws an exception.
193      *
194      * <p>The application may supply the URI for a TV program for filling in program specific data
195      * fields in the {@link android.media.tv.TvContract.RecordedPrograms} table.
196      * A non-null {@code programUri} implies the started recording should be of that specific
197      * program, whereas null {@code programUri} does not impose such a requirement and the
198      * recording can span across multiple TV programs. In either case, the application must call
199      * {@link TvRecordingClient#stopRecording()} to stop the recording.
200      *
201      * <p>The recording session will respond by calling {@link RecordingCallback#onError(int)} if
202      * the start request cannot be fulfilled.
203      *
204      * @param programUri The URI for the TV program to record, built by
205      *            {@link TvContract#buildProgramUri(long)}. Can be {@code null}.
206      * @param params Domain-specific data for this request. Keys <em>must</em> be a scoped
207      *            name, i.e. prefixed with a package name you own, so that different developers will
208      *            not create conflicting keys.
209      * @throws IllegalStateException If {@link #tune} request hasn't been handled yet or during
210      *            pause.
211      */
startRecording(@ullable Uri programUri, @NonNull Bundle params)212     public void startRecording(@Nullable Uri programUri, @NonNull Bundle params) {
213         if (mIsRecordingStopping || !mIsTuned || mIsPaused) {
214             throw new IllegalStateException("startRecording failed -"
215                     + "recording not yet stopped or not yet tuned or paused");
216         }
217         if (mIsRecordingStarted) {
218             Log.w(TAG, "startRecording failed - recording already started");
219         }
220         if (mSession != null) {
221             mSession.startRecording(programUri, params);
222             mIsRecordingStarted = true;
223         }
224     }
225 
226     /**
227      * Stops TV program recording in the current recording session. Recording is expected to stop
228      * immediately when this method is called. If recording has not yet started in the current
229      * recording session, this method does nothing.
230      *
231      * <p>The recording session is expected to create a new data entry in the
232      * {@link android.media.tv.TvContract.RecordedPrograms} table that describes the newly
233      * recorded program and pass the URI to that entry through to
234      * {@link RecordingCallback#onRecordingStopped(Uri)}.
235      * If the stop request cannot be fulfilled, the recording session will respond by calling
236      * {@link RecordingCallback#onError(int)}.
237      */
stopRecording()238     public void stopRecording() {
239         if (!mIsRecordingStarted) {
240             Log.w(TAG, "stopRecording failed - recording not yet started");
241         }
242         if (mSession != null) {
243             mSession.stopRecording();
244             if (mIsRecordingStarted) {
245                 mIsRecordingStopping = true;
246             }
247         }
248     }
249 
250     /**
251      * Pause TV program recording in the current recording session. Recording is expected to pause
252      * immediately when this method is called. If recording has not yet started in the current
253      * recording session, this method does nothing.
254      *
255      * <p>In pause status, the application can tune during recording. To continue recording,
256      * please call {@link TvRecordingClient#resumeRecording()} to resume instead of
257      * {@link TvRecordingClient#startRecording(Uri)}. Application can stop
258      * the recording with {@link TvRecordingClient#stopRecording()} in recording pause status.
259      *
260      * <p>If the pause request cannot be fulfilled, the recording session will respond by calling
261      * {@link RecordingCallback#onError(int)}.
262      */
pauseRecording()263     public void pauseRecording() {
264         pauseRecording(Bundle.EMPTY);
265     }
266 
267     /**
268      * Pause TV program recording in the current recording session. Recording is expected to pause
269      * immediately when this method is called. If recording has not yet started in the current
270      * recording session, this method does nothing.
271      *
272      * <p>In pause status, the application can tune during recording. To continue recording,
273      * please call {@link TvRecordingClient#resumeRecording()} to resume instead of
274      * {@link TvRecordingClient#startRecording(Uri)}. Application can stop
275      * the recording with {@link TvRecordingClient#stopRecording()} in recording pause status.
276      *
277      * <p>If the pause request cannot be fulfilled, the recording session will respond by calling
278      * {@link RecordingCallback#onError(int)}.
279      *
280      * @param params Domain-specific data for this request.
281      */
pauseRecording(@onNull Bundle params)282     public void pauseRecording(@NonNull Bundle params) {
283         if (!mIsRecordingStarted || mIsRecordingStopping) {
284             throw new IllegalStateException(
285                     "pauseRecording failed - recording not yet started or stopping");
286         }
287         TvInputInfo info = mTvInputManager.getTvInputInfo(mSessionCallback.mInputId);
288         if (info == null || !info.canPauseRecording()) {
289             throw new UnsupportedOperationException(
290                     "pauseRecording failed - operation not supported");
291         }
292         if (mIsPaused) {
293             Log.w(TAG, "pauseRecording failed - recording already paused");
294         }
295         if (mSession != null) {
296             mSession.pauseRecording(params);
297             mIsPaused  = true;
298         }
299     }
300 
301     /**
302      * Resume TV program recording only in recording pause status in the current recording session.
303      * Recording is expected to resume immediately when this method is called. If recording has not
304      * yet paused in the current recording session, this method does nothing.
305      *
306      * <p>When record is resumed, the recording is continue and can not re-tune. Application can
307      * stop the recording with {@link TvRecordingClient#stopRecording()} after record resumed.
308      *
309      * <p>If the pause request cannot be fulfilled, the recording session will respond by calling
310      * {@link RecordingCallback#onError(int)}.
311      */
resumeRecording()312     public void resumeRecording() {
313         resumeRecording(Bundle.EMPTY);
314     }
315 
316     /**
317      * Resume TV program recording only in recording pause status in the current recording session.
318      * Recording is expected to resume immediately when this method is called. If recording has not
319      * yet paused in the current recording session, this method does nothing.
320      *
321      * <p>When record is resumed, the recording is continues and can not re-tune. Application can
322      * stop the recording with {@link TvRecordingClient#stopRecording()} after record resumed.
323      *
324      * <p>If the resume request cannot be fulfilled, the recording session will respond by calling
325      * {@link RecordingCallback#onError(int)}.
326      *
327      * @param params Domain-specific data for this request.
328      */
resumeRecording(@onNull Bundle params)329     public void resumeRecording(@NonNull Bundle params) {
330         if (!mIsRecordingStarted || mIsRecordingStopping || !mIsTuned) {
331             throw new IllegalStateException(
332                     "resumeRecording failed - recording not yet started or stopping or "
333                             + "not yet tuned");
334         }
335         if (!mIsPaused) {
336             Log.w(TAG, "resumeRecording failed - recording not yet paused");
337         }
338         if (mSession != null) {
339             mSession.resumeRecording(params);
340             mIsPaused  = false;
341         }
342     }
343 
344     /**
345      * Sends a private command to the underlying TV input. This can be used to provide
346      * domain-specific features that are only known between certain clients and their TV inputs.
347      *
348      * @param action The name of the private command to send. This <em>must</em> be a scoped name,
349      *            i.e. prefixed with a package name you own, so that different developers will not
350      *            create conflicting commands.
351      * @param data An optional bundle to send with the command.
352      */
sendAppPrivateCommand(@onNull String action, Bundle data)353     public void sendAppPrivateCommand(@NonNull String action, Bundle data) {
354         if (TextUtils.isEmpty(action)) {
355             throw new IllegalArgumentException("action cannot be null or an empty string");
356         }
357         if (mSession != null) {
358             mSession.sendAppPrivateCommand(action, data);
359         } else {
360             Log.w(TAG, "sendAppPrivateCommand - session not yet created (action \"" + action
361                     + "\" pending)");
362             mPendingAppPrivateCommands.add(Pair.create(action, data));
363         }
364     }
365 
366     /**
367      * Callback used to receive various status updates on the
368      * {@link android.media.tv.TvInputService.RecordingSession}
369      */
370     public abstract static class RecordingCallback {
371         /**
372          * This is called when an error occurred while establishing a connection to the recording
373          * session for the corresponding TV input.
374          *
375          * @param inputId The ID of the TV input bound to the current TvRecordingClient.
376          */
onConnectionFailed(String inputId)377         public void onConnectionFailed(String inputId) {
378         }
379 
380         /**
381          * This is called when the connection to the current recording session is lost.
382          *
383          * @param inputId The ID of the TV input bound to the current TvRecordingClient.
384          */
onDisconnected(String inputId)385         public void onDisconnected(String inputId) {
386         }
387 
388         /**
389          * This is called when the recording session has been tuned to the given channel and is
390          * ready to start recording.
391          *
392          * @param channelUri The URI of a channel.
393          */
onTuned(Uri channelUri)394         public void onTuned(Uri channelUri) {
395         }
396 
397         /**
398          * This is called when the current recording session has stopped recording and created a
399          * new data entry in the {@link TvContract.RecordedPrograms} table that describes the newly
400          * recorded program.
401          *
402          * @param recordedProgramUri The URI for the newly recorded program.
403          */
onRecordingStopped(Uri recordedProgramUri)404         public void onRecordingStopped(Uri recordedProgramUri) {
405         }
406 
407         /**
408          * This is called when an issue has occurred. It may be called at any time after the current
409          * recording session is created until it is released.
410          *
411          * @param error The error code. Should be one of the followings.
412          * <ul>
413          * <li>{@link TvInputManager#RECORDING_ERROR_UNKNOWN}
414          * <li>{@link TvInputManager#RECORDING_ERROR_INSUFFICIENT_SPACE}
415          * <li>{@link TvInputManager#RECORDING_ERROR_RESOURCE_BUSY}
416          * </ul>
417          */
onError(@vInputManager.RecordingError int error)418         public void onError(@TvInputManager.RecordingError int error) {
419         }
420 
421         /**
422          * This is invoked when a custom event from the bound TV input is sent to this client.
423          *
424          * @param inputId The ID of the TV input bound to this client.
425          * @param eventType The type of the event.
426          * @param eventArgs Optional arguments of the event.
427          * @hide
428          */
429         @SystemApi
onEvent(String inputId, String eventType, Bundle eventArgs)430         public void onEvent(String inputId, String eventType, Bundle eventArgs) {
431         }
432     }
433 
434     private class MySessionCallback extends TvInputManager.SessionCallback {
435         final String mInputId;
436         Uri mChannelUri;
437         Bundle mConnectionParams;
438 
MySessionCallback(String inputId, Uri channelUri, Bundle connectionParams)439         MySessionCallback(String inputId, Uri channelUri, Bundle connectionParams) {
440             mInputId = inputId;
441             mChannelUri = channelUri;
442             mConnectionParams = connectionParams;
443         }
444 
445         @Override
onSessionCreated(TvInputManager.Session session)446         public void onSessionCreated(TvInputManager.Session session) {
447             if (DEBUG) {
448                 Log.d(TAG, "onSessionCreated()");
449             }
450             if (this != mSessionCallback) {
451                 Log.w(TAG, "onSessionCreated - session already created");
452                 // This callback is obsolete.
453                 if (session != null) {
454                     session.release();
455                 }
456                 return;
457             }
458             mSession = session;
459             if (session != null) {
460                 // Sends the pending app private commands.
461                 for (Pair<String, Bundle> command : mPendingAppPrivateCommands) {
462                     mSession.sendAppPrivateCommand(command.first, command.second);
463                 }
464                 mPendingAppPrivateCommands.clear();
465                 mSession.tune(mChannelUri, mConnectionParams);
466             } else {
467                 mSessionCallback = null;
468                 if (mCallback != null) {
469                     mCallback.onConnectionFailed(mInputId);
470                 }
471             }
472         }
473 
474         @Override
onTuned(TvInputManager.Session session, Uri channelUri)475         void onTuned(TvInputManager.Session session, Uri channelUri) {
476             if (DEBUG) {
477                 Log.d(TAG, "onTuned()");
478             }
479             if (this != mSessionCallback) {
480                 Log.w(TAG, "onTuned - session not created");
481                 return;
482             }
483             if (mIsTuned || !Objects.equals(mChannelUri, channelUri)) {
484                 Log.w(TAG, "onTuned - already tuned or not yet tuned to last channel");
485                 return;
486             }
487             mIsTuned = true;
488             mCallback.onTuned(channelUri);
489         }
490 
491         @Override
onSessionReleased(TvInputManager.Session session)492         public void onSessionReleased(TvInputManager.Session session) {
493             if (DEBUG) {
494                 Log.d(TAG, "onSessionReleased()");
495             }
496             if (this != mSessionCallback) {
497                 Log.w(TAG, "onSessionReleased - session not created");
498                 return;
499             }
500             mIsTuned = false;
501             mIsRecordingStarted = false;
502             mIsPaused = false;
503             mIsRecordingStopping = false;
504             mSessionCallback = null;
505             mSession = null;
506             if (mCallback != null) {
507                 mCallback.onDisconnected(mInputId);
508             }
509         }
510 
511         @Override
onRecordingStopped(TvInputManager.Session session, Uri recordedProgramUri)512         public void onRecordingStopped(TvInputManager.Session session, Uri recordedProgramUri) {
513             if (DEBUG) {
514                 Log.d(TAG, "onRecordingStopped(recordedProgramUri= " + recordedProgramUri + ")");
515             }
516             if (this != mSessionCallback) {
517                 Log.w(TAG, "onRecordingStopped - session not created");
518                 return;
519             }
520             if (!mIsRecordingStarted) {
521                 Log.w(TAG, "onRecordingStopped - recording not yet started");
522                 return;
523             }
524             mIsRecordingStarted = false;
525             mIsPaused = false;
526             mIsRecordingStopping = false;
527             mCallback.onRecordingStopped(recordedProgramUri);
528         }
529 
530         @Override
onError(TvInputManager.Session session, int error)531         public void onError(TvInputManager.Session session, int error) {
532             if (DEBUG) {
533                 Log.d(TAG, "onError(error=" + error + ")");
534             }
535             if (this != mSessionCallback) {
536                 Log.w(TAG, "onError - session not created");
537                 return;
538             }
539             mCallback.onError(error);
540         }
541 
542         @Override
onSessionEvent(TvInputManager.Session session, String eventType, Bundle eventArgs)543         public void onSessionEvent(TvInputManager.Session session, String eventType,
544                 Bundle eventArgs) {
545             if (DEBUG) {
546                 Log.d(TAG, "onSessionEvent(eventType=" + eventType + ", eventArgs=" + eventArgs
547                         + ")");
548             }
549             if (this != mSessionCallback) {
550                 Log.w(TAG, "onSessionEvent - session not created");
551                 return;
552             }
553             if (mCallback != null) {
554                 mCallback.onEvent(mInputId, eventType, eventArgs);
555             }
556         }
557     }
558 }
559