Skip to content

Commit 777739b

Browse files
authored
Handle Android media content URIs and gate READ_MEDIA permissions (#4130)
Fixed #4129
1 parent b93df5d commit 777739b

File tree

4 files changed

+408
-90
lines changed

4 files changed

+408
-90
lines changed

Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java

Lines changed: 194 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@
3939

4040
import android.webkit.CookieSyncManager;
4141
import android.content.*;
42-
import android.content.pm.*;
43-
import android.content.res.Configuration;
42+
import android.content.pm.*;
43+
import android.content.res.AssetFileDescriptor;
44+
import android.content.res.Configuration;
4445
import android.graphics.Bitmap;
4546
import android.graphics.BitmapFactory;
4647
import android.graphics.Canvas;
@@ -3603,41 +3604,70 @@ public Media createMedia(final String uri, boolean isVideo, final Runnable onCom
36033604
if (getActivity() == null) {
36043605
return null;
36053606
}
3606-
if(!uri.startsWith(FileSystemStorage.getInstance().getAppHomePath())) {
3607-
if(!PermissionsHelper.checkForPermission(isVideo ? DevicePermission.PERMISSION_READ_VIDEO : DevicePermission.PERMISSION_READ_AUDIO, "This is required to play media")){
3608-
return null;
3609-
}
3610-
}
3611-
if (uri.startsWith("file://")) {
3612-
return createMedia(removeFilePrefix(uri), isVideo, onCompletion);
3613-
}
3614-
File file = null;
3615-
if (uri.indexOf(':') < 0) {
3616-
// use a file object to play to try and workaround this issue:
3617-
// http://code.google.com/p/android/issues/detail?id=4124
3618-
file = new File(uri);
3619-
}
3620-
3621-
Media retVal;
3622-
3623-
if (isVideo) {
3624-
final AndroidImplementation.Video[] video = new AndroidImplementation.Video[1];
3625-
final boolean[] flag = new boolean[1];
3626-
final File f = file;
3627-
getActivity().runOnUiThread(new Runnable() {
3628-
@Override
3629-
public void run() {
3630-
VideoView v = new VideoView(getActivity());
3631-
v.setZOrderMediaOverlay(true);
3632-
if (f != null) {
3633-
v.setVideoURI(Uri.fromFile(f));
3634-
} else {
3635-
v.setVideoURI(Uri.parse(uri));
3636-
}
3637-
video[0] = new AndroidImplementation.Video(v, getActivity(), onCompletion);
3638-
flag[0] = true;
3639-
synchronized (flag) {
3640-
flag.notify();
3607+
if (uri.startsWith("file://")) {
3608+
return createMedia(removeFilePrefix(uri), isVideo, onCompletion);
3609+
}
3610+
File file = null;
3611+
if (uri.indexOf(':') < 0) {
3612+
// use a file object to play to try and workaround this issue:
3613+
// http://code.google.com/p/android/issues/detail?id=4124
3614+
file = new File(uri);
3615+
}
3616+
3617+
Uri parsedUri = null;
3618+
boolean isContentUri = false;
3619+
if (file == null) {
3620+
parsedUri = Uri.parse(uri);
3621+
isContentUri = parsedUri != null && "content".equalsIgnoreCase(parsedUri.getScheme());
3622+
}
3623+
3624+
// The document picker grants temporary permissions for content URIs. Requesting
3625+
// READ_EXTERNAL_STORAGE again would surface a redundant prompt on Android 13+, so we only
3626+
// ask for classic file paths that require the legacy permission. MediaStore URIs still
3627+
// require an explicit permission grant, so they remain subject to the legacy check even
3628+
// though they also use the content:// scheme.
3629+
boolean requiresLegacyPermission = !uri.startsWith(FileSystemStorage.getInstance().getAppHomePath());
3630+
if (isContentUri && parsedUri != null) {
3631+
String authority = parsedUri.getAuthority();
3632+
if (authority != null) {
3633+
authority = authority.toLowerCase();
3634+
if (!"media".equals(authority) && !authority.startsWith("media.")) {
3635+
if (!"com.android.providers.media.documents".equals(authority)) {
3636+
requiresLegacyPermission = false;
3637+
}
3638+
}
3639+
} else {
3640+
requiresLegacyPermission = false;
3641+
}
3642+
}
3643+
3644+
if(requiresLegacyPermission) {
3645+
if(!PermissionsHelper.checkForPermission(isVideo ? DevicePermission.PERMISSION_READ_VIDEO : DevicePermission.PERMISSION_READ_AUDIO, "This is required to play media")){
3646+
return null;
3647+
}
3648+
}
3649+
3650+
Media retVal;
3651+
3652+
if (isVideo) {
3653+
final AndroidImplementation.Video[] video = new AndroidImplementation.Video[1];
3654+
final boolean[] flag = new boolean[1];
3655+
final File f = file;
3656+
final Uri videoUri = parsedUri;
3657+
getActivity().runOnUiThread(new Runnable() {
3658+
@Override
3659+
public void run() {
3660+
VideoView v = new VideoView(getActivity());
3661+
v.setZOrderMediaOverlay(true);
3662+
if (f != null) {
3663+
v.setVideoURI(Uri.fromFile(f));
3664+
} else {
3665+
v.setVideoURI(videoUri != null ? videoUri : Uri.parse(uri));
3666+
}
3667+
video[0] = new AndroidImplementation.Video(v, getActivity(), onCompletion);
3668+
flag[0] = true;
3669+
synchronized (flag) {
3670+
flag.notify();
36413671
}
36423672
}
36433673
});
@@ -3652,18 +3682,47 @@ public void run() {
36523682
return video[0];
36533683
} else {
36543684
MediaPlayer player;
3655-
if (file != null) {
3656-
FileInputStream is = new FileInputStream(file);
3657-
player = new MediaPlayer();
3658-
player.setDataSource(is.getFD());
3659-
player.prepare();
3660-
} else {
3661-
player = MediaPlayer.create(getActivity(), Uri.parse(uri));
3662-
}
3663-
retVal = new Audio(getActivity(), player, null, onCompletion);
3664-
}
3665-
return retVal;
3666-
}
3685+
if (file != null) {
3686+
FileInputStream is = new FileInputStream(file);
3687+
player = new MediaPlayer();
3688+
player.setDataSource(is.getFD());
3689+
player.prepare();
3690+
} else {
3691+
player = MediaPlayer.create(getActivity(), parsedUri != null ? parsedUri : Uri.parse(uri));
3692+
if (player == null && isContentUri) {
3693+
// Android 13+ introduces stricter access rules for content:// URIs returned
3694+
// from the system document picker. The picker grants our activity a
3695+
// persistable read permission, but some OEM builds still reject the URI when it
3696+
// is passed directly to MediaPlayer. Opening the descriptor ourselves keeps the
3697+
// same permission grant while avoiding the OEM bug.
3698+
ContentResolver resolver = getContext().getContentResolver();
3699+
if (resolver != null && parsedUri != null) {
3700+
AssetFileDescriptor afd = null;
3701+
try {
3702+
afd = resolver.openAssetFileDescriptor(parsedUri, "r");
3703+
if (afd != null) {
3704+
player = new MediaPlayer();
3705+
player.setDataSource(afd.getFileDescriptor(), afd.getStartOffset(), afd.getLength());
3706+
player.prepare();
3707+
}
3708+
} finally {
3709+
if (afd != null) {
3710+
try {
3711+
afd.close();
3712+
} catch (IOException ignore) {
3713+
}
3714+
}
3715+
}
3716+
}
3717+
}
3718+
}
3719+
if (player == null) {
3720+
throw new IOException("Unable to create media player for uri " + uri);
3721+
}
3722+
retVal = new Audio(getActivity(), player, null, onCompletion);
3723+
}
3724+
return retVal;
3725+
}
36673726

36683727
@Override
36693728
public void addCompletionHandler(Media media, Runnable onCompletion) {
@@ -8072,15 +8131,17 @@ private String getImageFilePath(Uri uri) {
80728131
@Override
80738132
public void onActivityResult(int requestCode, int resultCode, Intent intent) {
80748133

8075-
if (requestCode == ZOOZ_PAYMENT) {
8076-
((IntentResultListener) pur).onActivityResult(requestCode, resultCode, intent);
8077-
return;
8078-
}
8079-
8080-
if (requestCode == REQUEST_SELECT_FILE || requestCode == FILECHOOSER_RESULTCODE) {
8081-
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
8082-
if (requestCode == REQUEST_SELECT_FILE) {
8083-
if (uploadMessage == null) return;
8134+
if (requestCode == ZOOZ_PAYMENT) {
8135+
((IntentResultListener) pur).onActivityResult(requestCode, resultCode, intent);
8136+
return;
8137+
}
8138+
8139+
takePersistablePermissionsFromIntent(intent);
8140+
8141+
if (requestCode == REQUEST_SELECT_FILE || requestCode == FILECHOOSER_RESULTCODE) {
8142+
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
8143+
if (requestCode == REQUEST_SELECT_FILE) {
8144+
if (uploadMessage == null) return;
80848145
Uri[] results = null;
80858146

80868147
// Check that the response is a good one
@@ -8618,45 +8679,92 @@ public void openGallery(final ActionListener response, int type){
86188679

86198680
callback = new EventDispatcher();
86208681
callback.addListener(response);
8621-
Intent galleryIntent = new Intent(Intent.ACTION_PICK, android.provider.MediaStore.Images.Media.INTERNAL_CONTENT_URI);
8622-
if (multi) {
8623-
galleryIntent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
8624-
}
8682+
Intent galleryIntent = new Intent(Intent.ACTION_PICK, android.provider.MediaStore.Images.Media.INTERNAL_CONTENT_URI);
8683+
galleryIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
8684+
if (multi) {
8685+
galleryIntent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
8686+
}
86258687
if(type == Display.GALLERY_VIDEO){
86268688
galleryIntent.setType("video/*");
86278689
}else if(type == Display.GALLERY_IMAGE){
86288690
galleryIntent.setType("image/*");
86298691
}else if(type == Display.GALLERY_ALL){
86308692
galleryIntent.setType("image/* video/*");
8631-
}else if (type == -9999) {
8632-
galleryIntent = new Intent();
8633-
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) {
8634-
galleryIntent.setAction(Intent.ACTION_OPEN_DOCUMENT);
8635-
} else {
8636-
galleryIntent.setAction(Intent.ACTION_GET_CONTENT);
8637-
}
8638-
galleryIntent.addCategory(Intent.CATEGORY_OPENABLE);
8639-
8640-
// set MIME type for image
8641-
galleryIntent.setType("*/*");
8642-
galleryIntent.putExtra(Intent.EXTRA_MIME_TYPES, Display.getInstance().getProperty("android.openGallery.accept", "*/*").split(","));
8643-
}else{
8693+
}else if (type == -9999) {
8694+
galleryIntent = new Intent();
8695+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) {
8696+
galleryIntent.setAction(Intent.ACTION_OPEN_DOCUMENT);
8697+
} else {
8698+
galleryIntent.setAction(Intent.ACTION_GET_CONTENT);
8699+
}
8700+
galleryIntent.addCategory(Intent.CATEGORY_OPENABLE);
8701+
galleryIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
8702+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) {
8703+
galleryIntent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION);
8704+
}
8705+
8706+
// set MIME type for image
8707+
galleryIntent.setType("*/*");
8708+
galleryIntent.putExtra(Intent.EXTRA_MIME_TYPES, Display.getInstance().getProperty("android.openGallery.accept", "*/*").split(","));
8709+
}else{
86448710
galleryIntent.setType("*/*");
86458711
}
86468712
this.getActivity().startActivityForResult(galleryIntent, multi ? OPEN_GALLERY_MULTI: OPEN_GALLERY);
86478713
}
86488714

8649-
class NativeImage extends Image {
8650-
8651-
public NativeImage(Bitmap nativeImage) {
8652-
super(nativeImage);
8653-
}
8654-
}
8655-
8656-
/**
8657-
* Create a File for saving an image or video
8658-
*/
8659-
private File getOutputMediaFile(boolean isVideo) {
8715+
class NativeImage extends Image {
8716+
8717+
public NativeImage(Bitmap nativeImage) {
8718+
super(nativeImage);
8719+
}
8720+
}
8721+
8722+
/**
8723+
* Persist read permissions that were granted by an activity result so that media playback can
8724+
* continue after {@link Activity#onActivityResult(int, int, Intent)} returns.
8725+
*
8726+
* <p>Android 13 and newer revoke temporary grants immediately after the callback unless the
8727+
* app calls {@link ContentResolver#takePersistableUriPermission(Uri, int)}. Without this call
8728+
* {@link #createMedia(String, boolean, Runnable)} loses access to the {@code content://} URI
8729+
* provided by the system picker and playback fails on Android 15.</p>
8730+
*/
8731+
private void takePersistablePermissionsFromIntent(Intent intent) {
8732+
if (intent == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
8733+
return;
8734+
}
8735+
int takeFlags = intent.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
8736+
if (takeFlags == 0) {
8737+
return;
8738+
}
8739+
ContentResolver resolver = getContext().getContentResolver();
8740+
if (resolver == null) {
8741+
return;
8742+
}
8743+
ClipData clip = intent.getClipData();
8744+
if (clip != null) {
8745+
for (int i = 0; i < clip.getItemCount(); i++) {
8746+
Uri uri = clip.getItemAt(i).getUri();
8747+
if (uri != null) {
8748+
try {
8749+
resolver.takePersistableUriPermission(uri, takeFlags);
8750+
} catch (SecurityException ignored) {
8751+
}
8752+
}
8753+
}
8754+
}
8755+
Uri dataUri = intent.getData();
8756+
if (dataUri != null) {
8757+
try {
8758+
resolver.takePersistableUriPermission(dataUri, takeFlags);
8759+
} catch (SecurityException ignored) {
8760+
}
8761+
}
8762+
}
8763+
8764+
/**
8765+
* Create a File for saving an image or video
8766+
*/
8767+
private File getOutputMediaFile(boolean isVideo) {
86608768
// To be safe, you should check that the SDCard is mounted
86618769
// using Environment.getExternalStorageState() before doing this.
86628770
if (getActivity() != null) {

maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,7 @@ public File getGradleProjectDirectory() {
293293
private boolean contactsPermission;
294294
private boolean wakeLock;
295295
private boolean recordAudio;
296+
private boolean mediaPlaybackPermission;
296297
private boolean phonePermission;
297298
private boolean purchasePermissions;
298299
private boolean accessNetworkStatePermission;
@@ -1162,6 +1163,7 @@ public boolean build(File sourceZip, final BuildRequest request) throws BuildExc
11621163
playFlag = "true";
11631164

11641165
gpsPermission = request.getArg("android.gpsPermission", "false").equals("true");
1166+
mediaPlaybackPermission = false;
11651167
try {
11661168
scanClassesForPermissions(dummyClassesDir, new Executor.ClassScanner() {
11671169

@@ -1288,6 +1290,12 @@ public void usesClassMethod(String cls, String method) {
12881290
if (cls.indexOf("com/codename1/ui/Display") == 0 && method.indexOf("createMediaRecorder") > -1) {
12891291
recordAudio = true;
12901292
}
1293+
if (cls.indexOf("com/codename1/media/MediaManager") == 0 && method.indexOf("createMedia") > -1 && method.indexOf("createMediaRecorder") < 0) {
1294+
mediaPlaybackPermission = true;
1295+
}
1296+
if (cls.indexOf("com/codename1/ui/Display") == 0 && method.indexOf("createMedia") > -1 && method.indexOf("createMediaRecorder") < 0) {
1297+
mediaPlaybackPermission = true;
1298+
}
12911299
if (cls.indexOf("com/codename1/ui/Display") == 0 && method.indexOf("createContact") > -1) {
12921300
contactsWritePermission = true;
12931301
}
@@ -2203,9 +2211,18 @@ public void usesClassMethod(String cls, String method) {
22032211
if (request.getArg("android.removeBasePermissions", "false").equals("true")) {
22042212
basePermissions = "";
22052213
}
2206-
String externalStoragePermission = " <uses-permission android:name=\"android.permission.WRITE_EXTERNAL_STORAGE\" android:required=\"false\" android:maxSdkVersion=\"32\" />\n";
2207-
if (request.getArg("android.blockExternalStoragePermission", "false").equals("true")) {
2208-
externalStoragePermission = "";
2214+
boolean blockExternalStoragePermission = request.getArg("android.blockExternalStoragePermission", "false").equals("true");
2215+
String externalStoragePermission = "";
2216+
if (!blockExternalStoragePermission) {
2217+
externalStoragePermission = " <uses-permission android:name=\"android.permission.WRITE_EXTERNAL_STORAGE\" android:required=\"false\" android:maxSdkVersion=\"32\" />\n";
2218+
}
2219+
boolean blockReadMediaPermissions = request.getArg("android.blockReadMediaPermissions", blockExternalStoragePermission ? "true" : "false").equals("true");
2220+
boolean requestReadMediaPermissions = request.getArg("android.requestReadMediaPermissions", "false").equals("true");
2221+
String readMediaPermissions = "";
2222+
if (!blockReadMediaPermissions && targetSDKVersionInt >= 33 && (mediaPlaybackPermission || requestReadMediaPermissions)) {
2223+
readMediaPermissions += permissionAdd(request, "\"android.permission.READ_MEDIA_IMAGES\"", " <uses-permission android:name=\"android.permission.READ_MEDIA_IMAGES\" android:required=\"false\" />\n");
2224+
readMediaPermissions += permissionAdd(request, "\"android.permission.READ_MEDIA_VIDEO\"", " <uses-permission android:name=\"android.permission.READ_MEDIA_VIDEO\" android:required=\"false\" />\n");
2225+
readMediaPermissions += permissionAdd(request, "\"android.permission.READ_MEDIA_AUDIO\"", " <uses-permission android:name=\"android.permission.READ_MEDIA_AUDIO\" android:required=\"false\" />\n");
22092226
}
22102227
String xmlizedDisplayName = xmlize(request.getDisplayName());
22112228

@@ -2356,6 +2373,7 @@ public void usesClassMethod(String cls, String method) {
23562373
+ " <uses-feature android:name=\"android.hardware.touchscreen\" android:required=\"false\" />\n"
23572374
+ basePermissions
23582375
+ externalStoragePermission
2376+
+ readMediaPermissions
23592377
+ permissions
23602378
+ " " + xPermissions
23612379
+ " " + xQueries

scripts/device-runner-app/tests/Cn1ssDeviceRunner.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
public final class Cn1ssDeviceRunner extends DeviceRunner {
99
private static final String[] TEST_CLASSES = new String[] {
1010
MainScreenScreenshotTest.class.getName(),
11-
BrowserComponentScreenshotTest.class.getName()
11+
BrowserComponentScreenshotTest.class.getName(),
12+
MediaPlaybackScreenshotTest.class.getName()
1213
};
1314

1415
public void runSuite() {

0 commit comments

Comments
 (0)