container history initial

This commit is contained in:
Fabian Freund
2026-07-03 17:29:02 +02:00
parent 509e0550a8
commit 7931f66f5a
45 changed files with 7893 additions and 136 deletions
File diff suppressed because it is too large Load Diff
@@ -49,6 +49,9 @@ class BrowserDataService extends _$BrowserDataService {
case DeleteBrowsingDataType.history: case DeleteBrowsingDataType.history:
await _service.deleteBrowsingHistory(); await _service.deleteBrowsingHistory();
await ref.read(tabDatabaseProvider).historyDao.clear(); await ref.read(tabDatabaseProvider).historyDao.clear();
// Places visits are gone; drop their container tags too so they
// don't dangle (and can't re-attach to a future same-URL visit).
await ref.read(tabDatabaseProvider).visitContainerDao.clearAll();
case DeleteBrowsingDataType.recentSearches: case DeleteBrowsingDataType.recentSearches:
await ref await ref
.read(bangDataRepositoryProvider.notifier) .read(bangDataRepositoryProvider.notifier)
@@ -49,6 +49,7 @@ import 'package:weblibre/features/geckoview/features/browser/domain/services/eng
import 'package:weblibre/features/geckoview/features/browser/domain/services/proxy_settings_replication.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/services/proxy_settings_replication.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_home.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_home.dart';
import 'package:weblibre/features/geckoview/features/history/domain/repositories/history.dart'; import 'package:weblibre/features/geckoview/features/history/domain/repositories/history.dart';
import 'package:weblibre/features/geckoview/features/history/domain/services/history_exclusion_replication.dart';
import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_observer.dart'; import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_observer.dart';
import 'package:weblibre/features/geckoview/features/pwa/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/pwa/domain/providers.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';
@@ -675,6 +676,19 @@ class _BrowserViewState extends ConsumerState<BrowserView>
}, },
); );
ref.listenManual(
fireImmediately: true,
historyExclusionReplicationProvider,
(previous, next) {},
onError: (error, stackTrace) {
logger.e(
'Error listening to historyExclusionReplicationProvider',
error: error,
stackTrace: stackTrace,
);
},
);
ref.listenManual( ref.listenManual(
fireImmediately: true, fireImmediately: true,
articleContentProcessorServiceProvider, articleContentProcessorServiceProvider,
@@ -0,0 +1,124 @@
/*
* 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:fast_equatable/fast_equatable.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
/// Maximum gap (epoch millis) between a WebLibre `visit_container` relation row
/// and a Mozilla Places `VisitInfo` for them to be considered the same visit.
/// The relation's `visit_time` is captured near — not exactly at — the Places
/// record time (the native delegate stamps `System.currentTimeMillis()`), so
/// tagging/filtering matches the closest relation within this tolerance.
const int historyVisitContainerMatchWindowMs = 5000;
/// A single history-screen row: a Mozilla Places visit (the source of truth for
/// url, title, visit type and time) annotated with the WebLibre container(s)
/// it belonged to.
///
/// [containerIds] is the visit's resolved container tag — at most one entry for
/// a normal visit (the nearest relation within
/// [historyVisitContainerMatchWindowMs]); empty when the visit was uncontained
/// or predates history-relation recording.
class HistoryEntry with FastEquatable {
/// The underlying Places visit; still used verbatim for opening the page and
/// for the precise `(url, time)` Places delete.
final VisitInfo visit;
final List<String> containerIds;
/// Primary key of the `visit_container` row that this visit paired with (the
/// same one-to-one pairing that produced [containerIds]), or null when the
/// visit is uncontained. Carried on the entry so a delete can drop exactly
/// this visit's relation without re-deriving the nearest-time join — which
/// could otherwise pick a sibling same-URL visit's relation and mislabel it.
final int? containerRelationId;
HistoryEntry({
required this.visit,
required this.containerIds,
this.containerRelationId,
});
String get url => visit.url;
String? get title => visit.title;
int get visitTime => visit.visitTime;
VisitType get visitType => visit.visitType;
String? get previewImageUrl => visit.previewImageUrl;
String? get contentId => visit.contentId;
@override
List<Object?> get hashParameters => [
visit.url,
visit.title,
visit.visitTime,
visit.visitType,
visit.previewImageUrl,
visit.contentId,
containerIds,
containerRelationId,
];
}
/// One-to-one nearest-time pairing between visits and `visit_container`
/// relations that share a canonical URL, given their epoch-millis timestamps.
///
/// Returns a map from visit index (into [visitTimes]) to the relation index
/// (into [relationTimes]) it pairs with. Pairs are assigned greedily from the
/// smallest in-window delta, consuming both sides, so a relation never tags two
/// visits and a visit never takes two relations. Shared by history annotation
/// and the per-container Places-delete mirror so they always agree on which
/// relation belongs to which visit.
Map<int, int> pairVisitsToRelationsByTime(
List<int> visitTimes,
List<int> relationTimes,
) {
// All in-window (visit, relation) pairs, smallest delta first.
final pairs = <({int visitIndex, int relationIndex, int delta})>[];
for (var visitIndex = 0; visitIndex < visitTimes.length; visitIndex++) {
for (
var relationIndex = 0;
relationIndex < relationTimes.length;
relationIndex++
) {
final delta = (relationTimes[relationIndex] - visitTimes[visitIndex])
.abs();
if (delta <= historyVisitContainerMatchWindowMs) {
pairs.add((
visitIndex: visitIndex,
relationIndex: relationIndex,
delta: delta,
));
}
}
}
pairs.sort((a, b) => a.delta.compareTo(b.delta));
final relationByVisit = <int, int>{};
final usedVisits = <int>{};
final usedRelations = <int>{};
for (final pair in pairs) {
if (usedVisits.contains(pair.visitIndex) ||
usedRelations.contains(pair.relationIndex)) {
continue;
}
usedVisits.add(pair.visitIndex);
usedRelations.add(pair.relationIndex);
relationByVisit[pair.visitIndex] = pair.relationIndex;
}
return relationByVisit;
}
@@ -33,13 +33,22 @@ class HistoryFilterOptions with FastEquatable {
final DateTimeRange<DateTime>? dateRange; final DateTimeRange<DateTime>? dateRange;
final Set<VisitType> visitTypes; final Set<VisitType> visitTypes;
HistoryFilterOptions({required this.dateRange, required this.visitTypes}); /// When non-null, restrict the timeline to visits that belonged to this
/// WebLibre container (resolved via the visit→container relation). Null shows
/// all visits, each still annotated with its own container tag.
final String? containerId;
HistoryFilterOptions({
required this.dateRange,
required this.visitTypes,
this.containerId,
});
HistoryFilterOptions.withDefaults() HistoryFilterOptions.withDefaults()
: this(dateRange: null, visitTypes: {VisitType.link}); : this(dateRange: null, visitTypes: {VisitType.link});
@override @override
List<Object?> get hashParameters => [dateRange, visitTypes]; List<Object?> get hashParameters => [dateRange, visitTypes, containerId];
factory HistoryFilterOptions.fromJson(Map<String, dynamic> json) => factory HistoryFilterOptions.fromJson(Map<String, dynamic> json) =>
_$HistoryFilterOptionsFromJson(json); _$HistoryFilterOptionsFromJson(json);
@@ -11,6 +11,8 @@ abstract class _$HistoryFilterOptionsCWProxy {
HistoryFilterOptions visitTypes(Set<VisitType> visitTypes); HistoryFilterOptions visitTypes(Set<VisitType> visitTypes);
HistoryFilterOptions containerId(String? containerId);
/// Creates a new instance with the provided field values. /// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `HistoryFilterOptions(...).copyWith.fieldName(value)`. /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `HistoryFilterOptions(...).copyWith.fieldName(value)`.
/// ///
@@ -21,6 +23,7 @@ abstract class _$HistoryFilterOptionsCWProxy {
HistoryFilterOptions call({ HistoryFilterOptions call({
DateTimeRange<DateTime>? dateRange, DateTimeRange<DateTime>? dateRange,
Set<VisitType> visitTypes, Set<VisitType> visitTypes,
String? containerId,
}); });
} }
@@ -40,6 +43,10 @@ class _$HistoryFilterOptionsCWProxyImpl
HistoryFilterOptions visitTypes(Set<VisitType> visitTypes) => HistoryFilterOptions visitTypes(Set<VisitType> visitTypes) =>
call(visitTypes: visitTypes); call(visitTypes: visitTypes);
@override
HistoryFilterOptions containerId(String? containerId) =>
call(containerId: containerId);
@override @override
/// Creates a new instance with the provided field values. /// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `HistoryFilterOptions(...).copyWith.fieldName(value)`. /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `HistoryFilterOptions(...).copyWith.fieldName(value)`.
@@ -51,6 +58,7 @@ class _$HistoryFilterOptionsCWProxyImpl
HistoryFilterOptions call({ HistoryFilterOptions call({
Object? dateRange = const $CopyWithPlaceholder(), Object? dateRange = const $CopyWithPlaceholder(),
Object? visitTypes = const $CopyWithPlaceholder(), Object? visitTypes = const $CopyWithPlaceholder(),
Object? containerId = const $CopyWithPlaceholder(),
}) { }) {
return HistoryFilterOptions( return HistoryFilterOptions(
dateRange: dateRange == const $CopyWithPlaceholder() dateRange: dateRange == const $CopyWithPlaceholder()
@@ -62,6 +70,10 @@ class _$HistoryFilterOptionsCWProxyImpl
? _value.visitTypes ? _value.visitTypes
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
: visitTypes as Set<VisitType>, : visitTypes as Set<VisitType>,
containerId: containerId == const $CopyWithPlaceholder()
? _value.containerId
// ignore: cast_nullable_to_non_nullable
: containerId as String?,
); );
} }
} }
@@ -87,6 +99,7 @@ HistoryFilterOptions _$HistoryFilterOptionsFromJson(
visitTypes: (json['visitTypes'] as List<dynamic>) visitTypes: (json['visitTypes'] as List<dynamic>)
.map((e) => $enumDecode(_$VisitTypeEnumMap, e)) .map((e) => $enumDecode(_$VisitTypeEnumMap, e))
.toSet(), .toSet(),
containerId: json['containerId'] as String?,
); );
Map<String, dynamic> _$HistoryFilterOptionsToJson( Map<String, dynamic> _$HistoryFilterOptionsToJson(
@@ -94,6 +107,7 @@ Map<String, dynamic> _$HistoryFilterOptionsToJson(
) => <String, dynamic>{ ) => <String, dynamic>{
'dateRange': const DateTimeRangeConverter().toJson(instance.dateRange), 'dateRange': const DateTimeRangeConverter().toJson(instance.dateRange),
'visitTypes': instance.visitTypes.map((e) => _$VisitTypeEnumMap[e]!).toList(), 'visitTypes': instance.visitTypes.map((e) => _$VisitTypeEnumMap[e]!).toList(),
'containerId': instance.containerId,
}; };
const _$VisitTypeEnumMap = { const _$VisitTypeEnumMap = {
@@ -22,9 +22,14 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:riverpod/experimental/persist.dart'; import 'package:riverpod/experimental/persist.dart';
import 'package:riverpod_annotation/experimental/json_persist.dart'; import 'package:riverpod_annotation/experimental/json_persist.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/geckoview/features/history/domain/entities/history_entry.dart';
import 'package:weblibre/features/geckoview/features/history/domain/entities/history_filter_options.dart'; import 'package:weblibre/features/geckoview/features/history/domain/entities/history_filter_options.dart';
import 'package:weblibre/features/geckoview/features/history/domain/repositories/history.dart'; import 'package:weblibre/features/geckoview/features/history/domain/repositories/history.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/visit_container.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart';
import 'package:weblibre/features/user/data/providers.dart'; import 'package:weblibre/features/user/data/providers.dart';
import 'package:weblibre/utils/url_canonical.dart';
part 'providers.g.dart'; part 'providers.g.dart';
@@ -39,6 +44,10 @@ class HistoryVisitsFilter extends _$HistoryVisitsFilter {
} }
} }
void setContainer(String? containerId) {
state = state.copyWith.containerId(containerId);
}
void reset() { void reset() {
state = HistoryFilterOptions.withDefaults(); state = HistoryFilterOptions.withDefaults();
} }
@@ -87,20 +96,113 @@ class HistoryDownloadsFilter extends _$HistoryDownloadsFilter {
} }
} }
/// Annotate Mozilla Places [visits] with the WebLibre container each belonged
/// to (from the `visit_container` relation), matched by canonical URL and
/// nearest visit time. Uncontained visits get an empty tag list. When
/// [filterContainerId] is set, only visits resolving to that container are
/// returned.
///
/// The match is **one-to-one within each canonical URL**: every relation row
/// tags at most one visit, and every visit takes at most one relation. Without
/// this, the same URL opened in several containers one after another (or a
/// container visit followed by an uncontained one of the same URL, within the
/// [historyVisitContainerMatchWindowMs] window) would let one relation bleed
/// onto neighbouring visits — an uncontained visit stealing the previous
/// container's tag. Pairs are assigned greedily from the smallest time delta,
/// consuming both sides, which approximates the minimum-skew assignment.
Future<List<HistoryEntry>> _annotateVisits(
VisitContainerDao dao,
List<VisitInfo> visits, {
String? filterContainerId,
}) async {
// Visit indices grouped by canonical URL (Places rows without an
// indexable/canonicalizable URL simply carry no container).
final canonicals = <String>{};
final visitIndicesByCanonical = <String, List<int>>{};
for (var i = 0; i < visits.length; i++) {
final canonical = canonicalizeUrl(visits[i].url)?.canonical;
if (canonical != null) {
canonicals.add(canonical);
(visitIndicesByCanonical[canonical] ??= <int>[]).add(i);
}
}
final relations = await dao.relationsForCanonicalUrls(canonicals);
final byCanonical = <String, List<VisitContainerData>>{};
for (final relation in relations) {
(byCanonical[relation.urlCanonical] ??= <VisitContainerData>[]).add(
relation,
);
}
// Resolve the relation per visit via one-to-one nearest-time matching within
// each canonical URL group.
final relationByVisitIndex = <int, VisitContainerData>{};
for (final MapEntry(key: canonical, value: visitIndices)
in visitIndicesByCanonical.entries) {
final candidates = byCanonical[canonical];
if (candidates == null) continue;
final pairing = pairVisitsToRelationsByTime(
[for (final visitIndex in visitIndices) visits[visitIndex].visitTime],
[for (final candidate in candidates) candidate.visitTime],
);
pairing.forEach((localVisitIndex, relationIndex) {
relationByVisitIndex[visitIndices[localVisitIndex]] =
candidates[relationIndex];
});
}
final entries = <HistoryEntry>[];
for (var i = 0; i < visits.length; i++) {
final relation = relationByVisitIndex[i];
if (filterContainerId != null &&
relation?.containerId != filterContainerId) {
continue;
}
entries.add(
HistoryEntry(
visit: visits[i],
containerIds: relation == null ? const [] : [relation.containerId],
containerRelationId: relation?.id,
),
);
}
return entries;
}
@Riverpod() @Riverpod()
Future<List<VisitInfo>> browsingHistory(Ref ref) { Future<List<HistoryEntry>> browsingHistory(Ref ref) async {
final options = ref.watch(historyVisitsFilterProvider); final options = ref.watch(historyVisitsFilterProvider);
return ref final visits = await ref
.read(historyRepositoryProvider.notifier) .read(historyRepositoryProvider.notifier)
.getDetailedVisits(options); .getDetailedVisits(options);
return _annotateVisits(
ref.read(tabDatabaseProvider).visitContainerDao,
visits,
filterContainerId: options.containerId,
);
} }
@Riverpod() @Riverpod()
Future<List<VisitInfo>> browsingDownloads(Ref ref) { Future<List<HistoryEntry>> browsingDownloads(Ref ref) async {
final options = ref.watch(historyDownloadsFilterProvider); final options = ref.watch(historyDownloadsFilterProvider);
return ref final visits = await ref
.read(historyRepositoryProvider.notifier) .read(historyRepositoryProvider.notifier)
.getDetailedVisits(options); .getDetailedVisits(options);
// Downloads are never recorded in the visit→container relation (it is written
// only from page-visit `onVisited` events). Do NOT run the nearest-time
// annotation here: a download sharing a canonical URL + time window with a
// contained page visit would otherwise steal that visit's tag, show a bogus
// container chip, and — on delete — drop the page visit's relation row.
return visits
.map((visit) => HistoryEntry(visit: visit, containerIds: const []))
.toList(growable: false);
} }
@@ -44,7 +44,7 @@ final class HistoryVisitsFilterProvider
} }
String _$historyVisitsFilterHash() => String _$historyVisitsFilterHash() =>
r'a4bf6c41c9180166365084cb7c7981558ac7ad36'; r'4c1530cfd2d94b1cdbb04fbe2df8037cb211a668';
@JsonPersist() @JsonPersist()
abstract class _$HistoryVisitsFilterBase abstract class _$HistoryVisitsFilterBase
@@ -129,11 +129,13 @@ final browsingHistoryProvider = BrowsingHistoryProvider._();
final class BrowsingHistoryProvider final class BrowsingHistoryProvider
extends extends
$FunctionalProvider< $FunctionalProvider<
AsyncValue<List<VisitInfo>>, AsyncValue<List<HistoryEntry>>,
List<VisitInfo>, List<HistoryEntry>,
FutureOr<List<VisitInfo>> FutureOr<List<HistoryEntry>>
> >
with $FutureModifier<List<VisitInfo>>, $FutureProvider<List<VisitInfo>> { with
$FutureModifier<List<HistoryEntry>>,
$FutureProvider<List<HistoryEntry>> {
BrowsingHistoryProvider._() BrowsingHistoryProvider._()
: super( : super(
from: null, from: null,
@@ -150,17 +152,17 @@ final class BrowsingHistoryProvider
@$internal @$internal
@override @override
$FutureProviderElement<List<VisitInfo>> $createElement( $FutureProviderElement<List<HistoryEntry>> $createElement(
$ProviderPointer pointer, $ProviderPointer pointer,
) => $FutureProviderElement(pointer); ) => $FutureProviderElement(pointer);
@override @override
FutureOr<List<VisitInfo>> create(Ref ref) { FutureOr<List<HistoryEntry>> create(Ref ref) {
return browsingHistory(ref); return browsingHistory(ref);
} }
} }
String _$browsingHistoryHash() => r'6f26228da28f6f67844551bf430025b161731bb2'; String _$browsingHistoryHash() => r'0ed9a3d1f10091eb1a20f1c388c1ace80c1f75da';
@ProviderFor(browsingDownloads) @ProviderFor(browsingDownloads)
final browsingDownloadsProvider = BrowsingDownloadsProvider._(); final browsingDownloadsProvider = BrowsingDownloadsProvider._();
@@ -168,11 +170,13 @@ final browsingDownloadsProvider = BrowsingDownloadsProvider._();
final class BrowsingDownloadsProvider final class BrowsingDownloadsProvider
extends extends
$FunctionalProvider< $FunctionalProvider<
AsyncValue<List<VisitInfo>>, AsyncValue<List<HistoryEntry>>,
List<VisitInfo>, List<HistoryEntry>,
FutureOr<List<VisitInfo>> FutureOr<List<HistoryEntry>>
> >
with $FutureModifier<List<VisitInfo>>, $FutureProvider<List<VisitInfo>> { with
$FutureModifier<List<HistoryEntry>>,
$FutureProvider<List<HistoryEntry>> {
BrowsingDownloadsProvider._() BrowsingDownloadsProvider._()
: super( : super(
from: null, from: null,
@@ -189,17 +193,17 @@ final class BrowsingDownloadsProvider
@$internal @$internal
@override @override
$FutureProviderElement<List<VisitInfo>> $createElement( $FutureProviderElement<List<HistoryEntry>> $createElement(
$ProviderPointer pointer, $ProviderPointer pointer,
) => $FutureProviderElement(pointer); ) => $FutureProviderElement(pointer);
@override @override
FutureOr<List<VisitInfo>> create(Ref ref) { FutureOr<List<HistoryEntry>> create(Ref ref) {
return browsingDownloads(ref); return browsingDownloads(ref);
} }
} }
String _$browsingDownloadsHash() => r'd05e3c079c6de349ff9e910ca597ad7c78f87a16'; String _$browsingDownloadsHash() => r'938ae4d26b4e0a3f428d3d530a71cb5d8720f317';
// ************************************************************************** // **************************************************************************
// JsonGenerator // JsonGenerator
@@ -0,0 +1,155 @@
/*
* 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/material.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/geckoview/features/history/domain/entities/history_entry.dart';
import 'package:weblibre/features/geckoview/features/history/domain/entities/history_filter_options.dart';
import 'package:weblibre/features/geckoview/features/history/domain/repositories/history.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart';
import 'package:weblibre/utils/url_canonical.dart';
part 'container_history.g.dart';
/// Mutations that combine the visit→container relation (`visit_container`) with
/// Mozilla Places, the source of truth for the visits themselves.
@Riverpod(keepAlive: true)
class ContainerHistoryRepository extends _$ContainerHistoryRepository {
/// Delete a single history entry: remove the Places visit precisely by its own
/// `(url, time)`, then drop the `visit_container` relation row that tagged it.
///
/// Dropping the relation matters: left behind, the nearest-time join in
/// `_annotateVisits` could reattach that now-orphaned tag to a *different*
/// same-URL visit within [historyVisitContainerMatchWindowMs], mislabeling an
/// uncontained (or different-container) visit. We delete exactly the relation
/// the annotation paired with this visit — carried on the entry as
/// [HistoryEntry.containerRelationId] — rather than re-deriving "the nearest
/// relation", which greedy one-to-one pairing may have assigned to a sibling
/// same-URL visit (deleting that would strip the sibling's tag and leave this
/// visit's real relation dangling).
Future<void> deleteVisit(HistoryEntry entry) async {
await ref.read(historyRepositoryProvider.notifier).deleteVisit(entry.visit);
final relationId = entry.containerRelationId;
if (relationId == null) return;
await ref
.read(tabDatabaseProvider)
.visitContainerDao
.deleteById(relationId);
}
/// Clear a container's history: delete the container's Places visits, then
/// remove its relation rows.
Future<void> deleteContainerHistory(String containerId) async {
await deletePlacesVisitsForContainer(containerId);
await ref
.read(tabDatabaseProvider)
.visitContainerDao
.deleteForContainer(containerId);
}
/// Delete from Mozilla Places exactly the visits recorded for [containerId] in
/// the relation, matched by canonical URL and nearest time. Only visits that
/// have a relation row are touched — uncontained Places visits (including of
/// the same URL) are never deleted. Leaves the relation rows in place; callers
/// remove them separately (explicit clear) or let ON DELETE CASCADE do it
/// (container deletion). Safe to call before the container itself is deleted.
Future<void> deletePlacesVisitsForContainer(String containerId) async {
final relations = await ref
.read(tabDatabaseProvider)
.visitContainerDao
.relationsForContainer(containerId);
if (relations.isEmpty) return;
var minTime = relations.first.visitTime;
var maxTime = relations.first.visitTime;
for (final relation in relations) {
if (relation.visitTime < minTime) minTime = relation.visitTime;
if (relation.visitTime > maxTime) maxTime = relation.visitTime;
}
final visits = await ref
.read(historyRepositoryProvider.notifier)
.getDetailedVisits(
HistoryFilterOptions(
dateRange: DateTimeRange(
start: DateTime.fromMillisecondsSinceEpoch(
minTime - historyVisitContainerMatchWindowMs,
),
end: DateTime.fromMillisecondsSinceEpoch(
maxTime + historyVisitContainerMatchWindowMs,
),
),
// Relations are only ever recorded for page visits (onVisited), so
// never let a download that merely shares a canonical URL + time
// window become a delete candidate — it would delete an unrelated
// download (and deleteVisit routes downloads to
// deleteDownload(contentId!), which throws on a null contentId).
visitTypes: VisitType.values
.where((type) => type != VisitType.download)
.toSet(),
),
);
// Index candidate Places visits by canonical URL for nearest-time matching.
final visitsByCanonical = <String, List<VisitInfo>>{};
for (final visit in visits) {
final canonical = canonicalizeUrl(visit.url)?.canonical;
if (canonical != null) {
(visitsByCanonical[canonical] ??= <VisitInfo>[]).add(visit);
}
}
final relationsByCanonical = <String, List<VisitContainerData>>{};
for (final relation in relations) {
(relationsByCanonical[relation.urlCanonical] ??= <VisitContainerData>[])
.add(relation);
}
// Collect matched Places visits using the same one-to-one nearest-time
// strategy as history annotation, so two relation rows never consume the
// same Places row and leave a sibling visit behind.
final toDelete = <(String, int), VisitInfo>{};
for (final MapEntry(key: canonical, value: candidates)
in visitsByCanonical.entries) {
final canonicalRelations = relationsByCanonical[canonical];
if (canonicalRelations == null) continue;
final pairing = pairVisitsToRelationsByTime(
[for (final visit in candidates) visit.visitTime],
[for (final relation in canonicalRelations) relation.visitTime],
);
for (final visitIndex in pairing.keys) {
final visit = candidates[visitIndex];
toDelete[(visit.url, visit.visitTime)] = visit;
}
}
final historyRepository = ref.read(historyRepositoryProvider.notifier);
for (final visit in toDelete.values) {
await historyRepository.deleteVisit(visit);
}
}
@override
void build() {}
}
@@ -0,0 +1,73 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'container_history.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Mutations that combine the visit→container relation (`visit_container`) with
/// Mozilla Places, the source of truth for the visits themselves.
@ProviderFor(ContainerHistoryRepository)
final containerHistoryRepositoryProvider =
ContainerHistoryRepositoryProvider._();
/// Mutations that combine the visit→container relation (`visit_container`) with
/// Mozilla Places, the source of truth for the visits themselves.
final class ContainerHistoryRepositoryProvider
extends $NotifierProvider<ContainerHistoryRepository, void> {
/// Mutations that combine the visit→container relation (`visit_container`) with
/// Mozilla Places, the source of truth for the visits themselves.
ContainerHistoryRepositoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'containerHistoryRepositoryProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$containerHistoryRepositoryHash();
@$internal
@override
ContainerHistoryRepository create() => ContainerHistoryRepository();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(void value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<void>(value),
);
}
}
String _$containerHistoryRepositoryHash() =>
r'8872a421664c0ac8d6b0bd3ca76e1300677b0873';
/// Mutations that combine the visit→container relation (`visit_container`) with
/// Mozilla Places, the source of truth for the visits themselves.
abstract class _$ContainerHistoryRepository extends $Notifier<void> {
void build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref = this.ref as $Ref<void, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<void, void>,
void,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}
@@ -0,0 +1,50 @@
/*
* 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_mozilla_components/flutter_mozilla_components.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
part 'history_exclusion_replication.g.dart';
/// The Gecko contextIds that must be hard-excluded from history: containers with
/// `excludeFromHistory` enabled that actually have a contextId (contextId-less
/// containers can't be distinguished at the delegate, so they can't be
/// excluded — the invariant on [ContainerMetadata] keeps the flag off for them).
List<String> excludedHistoryContextIds(Iterable<ContainerData> containers) {
return containers
.where((container) => container.metadata.excludeFromHistory)
.map((container) => container.metadata.contextualIdentity)
.whereType<String>()
.toList(growable: false);
}
/// Keeps the native history delegate's hard exclude-from-history set in sync
/// with WebLibre's containers, re-pushing whenever the container set changes.
/// Activated eagerly at startup (and once more before engine init) so an
/// excluded container never leaks a restored-tab visit to Places.
@Riverpod(keepAlive: true)
Future<void> historyExclusionReplication(Ref ref) async {
final containers = await ref.watch(watchContainersWithCountProvider.future);
await GeckoEngineSettingsService().setExcludedHistoryContextIds(
excludedHistoryContextIds(containers),
);
}
@@ -0,0 +1,58 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'history_exclusion_replication.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Keeps the native history delegate's hard exclude-from-history set in sync
/// with WebLibre's containers, re-pushing whenever the container set changes.
/// Activated eagerly at startup (and once more before engine init) so an
/// excluded container never leaks a restored-tab visit to Places.
@ProviderFor(historyExclusionReplication)
final historyExclusionReplicationProvider =
HistoryExclusionReplicationProvider._();
/// Keeps the native history delegate's hard exclude-from-history set in sync
/// with WebLibre's containers, re-pushing whenever the container set changes.
/// Activated eagerly at startup (and once more before engine init) so an
/// excluded container never leaks a restored-tab visit to Places.
final class HistoryExclusionReplicationProvider
extends $FunctionalProvider<AsyncValue<void>, void, FutureOr<void>>
with $FutureModifier<void>, $FutureProvider<void> {
/// Keeps the native history delegate's hard exclude-from-history set in sync
/// with WebLibre's containers, re-pushing whenever the container set changes.
/// Activated eagerly at startup (and once more before engine init) so an
/// excluded container never leaks a restored-tab visit to Places.
HistoryExclusionReplicationProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'historyExclusionReplicationProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$historyExclusionReplicationHash();
@$internal
@override
$FutureProviderElement<void> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<void> create(Ref ref) {
return historyExclusionReplication(ref);
}
}
String _$historyExclusionReplicationHash() =>
r'11eaf8bf0b4ef6e833a4b3659e1dbb3687b566be';
@@ -0,0 +1,125 @@
/*
* 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 'dart:async';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
import 'package:weblibre/utils/url_canonical.dart';
part 'visit_container_recorder.g.dart';
class _HistoryEventsReceiver extends GeckoHistoryEvents {
_HistoryEventsReceiver(this._onVisit);
final void Function(String url, int visitTime, String? contextId) _onVisit;
@override
void onVisitRecorded(String url, int visitTime, String? contextId) {
_onVisit(url, visitTime, contextId);
}
}
/// Records the visit→container relation. Mozilla Places owns the visit itself;
/// on each Places visit the native [WebLibreHistoryDelegate] forwards the
/// producing tab's Gecko contextId, which this service maps to a WebLibre
/// container and persists as a `visit_container` row (keyed on the visit's
/// canonical URL + time so the history UI can join it back to Places).
///
/// Graceful absence: a visit that resolves to no container — uncontained, or a
/// container without a Gecko contextId (cookie isolation off) — writes no row
/// and simply appears untagged. Activated eagerly at startup.
@Riverpod(keepAlive: true)
class VisitContainerRecorder extends _$VisitContainerRecorder {
@override
void build() {
// Cache contextId → container id so each visit event is an O(1) lookup
// instead of a linear scan of all containers. Kept fresh via ref.listen.
final contextIdToContainerId = <String, String>{};
void applyContainers(Iterable<ContainerDataWithCount>? containers) {
contextIdToContainerId.clear();
if (containers != null) {
for (final container in containers) {
final contextId = container.metadata.contextualIdentity;
if (contextId != null) {
contextIdToContainerId[contextId] = container.id;
}
}
}
}
// Seed from the stream's current value (may still be empty during startup —
// watchContainersWithCount is async and often hasn't emitted yet) and keep
// it fresh on every change.
applyContainers(ref.read(watchContainersWithCountProvider).value);
ref.listen(
watchContainersWithCountProvider,
(_, next) => applyContainers(next.value),
);
Future<void> recordVisit(
String url,
int visitTime,
String contextId,
) async {
var containerId = contextIdToContainerId[contextId];
// Empty cache means the container stream hasn't emitted yet (the startup
// window — restored tabs can fire visits before the first emission). A
// visit carrying a contextId implies a container with that contextId
// exists, so pull the containers directly rather than dropping the visit
// permanently. A genuine miss (deleted container) leaves the cache
// non-empty, so this fallback does not fire repeatedly in steady state.
if (containerId == null && contextIdToContainerId.isEmpty) {
applyContainers(await ref.read(watchContainersWithCountProvider.future));
containerId = contextIdToContainerId[contextId];
}
// Resolved to a contextId that maps to no known container (deleted or not
// yet synced) → skip rather than write a dangling relation.
if (containerId == null) return;
final canonical = canonicalizeUrl(url);
if (canonical == null) return;
await ref
.read(tabDatabaseProvider)
.visitContainerDao
.insertRelation(
rawUrl: url,
urlCanonical: canonical.canonical,
visitTime: visitTime,
containerId: containerId,
);
}
final receiver = _HistoryEventsReceiver((url, visitTime, contextId) {
// No contextId (uncontained / non-isolated / unresolved) → no relation.
if (contextId == null) return;
unawaited(recordVisit(url, visitTime, contextId));
});
GeckoHistoryEvents.setUp(receiver);
ref.onDispose(() => GeckoHistoryEvents.setUp(null));
}
}
@@ -0,0 +1,100 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'visit_container_recorder.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Records the visit→container relation. Mozilla Places owns the visit itself;
/// on each Places visit the native [WebLibreHistoryDelegate] forwards the
/// producing tab's Gecko contextId, which this service maps to a WebLibre
/// container and persists as a `visit_container` row (keyed on the visit's
/// canonical URL + time so the history UI can join it back to Places).
///
/// Graceful absence: a visit that resolves to no container — uncontained, or a
/// container without a Gecko contextId (cookie isolation off) — writes no row
/// and simply appears untagged. Activated eagerly at startup.
@ProviderFor(VisitContainerRecorder)
final visitContainerRecorderProvider = VisitContainerRecorderProvider._();
/// Records the visit→container relation. Mozilla Places owns the visit itself;
/// on each Places visit the native [WebLibreHistoryDelegate] forwards the
/// producing tab's Gecko contextId, which this service maps to a WebLibre
/// container and persists as a `visit_container` row (keyed on the visit's
/// canonical URL + time so the history UI can join it back to Places).
///
/// Graceful absence: a visit that resolves to no container — uncontained, or a
/// container without a Gecko contextId (cookie isolation off) — writes no row
/// and simply appears untagged. Activated eagerly at startup.
final class VisitContainerRecorderProvider
extends $NotifierProvider<VisitContainerRecorder, void> {
/// Records the visit→container relation. Mozilla Places owns the visit itself;
/// on each Places visit the native [WebLibreHistoryDelegate] forwards the
/// producing tab's Gecko contextId, which this service maps to a WebLibre
/// container and persists as a `visit_container` row (keyed on the visit's
/// canonical URL + time so the history UI can join it back to Places).
///
/// Graceful absence: a visit that resolves to no container — uncontained, or a
/// container without a Gecko contextId (cookie isolation off) — writes no row
/// and simply appears untagged. Activated eagerly at startup.
VisitContainerRecorderProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'visitContainerRecorderProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$visitContainerRecorderHash();
@$internal
@override
VisitContainerRecorder create() => VisitContainerRecorder();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(void value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<void>(value),
);
}
}
String _$visitContainerRecorderHash() =>
r'2d5742816ec08bc37edf2b56a933b6a324e8b038';
/// Records the visit→container relation. Mozilla Places owns the visit itself;
/// on each Places visit the native [WebLibreHistoryDelegate] forwards the
/// producing tab's Gecko contextId, which this service maps to a WebLibre
/// container and persists as a `visit_container` row (keyed on the visit's
/// canonical URL + time so the history UI can join it back to Places).
///
/// Graceful absence: a visit that resolves to no container — uncontained, or a
/// container without a Gecko contextId (cookie isolation off) — writes no row
/// and simply appears untagged. Activated eagerly at startup.
abstract class _$VisitContainerRecorder extends $Notifier<void> {
void build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref = this.ref as $Ref<void, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<void, void>,
void,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}
@@ -35,11 +35,14 @@ import 'package:sliver_tools/sliver_tools.dart';
import 'package:timeago/timeago.dart' as timeago; import 'package:timeago/timeago.dart' as timeago;
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/delete_data.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/delete_data.dart';
import 'package:weblibre/features/geckoview/features/history/domain/entities/history_entry.dart';
import 'package:weblibre/features/geckoview/features/history/domain/entities/history_filter_options.dart'; import 'package:weblibre/features/geckoview/features/history/domain/entities/history_filter_options.dart';
import 'package:weblibre/features/geckoview/features/history/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/history/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/history/domain/repositories/history.dart'; import 'package:weblibre/features/geckoview/features/history/domain/repositories/container_history.dart';
import 'package:weblibre/features/geckoview/features/history/presentation/dialogs/delete_file.dart'; import 'package:weblibre/features/geckoview/features/history/presentation/dialogs/delete_file.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';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart';
import 'package:weblibre/presentation/hooks/menu_controller.dart'; import 'package:weblibre/presentation/hooks/menu_controller.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart'; import 'package:weblibre/presentation/widgets/failure_widget.dart';
@@ -54,11 +57,12 @@ class Section extends MultiSliver {
super.key, super.key,
required BuildContext context, required BuildContext context,
required String title, required String title,
required List<VisitInfo> items, required List<HistoryEntry> items,
required Set<VisitInfo> selectedItems, required Set<HistoryEntry> selectedItems,
required void Function(VisitInfo) onTap, required Map<String, ContainerData> containersById,
required void Function(VisitInfo) onLongPress, required void Function(HistoryEntry) onTap,
required Future<void> Function(VisitInfo) onDelete, required void Function(HistoryEntry) onLongPress,
required Future<void> Function(HistoryEntry) onDelete,
}) : super( }) : super(
pushPinnedChildren: true, pushPinnedChildren: true,
children: [ children: [
@@ -122,6 +126,7 @@ class Section extends MultiSliver {
padding: const EdgeInsets.only(left: 54, right: 16), padding: const EdgeInsets.only(left: 54, right: 16),
child: Wrap( child: Wrap(
spacing: 8.0, spacing: 8.0,
runSpacing: 4.0,
children: [ children: [
Chip( Chip(
avatar: switch (item.visitType) { avatar: switch (item.visitType) {
@@ -147,9 +152,7 @@ class Section extends MultiSliver {
VisitType.download => const Text('Download'), VisitType.download => const Text('Download'),
VisitType.framedLink => const Text('Frame'), VisitType.framedLink => const Text('Frame'),
VisitType.reload => const Text('Page Reload'), VisitType.reload => const Text('Page Reload'),
VisitType.bookmark => throw UnimplementedError( VisitType.bookmark => const Text('Bookmark'),
'VisitType.bookmark chip display not implemented',
),
}, },
), ),
Chip( Chip(
@@ -161,6 +164,16 @@ class Section extends MultiSliver {
), ),
), ),
), ),
for (final containerId in item.containerIds)
if (containersById[containerId]
case final container?)
Chip(
avatar: CircleAvatar(
backgroundColor: container.color,
radius: 8,
),
label: Text(container.name ?? 'Container'),
),
], ],
), ),
), ),
@@ -195,7 +208,18 @@ class HistoryScreen extends HookConsumerWidget {
? ref.watch(browsingDownloadsProvider) ? ref.watch(browsingDownloadsProvider)
: ref.watch(browsingHistoryProvider); : ref.watch(browsingHistoryProvider);
final selectedItems = useState(<VisitInfo>{}); final containers = ref.watch(
watchContainersWithCountProvider.select((value) => value.value),
);
final containersById = <String, ContainerData>{
for (final container in containers ?? const <ContainerData>[])
container.id: container,
};
final filterContainer = historyFilter.containerId.mapNotNull(
(id) => containersById[id],
);
final selectedItems = useState(<HistoryEntry>{});
final defaultDownloadsFilter = HistoryFilterOptions( final defaultDownloadsFilter = HistoryFilterOptions(
dateRange: null, dateRange: null,
visitTypes: const {VisitType.download}, visitTypes: const {VisitType.download},
@@ -222,8 +246,10 @@ class HistoryScreen extends HookConsumerWidget {
} }
} }
Future<void> deleteHistoryItem(VisitInfo item) async { Future<void> deleteHistoryItem(HistoryEntry item) async {
await ref.read(historyRepositoryProvider.notifier).deleteVisit(item); await ref
.read(containerHistoryRepositoryProvider.notifier)
.deleteVisit(item);
final downloadedFile = item.title.mapNotNull((title) => File(title)); final downloadedFile = item.title.mapNotNull((title) => File(title));
@@ -241,6 +267,38 @@ class HistoryScreen extends HookConsumerWidget {
await refreshHistoryEntries(); await refreshHistoryEntries();
} }
Future<void> clearContainerHistory(ContainerData container) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
icon: const Icon(Icons.warning),
title: const Text('Clear Container History'),
content: Text(
'Delete all browsing history recorded for '
'"${container.name ?? 'Container'}"? The visits are removed from '
'history.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Clear'),
),
],
),
);
if (confirmed == true) {
await ref
.read(containerHistoryRepositoryProvider.notifier)
.deleteContainerHistory(container.id);
await refreshHistoryEntries();
}
}
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: textFilterEnabled.value title: textFilterEnabled.value
@@ -276,7 +334,7 @@ class HistoryScreen extends HookConsumerWidget {
for (final item in selectedItems.value) { for (final item in selectedItems.value) {
await ref await ref
.read(historyRepositoryProvider.notifier) .read(containerHistoryRepositoryProvider.notifier)
.deleteVisit(item); .deleteVisit(item);
final downloadedFile = item.title.mapNotNull( final downloadedFile = item.title.mapNotNull(
@@ -309,7 +367,18 @@ class HistoryScreen extends HookConsumerWidget {
) )
else else
IconButton( IconButton(
// Mirror what the list currently shows: with a container filter
// active, clear only that container's history; otherwise fall
// back to the full delete-browsing-data sheet.
tooltip: filterContainer != null
? 'Clear "${filterContainer.name ?? 'Container'}" history'
: null,
onPressed: () async { onPressed: () async {
if (filterContainer != null) {
await clearContainerHistory(filterContainer);
return;
}
await showDeleteDataDialog( await showDeleteDataDialog(
context, context,
initialSettings: { initialSettings: {
@@ -403,12 +472,51 @@ class HistoryScreen extends HookConsumerWidget {
VisitType.download => const Text('Downloads'), VisitType.download => const Text('Downloads'),
VisitType.framedLink => const Text('Frames'), VisitType.framedLink => const Text('Frames'),
VisitType.reload => const Text('Page Reloads'), VisitType.reload => const Text('Page Reloads'),
VisitType.bookmark => throw UnimplementedError( VisitType.bookmark => const Text('Bookmarks'),
'VisitType.bookmark filter not implemented',
),
}, },
), ),
), ),
if (!isDownloadsMode && (containers?.isNotEmpty ?? false)) ...[
const Divider(),
SubmenuButton(
leadingIcon: const Icon(MdiIcons.folderMultipleOutline),
menuChildren: [
MenuItemButton(
leadingIcon: Icon(
historyFilter.containerId == null
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
),
onPressed: () {
ref
.read(historyVisitsFilterProvider.notifier)
.setContainer(null);
},
child: const Text('All Containers'),
),
for (final container in containers!)
MenuItemButton(
leadingIcon: Icon(
historyFilter.containerId == container.id
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
color: container.color,
),
onPressed: () {
ref
.read(historyVisitsFilterProvider.notifier)
.setContainer(container.id);
},
child: Text(container.name ?? 'Container'),
),
],
child: Text(
filterContainer != null
? 'Container: ${filterContainer.name ?? 'Container'}'
: 'Filter Container',
),
),
],
const Divider(), const Divider(),
MenuItemButton( MenuItemButton(
leadingIcon: const Icon(MdiIcons.restore), leadingIcon: const Icon(MdiIcons.restore),
@@ -458,11 +566,11 @@ class HistoryScreen extends HookConsumerWidget {
final groups = useMemoized( final groups = useMemoized(
() => data () => data
.where( .where(
(visit) => (entry) =>
textFilter.isEmpty || textFilter.isEmpty ||
visit.title?.toLowerCase().contains(textFilter) == entry.title?.toLowerCase().contains(textFilter) ==
true || true ||
visit.url.toLowerCase().contains(textFilter), entry.url.toLowerCase().contains(textFilter),
) )
.groupListsBy( .groupListsBy(
(element) => timeago.format( (element) => timeago.format(
@@ -474,7 +582,7 @@ class HistoryScreen extends HookConsumerWidget {
[EquatableValue(data), textFilter], [EquatableValue(data), textFilter],
); );
void toggleSelected(VisitInfo item) { void toggleSelected(HistoryEntry item) {
if (selectedItems.value.contains(item)) { if (selectedItems.value.contains(item)) {
selectedItems.value = {...selectedItems.value} selectedItems.value = {...selectedItems.value}
..remove(item); ..remove(item);
@@ -495,6 +603,7 @@ class HistoryScreen extends HookConsumerWidget {
title: key, title: key,
items: value, items: value,
selectedItems: selectedItems.value, selectedItems: selectedItems.value,
containersById: containersById,
onLongPress: toggleSelected, onLongPress: toggleSelected,
onDelete: deleteHistoryItem, onDelete: deleteHistoryItem,
onTap: (item) async { onTap: (item) async {
@@ -0,0 +1,118 @@
/*
* 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 'dart:math' as math;
import 'package:drift/drift.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/visit_container.drift.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/database.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart';
/// Data access for the visit → container relation (`visit_container`).
///
/// Mozilla Places owns the browsing history itself; this table only records
/// which WebLibre container each contained visit belonged to. Reads are joined
/// back to Places `VisitInfo`s in the domain layer on
/// (url_canonical, nearest visit_time).
@DriftAccessor()
class VisitContainerDao extends DatabaseAccessor<TabDatabase>
with $VisitContainerDaoMixin {
VisitContainerDao(super.db);
/// Record that a visit to [rawUrl] (canonical [urlCanonical]) at [visitTime]
/// (epoch millis) belonged to [containerId]. Append-only: one row per visit.
Future<void> insertRelation({
required String rawUrl,
required String urlCanonical,
required int visitTime,
required String containerId,
}) {
return into(db.visitContainer).insert(
VisitContainerCompanion.insert(
rawUrl: rawUrl,
urlCanonical: urlCanonical,
visitTime: visitTime,
containerId: containerId,
),
);
}
/// All relation rows whose canonical URL is in [canonicals]. Used to tag a
/// page of Places visits with their container (matched by nearest visit_time
/// in the domain layer). Returns empty for an empty input.
///
/// Queried in chunks: an unfiltered history timeline can span every distinct
/// URL in Places, and a single `IN (?, ?, …)` over that whole set would blow
/// past SQLite's bound-variable limit and throw.
Future<List<VisitContainerData>> relationsForCanonicalUrls(
Set<String> canonicals,
) async {
if (canonicals.isEmpty) return const [];
// Stay well under SQLite's SQLITE_MAX_VARIABLE_NUMBER (defaults 999 on older
// builds, 32766 on newer ones).
const chunkSize = 500;
final canonicalList = canonicals.toList(growable: false);
final results = <VisitContainerData>[];
for (var start = 0; start < canonicalList.length; start += chunkSize) {
final chunk = canonicalList.sublist(
start,
math.min(start + chunkSize, canonicalList.length),
);
final rows = await (db.select(db.visitContainer)
..where((t) => t.urlCanonical.isIn(chunk)))
.get();
results.addAll(rows);
}
return results;
}
/// All relation rows for [containerId], newest first. Used by the
/// container-filtered timeline and the per-container Places-delete mirror.
Future<List<VisitContainerData>> relationsForContainer(String containerId) {
return (db.select(db.visitContainer)
..where((t) => t.containerId.equals(containerId))
..orderBy([(t) => OrderingTerm.desc(t.visitTime)]))
.get();
}
/// Remove a single relation row by primary key. Used when an individual
/// history entry is deleted, so its tag can't later reattach (via the
/// nearest-time join) to a different same-URL visit within the match window.
Future<int> deleteById(int id) {
return (db.delete(db.visitContainer)..where((t) => t.id.equals(id))).go();
}
/// Remove all relation rows for [containerId] (explicit per-container clear).
/// Container deletion dissolves relations automatically via ON DELETE CASCADE
/// and does not go through here.
Future<int> deleteForContainer(String containerId) {
return (db.delete(db.visitContainer)
..where((t) => t.containerId.equals(containerId)))
.go();
}
/// Remove every relation row, all containers. Used when the user clears all
/// browsing history: the Places visits these rows tag are gone, so the tags
/// must go too — otherwise they dangle and could re-attach to a future,
/// unrelated visit of the same URL within the nearest-time window.
Future<int> clearAll() {
return db.delete(db.visitContainer).go();
}
}
@@ -0,0 +1,14 @@
// dart format width=80
// ignore_for_file: type=lint
import 'package:drift/drift.dart' as i0;
import 'package:weblibre/features/geckoview/features/tabs/data/database/database.dart'
as i1;
mixin $VisitContainerDaoMixin on i0.DatabaseAccessor<i1.TabDatabase> {
VisitContainerDaoManager get managers => VisitContainerDaoManager(this);
}
class VisitContainerDaoManager {
final $VisitContainerDaoMixin _db;
VisitContainerDaoManager(this._db);
}
@@ -28,6 +28,7 @@ import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/cap
import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/container.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/container.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/history.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/history.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/tab.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/tab.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/visit_container.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/database.drift.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/database/database.drift.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/database.steps.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/database/database.steps.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart';
@@ -36,11 +37,11 @@ import 'package:weblibre/features/search/domain/fts_tokenizer.dart';
@DriftDatabase( @DriftDatabase(
include: {'definitions.drift'}, include: {'definitions.drift'},
daos: [ContainerDao, TabDao, CaptureTabDao, HistoryDao], daos: [ContainerDao, TabDao, CaptureTabDao, HistoryDao, VisitContainerDao],
) )
class TabDatabase extends $TabDatabase with TrigramQueryBuilderMixin { class TabDatabase extends $TabDatabase with TrigramQueryBuilderMixin {
@override @override
final int schemaVersion = 14; final int schemaVersion = 15;
@override @override
final int ftsTokenLimit = 10; final int ftsTokenLimit = 10;
@@ -247,5 +248,13 @@ class TabDatabase extends $TabDatabase with TrigramQueryBuilderMixin {
await m.create(schema.tabToHistoryOnContainerUpdate); await m.create(schema.tabToHistoryOnContainerUpdate);
await m.create(schema.containerToHistoryOnMetadataUpdate); await m.create(schema.containerToHistoryOnMetadataUpdate);
}, },
from14To15: (m, schema) async {
// Visit → container relation. Mozilla Places stays the source of truth
// for history; this table only records which container each contained
// visit belonged to. See definitions.drift.
await m.create(schema.visitContainer);
await m.create(schema.idxVcCanonical);
await m.create(schema.idxVcContainer);
},
); );
} }
@@ -13,8 +13,10 @@ import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/cap
as i5; as i5;
import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/history.dart' import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/history.dart'
as i6; as i6;
import 'package:drift/internal/modular.dart' as i7; import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/visit_container.dart'
import 'package:sqlite3/common.dart' as i8; as i7;
import 'package:drift/internal/modular.dart' as i8;
import 'package:sqlite3/common.dart' as i9;
abstract class $TabDatabase extends i0.GeneratedDatabase { abstract class $TabDatabase extends i0.GeneratedDatabase {
$TabDatabase(i0.QueryExecutor e) : super(e); $TabDatabase(i0.QueryExecutor e) : super(e);
@@ -31,6 +33,7 @@ abstract class $TabDatabase extends i0.GeneratedDatabase {
); );
late final i1.History history = i1.History(this); late final i1.History history = i1.History(this);
late final i1.HistoryFts historyFts = i1.HistoryFts(this); late final i1.HistoryFts historyFts = i1.HistoryFts(this);
late final i1.VisitContainer visitContainer = i1.VisitContainer(this);
late final i2.ContainerDao containerDao = i2.ContainerDao( late final i2.ContainerDao containerDao = i2.ContainerDao(
this as i3.TabDatabase, this as i3.TabDatabase,
); );
@@ -39,7 +42,10 @@ abstract class $TabDatabase extends i0.GeneratedDatabase {
this as i3.TabDatabase, this as i3.TabDatabase,
); );
late final i6.HistoryDao historyDao = i6.HistoryDao(this as i3.TabDatabase); late final i6.HistoryDao historyDao = i6.HistoryDao(this as i3.TabDatabase);
i1.DefinitionsDrift get definitionsDrift => i7.ReadDatabaseContainer( late final i7.VisitContainerDao visitContainerDao = i7.VisitContainerDao(
this as i3.TabDatabase,
);
i1.DefinitionsDrift get definitionsDrift => i8.ReadDatabaseContainer(
this, this,
).accessor<i1.DefinitionsDrift>(i1.DefinitionsDrift.new); ).accessor<i1.DefinitionsDrift>(i1.DefinitionsDrift.new);
@override @override
@@ -70,6 +76,9 @@ abstract class $TabDatabase extends i0.GeneratedDatabase {
i1.tabToHistoryOnUpdate, i1.tabToHistoryOnUpdate,
i1.tabToHistoryOnContainerUpdate, i1.tabToHistoryOnContainerUpdate,
i1.containerToHistoryOnMetadataUpdate, i1.containerToHistoryOnMetadataUpdate,
visitContainer,
i1.idxVcCanonical,
i1.idxVcContainer,
]; ];
@override @override
i0.StreamQueryUpdateRules get streamUpdateRules => i0.StreamQueryUpdateRules get streamUpdateRules =>
@@ -171,6 +180,15 @@ abstract class $TabDatabase extends i0.GeneratedDatabase {
i0.TableUpdate('history', kind: i0.UpdateKind.insert), i0.TableUpdate('history', kind: i0.UpdateKind.insert),
], ],
), ),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'container',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [
i0.TableUpdate('visit_container', kind: i0.UpdateKind.delete),
],
),
]); ]);
} }
@@ -191,9 +209,11 @@ class $TabDatabaseManager {
i1.$HistoryTableManager(_db, _db.history); i1.$HistoryTableManager(_db, _db.history);
i1.$HistoryFtsTableManager get historyFts => i1.$HistoryFtsTableManager get historyFts =>
i1.$HistoryFtsTableManager(_db, _db.historyFts); i1.$HistoryFtsTableManager(_db, _db.historyFts);
i1.$VisitContainerTableManager get visitContainer =>
i1.$VisitContainerTableManager(_db, _db.visitContainer);
} }
extension DefineFunctions on i8.CommonDatabase { extension DefineFunctions on i9.CommonDatabase {
void defineFunctions({ void defineFunctions({
required String Function(int, String?) lexoRankNext, required String Function(int, String?) lexoRankNext,
required String Function(int, String?) lexoRankPrevious, required String Function(int, String?) lexoRankPrevious,
@@ -207,7 +227,7 @@ extension DefineFunctions on i8.CommonDatabase {
}) { }) {
createFunction( createFunction(
functionName: 'lexo_rank_next', functionName: 'lexo_rank_next',
argumentCount: const i8.AllowedArgumentCount(2), argumentCount: const i9.AllowedArgumentCount(2),
function: (args) { function: (args) {
final arg0 = args[0] as int; final arg0 = args[0] as int;
final arg1 = args[1] as String?; final arg1 = args[1] as String?;
@@ -216,7 +236,7 @@ extension DefineFunctions on i8.CommonDatabase {
); );
createFunction( createFunction(
functionName: 'lexo_rank_previous', functionName: 'lexo_rank_previous',
argumentCount: const i8.AllowedArgumentCount(2), argumentCount: const i9.AllowedArgumentCount(2),
function: (args) { function: (args) {
final arg0 = args[0] as int; final arg0 = args[0] as int;
final arg1 = args[1] as String?; final arg1 = args[1] as String?;
@@ -225,7 +245,7 @@ extension DefineFunctions on i8.CommonDatabase {
); );
createFunction( createFunction(
functionName: 'lexo_rank_reorder_after', functionName: 'lexo_rank_reorder_after',
argumentCount: const i8.AllowedArgumentCount(2), argumentCount: const i9.AllowedArgumentCount(2),
function: (args) { function: (args) {
final arg0 = args[0] as String?; final arg0 = args[0] as String?;
final arg1 = args[1] as String?; final arg1 = args[1] as String?;
@@ -234,7 +254,7 @@ extension DefineFunctions on i8.CommonDatabase {
); );
createFunction( createFunction(
functionName: 'lexo_rank_reorder_before', functionName: 'lexo_rank_reorder_before',
argumentCount: const i8.AllowedArgumentCount(2), argumentCount: const i9.AllowedArgumentCount(2),
function: (args) { function: (args) {
final arg0 = args[0] as String?; final arg0 = args[0] as String?;
final arg1 = args[1] as String?; final arg1 = args[1] as String?;
@@ -243,14 +263,14 @@ extension DefineFunctions on i8.CommonDatabase {
); );
createFunction( createFunction(
functionName: 'generate_content_hash', functionName: 'generate_content_hash',
argumentCount: const i8.AllowedArgumentCount(0), argumentCount: const i9.AllowedArgumentCount(0),
function: (args) { function: (args) {
return generateContentHash(); return generateContentHash();
}, },
); );
createFunction( createFunction(
functionName: 'url_indexable', functionName: 'url_indexable',
argumentCount: const i8.AllowedArgumentCount(1), argumentCount: const i9.AllowedArgumentCount(1),
function: (args) { function: (args) {
final arg0 = args[0] as String?; final arg0 = args[0] as String?;
return urlIndexable(arg0); return urlIndexable(arg0);
@@ -258,7 +278,7 @@ extension DefineFunctions on i8.CommonDatabase {
); );
createFunction( createFunction(
functionName: 'url_canonical', functionName: 'url_canonical',
argumentCount: const i8.AllowedArgumentCount(1), argumentCount: const i9.AllowedArgumentCount(1),
function: (args) { function: (args) {
final arg0 = args[0] as String?; final arg0 = args[0] as String?;
return urlCanonical(arg0); return urlCanonical(arg0);
@@ -266,7 +286,7 @@ extension DefineFunctions on i8.CommonDatabase {
); );
createFunction( createFunction(
functionName: 'url_host', functionName: 'url_host',
argumentCount: const i8.AllowedArgumentCount(1), argumentCount: const i9.AllowedArgumentCount(1),
function: (args) { function: (args) {
final arg0 = args[0] as String?; final arg0 = args[0] as String?;
return urlHost(arg0); return urlHost(arg0);
@@ -274,7 +294,7 @@ extension DefineFunctions on i8.CommonDatabase {
); );
createFunction( createFunction(
functionName: 'url_path', functionName: 'url_path',
argumentCount: const i8.AllowedArgumentCount(1), argumentCount: const i9.AllowedArgumentCount(1),
function: (args) { function: (args) {
final arg0 = args[0] as String?; final arg0 = args[0] as String?;
return urlPath(arg0); return urlPath(arg0);
@@ -2039,6 +2039,299 @@ final class Schema14 extends i0.VersionedSchema {
); );
} }
final class Schema15 extends i0.VersionedSchema {
Schema15({required super.database}) : super(version: 15);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
container,
tab,
closedTabTombstone,
idxTabParentContainer,
captureTab,
idxCaptureTabCaptureId,
tabFts,
tabMaintainParentChainOnDelete,
tabAfterInsert,
tabAfterDelete,
tabAfterUpdate,
localIndexSetting,
history,
idxHistoryHost,
idxHistoryObserved,
historyFts,
historyAfterInsert,
historyAfterDelete,
historyAfterUpdate,
tabToHistoryOnInsert,
tabToHistoryOnUpdate,
tabToHistoryOnContainerUpdate,
containerToHistoryOnMetadataUpdate,
visitContainer,
idxVcCanonical,
idxVcContainer,
];
late final Shape12 container = Shape12(
source: i0.VersionedTable(
entityName: 'container',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_1,
_column_2,
_column_6,
_column_19,
_column_3,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape8 tab = Shape8(
source: i0.VersionedTable(
entityName: 'tab',
withoutRowId: false,
isStrict: false,
tableConstraints: [
'CHECK((tab_mode = 2 AND isolation_context_id IS NOT NULL)OR(tab_mode != 2 AND isolation_context_id IS NULL))',
],
columns: [
_column_0,
_column_16,
_column_4,
_column_5,
_column_6,
_column_7,
_column_8,
_column_17,
_column_18,
_column_19,
_column_10,
_column_11,
_column_12,
_column_13,
_column_14,
_column_15,
_column_27,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape6 closedTabTombstone = Shape6(
source: i0.VersionedTable(
entityName: 'closed_tab_tombstone',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [_column_20, _column_21],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxTabParentContainer = i1.Index(
'idx_tab_parent_container',
'CREATE INDEX idx_tab_parent_container ON tab (parent_id, container_id)',
);
late final Shape7 captureTab = Shape7(
source: i0.VersionedTable(
entityName: 'capture_tab',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [_column_22, _column_23, _column_24, _column_25, _column_26],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxCaptureTabCaptureId = i1.Index(
'idx_capture_tab_capture_id',
'CREATE INDEX idx_capture_tab_capture_id ON capture_tab (capture_id)',
);
late final Shape2 tabFts = Shape2(
source: i0.VersionedVirtualTable(
entityName: 'tab_fts',
moduleAndArgs:
'fts5(title, url, extracted_content_plain, full_content_plain, content=tab, tokenize="trigram")',
columns: [_column_8, _column_7, _column_12, _column_14],
attachedDatabase: database,
),
alias: null,
);
final i1.Trigger tabMaintainParentChainOnDelete = i1.Trigger(
'CREATE TRIGGER tab_maintain_parent_chain_on_delete BEFORE DELETE ON tab BEGIN UPDATE tab SET parent_id = CASE WHEN OLD.parent_id IS NOT NULL AND EXISTS (SELECT 1 FROM tab WHERE id = OLD.parent_id) THEN OLD.parent_id ELSE NULL END WHERE parent_id = OLD.id;END',
'tab_maintain_parent_chain_on_delete',
);
final i1.Trigger tabAfterInsert = i1.Trigger(
'CREATE TRIGGER tab_after_insert AFTER INSERT ON tab BEGIN INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END',
'tab_after_insert',
);
final i1.Trigger tabAfterDelete = i1.Trigger(
'CREATE TRIGGER tab_after_delete AFTER DELETE ON tab BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);END',
'tab_after_delete',
);
final i1.Trigger tabAfterUpdate = i1.Trigger(
'CREATE TRIGGER tab_after_update AFTER UPDATE OF title, url, extracted_content_plain, full_content_plain ON tab WHEN OLD.content_hash IS NOT NEW.content_hash OR OLD.url IS NOT NEW.url BEGIN INSERT INTO tab_fts (tab_fts, "rowid", title, url, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url, old.extracted_content_plain, old.full_content_plain);INSERT INTO tab_fts ("rowid", title, url, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url, new.extracted_content_plain, new.full_content_plain);END',
'tab_after_update',
);
late final Shape9 localIndexSetting = Shape9(
source: i0.VersionedTable(
entityName: 'local_index_setting',
withoutRowId: false,
isStrict: true,
tableConstraints: [],
columns: [_column_28, _column_29],
attachedDatabase: database,
),
alias: null,
);
late final Shape10 history = Shape10(
source: i0.VersionedTable(
entityName: 'history',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_30,
_column_31,
_column_32,
_column_8,
_column_10,
_column_11,
_column_12,
_column_13,
_column_14,
_column_33,
_column_34,
_column_35,
],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxHistoryHost = i1.Index(
'idx_history_host',
'CREATE INDEX idx_history_host ON history (url_host)',
);
final i1.Index idxHistoryObserved = i1.Index(
'idx_history_observed',
'CREATE INDEX idx_history_observed ON history (observed_at DESC)',
);
late final Shape11 historyFts = Shape11(
source: i0.VersionedVirtualTable(
entityName: 'history_fts',
moduleAndArgs:
'fts5(title, url_host, url_path, extracted_content_plain, full_content_plain, content=history, tokenize="trigram")',
columns: [_column_8, _column_36, _column_32, _column_12, _column_14],
attachedDatabase: database,
),
alias: null,
);
final i1.Trigger historyAfterInsert = i1.Trigger(
'CREATE TRIGGER history_after_insert AFTER INSERT ON history BEGIN INSERT INTO history_fts ("rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url_host, new.url_path, new.extracted_content_plain, new.full_content_plain);END',
'history_after_insert',
);
final i1.Trigger historyAfterDelete = i1.Trigger(
'CREATE TRIGGER history_after_delete AFTER DELETE ON history BEGIN INSERT INTO history_fts (history_fts, "rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url_host, old.url_path, old.extracted_content_plain, old.full_content_plain);END',
'history_after_delete',
);
final i1.Trigger historyAfterUpdate = i1.Trigger(
'CREATE TRIGGER history_after_update AFTER UPDATE OF title, url_host, url_path, extracted_content_plain, full_content_plain ON history BEGIN INSERT INTO history_fts (history_fts, "rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (\'delete\', old."rowid", old.title, old.url_host, old.url_path, old.extracted_content_plain, old.full_content_plain);INSERT INTO history_fts ("rowid", title, url_host, url_path, extracted_content_plain, full_content_plain) VALUES (new."rowid", new.title, new.url_host, new.url_path, new.extracted_content_plain, new.full_content_plain);END',
'history_after_update',
);
final i1.Trigger tabToHistoryOnInsert = i1.Trigger(
'CREATE TRIGGER tab_to_history_on_insert AFTER INSERT ON tab WHEN NEW.url IS NOT NULL AND url_indexable(CAST(NEW.url AS TEXT)) = 1 AND (SELECT value FROM local_index_setting WHERE "key" = \'enabled\') = 1 AND(NEW.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE "key" = \'index_private\') = 1)AND NOT EXISTS (SELECT 1 FROM container WHERE container.id = NEW.container_id AND json_extract(container.metadata, \'\$.excludeFromIndex\') = 1) BEGIN INSERT INTO history (url_canonical, url_host, url_path, title, is_probably_readerable, extracted_content_markdown, extracted_content_plain, full_content_markdown, full_content_plain, content_hash, observed_at, observed_count) VALUES (url_canonical(CAST(NEW.url AS TEXT)), url_host(CAST(NEW.url AS TEXT)), url_path(CAST(NEW.url AS TEXT)), NEW.title, NEW.is_probably_readerable, NEW.extracted_content_markdown, NEW.extracted_content_plain, NEW.full_content_markdown, NEW.full_content_plain, NEW.content_hash, strftime(\'%s\', \'now\') * 1000, 1) ON CONFLICT (url_canonical) DO UPDATE SET title = COALESCE(excluded.title, history.title), is_probably_readerable = excluded.is_probably_readerable, extracted_content_markdown = excluded.extracted_content_markdown, extracted_content_plain = excluded.extracted_content_plain, full_content_markdown = excluded.full_content_markdown, full_content_plain = excluded.full_content_plain, content_hash = excluded.content_hash, observed_at = excluded.observed_at, observed_count = history.observed_count + 1 WHERE history.content_hash IS NOT excluded.content_hash;END',
'tab_to_history_on_insert',
);
final i1.Trigger tabToHistoryOnUpdate = i1.Trigger(
'CREATE TRIGGER tab_to_history_on_update AFTER UPDATE OF title, url, extracted_content_plain, extracted_content_markdown, full_content_plain, full_content_markdown, is_probably_readerable ON tab WHEN NEW.url IS NOT NULL AND url_indexable(CAST(NEW.url AS TEXT)) = 1 AND(OLD.content_hash IS NOT NEW.content_hash OR OLD.url IS NOT NEW.url)AND (SELECT value FROM local_index_setting WHERE "key" = \'enabled\') = 1 AND(NEW.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE "key" = \'index_private\') = 1)AND NOT EXISTS (SELECT 1 FROM container WHERE container.id = NEW.container_id AND json_extract(container.metadata, \'\$.excludeFromIndex\') = 1) BEGIN INSERT INTO history (url_canonical, url_host, url_path, title, is_probably_readerable, extracted_content_markdown, extracted_content_plain, full_content_markdown, full_content_plain, content_hash, observed_at, observed_count) VALUES (url_canonical(CAST(NEW.url AS TEXT)), url_host(CAST(NEW.url AS TEXT)), url_path(CAST(NEW.url AS TEXT)), NEW.title, NEW.is_probably_readerable, NEW.extracted_content_markdown, NEW.extracted_content_plain, NEW.full_content_markdown, NEW.full_content_plain, NEW.content_hash, strftime(\'%s\', \'now\') * 1000, 1) ON CONFLICT (url_canonical) DO UPDATE SET title = COALESCE(excluded.title, history.title), is_probably_readerable = excluded.is_probably_readerable, extracted_content_markdown = excluded.extracted_content_markdown, extracted_content_plain = excluded.extracted_content_plain, full_content_markdown = excluded.full_content_markdown, full_content_plain = excluded.full_content_plain, content_hash = excluded.content_hash, observed_at = excluded.observed_at, observed_count = history.observed_count + 1 WHERE history.content_hash IS NOT excluded.content_hash;END',
'tab_to_history_on_update',
);
final i1.Trigger tabToHistoryOnContainerUpdate = i1.Trigger(
'CREATE TRIGGER tab_to_history_on_container_update AFTER UPDATE OF container_id ON tab WHEN NEW.url IS NOT NULL AND url_indexable(CAST(NEW.url AS TEXT)) = 1 AND (SELECT value FROM local_index_setting WHERE "key" = \'enabled\') = 1 BEGIN DELETE FROM history WHERE url_canonical = url_canonical(CAST(NEW.url AS TEXT)) AND NOT EXISTS (SELECT 1 FROM tab AS candidate WHERE candidate.url IS NOT NULL AND url_indexable(CAST(candidate.url AS TEXT)) = 1 AND url_canonical(CAST(candidate.url AS TEXT)) = history.url_canonical AND(candidate.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE "key" = \'index_private\') = 1)AND NOT EXISTS (SELECT 1 FROM container WHERE container.id = candidate.container_id AND json_extract(container.metadata, \'\$.excludeFromIndex\') = 1));INSERT INTO history (url_canonical, url_host, url_path, title, is_probably_readerable, extracted_content_markdown, extracted_content_plain, full_content_markdown, full_content_plain, content_hash, observed_at, observed_count) SELECT url_canonical(CAST(candidate.url AS TEXT)), url_host(CAST(candidate.url AS TEXT)), url_path(CAST(candidate.url AS TEXT)), candidate.title, candidate.is_probably_readerable, candidate.extracted_content_markdown, candidate.extracted_content_plain, candidate.full_content_markdown, candidate.full_content_plain, candidate.content_hash, strftime(\'%s\', \'now\') * 1000, 1 FROM tab AS candidate WHERE candidate.url IS NOT NULL AND url_indexable(CAST(candidate.url AS TEXT)) = 1 AND url_canonical(CAST(candidate.url AS TEXT)) = url_canonical(CAST(NEW.url AS TEXT)) AND(candidate.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE "key" = \'index_private\') = 1)AND NOT EXISTS (SELECT 1 FROM container WHERE container.id = candidate.container_id AND json_extract(container.metadata, \'\$.excludeFromIndex\') = 1) ORDER BY candidate.timestamp DESC, candidate."rowid" DESC LIMIT 1 ON CONFLICT (url_canonical) DO UPDATE SET title = COALESCE(excluded.title, history.title), is_probably_readerable = excluded.is_probably_readerable, extracted_content_markdown = excluded.extracted_content_markdown, extracted_content_plain = excluded.extracted_content_plain, full_content_markdown = excluded.full_content_markdown, full_content_plain = excluded.full_content_plain, content_hash = excluded.content_hash, observed_at = excluded.observed_at, observed_count = history.observed_count + 1;END',
'tab_to_history_on_container_update',
);
final i1.Trigger containerToHistoryOnMetadataUpdate = i1.Trigger(
'CREATE TRIGGER container_to_history_on_metadata_update AFTER UPDATE OF metadata ON container WHEN COALESCE(json_extract(OLD.metadata, \'\$.excludeFromIndex\') = 1, 0) != COALESCE(json_extract(NEW.metadata, \'\$.excludeFromIndex\') = 1, 0) AND (SELECT value FROM local_index_setting WHERE "key" = \'enabled\') = 1 BEGIN DELETE FROM history WHERE url_canonical IN (SELECT DISTINCT url_canonical(CAST(affected.url AS TEXT)) FROM tab AS affected WHERE affected.container_id = NEW.id AND affected.url IS NOT NULL AND url_indexable(CAST(affected.url AS TEXT)) = 1) AND NOT EXISTS (SELECT 1 FROM tab AS candidate WHERE candidate.url IS NOT NULL AND url_indexable(CAST(candidate.url AS TEXT)) = 1 AND url_canonical(CAST(candidate.url AS TEXT)) = history.url_canonical AND(candidate.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE "key" = \'index_private\') = 1)AND NOT EXISTS (SELECT 1 FROM container WHERE container.id = candidate.container_id AND json_extract(container.metadata, \'\$.excludeFromIndex\') = 1));INSERT INTO history (url_canonical, url_host, url_path, title, is_probably_readerable, extracted_content_markdown, extracted_content_plain, full_content_markdown, full_content_plain, content_hash, observed_at, observed_count) SELECT url_canonical(CAST(candidate.url AS TEXT)), url_host(CAST(candidate.url AS TEXT)), url_path(CAST(candidate.url AS TEXT)), candidate.title, candidate.is_probably_readerable, candidate.extracted_content_markdown, candidate.extracted_content_plain, candidate.full_content_markdown, candidate.full_content_plain, candidate.content_hash, strftime(\'%s\', \'now\') * 1000, 1 FROM tab AS candidate WHERE candidate.url IS NOT NULL AND url_indexable(CAST(candidate.url AS TEXT)) = 1 AND url_canonical(CAST(candidate.url AS TEXT)) IN (SELECT DISTINCT url_canonical(CAST(affected.url AS TEXT)) FROM tab AS affected WHERE affected.container_id = NEW.id AND affected.url IS NOT NULL AND url_indexable(CAST(affected.url AS TEXT)) = 1) AND(candidate.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE "key" = \'index_private\') = 1)AND NOT EXISTS (SELECT 1 FROM container WHERE container.id = candidate.container_id AND json_extract(container.metadata, \'\$.excludeFromIndex\') = 1) AND NOT EXISTS (SELECT 1 FROM tab AS newer WHERE newer.url IS NOT NULL AND url_indexable(CAST(newer.url AS TEXT)) = 1 AND url_canonical(CAST(newer.url AS TEXT)) = url_canonical(CAST(candidate.url AS TEXT)) AND(newer.tab_mode != 1 OR (SELECT value FROM local_index_setting WHERE "key" = \'index_private\') = 1)AND NOT EXISTS (SELECT 1 FROM container WHERE container.id = newer.container_id AND json_extract(container.metadata, \'\$.excludeFromIndex\') = 1) AND(newer.timestamp > candidate.timestamp OR(newer.timestamp = candidate.timestamp AND newer."rowid" > candidate."rowid"))) ON CONFLICT (url_canonical) DO UPDATE SET title = COALESCE(excluded.title, history.title), is_probably_readerable = excluded.is_probably_readerable, extracted_content_markdown = excluded.extracted_content_markdown, extracted_content_plain = excluded.extracted_content_plain, full_content_markdown = excluded.full_content_markdown, full_content_plain = excluded.full_content_plain, content_hash = excluded.content_hash, observed_at = excluded.observed_at, observed_count = history.observed_count + 1;END',
'container_to_history_on_metadata_update',
);
late final Shape13 visitContainer = Shape13(
source: i0.VersionedTable(
entityName: 'visit_container',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [_column_37, _column_38, _column_39, _column_40, _column_41],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxVcCanonical = i1.Index(
'idx_vc_canonical',
'CREATE INDEX idx_vc_canonical ON visit_container (url_canonical, visit_time)',
);
final i1.Index idxVcContainer = i1.Index(
'idx_vc_container',
'CREATE INDEX idx_vc_container ON visit_container (container_id, visit_time DESC)',
);
}
class Shape13 extends i0.VersionedTable {
Shape13({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get id =>
columnsByName['id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get rawUrl =>
columnsByName['raw_url']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get urlCanonical =>
columnsByName['url_canonical']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get visitTime =>
columnsByName['visit_time']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get containerId =>
columnsByName['container_id']! as i1.GeneratedColumn<String>;
}
i1.GeneratedColumn<int> _column_37(String aliasedName) =>
i1.GeneratedColumn<int>(
'id',
aliasedName,
false,
hasAutoIncrement: true,
type: i1.DriftSqlType.int,
$customConstraints: 'NOT NULL PRIMARY KEY AUTOINCREMENT',
);
i1.GeneratedColumn<String> _column_38(String aliasedName) =>
i1.GeneratedColumn<String>(
'raw_url',
aliasedName,
false,
type: i1.DriftSqlType.string,
$customConstraints: 'NOT NULL',
);
i1.GeneratedColumn<String> _column_39(String aliasedName) =>
i1.GeneratedColumn<String>(
'url_canonical',
aliasedName,
false,
type: i1.DriftSqlType.string,
$customConstraints: 'NOT NULL',
);
i1.GeneratedColumn<int> _column_40(String aliasedName) =>
i1.GeneratedColumn<int>(
'visit_time',
aliasedName,
false,
type: i1.DriftSqlType.int,
$customConstraints: 'NOT NULL',
);
i1.GeneratedColumn<String> _column_41(String aliasedName) =>
i1.GeneratedColumn<String>(
'container_id',
aliasedName,
false,
type: i1.DriftSqlType.string,
$customConstraints: 'NOT NULL REFERENCES container(id)ON DELETE CASCADE',
);
i0.MigrationStepWithVersion migrationSteps({ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3, required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4, required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
@@ -2052,6 +2345,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema12 schema) from11To12, required Future<void> Function(i1.Migrator m, Schema12 schema) from11To12,
required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13, required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13,
required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14, required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14,
required Future<void> Function(i1.Migrator m, Schema15 schema) from14To15,
}) { }) {
return (currentVersion, database) async { return (currentVersion, database) async {
switch (currentVersion) { switch (currentVersion) {
@@ -2115,6 +2409,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema); final migrator = i1.Migrator(database, schema);
await from13To14(migrator, schema); await from13To14(migrator, schema);
return 14; return 14;
case 14:
final schema = Schema15(database: database);
final migrator = i1.Migrator(database, schema);
await from14To15(migrator, schema);
return 15;
default: default:
throw ArgumentError.value('Unknown migration from $currentVersion'); throw ArgumentError.value('Unknown migration from $currentVersion');
} }
@@ -2134,6 +2433,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema12 schema) from11To12, required Future<void> Function(i1.Migrator m, Schema12 schema) from11To12,
required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13, required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13,
required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14, required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14,
required Future<void> Function(i1.Migrator m, Schema15 schema) from14To15,
}) => i0.VersionedSchema.stepByStepHelper( }) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps( step: migrationSteps(
from2To3: from2To3, from2To3: from2To3,
@@ -2148,5 +2448,6 @@ i1.OnUpgrade stepByStep({
from11To12: from11To12, from11To12: from11To12,
from12To13: from12To13, from12To13: from12To13,
from13To14: from13To14, from13To14: from13To14,
from14To15: from14To15,
), ),
); );
@@ -563,6 +563,39 @@ BEGIN
observed_count = history.observed_count + 1; observed_count = history.observed_count + 1;
END; END;
-- ===========================================================================
-- Visit → container relation. Mozilla Places remains the source of truth for
-- browsing history (url, title, visit type, visit time, sync); this table only
-- carries the one thing Places can't store: which WebLibre container a visit
-- belonged to. One row per contained visit, written by the Dart
-- VisitContainerRecorder from native history-delegate events. Uncontained
-- visits produce no row.
--
-- Joined back to a Places `VisitInfo` on (url_canonical, nearest visit_time):
-- `visit_time` is captured near — not exactly at — the Places record time, so
-- consumers match the closest visit within a small tolerance window.
--
-- `container_id` is ON DELETE CASCADE: deleting a container dissolves its
-- relations (the Places visits survive, untagged). Optionally deleting the
-- visits themselves is a separate, explicit Places mirror step in the repo.
-- ===========================================================================
CREATE TABLE visit_container (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
-- Original visited URL; used to open the page and as the key for the Places
-- delete mirror (Places keys visits by URL + time).
raw_url TEXT NOT NULL,
-- Structural canonical form; the join key to Places visits (computed in Dart
-- via canonicalizeUrl so it matches the local index's canonicalization).
url_canonical TEXT NOT NULL,
-- Epoch MILLISECONDS (plain INTEGER, not a drift DATETIME which stores
-- seconds). Matches Places `VisitInfo.visitTime`.
visit_time INTEGER NOT NULL,
container_id TEXT NOT NULL REFERENCES container(id) ON DELETE CASCADE
);
CREATE INDEX idx_vc_canonical ON visit_container(url_canonical, visit_time);
CREATE INDEX idx_vc_container ON visit_container(container_id, visit_time DESC);
containersWithCount WITH ContainerDataWithCount: containersWithCount WITH ContainerDataWithCount:
SELECT SELECT
container.*, container.*,
@@ -72,6 +72,31 @@ final class $ContainerReferences
manager.$state.copyWith(prefetchedData: cache), manager.$state.copyWith(prefetchedData: cache),
); );
} }
static i0.MultiTypedResultKey<i3.VisitContainer, List<i3.VisitContainerData>>
_visitContainerRefsTable(i0.GeneratedDatabase db) =>
i0.MultiTypedResultKey.fromTable(
i9.ReadDatabaseContainer(
db,
).resultSet<i3.VisitContainer>('visit_container'),
aliasName: 'container__id__visit_container__container_id',
);
i3.$VisitContainerProcessedTableManager get visitContainerRefs {
final manager = i3
.$VisitContainerTableManager(
$_db,
i9.ReadDatabaseContainer(
$_db,
).resultSet<i3.VisitContainer>('visit_container'),
)
.filter((f) => f.containerId.id.sqlEquals($_itemColumn<String>('id')!));
final cache = $_typedResult.readTableOrNull(_visitContainerRefsTable($_db));
return i0.ProcessedTableManager(
manager.$state.copyWith(prefetchedData: cache),
);
}
} }
class $ContainerFilterComposer class $ContainerFilterComposer
@@ -143,6 +168,35 @@ class $ContainerFilterComposer
); );
return f(composer); return f(composer);
} }
i0.Expression<bool> visitContainerRefs(
i0.Expression<bool> Function(i3.$VisitContainerFilterComposer f) f,
) {
final i3.$VisitContainerFilterComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.id,
referencedTable: i9.ReadDatabaseContainer(
$db,
).resultSet<i3.VisitContainer>('visit_container'),
getReferencedColumn: (t) => t.containerId,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i3.$VisitContainerFilterComposer(
$db: $db,
$table: i9.ReadDatabaseContainer(
$db,
).resultSet<i3.VisitContainer>('visit_container'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
),
);
return f(composer);
}
} }
class $ContainerOrderingComposer class $ContainerOrderingComposer
@@ -237,6 +291,35 @@ class $ContainerAnnotationComposer
); );
return f(composer); return f(composer);
} }
i0.Expression<T> visitContainerRefs<T extends Object>(
i0.Expression<T> Function(i3.$VisitContainerAnnotationComposer a) f,
) {
final i3.$VisitContainerAnnotationComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.id,
referencedTable: i9.ReadDatabaseContainer(
$db,
).resultSet<i3.VisitContainer>('visit_container'),
getReferencedColumn: (t) => t.containerId,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i3.$VisitContainerAnnotationComposer(
$db: $db,
$table: i9.ReadDatabaseContainer(
$db,
).resultSet<i3.VisitContainer>('visit_container'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
),
);
return f(composer);
}
} }
class $ContainerTableManager class $ContainerTableManager
@@ -252,7 +335,7 @@ class $ContainerTableManager
$ContainerUpdateCompanionBuilder, $ContainerUpdateCompanionBuilder,
(i1.ContainerData, i3.$ContainerReferences), (i1.ContainerData, i3.$ContainerReferences),
i1.ContainerData, i1.ContainerData,
i0.PrefetchHooks Function({bool tabRefs}) i0.PrefetchHooks Function({bool tabRefs, bool visitContainerRefs})
> { > {
$ContainerTableManager(i0.GeneratedDatabase db, i3.Container table) $ContainerTableManager(i0.GeneratedDatabase db, i3.Container table)
: super( : super(
@@ -309,12 +392,17 @@ class $ContainerTableManager
(e.readTable(table), i3.$ContainerReferences(db, table, e)), (e.readTable(table), i3.$ContainerReferences(db, table, e)),
) )
.toList(), .toList(),
prefetchHooksCallback: ({tabRefs = false}) { prefetchHooksCallback:
({tabRefs = false, visitContainerRefs = false}) {
return i0.PrefetchHooks( return i0.PrefetchHooks(
db: db, db: db,
explicitlyWatchedTables: [ explicitlyWatchedTables: [
if (tabRefs) if (tabRefs)
i9.ReadDatabaseContainer(db).resultSet<i3.Tab>('tab'), i9.ReadDatabaseContainer(db).resultSet<i3.Tab>('tab'),
if (visitContainerRefs)
i9.ReadDatabaseContainer(
db,
).resultSet<i3.VisitContainer>('visit_container'),
], ],
addJoins: null, addJoins: null,
getPrefetchedDataCallback: (items) async { getPrefetchedDataCallback: (items) async {
@@ -326,13 +414,30 @@ class $ContainerTableManager
i3.TabData i3.TabData
>( >(
currentTable: table, currentTable: table,
referencedTable: i3.$ContainerReferences._tabRefsTable( referencedTable: i3.$ContainerReferences
db, ._tabRefsTable(db),
),
managerFromTypedResult: (p0) => managerFromTypedResult: (p0) =>
i3.$ContainerReferences(db, table, p0).tabRefs, i3.$ContainerReferences(db, table, p0).tabRefs,
referencedItemsForCurrentItem: (item, referencedItems) => referencedItemsForCurrentItem:
referencedItems.where( (item, referencedItems) => referencedItems.where(
(e) => e.containerId == item.id,
),
typedResults: items,
),
if (visitContainerRefs)
await i0.$_getPrefetchedData<
i1.ContainerData,
i3.Container,
i3.VisitContainerData
>(
currentTable: table,
referencedTable: i3.$ContainerReferences
._visitContainerRefsTable(db),
managerFromTypedResult: (p0) => i3
.$ContainerReferences(db, table, p0)
.visitContainerRefs,
referencedItemsForCurrentItem:
(item, referencedItems) => referencedItems.where(
(e) => e.containerId == item.id, (e) => e.containerId == item.id,
), ),
typedResults: items, typedResults: items,
@@ -357,7 +462,7 @@ typedef $ContainerProcessedTableManager =
$ContainerUpdateCompanionBuilder, $ContainerUpdateCompanionBuilder,
(i1.ContainerData, i3.$ContainerReferences), (i1.ContainerData, i3.$ContainerReferences),
i1.ContainerData, i1.ContainerData,
i0.PrefetchHooks Function({bool tabRefs}) i0.PrefetchHooks Function({bool tabRefs, bool visitContainerRefs})
>; >;
typedef $TabCreateCompanionBuilder = typedef $TabCreateCompanionBuilder =
i3.TabCompanion Function({ i3.TabCompanion Function({
@@ -2411,6 +2516,340 @@ typedef $HistoryFtsProcessedTableManager =
i3.HistoryFt, i3.HistoryFt,
i0.PrefetchHooks Function() i0.PrefetchHooks Function()
>; >;
typedef $VisitContainerCreateCompanionBuilder =
i3.VisitContainerCompanion Function({
i0.Value<int> id,
required String rawUrl,
required String urlCanonical,
required int visitTime,
required String containerId,
});
typedef $VisitContainerUpdateCompanionBuilder =
i3.VisitContainerCompanion Function({
i0.Value<int> id,
i0.Value<String> rawUrl,
i0.Value<String> urlCanonical,
i0.Value<int> visitTime,
i0.Value<String> containerId,
});
final class $VisitContainerReferences
extends
i0.BaseReferences<
i0.GeneratedDatabase,
i3.VisitContainer,
i3.VisitContainerData
> {
$VisitContainerReferences(super.$_db, super.$_table, super.$_typedResult);
static i3.Container _containerIdTable(i0.GeneratedDatabase db) =>
i9.ReadDatabaseContainer(db)
.resultSet<i3.Container>('container')
.createAlias('visit_container__container_id__container__id');
i3.$ContainerProcessedTableManager get containerId {
final $_column = $_itemColumn<String>('container_id')!;
final manager = i3
.$ContainerTableManager(
$_db,
i9.ReadDatabaseContainer($_db).resultSet<i3.Container>('container'),
)
.filter((f) => f.id.sqlEquals($_column));
final item = $_typedResult.readTableOrNull(_containerIdTable($_db));
if (item == null) return manager;
return i0.ProcessedTableManager(
manager.$state.copyWith(prefetchedData: [item]),
);
}
}
class $VisitContainerFilterComposer
extends i0.Composer<i0.GeneratedDatabase, i3.VisitContainer> {
$VisitContainerFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnFilters<int> get id => $composableBuilder(
column: $table.id,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<String> get rawUrl => $composableBuilder(
column: $table.rawUrl,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<String> get urlCanonical => $composableBuilder(
column: $table.urlCanonical,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<int> get visitTime => $composableBuilder(
column: $table.visitTime,
builder: (column) => i0.ColumnFilters(column),
);
i3.$ContainerFilterComposer get containerId {
final i3.$ContainerFilterComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.containerId,
referencedTable: i9.ReadDatabaseContainer(
$db,
).resultSet<i3.Container>('container'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i3.$ContainerFilterComposer(
$db: $db,
$table: i9.ReadDatabaseContainer(
$db,
).resultSet<i3.Container>('container'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
),
);
return composer;
}
}
class $VisitContainerOrderingComposer
extends i0.Composer<i0.GeneratedDatabase, i3.VisitContainer> {
$VisitContainerOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnOrderings<int> get id => $composableBuilder(
column: $table.id,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<String> get rawUrl => $composableBuilder(
column: $table.rawUrl,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<String> get urlCanonical => $composableBuilder(
column: $table.urlCanonical,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<int> get visitTime => $composableBuilder(
column: $table.visitTime,
builder: (column) => i0.ColumnOrderings(column),
);
i3.$ContainerOrderingComposer get containerId {
final i3.$ContainerOrderingComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.containerId,
referencedTable: i9.ReadDatabaseContainer(
$db,
).resultSet<i3.Container>('container'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i3.$ContainerOrderingComposer(
$db: $db,
$table: i9.ReadDatabaseContainer(
$db,
).resultSet<i3.Container>('container'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
),
);
return composer;
}
}
class $VisitContainerAnnotationComposer
extends i0.Composer<i0.GeneratedDatabase, i3.VisitContainer> {
$VisitContainerAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.GeneratedColumn<int> get id =>
$composableBuilder(column: $table.id, builder: (column) => column);
i0.GeneratedColumn<String> get rawUrl =>
$composableBuilder(column: $table.rawUrl, builder: (column) => column);
i0.GeneratedColumn<String> get urlCanonical => $composableBuilder(
column: $table.urlCanonical,
builder: (column) => column,
);
i0.GeneratedColumn<int> get visitTime =>
$composableBuilder(column: $table.visitTime, builder: (column) => column);
i3.$ContainerAnnotationComposer get containerId {
final i3.$ContainerAnnotationComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.containerId,
referencedTable: i9.ReadDatabaseContainer(
$db,
).resultSet<i3.Container>('container'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i3.$ContainerAnnotationComposer(
$db: $db,
$table: i9.ReadDatabaseContainer(
$db,
).resultSet<i3.Container>('container'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
),
);
return composer;
}
}
class $VisitContainerTableManager
extends
i0.RootTableManager<
i0.GeneratedDatabase,
i3.VisitContainer,
i3.VisitContainerData,
i3.$VisitContainerFilterComposer,
i3.$VisitContainerOrderingComposer,
i3.$VisitContainerAnnotationComposer,
$VisitContainerCreateCompanionBuilder,
$VisitContainerUpdateCompanionBuilder,
(i3.VisitContainerData, i3.$VisitContainerReferences),
i3.VisitContainerData,
i0.PrefetchHooks Function({bool containerId})
> {
$VisitContainerTableManager(i0.GeneratedDatabase db, i3.VisitContainer table)
: super(
i0.TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
i3.$VisitContainerFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
i3.$VisitContainerOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () =>
i3.$VisitContainerAnnotationComposer($db: db, $table: table),
updateCompanionCallback:
({
i0.Value<int> id = const i0.Value.absent(),
i0.Value<String> rawUrl = const i0.Value.absent(),
i0.Value<String> urlCanonical = const i0.Value.absent(),
i0.Value<int> visitTime = const i0.Value.absent(),
i0.Value<String> containerId = const i0.Value.absent(),
}) => i3.VisitContainerCompanion(
id: id,
rawUrl: rawUrl,
urlCanonical: urlCanonical,
visitTime: visitTime,
containerId: containerId,
),
createCompanionCallback:
({
i0.Value<int> id = const i0.Value.absent(),
required String rawUrl,
required String urlCanonical,
required int visitTime,
required String containerId,
}) => i3.VisitContainerCompanion.insert(
id: id,
rawUrl: rawUrl,
urlCanonical: urlCanonical,
visitTime: visitTime,
containerId: containerId,
),
withReferenceMapper: (p0) => p0
.map(
(e) => (
e.readTable(table),
i3.$VisitContainerReferences(db, table, e),
),
)
.toList(),
prefetchHooksCallback: ({containerId = false}) {
return i0.PrefetchHooks(
db: db,
explicitlyWatchedTables: [],
addJoins:
<
T extends i0.TableManagerState<
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic
>
>(state) {
if (containerId) {
state =
state.withJoin(
currentTable: table,
currentColumn: table.containerId,
referencedTable: i3.$VisitContainerReferences
._containerIdTable(db),
referencedColumn: i3.$VisitContainerReferences
._containerIdTable(db)
.id,
)
as T;
}
return state;
},
getPrefetchedDataCallback: (items) async {
return [];
},
);
},
),
);
}
typedef $VisitContainerProcessedTableManager =
i0.ProcessedTableManager<
i0.GeneratedDatabase,
i3.VisitContainer,
i3.VisitContainerData,
i3.$VisitContainerFilterComposer,
i3.$VisitContainerOrderingComposer,
i3.$VisitContainerAnnotationComposer,
$VisitContainerCreateCompanionBuilder,
$VisitContainerUpdateCompanionBuilder,
(i3.VisitContainerData, i3.$VisitContainerReferences),
i3.VisitContainerData,
i0.PrefetchHooks Function({bool containerId})
>;
class Container extends i0.Table class Container extends i0.Table
with i0.TableInfo<Container, i1.ContainerData> { with i0.TableInfo<Container, i1.ContainerData> {
@@ -5430,6 +5869,318 @@ i0.Trigger get containerToHistoryOnMetadataUpdate => i0.Trigger(
'container_to_history_on_metadata_update', 'container_to_history_on_metadata_update',
); );
class VisitContainer extends i0.Table
with i0.TableInfo<VisitContainer, i3.VisitContainerData> {
@override
final i0.GeneratedDatabase attachedDatabase;
final String? _alias;
VisitContainer(this.attachedDatabase, [this._alias]);
late final i0.GeneratedColumn<int> id = i0.GeneratedColumn<int>(
'id',
aliasedName,
false,
hasAutoIncrement: true,
type: i0.DriftSqlType.int,
requiredDuringInsert: false,
$customConstraints: 'NOT NULL PRIMARY KEY AUTOINCREMENT',
);
late final i0.GeneratedColumn<String> rawUrl = i0.GeneratedColumn<String>(
'raw_url',
aliasedName,
false,
type: i0.DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
late final i0.GeneratedColumn<String> urlCanonical =
i0.GeneratedColumn<String>(
'url_canonical',
aliasedName,
false,
type: i0.DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
late final i0.GeneratedColumn<int> visitTime = i0.GeneratedColumn<int>(
'visit_time',
aliasedName,
false,
type: i0.DriftSqlType.int,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
late final i0.GeneratedColumn<String> containerId =
i0.GeneratedColumn<String>(
'container_id',
aliasedName,
false,
type: i0.DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints:
'NOT NULL REFERENCES container(id)ON DELETE CASCADE',
);
@override
List<i0.GeneratedColumn> get $columns => [
id,
rawUrl,
urlCanonical,
visitTime,
containerId,
];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'visit_container';
@override
Set<i0.GeneratedColumn> get $primaryKey => {id};
@override
i3.VisitContainerData map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return i3.VisitContainerData(
id: attachedDatabase.typeMapping.read(
i0.DriftSqlType.int,
data['${effectivePrefix}id'],
)!,
rawUrl: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}raw_url'],
)!,
urlCanonical: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}url_canonical'],
)!,
visitTime: attachedDatabase.typeMapping.read(
i0.DriftSqlType.int,
data['${effectivePrefix}visit_time'],
)!,
containerId: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}container_id'],
)!,
);
}
@override
VisitContainer createAlias(String alias) {
return VisitContainer(attachedDatabase, alias);
}
@override
bool get dontWriteConstraints => true;
}
class VisitContainerData extends i0.DataClass
implements i0.Insertable<i3.VisitContainerData> {
final int id;
/// Original visited URL; used to open the page and as the key for the Places
/// delete mirror (Places keys visits by URL + time).
final String rawUrl;
/// Structural canonical form; the join key to Places visits (computed in Dart
/// via canonicalizeUrl so it matches the local index's canonicalization).
final String urlCanonical;
/// Epoch MILLISECONDS (plain INTEGER, not a drift DATETIME which stores
/// seconds). Matches Places `VisitInfo.visitTime`.
final int visitTime;
final String containerId;
const VisitContainerData({
required this.id,
required this.rawUrl,
required this.urlCanonical,
required this.visitTime,
required this.containerId,
});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
map['id'] = i0.Variable<int>(id);
map['raw_url'] = i0.Variable<String>(rawUrl);
map['url_canonical'] = i0.Variable<String>(urlCanonical);
map['visit_time'] = i0.Variable<int>(visitTime);
map['container_id'] = i0.Variable<String>(containerId);
return map;
}
factory VisitContainerData.fromJson(
Map<String, dynamic> json, {
i0.ValueSerializer? serializer,
}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return VisitContainerData(
id: serializer.fromJson<int>(json['id']),
rawUrl: serializer.fromJson<String>(json['raw_url']),
urlCanonical: serializer.fromJson<String>(json['url_canonical']),
visitTime: serializer.fromJson<int>(json['visit_time']),
containerId: serializer.fromJson<String>(json['container_id']),
);
}
@override
Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<int>(id),
'raw_url': serializer.toJson<String>(rawUrl),
'url_canonical': serializer.toJson<String>(urlCanonical),
'visit_time': serializer.toJson<int>(visitTime),
'container_id': serializer.toJson<String>(containerId),
};
}
i3.VisitContainerData copyWith({
int? id,
String? rawUrl,
String? urlCanonical,
int? visitTime,
String? containerId,
}) => i3.VisitContainerData(
id: id ?? this.id,
rawUrl: rawUrl ?? this.rawUrl,
urlCanonical: urlCanonical ?? this.urlCanonical,
visitTime: visitTime ?? this.visitTime,
containerId: containerId ?? this.containerId,
);
VisitContainerData copyWithCompanion(i3.VisitContainerCompanion data) {
return VisitContainerData(
id: data.id.present ? data.id.value : this.id,
rawUrl: data.rawUrl.present ? data.rawUrl.value : this.rawUrl,
urlCanonical: data.urlCanonical.present
? data.urlCanonical.value
: this.urlCanonical,
visitTime: data.visitTime.present ? data.visitTime.value : this.visitTime,
containerId: data.containerId.present
? data.containerId.value
: this.containerId,
);
}
@override
String toString() {
return (StringBuffer('VisitContainerData(')
..write('id: $id, ')
..write('rawUrl: $rawUrl, ')
..write('urlCanonical: $urlCanonical, ')
..write('visitTime: $visitTime, ')
..write('containerId: $containerId')
..write(')'))
.toString();
}
@override
int get hashCode =>
Object.hash(id, rawUrl, urlCanonical, visitTime, containerId);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is i3.VisitContainerData &&
other.id == this.id &&
other.rawUrl == this.rawUrl &&
other.urlCanonical == this.urlCanonical &&
other.visitTime == this.visitTime &&
other.containerId == this.containerId);
}
class VisitContainerCompanion
extends i0.UpdateCompanion<i3.VisitContainerData> {
final i0.Value<int> id;
final i0.Value<String> rawUrl;
final i0.Value<String> urlCanonical;
final i0.Value<int> visitTime;
final i0.Value<String> containerId;
const VisitContainerCompanion({
this.id = const i0.Value.absent(),
this.rawUrl = const i0.Value.absent(),
this.urlCanonical = const i0.Value.absent(),
this.visitTime = const i0.Value.absent(),
this.containerId = const i0.Value.absent(),
});
VisitContainerCompanion.insert({
this.id = const i0.Value.absent(),
required String rawUrl,
required String urlCanonical,
required int visitTime,
required String containerId,
}) : rawUrl = i0.Value(rawUrl),
urlCanonical = i0.Value(urlCanonical),
visitTime = i0.Value(visitTime),
containerId = i0.Value(containerId);
static i0.Insertable<i3.VisitContainerData> custom({
i0.Expression<int>? id,
i0.Expression<String>? rawUrl,
i0.Expression<String>? urlCanonical,
i0.Expression<int>? visitTime,
i0.Expression<String>? containerId,
}) {
return i0.RawValuesInsertable({
if (id != null) 'id': id,
if (rawUrl != null) 'raw_url': rawUrl,
if (urlCanonical != null) 'url_canonical': urlCanonical,
if (visitTime != null) 'visit_time': visitTime,
if (containerId != null) 'container_id': containerId,
});
}
i3.VisitContainerCompanion copyWith({
i0.Value<int>? id,
i0.Value<String>? rawUrl,
i0.Value<String>? urlCanonical,
i0.Value<int>? visitTime,
i0.Value<String>? containerId,
}) {
return i3.VisitContainerCompanion(
id: id ?? this.id,
rawUrl: rawUrl ?? this.rawUrl,
urlCanonical: urlCanonical ?? this.urlCanonical,
visitTime: visitTime ?? this.visitTime,
containerId: containerId ?? this.containerId,
);
}
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
if (id.present) {
map['id'] = i0.Variable<int>(id.value);
}
if (rawUrl.present) {
map['raw_url'] = i0.Variable<String>(rawUrl.value);
}
if (urlCanonical.present) {
map['url_canonical'] = i0.Variable<String>(urlCanonical.value);
}
if (visitTime.present) {
map['visit_time'] = i0.Variable<int>(visitTime.value);
}
if (containerId.present) {
map['container_id'] = i0.Variable<String>(containerId.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('VisitContainerCompanion(')
..write('id: $id, ')
..write('rawUrl: $rawUrl, ')
..write('urlCanonical: $urlCanonical, ')
..write('visitTime: $visitTime, ')
..write('containerId: $containerId')
..write(')'))
.toString();
}
}
i0.Index get idxVcCanonical => i0.Index(
'idx_vc_canonical',
'CREATE INDEX idx_vc_canonical ON visit_container (url_canonical, visit_time)',
);
i0.Index get idxVcContainer => i0.Index(
'idx_vc_container',
'CREATE INDEX idx_vc_container ON visit_container (container_id, visit_time DESC)',
);
class DefinitionsDrift extends i9.ModularAccessor { class DefinitionsDrift extends i9.ModularAccessor {
DefinitionsDrift(i0.GeneratedDatabase db) : super(db); DefinitionsDrift(i0.GeneratedDatabase db) : super(db);
Future<int> optimizeFtsIndex() { Future<int> optimizeFtsIndex() {
@@ -50,6 +50,18 @@ class ContainerMetadata with FastEquatable {
@JsonKey(defaultValue: false) @JsonKey(defaultValue: false)
final bool excludeFromIndex; final bool excludeFromIndex;
// When true, this container's browsing history is not recorded at all: the
// native WebLibreHistoryDelegate skips the Mozilla Places write for its
// visits (hard exclude / "incognito container"), and no visit→container
// relation row is written. Gates history recording independently of
// `excludeFromIndex` (which only gates the local FTS search index).
//
// Invariant: requires a Gecko contextId — without one the native delegate
// can't distinguish the container's visits to skip them. Enforced by
// [sanitized] on write and normalized on read below.
@JsonKey(defaultValue: false)
final bool excludeFromHistory;
@JsonKey(defaultValue: false) @JsonKey(defaultValue: false)
final bool bypassGlobalProxy; final bool bypassGlobalProxy;
@@ -67,6 +79,7 @@ class ContainerMetadata with FastEquatable {
required this.proxyConnectionId, required this.proxyConnectionId,
required this.clearDataOnExit, required this.clearDataOnExit,
required this.excludeFromIndex, required this.excludeFromIndex,
required this.excludeFromHistory,
required this.bypassGlobalProxy, required this.bypassGlobalProxy,
required this.useCustomColor, required this.useCustomColor,
required this.assignedSites, required this.assignedSites,
@@ -78,6 +91,7 @@ class ContainerMetadata with FastEquatable {
ProxyConnectionId? proxyConnectionId, ProxyConnectionId? proxyConnectionId,
bool? clearDataOnExit, bool? clearDataOnExit,
bool? excludeFromIndex, bool? excludeFromIndex,
bool? excludeFromHistory,
bool? bypassGlobalProxy, bool? bypassGlobalProxy,
bool? useCustomColor, bool? useCustomColor,
List<Uri>? assignedSites, List<Uri>? assignedSites,
@@ -87,11 +101,31 @@ class ContainerMetadata with FastEquatable {
proxyConnectionId: proxyConnectionId, proxyConnectionId: proxyConnectionId,
clearDataOnExit: clearDataOnExit ?? false, clearDataOnExit: clearDataOnExit ?? false,
excludeFromIndex: excludeFromIndex ?? false, excludeFromIndex: excludeFromIndex ?? false,
// Invariant: exclude-from-history requires a Gecko contextId (cookie
// isolation). Without one there is no way to hard-exclude the
// container from Places — the native delegate can't tell its visits
// apart. This is the deserialization path, so a legacy/foreign record
// with the bad combination is normalized on read; writers re-apply it
// via [sanitized].
excludeFromHistory:
(excludeFromHistory ?? false) && contextualIdentity != null,
bypassGlobalProxy: bypassGlobalProxy ?? false, bypassGlobalProxy: bypassGlobalProxy ?? false,
useCustomColor: useCustomColor ?? false, useCustomColor: useCustomColor ?? false,
assignedSites: assignedSites, assignedSites: assignedSites,
); );
/// Enforce the [excludeFromHistory] invariant before persistence: it can only
/// be true for a cookie-isolated (contextId-bearing) container, since the
/// native delegate needs the contextId to hard-exclude visits from Places.
/// The primary constructor can't normalize (copy_with_extension_gen requires
/// params to map 1:1 to fields), so writers route through this.
ContainerMetadata sanitized() {
if (excludeFromHistory && contextualIdentity == null) {
return copyWith(excludeFromHistory: false);
}
return this;
}
bool get usesTorProxy => proxyConnectionId is TorProxyConnectionId; bool get usesTorProxy => proxyConnectionId is TorProxyConnectionId;
factory ContainerMetadata.fromJson(Map<String, dynamic> json) => factory ContainerMetadata.fromJson(Map<String, dynamic> json) =>
@@ -106,6 +140,7 @@ class ContainerMetadata with FastEquatable {
proxyConnectionId, proxyConnectionId,
clearDataOnExit, clearDataOnExit,
excludeFromIndex, excludeFromIndex,
excludeFromHistory,
bypassGlobalProxy, bypassGlobalProxy,
useCustomColor, useCustomColor,
assignedSites, assignedSites,
@@ -17,6 +17,8 @@ abstract class _$ContainerMetadataCWProxy {
ContainerMetadata excludeFromIndex(bool excludeFromIndex); ContainerMetadata excludeFromIndex(bool excludeFromIndex);
ContainerMetadata excludeFromHistory(bool excludeFromHistory);
ContainerMetadata bypassGlobalProxy(bool bypassGlobalProxy); ContainerMetadata bypassGlobalProxy(bool bypassGlobalProxy);
ContainerMetadata useCustomColor(bool useCustomColor); ContainerMetadata useCustomColor(bool useCustomColor);
@@ -36,6 +38,7 @@ abstract class _$ContainerMetadataCWProxy {
ProxyConnectionId? proxyConnectionId, ProxyConnectionId? proxyConnectionId,
bool clearDataOnExit, bool clearDataOnExit,
bool excludeFromIndex, bool excludeFromIndex,
bool excludeFromHistory,
bool bypassGlobalProxy, bool bypassGlobalProxy,
bool useCustomColor, bool useCustomColor,
List<Uri>? assignedSites, List<Uri>? assignedSites,
@@ -68,6 +71,10 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy {
ContainerMetadata excludeFromIndex(bool excludeFromIndex) => ContainerMetadata excludeFromIndex(bool excludeFromIndex) =>
call(excludeFromIndex: excludeFromIndex); call(excludeFromIndex: excludeFromIndex);
@override
ContainerMetadata excludeFromHistory(bool excludeFromHistory) =>
call(excludeFromHistory: excludeFromHistory);
@override @override
ContainerMetadata bypassGlobalProxy(bool bypassGlobalProxy) => ContainerMetadata bypassGlobalProxy(bool bypassGlobalProxy) =>
call(bypassGlobalProxy: bypassGlobalProxy); call(bypassGlobalProxy: bypassGlobalProxy);
@@ -94,6 +101,7 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy {
Object? proxyConnectionId = const $CopyWithPlaceholder(), Object? proxyConnectionId = const $CopyWithPlaceholder(),
Object? clearDataOnExit = const $CopyWithPlaceholder(), Object? clearDataOnExit = const $CopyWithPlaceholder(),
Object? excludeFromIndex = const $CopyWithPlaceholder(), Object? excludeFromIndex = const $CopyWithPlaceholder(),
Object? excludeFromHistory = const $CopyWithPlaceholder(),
Object? bypassGlobalProxy = const $CopyWithPlaceholder(), Object? bypassGlobalProxy = const $CopyWithPlaceholder(),
Object? useCustomColor = const $CopyWithPlaceholder(), Object? useCustomColor = const $CopyWithPlaceholder(),
Object? assignedSites = const $CopyWithPlaceholder(), Object? assignedSites = const $CopyWithPlaceholder(),
@@ -123,6 +131,12 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy {
? _value.excludeFromIndex ? _value.excludeFromIndex
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
: excludeFromIndex as bool, : excludeFromIndex as bool,
excludeFromHistory:
excludeFromHistory == const $CopyWithPlaceholder() ||
excludeFromHistory == null
? _value.excludeFromHistory
// ignore: cast_nullable_to_non_nullable
: excludeFromHistory as bool,
bypassGlobalProxy: bypassGlobalProxy:
bypassGlobalProxy == const $CopyWithPlaceholder() || bypassGlobalProxy == const $CopyWithPlaceholder() ||
bypassGlobalProxy == null bypassGlobalProxy == null
@@ -275,6 +289,7 @@ ContainerMetadata _$ContainerMetadataFromJson(Map<String, dynamic> json) =>
), ),
clearDataOnExit: json['clearDataOnExit'] as bool? ?? false, clearDataOnExit: json['clearDataOnExit'] as bool? ?? false,
excludeFromIndex: json['excludeFromIndex'] as bool? ?? false, excludeFromIndex: json['excludeFromIndex'] as bool? ?? false,
excludeFromHistory: json['excludeFromHistory'] as bool? ?? false,
bypassGlobalProxy: json['bypassGlobalProxy'] as bool? ?? false, bypassGlobalProxy: json['bypassGlobalProxy'] as bool? ?? false,
useCustomColor: json['useCustomColor'] as bool? ?? false, useCustomColor: json['useCustomColor'] as bool? ?? false,
assignedSites: (json['assignedSites'] as List<dynamic>?) assignedSites: (json['assignedSites'] as List<dynamic>?)
@@ -293,6 +308,7 @@ Map<String, dynamic> _$ContainerMetadataToJson(
'proxyConnectionId': _proxyConnectionIdToJson(instance.proxyConnectionId), 'proxyConnectionId': _proxyConnectionIdToJson(instance.proxyConnectionId),
'clearDataOnExit': instance.clearDataOnExit, 'clearDataOnExit': instance.clearDataOnExit,
'excludeFromIndex': instance.excludeFromIndex, 'excludeFromIndex': instance.excludeFromIndex,
'excludeFromHistory': instance.excludeFromHistory,
'bypassGlobalProxy': instance.bypassGlobalProxy, 'bypassGlobalProxy': instance.bypassGlobalProxy,
'useCustomColor': instance.useCustomColor, 'useCustomColor': instance.useCustomColor,
'assignedSites': instance.assignedSites?.map((e) => e.toString()).toList(), 'assignedSites': instance.assignedSites?.map((e) => e.toString()).toList(),
@@ -19,34 +19,79 @@
*/ */
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
/// Dialog to confirm container deletion. /// Result of the delete-container confirmation.
/// Returns true if user confirms deletion, false if cancelled, null if dismissed. class DeleteContainerDecision {
Future<bool?> showDeleteContainerDialog(BuildContext context) { /// Whether the container's browsing history should also be deleted from
return showDialog<bool?>( /// Mozilla Places. When false, the visit→container relation dissolves and the
/// visits are kept (shown as uncontained).
final bool wipeHistory;
const DeleteContainerDecision({required this.wipeHistory});
}
/// Confirm container deletion. Returns the decision on confirm, or `null` if
/// cancelled/dismissed.
Future<DeleteContainerDecision?> showDeleteContainerDialog(
BuildContext context,
) {
return showDialog<DeleteContainerDecision?>(
context: context, context: context,
builder: (BuildContext context) { builder: (context) => const _DeleteContainerDialog(),
);
}
class _DeleteContainerDialog extends HookWidget {
const _DeleteContainerDialog();
@override
Widget build(BuildContext context) {
final wipeHistory = useState(false);
return AlertDialog( return AlertDialog(
icon: const Icon(Icons.warning), icon: const Icon(Icons.warning),
title: const Text('Delete Container'), title: const Text('Delete Container'),
content: const Text( content: Column(
'Are you sure you want to delete this container and close all attached tabs?', mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Are you sure you want to delete this container and close all '
'attached tabs?',
),
const SizedBox(height: 8),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
value: wipeHistory.value,
onChanged: (value) {
wipeHistory.value = value ?? false;
},
title: const Text("Also delete this container's history"),
subtitle: const Text(
'Otherwise it is kept and shown as uncontained',
),
),
],
), ),
actions: <Widget>[ actions: <Widget>[
TextButton( TextButton(
onPressed: () { onPressed: () {
Navigator.pop(context, false); Navigator.pop(context, null);
}, },
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
TextButton( TextButton(
onPressed: () { onPressed: () {
Navigator.pop(context, true); Navigator.pop(
context,
DeleteContainerDecision(wipeHistory: wipeHistory.value),
);
}, },
child: const Text('Delete'), child: const Text('Delete'),
), ),
], ],
); );
}, }
);
} }
@@ -26,6 +26,7 @@ import 'package:flutter_material_design_icons/flutter_material_design_icons.dart
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/uuid.dart'; import 'package:weblibre/core/uuid.dart';
import 'package:weblibre/features/geckoview/features/history/domain/repositories/container_history.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/controllers/container_topic.dart'; import 'package:weblibre/features/geckoview/features/tabs/presentation/controllers/container_topic.dart';
@@ -100,6 +101,9 @@ class ContainerEditScreen extends HookConsumerWidget {
final excludeFromIndex = useState( final excludeFromIndex = useState(
initialContainer.metadata.excludeFromIndex, initialContainer.metadata.excludeFromIndex,
); );
final excludeFromHistory = useState(
initialContainer.metadata.excludeFromHistory,
);
final bypassGlobalProxy = useState( final bypassGlobalProxy = useState(
initialContainer.metadata.bypassGlobalProxy, initialContainer.metadata.bypassGlobalProxy,
); );
@@ -126,13 +130,18 @@ class ContainerEditScreen extends HookConsumerWidget {
clearDataOnExit: clearDataOnExit:
clearDataOnExit.value && contextualIdentity.value != null, clearDataOnExit.value && contextualIdentity.value != null,
excludeFromIndex: excludeFromIndex.value, excludeFromIndex: excludeFromIndex.value,
// Requires a Gecko contextId: the native delegate can't hard-exclude
// a container's visits without one. sanitized() enforces the same
// invariant defensively on write.
excludeFromHistory:
excludeFromHistory.value && contextualIdentity.value != null,
bypassGlobalProxy: bypassGlobalProxy:
contextualIdentity.value != null && contextualIdentity.value != null &&
proxyConnectionId.value == null && proxyConnectionId.value == null &&
bypassGlobalProxy.value, bypassGlobalProxy.value,
useCustomColor: useCustomColor.value, useCustomColor: useCustomColor.value,
assignedSites: assignedSites.value, assignedSites: assignedSites.value,
), ).sanitized(),
); );
} }
@@ -234,7 +243,16 @@ class ContainerEditScreen extends HookConsumerWidget {
Future<void> deleteContainer() async { Future<void> deleteContainer() async {
final result = await showDeleteContainerDialog(context); final result = await showDeleteContainerDialog(context);
if (result == true) { if (result != null) {
// Delete the container's Places visits BEFORE the container itself so
// the relation rows still exist to find them; deleting the container
// then dissolves the relations via ON DELETE CASCADE.
if (result.wipeHistory) {
await ref
.read(containerHistoryRepositoryProvider.notifier)
.deletePlacesVisitsForContainer(initialContainer.id);
}
await ref await ref
.read(containerRepositoryProvider.notifier) .read(containerRepositoryProvider.notifier)
.deleteContainer(initialContainer.id); .deleteContainer(initialContainer.id);
@@ -541,6 +559,24 @@ class ContainerEditScreen extends HookConsumerWidget {
excludeFromIndex.value = value; excludeFromIndex.value = value;
}, },
), ),
const Divider(height: 1, indent: 56),
SwitchListTile.adaptive(
value:
contextualIdentity.value != null &&
excludeFromHistory.value,
title: const Text('Exclude from History'),
subtitle: Text(
contextualIdentity.value != null
? "Don't record this container's browsing history"
: 'Requires cookie isolation to be enabled',
),
secondary: const Icon(MdiIcons.incognito),
onChanged: (contextualIdentity.value != null)
? (value) {
excludeFromHistory.value = value;
}
: null,
),
], ],
), ),
), ),
@@ -34,7 +34,7 @@ final class EngineSettingsRepositoryProvider
} }
String _$engineSettingsRepositoryHash() => String _$engineSettingsRepositoryHash() =>
r'03cfe93b6d4ac8cdb0b33627f4baa75240bff745'; r'db077d5aa6a7aec5cdb019308cc942079567bab9';
abstract class _$EngineSettingsRepository abstract class _$EngineSettingsRepository
extends $StreamNotifier<EngineSettings> { extends $StreamNotifier<EngineSettings> {
+37 -1
View File
@@ -29,7 +29,11 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart' import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
show GeckoBrowserService, GeckoLoggingService, LogLevel; show
GeckoBrowserService,
GeckoEngineSettingsService,
GeckoLoggingService,
LogLevel;
import 'package:home_widget/home_widget.dart'; import 'package:home_widget/home_widget.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
@@ -47,8 +51,11 @@ import 'package:weblibre/domain/services/app_initialization.dart';
import 'package:weblibre/domain/services/display_mode.dart'; import 'package:weblibre/domain/services/display_mode.dart';
import 'package:weblibre/features/account/domain/services/account_callback_handler.dart'; import 'package:weblibre/features/account/domain/services/account_callback_handler.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/services/engine_settings_replication.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/services/engine_settings_replication.dart';
import 'package:weblibre/features/geckoview/features/history/domain/services/history_exclusion_replication.dart';
import 'package:weblibre/features/geckoview/features/history/domain/services/visit_container_recorder.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/domain/services/url_cleaner_catalog_service.dart'; import 'package:weblibre/features/geckoview/features/open_link_tools/domain/services/url_cleaner_catalog_service.dart';
import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_observer.dart'; import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_observer.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/services/local_index_pruner.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/services/local_index_pruner.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/services/local_index_settings_sync.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/services/local_index_settings_sync.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_logs.dart'; import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_logs.dart';
@@ -235,6 +242,35 @@ class _MainWidget extends HookConsumerWidget {
final clearStartupUBlockFilterListsPref = final clearStartupUBlockFilterListsPref =
!engineSettings.ublockFilterListSettings.enabled; !engineSettings.ublockFilterListSettings.enabled;
// Push the hard exclude-from-history contextId set to native BEFORE the
// engine starts — it records visits as soon as restored tabs load, so an
// excluded ("incognito") container could otherwise leak to Places during
// the startup window. Best-effort: on failure the keepAlive provider still
// pushes once containers are available.
try {
final availableContainers = await ref
.read(containerRepositoryProvider.notifier)
.getAllContainersWithCount();
await GeckoEngineSettingsService().setExcludedHistoryContextIds(
excludedHistoryContextIds(availableContainers),
);
} catch (e, s) {
logger.w(
'Failed initial history-exclusion push at startup',
error: e,
stackTrace: s,
);
}
// Register the visit→container recorder BEFORE the engine starts. It only
// installs a Dart-side GeckoHistoryEvents handler (no native dependency),
// while native visit events can already fire as restored tabs load during
// initialize — Pigeon FlutterApi messages aren't buffered, so a recorder
// registered afterwards would silently drop those early visits' container
// relations.
ref.read(visitContainerRecorderProvider);
try { try {
await GeckoBrowserService().initialize( await GeckoBrowserService().initialize(
filesystem.relativeProfilePath, filesystem.relativeProfilePath,
@@ -17,6 +17,7 @@ import 'schema_v11.dart' as v11;
import 'schema_v12.dart' as v12; import 'schema_v12.dart' as v12;
import 'schema_v13.dart' as v13; import 'schema_v13.dart' as v13;
import 'schema_v14.dart' as v14; import 'schema_v14.dart' as v14;
import 'schema_v15.dart' as v15;
class GeneratedHelper implements SchemaInstantiationHelper { class GeneratedHelper implements SchemaInstantiationHelper {
@override @override
@@ -48,10 +49,27 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v13.DatabaseAtV13(db); return v13.DatabaseAtV13(db);
case 14: case 14:
return v14.DatabaseAtV14(db); return v14.DatabaseAtV14(db);
case 15:
return v15.DatabaseAtV15(db);
default: default:
throw MissingSchemaException(version, versions); throw MissingSchemaException(version, versions);
} }
} }
static const versions = const [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]; static const versions = const [
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
];
} }
File diff suppressed because it is too large Load Diff
@@ -7,8 +7,10 @@
package eu.weblibre.flutter_mozilla_components package eu.weblibre.flutter_mozilla_components
import eu.weblibre.flutter_mozilla_components.api.GeckoBrowserApiImpl import eu.weblibre.flutter_mozilla_components.api.GeckoBrowserApiImpl
import eu.weblibre.flutter_mozilla_components.api.GeckoEngineSettingsApiImpl
import eu.weblibre.flutter_mozilla_components.feature.SandboxCaptureFeature import eu.weblibre.flutter_mozilla_components.feature.SandboxCaptureFeature
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoBrowserApi import eu.weblibre.flutter_mozilla_components.pigeons.GeckoBrowserApi
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoEngineSettingsApi
import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityAware
@@ -23,10 +25,25 @@ class FlutterMozillaComponentsPlugin: FlutterPlugin, ActivityAware {
browserApi.attachBinding(flutterPluginBinding) browserApi.attachBinding(flutterPluginBinding)
GeckoBrowserApi.setUp(flutterPluginBinding.binaryMessenger, browserApi) GeckoBrowserApi.setUp(flutterPluginBinding.binaryMessenger, browserApi)
SandboxCaptureFeature.wireFlutterEvents(flutterPluginBinding.binaryMessenger) SandboxCaptureFeature.wireFlutterEvents(flutterPluginBinding.binaryMessenger)
// Register the engine-settings API at attach time (before GeckoBrowserService
// .initialize) so Dart can push the history-exclusion contextId set to native
// *before* the engine starts recording restored-tab visits, closing the
// startup window where an excluded container could leak to Places.
// setExcludedHistoryContextIds only writes GlobalComponents state and needs
// no initialized components; the remaining settings methods resolve
// components lazily and are not invoked until after initialize. The same
// instance is reused by GeckoBrowserApiImpl.initialize.
val engineSettingsApiImpl = GeckoEngineSettingsApiImpl(
flutterPluginBinding.applicationContext,
)
GeckoEngineSettingsApi.setUp(flutterPluginBinding.binaryMessenger, engineSettingsApiImpl)
GlobalComponents.engineSettingsApi = engineSettingsApiImpl
} }
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
SandboxCaptureFeature.detachFlutterEvents(binding.binaryMessenger) SandboxCaptureFeature.detachFlutterEvents(binding.binaryMessenger)
GlobalComponents.historyEvents = null
} }
override fun onAttachedToActivity(binding: ActivityPluginBinding) { override fun onAttachedToActivity(binding: ActivityPluginBinding) {
@@ -8,6 +8,8 @@ package eu.weblibre.flutter_mozilla_components
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import eu.weblibre.flutter_mozilla_components.pigeons.AddonCollection import eu.weblibre.flutter_mozilla_components.pigeons.AddonCollection
import eu.weblibre.flutter_mozilla_components.pigeons.BrowserExtensionEvents import eu.weblibre.flutter_mozilla_components.pigeons.BrowserExtensionEvents
import eu.weblibre.flutter_mozilla_components.pigeons.BounceTrackingProtectionMode import eu.weblibre.flutter_mozilla_components.pigeons.BounceTrackingProtectionMode
@@ -15,6 +17,7 @@ import eu.weblibre.flutter_mozilla_components.pigeons.ContentBlocking
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonEvents 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.GeckoGestureEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoGestureEvents
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.GeckoStateEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSuggestionEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSuggestionEvents
@@ -59,6 +62,8 @@ private const val HISTORY_METADATA_MAX_AGE_IN_MS = 14L * 24 * 60 * 60 * 1000 //
private const val DEFAULT_QUERY_PARAMETER_STRIPPING_STRIP_LIST = private const val DEFAULT_QUERY_PARAMETER_STRIPPING_STRIP_LIST =
"__hsfp __hssc __hstc __s _bhlid _branch_match_id _branch_referrer _gl _hsenc _kx _openstat at_recipient_id at_recipient_list bbeml bsft_clkid bsft_uid dclid et_rid fb_action_ids fb_comment_id fbclid gbraid gclid guce_referrer guce_referrer_sig hsCtaTracking igshid irclickid mc_eid mkt_tok ml_subscriber ml_subscriber_hash msclkid mtm_cid oft_c oft_ck oft_d oft_id oft_ids oft_k oft_lk oft_sk oly_anon_id oly_enc_id pk_cid rb_clickid s_cid sc_customer sc_eh sc_uid sms_click sms_source sms_uph srsltid ss_email_id syclid ttclid twclid unicorn_click_id vero_conv vero_id vgo_ee wbraid wickedid yclid ymclid ysclid" "__hsfp __hssc __hstc __s _bhlid _branch_match_id _branch_referrer _gl _hsenc _kx _openstat at_recipient_id at_recipient_list bbeml bsft_clkid bsft_uid dclid et_rid fb_action_ids fb_comment_id fbclid gbraid gclid guce_referrer guce_referrer_sig hsCtaTracking igshid irclickid mc_eid mkt_tok ml_subscriber ml_subscriber_hash msclkid mtm_cid oft_c oft_ck oft_d oft_id oft_ids oft_k oft_lk oft_sk oly_anon_id oly_enc_id pk_cid rb_clickid s_cid sc_customer sc_eh sc_uid sms_click sms_source sms_uph srsltid ss_email_id syclid ttclid twclid unicorn_click_id vero_conv vero_id vgo_ee wbraid wickedid yclid ymclid ysclid"
private const val UBLOCK_FILTER_LISTS_PREF = "browser.weblibre.uBO.filterLists" private const val UBLOCK_FILTER_LISTS_PREF = "browser.weblibre.uBO.filterLists"
private const val EXCLUDED_HISTORY_CONTEXT_IDS_PREF =
"browser.weblibre.excludedHistoryContextIds"
object GlobalComponents { object GlobalComponents {
private var _components: Components? = null private var _components: Components? = null
@@ -105,6 +110,36 @@ object GlobalComponents {
// GestureRecognizer on the UI thread. // GestureRecognizer on the UI thread.
var gestureEvents: GeckoGestureEvents? = null var gestureEvents: GeckoGestureEvents? = null
// Native -> Dart history visit notifications, consumed by Core's history
// delegate to forward the visit's WebLibre container. Null on the headless
// path (no Flutter engine); the delegate still hard-excludes persisted
// container contextIds but skips Dart relation emits.
var historyEvents: GeckoHistoryEvents? = null
// Gecko contextIds of containers with hard exclude-from-history enabled.
// Pushed from Dart; read by WebLibreHistoryDelegate to skip the Places
// write for visits resolved to one of these containers.
@Volatile
var excludedHistoryContextIds: Set<String> = emptySet()
fun setExcludedHistoryContextIds(context: Context?, contextIds: Collection<String>) {
val contextIdSet = contextIds.toSet()
excludedHistoryContextIds = contextIdSet
if (context != null) {
PreferenceManager.getDefaultSharedPreferences(context).edit {
putStringSet(EXCLUDED_HISTORY_CONTEXT_IDS_PREF, contextIdSet)
}
}
}
fun loadExcludedHistoryContextIds(context: Context) {
excludedHistoryContextIds = PreferenceManager.getDefaultSharedPreferences(context)
.getStringSet(EXCLUDED_HISTORY_CONTEXT_IDS_PREF, emptySet())
.orEmpty()
.toSet()
}
@Volatile @Volatile
var gestureConfig: GestureConfig? = null var gestureConfig: GestureConfig? = null
@@ -461,6 +496,9 @@ object GlobalComponents {
val profileContext = ProfileContext(baseContext.applicationContext, profileFolder) val profileContext = ProfileContext(baseContext.applicationContext, profileFolder)
val messenger = NoopBinaryMessenger() val messenger = NoopBinaryMessenger()
loadExcludedHistoryContextIds(baseContext.applicationContext)
historyEvents = null
val selectionActionEvents = GeckoSelectionActionEvents(messenger) val selectionActionEvents = GeckoSelectionActionEvents(messenger)
val selectionActionDelegate = DefaultSelectionActionDelegate(selectionActionEvents) { actions -> val selectionActionDelegate = DefaultSelectionActionDelegate(selectionActionEvents) { actions ->
val processTextAction = "android.intent.action.PROCESS_TEXT" val processTextAction = "android.intent.action.PROCESS_TEXT"
@@ -493,7 +531,7 @@ object GlobalComponents {
mode = ComponentsMode.EXTERNAL, mode = ComponentsMode.EXTERNAL,
) )
engineSettingsApi = GeckoEngineSettingsApiImpl() engineSettingsApi = GeckoEngineSettingsApiImpl(baseContext.applicationContext)
return true return true
} }
@@ -49,6 +49,7 @@ import eu.weblibre.flutter_mozilla_components.pigeons.GeckoMlApi
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPrefApi import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPrefApi
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.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
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents
@@ -265,6 +266,12 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
val syncStateEvents = GeckoSyncStateEvents(_flutterPluginBinding.binaryMessenger) val syncStateEvents = GeckoSyncStateEvents(_flutterPluginBinding.binaryMessenger)
// Set before GlobalComponents.setUp (which lazily builds the engine and
// its history delegate) so Core can wrap the delegate to forward the
// visit's WebLibre container to Dart.
GlobalComponents.historyEvents =
GeckoHistoryEvents(_flutterPluginBinding.binaryMessenger)
GlobalComponents.setUp( GlobalComponents.setUp(
profileApplicationContext, profileApplicationContext,
_flutterEvents, _flutterEvents,
@@ -281,12 +288,20 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
syncTokenServerOverride, syncTokenServerOverride,
) )
val engineSettingsApiImpl = GeckoEngineSettingsApiImpl() // GeckoEngineSettingsApi was already registered at plugin-attach time
// (FlutterMozillaComponentsPlugin.onAttachedToEngine) so the startup
// history-exclusion push lands before the engine starts. Reuse that
// instance; only register a fresh one if attach somehow did not run.
if (GlobalComponents.engineSettingsApi == null) {
val engineSettingsApiImpl = GeckoEngineSettingsApiImpl(
_flutterPluginBinding.applicationContext,
)
GeckoEngineSettingsApi.setUp( GeckoEngineSettingsApi.setUp(
_flutterPluginBinding.binaryMessenger, _flutterPluginBinding.binaryMessenger,
engineSettingsApiImpl engineSettingsApiImpl
) )
GlobalComponents.engineSettingsApi = engineSettingsApiImpl GlobalComponents.engineSettingsApi = engineSettingsApiImpl
}
GeckoAddonsApi.setUp( GeckoAddonsApi.setUp(
_flutterPluginBinding.binaryMessenger, _flutterPluginBinding.binaryMessenger,
GeckoAddonsApiImpl(profileApplicationContext) GeckoAddonsApiImpl(profileApplicationContext)
@@ -6,6 +6,7 @@
package eu.weblibre.flutter_mozilla_components.api package eu.weblibre.flutter_mozilla_components.api
import android.content.Context
import androidx.core.content.edit import androidx.core.content.edit
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import eu.weblibre.flutter_mozilla_components.ColorSchemePreference import eu.weblibre.flutter_mozilla_components.ColorSchemePreference
@@ -74,7 +75,9 @@ internal fun TrackingProtectionPolicy.withBounceTrackingProtectionMode(
/** /**
* Implementation of GeckoEngineSettingsApi that manages engine-specific settings * Implementation of GeckoEngineSettingsApi that manages engine-specific settings
*/ */
class GeckoEngineSettingsApiImpl : GeckoEngineSettingsApi { class GeckoEngineSettingsApiImpl(
private val applicationContext: Context? = null,
) : GeckoEngineSettingsApi {
companion object { companion object {
private const val TAG = "GeckoEngineSettingsApi" private const val TAG = "GeckoEngineSettingsApi"
} }
@@ -515,4 +518,8 @@ class GeckoEngineSettingsApiImpl : GeckoEngineSettingsApi {
activeReaderSessions, activeReaderSessions,
) )
} }
override fun setExcludedHistoryContextIds(contextIds: List<String>) {
GlobalComponents.setExcludedHistoryContextIds(applicationContext, contextIds)
}
} }
@@ -18,6 +18,7 @@ import eu.weblibre.flutter_mozilla_components.services.DownloadService
import eu.weblibre.flutter_mozilla_components.EngineProvider import eu.weblibre.flutter_mozilla_components.EngineProvider
import eu.weblibre.flutter_mozilla_components.EngineProvider.getOrCreateRuntime import eu.weblibre.flutter_mozilla_components.EngineProvider.getOrCreateRuntime
import eu.weblibre.flutter_mozilla_components.GlobalComponents import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.history.WebLibreHistoryDelegate
import eu.weblibre.flutter_mozilla_components.PermissionStorage import eu.weblibre.flutter_mozilla_components.PermissionStorage
import eu.weblibre.flutter_mozilla_components.services.MediaSessionService import eu.weblibre.flutter_mozilla_components.services.MediaSessionService
import eu.weblibre.flutter_mozilla_components.activities.NotificationActivity import eu.weblibre.flutter_mozilla_components.activities.NotificationActivity
@@ -26,6 +27,7 @@ import eu.weblibre.flutter_mozilla_components.ext.getPreferenceKey
import eu.weblibre.flutter_mozilla_components.middleware.FlutterEventMiddleware import eu.weblibre.flutter_mozilla_components.middleware.FlutterEventMiddleware
import eu.weblibre.flutter_mozilla_components.middleware.HistoryMetadataMiddleware import eu.weblibre.flutter_mozilla_components.middleware.HistoryMetadataMiddleware
import eu.weblibre.flutter_mozilla_components.middleware.HistoryMetadataService import eu.weblibre.flutter_mozilla_components.middleware.HistoryMetadataService
import eu.weblibre.flutter_mozilla_components.middleware.HistoryVisitCorrelationMiddleware
import eu.weblibre.flutter_mozilla_components.middleware.SandboxCaptureMiddleware import eu.weblibre.flutter_mozilla_components.middleware.SandboxCaptureMiddleware
import eu.weblibre.flutter_mozilla_components.middleware.SaveToPDFMiddleware import eu.weblibre.flutter_mozilla_components.middleware.SaveToPDFMiddleware
import eu.weblibre.flutter_mozilla_components.pigeons.BrowserExtensionEvents import eu.weblibre.flutter_mozilla_components.pigeons.BrowserExtensionEvents
@@ -99,9 +101,14 @@ class Core(
val engineSettings by lazy { val engineSettings by lazy {
DefaultSettings( DefaultSettings(
//historyTrackingDelegate = HistoryDelegate(lazyHistoryStorage)
requestInterceptor = requestInterceptor, requestInterceptor = requestInterceptor,
historyTrackingDelegate = HistoryDelegate(lazyHistoryStorage), // Wrap the Places-feeding delegate so WebLibre can hard-exclude
// container visits even on the headless external path. When Flutter
// is available, the wrapper also emits visit→container relations.
historyTrackingDelegate = WebLibreHistoryDelegate(
HistoryDelegate(lazyHistoryStorage),
GlobalComponents.historyEvents,
),
testingModeEnabled = false, testingModeEnabled = false,
remoteDebuggingEnabled = false, remoteDebuggingEnabled = false,
automaticFontSizeAdjustment = true, automaticFontSizeAdjustment = true,
@@ -225,6 +232,9 @@ class Core(
// sandbox new-tab URLs before Gecko issues a request. // sandbox new-tab URLs before Gecko issues a request.
SandboxCaptureMiddleware, SandboxCaptureMiddleware,
HistoryMetadataMiddleware(historyMetadataService), HistoryMetadataMiddleware(historyMetadataService),
// Correlates url -> contextId so WebLibreHistoryDelegate can
// resolve a visit's container at record time.
HistoryVisitCorrelationMiddleware(),
FlutterEventMiddleware(flutterEvents), FlutterEventMiddleware(flutterEvents),
DownloadMiddleware( DownloadMiddleware(
applicationContext = context, applicationContext = context,
@@ -0,0 +1,80 @@
package eu.weblibre.flutter_mozilla_components.history
/**
* Short-lived `url -> contextId` correlation captured at navigation time.
*
* Android Components strips the session before it reaches
* [mozilla.components.concept.engine.history.HistoryTrackingDelegate.onVisited],
* so the delegate only sees the visited URL not which tab (and therefore which
* Gecko contextual identity / WebLibre container) produced it. The
* [eu.weblibre.flutter_mozilla_components.middleware.HistoryVisitCorrelationMiddleware]
* records the navigating tab's contextId here on every `UpdateUrlAction`; the
* delegate reads it back when the matching visit is recorded moments later.
*
* This is the primary signal: the delegate trusts a live correlation before
* falling back to the selected tab (see
* [eu.weblibre.flutter_mozilla_components.history.WebLibreHistoryDelegate]). The
* TTL is deliberately short a record and its visit are milliseconds apart, so
* anything older is an orphan (e.g. recorded on a reload that fired its
* `onVisited` before the record) and must expire quickly, or it can leak onto a
* later same-URL visit in a different container.
*/
object HistoryVisitCorrelationCache {
private const val TTL_MS = 3_000L
private const val MAX_ENTRIES = 64
private data class Entry(val contextId: String?, val timestamp: Long)
// Insertion-ordered so we can drop the oldest entry when over capacity.
private val entries = LinkedHashMap<String, Entry>()
/** Record the [contextId] (nullable = uncontained tab) that navigated to [url]. */
@Synchronized
fun record(url: String, contextId: String?) {
val now = System.currentTimeMillis()
evictExpired(now)
// Re-insert so the most recent navigation to a URL wins and stays newest.
entries.remove(url)
entries[url] = Entry(contextId, now)
while (entries.size > MAX_ENTRIES) {
val oldest = entries.keys.firstOrNull() ?: break
entries.remove(oldest)
}
}
/** A live correlation for a URL: present in the cache, its [contextId]
* possibly null (the producing tab was uncontained). Distinct from a cache
* miss (`resolve` returns null), so the caller can tell a known-uncontained
* navigation apart from an unknown one. */
data class Resolution(val contextId: String?)
/**
* The correlation most recently recorded for [url] within the TTL, or null
* if there is no live entry. A non-null [Resolution] with a null contextId
* means the producing tab was uncontained the caller must treat that as an
* authoritative "uncontained" answer, NOT as a miss, so it does not fall
* back to a less specific signal (e.g. a merely-loading foreground tab).
*
* Consume-on-read: the entry is removed so a stale correlation can NEVER be
* reused for a later, unrelated visit of the same URL e.g. a page first
* visited in a container and later reopened uncontained (or in a different
* container) must not inherit the earlier container. A repeat navigation
* re-records it via the middleware.
*/
@Synchronized
fun resolve(url: String): Resolution? {
val now = System.currentTimeMillis()
evictExpired(now)
val entry = entries.remove(url) ?: return null
return Resolution(entry.contextId)
}
private fun evictExpired(now: Long) {
val iterator = entries.entries.iterator()
while (iterator.hasNext()) {
if (now - iterator.next().value.timestamp > TTL_MS) {
iterator.remove()
}
}
}
}
@@ -0,0 +1,150 @@
package eu.weblibre.flutter_mozilla_components.history
import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoHistoryEvents
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import mozilla.components.browser.state.selector.selectedTab
import mozilla.components.concept.engine.history.HistoryTrackingDelegate
import mozilla.components.concept.storage.PageVisit
/**
* Wraps the Android Components [HistoryTrackingDelegate] (which feeds Mozilla
* Places / FxA sync) and, on each recorded visit, additionally notifies Dart of
* the WebLibre container that produced it.
*
* Mozilla Places stays the source of truth for the visit itself (url, title,
* visit type, visit time); WebLibre only needs the one thing Places can't store:
* which container the visit belonged to. Places strips the session before
* [onVisited], so the container is recovered from the `url -> contextId`
* correlation cache populated by
* [eu.weblibre.flutter_mozilla_components.middleware.HistoryVisitCorrelationMiddleware]
* at navigation time; Dart maps the contextId to a WebLibre container and writes
* the visitcontainer relation row.
*
* Purely additive for Places/sync: every call delegates to [wrapped]. The one
* exception is hard exclude-from-history for a container opted out of history
* recording, the Places write is skipped too so the visit never lands anywhere.
*/
class WebLibreHistoryDelegate(
private val wrapped: HistoryTrackingDelegate,
private val events: GeckoHistoryEvents?,
) : HistoryTrackingDelegate by wrapped {
override suspend fun onVisited(uri: String, visit: PageVisit) {
// Resolve which container produced this visit once, and reuse it for
// both the hard-exclude decision and the Dart relation emit.
val resolution = resolveContextId(uri)
// Hard exclude-from-history: for a visit whose container has
// exclude-from-history enabled, skip BOTH the Places write and the Dart
// relation emit — the visit must not land in either store.
if (isHistoryExcluded(uri, resolution)) {
return
}
wrapped.onVisited(uri, visit)
// Timestamp near the Places record time; Dart joins to the actual Places
// visit by (url, nearest visit_time), tolerating the small skew. Best
// effort for tagging: even the non-authoritative guess is emitted, since
// a wrong tag is recoverable (unlike the exclude decision above).
val visitTime = System.currentTimeMillis()
// Flutter's binary messenger must be used on the platform (main) thread.
if (events != null) {
withContext(Dispatchers.Main) {
events.onVisitRecorded(uri, visitTime, resolution.contextId) {}
}
}
}
/**
* Decide whether this visit belongs to a hard exclude-from-history
* ("incognito") container and must therefore land nowhere.
*
* Fails **closed**: unlike tagging (where a missing tag is recoverable),
* leaking an excluded container's visit to Places is not, so any ambiguity
* that could involve an excluded container is resolved as "excluded".
*
* - When [resolution] is authoritative (steps 12 pinned the producing tab),
* trust it: excluded iff that tab's contextId is in the excluded set.
* - Otherwise (the last-resort step-3 guess or a full miss), scan open tabs:
* if any non-private tab is currently on this exact URL in an excluded
* container, the visit may have originated there drop it. This closes the
* window where a background-tab visit's correlation was evicted/expired.
*/
private fun isHistoryExcluded(uri: String, resolution: ContextResolution): Boolean {
val excluded = GlobalComponents.excludedHistoryContextIds
if (excluded.isEmpty()) {
return false
}
if (resolution.authoritative) {
return resolution.contextId != null && resolution.contextId in excluded
}
val tabs = GlobalComponents.components?.core?.store?.state?.tabs ?: return false
return tabs.any { tab ->
!tab.content.private &&
tab.content.url == uri &&
tab.contextId != null &&
tab.contextId in excluded
}
}
/**
* The producing Gecko [contextId] for a visit, plus whether it was pinned
* [authoritative]ly (a definite producer) or is only a best-effort guess.
*/
private data class ContextResolution(
val contextId: String?,
val authoritative: Boolean,
)
/**
* Resolve the visited [uri] to the Gecko contextId that produced it.
*
* Resolution order, most specific first:
*
* 1. The per-navigation correlation recorded by
* [eu.weblibre.flutter_mozilla_components.middleware.HistoryVisitCorrelationMiddleware]
* for the tab that actually navigated to [uri]. This correctly attributes
* a **background-tab** visit and crucially stops a loading foreground
* tab from stealing another tab's same-URL visit. A live
* [HistoryVisitCorrelationCache.Resolution] (even with a null contextId,
* i.e. known-uncontained) is authoritative.
* 2. On a cache miss, the selected tab only when it is **actively loading
* exactly [uri]** (`loading && url == uri`). With no recorded competing
* navigation, it is the best live producer signal. Authoritative.
* 3. Otherwise, the loading selected tab as a last resort: redirect chains
* can fire `onVisited(uri)` before the selected tab's url settles on [uri]
* and before the middleware records it. Marked **not** authoritative it
* may mis-attribute a background-tab visit, so the exclude decision does
* not trust it and re-checks via [isHistoryExcluded].
*
* A full miss returns a non-authoritative uncontained (null) result.
*/
private fun resolveContextId(uri: String): ContextResolution {
val selected = GlobalComponents.components?.core?.store?.state?.selectedTab
val correlation = HistoryVisitCorrelationCache.resolve(uri)
if (correlation != null) {
return ContextResolution(correlation.contextId, authoritative = true)
}
if (selected != null && !selected.content.private &&
selected.content.loading && selected.content.url == uri
) {
return ContextResolution(selected.contextId, authoritative = true)
}
if (selected != null && !selected.content.private &&
selected.content.loading
) {
return ContextResolution(selected.contextId, authoritative = false)
}
return ContextResolution(null, authoritative = false)
}
}
@@ -6,6 +6,7 @@
package eu.weblibre.flutter_mozilla_components.middleware package eu.weblibre.flutter_mozilla_components.middleware
import eu.weblibre.flutter_mozilla_components.GlobalComponents
import mozilla.components.browser.state.action.BrowserAction import mozilla.components.browser.state.action.BrowserAction
import mozilla.components.browser.state.action.ContentAction import mozilla.components.browser.state.action.ContentAction
import mozilla.components.browser.state.action.HistoryMetadataAction import mozilla.components.browser.state.action.HistoryMetadataAction
@@ -102,13 +103,30 @@ class HistoryMetadataMiddleware(
store: Store<BrowserState, BrowserAction>, store: Store<BrowserState, BrowserAction>,
tab: TabSessionState, tab: TabSessionState,
) { ) {
// Hard exclude-from-history: a tab in an excluded ("incognito") container
// must not persist anywhere in Places. WebLibreHistoryDelegate already
// skips the visit write; metadata is a separate persistent Places path
// (highlights / suggestions), so it must be gated on the same set.
if (isHistoryExcluded(tab)) {
return
}
val key = historyMetadataService.createMetadata(tab) val key = historyMetadataService.createMetadata(tab)
store.dispatch(HistoryMetadataAction.SetHistoryMetadataKeyAction(tab.id, key)) store.dispatch(HistoryMetadataAction.SetHistoryMetadataKeyAction(tab.id, key))
} }
private fun updateHistoryMetadata(tab: TabSessionState) { private fun updateHistoryMetadata(tab: TabSessionState) {
if (isHistoryExcluded(tab)) {
return
}
tab.historyMetadata?.let { tab.historyMetadata?.let {
historyMetadataService.updateMetadata(it, tab) historyMetadataService.updateMetadata(it, tab)
} }
} }
private fun isHistoryExcluded(tab: TabSessionState): Boolean {
val contextId = tab.contextId ?: return false
return contextId in GlobalComponents.excludedHistoryContextIds
}
} }
@@ -0,0 +1,48 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.middleware
import eu.weblibre.flutter_mozilla_components.history.HistoryVisitCorrelationCache
import mozilla.components.browser.state.action.BrowserAction
import mozilla.components.browser.state.action.ContentAction
import mozilla.components.browser.state.selector.findCustomTab
import mozilla.components.browser.state.selector.findNormalTab
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.lib.state.Middleware
import mozilla.components.lib.state.Store
/**
* Records a short-lived `url -> contextId` correlation whenever a normal tab or
* custom tab/PWA URL changes, so
* [eu.weblibre.flutter_mozilla_components.history.WebLibreHistoryDelegate] can
* resolve the tab's Gecko contextual identity (and hence its WebLibre container)
* at visit time the delegate itself only receives the visited URL.
*
* See [HistoryVisitCorrelationCache].
*/
class HistoryVisitCorrelationMiddleware :
Middleware<BrowserState, BrowserAction> {
override fun invoke(
store: Store<BrowserState, BrowserAction>,
next: (BrowserAction) -> Unit,
action: BrowserAction,
) {
next(action)
if (action is ContentAction.UpdateUrlAction) {
val normalTab = store.state.findNormalTab(action.sessionId)
if (normalTab != null) {
HistoryVisitCorrelationCache.record(action.url, normalTab.contextId)
return
}
store.state.findCustomTab(action.sessionId)?.let { customTab ->
HistoryVisitCorrelationCache.record(action.url, customTab.contextId)
}
}
}
}
@@ -7305,6 +7305,13 @@ interface GeckoEngineSettingsApi {
* cold-started reader view resolves the right value before Flutter runs. * cold-started reader view resolves the right value before Flutter runs.
*/ */
fun setReaderViewPureBlack(enabled: Boolean) fun setReaderViewPureBlack(enabled: Boolean)
/**
* The set of Gecko contextual-identity ids ("container" contextIds) whose
* browsing history must NOT be written to Mozilla Places (hard
* exclude-from-history / "incognito container"). WebLibreHistoryDelegate
* skips the Places write for a visit resolved to one of these containers.
*/
fun setExcludedHistoryContextIds(contextIds: List<String>)
companion object { companion object {
/** The codec used by GeckoEngineSettingsApi. */ /** The codec used by GeckoEngineSettingsApi. */
@@ -7490,6 +7497,24 @@ interface GeckoEngineSettingsApi {
channel.setMessageHandler(null) channel.setMessageHandler(null)
} }
} }
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoEngineSettingsApi.setExcludedHistoryContextIds$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val contextIdsArg = args[0] as List<String>
val wrapped: List<Any?> = try {
api.setExcludedHistoryContextIds(contextIdsArg)
listOf(null)
} catch (exception: Throwable) {
GeckoPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
} }
} }
} }
@@ -10314,6 +10339,46 @@ interface GeckoDeleteBrowsingDataController {
} }
} }
} }
/**
* Native -> Dart history visit notifications. Fired from WebLibreHistoryDelegate
* on each recorded Mozilla Places visit so WebLibre can persist the one thing
* Places can't store: which container the visit belonged to. The visit itself
* (title, visit type, exact time) stays owned by Places.
*
* Generated class from Pigeon that represents Flutter messages that can be called from Kotlin.
*/
class GeckoHistoryEvents(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") {
companion object {
/** The codec used by GeckoHistoryEvents. */
val codec: MessageCodec<Any?> by lazy {
GeckoPigeonCodec()
}
}
/**
* [contextId] is the Gecko contextual identity of the tab that produced the
* visit, resolved via the URLcontextId correlation cache (null when it
* couldn't be resolved / the tab was uncontained). Dart maps it to a
* WebLibre container and writes the visitcontainer relation, keyed on
* ([url], [visitTime]) to join back to the Places visit.
*/
fun onVisitRecorded(urlArg: String, visitTimeArg: Long, contextIdArg: String?, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoHistoryEvents.onVisitRecorded$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(urlArg, visitTimeArg, contextIdArg)) {
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)))
}
}
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ /** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface GeckoHistoryApi { interface GeckoHistoryApi {
fun getDetailedVisits(startMillis: Long, endMillis: Long, excludeTypes: List<VisitType>, callback: (Result<List<VisitInfo>>) -> Unit) fun getDetailedVisits(startMillis: Long, endMillis: Long, excludeTypes: List<VisitType>, callback: (Result<List<VisitInfo>>) -> Unit)
@@ -72,6 +72,7 @@ export 'src/pigeons/gecko.g.dart'
GeckoDeleteBrowsingDataController, GeckoDeleteBrowsingDataController,
GeckoEngineSettings, GeckoEngineSettings,
GeckoFetchResponse, GeckoFetchResponse,
GeckoHistoryEvents,
GeckoPref, GeckoPref,
GeckoProxySettings, GeckoProxySettings,
GeckoPublicSuffixListApi, GeckoPublicSuffixListApi,
@@ -236,4 +236,8 @@ class GeckoEngineSettingsService {
Future<void> setReaderViewPureBlack(bool enabled) { Future<void> setReaderViewPureBlack(bool enabled) {
return _api.setReaderViewPureBlack(enabled); return _api.setReaderViewPureBlack(enabled);
} }
Future<void> setExcludedHistoryContextIds(List<String> contextIds) {
return _api.setExcludedHistoryContextIds(contextIds);
}
} }
@@ -46,11 +46,14 @@ class GeckoHistoryService {
case VisitType.redirectTemporary: case VisitType.redirectTemporary:
case VisitType.framedLink: case VisitType.framedLink:
case VisitType.reload: case VisitType.reload:
// A bookmark-type visit is an ordinary Places visit (the user navigated
// via a bookmark); deleting it by (url, time) removes only that visit
// record and leaves the bookmark itself intact — same path as any page
// visit.
case VisitType.bookmark:
return _api.deleteVisit(info.url, info.visitTime); return _api.deleteVisit(info.url, info.visitTime);
case VisitType.download: case VisitType.download:
return _api.deleteDownload(info.contentId!); return _api.deleteDownload(info.contentId!);
case VisitType.bookmark:
throw UnimplementedError('VisitType.bookmark deletion not implemented');
} }
} }
@@ -7421,6 +7421,28 @@ class GeckoEngineSettingsApi {
) )
; ;
} }
/// The set of Gecko contextual-identity ids ("container" contextIds) whose
/// browsing history must NOT be written to Mozilla Places (hard
/// exclude-from-history / "incognito container"). WebLibreHistoryDelegate
/// skips the Places write for a visit resolved to one of these containers.
Future<void> setExcludedHistoryContextIds(List<String> contextIds) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoEngineSettingsApi.setExcludedHistoryContextIds$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[contextIds]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
_extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
)
;
}
} }
class GeckoSessionApi { class GeckoSessionApi {
@@ -10310,6 +10332,48 @@ class GeckoDeleteBrowsingDataController {
} }
} }
/// Native -> Dart history visit notifications. Fired from WebLibreHistoryDelegate
/// on each recorded Mozilla Places visit so WebLibre can persist the one thing
/// Places can't store: which container the visit belonged to. The visit itself
/// (title, visit type, exact time) stays owned by Places.
abstract class GeckoHistoryEvents {
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
/// [contextId] is the Gecko contextual identity of the tab that produced the
/// visit, resolved via the URL→contextId correlation cache (null when it
/// couldn't be resolved / the tab was uncontained). Dart maps it to a
/// WebLibre container and writes the visit→container relation, keyed on
/// ([url], [visitTime]) to join back to the Places visit.
void onVisitRecorded(String url, int visitTime, String? contextId);
static void setUp(GeckoHistoryEvents? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) {
messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
{
final pigeonVar_channel = BasicMessageChannel<Object?>(
'dev.flutter.pigeon.flutter_mozilla_components.GeckoHistoryEvents.onVisitRecorded$messageChannelSuffix', pigeonChannelCodec,
binaryMessenger: binaryMessenger);
if (api == null) {
pigeonVar_channel.setMessageHandler(null);
} else {
pigeonVar_channel.setMessageHandler((Object? message) async {
final List<Object?> args = message! as List<Object?>;
final String arg_url = args[0]! as String;
final int arg_visitTime = args[1]! as int;
final String? arg_contextId = args[2] as String?;
try {
api.onVisitRecorded(arg_url, arg_visitTime, arg_contextId);
return wrapResponse(empty: true);
} on PlatformException catch (e) {
return wrapResponse(error: e);
} catch (e) {
return wrapResponse(error: PlatformException(code: 'error', message: e.toString()));
}
});
}
}
}
}
class GeckoHistoryApi { class GeckoHistoryApi {
/// Constructor for [GeckoHistoryApi]. The [binaryMessenger] named argument is /// Constructor for [GeckoHistoryApi]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default /// available for dependency injection. If it is left null, the default
@@ -1586,6 +1586,12 @@ abstract class GeckoEngineSettingsApi {
/// Mozilla's reader view extension. Persisted in SharedPreferences so a /// Mozilla's reader view extension. Persisted in SharedPreferences so a
/// cold-started reader view resolves the right value before Flutter runs. /// cold-started reader view resolves the right value before Flutter runs.
void setReaderViewPureBlack(bool enabled); void setReaderViewPureBlack(bool enabled);
/// The set of Gecko contextual-identity ids ("container" contextIds) whose
/// browsing history must NOT be written to Mozilla Places (hard
/// exclude-from-history / "incognito container"). WebLibreHistoryDelegate
/// skips the Places write for a visit resolved to one of these containers.
void setExcludedHistoryContextIds(List<String> contextIds);
} }
@HostApi() @HostApi()
@@ -2230,6 +2236,20 @@ enum ClearDataType {
onlyCaches, onlyCaches,
} }
/// Native -> Dart history visit notifications. Fired from WebLibreHistoryDelegate
/// on each recorded Mozilla Places visit so WebLibre can persist the one thing
/// Places can't store: which container the visit belonged to. The visit itself
/// (title, visit type, exact time) stays owned by Places.
@FlutterApi()
abstract class GeckoHistoryEvents {
/// [contextId] is the Gecko contextual identity of the tab that produced the
/// visit, resolved via the URL→contextId correlation cache (null when it
/// couldn't be resolved / the tab was uncontained). Dart maps it to a
/// WebLibre container and writes the visit→container relation, keyed on
/// ([url], [visitTime]) to join back to the Places visit.
void onVisitRecorded(String url, int visitTime, String? contextId);
}
@HostApi() @HostApi()
abstract class GeckoHistoryApi { abstract class GeckoHistoryApi {
@async @async