Overriding Bundle.module for loading resources from Android assets

A Foundation Bundle expects resources to be present as a plain file on disk. For example, the call Bundle.module.url(forResource: "file", withExtension: "json") will return something like:

file:/…apppath…/Contents/Resources/ModuleName.bundle/Contents/Resources/file.json

This works fine for Darwin apps (iOS, macOS, etc.), because their contents are expanded on disk. This is not the case for Android apps: an app.apk file – the zip of an app's code and resources – is not expanded on disk when the app is installed, but instead remains zipped up. Android expects app resources to be stored in an assets/ folder of the .apk archive and accessed via an "Asset Manager" API.

This presents a problem for code that relies on Foundation conventions to access resources. In Skip, we handle this by adding resources to a module-specific folder in the /assets/ section of the archive and intercepting calls to Bundle.module.url(…) to return a custom URL like:

asset:/module-name/Resources/file.json

We handle the "asset" protocol by registering a custom URL protocol handler in both Java (using java.net.URL.setURLStreamHandlerFactory) and Foundation (using URLProtocol.registerClass) to process these requests with the Java SDK's android.content.res.AssetManager or the NDK's AAssetManager, respectively.

This works for single-shot loading of data (e.g., Data(contentsOf: Bundle.module.url(forResource: "file.json")), and in theory could even work to provide random-access to large assets with their support for asset-aware file descriptors (with AssetManager.openFd and AAsset_openFileDescriptor64).

All this works fairly well, but the way we have to implement it is ugly: the client code needs to import SkipFuse, which allows us to typealias Bundle = AndroidAssetBundle so we can override Bundle.module and Bundle.main. In order to make this work transparently, we would need a way to hook into the Bundle loading system. A couple ideas we have for this:

  1. Use @_dynamicReplacement to intercept calls to Bundle.url and the like. This would require that Android's Foundation module be built with -enable-dynamic-replacement, which may have performance implications.
  2. Add some SPI capabilities to Bundle like Bundle.registerBundleProvider(handler: (String) -> Bundle?) and alter SwiftPM's generated resource_bundle_accessor.swift to first try Bundle.bundleProvider?(handler: "BundleName") to see if a custom provider has been registered.

Does anyone have any other ideas?

5 Likes

Other platforms actually look for the bundle directory next to the executable and that's how it's currently working on Android. However the executable is /system/bin/app_process64 so that's very wrong.

I'm using SDL3 which does use the AssetManager as you suggest. That's probably the right approach. Which points to the need for this to be an extension point provided by the platform.

On piece I think you missed is that the Bundle.module extension is generated by the build system into a file called resource_bundle_accessor.swift. It might be interesting for platforms to provide their own generator for that. It could then generate maybe a subclass of Bundle as well that overrides how resources are accessed.

We didn't miss it; Skip's build plugin generates a peer function that intercepts the Bundle initializer call from resource_bundle_accessor.swift and returns an AndroidBundle subclass that "does the right thing" and forwards the bundle calls through to a registered URL scheme that passes through to the AssetManager.

So calls to Bundle.module.url(forResource:) transparently work the same way on Android as on Darwin platforms. But it is fragile and relies on some undocumented assumptions that could break in any release.

This is precisely what we need! An official sanctioned extension point for custom bundle loading would enable all platforms (not just Android) to handle bundle resource access the right way for the platform's conventions.

I will note, though, that there are some serious limitations with our approach: Skip's bundle interception works swell for API like Bundle.url(forResource:withExtension:subdirectory:), where we return a URL scheme that we can intercept to fetch the bytes for the requested resource (like with Data(contentsOf: URL)). But API like Bundle.path(forResource:ofType:inDirectory:), which is expected to return a plain file system path, won't ever be able to work, unless we were to handle that in an extreme way like having these calls trigger a side-effect of the extraction of all the (potentially huge) assets to some temporary/cache folder and then return the path to that folder.

Or we could do something really gonzo by having Bundle.path(forResource:…) return some virtual filesystem root (like /var/_foundation-apk/), which would then be registered through some other FileManager extension to intercept those paths through a virtual filesystem which is backed by calls to the AssetManager; this would unlock features that are impossible with the simplistic URL scheme interception, like the ability to perform mmap or random access on large embedded resources by leveraging AAsset_openFileDescriptor to return a virtual native fd for an asset. We haven't yet encountered anyone with this need, but I can imagine it not being an uncommon requirement for certain classes of apps that need to embed very large assets, like games.

1 Like

I have added it to my list. I've been playing with building a game using SDL3 to see what's missing from the package manifest and plugins to make that easy. I hit this pretty early when I got it building and running on my Android phone. I'll post more on that experience and proposed additions for SwiftPM in a few days.

3 Likes

We probably want to fatalError in that case. That's true for embedded as well where the resources are stored in a flash device without a file system, for example.

But then that speaks to your virtual file system idea, which would be interesting as well. Need to study how that would work. A lot of Swift tooling projects have invented virtual file systems on top of the FileManager one but it would make more sense for that to be a FileManager feature...