| // Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file |
| // for details. All rights reserved. Use of this source code is governed by a |
| // BSD-style license that can be found in the LICENSE file. |
| |
| import 'package:analyzer/dart/analysis/analysis_context.dart'; |
| import 'package:analyzer/dart/analysis/analysis_context_collection.dart'; |
| import 'package:analyzer/dart/analysis/results.dart'; |
| import 'package:analyzer/file_system/file_system.dart'; |
| import 'package:analyzer/file_system/overlay_file_system.dart'; |
| import 'package:analyzer/src/dart/analysis/analysis_context_collection.dart'; |
| import 'package:analyzer/src/dart/analysis/byte_store.dart'; |
| import 'package:analyzer/src/dart/analysis/file_content_cache.dart'; |
| import 'package:analyzer_plugin/channel/channel.dart'; |
| import 'package:analyzer_plugin/protocol/protocol.dart'; |
| import 'package:analyzer_plugin/protocol/protocol_common.dart'; |
| import 'package:analyzer_plugin/protocol/protocol_constants.dart'; |
| import 'package:analyzer_plugin/protocol/protocol_generated.dart'; |
| import 'package:analyzer_plugin/src/protocol/protocol_internal.dart'; |
| import 'package:analyzer_plugin/utilities/subscriptions/subscription_manager.dart'; |
| import 'package:pub_semver/pub_semver.dart'; |
| |
| /// The abstract superclass of any class implementing a plugin for the analysis |
| /// server. |
| /// |
| /// Clients may not implement or mix-in this class, but are expected to extend |
| /// it. |
| abstract class ServerPlugin { |
| /// The communication channel being used to communicate with the analysis |
| /// server. |
| late PluginCommunicationChannel _channel; |
| |
| /// The resource provider used to access the file system. |
| final OverlayResourceProvider resourceProvider; |
| |
| late final ByteStore _byteStore = createByteStore(); |
| |
| AnalysisContextCollectionImpl? _contextCollection; |
| |
| /// The next modification stamp for a changed file in the [resourceProvider]. |
| int _overlayModificationStamp = 0; |
| |
| /// The path to the Dart SDK, set by the analysis server. |
| String? _sdkPath; |
| |
| /// Paths of priority files. |
| Set<String> priorityPaths = {}; |
| |
| /// The object used to manage analysis subscriptions. |
| final SubscriptionManager subscriptionManager = SubscriptionManager(); |
| |
| /// Initialize a newly created analysis server plugin. If a resource [resourceProvider] |
| /// is given, then it will be used to access the file system. Otherwise a |
| /// resource provider that accesses the physical file system will be used. |
| ServerPlugin({ |
| required ResourceProvider resourceProvider, |
| }) : resourceProvider = OverlayResourceProvider(resourceProvider); |
| |
| /// Return the communication channel being used to communicate with the |
| /// analysis server. |
| PluginCommunicationChannel get channel => _channel; |
| |
| /// Return the user visible information about how to contact the plugin authors |
| /// with any problems that are found, or `null` if there is no contact info. |
| String? get contactInfo => null; |
| |
| /// Return a list of glob patterns selecting the files that this plugin is |
| /// interested in analyzing. |
| List<String> get fileGlobsToAnalyze; |
| |
| /// Return the user visible name of this plugin. |
| String get name; |
| |
| /// Return the version number of the plugin spec required by this plugin, |
| /// encoded as a string. |
| String get version; |
| |
| /// This method is invoked when a new instance of [AnalysisContextCollection] |
| /// is created, so the plugin can perform initial analysis of analyzed files. |
| /// |
| /// By default analyzes every [AnalysisContext] with [analyzeFiles]. |
| Future<void> afterNewContextCollection({ |
| required AnalysisContextCollection contextCollection, |
| }) async { |
| await _forAnalysisContexts(contextCollection, (analysisContext) async { |
| final paths = analysisContext.contextRoot.analyzedFiles().toList(); |
| await analyzeFiles( |
| analysisContext: analysisContext, |
| paths: paths, |
| ); |
| }); |
| } |
| |
| /// Analyzes the given file. |
| Future<void> analyzeFile({ |
| required AnalysisContext analysisContext, |
| required String path, |
| }); |
| |
| /// Analyzes the given files. |
| /// By default invokes [analyzeFile] for every file. |
| /// Implementations may override to optimize for batch analysis. |
| Future<void> analyzeFiles({ |
| required AnalysisContext analysisContext, |
| required List<String> paths, |
| }) async { |
| final pathSet = paths.toSet(); |
| |
| // First analyze priority files. |
| for (final path in priorityPaths) { |
| pathSet.remove(path); |
| await analyzeFile( |
| analysisContext: analysisContext, |
| path: path, |
| ); |
| } |
| |
| // Then analyze the remaining files. |
| for (final path in pathSet) { |
| await analyzeFile( |
| analysisContext: analysisContext, |
| path: path, |
| ); |
| } |
| } |
| |
| /// This method is invoked immediately before the current |
| /// [AnalysisContextCollection] is disposed. |
| Future<void> beforeContextCollectionDispose({ |
| required AnalysisContextCollection contextCollection, |
| }) async {} |
| |
| /// Handle the fact that files with [paths] were changed. |
| Future<void> contentChanged(List<String> paths) async { |
| final contextCollection = _contextCollection; |
| if (contextCollection != null) { |
| await _forAnalysisContexts(contextCollection, (analysisContext) async { |
| for (final path in paths) { |
| analysisContext.changeFile(path); |
| } |
| final affected = await analysisContext.applyPendingFileChanges(); |
| await handleAffectedFiles( |
| analysisContext: analysisContext, |
| paths: affected, |
| ); |
| }); |
| } |
| } |
| |
| /// This method is invoked once to create the [ByteStore] that is used for |
| /// all [AnalysisContextCollection] instances, and reused when new instances |
| /// are created (and so can perform analysis faster). |
| ByteStore createByteStore() { |
| return MemoryCachingByteStore( |
| NullByteStore(), |
| 1024 * 1024 * 256, |
| ); |
| } |
| |
| /// Plugin implementations can use this method to flush the state of |
| /// analysis, so reduce the used heap size, after performing a set of |
| /// operations, e.g. in [afterNewContextCollection] or [handleAffectedFiles]. |
| /// |
| /// The next analysis operation will be slower, because it will restore |
| /// the state from the byte store cache, or recompute. |
| Future<void> flushAnalysisState({ |
| bool elementModels = true, |
| }) async { |
| final contextCollection = _contextCollection; |
| if (contextCollection != null) { |
| for (final analysisContext in contextCollection.contexts) { |
| if (elementModels) { |
| analysisContext.driver.clearLibraryContext(); |
| } |
| } |
| } |
| } |
| |
| /// Return the result of analyzing the file with the given [path]. |
| /// |
| /// Throw a [RequestFailure] is the file cannot be analyzed. |
| Future<ResolvedUnitResult> getResolvedUnitResult(String path) async { |
| final contextCollection = _contextCollection; |
| if (contextCollection != null) { |
| final analysisContext = contextCollection.contextFor(path); |
| final analysisSession = analysisContext.currentSession; |
| final unitResult = await analysisSession.getResolvedUnit(path); |
| if (unitResult is ResolvedUnitResult) { |
| return unitResult; |
| } |
| } |
| // Return an error from the request. |
| throw RequestFailure( |
| RequestErrorFactory.pluginError('Failed to analyze $path', null), |
| ); |
| } |
| |
| /// Handles files that might have been affected by a content change of |
| /// one or more files. The implementation may check if these files should |
| /// be analyzed, do such analysis, and send diagnostics. |
| /// |
| /// By default invokes [analyzeFiles] only for files that are analyzed in |
| /// this [analysisContext]. |
| Future<void> handleAffectedFiles({ |
| required AnalysisContext analysisContext, |
| required List<String> paths, |
| }) async { |
| final analyzedPaths = paths |
| .where(analysisContext.contextRoot.isAnalyzed) |
| .toList(growable: false); |
| |
| await analyzeFiles( |
| analysisContext: analysisContext, |
| paths: analyzedPaths, |
| ); |
| } |
| |
| /// Handle an 'analysis.getNavigation' request. |
| /// |
| /// Throw a [RequestFailure] if the request could not be handled. |
| Future<AnalysisGetNavigationResult> handleAnalysisGetNavigation( |
| AnalysisGetNavigationParams parameters) async { |
| return AnalysisGetNavigationResult( |
| <String>[], <NavigationTarget>[], <NavigationRegion>[]); |
| } |
| |
| /// Handle an 'analysis.handleWatchEvents' request. |
| /// |
| /// Throw a [RequestFailure] if the request could not be handled. |
| Future<AnalysisHandleWatchEventsResult> handleAnalysisHandleWatchEvents( |
| AnalysisHandleWatchEventsParams parameters) async { |
| for (var event in parameters.events) { |
| switch (event.type) { |
| case WatchEventType.ADD: |
| // TODO(brianwilkerson) Handle the event. |
| break; |
| case WatchEventType.MODIFY: |
| await contentChanged([event.path]); |
| break; |
| case WatchEventType.REMOVE: |
| // TODO(brianwilkerson) Handle the event. |
| break; |
| default: |
| // Ignore unhandled watch event types. |
| break; |
| } |
| } |
| return AnalysisHandleWatchEventsResult(); |
| } |
| |
| /// Handle an 'analysis.setContextRoots' request. |
| /// |
| /// Throw a [RequestFailure] if the request could not be handled. |
| Future<AnalysisSetContextRootsResult> handleAnalysisSetContextRoots( |
| AnalysisSetContextRootsParams parameters) async { |
| final currentContextCollection = _contextCollection; |
| if (currentContextCollection != null) { |
| _contextCollection = null; |
| await beforeContextCollectionDispose( |
| contextCollection: currentContextCollection, |
| ); |
| await currentContextCollection.dispose(); |
| } |
| |
| final includedPaths = parameters.roots.map((e) => e.root).toList(); |
| final contextCollection = AnalysisContextCollectionImpl( |
| resourceProvider: resourceProvider, |
| includedPaths: includedPaths, |
| byteStore: _byteStore, |
| sdkPath: _sdkPath, |
| fileContentCache: FileContentCache(resourceProvider), |
| ); |
| _contextCollection = contextCollection; |
| await afterNewContextCollection( |
| contextCollection: contextCollection, |
| ); |
| return AnalysisSetContextRootsResult(); |
| } |
| |
| /// Handle an 'analysis.setPriorityFiles' request. |
| /// |
| /// Throw a [RequestFailure] if the request could not be handled. |
| Future<AnalysisSetPriorityFilesResult> handleAnalysisSetPriorityFiles( |
| AnalysisSetPriorityFilesParams parameters) async { |
| priorityPaths = parameters.files.toSet(); |
| return AnalysisSetPriorityFilesResult(); |
| } |
| |
| /// Handle an 'analysis.setSubscriptions' request. Most subclasses should not |
| /// override this method, but should instead use the [subscriptionManager] to |
| /// access the list of subscriptions for any given file. |
| /// |
| /// Throw a [RequestFailure] if the request could not be handled. |
| Future<AnalysisSetSubscriptionsResult> handleAnalysisSetSubscriptions( |
| AnalysisSetSubscriptionsParams parameters) async { |
| var subscriptions = parameters.subscriptions; |
| var newSubscriptions = subscriptionManager.setSubscriptions(subscriptions); |
| sendNotificationsForSubscriptions(newSubscriptions); |
| return AnalysisSetSubscriptionsResult(); |
| } |
| |
| /// Handle an 'analysis.updateContent' request. Most subclasses should not |
| /// override this method, but should instead use the [contentCache] to access |
| /// the current content of overlaid files. |
| /// |
| /// Throw a [RequestFailure] if the request could not be handled. |
| Future<AnalysisUpdateContentResult> handleAnalysisUpdateContent( |
| AnalysisUpdateContentParams parameters) async { |
| final changedPaths = <String>{}; |
| var paths = parameters.files; |
| paths.forEach((String path, Object? overlay) { |
| // Prepare the old overlay contents. |
| String? oldContents; |
| try { |
| if (resourceProvider.hasOverlay(path)) { |
| var file = resourceProvider.getFile(path); |
| oldContents = file.readAsStringSync(); |
| } |
| } catch (_) {} |
| |
| // Prepare the new contents. |
| String? newContents; |
| if (overlay is AddContentOverlay) { |
| newContents = overlay.content; |
| } else if (overlay is ChangeContentOverlay) { |
| if (oldContents == null) { |
| // The server should only send a ChangeContentOverlay if there is |
| // already an existing overlay for the source. |
| throw RequestFailure( |
| RequestErrorFactory.invalidOverlayChangeNoContent()); |
| } |
| try { |
| newContents = SourceEdit.applySequence(oldContents, overlay.edits); |
| } on RangeError { |
| throw RequestFailure( |
| RequestErrorFactory.invalidOverlayChangeInvalidEdit()); |
| } |
| } else if (overlay is RemoveContentOverlay) { |
| newContents = null; |
| } |
| |
| if (newContents != null) { |
| resourceProvider.setOverlay( |
| path, |
| content: newContents, |
| modificationStamp: _overlayModificationStamp++, |
| ); |
| } else { |
| resourceProvider.removeOverlay(path); |
| } |
| |
| changedPaths.add(path); |
| }); |
| await contentChanged(changedPaths.toList()); |
| return AnalysisUpdateContentResult(); |
| } |
| |
| /// Handle a 'completion.getSuggestions' request. |
| /// |
| /// Throw a [RequestFailure] if the request could not be handled. |
| Future<CompletionGetSuggestionsResult> handleCompletionGetSuggestions( |
| CompletionGetSuggestionsParams parameters) async { |
| return CompletionGetSuggestionsResult( |
| -1, -1, const <CompletionSuggestion>[]); |
| } |
| |
| /// Handle an 'edit.getAssists' request. |
| /// |
| /// Throw a [RequestFailure] if the request could not be handled. |
| Future<EditGetAssistsResult> handleEditGetAssists( |
| EditGetAssistsParams parameters) async { |
| return EditGetAssistsResult(const <PrioritizedSourceChange>[]); |
| } |
| |
| /// Handle an 'edit.getAvailableRefactorings' request. Subclasses that override |
| /// this method in order to participate in refactorings must also override the |
| /// method [handleEditGetRefactoring]. |
| /// |
| /// Throw a [RequestFailure] if the request could not be handled. |
| Future<EditGetAvailableRefactoringsResult> handleEditGetAvailableRefactorings( |
| EditGetAvailableRefactoringsParams parameters) async { |
| return EditGetAvailableRefactoringsResult(const <RefactoringKind>[]); |
| } |
| |
| /// Handle an 'edit.getFixes' request. |
| /// |
| /// Throw a [RequestFailure] if the request could not be handled. |
| Future<EditGetFixesResult> handleEditGetFixes( |
| EditGetFixesParams parameters) async { |
| return EditGetFixesResult(const <AnalysisErrorFixes>[]); |
| } |
| |
| /// Handle an 'edit.getRefactoring' request. |
| /// |
| /// Throw a [RequestFailure] if the request could not be handled. |
| Future<EditGetRefactoringResult?> handleEditGetRefactoring( |
| EditGetRefactoringParams parameters) async { |
| return null; |
| } |
| |
| /// Handle a 'kythe.getKytheEntries' request. |
| /// |
| /// Throw a [RequestFailure] if the request could not be handled. |
| Future<KytheGetKytheEntriesResult?> handleKytheGetKytheEntries( |
| KytheGetKytheEntriesParams parameters) async { |
| return null; |
| } |
| |
| /// Handle a 'plugin.shutdown' request. Subclasses can override this method to |
| /// perform any required clean-up, but cannot prevent the plugin from shutting |
| /// down. |
| /// |
| /// Throw a [RequestFailure] if the request could not be handled. |
| Future<PluginShutdownResult> handlePluginShutdown( |
| PluginShutdownParams parameters) async { |
| return PluginShutdownResult(); |
| } |
| |
| /// Handle a 'plugin.versionCheck' request. |
| /// |
| /// Throw a [RequestFailure] if the request could not be handled. |
| Future<PluginVersionCheckResult> handlePluginVersionCheck( |
| PluginVersionCheckParams parameters) async { |
| _sdkPath = parameters.sdkPath; |
| var versionString = parameters.version; |
| var serverVersion = Version.parse(versionString); |
| return PluginVersionCheckResult( |
| isCompatibleWith(serverVersion), name, version, fileGlobsToAnalyze, |
| contactInfo: contactInfo); |
| } |
| |
| /// Return `true` if this plugin is compatible with an analysis server that is |
| /// using the given version of the plugin API. |
| bool isCompatibleWith(Version serverVersion) => |
| serverVersion <= Version.parse(version); |
| |
| /// The method that is called when the analysis server closes the communication |
| /// channel. This method will not be invoked under normal conditions because |
| /// the server will send a shutdown request and the plugin will stop listening |
| /// to the channel before the server closes the channel. |
| void onDone() {} |
| |
| /// The method that is called when an error has occurred in the analysis |
| /// server. This method will not be invoked under normal conditions. |
| void onError(Object exception, StackTrace stackTrace) {} |
| |
| /// If the plugin provides folding information, send a folding notification |
| /// for the file with the given [path] to the server. |
| Future<void> sendFoldingNotification(String path) { |
| return Future.value(); |
| } |
| |
| /// If the plugin provides highlighting information, send a highlights |
| /// notification for the file with the given [path] to the server. |
| Future<void> sendHighlightsNotification(String path) { |
| return Future.value(); |
| } |
| |
| /// If the plugin provides navigation information, send a navigation |
| /// notification for the file with the given [path] to the server. |
| Future<void> sendNavigationNotification(String path) { |
| return Future.value(); |
| } |
| |
| /// Send notifications for the services subscribed to for the file with the |
| /// given [path]. |
| /// |
| /// This is a convenience method that subclasses can use to send notifications |
| /// after analysis has been performed on a file. |
| void sendNotificationsForFile(String path) { |
| for (var service in subscriptionManager.servicesForFile(path)) { |
| _sendNotificationForFile(path, service); |
| } |
| } |
| |
| /// Send notifications corresponding to the given description of |
| /// [subscriptions]. The map is keyed by the path of each file for which |
| /// notifications should be sent and has values representing the list of |
| /// services associated with the notifications to send. |
| /// |
| /// This method is used when the set of subscribed notifications has been |
| /// changed and notifications need to be sent even when the specified files |
| /// have already been analyzed. |
| void sendNotificationsForSubscriptions( |
| Map<String, List<AnalysisService>> subscriptions) { |
| subscriptions.forEach((String path, List<AnalysisService> services) { |
| for (var service in services) { |
| _sendNotificationForFile(path, service); |
| } |
| }); |
| } |
| |
| /// If the plugin provides occurrences information, send an occurrences |
| /// notification for the file with the given [path] to the server. |
| Future<void> sendOccurrencesNotification(String path) { |
| return Future.value(); |
| } |
| |
| /// If the plugin provides outline information, send an outline notification |
| /// for the file with the given [path] to the server. |
| Future<void> sendOutlineNotification(String path) { |
| return Future.value(); |
| } |
| |
| /// Start this plugin by listening to the given communication [channel]. |
| void start(PluginCommunicationChannel channel) { |
| _channel = channel; |
| _channel.listen(_onRequest, onError: onError, onDone: onDone); |
| } |
| |
| /// Invokes [f] first for priority analysis contexts, then for the rest. |
| Future<void> _forAnalysisContexts( |
| AnalysisContextCollection contextCollection, |
| Future<void> Function(AnalysisContext analysisContext) f, |
| ) async { |
| final nonPriorityAnalysisContexts = <AnalysisContext>[]; |
| for (final analysisContext in contextCollection.contexts) { |
| if (_isPriorityAnalysisContext(analysisContext)) { |
| await f(analysisContext); |
| } else { |
| nonPriorityAnalysisContexts.add(analysisContext); |
| } |
| } |
| |
| for (final analysisContext in nonPriorityAnalysisContexts) { |
| await f(analysisContext); |
| } |
| } |
| |
| /// Compute the response that should be returned for the given [request], or |
| /// `null` if the response has already been sent. |
| Future<Response?> _getResponse(Request request, int requestTime) async { |
| ResponseResult? result; |
| switch (request.method) { |
| case ANALYSIS_REQUEST_GET_NAVIGATION: |
| var params = AnalysisGetNavigationParams.fromRequest(request); |
| result = await handleAnalysisGetNavigation(params); |
| break; |
| case ANALYSIS_REQUEST_HANDLE_WATCH_EVENTS: |
| var params = AnalysisHandleWatchEventsParams.fromRequest(request); |
| result = await handleAnalysisHandleWatchEvents(params); |
| break; |
| case ANALYSIS_REQUEST_SET_CONTEXT_ROOTS: |
| var params = AnalysisSetContextRootsParams.fromRequest(request); |
| result = await handleAnalysisSetContextRoots(params); |
| break; |
| case ANALYSIS_REQUEST_SET_PRIORITY_FILES: |
| var params = AnalysisSetPriorityFilesParams.fromRequest(request); |
| result = await handleAnalysisSetPriorityFiles(params); |
| break; |
| case ANALYSIS_REQUEST_SET_SUBSCRIPTIONS: |
| var params = AnalysisSetSubscriptionsParams.fromRequest(request); |
| result = await handleAnalysisSetSubscriptions(params); |
| break; |
| case ANALYSIS_REQUEST_UPDATE_CONTENT: |
| var params = AnalysisUpdateContentParams.fromRequest(request); |
| result = await handleAnalysisUpdateContent(params); |
| break; |
| case COMPLETION_REQUEST_GET_SUGGESTIONS: |
| var params = CompletionGetSuggestionsParams.fromRequest(request); |
| result = await handleCompletionGetSuggestions(params); |
| break; |
| case EDIT_REQUEST_GET_ASSISTS: |
| var params = EditGetAssistsParams.fromRequest(request); |
| result = await handleEditGetAssists(params); |
| break; |
| case EDIT_REQUEST_GET_AVAILABLE_REFACTORINGS: |
| var params = EditGetAvailableRefactoringsParams.fromRequest(request); |
| result = await handleEditGetAvailableRefactorings(params); |
| break; |
| case EDIT_REQUEST_GET_FIXES: |
| var params = EditGetFixesParams.fromRequest(request); |
| result = await handleEditGetFixes(params); |
| break; |
| case EDIT_REQUEST_GET_REFACTORING: |
| var params = EditGetRefactoringParams.fromRequest(request); |
| result = await handleEditGetRefactoring(params); |
| break; |
| case KYTHE_REQUEST_GET_KYTHE_ENTRIES: |
| var params = KytheGetKytheEntriesParams.fromRequest(request); |
| result = await handleKytheGetKytheEntries(params); |
| break; |
| case PLUGIN_REQUEST_SHUTDOWN: |
| var params = PluginShutdownParams(); |
| result = await handlePluginShutdown(params); |
| _channel.sendResponse(result.toResponse(request.id, requestTime)); |
| _channel.close(); |
| return null; |
| case PLUGIN_REQUEST_VERSION_CHECK: |
| var params = PluginVersionCheckParams.fromRequest(request); |
| result = await handlePluginVersionCheck(params); |
| break; |
| } |
| if (result == null) { |
| return Response(request.id, requestTime, |
| error: RequestErrorFactory.unknownRequest(request.method)); |
| } |
| return result.toResponse(request.id, requestTime); |
| } |
| |
| bool _isPriorityAnalysisContext(AnalysisContext analysisContext) { |
| return priorityPaths.any(analysisContext.contextRoot.isAnalyzed); |
| } |
| |
| /// The method that is called when a [request] is received from the analysis |
| /// server. |
| Future<void> _onRequest(Request request) async { |
| var requestTime = DateTime.now().millisecondsSinceEpoch; |
| var id = request.id; |
| Response? response; |
| try { |
| response = await _getResponse(request, requestTime); |
| } on RequestFailure catch (exception) { |
| response = Response(id, requestTime, error: exception.error); |
| } catch (exception, stackTrace) { |
| response = Response(id, requestTime, |
| error: RequestError( |
| RequestErrorCode.PLUGIN_ERROR, exception.toString(), |
| stackTrace: stackTrace.toString())); |
| } |
| if (response != null) { |
| _channel.sendResponse(response); |
| } |
| } |
| |
| /// Send a notification for the file at the given [path] corresponding to the |
| /// given [service]. |
| void _sendNotificationForFile(String path, AnalysisService service) { |
| switch (service) { |
| case AnalysisService.FOLDING: |
| sendFoldingNotification(path); |
| break; |
| case AnalysisService.HIGHLIGHTS: |
| sendHighlightsNotification(path); |
| break; |
| case AnalysisService.NAVIGATION: |
| sendNavigationNotification(path); |
| break; |
| case AnalysisService.OCCURRENCES: |
| sendOccurrencesNotification(path); |
| break; |
| case AnalysisService.OUTLINE: |
| sendOutlineNotification(path); |
| break; |
| } |
| } |
| } |