docs/DEVELOPER_GUIDE.md
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.
| 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 |
lib/main.dartmain() bootstraps: PlatformDispatcher.onError handler (catches unhandled platform
errors and logs them), trusted certs, date formatting, EasyLocalization, edge-to-edge
system UI (SDK ≥ 29), notifications, then runApp inside a MultiProvider.ErrorWidget.builder is installed so that rendering crashes show a
user-friendly "close" screen rather than the default Flutter red screen.main() (not inside the widget tree) so background tasks
can use the same instances: AppsProvider, SettingsProvider, NotificationsProvider,
LogsProvider, SourceProvider. Read them everywhere via context.read/watch/select.buildObtainiumTheme() builds the app-wide Material 3 Expressive ThemeData once;
all shape/motion character lives here (squircle RoundedSuperellipseBorder
cards/dialogs, StadiumBorder pill buttons, emphasized page transitions, Material 3
expressive sliders/progress indicators). Do not re-style these per widget — extend the theme._ObtainiumState runs side effects in initState (post-frame), not in build():
permission requests, WorkManager scheduling (_scheduleWorkManager),
first-run handling (_handleFirstRun), and the launch-by-notification check. Each is
guarded so it runs once; a SettingsProvider listener re-runs service/first-run logic
on settings changes. Follow this pattern — never trigger navigation, dialogs, or
service starts directly from build().callbackDispatcher() (annotated with @pragma('vm:entry-point')) is the headless
WorkManager entry point registered via Workmanager().initialize(). It catches crashes,
logs them, and calls bgUpdateCheck() to perform the actual background work.There is also main_fdroid.dart for the F-Droid build flavour (sets isFdroidBuild = true).
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)
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 when field changes.context.watch<T>() — rebuild on any change. Avoid for big providers like
AppsProvider; prefer select. (Several perf fixes in this codebase were exactly
"replace watch with select".)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() runs appJSONCompatibilityModifiers() — 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.App is immutable — use App.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.
apps_provider_lifecycle.dart)app_data/<id>.json. Writes go to <id>.json.tmp then
renameSync — atomic write, never partially-written files (#2089).*.corrupt and skipped, not fatal.loadApps() is serialized via a Completer lock (waitForAppsToLoad()), not a
busy-wait. It batches all parsing then notifies once.saveApps() reconciles install status (unless attemptToCorrectInstallStatus: false),
updates in-memory state, notifies once, and schedules a debounced auto-export.icons/ cache dir and deleted when an app is uninstalled.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). |
standardizeUrlWithRegex(url, subdomainPrefix:, pathPattern:) — the common
"regex against host + path, return match or throw InvalidURLError" 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 .zip and 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 the HttpClient. Use this, not a raw http.get.filterApks, filterApksByArch, extractVersion, findStandardFormatsForVersion,
getLinksFromParsedHTML, getApkUrlsFromUrls.SourceProvider (the service)factory SourceProvider() => _instance). All SourceProvider() calls
return the same object.sources is a cached, shared, read-only list built lazily by _buildSources().
Because sources are immutable, this is safe. The only mutating path is
getSource(url, overrideSource: ...), which builds a throwaway fresh instance so the
cache stays pristine.getSource(url): first tries host-regex matching against sources with hosts, then
falls back to host-less sources via sourceSpecificStandardizeURL — HTML() is
always last as the catch-all. Match errors are logged, never swallowed silently.getApp(...): orchestrates getLatestAPKDetails → version extraction → APK filtering
→ arch filtering → builds the final App. This is where versionExtractionRegEx,
releaseDateAsVersion, apkFilterRegEx, autoApkFilterByArch, app-id inference, and
overrideSource are all applied.pages/home.dart)HomePage is an adaptive shell:
NavigationBar on compact screens, NavigationRail on wide
(width >= 600) / TV layouts.width >= 900) for the Apps tab.Badge driven by
context.select<AppsProvider>(...findAppIdsWithPendingUpdates...).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.
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/isLast round 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 (wrapping SliverAppBar.large),copyToClipboard(context, text), showConfirmDialog(...) -> Future<bool>,
showHelpDialog(context, title, content),showMessage(dynamic e, BuildContext, {bool isError}) — logs via LogsProvider
and shows a snackbar (informational) or dialog (unexpected errors).showError(dynamic e, BuildContext) — convenience wrapper around showMessage
with isError: true.settings_widgets.dart — SettingsGroup, SettingsSectionHeader, SettingsTile,
SettingsToggleRow, and shapeSettingsTiles() which auto-connects consecutive tiles.generated_form_renderer.dart — GeneratedForm widget (renders form items) and
GeneratedFormModal (a GeneratedForm inside an AlertDialog; 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.
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; supports disabled)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.
Background work is scheduled via workmanager (Android periodic tasks). The flow:
_scheduleWorkManager() (in lib/main.dart) registers a periodic task (obtainiumBgUpdateCheck)
with a 15-minute minimum interval, requiring network connectivity.callbackDispatcher() (lib/main.dart:64), a top-level
function annotated @pragma('vm:entry-point') registered via Workmanager().initialize().
It sets up the bare minimum (flush bindings, localization) and delegates to bgUpdateCheck().bgUpdateCheck(taskId, params) (lib/providers/apps_provider.dart:1090) runs headless
(no widget tree), loads apps/settings from disk, and performs the actual check.callbackDispatcher catches the exception, logs it, and returns false so
WorkManager knows the task failed.bgUpdateCheck behaviourBuildContext available).toCheck non-empty): checks updates, splits results into
notify-only vs silently-installable, sends grouped notifications, and schedules
retries that actually await the retry delay so rate-limited hosts aren't hammered.toCheck empty): downloads + silently installs pending updates;
Obtainium itself is always moved to install last.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.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 single saveApps() 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).apps_provider_install.dart)AppsProviderInstall extension handles the full pipeline:
downloadApp(...) → file or DownloadedDir (xAPK/zip/tarball get extracted).installApk / installApkDir select the installer strategy (StockInstaller,
ShizukuInstaller, or ExternalInstaller) based on user settings. See
lib/installers/.canInstallSilently(app) decides whether a background silent install is allowed.moveObbFile uses SAF (shared_storage) on Android 11+, direct file access on
older versions.downloadAndInstallLatestApps(...) is the orchestrator used by both UI and background.waitForUserToReturnToForeground); the swipe-to-install
tile stays locked from download start through install handoff.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.
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.
State / lifecycle
initState/post-frame callbacks/listeners, never in build().context.select over context.watch for large providers.setState/Navigator/ScaffoldMessenger call after an await with
if (!context.mounted) return; (preferred over bare mounted in Flutter ≥ 3.7).App before mutating (App.copyWith(...)); never mutate provider-owned
objects in place.Errors / robustness
Throw ObtainiumError (or a typed subclass in custom_errors.dart) rather than raw
Strings. 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() from custom_errors.dart passes through existing
ObtainiumErrors 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 via localizeErrorCode() only when message is read. This lets errors
be created in background tasks where no translation context is available.
MultiAppMultiError bundles multiple per-app errors for batch operations; use
errors.add(appId, error, appName:) to collect them.
Never silently swallow exceptions. Log them via LogsProvider().add(...) or the
Logger/AppLogger abstraction (preferred for structured logging: logger.debug(),
logger.info(), logger.warn(), logger.error()).
Categories & colour coding
Map<String, int> (name → ARGB colour) in shared preferences.generateRandomLightColor() in generated_form_renderer.dart produces pastel colours using the
HSLuv colour space with golden-angle hue distribution.addMissingCategories() in apps_provider_lifecycle.dart reconciles any categories
found in stored apps but missing from the settings map.Resources
close() an HttpClient/IOClient (use finally). Dispose
TextEditingControllers, stream subscriptions, and timers.UI / a11y / i18n
Theme.of(context).colorScheme.tr() / plural() with a key in every
assets/translations/*.json (at minimum en.json).ConnectedCard/SettingsTile/positionalTileShape for grouped tiles instead of
hand-rolling Material(shape: ...).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
normal (default, lib/main.dart) and fdroid (lib/main_fdroid.dart,
reproducible-build friendly).pubspec.yaml
(android_package_installer, android_package_manager, shared_storage,
shizuku_apk_installer, android_system_font) — keep them pinned; don't
switch to ref: main/ref: master.sign.sh reads the keystore password from an env var and locates apksigner robustly;
build.sh / docker/Dockerfile handle reproducible/CI builds.flutter analyze and
dart format --set-exit-if-changed . locally before opening a PR.| 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) |