blob: 41848a204bdc8054f5debd69cf6c1c0e9f2323aa [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 '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<String, Object?> generateRequestBody({
required String clientId,
required DashEvent eventName,
required Map<String, Object?> eventData,
required UserProperty userProperty,
}) =>
<String, Object?>{
'client_id': clientId,
'events': <Map<String, Object?>>[
<String, Object?>{
'name': eventName.label,
'params': eventData,
}
],
'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<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;
}
/// 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');
}