Reboot tests: verify offline license is valid after reboot
[ Merge of http://go/wvgerrit/143749 ] Tests are added to verify that the policy durations are enforced for an offline license after a device has been rebooted. Bug: 26163469 Test: GtsMediaTestCases on sunfish Change-Id: I54e65d7abc5e59eae7c150555b2244dbf96da3f5
This commit is contained in:
@@ -10,17 +10,23 @@
|
|||||||
|
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
|
|
||||||
|
#include "license_holder.h"
|
||||||
#include "log.h"
|
#include "log.h"
|
||||||
#include "test_sleep.h"
|
#include "test_sleep.h"
|
||||||
|
|
||||||
using wvutil::a2b_hex;
|
using wvutil::a2b_hex;
|
||||||
using wvutil::FileSystem;
|
using wvutil::FileSystem;
|
||||||
|
using wvutil::TestSleep;
|
||||||
using wvutil::unlimited_b2a_hex;
|
using wvutil::unlimited_b2a_hex;
|
||||||
|
|
||||||
namespace wvcdm {
|
namespace wvcdm {
|
||||||
FileSystem* RebootTest::file_system_;
|
FileSystem* RebootTest::file_system_;
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
// How much fudge or round off error do we allow in license durations for reboot
|
||||||
|
// tests.
|
||||||
|
constexpr int64_t kFudge = 10;
|
||||||
|
|
||||||
// We will encode a value string by wrapping it in braces, or as hex.
|
// We will encode a value string by wrapping it in braces, or as hex.
|
||||||
// If the string is not printable, or if it has unmatched braces, then we use
|
// If the string is not printable, or if it has unmatched braces, then we use
|
||||||
// hex. Otherwise, we surround the whole string with braces.
|
// hex. Otherwise, we surround the whole string with braces.
|
||||||
@@ -311,4 +317,413 @@ TEST_F(RebootTest, TimeMovesForward) {
|
|||||||
EXPECT_LT(previous_end, start);
|
EXPECT_LT(previous_end, start);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Test offline license durations work correctly after reboot. For time
|
||||||
|
constraints, this runs four sets of tests in parallel. For each set of tests
|
||||||
|
we load a list of licenses with a range of rental or playback durations so
|
||||||
|
that we can be confident that a short wait will find at least one duration
|
||||||
|
in the range that has expired and at least one duration that has not
|
||||||
|
expired.
|
||||||
|
|
||||||
|
RDa. Load an offline license, reboot the device, reload the license and
|
||||||
|
verify that the rental duration is enforced from the time the license was
|
||||||
|
requested.
|
||||||
|
|
||||||
|
RDb. Load an offline license, begin playback, reboot the device, reload the
|
||||||
|
license and verify that the rental duration is enforced from the time the
|
||||||
|
license was requested.
|
||||||
|
|
||||||
|
PDa. Load an offline license, reboot the device, reload the license and
|
||||||
|
verify that the playback duration is enforced from the time initial playback
|
||||||
|
started.
|
||||||
|
|
||||||
|
PDb. Load an offline license, begin playback, reboot the device, reload the
|
||||||
|
license and verify that the playback duration is enforced from the time
|
||||||
|
initial playback started.
|
||||||
|
|
||||||
|
Each of these four sets contains licenses with various durations so that the
|
||||||
|
test can run with a reasonable reboot time. We will assume that a reboot can
|
||||||
|
take anywhere from 10 seconds to almost an hour. With this in mind, we will
|
||||||
|
create a license that has a 10 second duration, one with a 20 second
|
||||||
|
duration, ... and one with an hour duration. All of these licenses will be
|
||||||
|
loaded before the reboot. Then after the reboot we should be in a situation
|
||||||
|
like this:
|
||||||
|
:-----------------------------------------> time axis.
|
||||||
|
: start of test.
|
||||||
|
:: licenses loaded.
|
||||||
|
:: : reboot starts
|
||||||
|
:: : : reboot finished. test resumes.
|
||||||
|
:: : :
|
||||||
|
:[---] 10s - expired :
|
||||||
|
:[-------] 20s expired :
|
||||||
|
: ... :
|
||||||
|
:[---------------------------] this license has not yet expired
|
||||||
|
:[----------------------------------------------] license not expired.
|
||||||
|
: ... :
|
||||||
|
:[---------------------------------------------------] 1 hour. not expired
|
||||||
|
|
||||||
|
After the test resumes, we will have at least one license that has not yet
|
||||||
|
expired. We will then sleep we are near the expiration time of that
|
||||||
|
license. We can then carefully test that this one license from the set
|
||||||
|
enforces its duration.
|
||||||
|
|
||||||
|
This is complicated by the fact that we are testing four different sets. So
|
||||||
|
what we really have is something like this:
|
||||||
|
:-----------------------------------------> time axis.
|
||||||
|
: start of test.
|
||||||
|
:: licenses loaded.
|
||||||
|
:: : reboot starts
|
||||||
|
:: : : reboot finished. test resumes.
|
||||||
|
:: : :
|
||||||
|
:[-----------------------------] first RDa license that has not exipred.
|
||||||
|
:[--------------------------] first RDb license that has not exipred.
|
||||||
|
:[---------------------------] first PDa license that has not exipred.
|
||||||
|
:[-------------------------------] first PDb license that has not exipred.
|
||||||
|
|
||||||
|
Since we want to test all four of these licenses, we will compute the
|
||||||
|
interesting times for each license. These are the times just before, and
|
||||||
|
just after the expiration time. After sorting these interesting times, we
|
||||||
|
will test each one in order.
|
||||||
|
*/
|
||||||
|
class OfflineLicense {
|
||||||
|
public:
|
||||||
|
OfflineLicense(const std::string& test_type, CdmEngine* cdm_engine,
|
||||||
|
const ConfigTestEnv& config, int64_t duration,
|
||||||
|
bool play_before_reboot)
|
||||||
|
: content_id_("CDM_Reboot_" + test_type +
|
||||||
|
(play_before_reboot ? "b_" : "a_") +
|
||||||
|
std::to_string(duration)),
|
||||||
|
duration_(duration),
|
||||||
|
play_before_reboot_(play_before_reboot),
|
||||||
|
license_holder_(content_id_, cdm_engine, config) {
|
||||||
|
license_holder_.set_can_persist(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual ~OfflineLicense() {}
|
||||||
|
|
||||||
|
// Fetch and load the license. The session is left open.
|
||||||
|
void LoadLicense() {
|
||||||
|
license_holder_.OpenSession();
|
||||||
|
start_of_rental_clock_ = wvutil::Clock().GetCurrentTime();
|
||||||
|
license_holder_.FetchLicense();
|
||||||
|
license_holder_.LoadLicense();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload the license. The session is left open.
|
||||||
|
void ReloadLicense() {
|
||||||
|
license_holder_.OpenSession();
|
||||||
|
license_holder_.ReloadLicense();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action to be taken after loading the license, but before the reboot.
|
||||||
|
virtual void BeforeReboot() {
|
||||||
|
if (play_before_reboot_) {
|
||||||
|
Decrypt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action to be taken after reloading the license.
|
||||||
|
virtual void AfterReboot() {}
|
||||||
|
|
||||||
|
void CloseSession() { license_holder_.CloseSession(); }
|
||||||
|
|
||||||
|
// The time this license should be cutoff. Decrypt should succeed before this
|
||||||
|
// time and should fail after this time.
|
||||||
|
virtual int64_t cutoff() = 0;
|
||||||
|
|
||||||
|
// Verify that the license may be used to decrypt content.
|
||||||
|
void Decrypt() {
|
||||||
|
if (start_of_playback_ == 0) {
|
||||||
|
start_of_playback_ = wvutil::Clock().GetCurrentTime();
|
||||||
|
}
|
||||||
|
const KeyId key_id = "0000000000000000";
|
||||||
|
EXPECT_EQ(NO_ERROR, license_holder_.Decrypt(key_id))
|
||||||
|
<< "Failed for " << content_id_
|
||||||
|
<< ", now = " << wvutil::Clock().GetCurrentTime() << ", rental_clock="
|
||||||
|
<< (wvutil::Clock().GetCurrentTime() - start_of_rental_clock_)
|
||||||
|
<< ", playback_clock = "
|
||||||
|
<< (wvutil::Clock().GetCurrentTime() - start_of_playback_)
|
||||||
|
<< ", delta to cutoff = "
|
||||||
|
<< (wvutil::Clock().GetCurrentTime() - cutoff());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the license has expired, and may not be used to decrypt
|
||||||
|
// content.
|
||||||
|
void FailDecrypt() {
|
||||||
|
const KeyId key_id = "0000000000000000";
|
||||||
|
EXPECT_EQ(NEED_KEY, license_holder_.Decrypt(key_id))
|
||||||
|
<< "Decrypt should have failed for " << content_id_
|
||||||
|
<< ", now = " << wvutil::Clock().GetCurrentTime() << ", rental_clock="
|
||||||
|
<< (wvutil::Clock().GetCurrentTime() - start_of_rental_clock_)
|
||||||
|
<< ", playback_clock = "
|
||||||
|
<< (wvutil::Clock().GetCurrentTime() - start_of_playback_)
|
||||||
|
<< ", delta to cutoff = "
|
||||||
|
<< (wvutil::Clock().GetCurrentTime() - cutoff());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save times and the key set id to persistent data.
|
||||||
|
void SaveData(RebootTest* reboot_test,
|
||||||
|
std::map<std::string, std::string>* persistent_data) {
|
||||||
|
reboot_test->SaveTime("start_of_rental_" + content_id_,
|
||||||
|
start_of_rental_clock_);
|
||||||
|
reboot_test->SaveTime("start_of_playback_" + content_id_,
|
||||||
|
start_of_playback_);
|
||||||
|
(*persistent_data)["key_set_id_" + content_id_] =
|
||||||
|
license_holder_.key_set_id();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load times and the key set id from persistent data.
|
||||||
|
void LoadData(RebootTest* reboot_test,
|
||||||
|
std::map<std::string, std::string>* persistent_data) {
|
||||||
|
start_of_rental_clock_ =
|
||||||
|
reboot_test->LoadTime("start_of_rental_" + content_id_);
|
||||||
|
start_of_playback_ =
|
||||||
|
reboot_test->LoadTime("start_of_playback_" + content_id_);
|
||||||
|
license_holder_.set_key_set_id(
|
||||||
|
(*persistent_data)["key_set_id_" + content_id_]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string& content_id() const { return content_id_; }
|
||||||
|
|
||||||
|
protected:
|
||||||
|
const std::string content_id_;
|
||||||
|
int64_t duration_;
|
||||||
|
int64_t start_of_rental_clock_ = 0;
|
||||||
|
int64_t start_of_playback_ = 0;
|
||||||
|
bool play_before_reboot_;
|
||||||
|
LicenseHolder license_holder_;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Holds an offline license that has a limit on the rental duration.
|
||||||
|
class RentalDurationLicense : public OfflineLicense {
|
||||||
|
public:
|
||||||
|
RentalDurationLicense(CdmEngine* cdm_engine, const ConfigTestEnv& config,
|
||||||
|
int64_t duration, bool play_before_reboot)
|
||||||
|
: OfflineLicense("RD", cdm_engine, config, duration, play_before_reboot) {
|
||||||
|
}
|
||||||
|
|
||||||
|
int64_t cutoff() override { return start_of_rental_clock_ + duration_; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Holds an offline license that has a limit on the playback duration.
|
||||||
|
class PlaybackDurationLicense : public OfflineLicense {
|
||||||
|
public:
|
||||||
|
PlaybackDurationLicense(CdmEngine* cdm_engine, const ConfigTestEnv& config,
|
||||||
|
int64_t duration, bool play_before_reboot)
|
||||||
|
: OfflineLicense("PD", cdm_engine, config, duration, play_before_reboot) {
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we did not start playback before the reboot, we will start playback
|
||||||
|
// just after reloading the license, post-reboot.
|
||||||
|
void AfterReboot() override {
|
||||||
|
if (!play_before_reboot_) {
|
||||||
|
Decrypt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int64_t cutoff() override { return start_of_playback_ + duration_; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Test that the rental and playback durations are enforced across a reboot.
|
||||||
|
class OfflineLicenseTest : public RebootTest {
|
||||||
|
public:
|
||||||
|
void SetUp() override {
|
||||||
|
RebootTest::SetUp();
|
||||||
|
EnsureProvisioned();
|
||||||
|
// Run each of the following test cases in parallel so that we don't have to
|
||||||
|
// sleep separately for each one. These durations need to match the polices
|
||||||
|
// specified on the UAT license server.
|
||||||
|
test_case_.resize(4);
|
||||||
|
const std::vector<int64_t> duration_range = {10, 20, 30, 45,
|
||||||
|
60, 300, 900, 3600};
|
||||||
|
for (size_t i = 0; i < duration_range.size(); i++) {
|
||||||
|
test_case_[0].push_back(
|
||||||
|
std::unique_ptr<OfflineLicense>(new RentalDurationLicense(
|
||||||
|
&cdm_engine_, config_, duration_range[i], false)));
|
||||||
|
test_case_[1].push_back(
|
||||||
|
std::unique_ptr<OfflineLicense>(new RentalDurationLicense(
|
||||||
|
&cdm_engine_, config_, duration_range[i], true)));
|
||||||
|
test_case_[2].push_back(
|
||||||
|
std::unique_ptr<OfflineLicense>(new PlaybackDurationLicense(
|
||||||
|
&cdm_engine_, config_, duration_range[i], false)));
|
||||||
|
test_case_[3].push_back(
|
||||||
|
std::unique_ptr<OfflineLicense>(new PlaybackDurationLicense(
|
||||||
|
&cdm_engine_, config_, duration_range[i], true)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load all of the licenses. For the tests that require playback before the
|
||||||
|
// reboot, we start playback here.
|
||||||
|
void LoadAllLicenses() {
|
||||||
|
DeleteAllLicenses();
|
||||||
|
// For each test case, load an offline license and save the data.
|
||||||
|
for (size_t n = 0; n < test_case_.size(); n++) {
|
||||||
|
for (size_t i = 0; i < test_case_[n].size(); i++) {
|
||||||
|
ASSERT_NO_FATAL_FAILURE(test_case_[n][i]->LoadLicense());
|
||||||
|
test_case_[n][i]->BeforeReboot(); // For some tests, we decrypt here.
|
||||||
|
test_case_[n][i]->SaveData(this, &persistent_data_);
|
||||||
|
test_case_[n][i]->CloseSession();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload all of the licenses. We also go through each license and figure out
|
||||||
|
// which ones have already expired, or close to expire. We then find the first
|
||||||
|
// license from each set that will be next to expire, so that we can test
|
||||||
|
// those more carefully.
|
||||||
|
void ReloadAllLicense() {
|
||||||
|
// first_valid is the index of the first license that has not expired and is
|
||||||
|
// not just about to expire.
|
||||||
|
first_valid_.resize(test_case_.size());
|
||||||
|
for (size_t n = 0; n < test_case_.size(); n++) {
|
||||||
|
for (size_t i = 0; i < test_case_[n].size(); i++) {
|
||||||
|
OfflineLicense* license = test_case_[n][i].get();
|
||||||
|
license->LoadData(this, &persistent_data_);
|
||||||
|
ASSERT_NO_FATAL_FAILURE(license->ReloadLicense());
|
||||||
|
license->AfterReboot();
|
||||||
|
// if past cutoff. make sure decrypt fails.
|
||||||
|
if (license->cutoff() + kFudge < wvutil::Clock().GetCurrentTime()) {
|
||||||
|
license->FailDecrypt();
|
||||||
|
}
|
||||||
|
// If past cutoff, or near cutoff, then we don't want to use it
|
||||||
|
// as our first valid.
|
||||||
|
if (license->cutoff() - 2 * kFudge < wvutil::Clock().GetCurrentTime()) {
|
||||||
|
first_valid_[n] = i + 1;
|
||||||
|
}
|
||||||
|
license->CloseSession();
|
||||||
|
}
|
||||||
|
// We expect there to be at least one license that has not expired yet.
|
||||||
|
// If this is not true, then the reboot time was probably longer than
|
||||||
|
// expected. If it is important to run these tests with a very long reboot
|
||||||
|
// time, then please ask a Widevine engineer to generate more license
|
||||||
|
// policies with longer reboot times.
|
||||||
|
ASSERT_LT(first_valid_[n], test_case_[n].size())
|
||||||
|
<< "For n=" << n
|
||||||
|
<< ", content_id = " << test_case_[n][0]->content_id()
|
||||||
|
<< ", time=" << wvutil::Clock().GetCurrentTime() << "\n"
|
||||||
|
<< "This is an indication that the reboot took a very long time.\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take the first_valid_ array, which tells us which licenses will expire
|
||||||
|
// soon, and compute the times we are interested in: a little before and a
|
||||||
|
// little after the cutoff. We want to test that the license is valid just
|
||||||
|
// before the cutoff and that the license is not valid just after the
|
||||||
|
// cutoff.
|
||||||
|
//
|
||||||
|
// The interesting_times_ is a std::set, so that we may iterate through the
|
||||||
|
// times in order.
|
||||||
|
void FindInterestingTimes() {
|
||||||
|
for (size_t n = 0; n < test_case_.size(); n++) {
|
||||||
|
OfflineLicense* license = test_case_[n][first_valid_[n]].get();
|
||||||
|
interesting_times_.insert(license->cutoff() - kFudge);
|
||||||
|
interesting_times_.insert(license->cutoff() + kFudge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test at each of the interesting times. These are the times just before and
|
||||||
|
// after the cutoff of the next license to expire from each list of test
|
||||||
|
// cases.
|
||||||
|
void TestInterestingTimes() {
|
||||||
|
int decrypt_count = 0;
|
||||||
|
int fail_count = 0;
|
||||||
|
for (auto time : interesting_times_) {
|
||||||
|
int64_t now = wvutil::Clock().GetCurrentTime();
|
||||||
|
int64_t delta = (time - now);
|
||||||
|
// It is not necessarily an error for the delta to be negative. But it is
|
||||||
|
// an indication that the we are near an error condition. If the current
|
||||||
|
// time is a few seconds past the interesting time, then the license
|
||||||
|
// should still be valid or expired. If delta is very large relative to
|
||||||
|
// kFudge, then the test might fail. This is still an indication that
|
||||||
|
// something is wrong: reloading a license and decrypting some content
|
||||||
|
// should not take multiple seconds.
|
||||||
|
if (delta < 0) LOGW("Sleep delta would be negative: %ld", delta);
|
||||||
|
if (delta > 0) TestSleep::Sleep(static_cast<unsigned int>(delta));
|
||||||
|
// We look at each of the four license that we used to generate the
|
||||||
|
// interesting times. It's possible that two licenses share the same
|
||||||
|
// interesting time, so we have to check each of the four against each
|
||||||
|
// time instead of keeping track of which license generated which time.
|
||||||
|
for (size_t n = 0; n < test_case_.size(); n++) {
|
||||||
|
OfflineLicense* license = test_case_[n][first_valid_[n]].get();
|
||||||
|
ASSERT_NO_FATAL_FAILURE(license->ReloadLicense());
|
||||||
|
if (time == license->cutoff() - kFudge) {
|
||||||
|
license->Decrypt();
|
||||||
|
decrypt_count++;
|
||||||
|
}
|
||||||
|
if (time == license->cutoff() + kFudge) {
|
||||||
|
license->FailDecrypt();
|
||||||
|
fail_count++;
|
||||||
|
}
|
||||||
|
license->CloseSession();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EXPECT_EQ(decrypt_count, 4) << "Test error. I missed a cutoff";
|
||||||
|
EXPECT_EQ(fail_count, 4) << "Test error. I missed a cutoff";
|
||||||
|
}
|
||||||
|
|
||||||
|
// After we have tested one license from each list of test cases carefully,
|
||||||
|
// all the rest of the licenses can be tested. We do not sleep in this
|
||||||
|
// function, we only test each license that is still valid, or has already
|
||||||
|
// expired. We ignore licenses that are near the cutoff because we already
|
||||||
|
// tested those license in the previous function.
|
||||||
|
void TestAfterInterestingTimes() {
|
||||||
|
// Make sure that all the rest of the licenses are still valid.
|
||||||
|
for (size_t n = 0; n < test_case_.size(); n++) {
|
||||||
|
for (size_t i = first_valid_[n] + 1; i < test_case_[n].size(); i++) {
|
||||||
|
OfflineLicense* license = test_case_[n][i].get();
|
||||||
|
ASSERT_NO_FATAL_FAILURE(license->ReloadLicense());
|
||||||
|
int64_t now = wvutil::Clock().GetCurrentTime();
|
||||||
|
if (now <= license->cutoff() - kFudge) {
|
||||||
|
license->Decrypt();
|
||||||
|
}
|
||||||
|
if (now >= license->cutoff() + kFudge) {
|
||||||
|
license->FailDecrypt();
|
||||||
|
}
|
||||||
|
license->CloseSession();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TearDown() override {
|
||||||
|
// Clean up licenses if any test failed.
|
||||||
|
if (::testing::Test::HasFailure() || test_pass() == 1) {
|
||||||
|
DeleteAllLicenses();
|
||||||
|
}
|
||||||
|
RebootTest::TearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
void DeleteAllLicenses() {
|
||||||
|
std::vector<std::string> key_set_ids;
|
||||||
|
EXPECT_EQ(NO_ERROR,
|
||||||
|
cdm_engine_.ListStoredLicenses(kSecurityLevelL1, &key_set_ids));
|
||||||
|
for (auto key_set : key_set_ids) {
|
||||||
|
cdm_engine_.RemoveOfflineLicense(key_set, kSecurityLevelL1);
|
||||||
|
}
|
||||||
|
// TODO(b/215230202): Is this necessary? It doesn't seem to work.
|
||||||
|
std::vector<std::string> ksids;
|
||||||
|
std::vector<std::string> pst;
|
||||||
|
std::string app_id = "";
|
||||||
|
EXPECT_EQ(NO_ERROR,
|
||||||
|
cdm_engine_.ListUsageIds(app_id, kSecurityLevelL1, &ksids, &pst));
|
||||||
|
for (auto k : ksids) {
|
||||||
|
EXPECT_EQ(NO_ERROR,
|
||||||
|
cdm_engine_.DeleteUsageRecord(app_id, kSecurityLevelL1, k));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::vector<std::unique_ptr<OfflineLicense>>> test_case_;
|
||||||
|
std::set<int64_t> interesting_times_;
|
||||||
|
std::vector<size_t> first_valid_;
|
||||||
|
};
|
||||||
|
|
||||||
|
TEST_F(OfflineLicenseTest, VariousTests) {
|
||||||
|
if (test_pass() == 0) {
|
||||||
|
LoadAllLicenses();
|
||||||
|
} else if (test_pass() == 1) {
|
||||||
|
ReloadAllLicense();
|
||||||
|
FindInterestingTimes();
|
||||||
|
TestInterestingTimes();
|
||||||
|
TestAfterInterestingTimes();
|
||||||
|
}
|
||||||
|
}
|
||||||
} // namespace wvcdm
|
} // namespace wvcdm
|
||||||
|
|||||||
Reference in New Issue
Block a user