blob: 7461dfce9d0ee628eb6a85cb10529d7150600ff6 [file] [log] [blame]
// 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();
}