Unverified Commit 1fd84f88 authored by Sigurd Meldgaard's avatar Sigurd Meldgaard Committed by GitHub

Always use user-level pub cache (#121802)

Use the pub cache resolved by pub itself.
To add packages to the flutter.zip download they are packaged as tar.gz and added to the pub-cache on first run by using  `pub cache preload`.
parent 9ba0d08e
......@@ -51,7 +51,7 @@ analysis_benchmark.json
.flutter-plugins-dependencies
**/generated_plugin_registrant.dart
.packages
.pub-cache/
.pub-preload-cache/
.pub/
build/
flutter_*.png
......
......@@ -22,8 +22,6 @@ SET script_path=%flutter_tools_dir%\bin\flutter_tools.dart
SET dart_sdk_path=%cache_dir%\dart-sdk
SET engine_stamp=%cache_dir%\engine-dart-sdk.stamp
SET engine_version_path=%FLUTTER_ROOT%\bin\internal\engine.version
SET pub_cache_path=%FLUTTER_ROOT%\.pub-cache
SET dart=%dart_sdk_path%\bin\dart.exe
REM Ensure that bin/cache exists.
......
......@@ -149,9 +149,6 @@ function upgrade_flutter () (
export PUB_SUMMARY_ONLY=1
fi
export PUB_ENVIRONMENT="$PUB_ENVIRONMENT:flutter_install"
if [[ -d "$FLUTTER_ROOT/.pub-cache" ]]; then
export PUB_CACHE="${PUB_CACHE:-"$FLUTTER_ROOT/.pub-cache"}"
fi
pub_upgrade_with_retry
# Move the old snapshot - we can't just overwrite it as the VM might currently have it
......
......@@ -8,11 +8,13 @@ import 'dart:io' hide Platform;
import 'dart:typed_data';
import 'package:args/args.dart';
import 'package:convert/convert.dart';
import 'package:crypto/crypto.dart';
import 'package:crypto/src/digest_sink.dart';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as path;
import 'package:platform/platform.dart' show LocalPlatform, Platform;
import 'package:pool/pool.dart';
import 'package:process/process.dart';
const String gobMirror =
......@@ -189,7 +191,7 @@ class ArchiveCreator {
subprocessOutput: subprocessOutput,
platform: platform,
)..environment['PUB_CACHE'] = path.join(
flutterRoot.absolute.path, '.pub-cache',
tempDir.path, '.pub-cache',
);
final String flutterExecutable = path.join(
flutterRoot.absolute.path,
......@@ -426,6 +428,104 @@ class ArchiveCreator {
await _unzipArchive(gitFile, workingDirectory: minGitPath);
}
/// Downloads an archive of every package that is present in the temporary
/// pub-cache from pub.dev. Stores the archives in
/// $flutterRoot/.pub-preload-cache.
///
/// These archives will be installed in the user-level cache on first
/// following flutter command that accesses the cache.
///
/// Precondition: all packages currently in the PUB_CACHE of [_processRunner]
/// are installed from pub.dev.
Future<void> _downloadPubPackageArchives() async {
final Pool pool = Pool(10); // Number of simultaneous downloads.
final http.Client client = http.Client();
final Directory preloadCache = Directory(path.join(flutterRoot.path, '.pub-preload-cache'));
preloadCache.createSync(recursive: true);
/// Fetch a single package.
Future<void> fetchPackageArchive(String name, String version) async {
await pool.withResource(() async {
stderr.write('Fetching package archive for $name-$version.\n');
int retries = 7;
while (true) {
retries-=1;
try {
final Uri packageListingUrl =
Uri.parse('https://pub.dev/api/packages/$name');
// Fetch the package listing to obtain the package download url.
final http.Response packageListingResponse =
await client.get(packageListingUrl);
if (packageListingResponse.statusCode != 200) {
throw Exception('Downloading $packageListingUrl failed. Status code ${packageListingResponse.statusCode}.');
}
final dynamic decodedPackageListing = json.decode(packageListingResponse.body);
if (decodedPackageListing is! Map) {
throw const FormatException('Package listing should be a map');
}
final dynamic versions = decodedPackageListing['versions'];
if (versions is! List) {
throw const FormatException('.versions should be a list');
}
final Map<String, dynamic> versionDescription = versions.firstWhere(
(dynamic description) {
if (description is! Map) {
throw const FormatException('.versions elements should be maps');
}
return description['version'] == version;
},
orElse: () => throw FormatException('Could not find $name-$version in package listing')
) as Map<String, dynamic>;
final dynamic downloadUrl = versionDescription['archive_url'];
if (downloadUrl is! String) {
throw const FormatException('archive_url should be a string');
}
final dynamic archiveSha256 = versionDescription['archive_sha256'];
if (archiveSha256 is! String) {
throw const FormatException('archive_sha256 should be a string');
}
final http.Request request = http.Request('get', Uri.parse(downloadUrl));
final http.StreamedResponse response = await client.send(request);
if (response.statusCode != 200) {
throw Exception('Downloading ${request.url} failed. Status code ${response.statusCode}.');
}
final File archiveFile = File(
path.join(preloadCache.path, '$name-$version.tar.gz'),
);
await response.stream.pipe(archiveFile.openWrite());
final Stream<List<int>> archiveStream = archiveFile.openRead();
final Digest r = await sha256.bind(archiveStream).first;
if (hex.encode(r.bytes) != archiveSha256) {
throw Exception('Hash mismatch of downloaded archive');
}
} on Exception catch (e) {
stderr.write('Failed downloading $name-$version. $e\n');
if (retries > 0) {
stderr.write('Retrying download of $name-$version...');
// Retry.
continue;
} else {
rethrow;
}
}
break;
}
});
}
final Map<String, dynamic> cacheDescription =
json.decode(await _runFlutter(<String>['pub', 'cache', 'list'])) as Map<String, dynamic>;
final Map<String, dynamic> packages = cacheDescription['packages'] as Map<String, dynamic>;
final List<Future<void>> downloads = <Future<void>>[];
for (final MapEntry<String, dynamic> package in packages.entries) {
final String name = package.key;
final Map<String, dynamic> versions = package.value as Map<String, dynamic>;
for (final String version in versions.keys) {
downloads.add(fetchPackageArchive(name, version));
}
}
await Future.wait(downloads);
client.close();
}
/// Prepare the archive repo so that it has all of the caches warmed up and
/// is configured for the user to begin working.
Future<void> _populateCaches() async {
......@@ -446,7 +546,7 @@ class ArchiveCreator {
workingDirectory: tempDir,
);
}
await _downloadPubPackageArchives();
// Yes, we could just skip all .packages files when constructing
// the archive, but some are checked in, and we don't want to skip
// those.
......@@ -795,8 +895,8 @@ class ArchivePublisher {
}
}
/// Prepares a flutter git repo to be packaged up for distribution.
/// It mainly serves to populate the .pub-cache with any appropriate Dart
/// Prepares a flutter git repo to be packaged up for distribution. It mainly
/// serves to populate the .pub-preload-cache with any appropriate Dart
/// packages, and the flutter cache in bin/cache with the appropriate
/// dependencies and snapshots.
///
......
......@@ -143,6 +143,7 @@ void main() {
'$flutter create --template=app ${createBase}app': null,
'$flutter create --template=package ${createBase}package': null,
'$flutter create --template=plugin ${createBase}plugin': null,
'$flutter pub cache list': <ProcessResult>[ProcessResult(0,0,'{"packages":{}}','')],
'git clean -f -x -- **/.packages': null,
'git clean -f -x -- **/.dart_tool/': null,
if (platform.isMacOS) 'codesign -vvvv --check-notarization ${path.join(tempDir.path, 'flutter', 'bin', 'cache', 'dart-sdk', 'bin', 'dart')}': null,
......@@ -180,6 +181,7 @@ void main() {
'$flutter create --template=app ${createBase}app': null,
'$flutter create --template=package ${createBase}package': null,
'$flutter create --template=plugin ${createBase}plugin': null,
'$flutter pub cache list': <ProcessResult>[ProcessResult(0,0,'{"packages":{}}','')],
'git clean -f -x -- **/.packages': null,
'git clean -f -x -- **/.dart_tool/': null,
if (platform.isMacOS) 'codesign -vvvv --check-notarization ${path.join(tempDir.path, 'flutter', 'bin', 'cache', 'dart-sdk', 'bin', 'dart')}': null,
......@@ -228,6 +230,7 @@ void main() {
'$flutter create --template=app ${createBase}app': null,
'$flutter create --template=package ${createBase}package': null,
'$flutter create --template=plugin ${createBase}plugin': null,
'$flutter pub cache list': <ProcessResult>[ProcessResult(0,0,'{"packages":{}}','')],
'git clean -f -x -- **/.packages': null,
'git clean -f -x -- **/.dart_tool/': null,
if (platform.isMacOS) 'codesign -vvvv --check-notarization ${path.join(tempDir.path, 'flutter', 'bin', 'cache', 'dart-sdk', 'bin', 'dart')}': null,
......@@ -286,6 +289,7 @@ void main() {
'$flutter create --template=app ${createBase}app': null,
'$flutter create --template=package ${createBase}package': null,
'$flutter create --template=plugin ${createBase}plugin': null,
'$flutter pub cache list': <ProcessResult>[ProcessResult(0,0,'{"packages":{}}','')],
'git clean -f -x -- **/.packages': null,
'git clean -f -x -- **/.dart_tool/': null,
if (platform.isWindows) 'attrib -h .git': null,
......@@ -336,6 +340,7 @@ void main() {
'$flutter create --template=app ${createBase}app': null,
'$flutter create --template=package ${createBase}package': null,
'$flutter create --template=plugin ${createBase}plugin': null,
'$flutter pub cache list': <ProcessResult>[ProcessResult(0,0,'{"packages":{}}','')],
'git clean -f -x -- **/.packages': null,
'git clean -f -x -- **/.dart_tool/': null,
if (platform.isMacOS) 'codesign -vvvv --check-notarization $binPath': <ProcessResult>[codesignFailure],
......
......@@ -34,39 +34,29 @@ const String _kPubCacheEnvironmentKey = 'PUB_CACHE';
typedef MessageFilter = String? Function(String message);
/// globalCachePath is the directory in which the content of the localCachePath will be moved in
void joinCaches({
required FileSystem fileSystem,
required Directory globalCacheDirectory,
required Directory dependencyDirectory,
/// Load any package-files stored in [preloadCacheDir] into the pub cache if it
/// exists.
///
/// Deletes the [preloadCacheDir].
@visibleForTesting
void preloadPubCache({
required Directory preloadCacheDir,
required ProcessManager processManager,
required Logger logger,
required List<String> pubCommand,
}) {
for (final FileSystemEntity entity in dependencyDirectory.listSync()) {
final String newPath = fileSystem.path.join(globalCacheDirectory.path, entity.basename);
if (entity is File) {
if (!fileSystem.file(newPath).existsSync()) {
entity.copySync(newPath);
}
} else if (entity is Directory) {
if (!globalCacheDirectory.childDirectory(entity.basename).existsSync()) {
final Directory newDirectory = globalCacheDirectory.childDirectory(entity.basename);
newDirectory.createSync();
joinCaches(
fileSystem: fileSystem,
globalCacheDirectory: newDirectory,
dependencyDirectory: entity,
);
}
}
if (preloadCacheDir.existsSync()) {
final Iterable<String> cacheFiles =
preloadCacheDir
.listSync()
.map((FileSystemEntity f) => f.path)
.where((String path) => path.endsWith('.tar.gz'));
processManager.runSync(<String>[...pubCommand, 'cache', 'preload',...cacheFiles]);
_tryDeleteDirectory(preloadCacheDir, logger);
}
}
Directory createDependencyDirectory(Directory pubGlobalDirectory, String dependencyName) {
final Directory newDirectory = pubGlobalDirectory.childDirectory(dependencyName);
newDirectory.createSync();
return newDirectory;
}
bool tryDelete(Directory directory, Logger logger) {
bool _tryDeleteDirectory(Directory directory, Logger logger) {
try {
if (directory.existsSync()) {
directory.deleteSync(recursive: true);
......@@ -78,24 +68,6 @@ bool tryDelete(Directory directory, Logger logger) {
return true;
}
/// When local cache (flutter_root/.pub-cache) and global cache (HOME/.pub-cache) are present a
/// merge needs to be done leaving only the global
///
/// Valid pubCache should look like this ./localCachePath/.pub-cache/hosted/pub.dartlang.org
bool needsToJoinCache({
required FileSystem fileSystem,
required String localCachePath,
required Directory? globalDirectory,
}) {
if (globalDirectory == null) {
return false;
}
final Directory localDirectory = fileSystem.directory(localCachePath);
return globalDirectory.childDirectory('hosted').childDirectory('pub.dartlang.org').existsSync() &&
localDirectory.childDirectory('hosted').childDirectory('pub.dartlang.org').existsSync();
}
/// Represents Flutter-specific data that is added to the `PUB_ENVIRONMENT`
/// environment variable and allows understanding the type of requests made to
/// the package site on Flutter's behalf.
......@@ -402,7 +374,7 @@ class _DefaultPub implements Pub {
}) async {
int exitCode;
final List<String> pubCommand = _pubCommand(arguments);
final List<String> pubCommand = <String>[..._pubCommand, ...arguments];
final Map<String, String> pubEnvironment = await _createPubEnvironment(context: context, flutterRootOverride: flutterRootOverride, summaryOnly: outputMode == PubOutputMode.summaryOnly);
try {
......@@ -536,7 +508,7 @@ class _DefaultPub implements Pub {
arguments.insert(0, '--trace');
}
final Map<String, String> pubEnvironment = await _createPubEnvironment(context: context, flutterRootOverride: flutterRootOverride);
final List<String> pubCommand = _pubCommand(arguments);
final List<String> pubCommand = <String>[..._pubCommand, ...arguments];
final int code = await _processUtils.stream(
pubCommand,
workingDirectory: directory,
......@@ -590,7 +562,9 @@ class _DefaultPub implements Pub {
}
/// The command used for running pub.
List<String> _pubCommand(List<String> arguments) {
late final List<String> _pubCommand = _computePubCommand();
List<String> _computePubCommand() {
// TODO(zanderso): refactor to use artifacts.
final String sdkPath = _fileSystem.path.joinAll(<String>[
Cache.flutterRoot!,
......@@ -607,7 +581,7 @@ class _DefaultPub implements Pub {
'permissions for the current user.'
);
}
return <String>[sdkPath, '--no-analytics', 'pub', ...arguments];
return <String>[sdkPath, '--no-analytics', 'pub'];
}
// Returns the environment value that should be used when running pub.
......@@ -629,88 +603,26 @@ class _DefaultPub implements Pub {
return values.join(':');
}
/// There are 3 ways to get the pub cache location
/// There are 2 ways to get the pub cache location
///
/// 1) Provide the _kPubCacheEnvironmentKey.
/// 2) There is a local cache (in the Flutter SDK) but not a global one (in the user's home directory).
/// 3) If both local and global are available then merge the local into global and return the global.
/// 2) The pub default user-level pub cache.
///
/// If we are using 2, check if there are pre-packaged packages in
/// $FLUTTER_ROOT/.pub-preload-cache and install them in the user-level cache.
String? _getPubCacheIfAvailable() {
if (_platform.environment.containsKey(_kPubCacheEnvironmentKey)) {
return _platform.environment[_kPubCacheEnvironmentKey];
}
final String localCachePath = _fileSystem.path.join(Cache.flutterRoot!, '.pub-cache');
final Directory? globalDirectory;
if (_platform.isWindows) {
globalDirectory = _getWindowsGlobalDirectory;
}
else {
if (_platform.environment['HOME'] == null) {
globalDirectory = null;
} else {
final String homeDirectoryPath = _platform.environment['HOME']!;
globalDirectory = _fileSystem.directory(_fileSystem.path.join(homeDirectoryPath, '.pub-cache'));
}
}
if (needsToJoinCache(
fileSystem: _fileSystem,
localCachePath: localCachePath,
globalDirectory: globalDirectory,
)) {
final Directory localDirectoryPub = _fileSystem.directory(
_fileSystem.path.join(localCachePath, 'hosted', 'pub.dartlang.org')
);
final Directory globalDirectoryPub = _fileSystem.directory(
_fileSystem.path.join(globalDirectory!.path, 'hosted', 'pub.dartlang.org')
);
for (final FileSystemEntity entity in localDirectoryPub.listSync()) {
if (entity is Directory && !globalDirectoryPub.childDirectory(entity.basename).existsSync()){
try {
final Directory newDirectory = createDependencyDirectory(globalDirectoryPub, entity.basename);
joinCaches(
fileSystem: _fileSystem,
globalCacheDirectory: newDirectory,
dependencyDirectory: entity,
);
} on FileSystemException {
if (!tryDelete(globalDirectoryPub.childDirectory(entity.basename), _logger)) {
_logger.printWarning('The join of pub-caches failed');
_logger.printStatus('Running "dart pub cache repair"');
_processManager.runSync(<String>['dart', 'pub', 'cache', 'repair']);
}
}
}
}
tryDelete(_fileSystem.directory(localCachePath), _logger);
return globalDirectory.path;
} else if (globalDirectory != null && globalDirectory.existsSync()) {
return globalDirectory.path;
} else if (_fileSystem.directory(localCachePath).existsSync()) {
return localCachePath;
}
final String flutterRootPath = Cache.flutterRoot!;
final Directory flutterRoot = _fileSystem.directory(flutterRootPath);
final Directory preloadCacheDir = flutterRoot.childDirectory('.pub-preload-cache');
preloadPubCache(preloadCacheDir: preloadCacheDir,logger: _logger,processManager: _processManager, pubCommand: _pubCommand);
// Use pub's default location by returning null.
return null;
}
Directory? get _getWindowsGlobalDirectory {
// %LOCALAPPDATA% is preferred as the cache location over %APPDATA%, because the latter is synchronised between
// devices when the user roams between them, whereas the former is not.
// The default cache dir used to be in %APPDATA%, so to avoid breaking old installs,
// we use the old dir in %APPDATA% if it exists. Else, we use the new default location
// in %LOCALAPPDATA%.
for (final String envVariable in <String>['APPDATA', 'LOCALAPPDATA']) {
if (_platform.environment[envVariable] != null) {
final String homePath = _platform.environment[envVariable]!;
final Directory globalDirectory = _fileSystem.directory(_fileSystem.path.join(homePath, 'Pub', 'Cache'));
if (globalDirectory.existsSync()) {
return globalDirectory;
}
}
}
return null;
}
/// The full environment used when running pub.
///
/// [context] provides extra information to package server requests to
......
// Copyright 2014 The Flutter 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:file/file.dart';
import 'package:file/memory.dart';
import 'package:flutter_tools/src/base/file_system.dart';
import 'package:flutter_tools/src/dart/pub.dart';
import '../../src/common.dart';
void main() {
testWithoutContext('join two folders', () async {
final MemoryFileSystem fileSystem = MemoryFileSystem();
final Directory target = fileSystem.currentDirectory.childDirectory('target');
final Directory extra = fileSystem.currentDirectory.childDirectory('extra');
target.createSync();
target.childFile('first.file').createSync();
target.childDirectory('dir').createSync();
extra.createSync();
extra.childFile('second.file').writeAsBytesSync(<int>[0]);
extra.childDirectory('dir').createSync();
extra.childDirectory('dir').childFile('third.file').writeAsBytesSync(<int>[0]);
extra.childDirectory('dir_2').createSync();
extra.childDirectory('dir_2').childFile('fourth.file').writeAsBytesSync(<int>[0]);
extra.childDirectory('dir_3').createSync();
extra.childDirectory('dir_3').childFile('fifth.file').writeAsBytesSync(<int>[0]);
joinCaches(
fileSystem: fileSystem,
globalCacheDirectory: target,
dependencyDirectory: extra,
);
expect(target.childFile('second.file').existsSync(), true);
expect(target.childDirectory('dir').childFile('third.file').existsSync(), false);
expect(target.childDirectory('dir_2').childFile('fourth.file').existsSync(), true);
expect(target.childDirectory('dir_3').childFile('fifth.file').existsSync(), true);
expect(extra.childDirectory('dir').childFile('third.file').existsSync(), true);
});
group('needsToJoinCache()', (){
testWithoutContext('make join', () async {
final MemoryFileSystem fileSystem = MemoryFileSystem();
final Directory local = fileSystem.currentDirectory.childDirectory('local');
final Directory global = fileSystem.currentDirectory.childDirectory('global');
for (final Directory directory in <Directory>[local, global]) {
directory.createSync();
directory.childDirectory('hosted').createSync();
directory.childDirectory('hosted').childDirectory('pub.dartlang.org').createSync();
}
final bool pass = needsToJoinCache(
fileSystem: fileSystem,
localCachePath: local.path,
globalDirectory: global,
);
expect(pass, true);
});
testWithoutContext('detects when global pub-cache does not have a pub.dartlang.org dir', () async {
final MemoryFileSystem fileSystem = MemoryFileSystem();
final Directory local = fileSystem.currentDirectory.childDirectory('local');
final Directory global = fileSystem.currentDirectory.childDirectory('global');
local.createSync();
global.createSync();
local.childDirectory('hosted').createSync();
local.childDirectory('hosted').childDirectory('pub.dartlang.org').createSync();
expect(
needsToJoinCache(
fileSystem: fileSystem,
localCachePath: local.path,
globalDirectory: global
),
false
);
});
testWithoutContext("don't join global directory null", () async {
final MemoryFileSystem fileSystem = MemoryFileSystem();
final Directory local = fileSystem.currentDirectory.childDirectory('local');
const Directory? global = null;
local.createSync();
local.childDirectory('hosted').createSync();
local.childDirectory('hosted').childDirectory('pub.dartlang.org').createSync();
expect(
needsToJoinCache(
fileSystem: fileSystem,
localCachePath: local.path,
globalDirectory: global
),
false
);
});
});
}
......@@ -757,29 +757,24 @@ exit code: 66
expect(processManager, hasNoRemainingExpectations);
});
testWithoutContext('pub cache local is merge to global', () async {
testWithoutContext('Preloaded packages are added to the pub cache', () async {
final FileSystem fileSystem = MemoryFileSystem.test();
final Directory local = fileSystem.currentDirectory.childDirectory('.pub-cache');
final Directory global = fileSystem.currentDirectory.childDirectory('/global');
global.createSync();
for (final Directory dir in <Directory>[global.childDirectory('.pub-cache'), local]) {
dir.createSync();
dir.childDirectory('hosted').createSync();
dir.childDirectory('hosted').childDirectory('pub.dartlang.org').createSync();
}
final Directory globalHosted = global.childDirectory('.pub-cache').childDirectory('hosted').childDirectory('pub.dartlang.org');
globalHosted.childFile('first.file').createSync();
globalHosted.childDirectory('dir').createSync();
final Directory localHosted = local.childDirectory('hosted').childDirectory('pub.dartlang.org');
localHosted.childFile('second.file').writeAsBytesSync(<int>[0]);
localHosted.childDirectory('dir').createSync();
localHosted.childDirectory('dir').childFile('third.file').writeAsBytesSync(<int>[0]);
localHosted.childDirectory('dir_2').createSync();
localHosted.childDirectory('dir_2').childFile('fourth.file').writeAsBytesSync(<int>[0]);
final Directory preloadCache = fileSystem.currentDirectory.childDirectory('.pub-preload-cache');
preloadCache.childFile('a.tar.gz').createSync(recursive: true);
preloadCache.childFile('b.tar.gz').createSync();
final FakeProcessManager processManager = FakeProcessManager.list(<FakeCommand>[
const FakeCommand(
command: <String>[
'bin/cache/dart-sdk/bin/dart',
'--no-analytics',
'pub',
'cache',
'preload',
'.pub-preload-cache/a.tar.gz',
'.pub-preload-cache/b.tar.gz',
],
),
const FakeCommand(
command: <String>[
'bin/cache/dart-sdk/bin/dart',
......@@ -793,7 +788,6 @@ exit code: 66
exitCode: 69,
environment: <String, String>{
'FLUTTER_ROOT': '',
'PUB_CACHE': '/global/.pub-cache',
'PUB_ENVIRONMENT': 'flutter_cli:flutter_tests',
},
),
......@@ -822,12 +816,7 @@ exit code: 66
}
expect(processManager, hasNoRemainingExpectations);
expect(local.existsSync(), false);
expect(globalHosted.childFile('second.file').existsSync(), false);
expect(
globalHosted.childDirectory('dir').childFile('third.file').existsSync(), false
); // do not copy dependencies that are already downloaded
expect(globalHosted.childDirectory('dir_2').childFile('fourth.file').existsSync(), true);
expect(preloadCache.existsSync(), false);
});
testWithoutContext('pub cache in environment is used', () async {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment