From d6f9323e3c8da47d91297381238a452874a741e5 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 2 Aug 2026 09:11:30 +0700 Subject: [PATCH 1/2] Stop declaring photo/video permissions for stream media playback (#5507) An app that plays a bundled sound with MediaManager.createMedia(InputStream, String, Runnable) was built with READ_MEDIA_IMAGES and READ_MEDIA_VIDEO in its manifest. Those two together put the app under Google Play's Photo and Video Permissions policy, so its author gets a declaration form and a compliance deadline for a capability the app does not have. Two separate causes. The scanner reported only a method name, so the createMedia overloads were indistinguishable. They are not interchangeable: the URI overloads hand a string to the platform, which may resolve it against the MediaStore, while the InputStream overloads either play an already-open FileInputStream's descriptor or copy the stream into a temp file in app-private storage -- AndroidImplementation asks for no permission on either path. ClassScanner gains usesClassMethodWithDescriptor, fed from both visitMethodInsn overloads and from the Handle of an invokedynamic, and the createMedia rule moves onto it. READ_MEDIA_IMAGES was also emitted for any detected playback. Nothing in the Android port can use it: the one runtime request site passes PERMISSION_READ_VIDEO or PERMISSION_READ_AUDIO, and no caller anywhere passes PERMISSION_READ_IMAGES. It is now declared only when an app asks outright with android.requestReadMediaPermissions. Video and audio stay paired, since the runtime chooses between them on the isVideo argument, which this scan does not read. An unrecognized descriptor still counts as the URI overload: over-declaring costs a permission, under-declaring costs a SecurityException on a device. Also documents android.blockReadMediaPermissions and android.requestReadMediaPermissions in the developer guide, neither of which was listed. Co-Authored-By: Claude Opus 5 (1M context) --- .../Advanced-Topics-Under-The-Hood.asciidoc | 10 + .../builders/AndroidGradleBuilder.java | 110 +++++- .../java/com/codename1/builders/Executor.java | 44 ++- .../MediaPlaybackPermissionScanTest.java | 342 ++++++++++++++++++ 4 files changed, 495 insertions(+), 11 deletions(-) create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/MediaPlaybackPermissionScanTest.java diff --git a/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc b/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc index 187c7bb2fc0..0b2f83eaf31 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 do not 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()); + } +} From 3427391eb5a7e6033c15a38085a745e5882d4c08 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 2 Aug 2026 09:27:45 +0700 Subject: [PATCH 2/2] docs: contraction the developer guide vale gate requires Co-Authored-By: Claude Opus 5 (1M context) --- docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc b/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc index 0b2f83eaf31..ba8a9c48279 100644 --- a/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc +++ b/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc @@ -782,7 +782,7 @@ 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 do not 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_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`.