/* * Copyright (c) 2024-2026 Fabian Freund. * * This file is part of WebLibre * (see https://weblibre.eu). * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:drift/drift.dart'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:nullability/nullability.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:weblibre/core/logger.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; import 'package:weblibre/features/geckoview/domain/providers.dart'; import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_source.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/selected_container.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; import 'package:weblibre/utils/debouncer.dart'; part 'tab.g.dart'; @Riverpod(keepAlive: true) class TabRepository extends _$TabRepository { final _tabsService = GeckoTabService(); final _tabFromIntent = {}; bool hasLaunchedFromIntent(String? tabId) { if (tabId == null) { return false; } return _tabFromIntent.contains(tabId); } Future _resolveParentIdForContext({ required String? parentId, required String? targetContextId, }) async { if (parentId == null) { return null; } final parentContainerData = await ref .read(tabDatabaseProvider) .tabDao .getTabContainerData(parentId) .getSingleOrNull(); final parentContextId = parentContainerData?.metadata.contextualIdentity; return (parentContextId == targetContextId) ? parentId : null; } Future addTab({ Uri? url, required bool selectTab, bool startLoading = true, String? parentId, LoadUrlFlags flags = LoadUrlFlags.NONE, Source source = Internal.newTab, required bool private, HistoryMetadataKey? historyMetadata, Map? additionalHeaders, Value? container, bool launchedFromIntent = false, }) async { final tabDao = ref.read(tabDatabaseProvider).tabDao; final assingedContainer = container ?? Value( // ignore: only_use_keep_alive_inside_keep_alive await ref.read(selectedContainerProvider.notifier).fetchData(), ); final validatedParentId = await _resolveParentIdForContext( parentId: parentId, targetContextId: assingedContainer.value?.metadata.contextualIdentity, ); final newTabId = await tabDao.upsertTabTransactional( () { return _tabsService.addTab( url: url, selectTab: selectTab, startLoading: startLoading, parentId: validatedParentId, flags: flags, contextId: assingedContainer.value?.metadata.contextualIdentity, source: source, private: private, historyMetadata: historyMetadata, additionalHeaders: additionalHeaders, ); }, parentId: Value(validatedParentId), containerId: Value(assingedContainer.value?.id), isPrivate: Value(private), url: Value(url), ); if (launchedFromIntent) { _tabFromIntent.add(newTabId); } return newTabId; } Future> addMultipleTabs({ required List tabs, String? selectTabId, Value? container, }) async { final tabDao = ref.read(tabDatabaseProvider).tabDao; final db = ref.read(tabDatabaseProvider); return await db.transaction(() async { final createdTabIds = await _tabsService.addMultipleTabs( tabs: tabs, selectTabId: selectTabId, ); // Build sets for validation final creatingTabIds = createdTabIds.toSet(); final parentIdsToValidate = tabs .map((tab) => tab.parentId) .whereType() .where((id) => !creatingTabIds.contains(id)) .toSet(); // Batch validate parent IDs that aren't in the current creation batch final existingParentIds = await tabDao .getExistingTabIds(parentIdsToValidate) .get() .then((ids) => ids.toSet()); // Upsert all tabs in the database for (var i = 0; i < createdTabIds.length; i++) { final tabId = createdTabIds[i]; final tab = tabs[i]; // Validate parent exists in either the batch being created or database String? validatedParentId; if (tab.parentId != null) { if (creatingTabIds.contains(tab.parentId) || existingParentIds.contains(tab.parentId)) { validatedParentId = tab.parentId; } } await tabDao.insertTab( tabId, parentId: Value(validatedParentId), source: TabSource.manual, containerId: Value(container?.value?.id), isPrivate: Value(tab.private), url: Value(Uri.tryParse(tab.url)), ); } return createdTabIds; }); } Future duplicateTab({ required String selectTabId, required ContainerData? containerData, required bool selectTab, }) async { final tabDao = ref.read(tabDatabaseProvider).tabDao; return await tabDao.upsertTabTransactional( () { return _tabsService.duplicateTab( selectTabId: selectTabId, newContextId: containerData?.metadata.contextualIdentity, selectNewTab: selectTab, ); }, parentId: const Value.absent(), containerId: Value(containerData?.id), isPrivate: Value( await tabDao.getTabIsPrivate(selectTabId).getSingleOrNull(), ), ); } Future selectPreviouslyOpenedTab(String tabId) async { final previousTabId = await ref .read(tabDatabaseProvider) .definitionsDrift .previousTabByTimestamp(tabId: tabId) .getSingleOrNull(); if (ref.mounted && previousTabId != null) { return selectTab(previousTabId); } return false; } Future selectPreviousTab( String tabId, { String? containerId, bool skipContainerCheck = true, }) async { final previousTabId = await ref .read(tabDatabaseProvider) .definitionsDrift .previousTabByOrderKey( tabId: tabId, containerId: containerId, skipContainerCheck: skipContainerCheck, ) .getSingleOrNull(); if (ref.mounted && previousTabId != null) { return selectTab(previousTabId); } return false; } Future selectNextTab( String tabId, { String? containerId, bool skipContainerCheck = true, }) async { final previousTabId = await ref .read(tabDatabaseProvider) .definitionsDrift .nextTabByOrderKey( tabId: tabId, containerId: containerId, skipContainerCheck: skipContainerCheck, ) .getSingleOrNull(); if (ref.mounted && previousTabId != null) { return selectTab(previousTabId); } return false; } Future selectTab(String tabId) async { final containerData = await ref .read(tabDataRepositoryProvider.notifier) .getTabContainerData(tabId); if (!ref.mounted) return false; if (containerData != null) { if (containerData.metadata.useProxy) { final proxyPluginHealthy = await GeckoContainerProxyService() .healthcheck(); if (!proxyPluginHealthy) { logger.w( 'Tried to open proxied tab $tabId but proxy plugin not responding', ); return false; } } } await _tabsService.selectTab(tabId: tabId); return true; } Future _selectNextTab(String tabId) async { // ignore: only_use_keep_alive_inside_keep_alive final tabState = ref.read(tabStateProvider(tabId)); final currentContainerId = await ref .read(tabDataRepositoryProvider.notifier) .getTabContainerId(tabId); if (!ref.mounted) return; final sameContainerTabs = await ref .read(containerRepositoryProvider.notifier) .getContainerTabIds(currentContainerId) .then((tabs) => tabs.where((tab) => tab != tabId).toList()); if (!ref.mounted) return; // Priority 1: Check for parent tab first if (tabState?.parentId != null) { return _tabsService.selectTab(tabId: tabState!.parentId!); } // Priority 2: Check for previous tab by timestamp final previousTabId = await ref .read(tabDatabaseProvider) .definitionsDrift .previousTabByTimestamp(tabId: tabId) .getSingleOrNull(); if (previousTabId != null) { if (sameContainerTabs.any((tab) => tab == previousTabId)) { return _tabsService.selectTab(tabId: previousTabId); } } if (!ref.mounted) return; final previousOrderedTabId = await ref .read(tabDatabaseProvider) .definitionsDrift .previousTabByOrderKey( tabId: tabId, containerId: currentContainerId, skipContainerCheck: false, ) .getSingleOrNull(); if (previousOrderedTabId != null) { return _tabsService.selectTab(tabId: previousOrderedTabId); } if (!ref.mounted) return; final nextOrderedTabId = await ref .read(tabDatabaseProvider) .definitionsDrift .nextTabByOrderKey( tabId: tabId, containerId: currentContainerId, skipContainerCheck: false, ) .getSingleOrNull(); if (nextOrderedTabId != null) { return _tabsService.selectTab(tabId: nextOrderedTabId); } if (!ref.mounted) return; final unassignedTabs = await ref .read(containerRepositoryProvider.notifier) .getContainerTabIds(null) .then((tabs) => tabs.where((tab) => tab != tabId).toList()); if (unassignedTabs.isNotEmpty) { return _tabsService.selectTab(tabId: unassignedTabs.first); } if (!ref.mounted) return; final availableContainers = await ref .read(containerRepositoryProvider.notifier) .getAllContainersWithCount(); final nextAvailableContainer = availableContainers.firstOrNull; if (!ref.mounted) return; final nextContainerTabs = await nextAvailableContainer .mapNotNull( (container) => ref .read(containerRepositoryProvider.notifier) .getContainerTabIds(container.id) .then((tabs) => tabs.where((tab) => tab != tabId).toList()), ); if (nextContainerTabs.isNotEmpty) { return _tabsService.selectTab(tabId: nextContainerTabs!.first); } } Future closeTab(String tabId) async { if (ref.read(selectedTabProvider) == tabId) { await _selectNextTab(tabId); } return _tabsService.removeTab(tabId: tabId); } Future closeTabs(List tabIds) async { final selectedTab = ref.read(selectedTabProvider); if (selectedTab.mapNotNull(tabIds.contains) ?? false) { await _selectNextTab(selectedTab!); } return _tabsService.removeTabs(ids: tabIds); } Future undoClose() { return _tabsService.undo(); } @override void build() { final eventSerivce = ref.watch(eventServiceProvider); final tabContentService = ref.watch(tabContentServiceProvider); final db = ref.watch(tabDatabaseProvider); final tabAddedSub = eventSerivce.tabAddedStream.listen( (tabId) async { final containerId = ref.read(selectedContainerProvider); await db.tabDao.insertTab( tabId, parentId: const Value.absent(), source: TabSource.addedEvent, containerId: Value(containerId), isPrivate: const Value.absent(), ); }, onError: (Object error, StackTrace stackTrace) { logger.e( 'Error in tab added stream', error: error, stackTrace: stackTrace, ); }, ); final containerSiteAssignementSub = eventSerivce.siteAssignementEvent.listen( (event) async { if (event.tabId != null) { // ignore: only_use_keep_alive_inside_keep_alive final tabState = ref.read(tabStateProvider(event.tabId)); if (tabState != null) { final uri = Uri.parse(event.url); final originUri = event.originUrl.mapNotNull(Uri.parse); final targetContainerId = await ref .read(containerRepositoryProvider.notifier) .siteAssignedContainerId(Uri.parse(uri.origin)); final containerData = await targetContainerId.mapNotNull( (id) => ref .read(containerRepositoryProvider.notifier) .getContainerData(id), ); if (containerData != null) { final tabIsEmpty = tabState.url == TabState.defaultUrl && tabState.historyState.items.isEmpty; if (event.blocked || tabIsEmpty) { await addTab( url: uri, private: tabState.isPrivate, container: Value(containerData), parentId: tabState.id, selectTab: true, ); if (tabState.historyState.items.isEmpty) { await closeTab(tabState.id); } } else { final tabContainerId = await ref .read(tabDataRepositoryProvider.notifier) .getTabContainerId(tabState.id); if (targetContainerId != tabContainerId) { if (originUri == null) { await ref .read(tabDataRepositoryProvider.notifier) .assignContainer(tabState.id, containerData); } else if (tabState.url == originUri) { await ref .read(tabDataRepositoryProvider.notifier) .assignContainer( tabState.id, containerData, closeOldTab: false, ); } else { logger.w( 'Could not match origin url for assignment ${tabState.url} to request ${event.originUrl}', ); } } } } } else { logger.w('Could not get tab for assignement ${tabState?.url}'); } } }, onError: (Object error, StackTrace stackTrace) { logger.e( 'Error in container site assignment stream', error: error, stackTrace: stackTrace, ); }, ); final tabContentSub = tabContentService.tabContentStream.listen( (content) async { await db.tabDao.updateTabContent( content.tabId, isProbablyReaderable: content.isProbablyReaderable, extractedContentMarkdown: content.extractedContentMarkdown, extractedContentPlain: content.extractedContentPlain, fullContentMarkdown: content.fullContentMarkdown, fullContentPlain: content.fullContentPlain, ); }, onError: (Object error, StackTrace stackTrace) { logger.e( 'Error in tab content stream', error: error, stackTrace: stackTrace, ); }, ); ref.listen( fireImmediately: true, selectedTabProvider, (previous, tabId) async { if (tabId != null) { await db.tabDao.touchTab(tabId, timestamp: DateTime.now()); } }, onError: (Object error, StackTrace stackTrace) { logger.e( 'Error listening to selectedTabProvider', error: error, stackTrace: stackTrace, ); }, ); ref.listen( tabListProvider, (previous, next) async { //Only sync tabs if there has been a previous value or is not empty final syncTabs = next.value.isNotEmpty || (previous?.value.isNotEmpty ?? false); if (syncTabs) { await db.tabDao.syncTabs(retainTabIds: next.value); } }, onError: (Object error, StackTrace stackTrace) { logger.e( 'Error listening to tabListProvider', error: error, stackTrace: stackTrace, ); }, ); final tabStateDebouncer = Debouncer(const Duration(seconds: 1)); Map? debounceStartValue; ref.listen( tabStatesProvider, (previous, next) { //Since state changes occure pretty often and our map always contains //the latest state, we cache the value before starting debouncing and //later diff to that, to avoid frequent database writes if (!tabStateDebouncer.isDebouncing) { debounceStartValue = previous; } tabStateDebouncer.eventOccured(() async { await db.tabDao.updateTabs(debounceStartValue, next); }); }, onError: (Object error, StackTrace stackTrace) { logger.e( 'Error listening to tabStatesProvider', error: error, stackTrace: stackTrace, ); }, ); ref.onDispose(() async { tabStateDebouncer.dispose(); await tabAddedSub.cancel(); await tabContentSub.cancel(); await containerSiteAssignementSub.cancel(); }); } }