fix cleanup issues
This commit is contained in:
+32
-4
@@ -17,8 +17,10 @@
|
|||||||
* You should have received a copy of the GNU Affero General Public License
|
* 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/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:weblibre/core/logger.dart';
|
||||||
import 'package:weblibre/features/bangs/domain/repositories/data.dart';
|
import 'package:weblibre/features/bangs/domain/repositories/data.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart';
|
||||||
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||||
@@ -27,9 +29,17 @@ part 'browser_data.g.dart';
|
|||||||
|
|
||||||
@Riverpod(keepAlive: true)
|
@Riverpod(keepAlive: true)
|
||||||
class BrowserDataService extends _$BrowserDataService {
|
class BrowserDataService extends _$BrowserDataService {
|
||||||
final _service = GeckoDeleteBrowserDataService();
|
/// The [service] seam is test-only injection; production always constructs
|
||||||
|
/// the default. Kept `final` so the live singleton's backing service can't be
|
||||||
|
/// swapped at runtime.
|
||||||
|
@visibleForTesting
|
||||||
|
BrowserDataService({GeckoDeleteBrowserDataService? service})
|
||||||
|
: _service = service ?? GeckoDeleteBrowserDataService();
|
||||||
|
|
||||||
|
final GeckoDeleteBrowserDataService _service;
|
||||||
|
|
||||||
var _onStartDeleted = false;
|
var _onStartDeleted = false;
|
||||||
|
var _onStartContainerDataCleared = false;
|
||||||
|
|
||||||
Future<void> deleteDataOnEngineStart(
|
Future<void> deleteDataOnEngineStart(
|
||||||
Set<DeleteBrowsingDataType>? types,
|
Set<DeleteBrowsingDataType>? types,
|
||||||
@@ -73,14 +83,32 @@ class BrowserDataService extends _$BrowserDataService {
|
|||||||
return _service.clearDataForContext(contextId);
|
return _service.clearDataForContext(contextId);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> clearContainerDataOnEngineStart(List<String> contextIds) async {
|
/// Clears Gecko session-context data for every [contextId], best-effort: a
|
||||||
if (!_onStartDeleted && contextIds.isNotEmpty) {
|
/// single failing context is logged and skipped so it never blocks the rest.
|
||||||
for (final contextId in contextIds) {
|
///
|
||||||
|
/// Not gated by the one-shot startup guard — used both by the guarded
|
||||||
|
/// [clearContainerDataOnEngineStart] fallback and directly on explicit Quit.
|
||||||
|
Future<void> clearContainerData(List<String> contextIds) async {
|
||||||
|
for (final contextId in contextIds) {
|
||||||
|
try {
|
||||||
await clearDataForContext(contextId);
|
await clearDataForContext(contextId);
|
||||||
|
} catch (e, st) {
|
||||||
|
logger.e(
|
||||||
|
'Failed to clear data for container context $contextId',
|
||||||
|
error: e,
|
||||||
|
stackTrace: st,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> clearContainerDataOnEngineStart(List<String> contextIds) async {
|
||||||
|
if (!_onStartContainerDataCleared && contextIds.isNotEmpty) {
|
||||||
|
_onStartContainerDataCleared = true;
|
||||||
|
await clearContainerData(contextIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void build() {}
|
void build() {}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -42,7 +42,7 @@ final class BrowserDataServiceProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$browserDataServiceHash() =>
|
String _$browserDataServiceHash() =>
|
||||||
r'a317f0d56d1bfc61dc99af1e60080aaa248d6eed';
|
r'2df2f652342efc3e16606b92fdef6062b02f72df';
|
||||||
|
|
||||||
abstract class _$BrowserDataService extends $Notifier<void> {
|
abstract class _$BrowserDataService extends $Notifier<void> {
|
||||||
void build();
|
void build();
|
||||||
|
|||||||
+33
-27
@@ -122,31 +122,33 @@ class ContainerEditScreen extends HookConsumerWidget {
|
|||||||
name: name.isNotEmpty ? name : null,
|
name: name.isNotEmpty ? name : null,
|
||||||
color: selectedColor.value,
|
color: selectedColor.value,
|
||||||
isPinned: isPinned.value,
|
isPinned: isPinned.value,
|
||||||
metadata: initialContainer.metadata.copyWith(
|
metadata: initialContainer.metadata
|
||||||
contextualIdentity: contextualIdentity.value,
|
.copyWith(
|
||||||
iconData: selectedIcon.value,
|
contextualIdentity: contextualIdentity.value,
|
||||||
proxyConnectionId: contextualIdentity.value != null
|
iconData: selectedIcon.value,
|
||||||
? proxyConnectionId.value
|
proxyConnectionId: contextualIdentity.value != null
|
||||||
: null,
|
? proxyConnectionId.value
|
||||||
clearDataOnExit:
|
: null,
|
||||||
clearDataOnExit.value && contextualIdentity.value != null,
|
clearDataOnExit:
|
||||||
excludeFromIndex: excludeFromIndex.value,
|
clearDataOnExit.value && contextualIdentity.value != null,
|
||||||
// Requires a Gecko contextId: the native delegate can't hard-exclude
|
excludeFromIndex: excludeFromIndex.value,
|
||||||
// a container's visits without one. sanitized() enforces the same
|
// Requires a Gecko contextId: the native delegate can't hard-exclude
|
||||||
// invariant defensively on write.
|
// a container's visits without one. sanitized() enforces the same
|
||||||
excludeFromHistory:
|
// invariant defensively on write.
|
||||||
excludeFromHistory.value && contextualIdentity.value != null,
|
excludeFromHistory:
|
||||||
bypassGlobalProxy:
|
excludeFromHistory.value && contextualIdentity.value != null,
|
||||||
contextualIdentity.value != null &&
|
bypassGlobalProxy:
|
||||||
proxyConnectionId.value == null &&
|
contextualIdentity.value != null &&
|
||||||
bypassGlobalProxy.value,
|
proxyConnectionId.value == null &&
|
||||||
useCustomColor: useCustomColor.value,
|
bypassGlobalProxy.value,
|
||||||
assignedSites: assignedSites.value,
|
useCustomColor: useCustomColor.value,
|
||||||
// Strict mode requires a Gecko contextId (the extension keys
|
assignedSites: assignedSites.value,
|
||||||
// strictness on the tab's cookieStoreId). sanitized() enforces the
|
// Strict mode requires a Gecko contextId (the extension keys
|
||||||
// same invariant defensively on write.
|
// strictness on the tab's cookieStoreId). sanitized() enforces the
|
||||||
strictMode: strictMode.value && contextualIdentity.value != null,
|
// same invariant defensively on write.
|
||||||
).sanitized(),
|
strictMode: strictMode.value && contextualIdentity.value != null,
|
||||||
|
)
|
||||||
|
.sanitized(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,7 +545,9 @@ class ContainerEditScreen extends HookConsumerWidget {
|
|||||||
value: clearDataOnExit.value,
|
value: clearDataOnExit.value,
|
||||||
title: const Text('Clear Data on Exit'),
|
title: const Text('Clear Data on Exit'),
|
||||||
subtitle: const Text(
|
subtitle: const Text(
|
||||||
'Clear cookies and site data when app closes',
|
"Clear cookies and site data for this container's "
|
||||||
|
'regular tabs when the app closes. Isolated tabs '
|
||||||
|
'keep separate data.',
|
||||||
),
|
),
|
||||||
secondary: const Icon(MdiIcons.databaseRemove),
|
secondary: const Icon(MdiIcons.databaseRemove),
|
||||||
onChanged: (contextualIdentity.value != null)
|
onChanged: (contextualIdentity.value != null)
|
||||||
@@ -572,7 +576,9 @@ class ContainerEditScreen extends HookConsumerWidget {
|
|||||||
title: const Text('Exclude from History'),
|
title: const Text('Exclude from History'),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
contextualIdentity.value != null
|
contextualIdentity.value != null
|
||||||
? "Don't record this container's browsing history"
|
? "Don't record new visits from this container's "
|
||||||
|
'regular tabs. Existing history is kept; '
|
||||||
|
'isolated tabs track separately.'
|
||||||
: 'Requires cookie isolation to be enabled',
|
: 'Requires cookie isolation to be enabled',
|
||||||
),
|
),
|
||||||
secondary: const Icon(MdiIcons.incognito),
|
secondary: const Icon(MdiIcons.incognito),
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
|||||||
import 'package:riverpod/riverpod.dart';
|
import 'package:riverpod/riverpod.dart';
|
||||||
import 'package:weblibre/core/database_registry.dart';
|
import 'package:weblibre/core/database_registry.dart';
|
||||||
import 'package:weblibre/core/logger.dart';
|
import 'package:weblibre/core/logger.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_data.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/tor/domain/services/tor_proxy.dart';
|
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
|
||||||
|
|
||||||
@@ -41,6 +43,44 @@ Future<void> exitApp(ProviderContainer container) async {
|
|||||||
logger.e('Failed to close tabs', error: e, stackTrace: st);
|
logger.e('Failed to close tabs', error: e, stackTrace: st);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1b. Explicit-Quit cleanup for containers with "Clear Data on Exit" enabled.
|
||||||
|
// Done here — while the databases and Gecko engine are still alive — so the
|
||||||
|
// clear gets a chance to run on a deliberate Quit rather than only on the
|
||||||
|
// next launch.
|
||||||
|
//
|
||||||
|
// Caveat: GeckoView's session-context clear is fire-and-forget (see
|
||||||
|
// GeckoDeleteBrowsingDataControllerImpl.clearDataForSessionContext) — it
|
||||||
|
// has no completion signal, so awaiting it does NOT mean the clear
|
||||||
|
// finished, only that it was dispatched. Since step 3 tears down the
|
||||||
|
// GeckoRuntime, we give the dispatched clear a short best-effort window to
|
||||||
|
// reach Gecko first. The startup fallback in browser_view.dart is retained
|
||||||
|
// as the actual guarantee — it covers force-stop/process death and this
|
||||||
|
// window elapsing before the clear lands.
|
||||||
|
try {
|
||||||
|
final containersToClear = await container
|
||||||
|
.read(containerRepositoryProvider.notifier)
|
||||||
|
.getContainersToClearOnExit();
|
||||||
|
|
||||||
|
if (containersToClear.isNotEmpty) {
|
||||||
|
await container
|
||||||
|
.read(browserDataServiceProvider.notifier)
|
||||||
|
.clearContainerData(containersToClear);
|
||||||
|
// Best-effort settle: yield before the engine shutdown in step 3 so the
|
||||||
|
// fire-and-forget native clear has a chance to be processed by Gecko.
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||||
|
logger.i(
|
||||||
|
'Dispatched data clear for ${containersToClear.length} '
|
||||||
|
'on-exit container(s)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
logger.e(
|
||||||
|
'Failed to clear container data on exit',
|
||||||
|
error: e,
|
||||||
|
stackTrace: st,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Stop Tor proxy (only if it was initialized)
|
// 2. Stop Tor proxy (only if it was initialized)
|
||||||
if (container.exists(torProxyServiceProvider)) {
|
if (container.exists(torProxyServiceProvider)) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_data.dart';
|
||||||
|
|
||||||
|
class _FakeGeckoDeleteBrowserDataService extends GeckoDeleteBrowserDataService {
|
||||||
|
final clearedContexts = <String>[];
|
||||||
|
|
||||||
|
/// Contexts for which [clearDataForContext] should throw (simulating a native
|
||||||
|
/// failure), so we can assert one failure doesn't block the remaining ones.
|
||||||
|
final failingContexts = <String>{};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> clearDataForContext(String contextId) async {
|
||||||
|
clearedContexts.add(contextId);
|
||||||
|
if (failingContexts.contains(contextId)) {
|
||||||
|
throw Exception('simulated native failure for $contextId');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('BrowserDataService.clearContainerDataOnEngineStart', () {
|
||||||
|
test(
|
||||||
|
'still clears container data after deleteDataOnEngineStart ran '
|
||||||
|
'(regression: shared one-shot flag skipped container clearing, #524)',
|
||||||
|
() async {
|
||||||
|
final gecko = _FakeGeckoDeleteBrowserDataService();
|
||||||
|
final service = BrowserDataService(service: gecko);
|
||||||
|
|
||||||
|
// Startup always runs the global on-start deletion first, even when
|
||||||
|
// no delete-on-quit types are configured (null).
|
||||||
|
await service.deleteDataOnEngineStart(null);
|
||||||
|
await service.clearContainerDataOnEngineStart(['ctx-1', 'ctx-2']);
|
||||||
|
|
||||||
|
expect(gecko.clearedContexts, ['ctx-1', 'ctx-2']);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('clears only once per app start', () async {
|
||||||
|
final gecko = _FakeGeckoDeleteBrowserDataService();
|
||||||
|
final service = BrowserDataService(service: gecko);
|
||||||
|
|
||||||
|
await service.clearContainerDataOnEngineStart(['ctx-1']);
|
||||||
|
await service.clearContainerDataOnEngineStart(['ctx-2']);
|
||||||
|
|
||||||
|
expect(gecko.clearedContexts, ['ctx-1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'empty context list neither clears nor consumes the one-shot run',
|
||||||
|
() async {
|
||||||
|
final gecko = _FakeGeckoDeleteBrowserDataService();
|
||||||
|
final service = BrowserDataService(service: gecko);
|
||||||
|
|
||||||
|
await service.clearContainerDataOnEngineStart(const []);
|
||||||
|
expect(gecko.clearedContexts, isEmpty);
|
||||||
|
|
||||||
|
await service.clearContainerDataOnEngineStart(['ctx-1']);
|
||||||
|
expect(gecko.clearedContexts, ['ctx-1']);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'a single failing context does not block the remaining ones',
|
||||||
|
() async {
|
||||||
|
final gecko = _FakeGeckoDeleteBrowserDataService()
|
||||||
|
..failingContexts.add('ctx-2');
|
||||||
|
final service = BrowserDataService(service: gecko);
|
||||||
|
|
||||||
|
await service.clearContainerDataOnEngineStart([
|
||||||
|
'ctx-1',
|
||||||
|
'ctx-2',
|
||||||
|
'ctx-3',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ctx-2 threw, but ctx-1 and ctx-3 were still attempted.
|
||||||
|
expect(gecko.clearedContexts, ['ctx-1', 'ctx-2', 'ctx-3']);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('BrowserDataService.clearContainerData (explicit Quit)', () {
|
||||||
|
test('is not gated by the one-shot startup guard', () async {
|
||||||
|
final gecko = _FakeGeckoDeleteBrowserDataService();
|
||||||
|
final service = BrowserDataService(service: gecko);
|
||||||
|
|
||||||
|
// Consume the startup one-shot guard first.
|
||||||
|
await service.clearContainerDataOnEngineStart(['startup']);
|
||||||
|
// Explicit Quit cleanup must still run afterwards.
|
||||||
|
await service.clearContainerData(['ctx-1', 'ctx-2']);
|
||||||
|
|
||||||
|
expect(gecko.clearedContexts, ['startup', 'ctx-1', 'ctx-2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('logs and skips a failing context, clearing the rest', () async {
|
||||||
|
final gecko = _FakeGeckoDeleteBrowserDataService()
|
||||||
|
..failingContexts.add('ctx-1');
|
||||||
|
final service = BrowserDataService(service: gecko);
|
||||||
|
|
||||||
|
await service.clearContainerData(['ctx-1', 'ctx-2']);
|
||||||
|
|
||||||
|
expect(gecko.clearedContexts, ['ctx-1', 'ctx-2']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
+15
-18
@@ -188,6 +188,7 @@ class GeckoDeleteBrowsingDataControllerImpl : GeckoDeleteBrowsingDataController
|
|||||||
) || dataTypes.contains(ClearDataType.ONLY_CACHES))
|
) || dataTypes.contains(ClearDataType.ONLY_CACHES))
|
||||||
) {
|
) {
|
||||||
callback(Result.failure(Exception("Cookies/Cache must be exclusively!")))
|
callback(Result.failure(Exception("Cookies/Cache must be exclusively!")))
|
||||||
|
return@withContext
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert ClearDataType to Engine.BrowsingData flags
|
// Convert ClearDataType to Engine.BrowsingData flags
|
||||||
@@ -200,8 +201,10 @@ class GeckoDeleteBrowsingDataControllerImpl : GeckoDeleteBrowsingDataController
|
|||||||
}
|
}
|
||||||
}.toIntArray()
|
}.toIntArray()
|
||||||
|
|
||||||
// Find tabs on this host (so we can detach their engine sessions and
|
// Find tabs on this host so we can detach their engine sessions
|
||||||
// also discover any container/contextId partitions that need clearing).
|
// before clearing (across every container — the base-domain clear
|
||||||
|
// below covers all partitions, so any open session on this host in
|
||||||
|
// any container could otherwise re-accumulate cleared data).
|
||||||
val matchingTabs = components.core.store.state.allTabs.filter { tab ->
|
val matchingTabs = components.core.store.state.allTabs.filter { tab ->
|
||||||
val tabHost = runCatching { tab.content.url.toUri().host }.getOrNull()
|
val tabHost = runCatching { tab.content.url.toUri().host }.getOrNull()
|
||||||
?: return@filter false
|
?: return@filter false
|
||||||
@@ -215,22 +218,16 @@ class GeckoDeleteBrowsingDataControllerImpl : GeckoDeleteBrowsingDataController
|
|||||||
components.core.store.dispatch(EngineAction.UnlinkEngineSessionAction(it.id))
|
components.core.store.dispatch(EngineAction.UnlinkEngineSessionAction(it.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
// GeckoView's clearDataFromBaseDomain (used by engine.clearData(host=))
|
// Clear data for the specific host only. clearDataFromBaseDomain
|
||||||
// only targets the default origin attributes partition. Tabs that live
|
// (used by engine.clearData(host=)) deletes the site under an
|
||||||
// inside a contextual identity ("container") store their cookies/storage
|
// empty OriginAttributes pattern, which already matches ALL
|
||||||
// under a `geckoViewSessionContextId` origin attribute that the
|
// partitions — including every container's
|
||||||
// base-domain clear does not touch. To make clearing actually effective
|
// `geckoViewSessionContextId`. So this clears this host across all
|
||||||
// for container tabs, also fire clearDataForSessionContext for any
|
// containers WITHOUT touching other sites in those containers.
|
||||||
// contextIds we found. This is broader than ideal (it clears the whole
|
//
|
||||||
// container, not just this host) but there is no GeckoView API to
|
// Do NOT fall back to clearDataForSessionContext here: that wipes a
|
||||||
// combine host + context.
|
// container's entire storage (every unrelated site in it), which is
|
||||||
val contextIds = matchingTabs.mapNotNull { it.contextId }.toSet()
|
// the container-wide data-loss reported in #524.
|
||||||
contextIds.forEach { contextId ->
|
|
||||||
components.core.runtime.storageController
|
|
||||||
.clearDataForSessionContext(contextId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear data for the specific host (default partition).
|
|
||||||
components.core.engine.clearData(
|
components.core.engine.clearData(
|
||||||
data = Engine.BrowsingData.select(*browsingDataTypes),
|
data = Engine.BrowsingData.select(*browsingDataTypes),
|
||||||
host = host,
|
host = host,
|
||||||
|
|||||||
+11
-7
@@ -31,18 +31,22 @@ class HistoryVisitCorrelationMiddleware :
|
|||||||
next: (BrowserAction) -> Unit,
|
next: (BrowserAction) -> Unit,
|
||||||
action: BrowserAction,
|
action: BrowserAction,
|
||||||
) {
|
) {
|
||||||
next(action)
|
// Record the correlation BEFORE forwarding the action. A tab's contextId
|
||||||
|
// is stable across a URL change, so it is already readable here — and Gecko
|
||||||
|
// can fire the delegate's onVisited for this navigation as soon as the store
|
||||||
|
// updates. Recording first closes the window where onVisited could resolve
|
||||||
|
// the visit before its correlation exists (and mis-attribute or leak it).
|
||||||
if (action is ContentAction.UpdateUrlAction) {
|
if (action is ContentAction.UpdateUrlAction) {
|
||||||
val normalTab = store.state.findNormalTab(action.sessionId)
|
val normalTab = store.state.findNormalTab(action.sessionId)
|
||||||
if (normalTab != null) {
|
if (normalTab != null) {
|
||||||
HistoryVisitCorrelationCache.record(action.url, normalTab.contextId)
|
HistoryVisitCorrelationCache.record(action.url, normalTab.contextId)
|
||||||
return
|
} else {
|
||||||
}
|
store.state.findCustomTab(action.sessionId)?.let { customTab ->
|
||||||
|
HistoryVisitCorrelationCache.record(action.url, customTab.contextId)
|
||||||
store.state.findCustomTab(action.sessionId)?.let { customTab ->
|
}
|
||||||
HistoryVisitCorrelationCache.record(action.url, customTab.contextId)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
next(action)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user