| // 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 'dart:io' as io; | |
| import 'dart:math' show Random; | |
| import 'package:clock/clock.dart'; | |
| import 'package:convert/convert.dart'; | |
| import 'package:file/file.dart'; | |
| import 'enums.dart'; | |
| import 'event.dart'; | |
| import 'survey_handler.dart'; | |
| import 'user_property.dart'; | |
| /// Get a string representation of the current date in the following format: | |
| /// ```text | |
| /// yyyy-MM-dd (2023-01-09) | |
| /// ``` | |
| String get dateStamp { | |
| return FixedDateTimeFormatter('YYYY-MM-DD').encode(clock.now()); | |
| } | |
| /// Returns `true` if analytics are suppressed based on the environment variable | |
| /// `DASH__SUPPRESS_ANALYTICS`. | |
| /// | |
| /// If the environment variable is set and not "false", return the | |
| /// corresponding boolean value. Otherwise, return the [defaultValue]. | |
| bool areAnalyticsSuppressed({bool defaultValue = false}) { | |
| final value = io.Platform.environment[DashEnvVar.suppressAnalytics.name]; | |
| if (value != null) { | |
| try { | |
| return bool.parse(value); | |
| } on Exception { | |
| // Fallback to `defaultValue` if the value in ENV is invalid. | |
| } | |
| } | |
| return defaultValue; | |
| } | |
| /// Reads in a directory and returns `true` if write permissions are enabled. | |
| /// | |
| /// Uses the [FileStat] method `modeString()` to return a string in the form | |
| /// of `rwxrwxrwx` where the second character in the string indicates if write | |
| /// is enabled with a `w` or disabled with `-`. | |
| bool checkDirectoryForWritePermissions(Directory directory) { | |
| if (!directory.existsSync()) return false; | |
| final fileStat = directory.statSync(); | |
| return fileStat.modeString()[1] == 'w'; | |
| } | |
| /// Format time as 'yyyy-MM-dd HH:mm:ss Z' where Z is the difference between the | |
| /// timezone of t and UTC formatted according to RFC 822. | |
| String formatDateTime(DateTime t) { | |
| final sign = t.timeZoneOffset.isNegative ? '-' : '+'; | |
| final tzOffset = t.timeZoneOffset.abs(); | |
| final hoursOffset = tzOffset.inHours; | |
| final minutesOffset = | |
| tzOffset.inMinutes - (Duration.minutesPerHour * hoursOffset); | |
| assert(hoursOffset < 24); | |
| assert(minutesOffset < 60); | |
| String twoDigits(int n) => (n >= 10) ? '$n' : '0$n'; | |
| return '$t $sign${twoDigits(hoursOffset)}${twoDigits(minutesOffset)}'; | |
| } | |
| /// Construct the Map that will be converted to json for the | |
| /// body of the request. | |
| /// | |
| /// Follows the following schema: | |
| /// | |
| /// ``` | |
| /// { | |
| /// "client_id": "46cc0ba6-f604-4fd9-aa2f-8a20beb24cd4", | |
| /// "events": [{ "name": "testing", "params": { "time_ns": 345 } }], | |
| /// "user_properties": { | |
| /// "session_id": { "value": 1673466750423 }, | |
| /// "flutter_channel": { "value": "ey-test-channel" }, | |
| /// "host": { "value": "macos" }, | |
| /// "flutter_version": { "value": "Flutter 3.6.0-7.0.pre.47" }, | |
| /// "dart_version": { "value": "Dart 2.19.0" }, | |
| /// "tool": { "value": "flutter-tools" }, | |
| /// "local_time": { "value": "2023-01-11 14:53:31.471816 -0500" } | |
| /// } | |
| /// } | |
| /// ``` | |
| /// https://developers.google.com/analytics/devguides/collection/protocol/ga4/sending-events?client_type=gtag | |
| Map<String, Object?> generateRequestBody({ | |
| required String clientId, | |
| required DashEvent eventName, | |
| required Map<String, Object?> eventData, | |
| required UserProperty userProperty, | |
| required String? enabledFeatures, | |
| }) => <String, Object?>{ | |
| 'client_id': clientId, | |
| 'events': <Map<String, Object?>>[ | |
| <String, Object?>{ | |
| 'name': eventName.label, | |
| 'params': {...eventData, 'enabled_features': ?enabledFeatures}, | |
| }, | |
| ], | |
| 'user_properties': userProperty.preparePayload(), | |
| }; | |
| /// Returns a Map of environment variables that should be included in processes | |
| /// that spawn a Dash sub-tool. | |
| Map<String, String> getEnvironment({ | |
| required DashTool currentTool, | |
| bool suppressAnalytics = false, | |
| }) => { | |
| ...io.Platform.environment, | |
| DashEnvVar.suppressAnalytics.name: areAnalyticsSuppressed( | |
| defaultValue: suppressAnalytics, | |
| ).toString(), | |
| DashEnvVar.tool.name: topLevelTool(current: currentTool).name, | |
| }; | |
| /// This will use environment variables to get the user's | |
| /// home directory where all the directory will be created that will | |
| /// contain all of the analytics files. | |
| Directory? getHomeDirectory(FileSystem fs) { | |
| String? home; | |
| final envVars = io.Platform.environment; | |
| if (io.Platform.isMacOS) { | |
| home = envVars['HOME']; | |
| } else if (io.Platform.isLinux) { | |
| home = envVars['HOME']; | |
| } else if (io.Platform.isWindows) { | |
| home = envVars['AppData']; | |
| } | |
| if (home == null) return null; | |
| return fs.directory(home); | |
| } | |
| /// Returns `true` if user has opted out of legacy analytics in | |
| /// Dart or Flutter. | |
| /// | |
| /// Checks legacy opt-out status for the Flutter | |
| /// and Dart in the following locations. | |
| /// | |
| /// Dart: `$HOME/.dart/dartdev.json` | |
| /// ``` | |
| /// { | |
| /// "firstRun": false, | |
| /// "enabled": false, <-- THIS USER HAS OPTED OUT | |
| /// "disclosureShown": true, | |
| /// "clientId": "52710e60-7c70-4335-b3a4-9d922630f12a" | |
| /// } | |
| /// ``` | |
| /// | |
| /// Flutter: `$HOME/.flutter` | |
| /// ``` | |
| /// { | |
| /// "firstRun": false, | |
| /// "clientId": "4c3a3d1e-e545-47e7-b4f8-10129f6ab169", | |
| /// "enabled": false <-- THIS USER HAS OPTED OUT | |
| /// } | |
| /// ``` | |
| /// | |
| /// Devtools: `$HOME/.flutter-devtools/.devtools` | |
| /// ``` | |
| /// { | |
| /// "analyticsEnabled": false, <-- THIS USER HAS OPTED OUT | |
| /// "isFirstRun": false, | |
| /// "lastReleaseNotesVersion": "2.31.0", | |
| /// "2023-Q4": { | |
| /// "surveyActionTaken": false, | |
| /// "surveyShownCount": 0 | |
| /// } | |
| /// } | |
| /// ``` | |
| bool legacyOptOut({required Directory homeDirectory}) { | |
| // List of Maps for each of the config file, `key` refers to the | |
| // key in the json file that indicates if the user has been opted | |
| // out or not | |
| final legacyConfigFiles = [ | |
| ( | |
| tool: DashTool.dartTool, | |
| file: homeDirectory.childDirectory('.dart').childFile('dartdev.json'), | |
| key: 'enabled', | |
| ), | |
| ( | |
| tool: DashTool.flutterTool, | |
| file: homeDirectory.childFile('.flutter'), | |
| key: 'enabled', | |
| ), | |
| ( | |
| tool: DashTool.devtools, | |
| file: homeDirectory | |
| .childDirectory('.flutter-devtools') | |
| .childFile('.devtools'), | |
| key: 'analyticsEnabled', | |
| ), | |
| ]; | |
| for (final legacyConfigObj in legacyConfigFiles) { | |
| final legacyFile = legacyConfigObj.file; | |
| final lookupKey = legacyConfigObj.key; | |
| if (legacyFile.existsSync()) { | |
| try { | |
| final legacyFileObj = | |
| jsonDecode(legacyFile.readAsStringSync()) as Map<String, Object?>; | |
| if (legacyFileObj.containsKey(lookupKey) && | |
| legacyFileObj[lookupKey] == false) { | |
| return true; | |
| } | |
| } on FormatException { | |
| // In the case of an error when parsing the json file, return true | |
| // which will result in the user being opted out of unified_analytics | |
| // | |
| // A corrupted file could mean they opted out previously but for some | |
| // reason, the file was written incorrectly | |
| return true; | |
| } on FileSystemException { | |
| return true; | |
| } | |
| } | |
| } | |
| return false; | |
| } | |
| /// Helper method that can be used to resolve the Dart SDK version for clients | |
| /// of package:unified_analytics. | |
| /// | |
| /// Input [versionString] for this method should be the returned string from | |
| /// [io.Platform.version]. | |
| /// | |
| /// For tools that don't already have a method for resolving the Dart | |
| /// SDK version in semver notation, this helper can be used. This uses | |
| /// the [io.Platform.version] to parse the semver. | |
| /// | |
| /// Example for stable version: | |
| /// `3.3.0 (stable) (Tue Feb 13 10:25:19 2024 +0000) on "macos_arm64"` into | |
| /// `3.3.0`. | |
| /// | |
| /// Example for non-stable version: | |
| /// `2.1.0-dev.8.0.flutter-312ae32` into `2.1.0 (build 2.1.0-dev.8.0 312ae32)`. | |
| String parseDartSDKVersion(String versionString) { | |
| versionString = versionString.trim(); | |
| final justVersion = versionString.split(' ')[0]; | |
| // For non-stable versions, this regex will include build information | |
| return justVersion.replaceFirstMapped(RegExp(r'(\d+\.\d+\.\d+)(.+)'), ( | |
| Match match, | |
| ) { | |
| final noFlutter = match[2]!.replaceAll('.flutter-', ' '); | |
| return '${match[1]} (build ${match[1]}$noFlutter)'.trim(); | |
| }); | |
| } | |
| /// Will use two strings to produce a double for applying a sampling | |
| /// rate for [Survey] to be returned to the user. | |
| double sampleRate(String string1, String string2) => | |
| ((string1.hashCode + string2.hashCode) % 101) / 100; | |
| /// Function to check if a given [Survey] can be shown again | |
| /// by checking if it was snoozed or permanently dismissed. | |
| /// | |
| /// If the [Survey] doesn't exist in the persisted file, then it | |
| /// will be shown to the user. | |
| /// | |
| /// If the [Survey] has been permanently dismissed, we will not | |
| /// show it to the user. | |
| /// | |
| /// If the [Survey] has been snoozed, we will check the timestamp | |
| /// that it was snoozed at with the current time from [clock] | |
| /// and if the snooze period has elapsed, then we will show it to the user. | |
| bool surveySnoozedOrDismissed( | |
| Survey survey, | |
| Map<String, PersistedSurvey> persistedSurveyMap, | |
| ) { | |
| // If this survey hasn't been persisted yet, it is okay to pass | |
| // to the user | |
| if (!persistedSurveyMap.containsKey(survey.uniqueId)) return false; | |
| final persistedSurveyObj = persistedSurveyMap[survey.uniqueId]!; | |
| // If the survey has been dismissed permanently, we will not show the | |
| // survey | |
| if (!persistedSurveyObj.snoozed) return true; | |
| // Find how many minutes has elapsed from the timestamp and now | |
| final minutesElapsed = clock | |
| .now() | |
| .difference(persistedSurveyObj.timestamp) | |
| .inMinutes; | |
| return survey.snoozeForMinutes > minutesElapsed; | |
| } | |
| /// Returns the top-level tool from the environment variable `DASH__TOOL`. | |
| /// | |
| /// If the environment variable is set and valid, return the corresponding | |
| /// [DashTool]. Otherwise, return the [current] tool. | |
| DashTool topLevelTool({required DashTool current}) { | |
| final toolValue = io.Platform.environment[DashEnvVar.tool.name]; | |
| if (toolValue != null) { | |
| try { | |
| return DashTool.fromLabel(toolValue); | |
| } on Exception { | |
| // Fallback to `current` if the value in ENV is invalid. | |
| } | |
| } | |
| return current; | |
| } | |
| /// Due to some limitations for GA4, this function can be used to | |
| /// truncate fields that we may not care about truncating, such as | |
| /// the host os details. | |
| /// | |
| /// [maxLength] represents the maximum length allowed for the string. | |
| /// | |
| /// Example: | |
| /// "Linux 6.2.0-1015-azure #15~22.04.1-Ubuntu SMP Fri Oct 6 13:20:44 UTC 2023" | |
| /// | |
| /// The above string is what is returned by [io.Platform.operatingSystemVersion] | |
| /// for certain machines running GitHub Actions, this function will truncate | |
| /// that value down to the maximum length at 36 characters and return the below | |
| /// | |
| /// Return: | |
| /// "Linux 6.2.0-1015-azure #15~22." | |
| /// | |
| /// This should only be used on fields that are okay to be truncated, this | |
| /// should not be used for parameters on the [Event] constructors. | |
| String truncateStringToLength(String str, int maxLength) { | |
| if (maxLength <= 0) { | |
| throw ArgumentError( | |
| 'The length to truncate a string must be greater than 0', | |
| ); | |
| } | |
| if (maxLength > str.length) return str; | |
| return str.substring(0, maxLength); | |
| } | |
| /// Writes the JSON string payload to the provided [sessionFile]. | |
| /// | |
| /// The `last_ping` key:value pair has been deprecated, it remains included | |
| /// for backward compatibility. | |
| void writeSessionContents({required File sessionFile}) { | |
| try { | |
| final now = clock.now(); | |
| sessionFile.writeAsStringSync( | |
| '{"session_id": ${now.millisecondsSinceEpoch}, ' | |
| '"last_ping": ${now.millisecondsSinceEpoch}}', | |
| ); | |
| } on Object { | |
| // Ignore exceptions and errors. | |
| } | |
| } | |
| /// A UUID generator. | |
| /// | |
| /// This will generate unique IDs in the format: | |
| /// | |
| /// f47ac10b-58cc-4372-a567-0e02b2c3d479 | |
| /// | |
| /// The generated uuids are 128 bit numbers encoded in a specific string format. | |
| /// For more information, see | |
| /// [en.wikipedia.org/wiki/Universally_unique_identifier](http://en.wikipedia.org/wiki/Universally_unique_identifier). | |
| /// | |
| /// This class was taken from the previous `usage` package (https://github.com/dart-lang/usage/blob/master/lib/uuid/uuid.dart). | |
| class Uuid { | |
| final Random _random; | |
| Uuid([int? seed]) : _random = Random(seed); | |
| /// Generate a version 4 (random) uuid. This is a uuid scheme that only uses | |
| /// random numbers as the source of the generated uuid. | |
| String generateV4() { | |
| // Generate xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx / 8-4-4-4-12. | |
| final special = 8 + _random.nextInt(4); | |
| return '${_bitsDigits(16, 4)}${_bitsDigits(16, 4)}-' | |
| '${_bitsDigits(16, 4)}-' | |
| '4${_bitsDigits(12, 3)}-' | |
| '${_printDigits(special, 1)}${_bitsDigits(12, 3)}-' | |
| '${_bitsDigits(16, 4)}${_bitsDigits(16, 4)}${_bitsDigits(16, 4)}'; | |
| } | |
| String _bitsDigits(int bitCount, int digitCount) => | |
| _printDigits(_generateBits(bitCount), digitCount); | |
| int _generateBits(int bitCount) => _random.nextInt(1 << bitCount); | |
| String _printDigits(int value, int count) => | |
| value.toRadixString(16).padLeft(count, '0'); | |
| } |