Lecture 10 Files and Permissions
This lecture discusses how to working with files in Android. Using the file system allows us to have persistant data storage in a more expansive and flexible manner than using the SharedPreferences
discussed in the previous lecture (and as a supplement to ContentProvider
databases).
This lecture references code found at https://github.com/info448-s17/lecture10-files-permissions.
In order to demonstrate all of the features discussed in this lecture, your device or emulator will need to be running API 23 (6.0 Marshmallow) or later.
10.1 File Storage Locations
Android devices split file storage into two types: Internal storage and External storage. These names come from when devices had built-in memory as well as external SD cards, each of which may have had different interactions. However, with modern systems the “external storage” can refer to a section of a phone’s built-in memory as well; the distinctions are instead used for specifying access rather than physical data location.
Internal storage is always accessible, and by default files saved internally are only accessible to your app. Similarly, when the user uninstalls your app, the internal files are deleted. This is usually the best place for “private” file data, or files that will only be used by your application.
External storage is not always accessible (e.g., if the physical storage is removed), and is usually (but not always) world-readable. Normally files stored in External storage persist even if an app is uninstalled, unless certain options are used. This is usually used for “public” files that may be shared between applications.
When do we use each? Basically, you shuold use Internal storage for “private” files that you don’t want to be available outside of the app, and use External storage otherwise.
- Note however that there are publicly-hidden External files—the big distinction between the storage locations is less visibility and more about access.
In addition, both of these storage systems also have a “cache” location (i.e., an Internal Cache and an External Cache). A cache is “(secret) storage for the future”, but in computing tends to refer to “temporary storage”. The Caches are different from other file storage, in that Android has the ability to automatically delete cached files if storage space is getting low… However, you can’t rely on the operating system to do that on its own in an efficient way, so you should still delete your own Cache files when you’re done with them! In short, use the Caches for temporary files, and try to keep them small (less than 1MB recommended).
- The user can easily clear an application’s cache as well.
In code, using all of these storage locations involve working with the File
class. This class represents a “file” (or a “directory”) object, and is the same class you may be familiar with from Java SE.
- We can instantiate a
File
by passing it a directory (which is anotherFile
) and a filename (aString
). Instantiating the file will create the file on disk (but empty, size 0) if it doesn’t already exist. - We can test if a
File
is a folder with the.isDirectory()
method, and create new directories by taking aFile
and calling.mkdir()
on it. We can get a list ofFiles
inside the directory with thelistFiles()
method. See more API documentation for more details and options.
The difference between saving files to Internal and External storage, in practice, simply involves which directory you put the file in! This lecture will focus on working with External storage, since that code ends up being a kind of “super-set” of implementation details needed for the file system in general. We will indicate what changes need to be made for interacting with Internal storage.
- This lecture will walk through implementing an application that will save whatever the user types into an text field to a file.
Because a device’s External storage may be on removable media, in order to interact with it in any way we first need to check whether it is available (e.g., that the SD card is mounted). This can be done with the following check (written as a helper method so it can be reused):
public static boolean isExternalStorageWritable() {
String state = Environment.getExternalStorageState();
if (Environment.MEDIA_MOUNTED.equals(state)) {
return true;
}
return false;
}
10.2 Permissions
Directly accessing the file system of any computer can be a significant security risk, so there are substantial protections in place to make sure that a malicious app doesn’t run roughshod over a user’s data. So in order to work with the file system, we first need to discuss how Android handles permissions in more detail.
One of the most import aspect of the Android operating system’s design is the idea of sandboxing: each application gets its own “sandbox” to play in (where all its toys are kept), but isn’t able to go outside the box and play with someone else’s toys. The “toys” (components) parts that are outside of the sandbox are things that would be impactful to the user, such as network or file access. Apps are not 100% locked into their sandbox, but we need to do extra work to step outside.
Sandboxing also occurs at a package level, where packages (applications) are isolated from packages from other developers; you can use certificate signing (which occurs as part of our build process automatically) to mark two packages as from the same developer if we want them to interact.
Additionally, Android’s underlying OS is Linux-based, so it actually uses Linux’s permission system under the hood (with user and group ids that grant access to particular files or processes).
In order for an app to go outside of its sandbox (and use different components), it needs to request permission to leave. We ask for this permission (“Mother may I?”) by declaring out-of-sandbox usages explicitly in the Manifest
, as we’ve done before with getting permission to access the Internet or send SMS messages.
Android permissions we can ask for are divided into two categories: normal and dangerous:
Normal permissions are those that may impact the user (so require permission), but don’t pose any serious risk. They are granted by the user at install time; if the user chooses to install the app, permission is granted to that app. See this list for examples of normal permissions.
INTERNET
is a normal permission.Dangerous permissions, on the other hand, have the risk of violating a user’s privacy, or otherwise messing with the user’s device or other apps. These permissions also need to be granted at install time. But IN ADDITION, starting from Android 6.0 Marshmallow (API 23), users additionally need to grant dangerous permission access at runtime, when the app tries to actually invoke the “permitted” dangerous action.
The user grants permission via a system-generated pop-up dialog. Note that permissions are granted in “groups”, so if the user agrees to give you
RECEIVE_SMS
permission, you getSEND_SMS
permission as well. See the list of permission groups.When the user grant permission at runtime, that permission stays granted as long as the app is installed. But the big caveat is that the user can choose to revoke or deny privileges at any time (they do this though System settings)! Thus you have to check each time you want to access the feature if the user has granted the privileges or not—you don’t know if the user has currently given you permission, even if they had i
Writing to external storage is a dangerous permission, and thus we will need to do extra work to support the Marshmallow runtime permission system.
- In order to support runtime permissions, we need to specify our app’s target SDK to be
23
or higher AND execute the app on a device running Android 6.0 (Marshmallow) or higher. Runtime permissions are only considered if the OS supports and the app is targeted that high. For lower-API devices or apps, permission is only granted at install time.
First we still need to request permission in the Manifest
; if we haven’t announced that we might ask for permission, we won’t be allowed to ask in the future. In particular, saving files to External storage requires android.permission.WRITE_EXTERNAL_STORAGE
permission (which will also grant us READ_EXTERNAL_STORAGE
access).
Before we perform a dangerous action, we can check that we currently have permission:
int permissionCheck = ContextCompat.checkSelfPermission(activity, Manifest.permission.PERMISSION_NAME);
- This function basically “looks up” whether we’ve been granted a particular permission or not. It will return either
PackageManager.PERMISSION_GRANTED
orPackageManager.PERMISSION_DENIED
.
If permission has been granted, great! We can go about our business (e.g., saving a file to external storage). But if permission has NOT been explicitly granted (at runtime), then we have to ask for it. We do this by calling:
ActivityCompat.requestPermissions(activity, new String[]{Manifest.permission.PERMISSION_NAME}, REQUEST_CODE);
- This method takes a context and then an array of permissions that we need access to (in case we need more than one). We also provide a request code (an
int
), which we can use to identify that particular request for permission in a callback that will be executed when the user chooses whether to give us access or not. This is the same pattern as when we sent an Intent for a result; asking for permission is conceptually like sending an Intent to the permission system!
We can then provide the callback that will be executed when the user decides whether to grant us permission or not:
public void onRequestPermissionsResult(int requestCode, String permissions[], int[] grantResults) {
switch (requestCode) {
case REQUEST_CODE:
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
//have permission! Do stuff!
}
default:
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
}
}
We check which request we’re hearing the results for, what permissions were granted (if any—the user can piece-wise grant permissions), and then we can react if everything is good… like by finally saving our file!
- Note that if the user deny us permission once, we might want to try and explain why we’re asking permission (see best practices) and ask again. Google offers a utility method (
ActivityCompat#shouldShowRequestPermissionRationale()
) which we can use to show a rationale dialog if they’ve denied us once. And if that’s true, we might show a Dialog or something to explain ourselves–and if they OK that dialog, then we can ask again.
10.3 External Storage
Once we have permission to write to external file, we can actually do so! Since we’ve verified that the External storage is available, we now need to pick what directory in that storage to save the file in. With External storage, we have two options:
We can save the file publicly. We use the
getExternalStoragePublicDirectory()
method to access a public directory, passing in what type of directory we want (e.g.,DIRECTORY_MUSIC
,DIRECTORY_PICTURES
,DIRECTORY_DOWNLOADS
etc). This basically drops files into the same folders that every other app is using, and is great for shared data and common formats like pictures, music, etc.. Files in the public directories can be easily accessed by other apps (assuming the app has permission to read/write from External storage!)Alternatively starting from API 18, we save the file privately, but still on External storage (these files are world-readable, but are hidden from the user as media, so they don’t “look” like public files). We access this directory with the
getExternalFilesDir()
method, again passing it a type (since we’re basically making our own version of the public folders). We can also usenull
for the type, giving us the root directory.
Since API 19 (4.4 KitKat), you don’t need permission to write to private External storage. So you can specify that you only need permission for versions lower than that:
xml <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="18" />
We can actually look at the emulator’s file-system and see our files by created using adb
. Connect to the emulator from the terminal using adb -s emulator-5554 shell
(note: adb
needs to be on your PATH). Public external files can usually be found in /storage/sdcard/Folder
, while private external files can be found in /storage/sdcard/Android/data/package.name/files
(these paths may vary on different devices).
Once we’ve opened up the file, we can write content to it by using the same IO classes we’ve used in Java:
The “low-level” way to do this is to create a a
FileOutputStream
object (or aFileInputStream
for reading). We just pass this constructor theFile
to write to. We writebytes
to this stream… but can write a String by callingmyString.getBytes()
. For reading, we’ll need to read in all the lines/characters, and probably build a String out of them to show. This is actually the same loop we used when reading data from an HTTP request!However, we can also use the same decorators as in Java (e.g.,
BufferedReader
,PrintWriter
, etc.) if we want those capabilities; it makes reading and writing to file a little easierIn either case, remember to
.close()
the stream when done (to avoid memory leaks)!
//writing
try {
//saving in public Documents directory
File dir = getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS);
if (!dir.exists()) { dir.mkdirs(); } //make dir if doesn't otherwise exist
File file = new File(dir, FILE_NAME);
Log.v(TAG, "Saving to " + file.getAbsolutePath());
PrintWriter out = new PrintWriter(new FileWriter(file, true));
out.println(textEntry.getText().toString());
out.close();
} catch (IOException ioe) {
Log.d(TAG, Log.getStackTraceString(ioe));
}
//reading
try {
File dir = getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS);
File file = new File(dir, FILE_NAME);
BufferedReader reader = new BufferedReader(new FileReader(file));
StringBuilder text = new StringBuilder();
//read the file
String line = reader.readLine();
while (line != null) {
text.append(line + "\n");
line = reader.readLine();
}
textDisplay.setText(text.toString());
reader.close();
} catch (IOException ioe) {
Log.d(TAG, Log.getStackTraceString(ioe));
}
This will allow us to have our “save” button write the message to the file, and have our “read” button load the message from the file (and display it on the screen)!
10.4 Internal Storage & Cache
Internal storage works pretty much the same way as External storage. Remember that Internal storage is always private to the app. We also don’t need permission to access Internal storage!
For Internal storage, we can use the getFilesDir()
method to access to the files directory (just like we did with External storage). This method normally returns the folder at /data/data/package.name/files
.
Alternatively, we can use Context#openFileOutput()
(or Context#openFileInput()
) and pass it the name of the file to open. This gives us back the Stream
object for that file in the Internal storage file directory, without us needing to do any extra work (cutting out the middle-man!)
- These methods take a second parameter:
MODE_PRIVATE
will create the file (or replace a file of the same name). Other modes available are:MODE_APPEND
(which adds to the end of the file if it exists instead of erasing)., andMODE_WORLD_READABLE
are deprecated.MODE_WORLD_WRITEABLE
- Note that you can wrap a
FileInputStream
in aInputStreamReader
in aBufferedReader
.
We can access the Internal Cache directory with getCacheDir()
(and same read/write process), or the External Cache directory with getExternalCacheDir()
. We almost always use the Internal Cache, because why would you want temporary files to be world-readable (other than maybe temporary images…)
And again, once you have the file, you use the same process for reading and writing as External storage.
For practice make the provided toggle support reading and writing to an Internal file as well. This will of course be different file than that used with the External switch. Ideally this code could be refactored to avoid duplication, but it gets tricky with the need for checked exception handling.
10.5 Example: Saving Pictures
As another example of how we might use the storage system, consider the “take a selfie” system from lecture 8. The code for taking a piecture can be found in a separate PhotoActivity
(which is accessible via the options menu).
To review: we sent an Intent
with the MediaStore.ACTION_IMAGE_CAPTURE
action, and the result of that Intent
included an Extra that was a BitMap
of a low-quality thumbnail for the image. But if we want to save a higher resolution version of that picture, we’ll need to store that image in the file system!
To do this, we’re actually going to modify the Intent
we send so it includes an additional Extra: a file in which the picture data can be saved. Effectively, we’ll have our Activity allocate some memory for the picture, and then tell the Camera where it can put the picture data that it captures. (Intent envelops are too small to carry entire photos around!)
Before we send the Intent
, we’re going to go ahead and create an (empty) file:
File file = null;
try {
String timestamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date()); //include timestamp
//ideally should check for permission here, skipping for time
File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES);
file = new File(dir, "PIC_"+timestamp+".jpg");
boolean created = file.createNewFile(); //actually make the file!
Log.v(TAG, "File created: "+created);
} catch (IOException ioe) {
Log.d(TAG, Log.getStackTraceString(ioe));
}
We will then specify an additional Extra to give that file’s location to the camera: if we use MediaStore.EXTRA_OUTPUT
as our Extra’s key, the camera will know what to do with that! However, the extra won’t actually be the File
but a Uri (recall: the “url” or location of a file). We’re not sending the file itself, but the location of that file (because it’s smaller data to fit in the Intent envelope).
We can get this Uri with the
Uri.fromFile(File)
method://save as instance variable to access later when picture comes back pictureFileUri = Uri.fromFile(file);
Then when we get the picture result back from the Camera (in our
onActivityResult
callback), we can access that file at the saved Uri and use it to display the image! TheImageView.setImageUri()
is a fast way of showing an image file.
Note that when working with images, we can very quickly run out of memory (because images can be huge). So we’ll often want to “scale down” the images as we load them into memory. Additionally, image processing can take a while so we’d like to do it off the main thread (e.g., in an AsyncTask
). This can become complicated; the recommended solution is to use a third-party library such as Glide, Picasso, or Fresco.