// 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()); } /// 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 generateRequestBody({ required String clientId, required DashEvent eventName, required Map eventData, required UserProperty userProperty, required String? enabledFeatures, }) => { 'client_id': clientId, 'events': >[ { 'name': eventName.label, 'params': { ...eventData, if (enabledFeatures != null) 'enabled_features': enabledFeatures, }, } ], 'user_properties': userProperty.preparePayload() }; /// 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; 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 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; } /// 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}) { final now = clock.now(); sessionFile.writeAsStringSync('{"session_id": ${now.millisecondsSinceEpoch}, ' '"last_ping": ${now.millisecondsSinceEpoch}}'); } /// 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'); }