未验证 提交 3f4f6061 编写于 作者: J Jason Simmons 提交者: GitHub

Simplify loading of app bundles on Android (#9360)

* Remove deprecated runBundle APIs
* Remove code related to dynamic patching (including support for multiple
  bundle paths)
* Change FlutterRunArugments.bundlePath to be the Android AssetManager path
  where the app's assets are located
上级 107fe823
......@@ -9,8 +9,6 @@ source_set("assets") {
"asset_resolver.h",
"directory_asset_bundle.cc",
"directory_asset_bundle.h",
"zip_asset_store.cc",
"zip_asset_store.h",
]
deps = [
......@@ -18,9 +16,5 @@ source_set("assets") {
"$flutter_root/fml",
]
public_deps = [
"//third_party/zlib:minizip",
]
public_configs = [ "$flutter_root:config" ]
}
// Copyright 2013 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.
#include "flutter/assets/zip_asset_store.h"
#include "flutter/fml/build_config.h"
#include <fcntl.h>
#if !defined(OS_WIN)
#include <unistd.h>
#endif
#include <string>
#include <utility>
#include "flutter/fml/trace_event.h"
namespace flutter {
void UniqueUnzipperTraits::Free(void* file) {
unzClose(file);
}
ZipAssetStore::ZipAssetStore(std::string file_path, std::string directory)
: file_path_(std::move(file_path)), directory_(std::move(directory)) {
BuildStatCache();
}
ZipAssetStore::~ZipAssetStore() = default;
UniqueUnzipper ZipAssetStore::CreateUnzipper() const {
return UniqueUnzipper{::unzOpen2(file_path_.c_str(), nullptr)};
}
// |AssetResolver|
bool ZipAssetStore::IsValid() const {
return stat_cache_.size() > 0;
}
// |AssetResolver|
std::unique_ptr<fml::Mapping> ZipAssetStore::GetAsMapping(
const std::string& asset_name) const {
TRACE_EVENT1("flutter", "ZipAssetStore::GetAsMapping", "name",
asset_name.c_str());
auto found = stat_cache_.find(directory_ + "/" + asset_name);
if (found == stat_cache_.end()) {
return nullptr;
}
auto unzipper = CreateUnzipper();
if (!unzipper.is_valid()) {
return nullptr;
}
int result = UNZ_OK;
result = unzGoToFilePos(unzipper.get(), &(found->second.file_pos));
if (result != UNZ_OK) {
FML_LOG(WARNING) << "unzGetCurrentFileInfo failed, error=" << result;
return nullptr;
}
result = unzOpenCurrentFile(unzipper.get());
if (result != UNZ_OK) {
FML_LOG(WARNING) << "unzOpenCurrentFile failed, error=" << result;
return nullptr;
}
std::vector<uint8_t> data(found->second.uncompressed_size);
int total_read = 0;
while (total_read < static_cast<int>(data.size())) {
int bytes_read = unzReadCurrentFile(
unzipper.get(), data.data() + total_read, data.size() - total_read);
if (bytes_read <= 0) {
return nullptr;
}
total_read += bytes_read;
}
return std::make_unique<fml::DataMapping>(std::move(data));
}
void ZipAssetStore::BuildStatCache() {
TRACE_EVENT0("flutter", "ZipAssetStore::BuildStatCache");
auto unzipper = CreateUnzipper();
if (!unzipper.is_valid()) {
return;
}
if (unzGoToFirstFile(unzipper.get()) != UNZ_OK) {
return;
}
do {
int result = UNZ_OK;
// Get the current file name.
unz_file_info file_info = {};
char file_name[255];
result = unzGetCurrentFileInfo(unzipper.get(), &file_info, file_name,
sizeof(file_name), nullptr, 0, nullptr, 0);
if (result != UNZ_OK) {
continue;
}
if (file_info.uncompressed_size == 0) {
continue;
}
// Get the current file position.
unz_file_pos file_pos = {};
result = unzGetFilePos(unzipper.get(), &file_pos);
if (result != UNZ_OK) {
continue;
}
std::string file_name_key(file_name, file_info.size_filename);
CacheEntry entry(file_pos, file_info.uncompressed_size);
stat_cache_.emplace(std::move(file_name_key), std::move(entry));
} while (unzGoToNextFile(unzipper.get()) == UNZ_OK);
}
} // namespace flutter
// Copyright 2013 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.
#ifndef FLUTTER_ASSETS_ZIP_ASSET_STORE_H_
#define FLUTTER_ASSETS_ZIP_ASSET_STORE_H_
#include <map>
#include "flutter/assets/asset_resolver.h"
#include "flutter/fml/macros.h"
#include "third_party/zlib/contrib/minizip/unzip.h"
namespace flutter {
struct UniqueUnzipperTraits {
static inline void* InvalidValue() { return nullptr; }
static inline bool IsValid(void* value) { return value != InvalidValue(); }
static void Free(void* file);
};
using UniqueUnzipper = fml::UniqueObject<void*, UniqueUnzipperTraits>;
class ZipAssetStore final : public AssetResolver {
public:
ZipAssetStore(std::string file_path, std::string directory);
~ZipAssetStore() override;
private:
struct CacheEntry {
unz_file_pos file_pos;
size_t uncompressed_size;
CacheEntry(unz_file_pos p_file_pos, size_t p_uncompressed_size)
: file_pos(p_file_pos), uncompressed_size(p_uncompressed_size) {}
};
const std::string file_path_;
const std::string directory_;
mutable std::map<std::string, CacheEntry> stat_cache_;
// |AssetResolver|
bool IsValid() const override;
// |AssetResolver|
std::unique_ptr<fml::Mapping> GetAsMapping(
const std::string& asset_name) const override;
void BuildStatCache();
UniqueUnzipper CreateUnzipper() const;
FML_DISALLOW_COPY_AND_ASSIGN(ZipAssetStore);
};
} // namespace flutter
#endif // FLUTTER_ASSETS_ZIP_ASSET_STORE_H_
......@@ -15,8 +15,6 @@ FILE: ../../../flutter/assets/asset_manager.h
FILE: ../../../flutter/assets/asset_resolver.h
FILE: ../../../flutter/assets/directory_asset_bundle.cc
FILE: ../../../flutter/assets/directory_asset_bundle.h
FILE: ../../../flutter/assets/zip_asset_store.cc
FILE: ../../../flutter/assets/zip_asset_store.h
FILE: ../../../flutter/benchmarking/benchmarking.cc
FILE: ../../../flutter/benchmarking/benchmarking.h
FILE: ../../../flutter/common/exported_symbols.sym
......
......@@ -60,7 +60,7 @@ void FlutterMain::Init(JNIEnv* env,
jclass clazz,
jobject context,
jobjectArray jargs,
jstring bundlePath,
jstring kernelPath,
jstring appStoragePath,
jstring engineCachesPath) {
std::vector<std::string> args;
......@@ -72,8 +72,6 @@ void FlutterMain::Init(JNIEnv* env,
auto settings = SettingsFromCommandLine(command_line);
settings.assets_path = fml::jni::JavaStringToString(env, bundlePath);
// Restore the callback cache.
// TODO(chinmaygarde): Route all cache file access through FML and remove this
// setter.
......@@ -85,11 +83,11 @@ void FlutterMain::Init(JNIEnv* env,
flutter::DartCallbackCache::LoadCacheFromDisk();
if (!flutter::DartVM::IsRunningPrecompiledCode()) {
if (!flutter::DartVM::IsRunningPrecompiledCode() && kernelPath) {
// Check to see if the appropriate kernel files are present and configure
// settings accordingly.
auto application_kernel_path =
fml::paths::JoinPaths({settings.assets_path, "kernel_blob.bin"});
fml::jni::JavaStringToString(env, kernelPath);
if (fml::IsFile(application_kernel_path)) {
settings.application_kernel_asset = application_kernel_path;
......
......@@ -33,8 +33,8 @@ class FlutterMain {
jclass clazz,
jobject context,
jobjectArray jargs,
jstring bundlePath,
jstring appRootPath,
jstring kernelPath,
jstring appStoragePath,
jstring engineCachesPath);
void SetupObservatoryUriCallback(JNIEnv* env);
......
......@@ -356,9 +356,7 @@ public final class FlutterActivityDelegate
private void runBundle(String appBundlePath) {
if (!flutterView.getFlutterNativeView().isApplicationRunning()) {
FlutterRunArguments args = new FlutterRunArguments();
ArrayList<String> bundlePaths = new ArrayList<>();
bundlePaths.add(appBundlePath);
args.bundlePaths = bundlePaths.toArray(new String[0]);
args.bundlePath = appBundlePath;
args.entrypoint = "main";
flutterView.runFromBundle(args);
}
......
......@@ -580,7 +580,7 @@ public class FlutterJNI {
*/
@UiThread
public void runBundleAndSnapshotFromLibrary(
@NonNull String[] prioritizedBundlePaths,
@NonNull String bundlePath,
@Nullable String entrypointFunctionName,
@Nullable String pathToEntrypointFunction,
@NonNull AssetManager assetManager
......@@ -589,7 +589,7 @@ public class FlutterJNI {
ensureAttachedToNative();
nativeRunBundleAndSnapshotFromLibrary(
nativePlatformViewId,
prioritizedBundlePaths,
bundlePath,
entrypointFunctionName,
pathToEntrypointFunction,
assetManager
......@@ -598,7 +598,7 @@ public class FlutterJNI {
private native void nativeRunBundleAndSnapshotFromLibrary(
long nativePlatformViewId,
@NonNull String[] prioritizedBundlePaths,
@NonNull String bundlePath,
@Nullable String entrypointFunctionName,
@Nullable String pathToEntrypointFunction,
@NonNull AssetManager manager
......
......@@ -120,10 +120,7 @@ public class DartExecutor implements BinaryMessenger {
Log.v(TAG, "Executing Dart entrypoint: " + dartEntrypoint);
flutterJNI.runBundleAndSnapshotFromLibrary(
new String[]{
dartEntrypoint.pathToPrimaryBundle,
dartEntrypoint.pathToFallbackBundle
},
dartEntrypoint.pathToBundle,
dartEntrypoint.dartEntrypointFunctionName,
null,
dartEntrypoint.androidAssetManager
......@@ -148,10 +145,7 @@ public class DartExecutor implements BinaryMessenger {
Log.v(TAG, "Executing Dart callback: " + dartCallback);
flutterJNI.runBundleAndSnapshotFromLibrary(
new String[]{
dartCallback.pathToPrimaryBundle,
dartCallback.pathToFallbackBundle
},
dartCallback.pathToBundle,
dartCallback.callbackHandle.callbackName,
dartCallback.callbackHandle.callbackLibraryPath,
dartCallback.androidAssetManager
......@@ -243,16 +237,10 @@ public class DartExecutor implements BinaryMessenger {
public final AssetManager androidAssetManager;
/**
* The first place that Dart will look for a given function or asset.
* The path within the AssetManager where the app will look for assets.
*/
@NonNull
public final String pathToPrimaryBundle;
/**
* A secondary fallback location that Dart will look for a given function or asset.
*/
@Nullable
public final String pathToFallbackBundle;
public final String pathToBundle;
/**
* The name of a Dart function to execute.
......@@ -264,31 +252,16 @@ public class DartExecutor implements BinaryMessenger {
@NonNull AssetManager androidAssetManager,
@NonNull String pathToBundle,
@NonNull String dartEntrypointFunctionName
) {
this(
androidAssetManager,
pathToBundle,
null,
dartEntrypointFunctionName
);
}
public DartEntrypoint(
@NonNull AssetManager androidAssetManager,
@NonNull String pathToPrimaryBundle,
@Nullable String pathToFallbackBundle,
@NonNull String dartEntrypointFunctionName
) {
this.androidAssetManager = androidAssetManager;
this.pathToPrimaryBundle = pathToPrimaryBundle;
this.pathToFallbackBundle = pathToFallbackBundle;
this.pathToBundle = pathToBundle;
this.dartEntrypointFunctionName = dartEntrypointFunctionName;
}
@Override
@NonNull
public String toString() {
return "DartEntrypoint( bundle path: " + pathToPrimaryBundle + ", function: " + dartEntrypointFunctionName + " )";
return "DartEntrypoint( bundle path: " + pathToBundle + ", function: " + dartEntrypointFunctionName + " )";
}
}
......@@ -303,14 +276,9 @@ public class DartExecutor implements BinaryMessenger {
public final AssetManager androidAssetManager;
/**
* The first place that Dart will look for a given function or asset.
* The path within the AssetManager where the app will look for assets.
*/
public final String pathToPrimaryBundle;
/**
* A secondary fallback location that Dart will look for a given function or asset.
*/
public final String pathToFallbackBundle;
public final String pathToBundle;
/**
* A Dart callback that was previously registered with the Dart VM.
......@@ -319,33 +287,18 @@ public class DartExecutor implements BinaryMessenger {
public DartCallback(
@NonNull AssetManager androidAssetManager,
@NonNull String pathToPrimaryBundle,
@NonNull FlutterCallbackInformation callbackHandle
) {
this(
androidAssetManager,
pathToPrimaryBundle,
null,
callbackHandle
);
}
public DartCallback(
@NonNull AssetManager androidAssetManager,
@NonNull String pathToPrimaryBundle,
@Nullable String pathToFallbackBundle,
@NonNull String pathToBundle,
@NonNull FlutterCallbackInformation callbackHandle
) {
this.androidAssetManager = androidAssetManager;
this.pathToPrimaryBundle = pathToPrimaryBundle;
this.pathToFallbackBundle = pathToFallbackBundle;
this.pathToBundle = pathToBundle;
this.callbackHandle = callbackHandle;
}
@Override
@NonNull
public String toString() {
return "DartCallback( bundle path: " + pathToPrimaryBundle
return "DartCallback( bundle path: " + pathToBundle
+ ", library path: " + callbackHandle.callbackLibraryPath
+ ", function: " + callbackHandle.callbackName + " )";
}
......
......@@ -163,8 +163,12 @@ public class FlutterMain {
if (args != null) {
Collections.addAll(shellArgs, args);
}
String kernelPath = null;
if (BuildConfig.DEBUG) {
shellArgs.add("--" + SNAPSHOT_ASSET_PATH_KEY + "=" + PathUtils.getDataDirectory(applicationContext) + "/" + sFlutterAssetsDir);
String snapshotAssetPath = PathUtils.getDataDirectory(applicationContext) + File.separator + sFlutterAssetsDir;
kernelPath = snapshotAssetPath + File.separator + DEFAULT_KERNEL_BLOB;
shellArgs.add("--" + SNAPSHOT_ASSET_PATH_KEY + "=" + snapshotAssetPath);
shellArgs.add("--" + VM_SNAPSHOT_DATA_KEY + "=" + sVmSnapshotData);
shellArgs.add("--" + ISOLATE_SNAPSHOT_DATA_KEY + "=" + sIsolateSnapshotData);
} else {
......@@ -176,11 +180,10 @@ public class FlutterMain {
shellArgs.add("--log-tag=" + sSettings.getLogTag());
}
String appBundlePath = findAppBundlePath(applicationContext);
String appStoragePath = PathUtils.getFilesDir(applicationContext);
String engineCachesPath = PathUtils.getCacheDirectory(applicationContext);
FlutterJNI.nativeInit(applicationContext, shellArgs.toArray(new String[0]),
appBundlePath, appStoragePath, engineCachesPath);
kernelPath, appStoragePath, engineCachesPath);
sInitialized = true;
} catch (Exception e) {
......@@ -263,9 +266,8 @@ public class FlutterMain {
private static void initResources(@NonNull Context applicationContext) {
new ResourceCleaner(applicationContext).start();
final String dataDirPath = PathUtils.getDataDirectory(applicationContext);
if (BuildConfig.DEBUG) {
final String dataDirPath = PathUtils.getDataDirectory(applicationContext);
final String packageName = applicationContext.getPackageName();
final PackageManager packageManager = applicationContext.getPackageManager();
final AssetManager assetManager = applicationContext.getResources().getAssets();
......@@ -279,20 +281,12 @@ public class FlutterMain {
.addResource(fromFlutterAssets(DEFAULT_KERNEL_BLOB));
sResourceExtractor.start();
} else {
// AOT modes obtain compiled Dart assets from a ELF library that does
// not need to be extracted out of the APK.
// Create an empty directory that can be passed as the bundle path
// in the engine RunBundle API.
new File(dataDirPath, sFlutterAssetsDir).mkdirs();
}
}
@Nullable
public static String findAppBundlePath(@NonNull Context applicationContext) {
String dataDirectory = PathUtils.getDataDirectory(applicationContext);
File appBundle = new File(dataDirectory, sFlutterAssetsDir);
return appBundle.exists() ? appBundle.getPath() : null;
return sFlutterAssetsDir;
}
/**
......
......@@ -86,44 +86,17 @@ public class FlutterNativeView implements BinaryMessenger {
}
public void runFromBundle(FlutterRunArguments args) {
boolean hasBundlePaths = args.bundlePaths != null && args.bundlePaths.length != 0;
if (args.bundlePath == null && !hasBundlePaths) {
throw new AssertionError("Either bundlePath or bundlePaths must be specified");
} else if ((args.bundlePath != null || args.defaultPath != null) &&
hasBundlePaths) {
throw new AssertionError("Can't specify both bundlePath and bundlePaths");
} else if (args.entrypoint == null) {
if (args.entrypoint == null) {
throw new AssertionError("An entrypoint must be specified");
}
if (hasBundlePaths) {
runFromBundleInternal(args.bundlePaths, args.entrypoint, args.libraryPath);
} else {
runFromBundleInternal(new String[] {args.bundlePath, args.defaultPath},
args.entrypoint, args.libraryPath);
}
}
/**
* @deprecated
* Please use runFromBundle with `FlutterRunArguments`.
* Parameter `reuseRuntimeController` has no effect.
*/
@Deprecated
public void runFromBundle(String bundlePath, String defaultPath, String entrypoint,
boolean reuseRuntimeController) {
runFromBundleInternal(new String[] {bundlePath, defaultPath}, entrypoint, null);
}
private void runFromBundleInternal(String[] bundlePaths, String entrypoint,
String libraryPath) {
assertAttached();
if (applicationIsRunning)
throw new AssertionError(
"This Flutter engine instance is already running an application");
mFlutterJNI.runBundleAndSnapshotFromLibrary(
bundlePaths,
entrypoint,
libraryPath,
args.bundlePath,
args.entrypoint,
args.libraryPath,
mContext.getResources().getAssets()
);
......
......@@ -9,9 +9,7 @@ package io.flutter.view;
* the first time.
*/
public class FlutterRunArguments {
public String[] bundlePaths;
public String bundlePath;
public String entrypoint;
public String libraryPath;
public String defaultPath;
}
......@@ -601,38 +601,6 @@ public class FlutterView extends SurfaceView implements BinaryMessenger, Texture
postRun();
}
/**
* @deprecated
* Please use runFromBundle with `FlutterRunArguments`.
*/
@Deprecated
public void runFromBundle(String bundlePath, String defaultPath) {
runFromBundle(bundlePath, defaultPath, "main", false);
}
/**
* @deprecated
* Please use runFromBundle with `FlutterRunArguments`.
*/
@Deprecated
public void runFromBundle(String bundlePath, String defaultPath, String entrypoint) {
runFromBundle(bundlePath, defaultPath, entrypoint, false);
}
/**
* @deprecated
* Please use runFromBundle with `FlutterRunArguments`.
* Parameter `reuseRuntimeController` has no effect.
*/
@Deprecated
public void runFromBundle(String bundlePath, String defaultPath, String entrypoint, boolean reuseRuntimeController) {
FlutterRunArguments args = new FlutterRunArguments();
args.bundlePath = bundlePath;
args.entrypoint = entrypoint;
args.defaultPath = defaultPath;
runFromBundle(args);
}
/**
* Return the most recent frame as a bitmap.
*
......
......@@ -9,7 +9,6 @@
#include <utility>
#include "flutter/assets/directory_asset_bundle.h"
#include "flutter/assets/zip_asset_store.h"
#include "flutter/common/settings.h"
#include "flutter/fml/file.h"
#include "flutter/fml/platform/android/jni_util.h"
......@@ -190,87 +189,34 @@ static void SurfaceDestroyed(JNIEnv* env, jobject jcaller, jlong shell_holder) {
ANDROID_SHELL_HOLDER->GetPlatformView()->NotifyDestroyed();
}
std::unique_ptr<IsolateConfiguration> CreateIsolateConfiguration(
const flutter::AssetManager& asset_manager) {
if (flutter::DartVM::IsRunningPrecompiledCode()) {
return IsolateConfiguration::CreateForAppSnapshot();
}
const auto configuration_from_blob =
[&asset_manager](const std::string& snapshot_name)
-> std::unique_ptr<IsolateConfiguration> {
auto blob = asset_manager.GetAsMapping(snapshot_name);
auto delta = asset_manager.GetAsMapping("kernel_delta.bin");
if (blob && delta) {
std::vector<std::unique_ptr<const fml::Mapping>> kernels;
kernels.emplace_back(std::move(blob));
kernels.emplace_back(std::move(delta));
return IsolateConfiguration::CreateForKernelList(std::move(kernels));
}
if (blob) {
return IsolateConfiguration::CreateForKernel(std::move(blob));
}
if (delta) {
return IsolateConfiguration::CreateForKernel(std::move(delta));
}
return nullptr;
};
if (auto kernel = configuration_from_blob("kernel_blob.bin")) {
return kernel;
}
// This happens when starting isolate directly from CoreJIT snapshot.
return IsolateConfiguration::CreateForAppSnapshot();
}
static void RunBundleAndSnapshotFromLibrary(JNIEnv* env,
jobject jcaller,
jlong shell_holder,
jobjectArray jbundlepaths,
jstring jBundlePath,
jstring jEntrypoint,
jstring jLibraryUrl,
jobject jAssetManager) {
auto asset_manager = std::make_shared<flutter::AssetManager>();
for (const auto& bundlepath :
fml::jni::StringArrayToVector(env, jbundlepaths)) {
if (bundlepath.empty()) {
continue;
}
// If we got a bundle path, attempt to use that as a directory asset
// bundle or a zip asset bundle.
const auto file_ext_index = bundlepath.rfind(".");
if (bundlepath.substr(file_ext_index) == ".zip") {
asset_manager->PushBack(std::make_unique<flutter::ZipAssetStore>(
bundlepath, "assets/flutter_assets"));
} else {
asset_manager->PushBack(
std::make_unique<flutter::DirectoryAssetBundle>(fml::OpenDirectory(
bundlepath.c_str(), false, fml::FilePermission::kRead)));
// Use the last path component of the bundle path to determine the
// directory in the APK assets.
const auto last_slash_index = bundlepath.rfind("/", bundlepath.size());
if (last_slash_index != std::string::npos) {
auto apk_asset_dir = bundlepath.substr(
last_slash_index + 1, bundlepath.size() - last_slash_index);
asset_manager->PushBack(std::make_unique<flutter::APKAssetProvider>(
env, // jni environment
jAssetManager, // asset manager
std::move(apk_asset_dir)) // apk asset dir
);
}
}
}
asset_manager->PushBack(std::make_unique<flutter::APKAssetProvider>(
env, // jni environment
jAssetManager, // asset manager
fml::jni::JavaStringToString(env, jBundlePath)) // apk asset dir
);
auto isolate_configuration = CreateIsolateConfiguration(*asset_manager);
if (!isolate_configuration) {
FML_DLOG(ERROR)
<< "Isolate configuration could not be determined for engine launch.";
return;
std::unique_ptr<IsolateConfiguration> isolate_configuration;
if (flutter::DartVM::IsRunningPrecompiledCode()) {
isolate_configuration = IsolateConfiguration::CreateForAppSnapshot();
} else {
std::unique_ptr<fml::Mapping> kernel_blob =
fml::FileMapping::CreateReadOnly(
ANDROID_SHELL_HOLDER->GetSettings().application_kernel_asset);
if (!kernel_blob) {
FML_DLOG(ERROR) << "Unable to load the kernel blob asset.";
return;
}
isolate_configuration =
IsolateConfiguration::CreateForKernel(std::move(kernel_blob));
}
RunConfiguration config(std::move(isolate_configuration),
......@@ -544,7 +490,7 @@ bool RegisterApi(JNIEnv* env) {
},
{
.name = "nativeRunBundleAndSnapshotFromLibrary",
.signature = "(J[Ljava/lang/String;Ljava/lang/String;"
.signature = "(JLjava/lang/String;Ljava/lang/String;"
"Ljava/lang/String;Landroid/content/res/AssetManager;)V",
.fnPtr = reinterpret_cast<void*>(&RunBundleAndSnapshotFromLibrary),
},
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册