container history initial
This commit is contained in:
File diff suppressed because it is too large
Load Diff
+3
@@ -49,6 +49,9 @@ class BrowserDataService extends _$BrowserDataService {
|
||||
case DeleteBrowsingDataType.history:
|
||||
await _service.deleteBrowsingHistory();
|
||||
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:
|
||||
await ref
|
||||
.read(bangDataRepositoryProvider.notifier)
|
||||
|
||||
+14
@@ -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/presentation/widgets/browser_home.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/pwa/domain/providers.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(
|
||||
fireImmediately: true,
|
||||
articleContentProcessorServiceProvider,
|
||||
|
||||
+124
@@ -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;
|
||||
}
|
||||
+11
-2
@@ -33,13 +33,22 @@ class HistoryFilterOptions with FastEquatable {
|
||||
final DateTimeRange<DateTime>? dateRange;
|
||||
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()
|
||||
: this(dateRange: null, visitTypes: {VisitType.link});
|
||||
|
||||
@override
|
||||
List<Object?> get hashParameters => [dateRange, visitTypes];
|
||||
List<Object?> get hashParameters => [dateRange, visitTypes, containerId];
|
||||
|
||||
factory HistoryFilterOptions.fromJson(Map<String, dynamic> json) =>
|
||||
_$HistoryFilterOptionsFromJson(json);
|
||||
|
||||
+14
@@ -11,6 +11,8 @@ abstract class _$HistoryFilterOptionsCWProxy {
|
||||
|
||||
HistoryFilterOptions visitTypes(Set<VisitType> visitTypes);
|
||||
|
||||
HistoryFilterOptions containerId(String? containerId);
|
||||
|
||||
/// 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)`.
|
||||
///
|
||||
@@ -21,6 +23,7 @@ abstract class _$HistoryFilterOptionsCWProxy {
|
||||
HistoryFilterOptions call({
|
||||
DateTimeRange<DateTime>? dateRange,
|
||||
Set<VisitType> visitTypes,
|
||||
String? containerId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -40,6 +43,10 @@ class _$HistoryFilterOptionsCWProxyImpl
|
||||
HistoryFilterOptions visitTypes(Set<VisitType> visitTypes) =>
|
||||
call(visitTypes: visitTypes);
|
||||
|
||||
@override
|
||||
HistoryFilterOptions containerId(String? containerId) =>
|
||||
call(containerId: containerId);
|
||||
|
||||
@override
|
||||
/// 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)`.
|
||||
@@ -51,6 +58,7 @@ class _$HistoryFilterOptionsCWProxyImpl
|
||||
HistoryFilterOptions call({
|
||||
Object? dateRange = const $CopyWithPlaceholder(),
|
||||
Object? visitTypes = const $CopyWithPlaceholder(),
|
||||
Object? containerId = const $CopyWithPlaceholder(),
|
||||
}) {
|
||||
return HistoryFilterOptions(
|
||||
dateRange: dateRange == const $CopyWithPlaceholder()
|
||||
@@ -62,6 +70,10 @@ class _$HistoryFilterOptionsCWProxyImpl
|
||||
? _value.visitTypes
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: 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>)
|
||||
.map((e) => $enumDecode(_$VisitTypeEnumMap, e))
|
||||
.toSet(),
|
||||
containerId: json['containerId'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$HistoryFilterOptionsToJson(
|
||||
@@ -94,6 +107,7 @@ Map<String, dynamic> _$HistoryFilterOptionsToJson(
|
||||
) => <String, dynamic>{
|
||||
'dateRange': const DateTimeRangeConverter().toJson(instance.dateRange),
|
||||
'visitTypes': instance.visitTypes.map((e) => _$VisitTypeEnumMap[e]!).toList(),
|
||||
'containerId': instance.containerId,
|
||||
};
|
||||
|
||||
const _$VisitTypeEnumMap = {
|
||||
|
||||
@@ -22,9 +22,14 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:riverpod/experimental/persist.dart';
|
||||
import 'package:riverpod_annotation/experimental/json_persist.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/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/utils/url_canonical.dart';
|
||||
|
||||
part 'providers.g.dart';
|
||||
|
||||
@@ -39,6 +44,10 @@ class HistoryVisitsFilter extends _$HistoryVisitsFilter {
|
||||
}
|
||||
}
|
||||
|
||||
void setContainer(String? containerId) {
|
||||
state = state.copyWith.containerId(containerId);
|
||||
}
|
||||
|
||||
void reset() {
|
||||
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()
|
||||
Future<List<VisitInfo>> browsingHistory(Ref ref) {
|
||||
Future<List<HistoryEntry>> browsingHistory(Ref ref) async {
|
||||
final options = ref.watch(historyVisitsFilterProvider);
|
||||
|
||||
return ref
|
||||
final visits = await ref
|
||||
.read(historyRepositoryProvider.notifier)
|
||||
.getDetailedVisits(options);
|
||||
|
||||
return _annotateVisits(
|
||||
ref.read(tabDatabaseProvider).visitContainerDao,
|
||||
visits,
|
||||
filterContainerId: options.containerId,
|
||||
);
|
||||
}
|
||||
|
||||
@Riverpod()
|
||||
Future<List<VisitInfo>> browsingDownloads(Ref ref) {
|
||||
Future<List<HistoryEntry>> browsingDownloads(Ref ref) async {
|
||||
final options = ref.watch(historyDownloadsFilterProvider);
|
||||
|
||||
return ref
|
||||
final visits = await ref
|
||||
.read(historyRepositoryProvider.notifier)
|
||||
.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() =>
|
||||
r'a4bf6c41c9180166365084cb7c7981558ac7ad36';
|
||||
r'4c1530cfd2d94b1cdbb04fbe2df8037cb211a668';
|
||||
|
||||
@JsonPersist()
|
||||
abstract class _$HistoryVisitsFilterBase
|
||||
@@ -129,11 +129,13 @@ final browsingHistoryProvider = BrowsingHistoryProvider._();
|
||||
final class BrowsingHistoryProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<List<VisitInfo>>,
|
||||
List<VisitInfo>,
|
||||
FutureOr<List<VisitInfo>>
|
||||
AsyncValue<List<HistoryEntry>>,
|
||||
List<HistoryEntry>,
|
||||
FutureOr<List<HistoryEntry>>
|
||||
>
|
||||
with $FutureModifier<List<VisitInfo>>, $FutureProvider<List<VisitInfo>> {
|
||||
with
|
||||
$FutureModifier<List<HistoryEntry>>,
|
||||
$FutureProvider<List<HistoryEntry>> {
|
||||
BrowsingHistoryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
@@ -150,17 +152,17 @@ final class BrowsingHistoryProvider
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<List<VisitInfo>> $createElement(
|
||||
$FutureProviderElement<List<HistoryEntry>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<List<VisitInfo>> create(Ref ref) {
|
||||
FutureOr<List<HistoryEntry>> create(Ref ref) {
|
||||
return browsingHistory(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$browsingHistoryHash() => r'6f26228da28f6f67844551bf430025b161731bb2';
|
||||
String _$browsingHistoryHash() => r'0ed9a3d1f10091eb1a20f1c388c1ace80c1f75da';
|
||||
|
||||
@ProviderFor(browsingDownloads)
|
||||
final browsingDownloadsProvider = BrowsingDownloadsProvider._();
|
||||
@@ -168,11 +170,13 @@ final browsingDownloadsProvider = BrowsingDownloadsProvider._();
|
||||
final class BrowsingDownloadsProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<List<VisitInfo>>,
|
||||
List<VisitInfo>,
|
||||
FutureOr<List<VisitInfo>>
|
||||
AsyncValue<List<HistoryEntry>>,
|
||||
List<HistoryEntry>,
|
||||
FutureOr<List<HistoryEntry>>
|
||||
>
|
||||
with $FutureModifier<List<VisitInfo>>, $FutureProvider<List<VisitInfo>> {
|
||||
with
|
||||
$FutureModifier<List<HistoryEntry>>,
|
||||
$FutureProvider<List<HistoryEntry>> {
|
||||
BrowsingDownloadsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
@@ -189,17 +193,17 @@ final class BrowsingDownloadsProvider
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<List<VisitInfo>> $createElement(
|
||||
$FutureProviderElement<List<HistoryEntry>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<List<VisitInfo>> create(Ref ref) {
|
||||
FutureOr<List<HistoryEntry>> create(Ref ref) {
|
||||
return browsingDownloads(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$browsingDownloadsHash() => r'd05e3c079c6de349ff9e910ca597ad7c78f87a16';
|
||||
String _$browsingDownloadsHash() => r'938ae4d26b4e0a3f428d3d530a71cb5d8720f317';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonGenerator
|
||||
|
||||
+155
@@ -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() {}
|
||||
}
|
||||
+73
@@ -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);
|
||||
}
|
||||
}
|
||||
+50
@@ -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),
|
||||
);
|
||||
}
|
||||
+58
@@ -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';
|
||||
+125
@@ -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));
|
||||
}
|
||||
}
|
||||
+100
@@ -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);
|
||||
}
|
||||
}
|
||||
+129
-20
@@ -35,11 +35,14 @@ import 'package:sliver_tools/sliver_tools.dart';
|
||||
import 'package:timeago/timeago.dart' as timeago;
|
||||
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/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/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/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/presentation/hooks/menu_controller.dart';
|
||||
import 'package:weblibre/presentation/widgets/failure_widget.dart';
|
||||
@@ -54,11 +57,12 @@ class Section extends MultiSliver {
|
||||
super.key,
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
required List<VisitInfo> items,
|
||||
required Set<VisitInfo> selectedItems,
|
||||
required void Function(VisitInfo) onTap,
|
||||
required void Function(VisitInfo) onLongPress,
|
||||
required Future<void> Function(VisitInfo) onDelete,
|
||||
required List<HistoryEntry> items,
|
||||
required Set<HistoryEntry> selectedItems,
|
||||
required Map<String, ContainerData> containersById,
|
||||
required void Function(HistoryEntry) onTap,
|
||||
required void Function(HistoryEntry) onLongPress,
|
||||
required Future<void> Function(HistoryEntry) onDelete,
|
||||
}) : super(
|
||||
pushPinnedChildren: true,
|
||||
children: [
|
||||
@@ -122,6 +126,7 @@ class Section extends MultiSliver {
|
||||
padding: const EdgeInsets.only(left: 54, right: 16),
|
||||
child: Wrap(
|
||||
spacing: 8.0,
|
||||
runSpacing: 4.0,
|
||||
children: [
|
||||
Chip(
|
||||
avatar: switch (item.visitType) {
|
||||
@@ -147,9 +152,7 @@ class Section extends MultiSliver {
|
||||
VisitType.download => const Text('Download'),
|
||||
VisitType.framedLink => const Text('Frame'),
|
||||
VisitType.reload => const Text('Page Reload'),
|
||||
VisitType.bookmark => throw UnimplementedError(
|
||||
'VisitType.bookmark chip display not implemented',
|
||||
),
|
||||
VisitType.bookmark => const Text('Bookmark'),
|
||||
},
|
||||
),
|
||||
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(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(
|
||||
dateRange: null,
|
||||
visitTypes: const {VisitType.download},
|
||||
@@ -222,8 +246,10 @@ class HistoryScreen extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteHistoryItem(VisitInfo item) async {
|
||||
await ref.read(historyRepositoryProvider.notifier).deleteVisit(item);
|
||||
Future<void> deleteHistoryItem(HistoryEntry item) async {
|
||||
await ref
|
||||
.read(containerHistoryRepositoryProvider.notifier)
|
||||
.deleteVisit(item);
|
||||
|
||||
final downloadedFile = item.title.mapNotNull((title) => File(title));
|
||||
|
||||
@@ -241,6 +267,38 @@ class HistoryScreen extends HookConsumerWidget {
|
||||
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(
|
||||
appBar: AppBar(
|
||||
title: textFilterEnabled.value
|
||||
@@ -276,7 +334,7 @@ class HistoryScreen extends HookConsumerWidget {
|
||||
|
||||
for (final item in selectedItems.value) {
|
||||
await ref
|
||||
.read(historyRepositoryProvider.notifier)
|
||||
.read(containerHistoryRepositoryProvider.notifier)
|
||||
.deleteVisit(item);
|
||||
|
||||
final downloadedFile = item.title.mapNotNull(
|
||||
@@ -309,7 +367,18 @@ class HistoryScreen extends HookConsumerWidget {
|
||||
)
|
||||
else
|
||||
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 {
|
||||
if (filterContainer != null) {
|
||||
await clearContainerHistory(filterContainer);
|
||||
return;
|
||||
}
|
||||
|
||||
await showDeleteDataDialog(
|
||||
context,
|
||||
initialSettings: {
|
||||
@@ -403,12 +472,51 @@ class HistoryScreen extends HookConsumerWidget {
|
||||
VisitType.download => const Text('Downloads'),
|
||||
VisitType.framedLink => const Text('Frames'),
|
||||
VisitType.reload => const Text('Page Reloads'),
|
||||
VisitType.bookmark => throw UnimplementedError(
|
||||
'VisitType.bookmark filter not implemented',
|
||||
),
|
||||
VisitType.bookmark => const Text('Bookmarks'),
|
||||
},
|
||||
),
|
||||
),
|
||||
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(),
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(MdiIcons.restore),
|
||||
@@ -458,11 +566,11 @@ class HistoryScreen extends HookConsumerWidget {
|
||||
final groups = useMemoized(
|
||||
() => data
|
||||
.where(
|
||||
(visit) =>
|
||||
(entry) =>
|
||||
textFilter.isEmpty ||
|
||||
visit.title?.toLowerCase().contains(textFilter) ==
|
||||
entry.title?.toLowerCase().contains(textFilter) ==
|
||||
true ||
|
||||
visit.url.toLowerCase().contains(textFilter),
|
||||
entry.url.toLowerCase().contains(textFilter),
|
||||
)
|
||||
.groupListsBy(
|
||||
(element) => timeago.format(
|
||||
@@ -474,7 +582,7 @@ class HistoryScreen extends HookConsumerWidget {
|
||||
[EquatableValue(data), textFilter],
|
||||
);
|
||||
|
||||
void toggleSelected(VisitInfo item) {
|
||||
void toggleSelected(HistoryEntry item) {
|
||||
if (selectedItems.value.contains(item)) {
|
||||
selectedItems.value = {...selectedItems.value}
|
||||
..remove(item);
|
||||
@@ -495,6 +603,7 @@ class HistoryScreen extends HookConsumerWidget {
|
||||
title: key,
|
||||
items: value,
|
||||
selectedItems: selectedItems.value,
|
||||
containersById: containersById,
|
||||
onLongPress: toggleSelected,
|
||||
onDelete: deleteHistoryItem,
|
||||
onTap: (item) async {
|
||||
|
||||
+118
@@ -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();
|
||||
}
|
||||
}
|
||||
+14
@@ -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/history.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.steps.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(
|
||||
include: {'definitions.drift'},
|
||||
daos: [ContainerDao, TabDao, CaptureTabDao, HistoryDao],
|
||||
daos: [ContainerDao, TabDao, CaptureTabDao, HistoryDao, VisitContainerDao],
|
||||
)
|
||||
class TabDatabase extends $TabDatabase with TrigramQueryBuilderMixin {
|
||||
@override
|
||||
final int schemaVersion = 14;
|
||||
final int schemaVersion = 15;
|
||||
|
||||
@override
|
||||
final int ftsTokenLimit = 10;
|
||||
@@ -247,5 +248,13 @@ class TabDatabase extends $TabDatabase with TrigramQueryBuilderMixin {
|
||||
await m.create(schema.tabToHistoryOnContainerUpdate);
|
||||
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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
+33
-13
@@ -13,8 +13,10 @@ import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/cap
|
||||
as i5;
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/history.dart'
|
||||
as i6;
|
||||
import 'package:drift/internal/modular.dart' as i7;
|
||||
import 'package:sqlite3/common.dart' as i8;
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/visit_container.dart'
|
||||
as i7;
|
||||
import 'package:drift/internal/modular.dart' as i8;
|
||||
import 'package:sqlite3/common.dart' as i9;
|
||||
|
||||
abstract class $TabDatabase extends i0.GeneratedDatabase {
|
||||
$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.HistoryFts historyFts = i1.HistoryFts(this);
|
||||
late final i1.VisitContainer visitContainer = i1.VisitContainer(this);
|
||||
late final i2.ContainerDao containerDao = i2.ContainerDao(
|
||||
this as i3.TabDatabase,
|
||||
);
|
||||
@@ -39,7 +42,10 @@ abstract class $TabDatabase extends i0.GeneratedDatabase {
|
||||
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,
|
||||
).accessor<i1.DefinitionsDrift>(i1.DefinitionsDrift.new);
|
||||
@override
|
||||
@@ -70,6 +76,9 @@ abstract class $TabDatabase extends i0.GeneratedDatabase {
|
||||
i1.tabToHistoryOnUpdate,
|
||||
i1.tabToHistoryOnContainerUpdate,
|
||||
i1.containerToHistoryOnMetadataUpdate,
|
||||
visitContainer,
|
||||
i1.idxVcCanonical,
|
||||
i1.idxVcContainer,
|
||||
];
|
||||
@override
|
||||
i0.StreamQueryUpdateRules get streamUpdateRules =>
|
||||
@@ -171,6 +180,15 @@ abstract class $TabDatabase extends i0.GeneratedDatabase {
|
||||
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.$HistoryFtsTableManager get 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({
|
||||
required String Function(int, String?) lexoRankNext,
|
||||
required String Function(int, String?) lexoRankPrevious,
|
||||
@@ -207,7 +227,7 @@ extension DefineFunctions on i8.CommonDatabase {
|
||||
}) {
|
||||
createFunction(
|
||||
functionName: 'lexo_rank_next',
|
||||
argumentCount: const i8.AllowedArgumentCount(2),
|
||||
argumentCount: const i9.AllowedArgumentCount(2),
|
||||
function: (args) {
|
||||
final arg0 = args[0] as int;
|
||||
final arg1 = args[1] as String?;
|
||||
@@ -216,7 +236,7 @@ extension DefineFunctions on i8.CommonDatabase {
|
||||
);
|
||||
createFunction(
|
||||
functionName: 'lexo_rank_previous',
|
||||
argumentCount: const i8.AllowedArgumentCount(2),
|
||||
argumentCount: const i9.AllowedArgumentCount(2),
|
||||
function: (args) {
|
||||
final arg0 = args[0] as int;
|
||||
final arg1 = args[1] as String?;
|
||||
@@ -225,7 +245,7 @@ extension DefineFunctions on i8.CommonDatabase {
|
||||
);
|
||||
createFunction(
|
||||
functionName: 'lexo_rank_reorder_after',
|
||||
argumentCount: const i8.AllowedArgumentCount(2),
|
||||
argumentCount: const i9.AllowedArgumentCount(2),
|
||||
function: (args) {
|
||||
final arg0 = args[0] as String?;
|
||||
final arg1 = args[1] as String?;
|
||||
@@ -234,7 +254,7 @@ extension DefineFunctions on i8.CommonDatabase {
|
||||
);
|
||||
createFunction(
|
||||
functionName: 'lexo_rank_reorder_before',
|
||||
argumentCount: const i8.AllowedArgumentCount(2),
|
||||
argumentCount: const i9.AllowedArgumentCount(2),
|
||||
function: (args) {
|
||||
final arg0 = args[0] as String?;
|
||||
final arg1 = args[1] as String?;
|
||||
@@ -243,14 +263,14 @@ extension DefineFunctions on i8.CommonDatabase {
|
||||
);
|
||||
createFunction(
|
||||
functionName: 'generate_content_hash',
|
||||
argumentCount: const i8.AllowedArgumentCount(0),
|
||||
argumentCount: const i9.AllowedArgumentCount(0),
|
||||
function: (args) {
|
||||
return generateContentHash();
|
||||
},
|
||||
);
|
||||
createFunction(
|
||||
functionName: 'url_indexable',
|
||||
argumentCount: const i8.AllowedArgumentCount(1),
|
||||
argumentCount: const i9.AllowedArgumentCount(1),
|
||||
function: (args) {
|
||||
final arg0 = args[0] as String?;
|
||||
return urlIndexable(arg0);
|
||||
@@ -258,7 +278,7 @@ extension DefineFunctions on i8.CommonDatabase {
|
||||
);
|
||||
createFunction(
|
||||
functionName: 'url_canonical',
|
||||
argumentCount: const i8.AllowedArgumentCount(1),
|
||||
argumentCount: const i9.AllowedArgumentCount(1),
|
||||
function: (args) {
|
||||
final arg0 = args[0] as String?;
|
||||
return urlCanonical(arg0);
|
||||
@@ -266,7 +286,7 @@ extension DefineFunctions on i8.CommonDatabase {
|
||||
);
|
||||
createFunction(
|
||||
functionName: 'url_host',
|
||||
argumentCount: const i8.AllowedArgumentCount(1),
|
||||
argumentCount: const i9.AllowedArgumentCount(1),
|
||||
function: (args) {
|
||||
final arg0 = args[0] as String?;
|
||||
return urlHost(arg0);
|
||||
@@ -274,7 +294,7 @@ extension DefineFunctions on i8.CommonDatabase {
|
||||
);
|
||||
createFunction(
|
||||
functionName: 'url_path',
|
||||
argumentCount: const i8.AllowedArgumentCount(1),
|
||||
argumentCount: const i9.AllowedArgumentCount(1),
|
||||
function: (args) {
|
||||
final arg0 = args[0] as String?;
|
||||
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({
|
||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||
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, Schema13 schema) from12To13,
|
||||
required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14,
|
||||
required Future<void> Function(i1.Migrator m, Schema15 schema) from14To15,
|
||||
}) {
|
||||
return (currentVersion, database) async {
|
||||
switch (currentVersion) {
|
||||
@@ -2115,6 +2409,11 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from13To14(migrator, schema);
|
||||
return 14;
|
||||
case 14:
|
||||
final schema = Schema15(database: database);
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from14To15(migrator, schema);
|
||||
return 15;
|
||||
default:
|
||||
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, Schema13 schema) from12To13,
|
||||
required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14,
|
||||
required Future<void> Function(i1.Migrator m, Schema15 schema) from14To15,
|
||||
}) => i0.VersionedSchema.stepByStepHelper(
|
||||
step: migrationSteps(
|
||||
from2To3: from2To3,
|
||||
@@ -2148,5 +2448,6 @@ i1.OnUpgrade stepByStep({
|
||||
from11To12: from11To12,
|
||||
from12To13: from12To13,
|
||||
from13To14: from13To14,
|
||||
from14To15: from14To15,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -563,6 +563,39 @@ BEGIN
|
||||
observed_count = history.observed_count + 1;
|
||||
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:
|
||||
SELECT
|
||||
container.*,
|
||||
|
||||
+783
-32
@@ -72,6 +72,31 @@ final class $ContainerReferences
|
||||
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
|
||||
@@ -143,6 +168,35 @@ class $ContainerFilterComposer
|
||||
);
|
||||
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
|
||||
@@ -237,6 +291,35 @@ class $ContainerAnnotationComposer
|
||||
);
|
||||
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
|
||||
@@ -252,7 +335,7 @@ class $ContainerTableManager
|
||||
$ContainerUpdateCompanionBuilder,
|
||||
(i1.ContainerData, i3.$ContainerReferences),
|
||||
i1.ContainerData,
|
||||
i0.PrefetchHooks Function({bool tabRefs})
|
||||
i0.PrefetchHooks Function({bool tabRefs, bool visitContainerRefs})
|
||||
> {
|
||||
$ContainerTableManager(i0.GeneratedDatabase db, i3.Container table)
|
||||
: super(
|
||||
@@ -309,38 +392,60 @@ class $ContainerTableManager
|
||||
(e.readTable(table), i3.$ContainerReferences(db, table, e)),
|
||||
)
|
||||
.toList(),
|
||||
prefetchHooksCallback: ({tabRefs = false}) {
|
||||
return i0.PrefetchHooks(
|
||||
db: db,
|
||||
explicitlyWatchedTables: [
|
||||
if (tabRefs)
|
||||
i9.ReadDatabaseContainer(db).resultSet<i3.Tab>('tab'),
|
||||
],
|
||||
addJoins: null,
|
||||
getPrefetchedDataCallback: (items) async {
|
||||
return [
|
||||
if (tabRefs)
|
||||
await i0.$_getPrefetchedData<
|
||||
i1.ContainerData,
|
||||
i3.Container,
|
||||
i3.TabData
|
||||
>(
|
||||
currentTable: table,
|
||||
referencedTable: i3.$ContainerReferences._tabRefsTable(
|
||||
prefetchHooksCallback:
|
||||
({tabRefs = false, visitContainerRefs = false}) {
|
||||
return i0.PrefetchHooks(
|
||||
db: db,
|
||||
explicitlyWatchedTables: [
|
||||
if (tabRefs)
|
||||
i9.ReadDatabaseContainer(db).resultSet<i3.Tab>('tab'),
|
||||
if (visitContainerRefs)
|
||||
i9.ReadDatabaseContainer(
|
||||
db,
|
||||
),
|
||||
managerFromTypedResult: (p0) =>
|
||||
i3.$ContainerReferences(db, table, p0).tabRefs,
|
||||
referencedItemsForCurrentItem: (item, referencedItems) =>
|
||||
referencedItems.where(
|
||||
(e) => e.containerId == item.id,
|
||||
),
|
||||
typedResults: items,
|
||||
),
|
||||
];
|
||||
).resultSet<i3.VisitContainer>('visit_container'),
|
||||
],
|
||||
addJoins: null,
|
||||
getPrefetchedDataCallback: (items) async {
|
||||
return [
|
||||
if (tabRefs)
|
||||
await i0.$_getPrefetchedData<
|
||||
i1.ContainerData,
|
||||
i3.Container,
|
||||
i3.TabData
|
||||
>(
|
||||
currentTable: table,
|
||||
referencedTable: i3.$ContainerReferences
|
||||
._tabRefsTable(db),
|
||||
managerFromTypedResult: (p0) =>
|
||||
i3.$ContainerReferences(db, table, p0).tabRefs,
|
||||
referencedItemsForCurrentItem:
|
||||
(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,
|
||||
),
|
||||
typedResults: items,
|
||||
),
|
||||
];
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -357,7 +462,7 @@ typedef $ContainerProcessedTableManager =
|
||||
$ContainerUpdateCompanionBuilder,
|
||||
(i1.ContainerData, i3.$ContainerReferences),
|
||||
i1.ContainerData,
|
||||
i0.PrefetchHooks Function({bool tabRefs})
|
||||
i0.PrefetchHooks Function({bool tabRefs, bool visitContainerRefs})
|
||||
>;
|
||||
typedef $TabCreateCompanionBuilder =
|
||||
i3.TabCompanion Function({
|
||||
@@ -2411,6 +2516,340 @@ typedef $HistoryFtsProcessedTableManager =
|
||||
i3.HistoryFt,
|
||||
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
|
||||
with i0.TableInfo<Container, i1.ContainerData> {
|
||||
@@ -5430,6 +5869,318 @@ i0.Trigger get containerToHistoryOnMetadataUpdate => i0.Trigger(
|
||||
'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 {
|
||||
DefinitionsDrift(i0.GeneratedDatabase db) : super(db);
|
||||
Future<int> optimizeFtsIndex() {
|
||||
|
||||
@@ -50,6 +50,18 @@ class ContainerMetadata with FastEquatable {
|
||||
@JsonKey(defaultValue: false)
|
||||
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)
|
||||
final bool bypassGlobalProxy;
|
||||
|
||||
@@ -67,6 +79,7 @@ class ContainerMetadata with FastEquatable {
|
||||
required this.proxyConnectionId,
|
||||
required this.clearDataOnExit,
|
||||
required this.excludeFromIndex,
|
||||
required this.excludeFromHistory,
|
||||
required this.bypassGlobalProxy,
|
||||
required this.useCustomColor,
|
||||
required this.assignedSites,
|
||||
@@ -78,6 +91,7 @@ class ContainerMetadata with FastEquatable {
|
||||
ProxyConnectionId? proxyConnectionId,
|
||||
bool? clearDataOnExit,
|
||||
bool? excludeFromIndex,
|
||||
bool? excludeFromHistory,
|
||||
bool? bypassGlobalProxy,
|
||||
bool? useCustomColor,
|
||||
List<Uri>? assignedSites,
|
||||
@@ -87,11 +101,31 @@ class ContainerMetadata with FastEquatable {
|
||||
proxyConnectionId: proxyConnectionId,
|
||||
clearDataOnExit: clearDataOnExit ?? 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,
|
||||
useCustomColor: useCustomColor ?? false,
|
||||
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;
|
||||
|
||||
factory ContainerMetadata.fromJson(Map<String, dynamic> json) =>
|
||||
@@ -106,6 +140,7 @@ class ContainerMetadata with FastEquatable {
|
||||
proxyConnectionId,
|
||||
clearDataOnExit,
|
||||
excludeFromIndex,
|
||||
excludeFromHistory,
|
||||
bypassGlobalProxy,
|
||||
useCustomColor,
|
||||
assignedSites,
|
||||
|
||||
@@ -17,6 +17,8 @@ abstract class _$ContainerMetadataCWProxy {
|
||||
|
||||
ContainerMetadata excludeFromIndex(bool excludeFromIndex);
|
||||
|
||||
ContainerMetadata excludeFromHistory(bool excludeFromHistory);
|
||||
|
||||
ContainerMetadata bypassGlobalProxy(bool bypassGlobalProxy);
|
||||
|
||||
ContainerMetadata useCustomColor(bool useCustomColor);
|
||||
@@ -36,6 +38,7 @@ abstract class _$ContainerMetadataCWProxy {
|
||||
ProxyConnectionId? proxyConnectionId,
|
||||
bool clearDataOnExit,
|
||||
bool excludeFromIndex,
|
||||
bool excludeFromHistory,
|
||||
bool bypassGlobalProxy,
|
||||
bool useCustomColor,
|
||||
List<Uri>? assignedSites,
|
||||
@@ -68,6 +71,10 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy {
|
||||
ContainerMetadata excludeFromIndex(bool excludeFromIndex) =>
|
||||
call(excludeFromIndex: excludeFromIndex);
|
||||
|
||||
@override
|
||||
ContainerMetadata excludeFromHistory(bool excludeFromHistory) =>
|
||||
call(excludeFromHistory: excludeFromHistory);
|
||||
|
||||
@override
|
||||
ContainerMetadata bypassGlobalProxy(bool bypassGlobalProxy) =>
|
||||
call(bypassGlobalProxy: bypassGlobalProxy);
|
||||
@@ -94,6 +101,7 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy {
|
||||
Object? proxyConnectionId = const $CopyWithPlaceholder(),
|
||||
Object? clearDataOnExit = const $CopyWithPlaceholder(),
|
||||
Object? excludeFromIndex = const $CopyWithPlaceholder(),
|
||||
Object? excludeFromHistory = const $CopyWithPlaceholder(),
|
||||
Object? bypassGlobalProxy = const $CopyWithPlaceholder(),
|
||||
Object? useCustomColor = const $CopyWithPlaceholder(),
|
||||
Object? assignedSites = const $CopyWithPlaceholder(),
|
||||
@@ -123,6 +131,12 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy {
|
||||
? _value.excludeFromIndex
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: excludeFromIndex as bool,
|
||||
excludeFromHistory:
|
||||
excludeFromHistory == const $CopyWithPlaceholder() ||
|
||||
excludeFromHistory == null
|
||||
? _value.excludeFromHistory
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: excludeFromHistory as bool,
|
||||
bypassGlobalProxy:
|
||||
bypassGlobalProxy == const $CopyWithPlaceholder() ||
|
||||
bypassGlobalProxy == null
|
||||
@@ -275,6 +289,7 @@ ContainerMetadata _$ContainerMetadataFromJson(Map<String, dynamic> json) =>
|
||||
),
|
||||
clearDataOnExit: json['clearDataOnExit'] as bool? ?? false,
|
||||
excludeFromIndex: json['excludeFromIndex'] as bool? ?? false,
|
||||
excludeFromHistory: json['excludeFromHistory'] as bool? ?? false,
|
||||
bypassGlobalProxy: json['bypassGlobalProxy'] as bool? ?? false,
|
||||
useCustomColor: json['useCustomColor'] as bool? ?? false,
|
||||
assignedSites: (json['assignedSites'] as List<dynamic>?)
|
||||
@@ -293,6 +308,7 @@ Map<String, dynamic> _$ContainerMetadataToJson(
|
||||
'proxyConnectionId': _proxyConnectionIdToJson(instance.proxyConnectionId),
|
||||
'clearDataOnExit': instance.clearDataOnExit,
|
||||
'excludeFromIndex': instance.excludeFromIndex,
|
||||
'excludeFromHistory': instance.excludeFromHistory,
|
||||
'bypassGlobalProxy': instance.bypassGlobalProxy,
|
||||
'useCustomColor': instance.useCustomColor,
|
||||
'assignedSites': instance.assignedSites?.map((e) => e.toString()).toList(),
|
||||
|
||||
+72
-27
@@ -19,34 +19,79 @@
|
||||
*/
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
/// Dialog to confirm container deletion.
|
||||
/// Returns true if user confirms deletion, false if cancelled, null if dismissed.
|
||||
Future<bool?> showDeleteContainerDialog(BuildContext context) {
|
||||
return showDialog<bool?>(
|
||||
/// Result of the delete-container confirmation.
|
||||
class DeleteContainerDecision {
|
||||
/// Whether the container's browsing history should also be deleted from
|
||||
/// 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,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
icon: const Icon(Icons.warning),
|
||||
title: const Text('Delete Container'),
|
||||
content: const Text(
|
||||
'Are you sure you want to delete this container and close all attached tabs?',
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context, false);
|
||||
},
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context, true);
|
||||
},
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
builder: (context) => const _DeleteContainerDialog(),
|
||||
);
|
||||
}
|
||||
|
||||
class _DeleteContainerDialog extends HookWidget {
|
||||
const _DeleteContainerDialog();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final wipeHistory = useState(false);
|
||||
|
||||
return AlertDialog(
|
||||
icon: const Icon(Icons.warning),
|
||||
title: const Text('Delete Container'),
|
||||
content: Column(
|
||||
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>[
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context, null);
|
||||
},
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(
|
||||
context,
|
||||
DeleteContainerDecision(wipeHistory: wipeHistory.value),
|
||||
);
|
||||
},
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+38
-2
@@ -26,6 +26,7 @@ import 'package:flutter_material_design_icons/flutter_material_design_icons.dart
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.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/domain/repositories/container.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/controllers/container_topic.dart';
|
||||
@@ -100,6 +101,9 @@ class ContainerEditScreen extends HookConsumerWidget {
|
||||
final excludeFromIndex = useState(
|
||||
initialContainer.metadata.excludeFromIndex,
|
||||
);
|
||||
final excludeFromHistory = useState(
|
||||
initialContainer.metadata.excludeFromHistory,
|
||||
);
|
||||
final bypassGlobalProxy = useState(
|
||||
initialContainer.metadata.bypassGlobalProxy,
|
||||
);
|
||||
@@ -126,13 +130,18 @@ class ContainerEditScreen extends HookConsumerWidget {
|
||||
clearDataOnExit:
|
||||
clearDataOnExit.value && contextualIdentity.value != null,
|
||||
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:
|
||||
contextualIdentity.value != null &&
|
||||
proxyConnectionId.value == null &&
|
||||
bypassGlobalProxy.value,
|
||||
useCustomColor: useCustomColor.value,
|
||||
assignedSites: assignedSites.value,
|
||||
),
|
||||
).sanitized(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -234,7 +243,16 @@ class ContainerEditScreen extends HookConsumerWidget {
|
||||
Future<void> deleteContainer() async {
|
||||
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
|
||||
.read(containerRepositoryProvider.notifier)
|
||||
.deleteContainer(initialContainer.id);
|
||||
@@ -541,6 +559,24 @@ class ContainerEditScreen extends HookConsumerWidget {
|
||||
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() =>
|
||||
r'03cfe93b6d4ac8cdb0b33627f4baa75240bff745';
|
||||
r'db077d5aa6a7aec5cdb019308cc942079567bab9';
|
||||
|
||||
abstract class _$EngineSettingsRepository
|
||||
extends $StreamNotifier<EngineSettings> {
|
||||
|
||||
@@ -29,7 +29,11 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.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:hooks_riverpod/hooks_riverpod.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/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/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/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_settings_sync.dart';
|
||||
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_logs.dart';
|
||||
@@ -235,6 +242,35 @@ class _MainWidget extends HookConsumerWidget {
|
||||
final clearStartupUBlockFilterListsPref =
|
||||
!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 {
|
||||
await GeckoBrowserService().initialize(
|
||||
filesystem.relativeProfilePath,
|
||||
|
||||
@@ -17,6 +17,7 @@ import 'schema_v11.dart' as v11;
|
||||
import 'schema_v12.dart' as v12;
|
||||
import 'schema_v13.dart' as v13;
|
||||
import 'schema_v14.dart' as v14;
|
||||
import 'schema_v15.dart' as v15;
|
||||
|
||||
class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
@override
|
||||
@@ -48,10 +49,27 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
return v13.DatabaseAtV13(db);
|
||||
case 14:
|
||||
return v14.DatabaseAtV14(db);
|
||||
case 15:
|
||||
return v15.DatabaseAtV15(db);
|
||||
default:
|
||||
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
+17
@@ -7,8 +7,10 @@
|
||||
package eu.weblibre.flutter_mozilla_components
|
||||
|
||||
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.pigeons.GeckoBrowserApi
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoEngineSettingsApi
|
||||
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||
@@ -23,10 +25,25 @@ class FlutterMozillaComponentsPlugin: FlutterPlugin, ActivityAware {
|
||||
browserApi.attachBinding(flutterPluginBinding)
|
||||
GeckoBrowserApi.setUp(flutterPluginBinding.binaryMessenger, browserApi)
|
||||
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) {
|
||||
SandboxCaptureFeature.detachFlutterEvents(binding.binaryMessenger)
|
||||
GlobalComponents.historyEvents = null
|
||||
}
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
|
||||
+39
-1
@@ -8,6 +8,8 @@ package eu.weblibre.flutter_mozilla_components
|
||||
|
||||
import android.content.Context
|
||||
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.BrowserExtensionEvents
|
||||
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.GeckoEngineSettings
|
||||
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.GeckoStateEvents
|
||||
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 =
|
||||
"__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 EXCLUDED_HISTORY_CONTEXT_IDS_PREF =
|
||||
"browser.weblibre.excludedHistoryContextIds"
|
||||
|
||||
object GlobalComponents {
|
||||
private var _components: Components? = null
|
||||
@@ -105,6 +110,36 @@ object GlobalComponents {
|
||||
// GestureRecognizer on the UI thread.
|
||||
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
|
||||
var gestureConfig: GestureConfig? = null
|
||||
|
||||
@@ -461,6 +496,9 @@ object GlobalComponents {
|
||||
val profileContext = ProfileContext(baseContext.applicationContext, profileFolder)
|
||||
val messenger = NoopBinaryMessenger()
|
||||
|
||||
loadExcludedHistoryContextIds(baseContext.applicationContext)
|
||||
historyEvents = null
|
||||
|
||||
val selectionActionEvents = GeckoSelectionActionEvents(messenger)
|
||||
val selectionActionDelegate = DefaultSelectionActionDelegate(selectionActionEvents) { actions ->
|
||||
val processTextAction = "android.intent.action.PROCESS_TEXT"
|
||||
@@ -493,7 +531,7 @@ object GlobalComponents {
|
||||
mode = ComponentsMode.EXTERNAL,
|
||||
)
|
||||
|
||||
engineSettingsApi = GeckoEngineSettingsApiImpl()
|
||||
engineSettingsApi = GeckoEngineSettingsApiImpl(baseContext.applicationContext)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
+21
-6
@@ -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.GeckoPwaApi
|
||||
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.GeckoSessionApi
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents
|
||||
@@ -265,6 +266,12 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
|
||||
|
||||
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(
|
||||
profileApplicationContext,
|
||||
_flutterEvents,
|
||||
@@ -281,12 +288,20 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
|
||||
syncTokenServerOverride,
|
||||
)
|
||||
|
||||
val engineSettingsApiImpl = GeckoEngineSettingsApiImpl()
|
||||
GeckoEngineSettingsApi.setUp(
|
||||
_flutterPluginBinding.binaryMessenger,
|
||||
engineSettingsApiImpl
|
||||
)
|
||||
GlobalComponents.engineSettingsApi = engineSettingsApiImpl
|
||||
// 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(
|
||||
_flutterPluginBinding.binaryMessenger,
|
||||
engineSettingsApiImpl
|
||||
)
|
||||
GlobalComponents.engineSettingsApi = engineSettingsApiImpl
|
||||
}
|
||||
GeckoAddonsApi.setUp(
|
||||
_flutterPluginBinding.binaryMessenger,
|
||||
GeckoAddonsApiImpl(profileApplicationContext)
|
||||
|
||||
+8
-1
@@ -6,6 +6,7 @@
|
||||
|
||||
package eu.weblibre.flutter_mozilla_components.api
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.edit
|
||||
import androidx.preference.PreferenceManager
|
||||
import eu.weblibre.flutter_mozilla_components.ColorSchemePreference
|
||||
@@ -74,7 +75,9 @@ internal fun TrackingProtectionPolicy.withBounceTrackingProtectionMode(
|
||||
/**
|
||||
* Implementation of GeckoEngineSettingsApi that manages engine-specific settings
|
||||
*/
|
||||
class GeckoEngineSettingsApiImpl : GeckoEngineSettingsApi {
|
||||
class GeckoEngineSettingsApiImpl(
|
||||
private val applicationContext: Context? = null,
|
||||
) : GeckoEngineSettingsApi {
|
||||
companion object {
|
||||
private const val TAG = "GeckoEngineSettingsApi"
|
||||
}
|
||||
@@ -515,4 +518,8 @@ class GeckoEngineSettingsApiImpl : GeckoEngineSettingsApi {
|
||||
activeReaderSessions,
|
||||
)
|
||||
}
|
||||
|
||||
override fun setExcludedHistoryContextIds(contextIds: List<String>) {
|
||||
GlobalComponents.setExcludedHistoryContextIds(applicationContext, contextIds)
|
||||
}
|
||||
}
|
||||
|
||||
+12
-2
@@ -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.getOrCreateRuntime
|
||||
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.services.MediaSessionService
|
||||
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.HistoryMetadataMiddleware
|
||||
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.SaveToPDFMiddleware
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.BrowserExtensionEvents
|
||||
@@ -99,9 +101,14 @@ class Core(
|
||||
|
||||
val engineSettings by lazy {
|
||||
DefaultSettings(
|
||||
//historyTrackingDelegate = HistoryDelegate(lazyHistoryStorage)
|
||||
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,
|
||||
remoteDebuggingEnabled = false,
|
||||
automaticFontSizeAdjustment = true,
|
||||
@@ -225,6 +232,9 @@ class Core(
|
||||
// sandbox new-tab URLs before Gecko issues a request.
|
||||
SandboxCaptureMiddleware,
|
||||
HistoryMetadataMiddleware(historyMetadataService),
|
||||
// Correlates url -> contextId so WebLibreHistoryDelegate can
|
||||
// resolve a visit's container at record time.
|
||||
HistoryVisitCorrelationMiddleware(),
|
||||
FlutterEventMiddleware(flutterEvents),
|
||||
DownloadMiddleware(
|
||||
applicationContext = context,
|
||||
|
||||
+80
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+150
@@ -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 visit→container 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 1–2 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)
|
||||
}
|
||||
}
|
||||
+18
@@ -6,6 +6,7 @@
|
||||
|
||||
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.ContentAction
|
||||
import mozilla.components.browser.state.action.HistoryMetadataAction
|
||||
@@ -102,13 +103,30 @@ class HistoryMetadataMiddleware(
|
||||
store: Store<BrowserState, BrowserAction>,
|
||||
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)
|
||||
store.dispatch(HistoryMetadataAction.SetHistoryMetadataKeyAction(tab.id, key))
|
||||
}
|
||||
|
||||
private fun updateHistoryMetadata(tab: TabSessionState) {
|
||||
if (isHistoryExcluded(tab)) {
|
||||
return
|
||||
}
|
||||
|
||||
tab.historyMetadata?.let {
|
||||
historyMetadataService.updateMetadata(it, tab)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isHistoryExcluded(tab: TabSessionState): Boolean {
|
||||
val contextId = tab.contextId ?: return false
|
||||
return contextId in GlobalComponents.excludedHistoryContextIds
|
||||
}
|
||||
}
|
||||
|
||||
+48
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+65
@@ -7305,6 +7305,13 @@ interface GeckoEngineSettingsApi {
|
||||
* cold-started reader view resolves the right value before Flutter runs.
|
||||
*/
|
||||
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 {
|
||||
/** The codec used by GeckoEngineSettingsApi. */
|
||||
@@ -7490,6 +7497,24 @@ interface GeckoEngineSettingsApi {
|
||||
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 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.
|
||||
*/
|
||||
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. */
|
||||
interface GeckoHistoryApi {
|
||||
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,
|
||||
GeckoEngineSettings,
|
||||
GeckoFetchResponse,
|
||||
GeckoHistoryEvents,
|
||||
GeckoPref,
|
||||
GeckoProxySettings,
|
||||
GeckoPublicSuffixListApi,
|
||||
|
||||
@@ -236,4 +236,8 @@ class GeckoEngineSettingsService {
|
||||
Future<void> setReaderViewPureBlack(bool 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.framedLink:
|
||||
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);
|
||||
case VisitType.download:
|
||||
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 {
|
||||
@@ -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 {
|
||||
/// Constructor for [GeckoHistoryApi]. The [binaryMessenger] named argument is
|
||||
/// 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
|
||||
/// cold-started reader view resolves the right value before Flutter runs.
|
||||
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()
|
||||
@@ -2230,6 +2236,20 @@ enum ClearDataType {
|
||||
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()
|
||||
abstract class GeckoHistoryApi {
|
||||
@async
|
||||
|
||||
Reference in New Issue
Block a user