intent gatekeeper initial
This commit is contained in:
+49
@@ -53,6 +53,11 @@ import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode
|
|||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.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/container.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
||||||
|
import 'package:weblibre/features/intent_gatekeeper/domain/entities/intent_source_policy.dart';
|
||||||
|
import 'package:weblibre/features/intent_gatekeeper/domain/entities/pending_intent_decision.dart';
|
||||||
|
import 'package:weblibre/features/intent_gatekeeper/domain/services/intent_gatekeeper.dart';
|
||||||
|
import 'package:weblibre/features/intent_gatekeeper/domain/services/native_gatekeeper_replicator.dart';
|
||||||
|
import 'package:weblibre/features/intent_gatekeeper/presentation/widgets/intent_gatekeeper_dialog.dart';
|
||||||
import 'package:weblibre/features/share_intent/domain/entities/shared_content.dart';
|
import 'package:weblibre/features/share_intent/domain/entities/shared_content.dart';
|
||||||
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||||
import 'package:weblibre/features/user/domain/providers/profile_auth.dart';
|
import 'package:weblibre/features/user/domain/providers/profile_auth.dart';
|
||||||
@@ -347,6 +352,37 @@ class _BrowserViewState extends ConsumerState<BrowserView>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ref.listenManual<AsyncValue<PendingIntentDecision>>(
|
||||||
|
intentGatekeeperProvider,
|
||||||
|
(previous, next) async {
|
||||||
|
final request = next.value;
|
||||||
|
if (request == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final gatekeeper = ref.read(intentGatekeeperProvider.notifier);
|
||||||
|
if (!context.mounted) {
|
||||||
|
await gatekeeper.resolve(
|
||||||
|
id: request.id,
|
||||||
|
decision: IntentSourcePolicy.block,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final outcome = await showDialog<DialogOutcome>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => IntentGatekeeperDialog(request: request),
|
||||||
|
);
|
||||||
|
|
||||||
|
await gatekeeper.resolve(
|
||||||
|
id: request.id,
|
||||||
|
decision: outcome?.decision ?? IntentSourcePolicy.block,
|
||||||
|
persist: outcome?.persist ?? false,
|
||||||
|
packageName: request.packageName,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
ref.listenManual(
|
ref.listenManual(
|
||||||
engineBoundIntentStreamProvider,
|
engineBoundIntentStreamProvider,
|
||||||
(previous, next) {
|
(previous, next) {
|
||||||
@@ -443,6 +479,19 @@ class _BrowserViewState extends ConsumerState<BrowserView>
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ref.listenManual(
|
||||||
|
fireImmediately: true,
|
||||||
|
nativeIntentGatekeeperReplicatorProvider,
|
||||||
|
(previous, next) {},
|
||||||
|
onError: (error, stackTrace) {
|
||||||
|
logger.e(
|
||||||
|
'Error listening to nativeIntentGatekeeperReplicatorProvider',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
ref.listenManual(
|
ref.listenManual(
|
||||||
fireImmediately: true,
|
fireImmediately: true,
|
||||||
selectionActionServiceProvider,
|
selectionActionServiceProvider,
|
||||||
|
|||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
/*
|
||||||
|
* 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/>.
|
||||||
|
*/
|
||||||
|
enum IntentSourcePolicy { allow, block }
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
/*
|
||||||
|
* 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';
|
||||||
|
|
||||||
|
class PendingIntentDecision with FastEquatable {
|
||||||
|
final int id;
|
||||||
|
final String packageName;
|
||||||
|
final String? url;
|
||||||
|
|
||||||
|
PendingIntentDecision({
|
||||||
|
required this.id,
|
||||||
|
required this.packageName,
|
||||||
|
required this.url,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get hashParameters => [id, packageName, url];
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/*
|
||||||
|
* 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:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:weblibre/features/intent_gatekeeper/domain/entities/intent_source_policy.dart';
|
||||||
|
import 'package:weblibre/features/intent_gatekeeper/domain/entities/pending_intent_decision.dart';
|
||||||
|
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
|
||||||
|
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||||
|
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||||
|
|
||||||
|
part 'intent_gatekeeper.g.dart';
|
||||||
|
|
||||||
|
const _ownPackageName = 'eu.weblibre.gecko';
|
||||||
|
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class IntentGatekeeper extends _$IntentGatekeeper {
|
||||||
|
late StreamController<PendingIntentDecision> _decisionRequests;
|
||||||
|
final _pending = <int, Completer<bool>>{};
|
||||||
|
int _nextId = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<PendingIntentDecision> build() {
|
||||||
|
_decisionRequests = StreamController<PendingIntentDecision>.broadcast();
|
||||||
|
|
||||||
|
ref.onDispose(() async {
|
||||||
|
for (final completer in _pending.values) {
|
||||||
|
if (!completer.isCompleted) {
|
||||||
|
completer.complete(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_pending.clear();
|
||||||
|
|
||||||
|
await _decisionRequests.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
return _decisionRequests.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves whether an intent coming from [fromPackageName] targeting [url]
|
||||||
|
/// should be allowed through. If the user has to decide, this waits for
|
||||||
|
/// [resolve] to be called with the matching decision id.
|
||||||
|
Future<bool> shouldAllow({
|
||||||
|
required String? fromPackageName,
|
||||||
|
required String? url,
|
||||||
|
}) async {
|
||||||
|
final settings = ref.read(generalSettingsWithDefaultsProvider);
|
||||||
|
|
||||||
|
if (!settings.blockExternalAppsEnabled) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal / unknown callers: no package to gate on — let through.
|
||||||
|
if (fromPackageName == null || fromPackageName == _ownPackageName) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final existing = settings.externalAppIntentPolicies[fromPackageName];
|
||||||
|
if (existing == IntentSourcePolicy.allow) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (existing == IntentSourcePolicy.block) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final id = _nextId++;
|
||||||
|
final completer = Completer<bool>();
|
||||||
|
_pending[id] = completer;
|
||||||
|
|
||||||
|
_decisionRequests.add(
|
||||||
|
PendingIntentDecision(id: id, packageName: fromPackageName, url: url),
|
||||||
|
);
|
||||||
|
|
||||||
|
return completer.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> resolve({
|
||||||
|
required int id,
|
||||||
|
required IntentSourcePolicy decision,
|
||||||
|
bool persist = false,
|
||||||
|
String? packageName,
|
||||||
|
}) async {
|
||||||
|
final completer = _pending.remove(id);
|
||||||
|
completer?.complete(decision == IntentSourcePolicy.allow);
|
||||||
|
|
||||||
|
if (persist && packageName != null) {
|
||||||
|
await ref
|
||||||
|
.read(saveGeneralSettingsControllerProvider.notifier)
|
||||||
|
.save(
|
||||||
|
(current) => current.copyWith.externalAppIntentPolicies({
|
||||||
|
...current.externalAppIntentPolicies,
|
||||||
|
packageName: decision,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'intent_gatekeeper.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint, type=warning
|
||||||
|
|
||||||
|
@ProviderFor(IntentGatekeeper)
|
||||||
|
final intentGatekeeperProvider = IntentGatekeeperProvider._();
|
||||||
|
|
||||||
|
final class IntentGatekeeperProvider
|
||||||
|
extends $StreamNotifierProvider<IntentGatekeeper, PendingIntentDecision> {
|
||||||
|
IntentGatekeeperProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'intentGatekeeperProvider',
|
||||||
|
isAutoDispose: false,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$intentGatekeeperHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
IntentGatekeeper create() => IntentGatekeeper();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$intentGatekeeperHash() => r'94df8850478ad6695eb14752e82af1919ea8a077';
|
||||||
|
|
||||||
|
abstract class _$IntentGatekeeper
|
||||||
|
extends $StreamNotifier<PendingIntentDecision> {
|
||||||
|
Stream<PendingIntentDecision> build();
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
void runBuild() {
|
||||||
|
final ref =
|
||||||
|
this.ref
|
||||||
|
as $Ref<AsyncValue<PendingIntentDecision>, PendingIntentDecision>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<
|
||||||
|
AsyncValue<PendingIntentDecision>,
|
||||||
|
PendingIntentDecision
|
||||||
|
>,
|
||||||
|
AsyncValue<PendingIntentDecision>,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
element.handleCreate(ref, build);
|
||||||
|
}
|
||||||
|
}
|
||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
/*
|
||||||
|
* 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:collection/collection.dart';
|
||||||
|
import 'package:fast_equatable/fast_equatable.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:simple_intent_receiver/simple_intent_receiver.dart';
|
||||||
|
import 'package:weblibre/core/logger.dart';
|
||||||
|
import 'package:weblibre/features/intent_gatekeeper/domain/entities/intent_source_policy.dart';
|
||||||
|
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||||
|
|
||||||
|
part 'native_gatekeeper_replicator.g.dart';
|
||||||
|
|
||||||
|
/// Mirrors the Flutter-side block list to the native side so the
|
||||||
|
/// `IntentReceiverActivity` can reject intents without launching Flutter.
|
||||||
|
/// Only blocked packages are replicated — allow/unknown still fall through to
|
||||||
|
/// the Flutter gatekeeper dialog.
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class NativeIntentGatekeeperReplicator
|
||||||
|
extends _$NativeIntentGatekeeperReplicator {
|
||||||
|
final _api = IntentGatekeeperHostApi();
|
||||||
|
|
||||||
|
Future<void> _push(
|
||||||
|
({bool enabled, Map<String, IntentSourcePolicy> policies}) config,
|
||||||
|
) async {
|
||||||
|
final blocked = config.policies.entries
|
||||||
|
.where((entry) => entry.value == IntentSourcePolicy.block)
|
||||||
|
.map((entry) => entry.key)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _api.setConfig(config.enabled, blocked);
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
logger.e(
|
||||||
|
'Failed to replicate intent gatekeeper config to native',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void build() {
|
||||||
|
ref.listen(
|
||||||
|
generalSettingsWithDefaultsProvider.select(
|
||||||
|
(settings) => EquatableValue((
|
||||||
|
enabled: settings.blockExternalAppsEnabled,
|
||||||
|
policies: settings.externalAppIntentPolicies,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
fireImmediately: true,
|
||||||
|
(p, n) {
|
||||||
|
final previous = p?.value;
|
||||||
|
final next = n.value;
|
||||||
|
|
||||||
|
if (previous != null &&
|
||||||
|
previous.enabled == next.enabled &&
|
||||||
|
const DeepCollectionEquality.unordered().equals(
|
||||||
|
previous.policies,
|
||||||
|
next.policies,
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unawaited(_push(next));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'native_gatekeeper_replicator.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint, type=warning
|
||||||
|
/// Mirrors the Flutter-side block list to the native side so the
|
||||||
|
/// `IntentReceiverActivity` can reject intents without launching Flutter.
|
||||||
|
/// Only blocked packages are replicated — allow/unknown still fall through to
|
||||||
|
/// the Flutter gatekeeper dialog.
|
||||||
|
|
||||||
|
@ProviderFor(NativeIntentGatekeeperReplicator)
|
||||||
|
final nativeIntentGatekeeperReplicatorProvider =
|
||||||
|
NativeIntentGatekeeperReplicatorProvider._();
|
||||||
|
|
||||||
|
/// Mirrors the Flutter-side block list to the native side so the
|
||||||
|
/// `IntentReceiverActivity` can reject intents without launching Flutter.
|
||||||
|
/// Only blocked packages are replicated — allow/unknown still fall through to
|
||||||
|
/// the Flutter gatekeeper dialog.
|
||||||
|
final class NativeIntentGatekeeperReplicatorProvider
|
||||||
|
extends $NotifierProvider<NativeIntentGatekeeperReplicator, void> {
|
||||||
|
/// Mirrors the Flutter-side block list to the native side so the
|
||||||
|
/// `IntentReceiverActivity` can reject intents without launching Flutter.
|
||||||
|
/// Only blocked packages are replicated — allow/unknown still fall through to
|
||||||
|
/// the Flutter gatekeeper dialog.
|
||||||
|
NativeIntentGatekeeperReplicatorProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'nativeIntentGatekeeperReplicatorProvider',
|
||||||
|
isAutoDispose: false,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$nativeIntentGatekeeperReplicatorHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
NativeIntentGatekeeperReplicator create() =>
|
||||||
|
NativeIntentGatekeeperReplicator();
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(void value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<void>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$nativeIntentGatekeeperReplicatorHash() =>
|
||||||
|
r'ee97dbd489e4e946e0a98cd640300f939f3b0682';
|
||||||
|
|
||||||
|
/// Mirrors the Flutter-side block list to the native side so the
|
||||||
|
/// `IntentReceiverActivity` can reject intents without launching Flutter.
|
||||||
|
/// Only blocked packages are replicated — allow/unknown still fall through to
|
||||||
|
/// the Flutter gatekeeper dialog.
|
||||||
|
|
||||||
|
abstract class _$NativeIntentGatekeeperReplicator extends $Notifier<void> {
|
||||||
|
void build();
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
void runBuild() {
|
||||||
|
final ref = this.ref as $Ref<void, void>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<void, void>,
|
||||||
|
void,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
element.handleCreate(ref, build);
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
/*
|
||||||
|
* 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:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:simple_intent_receiver/simple_intent_receiver.dart';
|
||||||
|
|
||||||
|
part 'package_label_resolver.g.dart';
|
||||||
|
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
Future<String?> packageLabel(Ref ref, String packageName) {
|
||||||
|
final api = IntentGatekeeperHostApi();
|
||||||
|
return api.resolvePackageLabel(packageName);
|
||||||
|
}
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'package_label_resolver.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint, type=warning
|
||||||
|
|
||||||
|
@ProviderFor(packageLabel)
|
||||||
|
final packageLabelProvider = PackageLabelFamily._();
|
||||||
|
|
||||||
|
final class PackageLabelProvider
|
||||||
|
extends $FunctionalProvider<AsyncValue<String?>, String?, FutureOr<String?>>
|
||||||
|
with $FutureModifier<String?>, $FutureProvider<String?> {
|
||||||
|
PackageLabelProvider._({
|
||||||
|
required PackageLabelFamily super.from,
|
||||||
|
required String super.argument,
|
||||||
|
}) : super(
|
||||||
|
retry: null,
|
||||||
|
name: r'packageLabelProvider',
|
||||||
|
isAutoDispose: false,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$packageLabelHash();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return r'packageLabelProvider'
|
||||||
|
''
|
||||||
|
'($argument)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
$FutureProviderElement<String?> $createElement($ProviderPointer pointer) =>
|
||||||
|
$FutureProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<String?> create(Ref ref) {
|
||||||
|
final argument = this.argument as String;
|
||||||
|
return packageLabel(ref, argument);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return other is PackageLabelProvider && other.argument == argument;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return argument.hashCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$packageLabelHash() => r'14f966e502c5dde332cc42d52727ab111bf6a2b2';
|
||||||
|
|
||||||
|
final class PackageLabelFamily extends $Family
|
||||||
|
with $FunctionalFamilyOverride<FutureOr<String?>, String> {
|
||||||
|
PackageLabelFamily._()
|
||||||
|
: super(
|
||||||
|
retry: null,
|
||||||
|
name: r'packageLabelProvider',
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
isAutoDispose: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
PackageLabelProvider call(String packageName) =>
|
||||||
|
PackageLabelProvider._(argument: packageName, from: this);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => r'packageLabelProvider';
|
||||||
|
}
|
||||||
+143
@@ -0,0 +1,143 @@
|
|||||||
|
/*
|
||||||
|
* 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:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:weblibre/features/intent_gatekeeper/domain/entities/intent_source_policy.dart';
|
||||||
|
import 'package:weblibre/features/intent_gatekeeper/domain/entities/pending_intent_decision.dart';
|
||||||
|
import 'package:weblibre/features/intent_gatekeeper/domain/services/package_label_resolver.dart';
|
||||||
|
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
|
||||||
|
|
||||||
|
class DialogOutcome {
|
||||||
|
final IntentSourcePolicy decision;
|
||||||
|
final bool persist;
|
||||||
|
|
||||||
|
const DialogOutcome({required this.decision, this.persist = false});
|
||||||
|
}
|
||||||
|
|
||||||
|
class IntentGatekeeperDialog extends HookConsumerWidget {
|
||||||
|
final PendingIntentDecision request;
|
||||||
|
|
||||||
|
const IntentGatekeeperDialog({super.key, required this.request});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
final bold = TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
);
|
||||||
|
|
||||||
|
final label = ref.watch(
|
||||||
|
packageLabelProvider(request.packageName).select((value) => value.value),
|
||||||
|
);
|
||||||
|
|
||||||
|
final displayName = (label != null && label.isNotEmpty)
|
||||||
|
? label
|
||||||
|
: request.packageName;
|
||||||
|
|
||||||
|
final uri = request.url != null ? Uri.tryParse(request.url!) : null;
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
icon: const Icon(Icons.shield_outlined, size: 32),
|
||||||
|
title: const Text('Open link in WebLibre?'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text.rich(
|
||||||
|
TextSpan(
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
TextSpan(text: displayName, style: bold),
|
||||||
|
const TextSpan(text: ' is trying to open a link in '),
|
||||||
|
TextSpan(text: 'WebLibre', style: bold),
|
||||||
|
const TextSpan(text: '.'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (uri != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: UriBreadcrumb(
|
||||||
|
uri: uri,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0),
|
||||||
|
actionsPadding: const EdgeInsets.all(24),
|
||||||
|
actions: [
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(
|
||||||
|
const DialogOutcome(
|
||||||
|
decision: IntentSourcePolicy.allow,
|
||||||
|
persist: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('Always allow'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
FilledButton.tonal(
|
||||||
|
onPressed: () => Navigator.of(context).pop(
|
||||||
|
const DialogOutcome(decision: IntentSourcePolicy.allow),
|
||||||
|
),
|
||||||
|
child: const Text('Allow once'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(
|
||||||
|
const DialogOutcome(decision: IntentSourcePolicy.block),
|
||||||
|
),
|
||||||
|
child: const Text('Block once'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(
|
||||||
|
const DialogOutcome(
|
||||||
|
decision: IntentSourcePolicy.block,
|
||||||
|
persist: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('Always block'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+146
@@ -25,6 +25,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:nullability/nullability.dart';
|
import 'package:nullability/nullability.dart';
|
||||||
import 'package:weblibre/core/routing/routes.dart';
|
import 'package:weblibre/core/routing/routes.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/delete_data.dart';
|
import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/delete_data.dart';
|
||||||
|
import 'package:weblibre/features/intent_gatekeeper/domain/entities/intent_source_policy.dart';
|
||||||
|
import 'package:weblibre/features/intent_gatekeeper/domain/services/package_label_resolver.dart';
|
||||||
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
|
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
|
||||||
import 'package:weblibre/features/settings/presentation/widgets/sections.dart';
|
import 'package:weblibre/features/settings/presentation/widgets/sections.dart';
|
||||||
import 'package:weblibre/features/user/data/models/engine_settings.dart';
|
import 'package:weblibre/features/user/data/models/engine_settings.dart';
|
||||||
@@ -54,6 +56,7 @@ class PrivacySecuritySettingsScreen extends StatelessWidget {
|
|||||||
_ConnectionSecuritySection(),
|
_ConnectionSecuritySection(),
|
||||||
_NetworkProtectionSection(),
|
_NetworkProtectionSection(),
|
||||||
_PrivacySignalsSection(),
|
_PrivacySignalsSection(),
|
||||||
|
_AppOpeningProtectionSection(),
|
||||||
_DataManagementSection(),
|
_DataManagementSection(),
|
||||||
_SafeBrowsingSection(),
|
_SafeBrowsingSection(),
|
||||||
_AdvancedSecuritySection(),
|
_AdvancedSecuritySection(),
|
||||||
@@ -853,6 +856,149 @@ Future<void> _showRestartDialog(BuildContext context, WidgetRef ref) async {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _AppOpeningProtectionSection extends HookConsumerWidget {
|
||||||
|
const _AppOpeningProtectionSection();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final enabled = ref.watch(
|
||||||
|
generalSettingsWithDefaultsProvider.select(
|
||||||
|
(s) => s.blockExternalAppsEnabled,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final policies = ref.watch(
|
||||||
|
generalSettingsWithDefaultsProvider.select(
|
||||||
|
(s) => s.externalAppIntentPolicies,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
const SettingSection(name: 'App-Opening Protection'),
|
||||||
|
SwitchListTile.adaptive(
|
||||||
|
title: const Text('Block apps from opening your browser'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Ask before opening links that other apps send to WebLibre.',
|
||||||
|
),
|
||||||
|
secondary: const Icon(MdiIcons.appsBox),
|
||||||
|
value: enabled,
|
||||||
|
onChanged: (value) async {
|
||||||
|
await ref
|
||||||
|
.read(saveGeneralSettingsControllerProvider.notifier)
|
||||||
|
.save(
|
||||||
|
(current) => current.copyWith.blockExternalAppsEnabled(value),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (enabled && policies.isNotEmpty)
|
||||||
|
_ManagedAppPolicyList(policies: policies),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ManagedAppPolicyList extends HookConsumerWidget {
|
||||||
|
final Map<String, IntentSourcePolicy> policies;
|
||||||
|
|
||||||
|
const _ManagedAppPolicyList({required this.policies});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final entries = policies.entries.toList(growable: false);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Managed apps', style: Theme.of(context).textTheme.titleSmall),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
for (final entry in entries)
|
||||||
|
_ManagedAppPolicyTile(
|
||||||
|
packageName: entry.key,
|
||||||
|
policy: entry.value,
|
||||||
|
onAction: (action) async {
|
||||||
|
final notifier = ref.read(
|
||||||
|
saveGeneralSettingsControllerProvider.notifier,
|
||||||
|
);
|
||||||
|
switch (action) {
|
||||||
|
case _PolicyAction.allow:
|
||||||
|
await notifier.save(
|
||||||
|
(current) => current.copyWith.externalAppIntentPolicies({
|
||||||
|
...current.externalAppIntentPolicies,
|
||||||
|
entry.key: IntentSourcePolicy.allow,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
case _PolicyAction.block:
|
||||||
|
await notifier.save(
|
||||||
|
(current) => current.copyWith.externalAppIntentPolicies({
|
||||||
|
...current.externalAppIntentPolicies,
|
||||||
|
entry.key: IntentSourcePolicy.block,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
case _PolicyAction.remove:
|
||||||
|
await notifier.save(
|
||||||
|
(current) => current.copyWith.externalAppIntentPolicies(
|
||||||
|
{...current.externalAppIntentPolicies}
|
||||||
|
..remove(entry.key),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ManagedAppPolicyTile extends HookConsumerWidget {
|
||||||
|
final String packageName;
|
||||||
|
final IntentSourcePolicy policy;
|
||||||
|
final Future<void> Function(_PolicyAction action) onAction;
|
||||||
|
|
||||||
|
const _ManagedAppPolicyTile({
|
||||||
|
required this.packageName,
|
||||||
|
required this.policy,
|
||||||
|
required this.onAction,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final label = ref.watch(
|
||||||
|
packageLabelProvider(packageName).select((value) => value.value),
|
||||||
|
);
|
||||||
|
final hasLabel = label != null && label.isNotEmpty;
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: Icon(
|
||||||
|
policy == IntentSourcePolicy.allow
|
||||||
|
? MdiIcons.checkCircleOutline
|
||||||
|
: MdiIcons.cancel,
|
||||||
|
),
|
||||||
|
title: Text(hasLabel ? label : packageName),
|
||||||
|
subtitle: Text(
|
||||||
|
hasLabel
|
||||||
|
? '${policy == IntentSourcePolicy.allow ? 'Always allowed' : 'Always blocked'} · $packageName'
|
||||||
|
: (policy == IntentSourcePolicy.allow
|
||||||
|
? 'Always allowed'
|
||||||
|
: 'Always blocked'),
|
||||||
|
),
|
||||||
|
trailing: PopupMenuButton<_PolicyAction>(
|
||||||
|
onSelected: onAction,
|
||||||
|
itemBuilder: (context) => const [
|
||||||
|
PopupMenuItem(value: _PolicyAction.allow, child: Text('Allow')),
|
||||||
|
PopupMenuItem(value: _PolicyAction.block, child: Text('Block')),
|
||||||
|
PopupMenuItem(value: _PolicyAction.remove, child: Text('Remove')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _PolicyAction { allow, block, remove }
|
||||||
|
|
||||||
class _NetworkProtectionSection extends StatelessWidget {
|
class _NetworkProtectionSection extends StatelessWidget {
|
||||||
const _NetworkProtectionSection();
|
const _NetworkProtectionSection();
|
||||||
|
|
||||||
|
|||||||
@@ -27,12 +27,33 @@ import 'package:simple_intent_receiver/simple_intent_receiver.dart';
|
|||||||
import 'package:uri_to_file/uri_to_file.dart' as uri_to_file;
|
import 'package:uri_to_file/uri_to_file.dart' as uri_to_file;
|
||||||
import 'package:weblibre/core/logger.dart';
|
import 'package:weblibre/core/logger.dart';
|
||||||
import 'package:weblibre/data/models/received_intent_parameter.dart';
|
import 'package:weblibre/data/models/received_intent_parameter.dart';
|
||||||
|
import 'package:weblibre/features/intent_gatekeeper/domain/services/intent_gatekeeper.dart';
|
||||||
|
|
||||||
part 'sharing_intent.g.dart';
|
part 'sharing_intent.g.dart';
|
||||||
|
|
||||||
final _sharingIntentTransformer =
|
StreamTransformer<Intent, ReceivedIntentParameter>
|
||||||
|
_buildSharingIntentTransformer(IntentGatekeeper gatekeeper) =>
|
||||||
StreamTransformer<Intent, ReceivedIntentParameter>.fromHandlers(
|
StreamTransformer<Intent, ReceivedIntentParameter>.fromHandlers(
|
||||||
handleData: (intent, sink) async {
|
handleData: (intent, sink) async {
|
||||||
|
// PWA shortcut intents carry our own signed context id — always allow.
|
||||||
|
final pwaContextId =
|
||||||
|
intent.action == 'android.intent.action.VIEW'
|
||||||
|
? intent.extra['pwa_context_id'] as String?
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (pwaContextId == null) {
|
||||||
|
final allowed = await gatekeeper.shouldAllow(
|
||||||
|
fromPackageName: intent.fromPackageName,
|
||||||
|
url: intent.data,
|
||||||
|
);
|
||||||
|
if (!allowed) {
|
||||||
|
logger.i(
|
||||||
|
'Blocked intent from ${intent.fromPackageName ?? 'unknown app'}',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final data = switch (intent.action) {
|
final data = switch (intent.action) {
|
||||||
'android.intent.action.PROCESS_TEXT' =>
|
'android.intent.action.PROCESS_TEXT' =>
|
||||||
intent.extra['android.intent.extra.PROCESS_TEXT'] as String?,
|
intent.extra['android.intent.extra.PROCESS_TEXT'] as String?,
|
||||||
@@ -46,10 +67,7 @@ final _sharingIntentTransformer =
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Extract container context from shortcut intents
|
// Extract container context from shortcut intents
|
||||||
final contextId =
|
final contextId = pwaContextId;
|
||||||
intent.action == 'android.intent.action.VIEW'
|
|
||||||
? intent.extra['pwa_context_id'] as String?
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
if (uri_to_file.isUriSupported(data)) {
|
if (uri_to_file.isUriSupported(data)) {
|
||||||
@@ -101,6 +119,7 @@ final _sharingIntentTransformer =
|
|||||||
@Riverpod(keepAlive: true)
|
@Riverpod(keepAlive: true)
|
||||||
Raw<Stream<ReceivedIntentParameter>> sharingIntentStream(Ref ref) {
|
Raw<Stream<ReceivedIntentParameter>> sharingIntentStream(Ref ref) {
|
||||||
final receiver = IntentReceiver.setUp();
|
final receiver = IntentReceiver.setUp();
|
||||||
|
final gatekeeper = ref.watch(intentGatekeeperProvider.notifier);
|
||||||
|
|
||||||
return receiver.events.transform(_sharingIntentTransformer);
|
return receiver.events.transform(_buildSharingIntentTransformer(gatekeeper));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,4 +56,4 @@ final class SharingIntentStreamProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$sharingIntentStreamHash() =>
|
String _$sharingIntentStreamHash() =>
|
||||||
r'486f994fc0e01a2cffdb19d93f0a332f96a9850c';
|
r'21b189c5df56f81ed5ab88115c11ddce1189cfb7';
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ class SettingDao extends DatabaseAccessor<UserDatabase> with $SettingDaoMixin {
|
|||||||
SettingDao(super.attachedDatabase);
|
SettingDao(super.attachedDatabase);
|
||||||
|
|
||||||
Future<int> updateSetting(String key, String? partitionKey, Object? value) {
|
Future<int> updateSetting(String key, String? partitionKey, Object? value) {
|
||||||
final normalizedValue = (value is Iterable) ? jsonEncode(value) : value;
|
final normalizedValue = (value is Iterable || value is Map)
|
||||||
|
? jsonEncode(value)
|
||||||
|
: value;
|
||||||
|
|
||||||
final driftvalue = normalizedValue.mapNotNull(
|
final driftvalue = normalizedValue.mapNotNull(
|
||||||
(normalizedValue) => DriftAny(normalizedValue),
|
(normalizedValue) => DriftAny(normalizedValue),
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import 'package:json_annotation/json_annotation.dart';
|
|||||||
import 'package:weblibre/core/routing/routes.dart';
|
import 'package:weblibre/core/routing/routes.dart';
|
||||||
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
|
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
|
||||||
import 'package:weblibre/features/bangs/data/models/bang_key.dart';
|
import 'package:weblibre/features/bangs/data/models/bang_key.dart';
|
||||||
|
import 'package:weblibre/features/intent_gatekeeper/domain/entities/intent_source_policy.dart';
|
||||||
import 'package:weblibre/features/search/domain/entities/abstract/i_search_suggestion_provider.dart';
|
import 'package:weblibre/features/search/domain/entities/abstract/i_search_suggestion_provider.dart';
|
||||||
|
|
||||||
part 'general_settings.g.dart';
|
part 'general_settings.g.dart';
|
||||||
@@ -119,6 +120,8 @@ class GeneralSettings with FastEquatable {
|
|||||||
final bool unshortenerEnabled;
|
final bool unshortenerEnabled;
|
||||||
final String unshortenerToken;
|
final String unshortenerToken;
|
||||||
final bool allowNonManifestPwaInstall;
|
final bool allowNonManifestPwaInstall;
|
||||||
|
final bool blockExternalAppsEnabled;
|
||||||
|
final Map<String, IntentSourcePolicy> externalAppIntentPolicies;
|
||||||
|
|
||||||
GeneralSettings({
|
GeneralSettings({
|
||||||
required this.themeMode,
|
required this.themeMode,
|
||||||
@@ -170,6 +173,8 @@ class GeneralSettings with FastEquatable {
|
|||||||
required this.unshortenerEnabled,
|
required this.unshortenerEnabled,
|
||||||
required this.unshortenerToken,
|
required this.unshortenerToken,
|
||||||
required this.allowNonManifestPwaInstall,
|
required this.allowNonManifestPwaInstall,
|
||||||
|
required this.blockExternalAppsEnabled,
|
||||||
|
required this.externalAppIntentPolicies,
|
||||||
});
|
});
|
||||||
|
|
||||||
GeneralSettings.withDefaults({
|
GeneralSettings.withDefaults({
|
||||||
@@ -222,6 +227,8 @@ class GeneralSettings with FastEquatable {
|
|||||||
bool? unshortenerEnabled,
|
bool? unshortenerEnabled,
|
||||||
String? unshortenerToken,
|
String? unshortenerToken,
|
||||||
bool? allowNonManifestPwaInstall,
|
bool? allowNonManifestPwaInstall,
|
||||||
|
bool? blockExternalAppsEnabled,
|
||||||
|
Map<String, IntentSourcePolicy>? externalAppIntentPolicies,
|
||||||
}) : themeMode = themeMode ?? ThemeMode.dark,
|
}) : themeMode = themeMode ?? ThemeMode.dark,
|
||||||
uiScaleFactor = uiScaleFactor ?? defaultUiScaleFactor,
|
uiScaleFactor = uiScaleFactor ?? defaultUiScaleFactor,
|
||||||
disableAnimations = disableAnimations ?? false,
|
disableAnimations = disableAnimations ?? false,
|
||||||
@@ -280,7 +287,9 @@ class GeneralSettings with FastEquatable {
|
|||||||
tabBarLongPressUrlCopy = tabBarLongPressUrlCopy ?? true,
|
tabBarLongPressUrlCopy = tabBarLongPressUrlCopy ?? true,
|
||||||
unshortenerEnabled = unshortenerEnabled ?? false,
|
unshortenerEnabled = unshortenerEnabled ?? false,
|
||||||
unshortenerToken = unshortenerToken ?? '',
|
unshortenerToken = unshortenerToken ?? '',
|
||||||
allowNonManifestPwaInstall = allowNonManifestPwaInstall ?? false;
|
allowNonManifestPwaInstall = allowNonManifestPwaInstall ?? false,
|
||||||
|
blockExternalAppsEnabled = blockExternalAppsEnabled ?? false,
|
||||||
|
externalAppIntentPolicies = externalAppIntentPolicies ?? const {};
|
||||||
|
|
||||||
factory GeneralSettings.fromJson(Map<String, dynamic> json) =>
|
factory GeneralSettings.fromJson(Map<String, dynamic> json) =>
|
||||||
_$GeneralSettingsFromJson(json);
|
_$GeneralSettingsFromJson(json);
|
||||||
@@ -353,5 +362,7 @@ class GeneralSettings with FastEquatable {
|
|||||||
unshortenerEnabled,
|
unshortenerEnabled,
|
||||||
unshortenerToken,
|
unshortenerToken,
|
||||||
allowNonManifestPwaInstall,
|
allowNonManifestPwaInstall,
|
||||||
|
blockExternalAppsEnabled,
|
||||||
|
externalAppIntentPolicies,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,12 @@ abstract class _$GeneralSettingsCWProxy {
|
|||||||
|
|
||||||
GeneralSettings allowNonManifestPwaInstall(bool allowNonManifestPwaInstall);
|
GeneralSettings allowNonManifestPwaInstall(bool allowNonManifestPwaInstall);
|
||||||
|
|
||||||
|
GeneralSettings blockExternalAppsEnabled(bool blockExternalAppsEnabled);
|
||||||
|
|
||||||
|
GeneralSettings externalAppIntentPolicies(
|
||||||
|
Map<String, IntentSourcePolicy> externalAppIntentPolicies,
|
||||||
|
);
|
||||||
|
|
||||||
/// Creates a new instance with the provided field values.
|
/// Creates a new instance with the provided field values.
|
||||||
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GeneralSettings(...).copyWith.fieldName(value)`.
|
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GeneralSettings(...).copyWith.fieldName(value)`.
|
||||||
///
|
///
|
||||||
@@ -180,6 +186,8 @@ abstract class _$GeneralSettingsCWProxy {
|
|||||||
bool unshortenerEnabled,
|
bool unshortenerEnabled,
|
||||||
String unshortenerToken,
|
String unshortenerToken,
|
||||||
bool allowNonManifestPwaInstall,
|
bool allowNonManifestPwaInstall,
|
||||||
|
bool blockExternalAppsEnabled,
|
||||||
|
Map<String, IntentSourcePolicy> externalAppIntentPolicies,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,6 +406,15 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
|
|||||||
GeneralSettings allowNonManifestPwaInstall(bool allowNonManifestPwaInstall) =>
|
GeneralSettings allowNonManifestPwaInstall(bool allowNonManifestPwaInstall) =>
|
||||||
call(allowNonManifestPwaInstall: allowNonManifestPwaInstall);
|
call(allowNonManifestPwaInstall: allowNonManifestPwaInstall);
|
||||||
|
|
||||||
|
@override
|
||||||
|
GeneralSettings blockExternalAppsEnabled(bool blockExternalAppsEnabled) =>
|
||||||
|
call(blockExternalAppsEnabled: blockExternalAppsEnabled);
|
||||||
|
|
||||||
|
@override
|
||||||
|
GeneralSettings externalAppIntentPolicies(
|
||||||
|
Map<String, IntentSourcePolicy> externalAppIntentPolicies,
|
||||||
|
) => call(externalAppIntentPolicies: externalAppIntentPolicies);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
/// Creates a new instance with the provided field values.
|
/// Creates a new instance with the provided field values.
|
||||||
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GeneralSettings(...).copyWith.fieldName(value)`.
|
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GeneralSettings(...).copyWith.fieldName(value)`.
|
||||||
@@ -457,6 +474,8 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
|
|||||||
Object? unshortenerEnabled = const $CopyWithPlaceholder(),
|
Object? unshortenerEnabled = const $CopyWithPlaceholder(),
|
||||||
Object? unshortenerToken = const $CopyWithPlaceholder(),
|
Object? unshortenerToken = const $CopyWithPlaceholder(),
|
||||||
Object? allowNonManifestPwaInstall = const $CopyWithPlaceholder(),
|
Object? allowNonManifestPwaInstall = const $CopyWithPlaceholder(),
|
||||||
|
Object? blockExternalAppsEnabled = const $CopyWithPlaceholder(),
|
||||||
|
Object? externalAppIntentPolicies = const $CopyWithPlaceholder(),
|
||||||
}) {
|
}) {
|
||||||
return GeneralSettings(
|
return GeneralSettings(
|
||||||
themeMode: themeMode == const $CopyWithPlaceholder() || themeMode == null
|
themeMode: themeMode == const $CopyWithPlaceholder() || themeMode == null
|
||||||
@@ -747,6 +766,18 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
|
|||||||
? _value.allowNonManifestPwaInstall
|
? _value.allowNonManifestPwaInstall
|
||||||
// ignore: cast_nullable_to_non_nullable
|
// ignore: cast_nullable_to_non_nullable
|
||||||
: allowNonManifestPwaInstall as bool,
|
: allowNonManifestPwaInstall as bool,
|
||||||
|
blockExternalAppsEnabled:
|
||||||
|
blockExternalAppsEnabled == const $CopyWithPlaceholder() ||
|
||||||
|
blockExternalAppsEnabled == null
|
||||||
|
? _value.blockExternalAppsEnabled
|
||||||
|
// ignore: cast_nullable_to_non_nullable
|
||||||
|
: blockExternalAppsEnabled as bool,
|
||||||
|
externalAppIntentPolicies:
|
||||||
|
externalAppIntentPolicies == const $CopyWithPlaceholder() ||
|
||||||
|
externalAppIntentPolicies == null
|
||||||
|
? _value.externalAppIntentPolicies
|
||||||
|
// ignore: cast_nullable_to_non_nullable
|
||||||
|
: externalAppIntentPolicies as Map<String, IntentSourcePolicy>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -858,6 +889,11 @@ GeneralSettings _$GeneralSettingsFromJson(
|
|||||||
unshortenerEnabled: json['unshortenerEnabled'] as bool?,
|
unshortenerEnabled: json['unshortenerEnabled'] as bool?,
|
||||||
unshortenerToken: json['unshortenerToken'] as String?,
|
unshortenerToken: json['unshortenerToken'] as String?,
|
||||||
allowNonManifestPwaInstall: json['allowNonManifestPwaInstall'] as bool?,
|
allowNonManifestPwaInstall: json['allowNonManifestPwaInstall'] as bool?,
|
||||||
|
blockExternalAppsEnabled: json['blockExternalAppsEnabled'] as bool?,
|
||||||
|
externalAppIntentPolicies:
|
||||||
|
(json['externalAppIntentPolicies'] as Map<String, dynamic>?)?.map(
|
||||||
|
(k, e) => MapEntry(k, $enumDecode(_$IntentSourcePolicyEnumMap, e)),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$GeneralSettingsToJson(
|
Map<String, dynamic> _$GeneralSettingsToJson(
|
||||||
@@ -923,6 +959,10 @@ Map<String, dynamic> _$GeneralSettingsToJson(
|
|||||||
'unshortenerEnabled': instance.unshortenerEnabled,
|
'unshortenerEnabled': instance.unshortenerEnabled,
|
||||||
'unshortenerToken': instance.unshortenerToken,
|
'unshortenerToken': instance.unshortenerToken,
|
||||||
'allowNonManifestPwaInstall': instance.allowNonManifestPwaInstall,
|
'allowNonManifestPwaInstall': instance.allowNonManifestPwaInstall,
|
||||||
|
'blockExternalAppsEnabled': instance.blockExternalAppsEnabled,
|
||||||
|
'externalAppIntentPolicies': instance.externalAppIntentPolicies.map(
|
||||||
|
(k, e) => MapEntry(k, _$IntentSourcePolicyEnumMap[e]!),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
const _$ThemeModeEnumMap = {
|
const _$ThemeModeEnumMap = {
|
||||||
@@ -985,3 +1025,8 @@ const _$QuickTabSwitcherModeEnumMap = {
|
|||||||
QuickTabSwitcherMode.lastUsedTabs: 'lastUsedTabs',
|
QuickTabSwitcherMode.lastUsedTabs: 'lastUsedTabs',
|
||||||
QuickTabSwitcherMode.containerTabs: 'containerTabs',
|
QuickTabSwitcherMode.containerTabs: 'containerTabs',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const _$IntentSourcePolicyEnumMap = {
|
||||||
|
IntentSourcePolicy.allow: 'allow',
|
||||||
|
IntentSourcePolicy.block: 'block',
|
||||||
|
};
|
||||||
|
|||||||
@@ -231,6 +231,13 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository {
|
|||||||
),
|
),
|
||||||
'allowNonManifestPwaInstall': settings['allowNonManifestPwaInstall']
|
'allowNonManifestPwaInstall': settings['allowNonManifestPwaInstall']
|
||||||
?.readAs(DriftSqlType.bool, db.typeMapping),
|
?.readAs(DriftSqlType.bool, db.typeMapping),
|
||||||
|
'blockExternalAppsEnabled': settings['blockExternalAppsEnabled']?.readAs(
|
||||||
|
DriftSqlType.bool,
|
||||||
|
db.typeMapping,
|
||||||
|
),
|
||||||
|
'externalAppIntentPolicies': settings['externalAppIntentPolicies']
|
||||||
|
?.readAs(DriftSqlType.string, db.typeMapping)
|
||||||
|
.mapNotNull(jsonDecode),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$generalSettingsRepositoryHash() =>
|
String _$generalSettingsRepositoryHash() =>
|
||||||
r'afc63f4d929ea146f0b8a7c0f6936b06c5a41024';
|
r'9d34ea4b802d2d1b1c9543f41ad5fdf28f279e06';
|
||||||
|
|
||||||
abstract class _$GeneralSettingsRepository
|
abstract class _$GeneralSettingsRepository
|
||||||
extends $StreamNotifier<GeneralSettings> {
|
extends $StreamNotifier<GeneralSettings> {
|
||||||
|
|||||||
+55
@@ -11,12 +11,15 @@ import android.app.AlertDialog
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.ShortcutManager
|
import android.content.pm.ShortcutManager
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import eu.weblibre.flutter_mozilla_components.Components
|
import eu.weblibre.flutter_mozilla_components.Components
|
||||||
import eu.weblibre.flutter_mozilla_components.GlobalComponents
|
import eu.weblibre.flutter_mozilla_components.GlobalComponents
|
||||||
import eu.weblibre.flutter_mozilla_components.PwaConstants
|
import eu.weblibre.flutter_mozilla_components.PwaConstants
|
||||||
|
import eu.weblibre.flutter_mozilla_components.gatekeeper.IntentBlockNotifier
|
||||||
|
import eu.weblibre.flutter_mozilla_components.gatekeeper.IntentGatekeeperPreferences
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -58,9 +61,55 @@ class IntentReceiverActivity : Activity() {
|
|||||||
intent.flags = intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK.inv()
|
intent.flags = intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK.inv()
|
||||||
intent.flags = intent.flags and Intent.FLAG_ACTIVITY_CLEAR_TASK.inv()
|
intent.flags = intent.flags and Intent.FLAG_ACTIVITY_CLEAR_TASK.inv()
|
||||||
|
|
||||||
|
if (shouldBlockIntent(intent)) {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
processIntent(intent)
|
processIntent(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fast native block-check. Only rejects packages explicitly on the blocked
|
||||||
|
* list; allowed and unknown packages fall through to the Flutter-side
|
||||||
|
* gatekeeper which can still prompt the user.
|
||||||
|
*
|
||||||
|
* PWA launches carrying our trusted profile metadata are never blocked here —
|
||||||
|
* those are treated as internal launches regardless of the caller.
|
||||||
|
*/
|
||||||
|
private fun shouldBlockIntent(intent: Intent): Boolean {
|
||||||
|
if (!IntentGatekeeperPreferences.isEnabled(applicationContext)) return false
|
||||||
|
if (intent.hasExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID)) return false
|
||||||
|
|
||||||
|
val caller = resolveCallerPackage(intent) ?: return false
|
||||||
|
if (caller == packageName) return false
|
||||||
|
if (!IntentGatekeeperPreferences.isBlocked(applicationContext, caller)) return false
|
||||||
|
|
||||||
|
Log.i(TAG, "Blocking intent from $caller (native gatekeeper)")
|
||||||
|
IntentBlockNotifier.notifyBlocked(applicationContext, caller)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveCallerPackage(intent: Intent): String? {
|
||||||
|
referrer?.let { uri ->
|
||||||
|
if (uri.scheme == "android-app") {
|
||||||
|
uri.host?.let { return it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val referrerUri: Uri? = intent.getParcelableExtra(Intent.EXTRA_REFERRER)
|
||||||
|
if (referrerUri?.scheme == "android-app") {
|
||||||
|
referrerUri.host?.let { return it }
|
||||||
|
}
|
||||||
|
|
||||||
|
intent.getStringExtra(Intent.EXTRA_REFERRER_NAME)?.let { name ->
|
||||||
|
Uri.parse(name).takeIf { it.scheme == "android-app" }?.host?.let { return it }
|
||||||
|
}
|
||||||
|
|
||||||
|
return callingPackage
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
coroutineScope.cancel()
|
coroutineScope.cancel()
|
||||||
@@ -439,6 +488,12 @@ class IntentReceiverActivity : Activity() {
|
|||||||
val mainActivityIntent = Intent(intent).apply {
|
val mainActivityIntent = Intent(intent).apply {
|
||||||
setClassName(this@IntentReceiverActivity, "eu.weblibre.gecko.MainActivity")
|
setClassName(this@IntentReceiverActivity, "eu.weblibre.gecko.MainActivity")
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
// Preserve the original caller so the gatekeeper on the Flutter side
|
||||||
|
// can identify which app triggered this intent (getReferrer() in the
|
||||||
|
// forwarded activity would otherwise resolve to ourselves).
|
||||||
|
if (!hasExtra(Intent.EXTRA_REFERRER) && !hasExtra(Intent.EXTRA_REFERRER_NAME)) {
|
||||||
|
referrer?.let { putExtra(Intent.EXTRA_REFERRER, it) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
startActivity(mainActivityIntent)
|
startActivity(mainActivityIntent)
|
||||||
finish()
|
finish()
|
||||||
|
|||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*/
|
||||||
|
package eu.weblibre.flutter_mozilla_components.gatekeeper
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import eu.weblibre.flutter_mozilla_components.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts a purely informational notification when an intent is blocked by the
|
||||||
|
* gatekeeper. The notification has no actions and no content intent.
|
||||||
|
*/
|
||||||
|
object IntentBlockNotifier {
|
||||||
|
private const val CHANNEL_ID = "intent_gatekeeper_channel"
|
||||||
|
private const val CHANNEL_NAME = "Blocked app launches"
|
||||||
|
private const val CHANNEL_DESC = "Informs you when another app is prevented from opening WebLibre."
|
||||||
|
|
||||||
|
fun notifyBlocked(context: Context, packageName: String) {
|
||||||
|
val appCtx = context.applicationContext
|
||||||
|
ensureChannel(appCtx)
|
||||||
|
|
||||||
|
val label = resolveAppLabel(appCtx, packageName) ?: packageName
|
||||||
|
val notificationId = (System.currentTimeMillis() and 0x7FFFFFFF).toInt()
|
||||||
|
|
||||||
|
val notification: Notification = NotificationCompat.Builder(appCtx, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||||
|
.setContentTitle("Blocked app launch")
|
||||||
|
.setContentText("Prevented $label from opening WebLibre.")
|
||||||
|
.setStyle(
|
||||||
|
NotificationCompat.BigTextStyle()
|
||||||
|
.bigText("Prevented $label from opening WebLibre.")
|
||||||
|
)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setSilent(true)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setShowWhen(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val manager = ContextCompat.getSystemService(appCtx, NotificationManager::class.java)
|
||||||
|
?: return
|
||||||
|
manager.notify(notificationId, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureChannel(context: Context) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
val manager = ContextCompat.getSystemService(context, NotificationManager::class.java)
|
||||||
|
?: return
|
||||||
|
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
||||||
|
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
CHANNEL_NAME,
|
||||||
|
NotificationManager.IMPORTANCE_DEFAULT,
|
||||||
|
).apply {
|
||||||
|
description = CHANNEL_DESC
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
manager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveAppLabel(context: Context, packageName: String): String? {
|
||||||
|
return try {
|
||||||
|
val pm = context.packageManager
|
||||||
|
val info = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
pm.getApplicationInfo(
|
||||||
|
packageName,
|
||||||
|
PackageManager.ApplicationInfoFlags.of(0),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
pm.getApplicationInfo(packageName, 0)
|
||||||
|
}
|
||||||
|
pm.getApplicationLabel(info).toString()
|
||||||
|
} catch (_: PackageManager.NameNotFoundException) {
|
||||||
|
null
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*/
|
||||||
|
package eu.weblibre.flutter_mozilla_components.gatekeeper
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-package shared-prefs file used to replicate the Flutter-side intent
|
||||||
|
* gatekeeper policy to the native side so [IntentReceiverActivity] can block
|
||||||
|
* intents without launching Flutter.
|
||||||
|
*
|
||||||
|
* The file name is a stable constant: other packages (e.g. simple_intent_receiver)
|
||||||
|
* write to the same file using [Context.getSharedPreferences] with this name.
|
||||||
|
*/
|
||||||
|
object IntentGatekeeperPreferences {
|
||||||
|
const val PREFS_NAME = "weblibre_intent_gatekeeper"
|
||||||
|
const val KEY_ENABLED = "enabled"
|
||||||
|
const val KEY_BLOCKED_PACKAGES = "blocked_packages"
|
||||||
|
|
||||||
|
fun get(context: Context): SharedPreferences =
|
||||||
|
context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
fun isEnabled(context: Context): Boolean =
|
||||||
|
get(context).getBoolean(KEY_ENABLED, false)
|
||||||
|
|
||||||
|
fun isBlocked(context: Context, packageName: String): Boolean {
|
||||||
|
val prefs = get(context)
|
||||||
|
if (!prefs.getBoolean(KEY_ENABLED, false)) return false
|
||||||
|
val blocked = prefs.getStringSet(KEY_BLOCKED_PACKAGES, emptySet()) ?: return false
|
||||||
|
return packageName in blocked
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*/
|
||||||
|
package eu.weblibre.simple_intent_receiver
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import eu.weblibre.simple_intent_receiver.pigeons.IntentGatekeeperHostApi
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists the Flutter-side gatekeeper policy to a shared-prefs file that
|
||||||
|
* [eu.weblibre.flutter_mozilla_components.activities.IntentReceiverActivity]
|
||||||
|
* reads on each incoming intent.
|
||||||
|
*
|
||||||
|
* The prefs file name MUST match
|
||||||
|
* [eu.weblibre.flutter_mozilla_components.gatekeeper.IntentGatekeeperPreferences.PREFS_NAME].
|
||||||
|
*/
|
||||||
|
class IntentGatekeeperHostApiImpl(private val context: Context) : IntentGatekeeperHostApi {
|
||||||
|
companion object {
|
||||||
|
private const val PREFS_NAME = "weblibre_intent_gatekeeper"
|
||||||
|
private const val KEY_ENABLED = "enabled"
|
||||||
|
private const val KEY_BLOCKED_PACKAGES = "blocked_packages"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setConfig(enabled: Boolean, blockedPackages: List<String>) {
|
||||||
|
val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit()
|
||||||
|
.putBoolean(KEY_ENABLED, enabled)
|
||||||
|
.putStringSet(KEY_BLOCKED_PACKAGES, blockedPackages.toSet())
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun resolvePackageLabel(packageName: String): String? {
|
||||||
|
return try {
|
||||||
|
val pm = context.applicationContext.packageManager
|
||||||
|
val info = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
pm.getApplicationInfo(packageName, PackageManager.ApplicationInfoFlags.of(0))
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
pm.getApplicationInfo(packageName, 0)
|
||||||
|
}
|
||||||
|
pm.getApplicationLabel(info).toString()
|
||||||
|
} catch (_: PackageManager.NameNotFoundException) {
|
||||||
|
null
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
-1
@@ -22,7 +22,10 @@ package eu.weblibre.simple_intent_receiver
|
|||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.ApplicationInfo
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import io.flutter.Log
|
import io.flutter.Log
|
||||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||||
@@ -30,20 +33,29 @@ import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
|||||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||||
import io.flutter.plugin.common.PluginRegistry
|
import io.flutter.plugin.common.PluginRegistry
|
||||||
import eu.weblibre.simple_intent_receiver.pigeons.Intent as PigeonIntent
|
import eu.weblibre.simple_intent_receiver.pigeons.Intent as PigeonIntent
|
||||||
|
import eu.weblibre.simple_intent_receiver.pigeons.IntentGatekeeperHostApi
|
||||||
|
|
||||||
class SimpleIntentReceiverPlugin: FlutterPlugin, ActivityAware, PluginRegistry.NewIntentListener {
|
class SimpleIntentReceiverPlugin: FlutterPlugin, ActivityAware, PluginRegistry.NewIntentListener {
|
||||||
private lateinit var context: Context
|
private lateinit var context: Context
|
||||||
private var intentReceiver: IntentReceiver? = null
|
private var intentReceiver: IntentReceiver? = null
|
||||||
private var lastHandledIntent: String? = null
|
private var lastHandledIntent: String? = null
|
||||||
private var activity: Activity? = null
|
private var activity: Activity? = null
|
||||||
|
private var binaryMessenger: io.flutter.plugin.common.BinaryMessenger? = null
|
||||||
|
|
||||||
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
context = flutterPluginBinding.applicationContext
|
context = flutterPluginBinding.applicationContext
|
||||||
intentReceiver = IntentReceiver(flutterPluginBinding.binaryMessenger)
|
intentReceiver = IntentReceiver(flutterPluginBinding.binaryMessenger)
|
||||||
|
binaryMessenger = flutterPluginBinding.binaryMessenger
|
||||||
|
IntentGatekeeperHostApi.setUp(
|
||||||
|
flutterPluginBinding.binaryMessenger,
|
||||||
|
IntentGatekeeperHostApiImpl(flutterPluginBinding.applicationContext),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
intentReceiver = null
|
intentReceiver = null
|
||||||
|
binaryMessenger?.let { IntentGatekeeperHostApi.setUp(it, null) }
|
||||||
|
binaryMessenger = null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||||
@@ -118,10 +130,59 @@ class SimpleIntentReceiverPlugin: FlutterPlugin, ActivityAware, PluginRegistry.N
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun resolveCallerPackage(intent: Intent): String? {
|
||||||
|
val raw = resolveRawCallerPackage(intent) ?: return null
|
||||||
|
// Treat system packages (launcher, shell, SystemUI, etc.) as internal — the
|
||||||
|
// gatekeeper shouldn't prompt the user when the OS itself forwards an intent.
|
||||||
|
if (isSystemPackage(raw)) return null
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveRawCallerPackage(intent: Intent): String? {
|
||||||
|
// 1. Try Activity.getReferrer() — handles EXTRA_REFERRER/_NAME and real caller.
|
||||||
|
activity?.referrer?.let { uri ->
|
||||||
|
if (uri.scheme == "android-app") {
|
||||||
|
uri.host?.let { return it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fallback to explicit referrer extras on the intent itself.
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val referrerUri: Uri? = intent.getParcelableExtra(Intent.EXTRA_REFERRER)
|
||||||
|
if (referrerUri?.scheme == "android-app") {
|
||||||
|
referrerUri.host?.let { return it }
|
||||||
|
}
|
||||||
|
|
||||||
|
intent.getStringExtra(Intent.EXTRA_REFERRER_NAME)?.let { name ->
|
||||||
|
Uri.parse(name).takeIf { it.scheme == "android-app" }?.host?.let { return it }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Caller for startActivityForResult flows.
|
||||||
|
return activity?.callingPackage
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isSystemPackage(packageName: String): Boolean {
|
||||||
|
return try {
|
||||||
|
val pm = context.packageManager
|
||||||
|
val info = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
pm.getApplicationInfo(packageName, PackageManager.ApplicationInfoFlags.of(0))
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
pm.getApplicationInfo(packageName, 0)
|
||||||
|
}
|
||||||
|
val systemFlags = ApplicationInfo.FLAG_SYSTEM or ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
|
||||||
|
(info.flags and systemFlags) != 0
|
||||||
|
} catch (_: PackageManager.NameNotFoundException) {
|
||||||
|
false
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun convertToPigeonIntent(intent: Intent): PigeonIntent {
|
private fun convertToPigeonIntent(intent: Intent): PigeonIntent {
|
||||||
val action = intent.action
|
val action = intent.action
|
||||||
val data = intent.dataString
|
val data = intent.dataString
|
||||||
val fromPackageName = intent.getPackage()
|
val fromPackageName = resolveCallerPackage(intent)
|
||||||
|
|
||||||
val categories = ArrayList<String>()
|
val categories = ArrayList<String>()
|
||||||
intent.categories?.let {
|
intent.categories?.let {
|
||||||
|
|||||||
+82
@@ -17,6 +17,26 @@ private object IntentPigeonUtils {
|
|||||||
|
|
||||||
fun createConnectionError(channelName: String): FlutterError {
|
fun createConnectionError(channelName: String): FlutterError {
|
||||||
return FlutterError("channel-error", "Unable to establish connection on channel: '$channelName'.", "") }
|
return FlutterError("channel-error", "Unable to establish connection on channel: '$channelName'.", "") }
|
||||||
|
|
||||||
|
fun wrapResult(result: Any?): List<Any?> {
|
||||||
|
return listOf(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun wrapError(exception: Throwable): List<Any?> {
|
||||||
|
return if (exception is FlutterError) {
|
||||||
|
listOf(
|
||||||
|
exception.code,
|
||||||
|
exception.message,
|
||||||
|
exception.details
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
listOf(
|
||||||
|
exception.javaClass.simpleName,
|
||||||
|
exception.toString(),
|
||||||
|
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
fun doubleEquals(a: Double, b: Double): Boolean {
|
fun doubleEquals(a: Double, b: Double): Boolean {
|
||||||
// Normalize -0.0 to 0.0 and handle NaN equality.
|
// Normalize -0.0 to 0.0 and handle NaN equality.
|
||||||
return (if (a == 0.0) 0.0 else a) == (if (b == 0.0) 0.0 else b) || (a.isNaN() && b.isNaN())
|
return (if (a == 0.0) 0.0 else a) == (if (b == 0.0) 0.0 else b) || (a.isNaN() && b.isNaN())
|
||||||
@@ -276,3 +296,65 @@ class IntentEvents(private val binaryMessenger: BinaryMessenger, private val mes
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||||
|
interface IntentGatekeeperHostApi {
|
||||||
|
/**
|
||||||
|
* Replicates the blocked-packages policy to the native side so the
|
||||||
|
* [IntentReceiverActivity] can reject intents without launching Flutter.
|
||||||
|
*/
|
||||||
|
fun setConfig(enabled: Boolean, blockedPackages: List<String>)
|
||||||
|
/**
|
||||||
|
* Resolves a package name to its user-visible application label via
|
||||||
|
* [PackageManager]. Returns `null` if the package is not installed or the
|
||||||
|
* label cannot be resolved.
|
||||||
|
*/
|
||||||
|
fun resolvePackageLabel(packageName: String): String?
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** The codec used by IntentGatekeeperHostApi. */
|
||||||
|
val codec: MessageCodec<Any?> by lazy {
|
||||||
|
IntentPigeonCodec()
|
||||||
|
}
|
||||||
|
/** Sets up an instance of `IntentGatekeeperHostApi` to handle messages through the `binaryMessenger`. */
|
||||||
|
@JvmOverloads
|
||||||
|
fun setUp(binaryMessenger: BinaryMessenger, api: IntentGatekeeperHostApi?, messageChannelSuffix: String = "") {
|
||||||
|
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.setConfig$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { message, reply ->
|
||||||
|
val args = message as List<Any?>
|
||||||
|
val enabledArg = args[0] as Boolean
|
||||||
|
val blockedPackagesArg = args[1] as List<String>
|
||||||
|
val wrapped: List<Any?> = try {
|
||||||
|
api.setConfig(enabledArg, blockedPackagesArg)
|
||||||
|
listOf(null)
|
||||||
|
} catch (exception: Throwable) {
|
||||||
|
IntentPigeonUtils.wrapError(exception)
|
||||||
|
}
|
||||||
|
reply.reply(wrapped)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.resolvePackageLabel$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { message, reply ->
|
||||||
|
val args = message as List<Any?>
|
||||||
|
val packageNameArg = args[0] as String
|
||||||
|
val wrapped: List<Any?> = try {
|
||||||
|
listOf(api.resolvePackageLabel(packageNameArg))
|
||||||
|
} catch (exception: Throwable) {
|
||||||
|
IntentPigeonUtils.wrapError(exception)
|
||||||
|
}
|
||||||
|
reply.reply(wrapped)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,4 +18,4 @@
|
|||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
export 'src/intent_receiver.dart';
|
export 'src/intent_receiver.dart';
|
||||||
export 'src/pigeons/intent.g.dart' show Intent;
|
export 'src/pigeons/intent.g.dart' show Intent, IntentGatekeeperHostApi;
|
||||||
|
|||||||
@@ -9,6 +9,32 @@ import 'dart:typed_data' show Float64List, Int32List, Int64List;
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
|
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
|
||||||
|
|
||||||
|
Object? _extractReplyValueOrThrow(
|
||||||
|
List<Object?>? replyList,
|
||||||
|
String channelName, {
|
||||||
|
required bool isNullValid,
|
||||||
|
}) {
|
||||||
|
if (replyList == null) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: 'channel-error',
|
||||||
|
message: 'Unable to establish connection on channel: "$channelName".',
|
||||||
|
);
|
||||||
|
} else if (replyList.length > 1) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: replyList[0]! as String,
|
||||||
|
message: replyList[1] as String?,
|
||||||
|
details: replyList[2],
|
||||||
|
);
|
||||||
|
} else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: 'null-error',
|
||||||
|
message: 'Host platform returned null value for non-null return value.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return replyList.firstOrNull;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty = false}) {
|
List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty = false}) {
|
||||||
if (empty) {
|
if (empty) {
|
||||||
return <Object?>[];
|
return <Object?>[];
|
||||||
@@ -204,3 +230,59 @@ abstract class IntentEvents {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class IntentGatekeeperHostApi {
|
||||||
|
/// Constructor for [IntentGatekeeperHostApi]. The [binaryMessenger] named argument is
|
||||||
|
/// available for dependency injection. If it is left null, the default
|
||||||
|
/// BinaryMessenger will be used which routes to the host platform.
|
||||||
|
IntentGatekeeperHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||||
|
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||||
|
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||||
|
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||||
|
|
||||||
|
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||||
|
|
||||||
|
final String pigeonVar_messageChannelSuffix;
|
||||||
|
|
||||||
|
/// Replicates the blocked-packages policy to the native side so the
|
||||||
|
/// [IntentReceiverActivity] can reject intents without launching Flutter.
|
||||||
|
Future<void> setConfig(bool enabled, List<String> blockedPackages) async {
|
||||||
|
final pigeonVar_channelName = 'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.setConfig$pigeonVar_messageChannelSuffix';
|
||||||
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
pigeonVar_channelName,
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
|
);
|
||||||
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[enabled, blockedPackages]);
|
||||||
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
|
_extractReplyValueOrThrow(
|
||||||
|
pigeonVar_replyList,
|
||||||
|
pigeonVar_channelName,
|
||||||
|
isNullValid: true,
|
||||||
|
)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves a package name to its user-visible application label via
|
||||||
|
/// [PackageManager]. Returns `null` if the package is not installed or the
|
||||||
|
/// label cannot be resolved.
|
||||||
|
Future<String?> resolvePackageLabel(String packageName) async {
|
||||||
|
final pigeonVar_channelName = 'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.resolvePackageLabel$pigeonVar_messageChannelSuffix';
|
||||||
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
pigeonVar_channelName,
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
|
);
|
||||||
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[packageName]);
|
||||||
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
|
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||||
|
pigeonVar_replyList,
|
||||||
|
pigeonVar_channelName,
|
||||||
|
isNullValid: true,
|
||||||
|
)
|
||||||
|
;
|
||||||
|
return pigeonVar_replyValue as String?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,3 +53,15 @@ class Intent {
|
|||||||
abstract class IntentEvents {
|
abstract class IntentEvents {
|
||||||
void onIntentReceived(int sequence, Intent intent);
|
void onIntentReceived(int sequence, Intent intent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HostApi()
|
||||||
|
abstract class IntentGatekeeperHostApi {
|
||||||
|
/// Replicates the blocked-packages policy to the native side so the
|
||||||
|
/// [IntentReceiverActivity] can reject intents without launching Flutter.
|
||||||
|
void setConfig(bool enabled, List<String> blockedPackages);
|
||||||
|
|
||||||
|
/// Resolves a package name to its user-visible application label via
|
||||||
|
/// [PackageManager]. Returns `null` if the package is not installed or the
|
||||||
|
/// label cannot be resolved.
|
||||||
|
String? resolvePackageLabel(String packageName);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user