Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
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.
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
4 changes: 4 additions & 0 deletions android/gradle.properties
Original file line number Diff line number Diff line change
@@ -1,3 +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
113 changes: 88 additions & 25 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,103 @@ 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.
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. Runs the two-phase flow: **`backgroundCheck(ref)`** (phase 1) then **`backgroundDownload(ref)`** (phase 2).

### `backgroundCheck(ProviderContainer ref)`
### `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 +148,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
(`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.
2 changes: 1 addition & 1 deletion docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ The main reading screen.
Three sections:
1. **App language** — `DropdownButtonAppLanguage`.
2. **`LanguageSettings`** — title + explanation text + `LanguagesTable` (the per-language download/update/delete table).
3. **`UpdateSettings`** — "Last check" timestamp + `CheckNowButton`. The check-frequency and automatic-updates dropdowns are commented out (for v0.9).
3. **`UpdateSettings`** — the `DropdownButtonCheckFrequency` (how often the background task runs) and `DropdownButtonAutomaticUpdates` (whether/when it downloads), "Last check" timestamp, `CheckNowButton`, and a `ConfirmUpdatesPrompt` shown under `requireConfirmation` when updates are waiting.

### `AboutPage` (`/about`)
Static text + version. Renders localized strings via `flutter_linkify` so URLs in the strings become tappable (`url_launcher`).
Expand Down
Loading
Loading