diff --git a/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc b/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc index 187c7bb2fc0..ba8a9c48279 100644 --- a/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc +++ b/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc @@ -167,6 +167,12 @@ If none of the services are defined to true then plus, auth, base, analytics, gc |android.blockExternalStoragePermission |Boolean true/false defaults to false. Disables the external storage (SD card) permission +|android.blockReadMediaPermissions +|Boolean true/false, defaults to the value of `android.blockExternalStoragePermission`. Suppresses the `READ_MEDIA_VIDEO` and `READ_MEDIA_AUDIO` permissions that playing a URI adds on API 33 and above + +|android.requestReadMediaPermissions +|Boolean true/false defaults to false. Declares `READ_MEDIA_IMAGES`, `READ_MEDIA_VIDEO` and `READ_MEDIA_AUDIO` on API 33 and above even when the build detected no media playback. `READ_MEDIA_IMAGES` is only ever added by this hint + |android.min_sdk_version |The least SDK required to run this app, the default value changes based on functionality but can be as low as 7. This corresponds to the XML attribute `android:minSdkVersion`. @@ -776,6 +782,10 @@ a specific permission came up. This maps Android permissions to the methods/clas `android.permission.READ_CONTACTS` - requested when accessing the device address book through `Display.getAllContacts()` and related APIs. +`android.permission.READ_MEDIA_VIDEO` & `android.permission.READ_MEDIA_AUDIO` - added on API 33 and above when the app plays a URI through `MediaManager.createMedia(String, boolean)` or `createMediaAsync(String, boolean, Runnable)`, since that URI can point at the shared media store. The `InputStream` overloads don't add them: the Android port copies the stream into app private storage and reads nothing shared. Block them with `android.blockReadMediaPermissions=true`. + +`android.permission.READ_MEDIA_IMAGES` - never added by media playback, because no Codename One API requests it at runtime. Declaring it alongside `READ_MEDIA_VIDEO` places the app under Google Play's Photo and Video Permissions policy, so ask for it only when you need it, with `android.requestReadMediaPermissions=true`. + ==== Permissions under Marshmallow (Android 6+) Starting with Marshmallow (Android 6+ API level 23) Android shifted to a permissions system that prompts users for permission the first time an API is used for example: when accessing contacts the user will receive a prompt whether to allow contacts access. diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index ecbf3da6312..6e6c8a6b06d 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -369,6 +369,87 @@ static String pendingPushReplayCode(int detectedPushVersion) { + " }, this);\n"; } + /** + * Whether a createMedia call reads media the app does not own, and so + * needs the READ_MEDIA_* permissions on API 33 and up. + * + *

Only the URI overloads can: {@code createMedia(String,boolean)} + * and {@code createMediaAsync(String,boolean,Runnable)} hand the + * string to the platform, which may resolve it against the + * MediaStore. The InputStream overloads cannot -- the Android + * implementation either plays an already-open FileInputStream's + * descriptor or copies the stream into a temp file in app-private + * storage, and asks for no permission at any point.

+ * + *

They were told apart by name alone, so they were not told apart + * at all, and an app playing a bundled resource through a stream was + * built asking to read the user's photos and videos. That is not + * merely a spurious permission: READ_MEDIA_IMAGES and + * READ_MEDIA_VIDEO put the app under Play's Photo and Video + * Permissions policy, so the author gets a declaration form and a + * compliance deadline for something the app never does (issue + * #5507).

+ * + *

A null descriptor is treated as the URI overload. Over-declaring + * costs a permission; under-declaring costs a SecurityException on a + * user's device.

+ */ + static boolean readsSharedMediaForPlayback(String cls, String method, + String descriptor) { + if (cls == null || method == null) { + return false; + } + if (cls.indexOf("com/codename1/media/MediaManager") != 0 + && cls.indexOf("com/codename1/ui/Display") != 0) { + return false; + } + if (method.indexOf("createMedia") < 0 + || method.indexOf("createMediaRecorder") > -1) { + return false; + } + return descriptor == null + || descriptor.startsWith("(Ljava/lang/String;"); + } + + /** + * The READ_MEDIA_* permissions the manifest should declare, in + * manifest order. + * + *

Images are declared only when the app asked for them outright + * with {@code android.requestReadMediaPermissions}, never because + * media playback was detected. Playback cannot read an image: the one + * runtime request site in the Android port passes + * {@code PERMISSION_READ_VIDEO} or {@code PERMISSION_READ_AUDIO}, and + * nothing anywhere in the port passes + * {@code PERMISSION_READ_IMAGES}, so an inferred READ_MEDIA_IMAGES + * was a permission the app had no way to use.

+ * + *

It was not free either. READ_MEDIA_IMAGES together with + * READ_MEDIA_VIDEO is what puts an app under Play's Photo and Video + * Permissions policy, so an app that only plays audio was handed a + * declaration form and a compliance deadline for a capability it does + * not have (issue #5507).

+ * + *

Video and audio stay paired because the runtime picks between + * them on the {@code isVideo} flag of the call, which is a value this + * scan does not read.

+ */ + static List readMediaPermissionNames(boolean blocked, + int targetSdkVersion, boolean mediaPlayback, + boolean requestedOutright) { + List out = new ArrayList(); + if (blocked || targetSdkVersion < 33 + || (!mediaPlayback && !requestedOutright)) { + return out; + } + if (requestedOutright) { + out.add("android.permission.READ_MEDIA_IMAGES"); + } + out.add("android.permission.READ_MEDIA_VIDEO"); + out.add("android.permission.READ_MEDIA_AUDIO"); + return out; + } + private boolean wakeLock; private boolean recordAudio; private boolean mediaPlaybackPermission; @@ -1704,6 +1785,15 @@ public void usesClassMethodWithBooleanArgument(String cls, } } + @Override + public void usesClassMethodWithDescriptor(String cls, + String method, String descriptor) { + if (readsSharedMediaForPlayback(cls, method, + descriptor)) { + mediaPlaybackPermission = true; + } + } + @Override public void usesClassMethod(String cls, String method) { // The catalog first: it decides frameworks, gradle @@ -1905,12 +1995,10 @@ public void usesClassMethod(String cls, String method) { if (cls.indexOf("com/codename1/ui/Display") == 0 && method.indexOf("createMediaRecorder") > -1) { recordAudio = true; } - if (cls.indexOf("com/codename1/media/MediaManager") == 0 && method.indexOf("createMedia") > -1 && method.indexOf("createMediaRecorder") < 0) { - mediaPlaybackPermission = true; - } - if (cls.indexOf("com/codename1/ui/Display") == 0 && method.indexOf("createMedia") > -1 && method.indexOf("createMediaRecorder") < 0) { - mediaPlaybackPermission = true; - } + // createMedia is handled in + // usesClassMethodWithDescriptor: which overload was + // called decides whether any shared media is read, + // and the name alone cannot say. if (cls.indexOf("com/codename1/ui/Display") == 0 && method.indexOf("createContact") > -1) { contactsWritePermission = true; } @@ -3986,10 +4074,12 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { boolean blockReadMediaPermissions = request.getArg("android.blockReadMediaPermissions", blockExternalStoragePermission ? "true" : "false").equals("true"); boolean requestReadMediaPermissions = request.getArg("android.requestReadMediaPermissions", "false").equals("true"); String readMediaPermissions = ""; - if (!blockReadMediaPermissions && targetSDKVersionInt >= 33 && (mediaPlaybackPermission || requestReadMediaPermissions)) { - readMediaPermissions += permissionAdd(request, "\"android.permission.READ_MEDIA_IMAGES\"", " \n"); - readMediaPermissions += permissionAdd(request, "\"android.permission.READ_MEDIA_VIDEO\"", " \n"); - readMediaPermissions += permissionAdd(request, "\"android.permission.READ_MEDIA_AUDIO\"", " \n"); + for (String p : readMediaPermissionNames(blockReadMediaPermissions, + targetSDKVersionInt, mediaPlaybackPermission, + requestReadMediaPermissions)) { + readMediaPermissions += permissionAdd(request, "\"" + p + "\"", + " \n"); } String xmlizedDisplayName = xmlize(request.getDisplayName()); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java index 2b3f7b8920b..5a3103e0830 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java @@ -500,6 +500,34 @@ public default void declaresConcreteType(String cls) { public default void usesClassMethodWithBooleanArgument(String cls, String method, Boolean value) { } + + /** + * Reports a call together with the descriptor of the method it + * resolves to. + * + *

{@link #usesClassMethod(String, String)} reports the name + * alone, which cannot separate overloads. That is not a detail + * where the overloads do different things: + * {@code MediaManager.createMedia(String,boolean)} plays a URI + * that may point at the MediaStore, while + * {@code createMedia(InputStream,String)} copies the stream into + * app-private storage and reads no shared media at all. Keyed on + * the name, an app doing the second was built asking for + * {@code READ_MEDIA_VIDEO}, which costs its author a Play Console + * Photo and Video Permissions declaration for a permission the + * app never uses.

+ * + *

{@code descriptor} is the call site's descriptor, so it is + * what the compiler resolved rather than what runs -- close + * enough for overload selection, which is all it is for. It is + * null when the scan could not recover one; a caller must treat + * null as "may be the overload that needs the permission", + * because under-declaring costs a SecurityException on a user's + * device.

+ */ + public default void usesClassMethodWithDescriptor(String cls, + String method, String descriptor) { + } } public static interface InternalClassRemapper { @@ -755,7 +783,7 @@ public void visitFieldInsn(int i, String string, String string1, String string2) } @Override - public void visitMethodInsn(int i, String owner, String name, String string2) { + public void visitMethodInsn(int i, String owner, String name, String descriptor) { Boolean arg = pushedBoolean; pushedBoolean = null; scanner.usesClass(owner); @@ -763,6 +791,8 @@ public void visitMethodInsn(int i, String owner, String name, String string2) { scanner.usesClassMethod(owner, name); scanner.usesClassMethodWithBooleanArgument( owner, name, arg); + scanner.usesClassMethodWithDescriptor( + owner, name, descriptor); } } @@ -775,6 +805,8 @@ public void visitMethodInsn(int opcode, String owner, String name, String descri scanner.usesClassMethod(owner, name); scanner.usesClassMethodWithBooleanArgument( owner, name, arg); + scanner.usesClassMethodWithDescriptor( + owner, name, descriptor); } } @@ -823,6 +855,16 @@ public void visitInvokeDynamicInsn(String name, scanner.usesClassMethodWithBooleanArgument( h.getOwner(), h.getName(), null); + // The Handle does carry the + // descriptor of the exact + // overload the reference was + // resolved against, so a + // MediaManager::createMedia + // reference is as selectable + // as a direct call. + scanner.usesClassMethodWithDescriptor( + h.getOwner(), h.getName(), + h.getDesc()); } } } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/MediaPlaybackPermissionScanTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/MediaPlaybackPermissionScanTest.java new file mode 100644 index 00000000000..88f68524e05 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/MediaPlaybackPermissionScanTest.java @@ -0,0 +1,342 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.Handle; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Which createMedia overload was called, and whether the build can tell. + * + *

The two do different things. + * {@code createMedia(String,boolean)} hands a URI to the platform, which + * may resolve it against the MediaStore and so needs READ_MEDIA_* on API + * 33 and up. {@code createMedia(InputStream,String)} copies the stream + * into app-private storage and reads no shared media at all. Keyed on the + * method name -- the only thing the scanner used to report -- they are + * indistinguishable, and an app playing a bundled sound through a stream + * was built asking to read the user's photos and videos (issue + * #5507).

+ */ +class MediaPlaybackPermissionScanTest { + + private static final String MEDIA_MANAGER = + "com/codename1/media/MediaManager"; + private static final String DISPLAY = "com/codename1/ui/Display"; + private static final String MEDIA = "com/codename1/media/Media"; + + /** Records the descriptor reported for each call. */ + private static final class Recorder implements Executor.ClassScanner { + private final List calls = new ArrayList(); + + @Override + public void usesClass(String cls) { + } + + @Override + public void usesClassMethod(String cls, String method) { + } + + @Override + public void implementsInterface(String cls, String iface) { + } + + @Override + public void usesClassMethodWithDescriptor(String cls, String method, + String descriptor) { + if (MEDIA_MANAGER.equals(cls) || DISPLAY.equals(cls)) { + calls.add(method + descriptor); + } + } + } + + /** Executor is abstract; the scan itself needs none of these. */ + private static final class Scanner extends Executor { + @Override + public boolean build(File sourceZip, BuildRequest request) { + return false; + } + + @Override + protected String getDeviceIdCode() { + return ""; + } + + @Override + protected String generatePeerComponentCreationCode( + String methodCallString) { + return ""; + } + + @Override + protected String convertPeerComponentToNative(String param) { + return ""; + } + } + + private static void write(File dir, String name, ClassWriter w) + throws Exception { + w.visitEnd(); + File pkg = new File(dir, "app"); + assertTrue(pkg.isDirectory() || pkg.mkdirs()); + OutputStream out = new FileOutputStream(new File(pkg, name + ".class")); + try { + out.write(w.toByteArray()); + } finally { + out.close(); + } + } + + /** Emits a class calling {@code owner.name} with {@code descriptor}. */ + private static void writeCall(File dir, String name, String owner, + String method, String descriptor) throws Exception { + ClassWriter w = new ClassWriter(ClassWriter.COMPUTE_MAXS); + w.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "app/" + name, null, + "java/lang/Object", null); + MethodVisitor m = w.visitMethod(Opcodes.ACC_PUBLIC + | Opcodes.ACC_STATIC, "run", "()V", null, null); + m.visitCode(); + for (Type arg : Type.getArgumentTypes(descriptor)) { + switch (arg.getSort()) { + case Type.BOOLEAN: + m.visitInsn(Opcodes.ICONST_0); + break; + default: + m.visitInsn(Opcodes.ACONST_NULL); + break; + } + } + m.visitMethodInsn(Opcodes.INVOKESTATIC, owner, method, descriptor, + false); + if (Type.getReturnType(descriptor).getSort() != Type.VOID) { + m.visitInsn(Opcodes.POP); + } + m.visitInsn(Opcodes.RETURN); + m.visitMaxs(0, 0); + m.visitEnd(); + write(dir, name, w); + } + + private static List scan(File dir) throws Exception { + Recorder r = new Recorder(); + new Scanner().scanClassesForPermissions(dir, r); + return r.calls; + } + + // ---- the scanner reports the descriptor ------------------------- + + @Test + void theDescriptorOfACallIsReported(@TempDir File dir) throws Exception { + writeCall(dir, "Stream", MEDIA_MANAGER, "createMedia", + "(Ljava/io/InputStream;Ljava/lang/String;)L" + MEDIA + ";"); + assertEquals("[createMedia(Ljava/io/InputStream;Ljava/lang/String;)L" + + MEDIA + ";]", scan(dir).toString(), + "the name alone cannot select an overload"); + } + + /** + * A method reference resolves to one overload just as a call does, so + * {@code MediaManager::createMedia} must arrive with its descriptor + * rather than as an unknown that has to be assumed permission-worthy. + */ + @Test + void aMethodReferenceReportsItsDescriptor(@TempDir File dir) + throws Exception { + String desc = "(Ljava/io/InputStream;Ljava/lang/String;)L" + MEDIA + ";"; + ClassWriter w = new ClassWriter(ClassWriter.COMPUTE_MAXS); + w.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "app/Ref", null, + "java/lang/Object", null); + MethodVisitor m = w.visitMethod(Opcodes.ACC_PUBLIC + | Opcodes.ACC_STATIC, "run", "()V", null, null); + m.visitCode(); + Handle metafactory = new Handle(Opcodes.H_INVOKESTATIC, + "java/lang/invoke/LambdaMetafactory", "metafactory", + "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;" + + "Ljava/lang/invoke/MethodType;" + + "Ljava/lang/invoke/MethodType;" + + "Ljava/lang/invoke/MethodHandle;" + + "Ljava/lang/invoke/MethodType;)" + + "Ljava/lang/invoke/CallSite;", false); + Handle target = new Handle(Opcodes.H_INVOKESTATIC, MEDIA_MANAGER, + "createMedia", desc, false); + m.visitInvokeDynamicInsn("apply", + "()Ljava/lang/Object;", metafactory, + Type.getType("(Ljava/lang/Object;Ljava/lang/Object;)" + + "Ljava/lang/Object;"), + target, + Type.getType("(Ljava/lang/Object;Ljava/lang/Object;)" + + "Ljava/lang/Object;")); + m.visitInsn(Opcodes.POP); + m.visitInsn(Opcodes.RETURN); + m.visitMaxs(0, 0); + m.visitEnd(); + write(dir, "Ref", w); + + assertEquals("[createMedia" + desc + "]", scan(dir).toString()); + } + + // ---- which overload needs the permission ------------------------- + + @Test + void theUriOverloadsNeedTheReadMediaPermissions() { + assertTrue(AndroidGradleBuilder.readsSharedMediaForPlayback( + MEDIA_MANAGER, "createMedia", + "(Ljava/lang/String;Z)L" + MEDIA + ";")); + assertTrue(AndroidGradleBuilder.readsSharedMediaForPlayback( + MEDIA_MANAGER, "createMedia", + "(Ljava/lang/String;ZLjava/lang/Runnable;)L" + MEDIA + ";")); + assertTrue(AndroidGradleBuilder.readsSharedMediaForPlayback( + MEDIA_MANAGER, "createMediaAsync", + "(Ljava/lang/String;ZLjava/lang/Runnable;)" + + "Lcom/codename1/util/AsyncResource;")); + assertTrue(AndroidGradleBuilder.readsSharedMediaForPlayback( + DISPLAY, "createMedia", + "(Ljava/lang/String;ZLjava/lang/Runnable;)L" + MEDIA + ";"), + "the Display facade is the same call"); + } + + /** + * The stream overloads read nothing shared: the Android + * implementation plays an already-open FileInputStream's descriptor + * or copies the stream to a temp file in app-private storage, and + * asks for no permission on either path. + */ + @Test + void theStreamOverloadsNeedNothing() { + assertFalse(AndroidGradleBuilder.readsSharedMediaForPlayback( + MEDIA_MANAGER, "createMedia", + "(Ljava/io/InputStream;Ljava/lang/String;)L" + MEDIA + ";")); + assertFalse(AndroidGradleBuilder.readsSharedMediaForPlayback( + MEDIA_MANAGER, "createMedia", + "(Ljava/io/InputStream;Ljava/lang/String;Ljava/lang/Runnable;)" + + "L" + MEDIA + ";"), + "the overload from issue #5507"); + assertFalse(AndroidGradleBuilder.readsSharedMediaForPlayback( + MEDIA_MANAGER, "createMediaAsync", + "(Ljava/io/InputStream;Ljava/lang/String;Ljava/lang/Runnable;)" + + "Lcom/codename1/util/AsyncResource;")); + assertFalse(AndroidGradleBuilder.readsSharedMediaForPlayback( + DISPLAY, "createMedia", + "(Ljava/io/InputStream;Ljava/lang/String;Ljava/lang/Runnable;)" + + "L" + MEDIA + ";")); + } + + /** + * The recorder writes, it does not read shared media, and its + * descriptor starts with a String like the URI overloads do -- so the + * name exclusion still has to hold. + */ + @Test + void theRecorderIsNotPlayback() { + assertFalse(AndroidGradleBuilder.readsSharedMediaForPlayback( + MEDIA_MANAGER, "createMediaRecorder", + "(Ljava/lang/String;)L" + MEDIA + ";")); + assertFalse(AndroidGradleBuilder.readsSharedMediaForPlayback( + MEDIA_MANAGER, "createMediaRecorder", + "(Ljava/lang/String;Ljava/lang/String;)L" + MEDIA + ";")); + } + + @Test + void unrelatedClassesAndMethodsAreIgnored() { + assertFalse(AndroidGradleBuilder.readsSharedMediaForPlayback( + "app/MyMediaManager", "createMedia", + "(Ljava/lang/String;Z)L" + MEDIA + ";")); + assertFalse(AndroidGradleBuilder.readsSharedMediaForPlayback( + MEDIA_MANAGER, "addCompletionHandler", + "(L" + MEDIA + ";Ljava/lang/Runnable;)V")); + assertFalse(AndroidGradleBuilder.readsSharedMediaForPlayback( + null, null, null)); + } + + /** + * Unknown reads as the URI overload. Over-declaring costs a + * permission the app does not use; under-declaring costs a + * SecurityException on a user's device. + */ + @Test + void anUnknownDescriptorIsAssumedToNeedThePermission() { + assertTrue(AndroidGradleBuilder.readsSharedMediaForPlayback( + MEDIA_MANAGER, "createMedia", null)); + } + + // ---- which permissions reach the manifest ------------------------ + + /** + * Playback declares video and audio. Not images: nothing in the + * Android port ever requests READ_MEDIA_IMAGES, and pairing it with + * READ_MEDIA_VIDEO is what puts the app under Play's Photo and Video + * Permissions policy. + */ + @Test + void playbackDeclaresVideoAndAudioButNotImages() { + assertEquals("[android.permission.READ_MEDIA_VIDEO," + + " android.permission.READ_MEDIA_AUDIO]", + AndroidGradleBuilder.readMediaPermissionNames( + false, 36, true, false).toString()); + } + + /** The explicit opt-in still declares all three. */ + @Test + void theOptInHintDeclaresImagesToo() { + assertEquals("[android.permission.READ_MEDIA_IMAGES," + + " android.permission.READ_MEDIA_VIDEO," + + " android.permission.READ_MEDIA_AUDIO]", + AndroidGradleBuilder.readMediaPermissionNames( + false, 36, false, true).toString()); + } + + @Test + void nothingIsDeclaredWithoutPlaybackOrTheOptIn() { + assertTrue(AndroidGradleBuilder.readMediaPermissionNames( + false, 36, false, false).isEmpty()); + } + + @Test + void theBlockHintWins() { + assertTrue(AndroidGradleBuilder.readMediaPermissionNames( + true, 36, true, true).isEmpty(), + "android.blockReadMediaPermissions must override both"); + } + + /** The permissions do not exist before API 33. */ + @Test + void nothingIsDeclaredBelowApi33() { + assertTrue(AndroidGradleBuilder.readMediaPermissionNames( + false, 32, true, true).isEmpty()); + } +}