Search code examples
javaandroidlibgdxstoragescoped-storage

Saving Text Files - Scoped Storage Android 11


Is there a way to create and save text files outside of the Android/data folder in external storage? I am creating a music app and I added a preset manager so users can save, load, and edit preset files (which worked great before Android 11), I don’t want these files to be automatically removed if the app is uninstalled. That would be like if Photoshop deleted all of your Photoshop documents when Photoshop is uninstalled, that’s terrible! These are files that the user saves and they can be deleted separately if the user wants to.

There has to be a way around this but I haven’t been able to find anything that works. ACTION_OPEN_DOCUMENT_TREE looked very promising, until I saw that Android has removed this option too.

https://developer.android.com/about/versions/11/privacy/storage

You can no longer use the ACTION_OPEN_DOCUMENT_TREE intent action to request access to the following directories:

  • The root directory of the internal storage volume.
  • The root directory of each SD card volume that the device manufacturer considers to be reliable, regardless of whether the card is emulated or removable. A reliable volume is one that an app can successfully access most of the time.
  • The Download directory.

I’ve read Google Play only allows MANAGE_EXTERNAL_STORAGE in apps that need it (like file browsers, or anti-virus, etc) which likely will not work in my case. I don’t want to rely on only targeting older API’s so requestLegacyExternalStorage won’t work either.

Everything I’ve looked into appears to be a dead end. Is there anything else I can do?

Here is a short test program (I’m using LibGDX), which at the moment can only save to the root location:

Android/data/com.mygdx.filetest/files/

[core] FileTest.java

package com.mygdx.filetest;

import com.badlogic.gdx.ApplicationListener;
import com.badlogic.gdx.Files;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.files.FileHandle;
import com.badlogic.gdx.utils.ScreenUtils;

public class FileTest implements ApplicationListener {
    private NativePermissions permissions;
    private Files.FileType fileType;
    private String directory = "TestDir/";
    private String name = "text.txt";
       
    public FileTest(final NativePermissions permissions){     
        this.permissions = permissions;
    }
    
    @Override
    public void create(){
        fileType = getFileType();
        if (permissions != null){
            permissions.checkExternalStoragePermission();
        } else {
            permissionGranted();
        }
    }
    private Files.FileType getFileType(){
        switch(Gdx.app.getType()) {
            case Android:
                return Files.FileType.External;
            default:
                return Files.FileType.Local;
        }
    }
    
    @Override public void render(){ ScreenUtils.clear(0.4f, 0.4f, 0.4f, 1); }
    @Override public void resize(int width, int height) {}
    @Override public void pause(){}
    @Override public void resume(){}
    @Override public void dispose (){}
    
    public void permissionGranted() {
        FileHandle fileHandle = Gdx.files.getFileHandle(directory+name, fileType); 
        if (fileHandle!=null) fileHandle.writeString("test", false);
    }
}

[android] AndroidLauncher.java

package com.mygdx.filetest;

import android.Manifest;
import android.app.AlertDialog;
import android.content.DialogInterface;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.widget.Toast;

import androidx.core.app.ActivityCompat;

import com.badlogic.gdx.backends.android.AndroidApplication;
import com.badlogic.gdx.backends.android.AndroidApplicationConfiguration;

public class AndroidLauncher extends AndroidApplication {
    private final FileTest application;
    private final int STORAGE_PERMISSION_CODE = 1;
    private boolean dialogBoxShowing = false;
    
    public AndroidLauncher(){
        final AndroidPermissions permissions = new AndroidPermissions(this);
        application = new FileTest(permissions);
    }
    
    @Override
    protected void onCreate (Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        AndroidApplicationConfiguration config = new AndroidApplicationConfiguration();
        initialize(application, config);
    }
    @Override
    public void onRequestPermissionsResult(final int requestCode, final String permissions[], final int[] grantResults) {
        dialogBoxShowing = false;
        if (requestCode == STORAGE_PERMISSION_CODE) {
            if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                Toast.makeText(this, "Permission GRANTED", Toast.LENGTH_SHORT).show();
                permissionGranted();
            } else {
                Toast.makeText(this, "Permission DENIED", Toast.LENGTH_SHORT).show();
            }
        }
    }
    public void promptExternalStoragePermission() {
        if (dialogBoxShowing) return;
        dialogBoxShowing = true;
        this.runOnUiThread(new Runnable() {
            @Override
            public void run() {
                final AlertDialog.Builder builder = new AlertDialog.Builder(AndroidLauncher.this);
                builder.setMessage("To save user presets and custom settings, allow access to your phone’s storage.");
                builder.setCancelable(false);
                // reverse these buttons to put "NO" on left and "YES" on right
                builder.setPositiveButton("NOT NOW", new DialogInterface.OnClickListener(){
                    @Override
                    public void onClick(DialogInterface dialog, int which) {
                        dialogBoxShowing = false;
                        dialog.dismiss();
                    }
                });
                builder.setNegativeButton("CONTINUE", new DialogInterface.OnClickListener(){
                    @Override
                    public void onClick(DialogInterface dialog, int which) {
                        ActivityCompat.requestPermissions(AndroidLauncher.this, new String[] {Manifest.permission.WRITE_EXTERNAL_STORAGE}, STORAGE_PERMISSION_CODE);
                    }
                });
                builder.create().show();
            }
        });
    }
    public void permissionGranted(){ application.permissionGranted(); }
}

[core] NativePermissions.java

package com.mygdx.filetest;

public interface NativePermissions {
    public void checkExternalStoragePermission();
}

[android] AndroidPermissions.java

package com.mygdx.filetest;

import android.Manifest;
import android.content.pm.PackageManager;

import androidx.core.content.ContextCompat;

public class AndroidPermissions implements NativePermissions {
    private final AndroidLauncher context;
    
    public AndroidPermissions(final AndroidLauncher context){
        this.context = context;
    }
    
    @Override
    public void checkExternalStoragePermission() {
        if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED){
            context.permissionGranted();
        } else {
            context.promptExternalStoragePermission();
        }
    }
}

[android] AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.mygdx.filetest">
    
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
    
    <application
        android:allowBackup="true"
        android:icon="@drawable/ic_launcher"
        android:isGame="true"
        android:appCategory="game"
        android:label="@string/app_name"
        android:theme="@style/GdxTheme" >
        <activity
            android:name="com.mygdx.filetest.AndroidLauncher"
            android:label="@string/app_name" 
            android:screenOrientation="fullUser"
            android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>

Solution

  • A lott of fuss.

    You can in the classic way save your files to the public Documents directory.

    Or use SAF with ACTION_OPEN_DOCUMENT_TREE for that directory.

    Both dont need 'all files access'.