
How nice would it be to display your app content before the user thinks about opening it? Guess what? This is possible with the Channels API!
In this post, we will go through a step-by-step guide with some helpful insights on creating and publishing your app’s Preview Channels to the Android TV home screen and how to handle user interaction with your channels. Make sure to check out the sample app with all the concepts of this post.
Preview Channels
Android TV supports various channels, from ATSC and DVB channels, i.e., cable channels, to other standards of satellite channels. Today we’ll discuss a specific channel type called Preview Channels.
You might have already seen a few if you’re familiar with the Android TV home screen. Every time you install an app, and you can discover the app’s content on the home screen, chances are that you’re seeing a Preview Channel. Preview Channels can be understood simply as rows of content that your app can add to the tv home screen.
Here’s the opening from the official documentation:
“The Android TV home screen, or simply the home screen, provides a UI that displays recommended content as a table of channels and programs. Each row is a channel. A channel contains cards for every program available on that channel.”
Keep your users engaged!
Having relevant and updated content on the home screen is an effective way to keep your users engaged with your app. Sometimes, the only thing apart from the user returning to your app is visual encouragement. That’s why Preview Channels are so appealing. On a glance at the home screen, the user is one click away from consuming your app’s content.
Note: Preview Channels are only available in Android TV 8.0 (API 26) and later. If you want to support a similar feature in older versions, look at the Recommendations Row documentation.
How does it work?
Apps can perform all CRUD (Create, Read, Update, and Delete) operations in channels using the TvProvider
and ContentProvider
APIs (we’ll cover those in more detail later). Still, to show the channels on the home screen, the app must ask the user’s permission, except for the first published channel.
A channel is not published, i.e., visible on the home screen as soon as it’s created. For that, we must ask the user permission to publish the channel.
The first channel created by your app can be published without asking for the user’s permission. We’ll call the first channel the “default” channel. With this in mind, it is important never to delete your default channel. Otherwise, we’ll need to ask the user permission to publish it again. You should instead repurpose it, changing its information and content, and never delete and recreate it.
Programs
Channels without content aren’t much. They’re not even displayed on the home screen. To add content to channels, we need to create Programs. A program will become a card on a channel row.
CRUD operations on programs are done using the same TvProvider
and ContentProvider
APIs mentioned above. After creating programs, we should add them to a channel to make their content available to the users.
You can provide a lot of information about your programs, such as title, description, type, genre, video preview URL, thumbnail, duration, and the list goes on… Depending on the type of your program (if it’s a movie, a series episode, a season, or any other kind), some pieces of information are more relevant than others. Keep this in mind when creating your programs to provide the best experience for your users. You can check the complete list of program attributes to decide which ones to provide here.
After creating programs, adding them to channels, and publishing them, they’ll be visible on the home screen, where users can quickly deep-link to your app’s home or straight to a video/content playback.
💡 ️Tip: Consider how your content can be structured in this channel/program hierarchy before writing any code.
Enough with theory. Please show me the code!
Start by adding the TvProvider
dependency to your app’s build.gradle
.
dependencies { ... implementation 'androidx.tvprovider:tvprovider:1.1.0-alpha01' }
Creating or Updating a Channel
The implementations to create or update a channel are very similar.
val existingChannel = queryChannels().firstOrNull { | |
it.internalProviderId == category.id.toString() | |
} | |
val channelBuilder = when (existingChannel) { | |
null -> Channel.Builder() | |
else -> Channel.Builder(existingChannel) | |
} | |
val channel = channelBuilder | |
.setType(TvContractCompat.Channels.TYPE_PREVIEW) | |
.setDisplayName(category.title) | |
.setInternalProviderId(category.id.toString()) | |
.setAppLinkIntentUri("content://channelsample.com/category/${category.id}".toUri()) | |
.build() |
Note: We will cover the
queryChannels()
function later. For now, assume that it returns all channels of your app.
After building the channel, we need to use the ContentResolver
to insert or update it on the database which is used by the system to get information about the channels.
when (existingChannel) { | |
null -> context.contentResolver.insert( | |
TvContractCompat.Channels.CONTENT_URI, | |
channel.toContentValues() | |
) | |
else -> context.contentResolver.update( | |
TvContractCompat.Channels.CONTENT_URI, | |
channel.toContentValues(), | |
null, | |
null | |
) | |
} |
Publishing a channel
As mentioned, creating a channel is not enough to make it visible on the home screen. We need to ask for the user’s permission to publish the channel. The code for this is very straightforward:
try { | |
val intent = Intent(TvContractCompat.ACTION_REQUEST_CHANNEL_BROWSABLE) | |
intent.putExtra(TvContractCompat.EXTRA_CHANNEL_ID, channelId) | |
startActivityForResult(intent, REQUEST_CHANNEL_BROWSABLE) | |
} catch (exception: ActivityNotFoundException) { | |
// Handle exception | |
} |
This will prompt the user with a dialog asking to add the channel to the TV home screen.
The default channel
To create the default channel, we will use a BroadcastReceiver
usually triggered once your app is installed. In rare cases, it can be triggered only on the first app launch. Add the following to your AndroidManifest.xml
<receiver | |
android:name=".InitProgramsBroadcastReceiver" | |
android:exported="true"> | |
<intent-filter> | |
<action android:name="android.media.tv.action.INITIALIZE_PROGRAMS" /> | |
<category android:name="android.intent.category.DEFAULT" /> | |
</intent-filter> | |
</receiver> |
And here’s how we can implement the BroadcastReceiver
:
class InitChannelsBroadcastReceiver : BroadcastReceiver() { | |
override fun onReceive(context: Context, intent: Intent?) { | |
if (defaultChannelAlreadyAdded) { | |
// Make sure we are not trying to re-create the channel if it's already added. | |
// You can save this on your shared preferences or database. | |
return | |
} | |
val channelHelper = PreviewChannelHelper(context) | |
val channel = Channel.Builder() | |
.setType(TvContractCompat.Channels.TYPE_PREVIEW) | |
.setInternalProviderId("default_channel") | |
.setDisplayName("ChannelSample: Hand picked recommendations!") | |
.setAppLinkIntentUri("content://channelsample.com/discover".toUri()) | |
.build() | |
val channelUri = context.contentResolver.insert( | |
TvContractCompat.Channels.CONTENT_URI, | |
channel.toContentValues() | |
) | |
if (channelUri != null) { | |
val channelId = ContentUris.parseId(channelUri) | |
val myChannels = channelHelper.allChannels.filter { | |
it.packageName == context.packageName | |
} | |
// If there are no browsable channels, i.e., channels visible on the TV Home screen, we can | |
// make the first channel browsable without asking the user permission. | |
if (myChannels.none { it.isBrowsable }) { | |
TvContractCompat.requestChannelBrowsable(context, channelId) | |
} | |
MovieList.list.forEach { movie -> | |
val program = movie.toPreviewProgram(channelId) | |
channelHelper.publishPreviewProgram(program) | |
} | |
} | |
} | |
} |
💡 Tip: Notice the call to Channel.setInternalProviderId()
function. This is a way to set the id your app uses to identify the channel. As the channel id
needs to be unique among all the installed apps, it is generated by the database, and we can’t change it. That’s why we have the setInternalProviderId()
API to store your app’s internal id for that channel.
To test your BroadcastReceiver
implementation, you can trigger its Intent
via adb
with the following code:
adb shell am broadcast -a android.media.tv.action.INITIALIZE_PROGRAMS -n \ your.package.name/.YourReceiverName
Querying channel information
To manage the app channels, we usually need to query for their data to decide what information needs to change, what programs we need to add or remove, and what shouldn’t be touched.
There are a few ways and APIs to help us with that. If you want complete control and access to all your channel data, use the ContentResolver
API and query the channels database for the exact information you need. If you only need to read the basics about your channel, you can use the PreviewChannelHelper
API.
PreviewChannelHelper
With PreviewChannelHelper
, you have out-of-the-box implementations of the most common CRUD operations with channels and programs. It is an abstraction layer on top of the low-level operations that can be performed with the ContentResolver
. Take a look at the documentation for the available functions.
When reading channel data with the PreviewChannelHelper
, you can only read the columns listed on the PreviewChannelHelper.Columns.PROJECTION
array which are:
public static final String[] PROJECTION = { | |
Channels._ID, | |
Channels.COLUMN_PACKAGE_NAME, | |
Channels.COLUMN_TYPE, | |
Channels.COLUMN_DISPLAY_NAME, | |
Channels.COLUMN_DESCRIPTION, | |
Channels.COLUMN_APP_LINK_INTENT_URI, | |
Channels.COLUMN_INTERNAL_PROVIDER_ID, | |
Channels.COLUMN_INTERNAL_PROVIDER_DATA, | |
Channels.COLUMN_INTERNAL_PROVIDER_FLAG1, | |
Channels.COLUMN_INTERNAL_PROVIDER_FLAG2, | |
Channels.COLUMN_INTERNAL_PROVIDER_FLAG3, | |
Channels.COLUMN_INTERNAL_PROVIDER_FLAG4 | |
}; |
A channel has plenty more attributes than this. Take a look at the getProjection()
function in its source code to see all the existing attributes.
A very useful attribute is the Channels.COLUMN_BROWSABLE
, which indicates if the channel is already published or not. With that, you can show or hide a button to remove the channel from the TV home screen from inside your app. To query that value from the database though, you must use the ContentProvider
.
💡 Tip: To avoid frustrations and lost time debugging things like a channel property value not making sense, e.g., a channel that is already published having the Channel.isBrowsable
property being false
, be aware that the PreviewChannelHelper
fills the Channel
instance variables with the result of the query from the PROJECTION
shown above, which has very limited information. In other words, properties not listed on the projection will have the field default value (false
for booleans, 0
for numbers, and so on…)
ContentProvider
Using the ContentProvider
APIs gives you full control of the data you want to query. If you want, for example, to query all attributes from a channel you can do the following:
private fun queryChannels(): List<Channel> { | |
val channels = ArrayList<Channel>() | |
context.contentResolver.query( | |
/* uri = */ TvContractCompat.Channels.CONTENT_URI, | |
/* projection = */ Channel.PROJECTION, | |
/* selection = */ null, | |
/* selectionArgs = */ null, | |
/* sortOrder = */ null | |
).use { cursor -> | |
while (cursor != null && cursor.moveToNext()) { | |
channels.add(Channel.fromCursor(cursor)) | |
} | |
} | |
return channels | |
} |
💡 Tip: Check the implementations of PreviewChannelHelper
functions and adjust them for your needs.
Creating or Updating a Program
In the same way we manage channels, managing programs is very similar. Look at how we can create or update an existing program and add it to a channel.
fun insertOrUpdateChannelPrograms(context: Context, channelId: Long) { | |
val existingPrograms = getChannelPrograms(channelId) | |
MovieList.list.forEach { movie -> | |
val existingProgram = existingPrograms.find { | |
it.internalProviderId == movie.id.toString() | |
} | |
val programBuilder = when (existingProgram) { | |
null -> PreviewProgram.Builder() | |
else -> PreviewProgram.Builder(existingProgram) | |
} | |
val program = programBuilder.setChannelId(channelId) | |
.setType(TvContractCompat.PreviewPrograms.TYPE_MOVIE) | |
.setTitle(title) | |
.setPreviewVideoUri(videoUrl?.toUri()) | |
.setDurationMillis(Random.nextInt(0, 10) * 60 * 1000) | |
.setLastPlaybackPositionMillis(0) | |
.setGenre(genre) | |
.setDescription(description) | |
.setPosterArtUri(cardImageUrl?.toUri()) | |
.setIntentUri("content://channelsample.com/movie/$id".toUri()) | |
.setInternalProviderId(id.toString()) | |
.build() | |
when (existingProgram) { | |
null -> context.contentResolver.insert( | |
/* url = */ TvContractCompat.PreviewPrograms.CONTENT_URI, | |
/* values = */ program.toContentValues() | |
) | |
else -> context.contentResolver.update( | |
/* uri = */ TvContractCompat.PreviewPrograms.CONTENT_URI, | |
/* values = */ program.toContentValues(), | |
/* where = */ null, | |
/* selectionArgs = */ null | |
) | |
} | |
} | |
} |
💡 Tip: Notice the call to PreviewProgram.Builder.setInternalProviderId()
function. It follows the same logic as the channel builder by providing a way to store the app internal content id
into a program.
⚠️ Attention: TheContentProvider
CRUD operations are I/O operations. To have the best performance on your app, make sure you are running these operations outside the main thread.
To wrap up the program management section, check out the implementation of the getChannelPrograms()
function.
fun getChannelPrograms(channelId: Long): List<PreviewProgram> { | |
val programs = ArrayList<PreviewProgram>() | |
context.contentResolver.query( | |
/* uri = */ TvContractCompat.PreviewPrograms.CONTENT_URI, | |
/* projection = */ PreviewProgram.PROJECTION, | |
/* selection = */ null, | |
/* selectionArgs = */ null, | |
/* sortOrder = */ null | |
).use { cursor -> | |
if (cursor != null) { | |
while (cursor.moveToNext()) { | |
val program = PreviewProgram.fromCursor(cursor) | |
if (program.channelId == channelId) { | |
programs.add(program) | |
} | |
} | |
} | |
} | |
return programs | |
} |
Handling User Actions on programs and channels
Handling user actions is basically handling App Links. When the user clicks on a channel or program, an Intent
will be sent to your app with the URI
provided on the channel/program creation. After that, it’s basically navigating to that piece of content on your app.
Start by adding an <intent-filter>
to an <activity>
on your app’s AndroidManifest.xml
like the following:
<intent-filter android:autoVerify="true"> | |
<action android:name="android.intent.action.VIEW" /> | |
<category android:name="android.intent.category.DEFAULT" /> | |
<category android:name="android.intent.category.BROWSABLE" /> | |
<data android:scheme="content" /> | |
<data android:host="channelsample.com" android:pathPrefix="/discover"/> | |
<data android:host="channelsample.com" android:pathPrefix="/category"/> | |
<data android:host="channelsample.com" android:pathPrefix="/movie"/> | |
</intent-filter> |
And add some code to handle the Intent
on your Activity
or Fragment
for example:
fun handleIntent(intent: Intent) { | |
val intentAction = intent.action | |
if (intentAction == Intent.ACTION_VIEW) { | |
val intentData = intent.data | |
val pathSegments = intentData?.pathSegments ?: emptyList() | |
when (pathSegments.firstOrNull()) { | |
"movie" -> pathSegments.get(1)?.let { movieId -> | |
val movie = MovieList.list.firstOrNull { it.id.toString() == movieId } | |
if (movie != null) { | |
val movieIntent = Intent(context, PlaybackActivity::class.java) | |
movieIntent.putExtra(PlaybackVideoFragment.MOVIE, movie) | |
startActivity(movieIntent) | |
} | |
} | |
"category" -> pathSegments.getOrNull(1)?.let { categoryId -> | |
selectRowForCategory(categoryId) | |
} | |
"discover", null -> { | |
// Just open the app. The user can browse content on the main screen | |
} | |
} | |
} | |
} |
That’s another wrap!
If you got to this point, thank you for reading this post! I’m sure you’re striving to build (or already built) an awesome app for your users!
Follow me to be notified about the next posts. You can also reach me on Twitter with questions or suggestions at @admqueiroga
Additional resources:
- Make sure to surf through the official and amazing documentation on channels. There’s a lot of knowledge in there.
- Check out the channels codelab.
- Check the App-Links documentation.
- Take a look at the sample app I used to learn and get inspiration for this post
See you soon!
This article was previously published on proandroiddev.com