650 lines
22 KiB
Dart
650 lines
22 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:lensai/core/logger.dart';
|
|
import 'package:lensai/domain/services/generic_website.dart';
|
|
import 'package:lensai/features/bangs/domain/providers.dart';
|
|
import 'package:lensai/features/chat_archive/domain/entities/chat_entity.dart';
|
|
import 'package:lensai/features/chat_archive/domain/repositories/archive.dart';
|
|
import 'package:lensai/features/search_browser/domain/entities/modes.dart';
|
|
import 'package:lensai/features/search_browser/domain/entities/sheet.dart';
|
|
import 'package:lensai/features/search_browser/domain/providers.dart';
|
|
import 'package:lensai/features/settings/data/models/settings.dart';
|
|
import 'package:lensai/features/settings/data/repositories/settings_repository.dart';
|
|
import 'package:lensai/features/web_view/domain/entities/web_view_page.dart';
|
|
import 'package:lensai/features/web_view/domain/providers.dart';
|
|
import 'package:lensai/features/web_view/presentation/controllers/readerability.dart';
|
|
import 'package:lensai/features/web_view/presentation/controllers/switch_new_tab.dart';
|
|
import 'package:lensai/features/web_view/presentation/widgets/web_page_dialog.dart';
|
|
import 'package:lensai/features/web_view/utils/download_helper.dart';
|
|
import 'package:lensai/utils/platform_util.dart' as platform_util;
|
|
import 'package:lensai/utils/ui_helper.dart' as ui_helper;
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
|
|
const _webViewSupportedSchemes = [
|
|
"http",
|
|
"https",
|
|
"file",
|
|
"chrome",
|
|
"data",
|
|
"javascript",
|
|
"about",
|
|
];
|
|
|
|
class WebView extends StatefulHookConsumerWidget {
|
|
final String tabId;
|
|
|
|
final ValueNotifier<WebViewPage> _pageNotifier;
|
|
|
|
final ValueNotifier<bool?> _isReaderable;
|
|
final ValueNotifier<bool> _readerableApplied;
|
|
|
|
ValueListenable<WebViewPage> get page => _pageNotifier;
|
|
|
|
/// Don't cache this value as it depends on current value of a ValueListenable
|
|
InAppWebViewController? get currentController =>
|
|
_pageNotifier.value.controller;
|
|
|
|
ValueListenable<bool?> get isReaderable => _isReaderable;
|
|
ValueListenable<bool> get readerableApplied => _readerableApplied;
|
|
|
|
void updatePage(WebViewPage Function(WebViewPage page) update) {
|
|
_pageNotifier.value = update(_pageNotifier.value);
|
|
}
|
|
|
|
void resetReaderable() {
|
|
_isReaderable.value = null;
|
|
_readerableApplied.value = false;
|
|
}
|
|
|
|
Future<void> updateReaderableApplied(bool value) async {
|
|
if (value == false) {
|
|
await _pageNotifier.value.controller?.reload();
|
|
}
|
|
|
|
_readerableApplied.value = value;
|
|
}
|
|
|
|
void updateIsReaderable(bool value) {
|
|
_isReaderable.value = value;
|
|
}
|
|
|
|
WebView({required WebViewPage tab})
|
|
: _pageNotifier = ValueNotifier(tab),
|
|
_isReaderable = ValueNotifier(null),
|
|
_readerableApplied = ValueNotifier(false),
|
|
tabId = tab.id,
|
|
super(key: tab.key);
|
|
|
|
@override
|
|
ConsumerState<ConsumerStatefulWidget> createState() => _WebViewState();
|
|
}
|
|
|
|
class _WebViewState extends ConsumerState<WebView> {
|
|
Timer? _onLoadStopDebounce;
|
|
Timer? _periodicScreenshotUpdate;
|
|
|
|
Future<bool> _downloadChat(
|
|
DownloadStartRequest downloadStartRequest,
|
|
BuildContext context,
|
|
) async {
|
|
final fileName = getDispositionFileName(
|
|
downloadStartRequest.contentDisposition!,
|
|
);
|
|
|
|
if (fileName != null) {
|
|
final entity = ChatEntity.fromFileName(fileName);
|
|
if (entity.name != null) {
|
|
final fileWrite = await ref
|
|
.read(chatArchiveRepositoryProvider.notifier)
|
|
.archiveChat(fileName, downloadStartRequest.url);
|
|
|
|
return fileWrite.fold(
|
|
(_) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
backgroundColor: Colors.green,
|
|
content: Text(
|
|
'Conversation "$entity" saved successfully!',
|
|
style: const TextStyle(color: Colors.white),
|
|
),
|
|
),
|
|
);
|
|
|
|
return true;
|
|
},
|
|
onFailure: (errorMessage) {
|
|
ui_helper.showErrorMessage(
|
|
context,
|
|
errorMessage.toString(),
|
|
);
|
|
|
|
return false;
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
Future<void> _updateScreenshot() async {
|
|
final screenshot = await widget.page.value.controller
|
|
?.takeScreenshot(
|
|
screenshotConfiguration: ScreenshotConfiguration(
|
|
compressFormat: CompressFormat.JPEG,
|
|
quality: 20,
|
|
),
|
|
)
|
|
.timeout(
|
|
const Duration(milliseconds: 1500),
|
|
onTimeout: () {
|
|
logger.w('Screenshot timed out');
|
|
return null;
|
|
},
|
|
);
|
|
|
|
widget.updatePage(
|
|
(page) => page.copyWith.screenshot(screenshot),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> dispose() async {
|
|
super.dispose();
|
|
|
|
_onLoadStopDebounce?.cancel();
|
|
_periodicScreenshotUpdate?.cancel();
|
|
|
|
widget._pageNotifier.dispose();
|
|
widget._isReaderable.dispose();
|
|
widget._readerableApplied.dispose();
|
|
|
|
logger.i('Disposed ${widget.key} (${widget.page.value.title})');
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final initialSettings = useMemoized(
|
|
() => InAppWebViewSettings(
|
|
// isInspectable: kDebugMode,
|
|
useOnDownloadStart: true,
|
|
allowsLinkPreview: false,
|
|
disableLongPressContextMenuOnLinks: true,
|
|
useShouldOverrideUrlLoading: true,
|
|
javaScriptEnabled: (ref.read(settingsRepositoryProvider).valueOrNull ??
|
|
Settings.withDefaults())
|
|
.enableJavascript,
|
|
saveFormData: false,
|
|
disabledActionModeMenuItems: ActionModeMenuItem.MENU_ITEM_WEB_SEARCH,
|
|
),
|
|
);
|
|
|
|
final showEarlyAccessFeatures = ref.watch(
|
|
settingsRepositoryProvider.select(
|
|
(value) => (value.valueOrNull ?? Settings.withDefaults())
|
|
.showEarlyAccessFeatures,
|
|
),
|
|
);
|
|
|
|
// We keep listening to changes and cache them in this ref.
|
|
// This is important, since our WebView will not have the main scope anymore
|
|
// to react to updates from ref.watch
|
|
//
|
|
// With this solution we dont need to read on every interception request
|
|
// and can just obtain the value from here
|
|
final blockContentHosts = useRef<Set<String>?>(null);
|
|
ref.listen(
|
|
blockContentHostsProvider,
|
|
(previous, next) {
|
|
blockContentHosts.value = next.valueOrNull;
|
|
},
|
|
);
|
|
|
|
ref.listen(
|
|
settingsRepositoryProvider.select(
|
|
(value) =>
|
|
(value.valueOrNull ?? Settings.withDefaults()).enableJavascript,
|
|
),
|
|
(previous, next) async {
|
|
await widget.page.value.controller?.setSettings(
|
|
settings: initialSettings.copy()..javaScriptEnabled = next,
|
|
);
|
|
},
|
|
);
|
|
|
|
final webViewProgress = useValueNotifier(100);
|
|
final inPageSearchResult =
|
|
useValueNotifier<({int activeMatchOrdinal, int numberOfMatches})?>(
|
|
null,
|
|
);
|
|
|
|
final findInteractionController = useMemoized(
|
|
() => FindInteractionController(
|
|
onFindResultReceived: (
|
|
controller,
|
|
activeMatchOrdinal,
|
|
numberOfMatches,
|
|
isDoneCounting,
|
|
) {
|
|
if (isDoneCounting) {
|
|
inPageSearchResult.value = (
|
|
activeMatchOrdinal: activeMatchOrdinal,
|
|
numberOfMatches: numberOfMatches,
|
|
);
|
|
}
|
|
},
|
|
),
|
|
);
|
|
|
|
useOnAppLifecycleStateChange((previous, current) async {
|
|
switch (current) {
|
|
case AppLifecycleState.paused:
|
|
if (platform_util.isAndroid()) {
|
|
await widget.page.value.controller?.pause();
|
|
}
|
|
if (platform_util.isAndroid() || platform_util.isIOS()) {
|
|
await widget.page.value.controller?.pauseTimers();
|
|
}
|
|
case AppLifecycleState.resumed:
|
|
if (platform_util.isAndroid()) {
|
|
await widget.page.value.controller?.resume();
|
|
}
|
|
if (platform_util.isAndroid() || platform_util.isIOS()) {
|
|
await widget.page.value.controller?.resumeTimers();
|
|
}
|
|
default:
|
|
}
|
|
});
|
|
|
|
return Stack(
|
|
children: [
|
|
InAppWebView(
|
|
initialUrlRequest: URLRequest(url: WebUri.uri(widget.page.value.url)),
|
|
initialSettings: initialSettings,
|
|
findInteractionController: findInteractionController,
|
|
contextMenu: ContextMenu(
|
|
menuItems: [
|
|
ContextMenuItem(
|
|
id: 1,
|
|
title: "Search",
|
|
action: () async {
|
|
final selectedText =
|
|
await widget.page.value.controller?.getSelectedText();
|
|
|
|
if (selectedText != null && selectedText.isNotEmpty) {
|
|
final searchBang =
|
|
await ref.read(kagiSearchBangDataProvider.future);
|
|
|
|
await ref
|
|
.read(switchNewTabControllerProvider.notifier)
|
|
.add(searchBang!.getUrl(selectedText));
|
|
}
|
|
},
|
|
),
|
|
if (showEarlyAccessFeatures)
|
|
ContextMenuItem(
|
|
id: 2,
|
|
title: "Assistant",
|
|
action: () async {
|
|
final selectedText =
|
|
await widget.page.value.controller?.getSelectedText();
|
|
|
|
if (selectedText != null && selectedText.isNotEmpty) {
|
|
ref.read(bottomSheetProvider.notifier).show(
|
|
CreateTab(
|
|
content: selectedText,
|
|
preferredTool: KagiTool.assistant,
|
|
),
|
|
);
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
onWebViewCreated: (controller) async {
|
|
if (platform_util.isAndroid()) {
|
|
await controller.startSafeBrowsing();
|
|
}
|
|
|
|
widget.updatePage((page) => page.copyWith.controller(controller));
|
|
},
|
|
onReceivedServerTrustAuthRequest: (controller, challenge) async {
|
|
final sslError = challenge.protectionSpace.sslError;
|
|
|
|
if (sslError != null && sslError.code != null) {
|
|
if (challenge.protectionSpace.host ==
|
|
await controller.getUrl().then((value) => value?.host)) {
|
|
widget.updatePage((page) => page.copyWith.sslError(sslError));
|
|
if (context.mounted) {
|
|
ui_helper.showErrorMessage(
|
|
context,
|
|
'We detected an security issue and did not continue to ${widget.page.value.url.authority}: ${sslError.message}',
|
|
);
|
|
}
|
|
}
|
|
|
|
return ServerTrustAuthResponse(
|
|
// ignore: avoid_redundant_argument_values
|
|
action: ServerTrustAuthResponseAction.CANCEL,
|
|
);
|
|
}
|
|
|
|
widget.updatePage((page) => page.copyWith.sslError(null));
|
|
return ServerTrustAuthResponse(
|
|
action: ServerTrustAuthResponseAction.PROCEED,
|
|
);
|
|
},
|
|
shouldInterceptRequest: (controller, request) async {
|
|
if (blockContentHosts.value?.contains(request.url.host) == true) {
|
|
return WebResourceResponse(
|
|
contentType: "text/plain",
|
|
data: utf8.encode("Blocked"),
|
|
statusCode: 403,
|
|
reasonPhrase: "Forbidden",
|
|
);
|
|
}
|
|
|
|
//Check if main page aka main frame is upgradeable
|
|
if (request.url.isScheme('http') &&
|
|
request.isForMainFrame == true) {
|
|
final upgradedUri = await ref
|
|
.read(genericWebsiteServiceProvider.notifier)
|
|
.tryUpgradeToHttps(request.url);
|
|
|
|
if (upgradedUri != null) {
|
|
unawaited(
|
|
controller.loadUrl(
|
|
urlRequest: URLRequest(url: WebUri.uri(upgradedUri)),
|
|
),
|
|
);
|
|
|
|
return WebResourceResponse(
|
|
statusCode: 200,
|
|
reasonPhrase: "Redirecting...",
|
|
);
|
|
}
|
|
}
|
|
|
|
if (request.url.isScheme('http')) {
|
|
if ((ref.read(settingsRepositoryProvider).valueOrNull ??
|
|
Settings.withDefaults())
|
|
.blockHttpProtocol) {
|
|
return WebResourceResponse(
|
|
contentType: "text/plain",
|
|
data: utf8.encode("HTTP protocol is blocked"),
|
|
statusCode: 403,
|
|
reasonPhrase: "Forbidden",
|
|
);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
},
|
|
onProgressChanged: (controller, progress) {
|
|
webViewProgress.value = progress;
|
|
},
|
|
onLoadStart: (controller, url) {
|
|
if (url != null) {
|
|
widget.updatePage(
|
|
(page) => page.copyWith(
|
|
url: url,
|
|
// ignore: avoid_redundant_argument_values
|
|
sslError: null,
|
|
),
|
|
);
|
|
}
|
|
|
|
widget.resetReaderable();
|
|
},
|
|
onLoadStop: (controller, url) async {
|
|
if (url != null) {
|
|
widget.updatePage((page) => page.copyWith.url(url));
|
|
}
|
|
|
|
_onLoadStopDebounce?.cancel();
|
|
_onLoadStopDebounce =
|
|
Timer(const Duration(milliseconds: 150), () async {
|
|
// final favicon = await widget.page.value.controller
|
|
// ?.getFavicons()
|
|
// .then((icons) => choseFavicon(icons));
|
|
// widget.updatePage((page) => page.copyWith.favicon(favicon));
|
|
|
|
await _updateScreenshot().whenComplete(() {
|
|
_periodicScreenshotUpdate?.cancel();
|
|
_periodicScreenshotUpdate =
|
|
Timer.periodic(const Duration(seconds: 5), (timer) async {
|
|
await _updateScreenshot().onError((error, stackTrace) {
|
|
logger.e(error, stackTrace: stackTrace);
|
|
timer.cancel();
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
final readabilityNotifier = ref.read(
|
|
readerabilityControllerProvider(controller).notifier,
|
|
);
|
|
|
|
widget.updateIsReaderable(await readabilityNotifier.isReaderable());
|
|
},
|
|
onUpdateVisitedHistory: (controller, url, isReload) async {
|
|
if (isReload != true) {
|
|
final history = (
|
|
canGoBack: await controller.canGoBack(),
|
|
canGoForward: await controller.canGoForward()
|
|
);
|
|
|
|
widget.updatePage((page) => page.copyWith.pageHistory(history));
|
|
}
|
|
},
|
|
shouldOverrideUrlLoading: (controller, navigationAction) async {
|
|
final url = navigationAction.request.url;
|
|
if (url != null) {
|
|
final launchExternal = ref.read(
|
|
settingsRepositoryProvider.select(
|
|
(value) => (value.valueOrNull ?? Settings.withDefaults())
|
|
.launchUrlExternal,
|
|
),
|
|
);
|
|
|
|
final unhandledScheme =
|
|
!_webViewSupportedSchemes.contains(url.scheme);
|
|
|
|
if (unhandledScheme ||
|
|
(launchExternal && url.host != 'kagi.com')) {
|
|
if (await canLaunchUrl(url)) {
|
|
var success = false;
|
|
if (unhandledScheme) {
|
|
success = await launchUrl(url);
|
|
} else if (launchExternal) {
|
|
success = await launchUrl(
|
|
url,
|
|
mode: LaunchMode.externalApplication,
|
|
);
|
|
}
|
|
|
|
if (!success) {
|
|
if (context.mounted) {
|
|
ui_helper.showErrorMessage(
|
|
context,
|
|
'Could not launch URL ($url)',
|
|
);
|
|
}
|
|
}
|
|
// and cancel the request
|
|
return NavigationActionPolicy.CANCEL;
|
|
}
|
|
}
|
|
}
|
|
|
|
return NavigationActionPolicy.ALLOW;
|
|
},
|
|
onLongPressHitTestResult: (controller, hitTestResult) async {
|
|
if (switch (hitTestResult.type) {
|
|
InAppWebViewHitTestResultType.SRC_IMAGE_ANCHOR_TYPE ||
|
|
InAppWebViewHitTestResultType.SRC_ANCHOR_TYPE ||
|
|
InAppWebViewHitTestResultType.IMAGE_TYPE =>
|
|
true,
|
|
_ => false,
|
|
}) {
|
|
final requestFocusNodeHrefResult =
|
|
await controller.requestFocusNodeHref();
|
|
|
|
final url = requestFocusNodeHrefResult?.url ??
|
|
Uri.tryParse(requestFocusNodeHrefResult?.src ?? '');
|
|
if (url?.hasScheme == true && url?.hasAuthority == true) {
|
|
ref.read(overlayDialogProvider.notifier).show(
|
|
LoadingWebPageDialog(
|
|
url!,
|
|
onDismiss: () {
|
|
ref.read(overlayDialogProvider.notifier).dismiss();
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
},
|
|
onTitleChanged: (controller, title) {
|
|
widget.updatePage((page) => page.copyWith.title(title));
|
|
},
|
|
onDownloadStartRequest: (controller, downloadStartRequest) async {
|
|
final handled = switch (downloadStartRequest.mimeType) {
|
|
'text/markdown' =>
|
|
await _downloadChat(downloadStartRequest, context),
|
|
_ => false
|
|
};
|
|
|
|
if (!handled) {
|
|
if (context.mounted) {
|
|
await ui_helper.launchUrlFeedback(
|
|
context,
|
|
downloadStartRequest.url,
|
|
mode: LaunchMode.platformDefault,
|
|
);
|
|
}
|
|
}
|
|
|
|
webViewProgress.value = 100;
|
|
},
|
|
),
|
|
Positioned(
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
child: HookBuilder(
|
|
builder: (context) {
|
|
final value = useValueListenable(webViewProgress);
|
|
|
|
return Visibility(
|
|
visible: value < 100,
|
|
child: LinearProgressIndicator(
|
|
value: value / 100,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
Positioned(
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
child: HookConsumer(
|
|
builder: (context, ref, child) {
|
|
final showFindInPage = ref.watch(showFindInPageProvider);
|
|
|
|
final textController = useTextEditingController();
|
|
final progress = useValueListenable(webViewProgress);
|
|
|
|
return Visibility(
|
|
visible: showFindInPage,
|
|
child: Padding(
|
|
padding:
|
|
EdgeInsets.only(bottom: (progress < 100) ? 4.0 : 0.0),
|
|
child: Material(
|
|
child: Row(
|
|
children: [
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: TextField(
|
|
controller: textController,
|
|
autofocus: true,
|
|
autocorrect: false,
|
|
decoration: const InputDecoration.collapsed(
|
|
hintText: 'Find in page',
|
|
),
|
|
keyboardType: TextInputType.text,
|
|
onSubmitted: (value) async {
|
|
if (value == '') {
|
|
inPageSearchResult.value = null;
|
|
await findInteractionController.clearMatches();
|
|
} else {
|
|
await findInteractionController.findAll(
|
|
find: value,
|
|
);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
HookConsumer(
|
|
builder: (context, ref, child) {
|
|
final searchResult =
|
|
useValueListenable(inPageSearchResult);
|
|
|
|
if (searchResult != null) {
|
|
if (searchResult.numberOfMatches == 0) {
|
|
return const Text('Not found');
|
|
}
|
|
|
|
return Text(
|
|
'${searchResult.activeMatchOrdinal + 1} of ${searchResult.numberOfMatches}',
|
|
);
|
|
}
|
|
|
|
return const SizedBox.shrink();
|
|
},
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.arrow_upward),
|
|
onPressed: () async {
|
|
await findInteractionController.findNext(
|
|
forward: false,
|
|
);
|
|
},
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.arrow_downward),
|
|
onPressed: () async {
|
|
await findInteractionController.findNext();
|
|
},
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.clear),
|
|
onPressed: () async {
|
|
ref
|
|
.read(showFindInPageProvider.notifier)
|
|
.update(false);
|
|
|
|
inPageSearchResult.value = null;
|
|
await findInteractionController.clearMatches();
|
|
textController.clear();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|