Merge changes from topics 'List parent profile custom ringtones from managed profile', 'Add/delete custom ringtones from storage'

* changes:
  [RingtoneManager] Option for cursor to list parent sounds
  [RingtoneManager] API to delete custom ringtones
  [RingtoneManager] API to add custom ringtones
This commit is contained in:
TreeHugger Robot
2017-01-06 19:35:54 +00:00
committed by Android (Google) Code Review
3 changed files with 469 additions and 19 deletions

View File

@@ -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;
}
}

View File

@@ -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<Cursor> ringtoneCursors = new ArrayList<Cursor>();
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<Uri> 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;
}
}
}

View File

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