The video demonstration above showcases the carousel-style notification that I created. Its essence lies in being a custom notification, but to achieve this effect, a slight modification to the FileProvider
is also necessary. Next, I will explain how to implement this functionality.
How to Download Images
I recommend using coroutines for concurrent image downloading, where individual image downloads can be handled using Picasso
.
val bitmap = Picasso.get().load(url).get()
Custom Notification
Since Android does not provide this style of notification natively, we need to use custom notification to achieve it. Therefore, our code for sending the notification is as follows:
// Later, I will explain how remoteViews are created.
val notification = NotificationCompat.Builder(requireContext(), CHANNEL_ID)
.setSmallIcon(R.drawable.baseline_photo_library_24)
.setContentTitle("ContentTitle")
.setContentText("Expand to view carousel notification")
.setCustomBigContentView(remoteViews)
.build()
NotificationManagerCompat.from(requireContext()).apply {
notify(generateId(), notification)
}
RemoteViews XML Layout
Custom notification requires the use of RemoteViews
. Although RemoteViews is also declared using XML, the layout is ultimately loaded by the system. As a result, the range of widgets that can be used within it is limited. For specific supported widgets, you can refer to the documentation below:
The ViewFlipper provided by Android allows us to achieve the carousel effect.
Next, let’s implement our RemoteViews XML layout, layout_remote_custom_carousel.xml
:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:id="@+id/title"
style="@style/TextAppearance.Compat.Notification.Title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:text="Custom Title" />
<TextView
android:id="@+id/desc"
style="@style/TextAppearance.Compat.Notification.Line2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:text="Custom Desc Custom Desc Custom Desc Custom Desc" />
<ViewFlipper
android:id="@+id/view_flipper"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:autoStart="true"
android:flipInterval="3000"
android:inAnimation="@anim/slide_in_right"
android:outAnimation="@anim/slide_out_left" />
</LinearLayout>
slide_in_right
and slide_out_left
are the animations used when switching views in the ViewFlipper. Here, we will implement a simple translate animation:
slide_in_right.xml
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate android:fromXDelta="100%p" android:toXDelta="0"
android:duration="@android:integer/config_mediumAnimTime"/>
</set>
slide_out_left.xml
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate android:fromXDelta="0" android:toXDelta="-100%p"
android:duration="@android:integer/config_mediumAnimTime"/>
</set>
Creating RemoteViews
In the code provided earlier, there was no declaration of remoteViews
. Here, we will create the remoteViews
:
val remoteViews = RemoteViews(
requireContext().packageName,
R.layout.layout_remote_custom_carousel
)
remoteViews.setTextViewText(R.id.title, "Travel to Republic of Singapore")
remoteViews.setTextViewText(
R.id.desc,
"Singapore is open to all travellers without quarantine or testing requirements"
)
Currently, we have only inflated the layout_remote_custom_carousel.xml
and set text to the TextView within it. However, the ViewFlipper inside it doesn't have any content yet. Next, we will populate the ViewFlipper with content.
Layout for Individual Element in ViewFlipper
To add elements to the ViewFlipper, you can use the remoteViews.addView(R.id.view_flipper, childView)
interface, where childView
is also a RemoteViews.
Next, define the layout for an individual element, layout_remote_custom_carousel_item.xml
:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/title"
style="@style/TextAppearance.Compat.Notification.Info"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/image_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:adjustViewBounds="true"
android:scaleType="centerCrop" />
<TextView
android:id="@+id/desc"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:background="@drawable/shadow_bottom_up"
android:padding="6dp"
android:paddingTop="10dp"
android:textColor="#ffffff"
android:textSize="12dp" />
</FrameLayout>
</LinearLayout>
Adding Carousel Elements to ViewFlipper
Assuming I have a val bitmaps: List<Bitmap>?
where the stored bitmaps are images for the carousel, and dataTriples
is an array of Triple
s where each Triple.second
stores the title of an individual carousel element, and Triple.third
stores the description on the image of an individual carousel element. You can iterate through the bitmaps
, construct RemoteViews
, and then use remoteViews.addView
to add them.
val childViews = Array(bitmaps.size) {
val remoteView = RemoteViews(
requireContext().packageName,
R.layout.layout_remote_custom_carousel_item
)
remoteView.setImageViewBitmap(R.id.image_view, bitmapUris[it])
remoteView.setTextViewText(R.id.title, dataTriples[it].second)
remoteView.setTextViewText(R.id.desc, dataTriples[it].third)
val pendingIntent = ...
remoteView.setOnClickPendingIntent(R.id.root, pendingIntent)
remoteView
}
for (each in childViews) {
remoteViews.addView(R.id.view_flipper, each)
}
Attempting to Run: Not Working
If you followed the approach I mentioned above, depending on your system version, you might notice that the ViewFlipper in the notification doesn’t show any content. This is because, in order to display the carousel images, we passed multiple bitmaps to the system. Since a single bitmap consumes a large amount of memory, when passing multiple bitmaps, the system may easily perceive it as excessive data being passed. By observing the logcat, you may notice log messages similar to the following:
NotificationService system_server W Removed too large RemoteViews (10093824 bytes) on pkg: com.example.shawtest tag: null id: 2
Therefore, we cannot use RemoteViews.setImageViewBitmap
, and instead, we should use RemoteViews.setImageViewUri
to avoid directly passing bitmaps to the system.
Save File and Pass Uri
Because passing a file path directly can result in a FileUriExposedException from the system, after saving the bitmap to a file, we also need to convert the file into a Uri.
The standard FileProvider might not work
For example, after saving the bitmap to a file using the code below, attempting to retrieve the Uri using the standard FileProvider might not work:
val bitmap = Picasso.get().load(url).get()
val file = File(dir, UUID.randomUUID().toString())
file.outputStream().use {
bitmap.compress(Bitmap.CompressFormat.WEBP, 90, it)
}
bitmap.recycle()
FileProvider.getUriForFile(requireContext(), authority, file)
Then set the path using the following code:
remoteView.setImageViewUri(R.id.image_view, bitmapUris[it])
You might encounter situations where, prior to Android 12 and on some Android 13 systems, the system lacks permission to access the Uri we’ve set:
android.widget.RemoteViews$ActionException: java.lang.SecurityException: Permission Denial: reading androidx.core.content.FileProvider uri content://com.example.shawtest.fileprovider/internal_cache/d2c5d24e-f9c1–48c4–9d5d-56f55f3efe42 from pid=2558, uid=1000 requires the provider be exported, or grantUriPermission()
If you attempt to set the ‘exported’ attribute of FileProvider to true, the app will crash as soon as it starts because FileProvider will check at runtime whether ‘exported’ is set to false. If it’s not set to false, an exception will be thrown:
@Override
public void attachInfo(@NonNull Context context, @NonNull ProviderInfo info) {
super.attachInfo(context, info);
// Check our security attributes
if (info.exported) {
throw new SecurityException("Provider must not be exported");
}
...
}
Modifying FileProvider to Allow exported
to be true
In this case, we can create a copy of FileProvider, remove the relevant validation code, and allow it to set exported
to true. Let’s name this customized version ExportedFileProvider
:
@Override
public void attachInfo(@NonNull Context context, @NonNull ProviderInfo info) {
super.attachInfo(context, info);
// Check our security attributes
// if (info.exported) {
// throw new SecurityException("Provider must not be exported");
// }
...
}
Declare in Manifest, and note that I’m using a specific authorities
:
<provider
android:name=".fileprovider.ExportedFileProvider"
android:authorities="${applicationId}.fileprovider.exported"
android:exported="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/exported_filepaths" />
</provider>
</application>
</manifest>
Here, I’m choosing to store the files in the internal cache directory.
Due to the FileProvider with exported
set to true, allowing third-party apps to access any exposed path, we can add a subdirectory named exported/
to avoid exposing the entire cache directory to other apps.
exported_filepaths.xml:
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="internal_cache" path="exported/" />
</paths>
Modify the code for saving files, keeping in mind that we want to save them under a path that can be exposed by the ExportedFileProvider:
val dir = File(requireContext().cacheDir, "exported")
// Please ensure that the directory path 'dir' exists.
val bitmap = Picasso.get().load(url).get()
val file = File(dir, UUID.randomUUID().toString())
file.outputStream().use {
bitmap.compress(Bitmap.CompressFormat.WEBP, 90, it)
}
bitmap.recycle()
ExportedFileProvider.getUriForFile(requireContext(), authority, file)
This way, any process, including system processes, can directly access the Uri we’ve passed.
Please note that no other files should be saved to this directory, as it can be accessed by any third-party app.
Further Security Modifications for ExportedFileProvider
FileProvider is designed with exported
set to false, so much of its code assumes that third-party apps won’t directly access it. Since ExportedFileProvider has exported
set to true, any app can access it, which means we need to make additional modifications in ExportedFileProvider to prevent malicious app exploitation.
Prohibit Deletion. Prevent other apps from attempting to delete files in the exposed directory:
@Override
public int delete(@NonNull Uri uri, @Nullable String selection,
@Nullable String[] selectionArgs) {
// ContentProvider has already checked granted permissions
// final File file = mStrategy.getFileForUri(uri);
// return file.delete() ? 1 : 0;
throw new UnsupportedOperationException("No external delete");
}
Prohibit opening files with modes other than read-only. Preventing other apps from attempting to modify or write files to the exposed directory:
/**
* Copied from ContentResolver.java
*/
private static int modeToMode(String mode) {
int modeBits;
if ("r".equals(mode)) {
modeBits = ParcelFileDescriptor.MODE_READ_ONLY;
// } else if ("w".equals(mode) || "wt".equals(mode)) {
// modeBits = ParcelFileDescriptor.MODE_WRITE_ONLY
// | ParcelFileDescriptor.MODE_CREATE
// | ParcelFileDescriptor.MODE_TRUNCATE;
// } else if ("wa".equals(mode)) {
// modeBits = ParcelFileDescriptor.MODE_WRITE_ONLY
// | ParcelFileDescriptor.MODE_CREATE
// | ParcelFileDescriptor.MODE_APPEND;
// } else if ("rw".equals(mode)) {
// modeBits = ParcelFileDescriptor.MODE_READ_WRITE
// | ParcelFileDescriptor.MODE_CREATE;
// } else if ("rwt".equals(mode)) {
// modeBits = ParcelFileDescriptor.MODE_READ_WRITE
// | ParcelFileDescriptor.MODE_CREATE
// | ParcelFileDescriptor.MODE_TRUNCATE;
} else {
throw new IllegalArgumentException("Invalid mode: " + mode);
}
return modeBits;
}
Is ExportedFileProvider Vulnerable to Arbitrary Path Traversal?
Because FileProvider itself has path validation in its code, specifically in SimplePathStrategy#getFileForUri, and we restrict the ExportedFileProvider to only expose files within the ‘exported’ directory under the internal cache directory through exported_filepaths.xml, ExportedFileProvider should not have arbitrary path traversal vulnerabilities. However, please note that the ‘exported’ directory under the internal cache directory is completely exposed to third-party apps and should only be used for this specific type of file storage.
Appendix: Complete ExportedFileProvider Code
public class ExportedFileProvider extends ContentProvider {
private static final String[] COLUMNS = {
OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE };
private static final String
META_DATA_FILE_PROVIDER_PATHS = "android.support.FILE_PROVIDER_PATHS";
private static final String TAG_ROOT_PATH = "root-path";
private static final String TAG_FILES_PATH = "files-path";
private static final String TAG_CACHE_PATH = "cache-path";
private static final String TAG_EXTERNAL = "external-path";
private static final String TAG_EXTERNAL_FILES = "external-files-path";
private static final String TAG_EXTERNAL_CACHE = "external-cache-path";
private static final String TAG_EXTERNAL_MEDIA = "external-media-path";
private static final String ATTR_NAME = "name";
private static final String ATTR_PATH = "path";
private static final String DISPLAYNAME_FIELD = "displayName";
private static final File DEVICE_ROOT = new File("/");
@GuardedBy("sCache")
private static final HashMap<String, PathStrategy> sCache = new HashMap<>();
private PathStrategy mStrategy;
private int mResourceId;
public ExportedFileProvider() {
mResourceId = ResourcesCompat.ID_NULL;
}
protected ExportedFileProvider(@XmlRes int resourceId) {
mResourceId = resourceId;
}
/**
* The default FileProvider implementation does not need to be initialized. If you want to
* override this method, you must provide your own subclass of FileProvider.
*/
@Override
public boolean onCreate() {
return true;
}
/**
* After the FileProvider is instantiated, this method is called to provide the system with
* information about the provider.
*
* @param context A {@link Context} for the current component.
* @param info A {@link ProviderInfo} for the new provider.
*/
@SuppressWarnings("StringSplitter")
@Override
public void attachInfo(@NonNull Context context, @NonNull ProviderInfo info) {
super.attachInfo(context, info);
// Check our security attributes
// if (info.exported) {
// throw new SecurityException("Provider must not be exported");
// }
if (!info.grantUriPermissions) {
throw new SecurityException("Provider must grant uri permissions");
}
String authority = info.authority.split(";")[0];
synchronized (sCache) {
sCache.remove(authority);
}
mStrategy = getPathStrategy(context, authority, mResourceId);
}
/**
* Return a content URI for a given {@link File}. Specific temporary
* permissions for the content URI can be set with
* {@link Context#grantUriPermission(String, Uri, int)}, or added
* to an {@link Intent} by calling {@link Intent#setData(Uri) setData()} and then
* {@link Intent#setFlags(int) setFlags()}; in both cases, the applicable flags are
* {@link Intent#FLAG_GRANT_READ_URI_PERMISSION} and
* {@link Intent#FLAG_GRANT_WRITE_URI_PERMISSION}. A FileProvider can only return a
* <code>content</code> {@link Uri} for file paths defined in their <code><paths></code>
* meta-data element. See the Class Overview for more information.
*
* @param context A {@link Context} for the current component.
* @param authority The authority of a {@link FileProvider} defined in a
* {@code <provider>} element in your app's manifest.
* @param file A {@link File} pointing to the filename for which you want a
* <code>content</code> {@link Uri}.
* @return A content URI for the file.
* @throws IllegalArgumentException When the given {@link File} is outside
* the paths supported by the provider.
*/
public static Uri getUriForFile(@NonNull Context context, @NonNull String authority,
@NonNull File file) {
final PathStrategy strategy = getPathStrategy(context, authority, ResourcesCompat.ID_NULL);
return strategy.getUriForFile(file);
}
/**
* Return a content URI for a given {@link File}. Specific temporary
* permissions for the content URI can be set with
* {@link Context#grantUriPermission(String, Uri, int)}, or added
* to an {@link Intent} by calling {@link Intent#setData(Uri) setData()} and then
* {@link Intent#setFlags(int) setFlags()}; in both cases, the applicable flags are
* {@link Intent#FLAG_GRANT_READ_URI_PERMISSION} and
* {@link Intent#FLAG_GRANT_WRITE_URI_PERMISSION}. A FileProvider can only return a
* <code>content</code> {@link Uri} for file paths defined in their <code><paths></code>
* meta-data element. See the Class Overview for more information.
*
* @param context A {@link Context} for the current component.
* @param authority The authority of a {@link FileProvider} defined in a
* {@code <provider>} element in your app's manifest.
* @param file A {@link File} pointing to the filename for which you want a
* <code>content</code> {@link Uri}.
* @param displayName The filename to be displayed. This can be used if the original filename
* is undesirable.
* @return A content URI for the file.
* @throws IllegalArgumentException When the given {@link File} is outside
* the paths supported by the provider.
*/
@SuppressLint("StreamFiles")
@NonNull
public static Uri getUriForFile(@NonNull Context context, @NonNull String authority,
@NonNull File file, @NonNull String displayName) {
Uri uri = getUriForFile(context, authority, file);
return uri.buildUpon().appendQueryParameter(DISPLAYNAME_FIELD, displayName).build();
}
/**
* Use a content URI returned by
* {@link #getUriForFile(Context, String, File) getUriForFile()} to get information about a file
* managed by the FileProvider.
* FileProvider reports the column names defined in {@link OpenableColumns}:
* <ul>
* <li>{@link OpenableColumns#DISPLAY_NAME}</li>
* <li>{@link OpenableColumns#SIZE}</li>
* </ul>
* For more information, see
* {@link ContentProvider#query(Uri, String[], String, String[], String)
* ContentProvider.query()}.
*
* @param uri A content URI returned by {@link #getUriForFile}.
* @param projection The list of columns to put into the {@link Cursor}. If null all columns are
* included.
* @param selection Selection criteria to apply. If null then all data that matches the content
* URI is returned.
* @param selectionArgs An array of {@link String}, containing arguments to bind to
* the <i>selection</i> parameter. The <i>query</i> method scans <i>selection</i> from left to
* right and iterates through <i>selectionArgs</i>, replacing the current "?" character in
* <i>selection</i> with the value at the current position in <i>selectionArgs</i>. The
* values are bound to <i>selection</i> as {@link String} values.
* @param sortOrder A {@link String} containing the column name(s) on which to sort
* the resulting {@link Cursor}.
* @return A {@link Cursor} containing the results of the query.
*
*/
@NonNull
@Override
public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection,
@Nullable String[] selectionArgs,
@Nullable String sortOrder) {
// ContentProvider has already checked granted permissions
final File file = mStrategy.getFileForUri(uri);
String displayName = uri.getQueryParameter(DISPLAYNAME_FIELD);
if (projection == null) {
projection = COLUMNS;
}
String[] cols = new String[projection.length];
Object[] values = new Object[projection.length];
int i = 0;
for (String col : projection) {
if (OpenableColumns.DISPLAY_NAME.equals(col)) {
cols[i] = OpenableColumns.DISPLAY_NAME;
values[i++] = (displayName == null) ? file.getName() : displayName;
} else if (OpenableColumns.SIZE.equals(col)) {
cols[i] = OpenableColumns.SIZE;
values[i++] = file.length();
}
}
cols = copyOf(cols, i);
values = copyOf(values, i);
final MatrixCursor cursor = new MatrixCursor(cols, 1);
cursor.addRow(values);
return cursor;
}
/**
* Returns the MIME type of a content URI returned by
* {@link #getUriForFile(Context, String, File) getUriForFile()}.
*
* @param uri A content URI returned by
* {@link #getUriForFile(Context, String, File) getUriForFile()}.
* @return If the associated file has an extension, the MIME type associated with that
* extension; otherwise <code>application/octet-stream</code>.
*/
@Nullable
@Override
public String getType(@NonNull Uri uri) {
// ContentProvider has already checked granted permissions
final File file = mStrategy.getFileForUri(uri);
final int lastDot = file.getName().lastIndexOf('.');
if (lastDot >= 0) {
final String extension = file.getName().substring(lastDot + 1);
final String mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
if (mime != null) {
return mime;
}
}
return "application/octet-stream";
}
/**
* By default, this method throws an {@link UnsupportedOperationException}. You must
* subclass FileProvider if you want to provide different functionality.
*/
@Override
public Uri insert(@NonNull Uri uri, @NonNull ContentValues values) {
throw new UnsupportedOperationException("No external inserts");
}
/**
* By default, this method throws an {@link UnsupportedOperationException}. You must
* subclass FileProvider if you want to provide different functionality.
*/
@Override
public int update(@NonNull Uri uri, @NonNull ContentValues values, @Nullable String selection,
@Nullable String[] selectionArgs) {
throw new UnsupportedOperationException("No external updates");
}
/**
* Deletes the file associated with the specified content URI, as
* returned by {@link #getUriForFile(Context, String, File) getUriForFile()}. Notice that this
* method does <b>not</b> throw an {@link IOException}; you must check its return value.
*
* @param uri A content URI for a file, as returned by
* {@link #getUriForFile(Context, String, File) getUriForFile()}.
* @param selection Ignored. Set to {@code null}.
* @param selectionArgs Ignored. Set to {@code null}.
* @return 1 if the delete succeeds; otherwise, 0.
*/
@Override
public int delete(@NonNull Uri uri, @Nullable String selection,
@Nullable String[] selectionArgs) {
// ContentProvider has already checked granted permissions
// final File file = mStrategy.getFileForUri(uri);
// return file.delete() ? 1 : 0;
throw new UnsupportedOperationException("No external delete");
}
/**
* By default, FileProvider automatically returns the
* {@link ParcelFileDescriptor} for a file associated with a <code>content://</code>
* {@link Uri}. To get the {@link ParcelFileDescriptor}, call
* {@link ContentResolver#openFileDescriptor(Uri, String)
* ContentResolver.openFileDescriptor}.
*
* To override this method, you must provide your own subclass of FileProvider.
*
* @param uri A content URI associated with a file, as returned by
* {@link #getUriForFile(Context, String, File) getUriForFile()}.
* @param mode Access mode for the file. May be "r" for read-only access, "rw" for read and
* write access, or "rwt" for read and write access that truncates any existing file.
* @return A new {@link ParcelFileDescriptor} with which you can access the file.
*/
@SuppressLint("UnknownNullness") // b/171012356
@Override
public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode)
throws FileNotFoundException {
// ContentProvider has already checked granted permissions
final File file = mStrategy.getFileForUri(uri);
final int fileMode = modeToMode(mode);
return ParcelFileDescriptor.open(file, fileMode);
}
/**
* Return {@link PathStrategy} for given authority, either by parsing or
* returning from cache.
*/
private static PathStrategy getPathStrategy(Context context, String authority, int resourceId) {
PathStrategy strat;
synchronized (sCache) {
strat = sCache.get(authority);
if (strat == null) {
try {
strat = parsePathStrategy(context, authority, resourceId);
} catch (IOException e) {
throw new IllegalArgumentException(
"Failed to parse " + META_DATA_FILE_PROVIDER_PATHS + " meta-data", e);
} catch (XmlPullParserException e) {
throw new IllegalArgumentException(
"Failed to parse " + META_DATA_FILE_PROVIDER_PATHS + " meta-data", e);
}
sCache.put(authority, strat);
}
}
return strat;
}
@VisibleForTesting
static XmlResourceParser getFileProviderPathsMetaData(Context context, String authority,
@Nullable ProviderInfo info,
int resourceId) {
if (info == null) {
throw new IllegalArgumentException(
"Couldn't find meta-data for provider with authority " + authority);
}
if (info.metaData == null && resourceId != ResourcesCompat.ID_NULL) {
info.metaData = new Bundle(1);
info.metaData.putInt(META_DATA_FILE_PROVIDER_PATHS, resourceId);
}
final XmlResourceParser in = info.loadXmlMetaData(
context.getPackageManager(), META_DATA_FILE_PROVIDER_PATHS);
if (in == null) {
throw new IllegalArgumentException(
"Missing " + META_DATA_FILE_PROVIDER_PATHS + " meta-data");
}
return in;
}
/**
* Parse and return {@link PathStrategy} for given authority as defined in
* {@link #META_DATA_FILE_PROVIDER_PATHS} {@code <meta-data>}.
*
* @see #getPathStrategy(Context, String, int)
*/
private static PathStrategy parsePathStrategy(Context context, String authority, int resourceId)
throws IOException, XmlPullParserException {
final SimplePathStrategy strat = new SimplePathStrategy(authority);
final ProviderInfo info = context.getPackageManager()
.resolveContentProvider(authority, PackageManager.GET_META_DATA);
final XmlResourceParser in = getFileProviderPathsMetaData(context, authority, info,
resourceId);
int type;
while ((type = in.next()) != END_DOCUMENT) {
if (type == START_TAG) {
final String tag = in.getName();
final String name = in.getAttributeValue(null, ATTR_NAME);
String path = in.getAttributeValue(null, ATTR_PATH);
File target = null;
if (TAG_ROOT_PATH.equals(tag)) {
target = DEVICE_ROOT;
} else if (TAG_FILES_PATH.equals(tag)) {
target = context.getFilesDir();
} else if (TAG_CACHE_PATH.equals(tag)) {
target = context.getCacheDir();
} else if (TAG_EXTERNAL.equals(tag)) {
target = Environment.getExternalStorageDirectory();
} else if (TAG_EXTERNAL_FILES.equals(tag)) {
File[] externalFilesDirs = ContextCompat.getExternalFilesDirs(context, null);
if (externalFilesDirs.length > 0) {
target = externalFilesDirs[0];
}
} else if (TAG_EXTERNAL_CACHE.equals(tag)) {
File[] externalCacheDirs = ContextCompat.getExternalCacheDirs(context);
if (externalCacheDirs.length > 0) {
target = externalCacheDirs[0];
}
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
&& TAG_EXTERNAL_MEDIA.equals(tag)) {
File[] externalMediaDirs = Api21Impl.getExternalMediaDirs(context);
if (externalMediaDirs.length > 0) {
target = externalMediaDirs[0];
}
}
if (target != null) {
strat.addRoot(name, buildPath(target, path));
}
}
}
return strat;
}
/**
* Strategy for mapping between {@link File} and {@link Uri}.
* <p>
* Strategies must be symmetric so that mapping a {@link File} to a
* {@link Uri} and then back to a {@link File} points at the original
* target.
* <p>
* Strategies must remain consistent across app launches, and not rely on
* dynamic state. This ensures that any generated {@link Uri} can still be
* resolved if your process is killed and later restarted.
*
* @see SimplePathStrategy
*/
interface PathStrategy {
/**
* Return a {@link Uri} that represents the given {@link File}.
*/
Uri getUriForFile(File file);
/**
* Return a {@link File} that represents the given {@link Uri}.
*/
File getFileForUri(Uri uri);
}
/**
* Strategy that provides access to files living under a narrow allowed list
* of filesystem roots. It will throw {@link SecurityException} if callers try
* accessing files outside the configured roots.
* <p>
* For example, if configured with
* {@code addRoot("myfiles", context.getFilesDir())}, then
* {@code context.getFileStreamPath("foo.txt")} would map to
* {@code content://myauthority/myfiles/foo.txt}.
*/
static class SimplePathStrategy implements PathStrategy {
private final String mAuthority;
private final HashMap<String, File> mRoots = new HashMap<>();
SimplePathStrategy(String authority) {
mAuthority = authority;
}
/**
* Add a mapping from a name to a filesystem root. The provider only offers
* access to files that live under configured roots.
*/
void addRoot(String name, File root) {
if (TextUtils.isEmpty(name)) {
throw new IllegalArgumentException("Name must not be empty");
}
try {
// Resolve to canonical path to keep path checking fast
root = root.getCanonicalFile();
} catch (IOException e) {
throw new IllegalArgumentException(
"Failed to resolve canonical path for " + root, e);
}
mRoots.put(name, root);
}
@Override
public Uri getUriForFile(File file) {
String path;
try {
path = file.getCanonicalPath();
} catch (IOException e) {
throw new IllegalArgumentException("Failed to resolve canonical path for " + file);
}
// Find the most-specific root path
Map.Entry<String, File> mostSpecific = null;
for (Map.Entry<String, File> root : mRoots.entrySet()) {
final String rootPath = root.getValue().getPath();
if (path.startsWith(rootPath) && (mostSpecific == null
|| rootPath.length() > mostSpecific.getValue().getPath().length())) {
mostSpecific = root;
}
}
if (mostSpecific == null) {
throw new IllegalArgumentException(
"Failed to find configured root that contains " + path);
}
// Start at first char of path under root
final String rootPath = mostSpecific.getValue().getPath();
if (rootPath.endsWith("/")) {
path = path.substring(rootPath.length());
} else {
path = path.substring(rootPath.length() + 1);
}
// Encode the tag and path separately
path = Uri.encode(mostSpecific.getKey()) + '/' + Uri.encode(path, "/");
return new Uri.Builder().scheme("content")
.authority(mAuthority).encodedPath(path).build();
}
@Override
public File getFileForUri(Uri uri) {
String path = uri.getEncodedPath();
final int splitIndex = path.indexOf('/', 1);
final String tag = Uri.decode(path.substring(1, splitIndex));
path = Uri.decode(path.substring(splitIndex + 1));
final File root = mRoots.get(tag);
if (root == null) {
throw new IllegalArgumentException("Unable to find configured root for " + uri);
}
File file = new File(root, path);
try {
file = file.getCanonicalFile();
} catch (IOException e) {
throw new IllegalArgumentException("Failed to resolve canonical path for " + file);
}
if (!file.getPath().startsWith(root.getPath())) {
throw new SecurityException("Resolved path jumped beyond configured root");
}
return file;
}
}
/**
* Copied from ContentResolver.java
*/
private static int modeToMode(String mode) {
int modeBits;
if ("r".equals(mode)) {
modeBits = ParcelFileDescriptor.MODE_READ_ONLY;
// } else if ("w".equals(mode) || "wt".equals(mode)) {
// modeBits = ParcelFileDescriptor.MODE_WRITE_ONLY
// | ParcelFileDescriptor.MODE_CREATE
// | ParcelFileDescriptor.MODE_TRUNCATE;
// } else if ("wa".equals(mode)) {
// modeBits = ParcelFileDescriptor.MODE_WRITE_ONLY
// | ParcelFileDescriptor.MODE_CREATE
// | ParcelFileDescriptor.MODE_APPEND;
// } else if ("rw".equals(mode)) {
// modeBits = ParcelFileDescriptor.MODE_READ_WRITE
// | ParcelFileDescriptor.MODE_CREATE;
// } else if ("rwt".equals(mode)) {
// modeBits = ParcelFileDescriptor.MODE_READ_WRITE
// | ParcelFileDescriptor.MODE_CREATE
// | ParcelFileDescriptor.MODE_TRUNCATE;
} else {
throw new IllegalArgumentException("Invalid mode: " + mode);
}
return modeBits;
}
private static File buildPath(File base, String... segments) {
File cur = base;
for (String segment : segments) {
if (segment != null) {
cur = new File(cur, segment);
}
}
return cur;
}
private static String[] copyOf(String[] original, int newLength) {
final String[] result = new String[newLength];
System.arraycopy(original, 0, result, 0, newLength);
return result;
}
private static Object[] copyOf(Object[] original, int newLength) {
final Object[] result = new Object[newLength];
System.arraycopy(original, 0, result, 0, newLength);
return result;
}
@RequiresApi(21)
static class Api21Impl {
private Api21Impl() {
// This class is not instantiable.
}
static File[] getExternalMediaDirs(Context context) {
// Deprecated, otherwise this would belong on ContextCompat as a public method.
return context.getExternalMediaDirs();
}
}
}