diff --git a/src/pandroid/app/src/main/AndroidManifest.xml b/src/pandroid/app/src/main/AndroidManifest.xml index d1d8f56f..0f097600 100644 --- a/src/pandroid/app/src/main/AndroidManifest.xml +++ b/src/pandroid/app/src/main/AndroidManifest.xml @@ -45,6 +45,17 @@ + + + + + + diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/preferences/GamesFoldersPreferences.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/preferences/GamesFoldersPreferences.java new file mode 100644 index 00000000..ce5c0f57 --- /dev/null +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/preferences/GamesFoldersPreferences.java @@ -0,0 +1,100 @@ +package com.panda3ds.pandroid.app.preferences; + +import android.annotation.SuppressLint; +import android.net.Uri; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.TextView; + +import androidx.activity.result.ActivityResultCallback; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.Nullable; +import androidx.preference.Preference; +import androidx.preference.PreferenceScreen; + +import com.google.android.material.bottomsheet.BottomSheetDialog; +import com.panda3ds.pandroid.R; +import com.panda3ds.pandroid.app.base.BasePreferenceFragment; +import com.panda3ds.pandroid.data.game.GamesFolder; +import com.panda3ds.pandroid.utils.FileUtils; +import com.panda3ds.pandroid.utils.GameUtils; + +public class GamesFoldersPreferences extends BasePreferenceFragment implements ActivityResultCallback { + private final ActivityResultContracts.OpenDocumentTree openFolderContract = new ActivityResultContracts.OpenDocumentTree(); + private ActivityResultLauncher pickFolderRequest; + + @Override + public void onCreatePreferences(@Nullable Bundle savedInstanceState, @Nullable String rootKey) { + setPreferencesFromResource(R.xml.empty_preferences, rootKey); + setActivityTitle(R.string.pref_games_folders); + refreshList(); + pickFolderRequest = registerForActivityResult(openFolderContract, this); + } + + @SuppressLint("RestrictedApi") + private void refreshList(){ + GamesFolder[] folders = GameUtils.getFolders(); + PreferenceScreen screen = getPreferenceScreen(); + screen.removeAll(); + for (GamesFolder folder: folders){ + Preference preference = new Preference(screen.getContext()); + preference.setOnPreferenceClickListener((item)-> { + showFolderInfo(folder); + screen.performClick(); + return false; + }); + preference.setTitle(FileUtils.getName(folder.getPath())); + preference.setSummary(String.format(getString(R.string.games_count_f), folder.getGames().size())); + preference.setIcon(R.drawable.ic_folder); + screen.addPreference(preference); + } + + Preference add = new Preference(screen.getContext()); + add.setTitle(R.string.import_folder); + add.setIcon(R.drawable.ic_add); + add.setOnPreferenceClickListener(preference -> { + pickFolderRequest.launch(null); + return false; + }); + screen.addPreference(add); + } + + private void showFolderInfo(GamesFolder folder) { + BottomSheetDialog dialog = new BottomSheetDialog(requireActivity()); + View layout = LayoutInflater.from(requireActivity()).inflate(R.layout.games_folder_about, null, false); + dialog.setContentView(layout); + + ((TextView) layout.findViewById(R.id.name)).setText(FileUtils.getName(folder.getPath())); + ((TextView) layout.findViewById(R.id.directory)).setText(folder.getPath()); + ((TextView) layout.findViewById(R.id.games)).setText(String.valueOf(folder.getGames().size())); + + layout.findViewById(R.id.ok).setOnClickListener(v -> dialog.dismiss()); + layout.findViewById(R.id.remove).setOnClickListener(v -> { + dialog.dismiss(); + GameUtils.removeFolder(folder); + refreshList(); + }); + + dialog.show(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (pickFolderRequest != null){ + pickFolderRequest.unregister(); + pickFolderRequest = null; + } + } + + @Override + public void onActivityResult(Uri result) { + if (result != null){ + FileUtils.makeUriPermanent(result.toString(), "r"); + GameUtils.registerFolder(result.toString()); + refreshList(); + } + } +} diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/preferences/GeneralPreferences.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/preferences/GeneralPreferences.java index 7ecfbcad..7ba3e7bb 100644 --- a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/preferences/GeneralPreferences.java +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/preferences/GeneralPreferences.java @@ -15,6 +15,7 @@ public class GeneralPreferences extends BasePreferenceFragment { setPreferencesFromResource(R.xml.general_preference, rootKey); setItemClick("appearance.theme", (pref) -> new ThemeSelectorDialog(requireActivity()).show()); setItemClick("appearance.ds", (pref) -> PreferenceActivity.launch(requireActivity(), DsListPreferences.class)); + setItemClick("games.folders", (pref) -> PreferenceActivity.launch(requireActivity(), GamesFoldersPreferences.class)); setActivityTitle(R.string.general); } } diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/provider/AppDataDocumentProvider.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/provider/AppDataDocumentProvider.java new file mode 100644 index 00000000..5b8feed2 --- /dev/null +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/app/provider/AppDataDocumentProvider.java @@ -0,0 +1,119 @@ +package com.panda3ds.pandroid.app.provider; + +import android.content.Context; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.os.CancellationSignal; +import android.os.ParcelFileDescriptor; +import android.provider.DocumentsContract.Document; +import android.provider.DocumentsContract.Root; +import android.provider.DocumentsProvider; + +import androidx.annotation.Nullable; + +import com.panda3ds.pandroid.R; +import com.panda3ds.pandroid.app.PandroidApplication; + +import java.io.File; +import java.io.FileNotFoundException; + +public class AppDataDocumentProvider extends DocumentsProvider { + private static final String ROOT_ID = "root"; + private static final String[] DEFAULT_ROOT_PROJECTION = new String[]{ + Root.COLUMN_ROOT_ID, + Root.COLUMN_MIME_TYPES, + Root.COLUMN_FLAGS, + Root.COLUMN_ICON, + Root.COLUMN_TITLE, + Root.COLUMN_SUMMARY, + Root.COLUMN_DOCUMENT_ID, + Root.COLUMN_AVAILABLE_BYTES + }; + + private static final String[] DEFAULT_DOCUMENT_PROJECTION = new String[]{ + Document.COLUMN_DOCUMENT_ID, + Document.COLUMN_DISPLAY_NAME, + Document.COLUMN_MIME_TYPE, + Document.COLUMN_LAST_MODIFIED, + Document.COLUMN_SIZE + }; + + private String obtainDocumentId(File file) { + String basePath = baseDirectory().getAbsolutePath(); + String fullPath = file.getAbsolutePath(); + return (ROOT_ID + "/" + fullPath.substring(basePath.length())).replaceAll("//", "/"); + } + + private File obtainFile(String documentId) { + if (documentId.startsWith(ROOT_ID)) { + return new File(baseDirectory(), documentId.substring(ROOT_ID.length())); + } + throw new IllegalArgumentException("Invalid document id: " + documentId); + } + + private Context context() { + return PandroidApplication.getAppContext(); + } + + private File baseDirectory() { + return context().getFilesDir(); + } + + @Override + public boolean onCreate() { + return true; + } + + @Override + public Cursor queryRoots(String[] projection) throws FileNotFoundException { + MatrixCursor cursor = new MatrixCursor(projection == null ? DEFAULT_ROOT_PROJECTION : projection); + cursor.newRow() + .add(Root.COLUMN_ROOT_ID, ROOT_ID) + .add(Root.COLUMN_SUMMARY, null) + .add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_IS_CHILD | Root.FLAG_SUPPORTS_CREATE) + .add(Root.COLUMN_DOCUMENT_ID, ROOT_ID + "/") + .add(Root.COLUMN_AVAILABLE_BYTES, baseDirectory().getFreeSpace()) + .add(Root.COLUMN_TITLE, context().getString(R.string.app_name)) + .add(Root.COLUMN_MIME_TYPES, "*/*") + .add(Root.COLUMN_ICON, R.mipmap.ic_launcher); + return cursor; + } + + @Override + public Cursor queryDocument(String documentId, String[] projection) throws FileNotFoundException { + File file = obtainFile(documentId); + MatrixCursor cursor = new MatrixCursor(projection == null ? DEFAULT_DOCUMENT_PROJECTION : projection); + includeFile(cursor, file); + return cursor; + } + + private void includeFile(MatrixCursor cursor, File file) { + cursor.newRow() + .add(Document.COLUMN_DOCUMENT_ID, obtainDocumentId(file)) + .add(Document.COLUMN_MIME_TYPE, file.isDirectory() ? Document.MIME_TYPE_DIR : "application/octect-stream") + .add(Document.COLUMN_FLAGS, 0) + .add(Document.COLUMN_LAST_MODIFIED, file.lastModified()) + .add(Document.COLUMN_DISPLAY_NAME, file.getName()) + .add(Document.COLUMN_SIZE, file.length()); + + } + + @Override + public Cursor queryChildDocuments(String parentDocumentId, String[] projection, String sortOrder) throws FileNotFoundException { + File file = obtainFile(parentDocumentId); + MatrixCursor cursor = new MatrixCursor(projection == null ? DEFAULT_DOCUMENT_PROJECTION : projection); + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + includeFile(cursor, child); + } + } + + return cursor; + } + + @Override + public ParcelFileDescriptor openDocument(String documentId, String mode, @Nullable CancellationSignal signal) throws FileNotFoundException { + return ParcelFileDescriptor.open(obtainFile(documentId), ParcelFileDescriptor.parseMode(mode)); + } +} \ No newline at end of file diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/data/game/GameMetadata.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/data/game/GameMetadata.java index 5acf2593..2c033171 100644 --- a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/data/game/GameMetadata.java +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/data/game/GameMetadata.java @@ -15,9 +15,9 @@ import java.util.UUID; public class GameMetadata { private final String id; private final String romPath; - private final String title; - private final String publisher; - private final GameRegion[] regions; + private String title; + private String publisher; + private GameRegion[] regions; private transient Bitmap icon; private GameMetadata(String id, String romPath, String title, String publisher, Bitmap icon, GameRegion[] regions) { @@ -60,7 +60,7 @@ public class GameMetadata { } public Bitmap getIcon() { - if (icon == null) { + if (icon == null || icon.isRecycled()) { icon = GameUtils.loadGameIcon(id); } return icon; @@ -78,10 +78,15 @@ public class GameMetadata { return false; } - public static GameMetadata applySMDH(GameMetadata meta, SMDH smdh) { + public void applySMDH(SMDH smdh) { Bitmap icon = smdh.getBitmapIcon(); - GameMetadata newMeta = new GameMetadata(meta.getId(), meta.getRomPath(), smdh.getTitle(), smdh.getPublisher(), icon, new GameRegion[]{smdh.getRegion()}); - icon.recycle(); - return newMeta; + this.title = smdh.getTitle(); + this.publisher = smdh.getPublisher(); + this.icon = icon; + if (icon != null){ + GameUtils.setGameIcon(id, icon); + } + this.regions = new GameRegion[]{smdh.getRegion()}; + GameUtils.writeChanges(); } } diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/data/game/GamesFolder.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/data/game/GamesFolder.java new file mode 100644 index 00000000..78d454a1 --- /dev/null +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/data/game/GamesFolder.java @@ -0,0 +1,61 @@ +package com.panda3ds.pandroid.data.game; + +import android.net.Uri; +import android.util.Log; + +import com.panda3ds.pandroid.R; +import com.panda3ds.pandroid.app.PandroidApplication; +import com.panda3ds.pandroid.utils.FileUtils; + +import java.util.Collection; +import java.util.HashMap; +import java.util.UUID; + +public class GamesFolder { + + private final String id = UUID.randomUUID().toString(); + private final String path; + private final HashMap games = new HashMap<>(); + + public GamesFolder(String path) { + this.path = path; + } + + public boolean isValid(){ + return FileUtils.exists(path); + } + + public String getId() { + return id; + } + + public String getPath() { + return path; + } + + public Collection getGames() { + return games.values(); + } + + public void refresh() { + String[] gamesId = games.keySet().toArray(new String[0]); + for (String file: gamesId){ + if (!FileUtils.exists(path+"/"+file)){ + games.remove(file); + } + } + String unknown = PandroidApplication.getAppContext().getString(R.string.unknown); + + for (String file: FileUtils.listFiles(path)){ + String path = FileUtils.getChild(this.path, file); + if (FileUtils.isDirectory(path) || games.containsKey(file)){ + continue; + } + String ext = FileUtils.extension(path); + if (ext.equals("3ds") || ext.equals("3dsx")){ + String name = FileUtils.getName(path).trim().split("\\.")[0]; + games.put(file, new GameMetadata(new Uri.Builder().path(file).authority(id).scheme("folder").build().toString(),name, unknown)); + } + } + } +} diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/utils/FileUtils.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/utils/FileUtils.java index 2920c7c6..bec8bae3 100644 --- a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/utils/FileUtils.java +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/utils/FileUtils.java @@ -2,8 +2,10 @@ package com.panda3ds.pandroid.utils; import android.content.Context; import android.content.Intent; +import android.database.Cursor; import android.net.Uri; import android.os.ParcelFileDescriptor; +import android.provider.DocumentsContract; import android.system.Os; import android.util.Log; @@ -21,6 +23,7 @@ import java.util.Objects; public class FileUtils { public static final String MODE_READ = "r"; + private static final String TREE_URI = "tree"; public static final int CANONICAL_SEARCH_DEEP = 8; private static DocumentFile parseFile(String path) { @@ -28,7 +31,14 @@ public class FileUtils { return DocumentFile.fromFile(new File(path)); } Uri uri = Uri.parse(path); - return DocumentFile.fromSingleUri(getContext(), uri); + DocumentFile singleFile = DocumentFile.fromSingleUri(getContext(), uri); + if (singleFile.length() > 0 && singleFile.length() != 4096){ + return singleFile; + } + if (uri.getScheme().equals("content") && uri.getPath().startsWith("/"+TREE_URI)){ + return DocumentFile.fromTreeUri(getContext(), uri); + } + return singleFile; } private static Context getContext() { @@ -310,4 +320,12 @@ public class FileUtils { } return true; } + + public static String getChild(String path, String name){ + return parseFile(path).findFile(name).getUri().toString(); + } + + public static boolean isDirectory(String path) { + return parseFile(path).isDirectory(); + } } diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/utils/GameUtils.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/utils/GameUtils.java index f050af0a..41e26537 100644 --- a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/utils/GameUtils.java +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/utils/GameUtils.java @@ -10,16 +10,18 @@ import android.util.Log; import com.panda3ds.pandroid.app.GameActivity; import com.panda3ds.pandroid.data.GsonConfigParser; import com.panda3ds.pandroid.data.game.GameMetadata; +import com.panda3ds.pandroid.data.game.GamesFolder; import java.io.InputStream; import java.io.OutputStream; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Objects; public class GameUtils { private static final Bitmap DEFAULT_ICON = Bitmap.createBitmap(48, 48, Bitmap.Config.ARGB_8888); - public static GsonConfigParser parser = new GsonConfigParser(Constants.PREF_GAME_UTILS); + private static GsonConfigParser parser = new GsonConfigParser(Constants.PREF_GAME_UTILS); private static DataModel data; @@ -27,10 +29,12 @@ public class GameUtils { public static void initialize() { data = parser.load(DataModel.class); + refreshFolders(); } public static GameMetadata findByRomPath(String romPath) { - for (GameMetadata game : data.games) { + ArrayList games = getGames(); + for (GameMetadata game : games) { if (Objects.equals(romPath, game.getRealPath())) { return game; } @@ -42,9 +46,7 @@ public class GameUtils { currentGame = game; String path = game.getRealPath(); if (path.contains("://")) { - String[] parts = Uri.decode(game.getRomPath()).split("/"); - String name = parts[parts.length - 1]; - path = "game://internal/" + name; + path = "game://internal/" + FileUtils.getName(game.getRealPath()); } context.startActivity(new Intent(context, GameActivity.class).putExtra(Constants.ACTIVITY_PARAMETER_PATH, path)); @@ -72,19 +74,39 @@ public class GameUtils { Uri uri = Uri.parse(path); switch (uri.getScheme().toLowerCase()) { + case "folder": { + return FileUtils.getChild(data.folders.get(uri.getAuthority()).getPath(), uri.getPathSegments().get(0)); + } case "elf": { - return FileUtils.getResourcePath(Constants.RESOURCE_FOLDER_ELF)+"/"+uri.getAuthority(); + return FileUtils.getResourcePath(Constants.RESOURCE_FOLDER_ELF) + "/" + uri.getAuthority(); } } - return path; } - public static ArrayList getGames() { - return new ArrayList<>(data.games); + public static void refreshFolders() { + String[] keys = data.folders.keySet().toArray(new String[0]); + for (String key : keys) { + GamesFolder folder = data.folders.get(key); + if (!folder.isValid()){ + data.folders.remove(key); + } else { + folder.refresh(); + } + } + writeChanges(); } - private static void writeChanges() { + public static ArrayList getGames() { + ArrayList games = new ArrayList<>(); + games.addAll(data.games); + for (GamesFolder folder: data.folders.values()){ + games.addAll(folder.getGames()); + } + return games; + } + + public static void writeChanges() { parser.save(data); } @@ -117,7 +139,26 @@ public class GameUtils { return DEFAULT_ICON; } + public static GamesFolder[] getFolders() { + return data.folders.values().toArray(new GamesFolder[0]); + } + + public static void registerFolder(String path) { + if (!data.folders.containsKey(path)){ + GamesFolder folder = new GamesFolder(path); + data.folders.put(folder.getId(),folder); + folder.refresh(); + writeChanges(); + } + } + + public static void removeFolder(GamesFolder folder) { + data.folders.remove(folder.getId()); + writeChanges(); + } + private static class DataModel { public final List games = new ArrayList<>(); + public final HashMap folders = new HashMap<>(); } } diff --git a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/view/PandaGlRenderer.java b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/view/PandaGlRenderer.java index b4c1c4b9..ac8b4922 100644 --- a/src/pandroid/app/src/main/java/com/panda3ds/pandroid/view/PandaGlRenderer.java +++ b/src/pandroid/app/src/main/java/com/panda3ds/pandroid/view/PandaGlRenderer.java @@ -122,9 +122,7 @@ public class PandaGlRenderer implements GLSurfaceView.Renderer, ConsoleRenderer SMDH smdh = new SMDH(smdhData); Log.i(Constants.LOG_TAG, "Loaded rom SDMH"); Log.i(Constants.LOG_TAG, String.format("You are playing '%s' published by '%s'", smdh.getTitle(), smdh.getPublisher())); - GameMetadata game = GameUtils.getCurrentGame(); - GameUtils.removeGame(game); - GameUtils.addGame(GameMetadata.applySMDH(game, smdh)); + GameUtils.getCurrentGame().applySMDH(smdh); } PerformanceMonitor.initialize(getBackendName()); diff --git a/src/pandroid/app/src/main/res/drawable/ic_folder.xml b/src/pandroid/app/src/main/res/drawable/ic_folder.xml new file mode 100644 index 00000000..011a26ef --- /dev/null +++ b/src/pandroid/app/src/main/res/drawable/ic_folder.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/pandroid/app/src/main/res/layout/games_folder_about.xml b/src/pandroid/app/src/main/res/layout/games_folder_about.xml new file mode 100644 index 00000000..5fff79e5 --- /dev/null +++ b/src/pandroid/app/src/main/res/layout/games_folder_about.xml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/pandroid/app/src/main/res/values-pt-rBR/strings.xml b/src/pandroid/app/src/main/res/values-pt-rBR/strings.xml index a48c6999..2d2093e6 100644 --- a/src/pandroid/app/src/main/res/values-pt-rBR/strings.xml +++ b/src/pandroid/app/src/main/res/values-pt-rBR/strings.xml @@ -69,4 +69,10 @@ Manter porporção Disposições para as duas telas. Altere as disposições disponiveis para as telas do console + Clique para mudar + Mudar telas + Pastas usadas para importar os jogos + Pastas de jogos + Adicionar pasta + %d Jogos diff --git a/src/pandroid/app/src/main/res/values/strings.xml b/src/pandroid/app/src/main/res/values/strings.xml index 747b567e..aa5b742e 100644 --- a/src/pandroid/app/src/main/res/values/strings.xml +++ b/src/pandroid/app/src/main/res/values/strings.xml @@ -75,4 +75,10 @@ Change layout of console screens. Click to change Swap screen + Folders for auto import games + Games folders + Import folder + %d Games + Directory + Remove diff --git a/src/pandroid/app/src/main/res/xml/general_preference.xml b/src/pandroid/app/src/main/res/xml/general_preference.xml index 1d20c566..3a81768c 100644 --- a/src/pandroid/app/src/main/res/xml/general_preference.xml +++ b/src/pandroid/app/src/main/res/xml/general_preference.xml @@ -16,7 +16,12 @@ app:iconSpaceReserved="false"/> + \ No newline at end of file