Android: Use SAF paths for scanning

This commit is contained in:
Connor McLaughlin 2021-04-17 20:46:19 +10:00
parent d6d8d21eff
commit c3f914565f
7 changed files with 231 additions and 363 deletions

View file

@ -305,19 +305,15 @@ public class EmulationActivity extends AppCompatActivity implements SurfaceHolde
applySettings();
}
} else if (requestCode == REQUEST_IMPORT_PATCH_CODES) {
if (data == null)
if (data == null || data.getData() == null)
return;
importPatchesFromFile(data.getData());
} else if (requestCode == REQUEST_CHANGE_DISC_FILE) {
if (data == null)
if (data == null || data.getData() == null)
return;
String path = GameDirectoriesActivity.getPathFromUri(this, data.getData());
if (path == null)
return;
AndroidHostInterface.getInstance().setMediaFilename(path);
AndroidHostInterface.getInstance().setMediaFilename(data.getDataString());
}
}
@ -687,7 +683,7 @@ public class EmulationActivity extends AppCompatActivity implements SurfaceHolde
}
private void importPatchesFromFile(Uri uri) {
String str = FileUtil.readFileFromUri(this, uri, 512 * 1024);
String str = FileHelper.readStringFromUri(this, uri, 512 * 1024);
if (str == null || !AndroidHostInterface.getInstance().importPatchCodesFromString(str)) {
reportErrorOnUIThread(getString(R.string.emulation_activity_failed_to_import_patch_codes));
}

View file

@ -3,10 +3,22 @@ package com.github.stenzek.duckstation;
import android.content.ContentResolver;
import android.content.Context;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.ImageDecoder;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.provider.DocumentsContract;
import android.provider.MediaStore;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
/**
@ -46,6 +58,7 @@ public class FileHelper {
/**
* File helper class - used to bridge native code to Java storage access framework APIs.
*
* @param context Context in which to perform file actions as.
*/
public FileHelper(Context context) {
@ -53,8 +66,185 @@ public class FileHelper {
this.contentResolver = context.getContentResolver();
}
/**
* Reads the specified file as a string, under the specified context.
*
* @param context context to access file under
* @param uri uri to write data to
* @param maxSize maximum file size to read
* @return String containing the file data, otherwise null
*/
public static String readStringFromUri(final Context context, final Uri uri, int maxSize) {
InputStream stream = null;
try {
stream = context.getContentResolver().openInputStream(uri);
} catch (FileNotFoundException e) {
return null;
}
StringBuilder os = new StringBuilder();
try {
char[] buffer = new char[1024];
InputStreamReader reader = new InputStreamReader(stream, Charset.forName(StandardCharsets.UTF_8.name()));
int len;
while ((len = reader.read(buffer)) > 0) {
os.append(buffer, 0, len);
if (os.length() > maxSize)
return null;
}
stream.close();
} catch (IOException e) {
return null;
}
if (os.length() == 0)
return null;
return os.toString();
}
/**
* Reads the specified file as a byte array, under the specified context.
*
* @param context context to access file under
* @param uri uri to write data to
* @param maxSize maximum file size to read
* @return byte array containing the file data, otherwise null
*/
public static byte[] readBytesFromUri(final Context context, final Uri uri, int maxSize) {
InputStream stream = null;
try {
stream = context.getContentResolver().openInputStream(uri);
} catch (FileNotFoundException e) {
return null;
}
ByteArrayOutputStream os = new ByteArrayOutputStream();
try {
byte[] buffer = new byte[512 * 1024];
int len;
while ((len = stream.read(buffer)) > 0) {
os.write(buffer, 0, len);
if (maxSize > 0 && os.size() > maxSize) {
return null;
}
}
stream.close();
} catch (IOException e) {
e.printStackTrace();
return null;
}
if (os.size() == 0)
return null;
return os.toByteArray();
}
/**
* Writes the specified data to a file referenced by the URI, as the specified context.
*
* @param context context to access file under
* @param uri uri to write data to
* @param bytes data to write file to
* @return true if write was succesful, otherwise false
*/
public static boolean writeBytesToUri(final Context context, final Uri uri, final byte[] bytes) {
OutputStream stream = null;
try {
stream = context.getContentResolver().openOutputStream(uri);
} catch (FileNotFoundException e) {
e.printStackTrace();
return false;
}
if (bytes != null && bytes.length > 0) {
try {
stream.write(bytes);
stream.close();
} catch (IOException e) {
e.printStackTrace();
return false;
}
}
return true;
}
/**
* Deletes the file referenced by the URI, under the specified context.
*
* @param context context to delete file under
* @param uri uri to delete
* @return
*/
public static boolean deleteFileAtUri(final Context context, final Uri uri) {
try {
if (uri.getScheme() == "file") {
final File file = new File(uri.getPath());
if (!file.isFile())
return false;
return file.delete();
}
return (context.getContentResolver().delete(uri, null, null) > 0);
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
/**
* Returns the name of the file pointed at by a SAF URI.
*
* @param context context to access file under
* @param uri uri to retrieve file name for
* @return the name of the file, or null
*/
public static String getDocumentNameFromUri(final Context context, final Uri uri) {
Cursor cursor = null;
try {
final String[] proj = {DocumentsContract.Document.COLUMN_DISPLAY_NAME};
cursor = context.getContentResolver().query(uri, proj, null, null, null);
final int columnIndex = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME);
cursor.moveToFirst();
return cursor.getString(columnIndex);
} catch (Exception e) {
e.printStackTrace();
return null;
} finally {
if (cursor != null)
cursor.close();
}
}
/**
* Loads a bitmap from the provided SAF URI.
*
* @param context context to access file under
* @param uri uri to retrieve file name for
* @return a decoded bitmap for the file, or null
*/
public static Bitmap loadBitmapFromUri(final Context context, final Uri uri) {
InputStream stream = null;
try {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
final ImageDecoder.Source source = ImageDecoder.createSource(context.getContentResolver(), uri);
return ImageDecoder.decodeBitmap(source);
} else {
return MediaStore.Images.Media.getBitmap(context.getContentResolver(), uri);
}
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
/**
* Retrieves a file descriptor for a content URI string. Called by native code.
*
* @param uriString string of the URI to open
* @param mode Java open mode
* @return file descriptor for URI, or -1
@ -73,6 +263,7 @@ public class FileHelper {
/**
* Recursively iterates documents in the specified tree, searching for files.
*
* @param treeUri Root tree in which to search for documents.
* @param documentId Document ID representing the directory to start searching.
* @param flags Native search flags.
@ -116,6 +307,7 @@ public class FileHelper {
/**
* Recursively iterates documents in the specified URI, searching for files.
*
* @param uriString URI containing directory to search.
* @param flags Native filter flags.
* @return Array of find results.

View file

@ -1,293 +0,0 @@
package com.github.stenzek.duckstation;
// https://stackoverflow.com/questions/34927748/android-5-0-documentfile-from-tree-uri
import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.content.Context;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.ImageDecoder;
import android.net.Uri;
import android.os.Build;
import android.os.storage.StorageManager;
import android.provider.DocumentsContract;
import android.provider.MediaStore;
import android.widget.Toast;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.lang.reflect.Array;
import java.lang.reflect.Method;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
public final class FileUtil {
static String TAG = "TAG";
private static final String PRIMARY_VOLUME_NAME = "primary";
@Nullable
public static String getFullPathFromTreeUri(@Nullable final Uri treeUri, Context con) {
if (treeUri == null) return null;
String volumePath = getVolumePath(getVolumeIdFromTreeUri(treeUri), con);
if (volumePath == null) return File.separator;
if (volumePath.endsWith(File.separator))
volumePath = volumePath.substring(0, volumePath.length() - 1);
String documentPath = getDocumentPathFromTreeUri(treeUri);
if (documentPath.endsWith(File.separator))
documentPath = documentPath.substring(0, documentPath.length() - 1);
if (documentPath.length() > 0) {
if (documentPath.startsWith(File.separator))
return volumePath + documentPath;
else
return volumePath + File.separator + documentPath;
} else return volumePath;
}
@SuppressLint("ObsoleteSdkInt")
private static String getVolumePath(final String volumeId, Context context) {
if (volumeId == null)
return null;
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return null;
try {
StorageManager mStorageManager =
(StorageManager) context.getSystemService(Context.STORAGE_SERVICE);
Class<?> storageVolumeClazz = Class.forName("android.os.storage.StorageVolume");
Method getVolumeList = mStorageManager.getClass().getMethod("getVolumeList");
Method getUuid = storageVolumeClazz.getMethod("getUuid");
Method getPath = storageVolumeClazz.getMethod("getPath");
Method isPrimary = storageVolumeClazz.getMethod("isPrimary");
Object result = getVolumeList.invoke(mStorageManager);
final int length = Array.getLength(result);
for (int i = 0; i < length; i++) {
Object storageVolumeElement = Array.get(result, i);
String uuid = (String) getUuid.invoke(storageVolumeElement);
Boolean primary = (Boolean) isPrimary.invoke(storageVolumeElement);
// primary volume?
if (primary && PRIMARY_VOLUME_NAME.equals(volumeId))
return (String) getPath.invoke(storageVolumeElement);
// other volumes?
if (uuid != null && uuid.equals(volumeId))
return (String) getPath.invoke(storageVolumeElement);
}
// not found.
return null;
} catch (Exception ex) {
return null;
}
}
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
private static String getVolumeIdFromTreeUri(final Uri treeUri) {
final String docId = DocumentsContract.getTreeDocumentId(treeUri);
final String[] split = docId.split(":");
if (split.length > 0) return split[0];
else return null;
}
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
private static String getDocumentPathFromTreeUri(final Uri treeUri) {
final String docId = DocumentsContract.getTreeDocumentId(treeUri);
final String[] split = docId.split(":");
if ((split.length >= 2) && (split[1] != null)) return split[1];
else return File.separator;
}
@Nullable
public static String getFullPathFromUri(@Nullable final Uri treeUri, Context con) {
if (treeUri == null) return null;
String volumePath = getVolumePath(getVolumeIdFromUri(treeUri), con);
if (volumePath == null) return File.separator;
if (volumePath.endsWith(File.separator))
volumePath = volumePath.substring(0, volumePath.length() - 1);
String documentPath = getDocumentPathFromUri(treeUri);
if (documentPath.endsWith(File.separator))
documentPath = documentPath.substring(0, documentPath.length() - 1);
if (documentPath.length() > 0) {
if (documentPath.startsWith(File.separator))
return volumePath + documentPath;
else
return volumePath + File.separator + documentPath;
} else return volumePath;
}
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
private static String getVolumeIdFromUri(final Uri treeUri) {
try {
final String docId = DocumentsContract.getDocumentId(treeUri);
final String[] split = docId.split(":");
if (split.length > 0) return split[0];
else return null;
} catch (Exception e) {
return null;
}
}
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
private static String getDocumentPathFromUri(final Uri treeUri) {
try {
final String docId = DocumentsContract.getDocumentId(treeUri);
final String[] split = docId.split(":");
if ((split.length >= 2) && (split[1] != null)) return split[1];
else return File.separator;
} catch (Exception e) {
return null;
}
}
public static String readFileFromUri(final Context context, final Uri uri, int maxSize) {
InputStream stream = null;
try {
stream = context.getContentResolver().openInputStream(uri);
} catch (FileNotFoundException e) {
return null;
}
StringBuilder os = new StringBuilder();
try {
char[] buffer = new char[1024];
InputStreamReader reader = new InputStreamReader(stream, Charset.forName(StandardCharsets.UTF_8.name()));
int len;
while ((len = reader.read(buffer)) > 0) {
os.append(buffer, 0, len);
if (os.length() > maxSize)
return null;
}
stream.close();
} catch (IOException e) {
return null;
}
if (os.length() == 0)
return null;
return os.toString();
}
public static byte[] readBytesFromUri(final Context context, final Uri uri, int maxSize) {
InputStream stream = null;
try {
stream = context.getContentResolver().openInputStream(uri);
} catch (FileNotFoundException e) {
return null;
}
ByteArrayOutputStream os = new ByteArrayOutputStream();
try {
byte[] buffer = new byte[512 * 1024];
int len;
while ((len = stream.read(buffer)) > 0) {
os.write(buffer, 0, len);
if (maxSize > 0 && os.size() > maxSize) {
return null;
}
}
stream.close();
} catch (IOException e) {
e.printStackTrace();
return null;
}
if (os.size() == 0)
return null;
return os.toByteArray();
}
public static boolean writeBytesToUri(final Context context, final Uri uri, final byte[] bytes) {
OutputStream stream = null;
try {
stream = context.getContentResolver().openOutputStream(uri);
} catch (FileNotFoundException e) {
e.printStackTrace();
return false;
}
if (bytes != null && bytes.length > 0) {
try {
stream.write(bytes);
stream.close();
} catch (IOException e) {
e.printStackTrace();
return false;
}
}
return true;
}
public static boolean deleteFileAtUri(final Context context, final Uri uri) {
try {
if (uri.getScheme() == "file") {
final File file = new File(uri.getPath());
if (!file.isFile())
return false;
return file.delete();
}
return (context.getContentResolver().delete(uri, null, null) > 0);
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
/**
* Returns the name of the file pointed at by a SAF URI.
* @param context context to access file under
* @param uri uri to retrieve file name for
* @return the name of the file, or null
*/
public static String getDocumentNameFromUri(final Context context, final Uri uri) {
Cursor cursor = null;
try {
final String[] proj = {DocumentsContract.Document.COLUMN_DISPLAY_NAME};
cursor = context.getContentResolver().query(uri, proj, null, null, null);
final int columnIndex = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME);
cursor.moveToFirst();
return cursor.getString(columnIndex);
} catch (Exception e) {
e.printStackTrace();
return null;
} finally {
if (cursor != null)
cursor.close();
}
}
public static Bitmap loadBitmapFromUri(final Context context, final Uri uri) {
InputStream stream = null;
try {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
final ImageDecoder.Source source =ImageDecoder.createSource(context.getContentResolver(), uri);
return ImageDecoder.decodeBitmap(source);
} else {
return MediaStore.Images.Media.getBitmap(context.getContentResolver(), uri);
}
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
}

View file

@ -239,38 +239,6 @@ public class GameDirectoriesActivity extends AppCompatActivity {
return super.onOptionsItemSelected(item);
}
public static String getPathFromTreeUri(Context context, Uri treeUri) {
String path = FileUtil.getFullPathFromTreeUri(treeUri, context);
if (path.length() < 5) {
new AlertDialog.Builder(context)
.setTitle(R.string.main_activity_error)
.setMessage(R.string.main_activity_get_path_from_directory_error)
.setPositiveButton(R.string.main_activity_ok, (dialog, button) -> {
})
.create()
.show();
return null;
}
return path;
}
public static String getPathFromUri(Context context, Uri uri) {
String path = FileUtil.getFullPathFromUri(uri, context);
if (path.length() < 5) {
new AlertDialog.Builder(context)
.setTitle(R.string.main_activity_error)
.setMessage(R.string.main_activity_get_path_from_file_error)
.setPositiveButton(R.string.main_activity_ok, (dialog, button) -> {
})
.create()
.show();
return null;
}
return path;
}
public static void addSearchDirectory(Context context, String path, boolean recursive) {
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
final String key = recursive ? "GameList/RecursivePaths" : "GameList/Paths";
@ -289,6 +257,8 @@ public class GameDirectoriesActivity extends AppCompatActivity {
Intent i = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
i.addCategory(Intent.CATEGORY_DEFAULT);
i.putExtra(Intent.EXTRA_LOCAL_ONLY, true);
i.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
i.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION);
startActivityForResult(Intent.createChooser(i, getString(R.string.main_activity_choose_directory)),
REQUEST_ADD_DIRECTORY_TO_GAME_LIST);
}
@ -318,14 +288,18 @@ public class GameDirectoriesActivity extends AppCompatActivity {
switch (requestCode) {
case REQUEST_ADD_DIRECTORY_TO_GAME_LIST: {
if (resultCode != RESULT_OK)
if (resultCode != RESULT_OK || data.getData() == null)
return;
String path = getPathFromTreeUri(this, data.getData());
if (path == null)
return;
try {
getContentResolver().takePersistableUriPermission(data.getData(),
Intent.FLAG_GRANT_READ_URI_PERMISSION);
} catch (Exception e) {
Toast.makeText(this, "Failed to take persistable permission.", Toast.LENGTH_LONG);
e.printStackTrace();
}
addSearchDirectory(this, path, true);
addSearchDirectory(this, data.getDataString(), true);
mDirectoryListAdapter.reload();
}
break;

View file

@ -9,7 +9,6 @@ import android.content.pm.PackageManager;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
@ -18,12 +17,10 @@ import android.view.Gravity;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.Button;
import android.widget.ListView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.app.AppCompatDelegate;
@ -195,6 +192,8 @@ public class MainActivity extends AppCompatActivity {
Intent i = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
i.addCategory(Intent.CATEGORY_DEFAULT);
i.putExtra(Intent.EXTRA_LOCAL_ONLY, true);
i.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
i.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION);
startActivityForResult(Intent.createChooser(i, getString(R.string.main_activity_choose_directory)),
REQUEST_ADD_DIRECTORY_TO_GAME_LIST);
}
@ -270,14 +269,18 @@ public class MainActivity extends AppCompatActivity {
switch (requestCode) {
case REQUEST_ADD_DIRECTORY_TO_GAME_LIST: {
if (resultCode != RESULT_OK)
if (resultCode != RESULT_OK || data.getData() == null)
return;
String path = GameDirectoriesActivity.getPathFromTreeUri(this, data.getData());
if (path == null)
return;
try {
getContentResolver().takePersistableUriPermission(data.getData(),
Intent.FLAG_GRANT_READ_URI_PERMISSION);
} catch (Exception e) {
Toast.makeText(this, "Failed to take persistable permission.", Toast.LENGTH_LONG);
e.printStackTrace();
}
GameDirectoriesActivity.addSearchDirectory(this, path, true);
GameDirectoriesActivity.addSearchDirectory(this, data.getDataString(), true);
mGameList.refresh(false, false, this);
}
break;
@ -291,14 +294,10 @@ public class MainActivity extends AppCompatActivity {
break;
case REQUEST_START_FILE: {
if (resultCode != RESULT_OK)
if (resultCode != RESULT_OK || data.getData() == null)
return;
String path = GameDirectoriesActivity.getPathFromUri(this, data.getData());
if (path == null)
return;
startEmulation(path, shouldResumeStateByDefault());
startEmulation(data.getDataString(), shouldResumeStateByDefault());
}
break;
@ -428,7 +427,7 @@ public class MainActivity extends AppCompatActivity {
if (gameListEntry == null)
return;
final Bitmap bitmap = FileUtil.loadBitmapFromUri(this, uri);
final Bitmap bitmap = FileHelper.loadBitmapFromUri(this, uri);
if (bitmap == null) {
Toast.makeText(this, "Failed to open/decode image.", Toast.LENGTH_LONG).show();
return;

View file

@ -286,13 +286,13 @@ public class MemoryCardEditorActivity extends AppCompatActivity {
if (card == null)
return;
final byte[] data = FileUtil.readBytesFromUri(this, uri, 16 * 1024 * 1024);
final byte[] data = FileHelper.readBytesFromUri(this, uri, 16 * 1024 * 1024);
if (data == null) {
displayError(getString(R.string.memory_card_editor_import_card_read_failed, uri.toString()));
return;
}
String importFileName = FileUtil.getDocumentNameFromUri(this, uri);
String importFileName = FileHelper.getDocumentNameFromUri(this, uri);
if (importFileName == null) {
importFileName = uri.getPath();
if (importFileName == null || importFileName.isEmpty())

View file

@ -41,7 +41,7 @@ public class MemoryCardImage {
}
public static MemoryCardImage open(Context context, Uri uri) {
byte[] data = FileUtil.readBytesFromUri(context, uri, DATA_SIZE);
byte[] data = FileHelper.readBytesFromUri(context, uri, DATA_SIZE);
if (data == null)
return null;
@ -145,11 +145,11 @@ public class MemoryCardImage {
private static native boolean importCard(byte[] data, String filename, byte[] importData);
public boolean save() {
return FileUtil.writeBytesToUri(context, uri, data);
return FileHelper.writeBytesToUri(context, uri, data);
}
public boolean delete() {
return FileUtil.deleteFileAtUri(context, uri);
return FileHelper.deleteFileAtUri(context, uri);
}
public boolean format() {