[Scummvm-git-logs] scummvm branch-2-9 -> 9c72161bb9d415700ccfefc62f63a6ecf2c15539

lephilousophe noreply at scummvm.org
Thu May 1 10:07:05 UTC 2025


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

Summary:
348acbf99a BAGEL: Don't allocate the save state on the stack
1eb37c5f3e TONY: Don't bundle the screenshot buffer in the object
4e48940667 TWP: Don't allocate a 1MB buffer on stack
9156d2abc3 DIRECTOR: Fix UB when calculating checksum
d7af43c335 DIRECTOR: Fix out of bound access while calling transformColor
d534dc414c IMAGE: Fix TGA color map loading
210d086e69 GRAPHICS: OPENGL: Fix shader version check discrepancy
ad0200cd79 AGS: Avoid stack frame size warning in AGS3::unload_game
8c2e95537d SWORD1: Fix GOG + DE translation detection entry
bb864c6172 MACVENTURE: Handle case where subdirectory is empty
be6b61b95c ANDROID: Update Android Gradle Plugin
112415c2bf ANDROID: Don't use a 0 API level
e48e1e674b ANDROID: Don't call error too early
b4395d05b1 ANDROID: Make assets installation depend on config.mk
c55b99cf87 ANDROID: Store the main activity layout in XML
e7e1cdb3bb ANDROID: Use a SharedPtr to track SAF tree global references
bedd00a8ee ANDROID: Don't track SAFFSNode using JNI global references anymore
2fc867aa4f ANDROID: Track time spent in SAF queries
a06ea3b36f ANDROID: Add a LED widget and use it to indicate IO activity in SAF
b6b0ef7379 ANDROID: Use the proper USAGE category
dd12e33c26 ANDROID: Improve emulated gamepad buttons handling
b33be76e36 ANDROID: Make overlay icons visible above white screen
974b9733d9 ANDROID: Upgrade Gadle and Android Gradle Plugin
19c529c45c ANDROID: Remove superfluous argument
cf0d96fd77 ANDROID: Check API version before using EXTRA_INITIAL_URI
cc7b653ca1 ANDROID: Don't close the inner inflater stream
4fdf121762 ANDROID: Refactor virtual path resolution
5b2a28c046 ANDROID: Various SAFFSTree improvements
28da272bf4 ANDROID: Improve selectWithNativeUI
017fd18f48 ANDROID: Preserve entries order in Java INI parser
d329c81195 ANDROID: Allow users to backup and restore their configuration and saves
9c72161bb9 DISTS: ANDROID: New versionCodes for 2.9.1


Commit: 348acbf99afee8087a1d49d1b1644e943bdbda6e
    https://github.com/scummvm/scummvm/commit/348acbf99afee8087a1d49d1b1644e943bdbda6e
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-05-01T11:29:08+02:00

Commit Message:
BAGEL: Don't allocate the save state on the stack

It's big and can be allocated on heap.

Changed paths:
    engines/bagel/baglib/save_game_file.cpp


diff --git a/engines/bagel/baglib/save_game_file.cpp b/engines/bagel/baglib/save_game_file.cpp
index 0f46503001b..3fbc1b30f86 100644
--- a/engines/bagel/baglib/save_game_file.cpp
+++ b/engines/bagel/baglib/save_game_file.cpp
@@ -152,12 +152,12 @@ ErrorCode CBagSaveGameFile::writeSavedGame() {
 	assert(isValidObject(this));
 
 	// Populate the save data
-	StBagelSave saveData;
-	g_engine->_masterWin->fillSaveBuffer(&saveData);
+	StBagelSave *saveData = new StBagelSave();
+	g_engine->_masterWin->fillSaveBuffer(saveData);
 
-	Common::String str = "./" + Common::String(saveData._szScript);
+	Common::String str = "./" + Common::String(saveData->_szScript);
 	str.replace('/', '\\');
-	Common::strcpy_s(saveData._szScript, str.c_str());
+	Common::strcpy_s(saveData->_szScript, str.c_str());
 
 	// Set up header fields
 	StSavegameHeader header;
@@ -171,11 +171,13 @@ ErrorCode CBagSaveGameFile::writeSavedGame() {
 
 	header.synchronize(s);
 	stream.writeUint32LE(StBagelSave::size());
-	saveData.synchronize(s);
+	saveData->synchronize(s);
 
 	// Add the record
 	addRecord(stream.getData(), stream.size(), true, 0);
 
+	delete saveData;
+
 	return _errCode;
 }
 
@@ -198,19 +200,21 @@ ErrorCode CBagSaveGameFile::readSavedGame(int32 slotNum) {
 			StSavegameHeader header;
 			header.synchronize(s);
 			s.skip(4);		// Skip save data structure size
-			StBagelSave saveData;
-			saveData.synchronize(s);
+			StBagelSave *saveData = new StBagelSave();
+			saveData->synchronize(s);
 
 			bofFree(pBuf);
 
-			CBofString str(saveData._szScript);
+			CBofString str(saveData->_szScript);
 			fixPathName(str);
 			const char *path = str.getBuffer();
 			assert(!strncmp(path, "./", 2));
-			Common::strcpy_s(saveData._szScript, path + 2);
+			Common::strcpy_s(saveData->_szScript, path + 2);
 
 			// Restore the game
-			g_engine->_masterWin->doRestore(&saveData);
+			g_engine->_masterWin->doRestore(saveData);
+
+			delete saveData;
 		}
 	} else {
 		_errCode = ERR_FREAD;


Commit: 1eb37c5f3e7c61817648d9978ae91a4d2a72260e
    https://github.com/scummvm/scummvm/commit/1eb37c5f3e7c61817648d9978ae91a4d2a72260e
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-05-01T11:29:14+02:00

Commit Message:
TONY: Don't bundle the screenshot buffer in the object

It's a big buffer and this object is allocated on the stack.
Use the lifecycle of the object to allocate and free the buffer instead.

Changed paths:
    engines/tony/window.h


diff --git a/engines/tony/window.h b/engines/tony/window.h
index dd0b0bf7fc6..2a2d28b267a 100644
--- a/engines/tony/window.h
+++ b/engines/tony/window.h
@@ -36,9 +36,15 @@ namespace Tony {
 
 class RMSnapshot {
 private:
+	static const int BUFFER_SIZE = RM_SX *RM_SY * 3;
 	// Buffer used to convert to RGB
-	byte _rgb[RM_SX *RM_SY * 3];
+	byte *_rgb;
 public:
+	RMSnapshot() : _rgb(new byte[BUFFER_SIZE]) {}
+	~RMSnapshot() {
+		delete[] _rgb;
+	}
+
 	/**
 	 * Take a screenshot
 	 */


Commit: 4e48940667639cdd32a64fae9d0d9aa95b5e5e91
    https://github.com/scummvm/scummvm/commit/4e48940667639cdd32a64fae9d0d9aa95b5e5e91
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-05-01T11:29:20+02:00

Commit Message:
TWP: Don't allocate a 1MB buffer on stack

100KB should be enough for a debug message

Changed paths:
    engines/twp/vm.cpp


diff --git a/engines/twp/vm.cpp b/engines/twp/vm.cpp
index 9157f6a9766..2f1f88088d9 100644
--- a/engines/twp/vm.cpp
+++ b/engines/twp/vm.cpp
@@ -61,10 +61,10 @@ static SQInteger aux_printerror(HSQUIRRELVM v) {
 }
 
 static void printfunc(HSQUIRRELVM, const SQChar *s, ...) {
-	char buf[1024 * 1024];
+	char buf[100 * 1024];
 	va_list vl;
 	va_start(vl, s);
-	vsnprintf(buf, 1024 * 1024, s, vl);
+	vsnprintf(buf, 100 * 1024, s, vl);
 	va_end(vl);
 
 	debug("TWP: %s", buf);


Commit: 9156d2abc33415b072c82b207df60c21cb8e5e7e
    https://github.com/scummvm/scummvm/commit/9156d2abc33415b072c82b207df60c21cb8e5e7e
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-05-01T11:29:33+02:00

Commit Message:
DIRECTOR: Fix UB when calculating checksum

The constant contained an extraneous F which is removed.
In addition, mark it as unsigned to avoid undefined behaviours while
manipulating too large constants.

Changed paths:
    engines/director/cast.cpp


diff --git a/engines/director/cast.cpp b/engines/director/cast.cpp
index 15b36205436..26c18d4ec45 100644
--- a/engines/director/cast.cpp
+++ b/engines/director/cast.cpp
@@ -509,7 +509,7 @@ bool Cast::loadConfig() {
 		check *= field25 + 25;
 		check += _frameRate + 26;
 		check *= platform + 27;
-		check *= (protection * 0xE06) + 0xFFF450000;
+		check *= (protection * 0xE06) + 0xFF450000u;
 		check ^= MKTAG('r', 'a', 'l', 'f');
 
 		if (check != checksum)


Commit: d7af43c3351ad2d4e9d6f27b340fd8073d35e6d0
    https://github.com/scummvm/scummvm/commit/d7af43c3351ad2d4e9d6f27b340fd8073d35e6d0
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-05-01T11:29:38+02:00

Commit Message:
DIRECTOR: Fix out of bound access while calling transformColor

The values read are in fact signed values but centered around 0x80.
So, 0 means 0x80 while 0x80 means 0.
Xoring the high order bit is enough for this and it's already done like
this at line 222 in the file.
This fixes an out of bound read when the value 0x80 is read (off by
one).

Changed paths:
    engines/director/frame.cpp


diff --git a/engines/director/frame.cpp b/engines/director/frame.cpp
index c7327d4afcc..f9a77b8a4dc 100644
--- a/engines/director/frame.cpp
+++ b/engines/director/frame.cpp
@@ -304,7 +304,7 @@ void readSpriteDataD2(Common::SeekableReadStreamEndian &stream, Sprite &sprite,
 				stream.readByte();
 			} else {
 				// Normalize D2 and D3 colors from -128 ... 127 to 0 ... 255.
-				sprite._foreColor = g_director->transformColor((128 + stream.readByte()) & 0xff);
+				sprite._foreColor = g_director->transformColor(stream.readByte() ^ 0x80);
 			}
 			break;
 		case 3:
@@ -312,7 +312,7 @@ void readSpriteDataD2(Common::SeekableReadStreamEndian &stream, Sprite &sprite,
 				stream.readByte();
 			} else {
 				// Normalize D2 and D3 colors from -128 ... 127 to 0 ... 255.
-				sprite._backColor = g_director->transformColor((128 + stream.readByte()) & 0xff);
+				sprite._backColor = g_director->transformColor(stream.readByte() ^ 0x80);
 			}
 			break;
 		case 4:
@@ -506,8 +506,8 @@ void Frame::readMainChannelsD4(Common::MemoryReadStreamEndian &stream, uint16 of
 			break;
 		case 22:
 			// loop points for color cycling
-			_mainChannels.palette.firstColor = g_director->transformColor(stream.readByte() + 0x80); // 22
-			_mainChannels.palette.lastColor = g_director->transformColor(stream.readByte() + 0x80); // 23
+			_mainChannels.palette.firstColor = g_director->transformColor(stream.readByte() ^ 0x80); // 22
+			_mainChannels.palette.lastColor = g_director->transformColor(stream.readByte() ^ 0x80); // 23
 			break;
 		case 24:
 			_mainChannels.palette.flags = stream.readByte(); // 24
@@ -826,8 +826,8 @@ void Frame::readMainChannelsD5(Common::MemoryReadStreamEndian &stream, uint16 of
 			_mainChannels.palette.overTime = (_mainChannels.palette.flags & 0x04) != 0;
 			break;
 		case 30:
-			_mainChannels.palette.firstColor = g_director->transformColor(stream.readByte() + 0x80); // 30
-			_mainChannels.palette.lastColor = g_director->transformColor(stream.readByte() + 0x80); // 31
+			_mainChannels.palette.firstColor = g_director->transformColor(stream.readByte() ^ 0x80); // 30
+			_mainChannels.palette.lastColor = g_director->transformColor(stream.readByte() ^ 0x80); // 31
 			break;
 		case 32:
 			_mainChannels.palette.frameCount = stream.readUint16(); // 32


Commit: d534dc414cd1fc0f014310a9f88a3990ef4b66a2
    https://github.com/scummvm/scummvm/commit/d534dc414cd1fc0f014310a9f88a3990ef4b66a2
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-05-01T11:30:01+02:00

Commit Message:
IMAGE: Fix TGA color map loading

The color map ordering is now fixed according to the specifications and
the palette is now stored according to our requirements: in interleaved
RGB.

Changed paths:
    image/tga.cpp


diff --git a/image/tga.cpp b/image/tga.cpp
index b4faaaa2619..8d2fd7478d6 100644
--- a/image/tga.cpp
+++ b/image/tga.cpp
@@ -186,32 +186,26 @@ bool TGADecoder::readColorMap(Common::SeekableReadStream &tga, byte imageType, b
 	for (int i = 0; i < _colorMapLength * 3; i += 3) {
 		byte r, g, b;
 		if (_colorMapEntryLength == 32) {
-			byte a;
-			Graphics::PixelFormat format(4, 8, 8, 8, 0, 16, 8, 0, 24);
-			uint32 color = tga.readUint32LE();
-			format.colorToARGB(color, a, r, g, b);
-		} else if (_colorMapEntryLength == 24) {
-			r = tga.readByte();
+			b = tga.readByte();
 			g = tga.readByte();
+			r = tga.readByte();
+			tga.readByte(); // for alpha
+		} else if (_colorMapEntryLength == 24) {
 			b = tga.readByte();
+			g = tga.readByte();
+			r = tga.readByte();
 		} else if (_colorMapEntryLength == 16) {
 			byte a;
-			Graphics::PixelFormat format(2, 5, 5, 5, 0, 10, 5, 0, 15);
+			static const Graphics::PixelFormat format(2, 5, 5, 5, 0, 10, 5, 0, 15);
 			uint16 color = tga.readUint16LE();
 			format.colorToARGB(color, a, r, g, b);
 		} else {
 			warning("Unsupported image type: %d", imageType);
 			r = g = b = 0;
 		}
-#ifdef SCUMM_LITTLE_ENDIAN
 		_colorMap[i] = r;
 		_colorMap[i + 1] = g;
 		_colorMap[i + 2] = b;
-#else
-		_colorMap[i] = b;
-		_colorMap[i + 1] = g;
-		_colorMap[i + 2] = r;
-#endif
 	}
 	return true;
 }


Commit: 210d086e695dbdd634a4a839d84eb4a49952fccf
    https://github.com/scummvm/scummvm/commit/210d086e695dbdd634a4a839d84eb4a49952fccf
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-05-01T11:33:17+02:00

Commit Message:
GRAPHICS: OPENGL: Fix shader version check discrepancy

We required version 110 for OpenGL backend shaders while we only checked
for version 100, although this version is not expected to work without
proper ARB extension support.
Increase minimum supported version to 1.10 which matches OpenGL 2.0

Changed paths:
    graphics/opengl/context.cpp


diff --git a/graphics/opengl/context.cpp b/graphics/opengl/context.cpp
index 0d783aa44b4..5e888125c48 100644
--- a/graphics/opengl/context.cpp
+++ b/graphics/opengl/context.cpp
@@ -254,7 +254,10 @@ void Context::initialize(ContextType contextType) {
 		// No mirror repeat in GLES
 		debug(5, "OpenGL: GLES context initialized");
 	} else if (type == kContextGL) {
-		shadersSupported = glslVersion >= 100;
+		// Official support of shaders starts with version 110
+		// Older versions didn't support the #version directive and were only available through
+		// ARB extensions which we removed support for
+		shadersSupported = glslVersion >= 110;
 
 		// In GL mode engines need GLSL 1.20
 		enginesShadersSupported = glslVersion >= 120;


Commit: ad0200cd7959e2684d8dab4e90639a7e133419b8
    https://github.com/scummvm/scummvm/commit/ad0200cd7959e2684d8dab4e90639a7e133419b8
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-05-01T11:36:01+02:00

Commit Message:
AGS: Avoid stack frame size warning in AGS3::unload_game

Instead of allocating the GameState and GameSetupStruct variables on
stack and erase the heap version, deallocate the old ones and reallocate
new ones.

Changed paths:
    engines/ags/engine/ac/game.cpp


diff --git a/engines/ags/engine/ac/game.cpp b/engines/ags/engine/ac/game.cpp
index d0a47f4d5ae..e80d693f417 100644
--- a/engines/ags/engine/ac/game.cpp
+++ b/engines/ags/engine/ac/game.cpp
@@ -400,8 +400,10 @@ void unload_game() {
 	_GP(thisroom).Free();
 
 	// Free game state and game struct
-	_GP(play) = GameState();
-	_GP(game) = GameSetupStruct();
+	delete _G(play);
+	_G(play) = new GameState();
+	delete _G(game);
+	_G(game) = new GameSetupStruct();
 
 	// Reset all resource caches
 	// IMPORTANT: this is hard reset, including locked items


Commit: 8c2e95537d13aea10ca3895afb62e22d69a82e4d
    https://github.com/scummvm/scummvm/commit/8c2e95537d13aea10ca3895afb62e22d69a82e4d
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-05-01T11:36:28+02:00

Commit Message:
SWORD1: Fix GOG + DE translation detection entry

Changed paths:
    engines/sword1/detection_tables.h


diff --git a/engines/sword1/detection_tables.h b/engines/sword1/detection_tables.h
index ebe4dcf5f6b..362cf32425c 100644
--- a/engines/sword1/detection_tables.h
+++ b/engines/sword1/detection_tables.h
@@ -283,12 +283,12 @@ static const ADGameDescription gameDescriptions[] = {
 		GUIO2(GAMEOPTION_WINDOWS_AUDIO_MODE, GAMEOPTION_MULTILANGUAGE)
 	},
 
-	{ // GOG.com version + german translation
+	{ // GOG.com version + german translation from tickets #14592, #14642, #15763
 		"sword1",
 		"GOG.com",
-		AD_ENTRY4s("clusters/scripts.clu",  "72b10193714e8c6e4daca51791c0db0c", 1087240,
+		AD_ENTRY4s("clusters/scripts.clu",  "72b10193714e8c6e4daca51791c0db0c", 1088292,
 				   "clusters/swordres.rif", "5463362dc77b6efc36e46ac84998bd2f", 59788,
-				   "clusters/text.clu",     "76f93f5feecc8915435105478f3c6615", 2705446,
+				   "clusters/text.clu",     "76f93f5feecc8915435105478f3c6615", 3193159,
 				   "video/intro.dxa",       "e27cd33593c08b66e8d20fbc40938789", 7397543),
 		Common::DE_DEU,
 		Common::kPlatformWindows,


Commit: bb864c617270fe760dfd0042c91acc219cb9d8bf
    https://github.com/scummvm/scummvm/commit/bb864c617270fe760dfd0042c91acc219cb9d8bf
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-05-01T12:03:09+02:00

Commit Message:
MACVENTURE: Handle case where subdirectory is empty

In this case, the path built had a leading /

Changed paths:
    engines/macventure/macventure.cpp


diff --git a/engines/macventure/macventure.cpp b/engines/macventure/macventure.cpp
index d42164111ac..ab43184b553 100644
--- a/engines/macventure/macventure.cpp
+++ b/engines/macventure/macventure.cpp
@@ -946,7 +946,9 @@ Common::Path MacVentureEngine::getFilePath(FilePathID id) const {
 	if (id <= 3) { // We don't want a file in the subdirectory
 		return Common::Path(_filenames->getString(id));
 	} else { // We want a game file
-		return Common::Path(_filenames->getString(3) + "/" + _filenames->getString(id));
+		Common::Path path(_filenames->getString(3));
+		path.joinInPlace(_filenames->getString(id));
+		return path;
 	}
 }
 


Commit: be6b61b95c3e039c6f1bd7aa6d6de83f277dafc0
    https://github.com/scummvm/scummvm/commit/be6b61b95c3e039c6f1bd7aa6d6de83f277dafc0
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-05-01T12:03:09+02:00

Commit Message:
ANDROID: Update Android Gradle Plugin

Changed paths:
    dists/android/build.gradle


diff --git a/dists/android/build.gradle b/dists/android/build.gradle
index 25eb4af78fc..89d727de4cf 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.2'
+        classpath 'com.android.tools.build:gradle:8.7.3'
     }
 }
 


Commit: 112415c2bfa77b9eb9e9ccf621ad98f76fea3d09
    https://github.com/scummvm/scummvm/commit/112415c2bfa77b9eb9e9ccf621ad98f76fea3d09
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-05-01T12:03:09+02:00

Commit Message:
ANDROID: Don't use a 0 API level

That doesn't exist, use 1 instead.

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


diff --git a/backends/platform/android/org/scummvm/scummvm/SAFFSTree.java b/backends/platform/android/org/scummvm/scummvm/SAFFSTree.java
index 01c657b82e1..fd61198d91b 100644
--- a/backends/platform/android/org/scummvm/scummvm/SAFFSTree.java
+++ b/backends/platform/android/org/scummvm/scummvm/SAFFSTree.java
@@ -64,7 +64,7 @@ public class SAFFSTree {
 		return _trees.get(name);
 	}
 
-	@RequiresApi(api = 0)
+	@RequiresApi(api = Build.VERSION_CODES.BASE)
 	public static void clearCaches() {
 		if (_trees == null) {
 			return;


Commit: e48e1e674b7dbe7f20ead1cc74f46f2e389fc52f
    https://github.com/scummvm/scummvm/commit/e48e1e674b7dbe7f20ead1cc74f46f2e389fc52f
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-05-01T12:03:09+02:00

Commit Message:
ANDROID: Don't call error too early

When the backend is not yet set up, avoid using error which ends up
using exit without any useful error log.

Changed paths:
    backends/fs/android/android-saf-fs.cpp


diff --git a/backends/fs/android/android-saf-fs.cpp b/backends/fs/android/android-saf-fs.cpp
index 7554936be0b..104fad5b96b 100644
--- a/backends/fs/android/android-saf-fs.cpp
+++ b/backends/fs/android/android-saf-fs.cpp
@@ -85,15 +85,20 @@ void AndroidSAFFilesystemNode::initJNI() {
 
 	JNIEnv *env = JNI::getEnv();
 
+	// We can't call error here as the backend is not built yet
 #define FIND_METHOD(prefix, name, signature) do {                           \
     _MID_ ## prefix ## name = env->GetMethodID(cls, #name, signature);      \
-        if (_MID_ ## prefix ## name == 0)                                   \
-            error("Can't find method ID " #name);                           \
+        if (_MID_ ## prefix ## name == 0) {                                 \
+            LOGE("Can't find method ID " #name);                            \
+            abort();                                                        \
+        }                                                                   \
     } while (0)
 #define FIND_FIELD(prefix, name, signature) do {                            \
     _FID_ ## prefix ## name = env->GetFieldID(cls, #name, signature);       \
-        if (_FID_ ## prefix ## name == 0)                                   \
-            error("Can't find field ID " #name);                            \
+        if (_FID_ ## prefix ## name == 0) {                                 \
+            LOGE("Can't find field ID " #name);                             \
+            abort();                                                        \
+        }                                                                   \
     } while (0)
 #define SAFFSNodeSig "Lorg/scummvm/scummvm/SAFFSTree$SAFFSNode;"
 


Commit: b4395d05b1c04c17a396ef075a3a6728e232c87f
    https://github.com/scummvm/scummvm/commit/b4395d05b1c04c17a396ef075a3a6728e232c87f
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-05-01T12:03:09+02:00

Commit Message:
ANDROID: Make assets installation depend on config.mk

This makes sure new needed files following a reconfigure are copied.
This does not do the cleanup though.

Changed paths:
    backends/platform/android/android.mk


diff --git a/backends/platform/android/android.mk b/backends/platform/android/android.mk
index 32f30fb5ee8..99184d7120a 100644
--- a/backends/platform/android/android.mk
+++ b/backends/platform/android/android.mk
@@ -45,7 +45,7 @@ $(PATH_BUILD)/src.properties: configure.stamp | $(PATH_BUILD)
 $(PATH_BUILD)/mainAssets/build.gradle: $(PATH_DIST)/mainAssets.gradle | $(PATH_BUILD_ASSETS)/MD5SUMS
 	$(INSTALL) -c -m 644 $< $@
 
-$(PATH_BUILD_ASSETS)/MD5SUMS: $(DIST_FILES_THEMES) $(DIST_FILES_ENGINEDATA) $(DIST_FILES_ENGINEDATA_BIG) $(DIST_FILES_SOUNDFONTS) $(DIST_FILES_NETWORKING) $(DIST_FILES_VKEYBD) $(DIST_FILES_DOCS) $(DIST_FILES_PLATFORM) $(DIST_FILES_SHADERS) | $(PATH_BUILD)
+$(PATH_BUILD_ASSETS)/MD5SUMS: config.mk $(DIST_FILES_THEMES) $(DIST_FILES_ENGINEDATA) $(DIST_FILES_ENGINEDATA_BIG) $(DIST_FILES_SOUNDFONTS) $(DIST_FILES_NETWORKING) $(DIST_FILES_VKEYBD) $(DIST_FILES_DOCS) $(DIST_FILES_PLATFORM) $(DIST_FILES_SHADERS) | $(PATH_BUILD)
 	$(INSTALL) -d $(PATH_BUILD_ASSETS)/assets/
 	$(INSTALL) -c -m 644 $(DIST_FILES_THEMES) $(DIST_FILES_ENGINEDATA) $(DIST_FILES_ENGINEDATA_BIG) $(DIST_FILES_SOUNDFONTS) $(DIST_FILES_NETWORKING) $(DIST_FILES_VKEYBD) $(DIST_FILES_DOCS) $(DIST_FILES_PLATFORM) $(PATH_BUILD_ASSETS)/assets/
 ifneq ($(DIST_FILES_SHADERS),)


Commit: c55b99cf87d93c586be1739b2280c565935c83b8
    https://github.com/scummvm/scummvm/commit/c55b99cf87d93c586be1739b2280c565935c83b8
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-05-01T12:03:09+02:00

Commit Message:
ANDROID: Store the main activity layout in XML

This lightens the code a bit and make it simpler to understand.
In addition, migrate the buttons layout to GridLayout for future
additions.

Changed paths:
  A dists/android/res/layout/scummvm_activity.xml
    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 ddea0574aed..fa1bc4937cc 100644
--- a/backends/platform/android/org/scummvm/scummvm/ScummVMActivity.java
+++ b/backends/platform/android/org/scummvm/scummvm/ScummVMActivity.java
@@ -12,8 +12,6 @@ import android.content.pm.PackageManager;
 import android.content.res.AssetManager;
 import android.content.res.Configuration;
 import android.content.res.Resources;
-import android.graphics.Bitmap;
-import android.graphics.BitmapFactory;
 import android.graphics.Rect;
 import android.media.AudioManager;
 import android.net.ConnectivityManager;
@@ -39,8 +37,8 @@ import android.view.ViewTreeObserver;
 import android.view.WindowManager;
 import android.view.inputmethod.InputMethodManager;
 import android.widget.FrameLayout;
+import android.widget.GridLayout;
 import android.widget.ImageView;
-import android.widget.LinearLayout;
 import android.widget.Toast;
 
 import androidx.annotation.NonNull;
@@ -116,9 +114,10 @@ public class ScummVMActivity extends Activity implements OnKeyboardVisibilityLis
 	FrameLayout _videoLayout = null;
 
 	private EditableSurfaceView _main_surface = null;
-	private LinearLayout _buttonLayout = null;
+	private GridLayout _buttonLayout = null;
 	private ImageView _toggleTouchModeKeyboardBtnIcon = null;
 	private ImageView _openMenuBtnIcon = null;
+	private int _layoutOrientation;
 
 	public View _screenKeyboard = null;
 	static boolean keyboardWithoutTextInputShown = false;
@@ -519,25 +518,29 @@ public class ScummVMActivity extends Activity implements OnKeyboardVisibilityLis
 	}
 
 	private void layoutButtonLayout(int orientation, boolean force) {
-		int newOrientation = orientation == Configuration.ORIENTATION_LANDSCAPE ? LinearLayout.VERTICAL : LinearLayout.HORIZONTAL;
-
-		if (!force && newOrientation == _buttonLayout.getOrientation()) {
+		if (!force && orientation == _layoutOrientation) {
 			return;
 		}
 
-		_buttonLayout.setOrientation(newOrientation);
-		_buttonLayout.removeAllViews();
-		if (newOrientation == LinearLayout.VERTICAL) {
-			_buttonLayout.addView(_openMenuBtnIcon, new FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT));
-			_buttonLayout.bringChildToFront(_openMenuBtnIcon);
-			_buttonLayout.addView(_toggleTouchModeKeyboardBtnIcon, new FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT));
-			_buttonLayout.bringChildToFront(_toggleTouchModeKeyboardBtnIcon);
+		_layoutOrientation = orientation;
+		if (orientation == Configuration.ORIENTATION_LANDSCAPE) {
+			GridLayout.LayoutParams params;
+			params = (GridLayout.LayoutParams)_openMenuBtnIcon.getLayoutParams();
+			params.rowSpec = GridLayout.spec(0);
+			params.columnSpec = GridLayout.spec(1);
+			params = (GridLayout.LayoutParams)_toggleTouchModeKeyboardBtnIcon.getLayoutParams();
+			params.rowSpec = GridLayout.spec(1);
+			params.columnSpec = GridLayout.spec(1);
 		} else {
-			_buttonLayout.addView(_toggleTouchModeKeyboardBtnIcon, new FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT));
-			_buttonLayout.bringChildToFront(_toggleTouchModeKeyboardBtnIcon);
-			_buttonLayout.addView(_openMenuBtnIcon, new FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT));
-			_buttonLayout.bringChildToFront(_openMenuBtnIcon);
-		}
+			GridLayout.LayoutParams params;
+			params = (GridLayout.LayoutParams)_openMenuBtnIcon.getLayoutParams();
+			params.rowSpec = GridLayout.spec(0);
+			params.columnSpec = GridLayout.spec(1);
+			params = (GridLayout.LayoutParams)_toggleTouchModeKeyboardBtnIcon.getLayoutParams();
+			params.rowSpec = GridLayout.spec(0);
+			params.columnSpec = GridLayout.spec(0);
+		}
+		_buttonLayout.requestLayout();
 	}
 
 	public void showScreenKeyboard(boolean force) {
@@ -921,37 +924,19 @@ public class ScummVMActivity extends Activity implements OnKeyboardVisibilityLis
 
 		safSyncObject = new Object();
 
-		_videoLayout = new FrameLayout(this);
-		_videoLayout.setLayerType(android.view.View.LAYER_TYPE_NONE, null);
 		getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
-		setContentView(_videoLayout);
-		_videoLayout.setFocusable(true);
-		_videoLayout.setFocusableInTouchMode(true);
-		_videoLayout.requestFocus();
-
-		_main_surface = new EditableSurfaceView(this);
-		_main_surface.setLayerType(android.view.View.LAYER_TYPE_NONE, null);
-
-		_videoLayout.addView(_main_surface, new FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
-
-		_buttonLayout = new LinearLayout(this);
-		FrameLayout.LayoutParams buttonLayoutParams = new FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.TOP | Gravity.RIGHT);
-		buttonLayoutParams.topMargin = 5;
-		buttonLayoutParams.rightMargin = 5;
-		_videoLayout.addView(_buttonLayout, buttonLayoutParams);
-		_videoLayout.bringChildToFront(_buttonLayout);
-
-		_openMenuBtnIcon = new ImageView(this);
-		_openMenuBtnIcon.setImageResource(R.drawable.ic_action_menu);
 
-		_toggleTouchModeKeyboardBtnIcon = new ImageView(this);
+		setContentView(R.layout.scummvm_activity);
+		_videoLayout = findViewById(R.id.video_layout);
+		_main_surface = findViewById(R.id.main_surface);
+		_buttonLayout = findViewById(R.id.button_layout);
+		_openMenuBtnIcon = findViewById(R.id.open_menu_button);
+		_toggleTouchModeKeyboardBtnIcon = findViewById(R.id.toggle_touch_button);
 
 		// Hide by default all buttons, they will be shown when native code will start
 		showToggleOnScreenBtnIcons(0);
 		layoutButtonLayout(getResources().getConfiguration().orientation, true);
 
-		_main_surface.setFocusable(true);
-		_main_surface.setFocusableInTouchMode(true);
 		_main_surface.requestFocus();
 
 		//Log.d(ScummVM.LOG_TAG, "onCreate - captureMouse(true)");
diff --git a/dists/android/res/layout/scummvm_activity.xml b/dists/android/res/layout/scummvm_activity.xml
new file mode 100644
index 00000000000..9cb4c23b74a
--- /dev/null
+++ b/dists/android/res/layout/scummvm_activity.xml
@@ -0,0 +1,48 @@
+<?xml version="1.0" encoding="utf-8"?>
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+	xmlns:app="http://schemas.android.com/apk/res-auto"
+	xmlns:tools="http://schemas.android.com/tools"
+	android:id="@+id/video_layout"
+	android:layout_width="match_parent"
+	android:layout_height="match_parent"
+	android:focusable="true"
+	android:focusableInTouchMode="true"
+	android:layerType="none">
+
+	<org.scummvm.scummvm.EditableSurfaceView
+		android:id="@+id/main_surface"
+		android:layout_width="match_parent"
+		android:layout_height="match_parent"
+		android:focusable="true"
+		android:focusableInTouchMode="true"
+		android:layerType="none" />
+
+	<GridLayout
+		android:id="@+id/button_layout"
+		android:layout_width="wrap_content"
+		android:layout_height="wrap_content"
+		android:layout_gravity="right|top"
+		android:layout_marginTop="5dp"
+		android:layout_marginRight="5dp"
+		android:orientation="horizontal">
+
+		<ImageView
+			android:id="@+id/toggle_touch_button"
+			android:layout_width="wrap_content"
+			android:layout_height="wrap_content"
+			android:layout_row="0"
+			android:layout_column="0"
+			android:src="@drawable/ic_action_mouse" />
+
+		<ImageView
+			android:id="@+id/open_menu_button"
+			android:layout_width="wrap_content"
+			android:layout_height="wrap_content"
+			android:layout_row="0"
+			android:layout_column="1"
+			android:src="@drawable/ic_action_menu"
+			android:visibility="gone"
+			tools:visibility="visible" />
+	</GridLayout>
+
+</FrameLayout>


Commit: e7e1cdb3bb7ef01ff4d48ffe00552d9fe02b01e5
    https://github.com/scummvm/scummvm/commit/e7e1cdb3bb7ef01ff4d48ffe00552d9fe02b01e5
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-05-01T12:03:09+02:00

Commit Message:
ANDROID: Use a SharedPtr to track SAF tree global references

This makes us use a single global reference per tree instead of
duplicating them on each filesystem node creation.
Thanks to the SharedPtr, the global reference is automatically
deallocated when needed.
This is the first step to avoid global references overflow.

Changed paths:
    backends/fs/android/android-saf-fs.cpp
    backends/fs/android/android-saf-fs.h


diff --git a/backends/fs/android/android-saf-fs.cpp b/backends/fs/android/android-saf-fs.cpp
index 104fad5b96b..f9d40dc5ed6 100644
--- a/backends/fs/android/android-saf-fs.cpp
+++ b/backends/fs/android/android-saf-fs.cpp
@@ -132,6 +132,11 @@ void AndroidSAFFilesystemNode::initJNI() {
 	_JNIinit = true;
 }
 
+void AndroidSAFFilesystemNode::GlobalRef::Deleter::operator()(_jobject *obj) {
+			JNIEnv *env = JNI::getEnv();
+			env->DeleteGlobalRef((jobject)obj);
+}
+
 AndroidSAFFilesystemNode *AndroidSAFFilesystemNode::makeFromPath(const Common::String &path) {
 	if (!path.hasPrefix(SAF_MOUNT_POINT)) {
 		// Not a SAF mount point
@@ -174,7 +179,7 @@ AndroidSAFFilesystemNode *AndroidSAFFilesystemNode::makeFromPath(const Common::S
 	}
 
 	if (node) {
-		AndroidSAFFilesystemNode *ret = new AndroidSAFFilesystemNode(safTree, node);
+		AndroidSAFFilesystemNode *ret = new AndroidSAFFilesystemNode(GlobalRef(env, safTree), node);
 
 		env->DeleteLocalRef(node);
 		env->DeleteLocalRef(safTree);
@@ -219,7 +224,7 @@ AndroidSAFFilesystemNode *AndroidSAFFilesystemNode::makeFromPath(const Common::S
 	}
 
 	if (node) {
-		AndroidSAFFilesystemNode *parent = new AndroidSAFFilesystemNode(safTree, node);
+		AndroidSAFFilesystemNode *parent = new AndroidSAFFilesystemNode(GlobalRef(env, safTree), node);
 		env->DeleteLocalRef(node);
 		env->DeleteLocalRef(safTree);
 
@@ -244,7 +249,7 @@ AndroidSAFFilesystemNode *AndroidSAFFilesystemNode::makeFromTree(jobject safTree
 		return nullptr;
 	}
 
-	AndroidSAFFilesystemNode *ret = new AndroidSAFFilesystemNode(safTree, node);
+	AndroidSAFFilesystemNode *ret = new AndroidSAFFilesystemNode(GlobalRef(env, safTree), node);
 
 	env->DeleteLocalRef(node);
 	env->DeleteLocalRef(safTree);
@@ -252,27 +257,27 @@ AndroidSAFFilesystemNode *AndroidSAFFilesystemNode::makeFromTree(jobject safTree
 	return ret;
 }
 
-AndroidSAFFilesystemNode::AndroidSAFFilesystemNode(jobject safTree, jobject safNode) :
+AndroidSAFFilesystemNode::AndroidSAFFilesystemNode(const GlobalRef &safTree, jobject safNode) :
 	_flags(0), _safParent(nullptr) {
 
 	JNIEnv *env = JNI::getEnv();
 
-	_safTree = env->NewGlobalRef(safTree);
-	assert(_safTree);
+	_safTree = safTree;
+	assert(_safTree != nullptr);
 	_safNode = env->NewGlobalRef(safNode);
 	assert(_safNode);
 
 	cacheData();
 }
 
-AndroidSAFFilesystemNode::AndroidSAFFilesystemNode(jobject safTree, jobject safParent,
+AndroidSAFFilesystemNode::AndroidSAFFilesystemNode(const GlobalRef &safTree, jobject safParent,
         const Common::String &path, const Common::String &name) :
 	_safNode(nullptr), _flags(0), _safParent(nullptr) {
 
 	JNIEnv *env = JNI::getEnv();
+	_safTree = safTree;
+	assert(_safTree != nullptr);
 
-	_safTree = env->NewGlobalRef(safTree);
-	assert(_safTree);
 	_safParent = env->NewGlobalRef(safParent);
 	assert(_safParent);
 
@@ -287,8 +292,8 @@ AndroidSAFFilesystemNode::AndroidSAFFilesystemNode(const AndroidSAFFilesystemNod
 
 	JNIEnv *env = JNI::getEnv();
 
-	_safTree = env->NewGlobalRef(node._safTree);
-	assert(_safTree);
+	_safTree = node._safTree;
+	assert(_safTree != nullptr);
 
 	if (node._safNode) {
 		_safNode = env->NewGlobalRef(node._safNode);
@@ -308,7 +313,6 @@ AndroidSAFFilesystemNode::AndroidSAFFilesystemNode(const AndroidSAFFilesystemNod
 AndroidSAFFilesystemNode::~AndroidSAFFilesystemNode() {
 	JNIEnv *env = JNI::getEnv();
 
-	env->DeleteGlobalRef(_safTree);
 	env->DeleteGlobalRef(_safNode);
 	env->DeleteGlobalRef(_safParent);
 }
diff --git a/backends/fs/android/android-saf-fs.h b/backends/fs/android/android-saf-fs.h
index 624b69594dd..e3ae99d46e0 100644
--- a/backends/fs/android/android-saf-fs.h
+++ b/backends/fs/android/android-saf-fs.h
@@ -25,6 +25,7 @@
 #include <jni.h>
 
 #include "backends/fs/abstract-fs.h"
+#include "common/ptr.h"
 
 #include "backends/fs/android/android-fs.h"
 
@@ -35,6 +36,35 @@
  */
 class AndroidSAFFilesystemNode final : public AbstractFSNode, public AndroidFSNode {
 protected:
+	/**
+	 * A class managing a global reference.
+	 *
+	 * This handles the reference management and avoids duplicating them in JNI.
+	 */
+	class GlobalRef final : public Common::SharedPtr<_jobject> {
+		struct Deleter {
+			void operator()(_jobject *obj);
+		};
+	public:
+		GlobalRef() : Common::SharedPtr<_jobject>() {}
+		GlobalRef(const GlobalRef &ref) : Common::SharedPtr<_jobject>(ref) {}
+		GlobalRef(JNIEnv *env, jobject jobj) : Common::SharedPtr<_jobject>(jobj ? env->NewGlobalRef(jobj) : nullptr, Deleter()) {
+			// Make sure NewGlobalRef succeeded
+			assert((jobj == nullptr) == (get() == nullptr));
+		}
+		GlobalRef &operator=(const GlobalRef &r) {
+			Common::SharedPtr<_jobject>::reset(r);
+			return *this;
+		}
+
+		operator jobject() {
+			return Common::SharedPtr<_jobject>::get();
+		}
+		operator jobject() const {
+			return Common::SharedPtr<_jobject>::get();
+		}
+	};
+
 	// SAFFSTree
 	static jmethodID _MID_getTreeId;
 	static jmethodID _MID_pathToNode;
@@ -63,7 +93,7 @@ protected:
 	static const int WRITABLE  = 2;
 	static const int READABLE  = 4;
 
-	jobject _safTree;
+	GlobalRef _safTree;
 	// When null, node doesn't exist yet
 	// In this case _path is the parent path, _newName the node name and _safParent the parent SAF object
 	jobject _safNode;
@@ -82,7 +112,7 @@ protected:
 	 * @param safTree SAF root in Java side
 	 * @param safNode SAF node in Java side
 	 */
-	AndroidSAFFilesystemNode(jobject safTree, jobject safNode);
+	AndroidSAFFilesystemNode(const GlobalRef &safTree, jobject safNode);
 
 public:
 	static const char SAF_MOUNT_POINT[];
@@ -151,7 +181,7 @@ protected:
 	 * @param path Parent path
 	 * @param name Item name
 	 */
-	AndroidSAFFilesystemNode(jobject safTree, jobject safParent,
+	AndroidSAFFilesystemNode(const GlobalRef &safTree, jobject safParent,
 	                         const Common::String &path, const Common::String &name);
 
 	void cacheData();


Commit: bedd00a8ee06ac4a71f89606b3f952d0cf4c539d
    https://github.com/scummvm/scummvm/commit/bedd00a8ee06ac4a71f89606b3f952d0cf4c539d
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-05-01T12:03:09+02:00

Commit Message:
ANDROID: Don't track SAFFSNode using JNI global references anymore

The JNI global references are limited in count and we overflow it for
games with a huge number of files (like Zork Nemesis).
Instead, track all the nodes from Java side using a unique identifier
generated using an atomic counter.
Then, all JNI calls either use a temporary local reference to access
node data or use the unique identifier.

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


diff --git a/backends/fs/android/android-saf-fs.cpp b/backends/fs/android/android-saf-fs.cpp
index f9d40dc5ed6..28715d1c90f 100644
--- a/backends/fs/android/android-saf-fs.cpp
+++ b/backends/fs/android/android-saf-fs.cpp
@@ -55,6 +55,12 @@
 #include "common/translation.h"
 #include "common/util.h"
 
+jclass AndroidSAFFilesystemNode::_CLS_SAFFSTree = nullptr;
+
+jmethodID AndroidSAFFilesystemNode::_MID_addNodeRef = 0;
+jmethodID AndroidSAFFilesystemNode::_MID_decNodeRef = 0;
+jmethodID AndroidSAFFilesystemNode::_MID_refToNode = 0;
+
 jmethodID AndroidSAFFilesystemNode::_MID_getTreeId = 0;
 jmethodID AndroidSAFFilesystemNode::_MID_pathToNode = 0;
 jmethodID AndroidSAFFilesystemNode::_MID_getChildren = 0;
@@ -69,6 +75,8 @@ jmethodID AndroidSAFFilesystemNode::_MID_removeTree = 0;
 jfieldID AndroidSAFFilesystemNode::_FID__treeName = 0;
 jfieldID AndroidSAFFilesystemNode::_FID__root = 0;
 
+jmethodID AndroidSAFFilesystemNode::_MID_addRef = 0;
+
 jfieldID AndroidSAFFilesystemNode::_FID__parent = 0;
 jfieldID AndroidSAFFilesystemNode::_FID__path = 0;
 jfieldID AndroidSAFFilesystemNode::_FID__documentId = 0;
@@ -86,48 +94,65 @@ void AndroidSAFFilesystemNode::initJNI() {
 	JNIEnv *env = JNI::getEnv();
 
 	// We can't call error here as the backend is not built yet
-#define FIND_METHOD(prefix, name, signature) do {                           \
-    _MID_ ## prefix ## name = env->GetMethodID(cls, #name, signature);      \
-        if (_MID_ ## prefix ## name == 0) {                                 \
-            LOGE("Can't find method ID " #name);                            \
-            abort();                                                        \
-        }                                                                   \
+#define FIND_STATIC_METHOD(prefix, name, signature) do {                     \
+    _MID_ ## prefix ## name = env->GetStaticMethodID(cls, #name, signature); \
+        if (_MID_ ## prefix ## name == 0) {                                  \
+            LOGE("Can't find method ID " #name);                             \
+            abort();                                                         \
+        }                                                                    \
+    } while (0)
+#define FIND_METHOD(prefix, name, signature) do {                            \
+    _MID_ ## prefix ## name = env->GetMethodID(cls, #name, signature);       \
+        if (_MID_ ## prefix ## name == 0) {                                  \
+            LOGE("Can't find method ID " #name);                             \
+            abort();                                                         \
+        }                                                                    \
     } while (0)
-#define FIND_FIELD(prefix, name, signature) do {                            \
-    _FID_ ## prefix ## name = env->GetFieldID(cls, #name, signature);       \
-        if (_FID_ ## prefix ## name == 0) {                                 \
-            LOGE("Can't find field ID " #name);                             \
-            abort();                                                        \
-        }                                                                   \
+#define FIND_FIELD(prefix, name, signature) do {                             \
+    _FID_ ## prefix ## name = env->GetFieldID(cls, #name, signature);        \
+        if (_FID_ ## prefix ## name == 0) {                                  \
+            LOGE("Can't find field ID " #name);                              \
+            abort();                                                         \
+        }                                                                    \
     } while (0)
 #define SAFFSNodeSig "Lorg/scummvm/scummvm/SAFFSTree$SAFFSNode;"
 
 	jclass cls = env->FindClass("org/scummvm/scummvm/SAFFSTree");
+	_CLS_SAFFSTree = (jclass)env->NewGlobalRef(cls);
+
+	FIND_STATIC_METHOD(, addNodeRef, "(J)V");
+	FIND_STATIC_METHOD(, decNodeRef, "(J)V");
+	FIND_STATIC_METHOD(, refToNode, "(J)" SAFFSNodeSig);
 
 	FIND_METHOD(, getTreeId, "()Ljava/lang/String;");
 	FIND_METHOD(, pathToNode, "(Ljava/lang/String;)" SAFFSNodeSig);
-	FIND_METHOD(, getChildren, "(" SAFFSNodeSig ")[" SAFFSNodeSig);
-	FIND_METHOD(, getChild, "(" SAFFSNodeSig "Ljava/lang/String;)" SAFFSNodeSig);
-	FIND_METHOD(, createDirectory, "(" SAFFSNodeSig "Ljava/lang/String;)" SAFFSNodeSig);
-	FIND_METHOD(, createFile, "(" SAFFSNodeSig "Ljava/lang/String;)" SAFFSNodeSig);
-	FIND_METHOD(, createReadStream, "(" SAFFSNodeSig ")I");
-	FIND_METHOD(, createWriteStream, "(" SAFFSNodeSig ")I");
-	FIND_METHOD(, removeNode, "(" SAFFSNodeSig ")Z");
+	FIND_METHOD(, getChildren, "(J)[" SAFFSNodeSig);
+	FIND_METHOD(, getChild, "(JLjava/lang/String;)" SAFFSNodeSig);
+	FIND_METHOD(, createDirectory, "(JLjava/lang/String;)" SAFFSNodeSig);
+	FIND_METHOD(, createFile, "(JLjava/lang/String;)" SAFFSNodeSig);
+	FIND_METHOD(, createReadStream, "(J)I");
+	FIND_METHOD(, createWriteStream, "(J)I");
+	FIND_METHOD(, removeNode, "(J)Z");
 	FIND_METHOD(, removeTree, "()V");
 
 	FIND_FIELD(, _treeName, "Ljava/lang/String;");
 	FIND_FIELD(, _root, SAFFSNodeSig);
 
+	env->DeleteLocalRef(cls);
 	cls = env->FindClass("org/scummvm/scummvm/SAFFSTree$SAFFSNode");
 
+	FIND_METHOD(, addRef, "()J");
+
 	FIND_FIELD(, _parent, SAFFSNodeSig);
 	FIND_FIELD(, _path, "Ljava/lang/String;");
 	FIND_FIELD(, _documentId, "Ljava/lang/String;");
 	FIND_FIELD(, _flags, "I");
 
+	env->DeleteLocalRef(cls);
 #undef SAFFSNodeSig
 #undef FIND_FIELD
 #undef FIND_METHOD
+#undef FIND_STATIC_METHOD
 
 	_JNIinit = true;
 }
@@ -137,6 +162,104 @@ void AndroidSAFFilesystemNode::GlobalRef::Deleter::operator()(_jobject *obj) {
 			env->DeleteGlobalRef((jobject)obj);
 }
 
+void AndroidSAFFilesystemNode::NodeRef::reset() {
+	if (_ref == 0) {
+		return;
+	}
+
+	JNIEnv *env = JNI::getEnv();
+
+	env->CallStaticVoidMethod(_CLS_SAFFSTree, _MID_decNodeRef, _ref);
+	if (env->ExceptionCheck()) {
+		LOGE("SAFFSTree::decNodeRef failed");
+		env->ExceptionDescribe();
+		env->ExceptionClear();
+	}
+	_ref = 0;
+}
+
+void AndroidSAFFilesystemNode::NodeRef::reset(const NodeRef &r) {
+	if (_ref == 0 && r._ref == 0) {
+		return;
+	}
+
+	JNIEnv *env = JNI::getEnv();
+
+	if (_ref) {
+		env->CallStaticVoidMethod(_CLS_SAFFSTree, _MID_decNodeRef, _ref);
+		if (env->ExceptionCheck()) {
+			LOGE("SAFFSTree::decNodeRef failed");
+			env->ExceptionDescribe();
+			env->ExceptionClear();
+		}
+	}
+
+	_ref = r._ref;
+	if (!_ref) {
+		return;
+	}
+
+	env->CallStaticVoidMethod(_CLS_SAFFSTree, _MID_addNodeRef, _ref);
+	if (env->ExceptionCheck()) {
+		LOGE("SAFFSTree::addNodeRef failed");
+
+		env->ExceptionDescribe();
+		env->ExceptionClear();
+		_ref = 0;
+		abort();
+	}
+}
+
+void AndroidSAFFilesystemNode::NodeRef::reset(JNIEnv *env, jobject node) {
+	if (_ref == 0 && node == nullptr) {
+		return;
+	}
+
+	if (_ref) {
+		env->CallStaticVoidMethod(_CLS_SAFFSTree, _MID_decNodeRef, _ref);
+		if (env->ExceptionCheck()) {
+			LOGE("SAFFSTree::decNodeRef failed");
+			env->ExceptionDescribe();
+			env->ExceptionClear();
+		}
+	}
+
+	if (node == nullptr) {
+		_ref = 0;
+		return;
+	}
+
+	_ref = env->CallLongMethod(node, _MID_addRef);
+	if (env->ExceptionCheck()) {
+		LOGE("SAFFSNode::addRef failed");
+
+		env->ExceptionDescribe();
+		env->ExceptionClear();
+		_ref = 0;
+		abort();
+	}
+
+	assert(_ref != 0);
+}
+
+jobject AndroidSAFFilesystemNode::NodeRef::localRef(JNIEnv *env) const {
+	if (_ref == 0) {
+		return nullptr;
+	}
+
+	jobject localRef = env->CallStaticObjectMethod(_CLS_SAFFSTree, _MID_refToNode, _ref);
+	if (env->ExceptionCheck()) {
+		LOGE("SAFFSTree::refToNode failed");
+
+		env->ExceptionDescribe();
+		env->ExceptionClear();
+
+		return nullptr;
+	}
+
+	return localRef;
+}
+
 AndroidSAFFilesystemNode *AndroidSAFFilesystemNode::makeFromPath(const Common::String &path) {
 	if (!path.hasPrefix(SAF_MOUNT_POINT)) {
 		// Not a SAF mount point
@@ -258,63 +381,40 @@ AndroidSAFFilesystemNode *AndroidSAFFilesystemNode::makeFromTree(jobject safTree
 }
 
 AndroidSAFFilesystemNode::AndroidSAFFilesystemNode(const GlobalRef &safTree, jobject safNode) :
-	_flags(0), _safParent(nullptr) {
+	_flags(0) {
 
 	JNIEnv *env = JNI::getEnv();
 
 	_safTree = safTree;
 	assert(_safTree != nullptr);
-	_safNode = env->NewGlobalRef(safNode);
-	assert(_safNode);
 
-	cacheData();
+	_safNode.reset(env, safNode);
+	cacheData(env, safNode);
 }
 
 AndroidSAFFilesystemNode::AndroidSAFFilesystemNode(const GlobalRef &safTree, jobject safParent,
-        const Common::String &path, const Common::String &name) :
-	_safNode(nullptr), _flags(0), _safParent(nullptr) {
+        const Common::String &path, const Common::String &name) : _flags(0) {
 
 	JNIEnv *env = JNI::getEnv();
-	_safTree = safTree;
-	assert(_safTree != nullptr);
 
-	_safParent = env->NewGlobalRef(safParent);
-	assert(_safParent);
+	_safTree = safTree;
+	_safParent.reset(env, safParent);
 
 	// In this case _path is the parent
 	_path = path;
 	_newName = name;
 }
 
-// We need the custom copy constructor because of the reference
-AndroidSAFFilesystemNode::AndroidSAFFilesystemNode(const AndroidSAFFilesystemNode &node)
-	: AbstractFSNode(), _safNode(nullptr), _safParent(nullptr) {
-
-	JNIEnv *env = JNI::getEnv();
-
-	_safTree = node._safTree;
-	assert(_safTree != nullptr);
-
-	if (node._safNode) {
-		_safNode = env->NewGlobalRef(node._safNode);
-		assert(_safNode);
-	}
-
-	if (node._safParent) {
-		_safParent = env->NewGlobalRef(node._safParent);
-		assert(_safParent);
-	}
-
-	_path = node._path;
-	_flags = node._flags;
-	_newName = node._newName;
-}
+AndroidSAFFilesystemNode::AndroidSAFFilesystemNode(const GlobalRef &safTree,
+		const NodeRef &safParent, const Common::String &path,
+		const Common::String &name) : _flags(0) {
 
-AndroidSAFFilesystemNode::~AndroidSAFFilesystemNode() {
-	JNIEnv *env = JNI::getEnv();
+	_safTree = safTree;
+	_safParent = safParent;
 
-	env->DeleteGlobalRef(_safNode);
-	env->DeleteGlobalRef(_safParent);
+	// In this case _path is the parent
+	_path = path;
+	_newName = name;
 }
 
 Common::String AndroidSAFFilesystemNode::getName() const {
@@ -329,7 +429,7 @@ Common::String AndroidSAFFilesystemNode::getName() const {
 Common::String AndroidSAFFilesystemNode::getPath() const {
 	assert(_safTree != nullptr);
 
-	if (_safNode != nullptr) {
+	if (_safNode) {
 		return _path;
 	}
 
@@ -339,7 +439,7 @@ Common::String AndroidSAFFilesystemNode::getPath() const {
 
 AbstractFSNode *AndroidSAFFilesystemNode::getChild(const Common::String &n) const {
 	assert(_safTree != nullptr);
-	assert(_safNode != nullptr);
+	assert(_safNode);
 
 	// Make sure the string contains no slashes
 	assert(!n.contains('/'));
@@ -348,7 +448,7 @@ AbstractFSNode *AndroidSAFFilesystemNode::getChild(const Common::String &n) cons
 
 	jstring name = env->NewStringUTF(n.c_str());
 
-	jobject child = env->CallObjectMethod(_safTree, _MID_getChild, _safNode, name);
+	jobject child = env->CallObjectMethod(_safTree, _MID_getChild, _safNode.get(), name);
 
 	env->DeleteLocalRef(name);
 
@@ -382,7 +482,7 @@ bool AndroidSAFFilesystemNode::getChildren(AbstractFSList &myList, ListMode mode
 	JNIEnv *env = JNI::getEnv();
 
 	jobjectArray array =
-	    (jobjectArray)env->CallObjectMethod(_safTree, _MID_getChildren, _safNode);
+	    (jobjectArray)env->CallObjectMethod(_safTree, _MID_getChildren, _safNode.get());
 
 	if (env->ExceptionCheck()) {
 		LOGE("SAFFSTree::getChildren failed");
@@ -419,11 +519,17 @@ AbstractFSNode *AndroidSAFFilesystemNode::getParent() const {
 	assert(_safTree != nullptr);
 	// No need to check for _safNode: if node doesn't exist yet parent is its parent
 
-	if (_safParent) {
-		return new AndroidSAFFilesystemNode(_safTree, _safParent);
+	JNIEnv *env = JNI::getEnv();
+	if (!_safParent) {
+		return AndroidFilesystemFactory::instance().makeRootFileNode();
 	}
 
-	return AndroidFilesystemFactory::instance().makeRootFileNode();
+	jobject parent = _safParent.localRef(env);
+	assert(parent);
+
+	AndroidSAFFilesystemNode *ret = new AndroidSAFFilesystemNode(_safTree, parent);
+	env->DeleteLocalRef(parent);
+	return ret;
 }
 
 Common::SeekableReadStream *AndroidSAFFilesystemNode::createReadStream() {
@@ -435,7 +541,7 @@ Common::SeekableReadStream *AndroidSAFFilesystemNode::createReadStream() {
 
 	JNIEnv *env = JNI::getEnv();
 
-	jint fd = env->CallIntMethod(_safTree, _MID_createReadStream, _safNode);
+	jint fd = env->CallIntMethod(_safTree, _MID_createReadStream, _safNode.get());
 
 	if (env->ExceptionCheck()) {
 		LOGE("SAFFSTree::createReadStream failed");
@@ -469,7 +575,7 @@ Common::SeekableWriteStream *AndroidSAFFilesystemNode::createWriteStream(bool at
 		jstring name = env->NewStringUTF(_newName.c_str());
 
 		// TODO: Add atomic support if possible
-		jobject child = env->CallObjectMethod(_safTree, _MID_createFile, _safParent, name);
+		jobject child = env->CallObjectMethod(_safTree, _MID_createFile, _safParent.get(), name);
 
 		env->DeleteLocalRef(name);
 
@@ -486,15 +592,13 @@ Common::SeekableWriteStream *AndroidSAFFilesystemNode::createWriteStream(bool at
 			return nullptr;
 		}
 
-		_safNode = env->NewGlobalRef(child);
-		assert(_safNode);
+		_safNode.reset(env, child);
+		cacheData(env, child);
 
 		env->DeleteLocalRef(child);
-
-		cacheData();
 	}
 
-	jint fd = env->CallIntMethod(_safTree, _MID_createWriteStream, _safNode);
+	jint fd = env->CallIntMethod(_safTree, _MID_createWriteStream, _safNode.get());
 	if (env->ExceptionCheck()) {
 		LOGE("SAFFSTree::createWriteStream failed");
 
@@ -530,7 +634,7 @@ bool AndroidSAFFilesystemNode::createDirectory() {
 
 	jstring name = env->NewStringUTF(_newName.c_str());
 
-	jobject child = env->CallObjectMethod(_safTree, _MID_createDirectory, _safParent, name);
+	jobject child = env->CallObjectMethod(_safTree, _MID_createDirectory, _safParent.get(), name);
 
 	env->DeleteLocalRef(name);
 
@@ -547,12 +651,11 @@ bool AndroidSAFFilesystemNode::createDirectory() {
 		return false;
 	}
 
-	_safNode = env->NewGlobalRef(child);
-	assert(_safNode);
+	_safNode.reset(env, child);
 
-	env->DeleteLocalRef(child);
+	cacheData(env, child);
 
-	cacheData();
+	env->DeleteLocalRef(child);
 
 	return true;
 }
@@ -576,7 +679,7 @@ bool AndroidSAFFilesystemNode::remove() {
 
 	JNIEnv *env = JNI::getEnv();
 
-	bool result = env->CallBooleanMethod(_safTree, _MID_removeNode, _safNode);
+	bool result = env->CallBooleanMethod(_safTree, _MID_removeNode, _safNode.get());
 
 	if (env->ExceptionCheck()) {
 		LOGE("SAFFSTree::removeNode failed");
@@ -591,11 +694,15 @@ bool AndroidSAFFilesystemNode::remove() {
 		return false;
 	}
 
-	env->DeleteGlobalRef(_safNode);
-	_safNode = nullptr;
+	_safNode.reset();
 
 	// Create the parent node to fetch informations needed to make us a non-existent node
-	AndroidSAFFilesystemNode *parent = new AndroidSAFFilesystemNode(_safTree, _safParent);
+
+	jobject jparent = _safParent.localRef(env);
+	if (!jparent)
+		return false;
+
+	AndroidSAFFilesystemNode *parent = new AndroidSAFFilesystemNode(_safTree, jparent);
 
 	size_t pos = _path.findLastOf('/');
 	if (pos == Common::String::npos) {
@@ -611,7 +718,7 @@ bool AndroidSAFFilesystemNode::remove() {
 }
 
 void AndroidSAFFilesystemNode::removeTree() {
-	assert(_safParent == nullptr);
+	assert(!_safParent);
 
 	JNIEnv *env = JNI::getEnv();
 
@@ -625,22 +732,13 @@ void AndroidSAFFilesystemNode::removeTree() {
 	}
 }
 
-void AndroidSAFFilesystemNode::cacheData() {
-	JNIEnv *env = JNI::getEnv();
+void AndroidSAFFilesystemNode::cacheData(JNIEnv *env, jobject node) {
+	_flags = env->GetIntField(node, _FID__flags);
 
-	_flags = env->GetIntField(_safNode, _FID__flags);
+	jobject safParent = env->GetObjectField(node, _FID__parent);
+	_safParent.reset(env, safParent);
 
-	jobject safParent = env->GetObjectField(_safNode, _FID__parent);
-	if (safParent) {
-		if (_safParent) {
-			env->DeleteGlobalRef(_safParent);
-		}
-		_safParent = env->NewGlobalRef(safParent);
-		assert(_safParent);
-		env->DeleteLocalRef(safParent);
-	}
-
-	if (_safParent == nullptr) {
+	if (!_safParent) {
 		jstring nameObj = (jstring)env->GetObjectField(_safTree, _FID__treeName);
 		const char *nameP = env->GetStringUTFChars(nameObj, 0);
 		if (nameP != 0) {
@@ -652,7 +750,7 @@ void AndroidSAFFilesystemNode::cacheData() {
 
 	Common::String workingPath;
 
-	jstring pathObj = (jstring)env->GetObjectField(_safNode, _FID__path);
+	jstring pathObj = (jstring)env->GetObjectField(node, _FID__path);
 	const char *path = env->GetStringUTFChars(pathObj, 0);
 	if (path == nullptr) {
 		env->DeleteLocalRef(pathObj);
diff --git a/backends/fs/android/android-saf-fs.h b/backends/fs/android/android-saf-fs.h
index e3ae99d46e0..765e878cc34 100644
--- a/backends/fs/android/android-saf-fs.h
+++ b/backends/fs/android/android-saf-fs.h
@@ -65,7 +65,53 @@ protected:
 		}
 	};
 
+	/**
+	 * A class managing our SAFFSNode references.
+	 *
+	 * Reference counting is managed by SAFFSNode in Java and this class uses
+	 * RAII to call the reference counting methods at the appropriate time.
+	 */
+	class NodeRef final {
+	private:
+		jlong _ref;
+
+	public:
+		NodeRef() : _ref(0) {}
+		~NodeRef() { reset(); }
+		NodeRef(const NodeRef &r) { reset(r); }
+		NodeRef(JNIEnv *env, jobject node) { reset(env, node); }
+
+		void reset();
+		void reset(const NodeRef &r);
+		void reset(JNIEnv *env, jobject node);
+
+		NodeRef &operator=(const NodeRef &r) {
+			reset(r);
+			return *this;
+		}
+
+		bool operator==(const NodeRef &r) const {
+			return _ref == r._ref;
+		}
+
+		bool operator!=(const NodeRef &r) const {
+			return _ref != r._ref;
+		}
+
+		explicit operator bool() const {
+			return _ref != 0;
+		}
+
+		jlong get() const { return _ref; }
+		jobject localRef(JNIEnv *env) const;
+	};
+
 	// SAFFSTree
+	static jclass    _CLS_SAFFSTree;
+
+	static jmethodID _MID_addNodeRef;
+	static jmethodID _MID_decNodeRef;
+	static jmethodID _MID_refToNode;
 	static jmethodID _MID_getTreeId;
 	static jmethodID _MID_pathToNode;
 	static jmethodID _MID_getChildren;
@@ -81,6 +127,8 @@ protected:
 	static jfieldID _FID__root;
 
 	// SAFFSNode
+	static jmethodID _MID_addRef;
+
 	static jfieldID _FID__parent;
 	static jfieldID _FID__path;
 	static jfieldID _FID__documentId;
@@ -94,26 +142,18 @@ protected:
 	static const int READABLE  = 4;
 
 	GlobalRef _safTree;
-	// When null, node doesn't exist yet
+	// When 0, node doesn't exist yet
 	// In this case _path is the parent path, _newName the node name and _safParent the parent SAF object
-	jobject _safNode;
+	NodeRef _safNode;
 
 	Common::String _path;
 	int _flags;
-	jobject _safParent;
+	NodeRef _safParent;
 
 	// Used when creating a new node
 	// Also used for root node to store its pretty name
 	Common::String _newName;
 
-	/**
-	 * Creates an AndroidSAFFilesystemNode given its tree and its node
-	 *
-	 * @param safTree SAF root in Java side
-	 * @param safNode SAF node in Java side
-	 */
-	AndroidSAFFilesystemNode(const GlobalRef &safTree, jobject safNode);
-
 public:
 	static const char SAF_MOUNT_POINT[];
 
@@ -137,19 +177,7 @@ public:
 	 */
 	static AndroidSAFFilesystemNode *makeFromTree(jobject safTree);
 
-	/**
-	 * Copy constructor.
-	 *
-	 * @note Needed because we keep references
-	 */
-	AndroidSAFFilesystemNode(const AndroidSAFFilesystemNode &node);
-
-	/**
-	 * Destructor.
-	 */
-	~AndroidSAFFilesystemNode() override;
-
-	bool exists() const override { return _safNode != nullptr; }
+	bool exists() const override { return (bool)_safNode; }
 	Common::U32String getDisplayName() const override { return Common::U32String(getName()); }
 	Common::String getName() const override;
 	Common::String getPath() const override;
@@ -173,6 +201,14 @@ public:
 	 */
 	void removeTree();
 protected:
+	/**
+	 * Creates an AndroidSAFFilesystemNode given its tree and its node
+	 *
+	 * @param safTree SAF root in Java side
+	 * @param safNode SAF node in Java side
+	 */
+	AndroidSAFFilesystemNode(const GlobalRef &safTree, jobject safNode);
+
 	/**
 	 * Creates an non-existent AndroidSAFFilesystemNode given its tree, parent node and name
 	 *
@@ -184,7 +220,18 @@ protected:
 	AndroidSAFFilesystemNode(const GlobalRef &safTree, jobject safParent,
 	                         const Common::String &path, const Common::String &name);
 
-	void cacheData();
+	/**
+	 * Creates an non-existent AndroidSAFFilesystemNode given its tree, parent node and name
+	 *
+	 * @param safTree SAF root in Java side
+	 * @param safParent SAF parent node reference in Java side
+	 * @param path Parent path
+	 * @param name Item name
+	 */
+	AndroidSAFFilesystemNode(const GlobalRef &safTree, const NodeRef &safParent,
+	                         const Common::String &path, const Common::String &name);
+
+	void cacheData(JNIEnv *env, jobject node);
 };
 
 class AddSAFFakeNode final : public AbstractFSNode, public AndroidFSNode {
diff --git a/backends/platform/android/org/scummvm/scummvm/SAFFSTree.java b/backends/platform/android/org/scummvm/scummvm/SAFFSTree.java
index fd61198d91b..fb49ace9c9e 100644
--- a/backends/platform/android/org/scummvm/scummvm/SAFFSTree.java
+++ b/backends/platform/android/org/scummvm/scummvm/SAFFSTree.java
@@ -18,6 +18,8 @@ import java.lang.ref.SoftReference;
 import java.util.ArrayDeque;
 import java.util.Collection;
 import java.util.HashMap;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicLong;
 
 /**
  * SAF primitives for C++ FSNode
@@ -26,9 +28,23 @@ import java.util.HashMap;
 public class SAFFSTree {
 	private static HashMap<String, SAFFSTree> _trees;
 
+	// This map will store the references of all our objects used
+	// by the native side.
+	// This avoids overflowing JNI will a pile of global references
+	private static ConcurrentHashMap<Long, SAFFSNode> _nodes;
+	// This atomic variable will generate unique identifiers for our objects
+	private static AtomicLong _idCounter;
+
 	public static void loadSAFTrees(Context context) {
 		final ContentResolver resolver = context.getContentResolver();
 
+		// As this function is called before starting to emit nodes,
+		// we can take the opportunity to setup the reference related stuff here
+		if (_nodes == null) {
+			_nodes = new ConcurrentHashMap<>();
+			_idCounter = new AtomicLong();
+		}
+
 		_trees = new HashMap<>();
 		for (UriPermission permission : resolver.getPersistedUriPermissions()) {
 			final Uri uri = permission.getUri();
@@ -74,6 +90,29 @@ public class SAFFSTree {
 		}
 	}
 
+	public static void addNodeRef(long nodeId) {
+		assert(nodeId != 0);
+		SAFFSNode node = _nodes.get(nodeId);
+		assert(node != null);
+
+		long newId = node.addRef();
+		assert(newId == nodeId);
+	}
+
+	public static void decNodeRef(long nodeId) {
+		assert(nodeId != 0);
+		SAFFSNode node = _nodes.get(nodeId);
+		assert(node != null);
+
+		node.decRef();
+	}
+
+	public static SAFFSNode refToNode(long nodeId) {
+		assert(nodeId != 0);
+		SAFFSNode node = _nodes.get(nodeId);
+		return node;
+	}
+
 	public static class SAFFSNode implements Comparable<SAFFSNode> {
 		public static final int DIRECTORY = 0x01;
 		public static final int WRITABLE  = 0x02;
@@ -88,9 +127,8 @@ public class SAFFSTree {
 
 		private HashMap<String, SoftReference<SAFFSNode>> _children;
 		private boolean _dirty;
-
-		private SAFFSNode() {
-		}
+		private int _refCnt; // Reference counter for the native side
+		private long _id; // Identifier for the native side
 
 		private SAFFSNode reset(SAFFSNode parent, String path, String documentId, int flags) {
 			_parent = parent;
@@ -129,6 +167,29 @@ public class SAFFSTree {
 			}
 			return _path.compareTo(o._path);
 		}
+
+		public synchronized long addRef() {
+			_refCnt += 1;
+			if (_refCnt > 1) {
+				return _id;
+			}
+			assert(_refCnt == 1);
+
+			if (_id == 0) {
+				_id = _idCounter.incrementAndGet();
+			}
+			_nodes.put(_id, this);
+
+			return _id;
+		}
+
+		public synchronized void decRef() {
+			if (_refCnt == 1) {
+				SAFFSNode tmp = _nodes.remove(_id);
+				assert(tmp == this);
+			}
+			_refCnt -= 1;
+		}
 	}
 
 	private Context _context;
@@ -221,6 +282,14 @@ public class SAFFSTree {
 		return results.toArray(new SAFFSNode[0]);
 	}
 
+	// This version is used by the C++ side
+	public SAFFSNode[] getChildren(long nodeId) {
+		SAFFSNode node = _nodes.get(nodeId);
+		assert(node != null);
+
+		return getChildren(node);
+	}
+
 	public Collection<SAFFSNode> fetchChildren(SAFFSNode node) {
 		final ContentResolver resolver = _context.getContentResolver();
 		final Uri searchUri = DocumentsContract.buildChildDocumentsUriUsingTree(_treeUri, node._documentId);
@@ -277,10 +346,6 @@ public class SAFFSTree {
 	}
 
 	public SAFFSNode getChild(SAFFSNode node, String name) {
-		final Uri searchUri = DocumentsContract.buildChildDocumentsUriUsingTree(_treeUri, node._documentId);
-
-		SAFFSNode newnode;
-
 		// This variable is used to hold a strong reference on every children nodes
 		Collection<SAFFSNode> children;
 
@@ -298,7 +363,7 @@ public class SAFFSTree {
 			return null;
 		}
 
-		newnode = ref.get();
+		SAFFSNode newnode = ref.get();
 		if (newnode != null) {
 			return newnode;
 		}
@@ -324,22 +389,62 @@ public class SAFFSTree {
 		return newnode;
 	}
 
+	// This version is used by the C++ side
+	public SAFFSNode getChild(long nodeId, String name) {
+		SAFFSNode node = _nodes.get(nodeId);
+		assert(node != null);
+
+		return getChild(node, name);
+	}
+
 	public SAFFSNode createDirectory(SAFFSNode node, String name) {
 		return createDocument(node, name, DocumentsContract.Document.MIME_TYPE_DIR);
 	}
 
+	// This version is used by the C++ side
+	public SAFFSNode createDirectory(long nodeId, String name) {
+		SAFFSNode node = _nodes.get(nodeId);
+		assert(node != null);
+
+		return createDirectory(node, name);
+	}
+
 	public SAFFSNode createFile(SAFFSNode node, String name) {
 		return createDocument(node, name, "application/octet-stream");
 	}
 
+	// This version is used by the C++ side
+	public SAFFSNode createFile(long nodeId, String name) {
+		SAFFSNode node = _nodes.get(nodeId);
+		assert(node != null);
+
+		return createFile(node, name);
+	}
+
 	public int createReadStream(SAFFSNode node) {
 		return createStream(node, "r");
 	}
 
+	// This version is used by the C++ side
+	public int createReadStream(long nodeId) {
+		SAFFSNode node = _nodes.get(nodeId);
+		assert(node != null);
+
+		return createReadStream(node);
+	}
+
 	public int createWriteStream(SAFFSNode node) {
 		return createStream(node, "wt");
 	}
 
+	// This version is used by the C++ side
+	public int createWriteStream(long nodeId) {
+		SAFFSNode node = _nodes.get(nodeId);
+		assert(node != null);
+
+		return createWriteStream(node);
+	}
+
 	public boolean removeNode(SAFFSNode node) {
 		final ContentResolver resolver = _context.getContentResolver();
 		final Uri uri = DocumentsContract.buildDocumentUriUsingTree(_treeUri, node._documentId);
@@ -372,6 +477,14 @@ public class SAFFSTree {
 		return true;
 	}
 
+	// This version is used by the C++ side
+	public boolean removeNode(long nodeId) {
+		SAFFSNode node = _nodes.get(nodeId);
+		assert(node != null);
+
+		return removeNode(node);
+	}
+
 	public void removeTree() {
 		final ContentResolver resolver = _context.getContentResolver();
 


Commit: 2fc867aa4f76d4c2344bbc73f686a5023be122c9
    https://github.com/scummvm/scummvm/commit/2fc867aa4f76d4c2344bbc73f686a5023be122c9
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-05-01T12:03:09+02:00

Commit Message:
ANDROID: Track time spent in SAF queries

This allows to monitor times where we are busy because of I/Os and we
will be able to notify the user.

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


diff --git a/backends/platform/android/org/scummvm/scummvm/SAFFSTree.java b/backends/platform/android/org/scummvm/scummvm/SAFFSTree.java
index fb49ace9c9e..5c700a18d16 100644
--- a/backends/platform/android/org/scummvm/scummvm/SAFFSTree.java
+++ b/backends/platform/android/org/scummvm/scummvm/SAFFSTree.java
@@ -18,7 +18,9 @@ import java.lang.ref.SoftReference;
 import java.util.ArrayDeque;
 import java.util.Collection;
 import java.util.HashMap;
+import java.util.Iterator;
 import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentLinkedQueue;
 import java.util.concurrent.atomic.AtomicLong;
 
 /**
@@ -26,6 +28,23 @@ import java.util.concurrent.atomic.AtomicLong;
  */
 @RequiresApi(api = Build.VERSION_CODES.N)
 public class SAFFSTree {
+	@RequiresApi(api = Build.VERSION_CODES.BASE)
+	public interface IOBusyListener {
+		public void onIOBusy(float ratio);
+	}
+
+	private static class IOTime {
+		long start;
+		long end;
+		long duration;
+	}
+	// Declare us as busy when I/O waits took more than 90% in 2 secs
+	private static final long IO_BUSINESS_TIMESPAN = 2000;
+	private static final long IO_BUSINESS_THRESHOLD = 1800;
+
+	private static ConcurrentLinkedQueue<IOTime> _lastIOs;
+	private static IOBusyListener _listener;
+
 	private static HashMap<String, SAFFSTree> _trees;
 
 	// This map will store the references of all our objects used
@@ -35,6 +54,51 @@ public class SAFFSTree {
 	// This atomic variable will generate unique identifiers for our objects
 	private static AtomicLong _idCounter;
 
+	@RequiresApi(api = Build.VERSION_CODES.BASE)
+	public static void setIOBusyListener(IOBusyListener l) {
+		if (_lastIOs == null) {
+			_lastIOs = new ConcurrentLinkedQueue<>();
+		}
+		_listener = l;
+	}
+
+	private static void reportIO(long start, long end) {
+		if (_listener == null) {
+			return;
+		}
+
+		// Register this new query
+		IOTime entry = new IOTime();
+		entry.start = start;
+		entry.end = end;
+		entry.duration = end - start;
+		_lastIOs.add(entry);
+
+		long deadline = end - IO_BUSINESS_TIMESPAN;
+		long duration = 0;
+
+		// Remove outdated entries and compute the time spent in I/Os
+		Iterator<IOTime> it = _lastIOs.iterator();
+		while (it.hasNext()) {
+			entry = it.next();
+			//Log.d(ScummVM.LOG_TAG, "ENTRY <" + Long.toString(entry.start) + " " + Long.toString(entry.end) + " " + Long.toString(entry.duration) + ">");
+			if (entry.end <= deadline) {
+				// entry is too old
+				it.remove();
+			} else if (entry.start < deadline) {
+				// This entry crossed the deadline
+				duration += entry.end - deadline;
+			} else {
+				duration += entry.duration;
+			}
+		}
+		//Log.d(ScummVM.LOG_TAG, "SUM: " + Long.toString(duration) + " DEADLINE WAS: " + Long.toString(deadline));
+
+		if (duration >= IO_BUSINESS_THRESHOLD && _listener != null) {
+			_listener.onIOBusy((float)duration / IO_BUSINESS_TIMESPAN);
+		}
+	}
+
 	public static void loadSAFTrees(Context context) {
 		final ContentResolver resolver = context.getContentResolver();
 
@@ -304,6 +368,9 @@ public class SAFFSTree {
 		HashMap<String, SoftReference<SAFFSNode>> newChildren = new HashMap<>();
 
 		Cursor c = null;
+
+		long startIO = System.currentTimeMillis();
+
 		try {
 			c = resolver.query(searchUri, new String[] { DocumentsContract.Document.COLUMN_DISPLAY_NAME,
 				DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_MIME_TYPE,
@@ -340,6 +407,9 @@ public class SAFFSTree {
 			if (c != null) {
 				c.close();
 			}
+
+			long endIO = System.currentTimeMillis();
+			reportIO(startIO, endIO);
 		}
 
 		return results;
@@ -451,20 +521,31 @@ public class SAFFSTree {
 
 		if ((node._flags & SAFFSNode.REMOVABLE) != 0) {
 			final Uri parentUri = DocumentsContract.buildDocumentUriUsingTree(_treeUri, node._parent._documentId);
+
+			long startIO = System.currentTimeMillis();
+
 			try {
 				if (!DocumentsContract.removeDocument(resolver, uri, parentUri)) {
 					return false;
 				}
 			} catch(FileNotFoundException e) {
 				return false;
+			} finally {
+				long endIO = System.currentTimeMillis();
+				reportIO(startIO, endIO);
 			}
 		} else if ((node._flags & SAFFSNode.DELETABLE) != 0) {
+			long startIO = System.currentTimeMillis();
+
 			try {
 				if (!DocumentsContract.deleteDocument(resolver, uri)) {
 					return false;
 				}
 			} catch(FileNotFoundException e) {
 				return false;
+			} finally {
+				long endIO = System.currentTimeMillis();
+				reportIO(startIO, endIO);
 			}
 		} else {
 			return false;
@@ -513,10 +594,15 @@ public class SAFFSTree {
 			}
 		}
 
+		long startIO = System.currentTimeMillis();
+
 		try {
 			newDocUri = DocumentsContract.createDocument(resolver, parentUri, mimeType, name);
 		} catch(FileNotFoundException e) {
 			return null;
+		} finally {
+			long endIO = System.currentTimeMillis();
+			reportIO(startIO, endIO);
 		}
 		if (newDocUri == null) {
 			return null;
@@ -556,10 +642,16 @@ public class SAFFSTree {
 		final Uri uri = DocumentsContract.buildDocumentUriUsingTree(_treeUri, node._documentId);
 
 		ParcelFileDescriptor pfd;
+
+		long startIO = System.currentTimeMillis();
+
 		try {
 			pfd = resolver.openFileDescriptor(uri, mode);
 		} catch(FileNotFoundException e) {
 			return null;
+		} finally {
+			long endIO = System.currentTimeMillis();
+			reportIO(startIO, endIO);
 		}
 
 		return pfd;
@@ -578,6 +670,9 @@ public class SAFFSTree {
 		final Uri uri = DocumentsContract.buildDocumentUriUsingTree(_treeUri, node._documentId);
 
 		Cursor c = null;
+
+		long startIO = System.currentTimeMillis();
+
 		try {
 			c = resolver.query(uri, new String[] { DocumentsContract.Document.COLUMN_DISPLAY_NAME,
 				DocumentsContract.Document.COLUMN_MIME_TYPE, DocumentsContract.Document.COLUMN_FLAGS }, null, null, null);
@@ -601,6 +696,9 @@ public class SAFFSTree {
 				} catch (Exception e) {
 				}
 			}
+
+			long endIO = System.currentTimeMillis();
+			reportIO(startIO, endIO);
 		}
 		// We should never end up here...
 		return null;


Commit: a06ea3b36f7711fbe5db3ee2e6b419fc4909b83f
    https://github.com/scummvm/scummvm/commit/a06ea3b36f7711fbe5db3ee2e6b419fc4909b83f
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-05-01T12:03:09+02:00

Commit Message:
ANDROID: Add a LED widget and use it to indicate IO activity in SAF

This will notify the user of IOs by blinking the LED. This indicates
that ScummVM is busy and not hung.

Changed paths:
  A backends/platform/android/org/scummvm/scummvm/LedView.java
  A dists/android/res/values/attrs.xml
    backends/platform/android/org/scummvm/scummvm/ScummVMActivity.java
    dists/android/res/layout/scummvm_activity.xml


diff --git a/backends/platform/android/org/scummvm/scummvm/LedView.java b/backends/platform/android/org/scummvm/scummvm/LedView.java
new file mode 100644
index 00000000000..a0be48df4b7
--- /dev/null
+++ b/backends/platform/android/org/scummvm/scummvm/LedView.java
@@ -0,0 +1,168 @@
+package org.scummvm.scummvm;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.util.AttributeSet;
+import android.view.View;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+
+public class LedView extends View {
+	public static final int DEFAULT_LED_COLOR = 0xffff0000;
+	private static final int BLINK_TIME = 30; // ms
+
+	private boolean _state;
+	private Runnable _blink;
+	private Paint _painter;
+	private int _radius;
+	private int _centerX;
+	private int _centerY;
+
+	public LedView(Context context) {
+		this(context, true, DEFAULT_LED_COLOR);
+	}
+
+	public LedView(Context context, boolean state) {
+			this(context, state, DEFAULT_LED_COLOR);
+	}
+
+	public LedView(Context context, boolean state, int color) {
+		super(context);
+		_state = state;
+		init(color);
+	}
+
+	public LedView(Context context, @Nullable AttributeSet attrs) {
+		this(context, attrs, 0);
+	}
+
+	public LedView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
+		super(context, attrs, defStyleAttr);
+		init(context, attrs, defStyleAttr, 0);
+	}
+
+	@RequiresApi(android.os.Build.VERSION_CODES.LOLLIPOP)
+	public LedView(
+		Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+		super(context, attrs, defStyleAttr, defStyleRes);
+		init(context, attrs, defStyleAttr, defStyleRes);
+	}
+
+	private void init(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+		TypedArray a = context.getTheme().obtainStyledAttributes(
+			attrs,
+			R.styleable.LedView,
+			defStyleAttr, defStyleRes);
+
+		try {
+			_state = a.getBoolean(R.styleable.LedView_state, true);
+			int color = a.getColor(R.styleable.LedView_color, DEFAULT_LED_COLOR);
+			init(color);
+		} finally {
+			a.recycle();
+		}
+	}
+
+	private void init(int color) {
+		_painter = new Paint();
+		_painter.setStyle(Paint.Style.FILL);
+		if (isInEditMode()) {
+			_painter.setStrokeWidth(2);
+			_painter.setStyle(_state ? Paint.Style.FILL : Paint.Style.STROKE);
+		}
+		_painter.setColor(color);
+		_painter.setAntiAlias(true);
+	}
+
+	@Override
+	protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+		int minw = getPaddingLeft() + getPaddingRight() + getSuggestedMinimumWidth();
+		int w = resolveSizeAndState(minw, widthMeasureSpec, 0);
+
+		int minh = MeasureSpec.getSize(w) - getPaddingLeft() - getPaddingRight() +
+			getPaddingBottom() + getPaddingTop();
+		int h = resolveSizeAndState(minh, heightMeasureSpec, 0);
+
+		setMeasuredDimension(w, h);
+	}
+
+	@Override
+	protected void onSizeChanged(int w, int h, int oldw, int oldh) {
+		super.onSizeChanged(w, h, oldw, oldh);
+
+		int xpad = (getPaddingLeft() + getPaddingRight());
+		int ypad = (getPaddingTop() + getPaddingBottom());
+
+		int ww = w - xpad;
+		int hh = h - ypad;
+
+		_radius = Math.min(ww, hh) / 2 - 2;
+		_centerX = w / 2;
+		_centerY = h / 2;
+	}
+
+	@Override
+	protected void onDraw(@NonNull Canvas canvas) {
+		super.onDraw(canvas);
+
+		if (!_state && !isInEditMode()) {
+			return;
+		}
+
+		canvas.drawCircle(_centerX, _centerY, _radius, _painter);
+	}
+
+	public void on() {
+		setState(true);
+	}
+
+	public void off() {
+		setState(false);
+	}
+
+	public void setState(boolean state) {
+		if (_blink != null) {
+			removeCallbacks(_blink);
+			_blink = null;
+		}
+
+		if (_state == state) {
+			return;
+		}
+		_state = state;
+		invalidate();
+	}
+
+	public void blinkOnce() {
+		if (_blink != null) {
+			return;
+		}
+
+		boolean oldState = _state;
+		_state = !oldState;
+		invalidate();
+
+		_blink = new Runnable() {
+			private boolean _ran;
+
+			@Override
+			public void run() {
+				if (_ran) {
+					_blink = null;
+					return;
+				}
+
+				_ran = true;
+				_state = oldState;
+				invalidate();
+
+				postDelayed(this, BLINK_TIME);
+			}
+		};
+		postDelayed(_blink, BLINK_TIME);
+	}
+}
diff --git a/backends/platform/android/org/scummvm/scummvm/ScummVMActivity.java b/backends/platform/android/org/scummvm/scummvm/ScummVMActivity.java
index fa1bc4937cc..32f44cb8b3d 100644
--- a/backends/platform/android/org/scummvm/scummvm/ScummVMActivity.java
+++ b/backends/platform/android/org/scummvm/scummvm/ScummVMActivity.java
@@ -117,6 +117,7 @@ public class ScummVMActivity extends Activity implements OnKeyboardVisibilityLis
 	private GridLayout _buttonLayout = null;
 	private ImageView _toggleTouchModeKeyboardBtnIcon = null;
 	private ImageView _openMenuBtnIcon = null;
+	private LedView _ioLed = null;
 	private int _layoutOrientation;
 
 	public View _screenKeyboard = null;
@@ -531,6 +532,9 @@ public class ScummVMActivity extends Activity implements OnKeyboardVisibilityLis
 			params = (GridLayout.LayoutParams)_toggleTouchModeKeyboardBtnIcon.getLayoutParams();
 			params.rowSpec = GridLayout.spec(1);
 			params.columnSpec = GridLayout.spec(1);
+			params = (GridLayout.LayoutParams)_ioLed.getLayoutParams();
+			params.rowSpec = GridLayout.spec(0, 2, GridLayout.TOP);
+			params.columnSpec = GridLayout.spec(0, GridLayout.RIGHT);
 		} else {
 			GridLayout.LayoutParams params;
 			params = (GridLayout.LayoutParams)_openMenuBtnIcon.getLayoutParams();
@@ -539,6 +543,9 @@ public class ScummVMActivity extends Activity implements OnKeyboardVisibilityLis
 			params = (GridLayout.LayoutParams)_toggleTouchModeKeyboardBtnIcon.getLayoutParams();
 			params.rowSpec = GridLayout.spec(0);
 			params.columnSpec = GridLayout.spec(0);
+			params = (GridLayout.LayoutParams)_ioLed.getLayoutParams();
+			params.rowSpec = GridLayout.spec(1, GridLayout.TOP);
+			params.columnSpec = GridLayout.spec(0, 2, GridLayout.RIGHT);
 		}
 		_buttonLayout.requestLayout();
 	}
@@ -932,6 +939,7 @@ public class ScummVMActivity extends Activity implements OnKeyboardVisibilityLis
 		_buttonLayout = findViewById(R.id.button_layout);
 		_openMenuBtnIcon = findViewById(R.id.open_menu_button);
 		_toggleTouchModeKeyboardBtnIcon = findViewById(R.id.toggle_touch_button);
+		_ioLed = findViewById(R.id.io_led);
 
 		// Hide by default all buttons, they will be shown when native code will start
 		showToggleOnScreenBtnIcons(0);
@@ -1043,6 +1051,18 @@ public class ScummVMActivity extends Activity implements OnKeyboardVisibilityLis
 			_main_surface.setOnHoverListener(_mouseHelper);
 		}
 
+		SAFFSTree.setIOBusyListener(new SAFFSTree.IOBusyListener() {
+			@Override
+			public void onIOBusy(float ratio) {
+				runOnUiThread(new Runnable() {
+					@Override
+					public void run() {
+						_ioLed.blinkOnce();
+					}
+				});
+			}
+		});
+
 		_scummvm_thread = new Thread(null, _scummvm, "ScummVM", 8388608); // 8MB
 		_scummvm_thread.start();
 	}
@@ -1159,6 +1179,8 @@ public class ScummVMActivity extends Activity implements OnKeyboardVisibilityLis
 
 		super.onDestroy();
 
+		SAFFSTree.setIOBusyListener(null);
+
 		if (isScreenKeyboardShown()) {
 			hideScreenKeyboard();
 		}
diff --git a/dists/android/res/layout/scummvm_activity.xml b/dists/android/res/layout/scummvm_activity.xml
index 9cb4c23b74a..7f1ed905053 100644
--- a/dists/android/res/layout/scummvm_activity.xml
+++ b/dists/android/res/layout/scummvm_activity.xml
@@ -43,6 +43,16 @@
 			android:src="@drawable/ic_action_menu"
 			android:visibility="gone"
 			tools:visibility="visible" />
+
+		<org.scummvm.scummvm.LedView
+			android:id="@+id/io_led"
+			android:layout_width="10dp"
+			android:layout_height="wrap_content"
+			android:layout_row="1"
+			android:layout_column="0"
+			android:layout_columnSpan="2"
+			android:layout_gravity="right|top"
+			app:state="false" />
 	</GridLayout>
 
 </FrameLayout>
diff --git a/dists/android/res/values/attrs.xml b/dists/android/res/values/attrs.xml
new file mode 100644
index 00000000000..5fb1fcb147f
--- /dev/null
+++ b/dists/android/res/values/attrs.xml
@@ -0,0 +1,6 @@
+<resources>
+   <declare-styleable name="LedView">
+	   <attr name="color" format="reference|color" />
+	   <attr name="state" format="boolean" />
+   </declare-styleable>
+</resources>


Commit: b6b0ef7379f62e9bc3a721cf92d76bd1ba459ddd
    https://github.com/scummvm/scummvm/commit/b6b0ef7379f62e9bc3a721cf92d76bd1ba459ddd
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-05-01T12:03:09+02:00

Commit Message:
ANDROID: Use the proper USAGE category

We are more game than movies.

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


diff --git a/backends/platform/android/org/scummvm/scummvm/CompatHelpers.java b/backends/platform/android/org/scummvm/scummvm/CompatHelpers.java
index 2d0dc9414db..93067e82067 100644
--- a/backends/platform/android/org/scummvm/scummvm/CompatHelpers.java
+++ b/backends/platform/android/org/scummvm/scummvm/CompatHelpers.java
@@ -302,7 +302,7 @@ class CompatHelpers {
 				AudioTrackCompatReturn ret = new AudioTrackCompatReturn();
 				ret.audioTrack = new AudioTrack(
 					new AudioAttributes.Builder()
-						.setUsage(AudioAttributes.USAGE_MEDIA)
+						.setUsage(AudioAttributes.USAGE_GAME)
 						.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
 						.build(),
 					new AudioFormat.Builder()
@@ -323,7 +323,7 @@ class CompatHelpers {
 				AudioTrackCompatReturn ret = new AudioTrackCompatReturn();
 				ret.audioTrack = new AudioTrack(
 					new AudioAttributes.Builder()
-						.setUsage(AudioAttributes.USAGE_MEDIA)
+						.setUsage(AudioAttributes.USAGE_GAME)
 						.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
 						.build(),
 					new AudioFormat.Builder()


Commit: dd12e33c26178228b43f1d4d461697b990492d98
    https://github.com/scummvm/scummvm/commit/dd12e33c26178228b43f1d4d461697b990492d98
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-05-01T12:03:10+02:00

Commit Message:
ANDROID: Improve emulated gamepad buttons handling

When triggering buttons on the virtual gamepad, the down and up events
are sent on release. That's a leftover from the previous virtual gamepad
which didn't allow to select the buttons easily.
On the new virtual gamepad, the button choice is better so we can
trigger a button down as soon as the finger is on it.
This fixes a bug in some AGS games which missed the button press because
the release was sent too soon.
Fixes Trac#15444.

Changed paths:
    backends/platform/android/touchcontrols.cpp


diff --git a/backends/platform/android/touchcontrols.cpp b/backends/platform/android/touchcontrols.cpp
index 73aa8965e40..1287d5793be 100644
--- a/backends/platform/android/touchcontrols.cpp
+++ b/backends/platform/android/touchcontrols.cpp
@@ -553,10 +553,26 @@ bool TouchControls::FunctionRight::isInside(int x, int y) {
 }
 
 void TouchControls::FunctionRight::touch(int dX, int dY, Action action) {
-	if (action == JACTION_CANCEL) {
+	static const Common::JoystickButton buttons[] = {
+		Common::JOYSTICK_BUTTON_Y, Common::JOYSTICK_BUTTON_B,
+		Common::JOYSTICK_BUTTON_A, Common::JOYSTICK_BUTTON_X
+	};
+	static const Common::JoystickButton modifiers[] = {
+		Common::JOYSTICK_BUTTON_INVALID, Common::JOYSTICK_BUTTON_INVALID,
+		Common::JOYSTICK_BUTTON_INVALID, Common::JOYSTICK_BUTTON_INVALID
+	};
+	if (action == JACTION_CANCEL ||
+		action == JACTION_UP) {
+		if (button) {
+			buttonUp(buttons[button - 1]);
+			buttonUp(modifiers[button - 1]);
+		}
+		resetState();
 		return;
 	}
 
+	uint32 newButton = 0;
+
 	// norm 2 squared (to avoid square root)
 	unsigned int sqNorm = (unsigned int)(dX * dX) + (unsigned int)(dY * dY);
 
@@ -573,36 +589,35 @@ void TouchControls::FunctionRight::touch(int dX, int dY, Action action) {
 		if (adY <= adX) {
 			// X or B
 			if (dX < 0) {
-				button = 4;
+				newButton = 4;
 			} else {
-				button = 2;
+				newButton = 2;
 			}
 		} else {
 			// Y or A
 			if (dY < 0) {
-				button = 1;
+				newButton = 1;
 			} else {
-				button = 3;
+				newButton = 3;
 			}
 
 		}
 	} else {
-		button = 0;
+		newButton = 0;
 	}
 
-	static const Common::JoystickButton buttons[] = {
-		Common::JOYSTICK_BUTTON_Y, Common::JOYSTICK_BUTTON_B,
-		Common::JOYSTICK_BUTTON_A, Common::JOYSTICK_BUTTON_X
-	};
-	static const Common::JoystickButton modifiers[] = {
-		Common::JOYSTICK_BUTTON_INVALID, Common::JOYSTICK_BUTTON_INVALID,
-		Common::JOYSTICK_BUTTON_INVALID, Common::JOYSTICK_BUTTON_INVALID
-	};
-	if (action == JACTION_UP && button) {
-		buttonDown(modifiers[button - 1]);
-		buttonPress(buttons[button - 1]);
-		buttonUp(modifiers[button - 1]);
-		button = 0;
+	if (button != newButton) {
+		// Release the previously pressed button, if any
+		if (button) {
+			buttonUp(buttons[button - 1]);
+			buttonUp(modifiers[button - 1]);
+		}
+		button = newButton;
+		// Press the new button
+		if (button) {
+			buttonDown(modifiers[button - 1]);
+			buttonDown(buttons[button - 1]);
+		}
 	}
 }
 
@@ -658,10 +673,26 @@ bool TouchControls::FunctionCenter::isInside(int x, int y) {
 }
 
 void TouchControls::FunctionCenter::touch(int dX, int dY, Action action) {
-	if (action == JACTION_CANCEL) {
+	static const Common::JoystickButton buttons[] = {
+		Common::JOYSTICK_BUTTON_GUIDE, Common::JOYSTICK_BUTTON_RIGHT_STICK,
+		Common::JOYSTICK_BUTTON_START, Common::JOYSTICK_BUTTON_LEFT_STICK
+	};
+	static const Common::JoystickButton modifiers[] = {
+		Common::JOYSTICK_BUTTON_INVALID, Common::JOYSTICK_BUTTON_INVALID,
+		Common::JOYSTICK_BUTTON_INVALID, Common::JOYSTICK_BUTTON_INVALID
+	};
+	if (action == JACTION_CANCEL ||
+		action == JACTION_UP) {
+		if (button) {
+			buttonUp(buttons[button - 1]);
+			buttonUp(modifiers[button - 1]);
+		}
+		resetState();
 		return;
 	}
 
+	uint32 newButton = 0;
+
 	// norm 2 squared (to avoid square root)
 	unsigned int sqNorm = (unsigned int)(dX * dX) + (unsigned int)(dY * dY);
 
@@ -678,36 +709,35 @@ void TouchControls::FunctionCenter::touch(int dX, int dY, Action action) {
 		if (adY <= adX) {
 			// X or B
 			if (dX < 0) {
-				button = 4;
+				newButton = 4;
 			} else {
-				button = 2;
+				newButton = 2;
 			}
 		} else {
 			// Y or A
 			if (dY < 0) {
-				button = 1;
+				newButton = 1;
 			} else {
-				button = 3;
+				newButton = 3;
 			}
 
 		}
 	} else {
-		button = 0;
+		newButton = 0;
 	}
 
-	static const Common::JoystickButton buttons[] = {
-		Common::JOYSTICK_BUTTON_GUIDE, Common::JOYSTICK_BUTTON_RIGHT_STICK,
-		Common::JOYSTICK_BUTTON_START, Common::JOYSTICK_BUTTON_LEFT_STICK
-	};
-	static const Common::JoystickButton modifiers[] = {
-		Common::JOYSTICK_BUTTON_INVALID, Common::JOYSTICK_BUTTON_INVALID,
-		Common::JOYSTICK_BUTTON_INVALID, Common::JOYSTICK_BUTTON_INVALID
-	};
-	if (action == JACTION_UP && button) {
-		buttonDown(modifiers[button - 1]);
-		buttonPress(buttons[button - 1]);
-		buttonUp(modifiers[button - 1]);
-		button = 0;
+	if (button != newButton) {
+		// Release the previously pressed button, if any
+		if (button) {
+			buttonUp(buttons[button - 1]);
+			buttonUp(modifiers[button - 1]);
+		}
+		button = newButton;
+		// Press the new button
+		if (button) {
+			buttonDown(modifiers[button - 1]);
+			buttonDown(buttons[button - 1]);
+		}
 	}
 }
 


Commit: b33be76e36890613ee3723fc62eaafbd2ac0bf41
    https://github.com/scummvm/scummvm/commit/b33be76e36890613ee3723fc62eaafbd2ac0bf41
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-05-01T12:03:10+02:00

Commit Message:
ANDROID: Make overlay icons visible above white screen

This is needed for GLK games.
The rendering over black screen is kept the same by changing alpha and
color.

Changed paths:
    dists/android/res/drawable/ic_action_gamepad.xml
    dists/android/res/drawable/ic_action_keyboard.xml
    dists/android/res/drawable/ic_action_menu.xml
    dists/android/res/drawable/ic_action_mouse.xml
    dists/android/res/drawable/ic_action_touchpad.xml


diff --git a/dists/android/res/drawable/ic_action_gamepad.xml b/dists/android/res/drawable/ic_action_gamepad.xml
index 392d5dee7d9..8f7fdfa7fb1 100644
--- a/dists/android/res/drawable/ic_action_gamepad.xml
+++ b/dists/android/res/drawable/ic_action_gamepad.xml
@@ -3,8 +3,8 @@
     android:height="48dp"
     android:viewportWidth="48"
     android:viewportHeight="48"
-    android:alpha="0.5">
+    android:alpha="0.65">
   <path
-      android:fillColor="#ffffff"
+      android:fillColor="#c4c4c4"
       android:pathData="M42,12L6,12c-2.2,0 -4,1.8 -4,4v16c0,2.2 1.8,4 4,4h36c2.2,0 4,-1.8 4,-4L46,16c0,-2.2 -1.8,-4 -4,-4zM22,26L16,26v6L12,32v-6L6,26v-4h6L12,16h4v6h6v4zM31,30c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3 3,1.34 3,3 -1.34,3 -3,3zM39,24c-1.66,0 -3,-1.34 -3,-3S37.34,18 39,18s3,1.34 3,3 -1.34,3 -3,3z"/>
 </vector>
diff --git a/dists/android/res/drawable/ic_action_keyboard.xml b/dists/android/res/drawable/ic_action_keyboard.xml
index 59bc2e92e5e..f82b9803f67 100644
--- a/dists/android/res/drawable/ic_action_keyboard.xml
+++ b/dists/android/res/drawable/ic_action_keyboard.xml
@@ -5,10 +5,8 @@
     android:height="48dp"
     android:viewportWidth="48"
     android:viewportHeight="48"
-    android:alpha="0.5">
+    android:alpha="0.65">
     <path
-        android:name="path"
-        android:pathData="M 40 10 L 8 10 C 5.79 10 4.02 11.79 4.02 14 L 4 34 C 4 36.21 5.79 38 8 38 L 40 38 C 42.21 38 44 36.21 44 34 L 44 14 C 44 11.79 42.21 10 40 10 Z M 22 16 L 26 16 L 26 20 L 22 20 L 22 16 Z M 22 22 L 26 22 L 26 26 L 22 26 L 22 22 Z M 16 16 L 20 16 L 20 20 L 16 20 L 16 16 Z M 16 22 L 20 22 L 20 26 L 16 26 L 16 22 Z M 14 26 L 10 26 L 10 22 L 14 22 L 14 26 Z M 14 20 L 10 20 L 10 16 L 14 16 L 14 20 Z M 32 34 L 16 34 L 16 30 L 32 30 L 32 34 Z M 32 26 L 28 26 L 28 22 L 32 22 L 32 26 Z M 32 20 L 28 20 L 28 16 L 32 16 L 32 20 Z M 38 26 L 34 26 L 34 22 L 38 22 L 38 26 Z M 38 20 L 34 20 L 34 16 L 38 16 L 38 20 Z"
-        android:fillColor="#ffffff"
-        android:strokeWidth="1"/>
+      android:fillColor="#c4c4c4"
+      android:pathData="M 40 10 L 8 10 C 5.79 10 4.02 11.79 4.02 14 L 4 34 C 4 36.21 5.79 38 8 38 L 40 38 C 42.21 38 44 36.21 44 34 L 44 14 C 44 11.79 42.21 10 40 10 Z M 22 16 L 26 16 L 26 20 L 22 20 L 22 16 Z M 22 22 L 26 22 L 26 26 L 22 26 L 22 22 Z M 16 16 L 20 16 L 20 20 L 16 20 L 16 16 Z M 16 22 L 20 22 L 20 26 L 16 26 L 16 22 Z M 14 26 L 10 26 L 10 22 L 14 22 L 14 26 Z M 14 20 L 10 20 L 10 16 L 14 16 L 14 20 Z M 32 34 L 16 34 L 16 30 L 32 30 L 32 34 Z M 32 26 L 28 26 L 28 22 L 32 22 L 32 26 Z M 32 20 L 28 20 L 28 16 L 32 16 L 32 20 Z M 38 26 L 34 26 L 34 22 L 38 22 L 38 26 Z M 38 20 L 34 20 L 34 16 L 38 16 L 38 20 Z"/>
 </vector>
diff --git a/dists/android/res/drawable/ic_action_menu.xml b/dists/android/res/drawable/ic_action_menu.xml
index 3bedcfbe73c..354c69b971f 100644
--- a/dists/android/res/drawable/ic_action_menu.xml
+++ b/dists/android/res/drawable/ic_action_menu.xml
@@ -5,10 +5,8 @@
     android:height="48dp"
     android:viewportWidth="48"
     android:viewportHeight="48"
-    android:alpha="0.5">
+    android:alpha="0.65">
     <path
-        android:name="path"
-        android:pathData="M 6 36 L 42 36 L 42 32 L 6 32 L 6 36 Z M 6 26 L 42 26 L 42 22 L 6 22 L 6 26 Z M 6 12 L 6 16 L 42 16 L 42 12 L 6 12 Z"
-        android:fillColor="#ffffff"
-        android:strokeWidth="1"/>
+      android:fillColor="#c4c4c4"
+      android:pathData="M 6 36 L 42 36 L 42 32 L 6 32 L 6 36 Z M 6 26 L 42 26 L 42 22 L 6 22 L 6 26 Z M 6 12 L 6 16 L 42 16 L 42 12 L 6 12 Z"/>
 </vector>
diff --git a/dists/android/res/drawable/ic_action_mouse.xml b/dists/android/res/drawable/ic_action_mouse.xml
index b9c57cc68d3..b94b7d7dc07 100644
--- a/dists/android/res/drawable/ic_action_mouse.xml
+++ b/dists/android/res/drawable/ic_action_mouse.xml
@@ -3,8 +3,8 @@
     android:height="48dp"
     android:viewportWidth="48"
     android:viewportHeight="48"
-    android:alpha="0.5">
+    android:alpha="0.65">
   <path
-      android:fillColor="#ffffff"
+      android:fillColor="#c4c4c4"
       android:pathData="M26,2.14L26,18h14c0,-8.16 -6.1,-14.88 -14,-15.86zM8,30c0,8.84 7.16,16 16,16s16,-7.16 16,-16v-8L8,22v8zM22,2.14C14.1,3.12 8,9.84 8,18h14L22,2.14z"/>
 </vector>
diff --git a/dists/android/res/drawable/ic_action_touchpad.xml b/dists/android/res/drawable/ic_action_touchpad.xml
index 3959ccb6f8e..8dd1988b3cc 100644
--- a/dists/android/res/drawable/ic_action_touchpad.xml
+++ b/dists/android/res/drawable/ic_action_touchpad.xml
@@ -5,13 +5,11 @@
     android:height="48dp"
     android:viewportWidth="48"
     android:viewportHeight="48"
-    android:alpha="0.5">
+    android:alpha="0.65">
   <path
-      android:fillColor="#ffffff"
-      android:pathData="M37.78,29.5l-8.18,-4.08c-0.56,-0.28 -1.16,-0.42 -1.78,-0.42H26.0v-12.0C26.0,11.34 24.66,10.0 23.0,10.0S20.0,11.34 20.0,13.0v21.48L13.5,33.0c-0.66,-0.14 -1.36,0.06 -1.84,0.56L10.0,35.24l9.08,9.58C19.84,45.58 21.36,46.0 22.42,46.0h12.32c2.0,0.0 3.68,-1.46 3.96,-3.44l1.26,-8.92C40.2,31.94 39.32,30.28 37.78,29.5z"
-	  android:strokeWidth="1"/>
+      android:fillColor="#c4c4c4"
+      android:pathData="M37.78,29.5l-8.18,-4.08c-0.56,-0.28 -1.16,-0.42 -1.78,-0.42H26.0v-12.0C26.0,11.34 24.66,10.0 23.0,10.0S20.0,11.34 20.0,13.0v21.48L13.5,33.0c-0.66,-0.14 -1.36,0.06 -1.84,0.56L10.0,35.24l9.08,9.58C19.84,45.58 21.36,46.0 22.42,46.0h12.32c2.0,0.0 3.68,-1.46 3.96,-3.44l1.26,-8.92C40.2,31.94 39.32,30.28 37.78,29.5z"/>
   <path
-      android:fillColor="#ffffff"
-      android:pathData="M40.26,7.74C37.38,4.34 31.2,2.0 24.0,2.0S10.62,4.34 7.74,7.74L4.0,4.0v10.0h10.0L9.86,9.86c2.0,-2.58 7.4,-4.86 14.14,-4.86s12.14,2.28 14.14,4.86L34.0,14.0h10.0V4.0L40.26,7.74z"
-	  android:strokeWidth="1"/>
+      android:fillColor="#c4c4c4"
+      android:pathData="M40.26,7.74C37.38,4.34 31.2,2.0 24.0,2.0S10.62,4.34 7.74,7.74L4.0,4.0v10.0h10.0L9.86,9.86c2.0,-2.58 7.4,-4.86 14.14,-4.86s12.14,2.28 14.14,4.86L34.0,14.0h10.0V4.0L40.26,7.74z"/>
 </vector>


Commit: 974b9733d96be96570058db6cc48aee78fdfda1b
    https://github.com/scummvm/scummvm/commit/974b9733d96be96570058db6cc48aee78fdfda1b
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-05-01T12:03:10+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 89d727de4cf..bd7d9fd6a45 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"
@@ -122,7 +122,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: 19c529c45c90591eeba7e7a1ef15ad5d167069d8
    https://github.com/scummvm/scummvm/commit/19c529c45c90591eeba7e7a1ef15ad5d167069d8
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-05-01T12:03:10+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: cf0d96fd7771ea6a425a9bc85951096a1c5653e8
    https://github.com/scummvm/scummvm/commit/cf0d96fd7771ea6a425a9bc85951096a1c5653e8
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-05-01T12:03:10+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: cc7b653ca13962bec26f4f50c5e955fa35fe5b57
    https://github.com/scummvm/scummvm/commit/cc7b653ca13962bec26f4f50c5e955fa35fe5b57
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-05-01T12:03:10+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: 4fdf1217628d4daf8acb4ce96b71e96a32718632
    https://github.com/scummvm/scummvm/commit/4fdf1217628d4daf8acb4ce96b71e96a32718632
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-05-01T12:03:10+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: 5b2a28c046162ffc0e89a7c6cd7b14d9c9d40cad
    https://github.com/scummvm/scummvm/commit/5b2a28c046162ffc0e89a7c6cd7b14d9c9d40cad
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-05-01T12:03:10+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: 28da272bf4110bc322bc95ad53c83a8f090822ee
    https://github.com/scummvm/scummvm/commit/28da272bf4110bc322bc95ad53c83a8f090822ee
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-05-01T12:03:10+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: 017fd18f487e899fffecd018d945e40bd407e7bd
    https://github.com/scummvm/scummvm/commit/017fd18f487e899fffecd018d945e40bd407e7bd
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-05-01T12:03:10+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: d329c8119526108ac05005769bccec1e95ad68b3
    https://github.com/scummvm/scummvm/commit/d329c8119526108ac05005769bccec1e95ad68b3
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-05-01T12:03:10+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;


Commit: 9c72161bb9d415700ccfefc62f63a6ecf2c15539
    https://github.com/scummvm/scummvm/commit/9c72161bb9d415700ccfefc62f63a6ecf2c15539
Author: Le Philousophe (lephilousophe at users.noreply.github.com)
Date: 2025-05-01T12:03:10+02:00

Commit Message:
DISTS: ANDROID: New versionCodes for 2.9.1

Changed paths:
    dists/android/build.gradle


diff --git a/dists/android/build.gradle b/dists/android/build.gradle
index bd7d9fd6a45..22a11671cff 100644
--- a/dists/android/build.gradle
+++ b/dists/android/build.gradle
@@ -44,7 +44,7 @@ android {
         targetSdkVersion 35
 
         versionName "2.9.0"
-        versionCode 2090001
+        versionCode 2091000
         // versioncode must be incremented for each market upload
         // maximum versioncode allowed by Play Store is: 2100000000
         // After 2.8.0 release the versioncode numbering schema changed to be more immediately helpful.
@@ -57,6 +57,7 @@ android {
         //     (0: unspecified/fat, 1: arm-v7a, 2: arm64-v8a, 3: x86, 4: x86_64)
         // eg. ScummVM 2.10.0 builds would have version codes: 2100000 - 2100004
         // --------------
+        // ScummVM 2.9.1:   2091000 - 2091004 (unspec/fat, arm-v7a, arm64-v8a, x86, x86_64 respectively)
         // ScummVM 2.9.0:   2090001 - 2090004 (arm-v7a, arm64-v8a, x86, x86_64 respectively)
         // ScummVM 2.8.1.1: 2081011 - 2081014 (arm-v7a, arm64-v8a, x86, x86_64 respectively) (release on Play Store)
         // ScummVM 2.8.1:   2081001 - 2081004 (arm-v7a, arm64-v8a, x86, x86_64 respectively) (rejected on Play Store)




More information about the Scummvm-git-logs mailing list