HIGH severity fixes: - Fix indexOf -> startsWith for URL scheme detection to prevent corrupting non-http URLs (source_provider.dart) - Fix OOM risk: use XFile(path) instead of readAsBytes() for large APKs (apps_provider_install.dart) - Fix GeneratedFormSwitch.ensureType returning String instead of bool, causing runtime _CastError (generated_form_model.dart) - Fix initForm() called during build() triggering parent setState by moving init to didUpdateWidget + postFrameCallback (generated_form_renderer.dart) - Fix CI sed no-op: update regex to match actual signingConfig pattern in build.gradle.kts (release.yml) - Fix build.sh auto git push on no-args: require explicit arg to sync+build - Pin all git dependencies to commit SHAs instead of mutable branches MEDIUM severity fixes: - Sequence background service start/stop calls to prevent races (main.dart) - Move loadSystemFont() from build() to init to prevent visible font flash - Prevent concurrent bgUpdateCheck calls in onRepeatEvent with guard flag - Extend download retry to SocketException and TimeoutException - Cap install permission loop at 10 attempts to prevent infinite hang - Remove dead unused headers variable causing double bcrypt hash (coolapk.dart) - Remove HTTP 304 from redirect status check (huaweiappgallery.dart) - Guard result.files.single against empty file picker results (import_export.dart) - Add error logging to linkFn catch block (add_app.dart) - Disable continue button when no download URL available (app_detail_widgets.dart) - Fix DEVELOPER_GUIDE.md: list all git-pinned deps, correct android_package_manager LOW severity fixes: - Use 'final' instead of 'late' for FDroid field (izzyondroid.dart) - Use stable 'name' instead of runtime-type sourceIdentifier (neutroncode.dart) - Fix typo: finalUrlKey -> urlDataKey (uptodown.dart) - Fix aptoide constructor field order for consistency - Remove dead app?.app == null condition (app.dart) - Reuse SourceProvider instance instead of throwaway (apps.dart) - Support descending sort for 'as added' column (app_list_tile.dart) - Remove SDK >= 31 guard causing layout shift for material-you option (settings.dart) - Secure standardize.mjs: atomic write-then-rename, Object.hasOwn, error handling - Add flutter_launcher_icons to dev_dependencies
23 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 | background_fetch (headless) and flutter_foreground_task (FG service) |
| Installation | 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, foreground/background service management (_manageServices), 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().bgUpdateCheck()is the headless background entry point (see §6).
There is also main_fdroid.dart for the F-Droid build flavour (sets isFdroidBuild = true).
2. Directory layout
lib/
├─ main.dart App bootstrap, FG/BG service control
├─ 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, category sections
│ ├─ 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
└─ 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** 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`".)
### 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 a `compatVersion` stamp.
- `App.toJson()` / `App.fromJson()` serialize to/from disk.
- `App.fromJson()` runs **`appJSONCompatibilityModifiers()`** — a long chain of one-time
schema migrations (legacy → current). It is wrapped in try/catch so a single bad
migration can't brick loading. The `compatVersion` constant
(`currentAppJSONCompatVersion`) gates the *one-time legacy* migrations so already-migrated
apps skip them; default-setting reconciliation still always runs.
- `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 `downloadProgress` (via a `ValueNotifier`
for efficient per-tile updates), `installedInfo` (`PackageInfo` from the OS), and the
cached `icon` bytes. `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.tmp` then
`renameSync` — **atomic write**, never partially-written files (#2089).
- Corrupt JSON on load is renamed to `*.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 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:
```dart
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.
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) andshowGeneratedFormModal()(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,AppListCategorySection, 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 LogsDialog 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 check (bgUpdateCheck in apps_provider.dart)
Runs headless (no widget tree) via background_fetch or the foreground service. It:
- Loads translations manually (no
BuildContextavailable). - Bails early on no network / restrictions (Wi-Fi-only, charging-only) / too-soon.
- 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 > 64 MB are stream-decompressed to a temp file rather than loaded into memory (OOM defense).
installApk/installApkDirchoose the installer: normal package installer, Shizuku (with optional "pretend to be Google Play"), or split-APK session install.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.
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. - Errors use deferred localization: the code is set at construction time (e.g.
code: 'NO_RELEASES') 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. - Use
rethrowOrWrapError(e)(fromcustom_errors.dart) in every source's try/catch block to wrap unexpected errors asObtainiumErrorwith stack traces. - Use
showError(dynamic e, BuildContext)for unexpected/error-level messages (shows a dialog) andshowMessage(...)for informational messages (shows a snackbar). 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 test # run the test suite
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) |