diff --git a/apps/weblibre/lib/features/geckoview/features/bookmarks/domain/entities/import_bookmark_node.dart b/apps/weblibre/lib/features/geckoview/features/bookmarks/domain/entities/import_bookmark_node.dart
index 1b929d4c..47a5f9e0 100644
--- a/apps/weblibre/lib/features/geckoview/features/bookmarks/domain/entities/import_bookmark_node.dart
+++ b/apps/weblibre/lib/features/geckoview/features/bookmarks/domain/entities/import_bookmark_node.dart
@@ -30,6 +30,40 @@
/// dropped during parsing rather than modelled here.
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.
sealed class ImportBookmarkNode {
/// Creation time recorded in the imported file, or null if it had none.
diff --git a/apps/weblibre/lib/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart b/apps/weblibre/lib/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart
index 901c0dce..e5477a5f 100644
--- a/apps/weblibre/lib/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart
+++ b/apps/weblibre/lib/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart
@@ -21,6 +21,7 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:riverpod_annotation/riverpod_annotation.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/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_import_isolate.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_importer.dart';
@@ -226,7 +227,12 @@ class BookmarksRepository extends _$BookmarksRepository {
required String path,
required BookmarkImportFormat format,
bool replace = false,
+ void Function(BookmarkImportProgress)? onProgress,
}) async {
+ onProgress?.call(
+ const BookmarkImportProgress(phase: BookmarkImportPhase.parsing),
+ );
+
final tree = await parseBookmarkFile(
path: path,
format: format,
@@ -237,7 +243,7 @@ class BookmarksRepository extends _$BookmarksRepository {
final count = await BookmarkTreeImporter(
_service,
- ).import(tree, replace: replace);
+ ).import(tree, replace: replace, onProgress: onProgress);
_notifyChanged();
return count;
diff --git a/apps/weblibre/lib/features/geckoview/features/bookmarks/presentation/dialogs/import_progress_dialog.dart b/apps/weblibre/lib/features/geckoview/features/bookmarks/presentation/dialogs/import_progress_dialog.dart
new file mode 100644
index 00000000..1e7949e6
--- /dev/null
+++ b/apps/weblibre/lib/features/geckoview/features/bookmarks/presentation/dialogs/import_progress_dialog.dart
@@ -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 .
+ */
+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 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(
+ 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),
+ ],
+ );
+ },
+ ),
+ ),
+ );
+ }
+}
diff --git a/apps/weblibre/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_list.dart b/apps/weblibre/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_list.dart
index 64788795..b8a2b5e1 100644
--- a/apps/weblibre/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_list.dart
+++ b/apps/weblibre/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_list.dart
@@ -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_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/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/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_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_progress_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/tabs/data/entities/tab_mode.dart';
@@ -1086,15 +1088,39 @@ class BookmarkListScreen extends HookConsumerWidget {
final shouldReplace = await showImportBookmarksDialog(context);
if (shouldReplace == null) return; // User cancelled dialog
- // Reading and parsing happen in a background isolate, so a large file
- // does not freeze the UI while it is being processed.
- final count = await ref
- .read(bookmarksRepositoryProvider.notifier)
- .importFromFile(
- path: file.path!,
- format: format,
- replace: shouldReplace,
- );
+ if (!context.mounted) return;
+
+ // Writing tens of thousands of bookmarks takes long enough that the app
+ // would look frozen without something to watch.
+ final progress = ValueNotifier(
+ const BookmarkImportProgress(phase: BookmarkImportPhase.parsing),
+ );
+
+ final progressDialog = showDialog(
+ 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) {
showInfoMessage(context, 'Imported $count bookmarks successfully');
diff --git a/apps/weblibre/lib/features/geckoview/features/bookmarks/utils/bookmark_html_utils.dart b/apps/weblibre/lib/features/geckoview/features/bookmarks/utils/bookmark_html_utils.dart
index 8108ca27..1c9db228 100644
--- a/apps/weblibre/lib/features/geckoview/features/bookmarks/utils/bookmark_html_utils.dart
+++ b/apps/weblibre/lib/features/geckoview/features/bookmarks/utils/bookmark_html_utils.dart
@@ -105,6 +105,15 @@ class _PendingItem {
_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 {
final _ParsedFolder folder;
int containerNesting = 0;
@@ -148,49 +157,47 @@ class _BookmarkHtmlParser {
return _buildTree();
}
- dom.Node? _nextSibling(dom.Node node) {
- final parent = node.parent;
- if (parent == null) return null;
-
- final siblings = parent.nodes;
- final index = siblings.indexOf(node);
-
- if (index != -1 && index + 1 < siblings.length) {
- return siblings[index + 1];
- }
-
- return null;
- }
-
+ /// Walks the document depth-first, opening each node on the way down and
+ /// closing it on the way back up.
+ ///
+ /// Keeps its own cursor into every level rather than asking a node for its
+ /// next sibling. Locating a sibling means searching the parent's child list,
+ /// 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
+ /// a single list, which turned a 25k-bookmark import into hundreds of
+ /// millions of comparisons and minutes of parsing.
void _walkTreeForImport(dom.Node? node) {
if (node == null) return;
- dom.Node? current = node;
- dom.Node? next;
+ final stack = <_WalkFrame>[_WalkFrame(node)];
+ _enterNode(node);
- for (;;) {
- if (current?.nodeType == dom.Node.ELEMENT_NODE) {
- _openContainer(current! as dom.Element);
- } else if (current?.nodeType == dom.Node.TEXT_NODE) {
- _appendText(current!.text ?? '');
- }
+ while (stack.isNotEmpty) {
+ final frame = stack.last;
+ final children = frame.node.nodes;
- if ((next = current?.firstChild) != null) {
- current = next;
- continue;
+ if (frame.index < children.length) {
+ final child = children[frame.index++];
+ _enterNode(child);
+ stack.add(_WalkFrame(child));
+ } else {
+ _leaveNode(frame.node);
+ stack.removeLast();
}
+ }
+ }
- for (;;) {
- if (current?.nodeType == dom.Node.ELEMENT_NODE) {
- _closeContainer(current! as dom.Element);
- }
- if (current == node) return;
- if ((next = _nextSibling(current!)) != null) {
- current = next;
- break;
- }
- current = current.parentNode;
- }
+ void _enterNode(dom.Node node) {
+ if (node.nodeType == dom.Node.ELEMENT_NODE) {
+ _openContainer(node as dom.Element);
+ } else if (node.nodeType == dom.Node.TEXT_NODE) {
+ _appendText(node.text ?? '');
+ }
+ }
+
+ void _leaveNode(dom.Node node) {
+ if (node.nodeType == dom.Node.ELEMENT_NODE) {
+ _closeContainer(node as dom.Element);
}
}
diff --git a/apps/weblibre/lib/features/geckoview/features/bookmarks/utils/bookmark_importer.dart b/apps/weblibre/lib/features/geckoview/features/bookmarks/utils/bookmark_importer.dart
index 75059252..a73f1407 100644
--- a/apps/weblibre/lib/features/geckoview/features/bookmarks/utils/bookmark_importer.dart
+++ b/apps/weblibre/lib/features/geckoview/features/bookmarks/utils/bookmark_importer.dart
@@ -38,10 +38,20 @@ class BookmarkTreeImporter {
/// 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
/// user's bookmarks and leave them with an empty library.
- Future import(ImportBookmarkTree tree, {required bool replace}) async {
+ Future import(
+ ImportBookmarkTree tree, {
+ required bool replace,
+ void Function(BookmarkImportProgress)? onProgress,
+ }) async {
if (tree.isEmpty) return 0;
+ final total = tree.stats.bookmarkCount;
+
if (replace) {
+ onProgress?.call(
+ const BookmarkImportProgress(phase: BookmarkImportPhase.erasing),
+ );
+
for (final root in BookmarkRoot.values) {
if (root != BookmarkRoot.root) {
await _service.eraseEverything(root);
@@ -51,20 +61,45 @@ class BookmarkTreeImporter {
var importedCount = 0;
- for (final section in tree.sections.entries) {
- if (section.value.isEmpty) continue;
-
- final result = await _service.insertTree(
- section.key,
- section.value.map(toPigeonImportNode).toList(),
+ // Native reports progress per insertion, counted from the start of that
+ // call, so completed sections have to be added back on.
+ void report(int insertedInSection) {
+ onProgress?.call(
+ BookmarkImportProgress(
+ phase: BookmarkImportPhase.inserting,
+ inserted: importedCount + insertedInSection,
+ total: total,
+ ),
);
+ }
- importedCount += result.insertedItemCount;
+ if (onProgress != null) {
+ GeckoBookmarksEvents.setUp(_ImportProgressReceiver(report));
+ }
- if (result.failedNodeCount > 0) {
- logger.e(
- 'Failed to import ${result.failedNodeCount} top-level nodes into ${section.key}',
+ try {
+ report(0);
+
+ 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.
///
/// Timestamps become milliseconds since epoch, with 0 standing in for "the file
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt
index 208bdd30..6b5b52f4 100644
--- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt
@@ -53,6 +53,10 @@ class FlutterMozillaComponentsPlugin: FlutterPlugin, ActivityAware {
// The UnifiedPush receiver outlives the Flutter engine; without this it would keep dispatching
// onto a dead messenger. Failures are still retained on Push.lastError.
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) {
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt
index 28a5a3e1..3de333dd 100644
--- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt
@@ -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.GeckoAppLinkEvents
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.GeckoPushEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionEvents
@@ -146,6 +147,11 @@ object GlobalComponents {
// container contextIds but skips Dart relation emits.
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
// arrive with no Flutter engine attached (the UnifiedPushReceiver cold-start
// path), in which case failures are logged natively only.
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBookmarksApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBookmarksApiImpl.kt
index d3f289bb..e471fccf 100644
--- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBookmarksApiImpl.kt
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBookmarksApiImpl.kt
@@ -26,6 +26,17 @@ class GeckoBookmarksApiImpl() : GeckoBookmarksApi {
* during an import. Only visible if an import is interrupted partway.
*/
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 {
@@ -259,54 +270,233 @@ class GeckoBookmarksApiImpl() : GeckoBookmarksApi {
}
/**
- * Appends [nodes] underneath [parentGuid], handing every top-level folder to
- * storage as a single tree insertion.
+ * Appends [nodes] underneath [parentGuid].
*
* `insertTree` is the only storage call that carries timestamps, and it can
- * only create a *folder*. Loose top-level items and separators would
- * therefore lose their `ADD_DATE` if inserted with `addItem`/`addSeparator`,
- * which have no timestamp parameters — so they are staged inside a scratch
- * folder and reparented instead. See [stageLooseNodes].
+ * only create a *folder*. Loose items and separators would therefore lose
+ * their `ADD_DATE` if inserted with `addItem`/`addSeparator`, which have no
+ * timestamp parameters — so they pass through a scratch folder instead. See
+ * [insertLooseChunk].
*
- * A failing top-level node is counted and skipped rather than aborting the
- * whole import, matching the per-node importer this replaced. Deliberately
- * does not emit `bookmarks.onCreated`: one event per imported node would
- * flood every installed WebExtension.
+ * A failing node is counted and skipped rather than aborting the whole
+ * import, matching the per-node importer this replaced. Deliberately does
+ * not emit `bookmarks.onCreated`: one event per imported node would flood
+ * every installed WebExtension.
*/
private suspend fun insertImportNodes(
parentGuid: String,
nodes: List
): 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 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,
+ state: InsertState
+ ) {
+ var index = 0
- for (node in nodes) {
- // Every branch appends (position = null). Walking the nodes in order
- // therefore reproduces the file's order, and merging into a folder
- // that already has children leaves those in place.
- val outcome: Result = when (node.type) {
- BookmarkNodeType.FOLDER -> {
- val folder = node.toInsertableFolder(position = null)
- storage.insertTree(InsertableBookmarkTreeRoot(parentGuid, folder))
- .map { folder.itemCount() }
- }
-
- // Already written by stageLooseNodes; only the move is left.
- else -> staged.reparent(node, parentGuid)
+ while (index < nodes.size) {
+ if (nodes[index].type == BookmarkNodeType.FOLDER) {
+ insertFolder(parentGuid, nodes[index], state).fold(
+ { count -> state.insertedItemCount += count },
+ { state.failedNodeCount += 1 }
+ )
+ state.progress.report(state.insertedItemCount)
+ index++
+ continue
}
- outcome.fold(
- { count -> insertedItemCount += count },
- { failedNodeCount += 1 }
- )
+ // Take the whole run of loose nodes so they keep their place
+ // relative to the folders around them.
+ 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,
+ state: InsertState
+ ) {
+ val storage = components.core.bookmarksStorage
+
+ val staged = ArrayList(chunk.size)
+ val insertable = ArrayList(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 {
+ 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(
@@ -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,
- /** Guid assigned to each entry of [staged], by index. */
- private val guids: List,
- 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 {
- 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
- ): StagedLooseNodes {
- val empty = StagedLooseNodes(null, emptyList(), emptyList(), null)
-
- val staged = ArrayList()
- val insertable = ArrayList()
- 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?) =
InsertableBookmarkTreeNode.Folder(
title = this.title,
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt
index cfbde531..0b1e3a8a 100644
--- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt
@@ -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.GeckoSelectionActionController
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.GeckoSelectionActionEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSessionApi
@@ -281,6 +282,10 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
GlobalComponents.historyEvents =
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).
GlobalComponents.appLinkEvents =
GeckoAppLinkEvents(_flutterPluginBinding.binaryMessenger)
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt
index 1c40a3bf..708d6382 100644
--- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt
@@ -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 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)
+{
+ val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
+ val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoBookmarksEvents.onImportProgress$separatedMessageChannelSuffix"
+ val channel = BasicMessageChannel(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.
*
diff --git a/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart b/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart
index 9d13f76c..756c6407 100644
--- a/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart
+++ b/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart
@@ -79,6 +79,7 @@ export 'src/pigeons/gecko.g.dart'
EmailHitResult,
FrecencyThresholdOption,
GeckoAppLinkEvents,
+ GeckoBookmarksEvents,
GeckoDeleteBrowsingDataController,
GeckoEngineSettings,
GeckoFetchResponse,
diff --git a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart
index 6d7cbb79..67d86211 100644
--- a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart
+++ b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart
@@ -10,9 +10,9 @@ import 'package:flutter/services.dart';
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
Object? _extractReplyValueOrThrow(
- List