From bb4332bdc2ff32aa41bdb7354731ac1ef38f7d9e Mon Sep 17 00:00:00 2001 From: "Ryan C. Gordon" Date: Mon, 20 Apr 2026 20:18:45 -0400 Subject: [PATCH] android: Parse the APK directory tree, replace AAssetManager enum/getpathinfo. The AAssetManager won't supply this information to us, but it's just a zip file, so we can read its central directory ourselves. We can still use AAssetManager for file i/o to the APK's assets, so we don't have to deal with compression and other zipfile features. Fixes #15220. Reference Issue #15347. --- src/core/android/SDL_android.c | 461 ++++++++++++++++++++++++++++++--- 1 file changed, 429 insertions(+), 32 deletions(-) diff --git a/src/core/android/SDL_android.c b/src/core/android/SDL_android.c index 07f71600e3..18ec7a8a1c 100644 --- a/src/core/android/SDL_android.c +++ b/src/core/android/SDL_android.c @@ -44,6 +44,7 @@ #include #include #include +#include #define SDL_JAVA_PREFIX org_libsdl_app #define CONCAT1(prefix, class, function) CONCAT2(prefix, class, function) @@ -1838,6 +1839,398 @@ static bool Android_JNI_ExceptionOccurred(bool silent) return false; } + +// APK file tree discovery... + +// APK files are just .zip files, so try to find parse out the file tree from it. We'll still +// use Android's system APIs to actually access data, but those APIs aren't reliable for +// enumerating the tree for various reasons. + +// we only care about the directories; Android's AAssetManager can enumerate all files in any +// known directory, but it won't enumerate subdirectories, so we track that by ourselves. +typedef struct APKNode +{ + char *name; + SDL_PathInfo info; + struct APKNode *children; + struct APKNode *next_sibling; +} APKNode; + +static APKNode *APKRootNode = NULL; + +static void FreeAPKNode(APKNode *node) +{ + if (node) { + FreeAPKNode(node->next_sibling); + FreeAPKNode(node->children); + SDL_free(node->name); + SDL_free(node); + } +} + +static APKNode *FindAPKChildNode(APKNode *parent, const char *child) +{ + for (APKNode *node = parent->children; node != NULL; node = node->next_sibling) { + if (SDL_strcmp(child, node->name) == 0) { + return node; + } + } + return NULL; +} + +static const APKNode *FindAPKNode(const char *constpath) +{ + APKNode *parent = APKRootNode; + if (!parent) { + return NULL; + } + + const size_t pathlen = SDL_strlen(constpath); + bool isstack = false; + char *alloc_path = SDL_small_alloc(char, pathlen + 1, &isstack); + if (!alloc_path) { + return NULL; + } + char *path = alloc_path; + SDL_strlcpy(path, constpath, pathlen + 1); + + while (parent) { + while (*path == '/') { + path++; // just in case there are absolute paths or double-slashes, drop them. + } + + if (*path == '\0') { // ended with a '/'? We're done. + break; + } + + char *ptr = SDL_strchr(path, '/'); + if (ptr) { + *ptr = '\0'; // terminate on the end of this subdir's name. + } + + APKNode *node = FindAPKChildNode(parent, path); + if (!node) { + SDL_SetError("No such file or directory"); + parent = NULL; + } else if ((node->info.type == SDL_PATHTYPE_FILE) && ptr) { // file where we want a directory? + SDL_SetError("%s is not a directory", alloc_path); + parent = NULL; + } else { + parent = node; + if (!ptr) { + break; + } + *ptr = '/'; + path = ptr + 1; + } + } + + SDL_small_free(alloc_path, isstack); + return parent; +} + +static APKNode *AddAPKChildNode(APKNode *parent, const char *child) +{ + APKNode *node = FindAPKChildNode(parent, child); + if (!node) { // don't have this one yet, make a new node. + node = (APKNode *) SDL_calloc(1, sizeof (*node)); + if (!node) { + return NULL; // uhoh. + } + + node->name = SDL_strdup(child); + if (!node->name) { + SDL_free(node); + return NULL; // uhoh. + } + + SDL_copyp(&node->info, &parent->info); // you probably need to update this afterwards. + + node->next_sibling = parent->children; + parent->children = node; + } + return node; +} + +static APKNode *AddAPKDirs(char *path, APKNode *parent) +{ + // zip files specify explicit directories by just having a path that ends with a dir separator, + // which works nicely for our needs here; if the last segment of the path doesn't end with a + // '/', it's a file and we can drop it, or we filled in the final subdirectory and the '/' at + // the end will put us at an empty string to be dropped. + // + // directories do not need to be explicitly specified if something uses some deeper path, they + // may need to be inferred from those references, so we build out the tree by looking at all + // files and filling in nodes they mention. + + SDL_assert(parent->info.type == SDL_PATHTYPE_DIRECTORY); + SDL_assert(parent->info.size == 0); + + while (true) { // while still subdirectories to handle... + while (*path == '/') { + path++; // just in case there are absolute paths or double-slashes, drop them. + } + + char *ptr = SDL_strchr(path, '/'); + if (!ptr) { + break; // last thing is either an empty string (we ended with a '/'), or an actual file's name, so drop it. + } + + *ptr = '\0'; // terminate on the end of this subdir's name. + APKNode *node = AddAPKChildNode(parent, path); + *ptr = '/'; + + if (!node) { + return NULL; // uhoh. + } + + parent = node; + path = ptr + 1; // point to start of next section. + } + + return parent; +} + + +static SDL_Time ZipDosTimeToSDLTime(Uint32 dostime) +{ + Uint32 dosdate; + struct tm unixtime; + SDL_zero(unixtime); + + dosdate = (Uint32) ((dostime >> 16) & 0xFFFF); + dostime &= 0xFFFF; + + /* dissect date */ + unixtime.tm_year = ((dosdate >> 9) & 0x7F) + 80; + unixtime.tm_mon = ((dosdate >> 5) & 0x0F) - 1; + unixtime.tm_mday = ((dosdate ) & 0x1F); + + /* dissect time */ + unixtime.tm_hour = ((dostime >> 11) & 0x1F); + unixtime.tm_min = ((dostime >> 5) & 0x3F); + unixtime.tm_sec = ((dostime << 1) & 0x3E); + + /* let mktime calculate daylight savings time. */ + unixtime.tm_isdst = -1; + + return ((SDL_Time) mktime(&unixtime)); +} + + + +#define ZIP_CENTRAL_DIR_SIG 0x02014b50 +#define ZIP_END_OF_CENTRAL_DIR_SIG 0x06054b50 +#define ZIP64_END_OF_CENTRAL_DIR_SIG 0x06064b50 +#define ZIP64_END_OF_CENTRAL_DIRECTORY_LOCATOR_SIG 0x07064b50 + +static bool ProcessZip(SDL_IOStream *io, APKNode *root) +{ + // There is a record at the end of a .zip file we can use to find the central directory; + // unfortunately it's before the variable-length comment field, so we might need to read + // from the end of the file until we see a magic signature. For now, we assume APKs don't + // have a comment, so we don't have to search backwards for the signature and it's always + // 22 bytes from EOF. + const Sint64 eocd = SDL_GetIOSize(io) - 22; + if (eocd < 0) { + SDL_Log("ANDROID: Couldn't find End Of Central Directory in APK (%s). Filesystem enumeration will fail.", SDL_GetError()); + return false; + } + + //bool zip64 = false; + Sint64 centraldir = -1; + Uint64 num_entries = 0; + Uint16 val16 = 0; + Uint32 val32 = 0; + Uint64 val64 = 0; + + // First, check if this is actually zip64 format instead. The zip64 magic is 20 bytes back. + if (eocd < 20) { // presumably we always _are_ > 20, but let's be defensive here. + goto corrupterr; + } else if (SDL_SeekIO(io, eocd - 20, SDL_IO_SEEK_SET) < 0) { + goto ioerr; + } else if (!SDL_ReadU32LE(io, &val32)) { + goto ioerr; + } else if (val32 == ZIP64_END_OF_CENTRAL_DIRECTORY_LOCATOR_SIG) { // this is a zip64 archive? + if (!SDL_ReadU32LE(io, &val32)) { // disk number with start of central directory. + goto ioerr; + } else if (val32 != 0) { + goto corrupterr; + } else if (!SDL_ReadU64LE(io, &val64)) { // file offset of zip64 end-of-central-dir record + goto ioerr; + + // note that this gets significantly more complex if there is data prepended to the .zip file + // (like a self-extracting .exe, etc), but until that happens, we're keeping this as simple + // as possible and assuming the file offset in val64 is correct. + + } else if (SDL_SeekIO(io, (Sint64) val64, SDL_IO_SEEK_SET) < 0) { + goto ioerr; + } else if (!SDL_ReadU32LE(io, &val32)) { // zip64 end-of-central-dir signature. + goto ioerr; + } else if (val32 != ZIP64_END_OF_CENTRAL_DIR_SIG) { + goto corrupterr; + } else if (SDL_SeekIO(io, 28, SDL_IO_SEEK_CUR) < 0) { // we don't care about several of the fields, skip over them. + goto ioerr; + } else if (!SDL_ReadU64LE(io, &num_entries)) { // total entries in the central dir. + goto ioerr; + } else if (!SDL_ReadU64LE(io, &val64)) { // size of the central dir. + goto ioerr; + } else if (!SDL_ReadU64LE(io, &val64)) { // offset of the central dir. + goto ioerr; + } + + //zip64 = true; + centraldir = (Sint64) val64; + } else if (SDL_SeekIO(io, eocd + 4 + 6, SDL_IO_SEEK_SET) < 0) { // skip back to where we were, plus skip some fields we don't care about. + goto ioerr; + } else if (!SDL_ReadU16LE(io, &val16)) { + goto ioerr; + } else if (!SDL_ReadU32LE(io, &val32)) { // size of the central dir. + goto ioerr; + } else if (!SDL_ReadU32LE(io, &val32)) { // offset of the central dir. + goto ioerr; + } else { + num_entries = (Uint64) val16; + centraldir = (Sint64) val32; + } + + // okay, we know where the central dir is now, go there and start reading entries. + SDL_assert(centraldir > 0); // negative means we failed, zero is impossible since there should be something else there. + + for (Uint64 i = 0; i < num_entries; i++) { + Uint16 fnamelen = 0; + Uint16 extralen = 0; + Uint16 commentlen = 0; + Uint32 dosmodtime = 0; + Uint32 uncompressed = 0; + + // we don't care about most of this information, just parse through it to get what we need. + if (SDL_SeekIO(io, centraldir, SDL_IO_SEEK_SET) < 0) { + goto ioerr; + } else if (!SDL_ReadU32LE(io, &val32)) { // central dir item signature. + goto ioerr; + } else if (val32 != ZIP_CENTRAL_DIR_SIG) { + goto corrupterr; + } else if (!SDL_ReadU16LE(io, &val16)) { // version made by + goto ioerr; + } else if (!SDL_ReadU16LE(io, &val16)) { // version needed + goto ioerr; + } else if (!SDL_ReadU16LE(io, &val16)) { // general bits + goto ioerr; + } else if (!SDL_ReadU16LE(io, &val16)) { // compression method + goto ioerr; + } else if (!SDL_ReadU32LE(io, &dosmodtime)) { // last mod date/time + goto ioerr; + } else if (!SDL_ReadU32LE(io, &val32)) { // CRC-32 + goto ioerr; + } else if (!SDL_ReadU32LE(io, &val32)) { // compressed size + goto ioerr; + } else if (!SDL_ReadU32LE(io, &uncompressed)) { // uncompressed size + goto ioerr; + } else if (!SDL_ReadU16LE(io, &fnamelen)) { // filename length + goto ioerr; + } else if (!SDL_ReadU16LE(io, &extralen)) { // extra length + goto ioerr; + } else if (!SDL_ReadU16LE(io, &commentlen)) { // comment length + goto ioerr; + } else if (!SDL_ReadU16LE(io, &val16)) { // disk number start + goto ioerr; + } else if (!SDL_ReadU16LE(io, &val16)) { // internal file attributes + goto ioerr; + } else if (!SDL_ReadU32LE(io, &val32)) { // external file attributes + goto ioerr; + } else if (!SDL_ReadU32LE(io, &val32)) { // relative offset of local header + goto ioerr; + } + + char fnamebuf[0xFFFF+1]; // just eat 64k of stack like a boss until someone complains. + if (SDL_ReadIO(io, fnamebuf, (size_t) fnamelen) != ((size_t) fnamelen)) { + goto ioerr; + } + + // !!! FIXME: parse out the extralen section for zip64 file sizes; needed if a file is > 4 gigabytes. + + // technically zip files might have '\\' dir separators, but these were mostly old DOS files and not Android APKs, I think. Revisit if necessary. + + fnamebuf[fnamelen] = '\0'; // make sure the string is null-terminated. + + //SDL_Log("ANDROID: Saw ZIP entry '%s'", fnamebuf); + + char *ptr = fnamebuf; + while (*ptr == '/') { // drop absolute paths. + ptr++; + } + + if (SDL_strncmp(ptr, "assets/", 7) == 0) { // we only care about things under 'assets' for now. Drop everything else. + ptr += 6; // keep the '/' so strrchr never returns NULL. + APKNode *node = AddAPKDirs(ptr, root); // this builds out any missing subdirs, returns parent dir's node. + if (!node) { + goto ioerr; // (probably out of memory.) + } + + const SDL_Time modtime = ZipDosTimeToSDLTime(dosmodtime); + ptr = SDL_strrchr(ptr, '/'); + SDL_assert(ptr != NULL); + if (*(++ptr) == '\0') { // explicit directory entry paths end with '/' ...`node` is the new node. + node->info.type = SDL_PATHTYPE_DIRECTORY; + node->info.size = 0; + } else { + node = AddAPKChildNode(node, ptr); + if (!node) { + goto ioerr; // (probably out of memory.) + } + node->info.type = SDL_PATHTYPE_FILE; + node->info.size = (Uint64) uncompressed; + } + node->info.create_time = node->info.modify_time = node->info.access_time = modtime; + } + + centraldir += (Sint64) (46 + fnamelen + extralen + commentlen); // will seek to next file entry. + } + + return true; + +corrupterr: + SDL_Log("ANDROID: Unexpected or corrupt data in APK. Filesystem enumeration will fail."); + return false; + +ioerr: + SDL_Log("ANDROID: i/o error in APK (%s). Filesystem enumeration will fail.", SDL_GetError()); + return false; +} + +static bool CreateAPKNodes(const char *path) +{ + SDL_Log("ANDROID: Parsing APK file '%s' ...", path); + + SDL_PathInfo apkinfo; + SDL_assert(path[0] == '/'); // So SDL_GetPathInfo goes through the `stat` path and doesn't try to dig into the APK. + if (!SDL_GetPathInfo(path, &apkinfo)) { + SDL_zero(apkinfo); // we just want the file times here, so oh well. + } + + if (!APKRootNode) { + APKRootNode = (APKNode *) SDL_calloc(1, sizeof (*APKRootNode)); + if (!APKRootNode) { + SDL_Log("ANDROID: Can't open APK (out of memory). Filesystem enumeration will fail."); + return false; + } + APKRootNode->info.type = SDL_PATHTYPE_DIRECTORY; + APKRootNode->info.create_time = apkinfo.create_time; + APKRootNode->info.modify_time = apkinfo.modify_time; + APKRootNode->info.access_time = apkinfo.access_time; + } + + SDL_IOStream *io = SDL_IOFromFile(path, "rb"); + if (!io) { + SDL_Log("ANDROID: Can't open APK '%s' for reading (%s). Filesystem enumeration will fail.", path, SDL_GetError()); + } else { + ProcessZip(io, APKRootNode); + SDL_CloseIO(io); + } + return true; // even on failure, leave an empty root node so we have zero files and don't try to load the .zip again. +} + static void Internal_Android_Create_AssetManager(void) { @@ -1874,6 +2267,21 @@ static void Internal_Android_Create_AssetManager(void) Android_JNI_ExceptionOccurred(true); } + // the assetmanager isn't useful for enumerating directories, so parse the APK directly for that info upfront. + jthrowable jexception = 0; + jstring jstr = 0; + + mid = (*env)->GetMethodID(env, (*env)->GetObjectClass(env, context), "getPackageResourcePath", "()Ljava/lang/String;"); + jstr = (jstring)(*env)->CallObjectMethod(env, context, mid); + jexception = (*env)->ExceptionOccurred(env); + if (jexception != NULL) { + (*env)->ExceptionClear(env); // oh well + } else { + const char *apkpath = (*env)->GetStringUTFChars(env, jstr, NULL); + CreateAPKNodes(apkpath); + (*env)->ReleaseStringUTFChars(env, jstr, apkpath); + } + LocalReferenceHolder_Cleanup(&refs); } @@ -1885,6 +2293,11 @@ static void Internal_Android_Destroy_AssetManager(void) (*env)->DeleteGlobalRef(env, javaAssetManagerRef); asset_manager = NULL; } + + if (APKRootNode) { + FreeAPKNode(APKRootNode); + APKRootNode = NULL; + } } static const char *GetAssetPath(const char *path) @@ -1970,21 +2383,21 @@ bool Android_JNI_EnumerateAssetDirectory(const char *path, SDL_EnumerateDirector } } + SDL_EnumerationResult result = SDL_ENUM_CONTINUE; const char *asset_path = GetAssetPath(path); - AAssetDir *adir = AAssetManager_openDir(asset_manager, asset_path); - if (!adir) { - return SDL_SetError("AAssetManager_openDir failed"); + // check our tree we built from the APK first. + const APKNode *apknode = FindAPKNode(asset_path); + if (!apknode) { + return SDL_SetError("No such directory"); + } else if (apknode->info.type != SDL_PATHTYPE_DIRECTORY) { + return SDL_SetError("Not a directory"); + } else { + for (const APKNode *node = apknode->children; node && (result == SDL_ENUM_CONTINUE); node = node->next_sibling) { + result = cb(userdata, path, node->name); + } } - SDL_EnumerationResult result = SDL_ENUM_CONTINUE; - const char *ent; - while ((result == SDL_ENUM_CONTINUE) && ((ent = AAssetDir_getNextFileName(adir)) != NULL)) { - result = cb(userdata, path, ent); - } - - AAssetDir_close(adir); - return (result != SDL_ENUM_FAILURE); } @@ -1998,28 +2411,12 @@ bool Android_JNI_GetAssetPathInfo(const char *path, SDL_PathInfo *info) } path = GetAssetPath(path); - - // this is sort of messy, but there isn't a stat()-like interface to the Assets. - AAsset *aasset = AAssetManager_open(asset_manager, path, AASSET_MODE_UNKNOWN); - if (aasset) { // it's a file! - info->type = SDL_PATHTYPE_FILE; - info->size = (Uint64) AAsset_getLength64(aasset); - AAsset_close(aasset); - return true; + const APKNode *apknode = FindAPKNode(path); + if (!apknode) { + return SDL_SetError("No such file or directory"); } - - AAssetDir *adir = AAssetManager_openDir(asset_manager, path); - if (adir) { // This does _not_ return NULL for a missing directory! Treat empty directories as missing. Better than nothing. :/ - const bool contains_something = (AAssetDir_getNextFileName(adir) != NULL); // if not NULL, there are files in this directory, so it's _definitely_ a directory. - AAssetDir_close(adir); - if (contains_something) { - info->type = SDL_PATHTYPE_DIRECTORY; - info->size = 0; - return true; - } - } - - return SDL_SetError("Couldn't open asset '%s'", path); + SDL_copyp(info, &apknode->info); + return true; } bool Android_JNI_SetClipboardText(const char *text)