diff options
Diffstat (limited to 'tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms')
16 files changed, 2307 insertions, 0 deletions
diff --git a/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/AppLinkActivity.java b/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/AppLinkActivity.java new file mode 100644 index 00000000..7b1d1393 --- /dev/null +++ b/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/AppLinkActivity.java @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2017 Google Inc. + * + * 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.example.android.tv.channelsprograms; + +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.util.Log; +import android.widget.Toast; + +import com.example.android.tv.channelsprograms.model.MockDatabase; +import com.example.android.tv.channelsprograms.model.Movie; +import com.example.android.tv.channelsprograms.model.Subscription; +import com.example.android.tv.channelsprograms.playback.PlaybackActivity; +import com.example.android.tv.channelsprograms.util.AppLinkHelper; + +/** + * Delegates to the correct activity based on how the user entered the app. + * + * <p>Supports two options: view and play. The view option will open the channel for the user to be + * able to view more programs. The play option will load the channel/program, + * subscriptions/mediaContent start playing the movie. + */ +public class AppLinkActivity extends Activity { + + private static final String TAG = "AppLinkActivity"; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + Intent intent = getIntent(); + Uri uri = intent.getData(); + + Log.v(TAG, uri.toString()); + + if (uri.getPathSegments().isEmpty()) { + Log.e(TAG, "Invalid uri " + uri); + finish(); + return; + } + + AppLinkHelper.AppLinkAction action = AppLinkHelper.extractAction(uri); + switch (action.getAction()) { + case AppLinkHelper.PLAYBACK: + play((AppLinkHelper.PlaybackAction) action); + break; + case AppLinkHelper.BROWSE: + browse((AppLinkHelper.BrowseAction) action); + break; + default: + throw new IllegalArgumentException("Invalid Action " + action); + } + } + + private void browse(AppLinkHelper.BrowseAction action) { + Subscription subscription = + MockDatabase.findSubscriptionByName(this, action.getSubscriptionName()); + if (subscription == null) { + Log.e(TAG, "Invalid subscription " + action.getSubscriptionName()); + } else { + // TODO: Open an activity that has the movies for the subscription. + Toast.makeText(this, action.getSubscriptionName(), Toast.LENGTH_LONG).show(); + } + finish(); + } + + private void play(AppLinkHelper.PlaybackAction action) { + if (action.getPosition() == AppLinkHelper.DEFAULT_POSITION) { + Log.d( + TAG, + "Playing program " + + action.getMovieId() + + " from channel " + + action.getChannelId()); + } else { + Log.d( + TAG, + "Continuing program " + + action.getMovieId() + + " from channel " + + action.getChannelId() + + " at time " + + action.getPosition()); + } + + Movie movie = MockDatabase.findMovieById(this, action.getChannelId(), action.getMovieId()); + if (movie == null) { + Log.e(TAG, "Invalid program " + action.getMovieId()); + } else { + startPlaying(action.getChannelId(), movie, action.getPosition()); + } + finish(); + } + + private void startPlaying(long channelId, Movie movie, long position) { + Intent playMovieIntent = new Intent(this, PlaybackActivity.class); + playMovieIntent.putExtra(PlaybackActivity.EXTRA_MOVIE, movie); + playMovieIntent.putExtra(PlaybackActivity.EXTRA_CHANNEL_ID, channelId); + playMovieIntent.putExtra(PlaybackActivity.EXTRA_POSITION, position); + startActivity(playMovieIntent); + } +} diff --git a/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/InitializeChannelsReceiver.java b/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/InitializeChannelsReceiver.java new file mode 100644 index 00000000..7ddb2f9c --- /dev/null +++ b/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/InitializeChannelsReceiver.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2017 Google Inc. + * + * 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.example.android.tv.channelsprograms; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +import com.example.android.tv.channelsprograms.util.TvUtil; + +/** Initializes channels and programs at installation time. */ +public class InitializeChannelsReceiver extends BroadcastReceiver { + + private static final String TAG = "InitializeChannelsRcvr"; + + @Override + public void onReceive(Context context, Intent intent) { + Log.d(TAG, "onReceive(): " + intent); + + TvUtil.scheduleSyncingChannel(context); + } +} diff --git a/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/MainActivity.java b/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/MainActivity.java new file mode 100644 index 00000000..04f09909 --- /dev/null +++ b/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/MainActivity.java @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2017 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.example.android.tv.channelsprograms; + +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.os.AsyncTask; +import android.os.Bundle; +import android.support.media.tv.TvContractCompat; +import android.util.Log; +import android.widget.Button; +import android.widget.Toast; + +import com.example.android.tv.channelsprograms.model.MockDatabase; +import com.example.android.tv.channelsprograms.model.Subscription; +import com.example.android.tv.channelsprograms.util.TvUtil; + +import java.util.Arrays; +import java.util.List; + +/* + * Displays subscriptions that can be added to the main launcher's channels. + */ +public class MainActivity extends Activity { + + private static final String TAG = "MainActivity"; + + private static final int MAKE_BROWSABLE_REQUEST_CODE = 9001; + + private Button mTvSubscribeButton; + private Button mVideoClipSubscribeButton; + private Button mCatVideosSubscribeButton; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + + mTvSubscribeButton = findViewById(R.id.subscribe_tv_button); + mVideoClipSubscribeButton = findViewById(R.id.subscribe_video_button); + mCatVideosSubscribeButton = findViewById(R.id.subscribe_cat_videos_button); + + final Subscription tvShowSubscription = + MockDatabase.getTvShowSubscription(getApplicationContext()); + setupButtonState(mTvSubscribeButton, tvShowSubscription); + + final Subscription videoSubscription = + MockDatabase.getVideoSubscription(getApplicationContext()); + setupButtonState(mVideoClipSubscribeButton, videoSubscription); + + final Subscription catVideosSubscription = + MockDatabase.getCatVideosSubscription(getApplicationContext()); + setupButtonState(mCatVideosSubscribeButton, catVideosSubscription); + + TvUtil.scheduleSyncingChannel(this); + } + + private void setupButtonState(Button button, final Subscription subscription) { + boolean channelExists = subscription.getChannelId() > 0L; + button.setEnabled(!channelExists); + button.setOnClickListener( + view -> new AddChannelTask(getApplicationContext()).execute(subscription)); + } + + private class AddChannelTask extends AsyncTask<Subscription, Void, Long> { + + private final Context mContext; + + AddChannelTask(Context context) { + this.mContext = context; + } + + @Override + protected Long doInBackground(Subscription... varArgs) { + List<Subscription> subscriptions = Arrays.asList(varArgs); + if (subscriptions.size() != 1) { + return -1L; + } + Subscription subscription = subscriptions.get(0); + // TODO: step 16 create channel. Replace declaration with code from code lab. + long channelId = TvUtil.createChannel(mContext, subscription); + + subscription.setChannelId(channelId); + MockDatabase.saveSubscription(mContext, subscription); + // Scheduler listen on channel's uri. Updates after the user interacts with the system + // dialog. + TvUtil.scheduleSyncingProgramsForChannel(getApplicationContext(), channelId); + return channelId; + } + + @Override + protected void onPostExecute(Long channelId) { + super.onPostExecute(channelId); + promptUserToDisplayChannel(channelId); + } + } + + private void promptUserToDisplayChannel(long channelId) { + // TODO: step 17 prompt user. + Intent intent = new Intent(TvContractCompat.ACTION_REQUEST_CHANNEL_BROWSABLE); + intent.putExtra(TvContractCompat.EXTRA_CHANNEL_ID, channelId); + try { + this.startActivityForResult(intent, MAKE_BROWSABLE_REQUEST_CODE); + } catch (ActivityNotFoundException e) { + Log.e(TAG, "Could not start activity: " + intent.getAction(), e); + } + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + // TODO step 18 handle response + if (resultCode == RESULT_OK) { + Toast.makeText(this, R.string.channel_added, Toast.LENGTH_LONG).show(); + } else { + Toast.makeText(this, R.string.channel_not_added, Toast.LENGTH_LONG).show(); + } + } +} diff --git a/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/SyncChannelJobService.java b/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/SyncChannelJobService.java new file mode 100644 index 00000000..45d4a3e4 --- /dev/null +++ b/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/SyncChannelJobService.java @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2017 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.example.android.tv.channelsprograms; + +import android.app.job.JobParameters; +import android.app.job.JobService; +import android.content.Context; +import android.os.AsyncTask; +import android.support.media.tv.TvContractCompat; +import android.util.Log; + +import com.example.android.tv.channelsprograms.model.MockDatabase; +import com.example.android.tv.channelsprograms.model.MockMovieService; +import com.example.android.tv.channelsprograms.model.Subscription; +import com.example.android.tv.channelsprograms.util.TvUtil; + +import java.util.List; + +/** + * Populates the TV provider with channels that every user should have. Once a channel is created, + * it triggers another service to add programs. + */ +public class SyncChannelJobService extends JobService { + + private static final String TAG = "RecommendChannelJobSvc"; + + private SyncChannelTask mSyncChannelTask; + + @Override + public boolean onStartJob(final JobParameters jobParameters) { + Log.d(TAG, "Starting channel creation job"); + mSyncChannelTask = + new SyncChannelTask(getApplicationContext()) { + @Override + protected void onPostExecute(Boolean success) { + super.onPostExecute(success); + jobFinished(jobParameters, !success); + } + }; + mSyncChannelTask.execute(); + return true; + } + + @Override + public boolean onStopJob(JobParameters jobParameters) { + if (mSyncChannelTask != null) { + mSyncChannelTask.cancel(true); + } + return true; + } + + private static class SyncChannelTask extends AsyncTask<Void, Void, Boolean> { + + private final Context mContext; + + SyncChannelTask(Context context) { + this.mContext = context; + } + + @Override + protected Boolean doInBackground(Void... voids) { + List<Subscription> subscriptions = MockDatabase.getSubscriptions(mContext); + int numOfChannelsInTVProvider = TvUtil.getNumberOfChannels(mContext); + // Checks if the default channels are added. Since a user can add more channels from + // your app later, the number of channels in the provider can be greater than the number + // of default channels. + if (numOfChannelsInTVProvider >= subscriptions.size() && !subscriptions.isEmpty()) { + Log.d(TAG, "Already loaded default channels into the provider"); + } else { + // Create subscriptions from mocked source. + subscriptions = MockMovieService.createUniversalSubscriptions(mContext); + for (Subscription subscription : subscriptions) { + long channelId = TvUtil.createChannel(mContext, subscription); + subscription.setChannelId(channelId); + TvContractCompat.requestChannelBrowsable(mContext, channelId); + } + + MockDatabase.saveSubscriptions(mContext, subscriptions); + } + + // Kick off a job to update default programs. + // The program job should verify if the channel is visible before updating programs. + for (Subscription channel : subscriptions) { + TvUtil.scheduleSyncingProgramsForChannel(mContext, channel.getChannelId()); + } + return true; + } + } +} diff --git a/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/SyncProgramsJobService.java b/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/SyncProgramsJobService.java new file mode 100644 index 00000000..47fde6a6 --- /dev/null +++ b/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/SyncProgramsJobService.java @@ -0,0 +1,241 @@ +/* + * Copyright (C) 2017 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.example.android.tv.channelsprograms; + +import android.app.job.JobParameters; +import android.app.job.JobService; +import android.content.ContentUris; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.PersistableBundle; +import android.support.annotation.NonNull; +import android.support.media.tv.Channel; +import android.support.media.tv.PreviewProgram; +import android.support.media.tv.TvContractCompat; +import android.util.Log; + +import com.example.android.tv.channelsprograms.model.MockDatabase; +import com.example.android.tv.channelsprograms.model.MockMovieService; +import com.example.android.tv.channelsprograms.model.Movie; +import com.example.android.tv.channelsprograms.model.Subscription; +import com.example.android.tv.channelsprograms.util.AppLinkHelper; +import com.example.android.tv.channelsprograms.util.TvUtil; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Syncs programs for a channel. A channel id is required to be passed via the {@link + * JobParameters}. This service is scheduled to listen to changes to a channel. Once the job + * completes, it will reschedule itself to listen for the next change to the channel. See {@link + * TvUtil#scheduleSyncingProgramsForChannel(Context, long)} for more details about the scheduling. + */ +public class SyncProgramsJobService extends JobService { + + private static final String TAG = "SyncProgramsJobService"; + + private SyncProgramsTask mSyncProgramsTask; + + @Override + public boolean onStartJob(final JobParameters jobParameters) { + Log.d(TAG, "onStartJob(): " + jobParameters); + + final long channelId = getChannelId(jobParameters); + if (channelId == -1L) { + return false; + } + Log.d(TAG, "onStartJob(): Scheduling syncing for programs for channel " + channelId); + + mSyncProgramsTask = + new SyncProgramsTask(getApplicationContext()) { + @Override + protected void onPostExecute(Boolean finished) { + super.onPostExecute(finished); + // Daisy chain listening for the next change to the channel. + TvUtil.scheduleSyncingProgramsForChannel( + SyncProgramsJobService.this, channelId); + mSyncProgramsTask = null; + jobFinished(jobParameters, !finished); + } + }; + mSyncProgramsTask.execute(channelId); + + return true; + } + + @Override + public boolean onStopJob(JobParameters jobParameters) { + if (mSyncProgramsTask != null) { + mSyncProgramsTask.cancel(true); + } + return true; + } + + private long getChannelId(JobParameters jobParameters) { + PersistableBundle extras = jobParameters.getExtras(); + if (extras == null) { + return -1L; + } + + return extras.getLong(TvContractCompat.EXTRA_CHANNEL_ID, -1L); + } + + /* + * Syncs programs by querying the given channel id. + * + * If the channel is not browsable, the programs will be removed to avoid showing + * stale programs when the channel becomes browsable in the future. + * + * If the channel is browsable, then it will check if the channel has any programs. + * If the channel does not have any programs, new programs will be added. + * If the channel does have programs, then a fresh list of programs will be fetched and the + * channel's programs will be updated. + */ + private void syncPrograms(long channelId, List<Movie> initialMovies) { + Log.d(TAG, "Sync programs for channel: " + channelId); + List<Movie> movies = new ArrayList<>(initialMovies); + + try (Cursor cursor = + getContentResolver() + .query( + TvContractCompat.buildChannelUri(channelId), + null, + null, + null, + null)) { + if (cursor != null && cursor.moveToNext()) { + Channel channel = Channel.fromCursor(cursor); + if (!channel.isBrowsable()) { + Log.d(TAG, "Channel is not browsable: " + channelId); + deletePrograms(channelId, movies); + } else { + Log.d(TAG, "Channel is browsable: " + channelId); + if (movies.isEmpty()) { + movies = createPrograms(channelId, MockMovieService.getList()); + } else { + movies = updatePrograms(channelId, movies); + } + MockDatabase.saveMovies(getApplicationContext(), channelId, movies); + } + } + } + } + + private List<Movie> createPrograms(long channelId, List<Movie> movies) { + + List<Movie> moviesAdded = new ArrayList<>(movies.size()); + for (Movie movie : movies) { + PreviewProgram previewProgram = buildProgram(channelId, movie); + + Uri programUri = + getContentResolver() + .insert( + TvContractCompat.PreviewPrograms.CONTENT_URI, + previewProgram.toContentValues()); + long programId = ContentUris.parseId(programUri); + Log.d(TAG, "Inserted new program: " + programId); + movie.setProgramId(programId); + moviesAdded.add(movie); + } + + return moviesAdded; + } + + private List<Movie> updatePrograms(long channelId, List<Movie> movies) { + + // By getting a fresh list, we should see a visible change in the home screen. + List<Movie> updateMovies = MockMovieService.getFreshList(); + for (int i = 0; i < movies.size(); ++i) { + Movie old = movies.get(i); + Movie update = updateMovies.get(i); + long programId = old.getProgramId(); + + getContentResolver() + .update( + TvContractCompat.buildPreviewProgramUri(programId), + buildProgram(channelId, update).toContentValues(), + null, + null); + Log.d(TAG, "Updated program: " + programId); + update.setProgramId(programId); + } + + return updateMovies; + } + + private void deletePrograms(long channelId, List<Movie> movies) { + if (movies.isEmpty()) { + return; + } + + int count = 0; + for (Movie movie : movies) { + count += + getContentResolver() + .delete( + TvContractCompat.buildPreviewProgramUri(movie.getProgramId()), + null, + null); + } + Log.d(TAG, "Deleted " + count + " programs for channel " + channelId); + + // Remove our local records to stay in sync with the TV Provider. + MockDatabase.removeMovies(getApplicationContext(), channelId); + } + + @NonNull + private PreviewProgram buildProgram(long channelId, Movie movie) { + Uri posterArtUri = Uri.parse(movie.getCardImageUrl()); + Uri appLinkUri = AppLinkHelper.buildPlaybackUri(channelId, movie.getId()); + Uri previewVideoUri = Uri.parse(movie.getVideoUrl()); + + PreviewProgram.Builder builder = new PreviewProgram.Builder(); + builder.setChannelId(channelId) + .setType(TvContractCompat.PreviewProgramColumns.TYPE_CLIP) + .setTitle(movie.getTitle()) + .setDescription(movie.getDescription()) + .setPosterArtUri(posterArtUri) + .setPreviewVideoUri(previewVideoUri) + .setIntentUri(appLinkUri); + return builder.build(); + } + + private class SyncProgramsTask extends AsyncTask<Long, Void, Boolean> { + + private final Context mContext; + + private SyncProgramsTask(Context context) { + this.mContext = context; + } + + @Override + protected Boolean doInBackground(Long... channelIds) { + List<Long> params = Arrays.asList(channelIds); + if (!params.isEmpty()) { + for (Long channelId : params) { + Subscription subscription = + MockDatabase.findSubscriptionByChannelId(mContext, channelId); + if (subscription != null) { + List<Movie> cachedMovies = MockDatabase.getMovies(mContext, channelId); + syncPrograms(channelId, cachedMovies); + } + } + } + return true; + } + } +} diff --git a/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/model/MockDatabase.java b/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/model/MockDatabase.java new file mode 100644 index 00000000..f540c395 --- /dev/null +++ b/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/model/MockDatabase.java @@ -0,0 +1,273 @@ +/* + * Copyright (C) 2017 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.example.android.tv.channelsprograms.model; + +import android.content.Context; +import android.content.SharedPreferences; +import android.support.annotation.DrawableRes; +import android.support.annotation.Nullable; +import android.support.annotation.StringRes; + +import com.example.android.tv.channelsprograms.R; +import com.example.android.tv.channelsprograms.util.AppLinkHelper; +import com.example.android.tv.channelsprograms.util.SharedPreferencesHelper; + +import java.util.Collections; +import java.util.List; + +/** Mock database stores data in {@link SharedPreferences}. */ +public final class MockDatabase { + + private MockDatabase() { + // Do nothing. + } + + /** + * Returns a subscription to mock content representing tv shows. + * + * @param context used for accessing shared preferences. + * @return a subscription with tv show data. + */ + public static Subscription getTvShowSubscription(Context context) { + + return findOrCreateSubscription( + context, + R.string.title_tv_shows, + R.string.tv_shows_description, + R.drawable.ic_video_library_blue_80dp); + } + + /** + * Returns a subscription to mock content representing your videos. + * + * @param context used for accessing shared preferences. + * @return a subscription with your video data. + */ + public static Subscription getVideoSubscription(Context context) { + + return findOrCreateSubscription( + context, + R.string.your_videos, + R.string.your_videos_description, + R.drawable.ic_video_library_blue_80dp); + } + + /** + * Returns a subscription to mock content representing cat videos. + * + * @param context used for accessing shared preferences. + * @return a subscription with cat videos. + */ + public static Subscription getCatVideosSubscription(Context context) { + + return findOrCreateSubscription( + context, + R.string.cat_videos, + R.string.cat_videos_description, + R.drawable.ic_movie_blue_80dp); + } + + private static Subscription findOrCreateSubscription( + Context context, + @StringRes int titleResource, + @StringRes int descriptionResource, + @DrawableRes int logoResource) { + // See if we have already created the channel in the TV Provider. + String title = context.getString(titleResource); + + Subscription subscription = findSubscriptionByTitle(context, title); + if (subscription != null) { + return subscription; + } + + return Subscription.createSubscription( + title, + context.getString(descriptionResource), + AppLinkHelper.buildBrowseUri(title).toString(), + logoResource); + } + + @Nullable + private static Subscription findSubscriptionByTitle(Context context, String title) { + for (Subscription subscription : getSubscriptions(context)) { + if (subscription.getName().equals(title)) { + return subscription; + } + } + return null; + } + + /** + * Overrides the subscriptions stored in {@link SharedPreferences}. + * + * @param context used for accessing shared preferences. + * @param subscriptions stored in shared preferences. + */ + public static void saveSubscriptions(Context context, List<Subscription> subscriptions) { + SharedPreferencesHelper.storeSubscriptions(context, subscriptions); + } + + /** + * Adds the subscription to the list of persisted subscriptions in {@link SharedPreferences}. + * Will update the persisted subscription if it already exists. + * + * @param context used for accessing shared preferences. + * @param subscription to be saved. + */ + public static void saveSubscription(Context context, Subscription subscription) { + List<Subscription> subscriptions = getSubscriptions(context); + int index = findSubscription(subscriptions, subscription); + if (index == -1) { + subscriptions.add(subscription); + } else { + subscriptions.set(index, subscription); + } + saveSubscriptions(context, subscriptions); + } + + private static int findSubscription( + List<Subscription> subscriptions, Subscription subscription) { + for (int index = 0; index < subscriptions.size(); ++index) { + Subscription current = subscriptions.get(index); + if (current.getName().equals(subscription.getName())) { + return index; + } + } + return -1; + } + + /** + * Returns subscriptions stored in {@link SharedPreferences}. + * + * @param context used for accessing shared preferences. + * @return a list of subscriptions or empty list if none exist. + */ + public static List<Subscription> getSubscriptions(Context context) { + return SharedPreferencesHelper.readSubscriptions(context); + } + + /** + * Finds a subscription given a channel id that the subscription is associated with. + * + * @param context used for accessing shared preferences. + * @param channelId of the channel that the subscription is associated with. + * @return a subscription or null if none exist. + */ + @Nullable + public static Subscription findSubscriptionByChannelId(Context context, long channelId) { + for (Subscription subscription : getSubscriptions(context)) { + if (subscription.getChannelId() == channelId) { + return subscription; + } + } + return null; + } + + /** + * Finds a subscription with the given name. + * + * @param context used for accessing shared preferences. + * @param name of the subscription. + * @return a subscription or null if none exist. + */ + @Nullable + public static Subscription findSubscriptionByName(Context context, String name) { + for (Subscription subscription : getSubscriptions(context)) { + if (subscription.getName().equals(name)) { + return subscription; + } + } + return null; + } + + /** + * Overrides the movies stored in {@link SharedPreferences} for a given subscription. + * + * @param context used for accessing shared preferences. + * @param channelId of the channel that the movies are associated with. + * @param movies to be stored. + */ + public static void saveMovies(Context context, long channelId, List<Movie> movies) { + SharedPreferencesHelper.storeMovies(context, channelId, movies); + } + + /** + * Removes the list of movies associated with a channel. Overrides the current list with an + * empty list in {@link SharedPreferences}. + * + * @param context used for accessing shared preferences. + * @param channelId of the channel that the movies are associated with. + */ + public static void removeMovies(Context context, long channelId) { + saveMovies(context, channelId, Collections.<Movie>emptyList()); + } + + /** + * Finds movie in subscriptions with channel id and updates it. Otherwise will add the new movie + * to the subscription. + * + * @param context to access shared preferences. + * @param channelId of the subscription that the movie is associated with. + * @param movie to be persisted or updated. + */ + public static void saveMovie(Context context, long channelId, Movie movie) { + List<Movie> movies = getMovies(context, channelId); + int index = findMovie(movies, movie); + if (index == -1) { + movies.add(movie); + } else { + movies.set(index, movie); + } + saveMovies(context, channelId, movies); + } + + private static int findMovie(List<Movie> movies, Movie movie) { + for (int index = 0; index < movies.size(); ++index) { + Movie current = movies.get(index); + if (current.getId() == movie.getId()) { + return index; + } + } + return -1; + } + + /** + * Returns movies stored in {@link SharedPreferences} for a given subscription. + * + * @param context used for accessing shared preferences. + * @param channelId of the subscription that the movie is associated with. + * @return a list of movies for a subscription + */ + public static List<Movie> getMovies(Context context, long channelId) { + return SharedPreferencesHelper.readMovies(context, channelId); + } + + /** + * Finds a movie in a subscription by its id. + * + * @param context to access shared preferences. + * @param channelId of the subscription that the movie is associated with. + * @param movieId of the movie. + * @return a movie or null if none exist. + */ + @Nullable + public static Movie findMovieById(Context context, long channelId, long movieId) { + for (Movie movie : getMovies(context, channelId)) { + if (movie.getId() == movieId) { + return movie; + } + } + return null; + } +} diff --git a/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/model/MockMovieService.java b/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/model/MockMovieService.java new file mode 100644 index 00000000..53b9d19a --- /dev/null +++ b/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/model/MockMovieService.java @@ -0,0 +1,173 @@ +/* + * Copyright (C) 2017 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.example.android.tv.channelsprograms.model; + +import android.content.Context; + +import com.example.android.tv.channelsprograms.R; +import com.example.android.tv.channelsprograms.util.AppLinkHelper; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** Mocks gathering movies from an external source. */ +public final class MockMovieService { + + private static List<Movie> list; + private static long count = 0; + + /** + * Creates a list of subscriptions that every users should have. + * + * @param context used for accessing shared preferences. + * @return a list of default subscriptions. + */ + public static List<Subscription> createUniversalSubscriptions(Context context) { + + String newForYou = context.getString(R.string.new_for_you); + Subscription flagshipSubscription = + Subscription.createSubscription( + newForYou, + context.getString(R.string.new_for_you_description), + AppLinkHelper.buildBrowseUri(newForYou).toString(), + R.drawable.ic_movie_blue_80dp); + + String trendingVideos = context.getString(R.string.trending_videos); + Subscription videoSubscription = + Subscription.createSubscription( + trendingVideos, + context.getString(R.string.trending_videos_description), + AppLinkHelper.buildBrowseUri(trendingVideos).toString(), + R.drawable.ic_movie_blue_80dp); + + String featuredFilms = context.getString(R.string.featured_films); + Subscription filmsSubscription = + Subscription.createSubscription( + featuredFilms, + context.getString(R.string.featured_films_description), + AppLinkHelper.buildBrowseUri(featuredFilms).toString(), + R.drawable.ic_video_library_blue_80dp); + + return Arrays.asList(flagshipSubscription, videoSubscription, filmsSubscription); + } + + /** + * Creates and caches a list of movies. + * + * @return a list of movies. + */ + public static List<Movie> getList() { + if (list == null || list.isEmpty()) { + list = createMovieList(); + } + return list; + } + + /** + * Shuffles the list of movies to make the returned list appear to be a different list from + * {@link #getList()}. + * + * @return a list of movies in random order. + */ + public static List<Movie> getFreshList() { + List<Movie> shuffledMovies = new ArrayList<>(getList()); + Collections.shuffle(shuffledMovies); + return shuffledMovies; + } + + private static List<Movie> createMovieList() { + List<Movie> list = new ArrayList<>(); + String title[] = { + "Zeitgeist 2010_ Year in Review", + "Google Demo Slam_ 20ft Search", + "Introducing Gmail Blue", + "Introducing Google Fiber to the Pole", + "Introducing Google Nose" + }; + + String description = + "Fusce id nisi turpis. Praesent viverra bibendum semper. " + + "Donec tristique, orci sed semper lacinia, quam erat rhoncus massa, non congue tellus est " + + "quis tellus. Sed mollis orci venenatis quam scelerisque accumsan. Curabitur a massa sit " + + "amet mi accumsan mollis sed et magna. Vivamus sed aliquam risus. Nulla eget dolor in elit " + + "facilisis mattis. Ut aliquet luctus lacus. Phasellus nec commodo erat. Praesent tempus id " + + "lectus ac scelerisque. Maecenas pretium cursus lectus id volutpat."; + + String studio[] = { + "Studio Zero", "Studio One", "Studio Two", "Studio Three", "Studio Four" + }; + String videoUrl[] = { + "http://commondatastorage.googleapis.com/android-tv/Sample%20videos/Zeitgeist/Zeitgeist%202010_%20Year%20in%20Review.mp4", + "http://commondatastorage.googleapis.com/android-tv/Sample%20videos/Demo%20Slam/Google%20Demo%20Slam_%2020ft%20Search.mp4", + "http://commondatastorage.googleapis.com/android-tv/Sample%20videos/April%20Fool's%202013/Introducing%20Gmail%20Blue.mp4", + "http://commondatastorage.googleapis.com/android-tv/Sample%20videos/April%20Fool's%202013/Introducing%20Google%20Fiber%20to%20the%20Pole.mp4", + "http://commondatastorage.googleapis.com/android-tv/Sample%20videos/April%20Fool's%202013/Introducing%20Google%20Nose.mp4" + }; + String bgImageUrl[] = { + "http://commondatastorage.googleapis.com/android-tv/Sample%20videos/Zeitgeist/Zeitgeist%202010_%20Year%20in%20Review/bg.jpg", + "http://commondatastorage.googleapis.com/android-tv/Sample%20videos/Demo%20Slam/Google%20Demo%20Slam_%2020ft%20Search/bg.jpg", + "http://commondatastorage.googleapis.com/android-tv/Sample%20videos/April%20Fool's%202013/Introducing%20Gmail%20Blue/bg.jpg", + "http://commondatastorage.googleapis.com/android-tv/Sample%20videos/April%20Fool's%202013/Introducing%20Google%20Fiber%20to%20the%20Pole/bg.jpg", + "http://commondatastorage.googleapis.com/android-tv/Sample%20videos/April%20Fool's%202013/Introducing%20Google%20Nose/bg.jpg", + }; + String cardImageUrl[] = { + "http://commondatastorage.googleapis.com/android-tv/Sample%20videos/Zeitgeist/Zeitgeist%202010_%20Year%20in%20Review/card.jpg", + "http://commondatastorage.googleapis.com/android-tv/Sample%20videos/Demo%20Slam/Google%20Demo%20Slam_%2020ft%20Search/card.jpg", + "http://commondatastorage.googleapis.com/android-tv/Sample%20videos/April%20Fool's%202013/Introducing%20Gmail%20Blue/card.jpg", + "http://commondatastorage.googleapis.com/android-tv/Sample%20videos/April%20Fool's%202013/Introducing%20Google%20Fiber%20to%20the%20Pole/card.jpg", + "http://commondatastorage.googleapis.com/android-tv/Sample%20videos/April%20Fool's%202013/Introducing%20Google%20Nose/card.jpg" + }; + + for (int index = 0; index < title.length; ++index) { + list.add( + buildMovieInfo( + "category", + title[index], + description, + studio[index], + videoUrl[index], + cardImageUrl[index], + bgImageUrl[index])); + } + + return list; + } + + private static Movie buildMovieInfo( + String category, + String title, + String description, + String studio, + String videoUrl, + String cardImageUrl, + String backgroundImageUrl) { + Movie movie = new Movie(); + movie.setId(count); + incCount(); + movie.setTitle(title); + movie.setDescription(description); + movie.setStudio(studio); + movie.setCategory(category); + movie.setCardImageUrl(cardImageUrl); + movie.setBackgroundImageUrl(backgroundImageUrl); + movie.setVideoUrl(videoUrl); + return movie; + } + + private static void incCount() { + count++; + } +} diff --git a/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/model/Movie.java b/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/model/Movie.java new file mode 100644 index 00000000..ea59c572 --- /dev/null +++ b/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/model/Movie.java @@ -0,0 +1,143 @@ +/* + * Copyright (C) 2017 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.example.android.tv.channelsprograms.model; + +import java.io.Serializable; + +/** Represents a video entity with title, description, image thumbs and video url. */ +public class Movie implements Serializable { + + private static final String TAG = "Movie"; + + static final long serialVersionUID = 727566175075960653L; + private long id; + private String title; + private String description; + private String bgImageUrl; + private String cardImageUrl; + private String videoUrl; + private String studio; + private String category; + // Program id / Watch Next id returned from the TV Provider. + private long programId; + private long watchNextId; + + public Movie() {} + + public long getProgramId() { + return programId; + } + + public void setProgramId(long programId) { + this.programId = programId; + } + + public long getWatchNextId() { + return watchNextId; + } + + public void setWatchNextId(long watchNextId) { + this.watchNextId = watchNextId; + } + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getStudio() { + return studio; + } + + public void setStudio(String studio) { + this.studio = studio; + } + + public String getVideoUrl() { + return videoUrl; + } + + public void setVideoUrl(String videoUrl) { + this.videoUrl = videoUrl; + } + + public String getBackgroundImageUrl() { + return bgImageUrl; + } + + public void setBackgroundImageUrl(String bgImageUrl) { + this.bgImageUrl = bgImageUrl; + } + + public String getCardImageUrl() { + return cardImageUrl; + } + + public void setCardImageUrl(String cardImageUrl) { + this.cardImageUrl = cardImageUrl; + } + + public String getCategory() { + return category; + } + + public void setCategory(String category) { + this.category = category; + } + + @Override + public String toString() { + return "Movie{" + + "id=" + + id + + ", programId='" + + programId + + '\'' + + ", watchNextId='" + + watchNextId + + '\'' + + ", title='" + + title + + '\'' + + ", videoUrl='" + + videoUrl + + '\'' + + ", backgroundImageUrl='" + + bgImageUrl + + '\'' + + ", cardImageUrl='" + + cardImageUrl + + '\'' + + '}'; + } +} diff --git a/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/model/Subscription.java b/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/model/Subscription.java new file mode 100644 index 00000000..ad8f558b --- /dev/null +++ b/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/model/Subscription.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2017 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.example.android.tv.channelsprograms.model; + +/** Contains the data about a channel that will be displayed on the launcher. */ +public class Subscription { + + private long channelId; + private String name; + private String description; + private String appLinkIntentUri; + private int channelLogo; + + /** Constructor for Gson to use. */ + public Subscription() {} + + private Subscription( + String name, String description, String appLinkIntentUri, int channelLogo) { + this.name = name; + this.description = description; + this.appLinkIntentUri = appLinkIntentUri; + this.channelLogo = channelLogo; + } + + public static Subscription createSubscription( + String name, String description, String appLinkIntentUri, int channelLogo) { + return new Subscription(name, description, appLinkIntentUri, channelLogo); + } + + public long getChannelId() { + return channelId; + } + + public void setChannelId(long channelId) { + this.channelId = channelId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getAppLinkIntentUri() { + return appLinkIntentUri; + } + + public void setAppLinkIntentUri(String appLinkIntentUri) { + this.appLinkIntentUri = appLinkIntentUri; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public int getChannelLogo() { + return channelLogo; + } + + public void setChannelLogo(int channelLogo) { + this.channelLogo = channelLogo; + } +} diff --git a/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/playback/PlaybackActivity.java b/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/playback/PlaybackActivity.java new file mode 100644 index 00000000..f08e931b --- /dev/null +++ b/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/playback/PlaybackActivity.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2017 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.example.android.tv.channelsprograms.playback; + +import android.app.Activity; +import android.os.Bundle; + +import com.example.android.tv.channelsprograms.R; + +/** Loads {@link PlaybackVideoFragment}. */ +public class PlaybackActivity extends Activity { + + public static final String EXTRA_MOVIE = "com.example.android.tv.recommendations.extra.MOVIE"; + public static final String EXTRA_CHANNEL_ID = + "com.example.android.tv.recommendations.extra.CHANNEL_ID"; + public static final String EXTRA_POSITION = + "com.example.android.tv.recommendations.extra.POSITION"; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.playback_controls); + } +} diff --git a/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/playback/PlaybackVideoFragment.java b/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/playback/PlaybackVideoFragment.java new file mode 100644 index 00000000..560b2d45 --- /dev/null +++ b/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/playback/PlaybackVideoFragment.java @@ -0,0 +1,135 @@ +/* + * Copyright (C) 2017 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.example.android.tv.channelsprograms.playback; + +import android.net.Uri; +import android.os.Bundle; +import android.support.v17.leanback.app.VideoFragment; +import android.support.v17.leanback.app.VideoFragmentGlueHost; +import android.support.v17.leanback.media.MediaPlayerAdapter; +import android.support.v17.leanback.media.PlaybackGlue; +import android.support.v17.leanback.widget.PlaybackControlsRow; +import android.util.Log; + +import com.example.android.tv.channelsprograms.model.Movie; + +/** Handles video playback with media controls. */ +public class PlaybackVideoFragment extends VideoFragment { + + private static final String TAG = "VideoFragment"; + + private SimplePlaybackTransportControlGlue<MediaPlayerAdapter> mMediaPlayerGlue; + + private long mChannelId; + private long mStartingPosition; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + mChannelId = getActivity().getIntent().getLongExtra(PlaybackActivity.EXTRA_CHANNEL_ID, -1L); + mStartingPosition = + getActivity().getIntent().getLongExtra(PlaybackActivity.EXTRA_POSITION, -1L); + final Movie movie = + (Movie) + getActivity() + .getIntent() + .getSerializableExtra(PlaybackActivity.EXTRA_MOVIE); + + VideoFragmentGlueHost glueHost = new VideoFragmentGlueHost(PlaybackVideoFragment.this); + + mMediaPlayerGlue = + new SimplePlaybackTransportControlGlue<>( + getActivity(), new MediaPlayerAdapter(getActivity())); + mMediaPlayerGlue.setHost(glueHost); + mMediaPlayerGlue.setRepeatMode(PlaybackControlsRow.RepeatAction.NONE); + mMediaPlayerGlue.addPlayerCallback( + new PlaybackGlue.PlayerCallback() { + WatchNextAdapter watchNextAdapter = new WatchNextAdapter(); + + @Override + public void onPlayStateChanged(PlaybackGlue glue) { + super.onPlayStateChanged(glue); + // TODO: step 10 update progress. + long position = mMediaPlayerGlue.getCurrentPosition(); + long duration = mMediaPlayerGlue.getDuration(); + watchNextAdapter.updateProgress( + getContext(), mChannelId, movie, position, duration); + } + + @Override + public void onPlayCompleted(PlaybackGlue glue) { + super.onPlayCompleted(glue); + // TODO: step 11 remove watch next. + watchNextAdapter.removeFromWatchNext( + getContext(), mChannelId, movie.getId()); + } + }); + + mMediaPlayerGlue.setTitle(movie.getTitle()); + mMediaPlayerGlue.setSubtitle(movie.getDescription()); + mMediaPlayerGlue.getPlayerAdapter().setDataSource(Uri.parse(movie.getVideoUrl())); + seekToStartingPosition(); + playWhenReady(mMediaPlayerGlue); + } + + @Override + public void onPause() { + if (mMediaPlayerGlue != null) { + mMediaPlayerGlue.pause(); + } + super.onPause(); + } + + private void playWhenReady(final PlaybackGlue glue) { + if (glue.isPrepared()) { + glue.play(); + } else { + glue.addPlayerCallback( + new PlaybackGlue.PlayerCallback() { + @Override + public void onPreparedStateChanged(PlaybackGlue glue) { + if (glue.isPrepared()) { + glue.removePlayerCallback(this); + glue.play(); + } + } + }); + } + } + + private void seekToStartingPosition() { + // Skip ahead if given a starting position. + if (mStartingPosition > -1L) { + if (mMediaPlayerGlue.isPrepared()) { + Log.d("VideoFragment", "Is prepped, seeking to " + mStartingPosition); + mMediaPlayerGlue.seekTo(mStartingPosition); + } else { + mMediaPlayerGlue.addPlayerCallback( + new PlaybackGlue.PlayerCallback() { + @Override + public void onPreparedStateChanged(PlaybackGlue glue) { + super.onPreparedStateChanged(glue); + if (mMediaPlayerGlue.isPrepared()) { + mMediaPlayerGlue.removePlayerCallback(this); + Log.d(TAG, "In callback, seeking to " + mStartingPosition); + mMediaPlayerGlue.seekTo(mStartingPosition); + } + } + }); + } + } + } +} diff --git a/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/playback/SimplePlaybackTransportControlGlue.java b/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/playback/SimplePlaybackTransportControlGlue.java new file mode 100644 index 00000000..9f197875 --- /dev/null +++ b/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/playback/SimplePlaybackTransportControlGlue.java @@ -0,0 +1,160 @@ +/* + * Copyright (C) 2017 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.example.android.tv.channelsprograms.playback; + +import android.content.Context; +import android.os.Handler; +import android.support.v17.leanback.media.PlaybackTransportControlGlue; +import android.support.v17.leanback.media.PlayerAdapter; +import android.support.v17.leanback.widget.Action; +import android.support.v17.leanback.widget.ArrayObjectAdapter; +import android.support.v17.leanback.widget.PlaybackControlsRow; +import android.view.KeyEvent; +import android.view.View; +import android.widget.Toast; + +/** + * Handles common primary and secondary actions such as repeat, thumbs up/down, picture in picture, + * and closed captions. + */ +class SimplePlaybackTransportControlGlue<T extends PlayerAdapter> + extends PlaybackTransportControlGlue<T> { + + private PlaybackControlsRow.RepeatAction mRepeatAction; + private PlaybackControlsRow.ThumbsUpAction mThumbsUpAction; + private PlaybackControlsRow.ThumbsDownAction mThumbsDownAction; + private PlaybackControlsRow.PictureInPictureAction mPipAction; + private PlaybackControlsRow.ClosedCaptioningAction mClosedCaptioningAction; + private Handler mHandler = new Handler(); + + public SimplePlaybackTransportControlGlue(Context context, T impl) { + super(context, impl); + mClosedCaptioningAction = new PlaybackControlsRow.ClosedCaptioningAction(context); + mThumbsUpAction = new PlaybackControlsRow.ThumbsUpAction(context); + mThumbsUpAction.setIndex(PlaybackControlsRow.ThumbsUpAction.OUTLINE); + mThumbsDownAction = new PlaybackControlsRow.ThumbsDownAction(context); + mThumbsDownAction.setIndex(PlaybackControlsRow.ThumbsDownAction.OUTLINE); + mRepeatAction = new PlaybackControlsRow.RepeatAction(context); + mPipAction = new PlaybackControlsRow.PictureInPictureAction(context); + } + + @Override + protected void onCreatePrimaryActions(ArrayObjectAdapter adapter) { + super.onCreatePrimaryActions(adapter); + adapter.add(mRepeatAction); + adapter.add(mClosedCaptioningAction); + } + + @Override + protected void onCreateSecondaryActions(ArrayObjectAdapter adapter) { + super.onCreateSecondaryActions(adapter); + adapter.add(mThumbsUpAction); + adapter.add(mThumbsDownAction); + adapter.add(mPipAction); + } + + @Override + public void onActionClicked(Action action) { + if (shouldDispatchAction(action)) { + dispatchAction(action, getPrimaryActionsAdapter()); + dispatchAction(action, getSecondaryActionsAdapter()); + return; + } + super.onActionClicked(action); + } + + @Override + public boolean onKey(View view, int keyCode, KeyEvent keyEvent) { + if (keyEvent.getAction() == KeyEvent.ACTION_DOWN) { + boolean dispatched = dispatchAction(keyEvent, getPrimaryActionsAdapter()); + dispatched |= dispatchAction(keyEvent, getSecondaryActionsAdapter()); + if (dispatched) { + return true; + } + } + return super.onKey(view, keyCode, keyEvent); + } + + private boolean dispatchAction(KeyEvent keyEvent, ArrayObjectAdapter adapter) { + Action action = getControlsRow().getActionForKeyCode(adapter, keyEvent.getKeyCode()); + if (shouldDispatchAction(action)) { + dispatchAction(action, adapter); + return true; + } + return false; + } + + private boolean shouldDispatchAction(Action action) { + return action == mRepeatAction || action == mThumbsUpAction || action == mThumbsDownAction; + } + + private void dispatchAction(Action action, ArrayObjectAdapter adapter) { + Toast.makeText(getContext(), action.toString(), Toast.LENGTH_SHORT).show(); + PlaybackControlsRow.MultiAction multiAction = (PlaybackControlsRow.MultiAction) action; + multiAction.nextIndex(); + notifyActionChanged(multiAction, adapter); + } + + private void notifyActionChanged( + PlaybackControlsRow.MultiAction action, ArrayObjectAdapter adapter) { + if (adapter != null) { + int index = adapter.indexOf(action); + if (index >= 0) { + adapter.notifyArrayItemRangeChanged(index, 1); + } + } + } + + private ArrayObjectAdapter getPrimaryActionsAdapter() { + if (getControlsRow() == null) { + return null; + } + return (ArrayObjectAdapter) getControlsRow().getPrimaryActionsAdapter(); + } + + private ArrayObjectAdapter getSecondaryActionsAdapter() { + if (getControlsRow() == null) { + return null; + } + return (ArrayObjectAdapter) getControlsRow().getSecondaryActionsAdapter(); + } + + @Override + protected void onPlayCompleted() { + super.onPlayCompleted(); + mHandler.post( + () -> { + if (mRepeatAction.getIndex() != PlaybackControlsRow.RepeatAction.INDEX_NONE) { + play(); + } + }); + } + + /** + * Sets the behavior for the repeat action. The possible modes are + * + * <ul> + * <li>{@link PlaybackControlsRow.RepeatAction#INDEX_NONE} + * <li>{@link PlaybackControlsRow.RepeatAction#INDEX_ALL} + * <li>{@link PlaybackControlsRow.RepeatAction#INDEX_ONE} + * </ul> + * + * @param mode for repeat behavior. + */ + public void setRepeatMode(int mode) { + mRepeatAction.setIndex(mode); + notifyActionChanged(mRepeatAction, getPrimaryActionsAdapter()); + } +} diff --git a/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/playback/WatchNextAdapter.java b/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/playback/WatchNextAdapter.java new file mode 100644 index 00000000..a06c3e31 --- /dev/null +++ b/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/playback/WatchNextAdapter.java @@ -0,0 +1,116 @@ +/* + * Copyright (C) 2017 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.example.android.tv.channelsprograms.playback; + +import android.content.ContentUris; +import android.content.Context; +import android.net.Uri; +import android.support.annotation.NonNull; +import android.support.media.tv.TvContractCompat; +import android.support.media.tv.WatchNextProgram; +import android.util.Log; + +import com.example.android.tv.channelsprograms.model.MockDatabase; +import com.example.android.tv.channelsprograms.model.Movie; +import com.example.android.tv.channelsprograms.util.AppLinkHelper; + +/** Adds, updates, and removes the currently playing {@link Movie} from the "Watch Next" channel. */ +public class WatchNextAdapter { + + private static final String TAG = "WatchNextAdapter"; + + public void updateProgress( + Context context, long channelId, Movie movie, long position, long duration) { + Log.d(TAG, String.format("Updating the movie (%d) in watch next.", movie.getId())); + + Movie entity = MockDatabase.findMovieById(context, channelId, movie.getId()); + if (entity == null) { + Log.e( + TAG, + String.format( + "Could not find movie in channel: channel id: %d, movie id: %d", + channelId, movie.getId())); + return; + } + + // TODO: step 12 add watch next program. + WatchNextProgram program = createWatchNextProgram(channelId, entity, position, duration); + if (entity.getWatchNextId() < 1L) { + // Create a program. + Uri watchNextProgramUri = + context.getContentResolver() + .insert( + TvContractCompat.WatchNextPrograms.CONTENT_URI, + program.toContentValues()); + long watchNextId = ContentUris.parseId(watchNextProgramUri); + entity.setWatchNextId(watchNextId); + MockDatabase.saveMovie(context, channelId, entity); + + Log.d(TAG, "Watch Next program added: " + watchNextId); + } else { + // TODO: step 14 update program. + // Updates the progress and last engagement time of the program. + context.getContentResolver() + .update( + TvContractCompat.buildWatchNextProgramUri(entity.getWatchNextId()), + program.toContentValues(), + null, + null); + + Log.d(TAG, "Watch Next program updated: " + entity.getWatchNextId()); + } + } + + @NonNull + private WatchNextProgram createWatchNextProgram( + long channelId, Movie movie, long position, long duration) { + // TODO: step 13 convert movie + Uri posterArtUri = Uri.parse(movie.getCardImageUrl()); + Uri intentUri = AppLinkHelper.buildPlaybackUri(channelId, movie.getId(), position); + + WatchNextProgram.Builder builder = new WatchNextProgram.Builder(); + builder.setType(TvContractCompat.PreviewProgramColumns.TYPE_MOVIE) + .setWatchNextType(TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE) + .setLastEngagementTimeUtcMillis(System.currentTimeMillis()) + .setLastPlaybackPositionMillis((int) position) + .setDurationMillis((int) duration) + .setTitle(movie.getTitle()) + .setDescription(movie.getDescription()) + .setPosterArtUri(posterArtUri) + .setIntentUri(intentUri); + return builder.build(); + } + + public void removeFromWatchNext(Context context, long channelId, long movieId) { + Movie movie = MockDatabase.findMovieById(context, channelId, movieId); + if (movie == null || movie.getWatchNextId() < 1L) { + Log.d(TAG, "No program to remove from watch next."); + return; + } + + // TODO: step 15 remove program + int rows = + context.getContentResolver() + .delete( + TvContractCompat.buildWatchNextProgramUri(movie.getWatchNextId()), + null, + null); + Log.d(TAG, String.format("Deleted %d programs(s) from watch next", rows)); + + // Sync our records with the system; remove reference to watch next program. + movie.setWatchNextId(-1); + MockDatabase.saveMovie(context, channelId, movie); + } +} diff --git a/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/util/AppLinkHelper.java b/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/util/AppLinkHelper.java new file mode 100644 index 00000000..a530cdad --- /dev/null +++ b/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/util/AppLinkHelper.java @@ -0,0 +1,215 @@ +package com.example.android.tv.channelsprograms.util; + +import android.net.Uri; +import android.support.annotation.StringDef; + +import java.util.List; + +/** Builds and parses uris for deep linking within the app. */ +public class AppLinkHelper { + + private static final String SCHEMA_URI_PREFIX = "tvrecommendation://app/"; + public static final String PLAYBACK = "playback"; + public static final String BROWSE = "browse"; + private static final String URI_PLAY = SCHEMA_URI_PREFIX + PLAYBACK; + private static final String URI_VIEW = SCHEMA_URI_PREFIX + BROWSE; + private static final int URI_INDEX_OPTION = 0; + private static final int URI_INDEX_CHANNEL = 1; + private static final int URI_INDEX_MOVIE = 2; + private static final int URI_INDEX_POSITION = 3; + public static final int DEFAULT_POSITION = -1; + + /** + * Builds a {@link Uri} for deep link into playing a movie from the beginning. + * + * @param channelId - id of the channel the movie is in. + * @param movieId - id of the movie. + * @return a uri. + */ + public static Uri buildPlaybackUri(long channelId, long movieId) { + return buildPlaybackUri(channelId, movieId, DEFAULT_POSITION); + } + + /** + * Builds a {@link Uri} to deep link into continue playing a movie from a position. + * + * @param channelId - id of the channel the movie is in. + * @param movieId - id of the movie. + * @param position - position to continue playing. + * @return a uri. + */ + public static Uri buildPlaybackUri(long channelId, long movieId, long position) { + return Uri.parse(URI_PLAY) + .buildUpon() + .appendPath(String.valueOf(channelId)) + .appendPath(String.valueOf(movieId)) + .appendPath(String.valueOf(position)) + .build(); + } + + /** + * Builds a {@link Uri} to deep link into viewing a subscription. + * + * @param subscriptionName - name of the subscription. + * @return a uri. + */ + public static Uri buildBrowseUri(String subscriptionName) { + return Uri.parse(URI_VIEW).buildUpon().appendPath(subscriptionName).build(); + } + + /** + * Returns an {@link AppLinkAction} for the given Uri. + * + * @param uri to determine the intended action. + * @return an action. + */ + public static AppLinkAction extractAction(Uri uri) { + if (isPlaybackUri(uri)) { + return new PlaybackAction( + extractChannelId(uri), extractMovieId(uri), extractPosition(uri)); + } else if (isBrowseUri(uri)) { + return new BrowseAction(extractSubscriptionName(uri)); + } + throw new IllegalArgumentException("No action found for uri " + uri); + } + + /** + * Tests if the {@link Uri} was built for playing a movie. + * + * @param uri to examine. + * @return true if the uri is for playing a movie. + */ + private static boolean isPlaybackUri(Uri uri) { + if (uri.getPathSegments().isEmpty()) { + return false; + } + String option = uri.getPathSegments().get(URI_INDEX_OPTION); + return PLAYBACK.equals(option); + } + + /** + * Tests if a {@link Uri} was built for browsing a subscription. + * + * @param uri to examine. + * @return true if the Uri is for browsing a subscription. + */ + private static boolean isBrowseUri(Uri uri) { + if (uri.getPathSegments().isEmpty()) { + return false; + } + String option = uri.getPathSegments().get(URI_INDEX_OPTION); + return BROWSE.equals(option); + } + + /** + * Extracts the subscription name from the {@link Uri}. + * + * @param uri that contains a subscription name. + * @return the subscription name. + */ + private static String extractSubscriptionName(Uri uri) { + return extract(uri, URI_INDEX_CHANNEL); + } + + /** + * Extracts the channel id from the {@link Uri}. + * + * @param uri that contains a channel id. + * @return the channel id. + */ + private static long extractChannelId(Uri uri) { + return extractLong(uri, URI_INDEX_CHANNEL); + } + + /** + * Extracts the movie id from the {@link Uri}. + * + * @param uri that contains a movie id. + * @return the movie id. + */ + private static long extractMovieId(Uri uri) { + return extractLong(uri, URI_INDEX_MOVIE); + } + + /** + * Extracts the playback mPosition from the {@link Uri}. + * + * @param uri that contains a playback mPosition. + * @return the playback mPosition. + */ + private static long extractPosition(Uri uri) { + return extractLong(uri, URI_INDEX_POSITION); + } + + private static long extractLong(Uri uri, int index) { + return Long.valueOf(extract(uri, index)); + } + + private static String extract(Uri uri, int index) { + List<String> pathSegments = uri.getPathSegments(); + if (pathSegments.isEmpty() || pathSegments.size() < index) { + return null; + } + return pathSegments.get(index); + } + + @StringDef({BROWSE, PLAYBACK}) + public @interface ActionFlags {} + + /** Action for deep linking. */ + public interface AppLinkAction { + /** Returns an string representation of the action. */ + @ActionFlags + String getAction(); + } + + /** Browse a subscription. */ + public static class BrowseAction implements AppLinkAction { + + private final String mSubscriptionName; + + private BrowseAction(String subscriptionName) { + this.mSubscriptionName = subscriptionName; + } + + public String getSubscriptionName() { + return mSubscriptionName; + } + + @Override + public String getAction() { + return BROWSE; + } + } + + /** Play a movie. */ + public static class PlaybackAction implements AppLinkAction { + + private final long mChannelId; + private final long mMovieId; + private final long mPosition; + + private PlaybackAction(long channelId, long movieId, long position) { + this.mChannelId = channelId; + this.mMovieId = movieId; + this.mPosition = position; + } + + public long getChannelId() { + return mChannelId; + } + + public long getMovieId() { + return mMovieId; + } + + public long getPosition() { + return mPosition; + } + + @Override + public String getAction() { + return PLAYBACK; + } + } +} diff --git a/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/util/SharedPreferencesHelper.java b/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/util/SharedPreferencesHelper.java new file mode 100644 index 00000000..79a0341c --- /dev/null +++ b/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/util/SharedPreferencesHelper.java @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2017 Google Inc. + * + * 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.example.android.tv.channelsprograms.util; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Log; + +import com.example.android.tv.channelsprograms.model.Movie; +import com.example.android.tv.channelsprograms.model.Subscription; +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * Helper class to store {@link Subscription}s and {@link Movie}s in {@link SharedPreferences}. + * + * <p>SharedPreferencesHelper provides static methods to set and get these objects. + * + * <p>The methods of this class should not be called on the UI thread. Marshalling an object into + * JSON can be expensive for large objects. + */ +public final class SharedPreferencesHelper { + + private static final String TAG = "SharedPreferencesHelper"; + + private static final String PREFS_NAME = "com.example.android.tv.recommendations"; + private static final String PREFS_SUBSCRIPTIONS_KEY = + "com.example.android.tv.recommendations.prefs.SUBSCRIPTIONS"; + private static final String PREFS_SUBSCRIBED_MOVIES_PREFIX = + "com.example.android.tv.recommendations.prefs.SUBSCRIBED_MOVIES_"; + + private static final Gson mGson = new Gson(); + + /** + * Reads the {@link List <Subscription>} from {@link SharedPreferences}. + * + * @param context used for getting an instance of shared preferences. + * @return a list of subscriptions or an empty list if none exist. + */ + public static List<Subscription> readSubscriptions(Context context) { + return getList(context, Subscription.class, PREFS_SUBSCRIPTIONS_KEY); + } + + /** + * Overrides the subscriptions stored in {@link SharedPreferences}. + * + * @param context used for getting an instance of shared preferences. + * @param subscriptions to be stored in shared preferences. + */ + public static void storeSubscriptions(Context context, List<Subscription> subscriptions) { + setList(context, subscriptions, PREFS_SUBSCRIPTIONS_KEY); + } + + /** + * Reads the {@link List <Movie>} from {@link SharedPreferences} for a given channel. + * + * @param context used for getting an instance of shared preferences. + * @param channelId of the channel that the movies are associated with. + * @return a list of movies or an empty list if none exist. + */ + public static List<Movie> readMovies(Context context, long channelId) { + return getList(context, Movie.class, PREFS_SUBSCRIBED_MOVIES_PREFIX + channelId); + } + + /** + * Overrides the movies stored in {@link SharedPreferences} for the associated channel id. + * + * @param context used for getting an instance of shared preferences. + * @param channelId of the channel that the movies are associated with. + * @param movies to be stored. + */ + public static void storeMovies(Context context, long channelId, List<Movie> movies) { + setList(context, movies, PREFS_SUBSCRIBED_MOVIES_PREFIX + channelId); + } + + /** + * Retrieves a set of Strings from {@link SharedPreferences} and returns as a List. + * + * @param context used for getting an instance of shared preferences. + * @param clazz the class that the strings will be unmarshalled into. + * @param key the key in shared preferences to access the string set. + * @param <T> the type of object that will be in the returned list, should be the same as the + * clazz that was supplied. + * @return a list of <T> objects that were stored in shared preferences or an empty list if no + * objects exists. + */ + private static <T> List<T> getList(Context context, Class<T> clazz, String key) { + SharedPreferences sharedPreferences = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + Set<String> stringSet = sharedPreferences.getStringSet(key, new HashSet<String>()); + if (stringSet.isEmpty()) { + // Favoring mutability of the list over Collections.emptyList(). + return new ArrayList<>(); + } + List<T> list = new ArrayList<>(stringSet.size()); + try { + for (String contactString : stringSet) { + list.add(mGson.fromJson(contactString, clazz)); + } + } catch (JsonSyntaxException e) { + Log.e(TAG, "Could not parse json.", e); + return Collections.emptyList(); + } + return list; + } + + /** + * Saves a list of Strings into {@link SharedPreferences}. + * + * @param context used for getting an instance of shared preferences. + * @param list of <T> object that need to be persisted. + * @param key the key in shared preferences which the string set will be stored. + * @param <T> type the of object we will be marshalling and persisting. + */ + private static <T> void setList(Context context, List<T> list, String key) { + SharedPreferences sharedPreferences = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + SharedPreferences.Editor editor = sharedPreferences.edit(); + + Set<String> strings = new LinkedHashSet<>(list.size()); + for (T item : list) { + strings.add(mGson.toJson(item)); + } + editor.putStringSet(key, strings); + editor.apply(); + } +} diff --git a/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/util/TvUtil.java b/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/util/TvUtil.java new file mode 100644 index 00000000..25bb8a4a --- /dev/null +++ b/tv/ChannelsPrograms/Application/src/main/java/com/example/android/tv/channelsprograms/util/TvUtil.java @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2017 Google Inc. + * + * 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.example.android.tv.channelsprograms.util; + +import android.app.job.JobInfo; +import android.app.job.JobScheduler; +import android.content.ComponentName; +import android.content.ContentUris; +import android.content.Context; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.VectorDrawable; +import android.media.tv.TvContract; +import android.net.Uri; +import android.os.PersistableBundle; +import android.support.annotation.NonNull; +import android.support.annotation.WorkerThread; +import android.support.media.tv.Channel; +import android.support.media.tv.ChannelLogoUtils; +import android.support.media.tv.TvContractCompat; +import android.util.Log; + +import com.example.android.tv.channelsprograms.SyncChannelJobService; +import com.example.android.tv.channelsprograms.SyncProgramsJobService; +import com.example.android.tv.channelsprograms.model.Subscription; + +/** Manages interactions with the TV Provider. */ +public class TvUtil { + + private static final String TAG = "TvUtil"; + private static final long CHANNEL_JOB_ID_OFFSET = 1000; + + private static final String[] CHANNELS_PROJECTION = { + TvContractCompat.Channels._ID, + TvContract.Channels.COLUMN_DISPLAY_NAME, + TvContractCompat.Channels.COLUMN_BROWSABLE + }; + + /** + * Converts a {@link Subscription} into a {@link Channel} and adds it to the tv provider. + * + * @param context used for accessing a content resolver. + * @param subscription to be converted to a channel and added to the tv provider. + * @return the id of the channel that the tv provider returns. + */ + @WorkerThread + public static long createChannel(Context context, Subscription subscription) { + + // Checks if our subscription has been added to the channels before. + Cursor cursor = + context.getContentResolver() + .query( + TvContractCompat.Channels.CONTENT_URI, + CHANNELS_PROJECTION, + null, + null, + null); + if (cursor != null && cursor.moveToFirst()) { + do { + Channel channel = Channel.fromCursor(cursor); + if (subscription.getName().equals(channel.getDisplayName())) { + Log.d( + TAG, + "Channel already exists. Returning channel " + + channel.getId() + + " from TV Provider."); + return channel.getId(); + } + } while (cursor.moveToNext()); + } + + // Create the channel since it has not been added to the TV Provider. + Uri appLinkIntentUri = Uri.parse(subscription.getAppLinkIntentUri()); + + Channel.Builder builder = new Channel.Builder(); + builder.setType(TvContractCompat.Channels.TYPE_PREVIEW) + .setDisplayName(subscription.getName()) + .setDescription(subscription.getDescription()) + .setAppLinkIntentUri(appLinkIntentUri); + + Log.d(TAG, "Creating channel: " + subscription.getName()); + Uri channelUrl = + context.getContentResolver() + .insert( + TvContractCompat.Channels.CONTENT_URI, + builder.build().toContentValues()); + + Log.d(TAG, "channel insert at " + channelUrl); + long channelId = ContentUris.parseId(channelUrl); + Log.d(TAG, "channel id " + channelId); + + Bitmap bitmap = convertToBitmap(context, subscription.getChannelLogo()); + ChannelLogoUtils.storeChannelLogo(context, channelId, bitmap); + + return channelId; + } + + public static int getNumberOfChannels(Context context) { + Cursor cursor = + context.getContentResolver() + .query( + TvContractCompat.Channels.CONTENT_URI, + CHANNELS_PROJECTION, + null, + null, + null); + return cursor != null ? cursor.getCount() : 0; + } + + /** + * Converts a resource into a {@link Bitmap}. If the resource is a vector drawable, it will be + * drawn into a new Bitmap. Otherwise the {@link BitmapFactory} will decode the resource. + * + * @param context used for getting the drawable from resources. + * @param resourceId of the drawable. + * @return a bitmap of the resource. + */ + @NonNull + public static Bitmap convertToBitmap(Context context, int resourceId) { + Drawable drawable = context.getDrawable(resourceId); + if (drawable instanceof VectorDrawable) { + Bitmap bitmap = + Bitmap.createBitmap( + drawable.getIntrinsicWidth(), + drawable.getIntrinsicHeight(), + Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); + drawable.draw(canvas); + return bitmap; + } + + return BitmapFactory.decodeResource(context.getResources(), resourceId); + } + + /** + * Schedules syncing channels via a {@link JobScheduler}. + * + * @param context for accessing the {@link JobScheduler}. + */ + public static void scheduleSyncingChannel(Context context) { + ComponentName componentName = new ComponentName(context, SyncChannelJobService.class); + JobInfo.Builder builder = new JobInfo.Builder(1, componentName); + builder.setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY); + + JobScheduler scheduler = + (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE); + + Log.d(TAG, "Scheduled channel creation."); + scheduler.schedule(builder.build()); + } + + /** + * Schedulers syncing programs for a channel. The scheduler will listen to a {@link Uri} for a + * particular channel. + * + * @param context for accessing the {@link JobScheduler}. + * @param channelId for the channel to listen for changes. + */ + public static void scheduleSyncingProgramsForChannel(Context context, long channelId) { + ComponentName componentName = new ComponentName(context, SyncProgramsJobService.class); + + JobInfo.Builder builder = + new JobInfo.Builder(getJobIdForChannelId(channelId), componentName); + + JobInfo.TriggerContentUri triggerContentUri = + new JobInfo.TriggerContentUri( + TvContractCompat.buildChannelUri(channelId), + JobInfo.TriggerContentUri.FLAG_NOTIFY_FOR_DESCENDANTS); + builder.addTriggerContentUri(triggerContentUri); + builder.setTriggerContentMaxDelay(0L); + builder.setTriggerContentUpdateDelay(0L); + + PersistableBundle bundle = new PersistableBundle(); + bundle.putLong(TvContractCompat.EXTRA_CHANNEL_ID, channelId); + builder.setExtras(bundle); + + JobScheduler scheduler = + (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE); + scheduler.cancel(getJobIdForChannelId(channelId)); + scheduler.schedule(builder.build()); + } + + private static int getJobIdForChannelId(long channelId) { + return (int) (CHANNEL_JOB_ID_OFFSET + channelId); + } +} |