27 KiB
Obtainium Developer Guide
Obtainium is a Flutter (Android-first) app that installs and updates Android apps directly from their release sources (GitHub, GitLab, F-Droid repos, HTML pages, APK hosts, etc.). It scrapes/queries each source for the latest release, downloads the APK (or split-APK container / archive), and installs it — optionally silently in the background.
This guide explains the architecture, the major subsystems, and the conventions you should follow when working in this codebase.
1. Tech stack & entry points
| Concern | Choice |
|---|---|
| UI | Flutter, Material 3 "Expressive" (useMaterial3: true) |
| State management | provider (ChangeNotifier) |
| Persistence | One JSON file per app on disk + SharedPreferences for settings + flutter_secure_storage for credentials + sqflite for logs |
| Localization | easy_localization (assets/translations/*.json, key-based tr() / plural()) |
| Background work | workmanager (periodic background tasks, Android-only) |
| Installation | Installer abstraction (StockInstaller / ShizukuInstaller / ExternalInstaller) backed by android_package_installer, shizuku_apk_installer, android_intent_plus |
Entry point: lib/main.dart
main()bootstraps:PlatformDispatcher.onErrorhandler (catches unhandled platform errors and logs them), trusted certs, date formatting,EasyLocalization, edge-to-edge system UI (SDK ≥ 29), notifications, thenrunAppinside aMultiProvider.- A custom
ErrorWidget.builderis installed so that rendering crashes show a user-friendly "close" screen rather than the default Flutter red screen. - Providers are created in
main()(not inside the widget tree) so background tasks can use the same instances:AppsProvider,SettingsProvider,NotificationsProvider,LogsProvider,SourceProvider. Read them everywhere viacontext.read/watch/select. buildObtainiumTheme()builds the app-wide Material 3 ExpressiveThemeDataonce; all shape/motion character lives here (squircleRoundedSuperellipseBordercards/dialogs,StadiumBorderpill buttons, emphasized page transitions, Material 3 expressive sliders/progress indicators). Do not re-style these per widget — extend the theme._ObtainiumStateruns side effects ininitState(post-frame), not inbuild(): permission requests, WorkManager scheduling (_scheduleWorkManager), first-run handling (_handleFirstRun), and the launch-by-notification check. Each is guarded so it runs once; aSettingsProviderlistener re-runs service/first-run logic on settings changes. Follow this pattern — never trigger navigation, dialogs, or service starts directly frombuild().callbackDispatcher()(annotated with@pragma('vm:entry-point')) is the headless WorkManager entry point registered viaWorkmanager().initialize(). It catches crashes, logs them, and callsbgUpdateCheck()to perform the actual background work.
There is also main_fdroid.dart for the F-Droid build flavour (sets isFdroidBuild = true).
2. Directory layout
lib/
├─ main.dart App bootstrap, WorkManager scheduling
├─ main_fdroid.dart F-Droid flavour entry point
├─ theme.dart Material 3 Expressive ThemeData builder, shapes + motion tokens
├─ custom_errors.dart ObtainiumError + typed errors with codes/stacks/data
├─ pages/ Full screens (each is a StatefulWidget in a single file)
│ ├─ home.dart
│ ├─ apps.dart
│ ├─ app.dart
│ ├─ add_app.dart
│ ├─ settings.dart
│ └─ import_export.dart
├─ components/ All UI: design tokens, form engine, feature widgets, dialogs
│ ├─ generated_form_model.dart Form data model (pure Dart)
│ ├─ generated_form_renderer.dart Form widget rendering (includes modal/dialog wrapper)
│ ├─ ui_widgets.dart AppIcon, EmptyState, ConnectedCard, LinkText, CustomAppBar, showMessage/showError, positional tile helpers
│ ├─ settings_widgets.dart SettingsGroup, SettingsTile, etc.
│ ├─ app_list_tile.dart AppListTile, AppListBuilder, changelog helpers
│ ├─ app_detail_widgets.dart AppInfoDialog, AppFilePicker
│ └─ category_editor.dart Category management UI
├─ providers/ State, business logic, services, models
│ ├─ apps_provider.dart Core AppsProvider + download primitives + TranslationLoader + NativeFeatures
│ ├─ apps_provider_*.dart Lifecycle, updates, install, import/export extensions
│ ├─ source_provider.dart Immutable App model + TypedSettings + AppSource + SourceProvider + HttpService + VersionService + legacy JSON migrations
│ ├─ settings_provider.dart Typed getters/setters over SharedPreferences
│ ├─ logs_provider.dart sqflite-backed logs + Logger/AppLogger
│ ├─ notifications_provider.dart Local notifications
│ └─ external_install_bridge.dart External installer discovery + content-URI conversion
├─ installers/ Install strategy abstraction
│ ├─ installer.dart Abstract Installer + InstallResult
│ ├─ stock_installer.dart AndroidPackageInstaller
│ ├─ shizuku_installer.dart Shizuku/Dhizuku/Sui
│ └─ external_installer.dart Third-party installer hand-off
└─ app_sources/ One file per supported source (28 sources + githubstars)
3. State management & data model
Providers
State lives in ChangeNotifier providers exposed through provider. Read providers
narrowly to avoid rebuild amplification:
context.read<T>()— one-off access (event handlers,initState).context.select<T, R>((p) => p.field)— rebuild only whenfieldchanges.context.watch<T>()— rebuild on any change. Avoid for big providers likeAppsProvider; preferselect. (Several perf fixes in this codebase were exactly "replacewatchwithselect".)
The App model (source_provider.dart)
App is the persisted unit. Key fields: id (Android package id or a temp hash),
url, author, name, installedVersion, latestVersion, apkUrls
(List<MapEntry<name, url>>), preferredApkIndex, additionalSettings
(Map<String, dynamic> — per-app source options), categories, pinned,
overrideSource, pendingRepoRenameUrl, and allowIdChange.
App.toJson()/App.fromJson()serialize to/from disk.App.fromJson()runsappJSONCompatibilityModifiers()— a chain of idempotent schema migrations (legacy → current). Every migration is written to be safe to re-run on already-migrated data, so they simply run on every load. It is wrapped in try/catch so a single bad migration can't brick loading. Default-setting reconciliation always runs regardless.Appis immutable — useApp.copyWith(...)to create a modified copy instead of mutating fields directly.
AppInMemory (apps_provider.dart)
Runtime wrapper around App that also holds a DownloadState (which wraps downloadProgress
as a ValueNotifier for efficient per-tile updates — shared by reference so UI listeners
survive saveApps copy-and-replace), installedInfo (PackageInfo from the OS), the
cached icon bytes, and a sourceType field for tracking which source produced the app.
AppsProvider.apps is a Map<String, AppInMemory> kept in sync with disk.
Persistence rules (apps_provider_lifecycle.dart)
- Each app is a JSON file in
app_data/<id>.json. Writes go to<id>.json.tmpthenrenameSync— atomic write, never partially-written files (#2089). - Corrupt JSON on load is renamed to
*.corruptand skipped, not fatal. loadApps()is serialized via aCompleterlock (waitForAppsToLoad()), not a busy-wait. It batches all parsing then notifies once.saveApps()reconciles install status (unlessattemptToCorrectInstallStatus: false), updates in-memory state, notifies once, and schedules a debounced auto-export.- Icons are cached as PNG in an
icons/cache dir and deleted when an app is uninstalled.
4. The Source system (the core extensibility model)
This is the heart of Obtainium. To add support for a new app source, add one file in
lib/app_sources/ and register it in SourceProvider._buildSources().
AppSource (abstract, in source_provider.dart)
A source is a subclass of AppSource. The base class is effectively immutable after
construction (all config is set in the subclass constructor body — a few sources
override name after super()), which is why instances can be cached and shared.
Configure behaviour by setting fields in the constructor:
class MySource extends AppSource {
MySource() {
hosts = ['example.com']; // domains this source matches
name = 'MySource'; // set automatically in super() as runtimeType, can be overridden
canSearch = true; // supports search()
appIdInferIsOptional = true;
showReleaseDateAsVersionToggle = true;
allowIncludeZips = true;
allowIncludeTarballs = true;
// Per-app options shown in the add/edit form:
additionalSourceAppSpecificSettingFormItems = [ [GeneratedFormSwitch(...)], ... ];
// Source-wide options stored in SettingsProvider (e.g. an API token):
sourceConfigSettingFormItems = [ GeneratedFormTextField('example-creds', ...) ];
}
}
Override the contract methods you need:
| Method | Responsibility |
|---|---|
sourceSpecificStandardizeURL(url, {forSelection}) |
Validate + normalize a URL to a canonical form, or throw InvalidURLError. Used for both selection and storage. |
getLatestAPKDetails(standardUrl, additionalSettings) |
The main job: fetch the latest release and return APKDetails(version, apkUrls, names, releaseDate, changeLog, allAssetUrls). |
tryInferringAppId(standardUrl, {...}) |
Best-effort detect the Android package id (optional). |
search(query, {querySettings}) |
Return {url: [name, description]} (only if canSearch). |
getRequestHeaders(...) |
Provide auth/format headers (defined on AppSource). |
getSourceNote() |
Markdown note shown in the UI (e.g. "add a token to avoid rate limits"). |
changeLogPageFromStandardUrl(url) |
URL of the human-readable changelog/releases page. Set changeLogPageIsStandardUrl = true in the constructor instead of overriding this if the changelog page is the same as the standard URL. |
postProcessApp(app) |
Transform the App object after all other processing (e.g. F-Droid repos update the URL with an appId query param). |
Helpers you should reuse (don't reinvent)
standardizeUrlWithRegex(url, subdomainPrefix:, pathPattern:)— the common "regex against host + path, return match or throwInvalidURLError" pattern. Most sources should adopt this helper rather than inlining their own regex construction.AppSource.isApkOrContainerFile(name, {includeArchives, includeTarballs})— the single source of truth for "is this file an installable container?". Recognizes.apk/.xapk/.apkm/.apks(+ optional.zipand tarballs). Use it instead of hand-rolling.endsWith('.apk'), which historically missed split-APK formats.sourceRequest(...)— the base HTTP method. It merges source config + per-app settings, applies header/prefetch modifiers, follows redirects with a cap, and always closes theHttpClient. Use this, not a rawhttp.get.filterApks,filterApksByArch,extractVersion,findStandardFormatsForVersion,getLinksFromParsedHTML,getApkUrlsFromUrls.
SourceProvider (the service)
- Singleton (
factory SourceProvider() => _instance). AllSourceProvider()calls return the same object. sourcesis a cached, shared, read-only list built lazily by_buildSources(). Because sources are immutable, this is safe. The only mutating path isgetSource(url, overrideSource: ...), which builds a throwaway fresh instance so the cache stays pristine.getSource(url): first tries host-regex matching against sources withhosts, then falls back to host-less sources viasourceSpecificStandardizeURL—HTML()is always last as the catch-all. Match errors are logged, never swallowed silently.getApp(...): orchestratesgetLatestAPKDetails→ version extraction → APK filtering → arch filtering → builds the finalApp. This is whereversionExtractionRegEx,releaseDateAsVersion,apkFilterRegEx,autoApkFilterByArch, app-id inference, andoverrideSourceare all applied.
5. UI layer & component conventions
Navigation shell (pages/home.dart)
HomePage is an adaptive shell:
- Bottom
NavigationBaron compact screens,NavigationRailon wide (width >= 600) / TV layouts. - Two-pane list+detail on very wide screens (
width >= 900) for the Apps tab. - Single-pane content on wide screens is width-capped at 720px and centered.
- Update count is shown as a live
Badgedriven bycontext.select<AppsProvider>(...findAppIdsWithPendingUpdates...). - Only two tabs (Apps, Settings). "Add App" is a FAB; Import/Export are folded into the Add App page and Settings respectively.
Deeplink routing (pages/home.dart)
interpretLink(Uri uri) (line 229) is the single deeplink dispatcher. It parses the
URI host as the action and dispatches accordingly:
Action (uri.host) |
Data source | Behaviour |
|---|---|---|
add |
uri.queryParameters['url'] or uri.path.substring(1) |
Standardizes the URL, checks for duplicates, navigates to Add App page |
app / apps |
URI-decoded query or path | Shows a confirmation dialog with the raw JSON, then imports via AppsProvider |
Inbound links arrive via AppLinks (Android App Links / intent filters) — the
obtainium:// scheme is registered in AndroidManifest.xml. Both getInitialLink()
(cold start) and uriLinkStream (warm start) feed into interpretLink, with a
dedup guard so the initial link isn't processed twice.
The share sheet (ACTION_SEND intent) added in the MainActivity.kt native layer
rewrites shared URLs as obtainium://add/<url> before they reach interpretLink, so
the same Dart-side dispatch handles both manual deeplinks and share-target intents.
Reusable components (lib/components/)
Prefer these over bespoke widgets:
theme.dart—positionalTileShape({isFirst, isLast}),StadiumBorder,ExpressiveMotion.{emphasized, short, medium}motion tokens. All shape and motion characters are defined here.ui_widgets.dart—AppIcon(squircle icon with Obtainium glyph fallback, excluded from semantics),ActionListTile(icon + label ListTile with optional auto-pop),ConnectedCard(single tonal card;isFirst/isLastround outer corners so runs read as one block),EmptyState(centered icon + caption for empty/loading/no-results),LinkText(tappable external link,Semantics(link: true)),HighlightableButton(FilledButton when "highlight touch targets" is on, else TextButton),CustomAppBar(wrappingSliverAppBar.large),copyToClipboard(context, text),showConfirmDialog(...) -> Future<bool>,showHelpDialog(context, title, content),showMessage(dynamic e, BuildContext, {bool isError})— logs viaLogsProviderand shows a snackbar (informational) or dialog (unexpected errors).showError(dynamic e, BuildContext)— convenience wrapper aroundshowMessagewithisError: true.
settings_widgets.dart—SettingsGroup,SettingsSectionHeader,SettingsTile,SettingsToggleRow, andshapeSettingsTiles()which auto-connects consecutive tiles.generated_form_renderer.dart—GeneratedFormwidget (renders form items) andGeneratedFormModal(aGeneratedForminside anAlertDialog; the standard way to ask the user for structured input or confirmation).app_list_tile.dart—AppListTile(the app row: swipe-to-install/remove, category gradient, pin/select states, download progress),AppIconWidget,DownloadProgressTrailing, and changelog dialog helpers (showChangeLogDialog,getChangeLogFn).app_detail_widgets.dart—AppInfoDialog(read-only app summary: icon, name, author, URL, version, last-check),AppFilePicker(choose among multiple APK/asset URLs),APKOriginWarningDialog(with "don't show again").category_editor.dart—showCategoryEditor(),CategorySelector,CategoryManager.
The LogsPage widget lives in pages/settings.dart since it's only used from the
settings page.
The form engine (generated_form_model.dart)
Forms throughout the app (per-app settings, source config, search filters, confirm
dialogs) are data-driven. You describe fields as List<List<GeneratedFormItem>>
(rows of fields) and GeneratedForm (in generated_form_renderer.dart) renders + validates
them:
GeneratedFormTextField(with optional autocomplete, password, multi-line, validators, help URL/dialog)GeneratedFormSwitch(bool; supportsdisabled)GeneratedFormDropdown(opts,disabledOptKeys)GeneratedFormSubForm(nested repeatable groups, e.g. HTML intermediate links)
It reports changes via onValueChanges(values, valid, isBuilding). Each
GeneratedFormItem has ensureType() (coerce stored value) and clone() (deep copy).
Form items owned by a source are cloned (cloneFormItems) before defaults are
pre-filled, because sources are cached/shared and in-place mutation would leak across
apps. tileMode: true renders fields in the connected-tile settings aesthetic.
6. Background updates & installation
Background task architecture
Background work is scheduled via workmanager (Android periodic tasks). The flow:
_scheduleWorkManager()(inlib/main.dart) registers a periodic task (obtainiumBgUpdateCheck) with a 15-minute minimum interval, requiring network connectivity.- When triggered, Android invokes
callbackDispatcher()(lib/main.dart:64), a top-level function annotated@pragma('vm:entry-point')registered viaWorkmanager().initialize(). It sets up the bare minimum (flush bindings, localization) and delegates tobgUpdateCheck(). bgUpdateCheck(taskId, params)(lib/providers/apps_provider.dart:1090) runs headless (no widget tree), loads apps/settings from disk, and performs the actual check.- On errors,
callbackDispatchercatches the exception, logs it, and returnsfalseso WorkManager knows the task failed.
bgUpdateCheck behaviour
- Loads translations manually (no
BuildContextavailable). - Bails early on no network / restrictions (Wi-Fi-only, charging-only) / disabled settings.
- Update mode (
toChecknon-empty): checks updates, splits results into notify-only vs silently-installable, sends grouped notifications, and schedules retries that actuallyawaitthe retry delay so rate-limited hosts aren't hammered. - Install mode (
toCheckempty): downloads + silently installs pending updates; Obtainium itself is always moved to install last. - Publishes saves via a broadcast
StreamController<void>so the foreground instance can detect background writes and reload automatically. Errors during background tasks are caught and logged rather than crashing the headless process.
Update checking (apps_provider_updates.dart)
fetchUpdate(appId)fetches new metadata without saving;checkUpdate(appId)fetches and saves.checkUpdates()processes app IDs in bounded chunks (max 8 concurrent) and persists each chunk with a singlesaveApps()call. This is the key fix for the pull-to-refresh UI freeze: it caps concurrent network/parse load and cuts rebuilds from O(N) to O(N/chunk).
Installation (apps_provider_install.dart)
AppsProviderInstall extension handles the full pipeline:
downloadApp(...)→ file orDownloadedDir(xAPK/zip/tarball get extracted).- Tarballs are extracted from supported compression formats (gzip, bzip2, xz) into split APK directories.
installApk/installApkDirselect the installer strategy (StockInstaller,ShizukuInstaller, orExternalInstaller) based on user settings. Seelib/installers/.canInstallSilently(app)decides whether a background silent install is allowed.moveObbFileuses SAF (shared_storage) on Android 11+, direct file access on older versions.downloadAndInstallLatestApps(...)is the orchestrator used by both UI and background.- Installs require the foreground (
waitForUserToReturnToForeground); the swipe-to-install tile stays locked from download start through install handoff.
Installer abstraction (lib/installers/)
The installer layer provides a strategy-pattern abstraction over Android package
installation methods. The abstract Installer class (installer.dart) defines:
abstract class Installer {
Future<InstallResult> installApk(App app, String path, ...);
Future<InstallResult> installApkDir(App app, String dir, ...);
}
Three concrete implementations:
| Installer | Backend | Use case |
|---|---|---|
StockInstaller |
android_package_installer plugin (PackageInstaller session API) |
Standard installs; supports silent install via ADB-granted INSTALL_PACKAGES |
ShizukuInstaller |
shizuku_apk_installer plugin |
Self-update of Obtainium itself, or when Shizuku/Dhizuku/Sui is available |
ExternalInstaller |
Native MethodChannel bridge (external_install_bridge.dart + MainActivity.kt) |
Hands off to a third-party installer app chosen by the user; lists eligible targets via listInstallTargets() and converts file paths to content:// URIs via FileProvider |
The selection logic (in apps_provider_install.dart) checks: self-update → ShizukuInstaller;
user has chosen an external installer → ExternalInstaller; otherwise → StockInstaller.
Credentials
Source credentials (e.g. github-creds, gitlab-creds) are stored in
flutter_secure_storage (encrypted), with automatic migration from any plaintext
SharedPreferences values left over from older versions.
7. Conventions & patterns to follow
State / lifecycle
- Side effects go in
initState/post-frame callbacks/listeners, never inbuild(). - Prefer
context.selectovercontext.watchfor large providers. - Guard every
setState/Navigator/ScaffoldMessengercall after anawaitwithif (!context.mounted) return;(preferred over baremountedin Flutter ≥ 3.7). - Deep-copy an
Appbefore mutating (App.copyWith(...)); never mutate provider-owned objects in place.
Errors / robustness
-
Throw
ObtainiumError(or a typed subclass incustom_errors.dart) rather than rawStrings. Key types:RateLimitError(with remaining minutes),InvalidURLError,NoReleasesError,NoAPKError,NoVersionError,DowngradeError,InstallError,IDChangedError,RepositoryRenamedError. -
In source files, wrap the main fetch logic:
try { // ... fetch and parse ... } catch (e, stack) { rethrowOrWrapError(e, stack: stack); }rethrowOrWrapError()fromcustom_errors.dartpasses through existingObtainiumErrors unchanged and wraps raw exceptions with a stack trace. -
In pages, use the helper functions:
try { await appsProvider.downloadAndInstallLatestApps([appId]); } catch (e) { if (!context.mounted) return; showError(e, context); // logs + shows dialog for unexpected errors } -
Errors use deferred localization: the code is set at construction time (e.g.
RateLimitError(5)) but the user-facing message is resolved vialocalizeErrorCode()only whenmessageis read. This lets errors be created in background tasks where no translation context is available. -
MultiAppMultiErrorbundles multiple per-app errors for batch operations; useerrors.add(appId, error, appName:)to collect them. -
Never silently swallow exceptions. Log them via
LogsProvider().add(...)or theLogger/AppLoggerabstraction (preferred for structured logging:logger.debug(),logger.info(),logger.warn(),logger.error()).
Categories & colour coding
- Categories are stored as
Map<String, int>(name → ARGB colour) in shared preferences. generateRandomLightColor()ingenerated_form_renderer.dartproduces pastel colours using the HSLuv colour space with golden-angle hue distribution.addMissingCategories()inapps_provider_lifecycle.dartreconciles any categories found in stored apps but missing from the settings map.
Resources
- Always
close()anHttpClient/IOClient(usefinally). DisposeTextEditingControllers, stream subscriptions, and timers.
UI / a11y / i18n
- Use the theme; don't hardcode colors/shapes. Pull colors from
Theme.of(context).colorScheme. - All user-facing strings go through
tr()/plural()with a key in everyassets/translations/*.json(at minimumen.json). - Reuse
ConnectedCard/SettingsTile/positionalTileShapefor grouped tiles instead of hand-rollingMaterial(shape: ...).
8. Building, running, testing
flutter pub get
flutter analyze # must be clean
dart format --set-exit-if-changed .
flutter run # default flavour
flutter build apk --flavor normal # or use ./build.sh
- Flavours:
normal(default,lib/main.dart) andfdroid(lib/main_fdroid.dart, reproducible-build friendly). - Several dependencies are git-pinned to commit SHAs in
pubspec.yaml(android_package_installer,android_package_manager,shared_storage,shizuku_apk_installer,android_system_font) — keep them pinned; don't switch toref: main/ref: master. sign.shreads the keystore password from an env var and locatesapksignerrobustly;build.sh/docker/Dockerfilehandle reproducible/CI builds.- Note: The project currently lacks automated tests. Run
flutter analyzeanddart format --set-exit-if-changed .locally before opening a PR.
9. Where to start for common tasks
| Task | Start here |
|---|---|
| Add a new app source | New file in app_sources/, register in SourceProvider._buildSources() |
| Add a per-app option | The source's additionalSourceAppSpecificSettingFormItems (or the base _commonAppSettingFormItems accessed via combinedAppSpecificSettingFormItems) |
| Add a global setting | A typed getter/setter in settings_provider.dart + a corresponding widget in settings.dart (e.g. SettingsToggleRow, GeneratedFormDropdown). Settings are organized into sections via _buildUpdatesSection() and _buildAppearanceSection() — add your control to the appropriate section. |
| Change update logic | apps_provider_updates.dart (foreground) / bgUpdateCheck (background) |
| Change install behaviour | apps_provider_install.dart |
| Add a reusable widget/dialog | components/ui_widgets.dart (or a dedicated component file) |
| Theme/shape/motion tweaks | buildObtainiumTheme() in lib/theme.dart (positionalTileShape, StadiumBorder, ExpressiveMotion tokens all live here) |