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:
@@ -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" />
|
||||
|
||||
+7
-15
@@ -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>) {
|
||||
|
||||
+58
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user