Instrumented Testing Storage Access Framework (SAF) Client Applications

Methods described here are available in Jelly Bean (API level 16) or later.
Additional document opening methods were introduced in KitKat (API level 19).
Current API level is 32. It means this is universally available in the Android ecosystem.

What is Storage Access Framework (SAF)?

SAF is a system consisting of client applications, document providers and a document picker system UI, which glues the first two.

Before SAF applications used to demand access to all your files just to show an in-app file picker, which allows selecting a particular file for further in-app actions. Before SAF this was the only way. SAF was introduced almost 10 years ago.

Interacting with user supplied files is not a reason for an application to request storage permission.
Posting dog pictures on the social media should not imply access permission to your cat pictures.

Difference between ACTION_CREATE_DOCUMENT, ACTION_GET_CONTENT and ACTION_OPEN_DOCUMENT intents

  • ACTION_CREATE_DOCUMENT is for creating new files.
  • ACTION_GET_CONTENT is a request to get the data of an existing file. Once.
  • ACTION_OPEN_DOCUMENT is a request to get persistent access to the file and its contents. Read, write, modify.

API levels 16 and 19

According to Android developer documentation, SAF is introduced in API level 19. Document picker, ACTION_OPEN_DOCUMENT and ACTION_CREATE_DOCUMENT is introduced in API level 19.

API level 16 devices with a decent file manager app installed can use ACTION_GET_CONTENT. File manager needs storage permission. That is understandable, because dealing with files is the one and only purpose of that application.

SAF, The Basic Usage

Send one of the intents, wait for reply with Uri of the selected document, interact with the Uri.

ActivityResultContract provides wrappers to tie requests and responses. ActivityResultContracts.GetContent(), ActivityResultContracts.OpenDocument() and ActivityResultContracts.CreateDocument().

Basic usage of CreateDocument():

// Create the ActivityResultContracts and response callback
private final ActivityResultLauncher<String> m_createDocument = registerForActivityResult(
    new ActivityResultContracts.CreateDocument(),
    selectedOutputDocument -> {
        if (null != selectedOutputDocument) {
            try (OutputStream outputStream = getContentResolver().openOutputStream(selectedOutputDocument)) {
                outputStream.write("Hello!".getBytes());
            } catch (IOException e) {
                Toast.makeText(this, "Writing failed! " + e.getMessage(), Toast.LENGTH_LONG).show();
            }
        }
    }
);

@Override
protected void onCreate(Bundle savedInstanceState) {
    // ...
    findViewById(R.id.button_ACTION_CREATE_DOCUMENT).setOnClickListener(view -> {
        String suggestedOutputFilename = "sampleFile.txt";
        m_createDocument.launch(suggestedOutputFilename);
    });
}

Why is SAF problematic?

Document picker is system UI, which means that buttons which need to be pushed can be different from one system version to another, from one vendor to another. Have fun checking and adapting tests for each device update.

Instrumented Testing SAF Workflows, The How

ACTION_CREATE_DOCUMENT, ACTION_GET_CONTENT and ACTION_OPEN_DOCUMENT are regular intents, which can be intercepted by Espresso-Intents.

@Test
public void test_ACTION_CREATE_DOCUMENT() {
    Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();

    // Prepare Uri, for the to be created file
    File fileToBeCreated = new File(new File(appContext.getCacheDir(), "shared"), "myFile.txt");
    String authority = appContext.getPackageName() + ".instrumentedTestsProvider";
    Uri sharedFileUri = FileProvider.getUriForFile(appContext, authority, fileToBeCreated);

    Intents.init();

    // SetUp intercept for Intent.ACTION_CREATE_DOCUMENT, which will respond with sharedFileUri
    Intents.intending(hasAction(Intent.ACTION_CREATE_DOCUMENT)).respondWith(
            new Instrumentation.ActivityResult(Activity.RESULT_OK,
                    new Intent().setData(sharedFileUri).addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
            )
    );

    // Click the button in the app. This needs to happen AFTER the intercept is set up
    onView(withId(R.id.button_ACTION_CREATE_DOCUMENT)).perform(click());

    // Verify that the app actually wrote what is expected
    Assert.assertTrue(verifyFileContent(fileToBeCreated, "Hello!"));

    Intents.release();
}

Full example available at GitHub:ViliusSutkus89/TestingStorageAccessFrameworkClients.

Drawbacks

Ideally the file being shared would come from a separate application, but this is more difficult to set up. Instrumented tests application is only somewhat separate. It has its own APK, but it does not have its own cache or files directories. Using the context of main application for FileProvider means that file permission granting is not being tested.