Skip to content

Instantly share code, notes, and snippets.

@antipatico
Created April 15, 2024 13:05
Show Gist options
  • Save antipatico/73f718d5b37b507b6b6dbf9bf92052e0 to your computer and use it in GitHub Desktop.
Save antipatico/73f718d5b37b507b6b6dbf9bf92052e0 to your computer and use it in GitHub Desktop.
ACTION_OPEN_DOCUMENT_TREE save files PoC for Love Android
/*
* Copyright (c) 2006-2024 LOVE Development Team
*
* This software is provided 'as-is', without any express or implied
* warranty. In no event will the authors be held liable for any damages
* arising from the use of this software.
*
* Permission is granted to anyone to use this software for any purpose,
* including commercial applications, and to alter it and redistribute it
* freely, subject to the following restrictions:
*
* 1. The origin of this software must not be misrepresented; you must not
* claim that you wrote the original software. If you use this software
* in a product, an acknowledgment in the product documentation would be
* appreciated but is not required.
* 2. Altered source versions must be plainly marked as such, and must not be
* misrepresented as being the original software.
* 3. This notice may not be removed or altered from any source distribution.
*/
package org.love2d.android;
import android.Manifest;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.content.res.AssetManager;
import android.database.Cursor;
import android.graphics.Rect;
import android.media.AudioManager;
import android.net.Uri;
import android.os.Bundle;
import android.os.Environment;
import android.os.ParcelFileDescriptor;
import android.os.VibrationEffect;
import android.os.Vibrator;
import android.provider.DocumentsContract;
import android.provider.MediaStore;
import android.util.DisplayMetrics;
import android.util.Log;
import android.view.DisplayCutout;
import android.view.WindowManager;
import android.widget.Toast;
import androidx.annotation.Keep;
import androidx.core.app.ActivityCompat;
import androidx.documentfile.provider.DocumentFile;
import androidx.preference.PreferenceManager;
import org.libsdl.app.SDLActivity;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
public class GameActivity extends SDLActivity {
private static final String TAG = "GameActivity";
public static final int RECORD_AUDIO_REQUEST_CODE = 3;
private static final int REQUEST_CODE_PICK_DIRECTORY = 0xf11e;
private static final String PREF_DIRECTORY_URI = "save_files_directory_uri";
protected Vibrator vibrator;
protected boolean shortEdgesMode;
protected final int[] recordAudioRequestDummy = new int[1];
private Uri delayedUri = null;
private String[] args;
private boolean isFused;
private static native void nativeSetDefaultStreamValues(int sampleRate, int framesPerBurst);
@Override
protected String getMainSharedObject() {
String[] libs = getLibraries();
// Since Lollipop, you can simply pass "libname.so" to dlopen
// and it will resolve correct paths and load correct library.
// This is mandatory for extractNativeLibs=false support in
// Marshmallow.
return "lib" + libs[libs.length - 1] + ".so";
}
@Override
protected String[] getLibraries() {
return new String[]{
"c++_shared",
"SDL2",
"oboe",
"openal",
"luajit",
"liblove",
"love",
};
}
@Override
protected String[] getArguments() {
return args;
}
@Override
protected void onCreate(Bundle savedInstanceState) {
Log.d(TAG, "started");
isFused = hasEmbeddedGame();
args = new String[0];
if (checkCallingOrSelfPermission(Manifest.permission.VIBRATE) == PackageManager.PERMISSION_GRANTED) {
vibrator = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE);
}
Intent intent = getIntent();
handleIntent(intent, true);
// Prevent SDL sending filedropped event. Let us do that instead.
intent.setData(null);
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
loadSaveFilesDirectory(prefs);
super.onCreate(savedInstanceState);
if (mBrokenLibraries) {
return;
}
// Set low-latency audio values
nativeSetDefaultStreamValues(getAudioFreq(), getAudioSMP());
if (android.os.Build.VERSION.SDK_INT >= 28) {
WindowManager.LayoutParams attr = getWindow().getAttributes();
attr.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER;
shortEdgesMode = false;
}
if (delayedUri != null) {
// This delayed fd is only sent if an embedded game is present.
sendUriAsDroppedFile(delayedUri);
delayedUri = null;
}
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
handleIntent(intent, false);
}
@Override
protected void onDestroy() {
if (vibrator != null) {
Log.d(TAG, "Cancelling vibration");
vibrator.cancel();
}
super.onDestroy();
}
@Override
protected void onPause() {
if (vibrator != null) {
Log.d(TAG, "Cancelling vibration");
vibrator.cancel();
}
super.onPause();
}
@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
if (grantResults.length > 0) {
Log.d("GameActivity", "Received a request permission result");
if (requestCode == RECORD_AUDIO_REQUEST_CODE) {
if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
Log.d("GameActivity", "Mic permission granted");
} else {
Log.d("GameActivity", "Did not get mic permission.");
}
Log.d("GameActivity", "Unlocking LÖVE thread");
synchronized (recordAudioRequestDummy) {
recordAudioRequestDummy[0] = grantResults[0];
recordAudioRequestDummy.notify();
}
} else {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
}
}
}
@Keep
public boolean hasEmbeddedGame() {
AssetManager am = getAssets();
InputStream inputStream;
try {
// Prioritize main.lua in assets folder
inputStream = am.open("main.lua");
} catch (IOException e) {
// Not found, try game.love in assets folder
try {
inputStream = am.open("game.love");
} catch (IOException e2) {
// Not found
return false;
}
}
try {
inputStream.close();
} catch (IOException ignored) {
}
return true;
}
@Keep
public void vibrate(double seconds) {
if (vibrator != null) {
long duration = (long) (seconds * 1000.);
if (android.os.Build.VERSION.SDK_INT >= 26) {
VibrationEffect ve = VibrationEffect.createOneShot(duration, VibrationEffect.DEFAULT_AMPLITUDE);
vibrator.vibrate(ve);
} else {
vibrator.vibrate(duration);
}
}
}
@Keep
public boolean openURLFromLOVE(String url) {
Log.d(TAG, "opening url = " + url);
return openURL(url) == 0;
}
@Keep
public boolean hasBackgroundMusic() {
AudioManager audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE);
return audioManager.isMusicActive();
}
@Keep
public String[] buildFileTree() {
// Map key is path, value is directory flag
HashMap<String, Boolean> map = buildFileTree(getAssets(), "", new HashMap<>());
ArrayList<String> result = new ArrayList<>();
for (Map.Entry<String, Boolean> data : map.entrySet()) {
result.add((data.getValue() ? "d" : "f") + data.getKey());
}
String[] r = new String[result.size()];
result.toArray(r);
return r;
}
@Keep
public float getDPIScale() {
DisplayMetrics metrics = getResources().getDisplayMetrics();
return metrics.density;
}
@Keep
public Rect getSafeArea() {
Rect rect = null;
if (android.os.Build.VERSION.SDK_INT >= 28) {
DisplayCutout cutout = getWindow().getDecorView().getRootWindowInsets().getDisplayCutout();
if (cutout != null) {
rect = new Rect();
rect.set(
cutout.getSafeInsetLeft(),
cutout.getSafeInsetTop(),
cutout.getSafeInsetRight(),
cutout.getSafeInsetBottom()
);
}
}
return rect;
}
@Keep
public String getCRequirePath() {
ApplicationInfo applicationInfo = getApplicationInfo();
if (isNativeLibsExtracted()) {
return applicationInfo.nativeLibraryDir + "/?.so";
} else {
// The native libs are inside the APK and can be loaded directly.
// FIXME: What about split APKs?
String abi = android.os.Build.SUPPORTED_ABIS[0];
return applicationInfo.sourceDir + "!/lib/" + abi + "/?.so";
}
}
@Keep
public void setImmersiveMode(boolean enable) {
if (android.os.Build.VERSION.SDK_INT >= 28) {
WindowManager.LayoutParams attr = getWindow().getAttributes();
if (enable) {
attr.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
} else {
attr.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER;
}
}
shortEdgesMode = enable;
}
@Keep
public boolean getImmersiveMode() {
return shortEdgesMode;
}
@Keep
public boolean hasRecordAudioPermission() {
return ActivityCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED;
}
@Keep
public void requestRecordAudioPermission() {
if (ActivityCompat.checkSelfPermission(this,
Manifest.permission.RECORD_AUDIO)
== PackageManager.PERMISSION_GRANTED) {
return;
}
Log.d("GameActivity", "Requesting mic permission and locking LÖVE thread until we have an answer.");
ActivityCompat.requestPermissions(this,
new String[]{Manifest.permission.RECORD_AUDIO},
RECORD_AUDIO_REQUEST_CODE);
synchronized (recordAudioRequestDummy) {
try {
recordAudioRequestDummy.wait();
} catch (InterruptedException e) {
Log.d("GameActivity", "requesting mic permission", e);
}
}
}
@Keep
public int convertToFileDescriptor(String uri) {
return convertToFileDescriptor(Uri.parse(uri));
}
public int getAudioSMP() {
int smp = 256;
AudioManager a = (AudioManager) getSystemService(Context.AUDIO_SERVICE);
if (a != null) {
int b = Integer.parseInt(a.getProperty(AudioManager.PROPERTY_OUTPUT_FRAMES_PER_BUFFER));
smp = b > 0 ? b : smp;
}
return smp;
}
public int getAudioFreq() {
int freq = 44100;
AudioManager a = (AudioManager) getSystemService(Context.AUDIO_SERVICE);
if (a != null) {
int b = Integer.parseInt(a.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE));
freq = b > 0 ? b : freq;
}
return freq;
}
public boolean isNativeLibsExtracted() {
if (android.os.Build.VERSION.SDK_INT >= 23) {
ApplicationInfo appInfo = getApplicationInfo();
return (appInfo.flags & ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS) != 0;
}
return false;
}
public void sendUriAsDroppedFile(Uri uri) {
SDLActivity.onNativeDropFile(uri.toString());
}
private void handleIntent(Intent intent, boolean onCreate) {
Uri game = intent.getData();
if (game == null) {
return;
}
if (onCreate) {
// Game is not running
if (isFused) {
// Send it as dropped file later
delayedUri = game;
} else {
// Process for arguments
processOpenGame(game);
}
} else {
// Game is already running. Send it as dropped file.
sendUriAsDroppedFile(game);
}
}
private HashMap<String, Boolean> buildFileTree(AssetManager assetManager, String dir, HashMap<String, Boolean> map) {
String strippedDir = dir.endsWith("/") ? dir.substring(0, dir.length() - 1) : dir;
// Try open dir
try {
InputStream test = assetManager.open(strippedDir);
// It's a file
test.close();
map.put(strippedDir, false);
} catch (FileNotFoundException e) {
// It's a directory
String[] list = null;
// List files
try {
list = assetManager.list(strippedDir);
} catch (IOException e2) {
Log.e(TAG, strippedDir, e2);
}
// Mark as file
map.put(dir, true);
if (!strippedDir.equals(dir)) {
map.put(strippedDir, true);
}
if (list != null) {
for (String path : list) {
buildFileTree(assetManager, dir + path + "/", map);
}
}
} catch (IOException e) {
Log.e(TAG, dir, e);
}
return map;
}
private int convertToFileDescriptor(Uri uri) {
int fd = -1;
try {
ParcelFileDescriptor pfd = getContentResolver().openFileDescriptor(uri, "r");
if (pfd == null) {
throw new RuntimeException("pfd is null");
}
fd = pfd.dup().detachFd();
pfd.close();
} catch (Exception e) {
Log.e(TAG, "Failed attempt to convert " + uri.toString() + " to file descriptor", e);
}
return fd;
}
private void processOpenGame(Uri game) {
String scheme = game.getScheme();
String path = game.getPath();
if (scheme != null) {
if (scheme.equals("content")) {
// Convert the URI to file descriptor.
args = new String[]{"/love2d://fd/" + convertToFileDescriptor(game)};
} else if (scheme.equals("file")) {
// Regular file, pass as-is.
args = new String[]{path};
}
}
}
/* Android 13+ External Save Files Patch */
private void loadSaveFilesDirectory(SharedPreferences preferences) {
String directoryUriString = preferences.getString(PREF_DIRECTORY_URI, null);
if (directoryUriString == null || !isUriAccessible(Uri.parse(directoryUriString))) {
requestDirectoryAccess();
}
}
private boolean isUriAccessible(Uri uri) {
try {
DocumentFile documentFile = DocumentFile.fromTreeUri(this, uri);
String filePath = null;
return (documentFile != null && documentFile.isDirectory());
} catch (Exception e) {
return false;
}
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == REQUEST_CODE_PICK_DIRECTORY) {
if (resultCode != RESULT_OK || data == null) {
finish();
}
Uri saveFilesUri = data.getData();
if (saveFilesUri == null) {
finish();
}
getContentResolver().takePersistableUriPermission(saveFilesUri,Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
prefs.edit().putString(PREF_DIRECTORY_URI, saveFilesUri.toString()).apply();
}
}
private void requestDirectoryAccess() {
Toast.makeText(getApplicationContext(), "Select a directory where to store game save-files", Toast.LENGTH_SHORT).show();
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
if (android.os.Build.VERSION.SDK_INT >= 26) {
String suggestedPath = Environment.getExternalStorageDirectory().getPath() + "/LOVE2D";
Log.d(TAG, suggestedPath);
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, Uri.parse(suggestedPath));
}
startActivityForResult(intent, REQUEST_CODE_PICK_DIRECTORY);
}
}
@antipatico
Copy link
Author

antipatico commented Apr 20, 2024

With conversion from content Uri to Fs path (even hackier):

    private void loadSaveFilesDirectory(SharedPreferences preferences) {
        String savesDirectoryPath = preferences.getString(PREF_DIRECTORY_URI, null);
        if (savesDirectoryPath == null || !isPathAccessible(savesDirectoryPath)) {
            requestDirectoryAccess();
        }
    }

//    private boolean isUriAccessible(Uri uri) {
//        try {
//            DocumentFile documentFile = DocumentFile.fromTreeUri(this, uri);
//            return (documentFile != null &&  documentFile.isDirectory());
//        } catch (Exception e) {
//            return false;
//        }
//    }

    private boolean isPathAccessible(String path) {
        try {
            File savesDirectory = new File(path);
            return savesDirectory.isDirectory();
        }
        catch (Exception e) {
            Log.d(TAG, "Old saves path not accessible anymore");
            return false;
        }
    }


    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);
        if (requestCode == REQUEST_CODE_PICK_DIRECTORY) {
            Activity activity = (Activity)getContext();
            if (resultCode != RESULT_OK || data == null) {
                activity.finish();
            }
            assert data != null; // Android Code linter, probably my code is shitty
            Uri saveFilesUri = data.getData();
            if (saveFilesUri == null) {
                activity.finish();
            }
            assert saveFilesUri != null; // Android Code linter, probably my code is shitty
            // TODO: fix API level error for old android
            String savesPath = FileUtils.getPath(this, saveFilesUri);
            if (savesPath == null) {
                activity.finish();
            }
            getContentResolver().takePersistableUriPermission(
                    saveFilesUri,
                    Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
            );
            SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
            prefs.edit().putString(PREF_DIRECTORY_URI, savesPath).apply();
        }
    }
    private void requestDirectoryAccess() {
        Toast.makeText(getApplicationContext(), "Select a directory where to store game save-files", Toast.LENGTH_SHORT).show();
        Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
        if (android.os.Build.VERSION.SDK_INT >= 26) {
            String suggestedPath = Environment.getExternalStorageDirectory().getPath() + "/LOVE2D";
            Log.d(TAG, suggestedPath);
            intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, Uri.parse(suggestedPath));
        }
        startActivityForResult(intent, REQUEST_CODE_PICK_DIRECTORY);
    }

FileUtils.java:

package org.love2d.android;

// Inspired by https://stackoverflow.com/a/20559418

import android.content.Context;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.provider.DocumentsContract;

import androidx.annotation.RequiresApi;


public class FileUtils {
    /**
     * Get a file path from a Uri. This will get the the path for Storage Access
     * Framework Documents, as well as the _data field for the MediaStore and
     * other file-based ContentProviders.
     *
     * @param context The context.
     * @param uri The Uri to query.
     * @author paulburke
     */
    @RequiresApi(api = Build.VERSION_CODES.N)
    public static String getPath(final Context context, final Uri uri) {
        // DocumentProvider
        if (DocumentsContract.isTreeUri(uri)) {
            // ExternalStorageProvider
            if (isExternalStorageDocument(uri)) {
                final String docId = DocumentsContract.getTreeDocumentId(uri);
                final String[] split = docId.split(":");
                final String type = split[0];

                if ("primary".equalsIgnoreCase(type)) {
                    return Environment.getExternalStorageDirectory() + "/" + split[1];
                }
                // TODO: handle external SD card.
            }
        }
        // File
        else if ("file".equalsIgnoreCase(uri.getScheme())) {
            return uri.getPath();
        }
        return null;
    }

    /**
     * @param uri The Uri to check.
     * @return Whether the Uri authority is ExternalStorageProvider.
     */
    public static boolean isExternalStorageDocument(Uri uri) {
        return "com.android.externalstorage.documents".equals(uri.getAuthority());
    }
}

@antipatico
Copy link
Author

SDL patch to core/android/SDL_android.c

const char *SDL_AndroidGetExternalStoragePath(void)
{
    static char *s_AndroidExternalFilesPath = NULL;

    if (s_AndroidExternalFilesPath == NULL) {
        struct LocalReferenceHolder refs = LocalReferenceHolder_Setup(__FUNCTION__);
        jmethodID mid;
        jobject context;
        jclass cls;
        jobject sharedPrefs;
        jstring prefKey;
        jstring defaultValue;
        jstring retString;
        const char *retChars;

        JNIEnv *env = Android_JNI_GetEnv();
        if (!LocalReferenceHolder_Init(&refs, env)) {
            LocalReferenceHolder_Cleanup(&refs);
            return NULL;
        }

        /* context = SDLActivity.getContext(); */
        context = (*env)->CallStaticObjectMethod(env, mActivityClass, midGetContext);

        /* sharedPrefs = PreferenceManager.getDefaultSharedPreferences(context) */
        cls = (*env)->FindClass(env, "androidx/preference/PreferenceManager");
        mid = (*env)->GetStaticMethodID(env, cls, "getDefaultSharedPreferences", "(Landroid/content/Context;)Landroid/content/SharedPreferences;");
        sharedPrefs = (*env)->CallStaticObjectMethod(env, cls, mid, context);

        /* retString = sharedPrefs.getString("save_files_directory_uri", NULL) */
        cls = (*env)->FindClass(env, "android/content/SharedPreferences");
        mid = (*env)->GetMethodID(env, cls, "getString", "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;");
        prefKey = (*env)->NewStringUTF(env, "save_files_directory_uri");
        defaultValue = NULL;
        retString = (jstring)(*env)->CallObjectMethod(env, sharedPrefs, mid, prefKey, defaultValue);

        retChars = (*env)->GetStringUTFChars(env, retString, NULL);
        s_AndroidExternalFilesPath = SDL_strdup(retChars);
        (*env)->ReleaseStringUTFChars(env, retString, retChars);
        LocalReferenceHolder_Cleanup(&refs);
    }
    return s_AndroidExternalFilesPath;
}

Patch to love/src/modules/filesystem/Filesystem.h:

	bool useExternal = true;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment