fix(android): grant shelf artwork to every installed launcher

The Watch Next poster art moved to a local content:// URI in 2.10.0, gated
twice on the package returned by resolveActivity(MAIN+HOME,
MATCH_DEFAULT_ONLY): once as the grantUriPermission target, once as a
caller-identity check inside SystemShelfArtworkProvider.openFile. Any
launcher that is not the resolved default HOME activity was denied on every
image and drew its broken-image placeholder instead. Fire OS pins its own
launcher and silently reverts a third-party default, so Projectivy could
never satisfy either gate; a device with several launchers and no chosen
default resolves to the resolver activity and granted nobody at all.

Discover consumers with queryIntentActivities(MAIN+HOME, MATCH_ALL) so every
installed launcher is granted, drop the hand-rolled identity check, and make
the provider non-exported so the framework enforces the per-URI grants that
are now the only access path. Because those grants became load-bearing,
grantReadAccess reports failure per poster and the sync rolls back rather
than committing a row no launcher can open.

close #1706
This commit is contained in:
edde746
2026-07-29 05:12:28 +02:00
parent f02924b9a5
commit 69ceea9466
5 changed files with 250 additions and 40 deletions
+4 -2
View File
@@ -89,10 +89,12 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_provider_paths" />
</provider>
<!-- Launcher shelf artwork. Not exported: readers reach it only through the
per-URI read grants issued to CATEGORY_HOME packages by WatchNextProvider. -->
<provider
android:name=".watchnext.SystemShelfArtworkProvider"
android:authorities="com.edde746.plezy.systemshelf.artwork"
android:exported="true"
android:exported="false"
android:grantUriPermissions="true" />
<receiver
android:name=".watchnext.SystemShelfUpdateReceiver"
@@ -132,7 +134,7 @@
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="video/*" />
</intent>
<!-- Required to identify only the active HOME launcher as a shelf artwork consumer. -->
<!-- Required to identify HOME launchers as shelf artwork consumers. -->
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.HOME" />
@@ -5,9 +5,7 @@ import android.content.ContentValues
import android.database.Cursor
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Binder
import android.os.ParcelFileDescriptor
import android.os.Process
import android.system.Os
import java.io.ByteArrayOutputStream
import java.io.File
@@ -27,22 +25,16 @@ class SystemShelfArtworkProvider : ContentProvider() {
override fun onCreate(): Boolean = context != null
/**
* The provider is not exported, so the framework admits a cross-process caller only when it
* holds a read grant for this exact URI. Grants are issued by [WatchNextProvider] to the
* packages that declare a HOME activity; do not re-derive the consumer here, because a device
* whose HOME launcher is not the resolved default (Fire OS, or any device with several
* launchers and no default) has no single package to compare against.
*/
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor {
if (mode != "r") throw FileNotFoundException("Read-only artwork")
val appContext = context ?: throw FileNotFoundException("Provider unavailable")
val callingUid = Binder.getCallingUid()
if (callingUid != Process.myUid()) {
val homeIntent = android.content.Intent(android.content.Intent.ACTION_MAIN)
.addCategory(android.content.Intent.CATEGORY_HOME)
val homePackage = appContext.packageManager
.resolveActivity(homeIntent, android.content.pm.PackageManager.MATCH_DEFAULT_ONLY)
?.activityInfo
?.packageName
val callerPackages = appContext.packageManager.getPackagesForUid(callingUid)
if (homePackage == null || callerPackages == null || homePackage !in callerPackages) {
throw FileNotFoundException("Artwork caller is not the active HOME launcher")
}
}
val file = SystemShelfArtworkStore(appContext.cacheDir).resolve(uri)
?: throw FileNotFoundException("Unknown artwork")
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
@@ -201,7 +201,13 @@ class WatchNextProvider internal constructor(
return@whileCurrent false
}
grantReadAccess(newUris, newPackages)
// The provider is not exported, so an ungranted row publishes artwork no launcher can
// open. Refuse to commit that rather than leave a broken tile on the home screen.
if (!grantReadAccess(newUris, newPackages)) {
reconcileReadAccess(newUris, newPackages, oldUris, oldPackages)
artwork.delete(publishedFiles)
return@whileCurrent false
}
if (session.isExpired()) {
reconcileReadAccess(newUris, newPackages, oldUris, oldPackages)
artwork.delete(publishedFiles)
@@ -455,17 +461,19 @@ class WatchNextProvider internal constructor(
false
}
/**
* Every installed launcher, not just the resolved default. `MATCH_DEFAULT_ONLY` names one
* package, which is wrong wherever the launcher rendering the shelf is not the system's default
* HOME: Fire OS pins its own launcher, and a device with several launchers and no chosen default
* resolves to the resolver activity instead. Both cases left the real consumer without a grant.
*/
internal fun consumerPackages(): Set<String> {
val homeIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
val packageManager = context.packageManager
val selectedHome = packageManager.resolveActivity(homeIntent, PackageManager.MATCH_DEFAULT_ONLY)
?.activityInfo ?: return emptySet()
val packageName = selectedHome.packageName?.takeIf(String::isNotBlank) ?: return emptySet()
val activityName = selectedHome.name?.takeIf(String::isNotBlank) ?: return emptySet()
val isHomeHandler = packageManager.queryIntentActivities(homeIntent, PackageManager.MATCH_ALL).any { candidate ->
candidate.activityInfo?.let { it.packageName == packageName && it.name == activityName } == true
}
return if (isHomeHandler) setOf(packageName) else emptySet()
return context.packageManager
.queryIntentActivities(homeIntent, PackageManager.MATCH_ALL)
.mapNotNullTo(LinkedHashSet()) { candidate ->
candidate.activityInfo?.packageName?.takeIf(String::isNotBlank)
}
}
private fun reconcileReadAccess(
@@ -489,13 +497,36 @@ class WatchNextProvider internal constructor(
grantReadAccess(currentUris, currentPackages)
}
private fun grantReadAccess(uris: Set<Uri>, packages: Set<String>) {
/**
* Grants read access and reports whether every poster reached at least one consumer. Losing one
* launcher is tolerated, because consumers are discovered per sync and one of them disappearing
* mid-sync must not cost the others their shelf. A poster that reached nobody would render as a
* broken tile, so it fails the sync instead.
*/
private fun grantReadAccess(uris: Set<Uri>, packages: Set<String>): Boolean {
if (uris.isEmpty() || packages.isEmpty()) return true
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
packages.forEach { packageName ->
uris.forEach { uri ->
runCatching { context.grantUriPermission(packageName, uri, flags) }
var failed = 0
var unreadable = 0
uris.forEach { uri ->
var granted = 0
packages.forEach { packageName ->
if (runCatching { context.grantUriPermission(packageName, uri, flags) }.isSuccess) {
granted++
} else {
failed++
}
}
if (granted == 0) unreadable++
}
if (failed > 0) {
Log.w(
TAG,
"Failed to grant $failed of ${uris.size * packages.size} shelf artwork reads; " +
"$unreadable of ${uris.size} posters reached no launcher"
)
}
return unreadable == 0
}
private fun revokeReadAccess(uris: Set<Uri>) {
@@ -0,0 +1,58 @@
package com.edde746.plezy.watchnext
import java.io.File
import javax.xml.parsers.DocumentBuilderFactory
import org.junit.Assert.assertEquals
import org.junit.Test
import org.w3c.dom.Element
/**
* [SystemShelfArtworkProvider] performs no caller-identity check of its own, because the launcher
* rendering the Watch Next row is not necessarily the resolved default HOME activity (issue #1706).
* The manifest declaration is therefore the only thing standing between a launcher-scoped read
* grant and every app on the device.
*/
class SystemShelfArtworkManifestTest {
private companion object {
const val ANDROID_NAMESPACE = "http://schemas.android.com/apk/res/android"
const val AUTHORITY = "com.edde746.plezy.systemshelf.artwork"
val MANIFEST_CANDIDATES = listOf(
"src/main/AndroidManifest.xml",
"app/src/main/AndroidManifest.xml",
"android/app/src/main/AndroidManifest.xml"
)
}
@Test
fun artworkProviderIsReachableOnlyThroughUriGrants() {
val provider = artworkProvider()
assertEquals("false", provider.getAttributeNS(ANDROID_NAMESPACE, "exported"))
assertEquals("true", provider.getAttributeNS(ANDROID_NAMESPACE, "grantUriPermissions"))
}
private fun artworkProvider(): Element {
val manifest = DocumentBuilderFactory.newInstance()
.apply { isNamespaceAware = true }
.newDocumentBuilder()
.parse(manifestFile())
val providers = manifest.getElementsByTagName("provider")
for (index in 0 until providers.length) {
val provider = providers.item(index) as Element
if (provider.getAttributeNS(ANDROID_NAMESPACE, "authorities") == AUTHORITY) return provider
}
throw AssertionError("No <provider> declares android:authorities=\"$AUTHORITY\"")
}
private fun manifestFile(): File {
var directory: File? = File(System.getProperty("user.dir")).absoluteFile
while (directory != null) {
for (candidate in MANIFEST_CANDIDATES) {
val manifest = File(directory, candidate)
if (manifest.isFile) return manifest
}
directory = directory.parentFile
}
throw AssertionError("AndroidManifest.xml not found from ${System.getProperty("user.dir")}")
}
}
@@ -464,25 +464,143 @@ class WatchNextProviderTest {
}
@Test
fun onlySelectedHomeHandlerIsAnArtworkGrantConsumer() {
fun everyHomeHandlerIsAnArtworkGrantConsumer() {
val homeIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
val selected = registerHandler(homeIntent, "selected.home.launcher")
val inactive = registerHandler(homeIntent, "inactive.home.launcher")
selectDefaultHome(selected, selected, inactive)
val sideloaded = registerHandler(homeIntent, "sideloaded.home.launcher")
selectDefaultHome(selected, selected, sideloaded)
registerHandler(
Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LEANBACK_LAUNCHER),
"unrelated.leanback.app"
)
assertEquals(setOf("selected.home.launcher"), WatchNextProvider(context).consumerPackages())
assertEquals(
setOf("selected.home.launcher", "sideloaded.home.launcher"),
WatchNextProvider(context).consumerPackages()
)
}
@Test
fun bootRestoresConfinedArtworkGrantOnlyToSelectedHomeLauncher() {
fun homeHandlersAreArtworkGrantConsumersWithoutASelectedDefault() {
val homeIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
registerHandler(homeIntent, "first.home.launcher")
registerHandler(homeIntent, "second.home.launcher")
assertEquals(
setOf("first.home.launcher", "second.home.launcher"),
WatchNextProvider(context).consumerPackages()
)
}
@Test
fun syncGrantsArtworkToAHomeLauncherThatIsNotTheSystemDefault() {
withServer("image/png", imageBytes) { source ->
val homeIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
val systemDefault = registerHandler(homeIntent, "system.default.launcher")
val sideloaded = registerHandler(homeIntent, "sideloaded.home.launcher")
selectDefaultHome(systemDefault, systemDefault, sideloaded)
val grantContext = RecordingGrantContext(context)
assertTrue(
WatchNextProvider(grantContext).syncWatchNextPrograms("owner-a", 1, listOf(item(source)))
)
val poster = committedPoster()!!
assertEquals(
setOf(
Grant("system.default.launcher", poster, Intent.FLAG_GRANT_READ_URI_PERMISSION),
Grant("sideloaded.home.launcher", poster, Intent.FLAG_GRANT_READ_URI_PERMISSION)
),
grantContext.grants.toSet()
)
assertEquals(
setOf("system.default.launcher", "sideloaded.home.launcher"),
context.getSharedPreferences("system_shelf_state", 0)
.getStringSet("granted_packages", emptySet())
)
assertArrayEquals(imageBytes, openArtwork(poster))
}
}
@Test
fun syncIsAbandonedWhenNoLauncherCanBeGrantedArtwork() {
withServer("image/png", imageBytes) { source ->
val homeIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
registerHandler(homeIntent, "rejecting.home.launcher")
val grantContext = RecordingGrantContext(context, setOf("rejecting.home.launcher"))
assertFalse(
WatchNextProvider(grantContext).syncWatchNextPrograms("owner-a", 1, listOf(item(source)))
)
assertTrue(tvProvider.inserted.isEmpty())
assertTrue(artworkFiles().isEmpty())
assertTrue(
context.getSharedPreferences("system_shelf_state", 0)
.getStringSet("granted_uris", null)
.isNullOrEmpty()
)
}
}
@Test
fun syncPublishesWhenOneLauncherRejectsTheGrantAndAnotherAcceptsIt() {
withServer("image/png", imageBytes) { source ->
val homeIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
registerHandler(homeIntent, "rejecting.home.launcher")
registerHandler(homeIntent, "accepting.home.launcher")
val grantContext = RecordingGrantContext(context, setOf("rejecting.home.launcher"))
assertTrue(
WatchNextProvider(grantContext).syncWatchNextPrograms("owner-a", 1, listOf(item(source)))
)
val poster = committedPoster()!!
assertEquals(
setOf(Grant("accepting.home.launcher", poster, Intent.FLAG_GRANT_READ_URI_PERMISSION)),
grantContext.grants.toSet()
)
assertArrayEquals(imageBytes, openArtwork(poster))
}
}
@Test
fun syncIsAbandonedWhenOnePosterOfManyReachesNoLauncher() {
ScriptedHttpServer(
listOf(
ScriptedResponse(body = imageBytes),
ScriptedResponse(body = imageBytes)
)
).use { server ->
val homeIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
registerHandler(homeIntent, "only.home.launcher")
// The first poster is granted, the second reaches nobody. A global "something was granted"
// check would happily publish the second as a broken tile.
val grantContext = RecordingGrantContext(context, acceptedGrantLimit = 1)
assertFalse(
WatchNextProvider(grantContext).syncWatchNextPrograms(
"owner-a",
1,
listOf(
item("${server.baseUrl}/first"),
item("${server.baseUrl}/second").copy(contentId = "second")
)
)
)
assertEquals(1, grantContext.grants.size)
assertTrue(tvProvider.inserted.isEmpty())
assertTrue(artworkFiles().isEmpty())
}
}
@Test
fun bootRestoresConfinedArtworkGrantsToEveryHomeLauncher() {
val homeIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
val selected = registerHandler(homeIntent, "selected.home.launcher")
val inactive = registerHandler(homeIntent, "inactive.home.launcher")
selectDefaultHome(selected, selected, inactive)
val sideloaded = registerHandler(homeIntent, "sideloaded.home.launcher")
selectDefaultHome(selected, selected, sideloaded)
val owner = "a".repeat(64)
val legacyKey = "${"b".repeat(32)}.art"
val contentKey = "${"c".repeat(64)}.art"
@@ -518,7 +636,9 @@ class WatchNextProviderTest {
assertEquals(
setOf(
Grant("selected.home.launcher", legacy, Intent.FLAG_GRANT_READ_URI_PERMISSION),
Grant("selected.home.launcher", contentAddressed, Intent.FLAG_GRANT_READ_URI_PERMISSION)
Grant("selected.home.launcher", contentAddressed, Intent.FLAG_GRANT_READ_URI_PERMISSION),
Grant("sideloaded.home.launcher", legacy, Intent.FLAG_GRANT_READ_URI_PERMISSION),
Grant("sideloaded.home.launcher", contentAddressed, Intent.FLAG_GRANT_READ_URI_PERMISSION)
),
recordingContext.grants.toSet()
)
@@ -1289,7 +1409,11 @@ private data class Grant(val packageName: String, val uri: Uri, val modeFlags: I
private data class PackageRevocation(val packageName: String, val uri: Uri, val modeFlags: Int)
private class RecordingGrantContext(base: Context) : ContextWrapper(base) {
private class RecordingGrantContext(
base: Context,
private val rejectingPackages: Set<String> = emptySet(),
private val acceptedGrantLimit: Int = Int.MAX_VALUE
) : ContextWrapper(base) {
val grants = mutableListOf<Grant>()
val uriWideRevocations = mutableListOf<Uri>()
val packageRevocations = mutableListOf<PackageRevocation>()
@@ -1297,6 +1421,9 @@ private class RecordingGrantContext(base: Context) : ContextWrapper(base) {
override fun getApplicationContext(): Context = this
override fun grantUriPermission(toPackage: String?, uri: Uri?, modeFlags: Int) {
if (toPackage in rejectingPackages || grants.size >= acceptedGrantLimit) {
throw SecurityException("Cannot grant to $toPackage")
}
if (toPackage != null && uri != null) grants += Grant(toPackage, uri, modeFlags)
}