Commit f620bc08 authored by cfredric's avatar cfredric Committed by Commit Bot

Plumb proper input digest to CanvasAsyncBlobCreator.

This CL also removes one UKM collection point, since it is redundant
with the UKM collection done by CanvasAsyncBlobCreator.

Change-Id: If985fd00bb4d24a7064e8f1252a0131cf1890fbb
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2481445
Commit-Queue: Chris Fredrickson <cfredric@google.com>
Reviewed-by: default avatarFernando Serboncini <fserb@chromium.org>
Reviewed-by: default avatarCaleb Raitto <caraitto@chromium.org>
Cr-Commit-Position: refs/heads/master@{#820811}
parent 464e6747
...@@ -155,7 +155,7 @@ IN_PROC_BROWSER_TEST_F(PrivacyBudgetBrowserTest, CallsCanvasToBlob) { ...@@ -155,7 +155,7 @@ IN_PROC_BROWSER_TEST_F(PrivacyBudgetBrowserTest, CallsCanvasToBlob) {
// adjust this test to deal. // adjust this test to deal.
ASSERT_EQ(1u, merged_entries.size()); ASSERT_EQ(1u, merged_entries.size());
constexpr uint64_t input_digest = 0; constexpr uint64_t input_digest = 9;
EXPECT_THAT(merged_entries.begin()->second->metrics, EXPECT_THAT(merged_entries.begin()->second->metrics,
IsSupersetOf({ IsSupersetOf({
Key(blink::IdentifiableSurface::FromTypeAndToken( Key(blink::IdentifiableSurface::FromTypeAndToken(
......
...@@ -151,6 +151,7 @@ CanvasAsyncBlobCreator::CanvasAsyncBlobCreator( ...@@ -151,6 +151,7 @@ CanvasAsyncBlobCreator::CanvasAsyncBlobCreator(
base::TimeTicks start_time, base::TimeTicks start_time,
ExecutionContext* context, ExecutionContext* context,
UkmParameters ukm_params, UkmParameters ukm_params,
const IdentifiableToken& input_digest,
ScriptPromiseResolver* resolver) ScriptPromiseResolver* resolver)
: CanvasAsyncBlobCreator(image, : CanvasAsyncBlobCreator(image,
options, options,
...@@ -159,6 +160,7 @@ CanvasAsyncBlobCreator::CanvasAsyncBlobCreator( ...@@ -159,6 +160,7 @@ CanvasAsyncBlobCreator::CanvasAsyncBlobCreator(
start_time, start_time,
context, context,
ukm_params, ukm_params,
input_digest,
resolver) {} resolver) {}
CanvasAsyncBlobCreator::CanvasAsyncBlobCreator( CanvasAsyncBlobCreator::CanvasAsyncBlobCreator(
...@@ -169,6 +171,7 @@ CanvasAsyncBlobCreator::CanvasAsyncBlobCreator( ...@@ -169,6 +171,7 @@ CanvasAsyncBlobCreator::CanvasAsyncBlobCreator(
base::TimeTicks start_time, base::TimeTicks start_time,
ExecutionContext* context, ExecutionContext* context,
UkmParameters ukm_params, UkmParameters ukm_params,
const IdentifiableToken& input_digest,
ScriptPromiseResolver* resolver) ScriptPromiseResolver* resolver)
: fail_encoder_initialization_for_test_(false), : fail_encoder_initialization_for_test_(false),
enforce_idle_encoding_for_test_(false), enforce_idle_encoding_for_test_(false),
...@@ -180,6 +183,7 @@ CanvasAsyncBlobCreator::CanvasAsyncBlobCreator( ...@@ -180,6 +183,7 @@ CanvasAsyncBlobCreator::CanvasAsyncBlobCreator(
static_bitmap_image_loaded_(false), static_bitmap_image_loaded_(false),
callback_(callback), callback_(callback),
ukm_params_(ukm_params), ukm_params_(ukm_params),
input_digest_(input_digest),
script_promise_resolver_(resolver) { script_promise_resolver_(resolver) {
DCHECK(image); DCHECK(image);
DCHECK(context); DCHECK(context);
...@@ -500,7 +504,8 @@ void CanvasAsyncBlobCreator::RecordIdentifiabilityMetric() { ...@@ -500,7 +504,8 @@ void CanvasAsyncBlobCreator::RecordIdentifiabilityMetric() {
->PostTask( ->PostTask(
FROM_HERE, FROM_HERE,
WTF::Bind( WTF::Bind(
[](scoped_refptr<StaticBitmapImage> image, [](IdentifiableToken input_digest,
scoped_refptr<StaticBitmapImage> image,
UkmParameters ukm_params) { UkmParameters ukm_params) {
std::unique_ptr<ImageDataBuffer> data_buffer = std::unique_ptr<ImageDataBuffer> data_buffer =
ImageDataBuffer::Create(image); ImageDataBuffer::Create(image);
...@@ -509,13 +514,13 @@ void CanvasAsyncBlobCreator::RecordIdentifiabilityMetric() { ...@@ -509,13 +514,13 @@ void CanvasAsyncBlobCreator::RecordIdentifiabilityMetric() {
blink::IdentifiabilityMetricBuilder(ukm_params.source_id) blink::IdentifiabilityMetricBuilder(ukm_params.source_id)
.Set(blink::IdentifiableSurface::FromTypeAndToken( .Set(blink::IdentifiableSurface::FromTypeAndToken(
blink::IdentifiableSurface::Type::kCanvasReadback, blink::IdentifiableSurface::Type::kCanvasReadback,
0), input_digest),
blink::IdentifiabilityDigestOfBytes( blink::IdentifiabilityDigestOfBytes(
base::make_span(data_buffer->Pixels(), base::make_span(data_buffer->Pixels(),
data_buffer->ComputeByteSize()))) data_buffer->ComputeByteSize())))
.Record(ukm_params.ukm_recorder); .Record(ukm_params.ukm_recorder);
}, },
image_, ukm_params_)); input_digest_, image_, ukm_params_));
} }
void CanvasAsyncBlobCreator::CreateNullAndReturnResult() { void CanvasAsyncBlobCreator::CreateNullAndReturnResult() {
......
...@@ -10,6 +10,7 @@ ...@@ -10,6 +10,7 @@
#include "base/location.h" #include "base/location.h"
#include "base/single_thread_task_runner.h" #include "base/single_thread_task_runner.h"
#include "services/metrics/public/cpp/ukm_recorder.h" #include "services/metrics/public/cpp/ukm_recorder.h"
#include "third_party/blink/public/common/privacy_budget/identifiable_token.h"
#include "third_party/blink/renderer/bindings/core/v8/script_promise_resolver.h" #include "third_party/blink/renderer/bindings/core/v8/script_promise_resolver.h"
#include "third_party/blink/renderer/bindings/core/v8/v8_blob_callback.h" #include "third_party/blink/renderer/bindings/core/v8/v8_blob_callback.h"
#include "third_party/blink/renderer/bindings/core/v8/v8_image_encode_options.h" #include "third_party/blink/renderer/bindings/core/v8/v8_image_encode_options.h"
...@@ -63,6 +64,7 @@ class CORE_EXPORT CanvasAsyncBlobCreator ...@@ -63,6 +64,7 @@ class CORE_EXPORT CanvasAsyncBlobCreator
base::TimeTicks start_time, base::TimeTicks start_time,
ExecutionContext*, ExecutionContext*,
UkmParameters ukm_params, UkmParameters ukm_params,
const IdentifiableToken& input_digest,
ScriptPromiseResolver*); ScriptPromiseResolver*);
CanvasAsyncBlobCreator(scoped_refptr<StaticBitmapImage>, CanvasAsyncBlobCreator(scoped_refptr<StaticBitmapImage>,
const ImageEncodeOptions*, const ImageEncodeOptions*,
...@@ -71,6 +73,7 @@ class CORE_EXPORT CanvasAsyncBlobCreator ...@@ -71,6 +73,7 @@ class CORE_EXPORT CanvasAsyncBlobCreator
base::TimeTicks start_time, base::TimeTicks start_time,
ExecutionContext*, ExecutionContext*,
UkmParameters ukm_params, UkmParameters ukm_params,
const IdentifiableToken& input_digest,
ScriptPromiseResolver* = nullptr); ScriptPromiseResolver* = nullptr);
virtual ~CanvasAsyncBlobCreator(); virtual ~CanvasAsyncBlobCreator();
...@@ -137,6 +140,7 @@ class CORE_EXPORT CanvasAsyncBlobCreator ...@@ -137,6 +140,7 @@ class CORE_EXPORT CanvasAsyncBlobCreator
Member<V8BlobCallback> callback_; Member<V8BlobCallback> callback_;
UkmParameters ukm_params_; UkmParameters ukm_params_;
IdentifiableToken input_digest_;
// Used for OffscreenCanvas only // Used for OffscreenCanvas only
Member<ScriptPromiseResolver> script_promise_resolver_; Member<ScriptPromiseResolver> script_promise_resolver_;
......
...@@ -37,6 +37,7 @@ class MockCanvasAsyncBlobCreator : public CanvasAsyncBlobCreator { ...@@ -37,6 +37,7 @@ class MockCanvasAsyncBlobCreator : public CanvasAsyncBlobCreator {
base::TimeTicks(), base::TimeTicks(),
document->GetExecutionContext(), document->GetExecutionContext(),
UkmParameters{document->UkmRecorder(), document->UkmSourceID()}, UkmParameters{document->UkmRecorder(), document->UkmSourceID()},
0,
nullptr) { nullptr) {
if (fail_encoder_initialization) if (fail_encoder_initialization)
fail_encoder_initialization_for_test_ = true; fail_encoder_initialization_for_test_ = true;
...@@ -300,7 +301,7 @@ TEST_F(CanvasAsyncBlobCreatorTest, ColorManagedConvertToBlob) { ...@@ -300,7 +301,7 @@ TEST_F(CanvasAsyncBlobCreatorTest, ColorManagedConvertToBlob) {
CanvasAsyncBlobCreator::ToBlobFunctionType:: CanvasAsyncBlobCreator::ToBlobFunctionType::
kHTMLCanvasConvertToBlobPromise, kHTMLCanvasConvertToBlobPromise,
base::TimeTicks(), GetFrame().DomWindow(), base::TimeTicks(), GetFrame().DomWindow(),
UkmParameters{UkmRecorder(), 0}, nullptr); UkmParameters{UkmRecorder(), 0}, 0, nullptr);
ASSERT_TRUE(async_blob_creator->EncodeImageForConvertToBlobTest()); ASSERT_TRUE(async_blob_creator->EncodeImageForConvertToBlobTest());
sk_sp<SkData> sk_data = SkData::MakeWithCopy( sk_sp<SkData> sk_data = SkData::MakeWithCopy(
......
...@@ -8,6 +8,7 @@ ...@@ -8,6 +8,7 @@
#include "base/metrics/histogram_functions.h" #include "base/metrics/histogram_functions.h"
#include "base/metrics/histogram_macros.h" #include "base/metrics/histogram_macros.h"
#include "third_party/blink/public/common/features.h" #include "third_party/blink/public/common/features.h"
#include "third_party/blink/public/common/privacy_budget/identifiability_study_settings.h"
#include "third_party/blink/public/platform/platform.h" #include "third_party/blink/public/platform/platform.h"
#include "third_party/blink/renderer/bindings/core/v8/v8_image_encode_options.h" #include "third_party/blink/renderer/bindings/core/v8/v8_image_encode_options.h"
#include "third_party/blink/renderer/core/html/canvas/canvas_async_blob_creator.h" #include "third_party/blink/renderer/core/html/canvas/canvas_async_blob_creator.h"
...@@ -287,7 +288,8 @@ CanvasColorParams CanvasRenderingContextHost::ColorParams() const { ...@@ -287,7 +288,8 @@ CanvasColorParams CanvasRenderingContextHost::ColorParams() const {
ScriptPromise CanvasRenderingContextHost::convertToBlob( ScriptPromise CanvasRenderingContextHost::convertToBlob(
ScriptState* script_state, ScriptState* script_state,
const ImageEncodeOptions* options, const ImageEncodeOptions* options,
ExceptionState& exception_state) { ExceptionState& exception_state,
const CanvasRenderingContext* const context) {
WTF::String object_name = "Canvas"; WTF::String object_name = "Canvas";
if (this->IsOffscreenCanvas()) if (this->IsOffscreenCanvas())
object_name = "OffscreenCanvas"; object_name = "OffscreenCanvas";
...@@ -338,7 +340,12 @@ ScriptPromise CanvasRenderingContextHost::convertToBlob( ...@@ -338,7 +340,12 @@ ScriptPromise CanvasRenderingContextHost::convertToBlob(
} }
auto* async_creator = MakeGarbageCollected<CanvasAsyncBlobCreator>( auto* async_creator = MakeGarbageCollected<CanvasAsyncBlobCreator>(
image_bitmap, options, function_type, start_time, image_bitmap, options, function_type, start_time,
ExecutionContext::From(script_state), ukm_params_, resolver); ExecutionContext::From(script_state), ukm_params_,
IdentifiabilityStudySettings::Get()->IsTypeAllowed(
IdentifiableSurface::Type::kCanvasReadback)
? IdentifiabilityInputDigest(context)
: 0,
resolver);
async_creator->ScheduleAsyncBlobCreation(options->quality()); async_creator->ScheduleAsyncBlobCreation(options->quality());
return resolver->Promise(); return resolver->Promise();
} }
...@@ -351,4 +358,42 @@ bool CanvasRenderingContextHost::IsOffscreenCanvas() const { ...@@ -351,4 +358,42 @@ bool CanvasRenderingContextHost::IsOffscreenCanvas() const {
return host_type_ == kOffscreenCanvasHost; return host_type_ == kOffscreenCanvasHost;
} }
IdentifiableToken CanvasRenderingContextHost::IdentifiabilityInputDigest(
const CanvasRenderingContext* const context) const {
const uint64_t context_digest =
context ? context->IdentifiableTextToken().ToUkmMetricValue() : 0;
const IdentifiabilityPaintOpDigest* const identifiability_paintop_digest =
ResourceProvider()
? &(ResourceProvider()->GetIdentifiablityPaintOpDigest())
: nullptr;
const uint64_t canvas_digest =
identifiability_paintop_digest
? identifiability_paintop_digest->GetToken().ToUkmMetricValue()
: 0;
const uint64_t context_type =
context ? context->GetContextType()
: CanvasRenderingContext::kContextTypeUnknown;
const bool encountered_skipped_ops =
(context && context->IdentifiabilityEncounteredSkippedOps()) ||
(identifiability_paintop_digest &&
identifiability_paintop_digest->encountered_skipped_ops());
const bool encountered_sensitive_ops =
context && context->IdentifiabilityEncounteredSensitiveOps();
const bool encountered_partially_digested_image =
identifiability_paintop_digest &&
identifiability_paintop_digest->encountered_partially_digested_image();
// Bits [0-3] are the context type, bits [4-6] are skipped ops, sensitive
// ops, and partial image ops bits, respectively. The remaining bits are
// for the canvas digest.
uint64_t final_digest =
((context_digest ^ canvas_digest) << 7) | context_type;
if (encountered_skipped_ops)
final_digest |= IdentifiableSurface::CanvasTaintBit::kSkipped;
if (encountered_sensitive_ops)
final_digest |= IdentifiableSurface::CanvasTaintBit::kSensitive;
if (encountered_partially_digested_image)
final_digest |= IdentifiableSurface::CanvasTaintBit::kPartiallyDigested;
return final_digest;
}
} // namespace blink } // namespace blink
...@@ -6,6 +6,7 @@ ...@@ -6,6 +6,7 @@
#define THIRD_PARTY_BLINK_RENDERER_CORE_HTML_CANVAS_CANVAS_RENDERING_CONTEXT_HOST_H_ #define THIRD_PARTY_BLINK_RENDERER_CORE_HTML_CANVAS_CANVAS_RENDERING_CONTEXT_HOST_H_
#include "services/metrics/public/cpp/ukm_recorder.h" #include "services/metrics/public/cpp/ukm_recorder.h"
#include "third_party/blink/public/common/privacy_budget/identifiable_token.h"
#include "third_party/blink/renderer/bindings/core/v8/script_promise.h" #include "third_party/blink/renderer/bindings/core/v8/script_promise.h"
#include "third_party/blink/renderer/core/core_export.h" #include "third_party/blink/renderer/core/core_export.h"
#include "third_party/blink/renderer/core/dom/events/event_dispatcher.h" #include "third_party/blink/renderer/core/dom/events/event_dispatcher.h"
...@@ -82,9 +83,10 @@ class CORE_EXPORT CanvasRenderingContextHost : public CanvasResourceHost, ...@@ -82,9 +83,10 @@ class CORE_EXPORT CanvasRenderingContextHost : public CanvasResourceHost,
// For deferred canvases this will have the side effect of drawing recorded // For deferred canvases this will have the side effect of drawing recorded
// commands in order to finalize the frame. // commands in order to finalize the frame.
virtual ScriptPromise convertToBlob(ScriptState*, ScriptPromise convertToBlob(ScriptState*,
const ImageEncodeOptions*, const ImageEncodeOptions*,
ExceptionState&); ExceptionState&,
const CanvasRenderingContext* const context);
bool IsPaintable() const; bool IsPaintable() const;
...@@ -119,6 +121,12 @@ class CORE_EXPORT CanvasRenderingContextHost : public CanvasResourceHost, ...@@ -119,6 +121,12 @@ class CORE_EXPORT CanvasRenderingContextHost : public CanvasResourceHost,
void CreateCanvasResourceProvider2D(RasterModeHint hint); void CreateCanvasResourceProvider2D(RasterModeHint hint);
void CreateCanvasResourceProvider3D(); void CreateCanvasResourceProvider3D();
// Computes the digest that corresponds to the "input" of this canvas,
// including the context type, and if applicable, canvas digest, and taint
// bits.
IdentifiableToken IdentifiabilityInputDigest(
const CanvasRenderingContext* const context) const;
bool did_fail_to_create_resource_provider_ = false; bool did_fail_to_create_resource_provider_ = false;
bool did_record_canvas_size_to_uma_ = false; bool did_record_canvas_size_to_uma_ = false;
HostType host_type_ = kNone; HostType host_type_ = kNone;
......
...@@ -270,42 +270,10 @@ void HTMLCanvasElement::IdentifiabilityReportWithDigest( ...@@ -270,42 +270,10 @@ void HTMLCanvasElement::IdentifiabilityReportWithDigest(
IdentifiableToken canvas_contents_token) const { IdentifiableToken canvas_contents_token) const {
if (IdentifiabilityStudySettings::Get()->IsTypeAllowed( if (IdentifiabilityStudySettings::Get()->IsTypeAllowed(
blink::IdentifiableSurface::Type::kCanvasReadback)) { blink::IdentifiableSurface::Type::kCanvasReadback)) {
const uint64_t context_digest =
context_ ? context_->IdentifiableTextToken().ToUkmMetricValue() : 0;
const IdentifiabilityPaintOpDigest* const identifiability_paintop_digest =
ResourceProvider()
? &(ResourceProvider()->GetIdentifiablityPaintOpDigest())
: nullptr;
const uint64_t canvas_digest =
identifiability_paintop_digest
? identifiability_paintop_digest->GetToken().ToUkmMetricValue()
: 0;
const uint64_t context_type =
context_ ? context_->GetContextType()
: CanvasRenderingContext::kContextTypeUnknown;
const bool encountered_skipped_ops =
(context_ && context_->IdentifiabilityEncounteredSkippedOps()) ||
(identifiability_paintop_digest &&
identifiability_paintop_digest->encountered_skipped_ops());
const bool encountered_sensitive_ops =
context_ && context_->IdentifiabilityEncounteredSensitiveOps();
const bool encountered_partially_digested_image =
identifiability_paintop_digest &&
identifiability_paintop_digest->encountered_partially_digested_image();
// Bits [0-3] are the context type, bits [4-6] are skipped ops, sensitive
// ops, and partial image ops bits, respectively. The remaining bits are
// for the canvas digest.
uint64_t final_digest =
((context_digest ^ canvas_digest) << 7) | context_type;
if (encountered_skipped_ops)
final_digest |= IdentifiableSurface::CanvasTaintBit::kSkipped;
if (encountered_sensitive_ops)
final_digest |= IdentifiableSurface::CanvasTaintBit::kSensitive;
if (encountered_partially_digested_image)
final_digest |= IdentifiableSurface::CanvasTaintBit::kPartiallyDigested;
RecordIdentifiabilityMetric( RecordIdentifiabilityMetric(
blink::IdentifiableSurface::FromTypeAndToken( blink::IdentifiableSurface::FromTypeAndToken(
blink::IdentifiableSurface::Type::kCanvasReadback, final_digest), blink::IdentifiableSurface::Type::kCanvasReadback,
IdentifiabilityInputDigest(context_)),
canvas_contents_token.ToUkmMetricValue()); canvas_contents_token.ToUkmMetricValue());
} }
} }
...@@ -432,7 +400,7 @@ ScriptPromise HTMLCanvasElement::convertToBlob( ...@@ -432,7 +400,7 @@ ScriptPromise HTMLCanvasElement::convertToBlob(
const ImageEncodeOptions* options, const ImageEncodeOptions* options,
ExceptionState& exception_state) { ExceptionState& exception_state) {
return CanvasRenderingContextHost::convertToBlob(script_state, options, return CanvasRenderingContextHost::convertToBlob(script_state, options,
exception_state); exception_state, context_);
} }
bool HTMLCanvasElement::ShouldBeDirectComposited() const { bool HTMLCanvasElement::ShouldBeDirectComposited() const {
...@@ -1068,13 +1036,13 @@ void HTMLCanvasElement::toBlob(V8BlobCallback* callback, ...@@ -1068,13 +1036,13 @@ void HTMLCanvasElement::toBlob(V8BlobCallback* callback,
image_bitmap, options, image_bitmap, options,
CanvasAsyncBlobCreator::kHTMLCanvasToBlobCallback, callback, start_time, CanvasAsyncBlobCreator::kHTMLCanvasToBlobCallback, callback, start_time,
GetExecutionContext(), GetExecutionContext(),
UkmParameters{GetDocument().UkmRecorder(), UkmParameters{GetDocument().UkmRecorder(), GetDocument().UkmSourceID()},
GetDocument().UkmSourceID()}); IdentifiabilityStudySettings::Get()->IsTypeAllowed(
IdentifiableSurface::Type::kCanvasReadback)
? IdentifiabilityInputDigest(context_)
: 0);
} }
// TODO(crbug.com/973801): Report real digest for toBlob().
IdentifiabilityReportWithDigest(IdentifiableToken(0));
if (async_creator) { if (async_creator) {
async_creator->ScheduleAsyncBlobCreation(quality); async_creator->ScheduleAsyncBlobCreation(quality);
} else { } else {
......
...@@ -292,7 +292,7 @@ class CORE_EXPORT HTMLCanvasElement final ...@@ -292,7 +292,7 @@ class CORE_EXPORT HTMLCanvasElement final
ScriptPromise convertToBlob(ScriptState*, ScriptPromise convertToBlob(ScriptState*,
const ImageEncodeOptions*, const ImageEncodeOptions*,
ExceptionState&) override; ExceptionState&);
bool NeedsUnbufferedInputEvents() const { return needs_unbuffered_input_; } bool NeedsUnbufferedInputEvents() const { return needs_unbuffered_input_; }
......
...@@ -179,6 +179,13 @@ void OffscreenCanvas::SetSize(const IntSize& size) { ...@@ -179,6 +179,13 @@ void OffscreenCanvas::SetSize(const IntSize& size) {
} }
} }
ScriptPromise OffscreenCanvas::convertToBlob(ScriptState* script_state,
const ImageEncodeOptions* options,
ExceptionState& exception_state) {
return CanvasRenderingContextHost::convertToBlob(script_state, options,
exception_state, context_);
}
void OffscreenCanvas::RecordTransfer() { void OffscreenCanvas::RecordTransfer() {
UMA_HISTOGRAM_BOOLEAN("Blink.OffscreenCanvas.Transferred", true); UMA_HISTOGRAM_BOOLEAN("Blink.OffscreenCanvas.Transferred", true);
} }
......
...@@ -69,6 +69,10 @@ class CORE_EXPORT OffscreenCanvas final ...@@ -69,6 +69,10 @@ class CORE_EXPORT OffscreenCanvas final
// API Methods // API Methods
ImageBitmap* transferToImageBitmap(ScriptState*, ExceptionState&); ImageBitmap* transferToImageBitmap(ScriptState*, ExceptionState&);
ScriptPromise convertToBlob(ScriptState* script_state,
const ImageEncodeOptions* options,
ExceptionState& exception_state);
const IntSize& Size() const override { return size_; } const IntSize& Size() const override { return size_; }
void SetSize(const IntSize&); void SetSize(const IntSize&);
void RecordTransfer(); void RecordTransfer();
......
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