In this codelab, you'll learn how to build an app that adds channels and programs to the new Android TV home screen. The home screen has more features than this code lab covers. Read the documentation to learn about all the features and capabilities of the new home screen.

Concepts

The home launcher lets your app create custom channels and content that your user can discover. Your app can offer any number of channels for the user to add to the launcher. The user usually has to select and approve each channel before it appears in the launcher. Every app has the option of creating one default channel. The default channel is special because it automatically appears in the launcher; the user does not have to explicitly request it.

The system uses a TV Provider which is a content provider that manages the channels and programs in the home screen. Your app also communicates with the TV Provider when you add and update channels and programs.

The support library has a class called TvContractCompat that contains constants and builder methods to help you work with channel and program data in a TV Provider.

Overview

This codelab shows how to create, add, and update channels and programs on the launcher screen. It uses a mock database of subscriptions and movies. For simplicity, the same the list of movies is used for all of the subscriptions.

Clone the starter project repo

This codelab uses Android Studio, an IDE for developing Android apps.

If you don't have it installed yet, please download and install it.

You need to download the source code for this codelab. You can either clone the repository from Github:

git clone https://github.com/googlecodelabs/tv-recommendations.git

...or you can download the repository as a zip file:

Download zip

Open Android Studio and click File > Open from the menu bar or Open an Existing Android Studio Project from the splash-screen and select the recently cloned folder.

Understanding the starter project

1-base is the base app that each successive step is based on.

In each step, you'll add more and more code to the 1-base app.

The other modules can be used as checkpoints to compare your work with the solution at each step along the way.

These are the main components in the app:

Run the starter project

Try running the project. If you have issues, see our documentation on how to get started.

  1. Connect your Android TV or start the emulator.
  1. Select the 1-base configuration and press the run button in the menu bar.
  2. Select your Android device and click OK.
  3. You should see a simple TV app outline with three buttons.

What you've learned

In this introduction, you've learned about:

What's next?

Adding channels to the launcher.

Start by adding channels to the launcher. Once you have channels, you can add programs to them, and users can discover your channels and select which ones to display in the launcher UI.

You should create all your channels when your app first launches. Use a JobService to add channels in a background thread. This codelab already schedules a job, SyncChannelJobService, in the MainActivity to help you get started.

You'll be adding code to the SyncChannelJobService class.

The class performs these tasks in the background:

  1. Retrieve TV channel subscription data from the our mock service.
  2. Create a channel for each subscription.
  3. Request that the system makes the channel visible in the launcher. (You will add code to do this to the class later.)
  4. Save the subscriptions in the database, including a new link to the corresponding channel ID.
  5. Schedule a background job to watch for updates on the channel to add programs.

Creating a channel

You must convert a Subscription into a channel and add it to the TV Provider. Be careful to only add each channel once: Query the TV Provider to see if the channel already exists.

Add the following code to the getChannelIdFromTvProvider() method (at TODO: step 1 query for channel):

Cursor cursor =
       context.getContentResolver()
               .query(
                       TvContractCompat.Channels.CONTENT_URI,
                       new String[] {
                               TvContractCompat.Channels._ID,
                               TvContract.Channels.COLUMN_DISPLAY_NAME
                       },
                       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());
}
return -1L;

If the channel did not exists, you must add it. Add the following code to the createChannel() method (at TODO: step 2 create a channel):

// Checks if our subscription has been added to the channels before.
long channelId = getChannelIdFromTvProvider(context, subscription);
if( channelId != -1L ) {
   return channelId;
}

// 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);
channelId = ContentUris.parseId(channelUrl);
Log.d(TAG, "channel id " + channelId);

Bitmap bitmap = TvUtil.convertToBitmap(context, subscription.getChannelLogo());
ChannelLogoUtils.storeChannelLogo(context, channelId, bitmap);

return channelId;

The method uses Channel.Builder to create an instance of the Channel class. The display name appears on the home screen. The system uses the app link intent Uri to launch the channel when the user selects its channel icon. There is an AppLinkActivity that handles the delegation of the app link Uris. Currently, the activity shows a toast when the user selects the channel from the launcher. For extra credit, try changing the AppLinkActivity to deep link into the main activity instead of displaying a toast.

The call to getContentResolver().insert() inserts the channel's content values into the TV Provider.

Finally, the method sets a logo image for the channel.

Note that the insert method returns a Uri for the channel This can be converted to a channel ID which is the return value for createChannel().

Make the default channel visible

When you add channels to the TV Provider, they are invisible. A Channel does not show up on the home screen until the user asks for it. The user usually has to select and approve each channel before it appears in the launcher. Every app has the option of creating one default channel. The default channel is special because it automatically appears in the launcher; the user does not have to explicitly request it.

Add the following code to the doInBackground() method (at TODO: step 3 make the channel visible):

TvContractCompat.requestChannelBrowsable(mContext, channelId);

Scheduling channel updates

A channel without content is useless. This codelab uses a scheduler to add programs to channels. This step only sets up the scheduler. You won't actually add programs until the next step.

Add the following code to the TvUtil class scheduleSyncingProgramsForChannel() method (at TODO: step 4 schedule a job):

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());

Since the TV Provider is a content provider, you can set up a JobService to be triggered upon updates from a particular Uri. You know each channel's ID, so you can listen to updates for a specific channel.

Run the app

When the app runs, the default channel appears but it does not have any programs.

Compare your code to the solution in the 2-channels directory.

What you've learned

What's next?

The next section shows to add programs to a channel.

Adding a program to a channel is very similar to creating a channel. Use PreviewProgram.Builder instead of Channel.Builder.

You'll be working with the syncPrograms() method in the SyncProgramsJobService class.

Verify the channel is visible

The first thing to do is make sure the channel is visible to the user. If the user cannot see the channel, you should not perform extra work that is not visible.

There are helper methods on the Channel class that convert a cursor's data into an object. To check if a channel is visible on the home screen, use the isBrowsable() method.

Add the following code to the syncPrograms() method (at TODO: step 5 check if visible):

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);
       }
   }
}

The code queries the TV Provider for a specific channel. Results are returned in a cursor and converted into a channel object.

If the channel is not currently visible, all the programs associated with the channel are deleted to prevent the data from going stale.

If the channel is visible, check if there are any movies associated with the channel.

If there are no movies on the channel, get a list of movies from the mock service.

Otherwise, update the programs with a fresh list of movies from the mock service.

Create a Preview Program

There are different types of programs you can add to a channel. For starters, create a PreviewProgram.

After you know that the channel is visible, create a PreviewProgram object using Movie objects. In the buildProgram() method, use the PreviewProgram.Builder to create a PreviewProgram object and save it in the channel.

Add the following code (at TODO: step 6 convert movie to program):

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();

The system launches the intentUri when the user selects a program from a channel in the launcher. The Uri should include the channel ID and movie ID to so the app can find and play the movie from the mock database when the user selects the program.

Set the poster art so each program has something to display in the channel. You can also set a thumbnail image for when a program is selected a different image may be shown. To experiment with the different images add the following line to the builder:

builder.setThumbnailUri(Uri.parse(movie.getBackgroundImageUrl()))

Set the preview video Uri so that when a program is selected, the home screen starts to play content. This is a great way to add a video description to your programs. This codelab reuses the actual video for the preview.

For best results, do not use both a thumbnail and a preview video at the same time.

Adding programs

Now that you can convert a Movie into a PreviewProgram, you can fetch the data for all the movies in a subscription and add them as programs to the corresponding channel.

To create a program in the channel you must convert a movie to a program and insert it into the TV Provider. Store the program ID that's returned in order to perform updates later.

Add the following code in the createPrograms() method (at TODO: step 7 add programs):

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;

The app now adds programs to channels.

Updating programs

Updating a program is very similar to adding it. There are several attributes that you can set on a program such as the episode number, price, and rating just to name a few. If these attributes change you can update the program to reflect the changes.

Add the following code in the updatePrograms() method (at TODO: step 8 update programs):

// By getting a fresh list, the update of the home screen is
// clearly visible.
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;

Each fresh list of movies is just a shuffled list of the mock data. The for loop walks through both movie lists together and updates the old movie's program reference with the new movie's data.

When you perform an update, you should pay attention to the user's preferences and remove a program if they've marked it to not be shown again.

Deleting programs

If a channel is not visible you should delete its programs so that the channel's content does not become stale if the user makes the channel visible in the future.

Deleting is very straightforward. Call delete() on the program's Uri. Add the following code to the deletePrograms() method (at TODO: step 9 delete programs)

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);

Run the app

When the app runs, go into Manage Channels at the bottom of the home screen and look for our app, "Codelab Channels and Programs". Toggle the three channels and watch the logs to see what is happening. Creating channels and programs happens in the background so feel free to add extra log statements to help you trace the events that are triggered.

Compare your code to the solution in the 3-programs directory.

What you've learned

What's next?

How to keep your users engaged by surfacing content on the Watch Next channel.

The Watch Next channel belongs to the launcher and appears below Apps and above all other channels.

Concepts

The Watch Next channel provides a way for your app to drive engagement with the user. There are several types of use cases for this channel:

This lesson shows how to use the Watch Next channel to continue watching a video: How to include a video in the Watch Next channel when the user pauses it. The video should be removed from the Watch Next channel when it plays to the end.

Player Callbacks

Start by hooking into the player's callbacks. The app listens for state change events in SimplePlaybackTransportControlGlue. There are several methods that can be overridden in the callback. You need to override onPlayStateChanged() and onPlayCompleted().

In PlaybackVideoFragment, add the following code in the onPlayStateChanged()callback method (at TODO: step 10 update progress):

long position = mMediaPlayerGlue.getCurrentPosition();
long duration = mMediaPlayerGlue.getDuration();
watchNextAdapter.updateProgress(
       getContext(), mChannelId, movie, position, duration);

onPlayStateChanged() is called whenever the video changes states from play to pause and vise versa. You can check the state by calling glue.isPlaying(). You need to update the progress in the Watch Next channel regardless of the state, so that when the user continues watching it starts where they left off.

Once the video completes playback, you should remove it from the Watch Next channel.

Add the following code in the onPlayCompleted()method (at TODO:step 11 remove watch next):

watchNextAdapter.removeFromWatchNext(
       getContext(), mChannelId, movie.getId());

Adding a program to the Watch Next channel

WatchNextAdapter interacts with the TV Provider. In the previous step you hooked into the callbacks, now you need to actually add a program to the Watch Next channel.

Add the following code in the updateProgress() method (at TODO: step 12 add watch next program):

WatchNextProgram program = createWatchNextProgram(channelId, entity, position, duration);
if (entity.getWatchNextId() < 1L) {
   // Need to create 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.
}

Before you can run the app, you must implement the createWatchNextProgram() method to convert a Movie to a WatchNextProgam.

Add the following code (at 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();

Add the position to the end of the intentUri to indicate where to continue playing. The watch next type and last engagement time are required. It is used to sort the programs in the channel. The program with the most recent last engagement time appears at the beginning of the channel.

Updating a program in the Watch Next Channel

Every time the user pauses a video, you must update the last playback position in the program and in the intent Uri so that the video continues from the right place. In the previous step, you added a new WatchNextProgram, this time you'll update an existing program.

Update the WatchNextProgram just like a PreviewProgram , but use the buildWatchNextProgramUri() method from the TvContractCompat instead.

Add the following in the else clause that you added to the updateProgress() method earlier (at TODO: step 14 update program):

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

Removing a program from the Watch Next Channel

When a video completes playing, you should clean up the Watch Next channel. This is almost the same as removing PreviewPrograms. Use the buildWatchNextProgramUri() to create a Uri that performs a delete.

Add the following code in the removeFromWatchNext()method (at 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));

Run the app

Select a movie from one of your channels and pause the player (spacebar if you're using the emulator). When you return home you should see the movie has been added to the Watch Next channel. Select the same movie from the Watch Next channel and it should continue from where you paused it. Once you watch the entire movie, it should be removed from the Watch Next channel. Experiment with the Watch Next channel and imagine all of the possibilities!

Compare your code to the solution in the 4-watch-next directory.

What you've learned

What's next?

Adding more channels from your app.

The user controls the channels they want to see. The interaction with the user is similar to the way they accept Android permissions.

If the user likes specific content from your app, they can personalize their home screen by adding your channels. For example, clicking the "Subscribe" button in the screenshot above adds the "Google Developers" channel to the user's home screen. The new channelappears at the bottom of the screen. To add a channel from your app, follow these steps:

  1. Provide some UI element that lets the user add one or more channels.
  2. Create a channel in the provider.
  3. Prompt the user to allow adding the channel to the home screen.
  4. Handle the user's response.

Provide a UI element

The MainActivity in the codelab has three buttons that simulate adding channels from the UI.

The OnClickListener displays a dialog asking the user to approve adding the channel. Once the channel is approved, you must add it in the background.

Create a new channel

When you add a channel in-app, you need to create a new channel as you did above. Similar create code already exists in the TvUtil.createChannel()convenience method, so you don't need to write it again.

You can trigger an AsyncTask from the onClick() method to interact with the TV Provider in the background while prompting the user in the foreground.

In MainActivity, add the following code to the doInBackground() method of your AsyncTask (at TODO: step 16 create channel):

long channelId = TvUtil.createChannel(mContext, subscription);

Prompt the user

After adding the channel, you need to tell the system to display it in the home screen. In the onPostExecute()the app asks the system to display the channel. However, before this can happen the user must approve. To do this, start a new activity that asks for permission to add the channel. The activity result tells you if the user approved.

Add the following code in the promptUserToDisplayChannel() method (at 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);
}

Handle the user's Response

Since you launched an activity for a result, you can check the user's response by evaluating the result code. If the user accepted adding the channel, you'll get RESULT_OK, otherwise they chose not to add the channel to their home screen.

Add the following code in the onActivityResult() method (at 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();
}

Optional

For extra credit, change the enabled state of the buttons based on the user's response. If the user chooses to add a channel, disable the button since there is no reason to allow the user to add the channel again once it's already on the screen.

Run the app

Congratulations! You've completed the codelab.

Compare your code to the solution in the 5-opt-in-channels directory.

What you've learned

What's next?

After completing the codelab, make the app your own. Replace the Subscription and Movie

classes with your own data model and convert them into channels and programs for the TV Provider.

To learn more, visit the documentation!