bookmark chunks with progress
This commit is contained in:
+34
@@ -30,6 +30,40 @@
|
|||||||
/// dropped during parsing rather than modelled here.
|
/// dropped during parsing rather than modelled here.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
/// What an import is doing right now.
|
||||||
|
enum BookmarkImportPhase {
|
||||||
|
/// Reading and parsing the file, off the UI isolate.
|
||||||
|
parsing,
|
||||||
|
|
||||||
|
/// Emptying the existing roots, for a replacing import.
|
||||||
|
erasing,
|
||||||
|
|
||||||
|
/// Writing the parsed tree into storage.
|
||||||
|
inserting,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far along an import is, for display while it runs.
|
||||||
|
class BookmarkImportProgress {
|
||||||
|
final BookmarkImportPhase phase;
|
||||||
|
|
||||||
|
/// Bookmarks written so far. Zero outside [BookmarkImportPhase.inserting].
|
||||||
|
final int inserted;
|
||||||
|
|
||||||
|
/// Bookmarks the file is expected to yield, or 0 while still unknown —
|
||||||
|
/// during parsing there is nothing to count against yet.
|
||||||
|
final int total;
|
||||||
|
|
||||||
|
const BookmarkImportProgress({
|
||||||
|
required this.phase,
|
||||||
|
this.inserted = 0,
|
||||||
|
this.total = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Fraction complete, or null when it cannot be known and the UI should show
|
||||||
|
/// an indeterminate indicator instead.
|
||||||
|
double? get fraction => total > 0 ? (inserted / total).clamp(0.0, 1.0) : null;
|
||||||
|
}
|
||||||
|
|
||||||
/// A single node of a parsed bookmark tree.
|
/// A single node of a parsed bookmark tree.
|
||||||
sealed class ImportBookmarkNode {
|
sealed class ImportBookmarkNode {
|
||||||
/// Creation time recorded in the imported file, or null if it had none.
|
/// Creation time recorded in the imported file, or null if it had none.
|
||||||
|
|||||||
+7
-1
@@ -21,6 +21,7 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
|||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:weblibre/core/logger.dart';
|
import 'package:weblibre/core/logger.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart';
|
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/import_bookmark_node.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_html_utils.dart';
|
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_html_utils.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_import_isolate.dart';
|
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_import_isolate.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_importer.dart';
|
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_importer.dart';
|
||||||
@@ -226,7 +227,12 @@ class BookmarksRepository extends _$BookmarksRepository {
|
|||||||
required String path,
|
required String path,
|
||||||
required BookmarkImportFormat format,
|
required BookmarkImportFormat format,
|
||||||
bool replace = false,
|
bool replace = false,
|
||||||
|
void Function(BookmarkImportProgress)? onProgress,
|
||||||
}) async {
|
}) async {
|
||||||
|
onProgress?.call(
|
||||||
|
const BookmarkImportProgress(phase: BookmarkImportPhase.parsing),
|
||||||
|
);
|
||||||
|
|
||||||
final tree = await parseBookmarkFile(
|
final tree = await parseBookmarkFile(
|
||||||
path: path,
|
path: path,
|
||||||
format: format,
|
format: format,
|
||||||
@@ -237,7 +243,7 @@ class BookmarksRepository extends _$BookmarksRepository {
|
|||||||
|
|
||||||
final count = await BookmarkTreeImporter(
|
final count = await BookmarkTreeImporter(
|
||||||
_service,
|
_service,
|
||||||
).import(tree, replace: replace);
|
).import(tree, replace: replace, onProgress: onProgress);
|
||||||
|
|
||||||
_notifyChanged();
|
_notifyChanged();
|
||||||
return count;
|
return count;
|
||||||
|
|||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/import_bookmark_node.dart';
|
||||||
|
|
||||||
|
/// Shows how far a running bookmark import has got.
|
||||||
|
///
|
||||||
|
/// A large file takes long enough that the app would otherwise look frozen.
|
||||||
|
/// The dialog cannot be dismissed: storage work is already under way and there
|
||||||
|
/// is no way to call it back, so offering a cancel button would be a lie.
|
||||||
|
///
|
||||||
|
/// [progress] is driven by the import itself; the dialog closes when the caller
|
||||||
|
/// pops it.
|
||||||
|
class ImportProgressDialog extends StatelessWidget {
|
||||||
|
final ValueListenable<BookmarkImportProgress> progress;
|
||||||
|
|
||||||
|
const ImportProgressDialog({required this.progress, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return PopScope(
|
||||||
|
canPop: false,
|
||||||
|
child: AlertDialog(
|
||||||
|
title: const Text('Importing bookmarks'),
|
||||||
|
content: ValueListenableBuilder<BookmarkImportProgress>(
|
||||||
|
valueListenable: progress,
|
||||||
|
builder: (context, value, child) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(switch (value.phase) {
|
||||||
|
BookmarkImportPhase.parsing => 'Reading the file…',
|
||||||
|
BookmarkImportPhase.erasing => 'Removing existing bookmarks…',
|
||||||
|
BookmarkImportPhase.inserting when value.total > 0 =>
|
||||||
|
'${value.inserted} of ${value.total} bookmarks',
|
||||||
|
BookmarkImportPhase.inserting => 'Saving bookmarks…',
|
||||||
|
}),
|
||||||
|
const SizedBox(height: 16.0),
|
||||||
|
LinearProgressIndicator(value: value.fraction),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
-9
@@ -35,6 +35,7 @@ import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
|||||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart';
|
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_list_ui_state.dart';
|
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_list_ui_state.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_sort_type.dart';
|
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_sort_type.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/import_bookmark_node.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/providers/bookmark_list_ui_state.dart';
|
import 'package:weblibre/features/geckoview/features/bookmarks/domain/providers/bookmark_list_ui_state.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/providers/bookmarks.dart';
|
import 'package:weblibre/features/geckoview/features/bookmarks/domain/providers/bookmarks.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart';
|
import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart';
|
||||||
@@ -42,6 +43,7 @@ import 'package:weblibre/features/geckoview/features/bookmarks/domain/utils/book
|
|||||||
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/delete_bookmark_dialog.dart';
|
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/delete_bookmark_dialog.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/delete_folder_dialog.dart';
|
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/delete_folder_dialog.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/import_bookmarks_dialog.dart';
|
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/import_bookmarks_dialog.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/import_progress_dialog.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/select_bookmark_folder_dialog.dart';
|
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/select_bookmark_folder_dialog.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_import_isolate.dart';
|
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_import_isolate.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
||||||
@@ -1086,15 +1088,39 @@ class BookmarkListScreen extends HookConsumerWidget {
|
|||||||
final shouldReplace = await showImportBookmarksDialog(context);
|
final shouldReplace = await showImportBookmarksDialog(context);
|
||||||
if (shouldReplace == null) return; // User cancelled dialog
|
if (shouldReplace == null) return; // User cancelled dialog
|
||||||
|
|
||||||
// Reading and parsing happen in a background isolate, so a large file
|
if (!context.mounted) return;
|
||||||
// does not freeze the UI while it is being processed.
|
|
||||||
final count = await ref
|
// Writing tens of thousands of bookmarks takes long enough that the app
|
||||||
.read(bookmarksRepositoryProvider.notifier)
|
// would look frozen without something to watch.
|
||||||
.importFromFile(
|
final progress = ValueNotifier<BookmarkImportProgress>(
|
||||||
path: file.path!,
|
const BookmarkImportProgress(phase: BookmarkImportPhase.parsing),
|
||||||
format: format,
|
);
|
||||||
replace: shouldReplace,
|
|
||||||
);
|
final progressDialog = showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (context) => ImportProgressDialog(progress: progress),
|
||||||
|
);
|
||||||
|
|
||||||
|
final int count;
|
||||||
|
try {
|
||||||
|
// Reading and parsing happen in a background isolate, so a large file
|
||||||
|
// does not freeze the UI while it is being processed.
|
||||||
|
count = await ref
|
||||||
|
.read(bookmarksRepositoryProvider.notifier)
|
||||||
|
.importFromFile(
|
||||||
|
path: file.path!,
|
||||||
|
format: format,
|
||||||
|
replace: shouldReplace,
|
||||||
|
onProgress: (value) => progress.value = value,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (context.mounted) {
|
||||||
|
Navigator.of(context, rootNavigator: true).pop();
|
||||||
|
await progressDialog;
|
||||||
|
}
|
||||||
|
progress.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
showInfoMessage(context, 'Imported $count bookmarks successfully');
|
showInfoMessage(context, 'Imported $count bookmarks successfully');
|
||||||
|
|||||||
+43
-36
@@ -105,6 +105,15 @@ class _PendingItem {
|
|||||||
_PendingItem({required this.url, this.dateAdded, this.lastModified});
|
_PendingItem({required this.url, this.dateAdded, this.lastModified});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One level of the document walk: a node and how far through its children the
|
||||||
|
/// walk has got.
|
||||||
|
class _WalkFrame {
|
||||||
|
final dom.Node node;
|
||||||
|
int index = 0;
|
||||||
|
|
||||||
|
_WalkFrame(this.node);
|
||||||
|
}
|
||||||
|
|
||||||
class _Frame {
|
class _Frame {
|
||||||
final _ParsedFolder folder;
|
final _ParsedFolder folder;
|
||||||
int containerNesting = 0;
|
int containerNesting = 0;
|
||||||
@@ -148,49 +157,47 @@ class _BookmarkHtmlParser {
|
|||||||
return _buildTree();
|
return _buildTree();
|
||||||
}
|
}
|
||||||
|
|
||||||
dom.Node? _nextSibling(dom.Node node) {
|
/// Walks the document depth-first, opening each node on the way down and
|
||||||
final parent = node.parent;
|
/// closing it on the way back up.
|
||||||
if (parent == null) return null;
|
///
|
||||||
|
/// Keeps its own cursor into every level rather than asking a node for its
|
||||||
final siblings = parent.nodes;
|
/// next sibling. Locating a sibling means searching the parent's child list,
|
||||||
final index = siblings.indexOf(node);
|
/// which is linear, so doing it per step made the walk quadratic in the
|
||||||
|
/// number of siblings at a level. A flat bookmark file puts every entry under
|
||||||
if (index != -1 && index + 1 < siblings.length) {
|
/// a single list, which turned a 25k-bookmark import into hundreds of
|
||||||
return siblings[index + 1];
|
/// millions of comparisons and minutes of parsing.
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _walkTreeForImport(dom.Node? node) {
|
void _walkTreeForImport(dom.Node? node) {
|
||||||
if (node == null) return;
|
if (node == null) return;
|
||||||
|
|
||||||
dom.Node? current = node;
|
final stack = <_WalkFrame>[_WalkFrame(node)];
|
||||||
dom.Node? next;
|
_enterNode(node);
|
||||||
|
|
||||||
for (;;) {
|
while (stack.isNotEmpty) {
|
||||||
if (current?.nodeType == dom.Node.ELEMENT_NODE) {
|
final frame = stack.last;
|
||||||
_openContainer(current! as dom.Element);
|
final children = frame.node.nodes;
|
||||||
} else if (current?.nodeType == dom.Node.TEXT_NODE) {
|
|
||||||
_appendText(current!.text ?? '');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((next = current?.firstChild) != null) {
|
if (frame.index < children.length) {
|
||||||
current = next;
|
final child = children[frame.index++];
|
||||||
continue;
|
_enterNode(child);
|
||||||
|
stack.add(_WalkFrame(child));
|
||||||
|
} else {
|
||||||
|
_leaveNode(frame.node);
|
||||||
|
stack.removeLast();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (;;) {
|
void _enterNode(dom.Node node) {
|
||||||
if (current?.nodeType == dom.Node.ELEMENT_NODE) {
|
if (node.nodeType == dom.Node.ELEMENT_NODE) {
|
||||||
_closeContainer(current! as dom.Element);
|
_openContainer(node as dom.Element);
|
||||||
}
|
} else if (node.nodeType == dom.Node.TEXT_NODE) {
|
||||||
if (current == node) return;
|
_appendText(node.text ?? '');
|
||||||
if ((next = _nextSibling(current!)) != null) {
|
}
|
||||||
current = next;
|
}
|
||||||
break;
|
|
||||||
}
|
void _leaveNode(dom.Node node) {
|
||||||
current = current.parentNode;
|
if (node.nodeType == dom.Node.ELEMENT_NODE) {
|
||||||
}
|
_closeContainer(node as dom.Element);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+56
-11
@@ -38,10 +38,20 @@ class BookmarkTreeImporter {
|
|||||||
/// first; the root itself is never deleted. A tree that parsed to nothing
|
/// first; the root itself is never deleted. A tree that parsed to nothing
|
||||||
/// erases nothing either, so a malformed or empty file cannot wipe the
|
/// erases nothing either, so a malformed or empty file cannot wipe the
|
||||||
/// user's bookmarks and leave them with an empty library.
|
/// user's bookmarks and leave them with an empty library.
|
||||||
Future<int> import(ImportBookmarkTree tree, {required bool replace}) async {
|
Future<int> import(
|
||||||
|
ImportBookmarkTree tree, {
|
||||||
|
required bool replace,
|
||||||
|
void Function(BookmarkImportProgress)? onProgress,
|
||||||
|
}) async {
|
||||||
if (tree.isEmpty) return 0;
|
if (tree.isEmpty) return 0;
|
||||||
|
|
||||||
|
final total = tree.stats.bookmarkCount;
|
||||||
|
|
||||||
if (replace) {
|
if (replace) {
|
||||||
|
onProgress?.call(
|
||||||
|
const BookmarkImportProgress(phase: BookmarkImportPhase.erasing),
|
||||||
|
);
|
||||||
|
|
||||||
for (final root in BookmarkRoot.values) {
|
for (final root in BookmarkRoot.values) {
|
||||||
if (root != BookmarkRoot.root) {
|
if (root != BookmarkRoot.root) {
|
||||||
await _service.eraseEverything(root);
|
await _service.eraseEverything(root);
|
||||||
@@ -51,20 +61,45 @@ class BookmarkTreeImporter {
|
|||||||
|
|
||||||
var importedCount = 0;
|
var importedCount = 0;
|
||||||
|
|
||||||
for (final section in tree.sections.entries) {
|
// Native reports progress per insertion, counted from the start of that
|
||||||
if (section.value.isEmpty) continue;
|
// call, so completed sections have to be added back on.
|
||||||
|
void report(int insertedInSection) {
|
||||||
final result = await _service.insertTree(
|
onProgress?.call(
|
||||||
section.key,
|
BookmarkImportProgress(
|
||||||
section.value.map(toPigeonImportNode).toList(),
|
phase: BookmarkImportPhase.inserting,
|
||||||
|
inserted: importedCount + insertedInSection,
|
||||||
|
total: total,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
importedCount += result.insertedItemCount;
|
if (onProgress != null) {
|
||||||
|
GeckoBookmarksEvents.setUp(_ImportProgressReceiver(report));
|
||||||
|
}
|
||||||
|
|
||||||
if (result.failedNodeCount > 0) {
|
try {
|
||||||
logger.e(
|
report(0);
|
||||||
'Failed to import ${result.failedNodeCount} top-level nodes into ${section.key}',
|
|
||||||
|
for (final section in tree.sections.entries) {
|
||||||
|
if (section.value.isEmpty) continue;
|
||||||
|
|
||||||
|
final result = await _service.insertTree(
|
||||||
|
section.key,
|
||||||
|
section.value.map(toPigeonImportNode).toList(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
importedCount += result.insertedItemCount;
|
||||||
|
report(0);
|
||||||
|
|
||||||
|
if (result.failedNodeCount > 0) {
|
||||||
|
logger.e(
|
||||||
|
'Failed to import ${result.failedNodeCount} top-level nodes into ${section.key}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (onProgress != null) {
|
||||||
|
GeckoBookmarksEvents.setUp(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +107,16 @@ class BookmarkTreeImporter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ImportProgressReceiver extends GeckoBookmarksEvents {
|
||||||
|
_ImportProgressReceiver(this._onProgress);
|
||||||
|
|
||||||
|
final void Function(int insertedItemCount) _onProgress;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onImportProgress(int insertedItemCount) =>
|
||||||
|
_onProgress(insertedItemCount);
|
||||||
|
}
|
||||||
|
|
||||||
/// Converts a parsed node into the Pigeon transport type.
|
/// Converts a parsed node into the Pigeon transport type.
|
||||||
///
|
///
|
||||||
/// Timestamps become milliseconds since epoch, with 0 standing in for "the file
|
/// Timestamps become milliseconds since epoch, with 0 standing in for "the file
|
||||||
|
|||||||
+4
@@ -53,6 +53,10 @@ class FlutterMozillaComponentsPlugin: FlutterPlugin, ActivityAware {
|
|||||||
// The UnifiedPush receiver outlives the Flutter engine; without this it would keep dispatching
|
// The UnifiedPush receiver outlives the Flutter engine; without this it would keep dispatching
|
||||||
// onto a dead messenger. Failures are still retained on Push.lastError.
|
// onto a dead messenger. Failures are still retained on Push.lastError.
|
||||||
GlobalComponents.pushEvents = null
|
GlobalComponents.pushEvents = null
|
||||||
|
// An import in flight keeps reporting after detach, and would otherwise hold
|
||||||
|
// the old messenger across an engine restart. Progress is advisory, so
|
||||||
|
// dropping the sink only costs the percentage, never the import itself.
|
||||||
|
GlobalComponents.bookmarksEvents = null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||||
|
|||||||
+6
@@ -18,6 +18,7 @@ import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonEvents
|
|||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoEngineSettings
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoEngineSettings
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAppLinkEvents
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAppLinkEvents
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoGestureEvents
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoGestureEvents
|
||||||
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoBookmarksEvents
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoHistoryEvents
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoHistoryEvents
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPushEvents
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPushEvents
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionEvents
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionEvents
|
||||||
@@ -146,6 +147,11 @@ object GlobalComponents {
|
|||||||
// container contextIds but skips Dart relation emits.
|
// container contextIds but skips Dart relation emits.
|
||||||
var historyEvents: GeckoHistoryEvents? = null
|
var historyEvents: GeckoHistoryEvents? = null
|
||||||
|
|
||||||
|
// Native -> Dart progress for a running bookmark import. Null whenever no
|
||||||
|
// import is in flight or Flutter is detached; progress is purely advisory,
|
||||||
|
// so a missing sink only means the UI shows no percentage.
|
||||||
|
var bookmarksEvents: GeckoBookmarksEvents? = null
|
||||||
|
|
||||||
// Native -> Dart UnifiedPush registration lifecycle. Null when push events
|
// Native -> Dart UnifiedPush registration lifecycle. Null when push events
|
||||||
// arrive with no Flutter engine attached (the UnifiedPushReceiver cold-start
|
// arrive with no Flutter engine attached (the UnifiedPushReceiver cold-start
|
||||||
// path), in which case failures are logged natively only.
|
// path), in which case failures are logged natively only.
|
||||||
|
|||||||
+221
-156
@@ -26,6 +26,17 @@ class GeckoBookmarksApiImpl() : GeckoBookmarksApi {
|
|||||||
* during an import. Only visible if an import is interrupted partway.
|
* during an import. Only visible if an import is interrupted partway.
|
||||||
*/
|
*/
|
||||||
private const val SCRATCH_FOLDER_TITLE = "Importing bookmarks…"
|
private const val SCRATCH_FOLDER_TITLE = "Importing bookmarks…"
|
||||||
|
|
||||||
|
/** Bookmarks written between progress reports. */
|
||||||
|
private const val PROGRESS_STEP = 64L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Largest subtree handed to storage as a single insertion.
|
||||||
|
*
|
||||||
|
* Bounds how long the import can run without reporting anything. Raise
|
||||||
|
* it for fewer, larger writes; lower it for a smoother progress bar.
|
||||||
|
*/
|
||||||
|
private const val BULK_INSERT_THRESHOLD = 1000L
|
||||||
}
|
}
|
||||||
|
|
||||||
private val components by lazy {
|
private val components by lazy {
|
||||||
@@ -259,54 +270,233 @@ class GeckoBookmarksApiImpl() : GeckoBookmarksApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Appends [nodes] underneath [parentGuid], handing every top-level folder to
|
* Appends [nodes] underneath [parentGuid].
|
||||||
* storage as a single tree insertion.
|
|
||||||
*
|
*
|
||||||
* `insertTree` is the only storage call that carries timestamps, and it can
|
* `insertTree` is the only storage call that carries timestamps, and it can
|
||||||
* only create a *folder*. Loose top-level items and separators would
|
* only create a *folder*. Loose items and separators would therefore lose
|
||||||
* therefore lose their `ADD_DATE` if inserted with `addItem`/`addSeparator`,
|
* their `ADD_DATE` if inserted with `addItem`/`addSeparator`, which have no
|
||||||
* which have no timestamp parameters — so they are staged inside a scratch
|
* timestamp parameters — so they pass through a scratch folder instead. See
|
||||||
* folder and reparented instead. See [stageLooseNodes].
|
* [insertLooseChunk].
|
||||||
*
|
*
|
||||||
* A failing top-level node is counted and skipped rather than aborting the
|
* A failing node is counted and skipped rather than aborting the whole
|
||||||
* whole import, matching the per-node importer this replaced. Deliberately
|
* import, matching the per-node importer this replaced. Deliberately does
|
||||||
* does not emit `bookmarks.onCreated`: one event per imported node would
|
* not emit `bookmarks.onCreated`: one event per imported node would flood
|
||||||
* flood every installed WebExtension.
|
* every installed WebExtension.
|
||||||
*/
|
*/
|
||||||
private suspend fun insertImportNodes(
|
private suspend fun insertImportNodes(
|
||||||
parentGuid: String,
|
parentGuid: String,
|
||||||
nodes: List<BookmarkImportNode>
|
nodes: List<BookmarkImportNode>
|
||||||
): BookmarkInsertTreeResult {
|
): BookmarkInsertTreeResult {
|
||||||
val storage = components.core.bookmarksStorage
|
val state = InsertState(ImportProgressReporter())
|
||||||
|
|
||||||
|
insertChildren(parentGuid, nodes, state)
|
||||||
|
|
||||||
|
state.progress.reportFinal(state.insertedItemCount)
|
||||||
|
|
||||||
|
return BookmarkInsertTreeResult(state.insertedItemCount, state.failedNodeCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Running totals for one import, shared across the recursion. */
|
||||||
|
private class InsertState(val progress: ImportProgressReporter) {
|
||||||
var insertedItemCount = 0L
|
var insertedItemCount = 0L
|
||||||
var failedNodeCount = 0L
|
var failedNodeCount = 0L
|
||||||
|
}
|
||||||
|
|
||||||
val staged = stageLooseNodes(parentGuid, nodes)
|
/**
|
||||||
|
* Writes [nodes] underneath [parentGuid], in order.
|
||||||
|
*
|
||||||
|
* Folders are written one at a time; runs of consecutive loose nodes are
|
||||||
|
* written in chunks. Every write appends, so walking the nodes strictly in
|
||||||
|
* order reproduces the file's order, and merging into a folder that already
|
||||||
|
* has children leaves those in place.
|
||||||
|
*/
|
||||||
|
private suspend fun insertChildren(
|
||||||
|
parentGuid: String,
|
||||||
|
nodes: List<BookmarkImportNode>,
|
||||||
|
state: InsertState
|
||||||
|
) {
|
||||||
|
var index = 0
|
||||||
|
|
||||||
for (node in nodes) {
|
while (index < nodes.size) {
|
||||||
// Every branch appends (position = null). Walking the nodes in order
|
if (nodes[index].type == BookmarkNodeType.FOLDER) {
|
||||||
// therefore reproduces the file's order, and merging into a folder
|
insertFolder(parentGuid, nodes[index], state).fold(
|
||||||
// that already has children leaves those in place.
|
{ count -> state.insertedItemCount += count },
|
||||||
val outcome: Result<Long> = when (node.type) {
|
{ state.failedNodeCount += 1 }
|
||||||
BookmarkNodeType.FOLDER -> {
|
)
|
||||||
val folder = node.toInsertableFolder(position = null)
|
state.progress.report(state.insertedItemCount)
|
||||||
storage.insertTree(InsertableBookmarkTreeRoot(parentGuid, folder))
|
index++
|
||||||
.map { folder.itemCount() }
|
continue
|
||||||
}
|
|
||||||
|
|
||||||
// Already written by stageLooseNodes; only the move is left.
|
|
||||||
else -> staged.reparent(node, parentGuid)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
outcome.fold(
|
// Take the whole run of loose nodes so they keep their place
|
||||||
{ count -> insertedItemCount += count },
|
// relative to the folders around them.
|
||||||
{ failedNodeCount += 1 }
|
var end = index
|
||||||
)
|
while (end < nodes.size && nodes[end].type != BookmarkNodeType.FOLDER) {
|
||||||
|
end++
|
||||||
|
}
|
||||||
|
|
||||||
|
for (chunk in nodes.subList(index, end).chunked(BULK_INSERT_THRESHOLD.toInt())) {
|
||||||
|
insertLooseChunk(parentGuid, chunk, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
index = end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a chunk of loose items and separators, then moves them into place.
|
||||||
|
*
|
||||||
|
* `insertTree` is the only call that carries timestamps and it can only
|
||||||
|
* create a folder, so loose nodes are written into a scratch folder and
|
||||||
|
* reparented out of it. Doing that for the whole file at once left the
|
||||||
|
* progress bar at zero for the entire bulk write, so it happens a chunk at
|
||||||
|
* a time and the moves report as they go.
|
||||||
|
*/
|
||||||
|
private suspend fun insertLooseChunk(
|
||||||
|
parentGuid: String,
|
||||||
|
chunk: List<BookmarkImportNode>,
|
||||||
|
state: InsertState
|
||||||
|
) {
|
||||||
|
val storage = components.core.bookmarksStorage
|
||||||
|
|
||||||
|
val staged = ArrayList<BookmarkImportNode>(chunk.size)
|
||||||
|
val insertable = ArrayList<InsertableBookmarkTreeNode>(chunk.size)
|
||||||
|
for (node in chunk) {
|
||||||
|
// Positions come from the surviving order so dropping an unusable
|
||||||
|
// node leaves no gap.
|
||||||
|
val converted = node.toInsertableNode(insertable.size.toUInt())
|
||||||
|
if (converted == null) {
|
||||||
|
state.failedNodeCount += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
insertable.add(converted)
|
||||||
|
staged.add(node)
|
||||||
}
|
}
|
||||||
|
|
||||||
staged.discardScratchFolder()
|
if (staged.isEmpty()) return
|
||||||
|
|
||||||
return BookmarkInsertTreeResult(insertedItemCount, failedNodeCount)
|
val scratch = InsertableBookmarkTreeNode.Folder(
|
||||||
|
title = SCRATCH_FOLDER_TITLE,
|
||||||
|
dateAddedTimestamp = 0L,
|
||||||
|
lastModifiedTimestamp = 0L,
|
||||||
|
position = null,
|
||||||
|
children = insertable
|
||||||
|
)
|
||||||
|
|
||||||
|
val scratchGuid = storage.insertTree(InsertableBookmarkTreeRoot(parentGuid, scratch))
|
||||||
|
.getOrElse {
|
||||||
|
state.failedNodeCount += staged.size
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the assigned guids back in position order, which is the order
|
||||||
|
// the nodes were handed to insertTree.
|
||||||
|
val written = storage.getTree(scratchGuid, false).getOrNull()?.children.orEmpty()
|
||||||
|
|
||||||
|
for ((position, node) in staged.withIndex()) {
|
||||||
|
val guid = written.getOrNull(position)?.guid
|
||||||
|
if (guid == null) {
|
||||||
|
state.failedNodeCount += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// A null field means "leave unchanged"; appending (null position)
|
||||||
|
// keeps the file's order as the caller walks the level.
|
||||||
|
val move = MozillaBookmarkInfo(
|
||||||
|
parentGuid = parentGuid,
|
||||||
|
position = null,
|
||||||
|
title = null,
|
||||||
|
url = null
|
||||||
|
)
|
||||||
|
|
||||||
|
storage.updateNode(guid, move).fold(
|
||||||
|
{
|
||||||
|
if (node.type == BookmarkNodeType.ITEM) {
|
||||||
|
state.insertedItemCount += 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ state.failedNodeCount += 1 }
|
||||||
|
)
|
||||||
|
|
||||||
|
state.progress.report(state.insertedItemCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleting cascades to children, so anything that failed to move is
|
||||||
|
// left behind in a visible folder rather than being silently destroyed.
|
||||||
|
val remaining = storage.getTree(scratchGuid, false).getOrNull()?.children
|
||||||
|
if (remaining.isNullOrEmpty()) {
|
||||||
|
storage.deleteNode(scratchGuid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes one folder and everything under it.
|
||||||
|
*
|
||||||
|
* Storage inserts a tree as a single indivisible operation with no way to
|
||||||
|
* observe it, so a whole import handed over in one call reports nothing
|
||||||
|
* until it has finished — a bookmark file whose top level is a single
|
||||||
|
* folder, which is what Firefox exports look like, would leave the progress
|
||||||
|
* bar at zero for the entire run.
|
||||||
|
*
|
||||||
|
* Subtrees up to [BULK_INSERT_THRESHOLD] items therefore go in whole, which
|
||||||
|
* is the fast path, while anything larger is split: the folder itself is
|
||||||
|
* created empty — keeping its own timestamps — and its children are written
|
||||||
|
* one level down. That trades some batching for a progress bar that moves.
|
||||||
|
*/
|
||||||
|
private suspend fun insertFolder(
|
||||||
|
parentGuid: String,
|
||||||
|
node: BookmarkImportNode,
|
||||||
|
state: InsertState
|
||||||
|
): Result<Long> {
|
||||||
|
val storage = components.core.bookmarksStorage
|
||||||
|
val folder = node.toInsertableFolder(position = null)
|
||||||
|
val itemCount = folder.itemCount()
|
||||||
|
|
||||||
|
if (itemCount <= BULK_INSERT_THRESHOLD) {
|
||||||
|
return storage.insertTree(InsertableBookmarkTreeRoot(parentGuid, folder))
|
||||||
|
.map { itemCount }
|
||||||
|
}
|
||||||
|
|
||||||
|
val shell = folder.copy(children = emptyList())
|
||||||
|
val guid = storage.insertTree(InsertableBookmarkTreeRoot(parentGuid, shell))
|
||||||
|
.getOrElse { return Result.failure(it) }
|
||||||
|
|
||||||
|
insertChildren(guid, node.children, state)
|
||||||
|
|
||||||
|
// The recursion already counted everything it wrote.
|
||||||
|
return Result.success(0L)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forwards insertion progress to Dart, throttled.
|
||||||
|
*
|
||||||
|
* A flat bookmark file has every entry as a top-level node, so the caller
|
||||||
|
* ticks once per bookmark — tens of thousands of times. Only every
|
||||||
|
* [PROGRESS_STEP]th tick crosses the channel, plus a final exact figure, so
|
||||||
|
* the reporting cannot itself become the bottleneck.
|
||||||
|
*/
|
||||||
|
private inner class ImportProgressReporter {
|
||||||
|
private var lastReported = 0L
|
||||||
|
|
||||||
|
fun report(insertedItemCount: Long) {
|
||||||
|
if (insertedItemCount - lastReported < PROGRESS_STEP) return
|
||||||
|
lastReported = insertedItemCount
|
||||||
|
emit(insertedItemCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reportFinal(insertedItemCount: Long) {
|
||||||
|
if (insertedItemCount == lastReported) return
|
||||||
|
lastReported = insertedItemCount
|
||||||
|
emit(insertedItemCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun emit(insertedItemCount: Long) {
|
||||||
|
val events = GlobalComponents.bookmarksEvents ?: return
|
||||||
|
// Pigeon callbacks must be dispatched from the platform thread.
|
||||||
|
coroutineScope.launch {
|
||||||
|
events.onImportProgress(insertedItemCount) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun countBookmarksInTrees(
|
override fun countBookmarksInTrees(
|
||||||
@@ -323,131 +513,6 @@ class GeckoBookmarksApiImpl() : GeckoBookmarksApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Loose top-level nodes written into a scratch folder, waiting to be moved
|
|
||||||
* to their real parent.
|
|
||||||
*
|
|
||||||
* The scratch folder is created under the import destination and holds the
|
|
||||||
* loose nodes in file order; [reparent] hands them out one at a time as the
|
|
||||||
* caller walks the top level, and [discardScratchFolder] removes the folder
|
|
||||||
* once it has been emptied.
|
|
||||||
*/
|
|
||||||
private inner class StagedLooseNodes(
|
|
||||||
private val scratchGuid: String?,
|
|
||||||
/** The loose nodes that made it into the scratch folder, in order. */
|
|
||||||
private val staged: List<BookmarkImportNode>,
|
|
||||||
/** Guid assigned to each entry of [staged], by index. */
|
|
||||||
private val guids: List<String>,
|
|
||||||
private val failure: Throwable?
|
|
||||||
) {
|
|
||||||
private var next = 0
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Moves the next staged node under [parentGuid].
|
|
||||||
*
|
|
||||||
* Reparenting preserves `dateAdded`, which is what bookmark ordering and
|
|
||||||
* "recently added" depend on. It does refresh `lastModified` — the pair
|
|
||||||
* cannot both survive, because the only storage call that accepts
|
|
||||||
* timestamps creates a folder.
|
|
||||||
*/
|
|
||||||
suspend fun reparent(node: BookmarkImportNode, parentGuid: String): Result<Long> {
|
|
||||||
failure?.let { return Result.failure(it) }
|
|
||||||
|
|
||||||
// Nodes dropped while converting (an item with no usable url) were
|
|
||||||
// never staged, so the cursor must not advance past them.
|
|
||||||
if (staged.getOrNull(next) !== node) {
|
|
||||||
return Result.failure(
|
|
||||||
IllegalArgumentException("Unusable bookmark node of type ${node.type}")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val guid = guids.getOrNull(next)
|
|
||||||
?: return Result.failure(
|
|
||||||
IllegalStateException("Storage did not report a guid for ${node.type}")
|
|
||||||
)
|
|
||||||
next++
|
|
||||||
|
|
||||||
// A null field means "leave unchanged"; appending (null position)
|
|
||||||
// keeps the file's order as the caller walks the top level.
|
|
||||||
val move = MozillaBookmarkInfo(
|
|
||||||
parentGuid = parentGuid,
|
|
||||||
position = null,
|
|
||||||
title = null,
|
|
||||||
url = null
|
|
||||||
)
|
|
||||||
|
|
||||||
return components.core.bookmarksStorage
|
|
||||||
.updateNode(guid, move)
|
|
||||||
.map { if (node.type == BookmarkNodeType.ITEM) 1L else 0L }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes the scratch folder, but only once it is empty.
|
|
||||||
*
|
|
||||||
* Deleting cascades to children, so anything that failed to move is left
|
|
||||||
* behind in a visible folder rather than being silently destroyed.
|
|
||||||
*/
|
|
||||||
suspend fun discardScratchFolder() {
|
|
||||||
val guid = scratchGuid ?: return
|
|
||||||
val storage = components.core.bookmarksStorage
|
|
||||||
|
|
||||||
val remaining = storage.getTree(guid, false).getOrNull()?.children
|
|
||||||
if (remaining.isNullOrEmpty()) {
|
|
||||||
storage.deleteNode(guid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Writes every loose top-level node of [nodes] into a scratch folder under
|
|
||||||
* [parentGuid] in a single tree insertion, so their timestamps survive.
|
|
||||||
*
|
|
||||||
* Returns an empty staging area when the import has no loose top-level
|
|
||||||
* nodes, which is the common case for Firefox exports and costs nothing.
|
|
||||||
*/
|
|
||||||
private suspend fun stageLooseNodes(
|
|
||||||
parentGuid: String,
|
|
||||||
nodes: List<BookmarkImportNode>
|
|
||||||
): StagedLooseNodes {
|
|
||||||
val empty = StagedLooseNodes(null, emptyList(), emptyList(), null)
|
|
||||||
|
|
||||||
val staged = ArrayList<BookmarkImportNode>()
|
|
||||||
val insertable = ArrayList<InsertableBookmarkTreeNode>()
|
|
||||||
for (node in nodes) {
|
|
||||||
if (node.type == BookmarkNodeType.FOLDER) continue
|
|
||||||
val converted = node.toInsertableNode(insertable.size.toUInt()) ?: continue
|
|
||||||
insertable.add(converted)
|
|
||||||
staged.add(node)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (staged.isEmpty()) return empty
|
|
||||||
|
|
||||||
val scratch = InsertableBookmarkTreeNode.Folder(
|
|
||||||
title = SCRATCH_FOLDER_TITLE,
|
|
||||||
dateAddedTimestamp = 0L,
|
|
||||||
lastModifiedTimestamp = 0L,
|
|
||||||
position = null,
|
|
||||||
children = insertable
|
|
||||||
)
|
|
||||||
|
|
||||||
val storage = components.core.bookmarksStorage
|
|
||||||
|
|
||||||
return storage.insertTree(InsertableBookmarkTreeRoot(parentGuid, scratch)).fold(
|
|
||||||
{ scratchGuid ->
|
|
||||||
// Read the assigned guids back in position order, which is the
|
|
||||||
// order the nodes were handed to insertTree.
|
|
||||||
val children = storage.getTree(scratchGuid, false).getOrNull()?.children
|
|
||||||
StagedLooseNodes(
|
|
||||||
scratchGuid = scratchGuid,
|
|
||||||
staged = staged,
|
|
||||||
guids = children.orEmpty().map { it.guid },
|
|
||||||
failure = null
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{ error -> StagedLooseNodes(null, staged, emptyList(), error) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun BookmarkImportNode.toInsertableFolder(position: UInt?) =
|
private fun BookmarkImportNode.toInsertableFolder(position: UInt?) =
|
||||||
InsertableBookmarkTreeNode.Folder(
|
InsertableBookmarkTreeNode.Folder(
|
||||||
title = this.title,
|
title = this.title,
|
||||||
|
|||||||
+5
@@ -52,6 +52,7 @@ import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPushEvents
|
|||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPwaApi
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPwaApi
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionController
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionController
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAppLinkEvents
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAppLinkEvents
|
||||||
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoBookmarksEvents
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoHistoryEvents
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoHistoryEvents
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionEvents
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionEvents
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSessionApi
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSessionApi
|
||||||
@@ -281,6 +282,10 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
|
|||||||
GlobalComponents.historyEvents =
|
GlobalComponents.historyEvents =
|
||||||
GeckoHistoryEvents(_flutterPluginBinding.binaryMessenger)
|
GeckoHistoryEvents(_flutterPluginBinding.binaryMessenger)
|
||||||
|
|
||||||
|
// Progress sink for long-running bookmark imports.
|
||||||
|
GlobalComponents.bookmarksEvents =
|
||||||
|
GeckoBookmarksEvents(_flutterPluginBinding.binaryMessenger)
|
||||||
|
|
||||||
// Availability signal for pending app-link prompts (Flutter-owned prompts).
|
// Availability signal for pending app-link prompts (Flutter-owned prompts).
|
||||||
GlobalComponents.appLinkEvents =
|
GlobalComponents.appLinkEvents =
|
||||||
GeckoAppLinkEvents(_flutterPluginBinding.binaryMessenger)
|
GeckoAppLinkEvents(_flutterPluginBinding.binaryMessenger)
|
||||||
|
|||||||
+40
@@ -12151,6 +12151,46 @@ interface GeckoFetchApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Native -> Dart progress for a bulk bookmark insertion.
|
||||||
|
*
|
||||||
|
* A large import is a single [GeckoBookmarksApi.insertTree] call that can run
|
||||||
|
* for a long time, so it reports how far along it is rather than leaving the
|
||||||
|
* app with nothing to show. Emission is throttled natively, so this fires
|
||||||
|
* far less often than once per bookmark.
|
||||||
|
*
|
||||||
|
* Generated class from Pigeon that represents Flutter messages that can be called from Kotlin.
|
||||||
|
*/
|
||||||
|
class GeckoBookmarksEvents(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") {
|
||||||
|
companion object {
|
||||||
|
/** The codec used by GeckoBookmarksEvents. */
|
||||||
|
val codec: MessageCodec<Any?> by lazy {
|
||||||
|
GeckoPigeonCodec()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* [insertedItemCount] is the running number of bookmark items written by the
|
||||||
|
* insertion currently in progress, counted from the start of that one call.
|
||||||
|
* Dart adds the offset of any earlier calls to get an overall figure.
|
||||||
|
*/
|
||||||
|
fun onImportProgress(insertedItemCountArg: Long, callback: (Result<Unit>) -> Unit)
|
||||||
|
{
|
||||||
|
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
|
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoBookmarksEvents.onImportProgress$separatedMessageChannelSuffix"
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||||
|
channel.send(listOf(insertedItemCountArg)) {
|
||||||
|
if (it is List<*>) {
|
||||||
|
if (it.size > 1) {
|
||||||
|
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||||
|
} else {
|
||||||
|
callback(Result.success(Unit))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Controls GeckoView's viewport behavior for dynamic toolbar and keyboard handling.
|
* Controls GeckoView's viewport behavior for dynamic toolbar and keyboard handling.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export 'src/pigeons/gecko.g.dart'
|
|||||||
EmailHitResult,
|
EmailHitResult,
|
||||||
FrecencyThresholdOption,
|
FrecencyThresholdOption,
|
||||||
GeckoAppLinkEvents,
|
GeckoAppLinkEvents,
|
||||||
|
GeckoBookmarksEvents,
|
||||||
GeckoDeleteBrowsingDataController,
|
GeckoDeleteBrowsingDataController,
|
||||||
GeckoEngineSettings,
|
GeckoEngineSettings,
|
||||||
GeckoFetchResponse,
|
GeckoFetchResponse,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2512,6 +2512,20 @@ class BookmarkInsertTreeResult {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Native -> Dart progress for a bulk bookmark insertion.
|
||||||
|
///
|
||||||
|
/// A large import is a single [GeckoBookmarksApi.insertTree] call that can run
|
||||||
|
/// for a long time, so it reports how far along it is rather than leaving the
|
||||||
|
/// app with nothing to show. Emission is throttled natively, so this fires
|
||||||
|
/// far less often than once per bookmark.
|
||||||
|
@FlutterApi()
|
||||||
|
abstract class GeckoBookmarksEvents {
|
||||||
|
/// [insertedItemCount] is the running number of bookmark items written by the
|
||||||
|
/// insertion currently in progress, counted from the start of that one call.
|
||||||
|
/// Dart adds the offset of any earlier calls to get an overall figure.
|
||||||
|
void onImportProgress(int insertedItemCount);
|
||||||
|
}
|
||||||
|
|
||||||
/// Class for making alterations to any bookmark node
|
/// Class for making alterations to any bookmark node
|
||||||
class BookmarkInfo {
|
class BookmarkInfo {
|
||||||
final String? parentGuid;
|
final String? parentGuid;
|
||||||
|
|||||||
Reference in New Issue
Block a user