Honor legacy opt out status (#80)
* Function added to check dart and flutter legacy analytics files
* Sort members in utils.dart
* Logic updated to change config string if legacy optout
* Update tests for both dart and flutter legacy analytics
* Doc clean up
* On error, assume user has opted out of legacy analytics
* Update CHANGELOG.md
* Alter consent message based on tool using package
* Clean up try blocks
* Update try blocks for add'l exception + ignore lint for http client
* Change home directory location for windows
* Update documentation on http client
diff --git a/pkgs/unified_analytics/CHANGELOG.md b/pkgs/unified_analytics/CHANGELOG.md
index 43aac7d..ba3b576 100644
--- a/pkgs/unified_analytics/CHANGELOG.md
+++ b/pkgs/unified_analytics/CHANGELOG.md
@@ -1,6 +1,7 @@
## 1.1.0
- Added a `okToSend` getter so that clients can easily and accurately check the state of the consent mechanism.
+- Initialize the config file with user opted out if user was opted out in legacy Flutter and Dart analytics
## 1.0.1
diff --git a/pkgs/unified_analytics/analysis_options.yaml b/pkgs/unified_analytics/analysis_options.yaml
index a3a02e6..e6794b3 100644
--- a/pkgs/unified_analytics/analysis_options.yaml
+++ b/pkgs/unified_analytics/analysis_options.yaml
@@ -18,6 +18,7 @@
linter:
rules:
- always_declare_return_types
+ - avoid_catches_without_on_clauses
- camel_case_types
- prefer_single_quotes
- unawaited_futures
diff --git a/pkgs/unified_analytics/lib/src/analytics.dart b/pkgs/unified_analytics/lib/src/analytics.dart
index c7b08ce..da67861 100644
--- a/pkgs/unified_analytics/lib/src/analytics.dart
+++ b/pkgs/unified_analytics/lib/src/analytics.dart
@@ -376,8 +376,15 @@
}
@override
- String get getConsentMessage =>
- kToolsMessage.replaceAll('[tool name]', tool.description);
+ String get getConsentMessage {
+ // The command to swap in the consent message
+ final String commandString =
+ tool == DashTool.flutterTool ? 'flutter' : 'dart';
+
+ return kToolsMessage
+ .replaceAll('[tool name]', tool.description)
+ .replaceAll('[dart|flutter]', commandString);
+ }
/// Checking the [telemetryEnabled] boolean reflects what the
/// config file reflects
diff --git a/pkgs/unified_analytics/lib/src/ga_client.dart b/pkgs/unified_analytics/lib/src/ga_client.dart
index 0fd01f5..8396eb9 100644
--- a/pkgs/unified_analytics/lib/src/ga_client.dart
+++ b/pkgs/unified_analytics/lib/src/ga_client.dart
@@ -47,15 +47,28 @@
/// Receive the payload in Map form and parse
/// into JSON to send to GA
+ ///
+ /// The [Response] returned from this method can be
+ /// checked to ensure that events have been sent. A response
+ /// status code of `2xx` indicates a successful send event.
+ /// A response status code of `500` indicates an error occured on the send
+ /// can the error message can be found in the [Response.body]
Future<http.Response> sendData(Map<String, Object?> body) async {
+ final Uri uri = Uri.parse(postUrl);
+
+ /// Using a try catch all since post method can result in several
+ /// errors; clients using this method can check the awaited status
+ /// code to get a specific error message if the status code returned
+ /// is a 500 error status code
try {
return await _client.post(
- Uri.parse(postUrl),
+ uri,
headers: <String, String>{
'Content-Type': 'application/json; charset=UTF-8',
},
body: jsonEncode(body),
);
+ // ignore: avoid_catches_without_on_clauses
} catch (error) {
return Future<http.Response>.value(http.Response(error.toString(), 500));
}
diff --git a/pkgs/unified_analytics/lib/src/initializer.dart b/pkgs/unified_analytics/lib/src/initializer.dart
index 981a747..256314f 100644
--- a/pkgs/unified_analytics/lib/src/initializer.dart
+++ b/pkgs/unified_analytics/lib/src/initializer.dart
@@ -60,7 +60,16 @@
required int toolsMessageVersion,
}) {
configFile.createSync(recursive: true);
- configFile.writeAsStringSync(kConfigString);
+
+ // If the user was previously opted out, then we will
+ // replace the line that assumes automatic opt in with
+ // an opt out from the start
+ if (legacyOptOut(fs: fs, home: homeDirectory)) {
+ configFile.writeAsStringSync(
+ kConfigString.replaceAll('reporting=1', 'reporting=0'));
+ } else {
+ configFile.writeAsStringSync(kConfigString);
+ }
}
/// Creates that log file that will store the record formatted
diff --git a/pkgs/unified_analytics/lib/src/utils.dart b/pkgs/unified_analytics/lib/src/utils.dart
index e9b5562..2b69d9a 100644
--- a/pkgs/unified_analytics/lib/src/utils.dart
+++ b/pkgs/unified_analytics/lib/src/utils.dart
@@ -2,10 +2,12 @@
// 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:file/file.dart';
+import 'package:path/path.dart' as p;
import 'enums.dart';
import 'user_property.dart';
@@ -75,12 +77,88 @@
} else if (io.Platform.isLinux) {
home = envVars['HOME'];
} else if (io.Platform.isWindows) {
- home = envVars['UserProfile'];
+ home = envVars['AppData'];
}
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`
+///
+/// Flutter: `$HOME/.flutter`
+bool legacyOptOut({
+ required FileSystem fs,
+ required Directory home,
+}) {
+ final File dartLegacyConfigFile =
+ fs.file(p.join(home.path, '.dart', 'dartdev.json'));
+ final File flutterLegacyConfigFile = fs.file(p.join(home.path, '.flutter'));
+
+ // Example of what the file looks like for dart
+ //
+ // {
+ // "firstRun": false,
+ // "enabled": false, <-- THIS USER HAS OPTED OUT
+ // "disclosureShown": true,
+ // "clientId": "52710e60-7c70-4335-b3a4-9d922630f12a"
+ // }
+ if (dartLegacyConfigFile.existsSync()) {
+ try {
+ // Read in the json object into a Map and check for
+ // the enabled key being set to false; this means the user
+ // has opted out of analytics for dart
+ final Map<String, Object?> dartObj =
+ jsonDecode(dartLegacyConfigFile.readAsStringSync());
+ if (dartObj.containsKey('enabled') && dartObj['enabled'] == 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;
+ }
+ }
+
+ // Example of what the file looks like for flutter
+ //
+ // {
+ // "firstRun": false,
+ // "clientId": "4c3a3d1e-e545-47e7-b4f8-10129f6ab169",
+ // "enabled": false <-- THIS USER HAS OPTED OUT
+ // }
+ if (flutterLegacyConfigFile.existsSync()) {
+ try {
+ // Same process as above for dart
+ final Map<String, Object?> flutterObj =
+ jsonDecode(dartLegacyConfigFile.readAsStringSync());
+ if (flutterObj.containsKey('enabled') && flutterObj['enabled'] == 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;
+}
+
/// A UUID generator.
///
/// This will generate unique IDs in the format:
diff --git a/pkgs/unified_analytics/test/legacy_analytics_test.dart b/pkgs/unified_analytics/test/legacy_analytics_test.dart
new file mode 100644
index 0000000..3a70f0b
--- /dev/null
+++ b/pkgs/unified_analytics/test/legacy_analytics_test.dart
@@ -0,0 +1,234 @@
+// 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:io' as io;
+
+import 'package:file/file.dart';
+import 'package:file/memory.dart';
+import 'package:test/test.dart';
+
+import 'package:unified_analytics/unified_analytics.dart';
+
+void main() {
+ late FileSystem fs;
+ late Directory home;
+ late Analytics analytics;
+
+ const String homeDirName = 'home';
+ const DashTool initialTool = DashTool.flutterTool;
+ const String measurementId = 'measurementId';
+ const String apiSecret = 'apiSecret';
+ const int toolsMessageVersion = 1;
+ const String toolsMessage = 'toolsMessage';
+ const String flutterChannel = 'flutterChannel';
+ const String flutterVersion = 'flutterVersion';
+ const String dartVersion = 'dartVersion';
+ const DevicePlatform platform = DevicePlatform.macos;
+
+ setUp(() {
+ // Setup the filesystem with the home directory
+ final FileSystemStyle fsStyle =
+ io.Platform.isWindows ? FileSystemStyle.windows : FileSystemStyle.posix;
+ fs = MemoryFileSystem.test(style: fsStyle);
+ home = fs.directory(homeDirName);
+ });
+
+ test('Honor legacy dart analytics opt out', () {
+ // Create the file for the dart legacy opt out
+ final File dartLegacyConfigFile =
+ home.childDirectory('.dart').childFile('dartdev.json');
+ dartLegacyConfigFile.createSync(recursive: true);
+ dartLegacyConfigFile.writeAsStringSync('''
+{
+ "firstRun": false,
+ "enabled": false,
+ "disclosureShown": true,
+ "clientId": "52710e60-7c70-4335-b3a4-9d922630f12a"
+}
+''');
+
+ // The main analytics instance, other instances can be spawned within tests
+ // to test how to instances running together work
+ analytics = Analytics.test(
+ tool: initialTool,
+ homeDirectory: home,
+ measurementId: measurementId,
+ apiSecret: apiSecret,
+ flutterChannel: flutterChannel,
+ toolsMessageVersion: toolsMessageVersion,
+ toolsMessage: toolsMessage,
+ flutterVersion: flutterVersion,
+ dartVersion: dartVersion,
+ fs: fs,
+ platform: platform,
+ );
+
+ expect(analytics.telemetryEnabled, false);
+ });
+
+ test('Telemetry enabled if legacy dart analytics is enabled', () {
+ // Create the file for the dart legacy opt out
+ final File dartLegacyConfigFile =
+ home.childDirectory('.dart').childFile('dartdev.json');
+ dartLegacyConfigFile.createSync(recursive: true);
+ dartLegacyConfigFile.writeAsStringSync('''
+{
+ "firstRun": false,
+ "enabled": true,
+ "disclosureShown": true,
+ "clientId": "52710e60-7c70-4335-b3a4-9d922630f12a"
+}
+''');
+
+ // The main analytics instance, other instances can be spawned within tests
+ // to test how to instances running together work
+ analytics = Analytics.test(
+ tool: initialTool,
+ homeDirectory: home,
+ measurementId: measurementId,
+ apiSecret: apiSecret,
+ flutterChannel: flutterChannel,
+ toolsMessageVersion: toolsMessageVersion,
+ toolsMessage: toolsMessage,
+ flutterVersion: flutterVersion,
+ dartVersion: dartVersion,
+ fs: fs,
+ platform: platform,
+ );
+
+ expect(analytics.telemetryEnabled, true);
+ });
+
+ test('Honor legacy flutter analytics opt out', () {
+ // Create the file for the dart legacy opt out
+ final File flutterLegacyConfigFile =
+ home.childDirectory('.dart').childFile('dartdev.json');
+ flutterLegacyConfigFile.createSync(recursive: true);
+ flutterLegacyConfigFile.writeAsStringSync('''
+{
+ "firstRun": false,
+ "clientId": "4c3a3d1e-e545-47e7-b4f8-10129f6ab169",
+ "enabled": false
+}
+''');
+
+ // The main analytics instance, other instances can be spawned within tests
+ // to test how to instances running together work
+ analytics = Analytics.test(
+ tool: initialTool,
+ homeDirectory: home,
+ measurementId: measurementId,
+ apiSecret: apiSecret,
+ flutterChannel: flutterChannel,
+ toolsMessageVersion: toolsMessageVersion,
+ toolsMessage: toolsMessage,
+ flutterVersion: flutterVersion,
+ dartVersion: dartVersion,
+ fs: fs,
+ platform: platform,
+ );
+
+ expect(analytics.telemetryEnabled, false);
+ });
+
+ test('Telemetry enabled if legacy flutter analytics is enabled', () {
+ // Create the file for the dart legacy opt out
+ final File flutterLegacyConfigFile =
+ home.childDirectory('.dart').childFile('dartdev.json');
+ flutterLegacyConfigFile.createSync(recursive: true);
+ flutterLegacyConfigFile.writeAsStringSync('''
+{
+ "firstRun": false,
+ "clientId": "4c3a3d1e-e545-47e7-b4f8-10129f6ab169",
+ "enabled": true
+}
+''');
+
+ // The main analytics instance, other instances can be spawned within tests
+ // to test how to instances running together work
+ analytics = Analytics.test(
+ tool: initialTool,
+ homeDirectory: home,
+ measurementId: measurementId,
+ apiSecret: apiSecret,
+ flutterChannel: flutterChannel,
+ toolsMessageVersion: toolsMessageVersion,
+ toolsMessage: toolsMessage,
+ flutterVersion: flutterVersion,
+ dartVersion: dartVersion,
+ fs: fs,
+ platform: platform,
+ );
+
+ expect(analytics.telemetryEnabled, true);
+ });
+
+ test('Telemetry disabled if dart config file corrupted', () {
+ // Create the file for the dart legacy opt out with text that
+ // is not valid JSON
+ final File dartLegacyConfigFile =
+ home.childDirectory('.dart').childFile('dartdev.json');
+ dartLegacyConfigFile.createSync(recursive: true);
+ dartLegacyConfigFile.writeAsStringSync('''
+NOT VALID JSON
+{
+ "firstRun": false,
+ "clientId": "4c3a3d1e-e545-47e7-b4f8-10129f6ab169",
+ "enabled": true
+}
+''');
+
+ // The main analytics instance, other instances can be spawned within tests
+ // to test how to instances running together work
+ analytics = Analytics.test(
+ tool: initialTool,
+ homeDirectory: home,
+ measurementId: measurementId,
+ apiSecret: apiSecret,
+ flutterChannel: flutterChannel,
+ toolsMessageVersion: toolsMessageVersion,
+ toolsMessage: toolsMessage,
+ flutterVersion: flutterVersion,
+ dartVersion: dartVersion,
+ fs: fs,
+ platform: platform,
+ );
+
+ expect(analytics.telemetryEnabled, false);
+ });
+
+ test('Telemetry disabled if flutter config file corrupted', () {
+ // Create the file for the dart legacy opt out with text that
+ // is not valid JSON
+ final File fluttterLegacyConfigFile =
+ home.childDirectory('.dart').childFile('dartdev.json');
+ fluttterLegacyConfigFile.createSync(recursive: true);
+ fluttterLegacyConfigFile.writeAsStringSync('''
+NOT VALID JSON
+{
+ "firstRun": false,
+ "clientId": "4c3a3d1e-e545-47e7-b4f8-10129f6ab169",
+ "enabled": true
+}
+''');
+
+ // The main analytics instance, other instances can be spawned within tests
+ // to test how to instances running together work
+ analytics = Analytics.test(
+ tool: initialTool,
+ homeDirectory: home,
+ measurementId: measurementId,
+ apiSecret: apiSecret,
+ flutterChannel: flutterChannel,
+ toolsMessageVersion: toolsMessageVersion,
+ toolsMessage: toolsMessage,
+ flutterVersion: flutterVersion,
+ dartVersion: dartVersion,
+ fs: fs,
+ platform: platform,
+ );
+
+ expect(analytics.telemetryEnabled, false);
+ });
+}
diff --git a/pkgs/unified_analytics/test/unified_analytics_test.dart b/pkgs/unified_analytics/test/unified_analytics_test.dart
index 4e555c1..acadae3 100644
--- a/pkgs/unified_analytics/test/unified_analytics_test.dart
+++ b/pkgs/unified_analytics/test/unified_analytics_test.dart
@@ -1095,7 +1095,7 @@
expect(kGoogleAnalyticsMeasurementId, 'G-04BXPVBCWJ');
});
- test('Consent message is formatted correctly', () {
+ test('Consent message is formatted correctly for the flutter tool', () {
// Retrieve the consent message for flutter tools
final String consentMessage = analytics.getConsentMessage;
@@ -1106,7 +1106,41 @@
Flutter framework, and related tools. Telemetry is not sent on the very first
run. To disable reporting of telemetry, run this terminal command:
-[dart|flutter] --disable-telemetry.
+flutter --disable-telemetry.
+
+If you opt out of telemetry, an opt-out event will be sent, and then no
+further information will be sent. This data is collected in accordance with
+the Google Privacy Policy (https://policies.google.com/privacy).
+'''));
+ });
+
+ test('Consent message is formatted correctly for any tool other than flutter',
+ () {
+ // Create a new instance of the analytics class with the new tool
+ final Analytics secondAnalytics = Analytics.test(
+ tool: secondTool,
+ homeDirectory: home,
+ measurementId: 'measurementId',
+ apiSecret: 'apiSecret',
+ flutterChannel: 'ey-test-channel',
+ toolsMessageVersion: toolsMessageVersion,
+ toolsMessage: toolsMessage,
+ flutterVersion: 'Flutter 3.6.0-7.0.pre.47',
+ dartVersion: 'Dart 2.19.0',
+ fs: fs,
+ platform: platform,
+ );
+
+ // Retrieve the consent message for flutter tools
+ final String consentMessage = secondAnalytics.getConsentMessage;
+ expect(consentMessage, equalsIgnoringWhitespace(r'''
+The Dart CLI developer tool uses Google Analytics to report usage and
+diagnostic data along with package dependencies, and crash reporting to
+send basic crash reports. This data is used to help improve the Dart platform,
+Flutter framework, and related tools. Telemetry is not sent on the very first
+run. To disable reporting of telemetry, run this terminal command:
+
+dart --disable-telemetry.
If you opt out of telemetry, an opt-out event will be sent, and then no
further information will be sent. This data is collected in accordance with