Keeping user data and other sensitive information secure and private is a key factor in building trust when using your app. This codelab helps you identify potential risks and take appropriate safeguards toward keeping this information safe.

What you will build

In this codelab, you're going to fix a secure photo-taking app. The app can do the following:

  • Take a photo.
  • Store it "securely" within the app so that no other apps on the device can access the photo.
  • Share photos with other apps.
  • Import photos stored elsewhere on the device into the app.

We will start with a version of the app that has some significant security issues and will address each of them.

What you'll learn

What you'll need

Download the Code

Click the following link to download all the code for this codelab:

Download source code

Unpack the downloaded ZIP file. This will unpack a root folder (android-storage-permissions), which contains the Android Studio project and a directory (data/) with a script to download some additional data to the device.

You can also check out the code directly from Github: (Start with the master branch.)

Github repository

We have also prepared a branch with the final code for the beginning of each step. If you get stuck, take a look at the branches on github or clone the entire repository: https://github.com/googlecodelabs/android-storage-permissions/branches/all

Features

This app is a secure photo-taking app. Photos stored within the app are not accessible to other apps unless they have been explicitly shared.

It also includes an import function, which allows you to copy images stored on the device into the app for secure storage.

The app consists of three screens:

Security Issues and missing features

We will start with an app that has several major security issues and does not actually store image securely!

App Architecture

The app follows the MVP architecture, separating the model (data storage) from the presenter (logic) and the view. These layers of abstractions are defined in the following contracts:

ImagesContract.java

/**
 * Contract that describes the "images" page, which displays a list of all images
 * stored within the app.
 * It includes options to take a photo, view a photo
 * (see  {@link com.google.samples.dataprivacy.page.viewimage.ImageViewerContract}) and
 * share an image.
 */
public interface ImagesContract {
    interface View {
        void setPresenter(Presenter presenter);

        /**
         * Display the list of images on screen.
         */
        void showImages(List<Image> images);

        /**
         * Display a message that no images can be displayed.
         */
        void showNoImages();

        /**
         * Open the image viewer page for an image.
         */
        void showImage(String path);

        /**
         * Request all necessary permissions required for running the app.
         */
        void requestPermissions();

        /**
         * Display an error message explaining that required permissions are missing.
         */
        void showMissingPermissions(boolean showMessage);

    }

    interface Presenter {

        /**
         * Start operation. Should be called to start the presenter.
         */
        void start();

        /**
         * The "take photo" option has been triggered. Open the "take photo" screen.
         */
        void openTakePhoto();

        /**
         * The "import photo" option has been triggered. Open the 
         * "import photo" screen.
         */
        void openImportPhoto();

        /**
         * An image has been selected for viewing. Opens the image viewer page.
         */
        void openImage(String path);

        /**
         * An image has been selected for import. Stores this {@link Bitmap} in the repository.
         */
        void onImportImage(Bitmap image);

        /**
         * A photo has been taken. Stores this {@link Bitmap} in the 
         * repository.
         */
        void onPhotoTaken(Bitmap image);

        /**
         * A result for a permission request has been received.
         * @param isGranted True if all required permissions have been granted.
         * False otherwise.
         */
        void onPermissionRequestResult(boolean isGranted);

        /**
         * Sets the picture taker, which can open the "take photo" screen.
         */
        void setPictureTaker(PictureTaker pictureTaker);

        /**
         * Clears the photo taker. See {@link #setPictureTaker(PictureTaker)}.
         */
        void clearPictureTaker();

        /**
         * Sets the image importer, which opens the "import image" screen.
         */
        void setImageImporter(ImageImporter imageImporter);

        /**
         * Clears the image importer. See {@link #setImageImporter(ImageImporter)}.
         */
        void clearImageImporter();
    }

}

The main contract is shown above (ImagesContract). It handles displaying, saving, sharing, and deleting stored images.

The app is divided into packages based on their logical function:

Building and running the app

  1. Start Android Studio and open the DataPrivacy directory as an Android project.
  2. Click on "run" to start the app:

    The app will start on the device. You need to grant access to the entire external storage before the app can run. Follow the prompts. (This is bad practice - we'll fix this in the following steps!)

    Note that no photos have been saved yet and that the list of photos is empty.

Take a photo

Next, verify functionality of the app by taking a photo and saving it on the device. Click on the floating action button and save the image:



The image will appear on the screen in the list of images:

Click on the image to see it full screen. The share and delete functions are accessible from this screen.

Note: The image may appear blurry. We have taken some shortcuts to keep this demo app simple.

Import an image

Next, try out the import functionality by copying a file from external storage into the app.

Click on the import option and select one of the displayed images. This screen lists up to 25 PNG images stored in external storage. Clicking on an image copies it into the app.

That's it, you have verified all functions of the app!

Now that we know the app works, we will fix some security and privacy issues in the app, starting with the location where images are stored.

At the moment, the app stores all its data in external storage using Environment.getExternalStorageDirectory().

Store data in internal storage

Each app has an internal storage directory that is private to the app and not accessible by any other apps. No special permissions are required to access this directory. When the user uninstalls the app, these files are removed.

Let's change our app to store images in internal storage instead of external storage.

All data storage in the app is handled by the LocalImagesRepository (see package com.google.samples.dataprivacy.storage).

In the constructor, replace the initialization with a call to context.getFilesDir(), which returns the location of internal storage. Remove the references to externalStorage as follows:

LocalImagesRepository.java

public LocalImagesRepository(Context context) {
        File internalStorage = context.getFilesDir();
        mStorage = new File(internalStorage, PATH);
        ...
}

Remove the WRITE_EXTERNAL_STORAGE permission

Using internal storage does not require any special permissions, so let's remove the WRITE_EXTERNAL_STORAGE permission. It's an Android best practice to minimize the permissions that an app uses. When prompted to grant a sensitive permission, such as this one, users may deny the permission due to privacy concerns.

First, change the AndroidManifest.xml file and remove the entry for the WRITE_EXTERNAL_STORAGE permission:

<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

Next, remove the runtime request for this permission. The app no longer needs to request permission before accessing the image repository to display images. Replace the call to mView.requestPermissions()—which requests the WRITE_EXTERNAL_STORAGE permission from the user—with a call to showImages() so that the app can load the images as soon as it is started.

In the ImagesPresenter, change the start() method as follows:

ImagesPresenter.java

@Override
public void start() {
    // Load all images when the app is started.
    showImages();        
}

Note: For the sake of simplicity, we are leaving the actual permission request code, which resides in the ImagesFragment, untouched. We are only changing the flow of the app to remove the calls that prompt the user for this permission. You can remove the associated methods in ImagesFragment later if you wish.

Build and run the app

  1. Stop the app if it is still running on the device.
  2. Reset all runtime permissions granted on the device. This ensures that the app will prompt for any runtime permissions again.

    Run the following command from a terminal:
adb shell pm reset-permissions
  1. Build and run the app as usual from Android Studio. The list of images will be empty for now.

    Note that we should not get any permission prompts when starting the app.
  2. Take a photo to verify that image storage works as expected.

You will still get prompted for camera access when taking a photo and storage access when importing photos. We will look at these permissions next.

Aside - Scoped Directory Access

Scoped directory access enables your app to ask for permission to access specific directories on a storage volume. In our app, we requested access to the entire external storage directory, but this could have been improved so that we were requesting access to only the Pictures/ directory on the external storage volume where images are usually stored.

Although the app would still need to ask the user for permission to access external storage, the user would at least know which directory the app would have accessed. This type of permission design gives users a better sense of choice and control.

You can learn more about scoped directory access, and see some code snippets to get you started, in the training guide. We are not going to dive into the feature in this codelab, as we'll focus on removing external storage access entirely in the next section.

The import function in the app requests read access to the entire external storage directory. Once granted, the user can select an image for importing into the app. This access model does not follow Android best practices, as it does not give users choice and control over exactly which files are accessed. Granting this permission grants it to the entire app.

Here, we have followed the best practices for runtime permissions:

However, it is still a dangerous permission that should be avoided.

Instead of granting a blanket read permission to the app, we can use a system intent. The app fires off an intent that prompts the user to select a file. When the intent returns, our app will only have access to the selected file. Using this access model, we can remove all storage permissions from the app, as they are no longer needed.

Add a File Picker Intent

First, let's construct a new intent that will open the system file browser and allow the user to select an image file.

In the file ImagesActivity.java, change the existing method importImage():

ImagesActivity.java

  @Override
    public void importImage() {
        // Use an ACTION_GET_CONTENT intent to select a file using the system's
        // file browser.
        Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
        // Filter files only to those that can be "opened" and directly accessed
        // as a stream.
        intent.addCategory(Intent.CATEGORY_OPENABLE);
        // Only show images.
        intent.setType("image/*");

        startActivityForResult(intent, REQUEST_IMAGE_IMPORT);
    }

This method is called when a user selects the import icon on the screen.

Instead of creating an Intent for our own ImageImportActivity, we are creating an intent that will be resolved by the system:

Intent.ACTION_GET_CONTENT

This intent will open a file browser and prompt the user to pick a file. It returns a Uri that points to the selected file.

Intent.CATEGORY_OPENABLE

Only list files that can be opened. Opened means that the files are accessible as a stream and that its contents can be read.

setType("image/*")

Only list images in the file browser.

This intent is started with a call to startActivityForResult(), Therefore we need to handle its returned data in onActivityResult().

First, we need to verify the request code (REQUEST_IMAGE_IMPORT), the return status of the activity (Activity.RESULT_OK), and confirm that it returned data (data != null).

Change the onActivityResult(...) method as follows:

ImagesActivity.java

    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {

        ...
            
        } else if (requestCode == REQUEST_IMAGE_IMPORT
                && resultCode == Activity.RESULT_OK && data != null) {
            // Image import intent result
            // The ACTION_GET_CONTENT Intent returns a URI pointing to the file.
            // It does not return the file itself. Extract the URI 
            // from the Intent and process it.
            Uri uri = data.getData();
            importUriImage(uri);

        } else {
        ...
        }
    }

Next, we need write a method that takes the URI returned by the intent and loads its referenced file as a bitmap.

Add the following method to the ImageActivity:

ImagesActivity.java

 /**
     * Uses the {@link MediaStore} content provider to load the {@link Uri}
     * as a Bitmap.
     * Next, notifies the presenter to import this image.
     *
     * @param uri Points to the image to create.
     */
    private void importUriImage(@NonNull Uri uri) {
        try {
            // Use the MediaStore to load the image.
            Bitmap image = MediaStore.Images.Media.getBitmap(getContentResolver(), uri);
            if (image != null) {
                // Tell the presenter to import this image.
                mPresenter.onImportImage(image);
            }
        } catch (IOException e) {
            e.printStackTrace();
            Log.e(TAG, "Error: " + e.getMessage() + "Could not open URI: "
                    + uri.toString());
        }
    }

Here we use the MediaStore content provider to resolve the URI and load the file directly as a bitmap.

Build and run the app

Build and run the app again. When you use the import function to load an image, the system file browser will open instead of our own activity. Select an image to import it into the app.

File Picker use cases

In our app, we have worked on the image import function in the last step. You have seen how we have improved upon our own import maintainability and security by replacing our activity's functionality with a system-provided intent.

If your app only requires users to select a file or item, then you can simplify your app by using a system intent instead. You don't need to maintain your own import code and handle corner cases. The ACTION_GET_CONTENT intent is a cleaner, simpler, and more secure way to open a file. Users only grant your app access to the selected file, which gives them confidence and autonomy in deciding how they share their data.

Consider taking advantage of system intents where possible, and follow our recommended best practices around data privacy and security.

In this step, we are giving our app the ability to share images with another app in a secure and safe manner.

We will be using the FileProvider component from the v4 Android Support Library to generate a content URI, which references a secure handle for a shared file.

Add the v4 support library dependency

The FileProvider class is part of the v4 Android Support library. In this codelab, we have already added the necessary dependencies for you. In your own projects, you may need to add this library as an additional dependency in your Gradle configuration.

Specify the FileProvider

Next, specify the FileProvider in the AndroidManifest.xml file. This step enables the authority for the content URIs and sets up the configuration for this component. The configuration also includes a reference to an XML file that specifies which directories can be shared using the FileProvider and how the directories are exposed.

To enable the provider, add the following declaration into the <application> tag in your app's manifest:

AndroidManifest.xml

<application ... >
        <provider
            android:name="android.support.v4.content.FileProvider"
            android:authorities="com.google.samples.dataprivacy.FileProvider"
            android:exported="false"
            android:grantUriPermissions="true">
            <meta-data
                android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/provider_paths" />
        </provider>
...
</application>

Although you don't need to make the file provider public because sharing is initiated from within the app (android:exported="false"), you should grant temporary access to shared files (android:grantUriPermissions="true").

The XML file that is referenced as android.support.FILE_PROVIDER_PATHS metadata should specify which storage paths and directories can be shared through the FileProvider. Only the paths that are set up in this file will be available for sharing.

Declare shareable directories

Next, create a new XML resource file with the name provider_paths.xml and save it in the res/xml directory. Add the following configuration into this file:

provider_paths.xml

<paths>
    <!-- Share the "secureimages/" directory from internal files storage
         as the name "data" -->
    <files-path name="data" path="secureimages/"/>
</paths>

Using this configuration, the provider has access to share files from the directory secureimages/ in the app's internal storage (Context.getFilesDir()) under the name data in the FileProvider. This does not automatically make all files in this directory accessible to other apps, it only enables the FileProvider to generate Uris for these files and makes them shareable. Next you need to send off an Intent or share the Uri while granting access permission to the receiving app.

This file is referenced from the provider declaration in the manifest file and specifies which directories are shared. For more information on how to declare sharable directories, see Specifying Available Files in the Android API reference.

Create a share intent

Now that we have set up the FileProvider, we need to trigger a share intent when a user selects the "share" option in the app.

Images are shared from the image viewer page. The ImageViewerActivity implements the ImageSharer interface, which the presenter (ImageViewerPresenter) uses to trigger the sharing functionality itself. This process might sound complicated, but all we need to do is implement the stubbed out ImageSharer interface methods in ImageViewerActivity.

Implement the method shareImage(String) as follows:

  1. Use the FileProvider to generate a shareable URI for the given file (contentUri). The path parameter in the method contains the absolute path to an image stored in the app.
Uri contentUri = FileProvider.getUriForFile(this, 
        "com.google.samples.dataprivacy.FileProvider", new File(path));
  1. Next, construct a new intent with the ACTION_SEND action.
Intent intent = new Intent();
intent.setAction(Intent.ACTION_SEND);
  1. Set the contentUri as data, and the data type as "image/png". This allows receiving apps to interpret the data correctly.
intent.setDataAndType(contentUri, "image/png");
  1. Grant the receiving app read access to this content URI by using Intent.FLAG_GRANT_READ_URI_PERMISSION. This flag tells the FileProvider to make the file available only to the recipient of this intent. If you do not specify this flag, the app that receives this intent will not be able to open the URI or access the referenced file.
intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);

Now you can start the intent as usual:

startActivity(intent);

This is the complete implementation of the shareImage(String) method:

ImageViewerActivity.java

    @Override
    public void shareImage(String path) {
        Toast.makeText(this, "Sharing: " + path, Toast.LENGTH_SHORT).show();
        Uri contentUri = FileProvider.getUriForFile(this, 
        "com.google.samples.dataprivacy.FileProvider", new File(path));

        Intent intent = new Intent();
        intent.setAction(Intent.ACTION_SEND);
        intent.setDataAndType(contentUri, "image/png");
        intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
        startActivity(intent);
    }

URI permissions and file providers

Setting the FLAG_GRANT_READ_URI_PERMISSION flag on an intent grants the recipient read access to the URI. The flag FLAG_GRANT_WRITE_URI_PERMISSION, on the other hand, grants write access. Both flags are temporary and only apply to an activity only when it's running.

Alternatively, you can grant a permission to a specific package temporarily by calling Context.grantUriPermission(package, Uri, mode_flags). This alternative is useful when your app is not using an intent.

You can also create custom permissions to enforce access to content providers. You declare these permissions in the <provider> element in an app's manifest file, and you can apply the permission to the entire provider or only to specific paths. Note that these custom permissions are persistent, unlike the temporary ones that we created earlier in this section. For further details, see the implementing content provider permissions guide.

Learn more

Congratulations! You have now completed the codelab!

To recap, we have explored the following concepts:

For your own app, consider the permissions that you require, which storage and sharing options you use, and how you interact with your users' personal information. The key is to minimize access to personal data to protect privacy and minimise security risks.

In this codelab, we have only touched on some of the techniques and tips you can use. Next, check out the security core app quality guidelines and the set of best practices guides for Android security and privacy to improve the security of your app!