The Death of External Storage: How Can I Stay Away From Files?
Under the fairly innocuous title of “scoped storage”, Google has announced that external storage, as Android developers have used it, effectively is dead, for Android Q and onwards.
I am going to spend some time on this over the course of the week, as this change needs some attention:
- Monday, I described what has changed and what you are supposed to use.
- Yesterday, I covered the limited options for actually getting a file on external or removable storage.
- Today, I will review how you might still wind up with references to files… that you cannot access
One of the salvos in the War on Files came in Android 7.0, with the
introduction of FileUriExposedException
. This would be raised if you put
a file://
Uri
into an Intent
and tried using that Intent
to start an
activity, etc. It would crash the app and steer you towards using FileProvider
to make that content available to other apps.
The hope was that this would limit the number of file://
Uri
values flitting
through the OS, and I am sure that it helped. However:
-
It does not come into play for apps with a
targetSdkVersion
below 24, so legacy apps that are not getting updates will still emitfile://
Uri
values -
I am sure that there are creative ways of passing a
Uri
around that bypass those checks -
FileUriExposedException
is thrown byStrictMode
, and so lots of people suggest that you simply disable that check to get past the “problem”
Most likely, in a world with READ_EXTERNAL_STORAGE
and WRITE_EXTERNAL_STORAGE
,
those file://
Uri
values would work, assuming that your app held the appropriate
permissions. On Android Q, you cannot hold those permissions. It is very unlikely
that a file://
Uri
that another app could use (to create it) will be one that
your app can use (to consume it). In other words, if you get a file://
Uri
, you will
probably crash when you go to open that file for reading or writing.
Sometimes, an <intent-filter>
controls what Uri
values we receive. For example,
if you have an activity that supports ACTION_VIEW
, you will have an <intent-filter>
in your manifest that, among other things, declares what sorts of content you can
accept, through <data>
elements.
For example, you might have an <activity>
like this in your manifest:
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="file" />
<data android:scheme="https" />
<data android:scheme="content" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
Here we have a <data android:scheme="file" />
element advertising support for the file
scheme.
You do not want those <data android:scheme="file" />
elements on Android Q,
as most likely you will be unable to use the Uri
that you receive.
What you can do is set up an <activity-alias>
for those:
-
Create an
<activity-alias>
pointing to the affected activity -
Clone the relevant
<intent-filter>
elements from the<activity>
onto the<activity-alias>
-
Remove the
<data android:scheme="file" />
elements from the<activity>
-
Remove the other schemes from the
<activity-alias>
, leaving only the<data android:scheme="file" />
for declaring schemes -
Define a
boolean
resource, one that istrue
by default butfalse
in ares/values-v29/
edition -
Use that
boolean
resource inandroid:enabled
on the<activity-alias>
You will wind up with a paired <activity>
and <activity-alias>
like this:
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:scheme="content" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
<activity-alias
android:name=".FileAlias"
android:enabled="@bool/supportFileScheme"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="file" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity-alias>
The net result is that the <activity-alias>
will be used on older devices,
where you can still consume the file://
Uri
values. But that <activity-alias>
will be disabled on Android Q and above. This helps reduce the number of unusable
Uri
values that your app will get.
You might wonder if Android Q would do this sort of thing automatically, basically
ignoring <data android:scheme="file" />
elements in manifests. At least as of
Q Beta 1, it does not, though it is possible it might get added in later releases.
Unfortunately, there are other ways that you can wind up with a Uri
from another
app that are not controllable via <intent-filter>
by scheme.
The big one is ACTION_SEND
. For reasons that were never clear to me, ACTION_SEND
does not work like ACTION_VIEW
and other Uri
-centric Intent
actions.
While you can use <data>
to filter by MIME type, AFAIK scheme and related restrictions
are ignored. As a result, you can wind up with EXTRA_STREAM
values that contain
file://
Uri
values.
There other similar sources of Uri
, like ACTION_SEND_MULTIPLE
and the clipboard,
that suffer from a similar lack of scheme-based filtering.
IMHO, the best solution is, if the scheme is file
and you are on Android Q or
higher, to detect that combination of conditions and display a dedicated error
UI. You can explain that the other app is out of date or has a bug and is incompatible
with your app. Try to give the user an understanding of what is causing the problem
(sending you stuff from this older app) and how to get past it (switch to a newer
app).
At worst, make sure that you are cleanly handling any IOException
that might come
from trying to use that Uri
(e.g., from ContentResolver
and openInputStream()
).
Do not just say “oh, we’ll let ACRA/Crashlytics/Firebase Crash Reporting/whatever
handle it”, as inevitably you wind up showing some generic “oops!” error message.
Leave that for the “unknown unknowns”, where you cannot predict the problem in
advance. For something like this, try to offer an error message that is more specific
and more actionable.
Tomorrow, I’ll review some reasons why all of this may be happening.