| // Copyright (c) 2023, 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 'dart:convert'; |
| |
| import 'package:clock/clock.dart'; |
| import 'package:file/file.dart'; |
| import 'package:http/http.dart' as http; |
| |
| import 'constants.dart'; |
| import 'enums.dart'; |
| import 'initializer.dart'; |
| import 'log_handler.dart'; |
| |
| class Condition { |
| /// How to query the log file. |
| /// |
| /// |
| /// Example: logFileStats.recordCount refers to the |
| /// total record count being returned by [LogFileStats]. |
| final String field; |
| |
| /// String representation of operator. |
| /// |
| /// |
| /// Allowed values: |
| /// - '>=' `greater than or equal to` |
| /// - '<=' `less than or equal to` |
| /// - '>' `greater than` |
| /// - '<' `less then` |
| /// - '==' `equals` |
| /// - '!=' `not equal` |
| final String operatorString; |
| |
| /// The value we will be comparing against using the [operatorString]. |
| final int value; |
| |
| /// One of the conditions that need to be valid for |
| /// a survey to be returned to the user. |
| /// |
| /// Example of raw json: |
| /// ``` |
| /// { |
| /// "field": "logFileStats.recordCount", |
| /// "operator": ">=", |
| /// "value": 1000 |
| /// } |
| /// ``` |
| Condition( |
| this.field, |
| this.operatorString, |
| this.value, |
| ); |
| |
| Condition.fromJson(Map<String, dynamic> json) |
| : field = json['field'] as String, |
| operatorString = json['operator'] as String, |
| value = json['value'] as int; |
| |
| Map<String, Object?> toMap() => <String, Object?>{ |
| 'field': field, |
| 'operator': operatorString, |
| 'value': value, |
| }; |
| |
| @override |
| String toString() => jsonEncode(toMap()); |
| } |
| |
| /// Data class for the persisted survey contents. |
| /// |
| /// [uniqueId] is the identifier for each survey, [timestamp] refers |
| /// to when the survey was added to the persisted file. |
| /// |
| /// The boolean [snoozed] is set to `true` if the survey has been dismissed |
| /// temporarily by the user. When set to `false` this indicates that the survey |
| /// has been dismissed permanently and will not be shown to the user again. |
| class PersistedSurvey { |
| final String uniqueId; |
| final bool snoozed; |
| final DateTime timestamp; |
| |
| PersistedSurvey({ |
| required this.uniqueId, |
| required this.snoozed, |
| required this.timestamp, |
| }); |
| |
| @override |
| String toString() => jsonEncode({ |
| 'uniqueId': uniqueId, |
| 'snoozed': snoozed, |
| 'timestamp': timestamp.toString(), |
| }); |
| } |
| |
| class Survey { |
| final String uniqueId; |
| final DateTime startDate; |
| final DateTime endDate; |
| final String description; |
| final int snoozeForMinutes; |
| final double samplingRate; |
| final List<DashTool> excludeDashToolList; |
| final List<Condition> conditionList; |
| final List<SurveyButton> buttonList; |
| |
| /// A data class that contains the relevant information for a given |
| /// survey parsed from the survey's metadata file. |
| const Survey({ |
| required this.uniqueId, |
| required this.startDate, |
| required this.endDate, |
| required this.description, |
| required this.snoozeForMinutes, |
| required this.samplingRate, |
| required this.excludeDashToolList, |
| required this.conditionList, |
| required this.buttonList, |
| }); |
| |
| /// Parse the contents of the json metadata file hosted externally. |
| Survey.fromJson(Map<String, dynamic> json) |
| : uniqueId = json['uniqueId'] as String, |
| startDate = DateTime.parse(json['startDate'] as String), |
| endDate = DateTime.parse(json['endDate'] as String), |
| description = json['description'] as String, |
| // Handle both string and integer fields |
| snoozeForMinutes = json['snoozeForMinutes'] is String |
| ? int.parse(json['snoozeForMinutes'] as String) |
| : json['snoozeForMinutes'] as int, |
| // Handle both string and double fields |
| samplingRate = json['samplingRate'] is String |
| ? double.parse(json['samplingRate'] as String) |
| : json['samplingRate'] as double, |
| excludeDashToolList = (json['excludeDashTools'] as List<dynamic>) |
| .map((e) => DashTool.fromLabel(e as String)) |
| .toList(), |
| conditionList = (json['conditions'] as List<dynamic>).map((e) { |
| return Condition.fromJson(e as Map<String, dynamic>); |
| }).toList(), |
| buttonList = (json['buttons'] as List<dynamic>).map((e) { |
| return SurveyButton.fromJson(e as Map<String, dynamic>); |
| }).toList(); |
| |
| @override |
| String toString() { |
| const encoder = JsonEncoder.withIndent(' '); |
| return encoder.convert({ |
| 'uniqueId': uniqueId, |
| 'startDate': startDate.toString(), |
| 'endDate': endDate.toString(), |
| 'description': description, |
| 'snoozeForMinutes': snoozeForMinutes, |
| 'samplingRate': samplingRate, |
| 'conditionList': conditionList.map((e) => e.toMap()).toList(), |
| 'buttonList': buttonList.map((e) => e.toMap()).toList(), |
| }); |
| } |
| } |
| |
| class SurveyButton { |
| final String buttonText; |
| final String action; |
| final bool promptRemainsVisible; |
| final String? url; |
| |
| SurveyButton({ |
| required this.buttonText, |
| required this.action, |
| required this.promptRemainsVisible, |
| this.url, |
| }); |
| |
| SurveyButton.fromJson(Map<String, dynamic> json) |
| : buttonText = json['buttonText'] as String, |
| action = json['action'] as String, |
| promptRemainsVisible = json['promptRemainsVisible'] as bool, |
| url = json['url'] as String?; |
| |
| Map<String, Object?> toMap() => <String, Object?>{ |
| 'buttonText': buttonText, |
| 'action': action, |
| 'promptRemainsVisible': promptRemainsVisible, |
| 'url': url, |
| }; |
| } |
| |
| class SurveyHandler { |
| final File dismissedSurveyFile; |
| |
| SurveyHandler({required this.dismissedSurveyFile}); |
| |
| /// Invoking this method will persist the survey's id in |
| /// the local file with either a snooze or permanently dismissed |
| /// indicator. |
| /// |
| /// In the snoozed state, the survey will be prompted again after |
| /// the survey's specified snooze period. |
| /// |
| /// Each entry for a survey will have the following format: |
| /// ``` |
| /// { |
| /// "survey-unique-id": { |
| /// "status": "snoozed", // status is either snoozed or dismissed |
| /// "timestamp": 1690219834859 |
| /// } |
| /// } |
| /// ``` |
| void dismiss(Survey survey, bool permanently) { |
| final contents = _parseJsonFile(); |
| |
| // Add the new data and write back out to the file |
| final status = permanently ? 'dismissed' : 'snoozed'; |
| contents[survey.uniqueId] = { |
| 'status': status, |
| 'timestamp': clock.now().millisecondsSinceEpoch, |
| }; |
| |
| dismissedSurveyFile.writeAsStringSync(jsonEncode(contents)); |
| } |
| |
| /// Retrieve a list of strings for each [Survey] persisted on disk. |
| /// |
| /// The survey may be in a snoozed or dismissed state based on user action. |
| Map<String, PersistedSurvey> fetchPersistedSurveys() { |
| final contents = _parseJsonFile(); |
| |
| // Initialize the list of persisted surveys and add to them |
| // as they are being parsed |
| final persistedSurveys = <String, PersistedSurvey>{}; |
| contents.forEach((key, value) { |
| value as Map<String, dynamic>; |
| |
| final uniqueId = key; |
| final snoozed = value['status'] == 'snoozed' ? true : false; |
| final timestamp = |
| DateTime.fromMillisecondsSinceEpoch(value['timestamp'] as int); |
| |
| persistedSurveys[uniqueId] = PersistedSurvey( |
| uniqueId: uniqueId, |
| snoozed: snoozed, |
| timestamp: timestamp, |
| ); |
| }); |
| |
| return persistedSurveys; |
| } |
| |
| /// Retrieves the survey metadata file from [kContextualSurveyUrl]. |
| Future<List<Survey>> fetchSurveyList() async { |
| final List<dynamic> body; |
| try { |
| final payload = await _fetchContents(); |
| body = jsonDecode(payload) as List<dynamic>; |
| // ignore: avoid_catches_without_on_clauses |
| } catch (err) { |
| return []; |
| } |
| |
| final surveyList = parseSurveysFromJson(body); |
| |
| return surveyList; |
| } |
| |
| /// Fetches the json in string form from the remote location. |
| Future<String> _fetchContents() async { |
| final uri = Uri.parse(kContextualSurveyUrl); |
| final response = await http.get(uri); |
| return response.body; |
| } |
| |
| /// Method to return a Map representation of the json persisted file. |
| Map<String, dynamic> _parseJsonFile() { |
| Map<String, dynamic> contents; |
| try { |
| contents = jsonDecode(dismissedSurveyFile.readAsStringSync()) |
| as Map<String, dynamic>; |
| } on FormatException { |
| createDismissedSurveyFile(dismissedSurveyFile: dismissedSurveyFile); |
| contents = {}; |
| } on FileSystemException { |
| createDismissedSurveyFile(dismissedSurveyFile: dismissedSurveyFile); |
| contents = {}; |
| } |
| |
| return contents; |
| } |
| |
| /// Function to ensure that each survey is still valid by |
| /// checking the [Survey.startDate] and [Survey.endDate] |
| /// against the current [clock] now date. |
| static bool checkSurveyDate(Survey survey) { |
| final now = clock.now(); |
| return survey.startDate.isBefore(now) && survey.endDate.isAfter(now); |
| } |
| |
| /// Function that takes in a json data structure that is in |
| /// the form of a list and returns a list of [Survey] items. |
| /// |
| /// This will also check the survey's dates to make sure it |
| /// has not expired. |
| static List<Survey> parseSurveysFromJson(List<dynamic> body) => body |
| .map((element) { |
| // Error handling to skip any surveys from the remote location |
| // that fail to parse |
| try { |
| return Survey.fromJson(element as Map<String, dynamic>); |
| // ignore: avoid_catching_errors |
| } on TypeError { |
| return null; |
| } on FormatException { |
| return null; |
| } on Exception { |
| return null; |
| } |
| }) |
| .whereType<Survey>() |
| .where(checkSurveyDate) |
| .toList(); |
| } |
| |
| class FakeSurveyHandler extends SurveyHandler { |
| final List<Survey> _fakeInitializedSurveys = []; |
| |
| /// Use this class in tests if you can provide the |
| /// list of [Survey] objects. |
| /// |
| /// Important: the surveys in the [initializedSurveys] list |
| /// will have their dates checked to ensure they are valid; it is |
| /// recommended to use `package:clock` to set a fixed time for testing. |
| FakeSurveyHandler.fromList({ |
| required super.dismissedSurveyFile, |
| required List<Survey> initializedSurveys, |
| }) { |
| // We must pass the surveys from the list to the |
| // `checkSurveyDate` function here and not for the |
| // `.fromString()` constructor because the `parseSurveysFromJson` |
| // method already checks their date |
| for (final survey in initializedSurveys) { |
| if (SurveyHandler.checkSurveyDate(survey)) { |
| _fakeInitializedSurveys.add(survey); |
| } |
| } |
| } |
| |
| /// Use this class in tests if you can provide raw |
| /// json strings to simulate a response from a remote server. |
| FakeSurveyHandler.fromString({ |
| required super.dismissedSurveyFile, |
| required String content, |
| }) { |
| final body = jsonDecode(content) as List<dynamic>; |
| for (final fakeSurvey in SurveyHandler.parseSurveysFromJson(body)) { |
| _fakeInitializedSurveys.add(fakeSurvey); |
| } |
| } |
| |
| @override |
| Future<List<Survey>> fetchSurveyList() => |
| Future<List<Survey>>.value(_fakeInitializedSurveys); |
| |
| @override |
| Future<String> _fetchContents() => throw UnimplementedError(); |
| } |