diff --git a/media/java/android/media/ExternalRingtonesCursorWrapper.java b/media/java/android/media/ExternalRingtonesCursorWrapper.java new file mode 100644 index 0000000000000..dd4c77a045778 --- /dev/null +++ b/media/java/android/media/ExternalRingtonesCursorWrapper.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.media; + +import android.content.ContentProvider; +import android.database.Cursor; +import android.database.CursorWrapper; +import android.net.Uri; + +/** + * Cursor that adds the user id to fetched URIs. This is especially useful for {@link getCursor} as + * a managed profile should also list its parent's ringtones + * + * @hide + */ +public class ExternalRingtonesCursorWrapper extends CursorWrapper { + + private int mUserId; + + public ExternalRingtonesCursorWrapper(Cursor cursor, int userId) { + super(cursor); + mUserId = userId; + } + + public String getString(int index) { + String result = super.getString(index); + if (index == RingtoneManager.URI_COLUMN_INDEX) { + result = ContentProvider.maybeAddUserId(Uri.parse(result), mUserId).toString(); + } + return result; + } +} diff --git a/media/java/android/media/RingtoneManager.java b/media/java/android/media/RingtoneManager.java index de9f0202a44c0..7614999074c62 100644 --- a/media/java/android/media/RingtoneManager.java +++ b/media/java/android/media/RingtoneManager.java @@ -17,9 +17,11 @@ package android.media; import android.Manifest; +import android.annotation.NonNull; import android.annotation.RequiresPermission; import android.annotation.SdkConstant; import android.annotation.SdkConstant.SdkConstantType; +import android.annotation.WorkerThread; import android.app.Activity; import android.content.ContentProvider; import android.content.ContentResolver; @@ -28,6 +30,7 @@ import android.content.Context; import android.content.pm.PackageManager; import android.content.pm.UserInfo; import android.database.Cursor; +import android.media.MediaScannerConnection.MediaScannerConnectionClient; import android.net.Uri; import android.os.Environment; import android.os.ParcelFileDescriptor; @@ -43,11 +46,16 @@ import com.android.internal.database.SortCursor; import libcore.io.Streams; +import java.io.Closeable; +import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.LinkedBlockingQueue; import static android.content.ContentProvider.maybeAddUserId; import static android.content.pm.PackageManager.NameNotFoundException; @@ -253,6 +261,8 @@ public class RingtoneManager { private boolean mStopPreviousRingtone = true; private Ringtone mPreviousRingtone; + private boolean mIncludeParentRingtones; + /** * Constructs a RingtoneManager. This constructor is recommended as its * constructed instance manages cursor(s). @@ -260,9 +270,24 @@ public class RingtoneManager { * @param activity The activity used to get a managed cursor. */ public RingtoneManager(Activity activity) { + this(activity, /* includeParentRingtones */ false); + } + + /** + * Constructs a RingtoneManager. This constructor is recommended if there's the need to also + * list ringtones from the user's parent. + * + * @param activity The activity used to get a managed cursor. + * @param includeParentRingtones if true, this ringtone manager's cursor will also retrieve + * ringtones from the parent of the user specified in the given activity + * + * @hide + */ + public RingtoneManager(Activity activity, boolean includeParentRingtones) { mActivity = activity; mContext = activity; setType(mType); + mIncludeParentRingtones = includeParentRingtones; } /** @@ -273,9 +298,23 @@ public class RingtoneManager { * @param context The context to used to get a cursor. */ public RingtoneManager(Context context) { + this(context, /* includeParentRingtones */ false); + } + + /** + * Constructs a RingtoneManager. + * + * @param context The context to used to get a cursor. + * @param includeParentRingtones if true, this ringtone manager's cursor will also retrieve + * ringtones from the parent of the user specified in the given context + * + * @hide + */ + public RingtoneManager(Context context, boolean includeParentRingtones) { mActivity = null; mContext = context; setType(mType); + mIncludeParentRingtones = includeParentRingtones; } /** @@ -395,14 +434,38 @@ public class RingtoneManager { if (mCursor != null && mCursor.requery()) { return mCursor; } - - final Cursor internalCursor = getInternalRingtones(); - final Cursor mediaCursor = getMediaRingtones(); - - return mCursor = new SortCursor(new Cursor[] { internalCursor, mediaCursor }, + + ArrayList ringtoneCursors = new ArrayList(); + ringtoneCursors.add(getInternalRingtones()); + ringtoneCursors.add(getMediaRingtones()); + + if (mIncludeParentRingtones) { + Cursor parentRingtonesCursor = getParentProfileRingtones(); + if (parentRingtonesCursor != null) { + ringtoneCursors.add(parentRingtonesCursor); + } + } + + return mCursor = new SortCursor(ringtoneCursors.toArray(new Cursor[ringtoneCursors.size()]), MediaStore.Audio.Media.DEFAULT_SORT_ORDER); } + private Cursor getParentProfileRingtones() { + final UserManager um = UserManager.get(mContext); + final UserInfo parentInfo = um.getProfileParent(mContext.getUserId()); + if (parentInfo != null && parentInfo.id != mContext.getUserId()) { + final Context parentContext = createPackageContextAsUser(mContext, parentInfo.id); + if (parentContext != null) { + // We don't need to re-add the internal ringtones for the work profile since + // they are the same as the personal profile. We just need the external + // ringtones. + return new ExternalRingtonesCursorWrapper(getMediaRingtones(parentContext), + parentInfo.id); + } + } + return null; + } + /** * Gets a {@link Ringtone} for the ringtone at the given position in the * {@link Cursor}. @@ -434,7 +497,35 @@ public class RingtoneManager { return getUriFromCursor(mCursor); } - + + /** + * Queries the database for the Uri to a ringtone in a specific path (the ringtone has to have + * been scanned before) + * + * @param context Context used to query the database + * @param path Path to the ringtone file + * @return Uri of the ringtone, null if something fails in the query or the ringtone doesn't + * exist + * + * @hide + */ + private static Uri getExistingRingtoneUriFromPath(Context context, String path) { + final String[] proj = {MediaStore.Audio.Media._ID}; + final String[] selectionArgs = {path}; + try (final Cursor cursor = context.getContentResolver().query( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, proj, + MediaStore.Audio.Media.DATA + "=? ", selectionArgs, /* sortOrder */ null)) { + if (cursor == null || !cursor.moveToFirst()) { + return null; + } + final int id = cursor.getInt(cursor.getColumnIndex(MediaStore.MediaColumns._ID)); + if (id == -1) { + return null; + } + return Uri.withAppendedPath(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, "" + id); + } + } + private static Uri getUriFromCursor(Cursor cursor) { return ContentUris.withAppendedId(Uri.parse(cursor.getString(URI_COLUMN_INDEX)), cursor .getLong(ID_COLUMN_INDEX)); @@ -523,7 +614,11 @@ public class RingtoneManager { } private Cursor getMediaRingtones() { - if (PackageManager.PERMISSION_GRANTED != mContext.checkPermission( + return getMediaRingtones(mContext); + } + + private Cursor getMediaRingtones(Context context) { + if (PackageManager.PERMISSION_GRANTED != context.checkPermission( android.Manifest.permission.READ_EXTERNAL_STORAGE, Process.myPid(), Process.myUid())) { Log.w(TAG, "No READ_EXTERNAL_STORAGE permission, ignoring ringtones on ext storage"); @@ -537,7 +632,7 @@ public class RingtoneManager { ? query( MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, MEDIA_COLUMNS, constructBooleanTrueWhereClause(mFilterColumns), null, - MediaStore.Audio.Media.DEFAULT_SORT_ORDER) + MediaStore.Audio.Media.DEFAULT_SORT_ORDER, context) : null; } @@ -592,10 +687,19 @@ public class RingtoneManager { String selection, String[] selectionArgs, String sortOrder) { + return query(uri, projection, selection, selectionArgs, sortOrder, mContext); + } + + private Cursor query(Uri uri, + String[] projection, + String selection, + String[] selectionArgs, + String sortOrder, + Context context) { if (mActivity != null) { return mActivity.managedQuery(uri, projection, selection, selectionArgs, sortOrder); } else { - return mContext.getContentResolver().query(uri, projection, selection, selectionArgs, + return context.getContentResolver().query(uri, projection, selection, selectionArgs, sortOrder); } } @@ -642,7 +746,29 @@ public class RingtoneManager { return null; } - + + /** + * Look up the path for a given {@link Uri} referring to a ringtone sound (TYPE_RINGTONE, + * TYPE_NOTIFICATION, or TYPE_ALARM). This is saved in {@link MediaStore.Audio.Media#DATA}. + * + * @return a {@link File} pointing at the location of the {@param uri} on disk, or {@code null} + * if there is no such file. + */ + private File getRingtonePathFromUri(Uri uri) { + // Query cursor to get ringtone path + final String[] projection = {MediaStore.Audio.Media.DATA}; + setFilterColumnsList(TYPE_RINGTONE | TYPE_NOTIFICATION | TYPE_ALARM); + + String path = null; + try (Cursor cursor = query(uri, projection, constructBooleanTrueWhereClause(mFilterColumns), + null, null)) { + if (cursor != null && cursor.moveToFirst()) { + path = cursor.getString(cursor.getColumnIndex(MediaStore.Audio.Media.DATA)); + } + } + return path != null ? new File(path) : null; + } + /** * Disables Settings.System.SYNC_PARENT_SOUNDS, copying the parent's ringtones to the current * profile @@ -659,17 +785,14 @@ public class RingtoneManager { UserManager um = UserManager.get(userContext); UserInfo parentInfo = um.getProfileParent(userContext.getUserId()); if (parentInfo != null) { - try { - Context targetContext = userContext.createPackageContextAsUser( - userContext.getPackageName(), 0 /* flags */, UserHandle.of(parentInfo.id)); + final Context targetContext = createPackageContextAsUser(userContext, parentInfo.id); + if (targetContext != null) { for (int ringtoneType : RINGTONE_TYPES) { Uri ringtoneUri = getActualDefaultRingtoneUri(targetContext, ringtoneType); // Add user id of parent so that custom ringtones can be read and played RingtoneManager.setActualDefaultRingtoneUri(userContext, ringtoneType, maybeAddUserId(ringtoneUri, parentInfo.id)); } - } catch (NameNotFoundException e) { - Log.e(TAG, "Unable to create parent context", e); } } } @@ -749,9 +872,157 @@ public class RingtoneManager { } private static boolean isInternalRingtoneUri(Uri uri) { - Uri uriWithoutUserId = ContentProvider.getUriWithoutUserId(uri); - return uriWithoutUserId == null ? false : uriWithoutUserId.toString() - .startsWith(MediaStore.Audio.Media.INTERNAL_CONTENT_URI.toString()); + return isRingtoneUriInStorage(uri, MediaStore.Audio.Media.INTERNAL_CONTENT_URI); + } + + private static boolean isExternalRingtoneUri(Uri uri) { + return isRingtoneUriInStorage(uri, MediaStore.Audio.Media.EXTERNAL_CONTENT_URI); + } + + private static boolean isRingtoneUriInStorage(Uri ringtone, Uri storage) { + Uri uriWithoutUserId = ContentProvider.getUriWithoutUserId(ringtone); + return uriWithoutUserId == null ? false + : uriWithoutUserId.toString().startsWith(storage.toString()); + } + + /** @hide */ + public boolean isCustomRingtone(Uri uri) { + if(!isExternalRingtoneUri(uri)) { + // A custom ringtone would be in the external storage + return false; + } + + final File ringtoneFile = (uri == null ? null : getRingtonePathFromUri(uri)); + final File parent = (ringtoneFile == null ? null : ringtoneFile.getParentFile()); + if (parent == null) { + return false; + } + + final String[] directories = { + Environment.DIRECTORY_RINGTONES, + Environment.DIRECTORY_NOTIFICATIONS, + Environment.DIRECTORY_ALARMS + }; + for (final String directory : directories) { + if (parent.equals(Environment.getExternalStoragePublicDirectory(directory))) { + return true; + } + } + return false; + } + + /** + * Adds an audio file to the list of ringtones. + * + * After making sure the given file is an audio file, copies the file to the ringtone storage, + * and asks the {@link android.media.MediaScanner} to scan that file. This call will block until + * the scan is completed. + * + * The directory where the copied file is stored is the directory that matches the ringtone's + * type, which is one of: {@link android.is.Environment#DIRECTORY_RINGTONES}; + * {@link android.is.Environment#DIRECTORY_NOTIFICATIONS}; + * {@link android.is.Environment#DIRECTORY_ALARMS}. + * + * This does not allow modifying the type of an existing ringtone file. To change type, use the + * APIs in {@link android.content.ContentResolver} to update the corresponding columns. + * + * @param fileUri Uri of the file to be added as ringtone. Must be a media file. + * @param type The type of the ringtone to be added. Must be one of {@link #TYPE_RINGTONE}, + * {@link #TYPE_NOTIFICATION}, or {@link #TYPE_ALARM}. + * + * @return The Uri of the installed ringtone, which may be the Uri of {@param fileUri} if it is + * already in ringtone storage. + * + * @throws FileNotFoundexception if an appropriate unique filename to save the new ringtone file + * as cannot be found, for example if the unique name is too long. + * @throws IllegalArgumentException if {@param fileUri} does not point to an existing audio + * file, or if the {@param type} is not one of the accepted ringtone types. + * @throws IOException if the audio file failed to copy to ringtone storage; for example, if + * external storage was not available, or if the file was copied but the media scanner + * did not recognize it as a ringtone. + * + * @hide + */ + @WorkerThread + public Uri addCustomExternalRingtone(@NonNull final Uri fileUri, final int type) + throws FileNotFoundException, IllegalArgumentException, IOException { + if (!Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) { + throw new IOException("External storage is not mounted. Unable to install ringtones."); + } + + // Sanity-check: are we actually being asked to install an audio file? + final String mimeType = mContext.getContentResolver().getType(fileUri); + if(mimeType == null || !mimeType.startsWith("audio/")) { + throw new IllegalArgumentException("Ringtone file must have MIME type \"audio/*\"." + + " Given file has MIME type \"" + mimeType + "\""); + } + + // Choose a directory to save the ringtone. Only one type of installation at a time is + // allowed. Throws IllegalArgumentException if anything else is given. + final String subdirectory = getExternalDirectoryForType(type); + + // Find a filename. Throws FileNotFoundException if none can be found. + final File outFile = Utils.getUniqueExternalFile(mContext, subdirectory, + Utils.getFileDisplayNameFromUri(mContext, fileUri), mimeType); + + // Copy contents to external ringtone storage. Throws IOException if the copy fails. + try (final InputStream input = mContext.getContentResolver().openInputStream(fileUri); + final OutputStream output = new FileOutputStream(outFile)) { + Streams.copy(input, output); + } + + // Tell MediaScanner about the new file. Wait for it to assign a {@link Uri}. + try (NewRingtoneScanner scanner = new NewRingtoneScanner(outFile)) { + return scanner.take(); + } catch (InterruptedException e) { + throw new IOException("Audio file failed to scan as a ringtone", e); + } + } + + private static final String getExternalDirectoryForType(final int type) { + switch (type) { + case TYPE_RINGTONE: + return Environment.DIRECTORY_RINGTONES; + case TYPE_NOTIFICATION: + return Environment.DIRECTORY_NOTIFICATIONS; + case TYPE_ALARM: + return Environment.DIRECTORY_ALARMS; + default: + throw new IllegalArgumentException("Unsupported ringtone type: " + type); + } + } + + /** + * Deletes the actual file in the Uri and its ringtone database entry if the Uri's actual path + * is in one of the following directories: {@link android.is.Environment#DIRECTORY_RINGTONES}, + * {@link android.is.Environment#DIRECTORY_NOTIFICATIONS} or + * {@link android.is.Environment#DIRECTORY_ALARMS}. + * + * The given Uri must be a ringtone Content Uri. + * + * Keep in mind that if the ringtone deleted is a default ringtone, it will still live in the + * ringtone cache file so it will be playable from there. However, if an app uses the ringtone + * as its own ringtone, it won't be played, which is the same behavior observed for 3rd party + * custom ringtones. + * + * @hide + */ + public boolean deleteExternalRingtone(Uri uri) { + if(!isCustomRingtone(uri)) { + // We can only delete custom ringtones in the default ringtone storages + return false; + } + + // Save the path of the ringtone before deleting from our content resolver. + final File ringtoneFile = getRingtonePathFromUri(uri); + try { + if (ringtoneFile != null && mContext.getContentResolver().delete(uri, null, null) > 0) { + return ringtoneFile.delete(); + } + } catch (SecurityException e) { + Log.d(TAG, "Unable to delete custom ringtone", e); + } + return false; } /** @@ -860,5 +1131,67 @@ public class RingtoneManager { return null; } } - + + /** + * Creates a {@link android.media.MediaScannerConnection} to scan a ringtone file and add its + * information to the internal database. + * + * It uses a {@link java.util.concurrent.LinkedBlockingQueue} so that the caller can block until + * the scan is completed. + */ + private class NewRingtoneScanner implements Closeable, MediaScannerConnectionClient { + private MediaScannerConnection mMediaScannerConnection; + private File mFile; + private LinkedBlockingQueue mQueue = new LinkedBlockingQueue<>(1); + + public NewRingtoneScanner(File file) { + mFile = file; + mMediaScannerConnection = new MediaScannerConnection(mContext, this); + mMediaScannerConnection.connect(); + } + + @Override + public void close() { + mMediaScannerConnection.disconnect(); + } + + @Override + public void onMediaScannerConnected() { + mMediaScannerConnection.scanFile(mFile.getAbsolutePath(), null); + } + + @Override + public void onScanCompleted(String path, Uri uri) { + if (uri == null) { + // There was some issue with scanning. Delete the copied file so it is not oprhaned. + mFile.delete(); + return; + } + try { + mQueue.put(uri); + } catch (InterruptedException e) { + Log.e(TAG, "Unable to put new ringtone Uri in queue", e); + } + } + + public Uri take() throws InterruptedException { + return mQueue.take(); + } + } + + /** + * Attempts to create a context for the given user. + * + * @return created context, or null if package does not exist + * @hide + */ + private static Context createPackageContextAsUser(Context context, int userId) { + try { + return context.createPackageContextAsUser(context.getPackageName(), 0 /* flags */, + UserHandle.of(userId)); + } catch (NameNotFoundException e) { + Log.e(TAG, "Unable to create package context", e); + return null; + } + } } diff --git a/media/java/android/media/Utils.java b/media/java/android/media/Utils.java index 35b083e66e699..5b62f16ad3151 100644 --- a/media/java/android/media/Utils.java +++ b/media/java/android/media/Utils.java @@ -16,12 +16,21 @@ package android.media; +import android.content.Context; +import android.content.ContentResolver; +import android.database.Cursor; +import android.net.Uri; +import android.os.Environment; +import android.os.FileUtils; +import android.provider.OpenableColumns; import android.util.Log; import android.util.Pair; import android.util.Range; import android.util.Rational; import android.util.Size; +import java.io.File; +import java.io.FileNotFoundException; import java.util.Arrays; import java.util.Comparator; import java.util.Vector; @@ -307,4 +316,66 @@ class Utils { Log.w(TAG, "could not parse size range '" + o + "'"); return null; } + + /** + * Creates a unique file in the specified external storage with the desired name. If the name is + * taken, the new file's name will have '(%d)' to avoid overwriting files. + * + * @param context {@link Context} to query the file name from. + * @param subdirectory One of the directories specified in {@link android.os.Environment} + * @param fileName desired name for the file. + * @param mimeType MIME type of the file to create. + * @return the File object in the storage, or null if an error occurs. + */ + public static File getUniqueExternalFile(Context context, String subdirectory, String fileName, + String mimeType) { + File externalStorage = Environment.getExternalStoragePublicDirectory(subdirectory); + // Make sure the storage subdirectory exists + externalStorage.mkdirs(); + + File outFile = null; + try { + // Ensure the file has a unique name, as to not override any existing file + outFile = FileUtils.buildUniqueFile(externalStorage, mimeType, fileName); + } catch (FileNotFoundException e) { + // This might also be reached if the number of repeated files gets too high + Log.e(TAG, "Unable to get a unique file name: " + e); + return null; + } + return outFile; + } + + /** + * Returns a file's display name from its {@link android.content.ContentResolver.SCHEME_FILE} + * or {@link android.content.ContentResolver.SCHEME_CONTENT} Uri. The display name of a file + * includes its extension. + * + * @param context Context trying to resolve the file's display name. + * @param uri Uri of the file. + * @return the file's display name, or the uri's string if something fails or the uri isn't in + * the schemes specified above. + */ + static String getFileDisplayNameFromUri(Context context, Uri uri) { + String scheme = uri.getScheme(); + + if (ContentResolver.SCHEME_FILE.equals(scheme)) { + return uri.getLastPathSegment(); + } else if (ContentResolver.SCHEME_CONTENT.equals(scheme)) { + // We need to query the ContentResolver to get the actual file name as the Uri masks it. + // This means we want the name used for display purposes only. + String[] proj = { + OpenableColumns.DISPLAY_NAME + }; + try (Cursor cursor = context.getContentResolver().query(uri, proj, null, null, null)) { + if (cursor != null && cursor.getCount() != 0) { + cursor.moveToFirst(); + return cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)); + } + } + } + + // This will only happen if the Uri isn't either SCHEME_CONTENT or SCHEME_FILE, so we assume + // it already represents the file's name. + return uri.toString(); + } }