import 'dart:io'; import 'package:file_picker/file_picker.dart'; import 'package:path/path.dart' as p; import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus; import '../database/database.dart'; import '../l10n/strings.dart'; import 'controllers/bookmarks_controller.dart'; import 'controllers/reader_settings_controller.dart' show ReaderSettingsController; import 'controllers/subscriptions_controller.dart'; import 'key_store.dart'; import 'pages/mews_urls.dart'; import 'pages/quotations_page.dart' show citationFor; import 'pem.dart'; /// Applies the change an action address encodes, e.g. mews:do/set-theme. /// Returns whether anything changed, so the navigator can skip a rerender that /// would show the same page. /// /// An unknown verb or a missing argument does nothing and reports nothing: the /// only links carrying these addresses are the ones the client builds, so a /// miss is a bug rather than something to explain to the reader. /// A PEM file the reader picked, or null when the pick was cancelled. [name] /// names the identity after its file, which is what the reader chose. typedef PickedPem = ({String name, String content}); class InternalActions { InternalActions({ required MewsDatabase db, required Future Function() applyLanguage, required ReaderSettingsController settings, required BookmarksController bookmarks, required SubscriptionsController subscriptions, required KeyStore keys, required Strings strings, Future Function(String title)? pickPemFile, Future Function(String text)? share, }) : _db = db, _applyLanguage = applyLanguage, _settings = settings, _bookmarks = bookmarks, _subscriptions = subscriptions, _keys = keys, _strings = strings, _pickPemFile = pickPemFile ?? _pickPemFromFileSystem, _share = share ?? _shareThroughSystem; final MewsDatabase _db; final Future Function() _applyLanguage; final ReaderSettingsController _settings; final BookmarksController _bookmarks; final SubscriptionsController _subscriptions; final KeyStore _keys; final Strings _strings; final Future Function(String title) _pickPemFile; final Future Function(String text) _share; /// Hands [text] to the system share sheet. static Future _shareThroughSystem(String text) => SharePlus.instance.share(ShareParams(text: text)); /// Opens the platform file picker for one PEM file, titled with what the /// step asks for. A single file may hold both the certificate and its key, /// or either one alone. A cancelled pick comes back as nothing. static Future _pickPemFromFileSystem(String title) async { final files = await FilePicker.pickFiles( dialogTitle: title, type: FileType.custom, allowedExtensions: const ['pem', 'crt', 'cer', 'key']); final path = files.singleOrNull?.path; if (path == null) return null; return ( name: p.basenameWithoutExtension(path), content: await File(path).readAsString(), ); } Future apply(String url) async { final verb = MewsUrls.verbOf(url); if (verb == null) return false; final args = Uri.parse(url).queryParameters; return switch (verb) { 'set-font' => _settings.setFont(args['value'] ?? ''), 'set-size' => _setSize(args['step']), 'set-theme' => _settings.setThemeMode(args['value'] ?? ''), 'set-colors' => _settings.setVariant(args['value'] ?? ''), 'set-language' => _setLanguage(args['value']), 'mark-read' => _markRead(args, read: true), 'mark-unread' => _markRead(args, read: false), 'refresh-subscriptions' => _refreshAll(), 'refresh-subscription' => _refreshOne(args['url']), 'remove-bookmark' => _withUrl(args, _bookmarks.remove), 'unsubscribe' => _withUrl(args, _subscriptions.remove), 'forget-page' => _withUrl(args, _db.forgetPage), 'clear-history' => _clearHistory(), 'add-tag' => _changeTag(args, add: true), 'remove-tag' => _changeTag(args, add: false), 'delete-tag' => _deleteTag(args['tag']), 'import-certificate' => _importCertificate(), 'rename-certificate' => _renameCertificate(args), 'delete-certificate' => _deleteCertificate(args), 'share-quotation' => _shareQuotation(args['id']), 'remove-quotation' => _removeQuotation(args['id']), _ => false, }; } /// Reads a certificate and its key from picked PEM files. The first pick /// may hold both; when it holds only one, the other is asked for again, by /// name, so a key picked on its own is not silently dropped. A cancelled /// pick imports nothing. Future _importCertificate() async { final first = await _pickPemFile(_strings.t('certificates-pick-title')); if (first == null) return false; String? cert = certificateOf(first.content); String? key = privateKeyOf(first.content); if (cert == null || key == null) { final asking = cert == null ? _strings.t('certificates-pick-cert-title') : _strings.t('certificates-pick-key-title'); final second = await _pickPemFile(asking); if (second == null) return false; cert ??= certificateOf(second.content); key ??= privateKeyOf(second.content); } if (cert == null || key == null) return false; final name = first.name.trim(); final id = await _db.addClientCertificate( name.isEmpty ? _strings.t('certificates-default-name') : name, cert); await _keys.save(id, key); return true; } Future _renameCertificate(Map args) async { final id = int.tryParse(args['id'] ?? ''); final name = args['name']?.trim(); if (id == null || name == null || name.isEmpty) return false; await _db.renameClientCertificate(id, name); return true; } Future _deleteCertificate(Map args) async { final id = int.tryParse(args['id'] ?? ''); if (id == null) return false; await _keys.delete(id); await _db.deleteClientCertificate(id); return true; } /// Changing the language has to load its messages before the page is built /// again, or the rerender would still read the old ones. Future _setLanguage(String? tag) async { if (tag == null || tag.isEmpty) return false; if (!await _settings.setLanguage(tag)) return false; await _applyLanguage(); return true; } Future _setSize(String? step) => switch (step) { 'up' => _settings.increaseSize(), 'down' => _settings.decreaseSize(), _ => Future.value(false), }; /// Runs [change] on the address the action names, if it names one. Future _withUrl( Map args, Future Function(String) change) async { final url = args['url']; if (url == null || url.isEmpty) return false; await change(url); return true; } /// Adds or removes one tag. An empty name is refused, so submitting an /// empty box does nothing. Future _changeTag(Map args, {required bool add}) async { final kind = switch (args['kind']) { 'bookmark' => TagKind.bookmark, 'subscription' => TagKind.subscription, _ => null, }; final url = args['url']; final tag = args['tag']?.trim(); if (kind == null || url == null || url.isEmpty) return false; if (tag == null || tag.isEmpty) return false; if (add) { await _db.addTag(kind, url, tag); } else { await _db.removeTag(kind, url, tag); } return true; } Future _deleteTag(String? tag) async { if (tag == null || tag.isEmpty) return false; await _db.deleteTag(tag); return true; } Future _clearHistory() async { await _db.clearHistory(); return true; } Future _markRead(Map args, {required bool read}) async { final url = args['url']; if (url == null || url.isEmpty) return false; final site = args['site']; if (read) { await _subscriptions.markRead(url, siteUrl: site); } else { await _subscriptions.markUnread(url, siteUrl: site); } return true; } /// The check redraws the page itself as it runs, so the caller need not. Future _refreshAll() async { await _subscriptions.refreshAll(); return false; } Future _refreshOne(String? siteUrl) async { if (siteUrl == null || siteUrl.isEmpty) return false; await _subscriptions.refreshOne(siteUrl); return false; } /// Shares the quotation with its citation. Nothing on the page changes, so /// the reader stays exactly where they were. Future _shareQuotation(String? id) async { final quotation = await _quotation(id); if (quotation == null) return false; await _share('${quotation.quote}\n\n' '${citationFor(quotation, _strings)}'); return false; } Future _removeQuotation(String? id) async { final quotation = await _quotation(id); if (quotation == null) return false; await _db.removeQuotation(quotation.id); return true; } Future _quotation(String? id) async { final parsed = int.tryParse(id ?? ''); if (parsed == null) return null; return _db.quotationById(parsed); } }