Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
eedfbb9
feat: add connectivity detection service
GabrielSousa02 Jul 14, 2026
dfd3865
feat: schedule background task from check-frequency setting
GabrielSousa02 Jul 14, 2026
121669b
feat: download languages in background per automatic-updates setting
GabrielSousa02 Jul 14, 2026
fc1ab0f
feat: initialize workmanager and wire providers in main
GabrielSousa02 Jul 14, 2026
93ac928
feat: prompt to confirm updates when requireConfirmation is set
GabrielSousa02 Jul 14, 2026
1da387d
docs: document settings-driven background download
GabrielSousa02 Jul 14, 2026
8e4a398
build: automated android build files
GabrielSousa02 Jul 15, 2026
eee0643
refactor(settings): update layout to remove overflow risk
GabrielSousa02 Jul 15, 2026
9a27b5c
refactor(test): mock the update check API call
GabrielSousa02 Jul 16, 2026
c9a9ccb
fix: correct rebase wrong merge
GabrielSousa02 Sep 24, 2026
36ee05c
bump: minor flutter version bumps
GabrielSousa02 Sep 24, 2026
0f49f3e
build: update exclusions from analyzer by flutter itself
GabrielSousa02 Sep 24, 2026
3fb4fdb
style: run dart auto format tool on the repo
GabrielSousa02 Sep 24, 2026
20890dc
bump: upgrade flutter version on `.fvmrc`
GabrielSousa02 Sep 24, 2026
864de4e
test: fix broken `startup_page_test.dart`
GabrielSousa02 Sep 24, 2026
2b1ff1e
build: auto update iOS build files
GabrielSousa02 Sep 30, 2026
1ff5f71
refactor(background): update background_task for iOS environment
GabrielSousa02 Sep 30, 2026
478640b
feat(ios): add background task to ios runner
GabrielSousa02 Sep 30, 2026
3e0a766
docs(background-tasks): describe new function flow for ios
GabrielSousa02 Sep 30, 2026
7078546
test: add new tests for `background_check_frequency`
GabrielSousa02 Sep 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .fvmrc
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"flutter": "3.41.6"
"flutter": "3.47.2"
}
10 changes: 5 additions & 5 deletions ONBOARDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ The detailed docs live in `docs/`. Read them in this order if you're new:
1. **[docs/content-rendering.md](docs/content-rendering.md)** — HTML rendering, the `sanitize()` workarounds, `flutter_html_table` filter in `main.dart`.
1. **[docs/onboarding-flow.md](docs/onboarding-flow.md)** — the three onboarding pages and their persistence side-effects.
1. **[docs/features.md](docs/features.md)** — pages, widgets, drawer, share menu, language switcher.
1. **[docs/background-tasks.md](docs/background-tasks.md)** — `workmanager` isolate, the result-sync trick, why parts are commented out.
1. **[docs/background-tasks.md](docs/background-tasks.md)** — `workmanager` isolate, the two-phase check/download flow, the settings matrix, and the result-sync trick.
1. **[docs/localization.md](docs/localization.md)** — `.arb` workflow, `context.l10n` extension, in-app language vs. content language.
1. **[docs/testing.md](docs/testing.md)** — unit/widget tests, fixtures, integration test, CI.
1. **[docs/conventions.md](docs/conventions.md)** — coding conventions, lints, do/don't.
Expand All @@ -131,7 +131,7 @@ These are the load-bearing oddities — read these before changing code in their
- **`LanguageDownloader`** (`lib/data/language_downloader.dart`) is the in-house module that downloads + unzips a language's HTML + PDF into a staging directory and swaps it into place atomically. Failed downloads never destroy prior offline content, concurrent `download()` calls are serialized (peak memory bounded by two zips), and a `.staging` leftover from a crashed run is cleaned up on the next attempt. It replaced the third-party `download_assets` package and is wired up in `main.dart` and the background isolate via `languageDownloaderProvider.overrideWithValue(...)`.
- **`flutter_html` error filter.** `main.dart` installs a `FlutterError.onError` shim that swallows four specific assertions thrown by `flutter_html_table` 3.0.0. Read the long docstring there before touching it — the four assertion variants are documented in detail.
- **HTML pre-processing.** `widgets/html_view.dart` calls `sanitize()` to fix bugs in `flutter_html` (e.g. percent table widths, fuzzy translations, stylized subtitles). Some workarounds are also in the HTML generator (`pywikitools`) upstream.
- **Background task is half-disabled.** Big chunks of `BackgroundScheduler.schedule()` and `Workmanager().initialize` in `main.dart` are commented out, gated on a "version 0.9" milestone. Don't delete them — they are the working scaffolding for the next release.
- **Background task is enabled and settings-driven.** `BackgroundScheduler.schedule()` registers a periodic `workmanager` task at the `CheckFrequency` interval (or cancels it when `never`), and `backgroundMain()` runs a two-phase flow: `backgroundCheck` then a settings-gated `backgroundDownload` (see the `AutomaticUpdates` × connectivity matrix in `docs/background-tasks.md`). Scheduling is owned by `BackgroundScheduler`, triggered from startup / onboarding step 3 / the check-frequency setting — `main.dart` only calls `Workmanager().initialize(backgroundTask)`.
- **No package on pub.dev.** `pubspec.yaml` has `publish_to: 'none'`.
- **License is AGPL** with an Apple App Store exception. See `LICENSE` and `COPYING.iOS`.
- **`test` is pinned to ^1.29.0** because 1.30+ needs a newer `test_api` than `flutter_test` allows.
Expand Down Expand Up @@ -170,8 +170,8 @@ ______________________________________________________________________

## 8. Roadmap context (from README)

- **0.9**: enable automatic background updates (scaffolding present but commented out).
- **0.9**: enable automatic background updates — **done**: the periodic task is
scheduled from `CheckFrequency` and downloads per `AutomaticUpdates` (see
`docs/background-tasks.md`).
- **1.0**: solid release.
- iOS planned for 2024.

If you see code with `TODO for version 0.9`, that's why.
5 changes: 5 additions & 0 deletions analysis_options.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@

# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
analyzer:
exclude:
- build/**
- android/**
- ios/**
include: package:flutter_lints/flutter.yaml

linter:
Expand Down
2 changes: 2 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" tools:node="remove" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" tools:node="remove" />
<uses-permission android:name="android.permission.INTERNET" />
<!-- connectivity_plus: query WiFi/mobile connection type for onlyOnWifi -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:label="4training.net"
android:name="${applicationName}"
Expand Down
2 changes: 2 additions & 0 deletions android/gradle.properties
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
org.gradle.jvmargs=-Xmx1536M
android.useAndroidX=true
android.enableJetifier=false
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
9 changes: 6 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,14 @@ There is no app-owned backend; all content comes from public GitHub repos and th
- **Background isolate**:
- Spawned by `workmanager`. Runs `backgroundTask()` in `lib/background/background_task.dart`,
builds its own `ProviderContainer`, checks each downloaded language for new GitHub commits,
persists results to `SharedPreferences`.
persists results to `SharedPreferences`, and — depending on the `AutomaticUpdates` setting and
connectivity — downloads the languages that have updates.
The main isolate later detects the activity by reloading shared prefs (see `BackgroundResultNotifier.checkForActivity`).

> **Note:** Currently the periodic registration of the background task is **commented out** (gated behind "version 0.9").
> The task is wired up and tested in the integration test, but is not actually being scheduled at app launch.
> **Note:** The periodic task is scheduled by `BackgroundScheduler.schedule()`
> at the `CheckFrequency` interval (not scheduled when `never`), and its run is
> gated by the `AutomaticUpdates` setting (check-only, or download on WiFi /
> always). See `docs/background-tasks.md`.

## Layered overview

Expand Down
153 changes: 127 additions & 26 deletions docs/background-tasks.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# Background Tasks

The app has a `workmanager`-based periodic background task that **checks** for content updates while the app is closed. Note: as of v0.8 the *scheduling* of this task is **disabled** (commented out, gated for v0.9). The task implementation is complete and integration-tested; only the registration is dormant.
The app has a `workmanager`-based periodic background task that **checks** for
content updates while the app is closed and, when the user's settings allow it,
**downloads** the languages that have updates. The task is scheduled at the
`CheckFrequency` interval (and not scheduled at all when the user picks
`never`); what it does once it runs is driven by the `AutomaticUpdates` setting.

## What runs in the background

Expand All @@ -25,49 +29,141 @@ void backgroundTask() {
### `backgroundMain()`
1. Gets a fresh `SharedPreferences` instance (the background isolate has its own memory).
2. Resolves `getApplicationDocumentsDirectory()` and builds a real `LanguageDownloaderImpl` with a fresh `Dio` and the local `FileSystem`.
3. Builds a new `ProviderContainer` with `sharedPrefsProvider` **and** `languageDownloaderProvider` overridden — the background isolate has the same atomicity and concurrency guarantees as the foreground path.
4. Calls `backgroundCheck(ref)`.
5. Currently, the task only checks for updates, never auto-downloads.

### `backgroundCheck(ProviderContainer ref)`
3. Builds a new `ProviderContainer` with `sharedPrefsProvider`, `languageDownloaderProvider` **and** `connectivityServiceProvider` overridden — the background isolate has the same atomicity and concurrency guarantees as the foreground path, plus a way to check the connection type without a `BuildContext`.
4. Calls **`backgroundRun(ref)`**, which first
[skips the run if the check frequency isn't due yet](#skipping-runs-before-the-check-frequency-is-due)
and otherwise runs the two-phase flow: **`backgroundCheck(ref)`** (phase 1) then **`backgroundDownload(ref)`** (phase 2).

### Skipping runs before the check frequency is due
`backgroundRun(ref, {now})` exits early — no check, no download, no network
calls — unless the user's `CheckFrequency` interval has (roughly) elapsed:

- `CheckFrequency.never` → always skip (a stray run, e.g. from the native iOS
registration, must not touch the network).
- Otherwise it compares `now` with **`mostRecentCheck(ref)`**: the *most recent*
`lastCheckedTimestamp` over all downloaded languages. (Not the oldest like
`lastCheckedProvider`: `backgroundCheck` doesn't re-check languages that
already have `updatesAvailable`, so their timestamps stay old and no run
would ever be skipped.) No downloaded language → the run goes ahead.
- The run is skipped only if **less than 90% of the interval** has elapsed. The
10% tolerance matters: the OS schedules the next run relative to when the
previous run *started*, while `lastChecked` is written a few seconds later —
a strict comparison would skip correctly-scheduled runs.

**Why it exists — iOS runs at a fixed daily frequency.** On iOS the periodic
task is registered natively in `AppDelegate.swift`
(`WorkmanagerPlugin.registerPeriodicTask(withIdentifier: "backgroundTask",
frequency: 24h)`, the shortest `CheckFrequency`). The Dart
`registerPeriodicTask(... initialDelay: interval ~/ 2)` only affects the first
run; after that `workmanager_apple` reschedules the task daily regardless of
the chosen frequency (and iOS may run it later than requested, never earlier).
With `weekly`, the daily wake-ups on days 1–6 therefore exit early and the one
on day ~7 checks. With `daily` nothing changes.

**On Android no run is skipped**: WorkManager already runs the task at the
requested interval, and the 10% tolerance absorbs the scheduling jitter.

`backgroundTestMain()` deliberately doesn't go through `backgroundRun`: its fake
language was just "downloaded", so the run the integration test needs would be
skipped.
The decision is unit-tested per `CheckFrequency` in
`test/background_check_frequency_test.dart`, driving `backgroundRun` with an
injected `now` and a counting `MockClient`.

### `backgroundCheck(ProviderContainer ref)` — phase 1
For each language code in `availableLanguagesProvider`:
1. `languageProvider(code).notifier.lazyInit()` — minimal disk check, no JSON parse.
2. Skip if not downloaded.
3. `languageStatusProvider(code).check()` — same GitHub Commits API call as the foreground.
3. `languageStatusProvider(code).check()` — same GitHub Commits API call as the foreground. This refreshes `updatesAvailable-<lang>` / `lastChecked-<lang>` in prefs.
4. Bail out of the loop if the rate limit (`apiRateLimitExceeded`) is hit.

### `backgroundDownload(ProviderContainer ref)` — phase 2
Reads `automaticUpdatesProvider` (from the isolate's own prefs) and decides
whether to download the languages that phase 1 flagged with `updatesAvailable`:

| `AutomaticUpdates` | metered (mobile) | unmetered (WiFi/ethernet) |
| --------------------- | ---------------- | ------------------------- |
| `never` | no download | no download |
| `requireConfirmation` | no download¹ | no download¹ |
| `onlyOnWifi` | no download | download |
| `yesAlways` | download | download |

¹ `requireConfirmation` deliberately leaves `updatesAvailable` set so the
foreground can surface it — see [Confirming updates in the foreground](#confirming-updates-in-the-foreground).

For `onlyOnWifi`, connectivity is queried via `connectivityServiceProvider`
(`ConnectivityService.isUnmetered()`). "Unmetered" means WiFi **or** ethernet —
the user's real intent behind `onlyOnWifi` is "don't burn mobile data". When
downloading, each language with `updatesAvailable` is re-downloaded via
`languageDownloader.download(code)` in a per-language `try`/`catch`, so one
language's failure never aborts the whole run. After each download the language's
`languageStatusProvider` is invalidated so `updatesAvailable` resets to false.

### Connectivity (`lib/data/connectivity_service.dart`)
`ConnectivityService.isUnmetered()` wraps the `connectivity_plus` package
(Android needs the `ACCESS_NETWORK_STATE` permission). It is exposed as
`connectivityServiceProvider`, a `MustOverrideProvider` — the real
`ConnectivityServiceImpl` is wired up in `main.dart` and in `backgroundMain()`,
and tests inject `FakeConnectivityService` (a controllable `unmetered` flag,
living in `lib/background/background_test.dart` next to `FakeLanguageDownloader`).

### Debug logging
`writeLog(message)` appends to `<docDir>/background.log` so the integration test (and human debuggers) can confirm the task ran. Marked `TODO: Remove later`.

## Scheduling (`lib/background/background_scheduler.dart`)

The current code is essentially:
`BackgroundScheduler.schedule()` registers (or cancels) the periodic task
according to the `CheckFrequency` setting:

```dart
class BackgroundScheduler extends Notifier<bool> {
@override
bool build() => false;

Future<void> schedule() async {
/* TODO Enable this with version 0.9
cancelByUniqueName('backgroundTask')
interval = checkFrequency.getDuration() // null if 'never'
if interval == null: state = false; return
Workmanager().registerPeriodicTask('backgroundTask', 'backgroundTask',
constraints: Constraints(networkType: NetworkType.connected),
initialDelay: interval ~/ 2)
state = true
*/
Future<void> schedule() async {
// idempotent re-scheduling: always cancel the prior registration first
await Workmanager().cancelByUniqueName('backgroundTask');
final interval = ref.read(checkFrequencyProvider).getDuration(); // null == never
if (interval == null) {
state = false; // CheckFrequency.never -> task stays cancelled
return;
}
await Workmanager().registerPeriodicTask('backgroundTask', 'backgroundTask',
constraints: Constraints(networkType: NetworkType.connected),
initialDelay: interval ~/ 2);
state = true;
}
```

`schedule()` is called from three places (and is currently a no-op):
- The provider's `bool` state reflects whether the task is currently scheduled.
- `CheckFrequency.never` → nothing registered, `state = false`.
- The `NetworkType.connected` constraint guarantees *some* connection when the
task fires (which is why `yesAlways` can download without a connectivity check).
- The `task` name `'backgroundTask'` is what reaches `executeTask` and selects
the `backgroundMain` branch (kept distinct from `'testTask'`).

`schedule()` is called from three places:
- `StartupPage.init()` after a successful startup,
- `SetUpdatePrefsPage` after the user submits onboarding step 3,
- `CheckFrequencyNotifier.setCheckFrequency` whenever the user changes the frequency.

The `Workmanager().initialize(backgroundTask, isInDebugMode: false)` call in `main()` is also commented out behind the same `TODO enable in version 0.9`.
`main()` calls `Workmanager().initialize(backgroundTask)` to register the isolate
entry point; the periodic scheduling itself is owned entirely by
`BackgroundScheduler` (there is no one-off registration on launch).

Unit tests can't touch the `workmanager` platform channel, so `schedule()`'s
branching logic is mirrored by `TestBackgroundScheduler`
(`test/background_scheduler_test.dart`), which is asserted across every
`CheckFrequency` and for cancel-before-register re-scheduling.

## Confirming updates in the foreground

Under `AutomaticUpdates.requireConfirmation` the background task finds updates but
does not download them. `updatesNeedConfirmationProvider`
(`lib/data/updates.dart`) is true exactly when that setting is active **and**
updates are available. It drives a **persistent indicator**:
- a red dot next to the Settings entry in the drawer (`MainDrawer`), and
- a `ConfirmUpdatesPrompt` on the settings page with a "download now" button that
runs the normal foreground download path for every language with updates.

After a successful foreground download, `updatesAvailable` resets via the usual
`LanguageStatusNotifier` rebuild, so the indicator and prompt disappear.

## How the foreground learns about background work

Expand All @@ -90,15 +186,20 @@ There's a comment in `BackgroundResultNotifier.checkForActivity`: "*languageStat
Two tests, both running on a real Android emulator (CI uses `reactivecircus/android-emulator-runner@v2`, API level 29):

1. **"Test that background task gets executed"**
- `Workmanager().initialize(backgroundTask, isInDebugMode: false)`.
- `Workmanager().initialize(backgroundTask)`.
- `registerOneOffTask(..., 'testTask', initialDelay: 2s)` — `task` argument is what gets passed to `executeTask` and what selects the `backgroundTestMain()` branch.
- Main isolate registers a port via `IsolateNameServer.registerPortWithName(port.sendPort, 'test')`.
- `backgroundTask` sends `'success'` via `IsolateNameServer.lookupPortByName('test')`.
- Main isolate awaits the port message with a 10-second timeout.

2. **"Test synchronization with main isolate"** — full happy-path: mount `App4Training` with `appLanguage='de'`, open settings to warm `languageStatusProvider`, fire the background task, then open a worksheet and verify the `foundBgActivity` snackbar appears.

The fixtures use `MemoryFileSystem` and `FakeLanguageDownloader` from `lib/background/background_test.dart`.
`backgroundTestMain()` mirrors `backgroundMain()`'s two-phase flow (without skipping runs based on `CheckFrequency`)
(`backgroundCheck` then `backgroundDownload`) with a `FakeConnectivityService`,
so the integration test exercises the full check-then-download path
deterministically. The fixtures use `MemoryFileSystem`,
`FakeLanguageDownloader` and `FakeConnectivityService` from
`lib/background/background_test.dart`.

## Why the test fixtures live in `lib/`

Expand Down
2 changes: 0 additions & 2 deletions docs/conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,12 @@ The repo's existing code is sparing with comments — but where comments exist,
- Don't write comments that restate the code.
- **Do** write comments when the *why* is non-obvious — e.g. why an internal Riverpod import is needed, why a button is intentionally clickable while looking disabled.
- `// FIXME` is used to mark workarounds that should ideally move upstream (`pywikitools`, `flutter_html`).
- `// TODO for version 0.9` marks the v0.9 milestone code.

## Don'ts

- **Don't** add a service/repository layer. Riverpod notifiers ARE the repository.
- **Don't** reintroduce `custom_lint` (removed for analyzer-version conflict).
- **Don't** unpin `test` without first reading the comments in `pubspec.yaml`.
- **Don't** reintroduce `download_assets` (replaced by the in-house `LanguageDownloader` in `lib/data/language_downloader.dart`, which gives atomic staging-and-swap downloads, a concurrency cap, and no same-filename workaround).
- **Don't** delete commented-out v0.9 code without project agreement — it's the working scaffold for the next release.
- **Don't** introduce a navigation library (`go_router`, etc.) — Navigator 1.0 is intentional and it interplays cleanly with the URL-mirroring `/view/<page>/<lang>` scheme.
- **Don't** delete the `flutter_html_table` error filter — the four assertion variants are real and well-documented.
Loading
Loading