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) }