mirror of
https://github.com/ImranR98/Obtainium.git
synced 2026-07-30 17:06:26 -04:00
504 lines
27 KiB
Markdown
504 lines
27 KiB
Markdown
# 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.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`.
|
|
- A custom `ErrorWidget.builder` is 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 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`).
|
|
|
|
---
|
|
|
|
## 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** 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 `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.
|
|
|
|
### 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 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)
|
|
|
|
- **Singleton** (`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.
|
|
|
|
---
|
|
|
|
## 5. UI layer & component conventions
|
|
|
|
### Navigation shell (`pages/home.dart`)
|
|
|
|
`HomePage` is an **adaptive** shell:
|
|
- **Bottom `NavigationBar`** on compact screens, **`NavigationRail`** on 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 `Badge` driven by
|
|
`context.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`/`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.
|
|
|
|
### 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; 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.
|
|
|
|
---
|
|
|
|
## 6. Background updates & installation
|
|
|
|
### Background task architecture
|
|
|
|
Background work is scheduled via **`workmanager`** (Android periodic tasks). The flow:
|
|
|
|
1. `_scheduleWorkManager()` (in `lib/main.dart`) registers a periodic task (`obtainiumBgUpdateCheck`)
|
|
with a 15-minute minimum interval, requiring network connectivity.
|
|
2. When triggered, Android invokes **`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()`.
|
|
3. **`bgUpdateCheck(taskId, params)`** (`lib/providers/apps_provider.dart:1090`) runs headless
|
|
(no widget tree), loads apps/settings from disk, and performs the actual check.
|
|
4. On errors, `callbackDispatcher` catches the exception, logs it, and returns `false` so
|
|
WorkManager knows the task failed.
|
|
|
|
### `bgUpdateCheck` behaviour
|
|
|
|
1. Loads translations manually (no `BuildContext` available).
|
|
2. Bails early on no network / restrictions (Wi-Fi-only, charging-only) / disabled settings.
|
|
3. **Update mode** (`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.
|
|
4. **Install mode** (`toCheck` empty): downloads + silently installs pending updates;
|
|
Obtainium itself is always moved to install **last**.
|
|
5. 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 **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).
|
|
|
|
### Installation (`apps_provider_install.dart`)
|
|
|
|
`AppsProviderInstall` extension handles the full pipeline:
|
|
- `downloadApp(...)` → file or `DownloadedDir` (xAPK/zip/tarball get extracted).
|
|
- Tarballs are extracted from supported compression formats (gzip, bzip2, xz) into
|
|
split APK directories.
|
|
- `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.
|
|
- 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:
|
|
|
|
```dart
|
|
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 in `build()`**.
|
|
- Prefer `context.select` over `context.watch` for large providers.
|
|
- Guard every `setState`/`Navigator`/`ScaffoldMessenger` call after an `await` with
|
|
`if (!context.mounted) return;` (preferred over bare `mounted` in Flutter ≥ 3.7).
|
|
- Deep-copy an `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
|
|
`String`s. Key types: `RateLimitError` (with remaining minutes), `InvalidURLError`,
|
|
`NoReleasesError`, `NoAPKError`, `NoVersionError`, `DowngradeError`, `InstallError`,
|
|
`IDChangedError`, `RepositoryRenamedError`.
|
|
- In source files, wrap the main fetch logic:
|
|
|
|
```dart
|
|
try {
|
|
// ... fetch and parse ...
|
|
} catch (e, stack) {
|
|
rethrowOrWrapError(e, stack: stack);
|
|
}
|
|
```
|
|
|
|
`rethrowOrWrapError()` from `custom_errors.dart` passes through existing
|
|
`ObtainiumError`s unchanged and wraps raw exceptions with a stack trace.
|
|
- In pages, use the helper functions:
|
|
|
|
```dart
|
|
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**
|
|
- Categories are stored as `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**
|
|
- Always `close()` an `HttpClient`/`IOClient` (use `finally`). Dispose
|
|
`TextEditingController`s, 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 **every**
|
|
`assets/translations/*.json` (at minimum `en.json`).
|
|
- Reuse `ConnectedCard`/`SettingsTile`/`positionalTileShape` for grouped tiles instead of
|
|
hand-rolling `Material(shape: ...)`.
|
|
|
|
---
|
|
|
|
## 8. Building, running, testing
|
|
|
|
```bash
|
|
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`) and `fdroid` (`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 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.
|
|
- **Note:** The project currently lacks automated tests. Run `flutter analyze` and
|
|
`dart 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) |
|