diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 926ed1cc..1f6532bb 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -19,7 +19,6 @@ ### :thinking: Checklist before submitting - [ ] All projects build -- [ ] Follows style guide lines ([code style guide](https://github.com/Baseflow/flutter_cache_manager/blob/main/CONTRIBUTING.md)) +- [ ] Follows style guide lines ([code style guide](https://github.com/Baseflow/flutter_cache_manager/blob/develop/CONTRIBUTING.md)) - [ ] Relevant documentation was updated -- [ ] Rebased onto current main -- [ ] Version bumped and dated CHANGELOG entry added +- [ ] Rebased onto current develop diff --git a/.github/workflows/build-firebase.yaml b/.github/workflows/build-firebase.yaml index e8fb1abd..65ed5626 100644 --- a/.github/workflows/build-firebase.yaml +++ b/.github/workflows/build-firebase.yaml @@ -6,12 +6,12 @@ on: push: tags: - "firebase-v[0-9]+.[0-9]+.[0-9]+*" - branches: [main] + branches: [develop, main] paths: - "flutter_cache_manager_firebase/**" - ".github/workflows/build-firebase.yaml" pull_request: - branches: [main] + branches: [develop, main] paths: - "flutter_cache_manager_firebase/**" - ".github/workflows/build-firebase.yaml" @@ -24,7 +24,7 @@ concurrency: cancel-in-progress: true env: - FLUTTER_VERSION: "3.47.4" + FLUTTER_VERSION: "3.47.1" jobs: quality: @@ -78,13 +78,5 @@ jobs: - name: Set up Dart uses: dart-lang/setup-dart@v1 - - name: Set up Flutter - uses: subosito/flutter-action@v2 - with: - channel: "stable" - flutter-version: ${{ env.FLUTTER_VERSION }} - cache: true - - - name: Publish package run: dart pub publish -v -f diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 8a3b9025..f5e8853e 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -6,12 +6,12 @@ on: push: tags: - "v[0-9]+.[0-9]+.[0-9]+*" - branches: [main] + branches: [develop, main] paths: - "flutter_cache_manager/**" - ".github/workflows/build.yaml" pull_request: - branches: [main] + branches: [develop, main] paths: - "flutter_cache_manager/**" - ".github/workflows/build.yaml" @@ -24,7 +24,7 @@ concurrency: cancel-in-progress: true env: - FLUTTER_VERSION: "3.47.4" + FLUTTER_VERSION: "3.47.1" jobs: quality: @@ -254,12 +254,5 @@ jobs: - name: Set up Dart uses: dart-lang/setup-dart@v1 - - name: Set up Flutter - uses: subosito/flutter-action@v2 - with: - channel: "stable" - flutter-version: ${{ env.FLUTTER_VERSION }} - cache: true - - name: Publish package run: dart pub publish -v -f diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 61fe44a0..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,256 +0,0 @@ -# flutter_cache_manager agent instructions - -Technical reference for AI agents and contributors developing in this repository. - -Process and conduct live in their own files: contribution workflow in -[CONTRIBUTING.md](CONTRIBUTING.md), the [Contributor Covenant Code of -Conduct](CODE_OF_CONDUCT.md) (report unacceptable behavior to -[hello@baseflow.com](mailto:hello@baseflow.com)). - -## Scope and stack - -- This repo is the **Flutter Cache Manager** monorepo maintained by [Baseflow](https://baseflow.com). -- It contains two Dart packages (not a federated plugin). Work inside the **specific package** you are changing; there is no Melos or root pub workspace, and neither should be added unless the team decides to. -- Run Flutter and Dart commands with the same tooling CI uses (`flutter`, `dart`). Nothing in this repo requires anything else. If you happen to manage SDK versions locally with [fvm](https://fvm.app), prefix commands with `fvm`; that is a personal setup choice and is never checked in. - -### Prerequisites - -- Basic Dart and Flutter knowledge -- A working Flutter SDK installation (stable channel, matching CI — currently Flutter **3.47.4** in workflows) -- Comfort with filesystem / HTTP caching concepts helps, but is not required to start -- For running or building the example on iOS/macOS, access to a Mac is required -- Android example builds require JDK 17 - -### Reference documentation - -This package is primarily a **Dart/Flutter library** (file download + disk cache), not a federated platform plugin. Prefer official Flutter/Dart docs and this repo's existing code for hands-on work: - -- [Using packages](https://docs.flutter.dev/packages-and-plugins/using-packages) -- [Developing packages & plugins](https://docs.flutter.dev/packages-and-plugins/developing-packages) -- [Effective Dart](https://dart.dev/effective-dart) -- [pub versioning philosophy](https://dart.dev/tools/pub/versioning) - -## Packages in this repo - -| Package | Role | -|---------|------| -| `flutter_cache_manager` | Core cache manager: download, store, and serve files with configurable TTL / capacity | -| `flutter_cache_manager_firebase` | Optional `FileService` / cache manager integration for `firebase_storage` (`gs://` → HTTPS) | - -There is no Melos workspace. Each package has its own `pubspec.yaml`, tests, and CI workflow. - -## Architecture overview - -``` -App → CacheManager / DefaultCacheManager / custom Config - → CacheStore (mem cache + CacheInfoRepository) - → WebHelper + FileService (HTTP or custom, e.g. Firebase) - → FileSystem (IO or memory on web) -``` - -Platform defaults for `CacheInfoRepository` (see `lib/src/config/_config_io.dart`): - -- **Android / iOS / macOS** → `CacheObjectProvider` (sqflite) -- **Windows / Linux** → `JsonCacheInfoRepository` -- **Web** → non-storing provider - -Custom `Config` can override `repo`, `fileSystem`, and `fileService`. Prefer extending or composing these abstractions rather than forking `CacheManager` internals. - -**Persistence**: treat durability seriously — especially `JsonCacheInfoRepository` (full-file rewrite). Prefer serialized writes and atomic replace (temp + rename) over long debounce windows that can lose data on process kill. Do not reintroduce long debounce delays for JSON persistence without an explicit durability strategy (flush on close is not enough for force-stop). - -## Authoritative project structure - -- Root overview: `README.md` (symlink to `flutter_cache_manager/README.md`) -- Contribution workflow: `CONTRIBUTING.md` -- App-facing API: `flutter_cache_manager/lib/flutter_cache_manager.dart` -- Core manager: `flutter_cache_manager/lib/src/cache_manager.dart` -- Default / image managers: `flutter_cache_manager/lib/src/cache_managers/` -- Config (IO / web / unsupported): `flutter_cache_manager/lib/src/config/` -- In-memory + DB orchestration: `flutter_cache_manager/lib/src/cache_store.dart` -- Download / HTTP: `flutter_cache_manager/lib/src/web/` -- Cache metadata storage: `flutter_cache_manager/lib/src/storage/cache_info_repositories/` - - `CacheObjectProvider` — sqflite (default on Android / iOS / macOS) - - `JsonCacheInfoRepository` — JSON file (default on Windows / Linux) - - `NonStoringObjectProvider` — web / no persistence -- File system abstraction: `flutter_cache_manager/lib/src/storage/file_system/` -- Firebase package: `flutter_cache_manager_firebase/lib/` -- Example app: `flutter_cache_manager/example/` -- CI: - - `.github/workflows/build.yaml` — `flutter_cache_manager` - - `.github/workflows/build-firebase.yaml` — `flutter_cache_manager_firebase` - -## Where to make changes - -- **Public API / docs for app developers** → `flutter_cache_manager/lib/` exports and `README.md` -- **Download / HTTP behavior** → `lib/src/web/` -- **Persistence / metadata** → `lib/src/storage/cache_info_repositories/` -- **Cache eviction / mem cache** → `lib/src/cache_store.dart` -- **Firebase integration** → `flutter_cache_manager_firebase/` only -- **Never** put Firebase-specific logic in `flutter_cache_manager`, or platform-specific logic in the core package when it belongs in config hooks or the Firebase package - -Keep changes minimal in scope — one concern per change; match existing naming, error-handling (`FlutterError.reportError` where used), and testing patterns. - -## Development setup - -Per [CONTRIBUTING.md](CONTRIBUTING.md) and Baseflow's open-source forking workflow: - -1. Fork `https://github.com/Baseflow/flutter_cache_manager` on GitHub. -2. Clone your fork: `git clone git@github.com:/flutter_cache_manager.git` -3. Add upstream (the official repo you fetch from, not your fork): - -```bash -git remote add upstream git@github.com:Baseflow/flutter_cache_manager.git -``` - -4. Branch from latest `main`: - -```bash -git fetch upstream -git checkout upstream/main -b -``` - -Expected remotes after setup: - -``` -origin git@github.com:/flutter_cache_manager.git # your fork (push here) -upstream git@github.com:Baseflow/flutter_cache_manager.git # official repo (fetch here) -``` - -## Commands - -Run from the package you are editing: - -```bash -cd flutter_cache_manager # or flutter_cache_manager_firebase -flutter pub get -dart format . -flutter analyze -flutter test -``` - -CI runs the same commands with stricter flags: - -```bash -dart format --set-exit-if-changed . -flutter analyze -flutter test --coverage -``` - -Run the example app: - -```bash -cd flutter_cache_manager/example -flutter run -``` - -Before finishing work, run the same checks CI runs for that package (format, analyze, test; example builds are covered in `build.yaml` for the main package). - -## Testing expectations - -| Package | Tests | -|---------|-------| -| `flutter_cache_manager` | Dart unit tests under `test/` (manager, store, web helper, repositories, image helpers) | -| `flutter_cache_manager_firebase` | Minimal Dart tests — verify via analyze/format and integration judgment | - -Prefer `MemoryFileSystem` / mocks over real disk or network in unit tests. When changing `JsonCacheInfoRepository`, cover persistence without relying on timers, and keep temp-file / failure paths in mind. - -## Platform notes - -- **Mobile / macOS**: default metadata store is sqflite (`CacheObjectProvider`). -- **Windows / Linux**: default metadata store is JSON (`JsonCacheInfoRepository`); writes should remain durable (write-through / short-lived queues, atomic replace). -- **Web**: limited / non-persisting storage via conditional imports (`_config_web.dart`, memory file system). -- **Firebase package**: depends on published `flutter_cache_manager`; local path overrides are only for integration experiments — do not assume Melos linking. - -## Pull request workflow - -**Hard requirement — `main` is the only long-lived branch.** Branch from `upstream/main`, open -every PR against `main`, and rebase onto `main`. There is no `develop` branch: do not create one, -do not target one, and do not reintroduce a two-branch (`develop` → `main`) flow. A repository -ruleset blocks creation of any branch named `develop`, so attempts to push one will be rejected. -PRs land as squash merges, so each PR becomes a single commit on `main`. - -This repo uses the **forking workflow**: contributors work on their own fork and open PRs to the main repository. Maintainers review and merge — do not push directly to `Baseflow/flutter_cache_manager`. - -1. Apply changes on a branch based on `upstream/main`, scoped to one package. -2. Bump that package's `version:` in `pubspec.yaml` following semver, and add a matching - `## [x.y.z] - YYYY-MM-DD` `CHANGELOG.md` entry describing the change (format: see - [Releases](#releases)). Date it with the day you open the PR; a maintainer will correct it if - it slips before tagging. -3. Verify locally (from the changed package): - - `dart format .` - - `flutter analyze` - - `flutter test` -4. Push to your fork: `git push origin ` -5. Open a PR against `Baseflow/flutter_cache_manager` and fill out the full [PR template](.github/PULL_REQUEST_TEMPLATE.md). - -Docs-only and CI-only PRs (for example `AGENTS.md`, `CONTRIBUTING.md`, `README.md`, or -`.github/` changes that ship nothing to pub.dev) do not bump the version and do not add a -`CHANGELOG.md` entry. - -If two PRs claim the same next version, the one merged second rebases and takes the next number; -this shows up as a conflict in `pubspec.yaml`. If a contributor's PR is missing the bump, the -maintainer adds the bump and the dated entry when merging rather than sending it back. - -Keep public API changes additive and non-breaking where possible; breaking changes need a clear major-version plan and README/CHANGELOG callouts. - -### PR description style - -**Hard requirement**: use the [PR template](.github/PULL_REQUEST_TEMPLATE.md)'s actual -section headings verbatim — `What kind of change does this PR introduce?`, `What is the -current behavior?`, `What is the new behavior (if this is a feature change)?`, `Does this -PR introduce a breaking change?`, `Recommendations for testing`, `Links to relevant -issues/docs`, and the `Checklist before submitting` with its exact five items. Do not -substitute a different structure (e.g. a generic "Description" / "Type of change" layout) -even for small or maintainer-authored PRs like release/version-bump PRs — every PR must -be created from the template file's own headings. - -Fill out the [PR template](.github/PULL_REQUEST_TEMPLATE.md), but keep each section tight: - -- State what changed and why. Don't narrate your own editing process or explain why one obvious, in-scope edit was made alongside another (e.g. "also updated X because it references Y") — that's a given fact of the PR, not something a reviewer needs spelled out. -- Don't repeat file paths in prose; the diff already shows them. -- Answer yes/no questions with a plain yes/no; add a sentence only when the answer is non-obvious. "Does this introduce a breaking change?" is the exception: answer "No" alone when it's not, but when it is, give a short explanation of what breaks and for whom. -- Keep "Recommendations for testing" to what a reviewer needs to act on: what ran, what didn't and why, and what to check on CI — one or two sentences, not a full incident writeup. - -### PR checklist - -- [ ] Project builds for the changed package(s) -- [ ] This PR only changes one package (or documents why an exception is needed) -- [ ] `pubspec.yaml` version bumped and a dated `## [x.y.z] - YYYY-MM-DD` `CHANGELOG.md` entry added in the changed package, following the [Flutter changelog style](https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changelog-style) (skip for docs-only and CI-only PRs) -- [ ] Public API documented with `///` doc comments where applicable -- [ ] Rebased onto `main` -- [ ] New tests added where applicable; all tests pass -- [ ] `dart format .` and `flutter analyze` pass with no errors, no warnings left unfixed -- [ ] Relevant README / docs updated for user-facing changes -- [ ] Full [PR template](.github/PULL_REQUEST_TEMPLATE.md) filled in - -## Releases - -Each package is versioned and released independently. Only maintainers cut releases. - -Releasing is tagging. Every merged PR already bumped its own package's `version:` and added its -`CHANGELOG.md` entry (see [Pull request workflow](#pull-request-workflow)), so there is no -separate release-prep PR. - -1. Confirm the merge commit on `main` carries the version you mean to release, and that its - `CHANGELOG.md` date is still correct; if the date slipped, fix it in a docs-only PR first. -2. Verify from that package directory: `dart format --set-exit-if-changed .`, `flutter analyze`, - `flutter test`, and `dart pub publish --dry-run`. -3. Tag that merge commit on `main`: - - `flutter_cache_manager` → `vX.Y.Z` - - `flutter_cache_manager_firebase` → `firebase-vX.Y.Z` -4. Push the tag. **Pushing the tag is what publishes**: `build.yaml` / `build-firebase.yaml` run - `dart pub publish` via pub.dev OIDC trusted publishing, gated on `github.ref_type == 'tag'`. - Never run `dart pub publish` by hand, and never bump a version without a tag to match. - -The workflows publish whatever is committed at the tagged commit — they do not bump versions or -edit changelogs. - -`CHANGELOG.md` uses `## [x.y.z] - YYYY-MM-DD` headings with `*` bullets and has no -`## [Unreleased]` section. Match that format and do not add an `[Unreleased]` section. -For a release with a breaking change, you may split the entry into `### Breaking -changes` and `### Other changes` subsections instead of a flat list — -`cached_network_image` 4.0.0 is the worked example. Not required for an ordinary -release. - -Keep branch names out of URLs in `pubspec.yaml` and docs; published versions are immutable, so a -branch-specific link becomes a permanent dead link once that branch is gone. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 55027e08..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,43 +0,0 @@ -@AGENTS.md - -## Claude Code - -Everything tool-neutral lives in `AGENTS.md` (imported above). Keep it that way: only add -things here that are specific to Claude Code, and put any new project rule in `AGENTS.md` -so other agents and human contributors pick it up too. - -### Tooling in this checkout - -- Use the plain `flutter` / `dart` commands in `AGENTS.md`. They are what CI runs and - what a contributor will have. If this checkout happens to carry a local fvm pin - (`.fvmrc` is gitignored, so it may or may not) prefix with `fvm`. Do not add one, - and do not tell anyone they need fvm to work on this repo. -- `flutter run` for `flutter_cache_manager/example/` is long-running — ask before starting - it and don't leave it running in the background. - -### Working style - -- Prefer the file tools (Read / Edit / Grep / Glob) over `cat`/`sed`/`grep` in Bash for - reading and editing repo files. -- Use the `Explore` subagent for wide searches across both packages; the tree is small - enough that targeted `Grep` is usually faster for anything narrower. -- Run `/code-review` on the diff before handing work over, and `/security-review` when a - change touches download/HTTP (`lib/src/web/`) or on-disk persistence - (`lib/src/storage/`). - -### Commits and PRs - -- Follow the forking + PR workflow in `AGENTS.md`; don't push to - `Baseflow/flutter_cache_manager` branches directly unless the maintainer asks for it in - that session. -- End commit messages with: - - ``` - Co-Authored-By: Claude - ``` - -- End PR descriptions with: - - ``` - 🤖 Generated with [Claude Code](https://claude.com/claude-code) - ``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 756eb1f5..6effc6f4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,9 +26,9 @@ to generate an SSH key. We really appreciate contributions via GitHub pull requests. To contribute take the following steps: -- Make sure you are up to date with the latest code on main: +- Make sure you are up to date with the latest code on develop: - `git fetch upstream` - - `git checkout upstream/main -b ` + - `git checkout upstream/develop -b ` - Apply your changes - Verify your changes and fix potential warnings/ errors (run from the package you changed): - Check formatting: `dart format .` @@ -48,5 +48,5 @@ Send us your pull request: Each pull request should follow these conventions: - **One package per PR** — keep changes confined to a single package directory (`flutter_cache_manager/`, `flutter_cache_manager_firebase/`). Cross-package changes require maintainer coordination and are usually split into separate PRs. -- **Bump the version and add a dated CHANGELOG entry** — bump that package's `version:` in `pubspec.yaml` following [semver](https://dart.dev/tools/pub/versioning), and add a matching `## [x.y.z] - YYYY-MM-DD` section to its `CHANGELOG.md`, dated the day you open the pull request. Docs-only and CI-only pull requests skip this step. Entries should follow the style conventions also used by Flutter which can be found in their [CHANGELOG style guide](https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changelog-style). +- **Add CHANGELOG entry to [Unreleased]** — for your change, add one or more entries under the "Unreleased" section of that package's `CHANGELOG.md`. Entries should follow the style conventions also used by Flutter which can be found in their [CHANGELOG style guide](https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changelog-style). diff --git a/flutter_cache_manager/CHANGELOG.md b/flutter_cache_manager/CHANGELOG.md index 9513fb80..6b072fc6 100644 --- a/flutter_cache_manager/CHANGELOG.md +++ b/flutter_cache_manager/CHANGELOG.md @@ -1,3 +1,7 @@ +## [3.5.1] - 2026-10-21 + +* Merge upstream 3.4.5 persistence and decoding fixes, retaining cancellation. + ## [3.4.5] - 2026-09-18 * Prevents image resize decoding from completing more than once and propagates decoding errors ([#400](https://github.com/Baseflow/flutter_cache_manager/issues/400)). @@ -8,6 +12,16 @@ ## [3.4.3] - 2026-09-15 +### Fork (Railway-Engineering-Solutions) + +* Adds IndexedDB-backed cache info repository and file system for web, replacing the + non-persistent `NonStoringObjectProvider` / `MemoryCacheSystem` defaults +* Syncs with Baseflow upstream `develop` (through 3.4.2 + unreleased changes) +* Raises dependency floors: `clock` 1.1.3, `path_provider` 2.1.6, `sqflite` 2.4.3, + `build_runner` 2.16.1, `mockito` 5.8.1, `web` 1.1.1 + +### Upstream + * Fixes `JsonCacheInfoRepository` losing metadata when the app exits within 3 seconds of a cache change by writing through promptly with serialized, atomic file writes ([#491](https://github.com/Baseflow/flutter_cache_manager/issues/491)) * Modernizes GitHub Actions CI (combined quality job, pinned Flutter 3.47.4, Dependabot for actions) * Adopts super parameters in `HttpExceptionWithStatus` to satisfy the stricter `use_super_parameters` lint in Dart 3.13 (no API or behavior change) diff --git a/flutter_cache_manager/analysis_options.yaml b/flutter_cache_manager/analysis_options.yaml index f9b30346..743e05ad 100644 --- a/flutter_cache_manager/analysis_options.yaml +++ b/flutter_cache_manager/analysis_options.yaml @@ -1 +1,10 @@ +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:flutter_lints/flutter.yaml diff --git a/flutter_cache_manager/example/analysis_options.yaml b/flutter_cache_manager/example/analysis_options.yaml index f9b30346..743e05ad 100644 --- a/flutter_cache_manager/example/analysis_options.yaml +++ b/flutter_cache_manager/example/analysis_options.yaml @@ -1 +1,10 @@ +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:flutter_lints/flutter.yaml diff --git a/flutter_cache_manager/example/lib/main.dart b/flutter_cache_manager/example/lib/main.dart index ee34ca00..a85ff169 100644 --- a/flutter_cache_manager/example/lib/main.dart +++ b/flutter_cache_manager/example/lib/main.dart @@ -17,7 +17,7 @@ void main() { CacheManager.logLevel = CacheManagerLogLevel.verbose; } -const url = 'https://picsum.photos/200/300'; +const url = 'https://i.imgur.com/7j7W5eq.jpeg'; /// Example [Widget] showing the functionalities of flutter_cache_manager class CacheManagerPage extends StatefulWidget { diff --git a/flutter_cache_manager/example/lib/test_indexeddb.dart b/flutter_cache_manager/example/lib/test_indexeddb.dart new file mode 100644 index 00000000..c06a3349 --- /dev/null +++ b/flutter_cache_manager/example/lib/test_indexeddb.dart @@ -0,0 +1,223 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; + +/// Simple test app to verify IndexedDB caching works on Flutter web +/// Run with: flutter run -d chrome lib/test_indexeddb.dart +void main() { + // Enable verbose logging to see caching in action + CacheManager.logLevel = CacheManagerLogLevel.verbose; + runApp(const IndexedDBTestApp()); +} + +class IndexedDBTestApp extends MaterialApp { + const IndexedDBTestApp({super.key}) + : super(home: const IndexedDBTestPage(), title: 'IndexedDB Cache Test'); +} + +class IndexedDBTestPage extends StatefulWidget { + const IndexedDBTestPage({super.key}); + + @override + State createState() => _IndexedDBTestPageState(); +} + +class _IndexedDBTestPageState extends State { + // Using a reliable CDN image that supports CORS + final String testUrl = 'https://i.imgur.com/7j7W5eq.jpeg'; + String status = 'Ready'; + FileInfo? cachedFile; + bool isLoading = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('IndexedDB Cache Test'), + backgroundColor: Colors.blue, + ), + body: Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Text( + 'Flutter Cache Manager - IndexedDB Test', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + if (cachedFile != null) ...[ + Container( + width: 400, + height: 300, + decoration: BoxDecoration( + border: Border.all(color: Colors.grey), + ), + child: Image.network( + testUrl, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return const Center(child: Text('Failed to load image')); + }, + ), + ), + const SizedBox(height: 16), + Text( + 'Source: ${cachedFile!.source.name}', + style: TextStyle( + color: cachedFile!.source == FileSource.Cache + ? Colors.green + : Colors.orange, + fontWeight: FontWeight.bold, + ), + ), + Text('Valid until: ${cachedFile!.validTill}'), + ], + const SizedBox(height: 32), + Text( + status, + style: const TextStyle(fontSize: 16), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + if (isLoading) const CircularProgressIndicator(), + if (!isLoading) ...[ + ElevatedButton.icon( + onPressed: _downloadFile, + icon: const Icon(Icons.download), + label: const Text('Download & Cache Image'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + ), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: _loadFromCache, + icon: const Icon(Icons.cached), + label: const Text('Load from Cache'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + ), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: _clearCache, + icon: const Icon(Icons.delete), + label: const Text('Clear Cache'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + ), + ), + ], + const SizedBox(height: 32), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + '💡 Test Instructions:\n' + '1. Click "Download & Cache Image" - should show source: Online\n' + '2. Refresh the page (F5)\n' + '3. Click "Load from Cache" - should show source: Cache\n' + '4. Open DevTools → Application → IndexedDB to see stored data', + style: TextStyle(fontSize: 14), + ), + ), + ], + ), + ), + ), + ); + } + + Future _downloadFile() async { + setState(() { + isLoading = true; + status = 'Downloading image...'; + }); + + try { + final info = await DefaultCacheManager().getFileFromCache(testUrl); + + setState(() { + cachedFile = info; + isLoading = false; + status = + 'Image downloaded and cached! Source: ${info?.source.name ?? "Unknown"}'; + }); + } catch (e) { + setState(() { + isLoading = false; + status = 'Error: $e'; + }); + } + } + + Future _loadFromCache() async { + setState(() { + isLoading = true; + status = 'Loading from cache...'; + }); + + try { + final info = await DefaultCacheManager().getFileFromCache(testUrl); + + if (info == null) { + setState(() { + isLoading = false; + status = 'No cached file found. Download first!'; + cachedFile = null; + }); + return; + } + + setState(() { + cachedFile = info; + isLoading = false; + status = 'Loaded from cache! Source: ${info.source.name}'; + }); + } catch (e) { + setState(() { + isLoading = false; + status = 'Error loading from cache: $e'; + }); + } + } + + Future _clearCache() async { + setState(() { + isLoading = true; + status = 'Clearing cache...'; + }); + + try { + await DefaultCacheManager().emptyCache(); + + setState(() { + cachedFile = null; + isLoading = false; + status = 'Cache cleared!'; + }); + } catch (e) { + setState(() { + isLoading = false; + status = 'Error clearing cache: $e'; + }); + } + } +} diff --git a/flutter_cache_manager/lib/flutter_cache_manager.dart b/flutter_cache_manager/lib/flutter_cache_manager.dart index 05064282..e83b9d20 100644 --- a/flutter_cache_manager/lib/flutter_cache_manager.dart +++ b/flutter_cache_manager/lib/flutter_cache_manager.dart @@ -10,6 +10,5 @@ export 'src/logger.dart'; export 'src/result/result.dart'; export 'src/storage/cache_info_repositories/cache_info_repositories.dart'; export 'src/storage/cache_object.dart'; -export 'src/storage/file_system/file_system.dart'; export 'src/web/file_service.dart'; export 'src/web/web_helper.dart' show HttpExceptionWithStatus; diff --git a/flutter_cache_manager/lib/src/cache_manager.dart b/flutter_cache_manager/lib/src/cache_manager.dart index b700e1c7..11f82c4e 100644 --- a/flutter_cache_manager/lib/src/cache_manager.dart +++ b/flutter_cache_manager/lib/src/cache_manager.dart @@ -70,13 +70,19 @@ class CacheManager implements BaseCacheManager { String url, { String? key, Map? headers, + CancellationToken? cancellationToken, }) async { key ??= url; final cacheFile = await getFileFromCache(key); if (cacheFile != null && cacheFile.validTill.isAfter(DateTime.now())) { return cacheFile.file; } - return (await downloadFile(url, key: key, authHeaders: headers)).file; + return (await downloadFile( + url, + key: key, + authHeaders: headers, + cancellationToken: cancellationToken, + )).file; } /// Get the file from the cache and/or online, depending on availability and age. @@ -89,11 +95,13 @@ class CacheManager implements BaseCacheManager { String url, { String? key, Map? headers, + CancellationToken? cancellationToken, }) { return getFileStream( url, key: key, withProgress: false, + cancellationToken: cancellationToken, ).where((r) => r is FileInfo).cast(); } @@ -114,10 +122,18 @@ class CacheManager implements BaseCacheManager { String? key, Map? headers, bool withProgress = false, + CancellationToken? cancellationToken, }) { key ??= url; final streamController = StreamController(); - _pushFileToStream(streamController, url, key, headers, withProgress); + _pushFileToStream( + streamController, + url, + key, + headers, + withProgress, + cancellationToken, + ); return streamController.stream; } @@ -127,6 +143,7 @@ class CacheManager implements BaseCacheManager { String? key, Map? headers, bool withProgress, + CancellationToken? cancellationToken, ) async { key ??= url; FileInfo? cacheFile; @@ -148,6 +165,7 @@ class CacheManager implements BaseCacheManager { url, key: key, authHeaders: headers, + cancellationToken: cancellationToken, )) { if (response is DownloadProgress && withProgress) { streamController.add(response); @@ -185,6 +203,7 @@ class CacheManager implements BaseCacheManager { String? key, Map? authHeaders, bool force = false, + CancellationToken? cancellationToken, }) async { key ??= url; final fileResponse = await _webHelper @@ -193,6 +212,7 @@ class CacheManager implements BaseCacheManager { key: key, authHeaders: authHeaders, ignoreMemCache: force, + cancellationToken: cancellationToken, ) .firstWhere((r) => r is FileInfo); return fileResponse as FileInfo; diff --git a/flutter_cache_manager/lib/src/cache_managers/base_cache_manager.dart b/flutter_cache_manager/lib/src/cache_managers/base_cache_manager.dart index c6f3a59d..71e8e785 100644 --- a/flutter_cache_manager/lib/src/cache_managers/base_cache_manager.dart +++ b/flutter_cache_manager/lib/src/cache_managers/base_cache_manager.dart @@ -3,6 +3,7 @@ import 'dart:typed_data'; import 'package:file/file.dart'; import 'package:flutter_cache_manager/src/result/file_info.dart'; import 'package:flutter_cache_manager/src/result/file_response.dart'; +import 'package:flutter_cache_manager/src/web/file_service.dart'; /// Interface of the CacheManager. In general [CacheManager] can be used /// directly. @@ -14,8 +15,9 @@ abstract class BaseCacheManager { /// newly downloaded file is returned. Future getSingleFile( String url, { - String key, - Map headers, + String? key, + Map? headers, + CancellationToken? cancellationToken, }); /// Get the file from the cache and/or online, depending on availability and age. @@ -25,8 +27,9 @@ abstract class BaseCacheManager { @Deprecated('Prefer to use the new getFileStream method') Stream getFile( String url, { - String key, - Map headers, + String? key, + Map? headers, + CancellationToken? cancellationToken, }); /// Get the file from the cache and/or online, depending on availability and age. @@ -44,7 +47,8 @@ abstract class BaseCacheManager { String url, { String? key, Map? headers, - bool withProgress, + bool withProgress = false, + CancellationToken? cancellationToken, }); ///Download the file and add to cache @@ -53,6 +57,7 @@ abstract class BaseCacheManager { String? key, Map? authHeaders, bool force = false, + CancellationToken? cancellationToken, }); /// Get the file from the cache. diff --git a/flutter_cache_manager/lib/src/cache_managers/image_cache_manager.dart b/flutter_cache_manager/lib/src/cache_managers/image_cache_manager.dart index 5851a7ab..d4f2b813 100644 --- a/flutter_cache_manager/lib/src/cache_managers/image_cache_manager.dart +++ b/flutter_cache_manager/lib/src/cache_managers/image_cache_manager.dart @@ -24,6 +24,7 @@ mixin ImageCacheManager on BaseCacheManager { bool withProgress = false, int? maxHeight, int? maxWidth, + CancellationToken? cancellationToken, }) async* { if (maxHeight == null && maxWidth == null) { yield* getFileStream( @@ -31,6 +32,7 @@ mixin ImageCacheManager on BaseCacheManager { key: key, headers: headers, withProgress: withProgress, + cancellationToken: cancellationToken, ); return; } @@ -56,6 +58,7 @@ mixin ImageCacheManager on BaseCacheManager { resizedKey, headers, withProgress, + cancellationToken: cancellationToken, maxWidth: maxWidth, maxHeight: maxHeight, ).asBroadcastStream(); @@ -130,12 +133,14 @@ mixin ImageCacheManager on BaseCacheManager { bool withProgress, { int? maxWidth, int? maxHeight, + CancellationToken? cancellationToken, }) async* { await for (final response in getFileStream( url, key: originalKey, headers: headers, withProgress: withProgress, + cancellationToken: cancellationToken, )) { if (response is DownloadProgress) { yield response; diff --git a/flutter_cache_manager/lib/src/cache_store.dart b/flutter_cache_manager/lib/src/cache_store.dart index 44ffbfeb..d03cbccf 100644 --- a/flutter_cache_manager/lib/src/cache_store.dart +++ b/flutter_cache_manager/lib/src/cache_store.dart @@ -1,8 +1,11 @@ import 'dart:async'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'storage/file_system/file_system.dart'; + ///Flutter Cache Manager ///Copyright (c) 2019 Rene Floor ///Released under MIT License. @@ -197,7 +200,7 @@ class CacheStore { } final file = await fileSystem.createFile(cacheObject.relativePath); - if (file.existsSync()) { + if (kIsWeb ? await file.exists() : file.existsSync()) { try { await file.delete(); // ignore: unused_catch_clause diff --git a/flutter_cache_manager/lib/src/compat/file_service_compat.dart b/flutter_cache_manager/lib/src/compat/file_service_compat.dart index 7d6fdbc6..92656fe8 100644 --- a/flutter_cache_manager/lib/src/compat/file_service_compat.dart +++ b/flutter_cache_manager/lib/src/compat/file_service_compat.dart @@ -13,8 +13,15 @@ class FileServiceCompat extends FileService { Future get( String url, { Map? headers, + CancellationToken? cancellationToken, }) async { + if (cancellationToken?.isCancelled ?? false) { + throw CancelledException(); + } final legacyResponse = await fileFetcher(url, headers: headers); + if (cancellationToken?.isCancelled ?? false) { + throw CancelledException(); + } return CompatFileServiceGetResponse(legacyResponse); } } diff --git a/flutter_cache_manager/lib/src/config/_config_io.dart b/flutter_cache_manager/lib/src/config/_config_io.dart index 8df79baf..5d69a58b 100644 --- a/flutter_cache_manager/lib/src/config/_config_io.dart +++ b/flutter_cache_manager/lib/src/config/_config_io.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_cache_manager/src/config/config.dart' as def; +import '../storage/file_system/file_system.dart'; + class Config implements def.Config { Config( this.cacheKey, { diff --git a/flutter_cache_manager/lib/src/config/_config_web.dart b/flutter_cache_manager/lib/src/config/_config_web.dart index 9a019781..ef8b62fa 100644 --- a/flutter_cache_manager/lib/src/config/_config_web.dart +++ b/flutter_cache_manager/lib/src/config/_config_web.dart @@ -1,8 +1,8 @@ +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_cache_manager/src/config/config.dart' as def; -import 'package:flutter_cache_manager/src/storage/cache_info_repositories/cache_info_repository.dart'; -import 'package:flutter_cache_manager/src/storage/cache_info_repositories/non_storing_object_provider.dart'; +import 'package:flutter_cache_manager/src/storage/cache_info_repositories/indexed_db_cache_info_repository.dart'; import 'package:flutter_cache_manager/src/storage/file_system/file_system.dart'; -import 'package:flutter_cache_manager/src/web/file_service.dart'; +import 'package:flutter_cache_manager/src/storage/file_system/indexed_db_file_system.dart'; class Config implements def.Config { Config( @@ -14,8 +14,13 @@ class Config implements def.Config { FileService? fileService, }) : stalePeriod = stalePeriod ?? const Duration(days: 30), maxNrOfCacheObjects = maxNrOfCacheObjects ?? 200, - repo = repo ?? NonStoringObjectProvider(), - fileSystem = fileSystem ?? MemoryCacheSystem(), + repo = + repo ?? + IndexedDbCacheInfoRepository( + databaseName: 'flutter_cache_manager_$cacheKey', + ), + fileSystem = + fileSystem ?? IndexedDbFileSystem('flutter_cache_manager_$cacheKey'), fileService = fileService ?? HttpFileService(); @override diff --git a/flutter_cache_manager/lib/src/config/config.dart b/flutter_cache_manager/lib/src/config/config.dart index 88dabb37..98597a70 100644 --- a/flutter_cache_manager/lib/src/config/config.dart +++ b/flutter_cache_manager/lib/src/config/config.dart @@ -4,6 +4,8 @@ import 'package:flutter_cache_manager/src/config/_config_unsupported.dart' if (dart.library.io) '_config_io.dart' as impl; +import '../storage/file_system/file_system.dart'; + abstract class Config { /// Config file for the CacheManager. /// [cacheKey] is used for the folder to store files and for the database diff --git a/flutter_cache_manager/lib/src/storage/cache_info_repositories/cache_info_repositories.dart b/flutter_cache_manager/lib/src/storage/cache_info_repositories/cache_info_repositories.dart index 14ab5ab6..b2e77fb4 100644 --- a/flutter_cache_manager/lib/src/storage/cache_info_repositories/cache_info_repositories.dart +++ b/flutter_cache_manager/lib/src/storage/cache_info_repositories/cache_info_repositories.dart @@ -2,3 +2,7 @@ export 'cache_info_repository.dart'; export 'cache_object_provider.dart'; export 'json_cache_info_repository.dart'; export 'non_storing_object_provider.dart'; + +// Note: indexed_db_cache_info_repository.dart is web-only and not exported here +// to avoid dart:js_interop import errors on non-web platforms. +// Web code should import it directly when needed. diff --git a/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_cache_info_repository.dart b/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_cache_info_repository.dart new file mode 100644 index 00000000..d7349910 --- /dev/null +++ b/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_cache_info_repository.dart @@ -0,0 +1,636 @@ +import 'dart:async'; +import 'dart:js_interop'; + +import 'package:flutter_cache_manager/src/logger.dart'; +import 'package:flutter_cache_manager/src/storage/cache_info_repositories/cache_info_repository.dart'; +import 'package:flutter_cache_manager/src/storage/cache_info_repositories/helper_methods.dart'; +import 'package:flutter_cache_manager/src/storage/cache_info_repositories/indexed_db_connection_pool.dart'; +import 'package:flutter_cache_manager/src/storage/cache_object.dart'; +import 'package:web/web.dart' as web; + +/// A cache info repository implementation that stores cache metadata in IndexedDB. +class IndexedDbCacheInfoRepository extends CacheInfoRepository + with CacheInfoRepositoryHelperMethods { + IndexedDbCacheInfoRepository({required this.databaseName}); + + final String databaseName; + + static const String _metadataStoreName = 'cache_metadata'; + static const String _keyIndexName = 'key_index'; + static const String _touchedIndexName = 'touched_index'; + static const int _dbVersion = 2; // Incremented for new index + + late final IndexedDbConnectionPool _connectionPool; + + Future _getDatabase() async { + return _connectionPool.getDatabase(); + } + + void _initConnectionPool() { + _connectionPool = IndexedDbConnectionPool.getInstance( + databaseName: databaseName, + version: _dbVersion, + onUpgrade: _onUpgradeNeeded, + ); + } + + void _onUpgradeNeeded(web.IDBDatabase db, web.IDBVersionChangeEvent e) { + final oldVersion = e.oldVersion; + + // Create cache_metadata object store if it doesn't exist (v1) + if (oldVersion < 1) { + final hasMetadataStore = db.objectStoreNames.contains(_metadataStoreName); + if (!hasMetadataStore) { + final objectStore = db.createObjectStore( + _metadataStoreName, + web.IDBObjectStoreParameters( + keyPath: CacheObject.columnId.toJS, + autoIncrement: true, + ), + ); + // Create index on key field for fast lookups + objectStore.createIndex( + _keyIndexName, + CacheObject.columnKey.toJS, + web.IDBIndexParameters(unique: true), + ); + // Create index on touched field for efficient sorting in cleanup + objectStore.createIndex( + _touchedIndexName, + CacheObject.columnTouched.toJS, + web.IDBIndexParameters(unique: false), + ); + } + + // Also create cache_files object store if it doesn't exist + // This ensures both stores are created in the same upgrade transaction + const fileStoreName = 'cache_files'; + final hasFileStore = db.objectStoreNames.contains(fileStoreName); + if (!hasFileStore) { + db.createObjectStore( + fileStoreName, + web.IDBObjectStoreParameters(keyPath: 'path'.toJS), + ); + } + } + + // Add touched index for existing databases (v2) + if (oldVersion < 2 && oldVersion >= 1) { + // We're in the upgrade transaction, get the object store + final request = e.target as web.IDBRequest; + final txn = request.transaction; + if (txn != null) { + final store = txn.objectStore(_metadataStoreName); + // Add index if it doesn't exist + if (!store.indexNames.contains(_touchedIndexName)) { + store.createIndex( + _touchedIndexName, + CacheObject.columnTouched.toJS, + web.IDBIndexParameters(unique: false), + ); + } + } + } + } + + @override + Future open() async { + if (!shouldOpenOnNewConnection()) { + return openCompleter!.future; + } + _initConnectionPool(); + await _getDatabase(); + return opened(); + } + + /// Creates a transaction with optimal performance settings. + /// Uses 'relaxed' durability for cache data which provides ~10x faster writes + /// while still persisting data on browser shutdown. + web.IDBTransaction _createTransaction( + web.IDBDatabase db, + String storeName, + String mode, + ) { + try { + // Try to create transaction with relaxed durability (modern browsers) + // Note: The durability hint may not be available in all browser versions + final options = web.IDBTransactionOptions(); + return db.transaction(storeName.toJS, mode, options); + } catch (e) { + // Fallback for older browsers that don't support transaction options + return db.transaction(storeName.toJS, mode); + } + } + + /// Checks if an error is a quota exceeded error. + bool _isQuotaExceededError(Object error) { + final errorString = error.toString().toLowerCase(); + return errorString.contains('quota') || + errorString.contains('quotaexceedederror') || + errorString.contains('exceeded') && errorString.contains('storage'); + } + + /// Handles quota exceeded errors by attempting to free up space. + Future _handleQuotaExceeded() async { + cacheLogger.log( + 'CacheManager: Quota exceeded, attempting to free up space', + CacheManagerLogLevel.warning, + ); + + try { + // Get the oldest 10% of objects and delete them + final allObjects = await getAllObjects(); + if (allObjects.isEmpty) { + return; + } + + allObjects.sort((a, b) => a.touched!.compareTo(b.touched!)); + final toRemoveCount = (allObjects.length * 0.1).ceil().clamp(1, 50); + final toRemove = allObjects + .take(toRemoveCount) + .map((e) => e.id!) + .toList(); + + await deleteAll(toRemove); + + cacheLogger.log( + 'CacheManager: Freed up space by removing $toRemoveCount old cache entries', + CacheManagerLogLevel.verbose, + ); + } catch (e) { + cacheLogger.log( + 'CacheManager: Failed to free up space: $e', + CacheManagerLogLevel.warning, + ); + } + } + + @override + Future get(String key) async { + final db = await _getDatabase(); + final completer = Completer(); + + final transaction = _createTransaction(db, _metadataStoreName, 'readonly'); + final store = transaction.objectStore(_metadataStoreName); + final index = store.index(_keyIndexName); + final request = index.get(key.toJS); + + request.onsuccess = (web.Event e) { + final result = request.result; + if (result != null) { + final map = _jsToMap(result); + completer.complete(CacheObject.fromMap(map)); + } else { + completer.complete(null); + } + }.toJS; + + request.onerror = (web.Event e) { + completer.completeError( + Exception('Failed to get object from IndexedDB: ${request.error}'), + ); + }.toJS; + + return completer.future; + } + + @override + Future> getAllObjects() async { + final db = await _getDatabase(); + final completer = Completer>(); + + final transaction = _createTransaction(db, _metadataStoreName, 'readonly'); + final store = transaction.objectStore(_metadataStoreName); + final request = store.getAll(); + + request.onsuccess = (web.Event e) { + final result = request.result; + final list = []; + if (result != null) { + final jsArray = result as JSArray; + for (var i = 0; i < jsArray.length; i++) { + final item = jsArray[i]; + final map = _jsToMap(item); + list.add(CacheObject.fromMap(map)); + } + } + completer.complete(list); + }.toJS; + + request.onerror = (web.Event e) { + completer.completeError( + Exception('Failed to get all objects from IndexedDB: ${request.error}'), + ); + }.toJS; + + return completer.future; + } + + @override + Future insert( + CacheObject cacheObject, { + bool setTouchedToNow = true, + }) async { + if (cacheObject.id != null) { + throw ArgumentError("Inserted objects shouldn't have an existing id."); + } + + try { + return await _insertWithRetry(cacheObject, setTouchedToNow, retries: 1); + } catch (e) { + if (_isQuotaExceededError(e)) { + cacheLogger.log( + 'CacheManager: Insert failed due to quota exceeded', + CacheManagerLogLevel.warning, + ); + } + rethrow; + } + } + + Future _insertWithRetry( + CacheObject cacheObject, + bool setTouchedToNow, { + required int retries, + }) async { + final db = await _getDatabase(); + final completer = Completer(); + + final transaction = _createTransaction(db, _metadataStoreName, 'readwrite'); + final store = transaction.objectStore(_metadataStoreName); + + final map = cacheObject.toMap(setTouchedToNow: setTouchedToNow); + map.remove(CacheObject.columnId); // Let IndexedDB auto-generate the id + + final request = store.add(map.jsify()); + + request.onsuccess = (web.Event e) { + final id = (request.result as JSNumber).toDartInt; + final newCacheObject = cacheObject.copyWith(id: id); + completer.complete(newCacheObject); + }.toJS; + + request.onerror = (web.Event e) { + completer.completeError( + Exception('Failed to insert object into IndexedDB: ${request.error}'), + ); + }.toJS; + + try { + return await completer.future; + } catch (e) { + if (_isQuotaExceededError(e) && retries > 0) { + // Try to free up space and retry once + await _handleQuotaExceeded(); + return await _insertWithRetry( + cacheObject, + setTouchedToNow, + retries: retries - 1, + ); + } + rethrow; + } + } + + @override + Future update( + CacheObject cacheObject, { + bool setTouchedToNow = true, + }) async { + if (cacheObject.id == null) { + throw ArgumentError('Updated objects should have an existing id.'); + } + + final db = await _getDatabase(); + final completer = Completer(); + + final transaction = _createTransaction(db, _metadataStoreName, 'readwrite'); + final store = transaction.objectStore(_metadataStoreName); + + final map = cacheObject.toMap(setTouchedToNow: setTouchedToNow); + final request = store.put(map.jsify()); + + request.onsuccess = (web.Event e) { + completer.complete(1); + }.toJS; + + request.onerror = (web.Event e) { + completer.completeError( + Exception('Failed to update object in IndexedDB: ${request.error}'), + ); + }.toJS; + + return completer.future; + } + + @override + Future updateOrInsert(CacheObject cacheObject) { + return cacheObject.id == null ? insert(cacheObject) : update(cacheObject); + } + + @override + Future> getObjectsOverCapacity(int capacity) async { + final db = await _getDatabase(); + final completer = Completer>(); + + try { + final transaction = _createTransaction( + db, + _metadataStoreName, + 'readonly', + ); + final store = transaction.objectStore(_metadataStoreName); + + // First, get the count to determine if we're over capacity + final countRequest = store.count(); + + countRequest.onsuccess = (web.Event e) { + final totalCount = (countRequest.result as JSNumber).toDartInt; + + if (totalCount <= capacity) { + completer.complete([]); + return; + } + + // Use the touched index to iterate in sorted order (oldest first) + final index = store.index(_touchedIndexName); + final result = []; + final toRemoveCount = totalCount - capacity; + var count = 0; + + // Open cursor to iterate through oldest items + final cursorRequest = index.openCursor(); + + cursorRequest.onsuccess = (web.Event e) { + final cursor = cursorRequest.result as web.IDBCursorWithValue?; + + if (cursor != null) { + if (count < toRemoveCount) { + final map = _jsToMap(cursor.value); + result.add(CacheObject.fromMap(map)); + count++; + cursor.continue_(); + } else { + // We have enough items, complete + completer.complete(result); + } + } else { + // No more items + completer.complete(result); + } + }.toJS; + + cursorRequest.onerror = (web.Event e) { + completer.completeError( + Exception('Failed to iterate objects: ${cursorRequest.error}'), + ); + }.toJS; + }.toJS; + + countRequest.onerror = (web.Event e) { + completer.completeError( + Exception('Failed to count objects: ${countRequest.error}'), + ); + }.toJS; + + return await completer.future; + } catch (e) { + // Fallback to old method if cursor fails (shouldn't happen with proper index) + final allObjects = await getAllObjects(); + allObjects.sort((c1, c2) => c1.touched!.compareTo(c2.touched!)); + if (allObjects.length <= capacity) return []; + return allObjects.getRange(0, allObjects.length - capacity).toList(); + } + } + + @override + Future> getOldObjects(Duration maxAge) async { + final oldestTimestamp = DateTime.now().subtract(maxAge); + final db = await _getDatabase(); + final completer = Completer>(); + + try { + final transaction = _createTransaction( + db, + _metadataStoreName, + 'readonly', + ); + final store = transaction.objectStore(_metadataStoreName); + + // Use the touched index to efficiently find old objects + final index = store.index(_touchedIndexName); + final result = []; + + // Create a key range for items older than the threshold + // Items with touched timestamp less than oldestTimestamp + final keyRange = web.IDBKeyRange.upperBound( + oldestTimestamp.millisecondsSinceEpoch.toJS, + false, // not open (inclusive) + ); + + final cursorRequest = index.openCursor(keyRange); + + cursorRequest.onsuccess = (web.Event e) { + final cursor = cursorRequest.result as web.IDBCursorWithValue?; + if (cursor != null) { + final map = _jsToMap(cursor.value); + final obj = CacheObject.fromMap(map); + // Double-check the timestamp (defensive programming) + if (obj.touched != null && obj.touched!.isBefore(oldestTimestamp)) { + result.add(obj); + } + cursor.continue_(); + } else { + // No more items + completer.complete(result); + } + }.toJS; + + cursorRequest.onerror = (web.Event e) { + completer.completeError( + Exception('Failed to iterate old objects: ${cursorRequest.error}'), + ); + }.toJS; + + return await completer.future; + } catch (e) { + // Fallback to old method if cursor fails + final allObjects = await getAllObjects(); + return allObjects + .where((element) => element.touched!.isBefore(oldestTimestamp)) + .toList(); + } + } + + @override + Future delete(int id) async { + final db = await _getDatabase(); + final completer = Completer(); + + final transaction = _createTransaction(db, _metadataStoreName, 'readwrite'); + final store = transaction.objectStore(_metadataStoreName); + final request = store.delete(id.toJS); + + request.onsuccess = (web.Event e) { + completer.complete(1); + }.toJS; + + request.onerror = (web.Event e) { + completer.completeError( + Exception('Failed to delete object from IndexedDB: ${request.error}'), + ); + }.toJS; + + return completer.future; + } + + @override + Future deleteAll(Iterable ids) async { + if (ids.isEmpty) return 0; + + final db = await _getDatabase(); + final completer = Completer(); + + try { + final transaction = _createTransaction( + db, + _metadataStoreName, + 'readwrite', + ); + final store = transaction.objectStore(_metadataStoreName); + + // Queue all delete operations in the transaction + final deleteRequests = []; + for (final id in ids) { + deleteRequests.add(store.delete(id.toJS)); + } + + // Wait for the entire transaction to complete + // This ensures all deletes are atomic + transaction.oncomplete = (web.Event e) { + completer.complete(ids.length); + }.toJS; + + transaction.onerror = (web.Event e) { + completer.completeError( + Exception( + 'Failed to delete objects from IndexedDB: ${transaction.error}', + ), + ); + }.toJS; + + transaction.onabort = (web.Event e) { + completer.completeError( + Exception('Delete transaction aborted: ${transaction.error}'), + ); + }.toJS; + + return await completer.future; + } catch (e) { + throw Exception('Failed to delete all objects: $e'); + } + } + + @override + Future close() async { + if (!shouldClose()) { + return false; + } + // Note: We don't close the connection pool here as it may be shared + // The pool will handle cleanup automatically or can be closed explicitly + // on app shutdown via IndexedDbConnectionPool.closeAll() + return true; + } + + @override + Future deleteDataFile() async { + await close(); + + // Close the connection pool before deleting the database + IndexedDbConnectionPool.removeInstance(databaseName); + + final completer = Completer(); + final request = web.window.indexedDB.deleteDatabase(databaseName); + + request.onsuccess = (web.Event e) { + completer.complete(); + }.toJS; + + request.onerror = (web.Event e) { + completer.completeError( + Exception('Failed to delete IndexedDB database: ${request.error}'), + ); + }.toJS; + + request.onblocked = (web.Event e) { + // Database deletion is blocked by open connections + // This shouldn't happen as we closed the connection above + }.toJS; + + return completer.future; + } + + @override + Future exists() async { + try { + final db = await _getDatabase(); + final hasStore = db.objectStoreNames.contains(_metadataStoreName); + return hasStore; + } catch (e) { + throw Exception('Failed to check if IndexedDB exists: $e'); + } + } + + /// Converts a JavaScript value to a Dart Map. + Map _jsToMap(JSAny? jsValue) { + if (jsValue == null) { + return {}; + } + + final map = {}; + final obj = jsValue as JSObject; + + // Get all property names + final keysArray = objectKeys(obj); + final keys = keysArray.toDart; + + for (var i = 0; i < keys.length; i++) { + final keyJs = keys[i] as JSString; + final key = keyJs.toDart; + final value = obj[keyJs]; + + if (value == null) { + map[key] = null; + } else if (value.typeofEquals('string')) { + map[key] = (value as JSString).toDart; + } else if (value.typeofEquals('number')) { + final num = (value as JSNumber).toDartDouble; + // Check if it's an integer + if (num == num.truncateToDouble()) { + map[key] = num.toInt(); + } else { + map[key] = num; + } + } else if (value.typeofEquals('boolean')) { + map[key] = (value as JSBoolean).toDart; + } else { + map[key] = value; + } + } + + return map; + } +} + +@JS('Object.keys') +external JSArray objectKeys(JSObject obj); + +/// Extension to access JSObject properties using [] operator +extension JSObjectExtension on JSObject { + external JSAny? operator [](JSAny key); +} + +/// Extension to access JSArray elements using [] operator +extension JSArrayExtension on JSArray { + external JSAny? operator [](JSAny index); +} diff --git a/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_connection_pool.dart b/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_connection_pool.dart new file mode 100644 index 00000000..88506388 --- /dev/null +++ b/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_connection_pool.dart @@ -0,0 +1,134 @@ +import 'dart:async'; +import 'dart:js_interop'; + +import 'package:web/web.dart' as web; + +/// A singleton connection pool for IndexedDB databases. +/// This prevents the performance overhead of repeatedly opening and closing +/// database connections, which can be 50-200ms per operation. +class IndexedDbConnectionPool { + static final Map _instances = {}; + + final String databaseName; + final int version; + final void Function(web.IDBDatabase, web.IDBVersionChangeEvent)? onUpgrade; + + web.IDBDatabase? _db; + Completer? _openingCompleter; + bool _isClosed = false; + + IndexedDbConnectionPool._({ + required this.databaseName, + required this.version, + this.onUpgrade, + }); + + /// Get or create a connection pool for the specified database. + static IndexedDbConnectionPool getInstance({ + required String databaseName, + int version = 1, + void Function(web.IDBDatabase, web.IDBVersionChangeEvent)? onUpgrade, + }) { + if (!_instances.containsKey(databaseName)) { + _instances[databaseName] = IndexedDbConnectionPool._( + databaseName: databaseName, + version: version, + onUpgrade: onUpgrade, + ); + } + return _instances[databaseName]!; + } + + /// Get the database connection, opening it if necessary. + /// This reuses the same connection across all operations for performance. + Future getDatabase() async { + // If already open, return immediately + if (_db != null && !_isClosed) { + return _db!; + } + + // If currently opening, wait for that operation + if (_openingCompleter != null) { + return _openingCompleter!.future; + } + + // Start opening the database + _openingCompleter = Completer(); + _isClosed = false; + + try { + final request = web.window.indexedDB.open(databaseName, version); + + request.onupgradeneeded = (web.IDBVersionChangeEvent e) { + final db = request.result as web.IDBDatabase; + onUpgrade?.call(db, e); + }.toJS; + + request.onsuccess = (web.Event e) { + _db = request.result as web.IDBDatabase; + + // Handle connection being closed externally (e.g., by browser) + _db!.onclose = (web.Event e) { + _db = null; + _isClosed = true; + }.toJS; + + // Handle version change (e.g., another tab upgraded the schema) + _db!.onversionchange = (web.IDBVersionChangeEvent e) { + _db?.close(); + _db = null; + _isClosed = true; + }.toJS; + + _openingCompleter?.complete(_db!); + _openingCompleter = null; + }.toJS; + + request.onerror = (web.Event e) { + _openingCompleter?.completeError( + Exception('Failed to open IndexedDB: ${request.error}'), + ); + _openingCompleter = null; + }.toJS; + + request.onblocked = (web.Event e) { + // Another connection is blocking the upgrade + // This is usually because another tab has an older version open + }.toJS; + + return await _openingCompleter!.future; + } catch (e) { + _openingCompleter = null; + rethrow; + } + } + + /// Close the database connection. + /// Note: This should typically only be called on app shutdown, + /// not after individual operations. + void close() { + if (_db != null && !_isClosed) { + _db!.close(); + _db = null; + _isClosed = true; + } + _openingCompleter = null; + } + + /// Check if the database is currently open. + bool get isOpen => _db != null && !_isClosed; + + /// Remove this instance from the pool (used for testing). + static void removeInstance(String databaseName) { + _instances[databaseName]?.close(); + _instances.remove(databaseName); + } + + /// Close all connections (used for testing or app shutdown). + static void closeAll() { + for (final pool in _instances.values) { + pool.close(); + } + _instances.clear(); + } +} diff --git a/flutter_cache_manager/lib/src/storage/file_system/file_system.dart b/flutter_cache_manager/lib/src/storage/file_system/file_system.dart index 2cd9764b..1a63f09d 100644 --- a/flutter_cache_manager/lib/src/storage/file_system/file_system.dart +++ b/flutter_cache_manager/lib/src/storage/file_system/file_system.dart @@ -1,9 +1,8 @@ -export 'file_system.dart'; -export 'file_system_io.dart'; -export 'file_system_web.dart'; - import 'package:file/file.dart'; +export 'file_system_io.dart' + if (dart.library.js_interop) 'file_system_web.dart'; + abstract class FileSystem { Future createFile(String name); } diff --git a/flutter_cache_manager/lib/src/storage/file_system/file_system_web.dart b/flutter_cache_manager/lib/src/storage/file_system/file_system_web.dart index ac080452..8e0b3e91 100644 --- a/flutter_cache_manager/lib/src/storage/file_system/file_system_web.dart +++ b/flutter_cache_manager/lib/src/storage/file_system/file_system_web.dart @@ -2,6 +2,12 @@ import 'package:file/file.dart' show File; import 'package:file/memory.dart'; import 'package:flutter_cache_manager/src/storage/file_system/file_system.dart'; +// Web-specific implementations: export on web only, export stubs elsewhere +export 'indexed_db_file_stub.dart' + if (dart.library.js_interop) 'indexed_db_file.dart'; +export 'indexed_db_file_system_stub.dart' + if (dart.library.js_interop) 'indexed_db_file_system.dart'; + class MemoryCacheSystem implements FileSystem { final directory = MemoryFileSystem().systemTempDirectory.createTemp('cache'); diff --git a/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file.dart b/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file.dart new file mode 100644 index 00000000..f6551e38 --- /dev/null +++ b/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file.dart @@ -0,0 +1,553 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:js_interop'; +import 'dart:typed_data'; + +import 'package:file/file.dart'; +import 'package:flutter_cache_manager/src/storage/cache_info_repositories/indexed_db_connection_pool.dart'; +import 'package:path/path.dart' as p; +import 'package:web/web.dart' as web; + +/// A file implementation that stores data in IndexedDB for web platforms. +class IndexedDbFile implements File { + IndexedDbFile(this._path, this._dbName); + + final String _path; + final String _dbName; + + static const String _fileStoreName = 'cache_files'; + static const int _dbVersion = 2; // Incremented to match repository version + + late final IndexedDbConnectionPool _connectionPool = + IndexedDbConnectionPool.getInstance( + databaseName: _dbName, + version: _dbVersion, + onUpgrade: _onUpgradeNeeded, + ); + + void _onUpgradeNeeded(web.IDBDatabase db, web.IDBVersionChangeEvent e) { + final oldVersion = e.oldVersion; + + // Create cache_files object store if it doesn't exist (v1) + if (oldVersion < 1) { + final hasFileStore = db.objectStoreNames.contains(_fileStoreName); + if (!hasFileStore) { + db.createObjectStore( + _fileStoreName, + web.IDBObjectStoreParameters(keyPath: 'path'.toJS), + ); + } + + // Also create cache_metadata object store if it doesn't exist + // This ensures both stores are created in the same upgrade transaction + const metadataStoreName = 'cache_metadata'; + const keyIndexName = 'key_index'; + const touchedIndexName = 'touched_index'; + final hasMetadataStore = db.objectStoreNames.contains(metadataStoreName); + if (!hasMetadataStore) { + final metadataStore = db.createObjectStore( + metadataStoreName, + web.IDBObjectStoreParameters( + keyPath: '_id'.toJS, + autoIncrement: true, + ), + ); + // Create index on key field for fast lookups + metadataStore.createIndex( + keyIndexName, + 'key'.toJS, + web.IDBIndexParameters(unique: true), + ); + // Create index on touched field for efficient sorting + metadataStore.createIndex( + touchedIndexName, + 'touched'.toJS, + web.IDBIndexParameters(unique: false), + ); + } + } + + // Add touched index for existing databases (v2) + if (oldVersion < 2 && oldVersion >= 1) { + const metadataStoreName = 'cache_metadata'; + const touchedIndexName = 'touched_index'; + final transaction = e.target as web.IDBOpenDBRequest; + final txn = transaction.transaction; + if (txn != null && db.objectStoreNames.contains(metadataStoreName)) { + final store = txn.objectStore(metadataStoreName); + if (!store.indexNames.contains(touchedIndexName)) { + store.createIndex( + touchedIndexName, + 'touched'.toJS, + web.IDBIndexParameters(unique: false), + ); + } + } + } + } + + Future _getDatabase() async { + return _connectionPool.getDatabase(); + } + + /// Creates a transaction with optimal performance settings. + web.IDBTransaction _createTransaction( + web.IDBDatabase db, + String storeName, + String mode, + ) { + try { + // Try to create transaction with relaxed durability (modern browsers) + // Note: The durability hint may not be available in all browser versions + final options = web.IDBTransactionOptions(); + return db.transaction(storeName.toJS, mode, options); + } catch (e) { + // Fallback for older browsers that don't support transaction options + return db.transaction(storeName.toJS, mode); + } + } + + @override + Future readAsBytes() async { + final db = await _getDatabase(); + final completer = Completer(); + final transaction = _createTransaction(db, _fileStoreName, 'readonly'); + final store = transaction.objectStore(_fileStoreName); + final request = store.get(_path.toJS); + + request.onsuccess = (web.Event e) { + final result = request.result; + if (result != null) { + final obj = result as JSObject; + final dataField = obj['data'.toJS]; + + if (dataField != null && dataField.isA()) { + final data = dataField as JSUint8Array; + completer.complete(data.toDart); + } else { + completer.complete(Uint8List(0)); + } + } else { + completer.completeError( + Exception('File not found in IndexedDB: $_path'), + ); + } + }.toJS; + + request.onerror = (web.Event e) { + completer.completeError( + Exception('Failed to read file from IndexedDB: ${request.error}'), + ); + }.toJS; + + return completer.future; + } + + @override + Future writeAsBytes( + List bytes, { + FileMode mode = FileMode.write, + bool flush = false, + }) async { + final db = await _getDatabase(); + final completer = Completer(); + final transaction = _createTransaction(db, _fileStoreName, 'readwrite'); + final store = transaction.objectStore(_fileStoreName); + + final data = bytes is Uint8List ? bytes : Uint8List.fromList(bytes); + + final fileObject = {'path': _path, 'data': data}.jsify(); + + final request = store.put(fileObject); + + request.onsuccess = (web.Event e) { + completer.complete(); + }.toJS; + + request.onerror = (web.Event e) { + completer.completeError( + Exception('Failed to write file to IndexedDB: ${request.error}'), + ); + }.toJS; + + await completer.future; + return this; + } + + @override + IOSink openWrite({FileMode mode = FileMode.write, Encoding encoding = utf8}) { + return _IndexedDbIOSink(this, mode, encoding); + } + + @override + Stream> openRead([int? start, int? end]) async* { + final bytes = await readAsBytes(); + final startOffset = start ?? 0; + final endOffset = end ?? bytes.length; + yield bytes.sublist(startOffset, endOffset); + } + + @override + Future exists() async { + final db = await _getDatabase(); + final completer = Completer(); + final transaction = _createTransaction(db, _fileStoreName, 'readonly'); + final store = transaction.objectStore(_fileStoreName); + final request = store.get(_path.toJS); + + request.onsuccess = (web.Event e) { + final result = request.result; + completer.complete(result != null); + }.toJS; + + request.onerror = (web.Event e) { + completer.complete(false); + }.toJS; + + return completer.future; + } + + @override + bool existsSync() { + throw UnsupportedError('existsSync is not supported on web'); + } + + @override + Future delete({bool recursive = false}) async { + final db = await _getDatabase(); + final completer = Completer(); + final transaction = _createTransaction(db, _fileStoreName, 'readwrite'); + final store = transaction.objectStore(_fileStoreName); + final request = store.delete(_path.toJS); + + request.onsuccess = (web.Event e) { + completer.complete(); + }.toJS; + + request.onerror = (web.Event e) { + completer.completeError( + Exception('Failed to delete file from IndexedDB: ${request.error}'), + ); + }.toJS; + + await completer.future; + return this; + } + + @override + void deleteSync({bool recursive = false}) { + throw UnsupportedError('deleteSync is not supported on web'); + } + + @override + String get path => _path; + + @override + String get basename => p.basename(_path); + + @override + String get dirname => p.dirname(_path); + + @override + Uri get uri => Uri.file(_path); + + @override + Directory get parent => + throw UnsupportedError('parent is not supported for IndexedDbFile'); + + @override + bool get isAbsolute => true; + + @override + File get absolute => this; + + @override + Future copy(String newPath) { + throw UnsupportedError('copy is not supported for IndexedDbFile'); + } + + @override + File copySync(String newPath) { + throw UnsupportedError('copySync is not supported for IndexedDbFile'); + } + + @override + Future create({bool recursive = false, bool exclusive = false}) async { + // For IndexedDB, we don't need to create the file explicitly + // It will be created when we write to it + return this; + } + + @override + void createSync({bool recursive = false, bool exclusive = false}) { + throw UnsupportedError('createSync is not supported on web'); + } + + @override + Future lastAccessed() { + throw UnsupportedError('lastAccessed is not supported for IndexedDbFile'); + } + + @override + DateTime lastAccessedSync() { + throw UnsupportedError('lastAccessedSync is not supported on web'); + } + + @override + Future lastModified() { + throw UnsupportedError('lastModified is not supported for IndexedDbFile'); + } + + @override + DateTime lastModifiedSync() { + throw UnsupportedError('lastModifiedSync is not supported on web'); + } + + @override + Future length() async { + final bytes = await readAsBytes(); + return bytes.length; + } + + @override + int lengthSync() { + throw UnsupportedError('lengthSync is not supported on web'); + } + + @override + Future open({FileMode mode = FileMode.read}) { + throw UnsupportedError('open is not supported for IndexedDbFile'); + } + + @override + RandomAccessFile openSync({FileMode mode = FileMode.read}) { + throw UnsupportedError('openSync is not supported on web'); + } + + @override + Stream watch({ + int events = FileSystemEvent.all, + bool recursive = false, + }) { + throw UnsupportedError('watch is not supported for IndexedDbFile'); + } + + @override + Future readAsString({Encoding encoding = utf8}) async { + final bytes = await readAsBytes(); + return encoding.decode(bytes); + } + + @override + String readAsStringSync({Encoding encoding = utf8}) { + throw UnsupportedError('readAsStringSync is not supported on web'); + } + + @override + Uint8List readAsBytesSync() { + throw UnsupportedError('readAsBytesSync is not supported on web'); + } + + @override + Future> readAsLines({Encoding encoding = utf8}) async { + final content = await readAsString(encoding: encoding); + return content.split('\n'); + } + + @override + List readAsLinesSync({Encoding encoding = utf8}) { + throw UnsupportedError('readAsLinesSync is not supported on web'); + } + + @override + Future rename(String newPath) { + throw UnsupportedError('rename is not supported for IndexedDbFile'); + } + + @override + File renameSync(String newPath) { + throw UnsupportedError('renameSync is not supported on web'); + } + + @override + Future writeAsString( + String contents, { + FileMode mode = FileMode.write, + Encoding encoding = utf8, + bool flush = false, + }) async { + final bytes = encoding.encode(contents); + return writeAsBytes(bytes, mode: mode, flush: flush); + } + + @override + void writeAsStringSync( + String contents, { + FileMode mode = FileMode.write, + Encoding encoding = utf8, + bool flush = false, + }) { + throw UnsupportedError('writeAsStringSync is not supported on web'); + } + + @override + void writeAsBytesSync( + List bytes, { + FileMode mode = FileMode.write, + bool flush = false, + }) { + throw UnsupportedError('writeAsBytesSync is not supported on web'); + } + + @override + Future resolveSymbolicLinks() async { + return _path; + } + + @override + String resolveSymbolicLinksSync() { + return _path; + } + + @override + Future stat() { + throw UnsupportedError('stat is not supported for IndexedDbFile'); + } + + @override + FileStat statSync() { + throw UnsupportedError('statSync is not supported on web'); + } + + @override + Future setLastAccessed(DateTime time) { + throw UnsupportedError( + 'setLastAccessed is not supported for IndexedDbFile', + ); + } + + @override + void setLastAccessedSync(DateTime time) { + throw UnsupportedError('setLastAccessedSync is not supported on web'); + } + + @override + Future setLastModified(DateTime time) { + throw UnsupportedError( + 'setLastModified is not supported for IndexedDbFile', + ); + } + + @override + void setLastModifiedSync(DateTime time) { + throw UnsupportedError('setLastModifiedSync is not supported on web'); + } + + @override + FileSystem get fileSystem => + throw UnsupportedError('fileSystem is not supported for IndexedDbFile'); +} + +class _IndexedDbIOSink implements IOSink { + _IndexedDbIOSink(this._file, this._mode, this.encoding); + + final IndexedDbFile _file; + final FileMode _mode; + final _buffer = []; + final _completer = Completer(); + bool _isClosed = false; + + @override + Encoding encoding; + + @override + void add(List data) { + if (_isClosed) { + throw StateError('StreamSink is closed'); + } + _buffer.addAll(data); + } + + @override + void write(Object? object) { + if (_isClosed) { + throw StateError('StreamSink is closed'); + } + final string = object.toString(); + _buffer.addAll(encoding.encode(string)); + } + + @override + void writeAll(Iterable objects, [String separator = '']) { + if (_isClosed) { + throw StateError('StreamSink is closed'); + } + final string = objects.join(separator); + _buffer.addAll(encoding.encode(string)); + } + + @override + void writeln([Object? object = '']) { + write(object); + write('\n'); + } + + @override + void writeCharCode(int charCode) { + write(String.fromCharCode(charCode)); + } + + @override + void addError(Object error, [StackTrace? stackTrace]) { + if (_isClosed) { + throw StateError('StreamSink is closed'); + } + _completer.completeError(error, stackTrace); + _isClosed = true; + } + + @override + Future addStream(Stream> stream) { + if (_isClosed) { + throw StateError('StreamSink is closed'); + } + final completer = Completer(); + stream.listen( + (data) => _buffer.addAll(data), + onError: completer.completeError, + onDone: completer.complete, + cancelOnError: true, + ); + return completer.future; + } + + @override + Future flush() async { + if (_buffer.isNotEmpty) { + await _file.writeAsBytes(_buffer, mode: _mode); + } + } + + @override + Future close() async { + if (_isClosed) { + return _completer.future; + } + _isClosed = true; + try { + await flush(); + _completer.complete(); + } catch (e, s) { + _completer.completeError(e, s); + } + return _completer.future; + } + + @override + Future get done => _completer.future; +} + +/// Extension to access JSObject properties using [] operator +extension JSObjectExtension on JSObject { + external JSAny? operator [](JSAny key); +} diff --git a/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file_stub.dart b/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file_stub.dart new file mode 100644 index 00000000..1c6751cb --- /dev/null +++ b/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file_stub.dart @@ -0,0 +1,183 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:file/file.dart'; + +// Non-web stub to satisfy conditional export when js_interop is unavailable +class IndexedDbFile implements File { + IndexedDbFile(String path, String dbName); + + @override + File get absolute => this; + + @override + Future copy(String newPath) => _unsupported(); + + @override + File copySync(String newPath) => _throw(); + + @override + Future create({bool recursive = false, bool exclusive = false}) => + _unsupported(); + + @override + void createSync({bool recursive = false, bool exclusive = false}) => _throw(); + + @override + Future delete({bool recursive = false}) => _unsupported(); + + @override + void deleteSync({bool recursive = false}) => _throw(); + + @override + bool existsSync() => _throw(); + + @override + Future exists() => _unsupported(); + + @override + bool get isAbsolute => true; + + @override + RandomAccessFile openSync({FileMode mode = FileMode.read}) => _throw(); + + @override + Future open({FileMode mode = FileMode.read}) => + _unsupported(); + + @override + Stream> openRead([int? start, int? end]) => + Stream>.error(_unsupportedError()); + + @override + IOSink openWrite({ + FileMode mode = FileMode.write, + Encoding encoding = utf8, + }) => _throw(); + + @override + Directory get parent => _throw(); + + @override + String get path => _throw(); + + @override + Future length() => _unsupported(); + + @override + int lengthSync() => _throw(); + + @override + Future readAsBytes() => _unsupported(); + + @override + Uint8List readAsBytesSync() => _throw(); + + @override + Future readAsString({Encoding encoding = utf8}) => _unsupported(); + + @override + String readAsStringSync({Encoding encoding = utf8}) => _throw(); + + @override + Future> readAsLines({Encoding encoding = utf8}) => + _unsupported(); + + @override + List readAsLinesSync({Encoding encoding = utf8}) => _throw(); + + @override + Future rename(String newPath) => _unsupported(); + + @override + File renameSync(String newPath) => _throw(); + + @override + Future resolveSymbolicLinks() => _unsupported(); + + @override + String resolveSymbolicLinksSync() => _throw(); + + @override + Future lastAccessed() => _unsupported(); + + @override + DateTime lastAccessedSync() => _throw(); + + @override + Future lastModified() => _unsupported(); + + @override + DateTime lastModifiedSync() => _throw(); + + @override + FileStat statSync() => _throw(); + + @override + Future stat() => _unsupported(); + + @override + Uri get uri => _throw(); + + @override + String get basename => _throw(); + + @override + String get dirname => _throw(); + + @override + Stream watch({ + int events = FileSystemEvent.all, + bool recursive = false, + }) => Stream.error(_unsupportedError()); + + @override + Future writeAsBytes( + List bytes, { + FileMode mode = FileMode.write, + bool flush = false, + }) => _unsupported(); + + @override + void writeAsBytesSync( + List bytes, { + FileMode mode = FileMode.write, + bool flush = false, + }) => _throw(); + + @override + Future writeAsString( + String contents, { + FileMode mode = FileMode.write, + Encoding encoding = utf8, + bool flush = false, + }) => _unsupported(); + + @override + void writeAsStringSync( + String contents, { + FileMode mode = FileMode.write, + Encoding encoding = utf8, + bool flush = false, + }) => _throw(); + + @override + void setLastAccessedSync(DateTime time) => _throw(); + + @override + Future setLastAccessed(DateTime time) => _unsupported(); + + @override + void setLastModifiedSync(DateTime time) => _throw(); + + @override + Future setLastModified(DateTime time) => _unsupported(); + + T _throw() => throw _unsupportedError(); + Future _unsupported() => Future.error(_unsupportedError()); + UnsupportedError _unsupportedError() => + UnsupportedError('IndexedDbFile is only available on web'); + + @override + FileSystem get fileSystem => _throw(); +} diff --git a/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file_system.dart b/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file_system.dart new file mode 100644 index 00000000..cf8cb3b1 --- /dev/null +++ b/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file_system.dart @@ -0,0 +1,16 @@ +import 'package:file/file.dart' as file_pkg; +import 'package:flutter_cache_manager/src/storage/file_system/file_system.dart' + as cache_fs; +import 'package:flutter_cache_manager/src/storage/file_system/indexed_db_file.dart'; + +/// A file system implementation that stores files in IndexedDB for web platforms. +class IndexedDbFileSystem implements cache_fs.FileSystem { + IndexedDbFileSystem(this.databaseName); + + final String databaseName; + + @override + Future createFile(String name) async { + return IndexedDbFile(name, databaseName); + } +} diff --git a/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file_system_stub.dart b/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file_system_stub.dart new file mode 100644 index 00000000..0177d33e --- /dev/null +++ b/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file_system_stub.dart @@ -0,0 +1,13 @@ +import 'package:file/file.dart' as file_pkg; +import 'package:flutter_cache_manager/src/storage/file_system/file_system.dart' + as cache_fs; + +// Non-web stub to satisfy conditional export when js_interop is unavailable +class IndexedDbFileSystem implements cache_fs.FileSystem { + IndexedDbFileSystem(String databaseName); + + @override + Future createFile(String name) async { + throw UnsupportedError('IndexedDbFileSystem is only available on web'); + } +} diff --git a/flutter_cache_manager/lib/src/web/file_service.dart b/flutter_cache_manager/lib/src/web/file_service.dart index 376d672e..11c3f174 100644 --- a/flutter_cache_manager/lib/src/web/file_service.dart +++ b/flutter_cache_manager/lib/src/web/file_service.dart @@ -9,6 +9,47 @@ import 'package:http/http.dart' as http; ///Copyright (c) 2019 Rene Floor ///Released under MIT License. +/// Token that can be used to cancel an in-flight request. +class CancellationToken { + bool _isCancelled = false; + Completer? _completer; + + /// Whether this token has been cancelled. + bool get isCancelled => _isCancelled; + + /// Cancel the request associated with this token. + void cancel() { + if (_isCancelled) return; + _isCancelled = true; + if (_completer != null && !_completer!.isCompleted) { + _completer!.complete(); + } + } + + /// A future that completes when this token is cancelled. + Future get whenCancelled { + if (_completer == null) { + _completer = Completer(); + if (_isCancelled) { + _completer!.complete(); + } + } + return _completer!.future; + } +} + +/// Exception thrown when a request is cancelled. +class CancelledException implements Exception { + /// Creates a new [CancelledException] with an optional message. + CancelledException([this.message = 'Request was cancelled']); + + /// The error message. + final String message; + + @override + String toString() => 'CancelledException: $message'; +} + /// Defines the interface for a file service. /// Most common file service will be an [HttpFileService], however one can /// also make something more specialized. For example you could fetch files @@ -16,7 +57,11 @@ import 'package:http/http.dart' as http; abstract class FileService { int concurrentFetches = 10; - Future get(String url, {Map? headers}); + Future get( + String url, { + Map? headers, + CancellationToken? cancellationToken, + }); } /// [HttpFileService] is the most common file service and the default for @@ -31,14 +76,33 @@ class HttpFileService extends FileService { Future get( String url, { Map? headers, + CancellationToken? cancellationToken, }) async { - final req = http.Request('GET', Uri.parse(url)); + if (cancellationToken?.isCancelled ?? false) { + throw CancelledException(); + } + + final http.BaseRequest req; + if (cancellationToken != null) { + req = http.AbortableRequest( + 'GET', + Uri.parse(url), + abortTrigger: cancellationToken.whenCancelled, + ); + } else { + req = http.Request('GET', Uri.parse(url)); + } + if (headers != null) { req.headers.addAll(headers); } - final httpResponse = await _httpClient.send(req); - return HttpGetResponse(httpResponse); + try { + final httpResponse = await _httpClient.send(req); + return HttpGetResponse(httpResponse); + } on http.RequestAbortedException { + throw CancelledException(); + } } } diff --git a/flutter_cache_manager/lib/src/web/queue_item.dart b/flutter_cache_manager/lib/src/web/queue_item.dart index ebfdea6b..239968aa 100644 --- a/flutter_cache_manager/lib/src/web/queue_item.dart +++ b/flutter_cache_manager/lib/src/web/queue_item.dart @@ -1,7 +1,10 @@ +import 'package:flutter_cache_manager/src/web/file_service.dart'; + class QueueItem { final String url; final String key; final Map? headers; + final CancellationToken? cancellationToken; - const QueueItem(this.url, this.key, this.headers); + const QueueItem(this.url, this.key, this.headers, [this.cancellationToken]); } diff --git a/flutter_cache_manager/lib/src/web/web_helper.dart b/flutter_cache_manager/lib/src/web/web_helper.dart index d5b77146..14ec3fad 100644 --- a/flutter_cache_manager/lib/src/web/web_helper.dart +++ b/flutter_cache_manager/lib/src/web/web_helper.dart @@ -34,13 +34,14 @@ class WebHelper { String? key, Map? authHeaders, bool ignoreMemCache = false, + CancellationToken? cancellationToken, }) { key ??= url; var subject = _memCache[key]; if (subject == null || ignoreMemCache) { subject = BehaviorSubject(); _memCache[key] = subject; - _downloadOrAddToQueue(url, key, authHeaders); + _downloadOrAddToQueue(url, key, authHeaders, cancellationToken); } return subject.stream; } @@ -51,10 +52,22 @@ class WebHelper { String url, String key, Map? authHeaders, + CancellationToken? cancellationToken, ) async { + // Check if already cancelled before starting + if (cancellationToken?.isCancelled ?? false) { + final subject = _memCache[key]; + if (subject != null) { + subject.addError(CancelledException()); + await subject.close(); + _memCache.remove(key); + } + return; + } + //Add to queue if there are too many calls. if (concurrentCalls >= fileFetcher.concurrentFetches) { - _queue.add(QueueItem(url, key, authHeaders)); + _queue.add(QueueItem(url, key, authHeaders, cancellationToken)); return; } cacheLogger.log( @@ -69,6 +82,7 @@ class WebHelper { url, key, authHeaders: authHeaders, + cancellationToken: cancellationToken, )) { subject.add(result); } @@ -85,7 +99,25 @@ class WebHelper { void _checkQueue() { if (_queue.isEmpty) return; final next = _queue.removeFirst(); - _downloadOrAddToQueue(next.url, next.key, next.headers); + // Skip cancelled items + if (next.cancellationToken?.isCancelled ?? false) { + // Clean up the subject for this cancelled request + final subject = _memCache[next.key]; + if (subject != null) { + subject.addError(CancelledException()); + subject.close(); + _memCache.remove(next.key); + } + // Check the next item in queue + _checkQueue(); + return; + } + _downloadOrAddToQueue( + next.url, + next.key, + next.headers, + next.cancellationToken, + ); } ///Download the file from the url @@ -93,8 +125,20 @@ class WebHelper { String url, String key, { Map? authHeaders, + CancellationToken? cancellationToken, }) async* { + // Check cancellation before any work + if (cancellationToken?.isCancelled ?? false) { + throw CancelledException(); + } + var cacheObject = await _store.retrieveCacheData(key); + + // Check cancellation after async work + if (cancellationToken?.isCancelled ?? false) { + throw CancelledException(); + } + cacheObject = cacheObject == null ? CacheObject( url, @@ -103,13 +147,24 @@ class WebHelper { relativePath: '${const Uuid().v1()}.file', ) : cacheObject.copyWith(url: url); - final response = await _download(cacheObject, authHeaders); - yield* _manageResponse(cacheObject, response); + final response = await _download( + cacheObject, + authHeaders, + cancellationToken, + ); + + // Check cancellation after download + if (cancellationToken?.isCancelled ?? false) { + throw CancelledException(); + } + + yield* _manageResponse(cacheObject, response, cancellationToken); } Future _download( CacheObject cacheObject, Map? authHeaders, + CancellationToken? cancellationToken, ) { final headers = {}; @@ -124,13 +179,23 @@ class WebHelper { headers.addAll(authHeaders); } - return fileFetcher.get(cacheObject.url, headers: headers); + return fileFetcher.get( + cacheObject.url, + headers: headers, + cancellationToken: cancellationToken, + ); } Stream _manageResponse( CacheObject cacheObject, FileServiceResponse response, + CancellationToken? cancellationToken, ) async* { + // Check cancellation at start + if (cancellationToken?.isCancelled ?? false) { + throw CancelledException(); + } + final hasNewFile = statusCodesNewFile.contains(response.statusCode); final keepOldFile = statusCodesFileNotChanged.contains(response.statusCode); if (!hasNewFile && !keepOldFile) { @@ -145,7 +210,15 @@ class WebHelper { var newCacheObject = _setDataFromHeaders(cacheObject, response); if (statusCodesNewFile.contains(response.statusCode)) { var savedBytes = 0; - await for (final progress in _saveFile(newCacheObject, response)) { + await for (final progress in _saveFile( + newCacheObject, + response, + cancellationToken, + )) { + // Check cancellation during file save + if (cancellationToken?.isCancelled ?? false) { + throw CancelledException(); + } savedBytes = progress; yield DownloadProgress( cacheObject.url, @@ -156,6 +229,10 @@ class WebHelper { newCacheObject = newCacheObject.copyWith(length: savedBytes); } + if (cancellationToken?.isCancelled ?? false) { + throw CancelledException(); + } + await _store.putFile(newCacheObject); if (newCacheObject.relativePath != oldCacheObject.relativePath) { await _removeOldFile(oldCacheObject.relativePath); @@ -164,6 +241,9 @@ class WebHelper { final file = await _store.fileSystem.createFile( newCacheObject.relativePath, ); + if (cancellationToken?.isCancelled ?? false) { + throw CancelledException(); + } yield FileInfo( file, FileSource.Online, @@ -195,12 +275,17 @@ class WebHelper { ); } - Stream _saveFile(CacheObject cacheObject, FileServiceResponse response) { + Stream _saveFile( + CacheObject cacheObject, + FileServiceResponse response, + CancellationToken? cancellationToken, + ) { final receivedBytesResultController = StreamController(); _saveFileAndPostUpdates( receivedBytesResultController, cacheObject, response, + cancellationToken, ); return receivedBytesResultController.stream; } @@ -209,19 +294,26 @@ class WebHelper { StreamController receivedBytesResultController, CacheObject cacheObject, FileServiceResponse response, + CancellationToken? cancellationToken, ) async { final file = await _store.fileSystem.createFile(cacheObject.relativePath); try { var receivedBytes = 0; final sink = file.openWrite(); - await response.content - .map((s) { - receivedBytes += s.length; - receivedBytesResultController.add(receivedBytes); - return s; - }) - .pipe(sink); + await for (final chunk in response.content) { + // Check cancellation while receiving data + if (cancellationToken?.isCancelled ?? false) { + await sink.close(); + receivedBytesResultController.addError(CancelledException()); + await receivedBytesResultController.close(); + return; + } + receivedBytes += chunk.length; + receivedBytesResultController.add(receivedBytes); + sink.add(chunk); + } + await sink.close(); } on Object catch (e, stacktrace) { receivedBytesResultController.addError(e, stacktrace); } diff --git a/flutter_cache_manager/pubspec.yaml b/flutter_cache_manager/pubspec.yaml index 3f0c9ce3..3a040aeb 100644 --- a/flutter_cache_manager/pubspec.yaml +++ b/flutter_cache_manager/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_cache_manager description: Generic cache manager for flutter. Saves web files on the storages of the device and saves the cache info using sqflite. -version: 3.4.5 +version: 3.5.1 homepage: https://github.com/Baseflow/flutter_cache_manager topics: - cache @@ -9,24 +9,25 @@ environment: sdk: '>=3.8.0 <4.0.0' dependencies: - clock: ^1.1.2 + clock: ^1.1.3 collection: ^1.19.1 file: ^7.0.1 flutter: sdk: flutter http: ^1.6.0 path: ^1.9.1 - path_provider: ^2.1.5 + path_provider: ^2.1.6 rxdart: ^0.28.0 - sqflite: ^2.4.2 + sqflite: ^2.4.3 uuid: ^4.6.0 + web: ^1.1.1 dev_dependencies: - build_runner: ^2.14.0 + build_runner: ^2.16.1 flutter_lints: ^6.0.0 flutter_test: sdk: flutter - mockito: ^5.7.0 + mockito: ^5.8.1 platforms: android: diff --git a/flutter_cache_manager/test/http_file_fetcher_test.dart b/flutter_cache_manager/test/http_file_fetcher_test.dart index 34f81d0b..d6cbe0cf 100644 --- a/flutter_cache_manager/test/http_file_fetcher_test.dart +++ b/flutter_cache_manager/test/http_file_fetcher_test.dart @@ -117,4 +117,94 @@ void main() { }); }); }); + + group('Cancellation', () { + test('CancellationToken basic functionality', () { + final token = CancellationToken(); + expect(token.isCancelled, false); + + token.cancel(); + expect(token.isCancelled, true); + }); + + test('CancellationToken whenCancelled completes after cancel', () async { + final token = CancellationToken(); + final future = token.whenCancelled; + + token.cancel(); + await future; + expect(token.isCancelled, true); + }); + + test( + 'CancellationToken whenCancelled completes immediately if already cancelled', + () async { + final token = CancellationToken(); + token.cancel(); + + final future = token.whenCancelled; + await future; + expect(token.isCancelled, true); + }, + ); + + test( + 'HttpFileService throws CancelledException when token is cancelled before request', + () async { + final token = CancellationToken(); + token.cancel(); + + final client = MockClient((request) async { + return Response.bytes(Uint8List(16), 200); + }); + + final httpFileFetcher = HttpFileService(httpClient: client); + + expect( + () => httpFileFetcher.get('test.com/image', cancellationToken: token), + throwsA(isA()), + ); + }, + ); + + test( + 'HttpFileService completes normally if cancelled after response received', + () async { + final token = CancellationToken(); + + final client = MockClient((request) async { + return Response.bytes(Uint8List(16), 200); + }); + + final httpFileFetcher = HttpFileService(httpClient: client); + + // Complete the request first + final response = await httpFileFetcher.get( + 'http://test.com/image', + cancellationToken: token, + ); + + // Cancelling after response is received has no effect + token.cancel(); + + expect(response.statusCode, 200); + }, + ); + + test('HttpFileService works normally without cancellation token', () async { + final client = MockClient((request) async { + return Response.bytes( + Uint8List(16), + 200, + headers: {'content-type': 'image/jpeg'}, + ); + }); + + final httpFileFetcher = HttpFileService(httpClient: client); + final response = await httpFileFetcher.get('test.com/image'); + + expect(response.statusCode, 200); + expect(response.contentLength, 16); + }); + }); } diff --git a/flutter_cache_manager/test/mock.mocks.dart b/flutter_cache_manager/test/mock.mocks.dart index a1496764..e9832caf 100644 --- a/flutter_cache_manager/test/mock.mocks.dart +++ b/flutter_cache_manager/test/mock.mocks.dart @@ -1,13 +1,15 @@ -// Mocks generated by Mockito 5.4.6 from annotations +// Mocks generated by Mockito from annotations // in flutter_cache_manager/test/mock.dart. // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes + import 'dart:async' as _i4; -import 'package:flutter_cache_manager/flutter_cache_manager.dart' as _i3; +import 'package:flutter_cache_manager/flutter_cache_manager.dart' as _i2; import 'package:flutter_cache_manager/src/cache_store.dart' as _i5; -import 'package:flutter_cache_manager/src/storage/cache_object.dart' as _i2; +import 'package:flutter_cache_manager/src/storage/file_system/file_system.dart' + as _i3; import 'package:flutter_cache_manager/src/web/web_helper.dart' as _i7; import 'package:mockito/mockito.dart' as _i1; import 'package:mockito/src/dummies.dart' as _i6; @@ -18,6 +20,7 @@ import 'package:mockito/src/dummies.dart' as _i6; // ignore_for_file: comment_references // ignore_for_file: deprecated_member_use // ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: experimental_member_use // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member // ignore_for_file: must_be_immutable @@ -48,12 +51,12 @@ class _FakeDateTime_3 extends _i1.SmartFake implements DateTime { } class _FakeFileServiceResponse_4 extends _i1.SmartFake - implements _i3.FileServiceResponse { + implements _i2.FileServiceResponse { _FakeFileServiceResponse_4(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeFileService_5 extends _i1.SmartFake implements _i3.FileService { +class _FakeFileService_5 extends _i1.SmartFake implements _i2.FileService { _FakeFileService_5(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } @@ -62,7 +65,7 @@ class _FakeFileService_5 extends _i1.SmartFake implements _i3.FileService { /// /// See the documentation for Mockito's code generation for more information. class MockCacheInfoRepositoryBase extends _i1.Mock - implements _i3.CacheInfoRepository { + implements _i2.CacheInfoRepository { MockCacheInfoRepositoryBase() { _i1.throwOnMissingStub(this); } @@ -273,7 +276,7 @@ class MockCacheStoreBase extends _i1.Mock implements _i5.CacheStore { ); @override - _i4.Future<_i3.FileInfo?> getFile( + _i4.Future<_i2.FileInfo?> getFile( String? key, { bool? ignoreMemCache = false, }) => @@ -283,9 +286,9 @@ class MockCacheStoreBase extends _i1.Mock implements _i5.CacheStore { [key], {#ignoreMemCache: ignoreMemCache}, ), - returnValue: _i4.Future<_i3.FileInfo?>.value(), + returnValue: _i4.Future<_i2.FileInfo?>.value(), ) - as _i4.Future<_i3.FileInfo?>); + as _i4.Future<_i2.FileInfo?>); @override _i4.Future putFile(_i2.CacheObject? cacheObject) => @@ -312,12 +315,12 @@ class MockCacheStoreBase extends _i1.Mock implements _i5.CacheStore { as _i4.Future<_i2.CacheObject?>); @override - _i4.Future<_i3.FileInfo?> getFileFromMemory(String? key) => + _i4.Future<_i2.FileInfo?> getFileFromMemory(String? key) => (super.noSuchMethod( Invocation.method(#getFileFromMemory, [key]), - returnValue: _i4.Future<_i3.FileInfo?>.value(), + returnValue: _i4.Future<_i2.FileInfo?>.value(), ) - as _i4.Future<_i3.FileInfo?>); + as _i4.Future<_i2.FileInfo?>); @override _i4.Future emptyCache() => @@ -372,7 +375,7 @@ class MockCacheStoreBase extends _i1.Mock implements _i5.CacheStore { /// A class which mocks [FileService]. /// /// See the documentation for Mockito's code generation for more information. -class MockFileServiceBase extends _i1.Mock implements _i3.FileService { +class MockFileServiceBase extends _i1.Mock implements _i2.FileService { MockFileServiceBase() { _i1.throwOnMissingStub(this); } @@ -389,20 +392,29 @@ class MockFileServiceBase extends _i1.Mock implements _i3.FileService { ); @override - _i4.Future<_i3.FileServiceResponse> get( + _i4.Future<_i2.FileServiceResponse> get( String? url, { Map? headers, + _i2.CancellationToken? cancellationToken, }) => (super.noSuchMethod( - Invocation.method(#get, [url], {#headers: headers}), - returnValue: _i4.Future<_i3.FileServiceResponse>.value( + Invocation.method( + #get, + [url], + {#headers: headers, #cancellationToken: cancellationToken}, + ), + returnValue: _i4.Future<_i2.FileServiceResponse>.value( _FakeFileServiceResponse_4( this, - Invocation.method(#get, [url], {#headers: headers}), + Invocation.method( + #get, + [url], + {#headers: headers, #cancellationToken: cancellationToken}, + ), ), ), ) - as _i4.Future<_i3.FileServiceResponse>); + as _i4.Future<_i2.FileServiceResponse>); } /// A class which mocks [WebHelper]. @@ -414,7 +426,7 @@ class MockWebHelper extends _i1.Mock implements _i7.WebHelper { } @override - _i3.FileService get fileFetcher => + _i2.FileService get fileFetcher => (super.noSuchMethod( Invocation.getter(#fileFetcher), returnValue: _FakeFileService_5( @@ -422,7 +434,7 @@ class MockWebHelper extends _i1.Mock implements _i7.WebHelper { Invocation.getter(#fileFetcher), ), ) - as _i3.FileService); + as _i2.FileService); @override int get concurrentCalls => @@ -436,11 +448,12 @@ class MockWebHelper extends _i1.Mock implements _i7.WebHelper { ); @override - _i4.Stream<_i3.FileResponse> downloadFile( + _i4.Stream<_i2.FileResponse> downloadFile( String? url, { String? key, Map? authHeaders, bool? ignoreMemCache = false, + _i2.CancellationToken? cancellationToken, }) => (super.noSuchMethod( Invocation.method( @@ -450,9 +463,10 @@ class MockWebHelper extends _i1.Mock implements _i7.WebHelper { #key: key, #authHeaders: authHeaders, #ignoreMemCache: ignoreMemCache, + #cancellationToken: cancellationToken, }, ), - returnValue: _i4.Stream<_i3.FileResponse>.empty(), + returnValue: _i4.Stream<_i2.FileResponse>.empty(), ) - as _i4.Stream<_i3.FileResponse>); + as _i4.Stream<_i2.FileResponse>); } diff --git a/flutter_cache_manager/test/web_helper_test.dart b/flutter_cache_manager/test/web_helper_test.dart index 9bc655a8..05f2ded1 100644 --- a/flutter_cache_manager/test/web_helper_test.dart +++ b/flutter_cache_manager/test/web_helper_test.dart @@ -289,6 +289,49 @@ void main() { await download; }); + test('cancellation during persistence does not yield FileInfo', () async { + const imageUrl = 'baseflow.com/testimage'; + + final persisted = Completer(); + var config = createTestConfig(); + var store = _createStore(config); + when(store.putFile(any)).thenAnswer((_) => persisted.future); + + final fileService = MockFileService(); + when( + fileService.get( + imageUrl, + headers: anyNamed('headers'), + cancellationToken: anyNamed('cancellationToken'), + ), + ).thenAnswer((_) { + return Future.value( + MockFileFetcherResponse( + Stream.value([0, 1, 2, 3, 4, 5]), + 6, + 'testv1', + '.jpg', + 200, + DateTime.now(), + ), + ); + }); + + final webHelper = WebHelper(store, fileService); + final token = CancellationToken(); + final download = webHelper + .downloadFile(imageUrl, cancellationToken: token) + .toList(); + final expectation = expectLater( + download, + throwsA(isA()), + ); + await pumpEventQueue(); + token.cancel(); + persisted.complete(); + await expectation; + }); + test('File should be removed if extension changed', () async { const imageUrl = 'baseflow.com/testimage'; var imageName = 'image.png'; diff --git a/flutter_cache_manager_firebase/analysis_options.yaml b/flutter_cache_manager_firebase/analysis_options.yaml index f9b30346..743e05ad 100644 --- a/flutter_cache_manager_firebase/analysis_options.yaml +++ b/flutter_cache_manager_firebase/analysis_options.yaml @@ -1 +1,10 @@ +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:flutter_lints/flutter.yaml diff --git a/flutter_cache_manager_firebase/lib/src/firebase_http_file_service.dart b/flutter_cache_manager_firebase/lib/src/firebase_http_file_service.dart index 75d79ce9..d481826e 100644 --- a/flutter_cache_manager_firebase/lib/src/firebase_http_file_service.dart +++ b/flutter_cache_manager_firebase/lib/src/firebase_http_file_service.dart @@ -16,6 +16,7 @@ class FirebaseHttpFileService extends HttpFileService { Future get( String url, { Map? headers, + CancellationToken? cancellationToken, }) async { late Reference ref; if (bucket != null) { @@ -35,6 +36,10 @@ class FirebaseHttpFileService extends HttpFileService { } else { downloadUrl = await ref.getDownloadURL(); } - return super.get(downloadUrl); + return super.get( + downloadUrl, + headers: headers, + cancellationToken: cancellationToken, + ); } } diff --git a/flutter_cache_manager_firebase/pubspec.yaml b/flutter_cache_manager_firebase/pubspec.yaml index 77096907..85742319 100644 --- a/flutter_cache_manager_firebase/pubspec.yaml +++ b/flutter_cache_manager_firebase/pubspec.yaml @@ -19,3 +19,10 @@ dev_dependencies: flutter_lints: ^6.0.0 flutter_test: sdk: flutter + +# This package tracks flutter_cache_manager in the same repository. The +# cancellation API it implements is not on pub.dev yet, so resolve the sibling +# package from source. Overrides are ignored by consumers of this package. +dependency_overrides: + flutter_cache_manager: + path: ../flutter_cache_manager