[verified] Publish ReelBox source under GPLv3

This commit is contained in:
Hermes committed 2026-11-12 23:41:13 +00:00
commit db2b505ca3
445 files changed
+105274

No files matched your search

+671
View File
@@ -0,0 +1,671 @@
{
"scope": "Resolved Dart/Flutter packages from the verified lockfile; native Android components are separately described in THIRD_PARTY_NOTICES.txt",
"packages": [
{
"name": "android_file_picker",
"version": "2.1.2",
"upstream": "https://github.com/vicajilau/flutter_file_picker/tree/main/packages/file_picker_android",
"license_file": "third_party/licenses/dart/android_file_picker.txt"
},
{
"name": "android_intent_plus",
"version": "6.1.0",
"upstream": "https://github.com/fluttercommunity/plus_plugins/tree/main/packages/android_intent_plus",
"license_file": "third_party/licenses/dart/android_intent_plus.txt"
},
{
"name": "archive",
"version": "4.6.0",
"upstream": "https://github.com/brendan-duncan/archive",
"license_file": "third_party/licenses/dart/archive.txt"
},
{
"name": "args",
"version": "2.7.0",
"upstream": "https://github.com/dart-lang/core/tree/main/pkgs/args",
"license_file": "third_party/licenses/dart/args.txt"
},
{
"name": "async",
"version": "2.13.1",
"upstream": "https://github.com/dart-lang/core/tree/main/pkgs/async",
"license_file": "third_party/licenses/dart/async.txt"
},
{
"name": "background_downloader",
"version": "9.6.5",
"upstream": "https://github.com/781flyingdutchman/background_downloader",
"license_file": "third_party/licenses/dart/background_downloader.txt"
},
{
"name": "boolean_selector",
"version": "2.1.2",
"upstream": "https://github.com/dart-lang/tools/tree/main/pkgs/boolean_selector",
"license_file": "third_party/licenses/dart/boolean_selector.txt"
},
{
"name": "characters",
"version": "1.4.1",
"upstream": "https://github.com/dart-lang/core/tree/main/pkgs/characters",
"license_file": "third_party/licenses/dart/characters.txt"
},
{
"name": "clock",
"version": "1.1.3",
"upstream": "https://github.com/dart-lang/tools/tree/main/pkgs/clock",
"license_file": "third_party/licenses/dart/clock.txt"
},
{
"name": "code_assets",
"version": "1.2.1",
"upstream": "https://github.com/dart-lang/native/tree/main/pkgs/code_assets",
"license_file": "third_party/licenses/dart/code_assets.txt"
},
{
"name": "collection",
"version": "1.19.1",
"upstream": "https://github.com/dart-lang/core/tree/main/pkgs/collection",
"license_file": "third_party/licenses/dart/collection.txt"
},
{
"name": "connectivity_plus",
"version": "7.3.2",
"upstream": "https://github.com/fluttercommunity/plus_plugins/tree/main/packages/connectivity_plus/connectivity_plus",
"license_file": "third_party/licenses/dart/connectivity_plus.txt"
},
{
"name": "connectivity_plus_platform_interface",
"version": "2.1.0",
"upstream": "https://github.com/fluttercommunity/plus_plugins/tree/main/packages/",
"license_file": "third_party/licenses/dart/connectivity_plus_platform_interface.txt"
},
{
"name": "cross_file",
"version": "0.3.5+5",
"upstream": "https://github.com/flutter/packages/tree/main/packages/cross_file",
"license_file": "third_party/licenses/dart/cross_file.txt"
},
{
"name": "crypto",
"version": "3.0.8",
"upstream": "https://github.com/dart-lang/core/tree/main/pkgs/crypto",
"license_file": "third_party/licenses/dart/crypto.txt"
},
{
"name": "dbus",
"version": "0.8.0",
"upstream": "https://github.com/canonical/dbus.dart",
"license_file": "third_party/licenses/dart/dbus.txt"
},
{
"name": "dio",
"version": "5.11.1",
"upstream": "https://github.com/cfug/dio/blob/main/dio",
"license_file": "third_party/licenses/dart/dio.txt"
},
{
"name": "dio_web_adapter",
"version": "2.2.2",
"upstream": "https://github.com/cfug/dio/blob/main/plugins/web_adapter",
"license_file": "third_party/licenses/dart/dio_web_adapter.txt"
},
{
"name": "fake_async",
"version": "1.3.3",
"upstream": "https://github.com/dart-lang/test/tree/master/pkgs/fake_async",
"license_file": "third_party/licenses/dart/fake_async.txt"
},
{
"name": "ffi",
"version": "2.2.0",
"upstream": "https://github.com/dart-lang/native/tree/main/pkgs/ffi",
"license_file": "third_party/licenses/dart/ffi.txt"
},
{
"name": "ffi_leak_tracker",
"version": "0.1.2",
"upstream": "https://github.com/halildurmus/win32/tree/main/packages/ffi_leak_tracker",
"license_file": "third_party/licenses/dart/ffi_leak_tracker.txt"
},
{
"name": "file",
"version": "7.0.1",
"upstream": "https://github.com/dart-lang/tools/tree/main/pkgs/file",
"license_file": "third_party/licenses/dart/file.txt"
},
{
"name": "file_picker",
"version": "13.3.1",
"upstream": "https://github.com/vicajilau/flutter_file_picker/tree/main/packages/file_picker",
"license_file": "third_party/licenses/dart/file_picker.txt"
},
{
"name": "file_picker_darwin",
"version": "2.4.1",
"upstream": "https://github.com/vicajilau/flutter_file_picker/tree/main/packages/file_picker_darwin",
"license_file": "third_party/licenses/dart/file_picker_darwin.txt"
},
{
"name": "file_picker_linux",
"version": "2.0.2",
"upstream": "https://github.com/vicajilau/flutter_file_picker/tree/main/packages/file_picker_linux",
"license_file": "third_party/licenses/dart/file_picker_linux.txt"
},
{
"name": "file_picker_platform_interface",
"version": "4.2.0",
"upstream": "https://github.com/vicajilau/flutter_file_picker/tree/main/packages/file_picker_platform_interface",
"license_file": "third_party/licenses/dart/file_picker_platform_interface.txt"
},
{
"name": "file_picker_web",
"version": "4.2.0",
"upstream": "https://github.com/vicajilau/flutter_file_picker/tree/main/packages/file_picker_web",
"license_file": "third_party/licenses/dart/file_picker_web.txt"
},
{
"name": "fixnum",
"version": "1.1.1",
"upstream": "https://github.com/dart-lang/core/tree/main/pkgs/fixnum",
"license_file": "third_party/licenses/dart/fixnum.txt"
},
{
"name": "flutter",
"version": "Flutter SDK 3.47.9",
"upstream": "https://flutter.dev",
"license_file": "third_party/licenses/dart/flutter.txt"
},
{
"name": "flutter_lints",
"version": "6.0.0",
"upstream": "https://github.com/flutter/packages/tree/main/packages/flutter_lints",
"license_file": "third_party/licenses/dart/flutter_lints.txt"
},
{
"name": "flutter_localizations",
"version": "Flutter SDK 3.47.9",
"upstream": "https://github.com/flutter/flutter",
"license_file": "third_party/licenses/dart/flutter_localizations.txt"
},
{
"name": "flutter_test",
"version": "Flutter SDK 3.47.9",
"upstream": "https://github.com/flutter/flutter",
"license_file": "third_party/licenses/dart/flutter_test.txt"
},
{
"name": "flutter_web_plugins",
"version": "Flutter SDK 3.47.9",
"upstream": "https://flutter.dev",
"license_file": "third_party/licenses/dart/flutter_web_plugins.txt"
},
{
"name": "hooks",
"version": "2.0.2",
"upstream": "https://github.com/dart-lang/native/tree/main/pkgs/hooks",
"license_file": "third_party/licenses/dart/hooks.txt"
},
{
"name": "http",
"version": "1.6.0",
"upstream": "https://github.com/dart-lang/http/tree/master/pkgs/http",
"license_file": "third_party/licenses/dart/http.txt"
},
{
"name": "http_parser",
"version": "4.1.2",
"upstream": "https://github.com/dart-lang/http/tree/master/pkgs/http_parser",
"license_file": "third_party/licenses/dart/http_parser.txt"
},
{
"name": "image",
"version": "4.12.0",
"upstream": "https://github.com/brendan-duncan/image",
"license_file": "third_party/licenses/dart/image.txt"
},
{
"name": "intl",
"version": "0.20.3 # This should follow semver to not break Flutter, where it is unpinned, see https://github.com/flutter/flutter/blob/main/pubspec.yaml",
"upstream": "https://github.com/dart-lang/i18n/tree/main/pkgs/intl",
"license_file": "third_party/licenses/dart/intl.txt"
},
{
"name": "jni",
"version": "1.1.1",
"upstream": "https://github.com/dart-lang/native/tree/main/pkgs/jni",
"license_file": "third_party/licenses/dart/jni.txt"
},
{
"name": "jni_flutter",
"version": "1.0.4+1",
"upstream": "https://github.com/dart-lang/native/tree/main/pkgs/jni_flutter",
"license_file": "third_party/licenses/dart/jni_flutter.txt"
},
{
"name": "jni_util",
"version": "1.0.0",
"upstream": "https://github.com/dart-lang/native/tree/main/pkgs/jni_util",
"license_file": "third_party/licenses/dart/jni_util.txt"
},
{
"name": "leak_tracker",
"version": "11.0.2",
"upstream": "https://github.com/dart-lang/leak_tracker/tree/main/pkgs/leak_tracker",
"license_file": "third_party/licenses/dart/leak_tracker.txt"
},
{
"name": "leak_tracker_flutter_testing",
"version": "3.0.10",
"upstream": "https://github.com/dart-lang/leak_tracker/tree/main/pkgs/leak_tracker_flutter_testing",
"license_file": "third_party/licenses/dart/leak_tracker_flutter_testing.txt"
},
{
"name": "leak_tracker_testing",
"version": "3.0.2",
"upstream": "https://github.com/dart-lang/leak_tracker/tree/main/pkgs/leak_tracker_testing",
"license_file": "third_party/licenses/dart/leak_tracker_testing.txt"
},
{
"name": "lints",
"version": "6.1.0",
"upstream": "https://github.com/dart-lang/core/tree/main/pkgs/lints",
"license_file": "third_party/licenses/dart/lints.txt"
},
{
"name": "logging",
"version": "1.3.0",
"upstream": "https://github.com/dart-lang/core/tree/main/pkgs/logging",
"license_file": "third_party/licenses/dart/logging.txt"
},
{
"name": "matcher",
"version": "0.12.20",
"upstream": "https://github.com/dart-lang/test/tree/master/pkgs/matcher",
"license_file": "third_party/licenses/dart/matcher.txt"
},
{
"name": "material_color_utilities",
"version": "0.13.0",
"upstream": "https://github.com/material-foundation/material-color-utilities/tree/main/dart",
"license_file": "third_party/licenses/dart/material_color_utilities.txt"
},
{
"name": "media_kit",
"version": "1.2.6",
"upstream": "https://github.com/media-kit/media-kit",
"license_file": "third_party/licenses/dart/media_kit.txt"
},
{
"name": "media_kit_libs_android_video",
"version": "1.3.8",
"upstream": "https://github.com/media-kit/media-kit.git",
"license_file": "third_party/licenses/dart/media_kit_libs_android_video.txt"
},
{
"name": "media_kit_video",
"version": "2.0.1",
"upstream": "https://github.com/media-kit/media-kit",
"license_file": "third_party/licenses/dart/media_kit_video.txt"
},
{
"name": "meta",
"version": "1.19.0",
"upstream": "https://github.com/dart-lang/sdk/tree/main/pkg/meta",
"license_file": "third_party/licenses/dart/meta.txt"
},
{
"name": "mime",
"version": "2.1.0",
"upstream": "https://github.com/dart-lang/tools/tree/main/pkgs/mime",
"license_file": "third_party/licenses/dart/mime.txt"
},
{
"name": "nm",
"version": "0.6.0",
"upstream": "https://github.com/canonical/nm.dart",
"license_file": "third_party/licenses/dart/nm.txt"
},
{
"name": "objective_c",
"version": "9.5.0",
"upstream": "https://github.com/dart-lang/native/tree/main/pkgs/objective_c",
"license_file": "third_party/licenses/dart/objective_c.txt"
},
{
"name": "package_config",
"version": "3.0.0",
"upstream": "https://github.com/dart-lang/tools/tree/main/pkgs/package_config",
"license_file": "third_party/licenses/dart/package_config.txt"
},
{
"name": "package_info_plus",
"version": "10.2.2",
"upstream": "https://github.com/fluttercommunity/plus_plugins/tree/main/packages/package_info_plus/package_info_plus",
"license_file": "third_party/licenses/dart/package_info_plus.txt"
},
{
"name": "package_info_plus_platform_interface",
"version": "4.1.0",
"upstream": "https://github.com/fluttercommunity/plus_plugins/tree/main/packages/",
"license_file": "third_party/licenses/dart/package_info_plus_platform_interface.txt"
},
{
"name": "path",
"version": "1.9.1",
"upstream": "https://github.com/dart-lang/core/tree/main/pkgs/path",
"license_file": "third_party/licenses/dart/path.txt"
},
{
"name": "path_provider",
"version": "2.1.6",
"upstream": "https://github.com/flutter/packages/tree/main/packages/path_provider/path_provider",
"license_file": "third_party/licenses/dart/path_provider.txt"
},
{
"name": "path_provider_android",
"version": "2.3.1",
"upstream": "https://github.com/flutter/packages/tree/main/packages/path_provider/path_provider_android",
"license_file": "third_party/licenses/dart/path_provider_android.txt"
},
{
"name": "path_provider_foundation",
"version": "2.6.0",
"upstream": "https://github.com/flutter/packages/tree/main/packages/path_provider/path_provider_foundation",
"license_file": "third_party/licenses/dart/path_provider_foundation.txt"
},
{
"name": "path_provider_linux",
"version": "2.2.2",
"upstream": "https://github.com/flutter/packages/tree/main/packages/path_provider/path_provider_linux",
"license_file": "third_party/licenses/dart/path_provider_linux.txt"
},
{
"name": "path_provider_platform_interface",
"version": "2.1.3",
"upstream": "https://github.com/flutter/packages/tree/main/packages/path_provider/path_provider_platform_interface",
"license_file": "third_party/licenses/dart/path_provider_platform_interface.txt"
},
{
"name": "path_provider_windows",
"version": "2.3.0",
"upstream": "https://github.com/flutter/packages/tree/main/packages/path_provider/path_provider_windows",
"license_file": "third_party/licenses/dart/path_provider_windows.txt"
},
{
"name": "petitparser",
"version": "7.1.0",
"upstream": "https://github.com/petitparser/dart-petitparser",
"license_file": "third_party/licenses/dart/petitparser.txt"
},
{
"name": "platform",
"version": "3.2.0",
"upstream": "https://github.com/dart-lang/core/tree/main/pkgs/platform",
"license_file": "third_party/licenses/dart/platform.txt"
},
{
"name": "plugin_platform_interface",
"version": "2.1.8",
"upstream": "https://github.com/flutter/packages/tree/main/packages/plugin_platform_interface",
"license_file": "third_party/licenses/dart/plugin_platform_interface.txt"
},
{
"name": "posix",
"version": "6.5.4",
"upstream": "https://github.com/onepub-dev/dart_posix",
"license_file": "third_party/licenses/dart/posix.txt"
},
{
"name": "pub_semver",
"version": "2.2.1",
"upstream": "https://github.com/dart-lang/tools/tree/main/pkgs/pub_semver",
"license_file": "third_party/licenses/dart/pub_semver.txt"
},
{
"name": "record_use",
"version": "0.6.0",
"upstream": "https://github.com/dart-lang/native/tree/main/pkgs/record_use",
"license_file": "third_party/licenses/dart/record_use.txt"
},
{
"name": "safe_local_storage",
"version": "2.0.7",
"upstream": "https://github.com/alexmercerind/safe_local_storage.git",
"license_file": "third_party/licenses/dart/safe_local_storage.txt"
},
{
"name": "share_plus",
"version": "13.3.1",
"upstream": "https://github.com/fluttercommunity/plus_plugins/tree/main/packages/share_plus/share_plus",
"license_file": "third_party/licenses/dart/share_plus.txt"
},
{
"name": "share_plus_platform_interface",
"version": "7.2.0",
"upstream": "https://github.com/fluttercommunity/plus_plugins/tree/main/packages/",
"license_file": "third_party/licenses/dart/share_plus_platform_interface.txt"
},
{
"name": "shared_preferences",
"version": "2.5.6",
"upstream": "https://github.com/flutter/packages/tree/main/packages/shared_preferences/shared_preferences",
"license_file": "third_party/licenses/dart/shared_preferences.txt"
},
{
"name": "shared_preferences_android",
"version": "2.5.4",
"upstream": "https://github.com/flutter/packages/tree/main/packages/shared_preferences/shared_preferences_android",
"license_file": "third_party/licenses/dart/shared_preferences_android.txt"
},
{
"name": "shared_preferences_foundation",
"version": "2.5.7",
"upstream": "https://github.com/flutter/packages/tree/main/packages/shared_preferences/shared_preferences_foundation",
"license_file": "third_party/licenses/dart/shared_preferences_foundation.txt"
},
{
"name": "shared_preferences_linux",
"version": "2.4.1",
"upstream": "https://github.com/flutter/packages/tree/main/packages/shared_preferences/shared_preferences_linux",
"license_file": "third_party/licenses/dart/shared_preferences_linux.txt"
},
{
"name": "shared_preferences_platform_interface",
"version": "2.4.2",
"upstream": "https://github.com/flutter/packages/tree/main/packages/shared_preferences/shared_preferences_platform_interface",
"license_file": "third_party/licenses/dart/shared_preferences_platform_interface.txt"
},
{
"name": "shared_preferences_web",
"version": "2.4.3",
"upstream": "https://github.com/flutter/packages/tree/main/packages/shared_preferences/shared_preferences_web",
"license_file": "third_party/licenses/dart/shared_preferences_web.txt"
},
{
"name": "shared_preferences_windows",
"version": "2.4.1",
"upstream": "https://github.com/flutter/packages/tree/main/packages/shared_preferences/shared_preferences_windows",
"license_file": "third_party/licenses/dart/shared_preferences_windows.txt"
},
{
"name": "sky_engine",
"version": "Flutter SDK 3.47.9",
"upstream": "https://github.com/flutter/flutter",
"license_file": "third_party/licenses/dart/sky_engine.txt"
},
{
"name": "source_span",
"version": "1.10.2",
"upstream": "https://github.com/dart-lang/tools/tree/main/pkgs/source_span",
"license_file": "third_party/licenses/dart/source_span.txt"
},
{
"name": "stack_trace",
"version": "1.12.2",
"upstream": "https://github.com/dart-lang/tools/tree/main/pkgs/stack_trace",
"license_file": "third_party/licenses/dart/stack_trace.txt"
},
{
"name": "stream_channel",
"version": "2.1.4",
"upstream": "https://github.com/dart-lang/tools/tree/main/pkgs/stream_channel",
"license_file": "third_party/licenses/dart/stream_channel.txt"
},
{
"name": "string_scanner",
"version": "1.4.1",
"upstream": "https://github.com/dart-lang/tools/tree/main/pkgs/string_scanner",
"license_file": "third_party/licenses/dart/string_scanner.txt"
},
{
"name": "synchronized",
"version": "3.5.0",
"upstream": "https://github.com/tekartik/synchronized.dart/tree/master/synchronized",
"license_file": "third_party/licenses/dart/synchronized.txt"
},
{
"name": "term_glyph",
"version": "1.2.2",
"upstream": "https://github.com/dart-lang/tools/tree/main/pkgs/term_glyph",
"license_file": "third_party/licenses/dart/term_glyph.txt"
},
{
"name": "test_api",
"version": "0.7.12",
"upstream": "https://github.com/dart-lang/test/tree/master/pkgs/test_api",
"license_file": "third_party/licenses/dart/test_api.txt"
},
{
"name": "typed_data",
"version": "1.4.0",
"upstream": "https://github.com/dart-lang/core/tree/main/pkgs/typed_data",
"license_file": "third_party/licenses/dart/typed_data.txt"
},
{
"name": "universal_platform",
"version": "1.1.0",
"upstream": "https://github.com/gskinnerTeam/flutter-universal-platform",
"license_file": "third_party/licenses/dart/universal_platform.txt"
},
{
"name": "uri_parser",
"version": "3.0.2",
"upstream": "https://github.com/alexmercerind/uri_parser",
"license_file": "third_party/licenses/dart/uri_parser.txt"
},
{
"name": "url_launcher",
"version": "6.3.3",
"upstream": "https://github.com/flutter/packages/tree/main/packages/url_launcher/url_launcher",
"license_file": "third_party/licenses/dart/url_launcher.txt"
},
{
"name": "url_launcher_android",
"version": "6.3.33",
"upstream": "https://github.com/flutter/packages/tree/main/packages/url_launcher/url_launcher_android",
"license_file": "third_party/licenses/dart/url_launcher_android.txt"
},
{
"name": "url_launcher_ios",
"version": "6.4.2",
"upstream": "https://github.com/flutter/packages/tree/main/packages/url_launcher/url_launcher_ios",
"license_file": "third_party/licenses/dart/url_launcher_ios.txt"
},
{
"name": "url_launcher_linux",
"version": "3.2.3",
"upstream": "https://github.com/flutter/packages/tree/main/packages/url_launcher/url_launcher_linux",
"license_file": "third_party/licenses/dart/url_launcher_linux.txt"
},
{
"name": "url_launcher_macos",
"version": "3.2.6",
"upstream": "https://github.com/flutter/packages/tree/main/packages/url_launcher/url_launcher_macos",
"license_file": "third_party/licenses/dart/url_launcher_macos.txt"
},
{
"name": "url_launcher_platform_interface",
"version": "2.3.2",
"upstream": "https://github.com/flutter/packages/tree/main/packages/url_launcher/url_launcher_platform_interface",
"license_file": "third_party/licenses/dart/url_launcher_platform_interface.txt"
},
{
"name": "url_launcher_web",
"version": "2.4.3",
"upstream": "https://github.com/flutter/packages/tree/main/packages/url_launcher/url_launcher_web",
"license_file": "third_party/licenses/dart/url_launcher_web.txt"
},
{
"name": "url_launcher_windows",
"version": "3.1.6",
"upstream": "https://github.com/flutter/packages/tree/main/packages/url_launcher/url_launcher_windows",
"license_file": "third_party/licenses/dart/url_launcher_windows.txt"
},
{
"name": "uuid",
"version": "4.6.0",
"upstream": "https://github.com/Daegalus/dart-uuid",
"license_file": "third_party/licenses/dart/uuid.txt"
},
{
"name": "vector_math",
"version": "2.4.0",
"upstream": "https://github.com/flutter/core-packages/tree/main/packages/vector_math",
"license_file": "third_party/licenses/dart/vector_math.txt"
},
{
"name": "vm_service",
"version": "15.4.0",
"upstream": "https://github.com/dart-lang/sdk/tree/main/pkg/vm_service",
"license_file": "third_party/licenses/dart/vm_service.txt"
},
{
"name": "wakelock_plus",
"version": "1.8.2",
"upstream": "https://github.com/fluttercommunity/wakelock_plus/tree/main/wakelock_plus",
"license_file": "third_party/licenses/dart/wakelock_plus.txt"
},
{
"name": "wakelock_plus_platform_interface",
"version": "1.7.0",
"upstream": ">-2",
"license_file": "third_party/licenses/dart/wakelock_plus_platform_interface.txt"
},
{
"name": "web",
"version": "1.1.2",
"upstream": "https://github.com/dart-lang/web",
"license_file": "third_party/licenses/dart/web.txt"
},
{
"name": "win32",
"version": "6.4.0",
"upstream": "https://github.com/halildurmus/win32",
"license_file": "third_party/licenses/dart/win32.txt"
},
{
"name": "windows_file_picker",
"version": "2.0.1",
"upstream": "https://github.com/vicajilau/flutter_file_picker/tree/main/packages/file_picker_windows",
"license_file": "third_party/licenses/dart/windows_file_picker.txt"
},
{
"name": "xdg_directories",
"version": "1.1.0",
"upstream": "https://github.com/flutter/packages/tree/main/packages/xdg_directories",
"license_file": "third_party/licenses/dart/xdg_directories.txt"
},
{
"name": "xml",
"version": "7.1.0",
"upstream": "https://github.com/renggli/dart-xml",
"license_file": "third_party/licenses/dart/xml.txt"
},
{
"name": "yaml",
"version": "3.1.4",
"upstream": "https://github.com/dart-lang/tools/tree/main/pkgs/yaml",
"license_file": "third_party/licenses/dart/yaml.txt"
}
]
}
+37
View File
@@ -0,0 +1,37 @@
Copyright 2022 BBFlight LLC
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
---
This software includes a modified version of package localstore (https://pub.dev/packages/localstore/versions/1.3.5). The license for that software is:
MIT License
Copyright (c) 2021 Hanoi University of Mining and Geology, Vietnam.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+63
View File
@@ -0,0 +1,63 @@
# background_downloader 9.6.5 — RM003 patch
This is a runtime-only vendoring of the exact published `background_downloader`
9.6.5 package, not a general dependency upgrade. `lib/`, `android/`, `ios/`,
`pubspec.yaml`, and `LICENSE` were copied from that pinned package. Examples,
upstream tests, and documentation are omitted to keep the fork small.
- Upstream: https://github.com/781flyingdutchman/background_downloader
- Published archive: https://pub.dev/api/archives/background_downloader-9.6.5.tar.gz
- Published archive SHA-256 (original application lockfile and pub cache):
`4df9b2a2d5b78b883ebd4b6338cbc1b541ea3482da24112339468a66a7492b43`
- Upstream `LICENSE` is retained verbatim, including its BSD-3-Clause terms and
the bundled localstore MIT notice. Preserve these notices in redistribution.
- Package version remains `9.6.5`; the root application's path override selects
this reviewed copy.
## Only production changes
1. `lib/src/chunk.dart`: `resumeChunkTasks` resumes only chunks whose serialized
status is not `TaskStatus.complete`. A completed chunk's final file and status
must remain available to the real parent stitching worker. Completed chunks
have no resume data; trying to resume them returns false and cancels the parent.
2. `lib/src/desktop/desktop_downloader.dart`: read and retain the parent's resume
snapshot before enqueue. Enqueue starts a desktop worker which consumes that
record; rereading after enqueue races the worker and can return null. This
separate race was reproduced while exercising the actual Linux workers.
## Download crash recovery fixes (0.4.0)
3. `lib/src/permissions.dart`: retain the current request's completer locally;
a permission callback may arrive before the request method returns. Release the
shared in-flight slot in `finally`, including dispatch failures.
4. `lib/src/base_downloader.dart`: propagate initialization failures to `ready`,
and mark readiness only after subclass/background-handler setup completes.
Await initial connectivity so errors are owned by initialization.
5. `lib/src/base_downloader.dart`: the database update queue reports write errors
through its flush barrier, including failed-then-successful batches, while
allowing later recovery. Detached futures no longer leak these errors.
The 0.4.0 fixes above did not modify native downloader sources. Dart regressions for
those paths are in `test/download_android_*_test.dart`. They reproduce callback,
initialization, connectivity and persistence failures on the host; they do not
claim an Android process crash was reproduced without logcat/device evidence.
## In-app transfer fixes (0.4.2)
6. `lib/src/native_downloader.dart`: `enqueueChild` now awaits actual native
enqueue and acknowledges its Boolean result. Rejection or a dispatch exception
returns false, allowing the parent to fail instead of waiting forever for a
nonexistent child. Background-channel tests cover rejection, a pending native
acknowledgement and an exception without detached errors.
7. Android `BDPlugin.kt` and `DownloadNetworkPolicy.java`: on API 28+, both
WorkManager and user-initiated JobScheduler jobs use the same explicit INTERNET
network request, without VALIDATED or NOT_VPN. Wi-Fi transport remains required
when the effective task policy is Wi-Fi-only. This avoids making successful
external OS connectivity validation a prerequisite for a reachable media host.
Older Android versions retain their original compatible scheduler constraints.
Application-side changes are outside this fork: multipart is selected only after
the server advertises the HEAD/length/range capabilities the native worker needs;
otherwise a normal streaming GET is used. A manual retry of failed multipart work
also uses a single stream and restores its ordinary retry budget.
+63
View File
@@ -0,0 +1,63 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.plugin.serialization' version '2.3.20'
}
def agpMajor = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.tokenize('.')[0] as int
def isBuiltInKotlinFalse = project.hasProperty('builtInKotlin') && (project.property('builtInKotlin') == false || project.property('builtInKotlin') == 'false')
if (agpMajor < 9 || isBuiltInKotlinFalse) {
apply plugin: ['kotlin', 'android'].join('-')
}
group = 'com.bbflight.background_downloader'
version = '1.0-SNAPSHOT'
allprojects {
repositories {
google()
mavenCentral()
}
}
android {
compileSdk = 36
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
defaultConfig {
minSdk = 23
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
if (project.android.hasProperty("namespace")) { namespace = 'com.bbflight.background_downloader' }
}
dependencies {
implementation "androidx.work:work-runtime-ktx:2.11.0"
implementation "androidx.concurrent:concurrent-futures-ktx:1.3.0"
implementation "androidx.preference:preference-ktx:1.2.1"
implementation "androidx.core:core-ktx:1.17.0"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0"
testImplementation "junit:junit:4.13.2"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2"
androidTestImplementation "androidx.test.ext:junit:1.3.0"
androidTestImplementation "androidx.test.espresso:espresso-core:3.7.0"
}
repositories {
mavenCentral()
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
@@ -0,0 +1,15 @@
## For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
#
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
# Default value: -Xmx1024m -XX:MaxPermSize=256m
# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
#
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
#Fri Dec 19 20:02:00 PST 2025
android.enableJetifier=true
android.useAndroidX=true
@@ -0,0 +1,13 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
plugins {
id 'com.android.library' version '9.0.1'
id 'org.jetbrains.kotlin.android' version '2.3.20'
}
}
rootProject.name = 'background_downloader'
@@ -0,0 +1,115 @@
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.bbflight.background_downloader.UriUtils
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class UriUtilsTest {
@Test
fun pack_should_pack_filename_and_uri_into_a_single_string() {
val filename = "myFile.txt"
val uri = Uri.parse("content://com.example.app/document/123")
val packedString = UriUtils.pack(filename, uri)
assertEquals(":::$filename::::::$uri:::", packedString)
}
@Test
fun unpack_should_unpack_a_valid_packed_string_into_filename_and_uri() {
val filename = "myFile.txt"
val uri = Uri.parse("content://com.example.app/document/123")
val packedString = ":::$filename::::::$uri:::"
val (unpackedFilename, unpackedUri) = UriUtils.unpack(packedString)
assertEquals(filename, unpackedFilename)
assertEquals(uri, unpackedUri)
}
@Test
fun unpack_should_return_original_string_and_null_uri_for_simple_filename_string() {
val invalidPackedString = "This is not a packed string"
val (filename, uri) = UriUtils.unpack(invalidPackedString)
assertEquals(invalidPackedString, filename)
assertNull(uri)
}
@Test
fun unpack_should_return_null_and_uri_for_simple_uri_string() {
val uriString = "https://www.example.com/path/to/resource"
val (filename, uri) = UriUtils.unpack(uriString)
assertNull(filename)
assertEquals(uri.toString(), uriString)
}
@Test
fun uriFromStringValue_should_return_Uri_for_a_valid_Uri_string() {
val uriString = "https://www.example.com/path/to/resource"
val expectedUri = Uri.parse(uriString)
val resultUri = UriUtils.uriFromStringValue(uriString)
assertEquals(expectedUri, resultUri)
}
@Test
fun uriFromStringValue_should_return_Uri_from_a_valid_packed_string() {
val filename = "myFile.txt"
val uri = Uri.parse("content://com.example.app/document/123")
val packedString = UriUtils.pack(filename, uri)
val resultUri = UriUtils.uriFromStringValue(packedString)
assertEquals(uri, resultUri)
}
@Test
fun uriFromStringValue_should_return_null_for_an_invalid_string() {
val invalidString = "This is not a Uri or packed string"
val resultUri = UriUtils.uriFromStringValue(invalidString)
assertNull(resultUri)
}
@Test
fun uriFromStringValue_should_return_null_for_a_packed_string_with_invalid_Uri() {
val filename = "myFile.txt"
val invalidUri = "invalid"
val packedString = ":::$filename::::::$invalidUri:::"
val resultUri = UriUtils.uriFromStringValue(packedString)
assertNull(resultUri)
}
@Test
fun containsUri_should_return_true_for_a_valid_Uri_string() {
val uriString = "https://www.example.com/path/to/resource"
assertTrue(UriUtils.containsUri(uriString))
}
@Test
fun containsUri_should_return_true_for_a_valid_packed_Uri_string() {
val filename = "myFile.txt"
val uri = Uri.parse("content://com.example.app/document/123")
val packedString = UriUtils.pack(filename, uri)
assertTrue(UriUtils.containsUri(packedString))
}
@Test
fun containsUri_should_return_false_for_an_invalid_string() {
val invalidString = "This is not a Uri or packed string"
assertFalse(UriUtils.containsUri(invalidString))
}
}
@@ -0,0 +1,36 @@
<manifest xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<receiver android:name="NotificationReceiver" android:exported="false">
<intent-filter>
<action android:name="com.bbflight.background_downloader.cancelActive" />
</intent-filter>
<intent-filter>
<action android:name="com.bbflight.background_downloader.pause" />
</intent-filter>
<intent-filter>
<action android:name="com.bbflight.background_downloader.resume" />
</intent-filter>
</receiver>
<provider
android:name="com.bbflight.background_downloader.OpenFileProvider"
android:authorities="${applicationId}.com.bbflight.background_downloader.fileprovider"
android:exported="false"
android:grantUriPermissions="true"
tools:replace="android:authorities">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/bgd_file_paths" />
</provider>
<service
android:name=".UIDTJobService"
android:permission="android.permission.BIND_JOB_SERVICE"
android:exported="true"
android:foregroundServiceType="dataSync" />
</application>
</manifest>
@@ -0,0 +1,94 @@
package com.bbflight.background_downloader
import android.net.Uri
import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.Serializable
@Serializable
@Suppress("unused")
@OptIn(InternalSerializationApi::class)
class Auth(
private var accessToken: String? = null,
private var accessHeaders: Map<String, String> = emptyMap(),
private var accessQueryParams: Map<String, String> = emptyMap(),
private var accessTokenExpiryTime: Long? = null,
private var refreshToken: String? = null,
private var refreshHeaders: Map<String, String> = emptyMap(),
private var refreshQueryParams: Map<String, String> = emptyMap(),
private var refreshUrl: String? = null,
private var onAuthRawHandle: Long? = null
) {
/**
* Returns the headers specified for the access request, with the
* template `{accessToken}` and `{refreshToken}` replaced.
*/
fun getExpandedAccessHeaders(): Map<String, String> = expandMap(accessHeaders)
/**
* Returns the query parameters specified for the access request, with the
* template `{accessToken}` and `{refreshToken}` replaced.
*/
fun getExpandedAccessQueryParams(): Map<String, String> = expandMap(accessQueryParams)
/**
* Add/update the query parameters in this [url] with [queryParams]
* and return the new uri
*/
fun addOrUpdateQueryParams(
url: String,
queryParams: Map<String, String> = emptyMap()
): Uri {
val startUri = Uri.parse(url)
if (queryParams.isEmpty()) {
return startUri
}
val updatedQueryParams =
startUri.queryParameterNames.associateWith { startUri.getQueryParameter(it) }
.toMutableMap()
updatedQueryParams.putAll(queryParams)
return startUri.buildUpon().clearQuery().apply {
updatedQueryParams.forEach { (key, value) ->
appendQueryParameter(key, value)
}
}.build()
}
/**
* Returns true if the [accessTokenExpiryTime is after now plus
* the [bufferTime], otherwise returns false
*/
fun isTokenExpired(bufferTime: Long = 10000): Boolean {
val expiry = accessTokenExpiryTime ?: return false
val expiryTimeWithBuffer = System.currentTimeMillis() + bufferTime
return expiryTimeWithBuffer > expiry
}
/**
* Expands the [mapToExpand] by replacing {accessToken} and {refreshToken}
*
* Returns the expanded map, without changing the original
*/
private fun expandMap(mapToExpand: Map<String, String>): Map<String, String> {
val newMap = mutableMapOf<String, String>()
mapToExpand.forEach { (key, value) ->
var newValue = value
if (accessToken != null) {
newValue = newValue.replace("{accessToken}", accessToken!!)
}
if (refreshToken != null) {
newValue = newValue.replace("{refreshToken}", refreshToken!!)
}
newMap[key] = newValue
}
return newMap
}
/**
* True if this Auth object has an 'onAuth' callback that can be called to refresh the
* access token
*/
fun hasOnAuthCallback(): Boolean = onAuthRawHandle != null
}
@@ -0,0 +1,199 @@
package com.bbflight.background_downloader
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.preference.PreferenceManager
import io.flutter.FlutterInjector
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.dart.DartExecutor
import io.flutter.plugin.common.MethodChannel
import io.flutter.view.FlutterCallbackInformation
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
class Callbacks {
companion object {
const val TAG = "Callbacks"
private var callbackMethodChannel: MethodChannel? = null
private val methodChannelMutex = Mutex()
/**
* Retrieves the [MethodChannel] used for communication with the Flutter background isolate.
*
* This method initializes the `MethodChannel` if it hasn't been created yet. It does so by:
* 1. Obtaining the `callbackDispatcherRawHandle` from shared preferences.
* 2. Creating a new [FlutterEngine] and executing the Dart callback dispatcher.
* 3. Creating a [MethodChannel] using the engine's binary messenger.
*
* The initialization process happens on the UI thread to avoid potential issues.
*
* This method is thread-safe due to the use of a mutex.
*
* @param context The [Context] required for accessing resources and the UI thread.
* @return The [MethodChannel] instance, or `null` if initialization fails.
*/
private suspend fun getMethodChannel(context: Context): MethodChannel? {
methodChannelMutex.withLock {
if (callbackMethodChannel == null) {
// create new flutterEngine and invoke callbackDispatcher, on the UI thread
val methodChannelCompleter = CompletableDeferred<MethodChannel?>()
Handler(Looper.getMainLooper()).post { // Run on UI thread
try {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val rawHandle =
prefs.getLong(BDPlugin.keyCallbackDispatcherRawHandle, -1L)
if (rawHandle == -1L) {
Log.w(
TAG,
"getMethodChannel without registered callbackDispatcherRawHandle"
)
methodChannelCompleter.complete(null)
return@post
}
val flutterEngine = FlutterEngine(context, null, false)
val callbackDispatcherCallback =
FlutterCallbackInformation.lookupCallbackInformation(rawHandle)
if (callbackDispatcherCallback == null) {
Log.w(
TAG,
"invokeOnStartCallback failed to find callbackDispatcher"
)
methodChannelCompleter.complete(null)
return@post
}
val appBundlePath: String =
FlutterInjector.instance().flutterLoader().findAppBundlePath()
val assets = context.assets
flutterEngine.dartExecutor.executeDartCallback(
DartExecutor.DartCallback(
assets,
appBundlePath,
callbackDispatcherCallback
)
)
callbackMethodChannel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
"com.bbflight.background_downloader.callbacks"
)
methodChannelCompleter.complete(callbackMethodChannel)
} catch (e: Exception) {
Log.e(TAG, "Error creating MethodChannel", e)
methodChannelCompleter.complete(null)
}
}
return methodChannelCompleter.await() // Wait for the UI thread to finish
}
return callbackMethodChannel
}
}
/**
* Invokes a callback method on the Flutter side using the specified method channel.
*
* This function handles the asynchronous communication with Flutter, ensuring thread safety
* and proper handling of the result. It serializes the given `task` object to JSON
* and passes it as an argument to the Flutter method.
*
* @param context The Android context.
* @param task The [Task] object to be passed to the Flutter callback, or null.
* @param statusUpdate The [TaskStatusUpdate] object to be passed to the Flutter callback, or null.
* @param methodName The name of the method to invoke on the Flutter side.
* @return The updated object returned by the Flutter callback, or `null` if
* the task is unchanged, an error occurs, or the method channel is not available.
*/
private suspend fun invokeCallback(
context: Context,
methodName: String,
task: Task? = null,
statusUpdate: TaskStatusUpdate? = null,
): Any? {
val methodChannel = getMethodChannel(context) ?: return null
methodChannelMutex.withLock {
val resultingObjectAsJsonStringCompleter = CompletableDeferred<String?>()
Handler(Looper.getMainLooper()).post {
// Run on UI thread
val arg = if (task != null) bdJson.encodeToString(task) else bdJson.encodeToString(
statusUpdate
)
Log.i(
TAG,
"Invoking $methodName for taskId ${task?.taskId}"
)
methodChannel.invokeMethod(
methodName,
arg, // either task or update
FlutterResultHandler(resultingObjectAsJsonStringCompleter)
)
}
val objectAsJsonString = resultingObjectAsJsonStringCompleter.await()
return if (objectAsJsonString == null) null else
if (methodName == "beforeTaskStartCallback") bdJson.decodeFromString<TaskStatusUpdate>(
objectAsJsonString
) else bdJson.decodeFromString<Task>(
objectAsJsonString
)
}
}
/**
* Invoke the beforeTaskStartCallback and return the result
*/
suspend fun invokeBeforeTaskStartCallback(context: Context, task: Task): TaskStatusUpdate? {
try {
return invokeCallback(
context,
"beforeTaskStartCallback",
task = task
) as TaskStatusUpdate?
} catch (e: Exception) {
Log.e(TAG, "Error in invokeBeforeTaskStartCallback", e)
}
return null
}
/**
* Invoke the onTaskStartCallback and return the result
*/
suspend fun invokeOnTaskStartCallback(context: Context, task: Task): Task? {
try {
return invokeCallback(context, "onTaskStartCallback", task = task) as Task?
} catch (e: Exception) {
Log.e(TAG, "Error in invokeOnTaskStartCallback", e)
}
return null
}
/**
* Invoke the invokeOnAuthCallback and return the result
*/
suspend fun invokeOnAuthCallback(context: Context, task: Task): Task? {
try {
return invokeCallback(context, "onAuthCallback", task = task) as Task?
} catch (e: Exception) {
Log.e(TAG, "Error in invokeOnAuthCallback", e)
}
return null
}
/**
* Invoke the onTaskFinishedCallback
*/
suspend fun invokeOnTaskFinishedCallback(context: Context, statusUpdate: TaskStatusUpdate) {
try {
invokeCallback(
context,
"onTaskFinishedCallback",
statusUpdate = statusUpdate
) as Task?
} catch (e: Exception) {
Log.e(TAG, "Error in invokeOnTaskFinishedCallback", e)
}
}
}
}
@@ -0,0 +1,50 @@
package com.bbflight.background_downloader
import android.util.Log
import java.net.HttpURLConnection
class DataTaskRunner(context: TaskJobContext) : TaskRunner(context) {
/** Process the response to the GET or POST request on this [connection]
*
* Returns the [TaskStatus]
*/
override suspend fun process(
connection: HttpURLConnection,
): TaskStatus {
responseStatusCode = connection.responseCode
if (responseStatusCode!! in 200..206) {
extractResponseHeaders(connection.headerFields)
extractContentType(connection.headerFields)
// transfer the bytes from the server to the temp file
return try {
responseBody = connection.inputStream.bufferedReader().readText()
TaskStatus.complete
} catch (e: Exception) {
Log.i(
TAG,
"Could not read response content: $e"
)
taskException = TaskException(ExceptionType.connection, description = "Could not read response content: $e")
TaskStatus.failed
}
} else {
// HTTP response code not OK
Log.i(
TAG,
"Response code ${connection.responseCode} for taskId ${task.taskId}"
)
val errorContent = responseErrorContent(connection)
taskException = TaskException(
ExceptionType.httpResponse, httpResponseCode = connection.responseCode,
description = if (errorContent?.isNotEmpty() == true) errorContent else connection.responseMessage
)
return if (responseStatusCode == 404) {
responseBody = errorContent
TaskStatus.notFound
} else {
TaskStatus.failed
}
}
}
}
@@ -0,0 +1,12 @@
package com.bbflight.background_downloader
import android.content.Context
import androidx.work.WorkerParameters
class DataTaskWorker(context: Context, params: WorkerParameters) :
TaskWorker(context, params) {
override fun createRunner(): TaskRunner {
return DataTaskRunner(this)
}
}
@@ -0,0 +1,22 @@
package com.bbflight.background_downloader;
import android.net.NetworkCapabilities;
import android.net.NetworkRequest;
/** Shared by WorkManager and user-initiated jobs on Android 9 and later. */
public final class DownloadNetworkPolicy {
private DownloadNetworkPolicy() {}
public static NetworkRequest request(boolean wifiOnly) {
// Media may be reachable even when an OS connectivity-check host is
// blocked. Require internet capability, not OS network validation.
NetworkRequest.Builder builder = new NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.removeCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
.removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN);
if (wifiOnly) {
builder.addTransportType(NetworkCapabilities.TRANSPORT_WIFI);
}
return builder.build();
}
}
@@ -0,0 +1,662 @@
package com.bbflight.background_downloader
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.storage.StorageManager
import android.util.Log
import androidx.core.net.toFile
import androidx.documentfile.provider.DocumentFile
import androidx.preference.PreferenceManager
import com.bbflight.background_downloader.UriUtils.unpack
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.BufferedInputStream
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.RandomAccessFile
import java.net.HttpURLConnection
import java.nio.channels.FileChannel
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.nio.file.StandardOpenOption
import android.system.Os
import android.system.ErrnoException
import kotlin.math.absoluteValue
import kotlin.random.Random
class DownloadTaskRunner(context: TaskJobContext) : TaskRunner(context) {
private var eTagHeader: String? = null
private var serverAcceptsRanges = false // if true, send resume data on fail
private var tempFilePath = ""
private var requiredStartByte = 0L // required start byte within the task range
private var taskRangeStartByte = 0L // Start of the Task's download range
private var eTag: String? = null
/**
* Make the request to the [connection] and process the [Task]
*
* Returns the [TaskStatus]
* */
override suspend fun connectAndProcess(connection: HttpURLConnection): TaskStatus {
if (isResume) {
val taskRangeHeader = task.headers["Range"] ?: ""
val taskRange = parseRange(taskRangeHeader)
taskRangeStartByte = taskRange.first
val resumeRange = Pair(taskRangeStartByte + requiredStartByte, taskRange.second)
val newRangeString = "bytes=${resumeRange.first}-${resumeRange.second ?: ""}"
connection.setRequestProperty("Range", newRangeString)
}
val result = super.connectAndProcess(connection)
if (result == TaskStatus.canceled) {
deleteTempFile()
}
if (result == TaskStatus.failed) {
prepResumeAfterFailure()
}
return result
}
/** Process the response to the GET or POST request on this [connection]
*
* Returns the [TaskStatus]
*/
override suspend fun process(
connection: HttpURLConnection,
): TaskStatus {
// Check if the file should be skipped
// (not possible if the filename will be suggested by the server)
val skipThreshold = prefs.getInt(BDPlugin.keyConfigSkipExistingFiles, -1)
if (skipThreshold != -1 && task.filename != "?") {
val filePath = task.filePath(context.appContext)
val file = File(filePath)
if (file.exists()) {
val fileSize = file.length()
// threshold 0 skips any existing file, including an empty one
if (skipThreshold == 0 || fileSize > skipThreshold * 1024L * 1024L) {
responseStatusCode = 304
return TaskStatus.complete
}
}
}
responseStatusCode = connection.responseCode
if (connection.responseCode in 200..206) {
// determine if we are using Uri or not. Uri means pause/resume not allowed
val directoryUri = UriUtils.uriFromStringValue(task.directory)
val usesUri = directoryUri != null
eTagHeader = connection.headerFields["ETag"]?.first()
val acceptRangesHeader = connection.headerFields["Accept-Ranges"]
serverAcceptsRanges =
acceptRangesHeader?.first() == "bytes" || connection.responseCode == 206
if (task.allowPause && !usesUri) {
taskCanResume = serverAcceptsRanges
processCanResume(
task,
taskCanResume
)
}
isResume = isResume && connection.responseCode == 206 // confirm resume response
if (isResume && !prepareResume(connection)) {
deleteTempFile()
return TaskStatus.failed
}
if (isResume && (eTagHeader != eTag || eTag?.startsWith("W/") == true)) {
deleteTempFile()
Log.i(TAG, "Cannot resume: ETag is not identical, or is weak")
taskException = TaskException(
ExceptionType.resume,
description = "Cannot resume: ETag is not identical, or is weak"
)
return TaskStatus.failed
}
// Determine destination - either [destFilePath] or [destUri]
// If no filename is set, get from headers or url, and update the task
var destFilePath = task.filePath(context.appContext)
var (uriFilename, destUri) = unpack(task.filename)
if (!task.hasFilename()) {
// If no filename is set, get from headers or url, and update the task
if (usesUri) {
uriFilename = suggestFilename(connection.headerFields, task.url)
if (uriFilename.isEmpty()) {
uriFilename = "${Random.nextInt().absoluteValue}"
}
task = task.copyWith(
filename = if (destUri == null) uriFilename else UriUtils.pack(
uriFilename,
destUri
)
)
} else {
destFilePath = destFilePath(connection)
}
}
extractResponseHeaders(connection.headerFields)
extractContentType(connection.headerFields)
val contentLength = getContentLength(connection.headerFields, task)
// determine tempFile, or set to null if Uri is used
val tempFile = if (!usesUri) {
val applicationSupportPath =
baseDirPath(context.appContext, BaseDirectory.applicationSupport)
val cachePath = baseDirPath(context.appContext, BaseDirectory.temporary)
if (applicationSupportPath == null || cachePath == null) {
throw IllegalStateException("External storage is requested but not available")
}
val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(context.appContext)
val tempFilePathConfig = sharedPrefs.getString(BDPlugin.keyConfigTempFilePath, null)
val tempDir = if (!tempFilePathConfig.isNullOrEmpty()) {
File(tempFilePathConfig)
} else {
when (sharedPrefs.getInt(BDPlugin.keyConfigUseCacheDir, -2)) {
0 -> File(cachePath) // 'always'
-1 -> File(applicationSupportPath) // 'never'
else -> {
// 'whenAble' -> determine based on cache quota
val storageManager =
context.appContext.getSystemService(Context.STORAGE_SERVICE) as StorageManager
val cacheQuota = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
storageManager.getCacheQuotaBytes(
storageManager.getUuidForPath(
File(cachePath)
)
)
} else {
50L shl (20) // for older OS versions, assume 50MB
}
if (contentLength < cacheQuota / 2) File(cachePath) else File(
applicationSupportPath
)
}
}
}
if (!tempDir.exists()) {
tempDir.mkdirs()
}
tempFilePath =
tempFilePath.ifEmpty { "${tempDir.absolutePath}/com.bbflight.background_downloader${Random.nextInt()}" }
// confirm enough storage space for download
if (insufficientSpace(context.appContext, contentLength)) {
Log.i(
TAG,
"Insufficient space to store the file to be downloaded for taskId ${task.taskId}"
)
taskException = TaskException(
ExceptionType.fileSystem,
description = "Insufficient space to store the file to be downloaded"
)
return TaskStatus.failed
}
File(tempFilePath)
} else {
null
}
val outputStream: java.io.OutputStream = if (tempFile != null) {
FileOutputStream(tempFile, isResume)
} else {
// no tempFile, because we have a Uri
uriFilename = uriFilename ?: "unknown"
if (directoryUri!!.scheme == "file") {
// fileUri is converted to a File, then to a FileOutputStream
if (destUri == null) {
// need to create file at directory
val dirObject = directoryUri.toFile()
val destFile = File(dirObject, uriFilename)
destUri = Uri.fromFile(destFile)
// Store destination Uri in task
task = task.copyWith(filename = UriUtils.pack(uriFilename, destUri))
FileOutputStream(destFile) // return outputStream
} else {
// use destination Uri that was set in previous attempt
FileOutputStream(destUri.toFile())
}
} else {
// other URL scheme will be attempted to resolve using content resolver
val resolver = context.appContext.contentResolver
// create destination Uri if not already exists
val documentFile = DocumentFile.fromTreeUri(context.appContext, directoryUri)
destUri = destUri ?: documentFile?.createFile(task.mimeType, uriFilename)?.uri
if (destUri == null) {
val message =
"Failed to create document within directory with URI: $directoryUri"
Log.e(TAG, message)
taskException = TaskException(
ExceptionType.fileSystem,
description = message
)
return TaskStatus.failed
}
val newFilename = getFilenameFromUri(destUri)
if (newFilename.isNotEmpty()) {
uriFilename = newFilename
}
task = task.copyWith(filename = UriUtils.pack(uriFilename, destUri))
val os = resolver.openOutputStream(destUri)
if (os == null) {
val message = "Failed to open output stream for URI: $destUri"
Log.e(TAG, message)
taskException = TaskException(
ExceptionType.fileSystem,
description = message
)
return TaskStatus.failed
} else os
}
}
BDPlugin.remainingBytesToDownload[task.taskId] = contentLength
determineRunInForeground(task, contentLength) // sets 'runInForeground'
context.updateEstimatedNetworkBytes(contentLength, 0L)
// transfer the bytes from the server to the output stream
val transferBytesResult: TaskStatus
outputStream.use { os ->
BufferedInputStream(connection.inputStream).use { inputStream ->
transferBytesResult = transferBytes(
inputStream, os, contentLength, task
)
}
os.flush()
}
// act on the result of the bytes transfer
when (transferBytesResult) {
TaskStatus.complete -> {
if (tempFile == null) {
Log.i(
TAG, "Successfully downloaded taskId ${task.taskId} to URI $destUri"
)
} else {
// move file from its temp location to the destination
val destFile = File(destFilePath)
val dir = destFile.parentFile!!
if (!dir.exists()) {
dir.mkdirs()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
withContext(Dispatchers.IO) {
try {
Files.move(
tempFile.toPath(),
destFile.toPath(),
StandardCopyOption.REPLACE_EXISTING
)
} catch (e: java.nio.file.FileSystemException) {
// Fallback if moving across different filesystems/mount points (EXDEV / Cross-device link)
Log.d(TAG, "Files.move failed ($e), falling back to copyTo + delete")
tempFile.copyTo(destFile, overwrite = true)
deleteTempFile()
}
}
setFileOwnership(destFile)
} else {
tempFile.copyTo(destFile, overwrite = true)
deleteTempFile()
setFileOwnership(destFile)
}
Log.i(
TAG, "Successfully downloaded taskId ${task.taskId} to $destFilePath"
)
}
return TaskStatus.complete
}
TaskStatus.canceled -> {
cleanup(usesUri, destUri)
Log.i(TAG, "Canceled taskId ${task.taskId}")
return TaskStatus.canceled
}
TaskStatus.paused -> return pauseResult(usesUri, destUri, bytesTotal + startByte)
TaskStatus.enqueued -> {
// Special status, in this context means that the task timed out
return timeoutResult(usesUri, destUri, bytesTotal + startByte)
}
TaskStatus.failed -> {
// resume after failure is prepared in [connectAndProcess]
return TaskStatus.failed
}
else -> {
Log.e(TAG, "Unknown transferBytesResult $transferBytesResult")
cleanup(usesUri, destUri)
return TaskStatus.failed
}
}
} else {
// HTTP response code not OK
Log.i(
TAG,
"Response code ${connection.responseCode} for taskId ${task.taskId}"
)
val errorContent = responseErrorContent(connection)
taskException = TaskException(
ExceptionType.httpResponse, httpResponseCode = connection.responseCode,
description = if (errorContent?.isNotEmpty() == true) errorContent else connection.responseMessage
)
return if (connection.responseCode == 404) {
responseBody = errorContent
TaskStatus.notFound
} else {
TaskStatus.failed
}
}
}
/**
* Returns the final [TaskStatus] for a task that was paused, with [resumeStart]
* the number of bytes in the temp file.
*
* Posts resume data if the task can resume, otherwise fails the task
*/
private suspend fun pauseResult(usesUri: Boolean, destUri: Uri?, resumeStart: Long): TaskStatus {
BDPlugin.pausedTaskIds.remove(task.taskId)
if (taskCanResume) {
Log.i(TAG, "Task ${task.taskId} paused")
processResumeData(
ResumeData(
task, tempFilePath, resumeStart, eTagHeader
), prefs
)
return TaskStatus.paused
}
if (BDPlugin.tasksToReEnqueue.contains(task) && serverAcceptsRanges) {
// pause was triggered by re-enqueue request due to WiFi requirement change
// so we only store local resumeData without posting it
Log.i(TAG, "Task ${task.taskId} paused in order to re-enqueue")
BDPlugin.localResumeData[task.taskId] = ResumeData(
task, tempFilePath, resumeStart, eTagHeader
)
return TaskStatus.paused
}
Log.i(TAG, "Task ${task.taskId} cannot resume, therefore pause failed")
taskException = TaskException(
ExceptionType.resume,
description = "Task was paused but cannot resume"
)
cleanup(usesUri, destUri)
return TaskStatus.failed
}
/**
* Returns the final [TaskStatus] for a task that timed out, with [resumeStart]
* the number of bytes in the temp file.
*
* If allowed, pauses the task and schedules the resume task immediately,
* otherwise fails the task. Never returns [TaskStatus.enqueued]
*/
private suspend fun timeoutResult(usesUri: Boolean, destUri: Uri?, resumeStart: Long): TaskStatus {
if (!task.allowPause) {
Log.i(TAG, "Task ${task.taskId} timed out")
taskException =
TaskException(
ExceptionType.connection,
description = "Task timed out"
)
return TaskStatus.failed
}
if (taskCanResume) {
Log.i(
TAG,
"Task ${task.taskId} paused due to timeout, will resume in 1 second"
)
val success = BDPlugin.doEnqueue(
context.appContext,
task,
notificationConfigJsonString,
ResumeData(task, tempFilePath, resumeStart, eTagHeader),
1000
)
if (!success) {
Log.w(TAG, "Task ${task.taskId} timed out and could not re-enqueue to resume")
taskException = TaskException(
ExceptionType.general,
description = "Task timed out and could not re-enqueue to resume"
)
cleanup(usesUri, destUri)
return TaskStatus.failed
}
return TaskStatus.paused
}
Log.i(TAG, "Task ${task.taskId} timed out and cannot pause/resume")
taskException =
TaskException(ExceptionType.connection, description = "Task timed out")
cleanup(usesUri, destUri)
return TaskStatus.failed
}
/**
* Pause interrupted the task outside of [transferBytes], e.g. while waiting
* for the response. Resume from whatever is in the temp file, if possible
*/
override suspend fun handlePause(): TaskStatus {
prepareInterruptedResume()
return pauseResult(usesUriDestination(), unpack(task.filename).second, tempFileLength())
}
/**
* Timeout interrupted the task outside of [transferBytes], e.g. while
* waiting for the response. Resume from whatever is in the temp file, if possible
*/
override suspend fun handleTimeout(): TaskStatus {
prepareInterruptedResume()
return timeoutResult(usesUriDestination(), unpack(task.filename).second, tempFileLength())
}
/**
* Prepare state for [handlePause] and [handleTimeout]: resume is only
* possible if the temp file holds data, and if the task already
* established that it can resume (in this or in a previous run)
*/
private fun prepareInterruptedResume() {
val hasTempData = !usesUriDestination() && tempFileLength() > 0
taskCanResume = task.allowPause && hasTempData && (taskCanResume || isResume)
eTagHeader = eTagHeader ?: eTag
}
/** True if the destination of this task is a Uri */
private fun usesUriDestination() = UriUtils.uriFromStringValue(task.directory) != null
/** Length of the temp file, or 0 if it does not exist */
private fun tempFileLength(): Long {
if (tempFilePath.isEmpty()) return 0
val tempFile = File(tempFilePath)
return if (tempFile.exists()) tempFile.length() else 0
}
/**
* Return destination filePath where the filename is set based on response headers, and
* update the [task] accordingly
*/
private fun destFilePath(connection: HttpURLConnection): String {
val destFilePath = task.filePath(context.appContext)
task = task.withSuggestedFilenameFromResponseHeaders(
context.appContext,
connection.headerFields,
unique = true
)
val dirName = File(destFilePath).parent ?: ""
return if (dirName.isEmpty()) task.filename else "$dirName/${task.filename}"
}
/**
* Return true if this is a resume, and resume is possible,
* given [tempFilePath] and [requiredStartByte]
* */
override fun determineIfResume(): Boolean {
// set tempFilePath from resume data, or "" if a new tempFile is needed
requiredStartByte = context.getInputLong(keyStartByte, 0)
if (requiredStartByte == 0L) {
return false
}
eTag = context.getInputString(keyETag)
tempFilePath = if (requiredStartByte > 0) context.getInputString(keyResumeDataData) ?: ""
else ""
val tempFile = File(tempFilePath)
if (tempFile.exists()) {
val tempFileLength = tempFile.length()
if (tempFileLength == requiredStartByte) {
return true
} else {
// attempt to truncate the file to the expected size
Log.d(
TAG,
"File length = ${tempFile.length()} vs requiredStartByte = $requiredStartByte"
)
if (tempFileLength > requiredStartByte && Build.VERSION.SDK_INT >= 26) {
try {
val fileChannel =
FileChannel.open(tempFile.toPath(), StandardOpenOption.WRITE)
fileChannel.truncate(requiredStartByte)
fileChannel.close()
Log.d(TAG, "Truncated temp file to desired length")
return true
} catch (e: IOException) {
e.printStackTrace()
}
}
Log.i(TAG, "Partially downloaded file is corrupted, resume not possible")
}
} else {
Log.i(TAG, "Partially downloaded file not available, resume not possible")
}
return false
}
/** Prepare for resume if possible
*
* Returns true if task can continue, false if task failed.
* Extracts and parses Range headers, and truncates temp file
*/
private fun prepareResume(connection: HttpURLConnection): Boolean {
if (tempFilePath.isEmpty()) {
return false
}
val contentRanges = connection.headerFields["Content-Range"]
if (contentRanges == null || contentRanges.size > 1) {
Log.i(
TAG, "Could not " +
"process partial response Content-Range"
)
return false
}
val range = contentRanges.first()
val contentRangeRegEx = Regex("(\\d+)-(\\d+)/(\\d+)")
val matchResult = contentRangeRegEx.find(range)
if (matchResult == null) {
Log.i(TAG, "Could not process partial response Content-Range $range")
taskException = TaskException(
ExceptionType.resume,
description = "Could not process partial response Content-Range $range"
)
return false
}
val start = matchResult.groups[1]?.value?.toLong()!!
val tempFile = File(tempFilePath)
val tempFileLength = tempFile.length()
startByte = start - taskRangeStartByte // relative to start of range
if (startByte > tempFileLength) {
Log.i(TAG, "Offered range not feasible: $range with startByte $startByte")
taskException = TaskException(
ExceptionType.resume,
description = "Offered range not feasible: $range with startByte $startByte"
)
return false
}
// resume possible, set start conditions
try {
RandomAccessFile(tempFilePath, "rw").use { it.setLength(startByte) }
} catch (_: IOException) {
Log.i(TAG, "Could not truncate temp file")
taskException =
TaskException(
ExceptionType.resume,
description = "Could not truncate temp file"
)
return false
}
return true
}
/**
* Attempt to allow resume after failure by sending resume data
* back to Dart
*
* If this is not possible, the temp file will be deleted
*/
private suspend fun prepResumeAfterFailure() {
// a Uri download has no temp file, and the temp file may have been deleted already
val hasTempFile = tempFilePath.isNotEmpty() && File(tempFilePath).exists()
if (hasTempFile && serverAcceptsRanges && bytesTotal + startByte > 1 shl 20) {
// if failure can be resumed, post resume data
processResumeData(
ResumeData(
task, tempFilePath, bytesTotal + startByte, eTagHeader
), prefs
)
} else {
// if it cannot be resumed, delete the temp file
deleteTempFile()
}
}
/**
* Deletes the temp file associated with this download
*/
private fun deleteTempFile() {
if (tempFilePath.isNotEmpty()) {
try {
val tempFile = File(tempFilePath)
tempFile.delete()
} catch (_: IOException) {
Log.i(TAG, "Could not delete temp file at $tempFilePath")
}
}
}
/**
* Deletes the destination Uri at [uri]
*/
private fun deleteDestinationUri(uri: Uri) {
try {
context.appContext.contentResolver.delete(uri, null, null)
} catch (_: Exception) {
Log.i(TAG, "Could not delete file at $uri")
}
}
/**
* Cleanup by deleting the temp file or destination uri
*/
private fun cleanup(usesUri: Boolean, destUri: Uri?) {
if (usesUri && destUri != null) {
deleteDestinationUri(destUri)
} else {
deleteTempFile()
}
}
/**
* Sets the group ownership of the downloaded file to unmark the file as a cache file.
*
* Determines the app's GID and then calls Os.chown. Logs success or failure.
* Likely to fail if the file is in external storage.
*/
private fun setFileOwnership(destFile: File) {
try {
val finalPath = destFile.absolutePath
val gid = context.appContext.applicationInfo.uid // App's own GID
Os.chown(finalPath, -1, gid) // -1 for UID means keep current owner
} catch (e: ErrnoException) {
// Log the error, common if the filesystem doesn't support chown or due to permissions.
// Check OsConstants for specific errno values if needed.
Log.w(
TAG,
"Failed to change group ownership for ${destFile.absolutePath}: ${e.message}"
)
}
}
}
@@ -0,0 +1,12 @@
package com.bbflight.background_downloader
import android.content.Context
import androidx.work.WorkerParameters
class DownloadTaskWorker(context: Context, params: WorkerParameters) :
TaskWorker(context, params) {
override fun createRunner(): TaskRunner {
return DownloadTaskRunner(this)
}
}
@@ -0,0 +1,344 @@
package com.bbflight.background_downloader
import android.annotation.SuppressLint
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.os.StatFs
import android.util.Log
import androidx.core.content.FileProvider.getUriForFile
import androidx.preference.PreferenceManager
import com.bbflight.background_downloader.TaskRunner.Companion.TAG
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.CompletableDeferred
import java.io.File
import java.net.URLDecoder
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager
import kotlin.io.path.Path
import kotlin.io.path.pathString
/**
* Installs a custom X509 TrustManager that accepts all certificates. Future
* HTTPS calls will therefore not check the validity of the TLS certificate.
*
* DO NOT CONFIGURE THE DOWNLOADER TO BYPASS CERTIFICATE VALIDATION IN RELEASE
*/
fun acceptUntrustedCertificates() {
try {
// Create a trust manager that does not validate certificate chains
val trustAllCerts = arrayOf<TrustManager>(
@SuppressLint("CustomX509TrustManager")
object : X509TrustManager {
@SuppressLint("TrustAllX509TrustManager")
override fun checkClientTrusted(
chain: Array<java.security.cert.X509Certificate>,
authType: String
) {
}
@SuppressLint("TrustAllX509TrustManager")
override fun checkServerTrusted(
chain: Array<java.security.cert.X509Certificate>,
authType: String
) {
}
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> {
return arrayOf()
}
})
// Install the all-trusting trust manager
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(null, trustAllCerts, java.security.SecureRandom())
// Create an ssl socket factory with our all-trusting manager
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
Log.w(
BDPlugin.TAG, "Bypassing TLS certificate validation\n" +
"HTTPS calls will NOT check the validity of the TLS certificate."
)
} catch (e: Exception) {
throw RuntimeException(e)
}
}
/**
* Returns true if there is insufficient space to store a file of length
* [contentLength]
*
* Returns false if [contentLength] <= 0
* Returns false if configCheckAvailableSpace has not been set, or if available
* space is greater than that setting
* Returns true otherwise
*/
fun insufficientSpace(applicationContext: Context, contentLength: Long): Boolean {
if (contentLength <= 0) {
return false
}
val checkValue = PreferenceManager.getDefaultSharedPreferences(applicationContext)
.getInt(BDPlugin.keyConfigCheckAvailableSpace, 0)
if (checkValue <= 0) {
return false
}
val path = Environment.getDataDirectory()
val stat = StatFs(path.path)
val available = stat.blockSizeLong * stat.availableBlocksLong
return available - (BDPlugin.remainingBytesToDownload.values.sum()
+ contentLength) < (checkValue.toLong() shl 20)
}
/**
* Parses the range in a Range header, and returns a Pair representing
* the range. The format needs to be "bytes=10-20"
*
* A missing lower range is substituted with 0L, and a missing upper
* range with null. If the string cannot be parsed, returns (0L, null)
*/
fun parseRange(rangeStr: String): Pair<Long, Long?> {
val regex = Regex("""bytes=(\d*)-(\d*)""")
val match = regex.find(rangeStr) ?: return Pair(0, null)
val start = match.groupValues[1].toLongOrNull() ?: 0L
val end = match.groupValues[2].toLongOrNull()
return Pair(start, end)
}
/**
* Returns the content length extracted from the [responseHeaders], or from
* the [task] headers
*/
fun getContentLength(responseHeaders: Map<String, List<String>>, task: Task): Long {
// if response provides contentLength, return it
val contentLength = responseHeaders["Content-Length"]?.get(0)?.toLongOrNull()
?: responseHeaders["content-length"]?.get(0)?.toLongOrNull()
?: -1L
if (contentLength != -1L) {
return contentLength
}
// try extracting it from Range header
val taskRangeHeader = task.headers["Range"] ?: task.headers["range"] ?: ""
val taskRange = parseRange(taskRangeHeader)
if (taskRange.second != null) {
val rangeLength = taskRange.second!! - taskRange.first + 1L
Log.d(TAG, "TaskId ${task.taskId} contentLength set to $rangeLength based on Range header")
return rangeLength
}
// try extracting it from a special "Known-Content-Length" header
val knownLength = (task.headers["Known-Content-Length"]?.toLongOrNull()
?: task.headers["known-content-length"]?.toLongOrNull()
?: -1)
if (knownLength != -1L) {
Log.d(
TAG,
"TaskId ${task.taskId} contentLength set to $knownLength based on Known-Content-Length header"
)
} else {
Log.d(TAG, "TaskId ${task.taskId} contentLength undetermined")
}
return knownLength
}
/**
* Return the path to the baseDir for this [baseDirectory], or null if path could not be reached
*
* Null only happens if external storage is requested but not available
*/
fun baseDirPath(context: Context, baseDirectory: BaseDirectory): String? {
val useExternalStorage = PreferenceManager.getDefaultSharedPreferences(context)
.getInt(BDPlugin.keyConfigUseExternalStorage, -1) == 0
val baseDirPath: String
if (!useExternalStorage) {
if (Build.VERSION.SDK_INT >= 26) {
baseDirPath = when (baseDirectory) {
BaseDirectory.applicationDocuments -> Path(
context.dataDir.path, "app_flutter"
).pathString
BaseDirectory.temporary -> context.cacheDir.path
BaseDirectory.applicationSupport -> context.filesDir.path
BaseDirectory.applicationLibrary -> Path(
context.filesDir.path, "Library"
).pathString
BaseDirectory.root -> ""
}
} else {
baseDirPath = when (baseDirectory) {
BaseDirectory.applicationDocuments -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
"${context.dataDir.path}/app_flutter"
} else {
"${context.applicationInfo.dataDir}/app_flutter"
}
BaseDirectory.temporary -> context.cacheDir.path
BaseDirectory.applicationSupport -> context.filesDir.path
BaseDirectory.applicationLibrary -> "${context.filesDir.path}/Library"
BaseDirectory.root -> ""
}
}
} else {
// external storage variant
val externalStorageDirectory = context.getExternalFilesDir(null)
val externalCacheDirectory = context.externalCacheDir
if (externalStorageDirectory == null || externalCacheDirectory == null) {
Log.e(TAG, "Could not access external storage")
return null
}
baseDirPath = when (baseDirectory) {
BaseDirectory.applicationDocuments -> externalStorageDirectory.path
BaseDirectory.temporary -> externalCacheDirectory.path
BaseDirectory.applicationSupport -> "${externalStorageDirectory.path}/Support"
BaseDirectory.applicationLibrary -> "${externalStorageDirectory.path}/Library"
BaseDirectory.root -> ""
}
}
return baseDirPath
}
fun getBasenameWithoutExtension(file: File): String {
val fileName = file.name
val extension = file.extension
return fileName.substringBeforeLast(".$extension")
}
/**
* Returns the Uri for the given [filePath], or null if not allowed/possible
*/
fun getUriFromFilePath(context: Context, filePath: String): String? {
try {
val contentUri = getUriForFile(
context,
context.packageName + ".com.bbflight.background_downloader.fileprovider",
File(filePath)
)
return contentUri.toString()
} catch (e: Exception) {
Log.i(BDPlugin.TAG, "Failed to get Uri for file $filePath: $e")
return null
}
}
/**
* Suggests a filename based on response headers and a URL. If none can be derived, returns ""
*
* @param responseHeaders The response headers map.
* @param url The URL the file would be downloaded from.
* @return A suggested filename, derived from the headers or the URL.
*/
fun suggestFilename(responseHeaders: Map<String, List<String>>, url: String): String {
try {
val disposition = (responseHeaders["Content-Disposition"]
?: responseHeaders["content-disposition"])?.get(0)
if (disposition != null) {
// Try filename*=UTF-8'language'"encodedFilename"
val encodedFilenameRegEx =
Regex("""filename\*=\s*([^']+)'([^']*)'"?([^"]+)"?""", RegexOption.IGNORE_CASE)
var match = encodedFilenameRegEx.find(disposition)
if (match != null && match.groupValues[1].isNotEmpty() && match.groupValues[3].isNotEmpty()) {
try {
val suggestedFilename = if (match.groupValues[1].uppercase() == "UTF-8") {
URLDecoder.decode(match.groupValues[3], "UTF-8")
} else {
match.groupValues[3]
}
return suggestedFilename
} catch (_: IllegalArgumentException) {
Log.d(
TAG,
"Could not interpret suggested filename (UTF-8 url encoded) ${match.groupValues[3]}"
)
}
}
// Try filename="filename"
val plainFilenameRegEx =
Regex("""filename=\s*"?([^"]+)"?.*$""", RegexOption.IGNORE_CASE)
match = plainFilenameRegEx.find(disposition)
if (match != null && match.groupValues[1].isNotEmpty()) {
return match.groupValues[1]
}
}
} catch (_: Throwable) {
}
// Try filename derived from last path segment of the url
try {
val uri = Uri.parse(url)
return getFilenameFromUri(uri)
} catch (_: Throwable) {
}
return "" // Default fallback
}
/**
* Returns the filename from the given [uri]
*
* Returns "" if not possible
*
* Slightly modified from lastPathSegment to account for possible subdirs in that last segment
* (e.g. '/primary:Documents/dog-drawing.jpg' will have 'Documents/dog-drawing.jpg' as last path
* segment, not 'dog-drawing.jpg')
*/
fun getFilenameFromUri(uri: Uri): String {
val lastPathSegment = uri.lastPathSegment
if (lastPathSegment != null) {
val lastSlashIndex = lastPathSegment.lastIndexOf("/")
val filename =
if (lastSlashIndex != -1) lastPathSegment.substring(lastSlashIndex + 1) else lastPathSegment
return filename
}
return ""
}
// Helper extension to filter out null values from Maps
fun <K, V> Map<K, V>?.filterNotNull(): Map<K, V> {
return this?.filter { (key, value) -> key != null && value != null } ?: emptyMap()
}
/**
* Simple Flutter result handler, completes the [completer] with true if the result of the
* method call is true, and false otherwise
*/
class FlutterBooleanResultHandler(private val completer: CompletableDeferred<Boolean>) :
MethodChannel.Result {
override fun success(result: Any?) {
completer.complete(result == true)
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
Log.i(BDPlugin.TAG, "Flutter result error $errorCode: $errorMessage")
completer.complete(false)
}
override fun notImplemented() {
Log.i(BDPlugin.TAG, "Flutter method not implemented")
completer.complete(false)
}
}
/**
* Flutter result handler, completes the [completer] with the result of the method call
*/
class FlutterResultHandler<T>(private val completer: CompletableDeferred<T?>) :
MethodChannel.Result {
override fun success(result: Any?) {
@Suppress("UNCHECKED_CAST")
val castedResult = result as? T?
completer.complete(castedResult)
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
Log.i(BDPlugin.TAG, "Flutter result error $errorCode: $errorMessage")
completer.complete(null)
}
override fun notImplemented() {
Log.i(BDPlugin.TAG, "Flutter method not implemented")
completer.complete(null)
}
}
@@ -0,0 +1,398 @@
package com.bbflight.background_downloader
import android.content.Context
import android.util.Log
import androidx.preference.PreferenceManager
import androidx.work.WorkManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.Date
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.PriorityBlockingQueue
import java.util.concurrent.atomic.AtomicInteger
import android.app.job.JobScheduler
import android.os.Build
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
/**
* Queue that holds [EnqueueItem] items before they are actually enqueued as a WorkManager job
*
* Configure [maxConcurrent], [maxConcurrentByHost] and [maxConcurrentByGroup] to limit which items
* can be enqueued simultaneously.
*
* Call:
* [add] to add an [EnqueueItem]
* [taskFinished] for all tasks that finish, so we may start a new one
* [cancelAllTasks] to empty the queue (sends status updates)
* [cancelTasksWithIds] to remove specific tasks (sends status updates)
* [allTasks] for a list of [Task] matching a group
* [taskForId] to get the task for a specific taskId
*/
class HoldingQueue(private val context: Context, private val workManager: WorkManager) {
var maxConcurrent: Int = 1 shl 20
var maxConcurrentByHost: Int = 1 shl 20
var maxConcurrentByGroup: Int = 1 shl 20
val hostByTaskId = ConcurrentHashMap<String, String>()
val enqueuedTaskIds: MutableSet<String> = ConcurrentHashMap.newKeySet<String>()
private val concurrent = AtomicInteger(0)
// so each slot is released only once. Guarded by [stateMutex]
private val taskIdsHoldingSlot = HashSet<String>()
private val concurrentByHost = ConcurrentHashMap<String, AtomicInteger>()
private val concurrentByGroup = ConcurrentHashMap<String, AtomicInteger>()
private val queue = PriorityBlockingQueue<EnqueueItem>()
private val taskFinishedQueue = Channel<Task>(capacity = Channel.UNLIMITED)
private var job: Job? = null // timer job
private val scope = CoroutineScope(Dispatchers.Default)
private val processSignal = Channel<Unit>(Channel.UNLIMITED)
val stateMutex = Mutex()
init {
// coroutine to process the queue
scope.launch {
for (signal in processSignal) { // signal comes from [advanceQueue]
stateMutex.withLock {
val mustWait = ArrayList<EnqueueItem>()
while (concurrent.get() < maxConcurrent && queue.isNotEmpty()) {
val item = queue.poll() ?: break
val host = item.task.host()
val group = item.task.group
// a task re-enqueued while its canceled run is still ending waits for
// that run to release its slot, so two runs of a task never overlap
if (!taskIdsHoldingSlot.contains(item.task.taskId) && (concurrentByHost[host]?.get()
?: 0) < maxConcurrentByHost && (concurrentByGroup[group]?.get()
?: 0) < maxConcurrentByGroup
) {
// enqueue this item after incrementing counters
concurrent.incrementAndGet()
concurrentByHost.computeIfAbsent(host) { AtomicInteger(0) }.incrementAndGet()
concurrentByGroup.computeIfAbsent(group) { AtomicInteger(0) }.incrementAndGet()
taskIdsHoldingSlot.add(item.task.taskId)
item.enqueue(afterDelayMillis = 0)
} else {
// this item has to wait
mustWait.add(item)
}
}
// add mustWait items back to the queue
queue.addAll(mustWait)
}
}
}
// coroutine to process 'taskFinished' messages
scope.launch {
for (task in taskFinishedQueue) {
executeTaskFinished(task)
}
}
// initial state calculation
scope.launch {
calculateState()
}
}
/**
* Add [EnqueueItem] [item] to the queue and advance the queue if possible
*/
suspend fun add(item: EnqueueItem) {
stateMutex.withLock {
queue.add(item)
enqueuedTaskIds.add(item.task.taskId)
NotificationService.registerEnqueue(
item,
success = true
) // for group notification count
}
advanceQueue()
}
/**
* Signals to the holdingQueue that a [task] has finished
*
* The processing is done async, so this method only adds
* the task to the processing queue
*/
suspend fun taskFinished(task: Task) {
taskFinishedQueue.send(task)
}
/**
* Removes all [EnqueueItem] where their taskId is in [taskIds], sends a
* [TaskStatus.canceled] update and returns a list of
* taskIds that were cancelled this way
*
* Because this is used in combination with the WorkManager tasks, use of this method
* requires the caller to acquire the [stateMutex]
*/
suspend fun cancelTasksWithIds(context: Context, taskIds: Iterable<String>): List<String> {
val removedTaskIds: List<String>
val toRemove = queue.filter { taskIds.contains(it.task.taskId) }
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
toRemove.forEach {
queue.remove(it)
TaskWorker.processStatusUpdate(it.task, TaskStatus.canceled, prefs, context = context)
if (it.notificationConfigJsonString != null) {
NotificationService.createUpdateNotificationWorker(
context,
bdJson.encodeToString(it.task),
it.notificationConfigJsonString,
TaskStatus.canceled.ordinal
)
}
Log.i(BDPlugin.TAG, "Canceled task with id ${it.task.taskId}")
}
removedTaskIds = toRemove.map { it.task.taskId }.toMutableList()
enqueuedTaskIds.removeAll(removedTaskIds.toSet())
removedTaskIds.forEach { hostByTaskId.remove(it) }
return removedTaskIds
}
/**
* Cancel (delete) all [EnqueueItem] matching [group], send a
* [TaskStatus.canceled] for each and return the number of items cancelled
*
* Because this is used in combination with the WorkManager tasks, use of this method
* requires the caller to acquire the [stateMutex]
*/
suspend fun cancelAllTasks(context: Context, group: String): Int {
val taskIds =
queue.filter { it.task.group == group }.map { it.task.taskId }.toMutableList()
cancelTasksWithIds(context, taskIds)
return taskIds.size
}
/**
* Return task matching [taskId], or null
*
* Because this is used in combination with the WorkManager tasks, use of this method
* requires the caller to acquire the [stateMutex]
*/
fun taskForId(taskId: String): Task? {
val tasks = queue.filter { it.task.taskId == taskId }.map { it.task }
if (tasks.isNotEmpty()) {
return tasks.first()
}
return null
}
/**
* Return list of [Task] for this [group]. If [group] is null, all tasks will be returned
*
* Because this is used in combination with the WorkManager tasks, use of this method
* requires the caller to acquire the [stateMutex]
*/
fun allTasks(group: String?): List<Task> {
return queue.filter { group == null || it.task.group == group }.map { it.task }
}
/**
* Advance the queue by signalling the queue processing coroutine
*
* Also restarts a timer that will advance the queue in 10 seconds, in case
* it dries up
*/
private fun advanceQueue() {
processSignal.trySend(Unit)
if (queue.isNotEmpty()) {
advanceQueueInFuture()
}
}
/**
* Calls [advanceQueue] in 10 seconds, thus ensuring it never dries up
*
* If called again before the timer fires, cancels the original timer and
* resets to 10 seconds
*/
private fun advanceQueueInFuture() {
job?.cancel()
job = scope.launch {
delay(10000)
advanceQueue()
}
}
/**
* Signals to the holdingQueue that a [task] has finished
*
* Adjusts the state variables and advances the queue
*/
private suspend fun executeTaskFinished(task: Task) {
hostByTaskId.remove(task.taskId)
enqueuedTaskIds.remove(task.taskId)
val host = task.host()
val group = task.group
stateMutex.withLock {
if (taskIdsHoldingSlot.remove(task.taskId)) {
concurrent.decrementAndGet()
concurrentByHost[host]?.decrementAndGet()
concurrentByGroup[group]?.decrementAndGet()
}
}
advanceQueue()
}
/**
* Calculates the [concurrent], [concurrentByHost] and [concurrentByGroup] values
*
* This is expensive, so is only done initially and when the [advanceQueueInFuture] timer
* fires
*/
private suspend fun calculateState() {
stateMutex.withLock {
val workInfos = workManager.getWorkInfosByTag(BDPlugin.TAG).get()
.filter { !it.state.isFinished }
var totalCount = 0
concurrentByHost.clear()
concurrentByGroup.clear()
taskIdsHoldingSlot.clear()
for (workInfo in workInfos) {
// update concurrentByHost
try {
val taskIdTag = workInfo.tags.first { it.startsWith("taskId=") }
val taskId = taskIdTag.substring(startIndex = 7)
if (!taskIdsHoldingSlot.add(taskId)) {
// a task holds one slot, however many unfinished works it has
continue
}
val host = hostByTaskId[taskId] ?: ""
if (!concurrentByHost.containsKey(host)) {
concurrentByHost[host] = AtomicInteger(0)
}
concurrentByHost[host]?.incrementAndGet()
} catch (_: NoSuchElementException) {
}
totalCount++
// update concurrentByGroup
try {
val groupTag = workInfo.tags.first { it.startsWith("group=") }
val group = groupTag.substring(startIndex = 6)
if (!concurrentByGroup.containsKey(group)) {
concurrentByGroup[group] = AtomicInteger(0)
}
concurrentByGroup[group]?.incrementAndGet()
} catch (_: NoSuchElementException) {
}
}
// JobScheduler (UIDT)
if (Build.VERSION.SDK_INT >= 34) {
val jobScheduler = context.getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler
val jobs = jobScheduler.allPendingJobs
for (job in jobs) {
if (job.service.className == UIDTJobService::class.java.name) {
val taskJson = job.extras.getString(TaskWorker.keyTask)
if (taskJson != null) {
try {
val task = bdJson.decodeFromString<Task>(taskJson)
if (!taskIdsHoldingSlot.add(task.taskId)) {
continue
}
totalCount++
val host = task.host()
val group = task.group
if (!concurrentByHost.containsKey(host)) {
concurrentByHost[host] = AtomicInteger(0)
}
concurrentByHost[host]?.incrementAndGet()
if (!concurrentByGroup.containsKey(group)) {
concurrentByGroup[group] = AtomicInteger(0)
}
concurrentByGroup[group]?.incrementAndGet()
} catch (e: Exception) {
Log.w(BDPlugin.TAG, "Could not decode task from job info: $e")
}
}
}
}
}
concurrent.set(totalCount)
}
}
}
/**
* Holds data related to enqueueing a task
*
* Used in the context of changing the RequireWiFi setting (where tasks need to be re-enqueued)
* and in the context of the [HoldingQueue]
*/
class EnqueueItem(
val context: Context,
val task: Task,
val notificationConfigJsonString: String?,
private val resumeData: ResumeData? = null,
private val plugin: BDPlugin? = null,
private val created: Date = Date()
) : Comparable<EnqueueItem> {
/** Execute the re-enqueue after an appropriate delay */
suspend fun enqueue(afterDelayMillis: Int = 1000) {
delayUntil(afterDelayMillis)
if (!BDPlugin.doEnqueue(
context = context,
task = task,
notificationConfigJsonString = notificationConfigJsonString,
resumeData = resumeData,
plugin = plugin
)
) {
Log.w(BDPlugin.TAG, "Delayed or retried enqueue failed for taskId ${task.taskId}")
TaskWorker.processStatusUpdate(
task, TaskStatus.failed, PreferenceManager.getDefaultSharedPreferences(context),
taskException = TaskException(
type = ExceptionType.general,
description = "Delayed or retried enqueue failed"
), context = context
)
BDPlugin.holdingQueue?.taskFinished(task)
NotificationService.registerEnqueue(this, success = false)
}
delay(20)
}
/**
* Re-enqueue after [afterDelayMillis] since creation, through the [HoldingQueue] if there is
* one, so the task waits for a slot like any other
*/
suspend fun reEnqueue(afterDelayMillis: Int = 1000) {
val holdingQueue = BDPlugin.holdingQueue
if (holdingQueue == null) {
enqueue(afterDelayMillis)
} else {
delayUntil(afterDelayMillis)
Log.i(BDPlugin.TAG, "Re-enqueueing task with id ${task.taskId} to the HoldingQueue")
holdingQueue.add(this)
}
}
private suspend fun delayUntil(afterDelayMillis: Int) {
val timeSinceCreatedMillis = Date().time - created.time
if (timeSinceCreatedMillis < afterDelayMillis) {
delay(afterDelayMillis - timeSinceCreatedMillis)
}
}
/**
* Items are sorted based on task priority first, then by task creation time
*/
override fun compareTo(other: EnqueueItem) = compareValuesBy(this, other,
{ it.task.priority },
{ it.task.creationTime })
}
@@ -0,0 +1,607 @@
@file:Suppress("EnumEntryName")
package com.bbflight.background_downloader
import android.content.Context
import android.net.Uri
import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.descriptors.element
import kotlinx.serialization.encoding.CompositeDecoder
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.encoding.decodeStructure
import kotlinx.serialization.encoding.encodeStructure
import kotlinx.serialization.json.Json
import java.io.File
import java.net.MalformedURLException
import java.net.URL
import kotlin.math.absoluteValue
import kotlin.random.Random
val bdJson = Json {
ignoreUnknownKeys = true
isLenient = true
}
/// Base directory in which files will be stored, based on their relative
/// path.
///
/// These correspond to the directories provided by the path_provider package
@Serializable(with = BaseDirectorySerializer::class)
enum class BaseDirectory {
applicationDocuments, // getApplicationDocumentsDirectory()
temporary, // getTemporaryDirectory()
applicationSupport, // getApplicationSupportDirectory()
applicationLibrary, // getApplicationSupportDirectory() subdir "Library"
root // system root directory
}
class BaseDirectorySerializer : EnumAsIntSerializer<BaseDirectory>(
"BaseDirectory",
{ it.ordinal },
{ v -> BaseDirectory.entries.first { it.ordinal == v } }
)
/// Type of updates requested for a group of tasks
@Serializable(with = UpdatesSerializer::class)
enum class Updates {
none, // no status or progress updates
status, // only calls upon change in DownloadTaskStatus
progress, // only calls for progress
statusAndProgress // calls also for progress along the way
}
class UpdatesSerializer : EnumAsIntSerializer<Updates>(
"Updates",
{ it.ordinal },
{ v -> Updates.entries.first { it.ordinal == v } }
)
/**
* Holds various options related to the task that are not included in the
* task's properties, as they are rare
*/
@OptIn(InternalSerializationApi::class)
@Serializable
class TaskOptions(
private val beforeTaskStartRawHandle: Long?,
private val onTaskStartRawHandle: Long?,
private val onTaskFinishedRawHandle: Long?,
var auth: Auth?
) {
fun hasBeforeStartCallback(): Boolean = beforeTaskStartRawHandle != null
fun hasOnStartCallback(): Boolean = onTaskStartRawHandle != null
fun hasOnFinishCallback(): Boolean = onTaskFinishedRawHandle != null
}
/**
* The Dart side Task
*
* A blend of UploadTask, DownloadTask and ParallelDownloadTask with [taskType] indicating what kind
* of task this is
*/
@Suppress("SameParameterValue")
@OptIn(InternalSerializationApi::class)
@Serializable
class Task(
val taskId: String = "${Random.nextInt().absoluteValue}",
val url: String,
val urls: List<String> = listOf(),
val filename: String,
val headers: Map<String, String>,
val httpRequestMethod: String = "GET",
val chunks: Int = 1,
val post: String? = null,
val postIsBinary: Boolean = false,
val fileField: String = "",
val mimeType: String = "",
val fields: Map<String, String> = mapOf(),
val directory: String = "",
val baseDirectory: BaseDirectory,
val group: String,
val updates: Updates,
val requiresWiFi: Boolean = false,
val retries: Int = 0,
var retriesRemaining: Int = 0,
val allowPause: Boolean = false,
val priority: Int = 5,
val metaData: String = "",
val displayName: String = "",
val creationTime: Long = System.currentTimeMillis(), // untouched, so kept as integer on Android side
val options: TaskOptions? = null,
val transferHints: List<Int>? = null,
val stallTimeout: Long? = null,
val taskType: String
) {
/**
* Returns a copy of the [Task] with optional changes to specific fields
*/
fun copyWith(
taskId: String? = null,
url: String? = null,
urls: List<String>? = null,
filename: String? = null,
headers: Map<String, String>? = null,
httpRequestMethod: String? = null,
chunks: Int? = null,
post: String? = null,
fileField: String? = null,
mimeType: String? = null,
fields: Map<String, String>? = null,
directory: String? = null,
baseDirectory: BaseDirectory? = null,
group: String? = null,
updates: Updates? = null,
requiresWiFi: Boolean? = null,
retries: Int? = null,
retriesRemaining: Int? = null,
allowPause: Boolean? = null,
priority: Int? = null,
metaData: String? = null,
displayName: String? = null,
creationTime: Long? = null,
options: TaskOptions? = null,
transferHints: List<Int>? = null,
stallTimeout: Long? = null,
taskType: String? = null
): Task {
return Task(
taskId = taskId ?: this.taskId,
url = url ?: this.url,
urls = urls ?: this.urls,
filename = filename ?: this.filename,
headers = headers ?: this.headers,
httpRequestMethod = httpRequestMethod ?: this.httpRequestMethod,
chunks = chunks ?: this.chunks,
post = post ?: this.post,
postIsBinary = this.postIsBinary,
fileField = fileField ?: this.fileField,
mimeType = mimeType ?: this.mimeType,
fields = fields ?: this.fields,
directory = directory ?: this.directory,
baseDirectory = baseDirectory ?: this.baseDirectory,
group = group ?: this.group,
updates = updates ?: this.updates,
requiresWiFi = requiresWiFi ?: this.requiresWiFi,
retries = retries ?: this.retries,
retriesRemaining = retriesRemaining ?: this.retriesRemaining,
allowPause = allowPause ?: this.allowPause,
priority = priority ?: this.priority,
metaData = metaData ?: this.metaData,
displayName = displayName ?: this.displayName,
creationTime = creationTime ?: this.creationTime,
options = options ?: this.options,
transferHints = transferHints ?: this.transferHints,
stallTimeout = stallTimeout ?: this.stallTimeout,
taskType = taskType ?: this.taskType
)
}
/** True if this task expects to provide progress updates */
fun providesProgressUpdates(): Boolean {
return updates == Updates.progress ||
updates == Updates.statusAndProgress
}
/** True if this task expects to provide status updates */
fun providesStatusUpdates(): Boolean {
return updates == Updates.status ||
updates == Updates.statusAndProgress
}
/** True if this task is a DownloadTask, UriDownloadTask or ParallelDownloadTask */
fun isDownloadTask(): Boolean {
return taskType == "DownloadTask" || taskType == "UriDownloadTask" || taskType == "ParallelDownloadTask"
}
/** True if this task is an UploadTask or MultiUploadTask */
fun isUploadTask(): Boolean {
return taskType == "UploadTask" || taskType == "UriUploadTask" || taskType == "MultiUploadTask"
}
/** True if this task is a ParallelDownloadTask */
fun isParallelDownloadTask(): Boolean {
return taskType == "ParallelDownloadTask"
}
/** True if this task is a MultiUploadTask */
private fun isMultiUploadTask(): Boolean {
return taskType == "MultiUploadTask"
}
/** True if this task is a DataTask */
fun isDataTask(): Boolean {
return taskType == "DataTask"
}
/**
* Returns full path (String) to the file,
* based on [withFilename] or the [Task.filename] (default)
*
* If the task is a MultiUploadTask and no [withFilename] is given,
* returns the empty string, as there is no single path that can be
* returned.
*/
fun filePath(context: Context, withFilename: String? = null): String {
if (isMultiUploadTask() && withFilename == null) {
return ""
}
val filenameToUse = withFilename ?: filename
val baseDirPath = baseDirPath(context, baseDirectory)
?: throw IllegalStateException("External storage is requested but not available")
return if (directory.isEmpty()) "$baseDirPath/${filenameToUse}" else
"$baseDirPath/${directory}/${filenameToUse}"
}
/**
* Returns a copy of the task with the [Task.filename] property changed
* to the filename suggested by the server, or derived from the url, or
* unchanged.
*
* If [unique] is true, the filename is guaranteed not to already exist. This
* is accomplished by adding a suffix to the suggested filename with a number,
* e.g. "data (2).txt"
*
* The server-suggested filename is obtained from the [responseHeaders] entry
* "Content-Disposition"
*/
fun withSuggestedFilenameFromResponseHeaders(
context: Context,
responseHeaders: MutableMap<String, MutableList<String>>,
unique: Boolean = false
): Task {
// Returns [Task] with a filename similar to the one
// supplied, but unused.
//
// If [unique], filename will sequence up in "filename (8).txt" format,
// otherwise returns the [task]
fun taskWithUniqueFilename(task: Task, unique: Boolean): Task {
if (!unique) {
return task
}
val sequenceRegEx = Regex("""\((\d+)\)\.?[^.]*$""")
val extensionRegEx = Regex("""\.[^.]*$""")
var newTask = task
var filePath = task.filePath(context)
var exists = File(filePath).exists()
while (exists) {
val extension = extensionRegEx.find(newTask.filename)?.value ?: ""
val match = sequenceRegEx.find(newTask.filename)
val newSequence = (match?.groupValues?.get(1)?.toIntOrNull() ?: 0) + 1
val newFilename = when (match) {
null -> "${getBasenameWithoutExtension(File(newTask.filename))} ($newSequence)$extension"
else -> "${
newTask.filename.substring(
0,
match.range.first - 1
)
} ($newSequence)$extension"
}
newTask = newTask.copyWith(filename = newFilename)
filePath = newTask.filePath(context)
exists = File(filePath).exists()
}
return newTask
}
// start of main method
val suggestedFilename = suggestFilename(responseHeaders, url)
return taskWithUniqueFilename(
this.copyWith(filename = if (suggestedFilename.isNotEmpty()) suggestedFilename else this.filename),
unique
)
}
/**
* Returns a list of fileData elements, one for each file to upload.
* Each element is a triple containing fileField, full filePath or Uri, mimeType
*
* The lists are stored in the similarly named String fields as a JSON list,
* with each list the same length. For the filenames list, if a filename refers
* to a file that exists (i.e. it is a full path) then that is the filePath used,
* otherwise the filename is appended to the [Task.baseDirectory] and [Task.directory]
* to form a full file path. If instead the filePath is given as a Uri string, then
* that string is used
*/
fun extractFilesData(context: Context): List<Triple<String, String, String>> {
val fileFields = bdJson.decodeFromString<List<String>>(fileField)
val filenames = bdJson.decodeFromString<List<String>>(filename)
val mimeTypes = bdJson.decodeFromString<List<String>>(mimeType)
val result = ArrayList<Triple<String, String, String>>()
for (i in fileFields.indices) {
val filenameOrPathOrUriString = filenames[i]
val maybeUri = Uri.parse(filenameOrPathOrUriString)
if (maybeUri.scheme == "content" || maybeUri.scheme == "file") {
// for Uri, keep the Uri string
result.add(Triple(fileFields[i], filenameOrPathOrUriString, mimeTypes[i]))
} else {
// For filePath, check if full path or construct it
if (File(filenameOrPathOrUriString).exists()) {
result.add(Triple(fileFields[i], filenameOrPathOrUriString, mimeTypes[i]))
} else {
result.add(
Triple(
fileFields[i],
filePath(context, withFilename = filenameOrPathOrUriString),
mimeTypes[i]
)
)
}
}
}
return result
}
/**
* Return the hos of the url in this task
*/
fun host() = try {
URL(url).host
} catch (e: MalformedURLException) {
""
}
/** True if this task has a filename
* False if it has only a URI, or if the filename is '?'
*/
fun hasFilename(): Boolean {
val filename = UriUtils.unpack(filename).first
return filename != null && filename != "?"
}
override fun toString(): String {
return "Task(taskId='$taskId', url='$url', filename='$filename', headers=$headers, httpRequestMethod=$httpRequestMethod, post=$post, fileField='$fileField', mimeType='$mimeType', fields=$fields, directory='$directory', baseDirectory=$baseDirectory, group='$group', updates=$updates, requiresWiFi=$requiresWiFi, retries=$retries, retriesRemaining=$retriesRemaining, allowPause=$allowPause, priority=$priority, metaData='$metaData', displayName='$displayName', creationTime=$creationTime, options=$options, transferHints=$transferHints, stallTimeout=$stallTimeout, taskType='$taskType')"
}
/**
* An equality test on a [Task] is a test on the [taskId] only
*/
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Task
return taskId == other.taskId
}
override fun hashCode(): Int {
return taskId.hashCode()
}
}
/** Defines a set of possible states which a [Task] can be in.
*
* Must match the Dart equivalent enum, as value are passed as ordinal/index integer
*/
@Serializable(with = TaskStatusSerializer::class)
enum class TaskStatus {
enqueued,
running,
complete,
notFound,
failed,
canceled,
waitingToRetry,
paused;
private fun isNotFinalState(): Boolean {
return this == enqueued || this == running || this == waitingToRetry || this == paused
}
fun isFinalState(): Boolean {
return !isNotFinalState()
}
}
class TaskStatusSerializer : EnumAsIntSerializer<TaskStatus>(
"TaskStatus",
{ it.ordinal },
{ v -> TaskStatus.entries.first { it.ordinal == v } }
)
/** Holds data associated with a task status update
*
* Stored locally in JSON format if posting on background channel fails,
* otherwise getter [argList] is used to extract the list of arguments
* to be passed to the background channel as arguments to the "statusUpdate" method invocation
*/
@Serializable
@OptIn(InternalSerializationApi::class)
data class TaskStatusUpdate(
val task: Task,
val taskStatus: TaskStatus, // note Dart field name is 'status'
val exception: TaskException?,
val responseBody: String?,
val responseStatusCode: Int?,
val responseHeaders: Map<String, String>?,
val mimeType: String?,
val charSet: String?
) {
/**
* Returns the list of arguments that represents this [TaskStatusUpdate] when posting
* a "statusUpdate" on the backgroundChannel
*
* Included data differs between failed tasks and other status updates
*/
val argList
get() = if (taskStatus == TaskStatus.failed) mutableListOf(
taskStatus.ordinal,
exception?.type?.typeString,
exception?.description,
exception?.httpResponseCode,
responseBody
) else mutableListOf(
taskStatus.ordinal,
if (taskStatus.isFinalState()) responseBody else null,
if (taskStatus.isFinalState()) responseHeaders else null,
if (taskStatus == TaskStatus.complete || taskStatus == TaskStatus.notFound) responseStatusCode else null,
if (taskStatus.isFinalState()) mimeType else null,
if (taskStatus.isFinalState()) charSet else null
)
override fun toString(): String {
return "TaskStatusUpdate(task=$task, taskStatus=$taskStatus, exception=$exception, responseBody=$responseBody, responseStatusCode=$responseStatusCode, responseHeaders=$responseHeaders, mimeType=$mimeType, charSet=$charSet)"
}
}
/** Holds data associated with a task progress update, for local storage */
@Serializable
@OptIn(InternalSerializationApi::class)
data class TaskProgressUpdate(val task: Task, val progress: Double, val expectedFileSize: Long)
/// Holds data associated with a resume
@Serializable
@OptIn(InternalSerializationApi::class)
data class ResumeData(
val task: Task,
val data: String,
val requiredStartByte: Long,
val eTag: String?
)
/**
* The type of a [TaskException]
*
* Exceptions are handled differently on the Kotlin side because they are only a vehicle to message
* to the Flutter side. An exception class hierarchy is therefore not required in Kotlin, and the
* single [TaskException] class has a field for the [TaskException.type] of exception, as well as all possible
* exception fields.
* The [TaskException.type] (as a String using the enum's [ExceptionType.typeString]) is used on the
* Flutter side to create the appropriate Exception subclass.
*/
enum class ExceptionType(val typeString: String) {
/// General error
general("TaskException"),
/// Could not save or find file, or create directory
fileSystem("TaskFileSystemException"),
/// URL incorrect
url("TaskUrlException"),
/// Connection problem, eg host not found, timeout
connection("TaskConnectionException"),
/// Could not resume or pause task
resume("TaskResumeException"),
/// Invalid HTTP response
httpResponse("TaskHttpException")
}
/**
* Contains error information associated with a failed [Task]
*
* The [type] categorizes the exception, used to create the appropriate subclass on the Flutter side
* The [httpResponseCode] is only valid if >0 and may offer details about the
* nature of the error
* The [description] is typically taken from the platform-generated
* error message, or from the plugin. The localization is undefined
*/
@Serializable(with = TaskExceptionSerializer::class)
class TaskException(
val type: ExceptionType,
val httpResponseCode: Int = -1,
val description: String = ""
)
object TaskExceptionSerializer : KSerializer<TaskException> {
override val descriptor = buildClassSerialDescriptor("TaskException") {
element<String>("type")
element<Int>("httpResponseCode")
element<String>("description")
}
override fun serialize(encoder: Encoder, value: TaskException) {
encoder.encodeStructure(descriptor) {
encodeStringElement(
descriptor, 0, when (value.type) {
ExceptionType.fileSystem -> "TaskFileSystemException"
ExceptionType.url -> "TaskUrlException"
ExceptionType.connection -> "TaskConnectionException"
ExceptionType.resume -> "TaskResumeException"
ExceptionType.httpResponse -> "TaskHttpException"
else -> "TaskException"
}
)
encodeIntElement(descriptor, 1, value.httpResponseCode)
encodeStringElement(descriptor, 2, value.description)
}
}
override fun deserialize(decoder: Decoder): TaskException {
return decoder.decodeStructure(descriptor) {
var type: ExceptionType? = null
var httpResponseCode = -1
var description = ""
while (true) {
when (val index = decodeElementIndex(descriptor)) {
0 -> type = when (decodeStringElement(descriptor, 0)) {
"TaskFileSystemException" -> ExceptionType.fileSystem
"TaskUrlException" -> ExceptionType.url
"TaskConnectionException" -> ExceptionType.connection
"TaskResumeException" -> ExceptionType.resume
"TaskHttpException" -> ExceptionType.httpResponse
else -> ExceptionType.general
}
1 -> httpResponseCode = decodeIntElement(descriptor, 1)
2 -> description = decodeStringElement(descriptor, 2)
CompositeDecoder.DECODE_DONE -> break
else -> error("Unexpected index: $index")
}
}
TaskException(type!!, httpResponseCode, description)
}
}
}
/**
* Wifi requirement modes at the application level
*/
enum class RequireWiFi {
asSetByTask,
forAllTasks,
forNoTasks
}
/**
* Serializer for enums, such that they are encoded as an Int representing
* the ordinal (index) of the value, instead of the String representation of
* the value.
*/
open class EnumAsIntSerializer<T : Enum<*>>(
serialName: String,
val serialize: (v: T) -> Int,
val deserialize: (v: Int) -> T
) : KSerializer<T> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor(serialName, PrimitiveKind.INT)
override fun serialize(encoder: Encoder, value: T) {
encoder.encodeInt(serialize(value))
}
override fun deserialize(decoder: Decoder): T {
val v = decoder.decodeInt()
return deserialize(v)
}
}
@@ -0,0 +1,39 @@
package com.bbflight.background_downloader
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.util.Log
import androidx.core.content.FileProvider
import androidx.core.content.FileProvider.getUriForFile
import java.io.File
class OpenFileProvider : FileProvider(R.xml.bgd_file_paths)
/**
* Opens the file at the given path or URI in the default file manager app.
*/
fun doOpenFile(activity: Activity, filePathOrUriString: String, mimeType: String): Boolean {
val uri = Uri.parse(filePathOrUriString)
val intent = Intent(Intent.ACTION_VIEW)
try {
val contentUri =
if (uri.scheme == "content" || uri.scheme == "file") uri
else
getUriForFile(
activity,
activity.packageName + ".com.bbflight.background_downloader.fileprovider",
File(filePathOrUriString)
)
intent.setDataAndType(contentUri, mimeType)
intent.addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
activity.startActivity(intent)
return true
} catch (e: Exception) {
Log.i(BDPlugin.TAG, "Failed to open file $filePathOrUriString: $e")
}
return false
}
@@ -0,0 +1,484 @@
package com.bbflight.background_downloader
import android.util.Log
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.encodeToString
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.net.HttpURLConnection
import kotlin.math.absoluteValue
import kotlin.math.min
import kotlin.random.Random
class ParallelDownloadTaskRunner(context: TaskJobContext) : TaskRunner(context) {
private var parallelDownloadContentLength = -1L
private var chunks: List<Chunk> = mutableListOf()
private var chunksJsonString = ""
private var parallelTaskStatusUpdateCompleter = CompletableDeferred<TaskStatus>()
private var lastTaskStatus = TaskStatus.enqueued
override fun determineIfResume(): Boolean {
chunksJsonString = context.getInputString(keyResumeDataData) ?: ""
return chunksJsonString.isNotEmpty()
}
override suspend fun connectAndProcess(connection: HttpURLConnection): TaskStatus {
// Register this runner with BDPlugin so it can receive chunk updates
BDPlugin.parallelDownloadTaskWorkers[task.taskId] = this
canRunInForeground = true
runInForeground = notificationConfig?.running != null
connection.requestMethod = "HEAD"
val result = super.connectAndProcess(connection)
BDPlugin.parallelDownloadTaskWorkers.remove(task.taskId)
return result
}
override suspend fun process(
connection: HttpURLConnection,
): TaskStatus {
return withContext(Dispatchers.Default) {
var enqueueJob: Job? = null
var testerJob: Job? = null
try {
enqueueJob = launch {
if (!isResume) {
// start the download by creating [Chunk]s and enqueuing chunk tasks
if (connection.responseCode in listOf(200, 201, 202, 203, 204, 205, 206)) {
// if no filename is set, get from headers or url, update task
if (!task.hasFilename()) {
task = task.withSuggestedFilenameFromResponseHeaders(
context.appContext,
connection.headerFields,
unique = true
)
Log.d(
TAG,
"Suggested filename for taskId ${task.taskId}: ${task.filename}"
)
}
extractResponseHeaders(connection.headerFields)
responseStatusCode = connection.responseCode
extractContentType(connection.headerFields)
chunks = createChunks(task, connection.headerFields)
for (chunk in chunks) {
// Ask Dart side to enqueue the child task. Updates related to the child
// will be sent to this (parent) task (the child's metaData is the parent taskId).
postOnBackgroundChannel(
"enqueueChild",
task,
bdJson.encodeToString<Task>(chunk.task),
onFail =
{
// failed to enqueue child
cancelAllChunkTasks()
Log.i(
TAG,
"Failed to enqueue chunk task with id ${chunk.task.taskId}"
)
taskException = TaskException(
ExceptionType.general,
description = "Failed to enqueue chunk task with id ${chunk.task.taskId}"
)
parallelTaskStatusUpdateCompleter.complete(TaskStatus.failed)
})
}
} else {
// HTTP response code not OK
Log.i(
TAG,
"Response code ${connection.responseCode} for taskId ${task.taskId}"
)
val errorContent = responseErrorContent(connection)
taskException = TaskException(
ExceptionType.httpResponse,
httpResponseCode = connection.responseCode,
description = if (errorContent?.isNotEmpty() == true) errorContent else connection.responseMessage
)
if (connection.responseCode == 404) {
responseBody = errorContent
parallelTaskStatusUpdateCompleter.complete(TaskStatus.notFound)
} else {
parallelTaskStatusUpdateCompleter.complete(TaskStatus.failed)
}
}
} else {
// resume: reconstruct [chunks] and wait for all chunk tasks to complete.
// The Dart side will resume each chunk task, so we just wait for the
// completer to complete
chunks = bdJson.decodeFromString(chunksJsonString)
parallelDownloadContentLength = chunks.fold(0L) { acc, chunk ->
acc + chunk.toByte - chunk.fromByte + 1
}
}
}
testerJob = launch {
while (isActive) {
// check if task is stopped (canceled), paused or timed out
if (context.isTaskStopped) {
withContext(NonCancellable) {
cancelAllChunkTasks()
parallelTaskStatusUpdateCompleter.complete(TaskStatus.failed)
}
break
}
// 'pause' is signalled by adding the taskId to a static list
if (BDPlugin.pausedTaskIds.remove(task.taskId)) {
pauseAllChunkTasks()
postOnBackgroundChannel(
"resumeData",
task,
bdJson.encodeToString<List<Chunk>>(chunks)
)
parallelTaskStatusUpdateCompleter.complete(TaskStatus.paused)
break
}
delay(200)
}
}
// wait for all chunks to finish
return@withContext parallelTaskStatusUpdateCompleter.await()
} catch (e: Exception) {
Log.i(TAG, "Exception for taskId ${task.taskId}: $e")
taskException = TaskException(
ExceptionType.general,
description = "Exception for taskId ${task.taskId}: $e"
)
return@withContext TaskStatus.failed
} finally {
enqueueJob?.cancelAndJoin()
testerJob?.cancelAndJoin()
}
}
}
/**
* Process incoming [status] update for a chunk with [chunkTaskId]
*
* If status is failure, may include [taskException] and [responseBody]
*/
suspend fun chunkStatusUpdate(
chunkTaskId: String,
status: TaskStatus,
taskException: TaskException?,
responseBody: String?
) {
val chunk = chunks.firstOrNull { it.task.taskId == chunkTaskId }
?: return // chunk is not part of this parent task
val chunkTask = chunk.task
// first check for fail -> retry
if (status == TaskStatus.failed && chunkTask.retriesRemaining > 0) {
chunkTask.retriesRemaining--
val waitTimeSeconds = 2 shl (min(chunkTask.retries - chunkTask.retriesRemaining - 1, 8))
Log.i(
TAG,
"Chunk task with taskId ${chunkTask.taskId} failed, waiting $waitTimeSeconds seconds before retrying. ${chunkTask.retriesRemaining} retries remaining"
)
delay(waitTimeSeconds * 1000L)
postOnBackgroundChannel(
"enqueueChild",
task,
bdJson.encodeToString<Task>(chunk.task),
onFail = {
chunkStatusUpdate(chunkTaskId, TaskStatus.failed, taskException, responseBody)
})
} else {
// no retry
val newStatusUpdate = updateChunkStatus(chunk, status)
if (newStatusUpdate != null) {
when (newStatusUpdate) {
TaskStatus.complete -> {
val stitchResult = stitchChunks()
parallelTaskStatusUpdateCompleter.complete(stitchResult)
}
TaskStatus.failed -> {
this.taskException = taskException
cancelAllChunkTasks()
parallelTaskStatusUpdateCompleter.complete(TaskStatus.failed)
}
TaskStatus.notFound -> {
this.taskException = taskException
this.responseBody = responseBody
cancelAllChunkTasks()
parallelTaskStatusUpdateCompleter.complete(TaskStatus.notFound)
}
else -> {
// ignore all other status updates
}
}
}
}
}
/**
* Process incoming [progress] update for a chunk with [chunkTaskId].
*
* Recalculates overall task progress (based on the average of the chunk
* task progress) and sends an update to the Dart side and updates the
* notification at the appropriate interval
*/
suspend fun chunkProgressUpdate(chunkTaskId: String, progress: Double) {
val chunk = chunks.firstOrNull { it.task.taskId == chunkTaskId }
?: return // chunk is not part of this parent task
if (progress > 0 && progress < 1) {
val parentProgress = updateChunkProgress(chunk, progress)
if (shouldSendProgressUpdate(parentProgress, System.currentTimeMillis())) {
updateProgressAndNotify(parentProgress, parallelDownloadContentLength, task)
}
}
}
/**
* Update the status for this chunk, and return the status for the parent task
* as derived from the sum of the child tasks, or null if undefined
*
* The updates are received from the NativeDownloader, which intercepts
* status updates for the chunkGroup
*/
private fun updateChunkStatus(chunk: Chunk, status: TaskStatus): TaskStatus? {
chunk.status = status
val parentStatus = parentTaskStatus()
if (parentStatus != null && parentStatus != lastTaskStatus) {
lastTaskStatus = parentStatus
return parentStatus
}
return null
}
/**
* Returns the [TaskStatus] for the parent of this chunk, as derived from
* the 'sum' of the child tasks, or null if undetermined
*
* The updates are received from the NativeDownloader, which intercepts
* status updates for the chunkGroup
*/
private fun parentTaskStatus(): TaskStatus? {
val failed = chunks.firstOrNull { it.status == TaskStatus.failed }
if (failed != null) {
return TaskStatus.failed
}
val notFound = chunks.firstOrNull { it.status == TaskStatus.notFound }
if (notFound != null) {
return TaskStatus.notFound
}
val allComplete = chunks.all { it.status == TaskStatus.complete }
if (allComplete) {
return TaskStatus.complete
}
return null
}
/**
* Updates the chunk's progress and returns the average progress
*
* Returns the [progress] for the parent of this chunk, as derived from
* its children by averaging
*/
private fun updateChunkProgress(chunk: Chunk, progress: Double): Double {
chunk.progress = progress
return chunks.fold(
0.0
) { previousValue, c ->
previousValue + c.progress
} / chunks.size
}
/**
* Cancel the tasks associated with each chunk
*
* Accomplished by sending list of taskIds to cancel to the NativeDownloader
*/
suspend fun cancelAllChunkTasks() {
postOnBackgroundChannel(
"cancelTasksWithId",
task,
bdJson.encodeToString<List<String>>(chunks.map { it.task.taskId })
)
}
/**
* Pause the tasks associated with each chunk
*
* Accomplished by sending a json decoded list of tasks to cancel
* to the NativeDownloader
*/
private suspend fun pauseAllChunkTasks() {
postOnBackgroundChannel("pauseTasks", task, bdJson.encodeToString<List<Task>>(chunks.map { it.task }))
}
/**
* Stitch all chunks together into one file
*/
private suspend fun stitchChunks(): TaskStatus {
return withContext(Dispatchers.IO) {
try {
val dataBuffer = ByteArray(bufferSize)
var numBytes: Int
val outFile = File(task.filePath(context.appContext))
if (outFile.exists()) {
outFile.delete()
}
FileOutputStream(outFile).use { outStream ->
for (chunk in chunks.sortedBy { it.fromByte }) {
val inFile = File(chunk.task.filePath(context.appContext))
if (!inFile.exists()) {
throw java.io.FileNotFoundException("Missing chunk file: ${inFile.path}")
}
FileInputStream(inFile).use { inStream ->
while (inStream.read(
dataBuffer, 0,
bufferSize
)
.also { numBytes = it } != -1
) {
outStream.write(dataBuffer, 0, numBytes)
}
}
}
outStream.flush()
}
} catch (e: Exception) {
Log.i(TAG, "Error stitching chunks: $e\n${e.stackTraceToString()}")
taskException = TaskException(
ExceptionType.fileSystem,
description = "Error stitching chunks: $e"
)
return@withContext TaskStatus.failed
} finally {
for (chunk in chunks) {
try {
val file = File(chunk.task.filePath(context.appContext))
file.delete()
} catch (_: Exception) {
// ignore
}
}
}
return@withContext TaskStatus.complete
}
}
/**
* Returns a list of chunk information for this task, and sets
* [parallelDownloadContentLength] to the total length of the download
*
* Throws a IllegalStateException if any information is missing, which should lead
* to a failure of the ParallelDownloadTask
*/
private fun createChunks(
task: Task,
headers: MutableMap<String, MutableList<String>>
): List<Chunk> {
val numChunks = task.urls.size * task.chunks
try {
val contentLength = getContentLength(headers, task)
if (contentLength <= 0) {
throw IllegalStateException("Server does not provide content length - cannot chunk download. If you know the length, set Range or Known-Content-Length header")
}
parallelDownloadContentLength = contentLength
context.updateEstimatedNetworkBytes(contentLength, 0L)
try {
headers.entries
.first { (it.key == "accept-ranges" || it.key == "Accept-Ranges") && it.value.first() == "bytes" }
} catch (_: NoSuchElementException) {
throw IllegalStateException("Server does not accept ranges - cannot chunk download")
}
val chunkSize = (contentLength / numChunks) + 1
return (0 until numChunks).map { i ->
Chunk(
parentTask = task,
url = task.urls[i % task.urls.size],
filename = "com.bbflight.background_downloader.${Random.nextInt().absoluteValue}",
from = i * chunkSize,
to = min(i * chunkSize + chunkSize - 1, contentLength - 1)
)
}
} catch (_: NoSuchElementException) {
throw IllegalStateException("Server does not provide content length - cannot chunk download. If you know the length, set Range or Known-Content-Length header")
}
}
}
@Suppress("unused")
@OptIn(InternalSerializationApi::class)
@Serializable
class Chunk private constructor(
private val parentTaskId: String,
private val url: String,
private val filename: String,
val task: Task,
val fromByte: Long,
val toByte: Long,
var status: TaskStatus = TaskStatus.enqueued,
var progress: Double = 0.0
) {
companion object {
/**
* Returns [Updates] that is based on the [parentTask]
*/
fun updatesBasedOnParent(parentTask: Task): Updates {
return when (parentTask.updates) {
Updates.none, Updates.status -> Updates.status
Updates.progress, Updates.statusAndProgress -> Updates.statusAndProgress
}
}
}
/**
* Constructor that also creates the [Task] associated with this [Chunk]
*/
constructor(parentTask: Task, url: String, filename: String, from: Long, to: Long) : this(
parentTaskId = parentTask.taskId,
url,
filename,
task = Task(
url = url,
filename = filename,
headers = parentTask.headers + mapOf("Range" to "bytes=$from-$to"),
httpRequestMethod = "GET",
chunks = 1,
post = null,
fileField = "",
mimeType = "",
baseDirectory = BaseDirectory.applicationDocuments,
group = TaskRunner.chunkGroup,
updates = updatesBasedOnParent(parentTask),
retries = parentTask.retries,
retriesRemaining = parentTask.retries,
requiresWiFi = parentTask.requiresWiFi,
allowPause = parentTask.allowPause,
priority = parentTask.priority,
metaData = bdJson.encodeToString<ChunkTaskMetaData>(ChunkTaskMetaData(parentTask.taskId, from, to)),
taskType = "DownloadTask"
),
from,
to
)
}
@Serializable
@OptIn(InternalSerializationApi::class)
/// Holder for metaData for [Task] related to a [Chunk]
data class ChunkTaskMetaData(val parentTaskId: String, val from: Long, val to: Long)
@@ -0,0 +1,12 @@
package com.bbflight.background_downloader
import android.content.Context
import androidx.work.WorkerParameters
class ParallelDownloadTaskWorker(context: Context, params: WorkerParameters) :
TaskWorker(context, params) {
override fun createRunner(): TaskRunner {
return ParallelDownloadTaskRunner(this)
}
}
@@ -0,0 +1,201 @@
@file:Suppress("EnumEntryName")
package com.bbflight.background_downloader
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.ActivityCompat
enum class PermissionType {
notifications, androidSharedStorage, iosAddToPhotoLibrary, iosChangePhotoLibrary
}
@Suppress("unused")
enum class PermissionStatus {
undetermined, denied, granted, partial, requestError
}
class PermissionsService {
companion object {
private const val baseRequestCode = 373900
/**
* Requests [PermissionStatus] for permission of [permissionType]
*
* Requests [PermissionStatus.granted] for unknown permissions
*/
fun getPermissionStatus(
context: Context,
permissionType: PermissionType
): PermissionStatus {
when (permissionType) {
PermissionType.notifications -> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// On Android 33+, check/ask for permission
val auth = ActivityCompat.checkSelfPermission(
context, Manifest.permission.POST_NOTIFICATIONS
)
if (auth == PackageManager.PERMISSION_GRANTED)
PermissionStatus.granted
else
PermissionStatus.denied
} else {
PermissionStatus.granted
}
}
PermissionType.androidSharedStorage -> {
return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q
) {
// On Android before 29, check/ask for permission
val auth = ActivityCompat.checkSelfPermission(
context, Manifest.permission.WRITE_EXTERNAL_STORAGE
)
if (auth == PackageManager.PERMISSION_GRANTED)
PermissionStatus.granted
else
PermissionStatus.denied
} else {
PermissionStatus.granted
}
}
else -> {
return PermissionStatus.granted
}
}
}
/**
* Requests permission for [permissionType] adn returns a Boolean
* If true a request has been made that the flutter side should wait for
* If false, no request has been made -> check permission status separately
*
* Does not wait for result. Once result is received via the
* [onRequestPermissionsResult], a message is sent back to the
* Flutter side with the permission result
*/
fun requestPermission(plugin: BDPlugin, permissionType: PermissionType): Boolean {
val requestCode = baseRequestCode + permissionType.ordinal
when (permissionType) {
PermissionType.notifications -> {
// On Android 33+, check/ask for permission
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (plugin.activity == null) {
return false
}
ActivityCompat.requestPermissions(
plugin.activity!!, arrayOf(Manifest.permission.POST_NOTIFICATIONS),
requestCode
)
return true
} else {
return false
}
}
PermissionType.androidSharedStorage -> {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
// On Android before 29, check/ask for permission
if (plugin.activity == null) {
return false
}
ActivityCompat.requestPermissions(
plugin.activity!!,
arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE),
requestCode
)
// completer will be completed via [onRequestPermissionsResult]
return true
} else {
return false
}
}
else -> {
return false
}
}
}
/**
* Returns true if Android shouldShowRequestPermissionRationale returns true for this
* [permissionType]. Returns false for unrecognized [permissionType]
*/
fun shouldShowRequestPermissionRationale(
plugin: BDPlugin,
permissionType: PermissionType
): Boolean {
val activity = plugin.activity
return if (activity != null) {
when (permissionType) {
PermissionType.notifications -> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ActivityCompat.shouldShowRequestPermissionRationale(
activity,
Manifest.permission.POST_NOTIFICATIONS
)
} else {
false
}
}
PermissionType.androidSharedStorage -> {
return ActivityCompat.shouldShowRequestPermissionRationale(
activity,
Manifest.permission.WRITE_EXTERNAL_STORAGE
)
}
else -> return false
}
} else {
false
}
}
/// Processes the onRequestPermissionsResult received by the Activity
fun onRequestPermissionsResult(
plugin: BDPlugin,
requestCode: Int, grantResults: IntArray
): Boolean {
val granted =
(grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED)
return when (requestCode) {
baseRequestCode + PermissionType.notifications.ordinal,
baseRequestCode + PermissionType.androidSharedStorage.ordinal -> {
sendPermissionResult(plugin, granted)
true
}
else -> {
false
}
}
}
/**
* Send the [granted] result via the background channel to Flutter
*
* Only one request can be 'in flight' at any time
*/
private fun sendPermissionResult(
plugin: BDPlugin,
granted: Boolean
) {
// send with dummy task ("")
val bgChannel = BDPlugin.backgroundChannel(plugin)
bgChannel?.invokeMethod(
"permissionRequestResult",
listOf(
"",
if (granted) PermissionStatus.granted.ordinal else PermissionStatus.denied.ordinal
)
)
}
}
}
@@ -0,0 +1,124 @@
package com.bbflight.background_downloader
import android.util.Log
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* Queue service that executes things on a queue, to ensure ordered execution
* and potentially manage delay
*/
@Suppress("ConstPropertyName")
object QueueService {
private val scope = CoroutineScope(Dispatchers.Default)
private val taskIdDeletionQueue = Channel<String>(capacity = Channel.UNLIMITED)
private var lastTaskIdAdditionTime: Long = 0
private const val minTaskIdDeletionDelay: Long = 2000L //ms
private val backgroundPostQueue = Channel<BackgroundPost>(capacity = Channel.UNLIMITED)
/**
* Starts listening to the queues and processes each item
*
* taskIdDeletionQueue:
* Each item is a taskId and it will be removed from the BDPlugin.pluginByTaskId,
* BDPlugin.bgChannelByTaskId, BDPlugin.localResumeData
* and BDPlugin.notificationConfigs maps
*
* backgroundPostQueue:
* Each item is a [BackgroundPost] that will be posted on the UI thread, and its
* success completer will complete with true if successfully posted
*/
init {
scope.launch {
for (taskId in taskIdDeletionQueue) {
val now = System.currentTimeMillis()
val elapsed = now - lastTaskIdAdditionTime
if (elapsed < minTaskIdDeletionDelay) {
delay(minTaskIdDeletionDelay - elapsed)
}
BDPlugin.flutterEngineByTaskId.remove(taskId)
BDPlugin.bgChannelByTaskId.remove(taskId)
BDPlugin.localResumeData.remove(taskId)
BDPlugin.notificationConfigJsonStrings.remove(taskId)
}
}
scope.launch {
for (bgPost in backgroundPostQueue) {
val success = CompletableDeferred<Boolean>()
launch(Dispatchers.Main) {
try {
val argList = mutableListOf<Any>(
TaskWorker.taskToJsonString(bgPost.task)
)
if (bgPost.arg is ArrayList<*>) {
argList.addAll(bgPost.arg)
} else {
argList.add(bgPost.arg)
}
val bgChannel = BDPlugin.backgroundChannel(taskId = bgPost.task.taskId)
if (bgChannel != null) {
bgChannel.invokeMethod(
bgPost.method, argList, FlutterBooleanResultHandler(success)
)
} else {
Log.i(
TaskRunner.TAG,
"Could not post ${bgPost.method} to background channel"
)
success.complete(false)
}
} catch (e: Exception) {
Log.w(
TaskRunner.TAG,
"Exception trying to post ${bgPost.method} to background channel: ${e.message}"
)
if (!success.isCompleted) {
success.complete(false)
}
}
val posted = !BDPlugin.forceFailPostOnBackgroundChannel && success.await()
if (posted) {
bgPost.onSuccess?.invoke()
} else {
bgPost.onFail?.invoke()
}
}
}
}
}
/**
* Remove this [taskId] from the [BDPlugin.bgChannelByTaskId] map and the
* [BDPlugin.localResumeData] map
*/
suspend fun cleanupTaskId(taskId: String) {
lastTaskIdAdditionTime = System.currentTimeMillis()
taskIdDeletionQueue.send(taskId)
}
/**
* Post this [BackgroundPost] on the background channel
*/
suspend fun postOnBackgroundChannel(bgPost: BackgroundPost) {
backgroundPostQueue.send(bgPost)
}
}
/**
* BackgroundPost to be sent via backgroundChannel to Flutter, used in [QueueService]
*/
data class BackgroundPost(
val task: Task,
val method: String,
val arg: Any,
val onFail: (suspend () -> Unit)? = null,
val onSuccess: (suspend () -> Unit)? = null
)
@@ -0,0 +1,325 @@
package com.bbflight.background_downloader
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.util.Log
import android.webkit.MimeTypeMap
import androidx.annotation.RequiresApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileInputStream
import java.io.OutputStream
/// Scoped Storage destinations for Android
@Suppress("EnumEntryName")
enum class SharedStorage { downloads, images, video, audio, files, external }
val leadingPathSeparatorRegEx = Regex("""^/+""")
val trailingPathSeparatorRegEx = Regex("""/$""")
/**
* Moves the file from filePath to the shared storage destination and returns the path to
* that file if successful, or null if not. If [asUriString] is true, the URI will be returned
* instead of the file path
*
* If successful, the original file will have been deleted
*/
suspend fun moveToSharedStorage(
context: Context,
filePathOrUriString: String,
destination: SharedStorage,
directory: String,
mimeType: String?,
asUriString: Boolean
): String? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
return moveToPublicDirectory(filePathOrUriString, destination, directory)
}
val file = if (filePathOrUriString.startsWith("file://")) {
File(Uri.parse(filePathOrUriString).path!!)
} else File(filePathOrUriString)
if (!file.exists()) {
Log.i(
BDPlugin.TAG,
"File $filePathOrUriString does not exist -> cannot move to shared storage"
)
return null
}
val cleanDirectory =
trailingPathSeparatorRegEx.replace(leadingPathSeparatorRegEx.replace(directory, ""), "")
// Set up the content values for the new file
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, file.name)
put(MediaStore.MediaColumns.MIME_TYPE, mimeType ?: getMimeType(file.name))
put(MediaStore.MediaColumns.RELATIVE_PATH, getRelativePath(destination, cleanDirectory))
put(MediaStore.MediaColumns.IS_PENDING, 1)
}
var success = false
// Insert the new file into the MediaStore
val resolver = context.contentResolver
val uri = try {
resolver.insert(getMediaStoreUri(destination), contentValues)
} catch (e: Exception) {
Log.i(BDPlugin.TAG, "Cannot insert $filePathOrUriString in MediaStore: $e")
return null
}
if (uri != null) {
try {
// Get an OutputStream and write the contents of the file
val os: OutputStream? = uri.let { resolver.openOutputStream(it) }
if (os != null) {
os.use { output ->
FileInputStream(file).use { input ->
input.copyTo(output)
}
}
success = true
}
} catch (e: Exception) {
Log.i(
BDPlugin.TAG,
"Error moving file $filePathOrUriString to shared storage: $e"
)
} finally {
if (success) {
contentValues.clear()
contentValues.put(MediaStore.MediaColumns.IS_PENDING, 0)
try {
resolver.update(uri, contentValues, null, null)
} catch (e: Exception) {
Log.i(
BDPlugin.TAG,
"Failed to reset IS_PENDING: $e"
)
}
} else {
try {
resolver.delete(uri, null, null)
} catch (e: Exception) {
Log.i(
BDPlugin.TAG,
"Failed to delete incomplete MediaStore entry: $e"
)
}
}
}
}
// If the file was moved successfully, remove the original
if (success) {
file.delete()
return if (asUriString) uri!!.toString() else pathFromUri(context, uri!!)
} else {
return null
}
}
/**
* Moves the file from filePath to the shared storage destination and returns the path to
* that file if successful, or null if not
*
* If successful, the original file will have been deleted
*
* This implementation is for Android versions before Q and requires app permissions
* READ_EXTERNAL_STORAGE and WRITE_EXTERNAL_STORAGE
*/
private fun moveToPublicDirectory(
filePath: String, destination: SharedStorage, directory: String
): String? {
try {
val file = File(filePath)
if (!file.exists()) {
Log.i(
BDPlugin.TAG,
"File $filePath does not exist -> cannot move to public directory"
)
return null
}
val destinationMediaPath = getMediaStorePathBelowQ(destination)
val rootDir = Environment.getExternalStoragePublicDirectory(destinationMediaPath)
val destDir = File(rootDir, directory)
if (!destDir.exists()) {
destDir.mkdirs()
}
// try to get a new file name if already exist
val maxCheck = 100
var currentCheck = 1
var destinationFile = File(destDir, file.name)
val fileName = file.nameWithoutExtension
val fileHasExtension = file.name.contains(".")
val fileExtension = if (fileHasExtension) ".${file.extension}" else ""
while (destinationFile.exists() && currentCheck < maxCheck) {
destinationFile = File(destDir, "${fileName}_$currentCheck$fileExtension")
currentCheck++
}
if (destinationFile.exists()) throw Exception("Destination file exist!")
// copy file
destinationFile.outputStream().use { output ->
FileInputStream(file).use { input ->
input.copyTo(output)
}
}
file.delete()
return destinationFile.absolutePath
} catch (e: Exception) {
Log.i(
BDPlugin.TAG,
"Unable to move file $filePath to public directory: $e"
)
return null
}
}
/**
* Returns the path to the file in shared storage, or null
*
* The [filePathOrUriString] can represent a file:// Uri but not a content:// Uri
*
* If [asUriString] is true, returns the URI as a String
*/
fun pathInSharedStorage(
context: Context,
filePathOrUriString: String,
destination: SharedStorage,
directory: String,
asUriString: Boolean
): String? {
// Check if filePath is a file:// URI
val fileUri = Uri.parse(filePathOrUriString)
val fileName = if (fileUri.scheme == "file") {
File(fileUri.path!!).name
} else {
File(filePathOrUriString).name
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
val destinationMediaPath = getMediaStorePathBelowQ(destination)
val rootDir = Environment.getExternalStoragePublicDirectory(destinationMediaPath)
val destDir = File(rootDir, directory)
val destinationFile = File(destDir, fileName)
return destinationFile.path
}
// Version above Q uses MediaStore
context.contentResolver.query(
getMediaStoreUri(destination),
arrayOf(
MediaStore.Images.Media.DATA,
MediaStore.MediaColumns._ID
), // same for all collections AFAIK
"${MediaStore.MediaColumns.DISPLAY_NAME} = ?",
arrayOf(fileName),
null
)?.use { cursor ->
if (cursor.moveToFirst()) {
return if (asUriString) {
Uri.withAppendedPath(
getMediaStoreUri(destination),
cursor.getLong(1).toString()
).toString()
} else cursor.getString(0)
}
}
return null
}
/**
* Returns path to media store [destination] for Android versions at or above Q
*/
@RequiresApi(Build.VERSION_CODES.Q)
fun getMediaStoreUri(destination: SharedStorage): Uri {
return when (destination) {
SharedStorage.files -> MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
SharedStorage.downloads -> MediaStore.Downloads.getContentUri(
MediaStore.VOLUME_EXTERNAL_PRIMARY
)
SharedStorage.images -> MediaStore.Images.Media.getContentUri(
MediaStore.VOLUME_EXTERNAL_PRIMARY
)
SharedStorage.video -> MediaStore.Video.Media.getContentUri(
MediaStore.VOLUME_EXTERNAL_PRIMARY
)
SharedStorage.audio -> MediaStore.Audio.Media.getContentUri(
MediaStore.VOLUME_EXTERNAL_PRIMARY
)
SharedStorage.external -> MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
}
}
/**
* Returns path to media store [destination] for Android versions below Q
*/
private fun getMediaStorePathBelowQ(destination: SharedStorage): String {
return when (destination) {
SharedStorage.files -> Environment.DIRECTORY_DOCUMENTS
SharedStorage.downloads -> Environment.DIRECTORY_DOWNLOADS
SharedStorage.images -> Environment.DIRECTORY_PICTURES
SharedStorage.video -> Environment.DIRECTORY_MOVIES
SharedStorage.audio -> Environment.DIRECTORY_MUSIC
SharedStorage.external -> ""
}
}
/**
* Returns file path to ScopedStorage [destination] and subdirectory [directory]
*/
private fun getRelativePath(destination: SharedStorage, directory: String): String {
val sharedStorageDirectory = when (destination) {
SharedStorage.files -> Environment.DIRECTORY_DOCUMENTS
SharedStorage.downloads -> Environment.DIRECTORY_DOWNLOADS
SharedStorage.images -> Environment.DIRECTORY_PICTURES
SharedStorage.video -> Environment.DIRECTORY_MOVIES
SharedStorage.audio -> Environment.DIRECTORY_MUSIC
SharedStorage.external -> ""
}
return if (directory.isEmpty()) sharedStorageDirectory else "$sharedStorageDirectory/$directory"
}
/**
* Return Mime type for this [fileNameOrUriString], as a String
*
* Defaults to application/octet-stream
*/
fun getMimeType(fileNameOrUriString: String): String {
val extension = fileNameOrUriString.substringAfterLast(".", "")
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
?: "application/octet-stream"
}
/**
* Returns the file path related to this MediaStore [uri], or null
*/
suspend fun pathFromUri(context: Context, uri: Uri): String? = withContext(Dispatchers.IO) {
val projection = arrayOf(MediaStore.Images.Media.DATA)
val cursor: Cursor? = context.contentResolver.query(uri, projection, null, null, null)
cursor?.use {
if (it.moveToFirst()) {
val columnIndex = it.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
return@withContext it.getString(columnIndex)
}
}
return@withContext null
}
//suspend fun pathFromUri(context: Context, uri: Uri): String? {
// val proj = arrayOf(MediaStore.Images.Media.DATA)
// val loader = CursorLoader(context, uri, proj, null, null, null)
// val cursor = withContext(Dispatchers.IO) { loader.loadInBackground() }
// ?: return null
// val columnIndex: Int = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
// cursor.moveToFirst()
// val result = cursor.getString(columnIndex)
// cursor.close()
// return result
//}
@@ -0,0 +1,231 @@
package com.bbflight.background_downloader
import android.app.Notification
import android.content.Context
import android.content.SharedPreferences
import androidx.preference.PreferenceManager
import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import android.util.Log
/**
* The worker to execute one task
*
* Wraps [TaskRunner], delegating the actual work to it, and implements
* [TaskJobContext] to provide the context for the [TaskRunner].
*/
@Suppress("ConstPropertyName")
open class TaskWorker(
val context: Context,
workerParams: WorkerParameters
) : CoroutineWorker(context, workerParams), TaskJobContext {
companion object {
const val keyTask = "Task"
const val keyNotificationConfig = "notificationConfig"
const val keyResumeDataData = "tempFilename"
const val keyStartByte = "startByte"
const val keyETag = "eTag"
const val bufferSize = 2 shl 12
/** Converts [Task] to JSON string representation */
fun taskToJsonString(task: Task): String {
return TaskRunner.taskToJsonString(task)
}
/**
* Processes a change in status for the task
*
* Delegates to [TaskRunner.processStatusUpdate]
*/
suspend fun processStatusUpdate(
task: Task,
status: TaskStatus,
prefs: SharedPreferences,
taskException: TaskException? = null,
responseBody: String? = null,
responseHeaders: Map<String, String>? = null,
responseStatusCode: Int? = null,
mimeType: String? = null,
charSet: String? = null,
context: Context
) {
TaskRunner.processStatusUpdate(
task,
status,
prefs,
taskException,
responseBody,
responseHeaders,
responseStatusCode,
mimeType,
charSet,
context
)
}
}
override val appContext: Context
get() = applicationContext
override val runId: String
get() = id.toString()
// TaskJobContext Mutable properties
override lateinit var task: Task
override var notificationConfig: NotificationConfig? = null
override var notificationId: Int = 0
override var notificationProgress: Double = 2.0 // indeterminate
override var networkSpeed: Double = -1.0
override var taskCanResume: Boolean = false
override var notificationConfigJsonString: String? = null
override var runInForeground: Boolean = false
override val isTaskStopped: Boolean
get() = isStopped
override val isActive: Boolean
get() = !isTaskStopped
override fun getInputLong(key: String, defaultValue: Long): Long {
return inputData.getLong(key, defaultValue)
}
override fun getInputString(key: String): String? {
return inputData.getString(key)
}
override suspend fun setForegroundNotification(
notificationId: Int,
notification: Notification,
notificationType: Int
) {
try {
setForeground(ForegroundInfo(notificationId, notification, notificationType))
} catch (e: IllegalStateException) {
Log.d("TaskWorker", "Ignoring setForeground: worker already completed. ${e.message}")
}
}
override suspend fun updateNotification(
task: Task,
status: TaskStatus,
progress: Double,
timeRemaining: Long
) {
NotificationService.updateNotification(this, status, progress, timeRemaining)
}
override fun updateEstimatedNetworkBytes(downloadBytes: Long, uploadBytes: Long) {
// No-op for TaskWorker
}
override suspend fun doWork(): Result {
try {
// Initialize task and notificationConfig from inputData
val taskJson = inputData.getString(keyTask)
if (taskJson != null) {
task = bdJson.decodeFromString(taskJson)
} else {
return Result.failure()
}
notificationConfigJsonString = inputData.getString(keyNotificationConfig)
if (notificationConfigJsonString != null) {
notificationConfig = bdJson.decodeFromString(notificationConfigJsonString!!)
}
runInForeground = false
if (runAttemptCount > 0 && skipReRun()) {
return Result.success()
}
val runner = createRunner()
runner.run()
return Result.success()
} catch (e: Exception) {
return Result.failure()
}
}
/**
* Returns true if this WorkManager re-run must end without running its task, because the
* task already finished or another work supersedes it, and gives back a finished task's slot
*
* WorkManager starts a work again after it stopped it, and after a process death for works
* it didn't see finish, without asking the holding queue
*/
private suspend fun skipReRun(): Boolean {
val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext)
val taskInTasksMap = getTaskMap(prefs).containsKey(task.taskId)
val otherUnfinishedWorkIds = withContext(Dispatchers.IO) {
WorkManager.getInstance(applicationContext)
.getWorkInfosByTag("taskId=${task.taskId}").get()
}.filter { !it.state.isFinished && it.id != id }.map { it.id.toString() }
val attempt = "WorkManager re-run (attempt $runAttemptCount) of taskId ${task.taskId}"
return when (reRunDecision(runAttemptCount, taskInTasksMap, otherUnfinishedWorkIds)) {
ReRunDecision.run -> {
Log.i(BDPlugin.TAG, "Running $attempt")
false
}
ReRunDecision.skipFinished -> {
Log.i(BDPlugin.TAG, "Skipping $attempt: finished")
// the slot the holding queue counted for this work, unless a cancel released it
if (!BDPlugin.slotReleasedRunIds.remove(runId)) {
BDPlugin.holdingQueue?.taskFinished(task)
}
true
}
ReRunDecision.skipSuperseded -> {
// the other work holds the task's slot, and releases it when it ends
Log.i(BDPlugin.TAG, "Skipping $attempt: superseded by ${otherUnfinishedWorkIds.first()}")
true
}
}
}
/**
* Create the appropriate [TaskRunner] for this worker
*
* Must be overridden by subclasses
*/
open fun createRunner(): TaskRunner {
throw NotImplementedError("Subclasses must override createRunner")
}
}
/** What a WorkManager run of a task does, see [reRunDecision] */
@Suppress("EnumEntryName")
enum class ReRunDecision {
run,
skipFinished, // the task's final state was already processed
skipSuperseded // another unfinished work for the task exists
}
/**
* Decides whether a WorkManager run with [runAttemptCount] runs its task, given whether the task
* is still in the tasks map ([taskInTasksMap]) and the ids of other unfinished works for it
*
* A first run (attempt 0) always runs. A re-run is skipped if another work supersedes it, or else
* if its task already finished
*/
fun reRunDecision(
runAttemptCount: Int,
taskInTasksMap: Boolean,
otherUnfinishedWorkIds: List<String>
): ReRunDecision = when {
runAttemptCount == 0 -> ReRunDecision.run
otherUnfinishedWorkIds.isNotEmpty() -> ReRunDecision.skipSuperseded
!taskInTasksMap -> ReRunDecision.skipFinished
else -> ReRunDecision.run
}
@@ -0,0 +1,156 @@
package com.bbflight.background_downloader
import android.app.Notification
import android.app.job.JobParameters
import android.app.job.JobService
import android.content.Context
import android.os.Build
import android.os.PersistableBundle
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
class UIDTJobService : JobService() {
val jobs = java.util.concurrent.ConcurrentHashMap<Int, Job>()
val jobContexts = java.util.concurrent.ConcurrentHashMap<Int, UIDTJobContext>()
override fun onStartJob(params: JobParameters?): Boolean {
Log.d(TaskRunner.TAG, "Starting UIDT JobService")
if (params == null) return false
val extras = params.extras
val taskJson = extras.getString(TaskWorker.keyTask)
if (taskJson == null) {
Log.e(TaskRunner.TAG, "Task JSON not found in job parameters")
return false
}
val jobContext = UIDTJobContext(this, params)
try {
jobContext.task = bdJson.decodeFromString(taskJson)
jobContext.notificationConfigJsonString = extras.getString(TaskWorker.keyNotificationConfig)
if (jobContext.notificationConfigJsonString != null) {
jobContext.notificationConfig = bdJson.decodeFromString(jobContext.notificationConfigJsonString!!)
}
} catch (e: Exception) {
Log.e(TaskRunner.TAG, "Failed to decode task or notification config: $e")
return false
}
// Determine runner based on task type.
val runner = when (jobContext.task.taskType) {
"DownloadTask" -> DownloadTaskRunner(jobContext)
"UriDownloadTask" -> DownloadTaskRunner(jobContext)
"UploadTask" -> UploadTaskRunner(jobContext)
"UriUploadTask" -> UploadTaskRunner(jobContext)
"MultiUploadTask" -> UploadTaskRunner(jobContext)
"DataTask" -> DataTaskRunner(jobContext)
"ParallelDownloadTask" -> ParallelDownloadTaskRunner(jobContext)
else -> {
Log.e(TaskRunner.TAG, "Unknown task type: ${jobContext.task.taskType}")
return false
}
}
jobContexts[params.jobId] = jobContext
val job = CoroutineScope(Dispatchers.IO).launch {
try {
runner.run()
Log.d(TaskRunner.TAG, "UIDT JobService finished for taskId ${jobContext.task.taskId}")
} finally {
jobs.remove(params.jobId)
jobContexts.remove(params.jobId)
jobFinished(params, false) // retries managed internally
NotificationService.onUIDTJobFinished(jobContext)
}
}
jobs[params.jobId] = job
return true // Work is still running on background thread
}
override fun onStopJob(params: JobParameters?): Boolean {
Log.i(TaskRunner.TAG, "Stopping UIDT JobService")
if (params != null) {
jobContexts[params.jobId]?.isStopped = true
jobs.remove(params.jobId)?.cancel()
jobContexts.remove(params.jobId)
}
return false // Do not reschedule automatically; background_downloader manages retries internally
}
/**
* Context for a single job execution, holding the state for that specific task/job
*/
class UIDTJobContext(val service: JobService, val params: JobParameters) : TaskJobContext {
// TaskJobContext Properties
override lateinit var task: Task
override var notificationConfig: NotificationConfig? = null
override var notificationId: Int = 0
override var notificationProgress: Double = 2.0
override var networkSpeed: Double = -1.0
override var taskCanResume: Boolean = false
override var notificationConfigJsonString: String? = null
override val runId: String? = null
override var runInForeground: Boolean = true // UIDT always runs in foreground service
@Volatile
var isStopped: Boolean = false
override val appContext: Context
get() = service.applicationContext
override val isTaskStopped: Boolean
get() = isStopped || !isActive
override val isActive: Boolean
get() = !isStopped && ((service as? UIDTJobService)?.jobs?.get(params.jobId)?.isActive ?: true)
override fun getInputLong(key: String, defaultValue: Long): Long {
return params.extras.getLong(key, defaultValue)
}
override fun getInputString(key: String): String? {
return params.extras.getString(key)
}
override suspend fun setForegroundNotification(
notificationId: Int,
notification: Notification,
notificationType: Int
) {
if (Build.VERSION.SDK_INT >= 34) {
service.setNotification(
params,
notificationId,
notification,
JobService.JOB_END_NOTIFICATION_POLICY_DETACH
)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
service.startForeground(notificationId, notification, notificationType)
} else {
service.startForeground(notificationId, notification)
}
}
override suspend fun updateNotification(
task: Task,
status: TaskStatus,
progress: Double,
timeRemaining: Long
) {
NotificationService.updateNotification(this, status, progress, timeRemaining)
}
override fun updateEstimatedNetworkBytes(downloadBytes: Long, uploadBytes: Long) {
if (Build.VERSION.SDK_INT >= 34) {
service.updateEstimatedNetworkBytes(params, downloadBytes, uploadBytes)
}
}
}
}
@@ -0,0 +1,47 @@
package com.bbflight.background_downloader
import android.content.Context
import androidx.core.app.NotificationManagerCompat
import androidx.work.WorkerParameters
import kotlinx.serialization.json.Json
/**
* Worker that updates the notification for a task, given a
* NotificationConfig and a TaskStatus.
*
* Used to update a task notification initiated from the Dart side, when
* no actual TaskWorker is active for the task, e.g. when failing for the
* last time on retry. We need a [TaskWorker] in order to call the
* [NotificationService] to update the associated notification.
*
* Note this implementation is only valid for two situations:
* - taskStatus is null -> deletes the notification
* - taskStatus is TaskStatus.failed -> displays error notification if needed
*/
class UpdateNotificationWorker(applicationContext: Context, workerParams: WorkerParameters) :
TaskWorker(applicationContext, workerParams) {
companion object {
const val keyTaskStatusOrdinal = "taskStatusOrdinal"
}
override suspend fun doWork(): Result {
task = bdJson.decodeFromString(inputData.getString(keyTask)!!)
notificationConfigJsonString = inputData.getString(keyNotificationConfig)
notificationConfig =
if (notificationConfigJsonString != null) bdJson.decodeFromString(notificationConfigJsonString!!) else null
val taskStatusOrdinal = inputData.getInt(keyTaskStatusOrdinal, -1)
notificationId = task.taskId.hashCode()
if (taskStatusOrdinal == -1) {
// delete notification
with(NotificationManagerCompat.from(applicationContext)) {
cancel(notificationId)
}
} else {
// update notification for this taskStatus
val taskStatus = TaskStatus.entries[taskStatusOrdinal]
NotificationService.updateNotification(this, taskStatus)
}
return Result.success()
}
}
@@ -0,0 +1,653 @@
package com.bbflight.background_downloader
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import android.util.Log
import androidx.core.net.toFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.DataOutputStream
import java.io.File
import java.io.FileInputStream
import java.io.InputStream
import java.net.HttpURLConnection
import android.database.Cursor
class UploadTaskRunner(context: TaskJobContext) : TaskRunner(context) {
companion object {
private val asciiOnlyRegEx = Regex("^[\\x00-\\x7F]+$")
private val jsonStringRegEx = Regex("^\\s*(\\{.*\\}|\\[.*\\])\\s*$")
private val newlineRegEx = Regex("\r\n|\r|\n")
const val boundary = "-----background_downloader-akjhfw281onqciyhnIk"
const val lineFeed = "\r\n"
}
/**
* Process the upload of the file
*
* If the [Task.post] field is set to "binary" then the file will be uploaded as a byte stream POST
*
* If the [Task.post] field is not "binary" then the file(s) will be uploaded as a multipart POST
*
* Note that the [Task.post] field is just used to set whether this is a binary or multipart
* upload. The bytes that will be posted are derived from the file to be uploaded.
*
* Returns the [TaskStatus]
*/
override suspend fun process(
connection: HttpURLConnection
): TaskStatus {
connection.doOutput = true
val transferBytesResult =
if (task.post?.lowercase() == "binary") {
processBinaryUpload(connection)
} else {
processMultipartUpload(connection)
}
when (transferBytesResult) {
TaskStatus.canceled -> {
Log.i(TAG, "Canceled taskId ${task.taskId}")
return TaskStatus.canceled
}
TaskStatus.failed -> {
return TaskStatus.failed
}
TaskStatus.complete -> {
extractResponseBody(connection)
extractResponseHeaders(connection.headerFields)
responseStatusCode = connection.responseCode
if (connection.responseCode in 200..206) {
Log.i(
TAG, "Successfully uploaded taskId ${task.taskId}"
)
return TaskStatus.complete
}
Log.i(
TAG,
"Response code ${connection.responseCode} for taskId ${task.taskId}"
)
val errorContent = responseErrorContent(connection)
taskException = TaskException(
ExceptionType.httpResponse, httpResponseCode = connection.responseCode,
description = if (errorContent?.isNotEmpty() == true) errorContent else connection.responseMessage
)
return if (connection.responseCode == 404) {
TaskStatus.notFound
} else {
TaskStatus.failed
}
}
else -> {
return TaskStatus.failed
}
}
}
/**
* Process the binary upload of the file
*
* Content-Disposition header will be:
* - set to 'attachment = "filename"' if the task.headers field does not contain an entry for
* 'Content-Disposition'
* - not set at all (i.e. omitted) if the task.headers field contains an entry for
* 'Content-Disposition' with the value "" (an empty string)
* - set to the value of task.headers['Content-Disposition'] in all other cases
*
* The mime-type will be set to [Task.mimeType]
*
* Returns the [TaskStatus]
*/
private suspend fun processBinaryUpload(
connection: HttpURLConnection
): TaskStatus {
val (filename, fileUri) = UriUtils.unpack(task.filename)
var resolvedMimeType = task.mimeType
val (fileSize, inputStream) = withContext(Dispatchers.IO) { // Use Dispatchers.IO for file operations
if (fileUri != null) {
try {
if (filename == null) {
// attempt to set a filename for the uploaded file in the task object
val derivedFilename = getFileNameFromUri(fileUri)
if (derivedFilename != null) {
task = task.copyWith(filename = UriUtils.pack(derivedFilename, fileUri))
}
if (resolvedMimeType.isEmpty()) resolvedMimeType =
getMimeType(derivedFilename ?: "")
}
if (fileUri.scheme != "file") {
// a content:// URI scheme is resolved via the contentResolver
val contentResolver = context.appContext.contentResolver
// Get file size from URI
val fileSize =
contentResolver.query(fileUri, null, null, null, null)?.use { cursor ->
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
cursor.moveToFirst()
if (sizeIndex != -1) cursor.getLong(sizeIndex) else null
} ?: run {
val message =
"Could not open file or determine file size for URI: $fileUri"
Log.w(TAG, message)
taskException = TaskException(
ExceptionType.fileSystem,
description = message
)
return@withContext Pair(
null,
null
) // Return nulls to indicate failure
}
if (resolvedMimeType.isEmpty()) resolvedMimeType =
getMimeType(contentResolver, fileUri)
// Get InputStream from URI
val inputStream = contentResolver.openInputStream(fileUri) ?: run {
val message = "Could not open input stream for URI: $fileUri"
Log.w(TAG, message)
taskException = TaskException(
ExceptionType.fileSystem,
description = message
)
return@withContext Pair(null, null) // Return nulls to indicate failure
}
Pair(fileSize, inputStream)
} else {
// a file:// Uri scheme is interpreted as a regular file path
val file = fileUri.toFile()
val fileSize = file.length()
if (resolvedMimeType.isEmpty()) resolvedMimeType =
getMimeType(file.name)
Pair(fileSize, FileInputStream(file))
}
} catch (e: Exception) {
val message = "Error processing URI: ${task.directory}"
Log.w(TAG, message, e)
taskException = TaskException(
ExceptionType.fileSystem,
description = message
)
return@withContext Pair(null, null) // Return nulls to indicate failure
}
} else {
val filePath = task.filePath(context.appContext)
val file = File(filePath)
if (!file.exists() || !file.isFile) {
val message = "File to upload does not exist: $filePath"
Log.w(TAG, message)
taskException = TaskException(
ExceptionType.fileSystem,
description = message
)
return@withContext Pair(null, null) // Return nulls to indicate failure
}
val fileSize = file.length()
if (fileSize <= 0) {
val message = "File $filePath has 0 length"
Log.w(TAG, message)
taskException = TaskException(
ExceptionType.fileSystem,
description = message
)
return@withContext Pair(null, null)
}
if (resolvedMimeType.isEmpty()) resolvedMimeType =
getMimeType(file.name)
Pair(fileSize, FileInputStream(file))
}
}
// Check for failures in getting fileSize and inputStream
if (fileSize == null || inputStream == null) {
try { inputStream?.close() } catch (_: Exception) {}
return TaskStatus.failed
}
return inputStream.use { fis ->
// Extract Range header information
var start = 0L
var end = fileSize - 1 // Default to the whole file
val rangeHeader = task.headers["Range"]
if (rangeHeader != null) {
val match = Regex("""bytes=(\d+)-(\d*)""").find(rangeHeader)
if (match != null) {
start = match.groupValues[1].toLong()
if (match.groupValues.size > 2 && match.groupValues[2].isNotEmpty()) {
end = match.groupValues[2].toLong()
}
} else {
val message = "Invalid Range header $rangeHeader"
Log.w(TAG, message)
taskException = TaskException(
ExceptionType.general,
description = message
)
return TaskStatus.failed
}
}
val contentLength = end - start + 1
determineRunInForeground(task, contentLength)
context.updateEstimatedNetworkBytes(0L, contentLength)
Log.d(TAG, "Binary upload for taskId ${task.taskId}")
connection.setRequestProperty("Content-Type", resolvedMimeType)
val taskContentDisposition =
task.headers["Content-Disposition"] ?: task.headers["content-disposition"]
if (taskContentDisposition != "") {
connection.setRequestProperty(
"Content-Disposition",
taskContentDisposition
?: ("attachment; filename=\"" + Uri.encode(task.filename) + "\"")
)
}
connection.setRequestProperty("Content-Length", contentLength.toString())
connection.setFixedLengthStreamingMode(contentLength)
withContext(Dispatchers.IO) {
if (rangeHeader != null) {
// Special treatment for partial uploads
fis.skip(start)
}
LimitedInputStream(fis, contentLength).use { limitedInputStream ->
DataOutputStream(connection.outputStream.buffered()).use { outputStream ->
return@withContext transferBytes(
limitedInputStream,
outputStream,
if (rangeHeader != null) contentLength else fileSize,
task
)
}
}
}
}
}
/**
* Process the multi-part upload of one or more files, and potential form fields
*
* Form fields are taken from [Task.fields]. If only one file is to be uploaded,
* then [Task.filename] determines the file (can be a file path or a Uri string),
* and [Task.fileField] and [Task.mimeType] are used to set the file field name
* and mime type respectively.
* For MultiUploadTasks, the list of fileField, filePath and mimeType are
* extracted from the [Task.fileField], [Task.filename] and [Task.mimeType] (which
* for MultiUploadTasks contain a JSON encoded list of strings).
*
* The total content length is calculated from the sum of all parts if all files are
* given as file paths or file:// Uris. If one or more files are given as content:// Uris,
* then chunked encoding is used.
*
* The connection is set up, and the bytes for each part are transferred to the host.
*
* Returns the [TaskStatus]
*/
private suspend fun processMultipartUpload(
connection: HttpURLConnection,
): TaskStatus {
// field portion of the multipart, all in one string
// multiple values should be encoded as '"value1", "value2", ...'
val multiValueRegEx = Regex("""^(?:"[^"]+"\s*,\s*)+"[^"]+"$""")
var fieldsString = ""
for (entry in task.fields.entries) {
// Check if the entry value matches the multiple values format
if (multiValueRegEx.matches(entry.value)) {
// Extract multiple values from entry.value
val valueMatches = Regex(""""([^"]+)"""").findAll(entry.value)
for (match in valueMatches) {
fieldsString += fieldEntry(entry.key, match.groupValues[1])
}
} else {
// Handle single value for key
fieldsString += fieldEntry(entry.key, entry.value)
}
}
// File portion of the multi-part
// Assumes list of files. If only one file, that becomes a list of length one.
// For each file, determine contentDispositionString, contentTypeString
// and file length or InputStream, so that we can calculate total size of
// upload or use chunked encoding.
val separator = "$lineFeed--$boundary$lineFeed" // between files
val terminator = "$lineFeed--$boundary--$lineFeed" // after last file
val fileUri = UriUtils.uriFromStringValue(task.filename)
val filePath = task.filePath(context.appContext)
// fileData's second field contains either a file path or a Uri string
val filesData = if (filePath.isNotEmpty()) {
listOf(
Triple(task.fileField, fileUri?.toString() ?: filePath, task.mimeType)
)
} else {
task.extractFilesData(context.appContext)
}
val contentDispositionStrings = ArrayList<String>()
val contentTypeStrings = ArrayList<String>()
val fileLengthsOrStreams = ArrayList<Pair<Long?, InputStream?>>()
try {
var useChunkedEncoding = false
for ((fileField, pathOrUriString, mimeType) in filesData) {
var resolvedMimeType = mimeType // we need to change it if it is empty
try {
val fileUri = UriUtils.uriFromStringValue(pathOrUriString)
val (fileSize, inputStream) = if (fileUri != null) {
if (fileUri.scheme != "file") {
// a content:// URI scheme is resolved via the contentResolver
val contentResolver = context.appContext.contentResolver
// Get file size from URI, or set to null
val fileSize =
contentResolver.query(fileUri, null, null, null, null)?.use { cursor ->
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
if (sizeIndex != -1 && cursor.moveToFirst()) cursor.getLong(
sizeIndex
) else null
}
useChunkedEncoding = useChunkedEncoding ||
fileSize == null // Use chunked encoding if file size is unknown
if (mimeType.isEmpty()) {
resolvedMimeType = getMimeType(contentResolver, fileUri)
}
// Get InputStream from URI
val fileInputStream = contentResolver.openInputStream(fileUri)
if (fileInputStream == null) {
val message = "Could not open input stream for URI: $fileUri"
Log.w(TAG, message)
taskException = TaskException(
ExceptionType.fileSystem,
description = message
)
return TaskStatus.failed
}
Log.v(TAG, "Using InputStream from URI $fileUri")
Pair(fileSize, fileInputStream)
} else {
// a file:// Uri scheme is interpreted as a regular file path
val file = fileUri.toFile()
val fileSize = file.length()
if (mimeType.isEmpty()) {
resolvedMimeType = getMimeType(fileUri.toString())
}
Log.v(TAG, "Using FileInputStream from URI $fileUri")
Pair(fileSize, FileInputStream(file))
}
} else {
val file = File(pathOrUriString)
if (!file.exists() || !file.isFile) {
Log.w(TAG, "File at $pathOrUriString does not exist")
taskException = TaskException(
ExceptionType.fileSystem,
description = "File to upload does not exist: $pathOrUriString"
)
return TaskStatus.failed
}
if (mimeType.isEmpty()) {
resolvedMimeType = getMimeType(file.path)
}
Pair(file.length(), FileInputStream(file))
}
// we now have a possible content length and InputStream for this file
if (!useChunkedEncoding && fileSize == null) {
val message = "Could not determine file size for $pathOrUriString"
Log.w(TAG, message)
taskException = TaskException(
ExceptionType.fileSystem,
description = message
)
return TaskStatus.failed
}
// determine the file name
val name = if (fileUri != null) {
getFileNameFromUri(fileUri) ?: "unknown"
} else {
File(pathOrUriString).name
}
contentDispositionStrings.add(
"Content-Disposition: form-data; name=\"${browserEncode(fileField)}\"; " +
"filename=\"${browserEncode(name)}\"$lineFeed"
)
if (filesData.size == 1 && task.taskType != "MultiUploadTask") {
// only for single file uploads do we set the task's filename property
task = task.copyWith(
filename = if (fileUri != null) UriUtils.pack(
name,
fileUri
) else name
)
}
contentTypeStrings.add("Content-Type: $resolvedMimeType$lineFeed$lineFeed")
fileLengthsOrStreams.add(Pair(fileSize, inputStream))
} catch (_: Exception) {
val message =
"Could not open file or determine file size for $pathOrUriString"
Log.w(TAG, message)
taskException = TaskException(
ExceptionType.fileSystem,
description = message
)
return TaskStatus.failed
}
}
// setup the connection
connection.setRequestProperty("Accept-Charset", "UTF-8")
connection.setRequestProperty("Connection", "Keep-Alive")
connection.setRequestProperty("Cache-Control", "no-cache")
connection.setRequestProperty(
"Content-Type", "multipart/form-data; boundary=$boundary"
)
if (!useChunkedEncoding) {
// Calculate total content length only if not using chunked encoding
val fileDataLength =
contentDispositionStrings.sumOf { string: String -> lengthInBytes(string) } +
contentTypeStrings.sumOf { string: String -> string.length } +
fileLengthsOrStreams.sumOf { pair ->
pair.first ?: 0
} + separator.length * contentDispositionStrings.size + 2
val contentLength =
lengthInBytes(fieldsString) + "--$boundary$lineFeed".length + fileDataLength
determineRunInForeground(task, contentLength)
context.updateEstimatedNetworkBytes(0L, contentLength)
connection.setRequestProperty("Content-Length", contentLength.toString())
connection.setFixedLengthStreamingMode(contentLength)
} else {
determineRunInForeground(task, 1024 * 1024 * 20) // assume at least 20MB
connection.setChunkedStreamingMode(0) // Use default chunk size
}
connection.useCaches = false
// transfer the bytes
return withContext(Dispatchers.IO) {
DataOutputStream(connection.outputStream).use { outputStream ->
val writer = outputStream.writer()
// write form fields
writer.append(fieldsString).append("--${boundary}").append(lineFeed)
// write each file
for (i in filesData.indices) {
fileLengthsOrStreams[i].second.use { inputStream ->
if (inputStream != null) {
writer.append(contentDispositionStrings[i])
.append(contentTypeStrings[i]).flush()
val transferBytesResult =
transferBytes(
inputStream,
outputStream,
fileLengthsOrStreams[i].first ?: 0,
task
)
if (transferBytesResult == TaskStatus.complete) {
if (i < filesData.size - 1) {
writer.append(separator)
} else
writer.append(terminator)
writer.flush()
} else {
return@withContext transferBytesResult
}
} else {
Log.w(TAG, "No input stream for ${filesData[i].first}")
taskException = TaskException(
ExceptionType.fileSystem,
description = "No input stream for ${filesData[i].first}"
)
return@withContext TaskStatus.failed
}
}
}
writer.close()
}
return@withContext TaskStatus.complete
}
} finally {
for (pair in fileLengthsOrStreams) {
try {
pair.second?.close()
} catch (_: Exception) {}
}
}
}
/**
* Returns the file name for the [uri] using the [ContentResolver], or the last path segment,
* or null
*/
private fun getFileNameFromUri(uri: Uri): String? {
if (uri.scheme == "content") {
val cursor = context.appContext.contentResolver.query(uri, null, null, null, null)
cursor?.use {
if (it.moveToFirst()) {
val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameIndex != -1) {
return it.getString(nameIndex)
}
}
}
}
return uri.lastPathSegment
}
/**
* Returns the mime type for the [uri] using the [ContentResolver] or extension
*/
fun getMimeType(contentResolver: ContentResolver, uri: Uri): String {
// Try to get it directly from ContentResolver
var mimeType = contentResolver.getType(uri)
if (mimeType != null) return mimeType
// Try to infer it from the URL file extension if available
return getMimeType(uri.toString())
}
/**
* Helper to get mimeType from extension
*/
private fun getMimeType(url: String): String {
var type: String? = null
val extension = android.webkit.MimeTypeMap.getFileExtensionFromUrl(url)
if (extension != null) {
type = android.webkit.MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
}
return type ?: "application/octet-stream"
}
/**
* Extract the response's body content as a String, or null if unable, and store
* in [responseBody]
*/
private fun extractResponseBody(connection: HttpURLConnection) {
try {
responseBody = connection.inputStream.bufferedReader().readText()
return
} catch (e: Exception) {
Log.i(
TAG,
"Could not read response body from httpResponseCode ${connection.responseCode}: $e"
)
}
responseBody = null
}
/**
* Returns the multipart entry for one field name/value pair
*/
private fun fieldEntry(name: String, value: String): String {
return "--$boundary$lineFeed${headerForField(name, value)}$value$lineFeed"
}
/**
* Returns the header string for a field
*
* The return value is guaranteed to contain only ASCII characters
*/
private fun headerForField(name: String, value: String): String {
var header = "Content-Disposition: form-data; name=\"${browserEncode(name)}\""
if (isJsonString(value)) {
header = "$header\r\n" +
"Content-Type: application/json; charset=utf-8"
} else if (!isPlainAscii(value)) {
header = "$header\r\n" +
"Content-Type: text/plain; charset=utf-8\r\n" +
"Content-Transfer-Encoding: binary"
}
return "$header\r\n\r\n"
}
/**
* Returns whether [string] is composed entirely of ASCII-compatible characters
*/
private fun isPlainAscii(string: String): Boolean {
return asciiOnlyRegEx.matches(string)
}
/**
* Returns whether [string] is a JSON formatted string
*/
private fun isJsonString(string: String): Boolean {
return jsonStringRegEx.matches(string)
}
/**
* Encode [value] in the same way browsers do
*/
private fun browserEncode(value: String): String {
// http://tools.ietf.org/html/rfc2388 mandates some complex encodings for
// field names and file names, but in practice user agents seem not to
// follow this at all. Instead, they URL-encode `\r`, `\n`, and `\r\n` as
// `\r\n`; URL-encode `"`; and do nothing else (even for `%` or non-ASCII
// characters). We follow their behavior.
return value.replace(newlineRegEx, "%0D%0A").replace("\"", "%22")
}
}
/**
* InputStream that limits the number of bytes read
*/
class LimitedInputStream(
private val inputStream: InputStream,
private val limit: Long
) : InputStream() {
private var bytesRead: Long = 0
override fun read(): Int {
if (bytesRead >= limit) {
return -1 // End of stream
}
val result = inputStream.read()
if (result != -1) {
bytesRead++
}
return result
}
override fun read(b: ByteArray, off: Int, len: Int): Int {
if (bytesRead >= limit) {
return -1
}
// Adjust length to not exceed limit
val remainingBytes: Long = limit - bytesRead
val maxBytesToRead =
minOf(len.toLong(), remainingBytes).toInt()
val result = inputStream.read(b, off, maxBytesToRead)
if (result != -1) {
bytesRead += result
}
return result
}
}
@@ -0,0 +1,12 @@
package com.bbflight.background_downloader
import android.content.Context
import androidx.work.WorkerParameters
class UploadTaskWorker(context: Context, params: WorkerParameters) :
TaskWorker(context, params) {
override fun createRunner(): TaskRunner {
return UploadTaskRunner(this)
}
}
@@ -0,0 +1,991 @@
package com.bbflight.background_downloader
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.os.ext.SdkExtensions
import android.provider.DocumentsContract
import android.provider.MediaStore
import android.util.Log
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import java.io.File
import java.io.IOException
object UriUtils {
/**
* Packs [filename] and [uri] into a single String.
*
* Use [unpack] to retrieve the filename and uri from the packed String.
*/
fun pack(filename: String, uri: Uri): String = ":::$filename::::::$uri:::"
/**
* Unpacks [packedString] into a filename and uri. If this is not a packed
* string, returns the original [packedString] as the filename and null.
*/
fun unpack(packedString: String): Pair<String?, Uri?> {
val regex = Regex(":::([\\s\\S]*?)::::::([\\s\\S]*?):::")
val match = regex.find(packedString)
return if (match != null && match.groupValues.size == 3) {
val filename = match.groupValues[1]
val uriString = match.groupValues[2]
val uri = Uri.parse(uriString)
val scheme = uri?.scheme
Pair(filename, if (scheme?.isNotEmpty() == true) uri else null)
} else {
val uri = Uri.parse(packedString)
val scheme = uri?.scheme
if (scheme?.isNotEmpty() == true) Pair(null, uri)
else Pair(packedString, null)
}
}
/**
* Returns the Uri represented by [value], or null if the String is not a
* valid Uri or packed Uri string.
*
* [value] should be a full Uri string, or a packed String containing
* a Uri (see [pack]).
*/
fun uriFromStringValue(value: String): Uri? {
val (_, uri) = unpack(value)
return uri
}
/**
* Returns true if [value] is a valid Uri or packed Uri string.
*
* [value] should be a full Uri string, or a packed String containing
* a Uri (see [pack]).
*/
fun containsUri(value: String): Boolean = uriFromStringValue(value) != null
}
/**
* Handles method calls from Flutter related to file and directory picking/creation.
*/
class UriUtilsMethodCallHelper(private val plugin: BDPlugin) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
val activity = plugin.activity
if (activity == null) {
result.error("NO_ACTIVITY", "No activity found", null)
return
}
when (call.method) {
"pickDirectory" -> {
/**
* results in selected directory URI (or null) for the [SharedStorage] starting location, which
* may be null for no selection. If persistedUriPermission is true, the URI will be persisted
*
* Arguments are startLocationOrdinal (of [SharedStorage]) and persistedUriPermission (bool)
*/
val args = call.arguments as? List<*>
val startLocationOrdinal = args?.get(0) as Int?
val startLocationUriString = args?.get(1) as? String
val persistedUriPermission = args?.get(2) as? Boolean == true
val startLocation =
if (startLocationOrdinal != null) SharedStorage.entries[startLocationOrdinal] else null
val startLocationUri = startLocationUriString?.toUri()
if (!DirectoryPicker.pickDirectory(
activity,
startLocation,
startLocationUri,
persistedUriPermission,
result
)
) {
result.error(
"PICK_DIRECTORY_FAILED",
"Failed to launch directory picker",
null
)
}
}
"pickFiles" -> {
/**
* Results in selected file URI(s) for the [SharedStorage] starting location, which
* may be null for no selection. If allowedExtensions is not null, only files with the
* given extensions will be allowed. If multipleAllowed is true, multiple files can be
* selected. If persistedUriPermission is true, the URI will be persisted.
*
* Arguments are startLocationOrdinal (of [SharedStorage]), allowedExtensions (list of
* strings, or null), multipleAllowed (bool), and persistedUriPermission (bool).
*/
val args = call.arguments as? List<*>
val startLocationOrdinal = args?.get(0) as Int?
val startLocationUriString = args?.get(1) as? String
val allowedExtensionsAnyList = args?.get(2) as? List<*>
val allowedExtensions = allowedExtensionsAnyList?.map { it as String }
val multipleAllowed = args?.get(3) as? Boolean == true
val persistedUriPermission = args?.get(4) as? Boolean == true
val startLocation =
if (startLocationOrdinal != null) SharedStorage.entries[startLocationOrdinal] else null
val startLocationUri = startLocationUriString?.toUri()
if (startLocation == SharedStorage.images || startLocation == SharedStorage.video) {
if (!FilePicker.pickMedia(
activity,
startLocation,
multipleAllowed,
persistedUriPermission,
result
)
) {
result.error("PICK_FILES_FAILED", "Failed to launch media picker", null)
}
} else {
if (!FilePicker.pickFiles(
activity,
startLocation,
startLocationUri,
allowedExtensions,
multipleAllowed,
persistedUriPermission,
result
)
) {
result.error("PICK_FILES_FAILED", "Failed to launch file picker", null)
}
}
}
"createDirectory" -> {
/**
* Creates a new directory with the given name inside the specified parent directory
* URI. Results in the URI of the new directory
*
* Arguments are parentDirectoryUri (string), newDirectoryName (string), and
* persistedUriPermission (bool).
*/
val args = call.arguments as? List<*>
val parentDirectoryUriString = args?.get(0) as? String
val newDirectoryName = args?.get(1) as? String
val persistedUriPermission = args?.get(2) as? Boolean == true
if (parentDirectoryUriString == null || newDirectoryName == null) {
result.error(
"INVALID_ARGUMENTS",
"Parent directory URI and new directory name are required",
null
)
return
}
val parentDirectoryUri = Uri.parse(parentDirectoryUriString)
DirectoryCreator.createDirectory(
activity,
parentDirectoryUri,
newDirectoryName,
persistedUriPermission,
result
)
}
"getFileBytes" -> {
val uriString = call.arguments as? String
if (uriString == null) {
result.error("INVALID_ARGUMENTS", "URI string is required", null)
return
}
val fileBytes = getFile(activity, Uri.parse(uriString))
if (fileBytes != null) {
result.success(fileBytes)
} else {
result.error("GET_FILE_FAILED", "Failed to get file", null)
}
}
"copyFile" -> {
val args = call.arguments as? List<*>
val sourceUriString = args?.get(0) as? String
val destinationUriString = args?.get(1) as? String
copyFile(activity, sourceUriString, destinationUriString, result)
}
"moveFile" -> {
val args = call.arguments as? List<*>
val sourceUriString = args?.get(0) as? String
val destinationUriString = args?.get(1) as? String
moveFile(activity, sourceUriString, destinationUriString, result)
}
"deleteFile" -> {
val uriString = call.arguments as? String
if (uriString == null) {
result.error("INVALID_ARGUMENTS", "URI string is required", null)
return
}
val uri = Uri.parse(uriString)
if (uri.scheme == "content") {
val docFile = DocumentFile.fromSingleUri(activity, uri)
if (docFile != null && docFile.delete()) {
result.success(null)
} else {
if (docFile == null) {
result.error("DELETE_FILE_FAILED", "File at $uri does not exist", null)
} else {
result.error("DELETE_FILE_FAILED", "Failed to delete file", null)
}
}
return
}
if (uri.scheme == "file") {
val file = File(uri.path!!)
if (file.delete()) {
result.success(null)
} else {
result.error("DELETE_FILE_FAILED", "Failed to delete file", null)
}
return
}
result.error("DELETE_FILE_FAILED", "Invalid URI: $uri", null)
}
"openFile" -> {
/**
* Opens the file at the given URI.
*
* Arguments are uriString (string) and mimeType (string, nullable).
*/
val args = call.arguments as? List<*>
val uriString = args?.get(0) as? String
val mimeType = args?.get(1) as? String
if (uriString == null) {
result.error("INVALID_ARGUMENTS", "URI string is required", null)
return
}
if (!doOpenFile(activity, uriString, mimeType ?: getMimeType(uriString))) {
result.error("OPEN_FILE_FAILED", "Failed to open file", null)
}
result.success(true)
}
else -> result.notImplemented()
}
}
/**
* Gets the file content as a byte array for a given URI.
*
* @param context The application context.
* @param uri The URI of the file.
* @return The file content as a byte array, or null if an error occurred.
*/
private fun getFile(context: Context, uri: Uri): ByteArray? {
return try {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
} catch (e: IOException) {
Log.e(BDPlugin.TAG, "Error reading file: $uri", e)
null
}
}
/**
* Copies the file from the source URI to the destination URI. Handles file:// and content:// schemes
* for both source and destination.
*/
private fun copyFile(
activity: Activity,
sourceUriString: String?,
destinationUriString: String?,
result: MethodChannel.Result
) {
if (sourceUriString == null || destinationUriString == null) {
result.error(
"INVALID_ARGUMENTS",
"Source and destination URI strings are required",
null
)
return
}
val sourceUri = Uri.parse(sourceUriString)
val destinationUri = Uri.parse(destinationUriString)
try {
activity.contentResolver?.openInputStream(sourceUri)?.use { input ->
activity.contentResolver?.openOutputStream(destinationUri)?.use { output ->
input.copyTo(output)
} ?: result.error(
"COPY_FAILED",
"Failed to open output stream for destination URI",
null
)
} ?: result.error("COPY_FAILED", "Failed to open input stream for source URI", null)
result.success(destinationUriString) // Return the destination URI as a String
} catch (e: Exception) {
result.error("COPY_FAILED", "Error copying file: ${e.message}", null)
}
}
/**
* Tries to move a file using [DocumentsContract.moveDocument] if possible (API >= 24 and content URIs),
* otherwise falls back to a copy-and-delete strategy. Handles file:// and content:// schemes
* for both source and destination.
*/
private fun moveFile(
activity: Activity,
sourceUriString: String?,
destinationUriString: String?,
result: MethodChannel.Result
) {
if (sourceUriString == null || destinationUriString == null) {
result.error(
"INVALID_ARGUMENTS",
"Source and destination URI strings are required",
null
)
return
}
val sourceUri = Uri.parse(sourceUriString)
val destinationUri = Uri.parse(destinationUriString)
// Try using DocumentsContract.moveDocument first (if applicable).
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && sourceUri.scheme == "content" && destinationUri.scheme == "content") {
try {
val sourceDocument = DocumentFile.fromSingleUri(activity, sourceUri)
if (sourceDocument == null || !sourceDocument.exists()) {
result.error("MOVE_FAILED", "Source file does not exist", null)
return
}
val destinationParent = DocumentFile.fromTreeUri(
activity, destinationUri.buildUpon().path(
destinationUri.pathSegments.dropLast(1).joinToString("/")
).build()
) ?: DocumentFile.fromSingleUri(
activity, destinationUri.buildUpon().path(
destinationUri.pathSegments.dropLast(1).joinToString("/")
).build()
)
if (destinationParent == null || !destinationParent.isDirectory) {
result.error(
"MOVE_FAILED",
"Destination directory does not exist or is invalid",
null
)
return
}
val movedDocument = DocumentsContract.moveDocument(
activity.contentResolver,
sourceUri,
sourceDocument.parentFile!!.uri,
destinationParent.uri
)
if (movedDocument != null) {
val renamedFile = DocumentsContract.renameDocument(
activity.contentResolver,
movedDocument,
destinationUri.lastPathSegment.toString()
)
result.success(renamedFile.toString())
return
} else {
result.error("MOVE_FAILED", "Failed to move file using DocumentsContract", null)
return
}
} catch (e: Exception) {
Log.i(
BDPlugin.TAG,
"Error moving file (DocumentsContract) from $sourceUri to $destinationUri",
e
)
}
}
// Fallback: Copy and Delete (works for mixed schemes and older Android versions).
try {
val success = copyForMove(
activity,
sourceUri,
destinationUri,
result
) // perform the copy operation
if (success) {
var deleteSuccess = false
if (sourceUri.scheme == "content") {
val sourceDocument = DocumentFile.fromSingleUri(activity, sourceUri)
// Use DocumentsContract.deleteDocument if available.
if (sourceDocument != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
deleteSuccess = try {
DocumentsContract.deleteDocument(
activity.contentResolver,
sourceUri
)
} catch (_: Exception) {
// Try to delete using the document (if deleteDocument fails)
sourceDocument.delete()
}
} else if (sourceDocument != null) {
deleteSuccess = sourceDocument.delete()
}
} else if (sourceUri.scheme == "file") {
val sourceFile = File(sourceUri.path!!)
deleteSuccess = sourceFile.delete()
}
if (deleteSuccess) {
result.success(destinationUriString)
} else {
result.error("MOVE_FAILED", "Failed to delete the source", null)
}
}
} catch (e: Exception) {
result.error("MOVE_FAILED", "Error moving file: ${e.message}", null)
}
}
/**
* Private helper function to perform copy, to be used in move.
* returns true if successful
*/
private fun copyForMove(
activity: Activity,
sourceUri: Uri,
destinationUri: Uri,
result: MethodChannel.Result
): Boolean {
try {
if (destinationUri.scheme == "file") {
val destFile = File(destinationUri.path!!)
if (!destFile.parentFile!!.exists()) {
if (!destFile.parentFile!!.mkdirs()) {
result.error(
"MOVE_FAILED",
"Could not create destination directory at ${destFile.parentFile!!.absolutePath}",
null
)
return false
}
}
}
activity.contentResolver?.openInputStream(sourceUri)?.use { input ->
activity.contentResolver?.openOutputStream(destinationUri)?.use { output ->
input.copyTo(output)
} ?: run {
result.error(
"MOVE_FAILED",
"Failed to open output stream for destination URI",
null
)
return false
}
} ?: run {
result.error("MOVE_FAILED", "Failed to open input stream for source URI", null)
return false
}
} catch (e: Exception) {
result.error("MOVE_FAILED", "Error copying file: ${e.message}", null)
return false
}
return true // copy was successful
}
}
/**
* Helper class for launching the Android file picker to select a directory.
*/
object DirectoryPicker {
const val TAG = "DirectoryPicker"
private var pendingResult: MethodChannel.Result? = null
private var persistedUriPermission: Boolean = false
private const val REQUEST_CODE_PICK_DIRECTORY = 165465106
/**
* Launches the Android file picker to select a directory.
*
* @param activity The current activity.
* @param startLocation The shared storage location to start the picker in (ignored on API levels below Q).
* @param result The MethodChannel result to complete when the picker is closed.
*
* Returns true if the picker was launched successfully, false otherwise.
*
* The result of the picker is handled in [handleActivityResult], and is either
* a URI string, null (if the picker was cancelled) or an error
*/
fun pickDirectory(
activity: Activity,
startLocation: SharedStorage?,
startLocationUri: Uri?,
persistedUriPermission: Boolean = false,
result: MethodChannel.Result
): Boolean {
if (pendingResult != null) {
Log.w(TAG, "Directory picker already in progress")
return false
}
pendingResult = result
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
if (persistedUriPermission) {
flags = flags or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
}
// Set the starting directory (for API 26+).
if ((startLocation != null || startLocationUri != null) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
putExtra(
DocumentsContract.EXTRA_INITIAL_URI,
if (startLocationUri != null) startLocationUri else getInitialDirectoryUri(
startLocation!!
)
)
}
}
this.persistedUriPermission = persistedUriPermission // for later reference
activity.startActivityForResult(intent, REQUEST_CODE_PICK_DIRECTORY)
return true
}
/**
* Handles the result from the directory picker activity, buy setting the [pendingResult] to the
* result value. Returns null if the request was cancelled by the user, and errors if something
* unusual happened
*
* @param requestCode The request code passed to startActivityForResult().
* @param resultCode The result code returned by the child activity.
* @param data An Intent, which can return result data to the caller.
* @return True if the result was handled, false otherwise.
*/
fun handleActivityResult(
context: Context,
requestCode: Int,
resultCode: Int,
data: Intent?
): Boolean {
if (requestCode == REQUEST_CODE_PICK_DIRECTORY) {
if (resultCode == Activity.RESULT_OK) {
val directoryUri = data?.data
if (persistedUriPermission && directoryUri != null) {
context.contentResolver.takePersistableUriPermission(
directoryUri,
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
)
}
pendingResult?.success(directoryUri?.toString())
} else {
pendingResult?.success(null)
}
pendingResult = null // cancelled
return true
}
return false
}
}
/**
* Helper class for launching the Android file picker to select one or more files.
*/
object FilePicker {
const val TAG = "FilePicker"
private var pendingResult: MethodChannel.Result? = null
private var multipleAllowed: Boolean = false
private var persistedUriPermission: Boolean = false
private const val REQUEST_CODE_PICK_FILES = 265465106
private const val REQUEST_CODE_PICK_MEDIA = 36546510
/**
* Launches the Android file picker to select one or more files. Only read access is granted
* to these files
*
* @param activity The current activity.
* @param startLocation The shared storage location to start the picker in (ignored on API levels below Q).
* @param allowedExtensions A list of allowed file extensions (without the dot), e.g., ["pdf", "jpg", "png"]. If null, all file types are allowed.
* @param multipleAllowed Whether to allow the user to select multiple files.
* @param persistedUriPermission Whether to persist the URI permission across device reboots.
* @param result The MethodChannel result to complete when the picker is closed.
*
* Returns true if the picker was launched successfully, false otherwise.
*
* The result posted back is either a list of URIs as a String, or null (if the user has
* cancelled the picker) or an error
*/
fun pickFiles(
activity: Activity,
startLocation: SharedStorage?,
startLocationUri: Uri?,
allowedExtensions: List<String>?,
multipleAllowed: Boolean = false,
persistedUriPermission: Boolean = false,
result: MethodChannel.Result
): Boolean {
if (pendingResult != null) {
Log.w(TAG, "File picker already in progress")
return false
}
this.multipleAllowed = multipleAllowed
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, multipleAllowed)
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
if (persistedUriPermission) {
flags = flags or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
}
if (!allowedExtensions.isNullOrEmpty()) {
val mimeTypes = extensionsToMimeTypes(allowedExtensions)
if (mimeTypes.size == 1) {
type = mimeTypes[0]
} else {
type = "*/*"
putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes)
}
} else {
type = "*/*" // Allow all file types if no extensions are specified
}
// Set the starting directory (for API 26+).
if ((startLocation != null || startLocationUri != null) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
putExtra(
DocumentsContract.EXTRA_INITIAL_URI,
if (startLocationUri != null) startLocationUri else getInitialDirectoryUri(
startLocation!!
)
)
}
}
this.persistedUriPermission = persistedUriPermission // for later reference
this.multipleAllowed = multipleAllowed // for later reference
pendingResult = result
activity.startActivityForResult(intent, REQUEST_CODE_PICK_FILES)
return true
}
/**
* Launches the Android photo picker to select one or more media files (images or videos).
*
* @param activity The current activity.
* @param startLocation The shared storage location to start the picker in (images or video).
* @param multipleAllowed Whether to allow the user to select multiple files.
* @param persistedUriPermission Whether to persist the URI permission across device reboots.
* @param result The MethodChannel result to complete when the picker is closed.
*
* Returns true if the picker was launched successfully, false otherwise.
*
* The result posted back is either a list of URIs as a String, or null (if the user has
* cancelled the picker) or an error
*/
fun pickMedia(
activity: Activity,
startLocation: SharedStorage,
multipleAllowed: Boolean = false,
persistedUriPermission: Boolean = false,
result: MethodChannel.Result
): Boolean {
if (pendingResult != null) {
Log.w(TAG, "Media picker already in progress")
return false
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && SdkExtensions.getExtensionVersion(
Build.VERSION_CODES.R
) >= 2
) {
// on Android version >R.2 we use the new photo picker
val photoPickerIntent = Intent(MediaStore.ACTION_PICK_IMAGES).apply {
if (multipleAllowed) {
val maxImages = MediaStore.getPickImagesMaxLimit()
putExtra(MediaStore.EXTRA_PICK_IMAGES_MAX, maxImages)
}
}
photoPickerIntent.apply {
if (startLocation == SharedStorage.images) {
type = "image/*"
} else if (startLocation == SharedStorage.video) {
type = "video/*"
}
}
this.persistedUriPermission = persistedUriPermission // for later reference
this.multipleAllowed = multipleAllowed // for later reference
pendingResult = result
activity.startActivityForResult(photoPickerIntent, REQUEST_CODE_PICK_MEDIA)
return true
} else {
// for older Android versions we use the filePicker with image or video extensions
return pickFiles(
activity,
startLocation,
null,
if (startLocation == SharedStorage.images) listOf(
"jpg",
"jpeg",
"png",
"gif",
"webp",
"bmp",
"heic",
"heif",
"svg"
) else listOf("mp4", "webm", "ogg", "webm", "mov", "avi", "mkv"),
multipleAllowed,
persistedUriPermission,
result
)
}
}
/**
* Handles the result from the file picker activity, by setting the [pendingResult] to the
* result value. Grants read access only
*
* @param context The context, needed to persist permissions
* @param requestCode The request code passed to startActivityForResult().
* @param resultCode The result code returned by the child activity.
* @param data An Intent, which can return result data to the caller.
* @return True if the result was handled, false otherwise.
*/
fun handleActivityResult(
context: Context,
requestCode: Int,
resultCode: Int,
data: Intent?
): Boolean {
if (requestCode == REQUEST_CODE_PICK_FILES || requestCode == REQUEST_CODE_PICK_MEDIA) {
if (resultCode == Activity.RESULT_OK) {
val uris = mutableListOf<String>()
if (multipleAllowed && data?.clipData != null) {
// Multiple files selected
val clipData = data.clipData!!
for (i in 0 until clipData.itemCount) {
val uri = clipData.getItemAt(i).uri
if (persistedUriPermission) {
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
}
uris.add(uri.toString())
}
} else if (data?.data != null) {
// Single file selected
val uri = data.data!!
if (persistedUriPermission) {
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
}
uris.add(uri.toString())
}
pendingResult?.success(uris)
} else {
pendingResult?.success(null) // cancelled
}
pendingResult = null // Reset the pending result
return true
}
return false
}
/**
* Converts a list of file extensions to a list of corresponding MIME types using the provided `getMimeType` function.
*
* @param extensions The list of file extensions (without the dot).
* @return A list of MIME types.
*/
private fun extensionsToMimeTypes(extensions: List<String>): Array<String> {
return extensions.map { extension ->
if (extension.startsWith('.'))
getMimeType(extension)
else getMimeType("file.$extension")
}.toSet().toTypedArray()
}
}
/**
* Helper class for creating a new directory using the Android SAF or file system.
*/
object DirectoryCreator {
const val TAG = "DirectoryCreator"
/**
* Creates a new directory within the given parent directory.
* If the new directory path contains multiple levels, it creates all intermediate directories as needed.
*
* @param context The application context.
* @param parentDirectoryUri The URI of the parent directory (file:// or content://).
* @param newDirectoryName The name of the new directory to create (can be a path).
* @param persistedUriPermission Whether to persist the URI permission across device reboots.
* @param result The MethodChannel result to complete when the operation is finished.
*
* Returns true if the directory creation was initiated successfully, false otherwise.
*
* The result posted back is either a URI string, or an error
*/
fun createDirectory(
context: Context,
parentDirectoryUri: Uri,
newDirectoryName: String,
persistedUriPermission: Boolean,
result: MethodChannel.Result
) {
if (parentDirectoryUri.scheme == "file") {
// Handle file scheme URIs using standard file operations
val parentDir = File(parentDirectoryUri.path!!)
if (!parentDir.exists() || !parentDir.isDirectory) {
result.error(
"INVALID_PARENT_URI",
"Invalid or inaccessible parent directory URI",
null
)
return
}
val sanitizedDirectoryName = newDirectoryName.trim(File.separatorChar)
val newDir = File(parentDir, sanitizedDirectoryName)
try {
if (!newDir.exists()) {
if (!newDir.mkdirs()) {
result.error(
"CREATE_FAILED",
"Failed to create directory: ${newDir.absolutePath}",
null
)
return
}
}
result.success(Uri.fromFile(newDir).toString())
} catch (e: Exception) {
result.error(
"CREATE_FAILED",
"Error creating directory: ${e.message}",
null
)
}
} else {
// handle content Uri scheme using DocumentFile API
val parentDir = DocumentFile.fromTreeUri(context, parentDirectoryUri)
if (parentDir == null || !parentDir.exists() || !parentDir.isDirectory) {
result.error(
"INVALID_PARENT_URI",
"Invalid or inaccessible parent directory URI",
null
)
return
}
// Sanitize the new directory name by removing leading/trailing path separators
val sanitizedDirectoryName = newDirectoryName.trim(File.separatorChar)
try {
var currentDir: DocumentFile = parentDir
val directoryPathParts = sanitizedDirectoryName.split(File.separatorChar)
for (directoryName in directoryPathParts) {
var newDir = currentDir.findFile(directoryName)
if (newDir == null || !newDir.exists()) {
newDir = currentDir.createDirectory(directoryName)
} else if (!newDir.isDirectory) {
result.error(
"INVALID_PATH",
"Invalid path: $directoryName is not a directory",
null
)
return
}
if (newDir == null) {
result.error(
"CREATE_FAILED",
"Failed to create directory: $directoryName",
null
)
return
}
currentDir = newDir
// Grant permissions to intermediate directories as well
if (persistedUriPermission) {
context.contentResolver.takePersistableUriPermission(
newDir.uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
}
}
// Return the URI of the final directory
result.success(currentDir.uri.toString())
} catch (e: Exception) {
result.error(
"CREATE_FAILED",
"Error creating directory: ${e.message}",
null
)
}
}
}
}
/**
* Gets the initial directory URI based on the specified shared storage location.
*
* @param location The shared storage location.
* @return The URI of the corresponding directory.
*/
private fun getInitialDirectoryUri(location: SharedStorage): Uri {
return when (location) {
SharedStorage.downloads -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
MediaStore.Downloads.EXTERNAL_CONTENT_URI
} else {
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
.toUri()
}
}
SharedStorage.images -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
"content://com.android.externalstorage.documents/tree/primary%3APictures".toUri()
} else {
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
.toUri()
}
}
SharedStorage.video -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
"content://com.android.externalstorage.documents/tree/primary%3AMovies".toUri()
} else {
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES)
.toUri()
}
}
SharedStorage.audio -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
"content://com.android.externalstorage.documents/tree/primary%3AMusic".toUri()
} else {
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC)
.toUri()
}
}
SharedStorage.external -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
"content://com.android.externalstorage.documents/tree/secondary%3A".toUri()
} else {
Environment.getExternalStorageDirectory().toUri()
}
}
SharedStorage.files -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
"content://com.android.externalstorage.documents/tree/primary%3A".toUri()
} else {
Environment.getDataDirectory().toUri()
}
}
}
}
@@ -0,0 +1,179 @@
package com.bbflight.background_downloader
import android.content.Context
import android.net.NetworkCapabilities
import android.os.Build
import android.util.Log
import androidx.preference.PreferenceManager
import androidx.work.Constraints
import androidx.work.NetworkType
import androidx.work.WorkInfo
import androidx.work.WorkManager
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
/**
* Processes WiFi requirement changes
*
* requireWiFiQueue:
* Each item is a [RequireWiFiChange] data object, and each workInfo
* will be either rescheduled (if enqueued) or paused and resumed (if running and
* possible) or cancelled and re-enqueued (if running and pause not possible
*
* reEnqueueQueue:
* Each item is a [ReEnqueue] data object represent one task re-enqueue. The task
* will be re-enqueued with an appropriate delay
*/
object WiFi {
private val scope = CoroutineScope(Dispatchers.Default)
private val requireWiFiQueue =
Channel<RequireWiFiChange>(capacity = Channel.UNLIMITED)
private val reEnqueueQueue = Channel<suspend () -> Unit>(capacity = Channel.UNLIMITED)
// completed once the current change's re-enqueues are enqueued
@Volatile
private var batchEnd: CompletableDeferred<Unit>? = null
init {
scope.launch {
for (change in requireWiFiQueue) {
val thisBatchEnd = CompletableDeferred<Unit>()
batchEnd = thisBatchEnd
try {
// wait for the re-enqueues before the next change, with a timeout
if (change.execute() && withTimeoutOrNull(15000) { thisBatchEnd.await() } == null) {
val clearedTaskIds = synchronized(BDPlugin.tasksToReEnqueue) {
BDPlugin.tasksToReEnqueue.map { it.taskId }
.also { BDPlugin.tasksToReEnqueue.clear() }
}
Log.w(
BDPlugin.TAG,
"WiFi re-enqueue watchdog timeout, cleared taskIds $clearedTaskIds"
)
}
} catch (e: Exception) {
Log.e(BDPlugin.TAG, "Exception during requireWiFiChange execution: $e")
}
}
}
scope.launch {
for (reEnqueue in reEnqueueQueue) {
try {
reEnqueue()
} catch (e: Exception) {
Log.e(BDPlugin.TAG, "Exception during WiFi reEnqueue: $e")
}
}
}
}
/**
* Execute the requireWiFi change request
*/
suspend fun requireWiFiChange(change: RequireWiFiChange) {
requireWiFiQueue.send(change)
}
/**
* Re-enqueue this task and associated data. Null signals end of batch
*/
suspend fun reEnqueue(enqueueItem: EnqueueItem?) {
if (enqueueItem != null) {
reEnqueueQueue.send { enqueueItem.reEnqueue() }
} else {
// captured now, so a late signal cannot end a later batch
val thisBatchEnd = batchEnd
reEnqueueQueue.send { thisBatchEnd?.complete(Unit) }
}
}
}
/**
* Change the global WiFi requirement setting
*/
class RequireWiFiChange(
private val applicationContext: Context,
private val requireWifi: RequireWiFi,
private val rescheduleRunningTasks: Boolean,
private val alsoRestartUploads: Boolean
) {
/**
* Execute the change in WiFi requirement and return
* true if one or more tasks have been scheduled for re-enqueue
*/
suspend fun execute(): Boolean {
BDPlugin.requireWifi = requireWifi
val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext)
prefs.edit().apply {
putInt(BDPlugin.keyRequireWiFi, requireWifi.ordinal)
apply()
}
val tasksMap = getTaskMap(prefs)
val workManager = WorkManager.getInstance(applicationContext)
val workInfos = workManager.getWorkInfosByTag(BDPlugin.TAG).get()
.filter { !it.state.isFinished }
// collected first and all added to tasksToReEnqueue before any is canceled or paused,
// so the set can't run empty, which ends the batch, before the last task is added
val tasksToCancel = mutableListOf<Task>()
val tasksToPause = mutableListOf<Task>()
for (workInfo in workInfos) {
val tags = workInfo.tags.filter { it.contains("taskId=") }
if (tags.isNotEmpty()) {
val taskId = tags.first().substring(7)
val task = tasksMap[taskId]
if (task != null && (task.isDownloadTask() || task.isUploadTask())) {
// compare with the constraints the work was enqueued with, which WorkManager
// keeps across app restarts, so work enqueued by an earlier process is included
if (BDPlugin.taskRequiresWifi(task) != workInfo.constraints.requiresWifi()) {
when (workInfo.state) {
WorkInfo.State.ENQUEUED -> tasksToCancel.add(task)
WorkInfo.State.RUNNING -> {
if (rescheduleRunningTasks && task.isDownloadTask()) {
tasksToPause.add(task)
} else if (alsoRestartUploads && task.isUploadTask()) {
tasksToCancel.add(task)
}
}
else -> {}
}
}
}
}
}
BDPlugin.tasksToReEnqueue.addAll(tasksToCancel + tasksToPause)
tasksToPause.forEach { BDPlugin.pauseTaskWithId(it.taskId) }
for (task in tasksToCancel) {
// the cancel's update is intercepted within cancelActiveTaskWithId, so an entry still
// there means it sent none: the task finished before it was added, and its entry
// would otherwise stay until the timeout
if (!BDPlugin.cancelActiveTaskWithId(applicationContext, task.taskId, workManager) ||
BDPlugin.tasksToReEnqueue.contains(task)
) {
BDPlugin.tasksToReEnqueue.remove(task)
if (BDPlugin.tasksToReEnqueue.isEmpty()) {
WiFi.reEnqueue(null) // nothing left to re-enqueue, so end the batch
}
}
}
return tasksToCancel.isNotEmpty() || tasksToPause.isNotEmpty()
}
/**
* Return true if these [Constraints] restrict the work to WiFi, as set in [BDPlugin.doEnqueue]
*
* From API 28 the WiFi requirement is a NetworkRequest, and WorkManager then stores
* NetworkType.NOT_REQUIRED as the network type. Before API 28 it is NetworkType.UNMETERED
*/
private fun Constraints.requiresWifi(): Boolean =
(Build.VERSION.SDK_INT >= 28 &&
requiredNetworkRequest?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true) ||
requiredNetworkType == NetworkType.UNMETERED
}
@@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M12,2C6.47,2 2,6.47 2,12s4.47,10 10,10 10,-4.47 10,-10S17.53,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8zM15.59,7L12,10.59 8.41,7 7,8.41 10.59,12 7,15.59 8.41,17 12,13.41 15.59,17 17,15.59 13.41,12 17,8.41z"/>
</vector>
@@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M5,18h14v2L5,20v-2zM9.6,15.3L5,10.7l2,-1.9 2.6,2.6L17,4l2,2 -9.4,9.3z"/>
</vector>
@@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M11,15h2v2h-2v-2zM11,7h2v6h-2L11,7zM11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z"/>
</vector>
@@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M18,15v3H6v-3H4v3c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2v-3H18zM17,11l-1.41,-1.41L13,12.17V4h-2v8.17L8.41,9.59L7,11l5,5L17,11z"/>
</vector>
@@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M18,15v3H6v-3H4v3c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2v-3H18zM7,9l1.41,1.41L11,7.83V16h2V7.83l2.59,2.58L17,9l-5,-5L7,9z"/>
</vector>
@@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z"/>
</vector>
@@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M10,8.64L15.27,12 10,15.36V8.64M8,5v14l11,-7L8,5z"/>
</vector>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="bg_downloader_cancel">Cancel</string>
<string name="bg_downloader_pause">Pause</string>
<string name="bg_downloader_resume">Resume</string>
<string name="bg_downloader_notification_channel_name">Downloads</string>
<string name="bg_downloader_notification_channel_description">Updates on downloads and
uploads in progress</string>
</resources>
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path
name="external-path"
path="."/>
<external-cache-path
name="external-cache-path"
path="."/>
<external-files-path
name="external-files-path"
path="."/>
<files-path
name="files_path"
path="."/>
<cache-path
name="cache-path"
path="."/>
<external-media-path
name="external_media"
path="." />
</paths>
@@ -0,0 +1,109 @@
package com.bbflight.background_downloader
import org.junit.Test
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
class AuthTest {
private fun createAuth(
accessToken: String? = null,
accessHeaders: Map<String, String> = emptyMap(),
accessQueryParams: Map<String, String> = emptyMap(),
accessTokenExpiryTime: Long? = null,
refreshToken: String? = null,
refreshHeaders: Map<String, String> = emptyMap(),
refreshQueryParams: Map<String, String> = emptyMap(),
refreshUrl: String? = null,
onAuthRawHandle: Long? = null
): Auth = Auth(
accessToken = accessToken,
accessHeaders = accessHeaders,
accessQueryParams = accessQueryParams,
accessTokenExpiryTime = accessTokenExpiryTime,
refreshToken = refreshToken,
refreshHeaders = refreshHeaders,
refreshQueryParams = refreshQueryParams,
refreshUrl = refreshUrl,
onAuthRawHandle = onAuthRawHandle
)
@Test
fun `getExpandedAccessHeaders replaces tokens correctly`() {
val auth = createAuth(
accessToken = "access123",
refreshToken = "refresh123",
accessHeaders = mapOf("Authorization" to "Bearer {accessToken}", "Refresh" to "{refreshToken}")
)
val expandedHeaders = auth.getExpandedAccessHeaders()
assertEquals("Bearer access123", expandedHeaders["Authorization"])
assertEquals("refresh123", expandedHeaders["Refresh"])
}
@Test
fun `getExpandedAccessQueryParams replaces tokens correctly`() {
val auth = createAuth(
accessToken = "access456",
refreshToken = "refresh456",
accessQueryParams = mapOf("token" to "A {accessToken}", "refresh" to "A {refreshToken}")
)
val expandedParams = auth.getExpandedAccessQueryParams()
assertEquals("A access456", expandedParams["token"])
assertEquals("A refresh456", expandedParams["refresh"])
}
@Test
fun `addOrUpdateQueryParams adds new query params to URL`() {
// ignored because we would have to mock Uri.parse
// val auth = createAuth()
// val url = "https://example.com/api"
// val queryParams = mapOf("param1" to "value1", "param2" to "value2")
// val updatedUri = auth.addOrUpdateQueryParams(url, queryParams)
// assertEquals("https://example.com/api?param1=value1&param2=value2", updatedUri.toString())
}
@Test
fun `addOrUpdateQueryParams updates existing query params`() {
// ignored because we would have to mock Uri.parse
// val auth = createAuth()
// val url = "https://example.com/api?param1=oldValue"
// val queryParams = mapOf("param1" to "newValue")
// val updatedUri = auth.addOrUpdateQueryParams(url, queryParams)
// assertEquals("https://example.com/api?param1=newValue", updatedUri.toString())
}
@Test
fun `isTokenExpired returns true when token is expired`() {
val expiryTime = System.currentTimeMillis() - 10000 // Expired 10 seconds ago
val auth = createAuth(accessTokenExpiryTime = expiryTime)
assertTrue(auth.isTokenExpired())
}
@Test
fun `isTokenExpired returns false when token is not expired`() {
val expiryTime = System.currentTimeMillis() + 60000 // Expires in 1 minute
val auth = createAuth(accessTokenExpiryTime = expiryTime)
assertFalse(auth.isTokenExpired())
}
@Test
fun `isTokenExpired applies buffer time correctly`() {
val expiryTime = System.currentTimeMillis() + 5000 // Expires in 5 seconds
val auth = createAuth(accessTokenExpiryTime = expiryTime)
assertTrue(auth.isTokenExpired(bufferTime = 7000)) // Buffer time makes it appear expired
assertFalse(auth.isTokenExpired(bufferTime = 3000)) // Within buffer, not expired
}
@Test
fun `hasOnAuthCallback returns true when onAuthRawHandle is set`() {
val auth = createAuth(onAuthRawHandle = 1234L)
assertTrue(auth.hasOnAuthCallback())
}
@Test
fun `hasOnAuthCallback returns false when onAuthRawHandle is not set`() {
val auth = createAuth(onAuthRawHandle = null)
assertFalse(auth.hasOnAuthCallback())
}
}
@@ -0,0 +1,35 @@
package com.bbflight.background_downloader
//val urlWithContentLength = "https://storage.googleapis.com/approachcharts/test/5MB-test.ZIP"
//var task = Task(mapOf("url" to urlWithContentLength, "filename" to "taskFilename.txt"))
//
//
//class HelpersTest {
//
// @Test
// fun parseRange() {
// assertEquals(Pair(10L, 20L), parseRange("bytes=10-20"))
// assertEquals(Pair(0L, 20L), parseRange("bytes=-20"))
// assertEquals(Pair(10L, null), parseRange("bytes=10-"))
// assertEquals(Pair(0L, null), parseRange(""))
// }
//
// @Test
// fun getContentLength() {
// var h = mapOf<String, List<String>>()
// assertEquals(-1, getContentLength(h, task))
// h = mapOf("Content-Length" to listOf("123"))
// assertEquals(123, getContentLength(h, task))
// h = mapOf("content-length" to listOf("123"))
// assertEquals(123, getContentLength(h, task))
// task = task.copyWith(headers = mapOf("Range" to "bytes=0-20"))
// h = mapOf()
// assertEquals(21, getContentLength(h, task))
// task = task.copyWith(headers = mapOf("Known-Content-Length" to "456"))
// assertEquals(456, getContentLength(h, task))
// task = task.copyWith(headers = mapOf("Known-Content-Length" to "456", "Range" to "bytes=0-20"))
// assertEquals(21, getContentLength(h, task))
// h = mapOf("Content-Length" to listOf("123"))
// assertEquals(123, getContentLength(h, task))
// }
//}
@@ -0,0 +1,33 @@
package com.bbflight.background_downloader
import org.junit.Assert.assertEquals
import org.junit.Test
class ReRunDecisionTest {
@Test
fun firstRunRuns() {
assertEquals(ReRunDecision.run, reRunDecision(0, true, emptyList()))
// also when its task left the tasks map, or another work exists
assertEquals(ReRunDecision.run, reRunDecision(0, false, emptyList()))
assertEquals(ReRunDecision.run, reRunDecision(0, true, listOf("other")))
}
@Test
fun reRunOfUnfinishedTaskRuns() {
assertEquals(ReRunDecision.run, reRunDecision(1, true, emptyList()))
assertEquals(ReRunDecision.run, reRunDecision(3, true, emptyList()))
}
@Test
fun reRunOfFinishedTaskIsSkipped() {
assertEquals(ReRunDecision.skipFinished, reRunDecision(1, false, emptyList()))
}
@Test
fun reRunWithAnotherUnfinishedWorkIsSkipped() {
assertEquals(ReRunDecision.skipSuperseded, reRunDecision(1, true, listOf("other")))
// the other work holds the slot, so superseded wins over finished
assertEquals(ReRunDecision.skipSuperseded, reRunDecision(2, false, listOf("other")))
}
}
@@ -0,0 +1,32 @@
package com.bbflight.background_downloader
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Test
@Serializable
data class Holder(val dir: BaseDirectory)
class SerializationTest {
@Test
fun encodeEnum() {
var enc = bdJson.encodeToString(Holder(BaseDirectory.applicationDocuments))
assertEquals("{\"dir\":0}", enc)
enc = bdJson.encodeToString(Holder(BaseDirectory.applicationLibrary))
assertEquals("{\"dir\":3}", enc)
val dec = bdJson.decodeFromString<Holder>(enc)
assertEquals(BaseDirectory.applicationLibrary, dec.dir)
}
@Test
fun ignoreUnknownKeysTest() {
val jsonWithUnknownKeys = "{\"dir\":0,\"unknownField\":123,\"anotherUnknown\":\"xyz\"}"
val dec = bdJson.decodeFromString<Holder>(jsonWithUnknownKeys)
assertEquals(BaseDirectory.applicationDocuments, dec.dir)
}
}
@@ -0,0 +1,24 @@
#
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html.
# Run `pod lib lint background_downloader.podspec` to validate before publishing.
#
Pod::Spec.new do |s|
s.name = 'background_downloader'
s.version = '0.0.1'
s.summary = 'A new Flutter plugin project.'
s.description = <<-DESC
A new Flutter plugin project.
DESC
s.homepage = 'http://example.com'
s.license = { :file => '../LICENSE' }
s.author = { 'Your Company' => 'email@example.com' }
s.source = { :path => '.' }
s.source_files = 'background_downloader/Sources/background_downloader/**/*.swift'
s.dependency 'Flutter'
s.platform = :ios, '14.0'
# Flutter.framework does not contain a i386 slice.
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' }
s.swift_version = '5.0'
s.resource_bundles = {'background_downloader_privacy' => ['background_downloader/Sources/background_downloader/PrivacyInfo.xcprivacy']}
end
@@ -0,0 +1,41 @@
// swift-tools-version: 5.9
// The swift-tools-version declares the minimum version of Swift required to build this package.
import Foundation
import PackageDescription
var swiftSettings: [SwiftSetting] = []
if ProcessInfo.processInfo.environment["BYPASS_PERMISSION_NOTIFICATIONS"] == "1" {
swiftSettings.append(.define("BYPASS_PERMISSION_NOTIFICATIONS"))
}
if ProcessInfo.processInfo.environment["BYPASS_PERMISSION_IOSADDTOPHOTOLIBRARY"] == "1" {
swiftSettings.append(.define("BYPASS_PERMISSION_IOSADDTOPHOTOLIBRARY"))
}
if ProcessInfo.processInfo.environment["BYPASS_PERMISSION_IOSCHANGEPHOTOLIBRARY"] == "1" {
swiftSettings.append(.define("BYPASS_PERMISSION_IOSCHANGEPHOTOLIBRARY"))
}
let package = Package(
name: "background_downloader",
platforms: [
.iOS("14.0")
],
products: [
.library(name: "background-downloader", targets: ["background_downloader"])
],
dependencies: [
.package(name: "FlutterFramework", path: "../FlutterFramework")
],
targets: [
.target(
name: "background_downloader",
dependencies: [
.product(name: "FlutterFramework", package: "FlutterFramework")
],
resources: [
.process("PrivacyInfo.xcprivacy"),
],
swiftSettings: swiftSettings.isEmpty ? nil : swiftSettings
)
]
)
@@ -0,0 +1,92 @@
//
// Auth.swift
//
// Created by Bram on 11/8/24.
//
import Foundation
struct Auth: Codable, Hashable {
var accessToken: String?
var accessHeaders: [String: String] = [:]
var accessQueryParams: [String: String] = [:]
var accessTokenExpiryTime: Int64?
var refreshToken: String?
var refreshHeaders: [String: String] = [:]
var refreshQueryParams: [String: String] = [:]
var refreshUrl: String?
var onAuthRawHandle: Int64?
/**
* Returns the headers specified for the access request, with the
* template `{accessToken}` and `{refreshToken}` replaced.
*/
func getExpandedAccessHeaders() -> [String: String] {
return expandMap(accessHeaders)
}
/**
* Returns the query parameters specified for the access request, with the
* template `{accessToken}` and `{refreshToken}` replaced.
*/
func getExpandedAccessQueryParams() -> [String: String] {
return expandMap(accessQueryParams)
}
/// Add/update the query parameters in this `url` with `queryParams`
/// and return the new URI.
func addOrUpdateQueryParams(
url: String,
queryParams: [String: String] = [:]
) -> URL {
guard let startUri = URL(string: url) else {
// Handle invalid URL gracefully, e.g., return the original URL or throw an error
return URL(string: url)!
}
guard !queryParams.isEmpty else { return startUri }
var components = URLComponents(url: startUri, resolvingAgainstBaseURL: false)!
var updatedQueryParams = components.queryItems?.reduce(into: [String: String]()) { (result, item) in
result[item.name] = item.value
} ?? [:]
for (key, value) in queryParams {
updatedQueryParams[key] = value
}
components.queryItems = updatedQueryParams.map { URLQueryItem(name: $0.key, value: $0.value) }
return components.url!
}
/// Returns true if the `accessTokenExpiryTime` is after now plus
/// the `bufferTime`, otherwise returns false.
func isTokenExpired(bufferTime: Int64 = 10000) -> Bool {
guard let expiry = accessTokenExpiryTime else { return false }
let expiryTimeWithBuffer = Int64(Date().timeIntervalSince1970 * 1000) + bufferTime
return expiryTimeWithBuffer > expiry
}
/// Expands the [mapToExpand] by replacing {accessToken} and {refreshToken}
///
/// Returns the expanded map, without changing the original
private func expandMap(_ mapToExpand: [String: String]) -> [String: String] {
var newMap: [String: String] = [:]
for (key, value) in mapToExpand {
var newValue = value
if let accessToken = accessToken {
newValue = newValue.replacingOccurrences(of: "{accessToken}", with: accessToken)
}
if let refreshToken = refreshToken {
newValue = newValue.replacingOccurrences(of: "{refreshToken}", with: refreshToken)
}
newMap[key] = newValue
}
return newMap
}
/// True if this `Auth` object has an 'onAuth' callback that can be called to refresh the
/// access token.
func hasOnAuthCallback() -> Bool {
return onAuthRawHandle != nil
}
}
@@ -0,0 +1,16 @@
//
// BackgroundDownloaderPlugin.swift
// background_downloader
//
// Created by Bram on 1/9/25.
//
import Flutter
import UIKit
@objc(BackgroundDownloaderPlugin) // Keep the Objective-C name for compatibility
public class BackgroundDownloaderPlugin: NSObject, FlutterPlugin {
public static func register(with registrar: FlutterPluginRegistrar) {
BDPlugin.register(with: registrar)
}
}
@@ -0,0 +1,81 @@
//
// Callbacks.swift
// background_downloader
//
// Created by Bram on 10/26/24.
//
import Foundation
import Flutter
import os.log
import UIKit
/// Helper function to get the callback channel with retry
private func getCallbackChannel() async -> FlutterMethodChannel? {
var retries = 0
while BDPlugin.callbackChannel == nil && retries < 5 {
try? await _Concurrency.Task.sleep(nanoseconds: 100_000_000) // 0.1 second
retries += 1
}
return BDPlugin.callbackChannel
}
/// Invoke a callback in Dart (via the method channel) and return the returned Task value or nil
private func invokeCallback(withMethod methodName: String, forTask task: Task) async -> Any? {
guard let callbackChannel = await getCallbackChannel() else {
os_log("Could not invoke %{public}@ for taskId %@: callbackChannel not set", log: log, type: .error, methodName, task.taskId)
return nil
}
return await withCheckedContinuation { continuation in
os_log("Invoking %{public}@ for taskId %@", log: log, type: .info, methodName, task.taskId)
callbackChannel.invokeMethod(methodName, arguments: jsonStringFor(task: task), result: { result in
guard let jsonString = result as? String else {
if let error = result as? FlutterError {
os_log("Error invoking %{public}@: %@", log: log, type: .error, methodName, error.message ?? "nil")
}
continuation.resume(returning: nil)
return
}
if methodName == "beforeTaskStartCallback" {
continuation.resume(returning: try? JSONDecoder().decode(TaskStatusUpdate.self, from: (jsonString).data(using: .utf8)!))
}
else {
continuation.resume(returning: taskFrom(jsonString: jsonString))
}
})
}
}
/// Invoke the onTaskStartCallback in Dart (via the method channel) and return the returned Task value or nil
func invokeBeforeTaskStartCallback(task: Task) async -> TaskStatusUpdate? {
return await invokeCallback(withMethod: "beforeTaskStartCallback", forTask: task) as? TaskStatusUpdate
}
/// Invoke the onTaskStartCallback in Dart (via the method channel) and return the returned Task value or nil
func invokeOnTaskStartCallback(task: Task) async -> Task? {
return await invokeCallback(withMethod: "onTaskStartCallback", forTask: task) as? Task
}
/// Invoke the onAuthCallback in Dart (via the method channel) and return the returned Task value or nil
func invokeOnAuthCallback(task: Task) async -> Task? {
return await invokeCallback(withMethod: "onAuthCallback", forTask: task) as? Task
}
/// Invoke the onTaskFinishedCallback in Dart (via the method channel) and return true if successful
func invokeOnTaskFinishedCallback(taskStatusUpdate: TaskStatusUpdate) async -> Bool {
guard let callbackChannel = await getCallbackChannel() else {
os_log("Could not invoke onTaskFinishedCallback: callbackChannel not set", log: log, type: .error)
return false
}
return await withCheckedContinuation { continuation in
callbackChannel.invokeMethod("onTaskFinishedCallback", arguments: jsonStringFor(taskStatusUpdate: taskStatusUpdate), result: { result in
if let error = result as? FlutterError {
os_log("Error invoking onTaskFinishedCallback: %@", log: log, type: .error, error.message ?? "nil")
continuation.resume(returning: false)
} else {
continuation.resume(returning: true)
}
})
}
}
@@ -0,0 +1,128 @@
//
// Helpers.swift
// background_downloader
//
// Created by Bram on 12/31/23.
//
import Foundation
import UIKit
import UniformTypeIdentifiers
import os.log
extension URL {
/// Uses .appending for iOS 16 and up, and .appendingPathComponent
/// for earlier versions
func appendingPath(_ component: String, isDirectory: Bool = false) -> URL {
if #available(iOS 16.0, *) {
return appending(path: component, directoryHint: isDirectory ? .isDirectory : .notDirectory)
} else {
return appendingPathComponent(component, isDirectory: isDirectory)
}
}
/// Excludes URL from backup
mutating func setCloudBackup(exclude: Bool) throws {
var resource = URLResourceValues()
resource.isExcludedFromBackup = exclude
try self.setResourceValues(resource)
}
}
/// Returns the task's URL if it can be parsed, otherwise null
func validateUrl(_ task: Task) -> URL? {
let url: URL?
// encodingInvalidCharacters is only available when compiling with Xcode 15, which uses Swift version 5.9
#if swift(>=5.9)
if #available(iOS 17.0, *) {
url = URL(string: task.url, encodingInvalidCharacters: false)
} else {
url = URL(string: task.url)
}
#else
url = URL(string: task.url)
#endif
return url
}
/// Converts the [map] to a [String:String] map with lowercased keys
func lowerCasedStringStringMap(_ map: [AnyHashable: Any]?) -> [String: String]? {
if map == nil {return nil}
var result: [String: String] = [:]
for (key, value) in map! {
if let stringKey = key as? String, let stringValue = value as? String {
result[stringKey.lowercased()] = stringValue
}
}
return result
}
/// Create a temp file that contains a subset of the [fileURL] based on [start] and [contentLength]
///
/// Returns the URL of the temp file, or nil if there was a problem (problem is logged)
func createTempFileWithRange(from fileURL: URL, start: UInt64, contentLength: UInt64) -> URL? {
let fileManager = FileManager.default
let tempDir = fileManager.temporaryDirectory
let tempFileURL = tempDir.appendingPathComponent(UUID().uuidString) // Create a unique temporary file
// Create the temporary file
fileManager.createFile(atPath: tempFileURL.path, contents: nil, attributes: nil)
guard let inputHandle = try? FileHandle(forReadingFrom: fileURL),
let outputHandle = try? FileHandle(forWritingTo: tempFileURL) else {
os_log("Cannot open file handles for partial upload temporary file creation", log: log, type: .error)
return nil
}
defer {
try? inputHandle.close()
try? outputHandle.close()
}
let bufferSize = 1024 * 1024 // 1MB chunks
var remainingBytes = contentLength
do {
// Seek to the start position
try inputHandle.seek(toOffset: start)
while remainingBytes > 0 {
let bytesToRead = Int(min(UInt64(bufferSize), remainingBytes))
guard let data = try inputHandle.read(upToCount: bytesToRead), !data.isEmpty else {
break // EOF
}
try outputHandle.write(contentsOf: data)
remainingBytes -= UInt64(data.count)
}
} catch {
os_log("Cannot create temporary file for partial upload: %@", log: log, type: .error,
error.localizedDescription)
return nil
}
return tempFileURL
}
/**
Returns mimetype of a filename based on its extension, or application/octet-stream
*/
func getMimeType(fromFilename filename: String) -> String {
// Extract the file extension from the filename
let fileExtension = (filename as NSString).pathExtension
if let type = UTType(filenameExtension: fileExtension) {
return type.preferredMIMEType ?? "application/octet-stream"
}
// Default MIME type if unable to determine
return "application/octet-stream"
}
/// Returns the root view controller of the app, or nil if it cannot be found
/// Handles both the old AppDelegate (window) and the new SceneDelegate (connectedScenes) ways of getting the window
func getRootViewController() -> UIViewController? {
if #available(iOS 13, *) {
let window = UIApplication.shared.connectedScenes
.filter { $0.activationState == .foregroundActive }
.compactMap { $0 as? UIWindowScene }
.flatMap { $0.windows }
.first { $0.isKeyWindow }
if let root = window?.rootViewController {
return root
}
}
return UIApplication.shared.keyWindow?.rootViewController ?? UIApplication.shared.delegate?.window??.rootViewController
}
@@ -0,0 +1,279 @@
//
// HoldingQueue.swift
// background_downloader
//
// Created by Bram on 3/16/24.
//
import Foundation
import os.log
/**
* Queue that holds [EnqueueItem] items before they are actually enqueued as a URLSessionTask
*
* Configure [maxConcurrent], [maxConcurrentByHost] and [maxConcurrentByGroup] to limit which items
* can be enqueued simultaneously.
*
* Call:
* [add] to add an [EnqueueItem]
* [taskFinished] for all tasks that finish, so we may start a new one
* [cancelAllTasks] to empty the queue (sends status updates)
* [cancelTasksWithIds] to remove specific tasks (sends status updates)
* [allTasks] for a list of [Task] matching a group
* [taskForId] to get the task for a specific taskId
*/
class HoldingQueue {
var maxConcurrent: Int = 1000000
var maxConcurrentByHost: Int = 1000000
var maxConcurrentByGroup: Int = 1000000
var enqueuedTaskIds = [String]()
private var concurrent = 0
private var concurrentByHost = [String: Int]()
private var concurrentByGroup = [String: Int]()
private var queue = [EnqueueItem]() // Using an array as a substitute for a priority queue
let stateLock = AsyncLock()
private var job: DispatchWorkItem? = nil // for advanceQueue in future
/**
* Add [EnqueueItem] [item] to the queue and advance the queue if possible
*/
func add(item: EnqueueItem) async {
await stateLock.lock()
queue.append(item)
queue.sort()
enqueuedTaskIds.append(item.task.taskId)
await registerEnqueue(task: item.task,
notificationConfigJsonString: item.notificationConfigJsonString,
success: true) // for accurate groupnotification count
advanceQueue()
await stateLock.unlock()
}
/**
* Signals to the holdingQueue that a [task] has finished
*
* Adjusts the state variables and advances the queue
*
* To prevent deadlock when called when the lock is already obtained, set [reEntry]
*/
func taskFinished(_ task: Task, reEntry: Bool = false) async {
if !reEntry {
await stateLock.lock()
}
let host = getHost(task)
concurrent -= 1
concurrentByHost[host]? -= 1
concurrentByGroup[task.group]? -= 1
if let index = enqueuedTaskIds.firstIndex(of: task.taskId) {
enqueuedTaskIds.remove(at: index)
}
advanceQueue()
if (!reEntry) {
await stateLock.unlock()
}
}
/**
* Removes all [EnqueueItem] where their taskId is in [taskIds], sends a
* [TaskStatus.canceled] update and returns a list of
* taskIds that were cancelled this way.
*
* Because this is used in combination with the UrlSessions tasks, use of this method
* requires the caller to acquire the [stateLock]
*/
func cancelTasksWithIds(_ taskIds: [String]) -> [String] {
let toRemove = queue.filter( { taskIds.contains($0.task.taskId) } )
toRemove.forEach { item in
processStatusUpdate(task: item.task, status: .canceled)
if let configString = item.notificationConfigJsonString,
let config = notificationConfigFrom(jsonString: configString) {
_Concurrency.Task {
await updateGroupNotification(task: item.task, notificationType: .canceled, notificationConfig: config)
}
}
os_log("Canceled task with id %@", log: log, type: .info, item.task.taskId)
}
queue.removeAll(where: { taskIds.contains($0.task.taskId)})
return toRemove.map { $0.task.taskId }
}
/**
* Cancel (delete) all [EnqueueItem] matching [group], send a
* [TaskStatus.canceled] for each and return the number of items cancelled
*
* Because this is used in combination with the UrlSessions tasks, use of this method
* requires the caller to acquire the [stateLock]
*/
func cancelAllTasks(group: String) -> Int {
let taskIds = queue.filter({ $0.task.group == group }).map { $0.task.taskId }
return cancelTasksWithIds(taskIds).count
}
/**
* Return task matching [taskId], or null
*
* Because this is used in combination with the UrlSessions tasks, use of this method
* requires the caller to acquire the [stateLock]
*/
func taskForId(_ taskId: String) -> Task? {
let tasks = queue.filter( { $0.task.taskId == taskId } ).map { $0.task }
if !tasks.isEmpty {
return tasks.first
}
return nil
}
/**
* Return list of [Task] for this [group]. If [group] is nil al tasks are returned
*
* Because this is used in combination with the UrlSessions tasks, use of this method
* requires the caller to acquire the [stateLock]
*/
func allTasks(group: String?) -> [Task] {
return queue.filter( { group == nil || $0.task.group == group } ).map { $0.task }
}
/**
* Advance the queue by signalling the queue processing coroutine
*
* Also restarts a timer that will advance the queue in 10 seconds, in case
* it dries up
*/
private func advanceQueue() {
DispatchQueue.global().async {
_Concurrency.Task {
await self.processQueue()
}
}
advanceQueueInFuture()
}
private func advanceQueueInFuture() {
job?.cancel()
job = DispatchWorkItem {
_Concurrency.Task {
await self.calculateState()
self.advanceQueue()
}
}
guard let job = job else { return }
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 10, execute: job)
}
/// Processes items in the queue, if possible
private func processQueue() async {
await stateLock.lock()
var itemsToEnqueue = [EnqueueItem]()
while concurrent < maxConcurrent && !queue.isEmpty {
var mustWait = [EnqueueItem]()
var foundItem: EnqueueItem? = nil
while !queue.isEmpty {
let item = queue.removeFirst()
let host = getHost(item.task)
if (concurrentByHost[host] ?? 0) < maxConcurrentByHost &&
(concurrentByGroup[item.task.group] ?? 0) < maxConcurrentByGroup {
concurrent += 1
concurrentByHost[host, default: 0] += 1
concurrentByGroup[item.task.group, default: 0] += 1
foundItem = item
break
} else {
mustWait.append(item)
}
}
queue.append(contentsOf: mustWait)
queue.sort()
if let item = foundItem {
itemsToEnqueue.append(item)
} else {
break
}
}
await stateLock.unlock()
// Enqueue items outside the stateLock to avoid blocking during network HEAD requests
for item in itemsToEnqueue {
await item.enqueue()
}
}
/**
* Calculates the [concurrent], [concurrentByHost] and [concurrentByGroup] values
*
* This is expensive, so is only done initially and when the [advanceQueueInFuture] timer
* fires
*/
private func calculateState() async {
await stateLock.lock()
UrlSessionDelegate.createUrlSession()
guard let urlSessionTasks = await UrlSessionDelegate.urlSession?.allTasks else {
await stateLock.unlock()
return
}
let tasks: [Task] = urlSessionTasks.filter({ $0.state != .completed }).compactMap({ getTaskFrom(urlSessionTask: $0)})
concurrent = tasks.count
concurrentByHost.removeAll()
concurrentByGroup.removeAll()
for task in tasks {
let host = getHost(task)
concurrentByHost[host, default: 0] += 1
concurrentByGroup[task.group, default: 0] += 1
}
await stateLock.unlock()
}
}
/**
* Holds data related to enqueueing a task
*
* Used in the context of changing the RequireWiFi setting (where tasks need to be re-enqueued)
* and in the context of the [HoldingQueue]
*/
struct EnqueueItem : Comparable {
let task: Task
let notificationConfigJsonString: String?
let resumeDataAsBase64String: String
let created = Date()
// Comparable implementation to sort based on task priority and creation time
static func < (lhs: EnqueueItem, rhs: EnqueueItem) -> Bool {
return lhs.task.priority == rhs.task.priority ? lhs.task.creationTime < rhs.task.creationTime : lhs.task.priority < rhs.task.priority
}
static func == (lhs: EnqueueItem, rhs: EnqueueItem) -> Bool {
return lhs.task.priority == rhs.task.priority && lhs.task.creationTime == rhs.task.creationTime
}
func enqueue() async {
let success = await BDPlugin.instance.doEnqueue(taskJsonString: jsonStringFor(task: task) ?? "", notificationConfigJsonString: notificationConfigJsonString, resumeDataAsBase64String: resumeDataAsBase64String)
if !success {
os_log("Delayed or retried enqueue failed for taskId %@", log: log, type: .info, task.taskId)
processStatusUpdate(task: task, status: .failed, taskException: TaskException(type: .general, description: "Delayed or retried enqueue failed"))
await BDPlugin.holdingQueue?.taskFinished(task, reEntry: true)
// register the failure with the notification service (for accurate groupnotification count)
await registerEnqueue(task: task, notificationConfigJsonString: notificationConfigJsonString, success: false)
}
}
}
/// Traditional lock for asyn/await environment
actor AsyncLock {
private var isLocked = false
func lock() async {
while isLocked {
await _Concurrency.Task.yield()
}
isLocked = true
}
func unlock() async {
isLocked = false
}
}
@@ -0,0 +1,483 @@
//
// Notifications.swift
// background_downloader
//
// Created on 3/19/23.
//
import Foundation
import UIKit
import os.log
/// NotificationContents
struct NotificationContents : Codable {
let title: String
let body: String
init(json: [String:Any]) {
title = json["title"] as? String ?? ""
body = json["body"] as? String ?? ""
}
}
struct NotificationConfig : Codable {
let running: NotificationContents?
let complete: NotificationContents?
let error: NotificationContents?
let paused: NotificationContents?
let canceled: NotificationContents?
let progressBar: Bool
let tapOpensFile: Bool
let groupNotificationId: String
}
enum NotificationType : Int {
case running,
complete,
error,
paused,
canceled
}
/// Data and methods related to a notification for a group of tasks
actor GroupNotification {
private static var registry: [String : GroupNotification] = [:]
private static let registryLock = NSLock()
/// Atomically gets (or creates and registers) the notification for this
/// group. Serialised via a lock: this is called concurrently from
/// [updateGroupNotification] for every task status update, and
/// unsynchronised access to the shared dictionary corrupts it
/// (EXC_BAD_ACCESS).
static func groupNotification(named name: String, notificationConfig: NotificationConfig) -> GroupNotification {
registryLock.lock()
defer { registryLock.unlock() }
if let existing = registry[name] {
return existing
}
let created = GroupNotification(name: name, notificationConfig: notificationConfig)
registry[name] = created
return created
}
/// Removes the notification for this group from the registry
static func remove(named name: String) {
registryLock.lock()
defer { registryLock.unlock() }
registry.removeValue(forKey: name)
}
let name: String
let notificationConfig: NotificationConfig
private var notifications: [Task : NotificationType]
init(name: String, notificationConfig: NotificationConfig) {
self.name = name
self.notificationConfig = notificationConfig
self.notifications = [:]
}
/// NotificationId derived from group name
var notificationId: String {
get {
return "groupNotification:\(name)"
}
}
/// Total number of notifications in this group
var numTotal: Int {
get {
return notifications.count
}
}
/// Progress expressed as [numFinished]/[numTotal], except
/// return 2.0 if numTotal is 0, to suggest that progress
/// is undetermined
var progress: Double {
get {
if numTotal == 0 {
return 2.0
} else {
return Double(numFinished) / Double(numTotal)
}
}
}
/// Number of "finished" notifications in this group.
///
/// A "finished" notification is one that is not .running,
/// so includes .complete, .error, .paused
///
var numFinished: Int {
get {
return notifications.filter { (_, v) in v != NotificationType.running }.count
}
}
/// Number of "failed" notifications in this group.
///
/// A "failed" notification is one of type .error
///
var numFailed: Int {
get {
return notifications.filter { (_, v) in v == NotificationType.error }.count
}
}
/// True if all tasks finished, regardless of outcome
var isFinished: Bool {
get {
return numFinished == numTotal
}
}
///
/// Return true if this group has an error
///
var hasError: Bool {
get {
return numFailed > 0
}
}
/// Returns a Set of running tasks in this notificationGroup
var runningTasks: Set<Task> {
get {
return Set(notifications.filter { (_, notificationType) in
notificationType == NotificationType.running
}.keys)
}
}
/// Int representing this group's state. If this number
/// does not change, the group state did not change.
///
/// State is determined by the number of finished notifications
/// and the number of total notifications
private var groupState: Int {
get {
return 1000 * numTotal + numFinished
}
}
/// Update a [task] and [notificationType] to this group,
/// and return True if this led to change in [groupState]
func update(task: Task, notificationType: NotificationType) -> Bool {
guard notifications[task] != notificationType else {
return false
}
let priorState = groupState
notifications[task] = notificationType
return priorState != groupState
}
}
enum NotificationCategory : String, CaseIterable {
case runningWithPause = "running_with_pause";
case runningWithoutPause = "running_without_pause";
case paused = "paused"
case complete = "complete"
case error = "error"
case canceled = "canceled"
}
/// List of all category identifiers
let ourCategories = NotificationCategory.allCases.map { $0.rawValue }
/// Update notification for this [task], based on [notificationType] and [notificationConfig]
func updateNotification(task: Task, notificationType: NotificationType, notificationConfig: NotificationConfig?) {
_Concurrency.Task { @MainActor in // run using concurrency on main thread
if (await getPermissionStatus(for: .notifications)) != .granted {
return
}
if !BDPlugin.haveregisteredNotificationCategories {
registerNotificationCategories()
BDPlugin.haveregisteredNotificationCategories = true
}
let notificationCenter = UNUserNotificationCenter.current()
if notificationConfig?.groupNotificationId.isEmpty ?? true {
// regular notification
var notification: NotificationContents?
switch notificationType {
case .running:
notification = notificationConfig?.running
case .complete:
notification = notificationConfig?.complete
case .error:
notification = notificationConfig?.error
case .paused:
notification = notificationConfig?.paused
case .canceled:
notification = notificationConfig?.canceled
}
if notification == nil {
// remove a previous notification for this task (e.g. 'running'), as on Android
notificationCenter.removeDeliveredNotifications(withIdentifiers: [task.taskId])
return
}
let content = UNMutableNotificationContent()
content.title = await replaceTokens(input: notification!.title, task: task)
content.body = await replaceTokens(input: notification!.body, task: task)
content.userInfo = [
"task": jsonStringFor(task: task) ?? "",
"notificationConfig": jsonStringFor(notificationConfig: notificationConfig!) ?? "",
"notificationType": notificationType.rawValue
]
addNotificationActions(task: task, notificationType: notificationType, content: content, notificationConfig: notificationConfig!)
let request = UNNotificationRequest(identifier: task.taskId,
content: content, trigger: nil)
do {
try await notificationCenter.add(request)
} catch {
os_log("Notification error %@", log: log, type: .info, error.localizedDescription)
}
} else {
// group notification
await updateGroupNotification(task: task, notificationType: notificationType, notificationConfig: notificationConfig!)
}
}
}
/**
* Update notification for this [taskWorker] in group
* [notificationGroupName] and type [notificationType].
*
* A group notification aggregates the state of all tasks in a group and
* presents a notification based on that value
*/
func updateGroupNotification(
task: Task,
notificationType: NotificationType,
notificationConfig: NotificationConfig
) async {
let groupNotificationId = notificationConfig.groupNotificationId
let groupNotification = GroupNotification.groupNotification(named: groupNotificationId, notificationConfig: notificationConfig)
let stateChange = await groupNotification.update(task: task, notificationType: notificationType)
if stateChange {
// need to update the group notification
let notificationCenter = UNUserNotificationCenter.current()
let hasError = await groupNotification.hasError
let isFinished = await groupNotification.isFinished
var notification: NotificationContents?
if isFinished {
if hasError {
notification = groupNotification.notificationConfig.error
} else {
notification = groupNotification.notificationConfig.complete
}
} else {
notification = groupNotification.notificationConfig.running
}
let notificationId = await groupNotification.notificationId
if let notification = notification {
// need to show a notification
let content = UNMutableNotificationContent()
content.title = await replaceTokens(input: notification.title, task: task, progress: await groupNotification.progress, notificationGroup: groupNotification)
content.body = await replaceTokens(input: notification.body, task: task, progress: await groupNotification.progress, notificationGroup: groupNotification)
// check if the notification title or body have changed relative to what may
// already be delivered, to avoid flashing notifications without change
var shouldPost = isFinished
if !shouldPost {
let existingNotifications = await notificationCenter.deliveredNotifications()
let previousNotification = existingNotifications.filter {
$0.request.identifier == notificationId
}
shouldPost = previousNotification.isEmpty || previousNotification.first?.request.content.title != content.title || previousNotification.first?.request.content.body != content.body
}
if shouldPost
{
content.categoryIdentifier = !isFinished
? NotificationCategory.runningWithoutPause.rawValue
: (hasError ? NotificationCategory.error.rawValue : NotificationCategory.complete.rawValue)
let request = UNNotificationRequest(identifier: notificationId,
content: content, trigger: nil)
do {
try await notificationCenter.add(request)
} catch {
os_log("Notification error %@", log: log, type: .info, error.localizedDescription)
}
}
} else {
// remove notification
notificationCenter.removeDeliveredNotifications(withIdentifiers: [notificationId])
}
if isFinished {
// remove only if not re-activated within 5 seconds
try? await _Concurrency.Task.sleep(nanoseconds: 5_000_000_000)
if await groupNotification.isFinished {
GroupNotification.remove(named: groupNotificationId)
}
}
}
}
/// Register that [task] was enqueued, with [success] or failure
///
/// This is only relevant for tasks that are part of a group notification, so that the
/// 'numTotal' count is based on enqueued tasks, not on running tasks (which may be limited
/// by holdingQueue or OS restrictions).
/// [notificationConfigJsonString] is only used to detect if this task has a group notification
func registerEnqueue(task: Task, notificationConfigJsonString: String?, success: Bool) async {
guard let notificationConfigJsonString = notificationConfigJsonString,
let notificationConfig = notificationConfigFrom(jsonString: notificationConfigJsonString),
!notificationConfig.groupNotificationId.isEmpty
else { return }
await updateGroupNotification(task: task, notificationType: success ? .running : .error, notificationConfig: notificationConfig)
}
/// Add action buttons to the notification
///
/// Which button(s) depends on the [notificationType]. Action buttons are defined when defining the notification categories
func addNotificationActions(task: Task, notificationType: NotificationType, content: UNMutableNotificationContent, notificationConfig: NotificationConfig) {
BDPlugin.propertyLock.withLock( {
switch notificationType {
case .running:
content.categoryIdentifier = BDPlugin.taskIdsThatCanResume.contains(task.taskId) && notificationConfig.paused != nil ? NotificationCategory.runningWithPause.rawValue : NotificationCategory.runningWithoutPause.rawValue
case .paused:
content.categoryIdentifier = NotificationCategory.paused.rawValue
case .complete:
content.categoryIdentifier = NotificationCategory.complete.rawValue
case .error:
content.categoryIdentifier = NotificationCategory.error.rawValue
case .canceled:
content.categoryIdentifier = NotificationCategory.canceled.rawValue
}
})
}
/// Returns the notificationType related to this [status]
func notificationTypeForTaskStatus(status: TaskStatus) -> NotificationType {
switch status {
case .enqueued, .running: return NotificationType.running
case .complete: return NotificationType.complete
case .paused: return NotificationType.paused
case .canceled: return NotificationType.canceled
default: return NotificationType.error
}
}
let displayNameRegEx = try! NSRegularExpression(pattern: "\\{displayName\\}", options: NSRegularExpression.Options.caseInsensitive)
let fileNameRegEx = try! NSRegularExpression(pattern: "\\{filename\\}", options: NSRegularExpression.Options.caseInsensitive)
let metaDataRegEx = try! NSRegularExpression(pattern: "\\{metadata\\}", options: NSRegularExpression.Options.caseInsensitive)
let progressRegEx = try! NSRegularExpression(pattern: "\\{progress\\}", options: NSRegularExpression.Options.caseInsensitive)
let networkSpeedRegEx = try! NSRegularExpression(pattern: "\\{networkSpeed\\}", options: NSRegularExpression.Options.caseInsensitive)
let timeRemainingRegEx = try! NSRegularExpression(pattern: "\\{timeRemaining\\}", options: NSRegularExpression.Options.caseInsensitive)
let numFinishedRegEx = try! NSRegularExpression(pattern: "\\{numFinished\\}", options: NSRegularExpression.Options.caseInsensitive)
let numFailedRegEx = try! NSRegularExpression(pattern: "\\{numFailed\\}", options: NSRegularExpression.Options.caseInsensitive)
let numTotalRegEx = try! NSRegularExpression(pattern: "\\{numTotal\\}", options: NSRegularExpression.Options.caseInsensitive)
/// Replace special tokens {filename} and {metadata} with their respective values
func replaceTokens(input: String, task: Task, progress: Double? = nil, notificationGroup: GroupNotification? = nil) async -> String {
let inputString = NSMutableString()
inputString.append(input)
let unpacked = unpack(packedString: task.filename)
let safeFilename = unpacked.filename ?? task.filename
displayNameRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: task.displayName)
fileNameRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: safeFilename)
metaDataRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: task.metaData)
if (progress == nil) {
progressRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: "")}
else {
progressRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: "\(Int(progress! * 100))%")
}
networkSpeedRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: "-- MB/s")
timeRemainingRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: "--:--")
if (notificationGroup != nil) {
let numFinished = await notificationGroup!.numFinished
let numFailed = await notificationGroup!.numFailed
let numTotal = await notificationGroup!.numTotal
numFinishedRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: "\(numFinished)")
numFailedRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: "\(numFailed)")
numTotalRegEx.replaceMatches(in: inputString, range: NSMakeRange(0, inputString.length), withTemplate: "\(numTotal)")
}
return inputString as String
}
/// Registers notification categories and actions for the different notification types
func registerNotificationCategories() {
// get values from shared preferences
let defaults = UserDefaults.standard
let localize = defaults.dictionary(forKey: BDPlugin.keyConfigLocalize)
// define the actions
let cancelAction = UNNotificationAction(identifier: "cancel_action",
title: localize?["Cancel"] as? String ?? "Cancel",
options: [])
let cancelInactiveAction = UNNotificationAction(identifier: "cancel_inactive_action",
title: localize?["Cancel"] as? String ?? "Cancel",
options: [])
let pauseAction = UNNotificationAction(identifier: "pause_action",
title: localize?["Pause"] as? String ?? "Pause",
options: [])
let resumeAction = UNNotificationAction(identifier: "resume_action",
title: localize?["Resume"] as? String ?? "Resume",
options: [])
// Define the notification categories using these actions
let runningWithPauseCategory =
UNNotificationCategory(identifier: NotificationCategory.runningWithPause.rawValue,
actions: [cancelAction, pauseAction],
intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: "",
options: .customDismissAction)
let runningWithoutPauseCategory =
UNNotificationCategory(identifier: NotificationCategory.runningWithoutPause.rawValue,
actions: [cancelAction],
intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: "",
options: .customDismissAction)
let pausedCategory =
UNNotificationCategory(identifier: NotificationCategory.paused.rawValue,
actions: [cancelInactiveAction, resumeAction],
intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: "",
options: .customDismissAction)
let completeCategory =
UNNotificationCategory(identifier: NotificationCategory.complete.rawValue,
actions: [],
intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: "",
options: .customDismissAction)
let errorCategory =
UNNotificationCategory(identifier: NotificationCategory.error.rawValue,
actions: [],
intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: "",
options: .customDismissAction)
let canceledCategory =
UNNotificationCategory(identifier: NotificationCategory.canceled.rawValue,
actions: [],
intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: "",
options: .customDismissAction)
// Register the notification type.
let notificationCenter = UNUserNotificationCenter.current()
notificationCenter.setNotificationCategories([runningWithPauseCategory, runningWithoutPauseCategory, pausedCategory, completeCategory, errorCategory, canceledCategory])
}
/// Returns a JSON string for this NotificationConfig, or nil
func jsonStringFor(notificationConfig: NotificationConfig) -> String? {
let jsonEncoder = JSONEncoder()
guard let jsonResultData = try? jsonEncoder.encode(notificationConfig)
else {
return nil
}
return String(data: jsonResultData, encoding: .utf8)
}
/// Returns a NotificationConfig from the supplied jsonString, or nil
func notificationConfigFrom(jsonString: String) -> NotificationConfig? {
let decoder = JSONDecoder()
let notificationConfig: NotificationConfig? = try? decoder.decode(NotificationConfig.self, from: (jsonString).data(using: .utf8)!)
return notificationConfig
}
@@ -0,0 +1,49 @@
//
// OpenFile.swift
// background_downloader
//
// Created on 4/27/23.
//
import Foundation
import MobileCoreServices
import UIKit
#if canImport(UniformTypeIdentifiers)
import UniformTypeIdentifiers
#endif
///
func doOpenFile(filePath: String, mimeType: String?) -> Bool {
let fileUrl = URL(fileURLWithPath: filePath)
let documentInteractionController = UIDocumentInteractionController(url: fileUrl)
let delegate = DocumentInteractionControllerDelegate()
documentInteractionController.delegate = delegate
if (mimeType != nil) {
if #available(iOS 14.0, *) {
if let type = UTType(mimeType: mimeType!) {
documentInteractionController.uti = type.identifier
}
} else {
if let uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, mimeType! as NSString, nil)?.takeRetainedValue()
{
documentInteractionController.uti = uti as String
}
}
}
guard let view = getRootViewController()?.view
else {
return false
}
if !documentInteractionController.presentPreview(animated: true) {
documentInteractionController.presentOpenInMenu(from: view.bounds, in: view, animated: true)
return true
}
return true
}
class DocumentInteractionControllerDelegate: NSObject, UIDocumentInteractionControllerDelegate {
func documentInteractionControllerViewControllerForPreview(_ controller: UIDocumentInteractionController) -> UIViewController {
return getRootViewController()!
}
}
@@ -0,0 +1,463 @@
//
// ParallelDownloader.swift
// background_downloader
//
// Created on 9/18/23.
//
import Foundation
import os.log
let chunkGroup = "chunk"
func scheduleParallelDownload(task: Task, taskDescription: String, baseRequest: URLRequest, resumeData: String) async -> Bool
{
let isResume = !resumeData.isEmpty
let parallelDownload = ParallelDownloader(task: task)
if !isResume {
return await withCheckedContinuation { continuation in
let dataTask = URLSession.shared.dataTask(with: baseRequest) { (data, response, error) in
if let httpResponse = response as? HTTPURLResponse, error == nil {
if httpResponse.statusCode == 404 {
os_log("URL not found for taskId %@", log: log, type: .info, task.taskId)
}
else if !parallelDownload.start(responseStatusCode: httpResponse.statusCode, contentLengthFromHeader: Int64(httpResponse.value(forHTTPHeaderField: "Content-Length") ?? "-1") ?? -1, responseHeaders: httpResponse.allHeaderFields ) {
os_log("Cannot chunk or enqueue download", log: log, type: .info)
} else {
if BDPlugin.holdingQueue?.enqueuedTaskIds.contains(task.taskId) != true {
// only send .enqueued if we didn't already do that when
// enqueueing with the holdingQueue
processStatusUpdate(task: task, status: TaskStatus.enqueued)
}
continuation.resume(returning: true)
return
}
} else {
os_log("Error making HEAD request for taskId %@", log: log, type: .info, task.taskId)
}
continuation.resume(returning: false)
}
dataTask.priority = 1 - Float(task.priority) / 10
dataTask.resume()
}
} else {
// resume
return parallelDownload.resume(resumeData: resumeData)
}
}
/**
/ Manages a parallel download
/
/ A ParallelDownloadTask pings the server to get the content-length of the
/ download, then creates a list of [Chunk]s, each representing a portion
/ of the download. Each chunk-task has its group set to 'chunk' and
/ has the taskId of the parent ParallelDownloadTask in its
/ [Task.metaData] field.
/ The ParallelDownloader sends 'enqueue' messages back to the NativeDownloader to
/ start each chunk-task, just like any other download task.
/ Messages with group 'chunk' are intercepted in the NativeDownloader,
/ and the update is sent to the ParallelDownloader.
/ In the ParallelDownloader, the update is processed and the new status/progress
/ of the ParallelDownloadTask is determined. If the status/progress has
/ changed, an update is sent and the status is processed (e.g., a complete
/ status triggers the piecing together of the downloaded file from
/ its chunk pieces).
/
/ Similarly, pause and cancel commands are sent to all chunk tasks before
/ updating the status of the parent ParallelDownloadTask
*/
public class ParallelDownloader: NSObject {
// downloads is the list of active parallel downloads, used to route child
// status and progress updates
static var downloads: [String : ParallelDownloader] = [:] // keyed by parentTask.taskId
var parentTask: Task
var chunks: [Chunk] = []
var parallelDownloadContentLength: Int64 = 0
var lastTaskStatus: TaskStatus = .enqueued
var lastProgress: Double = 0
var nextProgressUpdateTime = Date()
var taskException: TaskException? = nil
var responseBody: String? = nil
var responseHeaders: [AnyHashable : Any]? = nil
var initialResponseStatusCode: Int? = nil
/// Create a new ParallelDownloader
init(task:Task) {
self.parentTask = task
}
/// Start the parallel download by creating and enqueueing chunks based on
/// the content length and header fields
///
/// Returns false if start was unsuccessful
public func start(responseStatusCode: Int, contentLengthFromHeader: Int64, responseHeaders: [AnyHashable: Any]) -> Bool {
if !(200...206).contains(responseStatusCode) {
os_log("TaskId %@ returned response code %d", log: log, type: .info, parentTask.taskId, responseStatusCode)
return false
}
initialResponseStatusCode = responseStatusCode // may change due to chunk errors
self.responseHeaders = responseHeaders
// get suggested filename if needed
if parentTask.filename == "?" {
let newTask = taskWithSuggestedFilenameFromResponseHeaders(task: parentTask, responseHeaders: responseHeaders)
if newTask.filename != parentTask.filename {
// store for future replacement, and replace now
storeModifiedTask(task: newTask)
parentTask = newTask
}
}
parallelDownloadContentLength = contentLengthFromHeader > 0
? contentLengthFromHeader
: getContentLength(responseHeaders: responseHeaders, task: self.parentTask)
if parallelDownloadContentLength <= 0 {
os_log("Server does not provide content length - cannot chunk download. If you know the length, set Range or Known-Content-Length header", log: log, type: .info)
return false
}
if responseHeaders.first(where: { entry in (entry.key as! String).lowercased() == "accept-ranges" && entry.value as! String == "bytes"}) == nil {
os_log("Server does not accept ranges - cannot chunk download", log: log, type: .info)
return false
}
extractContentType(responseHeaders: responseHeaders, task: self.parentTask)
ParallelDownloader.downloads[parentTask.taskId] = self
chunks = createChunks(task: parentTask, contentLength: parallelDownloadContentLength)
let success = !chunks.isEmpty && enqueueChunkTasks()
if !success {
ParallelDownloader.downloads.removeValue(forKey: parentTask.taskId)
}
return success
}
/// resume: reconstruct [chunks] and wait for all chunk tasks to complete.
/// The Dart side will resume each chunk task, so we just wait for the
/// completer to complete
func resume(resumeData: String) -> Bool {
ParallelDownloader.downloads[parentTask.taskId] = self
let decoder = JSONDecoder()
guard
let chunkList = try? decoder.decode([Chunk].self, from: resumeData.data(using: .utf8)!)
else {
os_log("Could not decode resumeData for taskid %@", log: log, type: .info, parentTask.taskId)
return false
}
chunks = chunkList
parallelDownloadContentLength = chunks.reduce(0, { partialResult, chunk in
partialResult + chunk.toByte - chunk.fromByte + 1
})
lastTaskStatus = .paused
return true
}
/// Returns a list of chunk information for this task, and sets
/// [parallelDownloadContentLength] to the total length of the download
///
/// Throws a StateError if any information is missing, which should lead
/// to a failure of the [ParallelDownloadTask]
func createChunks(task: Task, contentLength: Int64) -> [Chunk] {
let numChunks = task.urls!.count * task.chunks!
if contentLength <= 0 {
os_log("Server does not provide content length - cannot chunk download", log: log, type: .info)
return []
}
parallelDownloadContentLength = contentLength
let chunkSize = (contentLength / Int64(numChunks)) + 1
var chunksList: [Chunk] = []
for i in 0..<numChunks {
chunksList.append(Chunk(
parentTask: task,
url: task.urls![i % task.urls!.count],
filename: "\(Int.random(in: 1..<1 << 32))",
fromByte: Int64(i) * chunkSize,
toByte: min(Int64(i) * chunkSize + chunkSize - 1, contentLength - 1)))
}
return chunksList
}
/// Enqueues all chunk tasks and returns true if successful
///
/// Enqueue request is posted to Dart side
func enqueueChunkTasks() -> Bool {
let jsonEncoder = JSONEncoder()
for chunk in chunks {
guard let taskAsJsonData = try? jsonEncoder.encode(chunk.task)
else {
return false
}
if !postOnBackgroundChannel(method: "enqueueChild", task: parentTask, arg: String(data: taskAsJsonData, encoding: .utf8) as Any) {
os_log("Could not enqueue child task for chunk", log: log, type: .info)
return false
}
}
return true
}
/// Process incoming [status] update for a chunk with [chunkTaskId]
///
/// If status is failure, may include [taskException] and [responseBody]
func chunkStatusUpdate(chunkTaskId: String, status: TaskStatus, taskException: TaskException?, responseBody: String?) {
// Confirm chunk is part of this parent task
guard let chunk = chunks.first(where: { $0.task.taskId == chunkTaskId }) else { return }
// first check for fail -> retry
if status == .failed && chunk.task.retriesRemaining > 0 {
chunk.task.retriesRemaining -= 1
let waitTimeSeconds = 2 << min(chunk.task.retries - chunk.task.retriesRemaining - 1, 8)
os_log("Chunk with taskId %@ failed, waiting %d seconds to retry; %d retries remaining", log: log, type: .info, chunk.task.taskId, waitTimeSeconds, chunk.task.retriesRemaining)
DispatchQueue.main.asyncAfter(deadline: .now() + Double(waitTimeSeconds)) {
if !postOnBackgroundChannel(
method: "enqueueChild",
task: self.parentTask,
arg: jsonStringFor(task: chunk.task)!
) {
self.chunkStatusUpdate(chunkTaskId: chunkTaskId, status: .failed, taskException: taskException, responseBody: responseBody)
}
}
} else {
// no retry
let newStatusUpdate = updateChunkStatus(chunk: chunk, status: status)
if let newStatusUpdate = newStatusUpdate {
switch newStatusUpdate {
case .running:
processStatusUpdate(task: parentTask, status: .running)
case .complete:
let stitchResult = stitchChunks()
if stitchResult == TaskStatus.complete {
os_log("Finished task with id %@", log: log, type: .info, parentTask.taskId)
}
finishTask(status: stitchResult)
case .failed:
self.taskException = taskException
cancelAllChunkTasks()
finishTask(status: .failed)
case .notFound:
self.responseBody = responseBody
cancelAllChunkTasks()
finishTask(status: .notFound)
default:
// ignore all other status updates
break
}
}
}
}
/// Process incoming [progress] update for a chunk with [chunkTaskId].
///
/// Recalculates overall task progress (based on the average of the chunk
/// task progress) and sends an updatre to the Dart isde and updates the
/// notification at the appropriate interval
func chunkProgressUpdate(chunkTaskId: String, progress: Double) {
guard let chunk = chunks.first(where: { $0.task.taskId == chunkTaskId }) else {
return // chunk is not part of this parent task
}
if progress > 0 && progress < 1 {
let parentProgress = updateChunkProgress(chunk: chunk, progress: progress)
let totalBytesDone = Int64(parentProgress * Double(parallelDownloadContentLength))
updateProgress(task: parentTask, totalBytesExpected: parallelDownloadContentLength, totalBytesDone: totalBytesDone)
}
}
/// Update the status for this chunk, and return the status for the parent task
/// as derived from the sum of the child tasks, or null if undefined
///
/// The updates are received from the NativeDownloader, which intercepts
/// status updates for the chunkGroup
private func updateChunkStatus(chunk: Chunk, status: TaskStatus) -> TaskStatus? {
chunk.status = status
let parentStatus = parentTaskStatus()
if let parentStatus = parentStatus, parentStatus != lastTaskStatus {
lastTaskStatus = parentStatus
return parentStatus
}
return nil
}
/// Returns the [TaskStatus] for the parent of this chunk, as derived from
/// the 'sum' of the child tasks, or nil if undetermined
///
/// The updates are received from the NativeDownloader, which intercepts
/// status updates for the chunkGroup
private func parentTaskStatus() -> TaskStatus? {
if chunks.first(where: { $0.status == .failed }) != nil {
return .failed
}
if chunks.first(where: { $0.status == .notFound }) != nil {
return .notFound
}
if (chunks.reduce(into: 0) { previousValue, chunk in
if chunk.status == .running {
previousValue += 1
}
}) == 1 {
return .running // if exactly one chunk task is running
}
if chunks.allSatisfy({ $0.status == .complete }) {
return .complete
}
return nil
}
/// Updates the chunk's progress and returns the average progress
///
/// Returns the [progress] for the parent of this chunk, as derived from
/// its children by averaging
private func updateChunkProgress(chunk: Chunk, progress: Double) -> Double {
chunk.progress = progress
return chunks.reduce(into: 0.0) { previousValue, chunk in
previousValue += chunk.progress
} / Double(chunks.count)
}
/// Stitch all chunks together into one file, per the [parentTask]
private func stitchChunks() -> TaskStatus {
do {
let fileManager = FileManager.default
let outputFilePath = getFilePath(for: parentTask)!
let outputFile = URL(fileURLWithPath: outputFilePath)
if fileManager.fileExists(atPath: outputFilePath) {
try? fileManager.removeItem(at: outputFile)
}
fileManager.createFile(atPath: outputFilePath, contents: nil)
let outputFileHandle = try FileHandle(forWritingTo: outputFile)
defer {
outputFileHandle.closeFile()
for chunk in chunks {
let inputFilePath = getFilePath(for: chunk.task)!
let inputFileURL = URL(fileURLWithPath: inputFilePath)
if fileManager.fileExists(atPath: inputFilePath) {
try? FileManager.default.removeItem(at: inputFileURL)
}
}
}
for chunk in chunks.sorted(by: { $0.fromByte < $1.fromByte }) {
let filePath = getFilePath(for: chunk.task)!
let fileHandle = try FileHandle(forReadingFrom: URL(fileURLWithPath: filePath))
defer {
fileHandle.closeFile()
}
while true {
let data = fileHandle.readData(ofLength: 2 << 13)
if data.isEmpty {
break
}
outputFileHandle.write(data)
}
}
} catch {
os_log("Error stitching chunks: %@", log: log, type: .info, error.localizedDescription)
taskException = TaskException(type: .fileSystem, description: "Error stitching chunks: \(error.localizedDescription)")
return .failed
}
return .complete
}
/// Cancel this task
///
/// Cancels all chunk tasks and completes the task with [TaskStatus.canceled]
func cancelTask() {
cancelAllChunkTasks()
finishTask(status: .canceled)
}
/// Pause this task
///
/// Pauses all chunk tasks
func pauseTask() async -> Bool {
let encoder = JSONEncoder()
guard
let chunkTasksData = try? encoder.encode(chunks.map({ chunk in
chunk.task })),
let chunksData = try? encoder.encode(chunks)
else {
return false
}
if !postOnBackgroundChannel(method: "pauseTasks", task: parentTask, arg: String(data: chunkTasksData, encoding: .utf8)!)
{
os_log("Could not pause chunk tasks for taskId %@", log: log, type: .info, parentTask.taskId)
return false
}
if !postOnBackgroundChannel(method: "resumeData", task: parentTask, arg: String(data: chunksData, encoding: .utf8)!) {
os_log("Could not post resume data for taskId %@", log: log, type: .info, parentTask.taskId)
// because we already paused the
cancelAllChunkTasks()
processStatusUpdate(task: parentTask, status: .failed)
return false
}
processStatusUpdate(task: parentTask, status: .paused)
return true
}
/// Cancel the tasks associated with each chunk
///
/// Accomplished by sending list of taskIds to cancel to the NativeDownloader
private func cancelAllChunkTasks() {
let encoder = JSONEncoder()
guard let data = try? encoder.encode(chunks.map({ $0.task.taskId })) else {
os_log("Could not encode chunk ids", log: log, type: .error)
return
}
if !postOnBackgroundChannel(method: "cancelTasksWithId", task: parentTask, arg: String(data: data, encoding: .utf8)!) {
os_log("Could not cancel chunk tasks related to taskId %@", log: log, type: .info, parentTask.taskId)
}
}
/// Finish the [ParallelDownloadTask] by posting a statusUpdate and clearning up
private func finishTask(status: TaskStatus) {
let taskId = parentTask.taskId
let (mimeType, charSet) = BDPlugin.propertyLock.withLock({
(BDPlugin.mimeTypes[taskId], BDPlugin.charSets[taskId])
})
var responseStatusCode: Int? = nil
switch status {
case .complete:
responseStatusCode = initialResponseStatusCode
case .notFound:
responseStatusCode = 404
default:
responseStatusCode = nil
}
processStatusUpdate(task: parentTask, status: status, taskException: taskException, responseBody: responseBody, responseHeaders: responseHeaders, responseStatusCode: responseStatusCode, mimeType: mimeType, charSet: charSet)
BDPlugin.propertyLock.withLock({
BDPlugin.mimeTypes.removeValue(forKey: taskId)
BDPlugin.charSets.removeValue(forKey: taskId)
BDPlugin.tasksWithModifications.removeValue(forKey: taskId)
ParallelDownloader.downloads.removeValue(forKey: taskId)
})
}
}
/// Represents one chunk of a parallel download
///
/// Initialized to a specific range of the parent task.
/// Holds state (status and progress) of the chunk
public class Chunk: NSObject, Codable {
let parentTaskId: String
let url: String
let filename: String
let fromByte: Int64
let toByte: Int64
var task: Task
var status: TaskStatus = TaskStatus.enqueued
var progress = 0.0
init(parentTask: Task, url: String, filename: String, fromByte: Int64, toByte: Int64) {
self.parentTaskId = parentTask.taskId
self.url = url
self.filename = filename
self.fromByte = fromByte
self.toByte = toByte
var headers = parentTask.headers
headers["Range"] = "bytes=\(fromByte)-\(toByte)"
let jsonEncoder = JSONEncoder()
let data = try? jsonEncoder.encode(["parentTaskId": self.parentTaskId, "from": String(fromByte), "to": String(toByte)])
let metaData = data != nil ? String(data: data!, encoding: .utf8) ?? "" : ""
self.task = Task(url: url, filename: filename, headers: headers, baseDirectory: BaseDirectory.temporary.rawValue, group: chunkGroup, updates: Chunk.updatesBasedOnParent(parentTask), retries: parentTask.retries, retriesRemaining: parentTask.retries, allowPause: parentTask.allowPause, priority: parentTask.priority, metaData: metaData, taskType: "DownloadTask")
}
/// Returns [Updates] enum rawValue based on its parent
static func updatesBasedOnParent(_ parentTask: Task) -> Int {
return parentTask.updates == Updates.none.rawValue || parentTask.updates == Updates.statusChange.rawValue ? Updates.statusChange.rawValue : Updates.statusChangeAndProgressUpdates.rawValue
}
}
@@ -0,0 +1,154 @@
//
// Permissions.swift
// background_downloader
//
// Created by Bram on 11/30/23.
//
import Foundation
import Photos
import UserNotifications
import os.log
public enum PermissionType: Int {
case notifications,
androidSharedStorage,
iosAddToPhotoLibrary,
iosChangePhotoLibrary
}
public enum PermissionStatus: Int {
case undetermined,
denied,
granted,
partial,
requestError }
/// Get current permission status for this [request]
///
/// Unknown permissions resturn .granted
public func getPermissionStatus(for permissionType: PermissionType) async -> PermissionStatus {
if permissionType == .notifications {
#if BYPASS_PERMISSION_NOTIFICATIONS
return .denied
#else
let center = UNUserNotificationCenter.current()
let status = (await center.notificationSettings()).authorizationStatus
switch status {
case .authorized:
return .granted
case .denied:
return .denied
case .notDetermined:
return .undetermined
default:
return .partial
}
#endif
}
if (permissionType == .iosAddToPhotoLibrary || permissionType == .iosChangePhotoLibrary) {
let status: PHAuthorizationStatus
if permissionType == .iosAddToPhotoLibrary {
#if BYPASS_PERMISSION_IOSADDTOPHOTOLIBRARY
status = .denied
#else
if #available(iOS 14, *) {
status = PHPhotoLibrary.authorizationStatus(for: .addOnly)
} else {
status = PHPhotoLibrary.authorizationStatus()
}
#endif
} else { // readwrite
#if BYPASS_PERMISSION_IOSCHANGEPHOTOLIBRARY
status = .denied
#else
if #available(iOS 14, *) {
status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
} else {
status = PHPhotoLibrary.authorizationStatus()
}
#endif
}
switch status {
case .authorized:
return .granted
case .denied:
return .denied
case .notDetermined:
return .undetermined
default:
return .partial
}
}
return .granted // default return if irrelevant permission for iOS
}
/// Request permission from user and return the [PermissionResult]
///
/// Unknown permissions are granted
public func requestPermission(for permissionType: PermissionType) async -> PermissionStatus {
if permissionType == .notifications {
#if BYPASS_PERMISSION_NOTIFICATIONS
return .denied
#else
let center = UNUserNotificationCenter.current()
guard let granted = try? await center.requestAuthorization(options: [.alert]) else {
return .requestError
}
return granted ? .granted : .denied
#endif
}
if (permissionType == .iosAddToPhotoLibrary || permissionType == .iosChangePhotoLibrary) {
let status: PHAuthorizationStatus
if permissionType == .iosAddToPhotoLibrary {
#if BYPASS_PERMISSION_IOSADDTOPHOTOLIBRARY
status = .denied
#else
if #available(iOS 14, *) {
status = await PHPhotoLibrary.requestAuthorization(for: .addOnly)
} else {
status = await withCheckedContinuation { continuation in
PHPhotoLibrary.requestAuthorization {status in
continuation.resume(returning: status)
}
}
}
#endif
} else { // readwrite
#if BYPASS_PERMISSION_IOSCHANGEPHOTOLIBRARY
status = .denied
#else
if #available(iOS 14, *) {
status = await PHPhotoLibrary.requestAuthorization(for: .readWrite)
} else {
status = await withCheckedContinuation { continuation in
PHPhotoLibrary.requestAuthorization {status in
continuation.resume(returning: status)
}
}
}
#endif
}
switch status {
case .authorized:
return .granted
case .denied:
return .denied
case .notDetermined:
return .undetermined
default:
return .partial
}
}
return .granted
}
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypePhotosorVideos</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<false/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
</dict>
</array>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
</array>
</dict>
</plist>
@@ -0,0 +1,200 @@
//
// SharedStorage.swift
// background_downloader
//
// Created on 3/26/23.
//
import Foundation
import os.log
import Photos
import Combine
public enum SharedStorage: Int {
case downloads,
images,
video,
audio,
files,
external
}
/// Check if photos permissions have been granted
///
/// if [addOnly] is true, checks .addOnly permission, otherwise checks
/// full .readWrite permission.
///
/// Returns true if granted
/// .addOnly requires the "NSPhotoLibraryAddUsageDescription" to be set in Info.plist
/// .readWrite requires the "NSPhotoLibraryUsageDescription" to be set in Info.plist
/// Prior to iOS 14, use NSPhotoLibraryUsageDescription
public func photosAccessAuthorized(addOnly: Bool) async -> Bool {
let permissionType: PermissionType = addOnly ? .iosAddToPhotoLibrary : .iosChangePhotoLibrary
var authStatus = await getPermissionStatus(for: permissionType)
if (authStatus == .granted) {
return true
}
return false
}
/// Move file at [filePath] to shared storage [destination] with optional [directory]
public func moveToSharedStorage(filePathOrUriString: String, destination: SharedStorage, directory: String, asUriString: Bool) async -> String? {
let fileUrl = URL(string: filePathOrUriString)
let filePath = fileUrl?.scheme == "file" ? fileUrl!.path : filePathOrUriString
guard FileManager().fileExists(atPath: filePath)
else {
os_log("Cannot move to shared storage: file %@ does not exist", log: log, type: .error, filePathOrUriString)
return nil
}
switch destination {
case .images, .video:
return await moveToPhotoLibrary(filePath: filePath, destination: destination)
default:
return moveToFakeSharedStorage(filePath: filePath, destination: destination, directory: directory, asUriString: asUriString)
}
}
/// Saves the file at [filePath] to the photolibrary [destination] and removes the original
///
/// The returned path is the 'localId'
private func moveToPhotoLibrary(filePath: String, destination: SharedStorage) async -> String? {
var localId: String?
guard await photosAccessAuthorized(addOnly: true) else {
os_log("Cannot move to shared storage: permission to add to photos library denied by user", log: log, type: .info)
return nil
}
#if BYPASS_PERMISSION_IOSADDTOPHOTOLIBRARY
return nil
#else
guard let fileURL = URL(string: filePath) else {
os_log("filePath invalid: %@", log: log, type: .info, filePath)
return nil
}
let result = await withCheckedContinuation { continuation in
PHPhotoLibrary.shared().performChanges({
let assetChangeRequest = destination == .video ? PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: fileURL) :
PHAssetChangeRequest.creationRequestForAssetFromImage(atFileURL: fileURL)
localId = assetChangeRequest?.placeholderForCreatedAsset?.localIdentifier
})
{ (success, error: Error?) in
guard success, let unwrappedId = localId else {
if let unwrappedError = error {
os_log("Could not save to Photos Library: %@", log: log, type: .info, String(describing: unwrappedError))
}
continuation.resume(returning: "")
return
}
try? FileManager.default.removeItem(at: fileURL)
continuation.resume(returning: unwrappedId)
}
}
return result.isEmpty ? nil : result
#endif
}
/// Returns the path to the file at [filePath] in shared storage [destination] subdir [directory], or null
public func pathInSharedStorage(filePath: String, destination: SharedStorage, directory: String, asUriString: Bool) async -> String? {
if destination == .images || destination == .video {
return await pathInPhotoLibrary(localId: filePath, destination: destination)
}
// regular 'faked' sharedStorage
guard let directory = try? directoryForSharedStorage(destination: destination, directory: directory) else {
os_log("Cannot determine path in shared storage: no permission for directory %@", log: log, type: .info, directory)
return nil
}
let destUrl = directory.appendingPath((filePath as NSString).lastPathComponent)
return asUriString ? destUrl.absoluteString : destUrl.path
}
/// Returns the file path associated with the [localId] in the Photos library
///
/// This requires .readWrite PhotoLibrary access, and therefore the filepath is NOT returned
/// when moving a photo or video to the PhotoLibrary using [moveToSharedStorage].
/// Instead, the local identifier is returned, and if the full file path is required, this identifier needs to
/// be passed to the [pathInSharedStorage] call
///
/// Returns the path to the photo/video asset, or nil if unsuccessful
public func pathInPhotoLibrary(localId: String, destination: SharedStorage) async -> String? {
guard await photosAccessAuthorized(addOnly: false) else {
os_log("Cannot get path in shared storage: permission to access photos library denied by user", log: log, type: .info)
return nil
}
#if BYPASS_PERMISSION_IOSCHANGEPHOTOLIBRARY
return nil
#else
let assetResult = PHAsset.fetchAssets(withLocalIdentifiers: [localId], options: nil)
guard let asset = assetResult.firstObject else {
os_log("Photos asset not found", log: log, type: .info)
return nil
}
let result = await withCheckedContinuation { continuation in
asset.requestContentEditingInput(with: nil, completionHandler: { input, _ in
if (destination == .video) {
if let urlAsset = input?.audiovisualAsset as? AVURLAsset {
continuation.resume(returning: urlAsset.url.path)
} else {
continuation.resume(returning: "")
}
} else {
continuation.resume(returning: input?.fullSizeImageURL?.path ?? "")
}
})
}
return result.isEmpty ? nil : result
#endif
}
/// Returns the URL of the directory associated with the [destination] and [directory], or nil
public func directoryForSharedStorage(destination: SharedStorage, directory: String) throws -> URL? {
var dir: String
switch destination {
case .downloads:
dir = "Downloads"
case .audio:
dir = "Music"
case .images, .video:
os_log("Destination .images and .video should use PhotoLibrary functions", log: log, type: .error)
return nil
case .files, .external:
os_log("Destination .files and .external are not supported on iOS", log: log, type: .info)
return nil
}
let documentsURL =
try? FileManager.default.url(for: FileManager.SearchPathDirectory.documentDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true)
return directory.isEmpty
? documentsURL?.appendingPath(dir, isDirectory: true)
: documentsURL?.appendingPath(dir, isDirectory: true).appendingPath(directory, isDirectory: true)
}
private func moveToFakeSharedStorage(filePath: String, destination: SharedStorage, directory: String, asUriString: Bool) -> String? {
let fileUrl = NSURL(fileURLWithPath: filePath)
guard let directory = try? directoryForSharedStorage(destination: destination, directory: directory) else {
os_log("Cannot move to shared storage: no permission for directory %@", log: log, type: .error, directory)
return nil
}
if !FileManager.default.fileExists(atPath: directory.path) {
do {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
} catch {
os_log("Cannot move to shared storage: failed to create directory %@: %@", log: log, type: .error, directory.path, error.localizedDescription)
return nil
}
}
let destUrl = directory.appendingPath((filePath as NSString).lastPathComponent)
if FileManager.default.fileExists(atPath: destUrl.path) {
try? FileManager.default.removeItem(at: destUrl)
}
do {
try FileManager.default.moveItem(at: fileUrl as URL, to: destUrl)
} catch {
os_log("Failed to move file %@ to %@: %@", log: log, type: .error, filePath, destUrl.path, error.localizedDescription)
return nil
}
return asUriString ? destUrl.absoluteString : destUrl.path
}
@@ -0,0 +1,328 @@
//
// Task.swift
// background_downloader
//
// Created on 3/4/23.
//
import Foundation
/// Partial version of the Dart side DownloadTask, only used for background loading
public struct Task : Codable, Hashable {
public var taskId: String = "\(Int.random(in: 1..<(1 << 32)))"
public var url: String
public var urls: [String]? = []
public var filename: String
public var headers: [String:String] = [:]
public var httpRequestMethod: String = "GET"
public var chunks: Int? = 1
public var post: String?
public var postIsBinary: Bool?
public var fileField: String?
public var mimeType: String?
public var fields: [String:String]?
public var directory: String = ""
public var baseDirectory: Int
public var group: String
public var updates: Int
public var requiresWiFi: Bool = false
public var retries: Int = 0
public var retriesRemaining: Int = 0
public var allowPause: Bool = false
public var priority: Int = 5
public var metaData: String = ""
public var displayName: String = ""
public var creationTime: Int64 = Int64((Date().timeIntervalSince1970 * 1000.0).rounded())
var options: TaskOptions?
var transferHints: [Int]?
var stallTimeout: Int64?
public var taskType: String
}
extension Task {
func copyWith(taskId: String? = nil,
url: String? = nil,
urls: [String]? = nil,
filename: String? = nil,
headers: [String:String]? = nil,
httpRequestMethod: String? = nil,
chunks: Int? = nil,
post: String? = nil,
fileField: String? = nil,
mimeType: String? = nil,
fields: [String:String]? = nil,
directory: String? = nil,
baseDirectory: Int? = nil,
group: String? = nil,
updates: Int? = nil,
requiresWiFi: Bool? = nil,
retries: Int? = nil,
retriesRemaining: Int? = nil,
allowPause: Bool? = nil,
priority: Int? = nil,
metaData: String? = nil,
displayName: String? = nil,
creationTime: Int64? = nil,
options: TaskOptions? = nil,
transferHints: [Int]? = nil,
stallTimeout: Int64? = nil,
taskType: String? = nil) -> Task {
var copiedTask = self
if let taskId = taskId {
copiedTask.taskId = taskId
}
if let url = url {
copiedTask.url = url
}
if let urls = urls {
copiedTask.urls = urls
}
if let filename = filename {
copiedTask.filename = filename
}
if let headers = headers {
copiedTask.headers = headers
}
if let httpRequestMethod = httpRequestMethod {
copiedTask.httpRequestMethod = httpRequestMethod
}
if let chunks = chunks {
copiedTask.chunks = chunks
}
if let post = post {
copiedTask.post = post
}
if let fileField = fileField {
copiedTask.fileField = fileField
}
if let mimeType = mimeType {
copiedTask.mimeType = mimeType
}
if let fields = fields {
copiedTask.fields = fields
}
if let directory = directory {
copiedTask.directory = directory
}
if let baseDirectory = baseDirectory {
copiedTask.baseDirectory = baseDirectory
}
if let group = group {
copiedTask.group = group
}
if let updates = updates {
copiedTask.updates = updates
}
if let requiresWiFi = requiresWiFi {
copiedTask.requiresWiFi = requiresWiFi
}
if let retries = retries {
copiedTask.retries = retries
}
if let retriesRemaining = retriesRemaining {
copiedTask.retriesRemaining = retriesRemaining
}
if let allowPause = allowPause {
copiedTask.allowPause = allowPause
}
if let priority = priority {
copiedTask.priority = priority
}
if let metaData = metaData {
copiedTask.metaData = metaData
}
if let displayName = displayName {
copiedTask.displayName = displayName
}
if let creationTime = creationTime {
copiedTask.creationTime = creationTime
}
if let options = options {
copiedTask.options = options
}
if let transferHints = transferHints {
copiedTask.transferHints = transferHints
}
if let stallTimeout = stallTimeout {
copiedTask.stallTimeout = stallTimeout
}
if let taskType = taskType {
copiedTask.taskType = taskType
}
return copiedTask
}
}
/// Base directory in which files will be stored, based on their relative
/// path.
///
/// These correspond to the directories provided by the path_provider package
enum BaseDirectory: Int {
case applicationDocuments, // getApplicationDocumentsDirectory()
temporary, // getTemporaryDirectory()
applicationSupport, // getApplicationSupportDirectory()
applicationLibrary, // getLibraryDirectory()
root // system root
}
/// Type of updates requested for a group of downloads
enum Updates: Int {
case none, // no status or progress updates
statusChange, // only calls upon change in DownloadTaskStatus
progressUpdates, // only calls for progress
statusChangeAndProgressUpdates // calls also for progress along the way
}
/// Holds various options related to the task that are not included in the
/// task's properties, as they are rare
struct TaskOptions : Codable, Hashable {
var beforeTaskStartRawHandle: Int64?
var onTaskStartRawHandle: Int64?
var onTaskFinishedRawHandle: Int64?
var auth: Auth?
/// True if [TaskOptions] contains a callback for beforeTaskStart
func hasBeforeStartCallback() -> Bool {
beforeTaskStartRawHandle != nil
}
/// True if [TaskOptions] contains a callback for onTaskStart
func hasOnStartCallback() -> Bool {
onTaskStartRawHandle != nil
}
/// True if [TaskOptions] contains a callback that must be called after the task finishes
func hasOnFinishedCallback() -> Bool {
onTaskFinishedRawHandle != nil
}
}
/// Defines a set of possible states which a [DownloadTask] can be in.
public enum TaskStatus: Int, Codable {
case enqueued,
running,
complete,
notFound,
failed,
canceled,
waitingToRetry,
paused
}
/** Holds data associated with a task status update, for local storage */
public struct TaskStatusUpdate: Codable {
public var task: Task
public var taskStatus: TaskStatus
public var exception: TaskException?
public var responseBody: String?
public var responseStatusCode: Int?
public var responseHeaders: [String: String]?
public var mimeType: String?
public var charSet: String?
func argList() -> [Any?] {
let finalState = isFinalState(status: taskStatus)
return taskStatus == .failed
? [taskStatus.rawValue,
exception?.type.rawValue,
exception?.description,
exception?.httpResponseCode,
responseBody]
: [taskStatus.rawValue,
finalState ? responseBody : nil,
finalState ? responseHeaders : nil,
(taskStatus == .complete || taskStatus == .notFound) ? responseStatusCode : nil,
finalState ? mimeType : nil,
finalState ? charSet : nil]
}
}
/** Holds data associated with a task progress update, for local storage */
struct TaskProgressUpdate: Encodable {
var task: Task
var progress: Double
var expectedFileSize: Int64
}
/** Holds data associated with a resume, for local storage */
struct ResumeData: Encodable {
var task: Task
var data: String
}
/// The type of [TaskException]
public enum ExceptionType: String, Codable {
case
// General error
general = "TaskException",
// Could not save or find file, or create directory
fileSystem = "TaskFileSystemException",
// URL incorrect
url = "TaskUrlException",
// Connection problem, eg host not found, timeout
connection = "TaskConnectionException",
// Could not resume or pause task
resume = "TaskResumeException",
// Invalid HTTP response
httpResponse = "TaskHttpException"
}
/**
* Contains error information associated with a failed [Task]
*
* The [type] categorizes the error
* The [httpResponseCode] is only valid if >0 and may offer details about the
* nature of the error
* The [description] is typically taken from the platform-generated
* error message, or from the plugin. The localization is undefined
*/
public struct TaskException : Codable {
public var type: ExceptionType
public var httpResponseCode: Int = -1
public var description: String
}
func taskException(jsonString: String) -> TaskException {
if let jsonMap = try! JSONSerialization.jsonObject(with: jsonString.data(using: .utf8)!, options: []) as? [String: Any] {
return TaskException(type: ExceptionType(rawValue: jsonMap["type"] as! String) ?? ExceptionType.general
, httpResponseCode: jsonMap["httpResponseCode"] as? Int ?? -1, description: jsonMap["description"] as! String )
}
return TaskException(type: .general, description: "Unknown error")
}
@@ -0,0 +1,735 @@
//
// TaskFunctions.swift
// background_downloader
//
// Created on 2/11/23.
//
import Foundation
import Flutter
import os.log
let updatesQueue = DispatchQueue(label: "updatesProcessingQueue")
let separatorString = "***<<<|>>>***"
/// True if this task expects to provide progress updates
func providesProgressUpdates(task: Task) -> Bool {
return task.updates == Updates.progressUpdates.rawValue || task.updates == Updates.statusChangeAndProgressUpdates.rawValue
}
/// True if this task expects to provide status updates
func providesStatusUpdates(downloadTask: Task) -> Bool {
return downloadTask.updates == Updates.statusChange.rawValue || downloadTask.updates == Updates.statusChangeAndProgressUpdates.rawValue
}
/// True if this task is a DownloadTask, UriDownloadTask or ParallelDownloadTask
func isDownloadTask(task: Task) -> Bool {
return task.taskType == "DownloadTask" || task.taskType == "UriDownloadTask" || task.taskType == "ParallelDownloadTask"
}
/// True if this task is a ParallelDownloadTask, false if not
func isParallelDownloadTask(task: Task) -> Bool
{
return task.taskType == "ParallelDownloadTask"
}
/// True if this task is an UploadTask, false if not
///
/// A MultiUploadTask is also an UploadTask
func isUploadTask(task: Task) -> Bool {
return task.taskType == "UploadTask" || task.taskType == "UriUploadTask" || task.taskType == "MultiUploadTask"
}
/// True if this task is a MultiUploadTask, false if not
func isMultiUploadTask(task: Task) -> Bool {
return task.taskType == "MultiUploadTask"
}
/// True if this task is a binary UploadTask
func isBinaryUploadTask(task: Task) -> Bool {
return isUploadTask(task: task) && task.post?.lowercased() == "binary"
}
/// True if this is a DataTask
func isDataTask(task: Task) -> Bool {
return task.taskType == "DataTask"
}
/// True if this state is not a final state (i.e. more changes may happen)
func isNotFinalState(status: TaskStatus) -> Bool {
return status == .enqueued || status == .running || status == .waitingToRetry || status == .paused
}
/// True if this state is a final state (i.e. no more changes will happen)
func isFinalState(status: TaskStatus) -> Bool {
return !isNotFinalState(status: status)
}
/// Returns the absolute path to the file represented by this task
/// based on the [Task.filename] (default) or [withFilename]
///
/// If the task is a MultiUploadTask and no [withFilename] is given,
/// returns the empty string, as there is no single path that can be
/// returned
func getFilePath(for task: Task, withFilename: String? = nil) -> String? {
if isMultiUploadTask(task: task) && withFilename == nil {
return ""
}
guard let directory = try? directoryForTask(task: task)
else {
return nil
}
return directory.appendingPath(withFilename ?? task.filename).path
}
func stripFileExtension ( _ filename: String ) -> String {
var components = filename.components(separatedBy: ".")
guard components.count > 1 else { return filename }
components.removeLast()
return components.joined(separator: ".")
}
/// Suggests a filename based on response headers and a URL.
/// If none can be derived, returns an empty string.
///
/// - Parameters:
/// - responseHeaders: The response headers dictionary.
/// - urlString: The URL string the file would be downloaded from.
/// - Returns: A suggested filename, derived from the headers or the URL.
func suggestFilename(responseHeaders: [AnyHashable: Any], urlString: String) -> String {
do {
if let disposition = responseHeaders["Content-Disposition"] as? String ?? responseHeaders["content-disposition"] as? String {
// Try filename*=UTF-8'language'"encodedFilename"
let encodedFilenameRegEx = try NSRegularExpression(pattern: #"filename\*=\s*([^']+)'([^']*)'"?([^"]+)"?"#, options: .caseInsensitive)
let range = NSRange(location: 0, length: disposition.utf16.count)
if let match = encodedFilenameRegEx.firstMatch(in: disposition, options: [], range: range) {
if let encodingRange = Range(match.range(at: 1), in: disposition),
let filenameRange = Range(match.range(at: 3), in: disposition) {
let encoding = String(disposition[encodingRange]).uppercased()
let filename = String(disposition[filenameRange])
if encoding == "UTF-8" {
if let decodedFilename = filename.removingPercentEncoding {
return decodedFilename
} else {
os_log("Could not interpret suggested filename (UTF-8 url encoded) %@", log: log, type: .debug, filename)
}
} else {
return filename
}
}
}
// Try filename="filename"
let plainFilenameRegEx = try NSRegularExpression(pattern: #"filename=\s*"?([^"]+)"?.*$"#, options: .caseInsensitive)
if let match = plainFilenameRegEx.firstMatch(in: disposition, options: [], range: range) {
if let filenameRange = Range(match.range(at: 1), in: disposition) {
return String(disposition[filenameRange])
}
}
}
} catch {
os_log("Error parsing suggested filename from headers: %@", log: log, type: .error, error.localizedDescription)
}
os_log("Could not determine suggested filename from server", log: log, type: .debug)
// Try filename derived from last path segment of the url
if let url = URL(string: urlString), !url.lastPathComponent.isEmpty {
return url.lastPathComponent
}
os_log("Could not parse URL pathSegment for suggested filename", log: log, type: .debug)
return "" // Default fallback
}
/**
* Returns a copy of the task with the [Task.filename] property changed
* to the filename suggested by the server, or derived from the url, or
* unchanged.
*
* If [unique] is true, the filename is guaranteed not to already exist. This
* is accomplished by adding a suffix to the suggested filename with a number,
* e.g. "data (2).txt"
*
* The server-suggested filename is obtained from the [responseHeaders] entry
* "Content-Disposition"
*/
func taskWithSuggestedFilenameFromResponseHeaders(
task: Task,
responseHeaders: [AnyHashable: Any],
unique: Bool = false
) -> Task {
let suggestedFilenameValue = suggestFilename(responseHeaders: responseHeaders, urlString: task.url)
if !suggestedFilenameValue.isEmpty {
return uniqueFilename(task: task.copyWith(filename: suggestedFilenameValue), unique: unique)
}
// If everything fails, return the task with an unchanged filename
// except for possibly making it unique
return uniqueFilename(task: task, unique: unique)
}
/// Returns [Task] with a filename similar to the one
/// supplied, but unused.
///
/// If [unique], filename will sequence up in "filename (8).txt" format,
/// otherwise returns the [task]
func uniqueFilename(task: Task, unique: Bool) -> Task {
if !unique {
return task
}
let sequenceRegEx = try! NSRegularExpression(pattern: #"\((\d+)\)\.?[^.]*$"#)
let extensionRegEx = try! NSRegularExpression(pattern: #"\.[^.]*$"#)
var newTask = task
guard let filePath = getFilePath(for: task) else {
return task
}
var exists = FileManager.default.fileExists(atPath: filePath)
while exists {
let range = NSMakeRange(0, newTask.filename.utf16.count)
let extMatch = extensionRegEx.firstMatch(in: newTask.filename, options: [], range: range)
let extString = extMatch != nil ? String(newTask.filename[Range(extMatch!.range, in: newTask.filename)!]) : ""
let seqMatch = sequenceRegEx.firstMatch(in: newTask.filename, options: [], range: range)
let seqString = seqMatch != nil ? String(newTask.filename[Range(seqMatch!.range(at: 1), in: newTask.filename)!]) : ""
let newSequence = seqString.isEmpty ? 1 : Int(seqString)! + 1
let newFilename: String
if seqMatch == nil {
let baseNameWithoutExtension = stripFileExtension(newTask.filename)
newFilename = "\(baseNameWithoutExtension) (\(newSequence))\(extString)"
} else {
let startOfSeq = seqMatch!.range.location
let index = newTask.filename.index(newTask.filename.startIndex, offsetBy: startOfSeq)
newFilename = "\(newTask.filename.prefix(upTo: index))(\(newSequence))\(extString)"
}
newTask = newTask.copyWith(filename: newFilename)
guard let filePath = getFilePath(for: newTask) else {
return task
}
exists = FileManager.default.fileExists(atPath: filePath)
}
return newTask
}
/**
* Parses the range in a Range header, and returns a Pair representing
* the range. The format needs to be "bytes=10-20"
*
* A missing lower range is substituted with 0L, and a missing upper
* range with null. If the string cannot be parsed, returns (0L, null)
*/
func parseRange(rangeStr: String) -> (Int64, Int64?) {
let regex = try! NSRegularExpression(pattern: #"bytes=(\d*)-(\d*)"#)
let range = NSMakeRange(0, rangeStr.utf16.count)
if let match = regex.firstMatch(in: rangeStr, options: [], range: range) {
let start = Int64(String(rangeStr[Range(match.range(at: 1), in: rangeStr)!]))
let end = Int64(String(rangeStr[Range(match.range(at: 2), in: rangeStr)!]))
return (start!, end!)
} else {
return (0, nil)
}
}
/// Returns the content length extracted from the [responseHeaders], or from
/// the [task] headers
func getContentLength(responseHeaders: [AnyHashable: Any], task: Task) -> Int64 {
// On iOS, the header has already been parsed for Content-Length so we don't need to
// repeat that here (actually, we use the bytesExpectedToSend which is for some reason
// not always set even whe a Content-Length is set)
// Try extracting content length from Range header
let taskRangeHeader = task.headers["Range"] ?? ""
let taskRange = parseRange(rangeStr: taskRangeHeader)
if let end = taskRange.1 {
let rangeLength = end - taskRange.0 + 1
os_log("TaskId %@ contentLength set to %d based on Range header", log: log, type: .info, task.taskId, rangeLength)
return rangeLength
}
// try extracting it from a special "Known-Content-Length" header
let knownLength = Int64(task.headers["Known-Content-Length"] ?? "-1") ?? -1
if knownLength != -1 {
os_log("TaskId %@ contentLength set to %d based on Known-Content-Length header", log: log, type: .info, task.taskId, knownLength)
} else {
os_log("TaskId %@ contentLength undetermined", log: log, type: .info, task.taskId)
}
return knownLength
}
/// Sets the mimeType and charSet extracted from the Content-Type header
/// in [responseHeaders] and stores in static maps keyed by [task.taskId]
func extractContentType(responseHeaders: [AnyHashable: Any], task: Task) {
guard let contentType = responseHeaders["Content-Type"] as? String else { return }
let regEx = try! NSRegularExpression(pattern: #"(.*);\s*charset\s*=(.*)"#)
let range = NSMakeRange(0, contentType.utf16.count)
let match = regEx.firstMatch(in: contentType, options: [], range: range)
BDPlugin.propertyLock.withLock({
if let match = match {
let mimeType = String(contentType[Range(match.range(at: 1), in: contentType)!])
let charSet = String(contentType[Range(match.range(at: 2), in: contentType)!])
BDPlugin.mimeTypes[task.taskId] = mimeType
BDPlugin.charSets[task.taskId] = charSet
} else {
BDPlugin.mimeTypes[task.taskId] = contentType
}
})
}
/// Returns a list of fileData elements, one for each file to upload.
/// Each element is a triple containing fileField, full filePath, mimeType
///
/// The lists are stored in the similarly named String fields as a JSON list,
/// with each list the same length. For the filenames list, if a filename refers
/// to a file that exists (i.e. it is a full path) then that is the filePath used,
/// otherwise the filename is appended to the [Task.baseDirectory] and [Task.directory]
/// to form a full file path
func extractFilesData(task: Task) -> [((String, String, String))] {
let decoder = JSONDecoder()
guard
let fileFields = try? decoder.decode([String].self, from: task.fileField!.data(using: .utf8)!),
let filenames = try? decoder.decode([String].self, from: task.filename.data(using: .utf8)!),
let mimeTypes = try? decoder.decode([String].self, from: task.mimeType!.data(using: .utf8)!)
else {
os_log("Could not parse filesData from field=%@, filename=%@ and mimeType=%@", log: log, type: .error, task.fileField!, task.filename, task.mimeType!)
return []
}
var result = [(String, String, String)]()
for i in 0 ..< fileFields.count {
let fileUrl = URL(string: filenames[i])
let decodedFileUrl = fileUrl != nil ? decodeToFileUrl(uri: fileUrl!) : nil
if decodedFileUrl?.scheme == "file" {
// URI mode, return path
result.append((fileFields[i], decodedFileUrl!.path, mimeTypes[i]))
} else {
// file path mode
if FileManager.default.fileExists(atPath: filenames[i]) {
result.append((fileFields[i], filenames[i], mimeTypes[i]))
} else {
result.append((
fileFields[i],
getFilePath(for: task, withFilename: filenames[i]) ?? "",
mimeTypes[i]
))
}
}
}
return result
}
/// Return the host name for the task's url or ""
func getHost(_ task: Task) -> String {
return URL(string: task.url)?.host ?? ""
}
/// Calculate progress, network speed and time remaining, and send this at an appropriate
/// interval to the Dart side
func updateProgress(task: Task, totalBytesExpected: Int64, totalBytesDone: Int64) {
let info = BDPlugin.propertyLock.withLock({
return BDPlugin.progressInfo[task.taskId] ?? (lastProgressUpdateTime: 0.0, lastProgressValue: 0.0, lastTotalBytesDone: 0, lastNetworkSpeed: -1.0)
})
let now = Date().timeIntervalSince1970
if totalBytesExpected != NSURLSessionTransferSizeUnknown && now > info.lastProgressUpdateTime + 0.5 {
let progress = min(Double(totalBytesDone) / Double(totalBytesExpected), 0.999)
if (progress - info.lastProgressValue > 0.02) || (progress > info.lastProgressValue && now > info.lastProgressUpdateTime + 2.5) {
// calculate network speed and time remaining
let now = Date().timeIntervalSince1970
let timeSinceLastUpdate = now - info.lastProgressUpdateTime
let bytesSinceLastUpdate = totalBytesDone - info.lastTotalBytesDone
let currentNetworkSpeed: Double = timeSinceLastUpdate > 3600 ? -1.0 : Double(bytesSinceLastUpdate) / timeSinceLastUpdate / 1000000.0
let newNetworkSpeed = info.lastNetworkSpeed == -1.0 ? currentNetworkSpeed : (info.lastNetworkSpeed * 3.0 + currentNetworkSpeed) / 4.0
let remainingBytes = (1.0 - progress) * Double(totalBytesExpected)
let timeRemaining: TimeInterval = newNetworkSpeed == -1.0 ? -1.0 : (remainingBytes / newNetworkSpeed / 1000000.0)
BDPlugin.propertyLock.withLock({
BDPlugin.progressInfo[task.taskId] = (lastProgressUpdateTime: now, lastProgressValue: progress, lastTotalBytesDone: totalBytesDone, lastNetworkSpeed: newNetworkSpeed)
})
processProgressUpdate(task: task, progress: progress, expectedFileSize: totalBytesExpected, networkSpeed: newNetworkSpeed, timeRemaining: timeRemaining)
}
}
}
/// Processes a change in status for the task
///
/// Sends status update via the background channel to Dart, if requested
/// If the task is finished, processes a final progressUpdate update and removes
/// task from persistent storage
func processStatusUpdate(task: Task, status: TaskStatus, taskException: TaskException? = nil, responseBody: String? = nil, responseHeaders: [AnyHashable:Any]? = nil, responseStatusCode: Int? = nil, mimeType: String? = nil, charSet: String? = nil) {
// Intercept status updates resulting from re-enqueue requests, which
// themselves are triggered by a change in WiFi requirement
let intercepted = BDPlugin.propertyLock.withLock {
if BDPlugin.tasksToReEnqueue.remove(task) != nil {
defer {
if BDPlugin.tasksToReEnqueue.isEmpty {
WiFiQueue.shared.reEnqueue(nil) // signal end of batch
}
}
if [TaskStatus.paused, TaskStatus.canceled, TaskStatus.failed].contains(status) {
_ = BDPlugin.progressInfo.removeValue(forKey: task.taskId) // ensure .running update on re-enqueue
BDPlugin.initialResponseDataProcessed.remove(task.taskId)
let resumeDataAsBase64String = status == .paused ? (BDPlugin.localResumeData[task.taskId] ?? "") : ""
if status != .paused {
BDPlugin.localResumeData.removeValue(forKey: task.taskId)
}
let reEnqueueData = EnqueueItem(task: task, notificationConfigJsonString: BDPlugin.notificationConfigJsonStrings[task.taskId], resumeDataAsBase64String: resumeDataAsBase64String)
WiFiQueue.shared.reEnqueue(reEnqueueData)
return true // intercepted
}
}
return false // not intercepted
}
if intercepted {
return
}
// Normal status update
// Post update if task expects one, or if failed and retry is needed
let retryNeeded = status == TaskStatus.failed && task.retriesRemaining > 0
// if task is in final state, process a final progressUpdate
// A 'failed' progress update is only provided if
// a retry is not needed: if it is needed, a `waitingToRetry` progress update
// will be generated on the Dart side
switch (status) {
case .complete:
processProgressUpdate(task: task, progress: 1.0)
case .failed:
if !retryNeeded {
processProgressUpdate(task: task, progress: -1.0)
}
case .canceled:
processProgressUpdate(task: task, progress: -2.0)
case .notFound:
processProgressUpdate(task: task, progress: -3.0)
case .paused:
processProgressUpdate(task: task, progress: -5.0)
default:
break
}
// determine the TaskStatusUpdate
let finalResponseStatusCode = status == .complete || status == .notFound
? responseStatusCode
: nil
let finalTaskException = taskException == nil
? TaskException(type: .general, httpResponseCode: -1, description: "")
: taskException
let statusUpdate = isFinalState(status: status)
? TaskStatusUpdate(task: task,
taskStatus: status,
exception: status == .failed ? finalTaskException : nil,
responseBody: responseBody,
responseStatusCode: (status == .complete || status == .notFound) ? finalResponseStatusCode : nil,
responseHeaders: lowerCasedStringStringMap(responseHeaders),
mimeType: mimeType,
charSet: charSet)
: TaskStatusUpdate(task: task, taskStatus: status)
if let onNativeTaskStatusChange = BDPlugin.onNativeTaskStatusChange {
onNativeTaskStatusChange(task, statusUpdate)
}
if providesStatusUpdates(downloadTask: task) || retryNeeded {
let arg = statusUpdate.argList()
if !postOnBackgroundChannel(method: "statusUpdate", task: task, arg: arg) {
// store update locally as a merged task/status JSON string, without error info
guard let jsonData = try? JSONEncoder().encode(statusUpdate)
else {
os_log("Could not store status update locally", log: log, type: .debug)
return }
storeLocally(prefsKey: BDPlugin.keyStatusUpdateMap, taskId: task.taskId, item: jsonData)
}
}
if isFinalState(status: status) {
// remove references to this task that are no longer needed
BDPlugin.propertyLock.withLock {
BDPlugin.progressInfo.removeValue(forKey: task.taskId)
BDPlugin.localResumeData.removeValue(forKey: task.taskId)
BDPlugin.remainingBytesToDownload.removeValue(forKey: task.taskId)
BDPlugin.tasksWithModifications.removeValue(forKey: task.taskId)
BDPlugin.tasksWithContentLengthOverride.removeValue(forKey: task.taskId)
BDPlugin.responseBodyData.removeValue(forKey: task.taskId)
BDPlugin.notificationConfigJsonStrings.removeValue(forKey: task.taskId)
BDPlugin.taskIdsThatCanResume.remove(task.taskId)
BDPlugin.taskIdsProgrammaticallyCanceledAfterStart.remove(task.taskId)
}
// invoke onTaskFinished callback if needed
if task.options?.hasOnFinishedCallback() == true {
_Concurrency.Task {
if !(await invokeOnTaskFinishedCallback(taskStatusUpdate: statusUpdate)) {
os_log("Could not invoke onTaskFinishedCallback", log: log, type: .error)
}
}
}
}
}
/// Processes a progress update for the task
///
/// Sends progress update via the background channel to Dart, if requested
func processProgressUpdate(task: Task, progress: Double, expectedFileSize: Int64 = -1, networkSpeed: Double = -1.0, timeRemaining: TimeInterval = -1.0) {
if let onNativeTaskProgressChange = BDPlugin.onNativeTaskProgressChange {
onNativeTaskProgressChange(task, progress)
}
if providesProgressUpdates(task: task) {
if (!postOnBackgroundChannel(method: "progressUpdate", task: task, arg: [progress, expectedFileSize, networkSpeed, Int(timeRemaining * 1000.0)] as [Any])) {
// store update locally as a merged task/progress JSON string
guard let jsonData = try? JSONEncoder().encode(TaskProgressUpdate(task: task, progress: progress, expectedFileSize: expectedFileSize))
else {
os_log("Could not store progress update locally", log: log, type: .info)
return }
storeLocally(prefsKey: BDPlugin.keyProgressUpdateMap, taskId: task.taskId, item: jsonData)
}
}
}
/// Process a 'canResume' message for the task
///
/// Sends the data via the background channel to Dart
func processCanResume(task: Task, taskCanResume: Bool) {
if !postOnBackgroundChannel(method: "canResume", task: task, arg: taskCanResume) {
os_log("Could not post CanResume", log: log, type: .info)
}
}
/// Post resume data for this task
///
/// Returns true if successful.
/// Sends the data via the background channel to Dart
func processResumeData(task: Task, resumeData: Data) -> Bool {
let resumeDataAsBase64String = resumeData.base64EncodedString()
BDPlugin.propertyLock.withLock {
BDPlugin.localResumeData[task.taskId] = resumeDataAsBase64String
}
if !postOnBackgroundChannel(method: "resumeData", task: task, arg: resumeDataAsBase64String) {
// store resume data locally
guard let jsonData = try? JSONEncoder().encode(ResumeData(task: task, data: resumeDataAsBase64String))
else {
os_log("Could not store resume data locally", log: log, type: .info)
return false}
storeLocally(prefsKey: BDPlugin.keyResumeDataMap, taskId: task.taskId, item: jsonData)
}
return true
}
/// Return the background channel for cummincation to Dart side, or nil
func getBackgroundChannel() -> FlutterMethodChannel? {
guard let channel = BDPlugin.backgroundChannel else {
os_log("Could not find background channel", log: log, type: .error)
return nil
}
return channel
}
/// Post method message on backgroundChannel with arguments and return true if this was successful
///
/// [arg] can be a list or a single variable
func postOnBackgroundChannel(method: String, task:Task, arg: Any) -> Bool {
guard let channel = BDPlugin.backgroundChannel else {
os_log("Could not find background channel", log: log, type: .error)
return false
}
guard let jsonString = jsonStringFor(task: task) else {
os_log("Could not convert task to JSON", log: log, type: .error)
return false
}
var argsList: [Any?] = [jsonString]
if arg is [Any?] {
argsList.append(contentsOf: arg as! [Any?])
} else {
argsList.append(arg)
}
if Thread.isMainThread {
DispatchQueue.main.async {
channel.invokeMethod(method, arguments: argsList)
}
return true
}
var success = false
updatesQueue.sync {
let dispatchGroup = DispatchGroup()
dispatchGroup.enter()
DispatchQueue.main.async {
channel.invokeMethod(method, arguments: argsList, result: {(r: Any?) -> () in
success = !(r is FlutterError)
if BDPlugin.forceFailPostOnBackgroundChannel {
success = false
}
dispatchGroup.leave()
})
}
dispatchGroup.wait()
}
return success
}
/// Store the [item] in preferences under [prefsKey], keyed by [taskId]
///
/// [item] is a JsonEncoded Data object
func storeLocally(prefsKey: String, taskId: String,
item: Data) {
let defaults = UserDefaults.standard
var map = defaults.dictionary(forKey: prefsKey) ?? [:]
map[taskId] = String(data: item, encoding: .utf8)
defaults.set(map, forKey: prefsKey)
}
/// Returns a JSON string for this Task, or nil
func jsonStringFor(task: Task) -> String? {
let jsonEncoder = JSONEncoder()
guard let jsonResultData = try? jsonEncoder.encode(task)
else {
return nil
}
return String(data: jsonResultData, encoding: .utf8)
}
/// Returns a JSON string for this TaskStatusUpdate, or nil
func jsonStringFor(taskStatusUpdate: TaskStatusUpdate) -> String? {
let jsonEncoder = JSONEncoder()
guard let jsonResultData = try? jsonEncoder.encode(taskStatusUpdate)
else {
return nil
}
return String(data: jsonResultData, encoding: .utf8)
}
/// Returns a Task from the supplied jsonString, or nil
func taskFrom(jsonString: String) -> Task? {
let decoder = JSONDecoder()
let task: Task? = try? decoder.decode(Task.self, from: (jsonString).data(using: .utf8)!)
return task
}
/// Return the task corresponding to the URLSessionTask, or nil if it cannot be matched
///
/// May return a modified version of the task instead of the original stored with the URLSessionTask, e.g.
/// if the filename has changed
func getTaskFrom(urlSessionTask: URLSessionTask) -> Task? {
guard let jsonData = getTaskJsonStringFrom(urlSessionTask: urlSessionTask)?.data(using: .utf8)
else {
return nil
}
let decoder = JSONDecoder()
if let task = try? decoder.decode(Task.self, from: jsonData) {
let modifiedTask = BDPlugin.propertyLock.withLock({
BDPlugin.tasksWithModifications[task.taskId]
})
return modifiedTask ?? task
}
return nil
}
/// Marks the task as modified such that [getTaskFrom] a UrlSession wlll get the modified task, not the one'
/// immutably stored with the urlSession
func storeModifiedTask(task: Task) {
BDPlugin.propertyLock.withLock({
BDPlugin.tasksWithModifications[task.taskId] = task
})
}
/// Returns the taskJsonString contained in the urlSessionTask
func getTaskJsonStringFrom(urlSessionTask: URLSessionTask) -> String? {
guard let taskDescription = urlSessionTask.taskDescription else {
return nil
}
if taskDescription.contains(separatorString) {
return taskDescription.components(separatedBy: separatorString)[0]
}
return taskDescription
}
/// Return the notificationConfig corresponding to the URLSessionTask, or nil if it cannot be matched
func getNotificationConfigFrom(urlSessionTask: URLSessionTask) -> NotificationConfig? {
guard let jsonData = getNotificationConfigJsonStringFrom(urlSessionTask: urlSessionTask)?.data(using: .utf8)
else {
return nil
}
let decoder = JSONDecoder()
return try? decoder.decode(NotificationConfig.self, from: jsonData)
}
/// Returns the notificationConfigJsonString contained in the urlSessionTask
func getNotificationConfigJsonStringFrom(urlSessionTask: URLSessionTask) -> String? {
guard let taskDescription = urlSessionTask.taskDescription else {
return nil
}
if taskDescription.contains(separatorString) {
return taskDescription.components(separatedBy: separatorString)[1]
}
return nil
}
/// Returns the URL of the directory where the file for this task is stored
///
/// This is made up of the baseDirectory and the directory fields of the Task
func directoryForTask(task: Task) throws -> URL {
let documentsURL: URL
if task.baseDirectory != BaseDirectory.root.rawValue {
var dir: FileManager.SearchPathDirectory
switch task.baseDirectory {
case 0:
dir = .documentDirectory
case 1:
dir = .cachesDirectory
case 2:
dir = .applicationSupportDirectory
case 3:
dir = .libraryDirectory
default:
dir = .documentDirectory
}
documentsURL =
try FileManager.default.url(for: dir,
in: .userDomainMask,
appropriateFor: nil,
create: true)
} else {
documentsURL = URL(fileURLWithPath: "/")
}
return task.directory.isEmpty
? documentsURL
: documentsURL.appendingPath(task.directory, isDirectory: true)
}
/// True if task requires WiFi, based on global and task-specific setting
func taskRequiresWiFi(task: Task) -> Bool {
return BDPlugin.requireWiFi == .forAllTasks || (BDPlugin.requireWiFi == .asSetByTask && task.requiresWiFi)
}
/**
* Returns true if there is insufficient space to store a file of length
* [contentLength]
*
* Returns false if [contentLength] <= 0
* Returns false if configCheckAvailableSpace has not been set, or if available
* space is greater than that setting
* Returns true otherwise
*/
func insufficientSpace(contentLength: Int64) -> Bool {
guard contentLength > 0 else {
return false
}
let checkValue = UserDefaults.standard.integer(forKey: BDPlugin.keyConfigCheckAvailableSpace)
guard
// Check if the configCheckAvailableSpace preference is set and is positive
checkValue > 0,
let path = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first,
let available = try? URL(fileURLWithPath: path).resourceValues(forKeys: [URLResourceKey.volumeAvailableCapacityForImportantUsageKey]).volumeAvailableCapacityForImportantUsage
else {
return false
}
// Calculate the total remaining bytes to download
let remainingBytesToDownload = BDPlugin.propertyLock.withLock( {
BDPlugin.remainingBytesToDownload.values.reduce(0, +)
})
// Return true if there is insufficient space to store the file
return available - (remainingBytesToDownload + contentLength) < checkValue << 20
}
/// Post result [value] on FlutterResult completer
func postResult(result: FlutterResult?, value: Any) {
if result != nil {
result!(value)
}
}
@@ -0,0 +1,257 @@
//
// Uploader.swift
// background_downloader
//
// Created on 2/11/23.
//
import Foundation
import os.log
enum StreamError: Error {
case Closed
}
/// Uploader associated with one URLSessionUploadTask
public class Uploader : NSObject, URLSessionTaskDelegate, StreamDelegate {
var task: Task
let outputFilename: String
var contentDispositionString = ""
var contentTypeString = ""
var fieldsString = ""
var totalBytesWritten: Int64 = 0
static let boundary = "-----background_downloader-akjhfw281onqciyhnIk"
let lineFeed = "\r\n"
let asciiOnly = try! NSRegularExpression(pattern: "^[\\x00-\\x7F]+$")
let jsonString = try! NSRegularExpression(pattern: "^\\s*(\\{.*\\}|\\[.*\\])\\s*$")
let newlineRegExp = try! NSRegularExpression(pattern: "\r\n|\r|\n")
let bufferSize = 2 << 13
/// Initialize an Uploader for this Task
init(task: Task) {
self.task = task
outputFilename = NSUUID().uuidString
}
/// Creates the multipart file so it can be uploaded
///
/// Returns true if successful, false otherwise (cleaning up any partial file on failure).
public func createMultipartFile() -> Bool {
if writeMultipartFile() {
return true
}
try? FileManager.default.removeItem(at: outputFileUrl())
return false
}
/// Writes the multipart file, returning true if successful
private func writeMultipartFile() -> Bool {
// create the output file
FileManager.default.createFile(atPath: outputFileUrl().path, contents:Data(" ".utf8), attributes: nil)
guard let fileHandle = try? FileHandle(forWritingTo: outputFileUrl()) else {
os_log("Could not open temporary file %@", log: log, type: .error, outputFileUrl().path)
return false
}
// field portion of the multipart, all in one string
// multiple values should be encoded as '"value1", "value2", ...'
let multiValueRegEx = try! NSRegularExpression(pattern: #"^(?:"[^"]+"\s*,\s*)+"[^"]+"$"#, options: [])
for entry in task.fields ?? [:] {
let value = entry.value
let fullRange = NSRange(location: 0, length: value.utf16.count)
// Check if the value matches the multi-value format
if multiValueRegEx.firstMatch(in: value, options: [], range: fullRange) != nil {
// Extract multiple values from entry.value
let valueMatchRegEx = try! NSRegularExpression(pattern: #""([^"]+)""#, options: [])
valueMatchRegEx.enumerateMatches(in: value, options: [], range: fullRange) { match, _, _ in
if let matchRange = match?.range(at: 1), // Capture group 1
let swiftRange = Range(matchRange, in: value) {
let matchedString = String(value[swiftRange])
fieldsString += fieldEntry(name: entry.key, value: matchedString)
}
}
} else {
// Handle single value for key
fieldsString += fieldEntry(name: entry.key, value: entry.value)
}
}
if (!writeFields(fileHandle: fileHandle)) {
os_log("Could not write to temporary file %@", log: log, type: .error, outputFileUrl().path)
return false
}
// File portion of the multi-part
// Assumes list of files. If only one file, that becomes a list of length one.
// For each file, determine contentDispositionString, contentTypeString
// and file length, so that we can calculate total size of upload
let separator = "\(lineFeed)--\(Uploader.boundary)\(lineFeed)" // between files
let terminator = "\(lineFeed)--\(Uploader.boundary)--\(lineFeed)" // after last file
let maybeFileUri = uriFromStringValue(maybePacked: task.filename)
let maybeDecodedFileUri = maybeFileUri != nil ? decodeToFileUrl(uri: maybeFileUri!) : nil
guard let filePath = getFilePath(for: task) else {return false}
let filesData = filePath.isEmpty
? extractFilesData(task: task) // MultiUpload case
: [(task.fileField!,maybeDecodedFileUri?.path ?? filePath, task.mimeType!)] // one file Upload case
for (fileField, path, mimeType) in filesData {
if !FileManager.default.fileExists(atPath: path) {
os_log("File to upload does not exist at %@", log: log, type: .error, path)
return false
}
let derivedFilename = path.components(separatedBy: "/").last!
if filesData.count == 1 && task.taskType != "MultiUploadTask" {
// only for single file uploads do we set the task's filename property
let newTask = task.copyWith(filename: maybeFileUri != nil
? pack(filename: derivedFilename, uri: maybeFileUri!)
: derivedFilename)
storeModifiedTask(task: newTask)
}
let contentDispositionString =
"Content-Disposition: form-data; name=\"\(browserEncode(fileField))\"; "
+ "filename=\"\(browserEncode(derivedFilename))\"\(lineFeed)"
let resolvedMimeType = mimeType.isEmpty ? getMimeType(fromFilename: path) : mimeType
let contentTypeString = "Content-Type: \(resolvedMimeType)\(lineFeed)\(lineFeed)"
let fileUrl = URL(fileURLWithPath: path)
guard let inputStream = InputStream(url: fileUrl) else {
os_log("Could not open file to upload at %@", log: log, type: .error, path)
return false
}
if (!writeFileBytes(fileHandle: fileHandle, inputStream: inputStream, contentDisposition: contentDispositionString, contentType: contentTypeString)) {
os_log("Could not upload file at %@", log: log, type: .error, path)
return false
}
if (!writeText(fileHandle: fileHandle, text: path == filesData.last!.1 ?
terminator : separator)) {
os_log("Could not write separator for file at %@", log: log, type: .error, path)
return false
}
}
return true
}
/// Return the URL of the generated outputfile with multipart data
///
/// Callers outside this class should only use this after calling
/// createMultipartFile, and only when that returns true
func outputFileUrl() -> URL {
return FileManager.default.temporaryDirectory.appendingPath(outputFilename)
}
/// Write form fields
///
/// Returns true if successful, false otherwise
private func writeFields(fileHandle: FileHandle) -> Bool {
// construct the preamble
return writeText(fileHandle: fileHandle, text: "\(fieldsString)--\(Uploader.boundary)\(lineFeed)")
}
/// Write the data bytes from the provided inputStream to the fileHandle
///
/// Retruns true if successful, false otherwise
private func writeFileBytes(fileHandle: FileHandle, inputStream: InputStream, contentDisposition: String, contentType: String) -> Bool {
// multipart file header
if !(writeText(fileHandle: fileHandle, text: contentDisposition) &&
writeText(fileHandle: fileHandle, text: contentType)) {
return false
}
// file bytes
inputStream.open()
while inputStream.hasBytesAvailable {
let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
defer {
buffer.deallocate()
}
let bytesRead = inputStream.read(buffer, maxLength: bufferSize)
if bytesRead < 0 {
// Stream error occured
os_log("Error reading from file for taskId %@", log: log, type: .error, task.taskId)
inputStream.close()
return false
} else if bytesRead == 0 {
inputStream.close()
return true
}
let data = Data.init(bytesNoCopy: buffer, count: bytesRead, deallocator: .none)
do {
try fileHandle.write(contentsOf: data)
} catch {
os_log("Error writing to file for taskId %@: %@", log: log, type: .error, task.taskId, error.localizedDescription)
inputStream.close()
return false
}
}
inputStream.close()
return true
}
/// Write text to the [fileHandle] and return true if successful
private func writeText(fileHandle: FileHandle, text: String) -> Bool {
guard let epilogue = text.data(using: .utf8) else {
return false
}
do {
try fileHandle.write(contentsOf: epilogue)
} catch {
os_log("Error writing to file for taskId %@: %@", log: log, type: .error, task.taskId, error.localizedDescription)
return false
}
totalBytesWritten += Int64(epilogue.count)
return true
}
/// Returns the multipart entry for one field name/value pair
private func fieldEntry(name: String, value: String) -> String {
return "--\(Uploader.boundary)\(lineFeed)\(headerForField(name: name, value: value))\(value)\(lineFeed)"
}
/// Returns the header string for a field
///
/// The return value is guaranteed to contain only ASCII characters
private func headerForField(name: String, value: String) -> String {
var header = "Content-Disposition: form-data; name=\"\(browserEncode(name))\""
if isJsonString(value) {
header = "\(header)\r\n" +
"Content-Type: application/json; charset=utf-8"
} else if !isPlainAscii(value) {
header = "\(header)\r\n" +
"Content-Type: text/plain; charset=utf-8\r\n" +
"Content-Transfer-Encoding: binary"
}
return "\(header)\r\n\r\n"
}
/// Returns whether [string] is composed entirely of ASCII-compatible characters
private func isPlainAscii(_ string: String)-> Bool {
let result = asciiOnly.matches(in: string,
range: NSMakeRange(0, (string as NSString).length))
return !result.isEmpty
}
/// Returns whether [string] is a JSON formatted string
private func isJsonString(_ string: String)-> Bool {
let result = jsonString.matches(in: string,
range: NSMakeRange(0, (string as NSString).length))
return !result.isEmpty
}
/// Encode [value] in the same way browsers do
private func browserEncode(_ value: String)-> String {
// http://tools.ietf.org/html/rfc2388 mandates some complex encodings for
// field names and file names, but in practice user agents seem not to
// follow this at all. Instead, they URL-encode `\r`, `\n`, and `\r\n` as
// `\r\n`; URL-encode `"`; and do nothing else (even for `%` or non-ASCII
// characters). We follow their behavior.
let newlinesReplaced = newlineRegExp.stringByReplacingMatches(
in: value,
options: [],
range: NSMakeRange(0, (value as NSString).length), withTemplate: "%0D%0A")
return newlinesReplaced.replacingOccurrences(of: "\"", with: "%22")
}
}
@@ -0,0 +1,762 @@
//
// UriUtils.swift
// background_downloader
//
// Created by Bram on 1/18/25.
//
import Flutter
import UIKit
import UniformTypeIdentifiers
import MobileCoreServices
import PhotosUI
import os.log
public class UriUtilsMethodCallHelper: NSObject,
FlutterPlugin,
UIDocumentPickerDelegate,
PHPickerViewControllerDelegate {
private var flutterResult: FlutterResult?
private var persistedUriPermission: Bool = false
private var multipleAllowed: Bool = false
private var allowVideos = false
private var localFileUrls: [String] = [] // for media picker
private var accessedSecurityScopedUrls: Set<URL> = Set()
public static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(name: "com.bbflight.background_downloader.uriutils", binaryMessenger: registrar.messenger())
let instance = UriUtilsMethodCallHelper()
registrar.addMethodCallDelegate(instance, channel: channel)
}
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "pickDirectory":
pickDirectory(call, result: result)
case "pickFiles":
pickFiles(call, result: result)
case "createDirectory":
createDirectory(call, result: result)
case "activateUri":
activateUri(call, result: result)
case "getFileBytes":
getFileBytes(call, result: result)
case "copyFile":
copyFile(call, result: result)
case "moveFile":
moveFile(call, result: result)
case "deleteFile":
deleteFile(call, result: result)
case "openFile":
openFile(call, result: result)
default:
result(FlutterMethodNotImplemented)
}
}
private func pickDirectory(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [Any],
let persistedUriPermission = args[2] as? Bool else {
result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments for pickDirectory", details: nil))
return
}
self.persistedUriPermission = persistedUriPermission
self.flutterResult = result
var startLocation: URL? = nil
if let startLocationUriString = args[1] as? String,
let startLocationUri = decodeToFileUrl(uriString: startLocationUriString) {
startLocation = startLocationUri
} else if let startLocationOrdinal = args[0] as? Int,
let sharedStorage = SharedStorage(rawValue: startLocationOrdinal) {
startLocation = getInitialDirectoryUrl(location: sharedStorage)
}
let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: [UTType.folder], asCopy: false)
documentPicker.delegate = self
documentPicker.allowsMultipleSelection = false
// Set the initial directory if available
if #available(iOS 14.0, *), let startLocation = startLocation {
documentPicker.directoryURL = startLocation
}
// Present the document picker
if let rootViewController = getRootViewController() {
rootViewController.present(documentPicker, animated: true, completion: nil)
} else {
completeFlutterResult(FlutterError(code: "NO_ROOT_VIEW_CONTROLLER", message: "Could not find root view controller", details: nil))
}
}
private func pickFiles(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [Any],
let multipleAllowed = args[3] as? Bool,
let persistedUriPermission = args[4] as? Bool else {
result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments for pickFiles", details: nil))
return
}
self.persistedUriPermission = persistedUriPermission
self.multipleAllowed = multipleAllowed
self.flutterResult = result
var startLocation: URL? = nil
if let startLocationUriString = args[1] as? String,
let startLocationUri = decodeToFileUrl(uriString: startLocationUriString) {
startLocation = startLocationUri
} else if let startLocationOrdinal = args[0] as? Int,
let sharedStorage = SharedStorage(rawValue: startLocationOrdinal) {
if sharedStorage == .images || sharedStorage == .video {
pickMedia(startLocation: sharedStorage)
return
}
startLocation = getInitialDirectoryUrl(location: sharedStorage)
}
// Convert allowed extensions to UTTypes
var allowedContentTypes: [UTType] = []
if let allowedExtensions = args[2] as? [String] {
for ext in allowedExtensions {
if let utType = UTType(filenameExtension: ext) {
allowedContentTypes.append(utType)
}
}
}
if allowedContentTypes.isEmpty {
allowedContentTypes = [UTType.item] // Allow all types if no valid extensions are provided
}
let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: allowedContentTypes, asCopy: false)
documentPicker.delegate = self
documentPicker.allowsMultipleSelection = multipleAllowed
// Set the initial directory if available
if let startLocation = startLocation {
documentPicker.directoryURL = startLocation
}
// Present the document picker
if let rootViewController = UIApplication.shared.keyWindow?.rootViewController {
rootViewController.present(documentPicker, animated: true, completion: nil)
} else {
completeFlutterResult(FlutterError(code: "NO_ROOT_VIEW_CONTROLLER", message: "Could not find root view controller", details: nil))
}
}
/**
* Launches the photo picker to select one or more media files (images or videos).
*
* Called only from [pickFiles] where argument
* parsing has already happened
*
* @param startLocation The shared storage location to start the picker in (images or video).
*
* The result posted back is either a list of URIs as a String, or null (if the user has
* cancelled the picker) or an error
*/
private func pickMedia(startLocation: SharedStorage) {
var configuration = PHPickerConfiguration()
configuration.filter = startLocation == .images ? .images : .videos
configuration.selectionLimit = multipleAllowed ? 0 : 1 // 0 means unlimited
let pickerViewController = PHPickerViewController(configuration: configuration)
pickerViewController.delegate = self
if let rootViewController = getRootViewController() {
rootViewController.present(pickerViewController, animated: true, completion: nil)
} else {
completeFlutterResult(FlutterError(code: "NO_ROOT_VIEW_CONTROLLER", message: "Could not find root view controller", details: nil))
}
}
/**
* Creates a new directory at the specified path within the given parent directory URI.
* The parent directory URI must be resolvable to a file:// URI. Supports creating intermediate directories.
*
* - Parameters:
* - call: The Flutter method call. The arguments are expected to be a list
* containing: the parent directory URI (String), the new directory name (String),
* and a boolean indicating whether to request persisted URI permission.
* - result: The Flutter result callback. On success, the URI of the newly
* created directory (String) is returned. On failure, a `FlutterError` is returned.
*/
private func createDirectory(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [Any],
let parentDirectoryUriString = args[0] as? String,
let newDirectoryName = args[1] as? String,
let persistedUriPermission = args[2] as? Bool,
let parentDirectoryUri = decodeToFileUrl(uriString: parentDirectoryUriString)
else {
result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments for createDirectory", details: nil))
return
}
do {
let newDirectoryURL = parentDirectoryUri.appendingPathComponent(newDirectoryName, isDirectory: true)
// Ensure we have access to the parent directory before attempting to create a subdirectory.
if !parentDirectoryUri.startAccessingSecurityScopedResource() {
result(FlutterError(code: "ACCESS_DENIED", message: "Failed to access parent directory: \(parentDirectoryUri)", details: nil))
return
}
accessedSecurityScopedUrls.insert(parentDirectoryUri)
// Create the directory
try FileManager.default.createDirectory(at: newDirectoryURL, withIntermediateDirectories: true, attributes: nil)
// If persistence is requested, we will also need to stop accessing and deallocate the URL when done.
if persistedUriPermission {
accessedSecurityScopedUrls.insert(newDirectoryURL)
// Return the persisted URI (bookmark)
let bookmarkUri = try createBookmarkUriFromUrl(fileURL: newDirectoryURL)
result(bookmarkUri.absoluteString)
} else {
// Return the regular file URI
result(newDirectoryURL.absoluteString)
}
} catch {
result(FlutterError(code: "CREATE_DIRECTORY_FAILED", message: "Failed to create directory: \(error.localizedDescription)", details: nil))
}
}
/**
* Activates a previously accessed directory or file (represented by a URI string)
* by calling `startAccessingSecurityScopedResource`. This is necessary on iOS
* to regain access to resources outside the app's sandbox that were previously
* granted access (e.g., through a document picker). Also decodes `urlbookmark://`
* and `media://` URIs to `file://` URIs.
*
* - Parameters:
* - call: The Flutter method call. The argument is expected to be the URI string.
* - result: The Flutter result callback. On success, the (potentially decoded)
* `file://` URI string is returned. On failure (e.g., invalid URI, access denied),
* a `FlutterError` is returned. If the URI is decoded, it is also added to
* `accessedSecurityScopedUrls`
*/
private func activateUri(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let uriString = call.arguments as? String,
let uri = decodeToFileUrl(uriString: uriString)
else {
result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments for activateUri", details: nil))
return
}
// Start accessing the security-scoped resource.
if !uri.startAccessingSecurityScopedResource() {
completeFlutterResult(FlutterError(code: "ACCESS_DENIED", message: "activateUri failed to access security-scoped resource: \(uri)", details: nil))
return
}
accessedSecurityScopedUrls.insert(uri)
result(uri.absoluteString)
}
/**
* Retrieves the file data (bytes) for a given URI string.
*
* - Parameters:
* - call: The Flutter method call containing the arguments. The argument is
* expected to be the URI string. This string will be decoded using `decodeToFileUrl`.
* - result: The Flutter result callback to be invoked with the resul---t. On success
* the file data as a `FlutterStandardTypedData` is returned. On failure,
* a `FlutterError` is returned.
*/
private func getFileBytes(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let uriString = call.arguments as? String,
let fileUrl = decodeToFileUrl(uriString: uriString) else {
result(FlutterError(code: "INVALID_URI", message: "Invalid or unresolvable URI: \(call.arguments)", details: nil))
return
}
// If this is a bookmark URI, we need to start accessing the security-scoped resource.
if uriString.starts(with: "urlbookmark://") {
if !fileUrl.startAccessingSecurityScopedResource() {
result(FlutterError(code: "ACCESS_DENIED", message: "Failed to access security-scoped resource: \(fileUrl)", details: nil))
return
}
accessedSecurityScopedUrls.insert(fileUrl)
}
do {
let fileData = try Data(contentsOf: fileUrl)
result(FlutterStandardTypedData(bytes: fileData))
} catch {
result(FlutterError(code: "FILE_READ_ERROR", message: "Failed to read file data: \(error.localizedDescription)", details: nil))
}
}
/**
* Copies the file at the given source URI to the destination URI.
* Both the source and destination URIs must be resolvable to file:// URIs.
*
* - Parameters:
* - call: The Flutter method call containing the arguments. The arguments
* are expected to be a list containing two strings: the source URI and
* the destination URI.
* - result: The Flutter result callback to be invoked with the result
* of the copy operation. On success, the destination URI string is
* returned. On failure, a `FlutterError` is returned.
*/
private func copyFile(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [Any],
let sourceUriString = args[0] as? String,
let destinationUriString = args[1] as? String,
let sourceUrl = decodeToFileUrl(uriString: sourceUriString), // Decode to file URL
let destinationUrl = decodeToFileUrl(uriString: destinationUriString) // Decode to file URL
else {
result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments for copyFile", details: nil))
return
}
do {
// Ensure destination directory exists
let destinationDirectory = destinationUrl.deletingLastPathComponent()
if !FileManager.default.fileExists(atPath: destinationDirectory.path) {
try FileManager.default.createDirectory(at: destinationDirectory, withIntermediateDirectories: true, attributes: nil)
}
try FileManager.default.copyItem(at: sourceUrl, to: destinationUrl)
result(destinationUrl.absoluteString)
} catch {
result(FlutterError(code: "COPY_FAILED", message: "Failed to copy file: \(error.localizedDescription)", details: nil))
}
}
/**
* Moves the file at the given source URI to the destination URI.
* Both the source and destination URIs must be resolvable to file:// URIs.
*
* - Parameters:
* - call: The Flutter method call containing the arguments. The arguments
* are expected to be a list containing two strings: the source URI and
* the destination URI.
* - result: The Flutter result callback to be invoked with the result
* of the move operation. On success, the destination URI string is
* returned. On failure, a `FlutterError` is returned.
*/
private func moveFile(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [Any],
let sourceUriString = args[0] as? String,
let destinationUriString = args[1] as? String,
let sourceUrl = decodeToFileUrl(uriString: sourceUriString), // Decode!
let destinationUrl = decodeToFileUrl(uriString: destinationUriString) // Decode!
else {
result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments for moveFile", details: nil))
return
}
do {
// Ensure destination directory exists
let destinationDirectory = destinationUrl.deletingLastPathComponent()
if !FileManager.default.fileExists(atPath: destinationDirectory.path) {
try FileManager.default.createDirectory(at: destinationDirectory, withIntermediateDirectories: true, attributes: nil)
}
try FileManager.default.moveItem(at: sourceUrl, to: destinationUrl)
result(destinationUrl.absoluteString)
} catch {
result(FlutterError(code: "MOVE_FAILED", message: "Failed to move file: \(error.localizedDescription)", details: nil))
}
}
/**
* Deletes the file at the given URI. The URI must be resolvable to a file:// URI.
*
* - Parameters:
* - call: The Flutter method call containing the arguments. The argument
* is expected to be a string representing the file URI.
* - result: The Flutter result callback. `true` is returned on success,
* and a `FlutterError` is returned on failure.
*/
private func deleteFile(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let uriString = call.arguments as? String,
let fileUrl = decodeToFileUrl(uriString: uriString),
fileUrl.scheme == "file" else {
result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid or non-file URI provided for deleteFile", details: nil))
return
}
do {
try FileManager.default.removeItem(at: fileUrl)
result(true)
} catch {
result(FlutterError(code: "DELETE_FILE_FAILED", message: "Failed to delete file: \(error.localizedDescription)", details: nil))
}
}
/**
* Opens the file at the given URI using the system's default application for the file type.
*
* - Parameters:
* - call: The Flutter method call containing the arguments. The arguments are
* expected to be a list where the first element is the URI string, and the
* optional second element is the MIME type (as a String).
* - result: The Flutter result callback. `true` is returned on successful launch,
* and a `FlutterError` is returned on failure.
*/
private func openFile(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [Any],
let uriString = args[0] as? String,
let mimeType = args[1] as? String?,
let fileUrl = decodeToFileUrl(uriString: uriString)
else {
result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments for openFile", details: nil))
return
}
let filePath = fileUrl.path
if !FileManager.default.fileExists(atPath: fileUrl.path) {
os_log("File does not exist at %@", log: log, type: .info, filePath)
result(FlutterError(code: "FILE_NOT_FOUND", message: "File does not exist at \(filePath)", details: nil))
return
}
result(doOpenFile(filePath: filePath, mimeType: mimeType != nil ? mimeType : getMimeType(fromFilename: filePath)))
}
/**
* Complete the flutter result callback and destroy the result object
*/
private func completeFlutterResult(_ result: Any?) {
self.flutterResult?(result)
self.flutterResult = nil
}
// MARK: - UIDocumentPickerDelegate
public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
if multipleAllowed {
var resultUrls: [String] = []
for url in urls {
// Start accessing the security-scoped resource.
if !url.startAccessingSecurityScopedResource() {
// Handle access error (e.g., by skipping this URL and logging an error message)
os_log("Failed to access security-scoped resource for %@", log: log, type: .info, url.absoluteString)
continue
}
accessedSecurityScopedUrls.insert(url)
let pickedUrl: URL
if persistedUriPermission {
do {
pickedUrl = try createBookmarkUriFromUrl(fileURL: url)
} catch {
completeFlutterResult(FlutterError(code: "CREATE_PERSISTED_URI_FAILED", message: "Failed to create persisted URI: \(error.localizedDescription)", details: nil))
return
}
} else {
pickedUrl = url
}
resultUrls.append(pickedUrl.absoluteString)
}
completeFlutterResult(resultUrls)
} else {
// Single selection
guard let url = urls.first else {
completeFlutterResult(nil) // User cancelled
return
}
// Start accessing the security-scoped resource.
if !url.startAccessingSecurityScopedResource() {
completeFlutterResult(FlutterError(code: "ACCESS_DENIED", message: "Failed to access security-scoped resource: \(url)", details: nil))
return
}
accessedSecurityScopedUrls.insert(url)
let pickedUrl: URL
if persistedUriPermission {
do {
pickedUrl = try createBookmarkUriFromUrl(fileURL: url)
} catch {
completeFlutterResult(FlutterError(code: "CREATE_PERSISTED_URI_FAILED", message: "Failed to create persisted URI: \(error.localizedDescription)", details: nil))
return
}
} else {
pickedUrl = url
}
completeFlutterResult(pickedUrl.absoluteString)
}
}
public func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
completeFlutterResult(nil)
}
// MARK: PHickerViewControllerelegate
public func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
guard !results.isEmpty else {
completeFlutterResult(nil)
return
}
let group = DispatchGroup()
for result in results {
group.enter()
let itemProvider = result.itemProvider
// Determine the preferred order based on what is allowed
var typeIdentifiers: [String] = []
if allowVideos {
typeIdentifiers.append(UTType.movie.identifier)
}
typeIdentifiers.append(UTType.image.identifier)
var handled = false
for typeIdentifier in typeIdentifiers {
if itemProvider.hasRepresentationConforming(toTypeIdentifier: typeIdentifier) {
handleLoadedFile(for: itemProvider, typeIdentifier: typeIdentifier, group: group)
handled = true
break // Break after handling the first matching type
}
}
// Leave the group if no suitable representation was found
if !handled {
group.leave()
}
}
// after all files have been copied, post the result
group.notify(queue: .main) {
picker.dismiss(animated: true)
if self.localFileUrls.isEmpty {
self.completeFlutterResult(nil)
} else {
self.completeFlutterResult(self.localFileUrls)
}
}
}
/// Handles the loading of a file representation from an `NSItemProvider`.
///
/// This function attempts to load a file of the specified `typeIdentifier` from the given `itemProvider`.
/// If successful, it copies the file to the app's local storage and appends the local file URL to the `localFileUrls` array.
/// This function is designed to be used within the `PHPickerViewControllerDelegate`'s `didFinishPicking` method to process selected assets.
///
/// - Parameters:
/// - itemProvider: The `NSItemProvider` representing the asset to load.
/// - typeIdentifier: The UTI (Uniform Type Identifier) representing the desired file type (e.g., `public.image`, `public.movie`).
/// - group: The `DispatchGroup` to which the asynchronous file loading operation belongs. `group.leave()` is called when the operation is completed (either successfully or with an error).
///
/// - Important: This function uses an escaping closure to handle the asynchronous loading of the file.
/// The `localFileUrls` array is a class property and is modified within the escaping closure.
private func handleLoadedFile(for itemProvider: NSItemProvider, typeIdentifier: String, group: DispatchGroup) {
itemProvider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { [weak self] url, error in
guard let self = self, let url = url, error == nil else {
group.leave()
return
}
let localUrl = self.copyFileToLocalStorage(url: url)
if let localUrl = localUrl {
self.localFileUrls.append(localUrl.absoluteString)
}
group.leave()
}
}
/// Copies a file from the provided URL into the app's temporary storage directory, returning the new URL relative to the storage root. This URL will have the "support" scheme and
/// itspath is the last pathsegment of the file URL. It will be converted to a proper file:// URL using `decodeToFileUrl`
///
/// This function creates a unique filename for the copied file to avoid collisions. The file is stored in a location that is:
/// 1. **Private to the application:** Other apps cannot access this directory.
/// 2. **Temporary:** The system may delete files in this directory when the app is not running to free up space. Your app should be prepared to recreate these files as needed.
/// 3. **Not backed up:** Files in this directory are not backed up to iCloud or other backup services.
/// 4. **Persistent across app launches:** The relative file paths are consistent between app launches.
///
/// - Parameter url: The source URL of the file to copy. This should be a file URL (e.g., obtained from a `PHPickerResult`).
/// - Returns: The relative path (as a String) to the copied file within the temporary storage directory if the copy is successful, otherwise `nil`.
/// You can reconstruct the full URL using `constructPersistentFileURL(for:)`.
private func copyFileToLocalStorage(url: URL) -> URL? {
let fileManager = FileManager.default
// Use the Application Support directory, which is private to the app but persistent.
// Create a custom subdirectory for temporary files.
guard let storageDirectory = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?.appendingPathComponent("com.bbflight.downloader.media", isDirectory: true) else {
completeFlutterResult(FlutterError(code: "PICK_FAILED", message: "Could not find Application Support directory", details: nil))
return nil
}
// Ensure the storage directory exists
do {
try fileManager.createDirectory(at: storageDirectory, withIntermediateDirectories: true, attributes: nil)
} catch {
completeFlutterResult(FlutterError(code: "PICK_FAILED", message: "Error creating storage directory: \(error)", details: nil))
return nil
}
// Create a unique filename
let uniqueFilename = "\(UUID().uuidString).\(url.pathExtension)"
let destinationURL = storageDirectory.appendingPathComponent(uniqueFilename)
do {
// If a file with the same name already exists, remove it. This should not normally happen because of the UUID, but it's good practice.
if fileManager.fileExists(atPath: destinationURL.path) {
try fileManager.removeItem(at: destinationURL)
}
// Copy the file from source to destination, and return the media URI to this location
try fileManager.copyItem(at: url, to: destinationURL)
return URL(string: "media://support/\(destinationURL.lastPathComponent)")
} catch {
completeFlutterResult(FlutterError(code: "PICK_FAILED", message: "Error copying file: \(error)", details: nil))
return nil
}
}
// MARK: - Helper Functions
private func getInitialDirectoryUrl(location: SharedStorage) -> URL? {
switch location {
case SharedStorage.downloads:
return FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
case SharedStorage.images:
return FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask).first
case SharedStorage.video:
return FileManager.default.urls(for: .moviesDirectory, in: .userDomainMask).first
case SharedStorage.audio:
return FileManager.default.urls(for: .musicDirectory, in: .userDomainMask).first
case SharedStorage.files:
return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
default:
return nil
}
}
// Stop accessing security-scoped resources when appropriate.
deinit {
// Iterate through all stored URLs and stop accessing the security-scoped resources.
for url in accessedSecurityScopedUrls {
url.stopAccessingSecurityScopedResource()
}
}
}
/// Packs `filename` and `uri` into a single String
///
/// use `unpack` to retrieve the filename and uri from the packed String
func pack(filename: String, uri: URL) -> String {
return ":::\(filename)::::::\(uri.absoluteString):::"
}
/// Unpacks `packedString` into a (filename, uri) tuple. If this is not a packed
/// string, returns the original `packedString` as (filename, nil) or,
/// if it is a Uri as (nil, the uri)
func unpack(packedString: String) -> (filename: String?, uri: URL?) {
let regex = try! NSRegularExpression(pattern: ":::([\\s\\S]*?)::::::([\\s\\S]*?):::")
let range = NSRange(packedString.startIndex..<packedString.endIndex, in: packedString)
if let match = regex.firstMatch(in: packedString, range: range) {
let filenameRange = match.range(at: 1)
let uriStringRange = match.range(at: 2)
if filenameRange.location != NSNotFound && uriStringRange.location != NSNotFound {
let filename = String(packedString[Range(filenameRange, in: packedString)!])
let uriString = String(packedString[Range(uriStringRange, in: packedString)!])
if let uri = URL(string: uriString), uri.scheme != nil {
return (filename: filename, uri: uri)
} else {
return (filename: filename, uri: nil)
}
}
} else {
if let uri = URL(string: packedString), uri.scheme != nil {
return (filename: nil, uri: uri)
}
}
return (filename: packedString, uri: nil)
}
/// Returns the Uri represented by `maybePacked`, or nil if the String is not a
/// valid Uri or packed Uri string.
///
/// `maybePacked` should be a full Uri string, or a packed String containing
/// a Uri (see `pack`)
func uriFromStringValue(maybePacked: String) -> URL? {
let unpacked = unpack(packedString: maybePacked)
return unpacked.uri
}
/// Returns true if `maybePacked` is a valid Uri or packed Uri string.
///
/// `maybePacked` should be a full Uri string, or a packed String containing
/// a Uri (see `pack`)
func containsUri(maybePacked: String) -> Bool {
return uriFromStringValue(maybePacked: maybePacked) != nil
}
/// Creates a urlboomark://authority/ URL containing encoded data representing the bookmark for this URL, which
/// can then be used later
private func createBookmarkUriFromUrl(fileURL: URL) throws -> URL {
let bookmarkData = try fileURL.bookmarkData(options: .suitableForBookmarkFile, includingResourceValuesForKeys: nil, relativeTo: nil)
let bookmarkDataBase64 = bookmarkData.base64EncodedString()
let bookmarkUriString = "urlbookmark://authority/\(bookmarkDataBase64)"
guard let bookmarkUri = URL(string: bookmarkUriString) else {
throw NSError(domain: "UriUtilsMethodCallHelper", code: 0, userInfo: [NSLocalizedDescriptionKey: "Failed to create bookmark URI from '\(bookmarkUriString)'"])
}
return bookmarkUri
}
/**
* Decodes a URI parameter on iOS.
*
* If the URI string starts with `urlbookmark://authority/`, it decodes the base64 encoded bookmark data to a file URL
* If the URI string starts with `media://support/` it will convert it to a file URL relateive to the applicationSupport directory
* Otherwise, it returns the original URI as a URL.
*
* - Parameter uriString: The URI string to decode.
* - Returns: The decoded `URL` or `nil` if the URI string is invalid or the bookmark data cannot be resolved.
*/
private func decodeToFileUrl(uriString: String) -> URL? {
guard let uri = URL(string: uriString) else { return nil }
if uri.scheme == "urlbookmark" {
// Decode base64 bookmark data
let base64String = String(uri.path.dropFirst())
guard let bookmarkData = Data(base64Encoded: base64String) else {
return nil
}
do {
var isStale = false
let resolvedUrl = try URL(resolvingBookmarkData: bookmarkData, options: .withoutUI, relativeTo: nil, bookmarkDataIsStale: &isStale)
if isStale {
os_log("Warning: Bookmark data is stale for %@", log: log, type: .info, resolvedUrl.absoluteString)
return nil
}
// Access to resolved URLs that are not persisted, is started when they are actually used
// (i.e. when creating a subdirectory in it, or picking it)
// And stopped in the deinit
return resolvedUrl
} catch {
os_log("Error resolving bookmark data: %@", log: log, type: .info, error.localizedDescription)
return nil
}
} else if uri.scheme == "media" {
guard let storageDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?.appendingPathComponent("com.bbflight.downloader.media", isDirectory: true) else {
os_log("Could not determine application support directory - required to decode support:// scheme URL", log: log, type: .error)
return nil
}
return storageDirectory.appendingPathComponent(uri.lastPathComponent, isDirectory: false)
} else {
// Regular file URL
return uri
}
}
/**
Returns a file URI, or nil if not possible.
Decodes a bookmark URI or a media scheme uri
*/
func decodeToFileUrl(uri: URL) -> URL? {
return uri.scheme == "file" ? uri : decodeToFileUrl(uriString: uri.absoluteString)
}
@@ -0,0 +1,628 @@
//
// UrlSessionDelegate.swift
// background_downloader
//
// Created by Bram on 11/12/23.
//
import Foundation
import os.log
public class UrlSessionDelegate : NSObject, URLSessionDelegate, URLSessionDownloadDelegate, URLSessionDataDelegate {
static let instance = UrlSessionDelegate()
static var urlSession: URLSession?
public static var sessionIdentifier = "com.bbflight.background_downloader.Downloader"
public static var backgroundCompletionHandler: (() -> Void)?
//MARK: URLSessionTaskDelegate
/// Called before the task starts, and may continue, cancel or modify the original request, based on native callbacks in the [TaskOptions] of the task
public func urlSession(_ session: URLSession, task: URLSessionTask, willBeginDelayedRequest request: URLRequest) async -> (URLSession.DelayedRequestDisposition, URLRequest?) {
guard let bgdTask = getTaskFrom(urlSessionTask: task)
else {
return (.continueLoading, nil)
}
// check & process beforeTaskStartCallback and cancel the task if returned value is not nil
if bgdTask.options?.hasBeforeStartCallback() == true {
if let statusUpdate = await invokeBeforeTaskStartCallback(task: bgdTask) {
os_log("TaskId %@ interrupted by beforeTaskStart callback", log: log, type: .info, bgdTask.taskId)
BDPlugin.propertyLock.withLock( {
BDPlugin.taskIdsProgrammaticallyCanceledBeforeStart.insert(bgdTask.taskId)
})
processStatusUpdate(task: bgdTask,
status: statusUpdate.taskStatus,
taskException: statusUpdate.exception,
responseBody: statusUpdate.responseBody,
responseHeaders: statusUpdate.responseHeaders,
responseStatusCode: statusUpdate.responseStatusCode)
return (.cancel, nil)
}
}
// check & process onStartCalback and onAuthCallback
guard bgdTask.options?.hasOnStartCallback() == true || bgdTask.options?.auth?.hasOnAuthCallback() == true
else {
return (.continueLoading, nil)
}
let (newTask, taskWasModified) = await getModifiedTask(task: bgdTask) // processes both onStart and onAuth
if !taskWasModified {
return (.continueLoading, nil)
}
// modify the request, copying the unmodified data from the original request
guard let url = validateUrl(newTask)
else {
os_log("Invalid url in modified task", log: log, type: .info)
return (.continueLoading, nil)
}
var newRequest = URLRequest(url: url)
// all original request headers are copied, then the newTask headers are set:
// you can change or add a header, but not remove one
if request.allHTTPHeaderFields != nil {
for (key, value) in request.allHTTPHeaderFields! {
newRequest.setValue(value, forHTTPHeaderField: key)
}}
for (key, value) in newTask.headers {
newRequest.setValue(value, forHTTPHeaderField: key)
}
newRequest.httpMethod = request.httpMethod
newRequest.httpBody = request.httpBody
newRequest.allowsCellularAccess = request.allowsCellularAccess
return (URLSession.DelayedRequestDisposition.useNewRequest, newRequest)
}
/// Handle task completion
///
/// DownloadTasks handle task completion in the :didFinishDownloadingTo function, so for download tasks
/// we only process status updates if there was an error.
/// For other tasks we handle error, or send .complete
public func urlSession(
_ session: URLSession,
task: URLSessionTask,
didCompleteWithError error: Error?
) {
guard let bgdTask = getTaskFrom(urlSessionTask: task) else { // refers to "our" task, not a URLSessionTask
os_log("Could not find task related to urlSessionTask %d", log: log, type: .error, task.taskIdentifier)
return
}
if BDPlugin.propertyLock.withLock( {
BDPlugin.taskIdsProgrammaticallyCanceledBeforeStart.remove(bgdTask.taskId) != nil
}) {
// task was canceled before start and the
// status update already generated, so simply return without any further processing
return
}
let multipartUploader = BDPlugin.propertyLock.withLock {
BDPlugin.uploaderForUrlSessionTaskIdentifier.removeValue(forKey: task.taskIdentifier)
}
if let uploader = multipartUploader {
try? FileManager.default.removeItem(at: uploader.outputFileUrl())
}
let responseStatusCode = (task.response as? HTTPURLResponse)?.statusCode ?? 0
let responseStatusDescription = HTTPURLResponse.localizedString(forStatusCode: responseStatusCode)
let responseHeaders = (task.response as? HTTPURLResponse)?.allHeaderFields
let notificationConfig = getNotificationConfigFrom(urlSessionTask: task)
if let tempUploadUrl = BDPlugin.propertyLock.withLock({ BDPlugin.tasksWithTempUploadFile.removeValue(forKey: bgdTask.taskId) }) {
try? FileManager.default.removeItem(at: tempUploadUrl)
}
if BDPlugin.holdingQueue != nil {
_Concurrency.Task {
await BDPlugin.holdingQueue?.taskFinished(bgdTask)
}
}
let responseBody = getResponseBody(taskId: bgdTask.taskId)
let taskWasProgramaticallyCanceledAfterStart = BDPlugin.propertyLock.withLock( {
BDPlugin.taskIdsProgrammaticallyCanceledAfterStart.remove(bgdTask.taskId) != nil
})
guard error == nil else {
var notificationType = taskWasProgramaticallyCanceledAfterStart ? nil : NotificationType.error
// handle the error if this task wasn't programatically cancelled (in which
// case the error has been handled already)
if !taskWasProgramaticallyCanceledAfterStart {
// check if we have resume data, and if so, process it
let userInfo = (error! as NSError).userInfo
let resumeData = userInfo[NSURLSessionDownloadTaskResumeData] as? Data
let canResume = resumeData != nil && processResumeData(task: bgdTask, resumeData: resumeData!)
// check different error codes
if (error! as NSError).code == NSURLErrorTimedOut {
os_log("Task with id %@ timed out", log: log, type: .info, bgdTask.taskId)
processStatusUpdate(task: bgdTask, status: .failed, taskException: TaskException(type: .connection, httpResponseCode: -1, description: error!.localizedDescription))
// replace the 'running' notification, unless the task will be retried
notificationType = bgdTask.retriesRemaining > 0 ? nil : .error
}
else if (error! as NSError).code == NSURLErrorCancelled {
// cancelled with resumedata implies 'pause'
if canResume {
os_log("Paused task with id %@", log: log, type: .info, bgdTask.taskId)
processStatusUpdate(task: bgdTask, status: .paused)
if isDownloadTask(task: bgdTask) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
updateNotification(task: bgdTask, notificationType: .paused, notificationConfig: notificationConfig)
}
}
BDPlugin.propertyLock.withLock({
_ = BDPlugin.progressInfo.removeValue(forKey: bgdTask.taskId) // ensure .running update on resume
BDPlugin.initialResponseDataProcessed.remove(bgdTask.taskId)
})
return
}
// cancelled without resumedata implies 'cancel'
let isReEnqueuing = BDPlugin.propertyLock.withLock {
BDPlugin.tasksToReEnqueue.contains(bgdTask)
}
os_log("Canceled task with id %@", log: log, type: .info, bgdTask.taskId)
processStatusUpdate(task: bgdTask, status: .canceled)
notificationType = isReEnqueuing ? nil : .canceled
}
else {
os_log("Error for taskId %@: %@", log: log, type: .error, bgdTask.taskId, error!.localizedDescription)
processStatusUpdate(task: bgdTask, status: .failed, taskException: TaskException(type: .general, httpResponseCode: -1, description: error!.localizedDescription))
}
}
if isDownloadTask(task: bgdTask) && notificationType != nil {
updateNotification(task: bgdTask, notificationType: notificationType!, notificationConfig: notificationConfig)
}
return
}
// there was no error
os_log("Finished task with id %@", log: log, type: .info, bgdTask.taskId)
// if this is an upload task, send final TaskStatus (based on HTTP status code).
// for download tasks, this is done in urlSession(downloadTask:, didFinishDownloadingTo:)
if isUploadTask(task: bgdTask) {
let taskException = TaskException(type: .httpResponse, httpResponseCode: responseStatusCode, description: responseStatusDescription)
let finalStatus = (200...206).contains(responseStatusCode)
? TaskStatus.complete
: responseStatusCode == 404
? TaskStatus.notFound
: TaskStatus.failed
let notificationType = finalStatus == .complete ? NotificationType.complete : NotificationType.error
updateNotification(task: bgdTask, notificationType: notificationType, notificationConfig: notificationConfig)
processStatusUpdate(task: bgdTask, status: finalStatus, taskException: taskException, responseBody: responseBody, responseHeaders: responseHeaders, responseStatusCode: responseStatusCode)
}
}
//MARK: URLSessionDownloadTaskDelegate
/// Process taskdelegate progress update for download task
///
/// If the task requires progress updates, provides these at a reasonable interval
/// If this is the first update for this file, also emits a 'running' status update
public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
// task is var because the filename can be changed on the first 'didWriteData' call
guard var task = getTaskFrom(urlSessionTask: downloadTask) else { return }
let progressInfo = BDPlugin.propertyLock.withLock({
BDPlugin.progressInfo[task.taskId]
})
if progressInfo == nil {
// first 'didWriteData' call, so send 'running' status update and show notification
os_log("Starting/resuming taskId %@", log: log, type: .info, task.taskId)
processStatusUpdate(task: task, status: TaskStatus.running)
processProgressUpdate(task: task, progress: 0.0)
BDPlugin.propertyLock.withLock({
BDPlugin.progressInfo[task.taskId] = (lastProgressUpdateTime: 0, lastProgressValue: 0.0, lastTotalBytesDone: 0, lastNetworkSpeed: -1.0)
})
let notificationConfig = getNotificationConfigFrom(urlSessionTask: downloadTask)
if (notificationConfig != nil) {
updateNotification(task: task, notificationType: .running, notificationConfig: notificationConfig)
}
}
if !BDPlugin.propertyLock.withLock({ BDPlugin.initialResponseDataProcessed.contains(task.taskId) }) {
// response can be nil in cases when progressInfo is nil due to app resuming in foreground
if let response = downloadTask.response as? HTTPURLResponse {
BDPlugin.propertyLock.withLock( {
BDPlugin.initialResponseDataProcessed.insert(task.taskId)
})
// get suggested filename if needed
let (filename, uri) = unpack(packedString: task.filename)
if filename == "?" {
if (uri == nil) {
let newTask = taskWithSuggestedFilenameFromResponseHeaders(task: task, responseHeaders: response.allHeaderFields)
if newTask.filename != task.filename {
storeModifiedTask(task: newTask)
task = newTask
}
} else {
let suggestedFilename = suggestFilename(responseHeaders: response.allHeaderFields, urlString: task.url)
let packed = pack(filename: suggestedFilename.isEmpty ? "unknown" : suggestedFilename, uri: uri!)
let newTask = task.copyWith(filename: packed)
storeModifiedTask(task: newTask)
task = newTask
}
}
extractContentType(responseHeaders: response.allHeaderFields, task: task)
// obtain content length override, if needed and available
if totalBytesExpectedToWrite == -1 {
let contentLength = getContentLength(responseHeaders: response.allHeaderFields, task: task)
if contentLength != -1 {
BDPlugin.propertyLock.withLock({
BDPlugin.tasksWithContentLengthOverride[task.taskId] = contentLength
})
}
}
// Check if there is enough space
if insufficientSpace(contentLength: totalBytesExpectedToWrite) {
let notProgrammaticallyCancelled = BDPlugin.propertyLock.withLock({
!BDPlugin.taskIdsProgrammaticallyCanceledAfterStart.contains(task.taskId)
})
if notProgrammaticallyCancelled {
os_log("Error for taskId %@: Insufficient space to store the file to be downloaded", log: log, type: .error, task.taskId)
processStatusUpdate(task: task, status: .failed, taskException: TaskException(type: .fileSystem, httpResponseCode: -1, description: "Insufficient space to store the file to be downloaded for taskId \(task.taskId)"))
BDPlugin.propertyLock.withLock({
_ = BDPlugin.taskIdsProgrammaticallyCanceledAfterStart.insert(task.taskId)
})
downloadTask.cancel()
}
return
}
// check if the task is resumable
if task.allowPause {
// header lookup must be case-insensitive, and a partial
// response also implies the server accepts ranges
let httpResponse = downloadTask.response as? HTTPURLResponse
let acceptRangesHeader = httpResponse?.value(forHTTPHeaderField: "Accept-Ranges")
let taskCanResume = acceptRangesHeader == "bytes" || httpResponse?.statusCode == 206
processCanResume(task: task, taskCanResume: taskCanResume)
if taskCanResume {
BDPlugin.propertyLock.withLock({
_ = BDPlugin.taskIdsThatCanResume.insert(task.taskId)
})
}
}
}
}
let contentLength = BDPlugin.propertyLock.withLock({
let l = totalBytesExpectedToWrite != -1 ? totalBytesExpectedToWrite : BDPlugin.tasksWithContentLengthOverride[task.taskId] ?? -1
BDPlugin.remainingBytesToDownload[task.taskId] = l - totalBytesWritten
return l
})
updateProgress(task: task, totalBytesExpected: contentLength, totalBytesDone: totalBytesWritten)
}
/// Process taskdelegate progress update for upload task
///
/// If the task requires progress updates, provide these at a reasonable interval
/// If this is the first update for this file, also emit a 'running' status update
public func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) {
let urlSessionTask = task
guard let task = getTaskFrom(urlSessionTask: task) else {return}
let taskId = task.taskId
let progressInfo = BDPlugin.propertyLock.withLock({
return BDPlugin.progressInfo[taskId]
})
if progressInfo == nil {
// first call to this method: send 'running' status update
processStatusUpdate(task: task, status: TaskStatus.running)
processProgressUpdate(task: task, progress: 0.0)
BDPlugin.propertyLock.withLock({
BDPlugin.progressInfo[task.taskId] = (lastProgressUpdateTime: 0, lastProgressValue: 0.0, lastTotalBytesDone: 0, lastNetworkSpeed: -1.0)
})
// notify if needed
let notificationConfig = getNotificationConfigFrom(urlSessionTask: urlSessionTask)
if (notificationConfig != nil) {
updateNotification(task: task, notificationType: .running, notificationConfig: notificationConfig)
}
}
updateProgress(task: task, totalBytesExpected: totalBytesExpectedToSend, totalBytesDone: totalBytesSent)
}
/// Process end of downloadTask sent by the urlSession.
///
/// If successful, (over)write file to final destination per Task info
public func urlSession(_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL) {
guard var task = getTaskFrom(urlSessionTask: downloadTask),
let response = downloadTask.response as? HTTPURLResponse
else {
os_log("Could not find task associated urlSessionTask %d, or did not get HttpResponse", log: log, type: .info, downloadTask.taskIdentifier)
return}
let responseHeaders = response.allHeaderFields
let taskId = task.taskId
let (mimeType, charSet) = BDPlugin.propertyLock.withLock({
let mimeType = BDPlugin.mimeTypes.removeValue(forKey: taskId)
let charSet = BDPlugin.charSets.removeValue(forKey: taskId)
BDPlugin.tasksWithModifications.removeValue(forKey: taskId)
BDPlugin.tasksWithContentLengthOverride.removeValue(forKey: taskId)
BDPlugin.initialResponseDataProcessed.remove(taskId)
return (mimeType, charSet)
})
let notificationConfig = getNotificationConfigFrom(urlSessionTask: downloadTask)
if response.statusCode == 404 {
let responseBody = readFile(url: location)
processStatusUpdate(task: task, status: TaskStatus.notFound, responseBody: responseBody, responseHeaders: responseHeaders, responseStatusCode: response.statusCode)
updateNotification(task: task, notificationType: .error, notificationConfig: notificationConfig)
return
}
if !(200...206).contains(response.statusCode) {
os_log("TaskId %@ returned response code %d", log: log, type: .info, task.taskId, response.statusCode)
let responseBody = readFile(url: location)
processStatusUpdate(task: task, status: TaskStatus.failed, taskException: TaskException(type: .httpResponse, httpResponseCode: response.statusCode, description: responseBody?.isEmpty == false ? responseBody! : HTTPURLResponse.localizedString(forStatusCode: response.statusCode)))
if task.retriesRemaining == 0 {
// update notification only if no retries remaining
updateNotification(task: task, notificationType: .error, notificationConfig: notificationConfig)
}
return
}
do {
var finalStatus = TaskStatus.failed
var taskException: TaskException? = nil
var responseBody: String? = nil
defer {
processStatusUpdate(task: task,
status: finalStatus,
taskException: taskException,
responseBody: responseBody,
responseHeaders: responseHeaders,
responseStatusCode: response.statusCode,
mimeType: mimeType,
charSet: charSet)
if finalStatus != TaskStatus.failed || task.retriesRemaining == 0 {
// update notification only if not failed, or no retries remaining
updateNotification(task: task, notificationType: notificationTypeForTaskStatus(status: finalStatus), notificationConfig: notificationConfig)
}
}
if (isDownloadTask(task: task)) {
// Determine directoryUri and fileUrl based on URI mode or filepath mode
var directoryUrl: URL?
var fileUrl: URL?
// filename field can contain filename and/or a file url (if already started)
let unpackedFilename = unpack(packedString: task.filename)
let filename = unpackedFilename.filename ?? "unknown"
fileUrl = unpackedFilename.uri
// directory may contain a path or a Uri representing the full destination directory
if let directoryUri = uriFromStringValue(maybePacked: task.directory) {
// URI mode
let uri = decodeToFileUrl(uri: directoryUri)
guard let uri = uri else {
os_log("Invalid directory URI (could not convert bookmark): %@", log: log, type: .error, directoryUri.absoluteString)
taskException = TaskException(type: .fileSystem, httpResponseCode: -1,
description: "Invalid directory URI (could not convert bookmark): %@ \(directoryUri.absoluteString)")
return
}
guard uri.isFileURL else {
os_log("Invalid directory URI (not a file URL): %@", log: log, type: .error, uri.absoluteString)
taskException = TaskException(type: .fileSystem, httpResponseCode: -1,
description: "Invalid directory URI (not a file URL): \(uri.absoluteString)")
return
}
directoryUrl = uri
fileUrl = uri.appendingPathComponent(filename)
// store the full Uri in the task.filename so it can be retrieved
let newTask = task.copyWith(filename: pack(filename: filename, uri: fileUrl!))
task = newTask
} else {
// Filepath mode
directoryUrl = try directoryForTask(task: task)
fileUrl = directoryUrl?.appendingPathComponent(filename)
}
// Guard against directoryUri or fileUrl being nil
guard let directoryUri = directoryUrl, var fileUrl = fileUrl else {
os_log("Could not determine directory Uri or file Uri", log: log, type: .error)
taskException = TaskException(type: .fileSystem, httpResponseCode: -1,
description: "Could not determine directory or file Uri")
return
}
do {
if !FileManager.default.fileExists(atPath: directoryUri.path) {
try FileManager.default.createDirectory(at: directoryUri, withIntermediateDirectories: true)
}
if FileManager.default.fileExists(atPath: fileUrl.path) {
try FileManager.default.removeItem(at: fileUrl)
}
try FileManager.default.moveItem(at: location, to: fileUrl)
do {
if UserDefaults.standard.bool(forKey: BDPlugin.keyConfigExcludeFromCloudBackup)
{
try fileUrl.setCloudBackup(exclude: true)
os_log("Excluded from iCloud backup: %@", log: log, type: .info, fileUrl.path)
}
} catch {
os_log("Could not exclude from iCloud backup: %@ - %@", log: log, type: .info, fileUrl.path, error.localizedDescription)
}
finalStatus = TaskStatus.complete
} catch {
os_log("File operation failed: %@", log: log, type: .error, error.localizedDescription)
taskException = TaskException(type: .fileSystem, httpResponseCode: -1, description: "File operation failed: \(error.localizedDescription)")
return
}
} else {
// this is a DataTask, so we read the file as the responseBody
responseBody = readFile(url: location)
try? FileManager.default.removeItem(at: location);
finalStatus = .complete
}
} catch {
os_log("Uncaught file download error for taskId %@ and file %@: %@", log: log, type: .error, task.taskId, task.filename, error.localizedDescription)
}
}
//MARK: URLSessionDataDelegate
/// Collects incoming data following a file upload, by appending the data block to a static dictionary keyed by taskId
public func urlSession(
_ session: URLSession,
dataTask: URLSessionDataTask,
didReceive data: Data
)
{
guard let task = getTaskFrom(urlSessionTask: dataTask)
else {
os_log("Could not find task associated urlSessionTask %d", log: log, type: .info, dataTask.taskIdentifier)
return
}
BDPlugin.propertyLock.withLock({
var dataList = BDPlugin.responseBodyData[task.taskId] ?? []
dataList.append(data)
BDPlugin.responseBodyData[task.taskId] = dataList
})
}
//MARK: URLSessionDelegate
/// Upon completion of download of all files, call the completion handler
public func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
DispatchQueue.main.async {
guard
let handler = UrlSessionDelegate.backgroundCompletionHandler,
session.configuration.identifier == UrlSessionDelegate.sessionIdentifier
else {
os_log("No handler or no identifier match in urlSessionDidFinishEvents", log: log, type: .info)
return
}
handler()
}
}
//MARK: Helpers
/// Read the contents of a file to a String, or nil if unable
private func readFile(url: URL) -> String? {
do {
let data = try Data(contentsOf: url)
let string = String(data: data, encoding: .utf8)
return string
} catch {
return nil
}
}
/// Get response body for upload task with this [taskId]
private func getResponseBody(taskId: String) -> String? {
guard let dataList = BDPlugin.propertyLock.withLock( { BDPlugin.responseBodyData[taskId] }) else {
return nil
}
var allData: Data? = nil
dataList.forEach { data in
if allData == nil {
allData = data
} else {
allData?.append(data)
}
}
if (allData == nil) {
return nil
}
return String(data: allData!, encoding: .utf8)!
}
//MARK: UrlSession and related Task helpers
/// Creates a urlSession
///
/// Configures defaultResourceTimeout, defaultRequestTimeout and proxy based on configuration parameters,
/// or defaults
static func createUrlSession() -> Void {
if UrlSessionDelegate.urlSession != nil {
return
}
let config = URLSessionConfiguration.background(withIdentifier: UrlSessionDelegate.sessionIdentifier)
let defaults = UserDefaults.standard
let storedTimeoutIntervalForResource = defaults.double(forKey: BDPlugin.keyConfigResourceTimeout) // seconds
let timeOutIntervalForResource = storedTimeoutIntervalForResource > 0 ? storedTimeoutIntervalForResource : BDPlugin.defaultResourceTimeout
os_log("timeoutIntervalForResource = %d seconds", log: log, type: .info, Int(timeOutIntervalForResource))
config.timeoutIntervalForResource = timeOutIntervalForResource
let storedTimeoutIntervalForRequest = defaults.double(forKey: BDPlugin.keyConfigRequestTimeout) // seconds
let timeoutIntervalForRequest = storedTimeoutIntervalForRequest > 0 ? storedTimeoutIntervalForRequest : BDPlugin.defaultRequestTimeout
os_log("timeoutIntervalForRequest = %d seconds", log: log, type: .info, Int(timeoutIntervalForRequest))
config.timeoutIntervalForRequest = timeoutIntervalForRequest
let proxyAddress = defaults.string(forKey: BDPlugin.keyConfigProxyAdress)
let proxyPort = defaults.integer(forKey: BDPlugin.keyConfigProxyPort)
if (proxyAddress != nil && proxyPort != 0) {
config.connectionProxyDictionary = [
kCFNetworkProxiesHTTPEnable: true,
kCFNetworkProxiesHTTPProxy: proxyAddress!,
kCFNetworkProxiesHTTPPort: proxyPort,
"HTTPSEnable": true,
"HTTPSProxy": proxyAddress!,
"HTTPSPort": proxyPort
]
os_log("Using proxy %@:%d for all tasks", log: log, type: .info, proxyAddress!, proxyPort)
} else {
os_log("Not using proxy for any task", log: log, type: .info)
}
UrlSessionDelegate.urlSession = URLSession(configuration: config, delegate: UrlSessionDelegate.instance, delegateQueue: nil)
}
/// Return all tasks in this urlSession
static func getAllTasks() async -> [Task] {
UrlSessionDelegate.createUrlSession()
guard let urlSessionTasks = await UrlSessionDelegate.urlSession?.allTasks else { return [] }
return urlSessionTasks.compactMap({ getTaskFrom(urlSessionTask: $0) })
}
/// Return the active task with this taskId, or nil
static func getTaskWithId(taskId: String) async -> Task? {
return await getAllTasks().first(where: { $0.taskId == taskId })
}
/// Return all urlSessionsTasks in this urlSession
static func getAllUrlSessionTasks(group: String? = nil) async -> [URLSessionTask] {
UrlSessionDelegate.createUrlSession()
if (group == nil) {
guard let urlSessionTasks = await UrlSessionDelegate.urlSession?.allTasks else { return [] }
return urlSessionTasks
}
guard let urlSessionTasks = await UrlSessionDelegate.urlSession?.allTasks else { return [] }
let urlSessionTasksInGroup = urlSessionTasks.filter({
guard let task = getTaskFrom(urlSessionTask: $0) else { return false }
return task.group == group
})
return urlSessionTasksInGroup
}
/// Return the urlSessionTask matching this taskId, or nil
static func getUrlSessionTaskWithId(taskId: String) async -> URLSessionTask? {
guard let urlSessionTask = await getAllUrlSessionTasks().first(where: {
guard let task = getTaskFrom(urlSessionTask: $0) else { return false }
return task.taskId == taskId
}) else { return nil }
return urlSessionTask
}
/// Returns a `task` that may be modified through callbacks, and a bool to indicate that the task was modified.
///
/// Callbacks would be attached to the task via its `Task.options` property, and if
/// present will be invoked by starting a taskDispatcher on a background isolate, then
/// sending the callback request via the MethodChannel.
///
/// First test is for auth refresh (the onAuth callback), then the onStart callback. Both
/// callbacks run in a Dart isolate, and may return a modified task, which will be used
/// for the actual task execution.
func getModifiedTask(task: Task) async -> (Task, Bool) {
var authTask: Task?
var taskWasModified: Bool = false
if let auth = task.options?.auth {
// Refresh token if needed
if auth.isTokenExpired() && auth.hasOnAuthCallback() == true {
authTask = await invokeOnAuthCallback(task: task)
}
taskWasModified = authTask != nil
authTask = authTask ?? task // Either original or newly authorized
guard let newAuth = authTask?.options?.auth else { return (authTask ?? task, taskWasModified) }
// Insert query parameters and headers
taskWasModified = true
let uri = newAuth.addOrUpdateQueryParams(
url: authTask!.url,
queryParams: newAuth.getExpandedAccessQueryParams()
)
var headers = authTask!.headers
headers.merge(newAuth.getExpandedAccessHeaders()) { (_, new) in new }
authTask = authTask!.copyWith(url: uri.absoluteString, headers: headers)
}
authTask = authTask ?? task
guard task.options?.hasOnStartCallback() == true else { return (authTask!, taskWasModified) }
// onStart callback
let modifiedTask = await invokeOnTaskStartCallback(task: authTask!)
taskWasModified = taskWasModified || modifiedTask != nil
return (modifiedTask ?? authTask!, taskWasModified)
}
}
@@ -0,0 +1,104 @@
//
// WiFi.swift
// background_downloader
//
// Created by Bram on 1/19/24.
//
import Foundation
import os.log
/// WiFi requirement modes at the application level
enum RequireWiFi: Int {
case
asSetByTask,
forAllTasks,
forNoTasks
}
/// Manages changes to WiFi requirement, by re-enqueuing tasks if needed
class WiFiQueue {
static let shared = WiFiQueue()
private let requireWiFiChangeQueue = DispatchQueue(label: "com.bbflight.background_downloader.requireWiFiChangeQueue", target: DispatchQueue.global())
private let reEnqueueQueue = DispatchQueue(label: "com.bbflight.background_downloader.reEnqueueQueue", target: DispatchQueue.global())
private let requireWiFiChangeSemaphore = DispatchSemaphore(value: 0)
// Private initializer to prevent creating new instances
private init() {}
// Change the application level WiFi requirement and re-enqueue tasks as necessary
func requireWiFiChange(requireWiFi: RequireWiFi, rescheduleRunningTasks: Bool, alsoRestartUploads: Bool) {
requireWiFiChangeQueue.async {
_Concurrency.Task {
BDPlugin.requireWiFi = requireWiFi
let defaults = UserDefaults.standard
defaults.setValue(requireWiFi.rawValue, forKey: BDPlugin.keyRequireWiFi)
UrlSessionDelegate.createUrlSession()
guard let urlSessionTasks = await UrlSessionDelegate.urlSession?.allTasks
else {
self.reEnqueuesDone()
return
}
var haveReEnqueued = false
urlSessionTasks.forEach { urlSessionTask in
if ((urlSessionTask is URLSessionDownloadTask || urlSessionTask is URLSessionUploadTask) && (urlSessionTask.state == .running || urlSessionTask.state == .suspended)) {
guard let task = getTaskFrom(urlSessionTask: urlSessionTask) else {
return
}
// compare with the request the task was created with, which the URLSession
// keeps across app launches, so tasks created by an earlier launch are included
let urlSessionTaskRequiresWiFi = urlSessionTask.originalRequest?.allowsCellularAccess == false
BDPlugin.propertyLock.withLock {
if taskRequiresWiFi(task: task) != urlSessionTaskRequiresWiFi {
// requirement differs, so we need to re-enqueue
if BDPlugin.progressInfo[task.taskId] == nil {
// enqueued only, so ensure it is re-enqueued and cancel
haveReEnqueued = true
BDPlugin.tasksToReEnqueue.insert(task)
urlSessionTask.cancel()
} else {
if (rescheduleRunningTasks && isDownloadTask(task: task)) || (alsoRestartUploads && isUploadTask(task: task)) {
// cancel without resume data so the new URLRequest (with the updated
// allowsCellularAccess setting) is used; downloadTask(withResumeData:)
// would reuse the request embedded in the resume data
haveReEnqueued = true
BDPlugin.tasksToReEnqueue.insert(task)
urlSessionTask.cancel()
}
}
}
}
}
}
if !haveReEnqueued {
self.reEnqueuesDone() // nothing left to do
}
}
self.requireWiFiChangeSemaphore.wait() // wait for re-enqueues to complete
}
}
func reEnqueuesDone() {
self.requireWiFiChangeSemaphore.signal()
}
/// Re-enqueue this task and associated data. Nil signals end of batch
func reEnqueue(_ enqueueItem: EnqueueItem?) {
reEnqueueQueue.async {
_Concurrency.Task {
guard let reEnqueueData = enqueueItem else {
// nil value indicates end of batch of re-enqueues
self.reEnqueuesDone()
return
}
let timeSinceCreated = Date().timeIntervalSince(reEnqueueData.created)
if timeSinceCreated < 0.3 {
try await _Concurrency.Task.sleep(nanoseconds: UInt64((0.3 - timeSinceCreated) * 1_000_000_000))
}
await enqueueItem?.enqueue()
try await _Concurrency.Task.sleep(nanoseconds: 20_000_000)
}
}
}
}
@@ -0,0 +1,25 @@
/// A comprehensive background file downloader and uploader for iOS, Android, Desktop and Web
///
/// Use [FileDownloader] to download and upload files.
library;
export 'src/database.dart';
export 'src/exceptions.dart';
export 'src/file_downloader.dart';
export 'src/models.dart';
export 'src/options/auth.dart';
export 'src/options/auth_callback.dart';
export 'src/options/mtls.dart';
export 'src/options/task_options.dart';
export 'src/permissions.dart' show PermissionType, PermissionStatus;
export 'src/persistent_storage.dart';
export 'src/progress_widget.dart';
export 'src/queue/task_queue.dart';
export 'src/task.dart';
export 'src/transfer.dart';
export 'src/transfers.dart';
export 'src/transfer_hint.dart';
export 'src/uri/uri_utils.dart';
export 'src/widgets/transfer_button.dart';
export 'src/widgets/transfer_list_tile.dart';
export 'src/widgets/transfer_progress_bar.dart';
File diff suppressed because it is too large. Load diff
+119
View File
@@ -0,0 +1,119 @@
import 'dart:convert';
import 'base_downloader.dart';
import 'file_downloader.dart';
import 'models.dart';
import 'task.dart';
/// Class representing a chunk of a download and its status
class Chunk {
// key parameters
final String parentTaskId;
final String url;
final String filename;
final int fromByte; // start byte
final int toByte; // end byte
final DownloadTask task; // task to download this chunk
// state parameters
late TaskStatus status;
late double progress;
/// Define a chunk by its key parameters, in default state
///
/// This also generates the [task] to download this chunk, and that
/// task contains the [parentTaskId] and [toByte] and [fromByte] values of the
/// chunk in its [Task.metaData] field as a JSON encoded map
Chunk({
required Task parentTask,
required this.url,
required this.filename,
required this.fromByte,
required this.toByte,
}) : parentTaskId = parentTask.taskId,
task = DownloadTask(
url: url,
filename: filename,
headers: {...parentTask.headers, 'Range': 'bytes=$fromByte-$toByte'},
baseDirectory: BaseDirectory.temporary,
group: BaseDownloader.chunkGroup,
updates: updatesBasedOnParent(parentTask),
retries: parentTask.retries,
allowPause: parentTask.allowPause,
priority: parentTask.priority,
requiresWiFi: parentTask.requiresWiFi,
metaData: jsonEncode({
'parentTaskId': parentTask.taskId,
'from': fromByte,
'to': toByte,
}),
) {
status = TaskStatus.enqueued;
progress = 0;
}
/// Creates object from [json]
Chunk.fromJson(Map<String, dynamic> json)
: parentTaskId = json['parentTaskId'],
url = json['url'],
filename = json['filename'],
fromByte = (json['fromByte'] as num).toInt(),
toByte = (json['toByte'] as num).toInt(),
task = Task.createFromJson(json['task']) as DownloadTask,
status = TaskStatus.values[(json['status'] as num? ?? 0).toInt()],
progress = (json['progress'] as num? ?? 0.0).toDouble();
/// Revive `List<Chunk>` from a JSON map in a jsonDecode operation,
/// where each element is a map representing the [Chunk]
static Object? listReviver(Object? key, Object? value) =>
key is int ? Chunk.fromJson(value as Map<String, dynamic>) : value;
/// Creates JSON map of this object
Map<String, dynamic> toJson() => {
'parentTaskId': parentTaskId,
'url': url,
'filename': filename,
'fromByte': fromByte,
'toByte': toByte,
'task': task.toJson(),
'status': status.index,
'progress': progress,
};
/// Return the parentTaskId embedded in the metaData of a chunkTask
static String getParentTaskId(Task task) =>
jsonDecode(task.metaData)['parentTaskId'] as String;
/// Return [Updates] that is based on the [parentTask]
static Updates updatesBasedOnParent(Task parentTask) =>
switch (parentTask.updates) {
.none || .status => Updates.status,
.progress || .statusAndProgress => Updates.statusAndProgress,
};
}
/// Resume all chunk tasks associated with this [task], and
/// return true if successful, otherwise cancels this [task]
/// which will also cancel all chunk tasks
Future<bool> resumeChunkTasks(
ParallelDownloadTask task,
ResumeData resumeData,
) async {
final chunks = List<Chunk>.from(
jsonDecode(resumeData.data, reviver: Chunk.listReviver),
);
final results = await Future.wait(
// Completed chunks retain their final file/status but have no resume data.
// Resuming them returns false and incorrectly cancels the whole parent.
chunks
.where((chunk) => chunk.status != TaskStatus.complete)
.map((chunk) => FileDownloader().resume(chunk.task)),
);
if (results.any((result) => result == false)) {
// cancel [ParallelDownloadTask] if any resume did not succeed.
// this will also cancel all chunk tasks
await FileDownloader().cancelTaskWithId(task.taskId);
return false;
}
return true;
}
+318
View File
@@ -0,0 +1,318 @@
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
import 'dart:async';
import 'base_downloader.dart';
import 'exceptions.dart';
import 'models.dart';
import 'persistent_storage.dart';
import 'task.dart';
/// Persistent database used for tracking task status and progress.
///
/// Stores [TaskRecord] objects.
///
/// This object is accessed by the [FileDownloader] and [BaseDownloader]
interface class Database {
static Database? _instance;
late final PersistentStorage _storage;
final StreamController<TaskRecord> _controller =
StreamController<TaskRecord>.broadcast();
final _log = Logger('Database');
/// Return the [Database] singleton instance, and creates it if required
factory Database(PersistentStorage persistentStorage) {
_instance ??= Database._internal(persistentStorage);
return _instance!;
}
Database._internal(PersistentStorage persistentStorage) {
assert(_instance == null);
_storage = persistentStorage;
}
/// Direct access to the [PersistentStorage] object underlying the
/// database. For testing only
@visibleForTesting
PersistentStorage get storage => _storage;
/// Stream of [TaskRecord] updates, emitted after a record is updated in
/// [PersistentStorage]
Stream<TaskRecord> get updates => _controller.stream;
/// Returns all [TaskRecord]
///
/// Optionally, specify a [group] to filter by
Future<List<TaskRecord>> allRecords({String? group}) async {
final allRecords = await _storage.retrieveAllTaskRecords();
return group == null
? allRecords.toList()
: allRecords.where((element) => element.group == group).toList();
}
/// Returns all [TaskRecord] older than [age]
///
/// Optionally, specify a [group] to filter by
Future<List<TaskRecord>> allRecordsOlderThan(
Duration age, {
String? group,
}) async {
final allRecordsInGroup = await allRecords(group: group);
final now = DateTime.now();
return allRecordsInGroup
.where((record) => now.difference(record.task.creationTime) > age)
.toList();
}
/// Returns all [TaskRecord] with [TaskStatus] [status]
///
/// Optionally, specify a [group] to filter by
Future<List<TaskRecord>> allRecordsWithStatus(
TaskStatus status, {
String? group,
}) async {
final allRecordsInGroup = await allRecords(group: group);
return allRecordsInGroup
.where((record) => record.status == status)
.toList();
}
/// Return [TaskRecord] for this [taskId] or null if not found
Future<TaskRecord?> recordForId(String taskId) =>
_storage.retrieveTaskRecord(taskId);
/// Return list of [TaskRecord] corresponding to the [taskIds]
///
/// Only records that can be found in the database will be included in the
/// list. TaskIds that cannot be found will be ignored.
Future<List<TaskRecord>> recordsForIds(Iterable<String> taskIds) async {
final records = await Future.wait(taskIds.map((id) => recordForId(id)));
return records.whereType<TaskRecord>().toList();
}
/// Delete all records
///
/// Optionally, specify a [group] to filter by
Future<void> deleteAllRecords({String? group}) async {
if (group == null) {
await _storage.removeTaskRecord(null);
_updateCount = 0;
return;
}
final allRecordsInGroup = await allRecords(group: group);
await deleteRecordsWithIds(
allRecordsInGroup.map((record) => record.taskId),
);
_updateCount = 0;
}
/// Delete record with this [taskId]
Future<void> deleteRecordWithId(String taskId) =>
deleteRecordsWithIds([taskId]);
/// Delete records with these [taskIds]
Future<void> deleteRecordsWithIds(Iterable<String> taskIds) async {
await Future.wait(
taskIds.map((taskId) => _storage.removeTaskRecord(taskId)),
);
}
int _updateCount = 0;
bool _autoClean = false;
int? _maxRecordCount;
Duration? _maxAge;
bool _isCleaning = false;
bool _waitingToClean = false;
/// Whether automatic database cleanup is enabled
bool get autoClean => _autoClean;
/// Clean up the database by removing old records and/or keeping the number
/// of records below a maximum.
///
/// The [maxRecordCount] determines the maximum number of records to keep.
/// The [maxAge] determines the maximum age of records to keep.
/// If [autoClean] is true, the database will be cleaned automatically
/// every 100th update.
///
/// If both [maxRecordCount] and [maxAge] are null, the defaults are 500
/// records and 10 days.
///
/// The function returns immediately, but the actual cleanup happens
/// asynchronously and is rate-limited to deleting 5 records per second.
void cleanUp({int? maxRecordCount, Duration? maxAge, bool autoClean = true}) {
_autoClean = autoClean;
if (maxRecordCount == null && maxAge == null) {
_maxRecordCount = 500;
_maxAge = const Duration(days: 10);
} else {
_maxRecordCount = maxRecordCount;
_maxAge = maxAge;
}
_cleanDatabase();
}
Future<void> _cleanDatabase() async {
if (_isCleaning) {
_waitingToClean = true;
return;
}
_isCleaning = true;
do {
_waitingToClean = false;
try {
final allRecords = await this.allRecords();
allRecords.sort(
(a, b) => b.task.creationTime.compareTo(a.task.creationTime),
); // Newest first
final recordsToDelete = <TaskRecord>{};
final now = DateTime.now();
// Check age
if (_maxAge != null) {
final maxAge = _maxAge!;
recordsToDelete.addAll(
allRecords.where(
(record) => now.difference(record.task.creationTime) > maxAge,
),
);
}
// Check count
if (_maxRecordCount != null) {
final count = _maxRecordCount!;
if (allRecords.length > count) {
// Because we sorted newly created first, the ones after 'count' are the oldest
final recordsBeyondCount = allRecords.skip(count);
recordsToDelete.addAll(recordsBeyondCount);
}
}
_log.finest(
'Database cleanup: ${recordsToDelete.length} out of ${allRecords.length} records to delete',
);
if (recordsToDelete.isNotEmpty) {
// Rate limit deletion to ~5 per second
for (final record in recordsToDelete) {
await deleteRecordWithId(record.taskId);
await Future.delayed(const Duration(milliseconds: 200));
}
_log.finest(
'Database cleanup: ${recordsToDelete.length} records deleted}',
);
}
} catch (e) {
_log.warning('Error during database cleanup: $e');
}
} while (_waitingToClean);
_isCleaning = false;
}
/// Update or insert the record in the database
///
/// This is used by the [FileDownloader] to track tasks, and should not
/// normally be used by the user of this package
Future<void> updateRecord(TaskRecord record) async {
if (_autoClean) {
_updateCount++;
if (_updateCount % 100 == 0) {
_cleanDatabase();
}
}
await _storage.storeTaskRecord(record);
if (_controller.hasListener) {
_controller.add(record);
}
}
/// Destroy the [Database] singleton instance
///
/// For testing purposes only
void destroy() {
_instance = null;
}
}
/// Record containing task, task status and task progress.
///
/// [TaskRecord] represents the state of the task as recorded in persistent
/// storage if [FileDownloader.trackTasks] has been called to activate this.
final class TaskRecord {
final Task task;
final TaskStatus status;
final double progress;
final int expectedFileSize;
final TaskException? exception;
TaskRecord(
this.task,
this.status,
this.progress,
this.expectedFileSize, [
this.exception,
]);
/// Returns the group collection this record is stored under, which is
/// the [task]'s [Task.group]
String get group => task.group;
/// Returns the record id, which is the [task]'s [Task.taskId]
String get taskId => task.taskId;
/// Create [TaskRecord] from [json]
TaskRecord.fromJson(Map<String, dynamic> json)
: task = Task.createFromJson(json),
status = TaskStatus
.values[(json['status'] as num?)?.toInt() ?? TaskStatus.failed.index],
progress = (json['progress'] as num?)?.toDouble() ?? progressFailed,
expectedFileSize = (json['expectedFileSize'] as num?)?.toInt() ?? -1,
exception = json['exception'] == null
? null
: TaskException.fromJson(json['exception']);
/// Returns JSON map representation of this [TaskRecord]
///
/// Note the [status], [progress] and [exception] fields are merged into
/// the JSON map representation of the [task]
Map<String, dynamic> toJson() {
final json = task.toJson();
json['status'] = status.index;
json['progress'] = progress;
json['expectedFileSize'] = expectedFileSize;
json['exception'] = exception?.toJson();
return json;
}
/// Copy with optional replacements. [exception] is always copied
TaskRecord copyWith({
Task? task,
TaskStatus? status,
double? progress,
int? expectedFileSize,
}) => TaskRecord(
task ?? this.task,
status ?? this.status,
progress ?? this.progress,
expectedFileSize ?? this.expectedFileSize,
exception,
);
@override
String toString() =>
'DatabaseRecord{task: $task, status: $status, progress: $progress,'
' expectedFileSize: $expectedFileSize, exception: $exception}';
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is TaskRecord &&
runtimeType == other.runtimeType &&
task == other.task &&
status == other.status &&
progress == other.progress &&
expectedFileSize == other.expectedFileSize &&
exception == other.exception;
@override
int get hashCode =>
task.hashCode ^ status.hashCode ^ progress.hashCode ^ exception.hashCode;
}
@@ -0,0 +1,59 @@
import 'dart:async';
import 'dart:isolate';
import 'package:http/http.dart' as http;
import '../../background_downloader.dart';
import 'desktop_downloader.dart';
import 'download_isolate.dart';
import 'isolate.dart';
/// Do the data task
///
/// Sends updates via the [sendPort] and can be commanded to cancel via
/// the [messagesToIsolate] queue
Future<void> doDataTask(
DataTask task,
Duration requestTimeout,
SendPort sendPort,
) async {
final client = DesktopDownloader.httpClientForUrl(task.url);
final request = http.Request(task.httpRequestMethod, Uri.parse(task.url));
request.headers.addAll(task.headers);
request.maxRedirects = defaultMaxRedirects;
if (task.post is String) {
request.body = task.post!;
}
var resultStatus = TaskStatus.failed;
try {
final response = await client.send(request).timeout(requestTimeout);
if (!isCanceled) {
responseHeaders = response.headers;
responseStatusCode = response.statusCode;
extractContentType(response.headers);
responseBody = await responseContent(response);
if (okResponses.contains(response.statusCode)) {
resultStatus = TaskStatus.complete;
} else {
if (response.statusCode == 404) {
resultStatus = TaskStatus.notFound;
} else {
taskException = TaskHttpException(
responseBody?.isNotEmpty == true
? responseBody!
: response.reasonPhrase ?? 'Invalid HTTP Request',
response.statusCode,
);
}
}
}
} catch (e) {
logError(task, e.toString());
setTaskError(e);
}
if (isCanceled) {
// cancellation overrides other results
resultStatus = TaskStatus.canceled;
}
processStatusUpdateInIsolate(task, resultStatus, sendPort);
}
@@ -0,0 +1,930 @@
import 'dart:async';
import 'dart:collection';
import 'dart:io';
import 'dart:isolate';
import 'package:async/async.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'package:http/io_client.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import '../base_downloader.dart';
import '../chunk.dart';
import '../exceptions.dart';
import '../file_downloader.dart';
import '../models.dart';
import '../task.dart';
import '../utils.dart';
import 'isolate.dart';
const okResponses = [200, 201, 202, 203, 204, 205, 206];
/// Implementation of download functionality for desktop platforms
///
/// On desktop (MacOS, Linux, Windows) the download and upload are implemented
/// in Dart, as there is no native platform equivalent of URLSession or
/// WorkManager as there is on iOS and Android
final class DesktopDownloader extends BaseDownloader {
static final _log = Logger('DesktopDownloader');
static const unlimited = 1 << 20;
var maxConcurrent = 10;
var maxConcurrentByHost = unlimited;
var maxConcurrentByGroup = unlimited;
static final DesktopDownloader _singleton = DesktopDownloader._internal();
final _queue = PriorityQueue<Task>();
final _running = Queue<Task>(); // subset that is running
final _resume = <Task>{};
final _isolateSendPorts =
<Task, SendPort?>{}; // isolate SendPort for running task
static final Map<String, http.Client> _clientsCache = {};
static http.Client? _defaultClient;
static Duration? _requestTimeout;
static var _proxy = <String, dynamic>{}; // 'address' and 'port'
static var _bypassTLSCertificateValidation = false;
static var _mtlsConfigs = <MTLSConfig>[];
static int _skipExistingFiles = -1;
static String? tempFilePath;
factory() => _singleton;
new _internal();
@override
Future<bool> enqueue(Task task) async {
try {
Uri.decodeFull(task.url);
} catch (e) {
_log.fine('Invalid url: ${task.url} error: $e');
return false;
}
super.enqueue(task);
_queue.add(task);
processStatusUpdate(TaskStatusUpdate(task, TaskStatus.enqueued));
_advanceQueue();
return true;
}
@override
Future<List<bool>> enqueueAll(Iterable<Task> tasks) async {
final results = <bool>[];
for (final task in tasks) {
results.add(await enqueue(task));
}
return results;
}
/// Advance the queue if it's not empty and there is room in the run queue
void _advanceQueue() {
while (_running.length < maxConcurrent && _queue.isNotEmpty) {
final task = _getNextTask();
if (task != null) {
_running.add(task);
_executeTask(task)
.catchError((Object e, StackTrace stackTrace) {
// an unexpected error must not leave the task without a final
// status, or keep occupying its slot in the run queue
_log.warning('Error executing taskId ${task.taskId}: $e');
_log.fine('Stack trace: $stackTrace');
if (!_running.contains(task)) {
return; // final status was already sent
}
processStatusUpdate(
TaskStatusUpdate(
task,
TaskStatus.failed,
TaskException('Error executing task: $e'),
),
);
})
.whenComplete(() {
_remove(task);
_advanceQueue();
});
} else {
return; // if no suitable task, done
}
}
}
/// Returns a [Task] to run, or null if no suitable task is available
Task? _getNextTask() {
final tasksThatHaveToWait = <Task>[];
while (_queue.isNotEmpty) {
final task = _queue.removeFirst();
if (_numActiveWithHostname(task.hostName) < maxConcurrentByHost &&
_numActiveWithGroup(task.group) < maxConcurrentByGroup) {
_queue.addAll(tasksThatHaveToWait); // put back in queue
return task;
}
tasksThatHaveToWait.add(task);
}
_queue.addAll(tasksThatHaveToWait); // put back in queue
return null;
}
/// Returns number of tasks active with this [hostname]
int _numActiveWithHostname(String hostname) => _running.fold(
0,
(previousValue, task) =>
task.hostName == hostname ? previousValue + 1 : previousValue,
);
/// Returns number of tasks active with this [group]
int _numActiveWithGroup(String group) => _running.fold(
0,
(previousValue, task) =>
task.group == group ? previousValue + 1 : previousValue,
);
/// Execute this task
///
/// The task runs on an Isolate, which is sent the task information and
/// which will emit status and progress updates. These updates will be
/// 'forwarded' to the [backgroundChannel] and processed by the
/// [FileDownloader]
Future<void> _executeTask(Task task) async {
// Check if the file should be skipped
// (not possible if the filename will be suggested by the server)
if (task is DownloadTask && _skipExistingFiles != -1 && task.hasFilename) {
final filePath = await task.filePath();
final file = File(filePath);
if (await file.exists()) {
final fileSize = await file.length();
// threshold 0 skips any existing file, including an empty one
if (_skipExistingFiles == 0 ||
fileSize > _skipExistingFiles * 1024 * 1024) {
processStatusUpdate(
TaskStatusUpdate(task, TaskStatus.complete, null, null, null, 304),
);
return;
}
}
}
final resumeData = await getResumeData(task.taskId);
if (resumeData != null) {
await removeResumeData(task.taskId);
}
final isResume = _resume.remove(task) && resumeData != null;
// spawn an isolate to do the task
final receivePort = ReceivePort();
final errorPort = ReceivePort();
errorPort.listen((message) {
final exceptionDescription = (message as List).first as String;
final stackTrace = message.last;
logError(task, exceptionDescription);
log.fine('Stack trace: $stackTrace');
processStatusUpdate(
TaskStatusUpdate(
task,
TaskStatus.failed,
TaskException(exceptionDescription),
),
);
receivePort.close(); // also ends listener at the end
});
final RootIsolateToken? rootIsolateToken = RootIsolateToken.instance;
if (rootIsolateToken == null) {
processStatusUpdate(
TaskStatusUpdate(
task,
TaskStatus.failed,
TaskException('Could not obtain rootIsolateToken'),
),
);
return;
}
try {
log.finer('${isResume ? "Resuming" : "Starting"} taskId ${task.taskId}');
await Isolate.spawn(doTask, (
rootIsolateToken,
receivePort.sendPort,
), onError: errorPort.sendPort);
final messagesFromIsolate = StreamQueue<dynamic>(receivePort);
if (!await messagesFromIsolate.hasNext) {
return; // isolate failed before starting, reported via errorPort
}
final sendPort = await messagesFromIsolate.next as SendPort;
sendPort.send((
task,
resumeData,
isResume,
requestTimeout,
proxy,
bypassTLSCertificateValidation,
_mtlsConfigs,
tempFilePath,
));
if (_isolateSendPorts.keys.contains(task)) {
// if already registered with null value, cancel immediately
sendPort.send('cancel');
}
// store the isolate's sendPort so we can send it messages for
// cancellation, and for managing parallel downloads
_isolateSendPorts[task] = sendPort;
// listen for messages sent back from the isolate, until 'done'
// note that the task sent by the isolate may have changed. Therefore, we
// use updatedTask instead of task from here on
while (await messagesFromIsolate.hasNext) {
final message = await messagesFromIsolate.next;
switch (message) {
case 'done':
receivePort.close();
case (
'statusUpdate',
final Task updatedTask,
final TaskStatus status,
final TaskException? exception,
final String? responseBody,
final Map<String, String>? responseHeaders,
final int? responseCode,
final String? mimeType,
final String? charSet,
):
final taskStatusUpdate = TaskStatusUpdate(
updatedTask,
status,
exception,
responseBody,
responseHeaders,
responseCode,
mimeType,
charSet,
);
if (updatedTask.group != BaseDownloader.chunkGroup) {
if (status.isFinalState) {
_remove(updatedTask);
}
processStatusUpdate(taskStatusUpdate);
} else {
_parallelTaskSendPort(Chunk.getParentTaskId(updatedTask))
?.send(taskStatusUpdate);
}
case (
'progressUpdate',
final Task updatedTask,
final double progress,
final int expectedFileSize,
final double downloadSpeed,
final Duration timeRemaining,
):
final taskProgressUpdate = TaskProgressUpdate(
updatedTask,
progress,
expectedFileSize,
downloadSpeed,
timeRemaining,
);
if (updatedTask.group != BaseDownloader.chunkGroup) {
processProgressUpdate(taskProgressUpdate);
} else {
_parallelTaskSendPort(Chunk.getParentTaskId(updatedTask))
?.send(taskProgressUpdate);
}
case ('taskCanResume', final bool taskCanResume):
setCanResume(task, taskCanResume);
case (
'resumeData',
final String data,
final int requiredStartByte,
final String? eTag,
):
try {
await setResumeData(
ResumeData(task, data, requiredStartByte, eTag),
);
} catch (e) {
_log.warning('Failed to store resume data: $e');
}
// from [ParallelDownloadTask]
case ('enqueueChild', final DownloadTask childTask):
await FileDownloader().enqueue(childTask);
// from [ParallelDownloadTask]
case ('cancelTasksWithId', final List<String> taskIds):
await FileDownloader().cancelTasksWithIds(taskIds);
// from [ParallelDownloadTask]
case ('pauseTasks', final List<DownloadTask> tasks):
for (final chunkTask in tasks) {
await FileDownloader().pause(chunkTask);
}
case ('log', final String logMessage):
_log.finest(logMessage);
default:
_log.warning(
'Received message with unknown type '
'$message from Isolate',
);
}
}
} finally {
receivePort.close();
errorPort.close();
_isolateSendPorts.remove(task);
}
}
// intercept the status and progress updates for tasks that are 'chunks', i.e.
// part of a [ParallelDownloadTask]. Updates for these tasks are sent to the
// isolate running the [ParallelDownloadTask] instead
@override
void processStatusUpdate(TaskStatusUpdate update) {
// Regular update if task's group is not chunkGroup
if (update.task.group != FileDownloader.chunkGroup) {
return super.processStatusUpdate(update);
}
// If chunkGroup, send update to task's parent isolate.
// The task's metadata contains taskId of parent
_parallelTaskSendPort(Chunk.getParentTaskId(update.task))?.send(update);
}
@override
void processProgressUpdate(TaskProgressUpdate update) {
// Regular update if task's group is not chunkGroup
if (update.task.group != FileDownloader.chunkGroup) {
return super.processProgressUpdate(update);
}
// If chunkGroup, send update to task's parent isolate.
// The task's metadata contains taskId of parent
_parallelTaskSendPort(Chunk.getParentTaskId(update.task))?.send(update);
}
/// Return the [SendPort] for the [ParallelDownloadTask] represented by [taskId]
/// or null if not a [ParallelDownloadTask] or not found
SendPort? _parallelTaskSendPort(String taskId) => _isolateSendPorts.entries
.firstWhereOrNull(
(entry) =>
entry.key is ParallelDownloadTask && entry.key.taskId == taskId,
)
?.value;
@override
Future<int> reset(String group) async {
final retryAndPausedTaskCount = await super.reset(group);
final inQueueIds = _queue.unorderedElements
.where((task) => task.group == group)
.map((task) => task.taskId);
final runningIds = _running
.where((task) => task.group == group)
.map((task) => task.taskId);
final taskIds = [...inQueueIds, ...runningIds];
if (taskIds.isNotEmpty) {
await cancelTasksWithIds(taskIds);
}
return retryAndPausedTaskCount + taskIds.length;
}
@override
Future<List<Task>> allTasks(
String group,
bool includeTasksWaitingToRetry,
allGroups,
) async {
final retryAndPausedTasks = await super.allTasks(
group,
includeTasksWaitingToRetry,
allGroups,
);
if (allGroups) {
return [...retryAndPausedTasks, ..._queue.unorderedElements, ..._running];
}
final inQueue = _queue.unorderedElements.where(
(task) => task.group == group,
);
final running = _running.where((task) => task.group == group);
return [...retryAndPausedTasks, ...inQueue, ...running];
}
/// Cancels ongoing platform tasks whose taskId is in the list provided
///
/// Returns true if all cancellations were successful
@override
Future<bool> cancelPlatformTasksWithIds(List<String> taskIds) async {
final inQueue = _queue.unorderedElements
.where((task) => taskIds.contains(task.taskId))
.toList(growable: false);
for (final task in inQueue) {
processStatusUpdate(TaskStatusUpdate(task, TaskStatus.canceled));
_remove(task);
}
final running = _running.where((task) => taskIds.contains(task.taskId));
for (final task in running) {
final sendPort = _isolateSendPorts[task];
if (sendPort != null) {
sendPort.send('cancel');
_isolateSendPorts.remove(task);
} else {
// register task for cancellation even if sendPort does not yet exist:
// this will lead to immediate cancellation when the Isolate starts
_isolateSendPorts[task] = null;
}
}
return true;
}
@override
Future<Task?> taskForId(String taskId) async {
final task = await super.taskForId(taskId);
if (task != null) {
return task;
}
try {
return _running.where((task) => task.taskId == taskId).first;
} on StateError {
try {
return _queue.unorderedElements
.where((task) => task.taskId == taskId)
.first;
} on StateError {
return null;
}
}
}
@override
Future<bool> pause(Task task) async {
final sendPort = _isolateSendPorts[task];
if (sendPort != null) {
sendPort.send('pause');
return true;
}
return false;
}
@override
Future<List<bool>> pauseTaskList(Iterable<Task> tasksToPause) async {
final pauseCalls = tasksToPause.map((task) => pause(task));
return Future.wait(pauseCalls);
}
@override
Future<bool> resume(Task task) async {
if (await super.resume(task)) {
task = awaitTasks.containsKey(task)
? awaitTasks.keys.firstWhere(
(awaitTask) => awaitTask.taskId == task.taskId,
)
: task;
// Enqueue starts _executeTask, which consumes the persisted resume data.
// Retain the parent snapshot before that worker can remove it.
final resumeData = task is ParallelDownloadTask
? await getResumeData(task.taskId)
: null;
_resume.add(task);
if (await enqueue(task)) {
if (task is ParallelDownloadTask) {
if (resumeData == null) {
return false;
}
return resumeChunkTasks(task, resumeData);
}
return true;
}
}
return false;
}
@override
Future<Map<String, String>> popUndeliveredData(Undelivered dataType) =>
Future.value({});
@override
Future<String?> moveToSharedStorage(
String filePath,
SharedStorage destination,
String directory,
String? mimeType, {
bool asUriString = false,
}) async {
final fileUri = Uri.tryParse(filePath);
if (fileUri case Uri(scheme: 'file')) {
filePath = fileUri.toFilePath(
windows: defaultTargetPlatform == TargetPlatform.windows,
);
}
final destDirectoryPath = await getDestinationDirectoryPath(
destination,
directory,
);
if (destDirectoryPath == null) {
return null;
}
if (!await Directory(destDirectoryPath).exists()) {
await Directory(destDirectoryPath).create(recursive: true);
}
final fileName = path.basename(filePath);
final destFilePath = path.join(destDirectoryPath, fileName);
try {
await File(filePath).rename(destFilePath);
} on FileSystemException catch (e) {
// rename fails across volumes (e.g. EXDEV), so fall back to copy + delete
_log.fine('Could not rename $filePath ($e), trying copy and delete');
try {
await File(filePath).copy(destFilePath);
} on FileSystemException catch (e) {
_log.warning('Error moving $filePath to shared storage: $e');
return null;
}
try {
await File(filePath).delete();
} on FileSystemException catch (e) {
_log.warning('Could not delete $filePath after copying: $e');
}
}
return asUriString ? Uri.file(destFilePath).toString() : destFilePath;
}
@override
Future<String?> pathInSharedStorage(
String filePath,
SharedStorage destination,
String directory, {
bool asUriString = false,
}) async {
final destDirectoryPath = await getDestinationDirectoryPath(
destination,
directory,
);
if (destDirectoryPath == null) {
return null;
}
final fileName = path.basename(filePath);
final destFilePath = path.join(destDirectoryPath, fileName);
return asUriString ? Uri.file(destFilePath).toString() : destFilePath;
}
/// Returns the path of the destination directory in shared storage, or null
///
/// Only the .Downloads directory is supported on desktop.
/// The [directory] is appended to the base Downloads directory.
/// The directory at the returned path is not guaranteed to exist.
Future<String?> getDestinationDirectoryPath(
SharedStorage destination,
String directory,
) async {
if (destination != SharedStorage.downloads) {
_log.finer('Desktop only supports .downloads destination');
return null;
}
final downloadsDirectory = await getDownloadsDirectory();
if (downloadsDirectory == null) {
_log.warning('Could not obtain downloads directory');
return null;
}
// remove leading and trailing slashes from [directory]
var cleanDirectory = directory.replaceAll(RegExp(r'^/+'), '');
cleanDirectory = cleanDirectory.replaceAll(RegExp(r'/$'), '');
return cleanDirectory.isEmpty
? downloadsDirectory.path
: path.join(downloadsDirectory.path, cleanDirectory);
}
@override
Future<bool> openFile(Task? task, String? filePath, String? mimeType) async {
final executable = defaultTargetPlatform == TargetPlatform.linux
? 'xdg-open'
: defaultTargetPlatform == TargetPlatform.macOS
? 'open'
: 'start';
filePath ??= await task!.filePath();
if (!await File(filePath).exists()) {
_log.fine('File to open does not exist: $filePath');
return false;
}
// On Windows, 'start' treats its first quoted argument as the window
// title, so pass an empty title before the (possibly quoted) filePath
final result = defaultTargetPlatform == TargetPlatform.windows
? await Process.run('cmd', ['/c', 'start', '', filePath])
: await Process.run(executable, [filePath], runInShell: true);
if (result.exitCode != 0) {
_log.fine(
'openFile command $executable returned exit code ${result.exitCode}',
);
}
return result.exitCode == 0;
}
@override
Future<Duration> getTaskTimeout() => Future.value(const Duration(days: 1));
@override
Future<void> setForceFailPostOnBackgroundChannel(bool value) {
throw UnimplementedError();
}
@override
Future<String> testSuggestedFilename(
DownloadTask task,
String contentDisposition,
) async {
final h = contentDisposition.isNotEmpty
? {'Content-disposition': contentDisposition}
: <String, String>{};
final t = await taskWithSuggestedFilename(task, h, false);
return t.filename;
}
@override
dynamic platformConfig({
dynamic globalConfig,
dynamic androidConfig,
dynamic iOSConfig,
dynamic desktopConfig,
}) => desktopConfig;
@override
Future<(String, String)> configureItem((String, dynamic) configItem) async {
switch (configItem) {
case (Config.requestTimeout, final Duration? duration):
requestTimeout = duration;
case (Config.proxy, (final String address, final int port)):
proxy = {'address': address, 'port': port};
case (Config.proxy, false):
proxy = {};
case (Config.bypassTLSCertificateValidation, final bool bypass):
bypassTLSCertificateValidation = bypass;
case (Config.mTLS, final MTLSConfig config):
mtlsConfig = config;
case (Config.mTLS, false):
case (Config.mTLS, null):
resetMtlsConfig();
case (
Config.holdingQueue,
(
final int? maxConcurrentParam,
final int? maxConcurrentByHostParam,
final int? maxConcurrentByGroupParam,
),
):
maxConcurrent = maxConcurrentParam ?? 10;
maxConcurrentByHost = maxConcurrentByHostParam ?? unlimited;
maxConcurrentByGroup = maxConcurrentByGroupParam ?? unlimited;
case (Config.holdingQueue, Config.never):
case (Config.holdingQueue, false):
maxConcurrent = unlimited;
maxConcurrentByHost = unlimited;
maxConcurrentByGroup = unlimited;
case (Config.skipExistingFiles, final int value):
_skipExistingFiles = value;
case (Config.skipExistingFiles, Config.never):
case (Config.skipExistingFiles, false):
_skipExistingFiles = -1;
case (Config.skipExistingFiles, Config.always):
case (Config.skipExistingFiles, true):
_skipExistingFiles = 0;
case (Config.tempFilePath, final String path):
tempFilePath = (path.isNotEmpty && path != Config.never) ? path : null;
case (Config.tempFilePath, null):
case (Config.tempFilePath, false):
tempFilePath = null;
default:
return (
configItem.$1,
'not implemented',
); // this method did not process this configItem
}
return (configItem.$1, ''); // normal result
}
/// Sets requestTimeout and recreates HttpClient
static set requestTimeout(Duration? value) {
_requestTimeout = value;
_recreateClient();
}
static Duration? get requestTimeout => _requestTimeout;
/// Sets proxy and recreates HttpClient
///
/// Value must be dict containing 'address' and 'port'
/// or empty for no proxy
static set proxy(Map<String, dynamic> value) {
_proxy = value;
_recreateClient();
}
static Map<String, dynamic> get proxy => _proxy;
/// Set or resets bypass for TLS certificate validation
static set bypassTLSCertificateValidation(bool value) {
_bypassTLSCertificateValidation = value;
_recreateClient();
}
static bool get bypassTLSCertificateValidation =>
_bypassTLSCertificateValidation;
/// Set or reset an [MTLSConfig]
static set mtlsConfig(MTLSConfig? config) {
if (config == null || config.isReset) {
resetMtlsConfig(config?.host);
} else {
final configHost = config.host?.toLowerCase();
_mtlsConfigs.removeWhere((c) => c.host?.toLowerCase() == configHost);
_mtlsConfigs.add(config);
_recreateClient();
}
}
/// Resets mTLS configuration for [host] or all hosts if [host] is null
static void resetMtlsConfig([String? host]) {
if (host == null) {
_mtlsConfigs.clear();
} else {
final normHost = host.toLowerCase();
_mtlsConfigs.removeWhere((c) => c.host?.toLowerCase() == normHost);
}
_recreateClient();
}
/// Returns active mTLS configurations
static List<MTLSConfig> get mtlsConfigs => List.unmodifiable(_mtlsConfigs);
/// Sets all mTLS configurations
static set mtlsConfigs(List<MTLSConfig> configs) {
_mtlsConfigs = List.from(configs);
_recreateClient();
}
/// Set the HTTP Client to use, with the given parameters
///
/// This is a convenience method, bundling the [requestTimeout],
/// [proxy], [bypassTLSCertificateValidation] and [mtlsConfigs]
static void setHttpClient(
Duration? requestTimeout,
Map<String, dynamic> proxy,
bool bypassTLSCertificateValidation, [
List<MTLSConfig>? mtlsConfigs,
]) {
_requestTimeout = requestTimeout;
_proxy = proxy;
_bypassTLSCertificateValidation = bypassTLSCertificateValidation;
if (mtlsConfigs != null) {
_mtlsConfigs = List.from(mtlsConfigs);
}
_recreateClient();
}
/// Returns an [http.Client] configured for the given [url].
static http.Client httpClientForUrl(String? url) {
final host = url != null ? Uri.tryParse(url)?.host : null;
return httpClientForHost(host);
}
/// Returns an [http.Client] configured for the given [host].
static http.Client httpClientForHost(String? host) {
if (_mtlsConfigs.isEmpty) {
return _defaultClient ??= _createRawClient(null);
}
final normHost = host?.toLowerCase();
final matchedConfig =
(normHost != null
? _mtlsConfigs.firstWhereOrNull(
(c) => c.host?.toLowerCase() == normHost && c.hasCredentials,
)
: null) ??
_mtlsConfigs.firstWhereOrNull(
(c) => c.host == null && c.hasCredentials,
);
if (matchedConfig == null) {
return _defaultClient ??= _createRawClient(null);
}
final cacheKey = matchedConfig.host?.toLowerCase() ?? '*';
return _clientsCache.putIfAbsent(
cacheKey,
() => _createRawClient(matchedConfig),
);
}
static const defaultMaxRedirects = 10;
/// Default HTTP client getter for backward compatibility
static http.Client get httpClient => httpClientForHost(null);
/// Sets the default HTTP client (primarily for testing)
static set httpClient(http.Client client) {
_defaultClient = client is _RedirectClient
? client
: _RedirectClient(client);
}
static http.Client _createRawClient(MTLSConfig? mtlsConfig) {
SecurityContext? securityContext;
if (mtlsConfig != null && mtlsConfig.hasCredentials) {
_log.finest(
'Creating HTTP client with mTLS configuration for host: ${mtlsConfig.host ?? "all hosts"}',
);
securityContext = SecurityContext(withTrustedRoots: true);
mtlsConfig.applyToSecurityContext(securityContext);
}
final client = HttpClient(context: securityContext);
client.connectionTimeout = requestTimeout;
client.findProxy = proxy.isNotEmpty
? (_) => 'PROXY ${_proxy['address']}:${_proxy['port']}'
: null;
client.badCertificateCallback =
bypassTLSCertificateValidation && !kReleaseMode
? (X509Certificate cert, String host, int port) => true
: null;
return _RedirectClient(IOClient(client));
}
/// Recreates the HTTP client instances used for Requests and isolate downloads/uploads
static void _recreateClient() {
for (final client in _clientsCache.values) {
client.close();
}
_clientsCache.clear();
_defaultClient?.close();
_defaultClient = null;
if (bypassTLSCertificateValidation) {
if (kReleaseMode) {
throw ArgumentError(
'You cannot bypass certificate validation in release mode',
);
} else {
_log.warning(
'TLS certificate validation is bypassed. This is insecure and cannot be '
'done in release mode',
);
}
}
_log.finest(
'Using HTTP client with requestTimeout $_requestTimeout, proxy $_proxy, TLSCertificateBypass = $bypassTLSCertificateValidation, mTLS configs = ${_mtlsConfigs.length}',
);
}
@override
void destroy() {
super.destroy();
_queue.clear();
_running.clear();
_isolateSendPorts.clear();
}
/// Remove all references to [task]
void _remove(Task task) {
_queue.remove(task);
_running.remove(task);
_isolateSendPorts.remove(task);
}
}
/// The default maximum number of redirects to follow for HTTP requests (10).
///
/// On Desktop platforms (macOS, Windows, Linux) and for [FileDownloader.request],
/// up to 10 redirects are followed. Mobile platforms follow their native defaults
/// (iOS allows up to 16, Android allows up to 20).
const defaultMaxRedirects = DesktopDownloader.defaultMaxRedirects;
/// Wrapper around [http.Client] that raises [http.BaseRequest.maxRedirects] to
/// [defaultMaxRedirects] if it is still at the Dart default of 5.
final class _RedirectClient extends http.BaseClient {
final http.Client _inner;
_RedirectClient(this._inner);
@override
Future<http.StreamedResponse> send(http.BaseRequest request) {
if (request.maxRedirects == 5) {
request.maxRedirects = defaultMaxRedirects;
}
return _inner.send(request);
}
@override
void close() {
_inner.close();
super.close();
}
}
@@ -0,0 +1,366 @@
import 'dart:async';
import 'dart:io';
import 'dart:isolate';
import 'dart:math';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../exceptions.dart';
import '../models.dart';
import '../task.dart';
import '../utils.dart';
import 'desktop_downloader.dart';
import 'isolate.dart';
var taskRangeStartByte = 0; // Start of the Task's download range
String? eTagHeader;
late DownloadTask downloadTask; // global because filename may change
/// Execute the download task
///
/// Sends updates via the [sendPort] and can be commanded to cancel/pause via
/// the [messagesToIsolate] queue
Future<void> doDownloadTask(
DownloadTask task,
ResumeData? resumeData,
bool isResume,
Duration requestTimeout,
String? tempFilePathConfig,
SendPort sendPort,
) async {
// use downloadTask from here on as a 'global' variable in this isolate,
// as we may change the filename of the task
downloadTask = task;
var filePath = await downloadTask.filePath();
// tempFilePath is taken from [resumeDataString] if this is a resuming task.
// Otherwise, it is a generated full path to the temp directory
final tempFilePath = isResume && resumeData != null
? resumeData.tempFilepath
: p.join(
tempFilePathConfig ?? (await getTemporaryDirectory()).path,
'com.bbflight.background_downloader${Random().nextInt(1 << 32).toString()}',
);
final requiredStartByte =
resumeData?.requiredStartByte ?? 0; // start for resume
final eTag = resumeData?.eTag;
isResume =
isResume &&
await determineIfResumeIsPossible(tempFilePath, requiredStartByte);
final client = DesktopDownloader.httpClientForUrl(downloadTask.url);
final request = http.Request(
downloadTask.httpRequestMethod,
Uri.parse(downloadTask.url),
);
request.headers.addAll(downloadTask.headers);
request.maxRedirects = defaultMaxRedirects;
request.persistentConnection = false;
if (isResume) {
final taskRangeHeader = downloadTask.headers['Range'] ?? '';
final taskRange = parseRange(taskRangeHeader);
taskRangeStartByte = taskRange.$1;
final resumeRange = (taskRangeStartByte + requiredStartByte, taskRange.$2);
final newRangeString = 'bytes=${resumeRange.$1}-${resumeRange.$2 ?? ""}';
request.headers['Range'] = newRangeString;
}
switch (downloadTask.postBody) {
case final Uint8List bytes:
request.bodyBytes = bytes;
case final String post:
request.body = post;
}
var resultStatus = TaskStatus.failed;
try {
final response = await client.send(request).timeout(requestTimeout);
if (!isCanceled) {
eTagHeader = response.headers['etag'] ?? response.headers['ETag'];
final acceptRangesHeader = response.headers['accept-ranges'];
final serverAcceptsRanges =
acceptRangesHeader == 'bytes' || response.statusCode == 206;
var taskCanResume = false;
if (downloadTask.allowPause) {
// determine if this task can be paused
taskCanResume = serverAcceptsRanges;
sendPort.send(('taskCanResume', taskCanResume));
}
isResume =
isResume && response.statusCode == 206; // confirm resume response
if (isResume && (eTagHeader != eTag || eTag?.startsWith('W/') == true)) {
throw TaskException('Cannot resume: ETag is not identical, or is weak');
}
if (!downloadTask.hasFilename) {
downloadTask = await taskWithSuggestedFilename(
downloadTask,
response.headers,
true,
);
// update the filePath by replacing the last segment with the new filename
filePath = p.join(p.dirname(filePath), downloadTask.filename);
log.finest(
'Suggested filename for taskId ${task.taskId}: ${task.filename}',
);
}
responseHeaders = response.headers;
responseStatusCode = response.statusCode;
extractContentType(response.headers);
if (okResponses.contains(response.statusCode)) {
resultStatus = await processOkDownloadResponse(
filePath,
tempFilePath,
serverAcceptsRanges,
taskCanResume,
isResume,
requestTimeout,
response,
sendPort,
);
} else {
// not an OK response
responseBody = await responseContent(response);
if (response.statusCode == 404) {
resultStatus = TaskStatus.notFound;
} else {
taskException = TaskHttpException(
responseBody?.isNotEmpty == true
? responseBody!
: response.reasonPhrase ?? 'Invalid HTTP Request',
response.statusCode,
);
}
}
}
} catch (e) {
logError(downloadTask, e.toString());
setTaskError(e);
}
if (isCanceled) {
// cancellation overrides other results
resultStatus = TaskStatus.canceled;
}
processStatusUpdateInIsolate(downloadTask, resultStatus, sendPort);
}
/// Return true if resume is possible
///
/// Confirms that file at [tempFilePath] exists and its length equals
/// [requiredStartByte]
Future<bool> determineIfResumeIsPossible(
String tempFilePath,
int requiredStartByte,
) async {
if (File(tempFilePath).existsSync()) {
if (await File(tempFilePath).length() == requiredStartByte) {
return true;
} else {
log.fine('Partially downloaded file is corrupted, resume not possible');
}
} else {
log.fine('Partially downloaded file not available, resume not possible');
}
return false;
}
/// Process response with valid response code
///
/// Performs the actual bytes transfer from response to a temp file,
/// and handles the result of the transfer:
/// - .complete -> copy temp to final file location
/// - .failed -> delete temp file
/// - .paused -> post resume information
Future<TaskStatus> processOkDownloadResponse(
String filePath,
String tempFilePath,
bool serverAcceptsRanges,
bool taskCanResume,
bool isResume,
Duration requestTimeout,
http.StreamedResponse response,
SendPort sendPort,
) async {
// contentLength is extracted from response header, and if not available
// we attempt to extract from [Task.headers], allowing developer to
// set the content length if already known
final contentLength = getContentLength(response.headers, downloadTask);
isResume = isResume && response.statusCode == 206;
if (isResume && !await prepareResume(response, tempFilePath)) {
deleteTempFile(tempFilePath);
return TaskStatus.failed;
}
var resultStatus = TaskStatus.failed;
var actualTempFilePath = tempFilePath;
IOSink? outStream;
try {
// do the actual download
try {
Directory(p.dirname(actualTempFilePath)).createSync(recursive: true);
outStream = File(actualTempFilePath)
.openWrite(mode: isResume ? FileMode.append : FileMode.write);
} catch (e) {
if (!isResume) {
// Fallback to the target download directory as a hidden file
final targetDir = p.dirname(filePath);
Directory(targetDir).createSync(recursive: true);
actualTempFilePath = p.join(targetDir, '.${p.basename(tempFilePath)}');
log.info(
'Standard temporary directory not writeable ($e). '
'Falling back to target directory temp file: $actualTempFilePath',
);
outStream = File(actualTempFilePath).openWrite(mode: FileMode.write);
} else {
rethrow;
}
}
final transferBytesResult = await transferBytes(
response.stream,
outStream,
contentLength,
downloadTask,
sendPort,
requestTimeout,
);
switch (transferBytesResult) {
case .complete:
// copy file to destination, creating dirs if needed
await outStream.flush();
final dirPath = p.dirname(filePath);
Directory(dirPath).createSync(recursive: true);
File(actualTempFilePath).copySync(filePath);
resultStatus = TaskStatus.complete;
case .canceled:
// temp file is deleted in finally, after closing outStream
resultStatus = TaskStatus.canceled;
case .paused:
if (taskCanResume) {
sendPort.send((
'resumeData',
actualTempFilePath,
bytesTotal + startByte,
eTagHeader,
));
resultStatus = TaskStatus.paused;
} else {
taskException = TaskResumeException(
'Task was paused but cannot resume',
);
resultStatus = TaskStatus.failed;
}
case .failed:
break;
default:
throw ArgumentError('Cannot process $transferBytesResult');
}
} catch (e) {
logError(downloadTask, e.toString());
setTaskError(e);
} finally {
try {
try {
await outStream?.close();
} catch (_) {}
if (resultStatus == TaskStatus.failed &&
serverAcceptsRanges &&
(bytesTotal + startByte > 1 << 20 || isResume)) {
// send ResumeData to allow resume after fail
sendPort.send((
'resumeData',
actualTempFilePath,
bytesTotal + startByte,
eTagHeader,
));
} else if (resultStatus != TaskStatus.paused &&
File(actualTempFilePath).existsSync()) {
File(actualTempFilePath).deleteSync();
}
} catch (e) {
logError(
downloadTask,
'Could not delete temp file $actualTempFilePath: $e',
);
}
}
return resultStatus;
}
/// Prepare for resume if possible
///
/// Returns true if task can continue, false if task failed.
/// Extracts and parses Range headers, and truncates temp file
Future<bool> prepareResume(
http.StreamedResponse response,
String tempFilePath,
) async {
final range = response.headers['content-range'];
if (range == null) {
log.fine('Could not process partial response Content-Range');
taskException = TaskResumeException(
'Could not process partial response Content-Range',
);
return false;
}
final contentRangeRegEx = RegExp(r"(\d+)-(\d+)/(\d+)");
final matchResult = contentRangeRegEx.firstMatch(range);
if (matchResult == null) {
log.fine('Could not process partial response Content-Range $range');
taskException = TaskResumeException(
'Could not process '
'partial response Content-Range $range',
);
return false;
}
final start = int.parse(matchResult.group(1) ?? '0');
final end = int.parse(matchResult.group(2) ?? '0');
final total = int.parse(matchResult.group(3) ?? '0');
final tempFile = File(tempFilePath);
final tempFileLength = await tempFile.length();
log.finest(
'Resume start=$start, end=$end of total=$total bytes, tempFile = $tempFileLength bytes',
);
startByte = start - taskRangeStartByte; // relative to start of range
if (startByte > tempFileLength) {
log.fine('Offered range not feasible: $range with startByte $startByte');
taskException = TaskResumeException(
'Offered range not feasible: $range with startByte $startByte',
);
return false;
}
try {
final file = await tempFile.open(mode: FileMode.writeOnlyAppend);
await file.truncate(startByte);
await file.close();
} on FileSystemException {
log.fine('Could not truncate temp file');
taskException = TaskResumeException('Could not truncate temp file');
return false;
}
return true;
}
/// Delete the temporary file
void deleteTempFile(String tempFilePath) async {
try {
File(tempFilePath).deleteSync();
} on FileSystemException {
log.fine('Could not delete temp file $tempFilePath');
}
}
/// Extract content type from [headers] and set [mimeType] and [charSet]
void extractContentType(Map<String, String> headers) {
final contentType = headers['content-type'];
if (contentType != null) {
final regEx = RegExp(r'(.*);\s*charset\s*=(.*)');
final match = regEx.firstMatch(contentType);
if (match != null) {
mimeType = match.group(1);
charSet = match.group(2);
} else {
mimeType = contentType;
}
}
}
@@ -0,0 +1,502 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:isolate';
import 'dart:math';
import 'package:async/async.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'package:logging/logging.dart';
import '../exceptions.dart';
import '../models.dart';
import '../task.dart';
import 'data_isolate.dart';
import 'desktop_downloader.dart';
import 'download_isolate.dart';
import 'parallel_download_isolate.dart';
import 'upload_isolate.dart';
/// global variables, unique to this isolate
var bytesTotal = 0; // total bytes read in this download session
var startByte =
0; // starting position within the original range, used for resume
var lastProgressUpdateTime = DateTime.fromMillisecondsSinceEpoch(0);
var nextProgressUpdateTime = DateTime.fromMillisecondsSinceEpoch(0);
var lastProgressUpdate = 0.0;
var bytesTotalAtLastProgressUpdate = 0;
var networkSpeed = 0.0; // in MB/s
var isPaused = false;
var isCanceled = false;
// additional parameters for final TaskStatusUpdate
TaskException? taskException;
String? responseBody;
Map<String, String>? responseHeaders;
int? responseStatusCode;
String? mimeType; // derived from Content-Type header
String? charSet; // derived from Content-Type header
// logging from isolate is always 'FINEST', as it is sent to
// the [DesktopDownloader] for processing
final log = Logger('FileDownloader');
/// Do the task, sending messages back to the main isolate via [sendPort]
///
/// The first message sent back is a [ReceivePort] that is the command port
/// for the isolate. The first command must be the arguments: task and filePath.
Future<void> doTask((RootIsolateToken, SendPort) isolateArguments) async {
final (rootIsolateToken, sendPort) = isolateArguments;
BackgroundIsolateBinaryMessenger.ensureInitialized(rootIsolateToken);
final receivePort = ReceivePort();
// send the receive port back to the main Isolate
sendPort.send(receivePort.sendPort);
final messagesToIsolate = StreamQueue<dynamic>(receivePort);
// get the arguments list and parse each argument
final (
Task originalTask,
ResumeData? resumeData,
bool isResume,
Duration? requestTimeout,
Map<String, dynamic> proxy,
bool bypassTLSCertificateValidation,
List<MTLSConfig> mtlsConfigs,
String? tempFilePathConfig,
) = await messagesToIsolate.next;
DesktopDownloader.setHttpClient(
requestTimeout,
proxy,
bypassTLSCertificateValidation,
mtlsConfigs,
);
Logger.root.level = Level.ALL;
Logger.root.onRecord.listen((LogRecord rec) {
if (kDebugMode) {
sendPort.send(('log', (rec.message)));
}
});
// process native callbacks beforeTaskStart, onTaskStart and onAuth
final statusUpdate = await originalTask.options?.beforeTaskStartCallBack
?.call(originalTask);
if (statusUpdate != null) {
log.fine(
'TaskId ${originalTask.taskId} interrupted by beforeTaskStart callback',
);
// set global vars for final TaskStatusUpdate
taskException = statusUpdate.exception;
responseBody = statusUpdate.responseBody;
responseHeaders = statusUpdate.responseHeaders;
responseStatusCode = statusUpdate.responseStatusCode;
processStatusUpdateInIsolate(originalTask, statusUpdate.status, sendPort);
return;
}
final task = await getModifiedTask(
originalTask,
); // processes onStart and onAuth
// start listener/processor for incoming messages
unawaited(listenToIncomingMessages(task, messagesToIsolate, sendPort));
processStatusUpdateInIsolate(task, TaskStatus.running, sendPort);
if (!isResume) {
processProgressUpdateInIsolate(task, 0.0, sendPort);
}
if (task.retriesRemaining < 0) {
const message = 'Task has negative retries remaining';
logError(task, message);
taskException = TaskException(message);
processStatusUpdateInIsolate(task, TaskStatus.failed, sendPort);
} else {
// allow immediate cancel message to come through
await Future.delayed(const Duration(milliseconds: 0));
await switch (task) {
ParallelDownloadTask() => doParallelDownloadTask(
task,
resumeData,
isResume,
requestTimeout ?? const Duration(seconds: 60),
sendPort,
),
DownloadTask() => doDownloadTask(
task,
resumeData,
isResume,
requestTimeout ?? const Duration(seconds: 60),
tempFilePathConfig,
sendPort,
),
UploadTask() => doUploadTask(task, sendPort),
DataTask() => doDataTask(
task,
requestTimeout ?? const Duration(seconds: 60),
sendPort,
),
_ => throw UnimplementedError(),
};
}
DesktopDownloader.httpClient.close();
receivePort.close();
sendPort.send('done'); // signals end
Isolate.exit();
}
/// Listen async to messages to the isolate, and process these
///
/// Called as unawaited Future, which completes when the [messagesToIsolate]
/// stream is closed
Future<void> listenToIncomingMessages(
Task task,
StreamQueue messagesToIsolate,
SendPort sendPort,
) async {
while (await messagesToIsolate.hasNext) {
final message = await messagesToIsolate.next;
switch (message) {
case 'cancel':
isCanceled = true; // checked in loop elsewhere
if (task is ParallelDownloadTask) {
cancelParallelDownloadTask(task, sendPort);
}
case 'pause':
isPaused = true; // checked in loop elsewhere
if (task is ParallelDownloadTask) {
pauseParallelDownloadTask(task, sendPort);
}
// Status and progress updates are incoming from chunk tasks, part of a
// [ParallelDownloadTask]. We update the chunk status/progress and
// determine the aggregate status/progress for the parent task. If changed,
// we process that update for the parent task as we would for a regular
// [DownloadTask].
// Note that [task] refers to the parent task, whereas [update.task] refers
// to the chunk (child) task
case final TaskStatusUpdate update:
await chunkStatusUpdate(task, update, sendPort);
case final TaskProgressUpdate update:
chunkProgressUpdate(task, update, sendPort);
}
}
}
/// Transfer all bytes from [inStream] to [outStream], expecting [contentLength]
/// total bytes
///
/// Sends updates via the [sendPort] and can be commanded to cancel/pause via
/// the [messagesToIsolate] queue
///
/// Returns a [TaskStatus] and will throw any exception generated within
///
/// Note: does not flush or close any streams
Future<TaskStatus> transferBytes(
Stream<List<int>> inStream,
StreamSink<List<int>> outStream,
int contentLength,
Task task,
SendPort sendPort, [
Duration requestTimeout = const Duration(seconds: 60),
]) async {
if (contentLength == 0) {
contentLength = -1;
}
var resultStatus = TaskStatus.complete;
try {
await outStream.addStream(
inStream
.timeout(
requestTimeout,
onTimeout: (sink) {
taskException = TaskConnectionException('Connection timed out');
resultStatus = TaskStatus.failed;
sink.close(); // ends the stream
},
)
.map((bytes) {
if (isCanceled) {
resultStatus = TaskStatus.canceled;
throw StateError('Canceled');
}
if (isPaused) {
resultStatus = TaskStatus.paused;
throw StateError('Paused');
}
bytesTotal += bytes.length;
final progress = min(
(bytesTotal + startByte).toDouble() / (contentLength + startByte),
0.999,
);
final now = DateTime.now();
if (contentLength > 0 && shouldSendProgressUpdate(progress, now)) {
processProgressUpdateInIsolate(
task,
progress,
sendPort,
contentLength + startByte,
);
lastProgressUpdate = progress;
nextProgressUpdateTime = now.add(
const Duration(milliseconds: 500),
);
}
return bytes;
}),
);
} catch (e) {
if (resultStatus == TaskStatus.complete) {
// this was an unintentional error thrown within the stream processing
logError(task, e.toString());
setTaskError(e);
resultStatus = TaskStatus.failed;
}
}
return resultStatus;
}
/// Processes a change in status for the [task]
///
/// Sends status update via the [sendPort], if requested
/// If the task is finished, processes a final progressUpdate update
void processStatusUpdateInIsolate(
Task task,
TaskStatus status,
SendPort sendPort,
) {
final retryNeeded = status == TaskStatus.failed && task.retriesRemaining > 0;
// if task is in final state, process a final progressUpdate
// A 'failed' progress update is only provided if
// a retry is not needed: if it is needed, a `waitingToRetry` progress update
// will be generated in the FileDownloader
switch (status) {
case .complete:
processProgressUpdateInIsolate(task, progressComplete, sendPort);
case .failed when !retryNeeded:
processProgressUpdateInIsolate(task, progressFailed, sendPort);
case .canceled:
processProgressUpdateInIsolate(task, progressCanceled, sendPort);
case .notFound:
processProgressUpdateInIsolate(task, progressNotFound, sendPort);
case .paused:
processProgressUpdateInIsolate(task, progressPaused, sendPort);
default:
{}
}
final statusUpdate = TaskStatusUpdate(
task,
status,
status == TaskStatus.failed ? taskException ?? TaskException('None') : null,
status.isFinalState ? responseBody : null,
status.isFinalState ? responseHeaders : null,
status == TaskStatus.complete || status == TaskStatus.notFound
? responseStatusCode
: null,
status.isFinalState ? mimeType : null,
status.isFinalState ? charSet : null,
);
// Post update if task expects one, or if failed and retry is needed
if (task.providesStatusUpdates || retryNeeded) {
sendPort.send((
'statusUpdate',
statusUpdate.task,
statusUpdate.status,
statusUpdate.exception,
statusUpdate.responseBody,
statusUpdate.responseHeaders,
statusUpdate.responseStatusCode,
statusUpdate.mimeType,
statusUpdate.charSet,
));
}
if (status.isFinalState) {
task.options?.onTaskFinishedCallBack?.call(statusUpdate);
}
}
/// Processes a progress update for the [task]
///
/// Sends progress update via the [sendPort], if requested
void processProgressUpdateInIsolate(
Task task,
double progress,
SendPort sendPort, [
int expectedFileSize = -1,
]) {
if (task.providesProgressUpdates) {
if (progress > 0 && progress < 1) {
// calculate download speed and time remaining
final now = DateTime.now();
final timeSinceLastUpdate = now.difference(lastProgressUpdateTime);
lastProgressUpdateTime = now;
if (task is ParallelDownloadTask) {
// approximate based on aggregate progress
bytesTotal = (progress * expectedFileSize).floor();
}
final bytesSinceLastUpdate = bytesTotal - bytesTotalAtLastProgressUpdate;
bytesTotalAtLastProgressUpdate = bytesTotal;
final currentNetworkSpeed = timeSinceLastUpdate.inHours > 0
? -1.0
: bytesSinceLastUpdate / timeSinceLastUpdate.inMicroseconds;
networkSpeed = switch (currentNetworkSpeed) {
-1.0 => -1.0,
_ when networkSpeed == -1.0 => currentNetworkSpeed,
_ => (networkSpeed * 3 + currentNetworkSpeed) / 4.0,
};
final remainingBytes = (1 - progress) * expectedFileSize;
final timeRemaining = networkSpeed == -1.0 || expectedFileSize < 0
? const Duration(seconds: -1)
: Duration(microseconds: (remainingBytes / networkSpeed).round());
sendPort.send((
'progressUpdate',
task,
progress,
expectedFileSize,
networkSpeed,
timeRemaining,
));
} else {
// no download speed or time remaining
sendPort.send((
'progressUpdate',
task,
progress,
expectedFileSize,
-1.0,
const Duration(seconds: -1),
));
}
}
}
/// Returns a [task] that may be modified through callbacks
///
/// Callbacks would be attached to the task via its [Task.options] property, and if
/// present will be invoked by starting a taskDispatcher on a background isolate, then
/// sending the callback request via the MethodChannel
Future<Task> getModifiedTask(Task task) async {
Task? authTask;
final auth = task.options?.auth;
if (auth != null) {
// refresh token if needed
if (auth.isTokenExpired() && auth.onAuthCallback != null) {
authTask = await auth.onAuthCallback?.call(task);
}
authTask ??= task; // either original or newly authorized
final newAuth = authTask.options?.auth;
if (newAuth == null) {
// no auth object on authorized task
return authTask;
}
// insert query parameters and headers
final uri = newAuth.addOrUpdateQueryParams(
url: authTask.url,
queryParams: newAuth.getAccessQueryParams(),
);
final headers = {...authTask.headers, ...newAuth.getAccessHeaders()};
authTask = authTask.copyWith(url: uri.toString(), headers: headers);
}
authTask = authTask ??= task;
final modifiedTask = await authTask.options?.onTaskStartCallBack?.call(
authTask,
);
return modifiedTask ?? authTask;
}
// The following functions are related to multipart uploads and are
// by and large copied from the dart:http package. Similar implementations
// in Kotlin and Swift are translations of the same code
/// Returns the multipart entry for one field name/value pair
String fieldEntry(String name, String value) =>
'--$boundary$lineFeed${headerForField(name, value)}$value$lineFeed';
/// Returns the header string for a field.
///
/// The return value is guaranteed to contain only ASCII characters.
String headerForField(String name, String value) {
var header = 'content-disposition: form-data; name="${browserEncode(name)}"';
if (isJsonString(value)) {
header =
'$header\r\n'
'content-type: application/json; charset=utf-8';
} else if (!isPlainAscii(value)) {
header =
'$header\r\n'
'content-type: text/plain; charset=utf-8\r\n'
'content-transfer-encoding: binary';
}
return '$header\r\n\r\n';
}
/// A regular expression that matches strings that are composed entirely of
/// ASCII-compatible characters.
final _asciiOnly = RegExp(r'^[\x00-\x7F]+$');
/// A regular expression that matches strings that start and end with JSON object
/// or JSON array characters, and that therefore are presumed to be in JSON
/// format
final _jsonString = RegExp(r'^\s*(\{.*\}|\[.*\])\s*$');
final _newlineRegExp = RegExp(r'\r\n|\r|\n');
/// Returns whether [string] is composed entirely of ASCII-compatible
/// characters.
bool isPlainAscii(String string) => _asciiOnly.hasMatch(string);
/// Returns whether [string] is a JSON formatted string, based on simple
/// test of start and end characters {} or []
bool isJsonString(String string) => _jsonString.hasMatch(string);
/// Encode [value] in the same way browsers do.
String browserEncode(String value) =>
// http://tools.ietf.org/html/rfc2388 mandates some complex encodings for
// field names and file names, but in practice user agents seem not to
// follow this at all. Instead, they URL-encode `\r`, `\n`, and `\r\n` as
// `\r\n`; URL-encode `"`; and do nothing else (even for `%` or non-ASCII
// characters). We follow their behavior.
value.replaceAll(_newlineRegExp, '%0D%0A').replaceAll('"', '%22');
/// Returns the length of the [string] in bytes when utf-8 encoded
int lengthInBytes(String string) => utf8.encode(string).length;
/// Log an error for this task
void logError(Task task, String error) {
log.fine('Error for taskId ${task.taskId}: $error');
}
/// Set the [taskException] variable based on error e
void setTaskError(dynamic e) {
taskException = switch (e) {
HttpException() ||
TimeoutException() => TaskConnectionException(e.toString()),
IOException() => TaskFileSystemException(e.toString()),
TaskException() => e,
_ => TaskException(e.toString()),
};
}
/// Return the response's content as a String, or null if unable
Future<String?> responseContent(http.StreamedResponse response) {
try {
return response.stream.bytesToString();
} catch (e) {
log.fine(
'Could not read response content from httpResponseCode ${response.statusCode}: $e',
);
return Future.value(null);
}
}
/// Returns true if [currentProgress] > [lastProgressUpdate] + 2% and
/// [now] > [nextProgressUpdateTime], or if there was progress and
/// [now] > [nextProgressUpdateTime] + 2 seconds
bool shouldSendProgressUpdate(double currentProgress, DateTime now) =>
(currentProgress - lastProgressUpdate > 0.02 &&
now.isAfter(nextProgressUpdateTime)) ||
(currentProgress > lastProgressUpdate &&
now.isAfter(nextProgressUpdateTime.add(const Duration(seconds: 2))));
@@ -0,0 +1,454 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:isolate';
import 'dart:math';
import 'package:collection/collection.dart';
import '../chunk.dart';
import '../exceptions.dart';
import '../models.dart';
import '../task.dart';
import '../utils.dart';
import 'desktop_downloader.dart';
import 'download_isolate.dart';
import 'isolate.dart';
/// A [ParallelDownloadTask] pings the server to get the content-length of the
/// download, then creates a list of [Chunk]s, each representing a portion
/// of the download. Each chunk-task has its group set to 'chunk' and
/// has the taskId of the parent [ParallelDownloadTask] in its
/// [Task.metaData] field.
/// The isolate sends 'enqueue' messages back to the [DesktopDownloader] to
/// start each chunk-task, just like any other download task.
/// Messages with group 'chunk' are intercepted in the [DesktopDownloader],
/// where the sendPort for the isolate running the parent task is
/// looked up, and the update is sent to the isolate via that sendPort.
/// In the isolate, the update is processed and the new status/progress
/// of the [ParallelDownloadTask] is determined. If the status/progress has
/// changed, an update is sent and the status is processed (e.g., a complete
/// status triggers the piecing together of the downloaded file from
/// its chunk pieces).
///
/// Similarly, pause and cancel commands are sent to all chunk tasks before
/// updating the status of the parent [ParallelDownloadTask]
late ParallelDownloadTask parentTask;
var chunks = <Chunk>[]; // chunks associated with this download
var lastTaskStatus = TaskStatus.running;
Completer<TaskStatusUpdate> parallelTaskStatusUpdateCompleter = Completer();
var parallelDownloadContentLength = -1;
/// Execute the parallel download task
///
/// Sends updates via the [sendPort] and can be commanded to cancel/pause via
/// the [messagesToIsolate] queue.
///
/// If [isResume] is false, we create [Chunk]s and enqueue each of its tasks
/// by sending a message to the [DesktopDownloader], then wait for the
/// completion of [parallelTaskStatusUpdateCompleter]
///
/// If [isResume] is true, [resumeData] contains the json encoded [chunks] list,
/// and the associated chunk [DownloadTask] tasks will be started by the
/// [DesktopDownloader], so we just wait for completion of
/// [parallelTaskStatusUpdateCompleter]
///
/// Incoming messages from the [DesktopDownloader] are received in the
/// [listenToIncomingMessages] function
Future<void> doParallelDownloadTask(
ParallelDownloadTask task,
ResumeData? resumeData,
bool isResume,
Duration requestTimeout,
SendPort sendPort,
) async {
parentTask = task;
if (!isResume) {
// start the download by creating [Chunk]s and enqueuing chunk tasks
final response = await DesktopDownloader.httpClientForUrl(task.url)
.head(Uri.parse(task.url), headers: task.headers)
.timeout(requestTimeout);
responseHeaders = response.headers;
responseStatusCode = response.statusCode;
if ([200, 201, 202, 203, 204, 205, 206].contains(response.statusCode)) {
// get suggested filename if needed, and change task and parentTask
if (!task.hasFilename) {
task = (await taskWithSuggestedFilename(
task,
response.headers,
true,
)) as ParallelDownloadTask;
parentTask = task;
log.finest(
'Suggested filename for taskId ${task.taskId}: ${task.filename}',
);
}
extractContentType(response.headers);
chunks = createChunks(task, response.headers);
for (final chunk in chunks) {
// Ask main isolate to enqueue the child task. Updates related to the child
// will be sent to this isolate (the child's metaData contains the parent taskId).
sendPort.send(('enqueueChild', chunk.task));
}
// wait for all chunk tasks to complete
final statusUpdate = await parallelTaskStatusUpdateCompleter.future;
processStatusUpdateInIsolate(task, statusUpdate.status, sendPort);
} else {
log.fine(
'TaskId ${task.taskId}: Invalid server response code ${response.statusCode}',
);
// not an OK response
responseBody = response.body;
if (response.statusCode == 404) {
processStatusUpdateInIsolate(task, TaskStatus.notFound, sendPort);
} else {
taskException = TaskHttpException(
responseBody?.isNotEmpty == true
? responseBody!
: response.reasonPhrase ?? 'Invalid HTTP Request',
response.statusCode,
);
processStatusUpdateInIsolate(task, TaskStatus.failed, sendPort);
}
}
} else {
// resume: reconstruct [chunks] and wait for all chunk tasks to complete
chunks = List.from(
jsonDecode(resumeData!.data, reviver: Chunk.listReviver),
);
parallelDownloadContentLength = chunks.fold(
0,
(previousValue, chunk) =>
previousValue + chunk.toByte - chunk.fromByte + 1,
);
final statusUpdate = await parallelTaskStatusUpdateCompleter.future;
processStatusUpdateInIsolate(task, statusUpdate.status, sendPort);
}
}
/// Process incoming [update] for a chunk, within the [ParallelDownloadTask]
/// represented by [task]
Future<void> chunkStatusUpdate(
Task task,
TaskStatusUpdate update,
SendPort sendPort,
) async {
final chunkTask = update.task;
// first check for fail -> retry
if (update.status == TaskStatus.failed && chunkTask.retriesRemaining > 0) {
chunkTask.decreaseRetriesRemaining();
final waitTime = Duration(
seconds: 2 << min(chunkTask.retries - chunkTask.retriesRemaining - 1, 8),
);
log.finer(
'Chunk task with taskId ${chunkTask.taskId} failed, waiting ${waitTime.inSeconds}'
' seconds before retrying. ${chunkTask.retriesRemaining}'
' retries remaining',
);
Future.delayed(waitTime, () async {
// after delay, resume or enqueue task again if it's still waiting
sendPort.send(('enqueueChild', chunkTask));
});
} else {
// no retry
final newStatusUpdate = updateChunkStatus(update);
switch (newStatusUpdate) {
case TaskStatus.complete:
final result = await stitchChunks();
parallelTaskStatusUpdateCompleter.complete(
TaskStatusUpdate(
task,
result,
null,
responseBody,
responseHeaders,
responseStatusCode,
),
);
case TaskStatus.failed:
taskException = update.exception;
responseBody = update.responseBody;
cancelAllChunkTasks(sendPort);
parallelTaskStatusUpdateCompleter.complete(
TaskStatusUpdate(
task,
TaskStatus.failed,
taskException,
responseBody,
responseHeaders,
responseStatusCode,
),
);
case TaskStatus.notFound:
responseBody = update.responseBody;
cancelAllChunkTasks(sendPort);
parallelTaskStatusUpdateCompleter.complete(
TaskStatusUpdate(
task,
TaskStatus.notFound,
null,
responseBody,
responseHeaders,
responseStatusCode,
),
);
default:
// ignore all other status updates, including null
break;
}
}
}
/// Update the status for this chunk, and return the status for the parent task
/// as derived from the sum of the child tasks, or null if undefined
///
/// The updates are received from the [DeskTopDownloader], which intercepts
/// status updates for the [chunkGroup]. Not all regular statuses are passed on
TaskStatus? updateChunkStatus(TaskStatusUpdate update) {
final chunk = chunks.firstWhereOrNull((chunk) => chunk.task == update.task);
if (chunk == null) {
return null; // chunk is not part of this parent task
}
chunk.status = update.status;
final newStatusUpdate = parentTaskStatus();
if ((newStatusUpdate != null && newStatusUpdate != lastTaskStatus)) {
lastTaskStatus = newStatusUpdate;
return newStatusUpdate;
}
return null;
}
/// Returns the [TaskStatus] for the parent of this chunk, as derived from
/// the 'sum' of the child tasks, or null if undetermined
///
/// The updates are received from the [DeskTopDownloader], which intercepts
/// status updates for the [chunkGroup]
TaskStatus? parentTaskStatus() {
final failed = chunks.firstWhereOrNull(
(chunk) => chunk.status == TaskStatus.failed,
);
if (failed != null) {
return TaskStatus.failed;
}
final notFound = chunks.firstWhereOrNull(
(chunk) => chunk.status == TaskStatus.notFound,
);
if (notFound != null) {
return TaskStatus.notFound;
}
final allComplete = chunks.every(
(chunk) => chunk.status == TaskStatus.complete,
);
if (allComplete) {
return TaskStatus.complete;
}
return null;
}
/// Process incoming [update] for a chunk, within the [ParallelDownloadTask]
/// represented by [task]
void chunkProgressUpdate(
Task task,
TaskProgressUpdate update,
SendPort sendPort,
) {
// update of child task of a [ParallelDownloadTask], only for regular
// progress updates
final now = DateTime.now();
if (update.progress > 0 && update.progress < 1) {
final parentProgressUpdate = updateChunkProgress(update);
if (parentProgressUpdate != null &&
shouldSendProgressUpdate(parentProgressUpdate, now)) {
processProgressUpdateInIsolate(
task,
parentProgressUpdate,
sendPort,
parallelDownloadContentLength,
);
lastProgressUpdate = parentProgressUpdate;
nextProgressUpdateTime = now.add(const Duration(milliseconds: 500));
}
}
}
/// Update the progress for this chunk, and return the progress for the parent
/// task as derived from the sum of the child tasks, or null if undefined
///
/// The updates are received from the [DeskTopDownloader], which intercepts
/// progress updates for the [chunkGroup].
/// Only true progress updates (in range 0-1) are passed on to this method
double? updateChunkProgress(TaskProgressUpdate update) {
final chunk = chunks.firstWhereOrNull((chunk) => chunk.task == update.task);
if (chunk == null) {
return null; // chunk is not part of this parent task
}
chunk.progress = update.progress;
return parentTaskProgress();
}
/// Returns the progress for the parent of this chunk, as derived
/// from the 'sum' of the child tasks
///
/// The updates are received from the [DeskTopDownloader], which intercepts
/// progress updates for the [chunkGroup].
/// Only true progress updates (in range 0-1) are passed on to this method,
/// so we just calculate the average progress
double parentTaskProgress() {
final avgProgress =
chunks.fold(
0.0,
(previousValue, chunk) => previousValue + chunk.progress,
) /
chunks.length;
return avgProgress;
}
/// Cancel this [ParallelDownloadTask]
void cancelParallelDownloadTask(ParallelDownloadTask task, SendPort sendPort) {
cancelAllChunkTasks(sendPort);
parallelTaskStatusUpdateCompleter.complete(
TaskStatusUpdate(task, TaskStatus.canceled),
);
}
/// Cancel the tasks associated with each chunk
///
/// Accomplished by sending list of taskIds to cancel to the
/// [DesktopDownloader]
void cancelAllChunkTasks(SendPort sendPort) {
sendPort.send((
'cancelTasksWithId',
chunks.map((e) => e.task.taskId).toList(),
));
}
/// Pause this [ParallelDownloadTask]
///
/// Because each [Chunk] is a [DownloadTask], each is paused
/// and generates its own [ResumeData].
/// [ResumeData] for a [ParallelDownloadTask] is the list of [Chunk]s,
/// including their status and progress, json encoded.
/// To resume, we need to recreate the list of [Chunk]s and pass this with
/// the resumed [ParallelDownloadTask], then resume each of the
/// [DownloadTask]s. This is done in [DesktopDownloader.resume]
void pauseParallelDownloadTask(ParallelDownloadTask task, SendPort sendPort) {
pauseAllChunkTasks(sendPort);
sendPort.send(('resumeData', jsonEncode(chunks), -1, null));
parallelTaskStatusUpdateCompleter.complete(
TaskStatusUpdate(task, TaskStatus.paused),
);
}
/// Pause the tasks associated with each chunk
///
/// Accomplished by sending list of tasks to pause to the
/// [DesktopDownloader]
void pauseAllChunkTasks(SendPort sendPort) {
sendPort.send(('pauseTasks', chunks.map((e) => e.task).toList()));
}
/// Stitch all chunks together into one file, per the [parentTask]
Future<TaskStatus> stitchChunks() async {
IOSink? outStream;
StreamSubscription? subscription;
try {
final outFile = File(await parentTask.filePath());
if (await outFile.exists()) {
await outFile.delete();
}
outStream = outFile.openWrite();
for (final chunk in chunks.sorted((a, b) => a.fromByte - b.fromByte)) {
final inFile = File(await chunk.task.filePath());
if (!await inFile.exists()) {
throw const FileSystemException('Missing chunk file');
}
final inStream = inFile.openRead();
final doneCompleter = Completer<bool>();
subscription = inStream.listen(
(bytes) {
outStream?.add(bytes);
},
onDone: () => doneCompleter.complete(true),
onError: (error) {
logError(parentTask, e.toString());
setTaskError(error);
doneCompleter.complete(false);
},
);
final success = await doneCompleter.future;
if (!success) {
return TaskStatus.failed;
}
subscription.cancel();
await inFile.delete();
}
await outStream.flush();
} catch (e) {
logError(parentTask, e.toString());
setTaskError(e);
return TaskStatus.failed;
} finally {
await outStream?.close();
subscription?.cancel();
for (final chunk in chunks) {
try {
final file = File(await chunk.task.filePath());
await file.delete();
} on FileSystemException {
// ignore
}
}
}
return TaskStatus.complete;
}
/// Returns a list of chunk information for this task, and sets
/// [parallelDownloadContentLength] to the total length of the download
///
/// Throws a StateError if any information is missing, which should lead
/// to a failure of the [ParallelDownloadTask]
List<Chunk> createChunks(
ParallelDownloadTask task,
Map<String, String> headers,
) {
try {
final numChunks = task.urls.length * task.chunks;
final contentLength = getContentLength(headers, task);
if (contentLength <= 0) {
throw StateError(
'Server does not provide content length - cannot chunk download. '
'If you know the length, set Range or Known-Content-Length header',
);
}
parallelDownloadContentLength = contentLength;
try {
headers.entries.firstWhere(
(element) =>
element.key.toLowerCase() == 'accept-ranges' &&
element.value == 'bytes',
);
} on StateError {
throw StateError('Server does not accept ranges - cannot chunk download');
}
final chunkSize = (contentLength / numChunks).ceil();
return [
for (var i = 0; i < numChunks; i++)
Chunk(
parentTask: task,
url: task.urls[i % task.urls.length],
filename: Random().nextInt(1 << 32).toString(),
fromByte: i * chunkSize,
toByte: min(i * chunkSize + chunkSize - 1, contentLength - 1),
),
];
} on StateError {
throw StateError(
'Server does not provide content length - cannot chunk download. '
'If you know the length, set Range or Known-Content-Length header',
);
}
}
@@ -0,0 +1,313 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:isolate';
import 'package:http/http.dart' as http;
import 'package:mime/mime.dart';
import 'package:path/path.dart' as p;
import '../exceptions.dart';
import '../models.dart';
import '../task.dart';
import 'desktop_downloader.dart';
import 'isolate.dart';
const boundary = '-----background_downloader-akjhfw281onqciyhnIk';
const lineFeed = '\r\n';
/// Do the binary or multi-part upload task
///
/// Sends updates via the [sendPort] and can be commanded to cancel via
/// the [messagesToIsolate] queue
Future<void> doUploadTask(UploadTask task, SendPort sendPort) async {
final (updatedTask, resultStatus) = task.post == 'binary'
? await binaryUpload(task, sendPort)
: await multipartUpload(task, sendPort);
processStatusUpdateInIsolate(updatedTask, resultStatus, sendPort);
}
/// Do the binary upload and return the TaskStatus
///
/// Content-Disposition header will be:
/// - set to 'attachment = "filename"' if the task.headers field does not contain
/// an entry for 'Content-Disposition'
/// - not set at all (i.e. omitted) if the task.headers field contains an entry
/// for 'Content-Disposition' with the value '' (an empty string)
/// - set to the value of task.headers['Content-Disposition'] in all other cases
///
/// The mime-type will be set to [Task.mimeType]
///
/// Sends updates via the [sendPort] and can be commanded to cancel via
/// the [messagesToIsolate] queue
Future<(Task, TaskStatus)> binaryUpload(
UploadTask task,
SendPort sendPort,
) async {
final filePath = await task.filePath();
final inFile = File(filePath);
if (!inFile.existsSync()) {
final message = 'File to upload does not exist: $filePath';
logError(task, message);
taskException = TaskFileSystemException(message);
return (task, TaskStatus.failed);
}
final fileSize = inFile.lengthSync();
if (fileSize == 0) {
final message = 'File $filePath has 0 length';
logError(task, message);
taskException = TaskFileSystemException(message);
return (task, TaskStatus.failed);
}
var resultStatus = TaskStatus.failed;
try {
// Extract Range header information, if present, for partial upload
int start = 0;
int end = fileSize - 1; // Default to the whole file
if (task.headers.containsKey('Range')) {
final rangeHeader = task.headers['Range']!;
final match = RegExp(r'bytes=(\d+)-(\d*)').firstMatch(rangeHeader);
if (match != null) {
start = int.parse(match.group(1)!);
if (match.group(2)!.isNotEmpty) {
end = int.parse(match.group(2)!);
}
} else {
final message = 'Invalid Range header $rangeHeader';
logError(task, message);
taskException = TaskException(message);
return (task, TaskStatus.failed);
}
task.headers.remove('Range'); // not passed on to server
}
if (task case UriUploadTask(fileUri: final fileUri)
when fileUri != null && task.filename.isEmpty) {
// for UriTasks without a filename, derive it from the Uri
task = task.copyWith(filename: fileUri.pathSegments.last);
}
final contentLength = end - start + 1;
final client = DesktopDownloader.httpClientForUrl(task.url);
final request = http.StreamedRequest(
task.httpRequestMethod,
Uri.parse(task.url),
);
request.maxRedirects = defaultMaxRedirects;
request.headers.addAll(task.headers);
request.contentLength = contentLength;
request.headers['Content-Type'] = task.mimeType;
final taskContentDisposition =
task.headers['Content-Disposition'] ??
task.headers['content-disposition'];
if (taskContentDisposition != '') {
request.headers['Content-Disposition'] =
taskContentDisposition ??
'attachment; filename="${Uri.encodeComponent(task.filename)}"';
} else {
request.headers.remove('Content-Disposition');
}
request.persistentConnection = false;
// initiate the request and handle completion async
final requestCompleter = Completer();
var transferBytesResult = TaskStatus.failed;
client.send(request).then((response) async {
// request completed, so send status update and finish
resultStatus =
transferBytesResult == TaskStatus.complete &&
!okResponses.contains(response.statusCode)
? TaskStatus.failed
: transferBytesResult;
responseBody = await responseContent(response);
responseHeaders = response.headers;
responseStatusCode = response.statusCode;
taskException ??= TaskHttpException(
responseBody?.isNotEmpty == true
? responseBody!
: response.reasonPhrase ?? 'Invalid HTTP response',
response.statusCode,
);
if (response.statusCode == 404) {
resultStatus = TaskStatus.notFound;
}
requestCompleter.complete();
});
// send the bytes to the request sink
final inStream = inFile.openRead(start, end + 1);
transferBytesResult = await transferBytes(
inStream,
request.sink,
fileSize,
task,
sendPort,
);
request.sink.close(); // triggers request completion, handled above
if (isCanceled) {
// cancellation overrides other results
resultStatus = TaskStatus.canceled;
} else {
await requestCompleter.future; // wait for request to complete
}
} catch (e) {
resultStatus = TaskStatus.failed;
setTaskError(e);
}
return (task, resultStatus);
}
/// Do the multipart upload and return the TaskStatus
///
/// Sends updates via the [sendPort] and can be commanded to cancel via
/// the [messagesToIsolate] queue
Future<(Task, TaskStatus)> multipartUpload(
UploadTask task,
SendPort sendPort,
) async {
// field portion of the multipart, all in one string
// multiple values should be encoded as '"value1", "value2", ...'
final multiValueRegEx = RegExp(r'^(?:"[^"]+"\s*,\s*)+"[^"]+"$');
var fieldsString = '';
for (final entry in task.fields.entries) {
if (multiValueRegEx.hasMatch(entry.value)) {
// extract multiple values from entry.value
for (final match in RegExp(r'"([^"]+)"').allMatches(entry.value)) {
fieldsString += fieldEntry(entry.key, match.group(1) ?? 'error');
}
} else {
fieldsString += fieldEntry(
entry.key,
entry.value,
); // single value for key
}
}
// File portion of the multi-part
// Assumes list of files. If only one file, that becomes a list of length one.
// For each file, determine contentDispositionString, contentTypeString
// and file length, so that we can calculate total size of upload
const separator = '$lineFeed--$boundary$lineFeed'; // between files
const terminator = '$lineFeed--$boundary--$lineFeed'; // after last file
final filesData = (task is MultiUploadTask)
? await task
.extractFilesData() // MultiUpload case
: [
(task.fileField, await task.filePath(), task.mimeType),
]; // one file Upload case
final contentDispositionStrings = <String>[];
final contentTypeStrings = <String>[];
final fileLengths = <int>[];
for (final (fileField, path, mimeType) in filesData) {
final file = File(path);
if (!await file.exists()) {
logError(task, 'File to upload does not exist: $path');
taskException = TaskFileSystemException(
'File to upload does not exist: $path',
);
return (task, TaskStatus.failed);
}
final resolvedMimeType = mimeType.isEmpty ? lookupMimeType(path) : mimeType;
final derivedFilename = p.basename(file.path);
if (filesData.length == 1) {
// only for single file uploads do we set the task's filename property
if (task case UriUploadTask(fileUri: final fileUri)
when fileUri != null) {
task = task.copyWith(filename: fileUri.pathSegments.last);
} else {
task = task.copyWith(filename: derivedFilename);
}
}
contentDispositionStrings.add(
'Content-Disposition: form-data; name="${browserEncode(fileField)}"; '
'filename="${browserEncode(derivedFilename)}"$lineFeed',
);
contentTypeStrings.add('Content-Type: $resolvedMimeType$lineFeed$lineFeed');
fileLengths.add(file.lengthSync());
}
final fileDataLength =
contentDispositionStrings.fold<int>(
0,
(sum, string) => sum + lengthInBytes(string),
) +
contentTypeStrings.fold<int>(0, (sum, string) => sum + string.length) +
fileLengths.fold<int>(0, (sum, length) => sum + length) +
separator.length * contentDispositionStrings.length +
2;
final contentLength =
lengthInBytes(fieldsString) +
'--$boundary$lineFeed'.length +
fileDataLength;
var resultStatus = TaskStatus.failed;
try {
// setup the connection
final client = DesktopDownloader.httpClientForUrl(task.url);
final request = http.StreamedRequest(
task.httpRequestMethod,
Uri.parse(task.url),
);
request.maxRedirects = defaultMaxRedirects;
request.contentLength = contentLength;
request.headers.addAll(task.headers);
request.headers.addAll({
'Content-Type': 'multipart/form-data; boundary=$boundary',
'Accept-Charset': 'UTF-8',
'Connection': 'Keep-Alive',
'Cache-Control': 'no-cache',
});
request.persistentConnection = false;
// initiate the request and handle completion async
final requestCompleter = Completer();
var transferBytesResult = TaskStatus.failed;
client.send(request).then((response) async {
// request completed, so send status update and finish
resultStatus =
transferBytesResult == TaskStatus.complete &&
!okResponses.contains(response.statusCode)
? TaskStatus.failed
: transferBytesResult;
responseBody = await responseContent(response);
responseHeaders = response.headers;
responseStatusCode = response.statusCode;
taskException ??= TaskHttpException(
responseBody?.isNotEmpty == true
? responseBody!
: response.reasonPhrase ?? 'Invalid HTTP response',
response.statusCode,
);
if (response.statusCode == 404) {
resultStatus = TaskStatus.notFound;
}
requestCompleter.complete();
});
// write fields
request.sink.add(utf8.encode('$fieldsString--$boundary$lineFeed'));
// write each file
for (final (index, fileData) in filesData.indexed) {
request.sink.add(utf8.encode(contentDispositionStrings[index]));
request.sink.add(utf8.encode(contentTypeStrings[index]));
// send the bytes to the request sink
final inStream = File(fileData.$2).openRead();
transferBytesResult = await transferBytes(
inStream,
request.sink,
contentLength,
task,
sendPort,
);
if (transferBytesResult != TaskStatus.complete || isCanceled) {
break;
} else {
request.sink.add(
utf8.encode(fileData == filesData.last ? terminator : separator),
);
}
}
request.sink.close(); // triggers request completion, handled above
await requestCompleter.future; // wait for request to complete
} catch (e) {
resultStatus = TaskStatus.failed;
setTaskError(e);
}
if (isCanceled) {
// cancellation overrides other results
resultStatus = TaskStatus.canceled;
}
return (task, resultStatus);
}
@@ -0,0 +1,105 @@
import 'dart:convert';
const _exceptions = {
'TaskException': TaskException.new,
'TaskFileSystemException': TaskFileSystemException.new,
'TaskUrlException': TaskUrlException.new,
'TaskConnectionException': TaskConnectionException.new,
'TaskResumeException': TaskResumeException.new,
'TaskHttpException': TaskHttpException.new,
};
/// Contains Exception information associated with a failed [Task]
///
/// The [exceptionType] categorizes and describes the exception
/// The [description] is typically taken from the platform-generated
/// exception message, or from the plugin. The localization is undefined
/// For the [TaskHttpException], the [httpResponseCode] is only valid if >0
/// and may offer details about the nature of the error
base class TaskException(final String description) implements Exception {
String get exceptionType => 'TaskException';
/// Create object from [json]
factory TaskException.fromJson(Map<String, dynamic> json) {
final typeString = json['type'] as String? ?? 'TaskException';
final exceptionType = _exceptions[typeString];
final description = json['description'] as String? ?? '';
if (exceptionType != null) {
final httpResponseCode =
(json['httpResponseCode'] as num?)?.toInt() ?? -1;
return typeString != 'TaskHttpException'
? exceptionType(description)
: exceptionType(description, httpResponseCode);
}
return TaskException('Unknown');
}
/// Create object from String description of the type, and parameters
factory TaskException.fromTypeString(
String typeString,
String description, [
int httpResponseCode = -1,
]) {
final exceptionType = _exceptions[typeString] ?? TaskException.new;
return typeString != 'TaskHttpException'
? exceptionType(description)
: exceptionType(description, httpResponseCode);
}
/// Return JSON Map representing object
Map<String, dynamic> toJson() => {
'type': exceptionType,
'description': description,
};
/// Return JSON String representing object
String toJsonString() => jsonEncode(toJson());
@override
String toString() => '$exceptionType: $description';
}
/// Exception related to the filesystem, e.g. insufficient space
/// or file not found
final class TaskFileSystemException(super.description) extends TaskException {
@override
String get exceptionType => 'TaskFileSystemException';
}
/// Exception related to the url, eg malformed
final class TaskUrlException(super.description) extends TaskException {
@override
String get exceptionType => 'TaskUrlException';
}
/// Exception related to the connection, e.g. socket exception
/// or request timeout
final class TaskConnectionException(super.description) extends TaskException {
@override
String get exceptionType => 'TaskConnectionException';
}
/// Exception related to an attempt to resume a task, e.g.
/// the temp filename no longer exists, or eTag has changed
final class TaskResumeException(super.description) extends TaskException {
@override
String get exceptionType => 'TaskResumeException';
}
/// Exception related to the HTTP response, e.g. a 403
/// response code
final class TaskHttpException(super.description, final int httpResponseCode)
extends TaskException {
@override
String get exceptionType => 'TaskHttpException';
@override
Map<String, dynamic> toJson() => {
...super.toJson(),
'httpResponseCode': httpResponseCode,
};
@override
String toString() =>
'$exceptionType, response code $httpResponseCode: $description';
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,243 @@
import 'dart:async';
import 'dart:convert';
import 'dart:isolate';
import 'base_downloader.dart';
import 'models.dart';
import 'task.dart';
/// Commands to be executed by the background isolate
sealed class JsonCommand {
final int id;
const JsonCommand(this.id);
}
class _TaskFromJson extends JsonCommand {
final String jsonString;
const _TaskFromJson(super.id, this.jsonString);
}
class _DownloadTaskListFromJson extends JsonCommand {
final String jsonString;
const _DownloadTaskListFromJson(super.id, this.jsonString);
}
class _TaskListFromListStrings extends JsonCommand {
final List<dynamic> jsonStrings;
const _TaskListFromListStrings(super.id, this.jsonStrings);
}
class _TaskAndNotificationConfigJsonStrings extends JsonCommand {
final List<Task> tasks;
final Set<TaskNotificationConfig> notificationConfigs;
const _TaskAndNotificationConfigJsonStrings(
super.id,
this.tasks,
this.notificationConfigs,
);
}
/// Singleton object that manages a background isolate for JSON encoding/decoding
class JsonProcessor {
static final JsonProcessor _instance = JsonProcessor._internal();
factory JsonProcessor() => _instance;
JsonProcessor._internal();
Isolate? _isolate;
SendPort? _sendPort;
final Map<int, Completer<dynamic>> _pendingCompleters = {};
Timer? _shutdownTimer;
int _nextId = 0;
bool _isStarting = false;
Completer<void> _startCompleter = Completer<void>();
/// Public API
Future<Task> decodeTask(String jsonString) async =>
await _process<Task>((id) => _TaskFromJson(id, jsonString));
Future<List<DownloadTask>> decodeDownloadTaskList(String jsonString) async =>
await _process<List<DownloadTask>>(
(id) => _DownloadTaskListFromJson(id, jsonString),
);
Future<List<Task>> decodeTaskList(List<dynamic> jsonStrings) async =>
await _process<List<Task>>(
(id) => _TaskListFromListStrings(id, jsonStrings),
);
Future<(String, String)> encodeTaskAndNotificationConfig(
Iterable<Task> tasks,
Set<TaskNotificationConfig> notificationConfigs,
) async {
// Convert Iterable to List to ensure it's sendable and fixed
final taskList = tasks.toList();
return await _process<(String, String)>(
(id) => _TaskAndNotificationConfigJsonStrings(
id,
taskList,
notificationConfigs,
),
);
}
/// Internal processing logic
Future<T> _process<T>(JsonCommand Function(int id) commandBuilder) async {
await _ensureStarted();
_resetShutdownTimer();
final id = _nextId++;
final completer = Completer<T>();
_pendingCompleters[id] = completer;
final command = commandBuilder(id);
_sendPort!.send(command);
try {
final result = await completer.future;
return result;
} catch (e) {
rethrow;
} finally {
_resetShutdownTimer(); // Reset timer on completion too
}
}
Future<void> _ensureStarted() async {
if (_isolate != null && _sendPort != null) return;
if (_isStarting) {
await _startCompleter.future;
return;
}
_isStarting = true;
_startCompleter = Completer<void>();
try {
final receivePort = ReceivePort();
_isolate = await Isolate.spawn(_isolateMain, receivePort.sendPort);
// Wait for the isolate to send its SendPort
_sendPort = await receivePort.first as SendPort;
// Create a new receive port for responses
final responsePort = ReceivePort();
_sendPort!.send(responsePort.sendPort);
responsePort.listen(_handleResponse);
if (!_startCompleter.isCompleted) {
_startCompleter.complete();
}
} catch (e) {
_isolate?.kill();
_isolate = null;
_sendPort = null;
_isStarting = false;
if (!_startCompleter.isCompleted) {
_startCompleter.completeError(e);
}
rethrow;
} finally {
_isStarting = false;
}
}
void _handleResponse(dynamic message) {
if (message is _JsonResponse) {
final completer = _pendingCompleters.remove(message.id);
if (completer != null) {
if (message.error != null) {
completer.completeError(message.error!);
} else {
completer.complete(message.result);
}
}
}
}
void _resetShutdownTimer() {
_shutdownTimer?.cancel();
_shutdownTimer = Timer(const Duration(minutes: 1), _shutdown);
}
void _shutdown() {
if (_pendingCompleters.isNotEmpty) {
// Defer shutdown while requests are still in flight
_resetShutdownTimer();
return;
}
_isolate?.kill();
_isolate = null;
_sendPort = null;
_shutdownTimer?.cancel();
_shutdownTimer = null;
}
}
class _JsonResponse {
final int id;
final dynamic result;
final Object? error;
_JsonResponse(this.id, this.result, this.error);
}
void _isolateMain(SendPort mainSendPort) {
final receivePort = ReceivePort();
mainSendPort.send(receivePort.sendPort);
SendPort? replyPort;
receivePort.listen((message) async {
if (message is SendPort) {
replyPort = message;
} else if (message is JsonCommand) {
if (replyPort == null) {
return; // Should not happen if protocol is followed
}
try {
final result = await _executeCommand(message);
replyPort!.send(_JsonResponse(message.id, result, null));
} catch (e) {
replyPort!.send(_JsonResponse(message.id, null, e));
}
}
});
}
Future<dynamic> _executeCommand(JsonCommand command) async {
switch (command) {
case final _TaskFromJson c:
return Task.createFromJson(jsonDecode(c.jsonString));
case final _DownloadTaskListFromJson c:
return (jsonDecode(c.jsonString) as List)
.map((e) => Task.createFromJson(e as Map<String, dynamic>))
.cast<DownloadTask>()
.toList();
case final _TaskListFromListStrings c:
return c.jsonStrings
.map((e) => Task.createFromJson(jsonDecode(e as String)))
.toList();
case final _TaskAndNotificationConfigJsonStrings c:
final tasksJsonString = jsonEncode(c.tasks);
final configs = c.tasks
.map(
(task) => BaseDownloader.notificationConfigForTaskUsingConfigSet(
task,
c.notificationConfigs,
),
)
.toList();
final notificationConfigsJsonString = jsonEncode(configs);
return (tasksJsonString, notificationConfigsJsonString);
}
}
@@ -0,0 +1,100 @@
part of 'localstore.dart';
/// A [CollectionRef] object can be used for adding documents, getting
/// [DocumentRef]s, and querying for documents.
final class CollectionRef implements CollectionRefImpl {
String _id;
/// A string representing the path of the referenced document (relative to the
/// root of the database).
String get path => _path;
String _path = '';
DocumentRef? _delegate;
CollectionRef? _parent;
List<List>? _conditions;
static final pathSeparatorRegEx = RegExp(r'[/\\]');
/// The parent [CollectionRef] of this document.
CollectionRef? get parent => _parent;
CollectionRef._(this._id, [this._parent, this._delegate, this._conditions]) {
_path = _buildPath(_parent?.path, _id, _delegate?.id);
}
static final _cache = <String, CollectionRef>{};
/// Returns an instance using the default [CollectionRef].
factory CollectionRef(
String id, [
CollectionRef? parent,
DocumentRef? delegate,
List<List>? conditions,
]) {
final key = _buildPath(parent?.path, id, delegate?.id);
final collectionRef = _cache.putIfAbsent(
key,
() => CollectionRef._(id, parent, delegate, conditions),
);
collectionRef._conditions = conditions;
return collectionRef;
}
static String _buildPath(String? parentPath, String path, String? docId) {
final docPath = ((docId != null && parentPath != null)
? '$docId.collection'
: '');
final pathSep = p.separator;
return '${parentPath ?? ''}$docPath$pathSep$path$pathSep';
}
final _utils = Utils.instance;
@override
Stream<Map<String, dynamic>> get stream => _utils.stream(path, _conditions);
@override
Future<Map<String, dynamic>?> get() async =>
await _utils.get(path, true, _conditions);
@override
DocumentRef doc([String? id]) {
id ??= int.parse(
'${Random().nextInt(1000000000)}${Random().nextInt(1000000000)}',
).toRadixString(35).substring(0, 9);
return DocumentRef(id, this);
}
@override
CollectionRef where(field, {isEqualTo}) {
final conditions = <List>[];
void addCondition(dynamic field, String operator, dynamic value) {
List<dynamic> condition;
condition = <dynamic>[field, operator, value];
conditions.add(condition);
}
if (isEqualTo != null) addCondition(field, '==', isEqualTo);
_conditions = conditions;
return this;
}
@override
Future<void> delete() async {
final docs = await _utils.get(path, true, _conditions);
if (docs != null) {
for (final key in docs.keys) {
final id = key.split(pathSeparatorRegEx).last;
DocumentRef(id, this)._data.clear();
}
}
await _utils.delete(path);
}
}
@@ -0,0 +1,29 @@
part of 'localstore.dart';
/// The interface that other CollectionRef must extend.
abstract class CollectionRefImpl {
/// Returns a `DocumentRef` with the provided id.
///
/// If no [id] is provided, an auto-generated ID is used.
///
/// The unique key generated is prefixed with a client-generated timestamp
/// so that the resulting list will be chronologically-sorted.
DocumentRef doc([String? id]);
/// Notifies of query results at this collection.
Stream<Map<String, dynamic>> get stream;
/// Fetch the documents for this collection
Future<Map<String, dynamic>?> get();
/// Creates and returns a new [CollectionRef] with additional filter on
/// specified [field]. [field] refers to a field in a document.
///
/// `where` is not implemented
CollectionRef where(dynamic field, {dynamic isEqualTo});
/// Delete collection
///
/// All collections and documents in this collection will be deleted.
Future<void> delete();
}
@@ -0,0 +1,66 @@
part of 'localstore.dart';
/// A [DocumentRef] refers to a document location in a [Localstore] database
/// and can be used to write, read, or listen to the location.
///
/// The document at the referenced location may or may not exist.
/// A [DocumentRef] can also be used to create a [CollectionRef]
/// to a subcollection.
final class DocumentRef implements DocumentRefImpl {
String _id;
/// This document's given ID within the collection.
String get id => _id;
CollectionRef? _delegate;
DocumentRef._(this._id, [this._delegate]);
static final _cache = <String, DocumentRef>{};
/// Returns an instance using the default [DocumentRef].
factory DocumentRef(String id, [CollectionRef? delegate]) {
final key = '${delegate?.path ?? ''}$id';
return _cache.putIfAbsent(key, () => DocumentRef._(id, delegate));
}
/// A string representing the path of the referenced document (relative to the
/// root of the database).
String get path => '${_delegate?.path}$id';
final _utils = Utils.instance;
final Map<String, dynamic> _data = {};
@override
Future<dynamic> set(Map<String, dynamic> data, [SetOptions? options]) async {
options ??= SetOptions();
if (options.merge) {
final output = Map<String, dynamic>.from(data);
final Map<String, dynamic>? input = _data[id] ?? {};
output.updateAll((key, value) {
input![key] = value;
});
_data[id] = input;
} else {
_data[id] = data;
}
return await _utils.set(_data[id], path);
}
@override
Future<Map<String, dynamic>?> get() async =>
_data[id] ?? await _utils.get(path);
@override
Future delete() async {
await _utils.delete(path);
_data.remove(id);
}
@override
CollectionRef collection(String id) => CollectionRef(id, _delegate, this);
@override
String toString() => _utils.toString();
}
@@ -0,0 +1,20 @@
part of 'localstore.dart';
/// The interface that other DocumentRef must extend.
abstract class DocumentRefImpl {
/// Gets a [CollectionRef] for the specified Localstore path.
CollectionRef collection(String path);
/// Sets data on the document, overwriting any existing data. If the document
/// does not yet exist, it will be created.
///
/// If [SetOptions] are provided, the data will be merged into an existing
/// document instead of overwriting.
Future<dynamic> set(Map<String, dynamic> data, [SetOptions? options]);
/// Reads the document referenced by this [DocumentRef].
Future<Map<String, dynamic>?> get();
/// Deletes the current document from the collection.
Future delete();
}
@@ -0,0 +1,19 @@
library;
import 'dart:async';
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'dart:math';
import 'utils/html.dart' if (dart.library.io) 'utils/io.dart';
part 'collection_ref.dart';
part 'collection_ref_impl.dart';
part 'document_ref.dart';
part 'document_ref_impl.dart';
part 'set_option.dart';
part 'localstore_base.dart';
part 'localstore_impl.dart';
@@ -0,0 +1,30 @@
part of 'localstore.dart';
/// The entry point for accessing a [Localstore].
///
/// You can get an instance by calling [Localstore.instance], for example:
///
/// ```dart
/// final db = Localstore.instance;
/// ```
final class Localstore implements LocalstoreImpl {
final _databaseDirectory = getApplicationSupportDirectory();
final _delegate = DocumentRef._('');
static final Localstore _localstore = Localstore._();
/// Private initializer
Localstore._();
/// Returns an instance using the default [Localstore].
static Localstore get instance => _localstore;
Future<Directory> get databaseDirectory => _databaseDirectory;
/// Clears the cache - needed only if filesystem has been manipulated directly
void clearCache() {
Utils.instance.clearCache();
}
@override
CollectionRef collection(String path) => CollectionRef(path, null, _delegate);
}
@@ -0,0 +1,7 @@
part of 'localstore.dart';
/// The interface that other Localstore must extend.
abstract class LocalstoreImpl {
/// Gets a [CollectionRef] for the specified Localstore path.
CollectionRef collection(String path);
}
@@ -0,0 +1,14 @@
part of 'localstore.dart';
/// An options class that configures the behavior of set() calls in
/// [DocumentRef].
final class SetOptions {
final bool _merge;
/// Changes the behavior of a set() call to only replace the values specified
/// in its data argument.
bool get merge => _merge;
/// Creates a [SetOptions] instance.
SetOptions({this._merge = false});
}
@@ -0,0 +1,41 @@
import 'dart:async';
import 'utils_impl.dart';
/// Utils class
class Utils implements UtilsImpl {
Utils._();
static final Utils _utils = Utils._();
static Utils get instance => _utils;
@override
void clearCache() {
throw UnimplementedError('Web is not supported');
}
@override
Future<Map<String, dynamic>?> get(
String path, [
bool? isCollection = false,
List<List>? conditions,
]) async {
throw UnimplementedError('Web is not supported');
}
@override
Future<dynamic>? set(Map<String, dynamic> data, String path) {
throw UnimplementedError('Web is not supported');
}
@override
Future delete(String path) async {
throw UnimplementedError('Web is not supported');
}
@override
Stream<Map<String, dynamic>> stream(String path, [List<List>? conditions]) {
throw UnimplementedError('Web is not supported');
}
}
@@ -0,0 +1,312 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:path/path.dart' as p;
import '../localstore.dart';
import 'utils_impl.dart';
import 'package:logging/logging.dart';
final _log = Logger('Localstore');
final class Utils implements UtilsImpl {
Utils._();
static final Utils _utils = Utils._();
static final lastPathComponentRegEx = RegExp(r'[^/\\]+[/\\]?$');
static Utils get instance => _utils;
final _storageCache = <String, StreamController<Map<String, dynamic>>>{};
final _fileCache = <String, File>{};
final _locks = <String, Future<void>>{};
/// Clears the cache
@override
void clearCache() {
_storageCache.clear();
_fileCache.clear();
}
Future<T> _synchronized<T>(String path, Future<T> Function() action) async {
final previous = _locks[path] ?? Future.value();
final controller = Completer<T>();
final newFuture = previous
.then((_) async {
try {
final result = await action();
controller.complete(result);
} catch (e, st) {
controller.completeError(e, st);
}
})
.catchError((_) {});
_locks[path] = newFuture;
// Cleanup
newFuture.whenComplete(() {
if (_locks[path] == newFuture) {
_locks.remove(path);
}
});
return controller.future;
}
String _resolvePath(String dbPath, String path) {
var relativePath = path;
if (relativePath.startsWith('/') || relativePath.startsWith('\\')) {
relativePath = relativePath.substring(1);
}
return p.join(dbPath, relativePath);
}
@override
Future<Map<String, dynamic>?> get(
String path, [
bool? isCollection = false,
List<List>? conditions,
]) async {
// Fetch the documents for this collection
if (isCollection != null && isCollection == true) {
final dbDir = await Localstore.instance.databaseDirectory;
final fullPath = _resolvePath(dbDir.path, path);
final dir = Directory(fullPath);
if (!await dir.exists()) {
return {};
}
final entries = await dir.list(recursive: false).toList();
return await _getAll(entries.whereType<File>().toList());
} else {
return _synchronized(path, () async {
try {
// Reads the document referenced by this [DocumentRef].
final file = await _getFile(path);
final randomAccessFile = await file!.open(mode: FileMode.append);
try {
final data = await _readFile(randomAccessFile);
if (data is Map<String, dynamic>) {
final key = path.replaceAll(lastPathComponentRegEx, '');
// ignore: close_sinks
final storage = _storageCache.putIfAbsent(
key,
() => _newStream(key),
);
storage.add(data);
return data;
}
} finally {
await randomAccessFile.close();
}
} on PathNotFoundException {
// return null if not found
}
return null;
});
}
}
@override
Future<dynamic>? set(Map<String, dynamic> data, String path) =>
_writeFile(data, path);
@override
Future delete(String path) async {
if (path.endsWith(Platform.pathSeparator)) {
await _deleteDirectory(path);
} else {
await _synchronized(path, () => _deleteFile(path));
}
}
@override
Stream<Map<String, dynamic>> stream(String path, [List<List>? conditions]) {
// ignore: close_sinks
var storage = _storageCache[path];
if (storage == null) {
storage = _storageCache.putIfAbsent(path, () => _newStream(path));
} else {
_initStream(storage, path);
}
return storage.stream;
}
Future<Map<String, dynamic>?> _getAll(List<FileSystemEntity> entries) async {
_log.finest('Getting all entries from ${entries.length} files');
final items = <String, dynamic>{};
final dbDir = await Localstore.instance.databaseDirectory;
for (final e in entries) {
final relativePath = p.relative(e.path, from: dbDir.path);
final path = Platform.isWindows
? relativePath.replaceAll(p.separator, '/')
: relativePath;
await _synchronized(path, () async {
final file = await _getFile(path);
try {
final randomAccessFile = await file!.open(mode: FileMode.append);
try {
final data = await _readFile(randomAccessFile);
if (data is Map<String, dynamic>) {
items[path] = data;
}
} finally {
await randomAccessFile.close();
}
} on PathNotFoundException {
// ignore if not found
} catch (e) {
_log.warning('Error reading file $path: $e');
}
});
}
if (items.isEmpty) return null;
return items;
}
/// Streams all file in the path
StreamController<Map<String, dynamic>> _newStream(String path) {
final storage = StreamController<Map<String, dynamic>>.broadcast();
_initStream(storage, path);
return storage;
}
Future _initStream(
StreamController<Map<String, dynamic>> storage,
String path,
) async {
final dbDir = await Localstore.instance.databaseDirectory;
final fullPath = _resolvePath(dbDir.path, path);
final dir = Directory(fullPath);
try {
if (!await dir.exists()) return;
final entries = await dir.list(recursive: false).toList();
for (final e in entries) {
if (e is! File) continue;
final relativePath = p.relative(e.path, from: dbDir.path);
final filePath = Platform.isWindows
? relativePath.replaceAll(p.separator, '/')
: relativePath;
// We use synchronized reading
await _synchronized(filePath, () async {
final file = await _getFile(filePath);
final randomAccessFile = await file!.open(mode: FileMode.append);
try {
final data = await _readFile(randomAccessFile);
if (data is Map<String, dynamic>) {
storage.add(data);
}
} finally {
await randomAccessFile.close();
}
});
}
} catch (e) {
// ignore
}
}
Future<dynamic> _readFile(RandomAccessFile file) async {
final length = await file.length();
await file.setPosition(0);
final buffer = Uint8List(length);
await file.readInto(buffer);
try {
final contentText = utf8.decode(buffer);
final data = json.decode(contentText) as Map<String, dynamic>;
return data;
} catch (e) {
return e;
}
}
Future<File?> _getFile(String path) async {
final dbDir = await Localstore.instance.databaseDirectory;
final file = _fileCache[path] ?? File(_resolvePath(dbDir.path, path));
if (!await file.exists()) {
try {
await file.create(recursive: true);
} on FileSystemException {
// Handle race condition where directory was being deleted/modified concurrently
await Future.delayed(const Duration(milliseconds: 50));
if (!await file.exists()) {
try {
await file.create(recursive: true);
} on FileSystemException catch (e) {
_log.warning('Could not create file $path: $e');
_fileCache.remove(path);
return null;
}
}
}
}
_fileCache[path] = file;
return file;
}
Future _writeFile(Map<String, dynamic> data, String path) =>
_synchronized(path, () async {
final serialized = json.encode(data);
final buffer = utf8.encode(serialized);
try {
final file = await _getFile(path);
if (file == null) return;
final randomAccessFile = await file.open(mode: FileMode.append);
try {
await randomAccessFile.lock();
await randomAccessFile.setPosition(0);
await randomAccessFile.writeFrom(buffer);
await randomAccessFile.truncate(buffer.length);
await randomAccessFile.unlock();
} finally {
await randomAccessFile.close();
}
} on FileSystemException catch (e) {
_log.warning('FileSystemException writing file $path: $e');
return;
} catch (e) {
_log.warning('Error writing file $path: $e');
return;
}
final key = path.replaceAll(lastPathComponentRegEx, '');
// ignore: close_sinks
final storage = _storageCache.putIfAbsent(key, () => _newStream(key));
storage.add(data);
});
Future _deleteFile(String path) async {
final dbDir = await Localstore.instance.databaseDirectory;
final file = File(_resolvePath(dbDir.path, path));
if (await file.exists()) {
try {
await file.delete();
} catch (e) {
_log.finest(e);
}
}
_fileCache.remove(path);
}
Future _deleteDirectory(String path) async {
final dbDir = await Localstore.instance.databaseDirectory;
final dir = Directory(_resolvePath(dbDir.path, path));
_fileCache.removeWhere((key, value) => key.startsWith(path));
if (await dir.exists()) {
try {
await dir.delete(recursive: true);
} catch (e) {
_log.finest(e);
}
}
_fileCache.removeWhere((key, value) => key.startsWith(path));
}
}
@@ -0,0 +1,11 @@
abstract class UtilsImpl {
void clearCache();
Future<Map<String, dynamic>?> get(
String path, [
bool? isCollection = false,
List<List>? conditions,
]);
Future<dynamic>? set(Map<String, dynamic> data, String path);
Future delete(String path);
Stream<Map<String, dynamic>> stream(String path, [List<List>? conditions]);
}
+665
View File
@@ -0,0 +1,665 @@
import 'dart:convert';
import 'exceptions.dart';
import 'task.dart';
export 'options/mtls.dart';
/// Defines a set of possible states which a [Task] can be in.
enum TaskStatus {
/// Task is enqueued on the native platform and waiting to start
///
/// It may wait for resources, or for an appropriate network to become
/// available before starting the actual download and changing state to
/// `running`.
enqueued,
/// Task is running, i.e. actively downloading
running,
/// Task has completed successfully
///
/// This is a final state
complete,
/// Task has completed because the url was not found (Http status code 404)
///
/// This is a final state
notFound,
/// Task has failed due to an exception
///
/// This is a final state
failed,
/// Task has been canceled by the user or the system
///
/// This is a final state
canceled,
/// Task failed, and is now waiting to retry
///
/// The task is held in this state until the exponential backoff time for
/// this retry has passed, and will then be rescheduled on the native
/// platform, switching state to `enqueued` and then `running`
waitingToRetry,
/// Task is in paused state and may be able to resume
///
/// To resume a paused Task, call [FileDownloader.resume]. If the resume is
/// possible, status will change to [TaskStatus.running] and continue from
/// there. If resume fails (e.g. because the temp file with the partial
/// download has been deleted by the operating system) status will switch
/// to [TaskStatus.failed]
paused;
/// True if this state is one of the 'final' states, meaning no more
/// state changes are possible
bool get isFinalState => switch (this) {
.complete || .notFound || .failed || .canceled => true,
.enqueued || .running || .waitingToRetry || .paused => false,
};
/// True if this state is not a 'final' state, meaning more
/// state changes are possible
bool get isNotFinalState => !isFinalState;
}
/// Base directory in which files will be stored, based on their relative
/// path.
///
/// These correspond to the directories provided by the path_provider package
enum BaseDirectory {
/// As returned by getApplicationDocumentsDirectory()
applicationDocuments,
/// As returned by getTemporaryDirectory()
temporary,
/// As returned by getApplicationSupportDirectory()
applicationSupport,
/// As returned by getApplicationLibrary() on iOS. For other platforms
/// this resolves to the subdirectory 'Library' created in the directory
/// returned by getApplicationSupportDirectory()
applicationLibrary,
/// System root directory. This allows you to set a path to any directory
/// via [Task.directory]. Only use this if you are certain that this
/// path is stable. on iOS and Android, references to paths within
/// the application's directory structure are *not* stable, and you
/// should use [applicationDocuments], [applicationSupport] or
/// [applicationLibrary] instead to avoid errors.
root,
}
/// Type of updates requested for a task or group of tasks
enum Updates {
/// no status change or progress updates
none,
/// only status changes
status,
/// only progress updates while downloading, no status change updates
progress,
/// Status change updates and progress updates while downloading
statusAndProgress,
}
/// Signature for a function you can register to be called
/// when the status of a [Task] changes.
typedef TaskStatusCallback = void Function(TaskStatusUpdate update);
/// Signature for a function you can register to be called
/// for every progress change of a [Task].
///
/// A successfully completed task will always finish with progress 1.0
/// [TaskStatus.failed] results in progress -1.0
/// [TaskStatus.canceled] results in progress -2.0
/// [TaskStatus.notFound] results in progress -3.0
/// [TaskStatus.waitingToRetry] results in progress -4.0
/// These constants are available as [progressFailed] etc
typedef TaskProgressCallback = void Function(TaskProgressUpdate update);
/// Signature for function you can register to be called when a notification
/// is tapped by the user
typedef TaskNotificationTapCallback = void Function(
Task task,
NotificationType notificationType,
);
/// Signature for a function you can provide to the [FileDownloader.downloadBatch] or
/// [FileDownloader.uploadBatch] that will be called upon completion of each task
/// in the batch.
///
/// [succeeded] will count the number of successful downloads, and
/// [failed] counts the number of failed downloads (for any reason).
typedef BatchProgressCallback = void Function(int succeeded, int failed);
/// Contains tasks and results related to a batch of tasks
class Batch(
final List<Task> tasks,
final BatchProgressCallback? batchProgressCallback,
) {
final results = <Task, TaskStatus>{};
/// Returns an Iterable with successful tasks in this batch
Iterable<Task> get succeeded => results.entries
.where((entry) => entry.value == TaskStatus.complete)
.map((e) => e.key);
/// Returns the number of successful tasks in this batch
int get numSucceeded =>
results.values.where((result) => result == TaskStatus.complete).length;
/// Returns an Iterable with failed tasks in this batch
Iterable<Task> get failed => results.entries
.where((entry) => entry.value != TaskStatus.complete)
.map((e) => e.key);
/// Returns the number of failed downloads in this batch
int get numFailed => results.values.length - numSucceeded;
}
/// Base class for updates related to [task]. Actual updates are
/// either a status update or a progress update.
///
/// When receiving an update, test if the update is a
/// [TaskStatusUpdate] or a [TaskProgressUpdate]
/// and treat the update accordingly
sealed class TaskUpdate {
final Task task;
const TaskUpdate(this.task);
/// Create object from [json]
TaskUpdate.fromJson(Map<String, dynamic> json)
: task = Task.createFromJson(json['task'] ?? json);
/// Return JSON Map representing object
Map<String, dynamic> toJson() => {'task': task.toJson()};
}
/// A status update
///
/// Contains [TaskStatus] and, if [TaskStatus.failed] possibly a
/// [TaskException] and if this is a final state possibly [responseBody],
/// [responseHeaders], [responseStatusCode], [mimeType] and [charSet].
/// Note: header names in [responseHeaders] are converted to lowercase
class TaskStatusUpdate extends TaskUpdate {
final TaskStatus status; // note: serialized as 'taskStatus'
final TaskException? exception;
final String? responseBody;
final int? responseStatusCode;
final Map<String, String>? responseHeaders;
final String? mimeType; // derived from Content-Type header
final String? charSet; // derived from Content-Type header
const TaskStatusUpdate(
super.task,
this.status, [
this.exception,
this.responseBody,
this.responseHeaders,
this.responseStatusCode,
this.mimeType,
this.charSet,
]);
/// Create object from [json]
TaskStatusUpdate.fromJson(super.json)
: status = TaskStatus.values[(json['taskStatus'] as num?)?.toInt() ?? 0],
exception = json['exception'] != null
? TaskException.fromJson(json['exception'])
: null,
responseBody = json['responseBody'],
responseHeaders = json['responseHeaders'] != null
? Map.from(json['responseHeaders'])
: null,
responseStatusCode = (json['responseStatusCode'] as num?)?.toInt(),
mimeType = json['mimeType'],
charSet = json['charSet'],
super.fromJson();
/// Create object from [jsonString]
factory TaskStatusUpdate.fromJsonString(String jsonString) =>
TaskStatusUpdate.fromJson(jsonDecode(jsonString));
/// Return JSON Map representing object
@override
Map<String, dynamic> toJson() => {
...super.toJson(),
'taskStatus': status.index,
'exception': exception?.toJson(),
'responseBody': responseBody,
'responseHeaders': responseHeaders,
'responseStatusCode': responseStatusCode,
'mimeType': mimeType,
'charSet': charSet,
};
TaskStatusUpdate copyWith({
Task? task,
TaskStatus? status,
TaskException? exception,
String? responseBody,
Map<String, String>? responseHeaders,
int? responseStatusCode,
String? mimeType,
String? charSet,
}) => TaskStatusUpdate(
task ?? this.task,
status ?? this.status,
exception ?? this.exception,
responseBody ?? this.responseBody,
responseHeaders ?? this.responseHeaders,
responseStatusCode ?? this.responseStatusCode,
mimeType ?? this.mimeType,
charSet ?? this.charSet,
);
}
/// A progress update
///
/// A successfully downloaded task will always finish with progress 1.0
///
/// [TaskStatus.failed] results in progress -1.0
/// [TaskStatus.canceled] results in progress -2.0
/// [TaskStatus.notFound] results in progress -3.0
/// [TaskStatus.waitingToRetry] results in progress -4.0
///
/// [expectedFileSize] will only be representative if the 0 < [progress] < 1,
/// so NOT representative when progress == 0 or progress == 1, and
/// will be -1 if the file size is not provided by the server or otherwise
/// not known.
/// [networkSpeed] is valid if positive, expressed in MB/second
/// [timeRemaining] is valid if positive
///
/// Use the [hasExpectedFileSize], [hasNetworkSpeed] and [hasTimeRemaining]
/// getters to determine whether a field is valid
class TaskProgressUpdate extends TaskUpdate {
final double progress;
final int expectedFileSize;
final double networkSpeed; // in MB/s
final Duration timeRemaining;
const TaskProgressUpdate(
super.task,
this.progress, [
this.expectedFileSize = -1,
this.networkSpeed = -1,
this.timeRemaining = const Duration(seconds: -1),
]);
/// Create object from [json]
TaskProgressUpdate.fromJson(super.json)
: progress = (json['progress'] as num?)?.toDouble() ?? progressFailed,
expectedFileSize = (json['expectedFileSize'] as num?)?.toInt() ?? -1,
networkSpeed = (json['networkSpeed'] as num?)?.toDouble() ?? -1,
timeRemaining = Duration(
seconds: (json['timeRemaining'] as num?)?.toInt() ?? -1,
),
super.fromJson();
/// Create object from [jsonString]
factory TaskProgressUpdate.fromJsonString(String jsonString) =>
TaskProgressUpdate.fromJson(jsonDecode(jsonString));
/// Return JSON Map representing object
@override
Map<String, dynamic> toJson() => {
...super.toJson(),
'progress': progress,
'expectedFileSize': expectedFileSize,
'networkSpeed': networkSpeed,
'timeRemaining': timeRemaining.inSeconds,
};
/// If true, [expectedFileSize] contains a valid value
bool get hasExpectedFileSize => expectedFileSize >= 0;
/// If true, [networkSpeed] contains a valid value
bool get hasNetworkSpeed => networkSpeed >= 0;
/// If true, [timeRemaining] contains a valid value
bool get hasTimeRemaining => !timeRemaining.isNegative;
/// Use the [hasExpectedFileSize], [hasNetworkSpeed] and [hasTimeRemaining]
/// getters to determine whether a field is valid
///
/// String is '-- MB/s' if N/A, otherwise in MB/s or kB/s
String get networkSpeedAsString => switch (networkSpeed) {
<= 0 => '-- MB/s',
>= 1 => '${networkSpeed.round()} MB/s',
_ => '${(networkSpeed * 1000).round()} kB/s',
};
/// String is '--:--' if N/A, otherwise HH:MM:SS or MM:SS
String get timeRemainingAsString => switch (timeRemaining.inSeconds) {
<= 0 => '--:--',
< 3600 =>
'${timeRemaining.inMinutes.toString().padLeft(2, "0")}'
':${timeRemaining.inSeconds.remainder(60).toString().padLeft(2, "0")}',
_ =>
'${timeRemaining.inHours}'
':${timeRemaining.inMinutes.remainder(60).toString().padLeft(2, "0")}'
':${timeRemaining.inSeconds.remainder(60).toString().padLeft(2, "0")}',
};
@override
String toString() =>
'TaskProgressUpdate{progress: $progress, expectedFileSize: $expectedFileSize, networkSpeed: $networkSpeed, timeRemaining: $timeRemaining}';
}
// Progress values representing a status
const progressRunning = 0.0;
const progressComplete = 1.0;
const progressFailed = -1.0;
const progressCanceled = -2.0;
const progressNotFound = -3.0;
const progressWaitingToRetry = -4.0;
const progressPaused = -5.0;
/// Holds data associated with a resume
class ResumeData {
final Task task;
final String data;
final int requiredStartByte;
final String? eTag;
const ResumeData(
this.task,
this.data, [
this.requiredStartByte = 0,
this.eTag,
]);
/// Create object from [json]
ResumeData.fromJson(Map<String, dynamic> json)
: task = Task.createFromJson(json['task']),
data = json['data'] as String,
requiredStartByte = (json['requiredStartByte'] as num?)?.toInt() ?? 0,
eTag = json['eTag'] as String?;
/// Create object from [jsonString]
factory ResumeData.fromJsonString(String jsonString) =>
ResumeData.fromJson(jsonDecode(jsonString));
/// Return JSON Map representing object
Map<String, dynamic> toJson() => {
'task': task.toJson(),
'data': data,
'requiredStartByte': requiredStartByte,
'eTag': eTag,
};
String get taskId => task.taskId;
/// The tempFilepath contained in the [data] field
String get tempFilepath => data;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ResumeData &&
runtimeType == other.runtimeType &&
task == other.task &&
data == other.data &&
requiredStartByte == other.requiredStartByte &&
eTag == other.eTag;
@override
int get hashCode =>
task.hashCode ^
data.hashCode ^
requiredStartByte.hashCode ^
(eTag?.hashCode ?? 0);
}
/// Types of undelivered data that can be requested
enum Undelivered {
/// Resume data
resumeData,
/// Status updates
statusUpdates,
/// Progress updates
progressUpdates,
}
/// Notification types, as configured in [TaskNotificationConfig] and passed
/// on to [TaskNotificationTapCallback]
enum NotificationType {
/// Task is running
running,
/// Task has completed
complete,
/// Task has failed
error,
/// Task is paused
paused,
/// Task is canceled
canceled,
}
/// Notification specification for a [Task]
///
/// [body] and [title] may contain special strings to substitute display values:
/// {filename] to insert the filename
/// {progress} to insert progress in %
/// {networkSpeed} to insert the network speed in MB/s or kB/s, or '--' if N/A
/// {timeRemaining} to insert the estimated time remaining to complete the task
/// in HH:MM:SS or MM:SS or --:-- if N/A
///
/// Actual appearance of notification is dependent on the platform, e.g.
/// on iOS {progress} is not available and ignored
final class const TaskNotification(final String title, final String body) {
/// Return JSON Map representing object
Map<String, dynamic> toJson() => {"title": title, "body": body};
}
/// Notification configuration object
///
/// Determines how a [taskOrGroup] or [Task.group] of tasks needs to be notified
///
/// [running] is the notification used while the task is in progress
/// [complete] is the notification used when the task completed
/// [error] is the notification used when something went wrong,
/// including pause, failed and notFound status
/// [progressBar] if set will show a progress bar
/// [tapOpensFile] if set will attempt to open the file when the [complete]
/// notification is tapped
/// [groupNotificationId] if set will group all notifications with the same
/// [groupNotificationId] and change the progress bar to number of finished
/// tasks versus total number of tasks in the groupNotification.
/// Use {finished} and {total} tokens in the [TaskNotification.title] and
/// [TaskNotification.body] to substitute. Task-specific substitutions
/// such as {filename} are not valid.
/// The groupNotification is considered [complete] when there are no
/// more tasks running within that group, and at that point the
/// [complete] notification is shown (if configured). If any task in the
/// groupNotification fails, the [error] notification is shown.
/// The first character of the [groupNotificationId] cannot be '*'.
final class TaskNotificationConfig {
final dynamic taskOrGroup;
final TaskNotification? running;
final TaskNotification? complete;
final TaskNotification? error;
final TaskNotification? paused;
final TaskNotification? canceled;
final bool progressBar;
final bool tapOpensFile;
final String groupNotificationId;
/// Create notification configuration that determines what notifications are shown,
/// whether a progress bar is shown (Android only), and whether tapping
/// the 'complete' notification opens the downloaded file.
///
/// [running] is the notification used while the task is in progress
/// [complete] is the notification used when the task completed
/// [error] is the notification used when something went wrong,
/// including failed and notFound status
/// [paused] is the notification shown when the task is paused
/// [canceled] is the notification shown when the task is canceled programmatically
/// or by the user via a notification button
/// [progressBar] if set will show a progress bar
/// [tapOpensFile] if set will attempt to open the file when the [complete]
/// notification is tapped
/// [groupNotificationId] if set will group all notifications with the same
/// [groupNotificationId] and change the progress bar to number of finished
/// tasks versus total number of tasks in the groupNotification.
/// Use {numFinished}, {numFailed} and {numTotal} tokens in the [TaskNotification.title]
/// and [TaskNotification.body] to substitute. Task-specific substitutions
/// such as {filename} are not valid.
/// The groupNotification is considered [complete] when there are no
/// more tasks running within that group, and at that point the
/// [complete] notification is shown (if configured). If any task in the
/// groupNotification fails, the [error] notification is shown.
/// The first character of the [groupNotificationId] cannot be '*'.
TaskNotificationConfig({
this.taskOrGroup,
this.running,
this.complete,
this.error,
this.paused,
this.canceled,
this.progressBar = false,
this.tapOpensFile = false,
this.groupNotificationId = '',
}) {
assert(
running != null ||
complete != null ||
error != null ||
paused != null ||
canceled != null,
'At least one notification must be set',
);
}
/// Return JSON Map representing object, excluding the [taskOrGroup] field,
/// as the JSON map is only required to pass along the config with a task
Map<String, dynamic> toJson() => {
'running': running?.toJson(),
'complete': complete?.toJson(),
'error': error?.toJson(),
'paused': paused?.toJson(),
'canceled': canceled?.toJson(),
'progressBar': progressBar,
'tapOpensFile': tapOpensFile,
'groupNotificationId': groupNotificationId,
};
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is TaskNotificationConfig &&
runtimeType == other.runtimeType &&
taskOrGroup == other.taskOrGroup;
@override
int get hashCode => taskOrGroup.hashCode;
}
/// Shared storage destinations
enum SharedStorage {
/// The 'Downloads' directory
downloads,
/// The 'Photos' or 'Images' or 'Pictures' directory
images,
/// The 'Videos' or 'Movies' directory
video,
/// The 'Music' or 'Audio' directory
audio,
/// Android-only: the 'Files' directory
files,
/// Android-only: the 'external storage' directory
external,
}
final class Config {
// Config topics
/// Config string for request timeout
static const requestTimeout = 'requestTimeout';
/// Config string for resource timeout
static const resourceTimeout = 'resourceTimeout';
/// Config string for check available space
static const checkAvailableSpace = 'checkAvailableSpace';
/// Config string for proxy
static const proxy = 'proxy';
/// Config string for bypass TLS certificate validation
static const bypassTLSCertificateValidation =
'bypassTLSCertificateValidation';
/// Config string for mutual TLS (mTLS)
static const mTLS = 'mTLS';
/// Config string for run in foreground
static const runInForeground = 'runInForeground';
/// Config string for run in foreground if file larger than
static const runInForegroundIfFileLargerThan =
'runInForegroundIfFileLargerThan';
/// Config string for localize
static const localize = 'localize';
/// Config string for exclude from cloud backup
static const excludeFromCloudBackup = 'excludeFromCloudBackup';
/// Config string for use cache dir
static const useCacheDir = 'useCacheDir';
/// Config string for use external storage
static const useExternalStorage = 'useExternalStorage';
/// Config string for holding queue
static const holdingQueue = 'holdingQueue';
/// Config string for skip existing files
static const skipExistingFiles = 'skipExistingFiles';
/// Config string for temp file path
static const tempFilePath = 'tempFilePath';
// Config arguments
/// Config argument for always
static const always = 'always'; // int 0 on native side
/// Config argument for never
static const never = 'never'; // int -1 on native side
/// Config argument for when able
static const whenAble = 'whenAble'; // int -2 on native side
/// Returns the int equivalent of commonly used String arguments
///
/// The int equivalent is used in communication with the native downloader
static int argToInt(String argument) => switch (argument) {
Config.always => 0,
Config.whenAble => -2,
Config.never => -1,
_ => throw ArgumentError('Argument $argument cannot be converted to int'),
};
}
/// Wifi requirement modes at the application level
enum RequireWiFi { asSetByTask, forAllTasks, forNoTasks }
@@ -0,0 +1,886 @@
import 'dart:async';
import 'dart:convert';
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'base_downloader.dart';
import 'chunk.dart';
import 'exceptions.dart';
import 'file_downloader.dart';
import 'models.dart';
import 'json_processor.dart';
import 'permissions.dart';
import 'queue/serial_job_queue.dart';
import 'task.dart';
/// Implementation of download functionality for native platforms
///
/// Uses [MethodChannel] to communicate with native platforms
abstract base class NativeDownloader extends BaseDownloader {
static const methodChannel = MethodChannel(
'com.bbflight.background_downloader',
);
static const _backgroundChannel = MethodChannel(
'com.bbflight.background_downloader.background',
);
late final SerialJobQueue<MethodCall, dynamic> _jobQueue;
/// Initializes the background channel and starts listening for messages from
/// the native side
@override
Future<void> initialize() async {
await super.initialize();
WidgetsFlutterBinding.ensureInitialized();
// listen to the background channel, receiving updates on download status
// or progress.
// The job queue ensures that messages are processed in order, even though
// the processing itself is asynchronous (using [compute])
_jobQueue = SerialJobQueue(_handleBackgroundMessage);
_backgroundChannel.setMethodCallHandler(_jobQueue.add);
}
/// Handles the background message
///
/// First argument is the Task as JSON string, next argument(s) depends
/// on the method.
///
/// If the task JsonString is empty, a dummy task will be created
Future<dynamic> _handleBackgroundMessage(MethodCall call) async {
final args = call.arguments as List<dynamic>;
final taskJsonString = args.first as String;
final task = taskJsonString.isNotEmpty
? await JsonProcessor().decodeTask(taskJsonString)
: DownloadTask(url: 'url');
final message = (
call.method,
args.length > 2
? args.getRange(1, args.length).toList(growable: false)
: args[1],
);
switch (message) {
// simple status update
case ('statusUpdate', final int statusOrdinal):
final status = TaskStatus.values[statusOrdinal];
if (task.group != BaseDownloader.chunkGroup) {
processStatusUpdate(TaskStatusUpdate(task, status));
} else {
// this is a chunk task, so pass to native
Future.delayed(const Duration(milliseconds: 100)).then(
(_) => methodChannel.invokeMethod('chunkStatusUpdate', [
Chunk.getParentTaskId(task),
task.taskId,
status.index,
null,
null,
]),
);
}
// status update with responseBody, responseHeaders, responseStatusCode, mimeType and charSet (normal completion)
case (
'statusUpdate',
[
final int statusOrdinal,
final String? responseBody,
final Map<Object?, Object?>? responseHeaders,
final int? responseStatusCode,
final String? mimeType,
final String? charSet,
],
):
final status = TaskStatus.values[statusOrdinal];
if (task.group != BaseDownloader.chunkGroup) {
final Map<String, String>? cleanResponseHeaders =
responseHeaders == null
? null
: {
for (final entry in responseHeaders.entries.where(
(entry) => entry.key != null && entry.value != null,
))
entry.key.toString().toLowerCase(): entry.value.toString(),
};
processStatusUpdate(
TaskStatusUpdate(
task,
status,
null,
responseBody,
cleanResponseHeaders,
responseStatusCode,
mimeType,
charSet,
),
);
} else {
// this is a chunk task, so pass to native
Future.delayed(const Duration(milliseconds: 100)).then(
(_) => methodChannel.invokeMethod('chunkStatusUpdate', [
Chunk.getParentTaskId(task),
task.taskId,
status.index,
null,
responseBody,
]),
);
}
// status update with TaskException and responseBody
case (
'statusUpdate',
[
final int statusOrdinal,
final String typeString,
final String description,
final int httpResponseCode,
final String? responseBody,
],
):
final status = TaskStatus.values[statusOrdinal];
TaskException? exception;
if (status == TaskStatus.failed) {
exception = TaskException.fromTypeString(
typeString,
description,
httpResponseCode,
);
}
if (task.group != BaseDownloader.chunkGroup) {
processStatusUpdate(
TaskStatusUpdate(task, status, exception, responseBody),
);
} else {
// this is a chunk task, so pass to native
Future.delayed(const Duration(milliseconds: 100)).then(
(_) => methodChannel.invokeMethod('chunkStatusUpdate', [
Chunk.getParentTaskId(task),
task.taskId,
status.index,
exception?.toJsonString(),
responseBody,
]),
);
}
case (
'progressUpdate',
[
final double progress,
final int expectedFileSize,
final double networkSpeed,
final int timeRemaining,
],
):
if (task.group != BaseDownloader.chunkGroup) {
processProgressUpdate(
TaskProgressUpdate(
task,
progress,
expectedFileSize,
networkSpeed,
Duration(milliseconds: timeRemaining),
),
);
} else {
// this is a chunk task, so pass parent taskId,
// chunk taskId and progress to native
Future.delayed(const Duration(milliseconds: 100)).then(
(_) => methodChannel.invokeMethod('chunkProgressUpdate', [
Chunk.getParentTaskId(task),
task.taskId,
progress,
]),
);
}
case ('canResume', final bool canResume):
setCanResume(task, canResume);
// resumeData Android and Desktop variant
case (
'resumeData',
[final String data, final int requiredStartByte, final String? eTag],
):
setResumeData(ResumeData(task, data, requiredStartByte, eTag));
// resumeData iOS and ParallelDownloads variant
case ('resumeData', final String data):
setResumeData(ResumeData(task, data));
case ('notificationTap', final int notificationTypeOrdinal):
final notificationType =
NotificationType.values[notificationTypeOrdinal];
processNotificationTap(task, notificationType);
return true; // this message requires a confirmation
// from ParallelDownloadTask
case ('enqueueChild', final String childTaskJsonString):
final childTask = await JsonProcessor().decodeTask(childTaskJsonString);
// The parent treats this acknowledgement as proof that a child exists.
// Returning before native enqueue finishes strands the parent forever
// when WorkManager rejects that child. Return the real result instead.
try {
return await FileDownloader().enqueue(childTask);
} catch (error) {
log.warning('Native child enqueue failed', error);
return false;
}
// from ParallelDownloadTask
case ('cancelTasksWithId', final String listOfTaskIdsJson):
final taskIds = List<String>.from(jsonDecode(listOfTaskIdsJson));
Future.delayed(const Duration(milliseconds: 100))
.then((_) => FileDownloader().cancelTasksWithIds(taskIds));
// from ParallelDownloadTask
case ('pauseTasks', final String listOfTasksJson):
final listOfTasks = await JsonProcessor().decodeDownloadTaskList(
listOfTasksJson,
);
Future.delayed(const Duration(milliseconds: 100)).then((_) async {
for (final chunkTask in listOfTasks) {
await FileDownloader().pause(chunkTask);
}
});
// for permission request results
case ('permissionRequestResult', final int statusOrdinal):
permissionsService.onPermissionRequestResult(
PermissionStatus.values[statusOrdinal],
);
default:
log.warning('Background channel: no match for message $message');
throw ArgumentError(
'Background channel: no match for message $message',
);
}
await flushDatabaseUpdates();
return true;
}
@override
Future<bool> enqueue(Task task) async {
super.enqueue(task);
final notificationConfig = notificationConfigForTask(task);
return await methodChannel.invokeMethod<bool>('enqueue', [
jsonEncode(task.toJson()),
if (notificationConfig != null)
jsonEncode(notificationConfig.toJson())
else
null,
]) ??
false;
}
@override
Future<List<bool>> enqueueAll(Iterable<Task> tasks) async {
for (final task in tasks.where((task) => task.allowPause)) {
canResumeTask[task] = Completer();
}
tasks.forEach(registerTaskNotificationConfig);
final (
String tasksJsonString,
String notificationConfigsJsonString,
) = await JsonProcessor().encodeTaskAndNotificationConfig(
tasks,
notificationConfigs,
);
final result =
await methodChannel.invokeMethod<List<Object?>>('enqueueAll', [
tasksJsonString,
notificationConfigsJsonString,
]) ??
[];
return result.map((item) => item is bool ? item : false).toList();
}
@override
Future<int> reset(String group) async {
final retryAndPausedTaskCount = await super.reset(group);
final nativeCount =
await methodChannel.invokeMethod<int>('reset', group) ?? 0;
return retryAndPausedTaskCount + nativeCount;
}
@override
Future<List<Task>> allTasks(
String group,
bool includeTasksWaitingToRetry,
allGroups,
) async {
final retryAndPausedTasks = await super.allTasks(
group,
includeTasksWaitingToRetry,
allGroups,
);
final result =
await methodChannel.invokeMethod<List<dynamic>?>(
'allTasks',
allGroups ? null : group,
) ??
[];
final tasks = await JsonProcessor().decodeTaskList(result);
return [...retryAndPausedTasks, ...tasks];
}
@override
Future<bool> cancelPlatformTasksWithIds(List<String> taskIds) async =>
await methodChannel.invokeMethod<bool>('cancelTasksWithIds', taskIds) ??
false;
@override
Future<Task?> taskForId(String taskId) async {
final task = await super.taskForId(taskId);
if (task != null) {
return task;
}
final jsonString = await methodChannel.invokeMethod<String>(
'taskForId',
taskId,
);
if (jsonString != null) {
return Task.createFromJsonString(jsonString);
}
return null;
}
@override
Future<bool> pause(Task task) async =>
await methodChannel.invokeMethod<bool>('pause', task.taskId) ?? false;
@override
Future<List<bool>> pauseTaskList(Iterable<Task> tasksToPause) async {
final taskIds = tasksToPause
.map((task) => task.taskId)
.toList(growable: false);
final results = await methodChannel.invokeMethod<List<Object?>>(
'pauseAll',
taskIds,
);
return results?.cast<bool>() ?? tasksToPause.map((task) => false).toList();
}
@override
Future<bool> resume(Task task) async {
if (await super.resume(task)) {
task = awaitTasks.containsKey(task)
? awaitTasks.keys.firstWhere(
(awaitTask) => awaitTask.taskId == task.taskId,
)
: task;
final taskResumeData = await getResumeData(task.taskId);
if (taskResumeData != null) {
final notificationConfig = notificationConfigForTask(task);
final enqueueSuccess =
await methodChannel.invokeMethod<bool>('enqueue', [
jsonEncode(task.toJson()),
if (notificationConfig != null)
jsonEncode(notificationConfig.toJson())
else
null,
taskResumeData.data,
taskResumeData.requiredStartByte,
taskResumeData.eTag,
]) ??
false;
if (enqueueSuccess && task is ParallelDownloadTask) {
return resumeChunkTasks(task, taskResumeData);
}
return enqueueSuccess;
}
}
return false;
}
@override
Future<bool> requireWiFi(
RequireWiFi requirement,
rescheduleRunningTasks,
alsoRestartUploads,
) async =>
await methodChannel.invokeMethod('requireWiFi', [
requirement.index,
rescheduleRunningTasks,
alsoRestartUploads,
]) ??
false;
@override
Future<RequireWiFi> getRequireWiFiSetting() async => RequireWiFi
.values[await methodChannel.invokeMethod('getRequireWiFiSetting') ?? 0];
@override
void updateNotification(Task task, TaskStatus? taskStatusOrNull) {
final notificationConfig = notificationConfigForTask(task);
if (notificationConfig != null) {
methodChannel.invokeMethod('updateNotification', [
jsonEncode(task.toJson()),
jsonEncode(notificationConfig.toJson()),
taskStatusOrNull?.index,
]);
}
}
/// Retrieve data that was not delivered to Dart, as a Map keyed by taskId
/// with one map for each taskId
///
/// Asks the native platform for locally stored data for resumeData,
/// status updates or progress updates.
/// ResumeData has a [ResumeData] json representation
/// StatusUpdates has a mixed Task & TaskStatus json representation 'taskStatus'
/// ProgressUpdates has a mixed Task & double json representation 'progress'
@override
Future<Map<String, String>> popUndeliveredData(Undelivered dataType) async {
final String jsonString = await switch (dataType) {
Undelivered.resumeData => methodChannel.invokeMethod('popResumeData'),
Undelivered.statusUpdates => methodChannel.invokeMethod(
'popStatusUpdates',
),
Undelivered.progressUpdates => methodChannel.invokeMethod(
'popProgressUpdates',
),
};
return Map.from(jsonDecode(jsonString));
}
@override
Future<String?> moveToSharedStorage(
String filePath,
SharedStorage destination,
String directory,
String? mimeType, {
bool asUriString = false,
}) => methodChannel.invokeMethod<String?>('moveToSharedStorage', [
filePath,
destination.index,
directory,
mimeType,
asUriString,
]);
@override
Future<String?> pathInSharedStorage(
String filePath,
SharedStorage destination,
String directory, {
bool asUriString = false,
}) => methodChannel.invokeMethod<String?>('pathInSharedStorage', [
filePath,
destination.index,
directory,
asUriString,
]);
@override
Future<bool> openFile(Task? task, String? filePath, String? mimeType) async {
final result = await methodChannel.invokeMethod<bool>('openFile', [
if (task != null) jsonEncode(task.toJson()) else null,
filePath,
mimeType,
]);
return result ?? false;
}
@override
Future<String> platformVersion() async =>
(await methodChannel.invokeMethod<String>('platformVersion')) ?? '';
@override
Future<Duration> getTaskTimeout() async {
if (defaultTargetPlatform == TargetPlatform.android) {
final timeoutMillis =
await methodChannel.invokeMethod<int>('getTaskTimeout') ?? 0;
return Duration(milliseconds: timeoutMillis);
}
return const Duration(hours: 4); // on iOS, resource timeout
}
@override
Future<void> setForceFailPostOnBackgroundChannel(bool value) async {
await methodChannel.invokeMethod('forceFailPostOnBackgroundChannel', value);
}
@override
Future<String> testSuggestedFilename(
DownloadTask task,
String contentDisposition,
) async =>
await methodChannel.invokeMethod<String>('testSuggestedFilename', [
jsonEncode(task.toJson()),
contentDisposition,
]) ??
'';
@override
Future<(String, String)> configureItem((String, dynamic) configItem) async {
switch (configItem) {
case (Config.requestTimeout, final Duration? duration):
await NativeDownloader.methodChannel.invokeMethod(
'configRequestTimeout',
duration?.inSeconds,
);
case (Config.proxy, (final String address, final int port)):
await NativeDownloader.methodChannel.invokeMethod(
'configProxyAddress',
address,
);
await NativeDownloader.methodChannel.invokeMethod(
'configProxyPort',
port,
);
case (Config.proxy, false):
await NativeDownloader.methodChannel.invokeMethod(
'configProxyAddress',
null,
);
await NativeDownloader.methodChannel.invokeMethod(
'configProxyPort',
null,
);
case (Config.checkAvailableSpace, final int minimum):
assert(minimum > 0, 'Minimum available space must be in MB and > 0');
await NativeDownloader.methodChannel.invokeMethod(
'configCheckAvailableSpace',
minimum,
);
case (Config.checkAvailableSpace, false):
case (Config.checkAvailableSpace, Config.never):
await NativeDownloader.methodChannel.invokeMethod(
'configCheckAvailableSpace',
null,
);
case (
Config.holdingQueue,
(
final int? maxConcurrent,
final int? maxConcurrentByHost,
final int? maxConcurrentByGroup,
),
):
await NativeDownloader.methodChannel.invokeMethod(
'configHoldingQueue',
[
maxConcurrent ?? 1 << 20,
maxConcurrentByHost ?? 1 << 20,
maxConcurrentByGroup ?? 1 << 20,
],
);
case (Config.holdingQueue, Config.never):
case (Config.holdingQueue, false):
await NativeDownloader.methodChannel.invokeMethod(
'configHoldingQueue',
[],
);
case (Config.skipExistingFiles, final int value):
await NativeDownloader.methodChannel.invokeMethod(
'configSkipExistingFiles',
value,
);
case (Config.skipExistingFiles, Config.never):
case (Config.skipExistingFiles, false):
await NativeDownloader.methodChannel.invokeMethod(
'configSkipExistingFiles',
-1,
);
case (Config.skipExistingFiles, Config.always):
case (Config.skipExistingFiles, true):
await NativeDownloader.methodChannel.invokeMethod(
'configSkipExistingFiles',
0,
);
default:
return (
configItem.$1,
'not implemented',
); // this method did not process this configItem
}
return (configItem.$1, ''); // normal result
}
}
/// Android native downloader
final class AndroidDownloader extends NativeDownloader {
static final AndroidDownloader _singleton = AndroidDownloader._internal();
static int? _callbackDispatcherRawHandle;
factory AndroidDownloader() => _singleton;
AndroidDownloader._internal();
@override
Future<bool> enqueue(Task task) async {
await _registerCallbackDispatcher(task);
return super.enqueue(task);
}
@override
Future<List<bool>> enqueueAll(Iterable<Task> tasks) async {
for (final task in tasks) {
await _registerCallbackDispatcher(task);
}
return super.enqueueAll(tasks);
}
/// On Android, need to register [_callbackDispatcherRawHandle] upon first
/// encounter of a task with callbacks
Future<void> _registerCallbackDispatcher(Task task) async {
// on Android, need to register [_callbackDispatcherRawHandle] upon first
// encounter of a task with callbacks
if (task.options?.hasCallback == true &&
_callbackDispatcherRawHandle == null) {
final rawHandle = PluginUtilities.getCallbackHandle(
initCallbackDispatcher,
)?.toRawHandle();
if (rawHandle != null) {
final success = await NativeDownloader.methodChannel.invokeMethod<bool>(
'registerCallbackDispatcher',
rawHandle,
);
if (success == true) {
_callbackDispatcherRawHandle = rawHandle;
log.fine('Registered callbackDispatcher with handle $rawHandle');
} else {
log.warning('Could not register callbackDispatcher');
}
} else {
log.warning('Could not obtain rawHandle for initCallbackDispatcher');
}
}
}
@override
dynamic platformConfig({
dynamic globalConfig,
dynamic androidConfig,
dynamic iOSConfig,
dynamic desktopConfig,
}) => androidConfig;
@override
Future<(String, String)> configureItem((String, dynamic) configItem) async {
final superResult = await super.configureItem(configItem);
if (superResult.$2 != 'not implemented') {
return superResult;
}
switch (configItem) {
case (Config.runInForeground, final bool activate):
await NativeDownloader.methodChannel.invokeMethod(
'configForegroundFileSize',
activate ? 0 : -1,
);
case (Config.runInForeground, final String whenTo):
assert(
[Config.never, Config.always].contains(whenTo),
'${Config.runInForeground} expects one of ${[Config.never, Config.always]}',
);
await NativeDownloader.methodChannel.invokeMethod(
'configForegroundFileSize',
Config.argToInt(whenTo),
);
case (Config.runInForegroundIfFileLargerThan, final int fileSize):
await NativeDownloader.methodChannel.invokeMethod(
'configForegroundFileSize',
fileSize,
);
case (Config.bypassTLSCertificateValidation, final bool bypass):
if (bypass) {
if (kReleaseMode) {
throw ArgumentError(
'You cannot bypass certificate validation in release mode',
);
}
await NativeDownloader.methodChannel.invokeMethod(
'configBypassTLSCertificateValidation',
);
log.warning(
'TLS certificate validation is bypassed. This is insecure and cannot be '
'done in release mode',
);
} else {
throw ArgumentError(
'To undo bypassing the certificate validation, '
'restart and leave out the "configBypassCertificateValidation" configuration',
);
}
case (Config.useCacheDir, final String whenTo):
assert(
[Config.never, Config.whenAble, Config.always].contains(whenTo),
'${Config.useCacheDir} expects one of ${[Config.never, Config.whenAble, Config.always]}',
);
await NativeDownloader.methodChannel.invokeMethod(
'configUseCacheDir',
Config.argToInt(whenTo),
);
case (Config.useExternalStorage, final String whenTo):
assert(
[Config.never, Config.always].contains(whenTo),
'${Config.useExternalStorage} expects one of ${[Config.never, Config.always]}',
);
await NativeDownloader.methodChannel.invokeMethod(
'configUseExternalStorage',
Config.argToInt(whenTo),
);
Task.useExternalStorage = whenTo == Config.always;
case (Config.tempFilePath, final String path):
final cleanPath = (path.isNotEmpty && path != Config.never)
? path
: null;
await NativeDownloader.methodChannel.invokeMethod(
'configTempFilePath',
cleanPath,
);
case (Config.tempFilePath, null):
case (Config.tempFilePath, false):
await NativeDownloader.methodChannel.invokeMethod(
'configTempFilePath',
null,
);
default:
return (
configItem.$1,
'not implemented',
); // this method did not process this configItem
}
return (configItem.$1, ''); // normal result
}
}
/// iOS native downloader
final class IOSDownloader extends NativeDownloader {
static final IOSDownloader _singleton = IOSDownloader._internal();
factory IOSDownloader() => _singleton;
IOSDownloader._internal();
/// On iOS we immediately initialize the callback dispatcher and start
/// listening for callback messages from the native side
///
/// Whereas on Android, the initialization is done directly by the native side
/// using a DartExecutor
@override
Future<void> initialize() async {
initCallbackDispatcher();
return super.initialize();
}
@override
dynamic platformConfig({
dynamic globalConfig,
dynamic androidConfig,
dynamic iOSConfig,
dynamic desktopConfig,
}) => iOSConfig;
@override
Future<(String, String)> configureItem((String, dynamic) configItem) async {
final superResult = await super.configureItem(configItem);
if (superResult.$2 != 'not implemented') {
return superResult;
}
switch (configItem) {
case (Config.resourceTimeout, final Duration? duration):
await NativeDownloader.methodChannel.invokeMethod(
'configResourceTimeout',
duration?.inSeconds,
);
case (Config.localize, final Map<String, String>? translation):
await NativeDownloader.methodChannel.invokeMethod(
'configLocalize',
translation,
);
case (Config.excludeFromCloudBackup, final dynamic exclude):
assert(
exclude is bool || [Config.always, Config.never].contains(exclude),
'${Config.excludeFromCloudBackup} expects one of ${['true', 'false', Config.never, Config.always]}',
);
final boolValue = (exclude == true || exclude == Config.always);
await NativeDownloader.methodChannel.invokeMethod(
'configExcludeFromCloudBackup',
boolValue,
);
default:
return (
configItem.$1,
'not implemented',
); // this method did not process this configItem
}
return (configItem.$1, ''); // normal result
}
}
const _callbackChannel = MethodChannel(
'com.bbflight.background_downloader.callbacks',
);
/// Initialize the callbackDispatcher for native callbacks
///
/// Establishes the methodChannel through which the native side will send its
/// callBacks, and the listener that processes the different callback types.
///
/// This method is called directly from the native platform prior to using
/// the [_callbackChannel] to post the actual callback
@pragma('vm:entry-point')
void initCallbackDispatcher() {
WidgetsFlutterBinding.ensureInitialized();
_callbackChannel.setMethodCallHandler((MethodCall call) async {
switch (call.method) {
case 'beforeTaskStartCallback':
final taskJsonString = call.arguments as String;
final task = Task.createFromJson(jsonDecode(taskJsonString));
final callBack = task.options?.beforeTaskStartCallBack;
final taskStatusUpdate = await callBack?.call(task);
if (taskStatusUpdate == null) {
return null;
}
return jsonEncode(taskStatusUpdate.toJson());
case 'onTaskStartCallback':
case 'onAuthCallback':
final taskJsonString = call.arguments as String;
final task = Task.createFromJson(jsonDecode(taskJsonString));
final callBack = call.method == 'onTaskStartCallback'
? task.options?.onTaskStartCallBack
: task.options?.auth?.onAuthCallback;
final newTask = await callBack?.call(task);
if (newTask == null) {
return null;
}
return jsonEncode(newTask.toJson());
case 'onTaskFinishedCallback':
final taskUpdateJsonString = call.arguments as String;
final taskStatusUpdate = TaskStatusUpdate.fromJsonString(
taskUpdateJsonString,
);
final callBack = taskStatusUpdate.task.options?.onTaskFinishedCallBack;
await callBack?.call(taskStatusUpdate);
return null;
}
});
}
@@ -0,0 +1,370 @@
import 'dart:convert';
import 'dart:core';
import 'dart:io';
import 'dart:ui';
import 'package:http/http.dart' as http;
import 'package:logging/logging.dart';
import 'auth_callback.dart';
/// Provides an authentication handler for HTTP requests with support for access
/// and refresh tokens, customizable headers, and query parameters.
///
/// This class manages token-based authentication for HTTP requests, allowing
/// you to configure access and refresh tokens, as well as related headers and
/// query parameters. It supports automatic token refresh if an access token
/// expires and facilitates using a refresh token endpoint.
///
/// To use, :
/// - Create a [Task] as with a simple url, without query parameters, and
/// no headers. Query parameters and headers will be added based on the
/// [Auth] object, that you add to the task via [Task.options]. Include
/// [TaskOptions] with the [Auth] object as follows:
/// - Create an [Auth] object
/// - Add [accessQueryParams] containing all url query parameters. If your auth
/// uses query parameters for auth, then use `{accessToken}` to indicate where
/// the token should go, eg `{"auth":"{accessToken}"}` will add the access
/// token to the ur query parameter auth
/// - Add [accessHeaders] containing all headers required for the task. If your
/// auth uses headers, then use `{accessToken}` to indicate where the token
/// should go, eg `{"Authentication":" Bearer {accessToken}"}` will add a
/// typical authentication header for OAuth or JWT based requests.
/// - Add information for refresh, eg [refreshToken], [refreshUrl] if you want
/// to use the default handler
/// - If you use your own handler, add that as `onAuth` - otherwise the refresh
/// will be attempted using [defaultOnAuth]
///
/// For tasks that contain an [Auth] object, the background_downloader will
/// first check if the token is expired. If the token is expired, it will call
/// the `onAuth` callback, which should refresh the token by calling
/// [refreshToken], which will update the [Auth] associated with the task, then
/// return the modified task.
/// The downloader will substitute the `{accessToken}` placeholder with the
/// accessToken in both query parameters and headers before executing the task.
///
/// ### Key Properties
/// - [accessToken]: The current access token used for authentication.
/// - [refreshToken]: The refresh token used to obtain a new access token.
/// - [accessHeaders]: Headers to include in authenticated requests, with
/// optional `{accessToken}` placeholders for dynamic token insertion.
/// - [refreshHeaders]: Headers to include when making a refresh token request,
/// with option `{refreshToken}` placeholders for dynamic token insertion.
/// - [onAuth]: callback to be called before the task starts if access token is
/// expired. Callback should return a modified Task, or null to try with the
/// original
///
/// ### Key Methods
/// - [getAccessUri]: Returns the URI with authentication query parameters,
/// automatically refreshing the access token if needed.
/// - [getAccessHeaders]: Returns the headers to be used with the access
/// request, with the `{accessToken}` placeholder replaced
/// - [refreshAccessToken]: Refreshes the access token using the configured
/// refresh token and updates the expiry time. Called by [getAccessUri]
/// - [isTokenExpired]: Checks if the access token is nearing expiry.
///
/// ### Example
/// ```dart
/// final auth = Auth(
/// accessToken: 'initialAccessToken',
/// refreshToken: 'initialRefreshToken',
/// refreshUrl: 'https://example.com/token/refresh',
/// );
///
/// // Make a request with updated headers and query parameters
/// Uri uri = await auth.getAccessUri(url: 'https://example.com/data');
/// Map<String, String> headers = auth.getAccessHeaders();
/// ```
class Auth {
static final log = Logger('Auth');
/// The access token used for authentication
String? accessToken;
/// Headers to use for access requests. Can contain {accessToken} and
/// {refreshToken} which will be replaced by the actual tokens
Map<String, String> accessHeaders;
/// Query parameters to use for access requests. Can contain {accessToken} and
/// {refreshToken} which will be replaced by the actual tokens
Map<String, String> accessQueryParams;
/// Time when the access token expires
DateTime? accessTokenExpiryTime;
/// The refresh token used to obtain a new access token
String? refreshToken;
/// Headers to use for refresh requests. Can contain {accessToken} and
/// {refreshToken} which will be replaced by the actual tokens
Map<String, String> refreshHeaders;
/// Query parameters to use for refresh requests. Can contain {accessToken} and
/// {refreshToken} which will be replaced by the actual tokens
Map<String, String> refreshQueryParams;
/// The URL to use for refreshing the token
String? refreshUrl;
int? _onAuthRawHandle; // for callback
/// Creates an [Auth] object
///
/// [accessToken] is the initial access token
/// [accessHeaders] are headers to use for access requests
/// [accessQueryParams] are query parameters to use for access requests
/// [accessTokenExpiryTime] is the time when the access token expires
/// [refreshToken] is the initial refresh token
/// [refreshUrl] is the URL to use for refreshing the token
/// [refreshHeaders] are headers to use for refresh requests
/// [refreshQueryParams] are query parameters to use for refresh requests
/// [onAuth] is a callback to be called when the access token expires
Auth({
this.accessToken,
this.accessHeaders = const {},
this.accessQueryParams = const {},
this.accessTokenExpiryTime,
this.refreshToken,
this.refreshUrl,
this.refreshHeaders = const {},
this.refreshQueryParams = const {},
OnAuthCallback? onAuth,
}) : _onAuthRawHandle = onAuth != null
? PluginUtilities.getCallbackHandle(onAuth)?.toRawHandle()
: null;
/// Convert the Auth instance to JSON
Map<String, dynamic> toJson() => {
'accessToken': accessToken,
'accessHeaders': accessHeaders,
'accessQueryParams': accessQueryParams,
'accessTokenExpiryTime': accessTokenExpiryTime?.millisecondsSinceEpoch,
'refreshToken': refreshToken,
'refreshUrl': refreshUrl,
'refreshHeaders': refreshHeaders,
'refreshQueryParams': refreshQueryParams,
'onAuthRawHandle': _onAuthRawHandle,
};
/// Create an Auth instance from JSON
Auth.fromJson(Map<String, dynamic> json)
: accessToken = json['accessToken'],
accessHeaders = json['accessHeaders'] != null
? Map<String, String>.from(json['accessHeaders'])
: const {},
accessQueryParams = json['accessQueryParams'] != null
? Map<String, String>.from(json['accessQueryParams'])
: const {},
accessTokenExpiryTime = json['accessTokenExpiryTime'] != null
? DateTime.fromMillisecondsSinceEpoch(
json['accessTokenExpiryTime'] as int,
)
: null,
refreshToken = json['refreshToken'],
refreshUrl = json['refreshUrl'],
refreshHeaders = json['refreshHeaders'] != null
? Map<String, String>.from(json['refreshHeaders'])
: const {},
refreshQueryParams = json['refreshQueryParams'] != null
? Map<String, String>.from(json['refreshQueryParams'])
: const {},
_onAuthRawHandle = json['onAuthRawHandle'] as int?;
/// Returns the [OnAuthCallback] registered with this [Auth], or null
OnAuthCallback? get onAuthCallback => _onAuthRawHandle != null
? PluginUtilities.getCallbackFromHandle(
CallbackHandle.fromRawHandle(_onAuthRawHandle!),
) as OnAuthCallback
: null;
/// Set the [OnAuthCallback]
set onAuthCallback(OnAuthCallback? onAuth) =>
_onAuthRawHandle = onAuth != null
? PluginUtilities.getCallbackHandle(onAuth)?.toRawHandle()
: null;
/// Returns the Uri for accessing the resource
///
/// Refreshes the [accessToken] if required, and returns the original
/// [url] or [uri] with access query parameters. If headers are used to
/// authenticate, then call [getAccessHeaders] after this call, to make sure
/// they contain updated access tokens.
Future<Uri> getAccessUri({
String? url,
Uri? uri,
http.Client? httpClient,
String? refreshUrl,
Uri? refreshUri,
}) async {
if (uri == null && url == null) {
throw ArgumentError('Either uri or url must be provided');
}
final accessUri = uri ?? Uri.parse(url!);
if (isTokenExpired()) {
// make the refresh request
final (updatedAccesstoken, _) = await refreshAccessToken(
httpClient: httpClient,
refreshUrl: refreshUrl,
refreshUri: refreshUri,
);
if (!updatedAccesstoken) {
throw const HttpException('Could not refresh access token');
}
}
return addOrUpdateQueryParams(
uri: accessUri,
queryParams: getAccessQueryParams(),
);
}
/// Returns the URL string for accessing the resource
///
/// Refreshes the [accessToken] if required, and returns the original
/// [url] or [uri] with access query parameters. If headers are used to
/// authenticate, then call [getAccessHeaders] after this call, to make sure
/// they contain updated access tokens.
Future<String> getAccessUrl({
String? url,
Uri? uri,
http.Client? httpClient,
String? refreshUrl,
Uri? refreshUri,
}) async {
final accessUri = await getAccessUri(
url: url,
uri: uri,
httpClient: httpClient,
refreshUrl: refreshUrl,
refreshUri: refreshUri,
);
return accessUri.toString();
}
/// Refresh the [accessToken] by calling [refreshUrl] or [refreshUri] using
/// [httpClient], adding [refreshHeaders] and [refreshQueryParams]
/// to the request and obtaining the token from the json response
/// 'access_token' and potential 'expires_in'.
/// If the response contains 'refresh_token' that will be updated as well.
///
/// Returns (bool updatedAccessToken, bool updatedRefreshToken)
///
/// If neither [refreshUrl] nor [refreshUri] are given, the Auth object's
/// [Auth.refreshUrl] is used
Future<(bool, bool)> refreshAccessToken({
http.Client? httpClient,
String? refreshUrl,
Uri? refreshUri,
}) async {
if (refreshUrl == null && refreshUri == null && this.refreshUrl == null) {
throw ArgumentError(
'refreshUrl, refreshUri or Auth object refreshUrl required',
);
}
var updatedAccessToken = false;
var updatedRefreshToken = false;
final client = httpClient ?? http.Client();
try {
final selectedRefreshUri =
refreshUri ?? Uri.parse(refreshUrl ?? this.refreshUrl!);
final uri = addOrUpdateQueryParams(
uri: selectedRefreshUri,
queryParams: expandMap(refreshQueryParams),
);
final headers = expandMap(refreshHeaders);
headers['Content-type'] = 'application/json';
final response = await client.post(
uri,
headers: headers,
body: jsonEncode({
'grant_type': 'refresh_token',
'refresh_token': refreshToken,
}),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final newAuthToken = data['access_token'];
if (newAuthToken != null) {
accessToken = newAuthToken;
updatedAccessToken = true;
final newExpiresIn = data['expires_in'];
if (newExpiresIn != null) {
accessTokenExpiryTime = DateTime.now().add(
Duration(seconds: newExpiresIn),
);
}
} else {
log.fine(
'Failed to refresh access token: no access_token in response body.\n'
'Body is ${response.body}',
);
}
final newRefreshToken = data['refresh_token'];
if (newRefreshToken != null) {
refreshToken = newRefreshToken;
updatedRefreshToken = true;
}
} else {
log.fine('Failed to refresh token: ${response.statusCode}');
}
} catch (e) {
log.warning('Error refreshing token: $e');
}
return (updatedAccessToken, updatedRefreshToken);
}
/// Returns the headers specified for the access request, with the
/// template {accessToken} and {refreshToken} replaced
Map<String, String> getAccessHeaders() => expandMap(accessHeaders);
/// Returns the query params specified for the access request, with the
/// template {accessToken} and {refreshToken} replaced
Map<String, String> getAccessQueryParams() => expandMap(accessQueryParams);
/// Add/update the query parameters in this [url] or [uri] with [queryParams]
/// and return the new uri
Uri addOrUpdateQueryParams({
String? url,
Uri? uri,
Map<String, String> queryParams = const {},
}) {
if (uri == null && url == null) {
throw ArgumentError('Either uri or url must be provided');
}
final startUri = uri ?? Uri.parse(url!);
if (queryParams.isEmpty) {
return startUri;
}
final updatedQueryParams = Map<String, String>.from(
startUri.queryParameters,
);
queryParams.forEach((key, value) {
updatedQueryParams[key] = value;
});
return startUri.replace(queryParameters: updatedQueryParams);
}
/// Returns true if the [accessTokenExpiryTime is after now plus
/// the [bufferTime], otherwise returns false
bool isTokenExpired({Duration bufferTime = const Duration(seconds: 10)}) {
if (accessTokenExpiryTime == null) return false;
return DateTime.now().add(bufferTime).isAfter(accessTokenExpiryTime!);
}
/// Expands the [mapToExpand] by replacing {accessToken} and {refreshToken}
///
/// Returns the expanded map, without changing the original
Map<String, String> expandMap(Map<String, String> mapToExpand) {
final (access, refresh) = (accessToken, refreshToken);
final newMap = <String, String>{};
mapToExpand.forEach((key, value) {
var newValue = value;
if (access != null) {
newValue = newValue.replaceAll('{accessToken}', access);
}
if (refresh != null) {
newValue = newValue.replaceAll('{refreshToken}', refresh);
}
newMap[key] = newValue;
});
return newMap;
}
}
@@ -0,0 +1,26 @@
import 'dart:io';
import '../task.dart';
typedef OnAuthCallback = Future<Task?> Function(Task original);
/// Default onAuth callback
///
/// Called when a task starts and the task has an auth object and the
/// object indicated that the access token has expired.
/// The callback attempts to refresh the access token using the information
/// contained in the [Auth] object, and returns the task, which now contains an
/// updated [Auth] object
@pragma("vm:entry-point")
Future<Task?> defaultOnAuth(Task task) async {
final auth = task.options?.auth;
if (auth == null) {
throw ArgumentError('Task has no auth object');
}
final (updatedAccessToken, updatedRefreshToken) = await auth
.refreshAccessToken();
if (!updatedAccessToken) {
throw const HttpException('Could not refresh access token');
}
return task;
}
@@ -0,0 +1,146 @@
import 'dart:io';
/// Holds configuration options for mutual TLS (mTLS) client authentication.
///
/// Use with [Config.mTLS] to configure client certificate authentication for
/// HTTPS connections on desktop platforms.
///
/// Provide either file paths ([certificatePath], [privateKeyPath]) or raw bytes
/// ([certificateBytes], [privateKeyBytes]) for the client certificate and private key.
/// An optional private key [password] can be supplied if required.
///
/// An optional [host] can be specified to restrict this configuration to a specific host
/// (e.g. `'api.example.com'`). If [host] is `null`, the mTLS configuration applies globally
/// (or as the default for all hosts).
///
/// To reset/clear mTLS for a host or globally, pass an [MTLSConfig] instance with no credentials
/// (or `null`/`false` in configuration).
final class MTLSConfig {
/// Optional host (domain or IP) to which this mTLS configuration applies.
/// If null, applies to all hosts / default.
final String? host;
/// Path to client certificate / chain file (PEM or DER format).
final String? certificatePath;
/// Bytes of client certificate / chain file (PEM or DER format).
final List<int>? certificateBytes;
/// Path to private key file (PEM format).
final String? privateKeyPath;
/// Bytes of private key file (PEM format).
final List<int>? privateKeyBytes;
/// Optional password for decrypting the private key or certificate.
final String? password;
/// Optional path to trusted server / CA certificate file.
final String? serverCertificatePath;
/// Optional bytes of trusted server / CA certificate file.
final List<int>? serverCertificateBytes;
/// Creates an [MTLSConfig] instance.
///
/// Asserts that parameters are legally combined (e.g. not providing both file path
/// and raw bytes for the same asset, and ensuring both certificate and key are provided
/// when configuring credentials).
const MTLSConfig({
this.host,
this.certificatePath,
this.certificateBytes,
this.privateKeyPath,
this.privateKeyBytes,
this.password,
this.serverCertificatePath,
this.serverCertificateBytes,
}) : assert(
!(certificatePath != null && certificateBytes != null),
'Cannot provide both certificatePath and certificateBytes',
),
assert(
!(privateKeyPath != null && privateKeyBytes != null),
'Cannot provide both privateKeyPath and privateKeyBytes',
),
assert(
!(serverCertificatePath != null && serverCertificateBytes != null),
'Cannot provide both serverCertificatePath and serverCertificateBytes',
),
assert(
((certificatePath != null || certificateBytes != null) &&
(privateKeyPath != null || privateKeyBytes != null)) ||
((certificatePath == null && certificateBytes == null) &&
(privateKeyPath == null && privateKeyBytes == null)),
'Both certificate and privateKey must be provided together, or both omitted for reset',
);
/// Returns true if client certificate and key credentials are specified.
bool get hasCredentials =>
(certificatePath != null || certificateBytes != null) &&
(privateKeyPath != null || privateKeyBytes != null);
/// Returns true if this configuration is a reset configuration (no credentials).
bool get isReset => !hasCredentials;
/// Applies this mTLS configuration to the given [context].
void applyToSecurityContext(SecurityContext context) {
if (certificatePath != null) {
context.useCertificateChain(certificatePath!, password: password);
} else if (certificateBytes != null) {
context.useCertificateChainBytes(certificateBytes!, password: password);
}
if (privateKeyPath != null) {
context.usePrivateKey(privateKeyPath!, password: password);
} else if (privateKeyBytes != null) {
context.usePrivateKeyBytes(privateKeyBytes!, password: password);
}
if (serverCertificatePath != null) {
context.setTrustedCertificates(
serverCertificatePath!,
password: password,
);
} else if (serverCertificateBytes != null) {
context.setTrustedCertificatesBytes(
serverCertificateBytes!,
password: password,
);
}
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is MTLSConfig &&
runtimeType == other.runtimeType &&
host == other.host &&
certificatePath == other.certificatePath &&
_listEquals(certificateBytes, other.certificateBytes) &&
privateKeyPath == other.privateKeyPath &&
_listEquals(privateKeyBytes, other.privateKeyBytes) &&
password == other.password &&
serverCertificatePath == other.serverCertificatePath &&
_listEquals(serverCertificateBytes, other.serverCertificateBytes);
@override
int get hashCode =>
host.hashCode ^
certificatePath.hashCode ^
Object.hashAll(certificateBytes ?? []) ^
privateKeyPath.hashCode ^
Object.hashAll(privateKeyBytes ?? []) ^
password.hashCode ^
serverCertificatePath.hashCode ^
Object.hashAll(serverCertificateBytes ?? []);
}
bool _listEquals(List<int>? a, List<int>? b) {
if (a == null) return b == null;
if (b == null || a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
@@ -0,0 +1,116 @@
import 'dart:ui';
import '../models.dart';
import '../task.dart';
import 'auth.dart';
typedef BeforeTaskStartCallback = Future<TaskStatusUpdate?> Function(Task task);
typedef OnTaskStartCallback = Future<Task?> Function(Task original);
typedef OnTaskFinishedCallback = Future<void> Function(
TaskStatusUpdate taskStatusUpdate,
);
/// Holds various options related to the task that are not included in the
/// task's properties, as they are rare
class TaskOptions {
final int? _beforeTaskStartRawHandle;
final int? _onTaskStartRawHandle;
final int? _onTaskFinishedRawHandle;
/// The [Auth] object associated with this task
final Auth? auth;
/// Constructor for [TaskOptions], containing "native" callbacks:
///
/// * `beforeTaskStart`: a callback called before a task starts executing.
/// The callback receives the `Task` and returns `null` if the task should
/// continue, or a `TaskStatusUpdate` if it should not start - in which
/// case the `TaskStatusUpdate` is posted as the last state update for the task
/// * `onTaskStart`: a callback called before a task starts executing.
/// The callback receives the `Task` and returns `null` if it did not
/// change anything, or a modified `Task` if it needs to use a different
/// url or header. It is called after `onAuth` for token refresh, if that is set
/// * `onTaskFinished`: a callback called when the task has finished.
/// The callback receives the final `TaskStatusUpdate`.
/// * `auth`: an [Auth] object that facilitates management of authorization
/// tokens and refresh tokens, and includes an `onAuth` callback similar to
/// `onTaskStart`
TaskOptions({
BeforeTaskStartCallback? beforeTaskStart,
OnTaskStartCallback? onTaskStart,
OnTaskFinishedCallback? onTaskFinished,
this.auth,
}) : _beforeTaskStartRawHandle = beforeTaskStart != null
? PluginUtilities.getCallbackHandle(beforeTaskStart)?.toRawHandle()
: null,
_onTaskStartRawHandle = onTaskStart != null
? PluginUtilities.getCallbackHandle(onTaskStart)?.toRawHandle()
: null,
_onTaskFinishedRawHandle = onTaskFinished != null
? PluginUtilities.getCallbackHandle(onTaskFinished)?.toRawHandle()
: null {
assert(
beforeTaskStart == null || _beforeTaskStartRawHandle != null,
_notTopLevel('beforeTaskStart'),
);
assert(
onTaskStart == null || _onTaskStartRawHandle != null,
_notTopLevel('onTaskStart'),
);
assert(
onTaskFinished == null || _onTaskFinishedRawHandle != null,
_notTopLevel('onTaskFinished'),
);
}
static String _notTopLevel(String callbackName) =>
'$callbackName callback must be a top level or static function, '
'and marked with @pragma("vm:entry-point")';
/// Create the object from JSON
TaskOptions.fromJson(Map<String, dynamic> json)
: _beforeTaskStartRawHandle = json['beforeTaskStartRawHandle'] as int?,
_onTaskStartRawHandle = json['onTaskStartRawHandle'] as int?,
_onTaskFinishedRawHandle = json['onTaskFinishedRawHandle'] as int?,
auth = json['auth'] != null ? Auth.fromJson(json['auth']) : null;
/// Returns the [BeforeTaskStartCallback] registered with this [TaskOptions], or null
BeforeTaskStartCallback? get beforeTaskStartCallBack =>
_beforeTaskStartRawHandle != null
? PluginUtilities.getCallbackFromHandle(
CallbackHandle.fromRawHandle(_beforeTaskStartRawHandle),
) as BeforeTaskStartCallback
: null;
/// Returns the [OnTaskStartCallback] registered with this [TaskOptions], or null
OnTaskStartCallback? get onTaskStartCallBack => _onTaskStartRawHandle != null
? PluginUtilities.getCallbackFromHandle(
CallbackHandle.fromRawHandle(_onTaskStartRawHandle),
) as OnTaskStartCallback
: null;
/// Returns the [OnTaskFinishedCallback] registered with this [TaskOptions], or null
OnTaskFinishedCallback? get onTaskFinishedCallBack =>
_onTaskFinishedRawHandle != null
? PluginUtilities.getCallbackFromHandle(
CallbackHandle.fromRawHandle(_onTaskFinishedRawHandle),
) as OnTaskFinishedCallback
: null;
/// True if [TaskOptions] contains any callback
bool get hasCallback =>
beforeTaskStartCallBack != null ||
onTaskStartCallBack != null ||
onTaskFinishedCallBack != null ||
auth?.onAuthCallback != null;
/// Creates JSON map of this object
Map<String, dynamic> toJson() => {
'beforeTaskStartRawHandle': _beforeTaskStartRawHandle,
'onTaskStartRawHandle': _onTaskStartRawHandle,
'onTaskFinishedRawHandle': _onTaskFinishedRawHandle,
'auth': auth?.toJson(),
};
}
@@ -0,0 +1,144 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
import 'native_downloader.dart';
enum PermissionType {
notifications,
androidSharedStorage,
iosAddToPhotoLibrary,
iosChangePhotoLibrary,
}
enum PermissionStatus { undetermined, denied, granted, partial, requestError }
/// Developer visible interface to the PermissionService
abstract interface class Permissions {
/// Request a permission; returns the [PermissionStatus]
///
/// Ensure that only one request is being handled at the same time,
/// i.e. wait for the result before requesting another permission
Future<PermissionStatus> request(PermissionType permissionType);
/// Get the auth status of a permission; returns the [PermissionStatus]
Future<PermissionStatus> status(PermissionType permissionType);
/// Returns true if the developer should show a rationale for
/// requesting this permission
Future<bool> shouldShowRationale(PermissionType permissionType);
}
/// Basic implementation of the PermissionsService
base class PermissionsService implements Permissions {
final log = Logger("PermissionService");
PermissionsService();
/// Creates a [PermissionsService] appropriate for this platform
factory PermissionsService.instance() => switch (defaultTargetPlatform) {
.android => AndroidPermissionsService(),
.iOS => IOSPermissionsService(),
.linux || .macOS || .windows => PermissionsService(),
_ => throw ArgumentError(
'Platform $defaultTargetPlatform is not supported',
),
};
@override
Future<PermissionStatus> request(PermissionType permissionType) =>
Future.value(PermissionStatus.granted);
@override
Future<bool> shouldShowRationale(PermissionType permissionType) =>
Future.value(false);
@override
Future<PermissionStatus> status(PermissionType permissionType) =>
Future.value(PermissionStatus.granted);
/// Process the permissionRequest result sent from the native side
/// (Android only)
void onPermissionRequestResult(PermissionStatus permissionStatus) {}
}
final class IOSPermissionsService extends PermissionsService {
IOSPermissionsService();
@override
Future<PermissionStatus> request(PermissionType permissionType) async {
final result = await NativeDownloader.methodChannel.invokeMethod<int>(
'requestPermission',
permissionType.index,
);
return result != null
? PermissionStatus.values[result]
: PermissionStatus.requestError;
}
@override
Future<PermissionStatus> status(PermissionType permissionType) async {
final result = await NativeDownloader.methodChannel.invokeMethod<int>(
'permissionStatus',
permissionType.index,
);
return result != null
? PermissionStatus.values[result]
: PermissionStatus.requestError;
}
}
final class AndroidPermissionsService extends IOSPermissionsService {
Completer<PermissionStatus>? permissionStatusCompleter;
@override
Future<PermissionStatus> request(PermissionType permissionType) async {
if (![
PermissionType.notifications,
PermissionType.androidSharedStorage,
].contains(permissionType)) {
return PermissionStatus.granted;
}
if (permissionStatusCompleter != null) {
log.warning(
'Permission request already in progress - failing this one immediately',
);
return PermissionStatus.requestError;
}
final request = Completer<PermissionStatus>();
permissionStatusCompleter = request;
try {
final waitForCompletion = await NativeDownloader.methodChannel
.invokeMethod<bool>('requestPermission', permissionType.index);
if (waitForCompletion == true) {
// The callback can clear the shared slot before invokeMethod replies.
return await request.future;
}
log.warning(
'Failure to request for permission - was it already granted?',
);
return PermissionStatus.requestError;
} finally {
// Release only this request's slot, including platform dispatch errors.
if (identical(permissionStatusCompleter, request)) {
permissionStatusCompleter = null;
}
}
}
@override
Future<bool> shouldShowRationale(PermissionType permissionType) async {
final result = await NativeDownloader.methodChannel.invokeMethod<bool>(
'shouldShowPermissionRationale',
permissionType.index,
);
return result ?? false;
}
@override
void onPermissionRequestResult(PermissionStatus permissionStatus) {
permissionStatusCompleter?.complete(permissionStatus);
permissionStatusCompleter = null;
}
}
@@ -0,0 +1,690 @@
import 'dart:async';
import 'dart:io';
import 'dart:isolate';
import 'package:flutter/services.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'base_downloader.dart';
import 'database.dart';
import 'localstore/localstore.dart';
import 'models.dart';
import 'task.dart';
/// Interface for the persistent storage used to back the downloader
///
/// Defines 'store', 'retrieve', 'retrieveAll' and 'remove' methods for:
/// - [TaskRecord]s, keyed by taskId
/// - paused [Task]s, keyed by taskId
/// - [ResumeData], keyed by taskId
///
/// Each of the objects has a toJson method and can be created using
/// fromJson (use .createFromJson for [Task] objects)
///
/// Also defined methods to allow migration from one database version to another
abstract interface class PersistentStorage {
/// Store a [TaskRecord], keyed by taskId
Future<void> storeTaskRecord(TaskRecord record);
/// Retrieve [TaskRecord] with [taskId], or null if not found
Future<TaskRecord?> retrieveTaskRecord(String taskId);
/// Retrieve all [TaskRecord]
Future<List<TaskRecord>> retrieveAllTaskRecords();
/// Remove [TaskRecord] with [taskId] from storage. If null, remove all
Future<void> removeTaskRecord(String? taskId);
/// Store a paused [task], keyed by taskId
Future<void> storePausedTask(Task task);
/// Retrieve paused [Task] with [taskId], or null if not found
Future<Task?> retrievePausedTask(String taskId);
/// Retrieve all paused [Task]
Future<List<Task>> retrieveAllPausedTasks();
/// Remove paused [Task] with [taskId] from storage. If null, remove all
Future<void> removePausedTask(String? taskId);
/// Store [ResumeData], keyed by its taskId
Future<void> storeResumeData(ResumeData resumeData);
/// Retrieve [ResumeData] with [taskId], or null if not found
Future<ResumeData?> retrieveResumeData(String taskId);
/// Retrieve all [ResumeData]
Future<List<ResumeData>> retrieveAllResumeData();
/// Remove [ResumeData] with [taskId] from storage. If null, remove all
Future<void> removeResumeData(String? taskId);
/// Name and version number for this type of persistent storage
///
/// Used for database migration: this is the version represented by the code
(String, int) get currentDatabaseVersion;
/// Name and version number for database as stored
///
/// Used for database migration, may be 'older' than the code version
Future<(String, int)> get storedDatabaseVersion;
/// Initialize the database - only called when the [BaseDownloader]
/// is created with this object, which happens when the [FileDownloader]
/// singleton is instantiated, OR as part of a migration away from this
/// database type.
///
/// Migrates the data from stored name and version to the current
/// name and version, if needed
/// This call runs async with the rest of the initialization
Future<void> initialize();
}
enum _StorageCommand {
getStoredDatabaseVersion,
initialize,
storeTaskRecord,
retrieveTaskRecord,
retrieveAllTaskRecords,
removeTaskRecord,
storePausedTask,
retrievePausedTask,
retrieveAllPausedTasks,
removePausedTask,
storeResumeData,
retrieveResumeData,
retrieveAllResumeData,
removeResumeData,
retrieveAll,
clearCache,
}
/// Default implementation of [PersistentStorage] using Localstore package
///
/// Runs the actual [Localstore] based storage on a background isolate to
/// prevent jank on the main thread
class LocalStorePersistentStorage implements PersistentStorage {
static const taskRecordsPath =
_LocalStorePersistentStorageExecutor.taskRecordsPath;
static const resumeDataPath =
_LocalStorePersistentStorageExecutor.resumeDataPath;
static const pausedTasksPath =
_LocalStorePersistentStorageExecutor.pausedTasksPath;
final log = Logger('LocalStorePersistentStorage');
SendPort? _sendPort;
final _responseCompleters = <int, Completer<dynamic>>{};
int _nextRequestId = 0;
@override
(String, int) get currentDatabaseVersion => ('Localstore', 1);
@override
Future<(String, int)> get storedDatabaseVersion async {
final result = await _sendRequest<List<dynamic>>(
_StorageCommand.getStoredDatabaseVersion,
[],
);
return (result[0] as String, result[1] as int);
}
Future<void>? _initializationFuture;
@override
Future<void> initialize() {
if (_sendPort != null) {
return Future.value();
}
_initializationFuture ??= _doInitialize();
return _initializationFuture!;
}
Future<void> _doInitialize() async {
try {
final receivePort = ReceivePort();
await Isolate.spawn(_isolateEntry, receivePort.sendPort);
_sendPort = await receivePort.first as SendPort;
// Listen for responses
final responsePort = ReceivePort();
_sendPort!.send(responsePort.sendPort); // Send response port to isolate
responsePort.listen(_handleResponse);
// Initialize the executor in the isolate
// pass the RootIsolateToken to allow background isolate to use platform channels
// for path_provider
final rootIsolateToken = RootIsolateToken.instance;
await _sendRequest(_StorageCommand.initialize, [rootIsolateToken]);
} catch (e) {
_initializationFuture = null; // allow retry
rethrow;
}
}
void _handleResponse(dynamic message) {
if (message is List && message.length >= 2) {
final requestId = message[0] as int;
final response = message[1];
final error = message.length > 2 ? message[2] : null;
final completer = _responseCompleters.remove(requestId);
if (completer != null) {
if (error != null) {
completer.completeError(error);
} else {
completer.complete(response);
}
}
}
}
Future<T> _sendRequest<T>(_StorageCommand method, List<dynamic> args) async {
if (_sendPort == null) {
await initialize();
}
final completer = Completer<T>();
final requestId = _nextRequestId++;
_responseCompleters[requestId] = completer;
_sendPort!.send([requestId, method, args]);
return completer.future;
}
@override
Future<void> storeTaskRecord(TaskRecord record) =>
_sendRequest(_StorageCommand.storeTaskRecord, [record.toJson()]);
@override
Future<TaskRecord?> retrieveTaskRecord(String taskId) async {
final result = await _sendRequest<TaskRecord?>(
_StorageCommand.retrieveTaskRecord,
[taskId],
);
return result;
}
@override
Future<List<TaskRecord>> retrieveAllTaskRecords() async {
final result = await _sendRequest<List<dynamic>>(
_StorageCommand.retrieveAllTaskRecords,
[],
);
return result.cast<TaskRecord>();
}
@override
Future<void> removeTaskRecord(String? taskId) =>
_sendRequest(_StorageCommand.removeTaskRecord, [taskId]);
@override
Future<void> storePausedTask(Task task) =>
_sendRequest(_StorageCommand.storePausedTask, [task.toJson()]);
@override
Future<Task?> retrievePausedTask(String taskId) async {
final result = await _sendRequest<Task?>(
_StorageCommand.retrievePausedTask,
[taskId],
);
return result;
}
@override
Future<List<Task>> retrieveAllPausedTasks() async {
final result = await _sendRequest<List<dynamic>>(
_StorageCommand.retrieveAllPausedTasks,
[],
);
return result.cast<Task>();
}
@override
Future<void> removePausedTask(String? taskId) =>
_sendRequest(_StorageCommand.removePausedTask, [taskId]);
@override
Future<void> storeResumeData(ResumeData resumeData) =>
_sendRequest(_StorageCommand.storeResumeData, [resumeData.toJson()]);
@override
Future<ResumeData?> retrieveResumeData(String taskId) async {
final result = await _sendRequest<ResumeData?>(
_StorageCommand.retrieveResumeData,
[taskId],
);
return result;
}
@override
Future<List<ResumeData>> retrieveAllResumeData() async {
final result = await _sendRequest<List<dynamic>>(
_StorageCommand.retrieveAllResumeData,
[],
);
return result.cast<ResumeData>();
}
@override
Future<void> removeResumeData(String? taskId) =>
_sendRequest(_StorageCommand.removeResumeData, [taskId]);
Future<Map<String, dynamic>> retrieveAll(String collection) async {
final result = await _sendRequest<Map<String, dynamic>>(
_StorageCommand.retrieveAll,
[collection],
);
return result;
}
Future<void> clearCache() => _sendRequest(_StorageCommand.clearCache, []);
}
// Entry point for the isolate
void _isolateEntry(SendPort mainSendPort) {
final receivePort = ReceivePort();
mainSendPort.send(receivePort.sendPort);
SendPort? responseSendPort;
_LocalStorePersistentStorageExecutor? executor;
receivePort.listen((message) async {
if (message is SendPort) {
responseSendPort = message;
} else if (message is List) {
final requestId = message[0] as int;
final method = message[1] as _StorageCommand;
final args = message[2] as List<dynamic>;
try {
dynamic result;
if (method == _StorageCommand.initialize) {
final token = args[0] as RootIsolateToken?;
if (token != null) {
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
}
if (token != null) {
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
}
executor = _LocalStorePersistentStorageExecutor();
await executor!.initialize();
executor!.log.fine(
'Initialized isolate with dbDir ${await Localstore.instance.databaseDirectory}',
);
result = null;
} else {
// All other methods require executor to be initialized
if (executor == null) {
throw StateError('Executor not initialized');
}
result = await _dispatch(executor!, method, args);
}
responseSendPort?.send([requestId, result]);
} catch (e) {
responseSendPort?.send([requestId, null, e.toString()]);
}
}
});
}
Future<dynamic> _dispatch(
_LocalStorePersistentStorageExecutor executor,
_StorageCommand method,
List<dynamic> args,
) => switch (method) {
_StorageCommand.getStoredDatabaseVersion =>
executor.storedDatabaseVersion.then((r) => [r.$1, r.$2]), // tuple to list
_StorageCommand.storeTaskRecord => executor.storeTaskRecord(
args[0] as Map<String, dynamic>,
),
_StorageCommand.retrieveTaskRecord => executor.retrieveTaskRecord(
args[0] as String,
),
_StorageCommand.retrieveAllTaskRecords => executor.retrieveAllTaskRecords(),
_StorageCommand.removeTaskRecord => executor.removeTaskRecord(
args[0] as String?,
),
_StorageCommand.storePausedTask => executor.storePausedTask(
args[0] as Map<String, dynamic>,
),
_StorageCommand.retrievePausedTask => executor.retrievePausedTask(
args[0] as String,
),
_StorageCommand.retrieveAllPausedTasks => executor.retrieveAllPausedTasks(),
_StorageCommand.removePausedTask => executor.removePausedTask(
args[0] as String?,
),
_StorageCommand.storeResumeData => executor.storeResumeData(
args[0] as Map<String, dynamic>,
),
_StorageCommand.retrieveResumeData => executor.retrieveResumeData(
args[0] as String,
),
_StorageCommand.retrieveAllResumeData => executor.retrieveAllResumeData(),
_StorageCommand.removeResumeData => executor.removeResumeData(
args[0] as String?,
),
_StorageCommand.retrieveAll => executor.retrieveAll(args[0] as String),
_StorageCommand.clearCache => executor.clearCache(),
_StorageCommand.initialize => throw StateError(
'Initialize should be handled in isolate entry',
),
};
/// The executor that runs in the isolate and does the actual work
class _LocalStorePersistentStorageExecutor {
final log = Logger('LocalStorePersistentStorageExecutor');
final _db = Localstore.instance;
final _illegalPathCharacters = RegExp(r'[\\/:*?"<>|]');
static const taskRecordsPath = 'backgroundDownloaderTaskRecords';
static const resumeDataPath = 'backgroundDownloaderResumeData';
static const pausedTasksPath = 'backgroundDownloaderPausedTasks';
static const metaDataCollection = 'backgroundDownloaderDatabase';
Future<void> storeTaskRecord(Map<String, dynamic> recordJson) async {
final taskId = recordJson['taskId'] as String;
await store(recordJson, taskRecordsPath, _safeId(taskId));
}
Future<TaskRecord?> retrieveTaskRecord(String taskId) async {
final json = await retrieve(taskRecordsPath, _safeId(taskId));
return json != null ? TaskRecord.fromJson(json) : null;
}
Future<List<TaskRecord>> retrieveAllTaskRecords() async {
final jsonMaps = await retrieveAll(taskRecordsPath);
return jsonMaps.values
.map((e) => TaskRecord.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<void> removeTaskRecord(String? taskId) =>
remove(taskRecordsPath, _safeIdOrNull(taskId));
Future<void> storePausedTask(Map<String, dynamic> taskJson) async {
final taskId = taskJson['taskId'] as String;
await store(taskJson, pausedTasksPath, _safeId(taskId));
}
Future<Task?> retrievePausedTask(String taskId) async {
final json = await retrieve(pausedTasksPath, _safeId(taskId));
return json != null ? Task.createFromJson(json) : null;
}
Future<List<Task>> retrieveAllPausedTasks() async {
final jsonMaps = await retrieveAll(pausedTasksPath);
return jsonMaps.values
.map((e) => Task.createFromJson(e as Map<String, dynamic>))
.toList();
}
Future<void> removePausedTask(String? taskId) =>
remove(pausedTasksPath, _safeIdOrNull(taskId));
Future<void> storeResumeData(Map<String, dynamic> dataJson) async {
final taskMap = dataJson['task'] as Map<String, dynamic>;
final taskId = taskMap['taskId'] as String;
await store(dataJson, resumeDataPath, _safeId(taskId));
}
Future<ResumeData?> retrieveResumeData(String taskId) async {
final json = await retrieve(resumeDataPath, _safeId(taskId));
return json != null ? ResumeData.fromJson(json) : null;
}
Future<List<ResumeData>> retrieveAllResumeData() async {
final jsonMaps = await retrieveAll(resumeDataPath);
return jsonMaps.values
.map((e) => ResumeData.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<void> removeResumeData(String? taskId) =>
remove(resumeDataPath, _safeIdOrNull(taskId));
/// Stores [Map<String, dynamic>] formatted [document] in [collection] keyed under [identifier]
Future<void> store(
Map<String, dynamic> document,
String collection,
String identifier,
) async {
await _db.collection(collection).doc(identifier).set(document);
}
/// Returns [document] stored in [collection] under key [identifier]
/// as a [Map<String, dynamic>], or null if not found
Future<Map<String, dynamic>?> retrieve(
String collection,
String identifier,
) => _db.collection(collection).doc(identifier).get();
/// Returns all documents in collection as a [Map<String, dynamic>] keyed by the
/// document identifier, with the value a [Map<String, dynamic>] representing the document
Future<Map<String, dynamic>> retrieveAll(String collection) async =>
await _db.collection(collection).get() ?? {};
/// Removes document with [identifier] from [collection]
///
/// If [identifier] is null, removes all documents in the [collection]
Future<void> remove(String collection, [String? identifier]) async {
if (identifier == null) {
await _db.collection(collection).delete();
} else {
await _db.collection(collection).doc(identifier).delete();
}
}
/// Returns possibly modified id, safe for storing in the localStore
String _safeId(String id) => id.replaceAll(_illegalPathCharacters, '_');
/// Returns possibly modified id, safe for storing in the localStore, or null
/// if [id] is null
String? _safeIdOrNull(String? id) =>
id?.replaceAll(_illegalPathCharacters, '_');
Future<(String, int)> get storedDatabaseVersion async {
final metaData = await _db
.collection(metaDataCollection)
.doc('metaData')
.get();
return ('Localstore', (metaData?['version'] as num?)?.toInt() ?? 0);
}
(String, int) get currentDatabaseVersion => ('Localstore', 1);
Future<void> initialize() async {
final (currentName, currentVersion) = currentDatabaseVersion;
final (storedName, storedVersion) = await storedDatabaseVersion;
if (storedName != currentName) {
log.warning('Cannot migrate from database name $storedName');
return;
}
if (storedVersion == currentVersion) {
return;
}
log.fine(
'Migrating $currentName database from version $storedVersion to $currentVersion',
);
switch (storedVersion) {
case 0:
// move files from docDir to supportDir
final docDir = await getApplicationDocumentsDirectory();
final supportDir = await getApplicationSupportDirectory();
await Future.wait(
[resumeDataPath, pausedTasksPath, taskRecordsPath].map((path) async {
try {
final fromPath = join(docDir.path, path);
if (await Directory(fromPath).exists()) {
log.finest('Moving $path to support directory');
final toPath = join(supportDir.path, path);
await Directory(toPath).create(recursive: true);
final entities = await Directory(fromPath).list().toList();
await Future.wait(
entities.whereType<File>().map(
(file) => file.copy(join(toPath, basename(file.path))),
),
);
await Directory(fromPath).delete(recursive: true);
}
} catch (e) {
log.fine('Error migrating database for path $path: $e');
}
}),
);
default:
log.warning('Illegal starting version: $storedVersion');
}
await _db.collection(metaDataCollection).doc('metaData').set({
'version': currentVersion,
});
}
Future<void> clearCache() async {
Localstore.instance.clearCache();
}
}
/// Interface to migrate from one persistent storage to another
abstract interface class PersistentStorageMigrator {
/// Migrate data from one of the [migrationOptions] to the [toStorage]
///
/// If migration took place, returns the name of the migration option,
/// otherwise returns null
Future<String?> migrate(
List<String> migrationOptions,
PersistentStorage toStorage,
);
}
/// Migrates from [LocalStorePersistentStorage] to another [PersistentStorage]
class BasePersistentStorageMigrator implements PersistentStorageMigrator {
final log = Logger('PersistentStorageMigrator');
/// Create [BasePersistentStorageMigrator] object to migrate between persistent
/// storage solutions
///
/// [BasePersistentStorageMigrator] only migrates from:
/// * local_store (the default implementation of the database in
/// background_downloader).
///
/// To add other migrations, extend this class and inject it in the
/// [PersistentStorage] class that you want to migrate to.
///
/// See package background_downloader_sql for an implementation
/// that migrates to a SQLite based [PersistentStorage], including
/// migration from Flutter Downloader
BasePersistentStorageMigrator();
/// Migrate data from one of the [migrationOptions] to the [toStorage]
///
/// If migration took place, returns the name of the migration option,
/// otherwise returns null
///
/// This is the public interface to use in other [PersistentStorage]
/// solutions.
@override
Future<String?> migrate(
List<String> migrationOptions,
PersistentStorage toStorage,
) async {
for (final persistentStorageName in migrationOptions) {
try {
if (await migrateFrom(persistentStorageName, toStorage)) {
return persistentStorageName;
}
} on Exception catch (e, stacktrace) {
log.warning(
'Error attempting to migrate from $persistentStorageName: $e\n$stacktrace',
);
}
}
return null; // no migration
}
/// Attempt to migrate data from [persistentStorageName] to [toStorage]
///
/// Returns true if the migration was successfully executed, false if it
/// was not a viable migration
///
/// If extending the class, add your mapping from a migration option String
/// to a _migrateFrom... method that does your migration.
Future<bool> migrateFrom(
String persistentStorageName,
PersistentStorage toStorage,
) => switch (persistentStorageName.toLowerCase().replaceAll('_', '')) {
'localstore' => migrateFromLocalStore(toStorage),
_ => Future.value(false),
};
/// Migrate from a persistent storage to our database
///
/// Returns true if this migration took place
///
/// This is a generic migrator that copies from one storage to another, and
/// is used by the _migrateFrom... methods
Future<bool> migrateFromPersistentStorage(
PersistentStorage fromStorage,
PersistentStorage toStorage,
) async {
bool migratedSomething = false;
await fromStorage.initialize();
final pausedTasks = await fromStorage.retrieveAllPausedTasks();
if (pausedTasks.isNotEmpty) {
await Future.wait(pausedTasks.map((e) => toStorage.storePausedTask(e)));
migratedSomething = true;
}
final resumeData = await fromStorage.retrieveAllResumeData();
if (resumeData.isNotEmpty) {
await Future.wait(resumeData.map((e) => toStorage.storeResumeData(e)));
migratedSomething = true;
}
final taskRecords = await fromStorage.retrieveAllTaskRecords();
if (taskRecords.isNotEmpty) {
await Future.wait(taskRecords.map((e) => toStorage.storeTaskRecord(e)));
migratedSomething = true;
}
return migratedSomething;
}
/// Attempt to migrate from [LocalStorePersistentStorage]
///
/// Return true if successful. Successful migration removes the original
/// data
///
/// If extending this class, add a method like this that does the
/// migration by:
/// 1. Setting up the [PersistentStorage] object you want to migrate from
/// 2. Call [migrateFromPersistentStorage] to do the transfer from that
/// object to the new object, passed as [toStorage]
/// 3. Remove all traces of the [PersistentStorage] object you want to migrate
/// from
Future<bool> migrateFromLocalStore(PersistentStorage toStorage) async {
final localStore = LocalStorePersistentStorage();
if (await migrateFromPersistentStorage(localStore, toStorage)) {
// delete all paths related to LocalStore
final supportDir = await getApplicationSupportDirectory();
await Future.wait(
[
_LocalStorePersistentStorageExecutor.resumeDataPath,
_LocalStorePersistentStorageExecutor.pausedTasksPath,
_LocalStorePersistentStorageExecutor.taskRecordsPath,
_LocalStorePersistentStorageExecutor.metaDataCollection,
].map((collectionPath) async {
try {
final path = join(supportDir.path, collectionPath);
if (await Directory(path).exists()) {
log.finest('Removing directory $path for LocalStore');
await Directory(path).delete(recursive: true);
}
} catch (e) {
log.fine('Error deleting collection path $collectionPath: $e');
}
}),
);
return true; // we migrated a database
}
return false; // we did not migrate a database
}
}
@@ -0,0 +1,397 @@
import 'dart:async';
import 'dart:math';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'file_downloader.dart';
import 'models.dart';
import 'task.dart';
import 'uri/uri_helpers.dart';
/// Progress indicator for use with the [FileDownloader]
///
/// Configuration parameters:
/// [message] message to show for a single download. Templates {filename} and
/// {metadata} are replaced by a task's filename and metadata respectively
/// [collapsedMessage] message to show when multiple file downloads are
/// collapsed into a single row. The template {n} is replaced by the number
/// of tasks finished, and {total} is replaced by the total number of tasks
/// started in this batch. Both reset to 0 when all downloads finish.
/// In collapsed mode, progress is indicated as {n}/{total}
/// [showPauseButton] if true, shows a pause button if the task allows it
/// [showCancelButton] if true, shows a cancel button
/// [height] height of the [DownloadProgressIndicator], and of each row when
/// in expanded mode
/// [maxExpandable] maximum number of rows the indicator can expand to, with
/// each row showing one download in progress. If set to 1 (the default) the
/// indicator will not expand: if the number of downloads in progress exceeds
/// 1, the indicator will switch to a 'collapsed' state showing the
/// number of files finished out of the total started. It will stay in
/// 'collapsed' state until all downloads have finished. To start the
/// indicator in collapsed state (i.e. never show the download progress of a
/// single file), set [maxExpandable] to 0
/// [backgroundColor] background color for the widget
class DownloadProgressIndicator extends StatefulWidget {
const DownloadProgressIndicator(
this.updates, {
this.message = '{filename}',
this.collapsedMessage = '{n}/{total}',
this.showPauseButton = false,
this.showCancelButton = false,
this.height = 50,
this.maxExpandable = 1,
this.backgroundColor = Colors.grey,
super.key,
});
final Stream<TaskUpdate> updates;
final String message;
final String collapsedMessage;
final bool showPauseButton;
final bool showCancelButton;
final double height;
final int maxExpandable;
final Color backgroundColor;
@override
State<DownloadProgressIndicator> createState() =>
_DownloadProgressIndicatorState();
}
class _DownloadProgressIndicatorState extends State<DownloadProgressIndicator> {
StreamSubscription<TaskUpdate>? downloadUpdates;
final inProgress = <Task, (double, int)>{};
final pausedTasks = <Task>{};
final totalTasks = <Task>{};
final finishedTasks = <Task>{};
bool isExpanded = false;
bool isCollapsed = false;
@override
void initState() {
super.initState();
isCollapsed = widget.maxExpandable == 0;
downloadUpdates = widget.updates.listen((update) {
if (update is TaskStatusUpdate) {
switch (update.status) {
case .running:
totalTasks.add(update.task);
pausedTasks.remove(update.task);
inProgress[update.task] = (
0,
DateTime.now().millisecondsSinceEpoch,
);
case .waitingToRetry:
break;
case .paused:
pausedTasks.add(update.task);
default:
// task finished
_finish(update.task);
}
} else if (update is TaskProgressUpdate) {
switch (update.progress) {
case >= 0 && < 1:
// active, so add task to set and update progress
totalTasks.add(update.task);
pausedTasks.remove(update.task);
final previousInProgress = inProgress[update.task];
inProgress[update.task] = (
update.progress,
previousInProgress?.$2 ?? DateTime.now().millisecondsSinceEpoch,
);
case progressWaitingToRetry:
break;
case progressPaused:
pausedTasks.add(update.task);
default:
// task finished
_finish(update.task);
}
if (mounted) {
setState(() {});
}
}
});
}
@override
void dispose() {
super.dispose();
downloadUpdates?.cancel();
downloadUpdates = null;
}
/// Process a finished task
///
/// Remove the task from [inProgress] and [pausedTasks], add to [finishedTasks]
/// If [inProgress] is empty, also empty the sets and reset
/// the [isExpanded] and [isCollapsed] flags.
void _finish(Task task) {
inProgress.remove(task);
finishedTasks.add(task);
pausedTasks.remove(task);
if (inProgress.isEmpty) {
totalTasks.clear();
finishedTasks.clear();
isExpanded = false;
isCollapsed = widget.maxExpandable == 0;
}
}
@override
Widget build(BuildContext context) {
final activeTasks = inProgress.keys
.where((taskId) => inProgress[taskId]!.$1 >= 0)
.sorted((a, b) => inProgress[a]!.$2.compareTo(inProgress[b]!.$2));
final numActive = activeTasks.length;
if (numActive > 1) {
if (widget.maxExpandable > 1) {
isExpanded = true; // only resets when all downloads finish
} else {
isCollapsed = true; // only resets when all downloads finish
}
}
final itemsToShow = isExpanded
? min(numActive, widget.maxExpandable)
: isCollapsed
? min(1, numActive)
: numActive;
return AnimatedSize(
duration: const Duration(milliseconds: 200),
alignment: Alignment.bottomCenter,
child: switch (itemsToShow) {
0 => Container(height: 0),
1 =>
isCollapsed
? _CollapsedDownloadProgress(
finishedTasks.length,
totalTasks.length,
widget.collapsedMessage,
widget.height,
widget.backgroundColor,
)
: _DownloadProgressItem(
activeTasks.first,
inProgress[activeTasks.first]!.$1,
widget.message,
widget.showPauseButton,
widget.showCancelButton,
widget.height,
widget.backgroundColor,
pausedTasks,
),
_ => _ExpandedDownloadProgress(
activeTasks.take(widget.maxExpandable).toList(growable: false),
widget.message,
widget.height,
widget.backgroundColor,
inProgress,
),
},
);
}
}
final _fileNameRegEx = RegExp("""{filename}""", caseSensitive: false);
final _metadataRegEx = RegExp("""{metadata}""", caseSensitive: false);
/// Single file download progress widget
class _DownloadProgressItem extends StatelessWidget {
const _DownloadProgressItem(
this.task,
this.progress,
this.message,
this.showPauseButton,
this.showCancelButton,
this.height,
this.backgroundColor,
this.pausedTasks,
);
final Task task;
final double progress;
final String message;
final bool showPauseButton;
final bool showCancelButton;
final double height;
final Color backgroundColor;
final Set<Task> pausedTasks;
@override
Widget build(BuildContext context) {
final messageText = message
.replaceAll(
_fileNameRegEx,
unpack(task.filename).filename ?? task.filename,
)
.replaceAll(_metadataRegEx, task.metaData);
return Container(
height: height,
decoration: BoxDecoration(color: backgroundColor),
child: Padding(
padding: const EdgeInsets.only(left: 8, right: 8),
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: Text(
messageText,
style: Theme.of(context).textTheme.bodyMedium,
),
),
Expanded(
child: LinearProgressIndicator(
value: progress == 0 ? null : progress,
),
),
if (showPauseButton &&
task.allowPause &&
!pausedTasks.contains(task))
IconButton(
onPressed: () => FileDownloader().pause(task as DownloadTask),
icon: const Icon(Icons.pause),
color: Theme.of(context).primaryColor,
),
if (showPauseButton &&
task.allowPause &&
pausedTasks.contains(task))
IconButton(
onPressed: () => FileDownloader().resume(task as DownloadTask),
icon: const Icon(Icons.play_arrow),
color: Theme.of(context).primaryColor,
),
if (showCancelButton)
IconButton(
onPressed: () => FileDownloader().cancelTaskWithId(task.taskId),
icon: const Icon(Icons.cancel),
color: Theme.of(context).primaryColor,
),
],
),
),
);
}
}
/// Collapsed file download progress widget, showing the number of files
/// downloaded out of the total number of files downloaded as a progress
/// indicator
class _CollapsedDownloadProgress extends StatelessWidget {
const _CollapsedDownloadProgress(
this.numCompleted,
this.numTotal,
this.collapsedMessage,
this.height,
this.backgroundColor,
);
final int numCompleted;
final int numTotal;
final String collapsedMessage;
final double height;
final Color backgroundColor;
@override
Widget build(BuildContext context) {
final messageText = collapsedMessage
.replaceAll('{n}', '$numCompleted')
.replaceAll('{total}', '$numTotal');
final progress = numCompleted / numTotal;
return Container(
height: height,
decoration: BoxDecoration(color: backgroundColor),
child: Padding(
padding: const EdgeInsets.only(left: 8, right: 8),
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: Text(
messageText,
style: Theme.of(context).textTheme.bodyMedium,
),
),
Expanded(child: LinearProgressIndicator(value: progress)),
],
),
),
);
}
}
/// Expanded file download progress widget, showing multiple file download
/// progress indicators (up to expandable as defined in the core widget
class _ExpandedDownloadProgress extends StatelessWidget {
const _ExpandedDownloadProgress(
this.tasks,
this.message,
this.height,
this.backgroundColor,
this.inProgress,
);
final List<Task> tasks;
final String message;
final double height;
final Color backgroundColor;
final Map<Task, (double, int)> inProgress;
@override
Widget build(BuildContext context) => DecoratedBox(
decoration: BoxDecoration(
color: backgroundColor,
border: Border(top: BorderSide(color: Theme.of(context).dividerColor)),
),
child: Table(
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
columnWidths: const <int, TableColumnWidth>{
0: IntrinsicColumnWidth(),
1: FlexColumnWidth(),
},
children: tasks
.map(
(task) => TableRow(
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: Theme.of(context).dividerColor),
),
),
children: [
SizedBox(
height: height,
child: Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(left: 8, right: 8),
child: Text(
message
.replaceAll(
_fileNameRegEx,
unpack(task.filename).filename ?? task.filename,
)
.replaceAll(_metadataRegEx, task.metaData),
style: Theme.of(context).textTheme.bodyMedium,
),
),
),
),
Padding(
padding: const EdgeInsets.only(right: 8),
child: LinearProgressIndicator(value: inProgress[task]!.$1),
),
],
),
)
.toList(),
),
);
}
Loaded 100 of 445 files, more files were not shown because too many files have changed in this diff. Show more