[Scummvm-git-logs] scummvm master -> 038a13b0f9a5a8b9aa6f4faa196170e03ad02aa0

lephilousophe noreply at scummvm.org
Thu Apr 17 15:33:37 UTC 2025


This automated email contains information about 9 new commits which have been
pushed to the 'scummvm' repo located at https://api.github.com/repos/scummvm/scummvm .

Summary:
f68d539176 ANDROID: Upgrade Gadle and Android Gradle Plugin
5356618bac ANDROID: Remove superfluous argument
ddfe04e035 ANDROID: Check API version before using EXTRA_INITIAL_URI
e1391b03d2 ANDROID: Don't close the inner inflater stream
bbfb504b69 ANDROID: Refactor virtual path resolution
37104bb601 ANDROID: Various SAFFSTree improvements
b3531434d7 ANDROID: Improve selectWithNativeUI
97cf96532f ANDROID: Preserve entries order in Java INI parser
038a13b0f9 ANDROID: Allow users to backup and restore their configuration and saves


Commit: f68d539176a33ed2e1294b8d12d913dd7a17aa2f
    https://github.com/scummvm/scummvm/commit/f68d539176a33ed2e1294b8d12d913dd7a17aa2f
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-04-17T17:33:31+02:00

Commit Message:
ANDROID: Upgrade Gadle and Android Gradle Plugin

Also fix deprecation warnings.

Changed paths:
    dists/android/build.gradle
    dists/android/gradle/wrapper/gradle-wrapper.jar
    dists/android/gradle/wrapper/gradle-wrapper.properties
    dists/android/gradlew


diff --git a/dists/android/build.gradle b/dists/android/build.gradle
index dcf561ba1c9..4bcf42f39df 100644
--- a/dists/android/build.gradle
+++ b/dists/android/build.gradle
@@ -5,7 +5,7 @@ buildscript {
         mavenCentral()
     }
     dependencies {
-        classpath 'com.android.tools.build:gradle:8.7.3'
+        classpath 'com.android.tools.build:gradle:8.9.1'
     }
 }
 
@@ -30,10 +30,10 @@ tasks.withType(JavaCompile).configureEach {
 apply plugin: 'com.android.application'
 
 android {
-    compileSdk 35
-    ndkVersion "23.2.8568313"
+    compileSdk = 35
+    ndkVersion = "23.2.8568313"
 
-    namespace "org.scummvm.scummvm"
+    namespace = "org.scummvm.scummvm"
 
     defaultConfig {
         applicationId "org.scummvm.scummvm"
@@ -123,7 +123,7 @@ android {
         }
     }
     lint {
-        abortOnError false
+        abortOnError = false
     }
 
     if (project.hasProperty('splitAssets')) {
diff --git a/dists/android/gradle/wrapper/gradle-wrapper.jar b/dists/android/gradle/wrapper/gradle-wrapper.jar
index d64cd491770..a4b76b9530d 100644
Binary files a/dists/android/gradle/wrapper/gradle-wrapper.jar and b/dists/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/dists/android/gradle/wrapper/gradle-wrapper.properties b/dists/android/gradle/wrapper/gradle-wrapper.properties
index df97d72b8b9..37f853b1c84 100644
--- a/dists/android/gradle/wrapper/gradle-wrapper.properties
+++ b/dists/android/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
 distributionBase=GRADLE_USER_HOME
 distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
 networkTimeout=10000
 validateDistributionUrl=true
 zipStoreBase=GRADLE_USER_HOME
diff --git a/dists/android/gradlew b/dists/android/gradlew
index 1aa94a42690..f5feea6d6b1 100755
--- a/dists/android/gradlew
+++ b/dists/android/gradlew
@@ -15,6 +15,8 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 #
+# SPDX-License-Identifier: Apache-2.0
+#
 
 ##############################################################################
 #
@@ -55,7 +57,7 @@
 #       Darwin, MinGW, and NonStop.
 #
 #   (3) This script is generated from the Groovy template
-#       https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+#       https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
 #       within the Gradle project.
 #
 #       You can find Gradle at https://github.com/gradle/gradle/.
@@ -84,7 +86,8 @@ done
 # shellcheck disable=SC2034
 APP_BASE_NAME=${0##*/}
 # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
-APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
+' "$PWD" ) || exit
 
 # Use the maximum available, or set MAX_FD != -1 to use that value.
 MAX_FD=maximum


Commit: 5356618bac3b52919c48c7a7959a0225eff0bbcc
    https://github.com/scummvm/scummvm/commit/5356618bac3b52919c48c7a7959a0225eff0bbcc
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-04-17T17:33:31+02:00

Commit Message:
ANDROID: Remove superfluous argument

As getNewSAFTree always returns a tree, asking for a file makes no
sense.

Changed paths:
    backends/fs/android/android-saf-fs.cpp
    backends/platform/android/jni-android.cpp
    backends/platform/android/jni-android.h
    backends/platform/android/org/scummvm/scummvm/ScummVM.java
    backends/platform/android/org/scummvm/scummvm/ScummVMActivity.java


diff --git a/backends/fs/android/android-saf-fs.cpp b/backends/fs/android/android-saf-fs.cpp
index 28715d1c90f..fafd4e5b346 100644
--- a/backends/fs/android/android-saf-fs.cpp
+++ b/backends/fs/android/android-saf-fs.cpp
@@ -915,7 +915,7 @@ void AddSAFFakeNode::makeProxySAF() const {
 	}
 
 	// I18N: This may be displayed in the Android UI used to add a Storage Attach Framework authorization
-	jobject saftree = JNI::getNewSAFTree(true, true, "", _("Choose a new folder"));
+	jobject saftree = JNI::getNewSAFTree(true, "", _("Choose a new folder"));
 	if (!saftree) {
 		return;
 	}
diff --git a/backends/platform/android/jni-android.cpp b/backends/platform/android/jni-android.cpp
index 156e0cb290e..1092dc8162e 100644
--- a/backends/platform/android/jni-android.cpp
+++ b/backends/platform/android/jni-android.cpp
@@ -779,7 +779,7 @@ void JNI::create(JNIEnv *env, jobject self, jobject asset_manager,
 	FIND_METHOD(, deinitSurface, "()V");
 	FIND_METHOD(, eglVersion, "()I");
 	FIND_METHOD(, getNewSAFTree,
-	            "(ZZLjava/lang/String;Ljava/lang/String;)Lorg/scummvm/scummvm/SAFFSTree;");
+	            "(ZLjava/lang/String;Ljava/lang/String;)Lorg/scummvm/scummvm/SAFFSTree;");
 	FIND_METHOD(, getSAFTrees, "()[Lorg/scummvm/scummvm/SAFFSTree;");
 	FIND_METHOD(, findSAFTree, "(Ljava/lang/String;)Lorg/scummvm/scummvm/SAFFSTree;");
 
@@ -1069,14 +1069,14 @@ Common::Array<Common::String> JNI::getAllStorageLocations() {
 	return res;
 }
 
-jobject JNI::getNewSAFTree(bool folder, bool writable, const Common::String &initURI,
+jobject JNI::getNewSAFTree(bool writable, const Common::String &initURI,
                            const Common::String &prompt) {
 	JNIEnv *env = JNI::getEnv();
 	jstring javaInitURI = env->NewStringUTF(initURI.c_str());
 	jstring javaPrompt = env->NewStringUTF(prompt.c_str());
 
 	jobject tree = env->CallObjectMethod(_jobj, _MID_getNewSAFTree,
-	                                     folder, writable, javaInitURI, javaPrompt);
+	                                     writable, javaInitURI, javaPrompt);
 
 	if (env->ExceptionCheck()) {
 		LOGE("getNewSAFTree: error");
diff --git a/backends/platform/android/jni-android.h b/backends/platform/android/jni-android.h
index e2697b53788..90fea250c98 100644
--- a/backends/platform/android/jni-android.h
+++ b/backends/platform/android/jni-android.h
@@ -121,7 +121,7 @@ public:
 
 	static Common::Array<Common::String> getAllStorageLocations();
 
-	static jobject getNewSAFTree(bool folder, bool writable, const Common::String &initURI, const Common::String &prompt);
+	static jobject getNewSAFTree(bool writable, const Common::String &initURI, const Common::String &prompt);
 	static Common::Array<jobject> getSAFTrees();
 	static jobject findSAFTree(const Common::String &name);
 
diff --git a/backends/platform/android/org/scummvm/scummvm/ScummVM.java b/backends/platform/android/org/scummvm/scummvm/ScummVM.java
index a21af60e1d6..e32087afbef 100644
--- a/backends/platform/android/org/scummvm/scummvm/ScummVM.java
+++ b/backends/platform/android/org/scummvm/scummvm/ScummVM.java
@@ -96,7 +96,7 @@ public abstract class ScummVM implements SurfaceHolder.Callback,
 	abstract protected String[] getSysArchives();
 	abstract protected String[] getAllStorageLocations();
 	abstract protected String[] getAllStorageLocationsNoPermissionRequest();
-	abstract protected SAFFSTree getNewSAFTree(boolean folder, boolean write, String initialURI, String prompt);
+	abstract protected SAFFSTree getNewSAFTree(boolean write, String initialURI, String prompt);
 	abstract protected SAFFSTree[] getSAFTrees();
 	abstract protected SAFFSTree findSAFTree(String name);
 
diff --git a/backends/platform/android/org/scummvm/scummvm/ScummVMActivity.java b/backends/platform/android/org/scummvm/scummvm/ScummVMActivity.java
index 32f44cb8b3d..5e3699e0378 100644
--- a/backends/platform/android/org/scummvm/scummvm/ScummVMActivity.java
+++ b/backends/platform/android/org/scummvm/scummvm/ScummVMActivity.java
@@ -893,9 +893,9 @@ public class ScummVMActivity extends Activity implements OnKeyboardVisibilityLis
 
 		@Override
 		@RequiresApi(api = Build.VERSION_CODES.N)
-		protected SAFFSTree getNewSAFTree(boolean folder, boolean write, String initialURI, String prompt) {
+		protected SAFFSTree getNewSAFTree(boolean write, String initialURI, String prompt) {
 			Uri initialURI_ = Uri.parse(initialURI);
-			Uri uri = selectWithNativeUI(folder, write, initialURI_, prompt);
+			Uri uri = selectWithNativeUI(true, write, initialURI_, prompt);
 			if (uri == null) {
 				return null;
 			}


Commit: ddfe04e035bc2a2021959c82ad22511f88f4a9ff
    https://github.com/scummvm/scummvm/commit/ddfe04e035bc2a2021959c82ad22511f88f4a9ff
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-04-17T17:33:31+02:00

Commit Message:
ANDROID: Check API version before using EXTRA_INITIAL_URI

Changed paths:
    backends/platform/android/org/scummvm/scummvm/ScummVMActivity.java


diff --git a/backends/platform/android/org/scummvm/scummvm/ScummVMActivity.java b/backends/platform/android/org/scummvm/scummvm/ScummVMActivity.java
index 5e3699e0378..4c529ff3799 100644
--- a/backends/platform/android/org/scummvm/scummvm/ScummVMActivity.java
+++ b/backends/platform/android/org/scummvm/scummvm/ScummVMActivity.java
@@ -2281,7 +2281,7 @@ public class ScummVMActivity extends Activity implements OnKeyboardVisibilityLis
 			intent.addCategory(Intent.CATEGORY_OPENABLE);
 			intent.setType("*/*");
 		}
-		if (initialURI != null) {
+		if (initialURI != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
 			intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, initialURI);
 		}
 		if (prompt != null) {


Commit: e1391b03d23fa8144c85d44ea9c401dc2ca4848e
    https://github.com/scummvm/scummvm/commit/e1391b03d23fa8144c85d44ea9c401dc2ca4848e
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-04-17T17:33:31+02:00

Commit Message:
ANDROID: Don't close the inner inflater stream

Closing it makes an error when trying to recycle the Inflater.

Changed paths:
    backends/platform/android/org/scummvm/scummvm/zip/ZipFile.java


diff --git a/backends/platform/android/org/scummvm/scummvm/zip/ZipFile.java b/backends/platform/android/org/scummvm/scummvm/zip/ZipFile.java
index fb4c9a219ad..e0aef375c95 100644
--- a/backends/platform/android/org/scummvm/scummvm/zip/ZipFile.java
+++ b/backends/platform/android/org/scummvm/scummvm/zip/ZipFile.java
@@ -530,7 +530,14 @@ public class ZipFile implements ZipConstants, Closeable {
             if (closeRequested)
                 return;
             closeRequested = true;
-            super.close();
+            /* ScummVM-changed:
+                don't call InflaterInputStream.close as it closes the Inflater.
+                This doesn't happen in Android because they pass ownsInflater to false but this
+                function is hidden to us.
+                Directly call the underlying input stream close function instead.
+             */
+            //super.close();
+            in.close();
             synchronized (res.istreams) {
                 res.istreams.remove(this);
             }


Commit: bbfb504b696f28c177f740b4826ae054425b9230
    https://github.com/scummvm/scummvm/commit/bbfb504b696f28c177f740b4826ae054425b9230
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-04-17T17:33:31+02:00

Commit Message:
ANDROID: Refactor virtual path resolution

Makes it available to other uses as this will avoid code duplication.

Changed paths:
    backends/platform/android/org/scummvm/scummvm/SAFFSTree.java
    backends/platform/android/org/scummvm/scummvm/ShortcutCreatorActivity.java


diff --git a/backends/platform/android/org/scummvm/scummvm/SAFFSTree.java b/backends/platform/android/org/scummvm/scummvm/SAFFSTree.java
index 5c700a18d16..4392ea39108 100644
--- a/backends/platform/android/org/scummvm/scummvm/SAFFSTree.java
+++ b/backends/platform/android/org/scummvm/scummvm/SAFFSTree.java
@@ -144,6 +144,47 @@ public class SAFFSTree {
 		return _trees.get(name);
 	}
 
+	public static class PathResult {
+		public final SAFFSTree tree;
+		public final SAFFSNode node;
+
+		PathResult(SAFFSTree tree, SAFFSNode node) {
+			this.tree = tree;
+			this.node = node;
+		}
+	}
+
+	/**
+	 * Resolves a ScummVM virtual path to SAF objects if it's in the SAF domain.
+	 * Returns null otherwise and throws a FileNotFoundException if the SAF path doesn't exist.
+	 */
+	@RequiresApi(api = Build.VERSION_CODES.BASE)
+	public static PathResult fullPathToNode(Context context, String path) throws FileNotFoundException {
+		if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N ||
+			!path.startsWith("/saf/")) {
+			return null;
+		}
+
+		// This is a SAF fake mount point
+		int slash = path.indexOf('/', 5);
+		if (slash == -1) {
+			slash = path.length();
+		}
+		String treeName = path.substring(5, slash);
+		String innerPath = path.substring(slash);
+
+		SAFFSTree tree = SAFFSTree.findTree(context, treeName);
+		if (tree == null) {
+			throw new FileNotFoundException();
+		}
+		SAFFSNode node = tree.pathToNode(innerPath);
+		if (node == null) {
+			throw new FileNotFoundException();
+		}
+
+		return new PathResult(tree, node);
+	}
+
 	@RequiresApi(api = Build.VERSION_CODES.BASE)
 	public static void clearCaches() {
 		if (_trees == null) {
diff --git a/backends/platform/android/org/scummvm/scummvm/ShortcutCreatorActivity.java b/backends/platform/android/org/scummvm/scummvm/ShortcutCreatorActivity.java
index b0931031024..4bc878eebca 100644
--- a/backends/platform/android/org/scummvm/scummvm/ShortcutCreatorActivity.java
+++ b/backends/platform/android/org/scummvm/scummvm/ShortcutCreatorActivity.java
@@ -176,8 +176,15 @@ public class ShortcutCreatorActivity extends Activity implements CompatHelpers.S
 	}
 
 	static private FileInputStream[] openFiles(Context context, File basePath, String regex) {
-		if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N ||
-			!basePath.getPath().startsWith("/saf/")) {
+		SAFFSTree.PathResult pr;
+		try {
+			pr = SAFFSTree.fullPathToNode(context, basePath.getPath());
+		} catch (FileNotFoundException e) {
+			return new FileInputStream[0];
+		}
+
+		// This version check is only to make Android Studio linter happy
+		if (pr == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
 			// This is a standard filesystem path
 			File[] children = basePath.listFiles((dir, name) -> name.matches(regex));
 			if (children == null) {
@@ -192,24 +199,9 @@ public class ShortcutCreatorActivity extends Activity implements CompatHelpers.S
 			}
 			return ret;
 		}
-		// This is a SAF fake mount point
-		String baseName = basePath.getPath();
-		int slash = baseName.indexOf('/', 5);
-		if (slash == -1) {
-			slash = baseName.length();
-		}
-		String treeName = baseName.substring(5, slash);
-		String path = baseName.substring(slash);
 
-		SAFFSTree tree = SAFFSTree.findTree(context, treeName);
-		if (tree == null) {
-			return new FileInputStream[0];
-		}
-		SAFFSTree.SAFFSNode node = tree.pathToNode(path);
-		if (node == null) {
-			return new FileInputStream[0];
-		}
-		SAFFSTree.SAFFSNode[] children = tree.getChildren(node);
+		// This is a SAF fake mount point
+		SAFFSTree.SAFFSNode[] children = pr.tree.getChildren(pr.node);
 		if (children == null) {
 			return new FileInputStream[0];
 		}
@@ -224,7 +216,7 @@ public class ShortcutCreatorActivity extends Activity implements CompatHelpers.S
 			if (!component.matches(regex)) {
 				continue;
 			}
-			ParcelFileDescriptor pfd = tree.createFileDescriptor(child, "r");
+			ParcelFileDescriptor pfd = pr.tree.createFileDescriptor(child, "r");
 			if (pfd == null) {
 				continue;
 			}


Commit: 37104bb60106a183a3c45a37eb319a377f0e37fc
    https://github.com/scummvm/scummvm/commit/37104bb60106a183a3c45a37eb319a377f0e37fc
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-04-17T17:33:31+02:00

Commit Message:
ANDROID: Various SAFFSTree improvements

These new functions will be needed soon.
The loadSAFTrees function could be dangerous and should not be used from
outside SAFFSTree.

Changed paths:
    backends/fs/android/android-saf-fs.cpp
    backends/platform/android/org/scummvm/scummvm/SAFFSTree.java
    backends/platform/android/org/scummvm/scummvm/ShortcutCreatorActivity.java


diff --git a/backends/fs/android/android-saf-fs.cpp b/backends/fs/android/android-saf-fs.cpp
index fafd4e5b346..527fd98247f 100644
--- a/backends/fs/android/android-saf-fs.cpp
+++ b/backends/fs/android/android-saf-fs.cpp
@@ -125,7 +125,7 @@ void AndroidSAFFilesystemNode::initJNI() {
 	FIND_STATIC_METHOD(, refToNode, "(J)" SAFFSNodeSig);
 
 	FIND_METHOD(, getTreeId, "()Ljava/lang/String;");
-	FIND_METHOD(, pathToNode, "(Ljava/lang/String;)" SAFFSNodeSig);
+	FIND_METHOD(, pathToNode, "(Ljava/lang/String;Z)" SAFFSNodeSig);
 	FIND_METHOD(, getChildren, "(J)[" SAFFSNodeSig);
 	FIND_METHOD(, getChild, "(JLjava/lang/String;)" SAFFSNodeSig);
 	FIND_METHOD(, createDirectory, "(JLjava/lang/String;)" SAFFSNodeSig);
@@ -287,7 +287,7 @@ AndroidSAFFilesystemNode *AndroidSAFFilesystemNode::makeFromPath(const Common::S
 
 	jstring pathObj = env->NewStringUTF(realPath.c_str());
 
-	jobject node = env->CallObjectMethod(safTree, _MID_pathToNode, pathObj);
+	jobject node = env->CallObjectMethod(safTree, _MID_pathToNode, pathObj, false);
 
 	env->DeleteLocalRef(pathObj);
 
@@ -332,7 +332,7 @@ AndroidSAFFilesystemNode *AndroidSAFFilesystemNode::makeFromPath(const Common::S
 
 	pathObj = env->NewStringUTF(realPath.c_str());
 
-	node = env->CallObjectMethod(safTree, _MID_pathToNode, pathObj);
+	node = env->CallObjectMethod(safTree, _MID_pathToNode, pathObj, false);
 
 	env->DeleteLocalRef(pathObj);
 
diff --git a/backends/platform/android/org/scummvm/scummvm/SAFFSTree.java b/backends/platform/android/org/scummvm/scummvm/SAFFSTree.java
index 4392ea39108..f27f05a467c 100644
--- a/backends/platform/android/org/scummvm/scummvm/SAFFSTree.java
+++ b/backends/platform/android/org/scummvm/scummvm/SAFFSTree.java
@@ -99,7 +99,7 @@ public class SAFFSTree {
 		}
 	}
 
-	public static void loadSAFTrees(Context context) {
+	private static void loadSAFTrees(Context context) {
 		final ContentResolver resolver = context.getContentResolver();
 
 		// As this function is called before starting to emit nodes,
@@ -159,7 +159,7 @@ public class SAFFSTree {
 	 * Returns null otherwise and throws a FileNotFoundException if the SAF path doesn't exist.
 	 */
 	@RequiresApi(api = Build.VERSION_CODES.BASE)
-	public static PathResult fullPathToNode(Context context, String path) throws FileNotFoundException {
+	public static PathResult fullPathToNode(Context context, String path, boolean createDirIfNotExists) throws FileNotFoundException {
 		if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N ||
 			!path.startsWith("/saf/")) {
 			return null;
@@ -177,7 +177,7 @@ public class SAFFSTree {
 		if (tree == null) {
 			throw new FileNotFoundException();
 		}
-		SAFFSNode node = tree.pathToNode(innerPath);
+		SAFFSNode node = tree.pathToNode(innerPath, createDirIfNotExists);
 		if (node == null) {
 			throw new FileNotFoundException();
 		}
@@ -315,6 +315,12 @@ public class SAFFSTree {
 	public String getTreeId() {
 		return Uri.encode(DocumentsContract.getTreeDocumentId(_treeUri));
 	}
+	public String getTreeName() {
+		return _treeName;
+	}
+	public Uri getTreeDocumentUri() {
+		return DocumentsContract.buildDocumentUriUsingTree(_treeUri, _root._documentId);
+	}
 
 	private void clearCache() {
 		ArrayDeque<SAFFSNode> stack = new ArrayDeque<>();
@@ -334,7 +340,7 @@ public class SAFFSTree {
 		}
 	}
 
-	public SAFFSNode pathToNode(String path) {
+	public SAFFSNode pathToNode(String path, boolean createDirIfNotExists) {
 		String[] components = path.split("/");
 
 		SAFFSNode node = _root;
@@ -349,10 +355,14 @@ public class SAFFSTree {
 				continue;
 			}
 
-			node = getChild(node, component);
-			if (node == null) {
+			SAFFSNode newNode = getChild(node, component);
+			if (newNode == null && createDirIfNotExists) {
+				newNode = createDirectory(node, component);
+			}
+			if (newNode == null) {
 				return null;
 			}
+			node = newNode;
 		}
 		return node;
 	}
diff --git a/backends/platform/android/org/scummvm/scummvm/ShortcutCreatorActivity.java b/backends/platform/android/org/scummvm/scummvm/ShortcutCreatorActivity.java
index 4bc878eebca..02f0cd4ad7c 100644
--- a/backends/platform/android/org/scummvm/scummvm/ShortcutCreatorActivity.java
+++ b/backends/platform/android/org/scummvm/scummvm/ShortcutCreatorActivity.java
@@ -178,7 +178,7 @@ public class ShortcutCreatorActivity extends Activity implements CompatHelpers.S
 	static private FileInputStream[] openFiles(Context context, File basePath, String regex) {
 		SAFFSTree.PathResult pr;
 		try {
-			pr = SAFFSTree.fullPathToNode(context, basePath.getPath());
+			pr = SAFFSTree.fullPathToNode(context, basePath.getPath(), false);
 		} catch (FileNotFoundException e) {
 			return new FileInputStream[0];
 		}


Commit: b3531434d708c00b50ebdcd9d1023f46c979f25e
    https://github.com/scummvm/scummvm/commit/b3531434d708c00b50ebdcd9d1023f46c979f25e
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-04-17T17:33:31+02:00

Commit Message:
ANDROID: Improve selectWithNativeUI

Allow to specify a MIME type and a default file name.
Also use the proper Inten action to save a file.

Changed paths:
    backends/platform/android/org/scummvm/scummvm/ScummVMActivity.java


diff --git a/backends/platform/android/org/scummvm/scummvm/ScummVMActivity.java b/backends/platform/android/org/scummvm/scummvm/ScummVMActivity.java
index 4c529ff3799..b0a4d11d163 100644
--- a/backends/platform/android/org/scummvm/scummvm/ScummVMActivity.java
+++ b/backends/platform/android/org/scummvm/scummvm/ScummVMActivity.java
@@ -895,7 +895,7 @@ public class ScummVMActivity extends Activity implements OnKeyboardVisibilityLis
 		@RequiresApi(api = Build.VERSION_CODES.N)
 		protected SAFFSTree getNewSAFTree(boolean write, String initialURI, String prompt) {
 			Uri initialURI_ = Uri.parse(initialURI);
-			Uri uri = selectWithNativeUI(true, write, initialURI_, prompt);
+			Uri uri = selectWithNativeUI(true, write, initialURI_, prompt, null, null);
 			if (uri == null) {
 				return null;
 			}
@@ -2271,15 +2271,22 @@ public class ScummVMActivity extends Activity implements OnKeyboardVisibilityLis
 	// - The Android/data/ directory and all subdirectories.
 	// - The Android/obb/ directory and all subdirectories.
 	@RequiresApi(api = Build.VERSION_CODES.N)
-	public Uri selectWithNativeUI(boolean folder, boolean write, Uri initialURI, String prompt) {
+	public Uri selectWithNativeUI(boolean folder, boolean write, Uri initialURI, String prompt, String mimeType, String fileName) {
 		// Choose a directory using the system's folder picker.
 		Intent intent;
 		if (folder) {
 			intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
 		} else {
-			intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
+			if (write) {
+				intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
+				if (fileName != null) {
+					intent.putExtra(Intent.EXTRA_TITLE, fileName);
+				}
+			} else {
+				intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
+			}
 			intent.addCategory(Intent.CATEGORY_OPENABLE);
-			intent.setType("*/*");
+			intent.setType(mimeType);
 		}
 		if (initialURI != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
 			intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, initialURI);


Commit: 97cf96532fe83c2c24e935b3c07857bd2a564e9f
    https://github.com/scummvm/scummvm/commit/97cf96532fe83c2c24e935b3c07857bd2a564e9f
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-04-17T17:33:31+02:00

Commit Message:
ANDROID: Preserve entries order in Java INI parser

This will make sure every domain is always enumerated in the same order.

Changed paths:
    backends/platform/android/org/scummvm/scummvm/INIParser.java


diff --git a/backends/platform/android/org/scummvm/scummvm/INIParser.java b/backends/platform/android/org/scummvm/scummvm/INIParser.java
index 3aed5e06660..ede8745f268 100644
--- a/backends/platform/android/org/scummvm/scummvm/INIParser.java
+++ b/backends/platform/android/org/scummvm/scummvm/INIParser.java
@@ -6,7 +6,7 @@ import java.io.BufferedReader;
 import java.io.File;
 import java.io.IOException;
 import java.io.Reader;
-import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.Map;
 
 /**
@@ -20,7 +20,7 @@ public class INIParser {
 	}
 
 	public static Map<String, Map<String, String>> parse(Reader reader) throws IOException {
-		Map<String, Map<String, String>> ret = new HashMap<>();
+		Map<String, Map<String, String>> ret = new LinkedHashMap<>();
 		BufferedReader lineReader = new BufferedReader(reader);
 		Map<String, String> domain = null;
 		int lineno = 0;
@@ -64,7 +64,7 @@ public class INIParser {
 				}
 
 				String domainName = line.substring(1, i);
-				domain = new HashMap<>();
+				domain = new LinkedHashMap<>();
 				ret.put(domainName, domain);
 
 				continue;


Commit: 038a13b0f9a5a8b9aa6f4faa196170e03ad02aa0
    https://github.com/scummvm/scummvm/commit/038a13b0f9a5a8b9aa6f4faa196170e03ad02aa0
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-04-17T17:33:31+02:00

Commit Message:
ANDROID: Allow users to backup and restore their configuration and saves

This also try to restore the SAF authorizations registered in the
backup.
With this, a user can wipe the application and reinstall it without too
much hassle.

Changed paths:
  A backends/platform/android/org/scummvm/scummvm/BackupManager.java
    backends/platform/android/jni-android.cpp
    backends/platform/android/jni-android.h
    backends/platform/android/options.cpp
    backends/platform/android/org/scummvm/scummvm/ScummVM.java
    backends/platform/android/org/scummvm/scummvm/ScummVMActivity.java


diff --git a/backends/platform/android/jni-android.cpp b/backends/platform/android/jni-android.cpp
index 1092dc8162e..ccfd57d0a05 100644
--- a/backends/platform/android/jni-android.cpp
+++ b/backends/platform/android/jni-android.cpp
@@ -107,6 +107,8 @@ jmethodID JNI::_MID_eglVersion = 0;
 jmethodID JNI::_MID_getNewSAFTree = 0;
 jmethodID JNI::_MID_getSAFTrees = 0;
 jmethodID JNI::_MID_findSAFTree = 0;
+jmethodID JNI::_MID_exportBackup = 0;
+jmethodID JNI::_MID_importBackup = 0;
 
 jmethodID JNI::_MID_EGL10_eglSwapBuffers = 0;
 
@@ -782,6 +784,8 @@ void JNI::create(JNIEnv *env, jobject self, jobject asset_manager,
 	            "(ZLjava/lang/String;Ljava/lang/String;)Lorg/scummvm/scummvm/SAFFSTree;");
 	FIND_METHOD(, getSAFTrees, "()[Lorg/scummvm/scummvm/SAFFSTree;");
 	FIND_METHOD(, findSAFTree, "(Ljava/lang/String;)Lorg/scummvm/scummvm/SAFFSTree;");
+	FIND_METHOD(, exportBackup, "(Ljava/lang/String;)I");
+	FIND_METHOD(, importBackup, "(Ljava/lang/String;Ljava/lang/String;)I");
 
 	_jobj_egl = env->NewGlobalRef(egl);
 	_jobj_egl_display = env->NewGlobalRef(egl_display);
@@ -1140,3 +1144,53 @@ jobject JNI::findSAFTree(const Common::String &name) {
 
 	return tree;
 }
+
+int JNI::exportBackup(const Common::U32String &prompt) {
+	JNIEnv *env = JNI::getEnv();
+
+	jstring promptObj = convertToJString(env, prompt);
+
+	jint result = env->CallIntMethod(_jobj, _MID_exportBackup, promptObj);
+
+	env->DeleteLocalRef(promptObj);
+
+	if (env->ExceptionCheck()) {
+		LOGE("exportBackup: error");
+
+		env->ExceptionDescribe();
+		env->ExceptionClear();
+
+		// BackupManager.ERROR_INVALID_BACKUP
+		return -1;
+	}
+
+	return result;
+}
+
+int JNI::importBackup(const Common::U32String &prompt, const Common::String &path) {
+	JNIEnv *env = JNI::getEnv();
+
+	jstring promptObj = convertToJString(env, prompt);
+
+	jstring pathObj = nullptr;
+	if (!path.empty()) {
+		pathObj = env->NewStringUTF(path.c_str());
+	}
+
+	jint result = env->CallIntMethod(_jobj, _MID_importBackup, promptObj, pathObj);
+
+	env->DeleteLocalRef(pathObj);
+	env->DeleteLocalRef(promptObj);
+
+	if (env->ExceptionCheck()) {
+		LOGE("importBackup: error");
+
+		env->ExceptionDescribe();
+		env->ExceptionClear();
+
+		// BackupManager.ERROR_INVALID_BACKUP
+		return -1;
+	}
+
+	return result;
+}
diff --git a/backends/platform/android/jni-android.h b/backends/platform/android/jni-android.h
index 90fea250c98..bf486a927e4 100644
--- a/backends/platform/android/jni-android.h
+++ b/backends/platform/android/jni-android.h
@@ -125,6 +125,9 @@ public:
 	static Common::Array<jobject> getSAFTrees();
 	static jobject findSAFTree(const Common::String &name);
 
+	static int exportBackup(const Common::U32String &prompt);
+	static int importBackup(const Common::U32String &prompt, const Common::String &path);
+
 private:
 	static pthread_key_t _env_tls;
 
@@ -168,6 +171,8 @@ private:
 	static jmethodID _MID_getNewSAFTree;
 	static jmethodID _MID_getSAFTrees;
 	static jmethodID _MID_findSAFTree;
+	static jmethodID _MID_exportBackup;
+	static jmethodID _MID_importBackup;
 
 	static jmethodID _MID_EGL10_eglSwapBuffers;
 
diff --git a/backends/platform/android/options.cpp b/backends/platform/android/options.cpp
index f1bd868c864..7bdcc945049 100644
--- a/backends/platform/android/options.cpp
+++ b/backends/platform/android/options.cpp
@@ -41,7 +41,9 @@
 #include "backends/fs/android/android-fs-factory.h"
 #include "backends/fs/android/android-saf-fs.h"
 
+#include "gui/browser.h"
 #include "gui/gui-manager.h"
+#include "gui/message.h"
 #include "gui/ThemeEval.h"
 #include "gui/widget.h"
 #include "gui/widgets/list.h"
@@ -51,6 +53,8 @@
 
 enum {
 	kRemoveCmd = 'RemS',
+	kExportBackupCmd = 'ExpD',
+	kImportBackupCmd = 'ImpD',
 };
 
 class AndroidOptionsWidget final : public GUI::OptionsContainerWidget {
@@ -183,10 +187,14 @@ AndroidOptionsWidget::AndroidOptionsWidget(GuiObject *boss, const Common::String
 	_orientationGamesPopUp->appendEntry(_("Portrait"), kOrientationPortrait);
 	_orientationGamesPopUp->appendEntry(_("Landscape"), kOrientationLandscape);
 
-	if (inAppDomain && AndroidFilesystemFactory::instance().hasSAF()) {
-		// Only show this checkbox in Options (via Options... in the launcher), and not at game domain level (via Edit Game...)
-		// I18N: This button opens a list of all folders added for Android Storage Attached Framework
-		(new GUI::ButtonWidget(widgetsBoss(), "AndroidOptionsDialog.ForgetSAFButton", _("Remove folder authorizations..."), Common::U32String(), kRemoveCmd))->setTarget(this);
+	if (inAppDomain) {
+		// Only show these buttons in Options (via Options... in the launcher), and not at game domain level (via Edit Game...)
+		(new GUI::ButtonWidget(widgetsBoss(), "AndroidOptionsDialog.ExportDataButton", _("Export backup"), _("Export a backup of the configuration and save files"), kExportBackupCmd))->setTarget(this);
+		(new GUI::ButtonWidget(widgetsBoss(), "AndroidOptionsDialog.ImportDataButton", _("Import backup"), _("Import a previously exported backup file"), kImportBackupCmd))->setTarget(this);
+		if (AndroidFilesystemFactory::instance().hasSAF()) {
+			// I18N: This button opens a list of all folders added for Android Storage Attached Framework
+			(new GUI::ButtonWidget(widgetsBoss(), "AndroidOptionsDialog.ForgetSAFButton", _("Remove folder authorizations..."), Common::U32String(), kRemoveCmd))->setTarget(this);
+		}
 	}
 }
 
@@ -234,8 +242,12 @@ void AndroidOptionsWidget::defineLayout(GUI::ThemeEval &layouts, const Common::S
 			.addWidget("OGames", "PopUp")
 		.closeLayout();
 
-	if (inAppDomain && AndroidFilesystemFactory::instance().hasSAF()) {
-		layouts.addWidget("ForgetSAFButton", "WideButton");
+	if (inAppDomain) {
+		layouts.addWidget("ExportDataButton", "WideButton");
+		layouts.addWidget("ImportDataButton", "WideButton");
+		if (AndroidFilesystemFactory::instance().hasSAF()) {
+			layouts.addWidget("ForgetSAFButton", "WideButton");
+		}
 	}
 	layouts.closeLayout()
 	    .closeDialog();
@@ -251,6 +263,60 @@ void AndroidOptionsWidget::handleCommand(GUI::CommandSender *sender, uint32 cmd,
 		removeDlg.runModal();
 		break;
 	}
+	case kExportBackupCmd:
+	{
+		Common::U32String prompt(_("Select backup destination"));
+		int ret = JNI::exportBackup(prompt);
+		if (ret == 1) {
+			// BackupManager.ERROR_CANCELLED
+			break;
+		}
+
+		if (ret == 0 && AndroidFilesystemFactory::instance().hasSAF()) {
+			prompt = _("The backup has been saved successfully.");
+		} else if (ret == 0) {
+			prompt = _("The backup has been saved successfully to the Downloads folder.");
+		} else if (ret == -2) {
+			prompt = _("The game saves couldn't be backed up");
+		} else {
+			prompt = _("An error occured while saving the backup.");
+		}
+		g_system->displayMessageOnOSD(prompt);
+		break;
+	}
+	case kImportBackupCmd:
+	{
+		GUI::MessageDialog alert(_("Restoring a backup will erase the current configuration and overwrite existing saves. Do you want to proceed?"), _("Proceed"), _("Cancel"));
+		if (alert.runModal() != GUI::kMessageOK) {
+			break;
+		}
+
+		Common::U32String prompt(_("Select backup file"));
+		Common::Path path;
+		if (!AndroidFilesystemFactory::instance().hasSAF()) {
+			GUI::BrowserDialog browser(prompt, false);
+			if (browser.runModal() <= 0) {
+				break;
+			}
+
+			path = browser.getResult().getPath();
+		}
+		int ret = JNI::importBackup(prompt, path.toString());
+		if (ret == 1) {
+			// BackupManager.ERROR_CANCELLED
+			break;
+		}
+
+		if (ret == 0) {
+			prompt = _("The backup has been restored successfully.");
+		} else if (ret == -2) {
+			prompt = _("The game saves couldn't be backed up");
+		} else {
+			prompt = _("An error occured while restoring the backup.");
+		}
+		g_system->displayMessageOnOSD(prompt);
+		break;
+	}
 	default:
 		GUI::OptionsContainerWidget::handleCommand(sender, cmd, data);
 	}
diff --git a/backends/platform/android/org/scummvm/scummvm/BackupManager.java b/backends/platform/android/org/scummvm/scummvm/BackupManager.java
new file mode 100644
index 00000000000..4c9d8a978bf
--- /dev/null
+++ b/backends/platform/android/org/scummvm/scummvm/BackupManager.java
@@ -0,0 +1,519 @@
+package org.scummvm.scummvm;
+
+import android.content.Context;
+import android.net.Uri;
+import android.os.Build;
+import android.os.ParcelFileDescriptor;
+import android.util.Log;
+
+import androidx.annotation.RequiresApi;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.FileReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.OutputStream;
+import java.util.Arrays;
+import java.util.Enumeration;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+import org.scummvm.scummvm.zip.ZipFile;
+
+public class BackupManager {
+	public static final int ERROR_CANCELLED = 1;
+	public static final int ERROR_NO_ERROR = 0;
+	public static final int ERROR_INVALID_BACKUP = -1;
+	public static final int ERROR_INVALID_SAVES = -2;
+
+	@RequiresApi(api = Build.VERSION_CODES.N)
+	public static int exportBackup(Context context, Uri output) {
+		try (ParcelFileDescriptor pfd = context.getContentResolver().openFileDescriptor(output, "wt")) {
+			if (pfd == null) {
+				return ERROR_INVALID_BACKUP;
+			}
+			return exportBackup(context, new FileOutputStream(pfd.getFileDescriptor()));
+		} catch(FileNotFoundException ignored) {
+			return ERROR_INVALID_BACKUP;
+		} catch(IOException ignored) {
+			return ERROR_INVALID_BACKUP;
+		}
+	}
+
+	public static int exportBackup(Context context, File output) {
+		try {
+			return exportBackup(context, new FileOutputStream(output, false));
+		} catch(FileNotFoundException ignored) {
+			return ERROR_INVALID_BACKUP;
+		}
+	}
+
+	@RequiresApi(api = Build.VERSION_CODES.N)
+	public static int importBackup(ScummVMActivity context, Uri input) {
+		try (ParcelFileDescriptor pfd = context.getContentResolver().openFileDescriptor(input, "r")) {
+			if (pfd == null) {
+				return ERROR_INVALID_BACKUP;
+			}
+			return importBackup(context, new FileInputStream(pfd.getFileDescriptor()));
+		} catch(FileNotFoundException ignored) {
+			return ERROR_INVALID_BACKUP;
+		} catch(IOException ignored) {
+			return ERROR_INVALID_BACKUP;
+		}
+	}
+
+	public static int importBackup(ScummVMActivity context, File input) {
+		try {
+			return importBackup(context, new FileInputStream(input));
+		} catch(FileNotFoundException ignored) {
+			return ERROR_INVALID_BACKUP;
+		}
+	}
+
+	private static int exportBackup(Context context, FileOutputStream output) {
+		File configuration = new File(context.getFilesDir(), "scummvm.ini");
+
+		Map<String, Map<String, String>> parsedIniMap;
+		try (FileReader reader = new FileReader(configuration)) {
+			parsedIniMap = INIParser.parse(reader);
+		} catch(FileNotFoundException ignored) {
+			parsedIniMap = null;
+		} catch(IOException ignored) {
+			parsedIniMap = null;
+		}
+
+		if (parsedIniMap == null) {
+			return ERROR_INVALID_BACKUP;
+		}
+
+		ZipOutputStream zos = new ZipOutputStream(output);
+
+		try (FileInputStream stream = new FileInputStream(configuration)) {
+			ZipEntry entry = new ZipEntry("scummvm.ini");
+			entry.setSize(configuration.length());
+			entry.setTime(configuration.lastModified());
+
+			zos.putNextEntry(entry);
+			copyStream(zos, stream);
+			zos.closeEntry();
+		} catch(FileNotFoundException ignored) {
+			return ERROR_INVALID_BACKUP;
+		} catch(IOException ignored) {
+			return ERROR_INVALID_BACKUP;
+		}
+
+		try {
+			ZipEntry entry;
+			if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+				entry = new ZipEntry("saf");
+				zos.putNextEntry(entry);
+				if (!exportTrees(context, zos)) {
+					return ERROR_INVALID_BACKUP;
+				}
+				zos.closeEntry();
+			}
+
+			entry = new ZipEntry("saves/");
+			zos.putNextEntry(entry);
+			zos.closeEntry();
+		} catch(FileNotFoundException ignored) {
+			return ERROR_INVALID_BACKUP;
+		} catch(IOException ignored) {
+			return ERROR_INVALID_BACKUP;
+		}
+
+		File savesPath = INIParser.getPath(parsedIniMap, "scummvm", "savepath",
+			new File(context.getFilesDir(), "saves"));
+		boolean ret = exportSaves(context, savesPath, zos, "saves/");
+		if (!ret) {
+			try {
+				zos.close();
+			} catch(IOException ignored) {
+			}
+			return ERROR_INVALID_BACKUP;
+		}
+
+		HashSet<File> savesPaths = new HashSet<>();
+		savesPaths.add(savesPath);
+
+		int sectionId = -1;
+		for (String section : parsedIniMap.keySet()) {
+			sectionId++;
+			if ("scummvm".equals(section)) {
+				continue;
+			}
+
+			savesPath = INIParser.getPath(parsedIniMap, section, "savepath", null);
+			if (savesPath == null) {
+				continue;
+			}
+
+			if (savesPaths.contains(savesPath)) {
+				continue;
+			}
+			savesPaths.add(savesPath);
+
+			String folderName = "saves-" + sectionId + "/";
+			ZipEntry entry = new ZipEntry(folderName);
+			try {
+				zos.putNextEntry(entry);
+				zos.closeEntry();
+			} catch(IOException ignored) {
+				return ERROR_INVALID_BACKUP;
+			}
+
+			ret = exportSaves(context, savesPath, zos, folderName);
+			if (!ret) {
+				break;
+			}
+		}
+
+		try {
+			zos.close();
+		} catch(IOException ignored) {
+			return ERROR_INVALID_BACKUP;
+		}
+
+		return ret ? ERROR_NO_ERROR : ERROR_INVALID_SAVES;
+	}
+
+	@RequiresApi(api = Build.VERSION_CODES.N)
+	static private boolean exportTrees(Context context, ZipOutputStream zos) throws IOException {
+		ObjectOutputStream oos = new ObjectOutputStream(zos);
+		// Version 1
+		oos.writeInt(1);
+
+		SAFFSTree[] trees = SAFFSTree.getTrees(context);
+
+		oos.writeInt(trees.length);
+		for (SAFFSTree tree : trees) {
+			oos.writeObject(tree.getTreeName());
+			oos.writeObject(tree.getTreeId());
+			oos.writeObject(tree.getTreeDocumentUri().toString());
+		}
+
+		// Don't close as it would close the underlying ZipOutputStream
+		oos.flush();
+
+		return true;
+	}
+
+	static private boolean exportSaves(Context context, File folder, ZipOutputStream zos, String folderName) {
+		SAFFSTree.PathResult pr;
+		try {
+			pr = SAFFSTree.fullPathToNode(context, folder.getPath(), false);
+		} catch (FileNotFoundException e) {
+			return false;
+		}
+
+		// This version check is only to make Android Studio linter happy
+		if (pr == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
+			// This is a standard filesystem path
+			File[] children = folder.listFiles();
+			if (children == null) {
+				return true;
+			}
+			Arrays.sort(children);
+			for (File f: children) {
+				if ("timestamps".equals(f.getName())) {
+					continue;
+				}
+				try (FileInputStream stream = new FileInputStream(f)) {
+					ZipEntry entry = new ZipEntry(folderName + f.getName());
+					entry.setSize(f.length());
+					entry.setTime(f.lastModified());
+
+					zos.putNextEntry(entry);
+					copyStream(zos, stream);
+					zos.closeEntry();
+				} catch(FileNotFoundException ignored) {
+					return false;
+				} catch(IOException ignored) {
+					return false;
+				}
+			}
+			return true;
+		}
+
+		// This is a SAF fake mount point
+		SAFFSTree.SAFFSNode[] children = pr.tree.getChildren(pr.node);
+		if (children == null) {
+			return false;
+		}
+		Arrays.sort(children);
+
+		for (SAFFSTree.SAFFSNode child : children) {
+			if ((child._flags & SAFFSTree.SAFFSNode.DIRECTORY) != 0) {
+				continue;
+			}
+			String component = child._path.substring(child._path.lastIndexOf('/') + 1);
+			if ("timestamps".equals(component)) {
+				continue;
+			}
+			try (ParcelFileDescriptor pfd = pr.tree.createFileDescriptor(child, "r")) {
+				ZipEntry entry = new ZipEntry(folderName + component);
+
+				zos.putNextEntry(entry);
+				copyStream(zos, new FileInputStream(pfd.getFileDescriptor()));
+				zos.closeEntry();
+			} catch(FileNotFoundException ignored) {
+				return false;
+			} catch(IOException ignored) {
+				return false;
+			}
+		}
+		return true;
+	}
+
+	private static int importBackup(ScummVMActivity context, FileInputStream input) {
+		ZipFile zf;
+		try {
+			zf = new ZipFile(input);
+		} catch(IOException ignored) {
+			return ERROR_INVALID_BACKUP;
+		}
+
+		// Load configuration
+		org.scummvm.scummvm.zip.ZipEntry ze = zf.getEntry("scummvm.ini");
+		if (ze == null) {
+			// No configuration file, not a backup
+			return ERROR_INVALID_BACKUP;
+		}
+
+		// Avoid using tmp suffix as it's used by atomic file support
+		File configurationTmp = new File(context.getFilesDir(), "scummvm.ini.tmp2");
+
+		try (FileOutputStream os = new FileOutputStream(configurationTmp);
+			InputStream is = zf.getInputStream(ze)) {
+			copyStream(os, is);
+		} catch(FileNotFoundException ignored) {
+			return ERROR_INVALID_BACKUP;
+		} catch(IOException ignored) {
+			return ERROR_INVALID_BACKUP;
+		}
+
+		// Load the new configuration to know where to put saves
+		Map<String, Map<String, String>> parsedIniMap;
+		try (FileReader reader = new FileReader(configurationTmp)) {
+			parsedIniMap = INIParser.parse(reader);
+		} catch(FileNotFoundException ignored) {
+			parsedIniMap = null;
+		} catch(IOException ignored) {
+			parsedIniMap = null;
+		}
+
+		if (parsedIniMap == null) {
+			configurationTmp.delete();
+			return ERROR_INVALID_BACKUP;
+		}
+
+		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+			// Restore the missing SAF trees
+			ze = zf.getEntry("saf");
+			if (ze == null) {
+				// No configuration file, not a backup
+				return ERROR_INVALID_BACKUP;
+			}
+			try (InputStream is = zf.getInputStream(ze)) {
+				if (importTrees(context, is) == ERROR_INVALID_BACKUP) {
+					// Try to continue except obvious error
+					return ERROR_INVALID_BACKUP;
+				}
+			} catch(FileNotFoundException ignored) {
+				return ERROR_INVALID_BACKUP;
+			} catch(IOException ignored) {
+				return ERROR_INVALID_BACKUP;
+			} catch (ClassNotFoundException e) {
+				return ERROR_INVALID_BACKUP;
+			}
+		}
+
+		// Move the configuration back now that we know it's parsable and that SAF is set up
+		Log.i(ScummVM.LOG_TAG, "Writing new ScummVM configuration");
+		File configuration = new File(context.getFilesDir(), "scummvm.ini");
+		if (!configurationTmp.renameTo(configuration)) {
+			try (FileOutputStream os = new FileOutputStream(configuration);
+				FileInputStream is = new FileInputStream(configurationTmp)) {
+				copyStream(os, is);
+			} catch(FileNotFoundException ignored) {
+				return ERROR_INVALID_BACKUP;
+			} catch(IOException ignored) {
+				return ERROR_INVALID_BACKUP;
+			}
+			configurationTmp.delete();
+		}
+
+		File savesPath = INIParser.getPath(parsedIniMap, "scummvm", "savepath",
+			new File(context.getFilesDir(), "saves"));
+
+		boolean ret = importSaves(context, savesPath, zf, "saves/");
+		if (!ret) {
+			try {
+				zf.close();
+			} catch(IOException ignored) {
+			}
+			return ERROR_INVALID_BACKUP;
+		}
+
+		HashSet<File> savesPaths = new HashSet<>();
+		savesPaths.add(savesPath);
+
+		int sectionId = -1;
+		for (String section : parsedIniMap.keySet()) {
+			sectionId++;
+			if ("scummvm".equals(section)) {
+				continue;
+			}
+
+			savesPath = INIParser.getPath(parsedIniMap, section, "savepath", null);
+			if (savesPath == null) {
+				continue;
+			}
+
+			if (savesPaths.contains(savesPath)) {
+				continue;
+			}
+			savesPaths.add(savesPath);
+
+			String folderName = "saves-" + sectionId + "/";
+			ret = importSaves(context, savesPath, zf, folderName);
+			if (!ret) {
+				break;
+			}
+		}
+
+		try {
+			zf.close();
+		} catch(IOException ignored) {
+		}
+
+		return ret ? ERROR_NO_ERROR : ERROR_INVALID_SAVES;
+	}
+
+	@RequiresApi(api = Build.VERSION_CODES.N)
+	static private int importTrees(ScummVMActivity context, InputStream is) throws IOException, ClassNotFoundException {
+		boolean failed = false;
+
+		ObjectInputStream ois = new ObjectInputStream(is);
+		// Version 1
+		if (ois.readInt() != 1) {
+			// Invalid version
+			return ERROR_INVALID_BACKUP;
+		}
+
+		for (int length = ois.readInt(); length > 0; length--) {
+			String treeName = (String)ois.readObject();
+			String treeId = (String)ois.readObject();
+			String treeUri = (String)ois.readObject();
+
+			if (SAFFSTree.findTree(context, treeId) != null) {
+				continue;
+			}
+
+			Uri uri = context.selectWithNativeUI(true, true, Uri.parse(treeUri), treeName, null, null);
+			if (uri == null) {
+				failed = true;
+				continue;
+			}
+
+			// Register the new selected tree
+			SAFFSTree.newTree(context, uri);
+		}
+
+		ois.close();
+
+		return failed ? ERROR_CANCELLED : ERROR_NO_ERROR;
+	}
+
+	static private boolean importSaves(Context context, File folder, ZipFile zf, String folderName) {
+		SAFFSTree.PathResult pr;
+		try {
+			pr = SAFFSTree.fullPathToNode(context, folder.getPath(), true);
+		} catch (FileNotFoundException e) {
+			return false;
+		}
+
+		// This version check is only to make Android Studio linter happy
+		if (pr == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
+			// This is a standard filesystem path
+			if (!folder.mkdirs()) {
+				return false;
+			}
+
+			Enumeration<? extends org.scummvm.scummvm.zip.ZipEntry> entries = zf.entries();
+			while (entries.hasMoreElements()) {
+				org.scummvm.scummvm.zip.ZipEntry entry = entries.nextElement();
+				String name = entry.getName();
+				if (!name.startsWith(folderName)) {
+					continue;
+				}
+
+				// Get the base name (this avoids directory traversal)
+				name = name.substring(name.lastIndexOf("/") + 1);
+				if (name.isEmpty() || "timestamps".equals(name)) {
+					continue;
+				}
+
+				File f = new File(folder, name);
+				try (InputStream is = zf.getInputStream(entry);
+					FileOutputStream os = new FileOutputStream(f)) {
+					copyStream(os, is);
+				} catch(FileNotFoundException ignored) {
+					return false;
+				} catch(IOException ignored) {
+					return false;
+				}
+			}
+			return true;
+		}
+
+		// This is a SAF fake mount point
+		Enumeration<? extends org.scummvm.scummvm.zip.ZipEntry> entries = zf.entries();
+		while (entries.hasMoreElements()) {
+			org.scummvm.scummvm.zip.ZipEntry entry = entries.nextElement();
+			String name = entry.getName();
+			if (!name.startsWith(folderName)) {
+				continue;
+			}
+
+			// Get the base name (this avoids directory traversal)
+			name = name.substring(name.lastIndexOf("/") + 1);
+			if (name.isEmpty() || "timestamps".equals(name)) {
+				continue;
+			}
+
+			SAFFSTree.SAFFSNode child = pr.tree.getChild(pr.node, name);
+			if (child == null) {
+				child = pr.tree.createFile(pr.node, name);
+			}
+			if (child == null) {
+				return false;
+			}
+
+			try (InputStream is = zf.getInputStream(entry);
+				ParcelFileDescriptor pfd = pr.tree.createFileDescriptor(child, "wt")) {
+				copyStream(new FileOutputStream(pfd.getFileDescriptor()), is);
+			} catch(FileNotFoundException ignored) {
+				return false;
+			} catch(IOException ignored) {
+				return false;
+			}
+		}
+		return true;
+	}
+
+	public static void copyStream(OutputStream out, InputStream in) throws IOException {
+		byte[] buffer = new byte[4096];
+		int sz;
+		while((sz = in.read(buffer)) != -1) {
+			out.write(buffer, 0, sz);
+		}
+	}
+}
diff --git a/backends/platform/android/org/scummvm/scummvm/ScummVM.java b/backends/platform/android/org/scummvm/scummvm/ScummVM.java
index e32087afbef..e66de6faf80 100644
--- a/backends/platform/android/org/scummvm/scummvm/ScummVM.java
+++ b/backends/platform/android/org/scummvm/scummvm/ScummVM.java
@@ -99,6 +99,8 @@ public abstract class ScummVM implements SurfaceHolder.Callback,
 	abstract protected SAFFSTree getNewSAFTree(boolean write, String initialURI, String prompt);
 	abstract protected SAFFSTree[] getSAFTrees();
 	abstract protected SAFFSTree findSAFTree(String name);
+	abstract protected int exportBackup(String prompt);
+	abstract protected int importBackup(String prompt, String path);
 
 	public ScummVM(AssetManager asset_manager, SurfaceHolder holder, final MyScummVMDestroyedCallback scummVMDestroyedCallback) {
 		_asset_manager = asset_manager;
diff --git a/backends/platform/android/org/scummvm/scummvm/ScummVMActivity.java b/backends/platform/android/org/scummvm/scummvm/ScummVMActivity.java
index b0a4d11d163..317368e0e59 100644
--- a/backends/platform/android/org/scummvm/scummvm/ScummVMActivity.java
+++ b/backends/platform/android/org/scummvm/scummvm/ScummVMActivity.java
@@ -5,6 +5,7 @@ import android.annotation.TargetApi;
 import android.app.Activity;
 import android.app.AlertDialog;
 import android.content.ClipboardManager;
+import android.content.ComponentName;
 import android.content.Context;
 import android.content.DialogInterface;
 import android.content.Intent;
@@ -19,6 +20,8 @@ import android.net.Uri;
 import android.os.Build;
 import android.os.Bundle;
 import android.os.Environment;
+import android.os.Handler;
+import android.os.Looper;
 import android.os.Process;
 import android.os.SystemClock;
 import android.provider.DocumentsContract;
@@ -53,7 +56,9 @@ import java.io.FileReader;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
+import java.text.SimpleDateFormat;
 import java.util.Arrays;
+import java.util.Date;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.Map;
@@ -914,6 +919,61 @@ public class ScummVMActivity extends Activity implements OnKeyboardVisibilityLis
 		protected SAFFSTree findSAFTree(String name) {
 			return SAFFSTree.findTree(ScummVMActivity.this, name);
 		}
+
+		@Override
+		protected int exportBackup(String prompt) {
+			String filename = (new SimpleDateFormat("'ScummVM backup 'yyyyMMdd-HHmmss'.zip'")).format(new Date());
+			int ret;
+			if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+				Uri uri = selectWithNativeUI(false, true, null, prompt, "application/zip", filename);
+				if (uri == null) {
+					return BackupManager.ERROR_CANCELLED;
+				}
+				ret = BackupManager.exportBackup(ScummVMActivity.this, uri);
+				getContentResolver().releasePersistableUriPermission(uri,
+					Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
+			} else {
+				File path = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
+						filename);
+				ret = BackupManager.exportBackup(ScummVMActivity.this, path);
+			}
+			return ret;
+		}
+
+		@Override
+		protected int importBackup(String prompt, String path) {
+			int ret;
+			if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+				Uri uri = selectWithNativeUI(false, false, null, prompt, "application/zip", null);
+				if (uri == null) {
+					return BackupManager.ERROR_CANCELLED;
+				}
+				ret = BackupManager.importBackup(ScummVMActivity.this, uri);
+				getContentResolver().releasePersistableUriPermission(uri,
+					Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
+			} else if (path != null) {
+				ret = BackupManager.importBackup(ScummVMActivity.this, new File(path));
+			} else {
+				return BackupManager.ERROR_CANCELLED;
+			}
+			if (ret != BackupManager.ERROR_INVALID_BACKUP) {
+				// Trigger a restart after letting the code return to display the status
+				// This is the same as runOnUiThread but allows to delay the restart
+				new Handler(Looper.getMainLooper()).postDelayed(new Runnable() {
+					@Override
+					public void run() {
+						Intent restartIntent = Intent.makeRestartActivityTask(new ComponentName(ScummVMActivity.this, SplashActivity.class));
+						restartIntent.setPackage(getPackageName());
+
+						ScummVMActivity.this.startActivity(restartIntent);
+
+						// Kill us to make sure we start from a clean state
+						System.exit(0);
+					}
+				}, 500);
+			}
+			return ret;
+		}
 	}
 
 	private MyScummVM _scummvm;




More information about the Scummvm-git-logs mailing list