[flutter_tool] Update analytics policy, send event on disable (#43217)

This commit is contained in:
Zachary Anderson 2019-11-05 10:43:52 -08:00 committed by GitHub
parent f6eb129597
commit 372fe290e4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 323 additions and 37 deletions

View File

@ -215,9 +215,8 @@ Future<String> _doctorText() async {
}
Future<int> _exit(int code) async {
if (flutterUsage.isFirstRun) {
flutterUsage.printWelcome();
}
// Prints the welcome message if needed.
flutterUsage.printWelcome();
// Send any last analytics calls that are in progress without overly delaying
// the tool's exit (we wait a maximum of 250ms).

View File

@ -5,11 +5,10 @@
import '../convert.dart';
import 'context.dart';
import 'file_system.dart';
import 'platform.dart';
class Config {
Config([File configFile]) {
_configFile = configFile ?? fs.file(fs.path.join(_userHomeDir(), '.flutter_settings'));
_configFile = configFile ?? fs.file(fs.path.join(userHomePath(), '.flutter_settings'));
if (_configFile.existsSync()) {
_values = json.decode(_configFile.readAsStringSync());
}
@ -44,8 +43,3 @@ class Config {
_configFile.writeAsStringSync(json);
}
}
String _userHomeDir() {
final String envKey = platform.operatingSystem == 'windows' ? 'APPDATA' : 'HOME';
return platform.environment[envKey] ?? '.';
}

View File

@ -185,3 +185,11 @@ class FileNotFoundException implements IOException {
@override
String toString() => 'File not found: $path';
}
/// Reads the process environment to find the current user's home directory.
///
/// If the searched environment variables are not set, '.' is returned instead.
String userHomePath() {
final String envKey = platform.operatingSystem == 'windows' ? 'APPDATA' : 'HOME';
return platform.environment[envKey] ?? '.';
}

View File

@ -117,6 +117,7 @@ class ConfigCommand extends FlutterCommand {
if (argResults.wasParsed('analytics')) {
final bool value = argResults['analytics'];
flutterUsage.enabled = value;
AnalyticsConfigEvent(enabled: value).send();
printStatus('Analytics reporting ${value ? 'enabled' : 'disabled'}.');
}

View File

@ -15,12 +15,14 @@ import '../base/process.dart';
import '../cache.dart';
import '../dart/pub.dart';
import '../globals.dart';
import '../persistent_tool_state.dart';
import '../runner/flutter_command.dart';
import '../version.dart';
import 'channel.dart';
class UpgradeCommand extends FlutterCommand {
UpgradeCommand() {
UpgradeCommand([UpgradeCommandRunner commandRunner])
: _commandRunner = commandRunner ?? UpgradeCommandRunner() {
argParser
..addFlag(
'force',
@ -32,10 +34,14 @@ class UpgradeCommand extends FlutterCommand {
'continue',
hide: true,
negatable: false,
help: 'For the second half of the upgrade flow requiring the new version of Flutter. Should not be invoked manually, but re-entrantly by the standard upgrade command.',
help: 'For the second half of the upgrade flow requiring the new '
'version of Flutter. Should not be invoked manually, but '
're-entrantly by the standard upgrade command.',
);
}
final UpgradeCommandRunner _commandRunner;
@override
final String name = 'upgrade';
@ -52,8 +58,7 @@ class UpgradeCommand extends FlutterCommand {
@override
Future<FlutterCommandResult> runCommand() async {
final UpgradeCommandRunner upgradeCommandRunner = UpgradeCommandRunner();
await upgradeCommandRunner.runCommand(
await _commandRunner.runCommand(
argResults['force'],
argResults['continue'],
GitTagVersion.determine(),
@ -141,9 +146,13 @@ class UpgradeCommandRunner {
// This method should only be called if the upgrade command is invoked
// re-entrantly with the `--continue` flag
Future<void> runCommandSecondHalf(FlutterVersion flutterVersion) async {
// Make sure the welcome message re-display is delayed until the end.
persistentToolState.redisplayWelcomeMessage = false;
await precacheArtifacts();
await updatePackages(flutterVersion);
await runDoctor();
// Force the welcome message to re-display following the upgrade.
persistentToolState.redisplayWelcomeMessage = true;
}
Future<bool> hasUncomittedChanges() async {

View File

@ -48,6 +48,7 @@ import 'macos/macos_workflow.dart';
import 'macos/xcode.dart';
import 'macos/xcode_validator.dart';
import 'mdns_discovery.dart';
import 'persistent_tool_state.dart';
import 'reporting/reporting.dart';
import 'run_hot.dart';
import 'version.dart';
@ -106,9 +107,10 @@ Future<T> runInContext<T>(
MacOSWorkflow: () => const MacOSWorkflow(),
MDnsObservatoryDiscovery: () => MDnsObservatoryDiscovery(),
OperatingSystemUtils: () => OperatingSystemUtils(),
Pub: () => const Pub(),
PersistentToolState: () => PersistentToolState(),
ProcessInfo: () => ProcessInfo(),
ProcessUtils: () => ProcessUtils(),
Pub: () => const Pub(),
Signals: () => Signals(),
SimControl: () => SimControl(),
Stdio: () => const Stdio(),
@ -121,8 +123,8 @@ Future<T> runInContext<T>(
WebWorkflow: () => const WebWorkflow(),
WindowsWorkflow: () => const WindowsWorkflow(),
Xcode: () => Xcode(),
XcodeValidator: () => const XcodeValidator(),
XcodeProjectInterpreter: () => XcodeProjectInterpreter(),
XcodeValidator: () => const XcodeValidator(),
},
);
}

View File

@ -0,0 +1,41 @@
// Copyright 2019 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'base/config.dart';
import 'base/context.dart';
import 'base/file_system.dart';
PersistentToolState get persistentToolState => PersistentToolState.instance;
/// A class that represents global (non-project-specific) internal state that
/// must persist across tool invocations.
abstract class PersistentToolState {
factory PersistentToolState([File configFile]) =>
_DefaultPersistentToolState(configFile);
static PersistentToolState get instance => context.get<PersistentToolState>();
/// Whether the welcome message should be redisplayed.
///
/// May give null if the value has not been set.
bool redisplayWelcomeMessage;
}
class _DefaultPersistentToolState implements PersistentToolState {
_DefaultPersistentToolState([File configFile]) :
_config = Config(configFile ?? fs.file(fs.path.join(userHomePath(), _kFileName)));
static const String _kFileName = '.flutter_tool_state';
static const String _kRedisplayWelcomeMessage = 'redisplay-welcome-message';
final Config _config;
@override
bool get redisplayWelcomeMessage => _config.getValue(_kRedisplayWelcomeMessage);
@override
set redisplayWelcomeMessage(bool value) {
_config.setValue(_kRedisplayWelcomeMessage, value);
}
}

View File

@ -199,3 +199,15 @@ class CommandResultEvent extends UsageEvent {
}
}
}
/// An event that reports on changes in the configuration of analytics.
class AnalyticsConfigEvent extends UsageEvent {
AnalyticsConfigEvent({
/// Whether analytics reporting is being enabled (true) or disabled (false).
@required bool enabled,
}) : super(
'analytics',
'enabled',
label: enabled ? 'true' : 'false',
);
}

View File

@ -21,6 +21,7 @@ import '../base/utils.dart';
import '../doctor.dart';
import '../features.dart';
import '../globals.dart';
import '../persistent_tool_state.dart';
import '../runner/flutter_command.dart';
import '../version.dart';

View File

@ -323,35 +323,55 @@ class _DefaultUsage implements Usage {
await _analytics.waitForLastPing(timeout: const Duration(milliseconds: 250));
}
@override
void printWelcome() {
// This gets called if it's the first run by the selected command, if any,
// and on exit, in case there was no command.
if (_printedWelcome) {
return;
}
_printedWelcome = true;
void _printWelcome() {
printStatus('');
printStatus('''
Welcome to Flutter! - https://flutter.dev
The Flutter tool anonymously reports feature usage statistics and crash
reports to Google in order to help Google contribute improvements to
Flutter over time.
The Flutter tool uses Google Analytics to anonymously report feature usage
statistics and basic crash reports. This data is used to help improve
Flutter tools over time.
Flutter tool analytics are not sent on the very first run. To disable
reporting, type 'flutter config --no-analytics'. To display the current
setting, type 'flutter config'. If you opt out of analytics, an opt-out
event will be sent, and then no further information will be sent by the
Flutter tool.
By downloading the Flutter SDK, you agree to the Google Terms of Service.
Note: The Google Privacy Policy describes how data is handled in this
service.
Moreover, Flutter includes the Dart SDK, which may send usage metrics and
crash reports to Google.
Read about data we send with crash reports:
https://github.com/flutter/flutter/wiki/Flutter-CLI-crash-reporting
See Google's privacy policy: ║
https://www.google.com/intl/en/policies/privacy/
Use "flutter config --no-analytics" to disable analytics and crash
reporting.
''', emphasis: true);
}
@override
void printWelcome() {
// Only print once per run.
if (_printedWelcome) {
return;
}
if (// Display the welcome message if this is the first run of the tool.
isFirstRun ||
// Display the welcome message if we are not on master, and if the
// persistent tool state instructs that we should.
(!FlutterVersion.instance.isMaster &&
(persistentToolState.redisplayWelcomeMessage ?? true))) {
_printWelcome();
_printedWelcome = true;
persistentToolState.redisplayWelcomeMessage = false;
}
}
}
// An Analytics mock that logs to file. Unimplemented methods goes to stdout.

View File

@ -447,9 +447,8 @@ abstract class FlutterCommand extends Command<void> {
name: 'command',
overrides: <Type, Generator>{FlutterCommand: () => this},
body: () async {
if (flutterUsage.isFirstRun) {
flutterUsage.printWelcome();
}
// Prints the welcome message if needed.
flutterUsage.printWelcome();
final String commandPath = await usagePath;
_registerSignalHandlers(commandPath, startTime);
FlutterCommandResult commandResult;

View File

@ -14,6 +14,7 @@ import 'package:flutter_tools/src/base/logger.dart';
import 'package:flutter_tools/src/build_info.dart';
import 'package:flutter_tools/src/cache.dart';
import 'package:flutter_tools/src/commands/config.dart';
import 'package:flutter_tools/src/reporting/reporting.dart';
import 'package:flutter_tools/src/version.dart';
import 'package:mockito/mockito.dart';
@ -24,6 +25,7 @@ void main() {
MockAndroidStudio mockAndroidStudio;
MockAndroidSdk mockAndroidSdk;
MockFlutterVersion mockFlutterVersion;
MockUsage mockUsage;
setUpAll(() {
Cache.disableLocking();
@ -33,8 +35,31 @@ void main() {
mockAndroidStudio = MockAndroidStudio();
mockAndroidSdk = MockAndroidSdk();
mockFlutterVersion = MockFlutterVersion();
mockUsage = MockUsage();
when(mockUsage.isFirstRun).thenReturn(false);
});
void verifyNoAnalytics() {
verifyNever(mockUsage.sendCommand(
any,
parameters: anyNamed('parameters'),
));
verifyNever(mockUsage.sendEvent(
any,
any,
label: anyNamed('label'),
value: anyNamed('value'),
parameters: anyNamed('parameters'),
));
verifyNever(mockUsage.sendTiming(
any,
any,
any,
label: anyNamed('label'),
));
}
group('config', () {
testUsingContext('machine flag', () async {
final BufferLogger logger = context.get<Logger>();
@ -50,9 +75,11 @@ void main() {
expect(jsonObject.containsKey('android-sdk'), true);
expect(jsonObject['android-sdk'], isNotNull);
verifyNoAnalytics();
}, overrides: <Type, Generator>{
AndroidStudio: () => mockAndroidStudio,
AndroidSdk: () => mockAndroidSdk,
Usage: () => mockUsage,
});
testUsingContext('Can set build-dir', () async {
@ -65,6 +92,9 @@ void main() {
]);
expect(getBuildDirectory(), 'foo');
verifyNoAnalytics();
}, overrides: <Type, Generator>{
Usage: () => mockUsage,
});
testUsingContext('throws error on absolute path to build-dir', () async {
@ -75,6 +105,9 @@ void main() {
'config',
'--build-dir=/foo',
]), throwsA(isInstanceOf<ToolExit>()));
verifyNoAnalytics();
}, overrides: <Type, Generator>{
Usage: () => mockUsage,
});
testUsingContext('allows setting and removing feature flags', () async {
@ -115,9 +148,11 @@ void main() {
expect(Config.instance.getValue('enable-linux-desktop'), false);
expect(Config.instance.getValue('enable-windows-desktop'), false);
expect(Config.instance.getValue('enable-macos-desktop'), false);
verifyNoAnalytics();
}, overrides: <Type, Generator>{
AndroidStudio: () => mockAndroidStudio,
AndroidSdk: () => mockAndroidSdk,
Usage: () => mockUsage,
});
testUsingContext('displays which config settings are available on stable', () async {
@ -142,10 +177,86 @@ void main() {
expect(logger.statusText, contains('enable-linux-desktop: true (Unavailable)'));
expect(logger.statusText, contains('enable-windows-desktop: true (Unavailable)'));
expect(logger.statusText, contains('enable-macos-desktop: true (Unavailable)'));
verifyNoAnalytics();
}, overrides: <Type, Generator>{
AndroidStudio: () => mockAndroidStudio,
AndroidSdk: () => mockAndroidSdk,
FlutterVersion: () => mockFlutterVersion,
Usage: () => mockUsage,
});
testUsingContext('no-analytics flag flips usage flag and sends event', () async {
final ConfigCommand configCommand = ConfigCommand();
final CommandRunner<void> commandRunner = createTestCommandRunner(configCommand);
await commandRunner.run(<String>[
'config',
'--no-analytics',
]);
expect(mockUsage.enabled, false);
// Verify that we only send the analytics disable event, and no other
// info.
verifyNever(mockUsage.sendCommand(
any,
parameters: anyNamed('parameters'),
));
verifyNever(mockUsage.sendTiming(
any,
any,
any,
label: anyNamed('label'),
));
expect(verify(mockUsage.sendEvent(
captureAny,
captureAny,
label: captureAnyNamed('label'),
value: anyNamed('value'),
parameters: anyNamed('parameters'),
)).captured,
<dynamic>['analytics', 'enabled', 'false'],
);
}, overrides: <Type, Generator>{
Usage: () => mockUsage,
});
testUsingContext('analytics flag flips usage flag and sends event', () async {
final ConfigCommand configCommand = ConfigCommand();
final CommandRunner<void> commandRunner = createTestCommandRunner(configCommand);
await commandRunner.run(<String>[
'config',
'--analytics',
]);
expect(mockUsage.enabled, true);
// Verify that we only send the analytics disable event, and no other
// info.
verifyNever(mockUsage.sendCommand(
any,
parameters: anyNamed('parameters'),
));
verifyNever(mockUsage.sendTiming(
any,
any,
any,
label: anyNamed('label'),
));
expect(verify(mockUsage.sendEvent(
captureAny,
captureAny,
label: captureAnyNamed('label'),
value: anyNamed('value'),
parameters: anyNamed('parameters'),
)).captured,
<dynamic>['analytics', 'enabled', 'true'],
);
}, overrides: <Type, Generator>{
Usage: () => mockUsage,
});
});
}
@ -161,3 +272,8 @@ class MockAndroidSdk extends Mock implements AndroidSdk {
}
class MockFlutterVersion extends Mock implements FlutterVersion {}
class MockUsage extends Mock implements Usage {
@override
bool enabled = true;
}

View File

@ -2,6 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter_tools/runner.dart' as runner;
import 'package:flutter_tools/src/base/common.dart';
import 'package:flutter_tools/src/base/file_system.dart';
import 'package:flutter_tools/src/base/io.dart';
@ -9,6 +10,7 @@ import 'package:flutter_tools/src/base/platform.dart';
import 'package:flutter_tools/src/base/os.dart';
import 'package:flutter_tools/src/cache.dart';
import 'package:flutter_tools/src/commands/upgrade.dart';
import 'package:flutter_tools/src/persistent_tool_state.dart';
import 'package:flutter_tools/src/runner/flutter_command.dart';
import 'package:flutter_tools/src/version.dart';
import 'package:mockito/mockito.dart';
@ -17,6 +19,7 @@ import 'package:process/process.dart';
import '../../src/common.dart';
import '../../src/context.dart';
import '../../src/fake_process_manager.dart';
import '../../src/mocks.dart';
void main() {
@ -184,6 +187,49 @@ void main() {
ProcessManager: () => processManager,
Platform: () => fakePlatform,
});
group('full command', () {
final FakeProcessManager fakeProcessManager = FakeProcessManager.list(<FakeCommand>[
const FakeCommand(command: <String>[
'git', 'describe', '--match', 'v*.*.*', '--first-parent', '--long', '--tags',
]),
]);
Directory tempDir;
File flutterToolState;
FlutterVersion mockFlutterVersion;
setUp(() {
Cache.disableLocking();
tempDir = fs.systemTempDirectory.createTempSync('flutter_upgrade_test.');
flutterToolState = tempDir.childFile('.flutter_tool_state');
mockFlutterVersion = MockFlutterVersion(isStable: true);
});
tearDown(() {
Cache.enableLocking();
tryToDelete(tempDir);
});
testUsingContext('upgrade continue prints welcome message', () async {
final UpgradeCommand upgradeCommand = UpgradeCommand(fakeCommandRunner);
await runner.run(
<String>[
'upgrade',
'--continue',
],
<FlutterCommand>[
upgradeCommand,
],
);
expect(testLogger.statusText, contains('Welcome to Flutter!'));
}, overrides: <Type, Generator>{
FlutterVersion: () => mockFlutterVersion,
ProcessManager: () => fakeProcessManager,
PersistentToolState: () => PersistentToolState(flutterToolState),
});
});
});
group('matchesGitLine', () {
@ -267,7 +313,6 @@ class FakeUpgradeCommandRunner extends UpgradeCommandRunner {
Future<void> runDoctor() async {}
}
class MockFlutterVersion extends Mock implements FlutterVersion {}
class MockProcess extends Mock implements Process {}
class MockProcessManager extends Mock implements ProcessManager {}
class FakeProcessResult implements ProcessResult {

View File

@ -0,0 +1,31 @@
// Copyright 2019 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter_tools/src/base/file_system.dart';
import 'package:flutter_tools/src/persistent_tool_state.dart';
import '../src/common.dart';
import '../src/testbed.dart';
void main() {
Testbed testbed;
setUp(() {
testbed = Testbed();
});
test('state can be set and persists', () => testbed.run(() {
final File stateFile = fs.file('.flutter_tool_state');
final PersistentToolState state1 = PersistentToolState(stateFile);
expect(state1.redisplayWelcomeMessage, null);
state1.redisplayWelcomeMessage = true;
expect(stateFile.existsSync(), true);
expect(state1.redisplayWelcomeMessage, true);
state1.redisplayWelcomeMessage = false;
expect(state1.redisplayWelcomeMessage, false);
final PersistentToolState state2 = PersistentToolState(stateFile);
expect(state2.redisplayWelcomeMessage, false);
}));
}

View File

@ -23,6 +23,7 @@ import 'package:flutter_tools/src/doctor.dart';
import 'package:flutter_tools/src/ios/plist_parser.dart';
import 'package:flutter_tools/src/ios/simulators.dart';
import 'package:flutter_tools/src/ios/xcodeproj.dart';
import 'package:flutter_tools/src/persistent_tool_state.dart';
import 'package:flutter_tools/src/project.dart';
import 'package:flutter_tools/src/reporting/reporting.dart';
import 'package:flutter_tools/src/version.dart';
@ -71,12 +72,18 @@ void testUsingContext(
}
});
Config buildConfig(FileSystem fs) {
configDir = fs.systemTempDirectory.createTempSync('flutter_config_dir_test.');
configDir ??= fs.systemTempDirectory.createTempSync('flutter_config_dir_test.');
final File settingsFile = fs.file(
fs.path.join(configDir.path, '.flutter_settings')
);
return Config(settingsFile);
}
PersistentToolState buildPersistentToolState(FileSystem fs) {
configDir ??= fs.systemTempDirectory.createTempSync('flutter_config_dir_test.');
final File toolStateFile = fs.file(
fs.path.join(configDir.path, '.flutter_tool_state'));
return PersistentToolState(toolStateFile);
}
test(description, () async {
await runInContext<dynamic>(() {
@ -96,6 +103,7 @@ void testUsingContext(
OutputPreferences: () => OutputPreferences.test(),
Logger: () => BufferLogger(),
OperatingSystemUtils: () => FakeOperatingSystemUtils(),
PersistentToolState: () => buildPersistentToolState(fs),
SimControl: () => MockSimControl(),
Usage: () => FakeUsage(),
XcodeProjectInterpreter: () => FakeXcodeProjectInterpreter(),

View File

@ -64,7 +64,7 @@ final Map<Type, Generator> _testbedDefaults = <Type, Generator>{
/// });
/// })
///
/// test('Can delete a file', () => testBed.run(() {
/// test('Can delete a file', () => testbed.run(() {
/// expect(fs.file('foo').existsSync(), true);
/// fs.file('foo').deleteSync();
/// expect(fs.file('foo').existsSync(), false);