Android Loader
Install, startup, updates and storage on Android.
The Android loader is a libxposed module plus a native library, libunbound.so. Kotlin hooks Discord's React Native runtime and UI; the library runs the same C++ core as iOS.
Install
Both options use the same module APK, signed with the same key every release. Requires Android 9 (API 28) or newer on arm64-v8a or x86_64.
- Install
app.unbound.android.xposed.apkfrom the latestdiscord-<version>-androidrelease. - Enable the module in Vector or another Xposed framework such as LSPosed. Scope:
com.discord. - Force stop Discord and reopen it.
Works with every Discord channel. Needs libxposed API 101 or newer.
Install Discord's split APKs with the module embedded through LSPatch:
| Channel | Release tag | Asset |
|---|---|---|
| Stable | discord-<version>-android and loader-latest | Unbound.apks |
| Beta | discord-<version>-android-beta (prerelease) | Unbound-beta.apks |
| Alpha | discord-<version>-android-alpha (prerelease) | Unbound-alpha.apks |
Install with Obtainium or a split-APK installer such as SAI. It replaces a Play Store Discord because the signature differs.
Obtainium links for every build are in the loader's README.
Startup
When Discord's process starts, the module:
- Loads
libunbound.so. If the APK lacks it, extracts it once tocode_cache/unboundin Discord's data directory, reused until the module updates. - Opens the log file, creates the
Unboundfolder and readssettings.json. - Reads the Hermes bytecode version of Discord's bundle to pick the bundle build.
- Hooks
ReactInstance's bundle loading. - Blocks Sentry and AppsFlyer, then hooks fonts, theme colors, the recovery shortcut, toolbox gestures and the chat UI.
Each time Discord loads its bundle, the loader:
- Reloads
settings.json. Ifloader.enabledisfalse, it logsLoader is disabled. Aborting.and stops. - Rescans the addon folders.
- Reads the cached Unbound bundle and starts an update in the background.
- Runs the cached bundle, then Discord's.
Before Unbound's bundle runs, the loader installs native modules and console capture, runs the DevTools script if loader.devtools is true, then installs UnboundNative:
| Member | Returns |
|---|---|
getPlugins() | Installed plugins, each with a manifest and a bundle code string. |
getThemes() | Installed themes, each with a manifest and the bundle.json text. |
fonts | The font store: list, resolve, install, remove, setOverride, setTargets and preset methods. See Themes and Fonts. |
getLoader() | origin (libunbound.so), loader version and runtime bytecodeVersion. |
getSha256(text) | Hex SHA-256 of the text's UTF-8 bytes. |
downloadFile(url, path) | Saves the response to path in the Unbound folder on a background thread. Used by fonts and icon packs. |
Startup Errors
| Message | Cause |
|---|---|
This version of Discord is incompatible with this version of Unbound. | No bundle loader method found. Discord runs without Unbound. |
Failed to load Unbound's bundle. Please report this to the developers. | No bundle is ready. Discord runs without Unbound. |
Storage
Files live in <data dir>/files/Unbound. <data dir> is usually /data/data/com.discord or /data/user/0/com.discord; browsing it needs root. The toolbox's Copy App Folder Path copies the full path.
| Path | Contents |
|---|---|
Plugins/<folder> | manifest.json and the file its main names. |
Themes/<folder> | manifest.json and bundle.json. |
Fonts | The font store: state.json and font files in files/. |
settings.json | All settings. Outside edits apply live; invalid JSON is copied to settings.json.corrupt-<timestamp> and reset to {}. |
unbound.bundle or unbound.js | The active bundle (Hermes bytecode or JS). |
.trust, .candidate files | Updater state. Don't edit; use the toolbox. |
addon-starting.json, addon-running.json, addon-frozen.json | Which addon was starting, running or frozen. Removed when the step ends. |
addon-crashes.json | Launches in a row that ended in the same addon. |
Logs/unbound.log | Current log. Older: unbound.1.log, unbound.2.log. |
The scan skips an entry and logs why when:
- it is a file, not a folder;
manifest.jsonis missing or not a JSON object;- a plugin's
mainis missing, outside its folder or unreadable; - a theme has no valid
bundle.json; - a plugin's signature is invalid.
Bundle Updates
The source is loader.update.url. Empty means https://builds.unbound.rip/; on the emulator, a running dev server at http://10.0.2.2:3000/ wins. A URL ending in .bundle or .js names the file; any other URL is a folder.
On each launch:
- Fetch
manifest.json. If it lists Discord's bytecode version, downloadunbound.<version>.bundle; otherwise, or with no manifest,unbound.js. - Verify
manifest.json.sigagainst the built-in key. Refuse the update if the signature fails or the manifest:- lacks build time, revision, channel or hashes;
- is on another channel than the active bundle, or older;
- needs a different native bridge version;
- has no SHA-512 for the chosen file.
- Download as a candidate, sending the stored ETag as
If-None-Matchunlessloader.update.forceistrue. Unchanged returns304. - Check the candidate's SHA-512 against the manifest.
- Run it. Only after it runs is it promoted and its ETag saved to
loader.update.etag. If it fails or the app dies, it's discarded next launch and the previous bundle is used.
| Situation | Result |
|---|---|
| Cached bundle exists | Runs now; the update applies next launch. |
| No cached bundle | Discord starts without Unbound and restarts once the download lands. |
loader.update.hmr is true | Discord also restarts whenever a newer bundle downloads. |
The signature check is skipped when loader.update.unsigned is true, the host is localhost, *.localhost, *.local, ::1 or a private IPv4 address, or the loader was built without a signing key.
Signed Plugins
A manifest may carry signature with version: 1 and an ed25519 signature over the id, version, SHA-512 of the code and any bytecode digests. The loader adds trust to each manifest:
trust | Meaning |
|---|---|
signed | Signature matches a trusted key. |
unsigned | No signature, or the loader has no signing key. |
A present but mismatched signature means the plugin is not loaded.
A plugin can ship precompiled bytecode beside main as <name>.<bytecode version>.hbc, with its SHA-512 under the manifest's bytecode key. The loader returns it only when the version and digest match.
Crash Guard
When Discord exits while an addon is starting or running, the next launch counts a crash. After two launches in a row end in the same addon, Unbound disables it and shows one of:
| Case | Message |
|---|---|
| Starting | Disabled <name> because Discord crashed while starting it. |
| Running | Disabled <name> because Discord crashed while running it. |
| Frozen | Disabled <name> because Discord froze in it and had to be closed. |
A watchdog pings the JavaScript thread every second. After 6 seconds without a reply while an addon's code is on it, the loader writes addon-frozen.json and logs the addon. Closing Discord while frozen counts as that addon's crash. If the thread recovers, the loader logs the freeze length.
Toolbox
Open it by shaking the device or holding three fingers on the screen.
| Section | Actions |
|---|---|
| Bundle | Refetch Bundle, Delete Bundle, Load Custom Bundle |
| Addons | Wipe Plugins, Wipe Themes, Wipe Fonts, Wipe Icon Packs |
| Utilities | Enable/Disable Recovery Mode, Factory Reset, Copy App Folder Path, Open GitHub Issue |
| Settings | Enable Shake Motion, Enable Three Finger Press |
The gesture switches are toolbox.shake and toolbox.three-finger. Turning one off turns the other on, so you can't lock yourself out.
Long-pressing Discord's launcher icon offers a shortcut that toggles Recovery Mode and shows the new state; Discord restarts if Unbound had loaded. Use it when Discord crashes before the toolbox opens. See Troubleshooting.
Themes and Fonts
The loader applies the theme whose id is in the themes bucket's applied key, unless Recovery Mode is on. Changes apply without a restart.
rawcolors replace Discord's color resources.semanticcolors override Discord's semantic color getters, or the matching color resources on builds with obfuscated theme classes.
The loader is the only writer of Fonts/state.json, which holds installed faces, overrides and presets. UnboundNative.fonts.install:
- Resolves a name or link through
GET /api/v1/fonts/resolve. - Downloads each file from its provider and checks its first bytes.
- Unpacks
.zipfiles within size limits, rejecting WOFF and variable fonts. - Moves the checked faces into
Fonts/filesin one step.
An override maps a Discord family such as ggsans-Semibold to a face. Overriding a group such as PRIMARY sets each weight to the closest face of the chosen family. Families are replaced through React Native's font manager, or where Android creates typefaces when that manager is obfuscated.
A change clears cached typefaces, so new layouts use the new fonts. Text already on screen keeps its font, because React Native caches measurements by content and style. The Fonts settings tab offers a restart after overrides change.
On first launch with the font store, the loader adopts existing files in Fonts and moves the old font-states setting into state.json.
Hot Reload
With loader.update.hmr set to true, the loader connects to /__hot on the bundle source's origin. When the dev server reports a new ETag, it restarts Discord. Toggling the key applies right away.
Logs
The loader logs to Android's log under the Unbound tag and to Logs/unbound.log. The file rotates at 1 MB, keeping two previous files. To pull them from a rooted device or emulator:
ubd device logs --pull