diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index c7cdd6fd..6c56eb1d 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -89,10 +89,12 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_provider_paths" />
+
-
+
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/SystemShelfArtworkProvider.kt b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/SystemShelfArtworkProvider.kt
index 808f6298..85d43ce5 100644
--- a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/SystemShelfArtworkProvider.kt
+++ b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/SystemShelfArtworkProvider.kt
@@ -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)
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextProvider.kt b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextProvider.kt
index fbdf0a1b..c71a81fb 100644
--- a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextProvider.kt
+++ b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextProvider.kt
@@ -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 {
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, packages: Set) {
+ /**
+ * 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, packages: Set): 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) {
diff --git a/android/app/src/test/kotlin/com/edde746/plezy/watchnext/SystemShelfArtworkManifestTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/watchnext/SystemShelfArtworkManifestTest.kt
new file mode 100644
index 00000000..e5fac70f
--- /dev/null
+++ b/android/app/src/test/kotlin/com/edde746/plezy/watchnext/SystemShelfArtworkManifestTest.kt
@@ -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 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")}")
+ }
+}
diff --git a/android/app/src/test/kotlin/com/edde746/plezy/watchnext/WatchNextProviderTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/watchnext/WatchNextProviderTest.kt
index 1684205b..3e53053b 100644
--- a/android/app/src/test/kotlin/com/edde746/plezy/watchnext/WatchNextProviderTest.kt
+++ b/android/app/src/test/kotlin/com/edde746/plezy/watchnext/WatchNextProviderTest.kt
@@ -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 = emptySet(),
+ private val acceptedGrantLimit: Int = Int.MAX_VALUE
+) : ContextWrapper(base) {
val grants = mutableListOf()
val uriWideRevocations = mutableListOf()
val packageRevocations = mutableListOf()
@@ -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)
}