diff --git a/README.md b/README.md index d2471101..7ef0b0ab 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Run `flutter doctor` and fix everything it complains before proceeding - Make sure you have `gem` installed with `sudo gem install` - If on MacOS arm, `sudo gem install ffi -- --enable-libffi-alloc` -If the iOS build complains that MobileNebula.xcframework is stale or missing, run `./ensure-mobile-nebula.sh` from the repo root. It rebuilds the gomobile framework only when the Go code in `nebula/` changes, Xcode runs it automatically as a pre-build action on the Runner scheme. +After a fresh checkout, run `./ensure-mobile-nebula.sh` once before building for iOS. Swift package resolution needs MobileNebula.xcframework to exist before the pre-build action that maintains it gets a chance to run. After that, Xcode keeps it fresh automatically, rebuilding only when the Go code in `nebula/` changes. If an iOS build ever complains the framework is stale or missing, run the script again by hand. # Formatting diff --git a/lib/main.dart b/lib/main.dart index 258c8a38..e195efd3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -59,6 +59,8 @@ class AppState extends State { @override void initState() { + unawaited(Utils.clearLegacyPickedFiles()); + //TODO: wait until settings is ready? settings.onChange().listen((_) { setState(() { diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index fc70ef2f..765526d3 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -266,7 +266,7 @@ class MainScreenState extends State { // Remove the modal Navigator.pop(context); - final rawContent = await Utils.pickFile(context); + final rawContent = await Utils.pickFile(); if (rawContent == null) { return Utils.popError('Load YAML config', 'File was empty'); } diff --git a/lib/screens/siteConfig/add_certificate_screen.dart b/lib/screens/siteConfig/add_certificate_screen.dart index d9d7bccb..bdf9759b 100644 --- a/lib/screens/siteConfig/add_certificate_screen.dart +++ b/lib/screens/siteConfig/add_certificate_screen.dart @@ -200,7 +200,7 @@ class AddCertificateScreenState extends State { content: Center(child: Text('Choose a file')), onPressed: () async { try { - final content = await Utils.pickFile(context); + final content = await Utils.pickFile(); if (content == null) { return; } diff --git a/lib/screens/siteConfig/ca_list_screen.dart b/lib/screens/siteConfig/ca_list_screen.dart index 12f0e5ee..afe9cc5e 100644 --- a/lib/screens/siteConfig/ca_list_screen.dart +++ b/lib/screens/siteConfig/ca_list_screen.dart @@ -204,7 +204,7 @@ class CAListScreenState extends State { content: Text('Choose a file'), onPressed: () async { try { - final content = await Utils.pickFile(context); + final content = await Utils.pickFile(); if (content == null) { return; } diff --git a/lib/services/utils.dart b/lib/services/utils.dart index e17d2ba8..33890d88 100644 --- a/lib/services/utils.dart +++ b/lib/services/utils.dart @@ -1,10 +1,15 @@ import 'dart:io'; -import 'package:file_picker/file_picker.dart'; +import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; import 'package:mobile_nebula/main.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; import 'package:url_launcher/url_launcher_string.dart'; +final _log = Logger('utils'); + class Utils { /// Minimum size (width or height) of a interactive component static const double minInteractiveSize = 48; @@ -126,15 +131,60 @@ class Utils { } } - static Future pickFile(BuildContext context) async { - await FilePicker.clearTemporaryFiles(); - final result = await FilePicker.pickFiles(allowMultiple: false); - if (result == null) { + static Future pickFile() async { + final file = await openFile(); + if (file == null) { return null; } - final file = File(result.files.first.path!); - return file.readAsString(); + final content = await file.readAsString(); + + // It is a copy, so don't leave a plaintext key around. Android puts each in its own + // //, so drop the empty parent too; on iOS that throws into the catch. + try { + final copy = File(file.path); + await copy.delete(); + await copy.parent.delete(); + } catch (err) { + _log.warning('Failed to delete imported file copy, plaintext may remain in temp', err); + } + + return content; + } + + /// Removes copies the old file_picker left in temp; it only cleared them on the next + /// pick, so the last thing imported lingers. + static Future clearLegacyPickedFiles() async { + if (Platform.isAndroid) { + // file_picker's copies live under getTemporaryDirectory()/file_picker/. + try { + final cacheDir = await getTemporaryDirectory(); + final legacyCache = Directory(p.join(cacheDir.path, 'file_picker')); + if (await legacyCache.exists()) { + await legacyCache.delete(recursive: true); + } + } catch (err) { + _log.warning('Failed to clear legacy file_picker cache', err); + } + } else if (Platform.isIOS) { + // Old file_picker left loose files at the top of tmp, file_selector leaves -Inbox + // stragglers; sweep both. systemTemp because getTemporaryDirectory is Caches on iOS, and + // nothing of ours is loose in tmp (Share also writes to Caches). Only safe because we + // register no document types, so nothing but our own picker ever creates an -Inbox. + try { + await for (final entry in Directory.systemTemp.list()) { + try { + if (entry is File || (entry is Directory && entry.path.endsWith('-Inbox'))) { + await entry.delete(recursive: true); + } + } catch (err) { + _log.warning('Failed to delete temp entry ${entry.path}', err); + } + } + } catch (err) { + _log.warning('Failed to list temp directory for cleanup', err); + } + } } static TextTheme createTextTheme() { diff --git a/pubspec.lock b/pubspec.lock index 49ada06c..493f060a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -153,14 +153,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.0" - dbus: - dependency: transitive - description: - name: dbus - sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" - url: "https://pub.dev" - source: hosted - version: "0.7.14" fake_async: dependency: transitive description: @@ -193,14 +185,70 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" - file_picker: + file_selector: dependency: "direct main" description: - name: file_picker - sha256: fdc6a37f715d19f35b131decf1ce39242eeed5ddae18c0818c3eccb731ab76be + name: file_selector + sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a + url: "https://pub.dev" + source: hosted + version: "1.1.0" + file_selector_android: + dependency: transitive + description: + name: file_selector_android + sha256: "6a26687fa65cbc28a5345c7ae6f227e89f0b47740978a4c475b1a625da7a331b" + url: "https://pub.dev" + source: hosted + version: "0.5.2+8" + file_selector_ios: + dependency: transitive + description: + name: file_selector_ios + sha256: e2ecf2885c121691ce13b60db3508f53c01f869fb6e8dc5c1cfa771e4c46aeca + url: "https://pub.dev" + source: hosted + version: "0.5.3+5" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" url: "https://pub.dev" source: hosted - version: "12.0.0-beta.7" + version: "2.7.0" + file_selector_web: + dependency: transitive + description: + name: file_selector_web + sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" fixnum: dependency: transitive description: @@ -222,14 +270,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" - flutter_plugin_android_lifecycle: - dependency: transitive - description: - name: flutter_plugin_android_lifecycle - sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" - url: "https://pub.dev" - source: hosted - version: "2.0.35" flutter_svg: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index ae4fe376..1ea1db1f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,16 +23,15 @@ dependencies: sdk: flutter path_provider: ^2.0.11 - # 12.0.0 drops the DKImagePickerController dependency chain on iOS in favor of - # PHPickerViewController, keeping photo library permissions out of our binary - file_picker: ^12.0.0-beta.7 + # We only pick documents (certs, keys, configs). file_selector is first party and + # has no media picker code, so photo library permissions can never sneak into our binary + file_selector: ^1.1.0 uuid: ^4.5.2 package_info_plus: ^10.2.0 url_launcher: ^6.3.2 pull_to_refresh: ^2.0.0 flutter_svg: ^2.2.3 intl: ^0.20.2 - # 13.x is required to share the win32 constraint with file_picker 12 share_plus: ^13.2.0 sentry_flutter: ^9.12.0 sentry_dart_plugin: ^3.2.1