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