Top Shelf API

Choose what ChillHub shows above the dock while your app is highlighted.

Version 1. For Android TV and Fire TV app developers.

What the user sees

When someone moves onto an app in ChillHub's dock, the top shelf above it fills with that app's content: a title and a row of posters, each of which opens straight into the app. With this API your app supplies that row itself, under a title of your choosing, such as "Trending Movies" or "Because you watched".

You do not need this API to appear on the shelf. If your app already publishes Watch Next programs or a preview channel through Android's TV provider, ChillHub shows those under "Continue Watching" or "Watch Next", where the person has allowed it to read TV listings and the program has poster art. The Top Shelf API is for choosing the title and the items yourself. It applies to apps in the dock, in ChillHub versions after 1.4.0.

1. Declare a provider

A top shelf is an exported ContentProvider that ChillHub finds by its intent filter. Four things are required: the action, the version, android:exported="true" with no read permission, and an android:process that is not your app's main process. Declare one such provider per app.

<application android:name=".MyApp" ...> <!-- What a shelf item opens. It must be exported. --> <activity android:name=".DetailsActivity" android:exported="true"> <intent-filter> <action android:name="android.intent.action.VIEW" /> <category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.BROWSABLE" /> <data android:scheme="myapp" android:host="item" /> </intent-filter> </activity> <provider android:name=".TopShelfProvider" android:authorities="com.example.myapp.shelf" android:exported="true" android:process=":topshelf"> <intent-filter> <action android:name="app.lumoslabs.chillhub.action.TOP_SHELF" /> </intent-filter> <meta-data android:name="app.lumoslabs.chillhub.topshelf.version" android:value="1" /> </provider> </application>

2. Answer the query

ChillHub queries content://<your authority>/top_shelf (the provider's first authority if it has several). Each row of the cursor is one item, and the shelf's title goes in the cursor's extras. ChillHub passes no projection, selection or sort order today. Ignore any you do not understand, so later versions can add hints.

class TopShelfProvider : ContentProvider() { override fun onCreate() = true override fun query( uri: Uri, projection: Array<String>?, selection: String?, selectionArgs: Array<String>?, sortOrder: String?, ): Cursor? { if (uri.lastPathSegment != "top_shelf") return null val cursor = MatrixCursor(arrayOf( "id", "title", "subtitle", "image_uri", "aspect_ratio", "intent_uri", "progress", )) // Read the shelf your app saved earlier. No network, no heavy work here. for (item in SavedShelf.read(context!!)) { cursor.addRow(arrayOf( item.id, // "movie-42" item.title, // "The First One" item.subtitle, // "2024", or null item.imageUrl, // "https://img.example.com/42.jpg" "2:3", "myapp://item/" + item.id, item.progress, // "0.5", or null )) } // The shelf's title. A MatrixCursor has no extras unless you give it some. cursor.extras = Bundle().apply { putString("app.lumoslabs.chillhub.topshelf.title", "Trending Movies") } return cursor } // getType, insert, delete and update: nothing to do. Return null, null, 0 and 0. }
WhereNameMeaning
cursor extrasapp.lumoslabs.chillhub.topshelf.titleThe shelf's title, in the user's language. Missing or blank: ChillHub shows your app's name.
columnidRequired. A string that stays the same for the same item. A row repeating an earlier id is dropped.
columntitleRequired. The item's name. It is also what a screen reader says.
columnsubtitleOptional. A short second line. When you give a progress, ChillHub puts it first ("50% · 42 min left"), so leave the percentage out.
columnimage_uriRequired. An https:// or http:// URL, or a content:// URI on this provider's own authority. Anything else drops the row.
columnaspect_ratioOptional. 2:3 (the default), 16:9, 1:1, 4:3 or 3:2. An unknown value means the default.
columnintent_uriRequired. A deep link, or Intent.toUri(Intent.URI_INTENT_SCHEME). It must open an exported activity of your own app, or the row is dropped.
columnprogressOptional. How far through the item the viewer is, from 0.0 to 1.0. Shown as a percentage before the subtitle.

Every value is read as a string. Titles and subtitles longer than 200 characters are cut, and a URI longer than 2,000 characters drops its row. Columns and extras ChillHub does not know are ignored.

What each answer means

  • Rows: they are the shelf. ChillHub shows the first 30 usable ones, in your order, and reads no more than 200 rows to find them.
  • An empty cursor: "show ChillHub's default for my app", which is your Watch Next and preview programs if you publish any. There is no way to ask for an empty shelf in version 1.
  • Null or an exception: a failure. ChillHub keeps showing what it had and asks again later, after one minute at first and doubling up to an hour while the failures continue.

A row is dropped when it has no id, repeats an id, has no title, has an image ChillHub may not load, or has a link that does not open an exported activity of your own app. If every row is dropped, that counts as the empty answer.

The shelf process

Android has no app extensions, so asking for your shelf means starting a process of your app. ChillHub asks as focus moves across the dock, and starting a whole streaming app for each tile would be slow and heavy on a TV stick. That is why the provider must live in a small process of its own, and why ChillHub ignores one that does not. In our measurements on a Fire TV Stick 4K Max, such a process answered about half a second after a cold start and used about 19 MB.

  • Android creates your Application in that process too and runs its onCreate. Skip your normal start-up there.
  • Providers are created before Application.onCreate runs, so a query can arrive before or during it. Do not depend on anything it sets up.
  • Initializers that are themselves providers in the main process (androidx.startup, Firebase) do not run in the shelf process. Code that assumes them crashes only there, where you are unlikely to see it.
  • Answer from something already saved, such as a file your main app wrote. Do not use the network or compute the shelf inside query(). ChillHub stops waiting after 30 seconds and treats that as a failure. A query that never returns keeps one of the few threads ChillHub sets aside for shelves, so a provider that hangs can delay other apps' shelves as well as its own.
  • Android keeps the process cached after it answers and reclaims it when memory is needed. Keep nothing running in it.
class MyApp : Application() { override fun onCreate() { super.onCreate() if (isShelfProcess()) return // nothing but the saved shelf is needed here // The app's real start-up: dependency injection, analytics, network clients. } private fun isShelfProcess() = currentProcessName().endsWith(":topshelf") private fun currentProcessName(): String { if (Build.VERSION.SDK_INT >= 28) return getProcessName() val pid = Process.myPid() val manager = getSystemService(ACTIVITY_SERVICE) as ActivityManager return manager.runningAppProcesses?.firstOrNull { it.pid == pid }?.processName.orEmpty() } }

When your shelf changes

ChillHub keeps your shelf and shows it at once the next time your app is highlighted. It asks again when a shelf is more than 30 minutes old, when the user returns to ChillHub with your app highlighted, and when you tell it the shelf changed:

// From any process of your app, whenever the saved shelf changes: contentResolver.notifyChange(Uri.parse("content://com.example.myapp.shelf/top_shelf"), null)

To show fresh content, fetch it in your main app, save it, then notify. A notification while another app is highlighted is remembered and acted on when focus returns to yours. ChillHub asks any one app at most 6 times a minute, however often it is notified, and it does not replace the shelf while the user is moving through its posters.

Artwork

A web URL is the simplest. To serve images yourself, use a content:// URI on the shelf provider's own authority and implement openFile. ChillHub reads it only when the poster is on screen, accepts up to 2 MB per image, and waits up to 10 seconds. Other content URIs, file:// and android.resource:// are not accepted. Posters are cached by their URI, so an image that changes needs a new URI.

Testing

ChillHub says why it ignored a provider, dropped a row or gave up on a query, in release builds too:

adb logcat -s ChillHubTopShelf

An app that was just installed, or was force-stopped, is skipped until the user opens it: ChillHub will not start an app the system considers stopped. After adb install, open your app once before expecting a shelf. Then put it in the dock and move onto its tile.

Logged reasonWhat to fix
MAIN_PROCESSThe provider has no android:process of its own. See the section on the shelf process.
NOT_EXPORTEDThe provider is not android:exported="true".
READ_PERMISSIONThe provider is behind a read permission ChillHub does not hold. Declare none.
VERSIONThe version meta-data is missing or is not a number from 1 up.
STOPPEDThe app was just installed or was force-stopped. Open it once.
NO_AUTHORITYThe provider declares no authority.

A provider that is disabled, or whose app is, is not found at all, so nothing is logged for it.

A row that is dropped is logged with its position in the cursor and one of these:

Dropped rowWhy
NO_IDThe id is missing, blank, or longer than 200 characters.
DUPLICATE_IDAn earlier row has the same id.
NO_TITLEThe title is missing or blank.
BAD_IMAGEThe image is missing, or is not a web URL or a content:// URI on this provider's authority.
BAD_INTENTThe link is missing, or does not open an exported activity of your own app.

Answering only ChillHub (optional)

An exported provider can be read by any app on the device. If your shelf should go to ChillHub alone, check the caller inside query(), by package and by signing certificate. Do not use a read permission for this: ChillHub ignores a provider that has one. Your app needs to be able to see ChillHub to check it:

<queries> <package android:name="app.lumoslabs.chillhub" /> </queries>
private val chillHubSigners = listOf( "25fceb02b06a7b97d2a7fd2c933a8d37c1cdb6fff208a8f935f42ce7a1b3d2d2", // Google Play "4b3516cff97db09dbaa159b0a59deea25dc80a75c2b697c1cb3090c98be252d0", // sideload ) private fun calledByChillHub(): Boolean { if (callingPackage != "app.lumoslabs.chillhub") return false // Before Android 9, compare PackageManager.GET_SIGNATURES digests instead. if (Build.VERSION.SDK_INT < 28) return false val packages = context!!.packageManager return chillHubSigners.any { hex -> val digest = ByteArray(hex.length / 2) { hex.substring(it * 2, it * 2 + 2).toInt(16).toByte() } packages.hasSigningCertificate("app.lumoslabs.chillhub", digest, PackageManager.CERT_INPUT_SHA256) } } // In query(): if (!calledByChillHub()) throw SecurityException("not ChillHub")

The same check belongs in openFile if you serve artwork yourself.

Versions

The version in your manifest is the version of this page you built against. Later versions only add columns, extras and query hints that an older provider can ignore, and ChillHub accepts any version from 1 up. A change that would break existing providers will use a new action name instead.