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.

  1. Install app.unbound.android.xposed.apk from the latest discord-<version>-android release.
  2. Enable the module in Vector or another Xposed framework such as LSPosed. Scope: com.discord.
  3. Force stop Discord and reopen it.

Works with every Discord channel. Needs libxposed API 101 or newer.

Obtainium links for every build are in the loader's README.

Startup

When Discord's process starts, the module:

  1. Loads libunbound.so. If the APK lacks it, extracts it once to code_cache/unbound in Discord's data directory, reused until the module updates.
  2. Opens the log file, creates the Unbound folder and reads settings.json.
  3. Reads the Hermes bytecode version of Discord's bundle to pick the bundle build.
  4. Hooks ReactInstance's bundle loading.
  5. 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:

  1. Reloads settings.json. If loader.enabled is false, it logs Loader is disabled. Aborting. and stops.
  2. Rescans the addon folders.
  3. Reads the cached Unbound bundle and starts an update in the background.
  4. 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:

MemberReturns
getPlugins()Installed plugins, each with a manifest and a bundle code string.
getThemes()Installed themes, each with a manifest and the bundle.json text.
fontsThe 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

MessageCause
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.

PathContents
Plugins/<folder>manifest.json and the file its main names.
Themes/<folder>manifest.json and bundle.json.
FontsThe font store: state.json and font files in files/.
settings.jsonAll settings. Outside edits apply live; invalid JSON is copied to settings.json.corrupt-<timestamp> and reset to {}.
unbound.bundle or unbound.jsThe active bundle (Hermes bytecode or JS).
.trust, .candidate filesUpdater state. Don't edit; use the toolbox.
addon-starting.json, addon-running.json, addon-frozen.jsonWhich addon was starting, running or frozen. Removed when the step ends.
addon-crashes.jsonLaunches in a row that ended in the same addon.
Logs/unbound.logCurrent 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.json is missing or not a JSON object;
  • a plugin's main is 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:

  1. Fetch manifest.json. If it lists Discord's bytecode version, download unbound.<version>.bundle; otherwise, or with no manifest, unbound.js.
  2. Verify manifest.json.sig against 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.
  3. Download as a candidate, sending the stored ETag as If-None-Match unless loader.update.force is true. Unchanged returns 304.
  4. Check the candidate's SHA-512 against the manifest.
  5. 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.
SituationResult
Cached bundle existsRuns now; the update applies next launch.
No cached bundleDiscord starts without Unbound and restarts once the download lands.
loader.update.hmr is trueDiscord 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:

trustMeaning
signedSignature matches a trusted key.
unsignedNo 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:

CaseMessage
StartingDisabled <name> because Discord crashed while starting it.
RunningDisabled <name> because Discord crashed while running it.
FrozenDisabled <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.

SectionActions
BundleRefetch Bundle, Delete Bundle, Load Custom Bundle
AddonsWipe Plugins, Wipe Themes, Wipe Fonts, Wipe Icon Packs
UtilitiesEnable/Disable Recovery Mode, Factory Reset, Copy App Folder Path, Open GitHub Issue
SettingsEnable 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.

  • raw colors replace Discord's color resources.
  • semantic colors 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:

  1. Resolves a name or link through GET /api/v1/fonts/resolve.
  2. Downloads each file from its provider and checks its first bytes.
  3. Unpacks .zip files within size limits, rejecting WOFF and variable fonts.
  4. Moves the checked faces into Fonts/files in 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