Commit fcefceac authored by Gang Wu's avatar Gang Wu Committed by Commit Bot

[Feed] Implement Content Storage leveldb_proto

This CL adds persistence for content data, and unittest for it.

Bug:831633

Change-Id: I944d9812133192e24306699bd696b5de8c2e411f
Reviewed-on: https://chromium-review.googlesource.com/1080274
Commit-Queue: Gang Wu <gangwu@chromium.org>
Reviewed-by: default avatarFilip Gorski <fgorski@chromium.org>
Reviewed-by: default avatarIlya Sherman <isherman@chromium.org>
Cr-Commit-Position: refs/heads/master@{#564775}
parent 9be6fcb3
...@@ -18,6 +18,8 @@ source_set("feed_core") { ...@@ -18,6 +18,8 @@ source_set("feed_core") {
"feed_networking_host.h", "feed_networking_host.h",
"feed_scheduler_host.cc", "feed_scheduler_host.cc",
"feed_scheduler_host.h", "feed_scheduler_host.h",
"feed_storage_database.cc",
"feed_storage_database.h",
"time_serialization.cc", "time_serialization.cc",
"time_serialization.h", "time_serialization.h",
] ]
...@@ -58,6 +60,7 @@ source_set("core_unit_tests") { ...@@ -58,6 +60,7 @@ source_set("core_unit_tests") {
"feed_image_database_unittest.cc", "feed_image_database_unittest.cc",
"feed_image_manager_unittest.cc", "feed_image_manager_unittest.cc",
"feed_networking_host_unittest.cc", "feed_networking_host_unittest.cc",
"feed_storage_database_unittest.cc",
] ]
deps = [ deps = [
......
...@@ -108,6 +108,7 @@ class FeedImageDatabase { ...@@ -108,6 +108,7 @@ class FeedImageDatabase {
std::vector<std::pair<std::string, FeedImageDatabaseCallback>> std::vector<std::pair<std::string, FeedImageDatabaseCallback>>
pending_image_callbacks_; pending_image_callbacks_;
// Used to check that functions are called on the correct sequence.
SEQUENCE_CHECKER(sequence_checker_); SEQUENCE_CHECKER(sequence_checker_);
base::WeakPtrFactory<FeedImageDatabase> weak_ptr_factory_; base::WeakPtrFactory<FeedImageDatabase> weak_ptr_factory_;
......
// Copyright 2018 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.
#include "components/feed/core/feed_storage_database.h"
#include "base/bind.h"
#include "base/files/file_path.h"
#include "base/strings/string_util.h"
#include "base/sys_info.h"
#include "base/task_scheduler/post_task.h"
#include "components/feed/core/proto/feed_storage.pb.h"
#include "components/leveldb_proto/proto_database_impl.h"
namespace feed {
namespace {
using StorageEntryVector =
leveldb_proto::ProtoDatabase<FeedStorageProto>::KeyEntryVector;
// Statistics are logged to UMA with this string as part of histogram name. They
// can all be found under LevelDB.*.FeedStorageDatabase. Changing this needs to
// synchronize with histograms.xml, AND will also become incompatible with older
// browsers still reporting the previous values.
const char kStorageDatabaseUMAClientName[] = "FeedStorageDatabase";
const char kStorageDatabaseFolder[] = "storage";
const size_t kDatabaseWriteBufferSizeBytes = 512 * 1024;
const size_t kDatabaseWriteBufferSizeBytesForLowEndDevice = 128 * 1024;
// Key prefix for content storage.
const char kContentStoragePrefix[] = "cs-";
// Formats key prefix for content data's key.
std::string FormatContentDatabaseKey(const std::string& key) {
return kContentStoragePrefix + key;
}
bool DatabaseKeyFilter(const std::unordered_set<std::string>& key_set,
const std::string& key) {
return key_set.find(key) != key_set.end();
}
bool DatabasePrefixFilter(const std::string& key_prefix,
const std::string& key) {
return base::StartsWith(key, key_prefix, base::CompareCase::SENSITIVE);
}
} // namespace
FeedStorageDatabase::FeedStorageDatabase(
const base::FilePath& database_folder,
const scoped_refptr<base::SequencedTaskRunner>& task_runner)
: FeedStorageDatabase(
database_folder,
std::make_unique<leveldb_proto::ProtoDatabaseImpl<FeedStorageProto>>(
task_runner)) {}
FeedStorageDatabase::FeedStorageDatabase(
const base::FilePath& database_folder,
std::unique_ptr<leveldb_proto::ProtoDatabase<FeedStorageProto>>
storage_database)
: database_status_(UNINITIALIZED),
storage_database_(std::move(storage_database)),
weak_ptr_factory_(this) {
leveldb_env::Options options = leveldb_proto::CreateSimpleOptions();
if (base::SysInfo::IsLowEndDevice()) {
options.write_buffer_size = kDatabaseWriteBufferSizeBytesForLowEndDevice;
} else {
options.write_buffer_size = kDatabaseWriteBufferSizeBytes;
}
base::FilePath storage_folder =
database_folder.AppendASCII(kStorageDatabaseFolder);
storage_database_->Init(
kStorageDatabaseUMAClientName, storage_folder, options,
base::BindOnce(&FeedStorageDatabase::OnDatabaseInitialized,
weak_ptr_factory_.GetWeakPtr()));
}
FeedStorageDatabase::~FeedStorageDatabase() = default;
bool FeedStorageDatabase::IsInitialized() const {
return INITIALIZED == database_status_;
}
void FeedStorageDatabase::LoadContentEntries(
const std::vector<std::string>& keys,
FeedContentStorageDatabaseCallback callback) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
std::unordered_set<std::string> key_set;
for (const auto& key : keys) {
key_set.insert(FormatContentDatabaseKey(key));
}
storage_database_->LoadEntriesWithFilter(
base::BindRepeating(&DatabaseKeyFilter, std::move(key_set)),
base::BindOnce(&FeedStorageDatabase::OnContentEntriesLoaded,
weak_ptr_factory_.GetWeakPtr(), std::move(callback)));
}
void FeedStorageDatabase::LoadContentEntriesByPrefix(
const std::string& prefix,
FeedContentStorageDatabaseCallback callback) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
std::string key_prefix = FormatContentDatabaseKey(prefix);
storage_database_->LoadEntriesWithFilter(
base::BindRepeating(&DatabasePrefixFilter, std::move(key_prefix)),
base::BindOnce(&FeedStorageDatabase::OnContentEntriesLoaded,
weak_ptr_factory_.GetWeakPtr(), std::move(callback)));
}
void FeedStorageDatabase::SaveContentEntries(
std::vector<KeyAndData> entries,
FeedStorageCommitCallback callback) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
auto entries_to_save = std::make_unique<StorageEntryVector>();
for (const auto& entry : entries) {
FeedStorageProto proto;
proto.set_key(entry.first);
proto.set_content_data(entry.second);
entries_to_save->emplace_back(FormatContentDatabaseKey(entry.first),
std::move(proto));
}
storage_database_->UpdateEntries(
std::move(entries_to_save), std::make_unique<std::vector<std::string>>(),
base::BindOnce(&FeedStorageDatabase::OnStorageCommitted,
weak_ptr_factory_.GetWeakPtr(), std::move(callback)));
}
void FeedStorageDatabase::DeleteContentEntries(
std::vector<std::string> keys_to_delete,
FeedStorageCommitCallback callback) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
std::unordered_set<std::string> key_set;
for (const auto& key : keys_to_delete) {
key_set.insert(FormatContentDatabaseKey(key));
}
storage_database_->LoadEntriesWithFilter(
base::BindRepeating(&DatabaseKeyFilter, std::move(key_set)),
base::BindOnce(&FeedStorageDatabase::OnContentDeletedEntriesLoaded,
weak_ptr_factory_.GetWeakPtr(), std::move(callback)));
}
void FeedStorageDatabase::DeleteContentEntriesByPrefix(
const std::string& prefix_to_delete,
FeedStorageCommitCallback callback) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
std::string key_prefix = FormatContentDatabaseKey(prefix_to_delete);
storage_database_->LoadEntriesWithFilter(
base::BindRepeating(&DatabasePrefixFilter, std::move(key_prefix)),
base::BindOnce(&FeedStorageDatabase::OnContentDeletedEntriesLoaded,
weak_ptr_factory_.GetWeakPtr(), std::move(callback)));
}
void FeedStorageDatabase::OnDatabaseInitialized(bool success) {
DCHECK_EQ(database_status_, UNINITIALIZED);
if (success) {
database_status_ = INITIALIZED;
} else {
database_status_ = INIT_FAILURE;
DVLOG(1) << "FeedStorageDatabase init failed.";
}
}
void FeedStorageDatabase::OnContentEntriesLoaded(
FeedContentStorageDatabaseCallback callback,
bool success,
std::unique_ptr<std::vector<FeedStorageProto>> entries) {
std::vector<KeyAndData> results;
if (!success || !entries) {
DVLOG_IF(1, !success) << "FeedStorageDatabase load content failed.";
std::move(callback).Run(std::move(results));
return;
}
for (const auto& entry : *entries) {
DCHECK(entry.has_key());
DCHECK(entry.has_content_data());
results.emplace_back(std::make_pair(entry.key(), entry.content_data()));
}
std::move(callback).Run(std::move(results));
}
void FeedStorageDatabase::OnContentDeletedEntriesLoaded(
FeedStorageCommitCallback callback,
bool success,
std::unique_ptr<std::vector<FeedStorageProto>> entries) {
auto entries_to_delete = std::make_unique<std::vector<std::string>>();
if (!success || !entries) {
DVLOG_IF(1, !success) << "FeedStorageDatabase load content failed.";
std::move(callback).Run(success);
return;
}
for (const auto& entry : *entries) {
DCHECK(entry.has_content_data());
entries_to_delete->push_back(FormatContentDatabaseKey(entry.key()));
}
storage_database_->UpdateEntries(
std::make_unique<StorageEntryVector>(), std::move(entries_to_delete),
base::BindOnce(&FeedStorageDatabase::OnStorageCommitted,
weak_ptr_factory_.GetWeakPtr(), std::move(callback)));
}
void FeedStorageDatabase::OnStorageCommitted(FeedStorageCommitCallback callback,
bool success) {
DVLOG_IF(1, !success) << "FeedStorageDatabase committed failed.";
std::move(callback).Run(success);
}
} // namespace feed
// Copyright 2018 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.
#ifndef COMPONENTS_FEED_CORE_FEED_STORAGE_DATABASE_H_
#define COMPONENTS_FEED_CORE_FEED_STORAGE_DATABASE_H_
#include "base/memory/weak_ptr.h"
#include "base/sequenced_task_runner.h"
#include "components/leveldb_proto/proto_database.h"
namespace feed {
class FeedStorageProto;
// FeedStorageDatabase is leveldb backed store for feed's content storage data
// and jounal storage data.
class FeedStorageDatabase {
public:
enum State {
UNINITIALIZED,
INITIALIZED,
INIT_FAILURE,
};
using KeyAndData = std::pair<std::string, std::string>;
// Returns the storage data as a vector of key-value pairs when calling
// loading data.
using FeedContentStorageDatabaseCallback =
base::OnceCallback<void(std::vector<KeyAndData>)>;
// Returns the commit operations success or not.
using FeedStorageCommitCallback = base::OnceCallback<void(bool)>;
// Initializes the database with |database_folder|.
FeedStorageDatabase(
const base::FilePath& database_folder,
const scoped_refptr<base::SequencedTaskRunner>& task_runner);
// Initializes the database with |database_folder|. Creates storage using the
// given |storage_database| for local storage. Useful for testing.
FeedStorageDatabase(
const base::FilePath& database_folder,
std::unique_ptr<leveldb_proto::ProtoDatabase<FeedStorageProto>>
storage_database);
~FeedStorageDatabase();
// Returns true if initialization has finished successfully, else false.
// While this is false, initialization may already started, or initialization
// failed.
bool IsInitialized() const;
// content storage.
void LoadContentEntries(const std::vector<std::string>& keys,
FeedContentStorageDatabaseCallback callback);
void LoadContentEntriesByPrefix(const std::string& prefix,
FeedContentStorageDatabaseCallback callback);
void SaveContentEntries(std::vector<KeyAndData> entries,
FeedStorageCommitCallback callback);
void DeleteContentEntries(std::vector<std::string> keys_to_delete,
FeedStorageCommitCallback callback);
void DeleteContentEntriesByPrefix(const std::string& prefix_to_delete,
FeedStorageCommitCallback callback);
private:
// Initialization
void OnDatabaseInitialized(bool success);
// Loading
void OnContentEntriesLoaded(
FeedContentStorageDatabaseCallback callback,
bool success,
std::unique_ptr<std::vector<FeedStorageProto>> entries);
// Deleting
void OnContentDeletedEntriesLoaded(
FeedStorageCommitCallback callback,
bool success,
std::unique_ptr<std::vector<FeedStorageProto>> entries);
// Commit callback
void OnStorageCommitted(FeedStorageCommitCallback callback, bool success);
State database_status_;
std::unique_ptr<leveldb_proto::ProtoDatabase<FeedStorageProto>>
storage_database_;
SEQUENCE_CHECKER(sequence_checker_);
base::WeakPtrFactory<FeedStorageDatabase> weak_ptr_factory_;
DISALLOW_COPY_AND_ASSIGN(FeedStorageDatabase);
};
} // namespace feed
#endif // COMPONENTS_FEED_CORE_FEED_STORAGE_DATABASE_H_
// Copyright 2018 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.
#include "components/feed/core/feed_storage_database.h"
#include <map>
#include "base/test/scoped_task_environment.h"
#include "components/feed/core/proto/feed_storage.pb.h"
#include "components/feed/core/time_serialization.h"
#include "components/leveldb_proto/testing/fake_db.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
using leveldb_proto::test::FakeDB;
using testing::Mock;
using testing::NotNull;
using testing::_;
namespace feed {
namespace {
const std::string kContentKeyPrefix = "ContentKey";
const std::string kContentKey1 = "ContentKey1";
const std::string kContentKey2 = "ContentKey2";
const std::string kContentKey3 = "ContentKey3";
const std::string kContentData1 = "Content Data1";
const std::string kContentData2 = "Content Data2";
const std::string kContentData3 = "Content Data3";
} // namespace
class FeedStorageDatabaseTest : public testing::Test {
public:
FeedStorageDatabaseTest() : storage_db_(nullptr) {}
void CreateDatabase(bool init_database) {
// The FakeDBs are owned by |feed_db_|, so clear our pointers before
// resetting |feed_db_| itself.
storage_db_ = nullptr;
// Explicitly destroy any existing database before creating a new one.
feed_db_.reset();
auto storage_db =
std::make_unique<FakeDB<FeedStorageProto>>(&storage_db_storage_);
storage_db_ = storage_db.get();
feed_db_ = std::make_unique<FeedStorageDatabase>(base::FilePath(),
std::move(storage_db));
if (init_database) {
storage_db_->InitCallback(true);
ASSERT_TRUE(db()->IsInitialized());
}
}
void InjectContentStorageProto(const std::string& key,
const std::string& data) {
FeedStorageProto storage_proto;
storage_proto.set_key(key);
storage_proto.set_content_data(data);
storage_db_storage_["cs-" + key] = storage_proto;
}
void RunUntilIdle() { scoped_task_environment_.RunUntilIdle(); }
FakeDB<FeedStorageProto>* storage_db() { return storage_db_; }
FeedStorageDatabase* db() { return feed_db_.get(); }
MOCK_METHOD1(OnContentEntriesReceived,
void(std::vector<std::pair<std::string, std::string>>));
MOCK_METHOD1(OnStorageCommitted, void(bool));
private:
base::test::ScopedTaskEnvironment scoped_task_environment_;
std::map<std::string, FeedStorageProto> storage_db_storage_;
// Owned by |feed_db_|.
FakeDB<FeedStorageProto>* storage_db_;
std::unique_ptr<FeedStorageDatabase> feed_db_;
DISALLOW_COPY_AND_ASSIGN(FeedStorageDatabaseTest);
};
TEST_F(FeedStorageDatabaseTest, Init) {
ASSERT_FALSE(db());
CreateDatabase(/*init_database=*/false);
storage_db()->InitCallback(true);
EXPECT_TRUE(db()->IsInitialized());
}
TEST_F(FeedStorageDatabaseTest, LoadContentAfterInitSuccess) {
CreateDatabase(/*init_database=*/true);
EXPECT_CALL(*this, OnContentEntriesReceived(_));
db()->LoadContentEntries(
{kContentKey1},
base::BindOnce(&FeedStorageDatabaseTest::OnContentEntriesReceived,
base::Unretained(this)));
storage_db()->LoadCallback(true);
}
TEST_F(FeedStorageDatabaseTest, LoadContentsEntries) {
CreateDatabase(/*init_database=*/true);
// Store |kContentKey1| and |kContentKey2|.
InjectContentStorageProto(kContentKey1, kContentData1);
InjectContentStorageProto(kContentKey2, kContentData2);
// Try to Load |kContentKey2| and |kContentKey3|, only |kContentKey2| should
// return.
EXPECT_CALL(*this, OnContentEntriesReceived(_))
.WillOnce([](std::vector<std::pair<std::string, std::string>> results) {
ASSERT_EQ(results.size(), 1U);
EXPECT_EQ(results[0].first, kContentKey2);
EXPECT_EQ(results[0].second, kContentData2);
});
db()->LoadContentEntries(
{kContentKey2, kContentKey3},
base::BindOnce(&FeedStorageDatabaseTest::OnContentEntriesReceived,
base::Unretained(this)));
storage_db()->LoadCallback(true);
}
TEST_F(FeedStorageDatabaseTest, LoadContentsEntriesByPrefix) {
CreateDatabase(/*init_database=*/true);
// Store |kContentKey1| and |kContentKey2|.
InjectContentStorageProto(kContentKey1, kContentData1);
InjectContentStorageProto(kContentKey2, kContentData2);
// Try to Load "ContentKey", both |kContentKey1| and |kContentKey2| should
// return.
EXPECT_CALL(*this, OnContentEntriesReceived(_))
.WillOnce([](std::vector<std::pair<std::string, std::string>> results) {
ASSERT_EQ(results.size(), 2U);
EXPECT_EQ(results[0].first, kContentKey1);
EXPECT_EQ(results[0].second, kContentData1);
EXPECT_EQ(results[1].first, kContentKey2);
EXPECT_EQ(results[1].second, kContentData2);
});
db()->LoadContentEntriesByPrefix(
kContentKeyPrefix,
base::BindOnce(&FeedStorageDatabaseTest::OnContentEntriesReceived,
base::Unretained(this)));
storage_db()->LoadCallback(true);
}
TEST_F(FeedStorageDatabaseTest, SaveContent) {
CreateDatabase(/*init_database=*/true);
// Save |kContentKey1| and |kContentKey2|.
std::vector<std::pair<std::string, std::string>> entries;
entries.push_back(std::make_pair(kContentKey1, kContentData1));
entries.push_back(std::make_pair(kContentKey2, kContentData2));
EXPECT_CALL(*this, OnStorageCommitted(true));
db()->SaveContentEntries(
std::move(entries),
base::BindOnce(&FeedStorageDatabaseTest::OnStorageCommitted,
base::Unretained(this)));
storage_db()->UpdateCallback(true);
// Make sure they're there.
EXPECT_CALL(*this, OnContentEntriesReceived(_))
.WillOnce([](std::vector<std::pair<std::string, std::string>> results) {
ASSERT_EQ(results.size(), 2U);
EXPECT_EQ(results[0].first, kContentKey1);
EXPECT_EQ(results[0].second, kContentData1);
EXPECT_EQ(results[1].first, kContentKey2);
EXPECT_EQ(results[1].second, kContentData2);
});
db()->LoadContentEntries(
{kContentKey1, kContentKey2},
base::BindOnce(&FeedStorageDatabaseTest::OnContentEntriesReceived,
base::Unretained(this)));
storage_db()->LoadCallback(true);
}
TEST_F(FeedStorageDatabaseTest, DeleteContent) {
CreateDatabase(/*init_database=*/true);
// Store |kContentKey1| and |kContentKey2|.
InjectContentStorageProto(kContentKey1, kContentData1);
InjectContentStorageProto(kContentKey2, kContentData2);
// Delete |kContentKey2| and |kContentKey3|
std::vector<std::string> keys;
keys.push_back(kContentKey2);
keys.push_back(kContentKey3);
EXPECT_CALL(*this, OnStorageCommitted(true));
db()->DeleteContentEntries(
std::move(keys),
base::BindOnce(&FeedStorageDatabaseTest::OnStorageCommitted,
base::Unretained(this)));
storage_db()->LoadCallback(true);
storage_db()->UpdateCallback(true);
// Make sure only |kContentKey2| got deleted.
EXPECT_CALL(*this, OnContentEntriesReceived(_))
.WillOnce([](std::vector<std::pair<std::string, std::string>> results) {
EXPECT_EQ(results.size(), 1U);
EXPECT_EQ(results[0].first, kContentKey1);
EXPECT_EQ(results[0].second, kContentData1);
});
db()->LoadContentEntries(
{kContentKey1, kContentKey2},
base::BindOnce(&FeedStorageDatabaseTest::OnContentEntriesReceived,
base::Unretained(this)));
storage_db()->LoadCallback(true);
}
TEST_F(FeedStorageDatabaseTest, DeleteContentByPrefix) {
CreateDatabase(/*init_database=*/true);
// Store |kContentKey1| and |kContentKey2|.
InjectContentStorageProto(kContentKey1, kContentData1);
InjectContentStorageProto(kContentKey2, kContentData2);
// Delete |kContentKey1| and |kContentKey2|
EXPECT_CALL(*this, OnStorageCommitted(true));
db()->DeleteContentEntriesByPrefix(
kContentKeyPrefix,
base::BindOnce(&FeedStorageDatabaseTest::OnStorageCommitted,
base::Unretained(this)));
storage_db()->LoadCallback(true);
storage_db()->UpdateCallback(true);
// Make sure |kContentKey1| and |kContentKey2| got deleted.
EXPECT_CALL(*this, OnContentEntriesReceived(_))
.WillOnce([](std::vector<std::pair<std::string, std::string>> results) {
EXPECT_EQ(results.size(), 0U);
});
db()->LoadContentEntries(
{kContentKey1, kContentKey2},
base::BindOnce(&FeedStorageDatabaseTest::OnContentEntriesReceived,
base::Unretained(this)));
storage_db()->LoadCallback(true);
}
} // namespace feed
...@@ -7,5 +7,6 @@ import("//third_party/protobuf/proto_library.gni") ...@@ -7,5 +7,6 @@ import("//third_party/protobuf/proto_library.gni")
proto_library("proto") { proto_library("proto") {
sources = [ sources = [
"cached_image.proto", "cached_image.proto",
"feed_storage.proto",
] ]
} }
// Copyright 2018 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.
syntax = "proto2";
option optimize_for = LITE_RUNTIME;
package feed;
message FeedStorageProto {
// original key for data.
optional string key = 1;
// Content data.
optional bytes content_data = 2;
// Journal data.
repeated bytes journal_data = 3;
}
...@@ -115071,6 +115071,7 @@ uploading your change for review. ...@@ -115071,6 +115071,7 @@ uploading your change for review.
<suffix name="FeatureEngagementTrackerEventStore" <suffix name="FeatureEngagementTrackerEventStore"
label="Database for FeatureEngagementTracker events."/> label="Database for FeatureEngagementTracker events."/>
<suffix name="FeedImageDatabase" label="Databases for Feed Image Loader."/> <suffix name="FeedImageDatabase" label="Databases for Feed Image Loader."/>
<suffix name="FeedStorageDatabase" label="Databases for Feed Storage."/>
<suffix name="GCMKeyStore" label="Databases for GCMKeyStore"/> <suffix name="GCMKeyStore" label="Databases for GCMKeyStore"/>
<suffix name="ImageManager" label="Databases for ImageManager"/> <suffix name="ImageManager" label="Databases for ImageManager"/>
<suffix name="OfflinePageMetadataStore" <suffix name="OfflinePageMetadataStore"
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