Created
April 15, 2024 13:05
-
-
Save antipatico/73f718d5b37b507b6b6dbf9bf92052e0 to your computer and use it in GitHub Desktop.
ACTION_OPEN_DOCUMENT_TREE save files PoC for Love Android
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* 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); | |
} | |
} |
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
With conversion from content Uri to Fs path (even hackier):
FileUtils.java: