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());
+ }
+}