Commit d48ffa42 authored by Jonathan Freed's avatar Jonathan Freed Committed by Commit Bot

Add support for prefetching images for the Feed.

Bug: 1128169
Change-Id: I423085d3d3f6ac7b9434b72cb794cd7f19ace64e
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2531365
Commit-Queue: Jonathan Freed <freedjm@chromium.org>
Reviewed-by: default avatarDan H <harringtond@chromium.org>
Reviewed-by: default avatarJustin DeWitt <dewittj@chromium.org>
Cr-Commit-Position: refs/heads/master@{#827127}
parent 9ee91a20
// Copyright 2020 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.
package org.chromium.chrome.browser.feed.v2;
import android.content.Context;
import org.chromium.base.BundleUtils;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.ThreadUtils;
import org.chromium.base.task.PostTask;
import org.chromium.base.task.TaskTraits;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.signin.IdentityServicesProvider;
import org.chromium.chrome.browser.xsurface.ImageFetchClient;
import org.chromium.chrome.browser.xsurface.ProcessScopeDependencyProvider;
import org.chromium.components.signin.base.CoreAccountInfo;
import org.chromium.components.signin.identitymanager.ConsentLevel;
import org.chromium.content_public.browser.UiThreadTaskTraits;
/**
* Provides logging and context for all surfaces.
*/
public class FeedProcessScopeDependencyProvider implements ProcessScopeDependencyProvider {
private static final String FEED_SPLIT_NAME = "feedv2";
private Context mContext;
private ImageFetchClient mImageFetchClient;
private LibraryResolver mLibraryResolver;
FeedProcessScopeDependencyProvider() {
mContext = createFeedContext(ContextUtils.getApplicationContext());
mImageFetchClient = new FeedImageFetchClient();
if (BundleUtils.isIsolatedSplitInstalled(mContext, FEED_SPLIT_NAME)) {
mLibraryResolver = (libName) -> {
return BundleUtils.getNativeLibraryPath(libName);
};
}
}
@Override
public Context getContext() {
return mContext;
}
@Deprecated
@Override
public String getAccountName() {
assert ThreadUtils.runningOnUiThread();
CoreAccountInfo primaryAccount =
IdentityServicesProvider.get()
.getIdentityManager(Profile.getLastUsedRegularProfile())
.getPrimaryAccountInfo(ConsentLevel.NOT_REQUIRED);
return (primaryAccount == null) ? "" : primaryAccount.getEmail();
}
@Deprecated
@Override
public int[] getExperimentIds() {
// Note: this is thread-safe.
return FeedStreamSurfaceJni.get().getExperimentIds();
}
@Deprecated
@Override
public String getClientInstanceId() {
assert ThreadUtils.runningOnUiThread();
return FeedServiceBridge.getClientInstanceId();
}
@Override
public ImageFetchClient getImageFetchClient() {
return mImageFetchClient;
}
@Override
public void logError(String tag, String format, Object... args) {
Log.e(tag, format, args);
}
@Override
public void logWarning(String tag, String format, Object... args) {
Log.w(tag, format, args);
}
@Override
public void postTask(int taskType, Runnable task, long delayMs) {
TaskTraits traits;
switch (taskType) {
case ProcessScopeDependencyProvider.TASK_TYPE_UI_THREAD:
traits = UiThreadTaskTraits.DEFAULT;
break;
case ProcessScopeDependencyProvider.TASK_TYPE_BACKGROUND_MAY_BLOCK:
traits = TaskTraits.BEST_EFFORT_MAY_BLOCK;
break;
default:
assert false : "Invalid task type";
return;
}
PostTask.postDelayedTask(traits, task, delayMs);
}
@Override
public LibraryResolver getLibraryResolver() {
return mLibraryResolver;
}
public static Context createFeedContext(Context context) {
if (!BundleUtils.isIsolatedSplitInstalled(context, FEED_SPLIT_NAME)) {
return context;
}
return BundleUtils.createIsolatedSplitContext(context, FEED_SPLIT_NAME);
}
}
......@@ -10,13 +10,24 @@ import org.chromium.base.ContextUtils;
import org.chromium.base.annotations.CalledByNative;
import org.chromium.base.annotations.JNINamespace;
import org.chromium.base.annotations.NativeMethods;
import org.chromium.chrome.browser.AppHooks;
import org.chromium.chrome.browser.feed.library.common.locale.LocaleUtils;
import org.chromium.chrome.browser.xsurface.ProcessScope;
/**
* Bridge for FeedService-related calls.
*/
@JNINamespace("feed")
public final class FeedServiceBridge {
private static ProcessScope sXSurfaceProcessScope;
public static ProcessScope xSurfaceProcessScope() {
if (sXSurfaceProcessScope == null) {
sXSurfaceProcessScope = AppHooks.get().getExternalSurfaceProcessScope(
new FeedProcessScopeDependencyProvider());
}
return sXSurfaceProcessScope;
}
public static boolean isEnabled() {
return FeedServiceBridgeJni.get().isEnabled();
}
......@@ -39,6 +50,11 @@ public final class FeedServiceBridge {
FeedStreamSurface.clearAll();
}
@CalledByNative
public static void prefetchImage(String url) {
xSurfaceProcessScope().provideImagePrefetcher().prefetchImage(url);
}
/** Called at startup to trigger creation of |FeedService|. */
public static void startup() {
FeedServiceBridgeJni.get().startup();
......
......@@ -19,9 +19,7 @@ import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.ItemAnimator.ItemAnimatorFinishedListener;
import org.chromium.base.BundleUtils;
import org.chromium.base.Callback;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.ObserverList;
import org.chromium.base.ThreadUtils;
......@@ -30,7 +28,6 @@ import org.chromium.base.annotations.JNINamespace;
import org.chromium.base.annotations.NativeMethods;
import org.chromium.base.supplier.Supplier;
import org.chromium.base.task.PostTask;
import org.chromium.base.task.TaskTraits;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.AppHooks;
import org.chromium.chrome.browser.base.SplitCompatUtils;
......@@ -52,9 +49,7 @@ import org.chromium.chrome.browser.ui.messages.snackbar.Snackbar;
import org.chromium.chrome.browser.ui.messages.snackbar.SnackbarManager;
import org.chromium.chrome.browser.xsurface.FeedActionsHandler;
import org.chromium.chrome.browser.xsurface.HybridListRenderer;
import org.chromium.chrome.browser.xsurface.ImageFetchClient;
import org.chromium.chrome.browser.xsurface.ProcessScope;
import org.chromium.chrome.browser.xsurface.ProcessScopeDependencyProvider;
import org.chromium.chrome.browser.xsurface.SurfaceActionsHandler;
import org.chromium.chrome.browser.xsurface.SurfaceScope;
import org.chromium.chrome.browser.xsurface.SurfaceScopeDependencyProvider;
......@@ -240,95 +235,6 @@ public class FeedStreamSurface implements SurfaceActionsHandler, FeedActionsHand
}
}
/**
* Provides logging and context for all surfaces.
*
* TODO(rogerm): Find a more global home for this.
*/
private static class FeedProcessScopeDependencyProvider
implements ProcessScopeDependencyProvider {
private Context mContext;
private ImageFetchClient mImageFetchClient;
private LibraryResolver mLibraryResolver;
FeedProcessScopeDependencyProvider() {
mContext = createFeedContext(ContextUtils.getApplicationContext());
mImageFetchClient = new FeedImageFetchClient();
if (BundleUtils.isIsolatedSplitInstalled(mContext, FEED_SPLIT_NAME)) {
mLibraryResolver = (libName) -> {
return BundleUtils.getNativeLibraryPath(libName);
};
}
}
@Override
public Context getContext() {
return mContext;
}
@Deprecated
@Override
public String getAccountName() {
assert ThreadUtils.runningOnUiThread();
CoreAccountInfo primaryAccount =
IdentityServicesProvider.get()
.getIdentityManager(Profile.getLastUsedRegularProfile())
.getPrimaryAccountInfo(ConsentLevel.NOT_REQUIRED);
return (primaryAccount == null) ? "" : primaryAccount.getEmail();
}
@Deprecated
@Override
public int[] getExperimentIds() {
// Note: this is thread-safe.
return FeedStreamSurfaceJni.get().getExperimentIds();
}
@Deprecated
@Override
public String getClientInstanceId() {
assert ThreadUtils.runningOnUiThread();
return FeedServiceBridge.getClientInstanceId();
}
@Override
public ImageFetchClient getImageFetchClient() {
return mImageFetchClient;
}
@Override
public void logError(String tag, String format, Object... args) {
Log.e(tag, format, args);
}
@Override
public void logWarning(String tag, String format, Object... args) {
Log.w(tag, format, args);
}
@Override
public void postTask(int taskType, Runnable task, long delayMs) {
TaskTraits traits;
switch (taskType) {
case ProcessScopeDependencyProvider.TASK_TYPE_UI_THREAD:
traits = UiThreadTaskTraits.DEFAULT;
break;
case ProcessScopeDependencyProvider.TASK_TYPE_BACKGROUND_MAY_BLOCK:
traits = TaskTraits.BEST_EFFORT_MAY_BLOCK;
break;
default:
assert false : "Invalid task type";
return;
}
PostTask.postDelayedTask(traits, task, delayMs);
}
@Override
public LibraryResolver getLibraryResolver() {
return mLibraryResolver;
}
}
/**
* Provides activity and darkmode context for a single surface.
*/
......@@ -337,7 +243,8 @@ public class FeedStreamSurface implements SurfaceActionsHandler, FeedActionsHand
final boolean mDarkMode;
FeedSurfaceScopeDependencyProvider(Context activityContext, boolean darkMode) {
mActivityContext = createFeedContext(activityContext);
mActivityContext =
FeedProcessScopeDependencyProvider.createFeedContext(activityContext);
mDarkMode = darkMode;
}
......
......@@ -47,6 +47,7 @@ feed_java_sources = [
"//chrome/android/feed/core/java/src/org/chromium/chrome/browser/feed/v2/CardMenuBottomSheetContent.java",
"//chrome/android/feed/core/java/src/org/chromium/chrome/browser/feed/v2/FeedImageFetchClient.java",
"//chrome/android/feed/core/java/src/org/chromium/chrome/browser/feed/v2/FeedListContentManager.java",
"//chrome/android/feed/core/java/src/org/chromium/chrome/browser/feed/v2/FeedProcessScopeDependencyProvider.java",
"//chrome/android/feed/core/java/src/org/chromium/chrome/browser/feed/v2/FeedServiceBridge.java",
"//chrome/android/feed/core/java/src/org/chromium/chrome/browser/feed/v2/FeedSliceViewTracker.java",
"//chrome/android/feed/core/java/src/org/chromium/chrome/browser/feed/v2/FeedStream.java",
......
......@@ -92,4 +92,10 @@ bool FeedServiceBridge::IsEnabled() {
return FeedService::IsEnabled(*profile->GetPrefs());
}
void FeedServiceBridge::PrefetchImage(const GURL& url) {
JNIEnv* env = base::android::AttachCurrentThread();
Java_FeedServiceBridge_prefetchImage(
env, base::android::ConvertUTF8ToJavaString(env, url.spec()));
}
} // namespace feed
......@@ -18,6 +18,7 @@ class FeedServiceBridge {
static DisplayMetrics GetDisplayMetrics();
static void ClearAll();
static bool IsEnabled();
static void PrefetchImage(const GURL& url);
};
} // namespace feed
......
......@@ -46,6 +46,9 @@ class FeedServiceDelegateImpl : public FeedService::Delegate {
return FeedServiceBridge::GetDisplayMetrics();
}
void ClearAll() override { FeedServiceBridge::ClearAll(); }
void PrefetchImage(const GURL& url) override {
FeedServiceBridge::PrefetchImage(url);
}
};
} // namespace
......
......@@ -9,6 +9,7 @@ android_library("java") {
"android/java/src/org/chromium/chrome/browser/xsurface/FeedActionsHandler.java",
"android/java/src/org/chromium/chrome/browser/xsurface/HybridListRenderer.java",
"android/java/src/org/chromium/chrome/browser/xsurface/ImageFetchClient.java",
"android/java/src/org/chromium/chrome/browser/xsurface/ImagePrefetcher.java",
"android/java/src/org/chromium/chrome/browser/xsurface/ListContentManager.java",
"android/java/src/org/chromium/chrome/browser/xsurface/ListContentManagerObserver.java",
"android/java/src/org/chromium/chrome/browser/xsurface/ProcessScope.java",
......
// Copyright 2020 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.
package org.chromium.chrome.browser.xsurface;
/**
* Interface to prefetch an image and cache it on disk. This
* allows native code to call to the image loader across the
* xsurface.
*/
public interface ImagePrefetcher {
default void prefetchImage(String url) {}
}
......@@ -34,4 +34,9 @@ public interface ProcessScope {
default SurfaceScope obtainSurfaceScope(Context activityContext) {
return null;
}
@Nullable
default ImagePrefetcher provideImagePrefetcher() {
return null;
}
}
......@@ -65,6 +65,8 @@ source_set("feed_core_v2") {
"tasks/load_stream_from_store_task.h",
"tasks/load_stream_task.cc",
"tasks/load_stream_task.h",
"tasks/prefetch_images_task.cc",
"tasks/prefetch_images_task.h",
"tasks/upload_actions_task.cc",
"tasks/upload_actions_task.h",
"tasks/wait_for_store_initialize_task.cc",
......
......@@ -86,6 +86,11 @@ void OverrideWithFinch(Config* config) {
kInterestFeedV2, "session_id_max_age_days",
config->session_id_max_age.InDays()));
config->max_prefetch_image_requests_per_refresh =
base::GetFieldTrialParamByFeatureAsInt(
kInterestFeedV2, "max_prefetch_image_requests_per_refresh",
config->max_prefetch_image_requests_per_refresh);
// Erase any capabilities with "enable_CAPABILITY = false" set.
base::EraseIf(config->experimental_capabilities, CapabilityDisabled);
}
......
......@@ -42,6 +42,8 @@ struct Config {
bool send_signed_out_session_logs = true;
// The max age of a signed-out session token.
base::TimeDelta session_id_max_age = base::TimeDelta::FromDays(30);
// Maximum number of images prefetched per refresh.
int max_prefetch_image_requests_per_refresh = 50;
// Set of optional capabilities included in requests. See
// CreateFeedQueryRequest() for required capabilities.
base::flat_set<feedwire::Capability> experimental_capabilities = {
......
......@@ -34,6 +34,7 @@
#include "components/feed/core/v2/tasks/clear_all_task.h"
#include "components/feed/core/v2/tasks/get_prefetch_suggestions_task.h"
#include "components/feed/core/v2/tasks/load_stream_task.h"
#include "components/feed/core/v2/tasks/prefetch_images_task.h"
#include "components/feed/core/v2/tasks/upload_actions_task.h"
#include "components/feed/core/v2/tasks/wait_for_store_initialize_task.h"
#include "components/feed/feed_feature_list.h"
......@@ -278,6 +279,10 @@ std::string FeedStream::GetSessionId() const {
return GetMetadata()->GetSessionIdToken();
}
void FeedStream::PrefetchImage(const GURL& url) {
delegate_->PrefetchImage(url);
}
void FeedStream::AttachSurface(SurfaceInterface* surface) {
metrics_reporter_->SurfaceOpened(surface->GetSurfaceId());
......@@ -703,6 +708,10 @@ void FeedStream::BackgroundRefreshComplete(LoadStreamTask::Result result) {
if (result.loaded_new_content_from_network && prefetch_service_)
prefetch_service_->NewSuggestionsAvailable();
// Add prefetch images to task queue without waiting to finish
// since we treat them as best-effort.
task_queue_.AddTask(std::make_unique<PrefetchImagesTask>(this));
refresh_task_scheduler_->RefreshTaskComplete();
}
......
......@@ -70,6 +70,7 @@ class FeedStream : public FeedStreamApi,
virtual std::string GetLanguageTag() = 0;
virtual void ClearAll() = 0;
virtual bool IsSignedIn() = 0;
virtual void PrefetchImage(const GURL& url) = 0;
};
// Forwards to |feed::TranslateWireResponse()| by default. Can be overridden
......@@ -225,6 +226,8 @@ class FeedStream : public FeedStreamApi,
const Metadata* GetMetadata() const { return &metadata_; }
MetricsReporter* GetMetricsReporter() const { return metrics_reporter_; }
void PrefetchImage(const GURL& url);
// Returns the time of the last content fetch.
base::Time GetLastFetchTime();
......
......@@ -602,6 +602,10 @@ class FeedStreamTest : public testing::Test, public FeedStream::Delegate {
std::string GetLanguageTag() override { return "en-US"; }
void ClearAll() override {}
bool IsSignedIn() override { return is_signed_in_; }
void PrefetchImage(const GURL& url) override {
prefetched_images_.push_back(url);
prefetch_image_call_count_++;
}
// For tests.
......@@ -692,6 +696,8 @@ class FeedStreamTest : public testing::Test, public FeedStream::Delegate {
bool is_offline_ = false;
bool is_signed_in_ = true;
base::test::ScopedFeatureList scoped_feature_list_;
int prefetch_image_call_count_ = 0;
std::vector<GURL> prefetched_images_;
};
class FeedStreamConditionalActionsUploadTest : public FeedStreamTest {
......@@ -744,6 +750,21 @@ TEST_F(FeedStreamTest, BackgroundRefreshSuccess) {
EXPECT_EQ(1, prefetch_service_.NewSuggestionsAvailableCallCount());
}
TEST_F(FeedStreamTest, BackgroundRefreshPrefetchesImages) {
// Trigger a background refresh.
response_translator_.InjectResponse(MakeTypicalInitialModelState());
stream_->ExecuteRefreshTask();
EXPECT_EQ(0, prefetch_image_call_count_);
WaitForIdleTaskQueue();
std::vector<GURL> expected_fetches(
{GURL("http://image0/"), GURL("http://favicon0/"), GURL("http://image1/"),
GURL("http://favicon1/")});
// Verify that images were prefetched.
EXPECT_EQ(4, prefetch_image_call_count_);
EXPECT_EQ(expected_fetches, prefetched_images_);
}
TEST_F(FeedStreamTest, BackgroundRefreshNotAttemptedWhenModelIsLoading) {
response_translator_.InjectResponse(MakeTypicalInitialModelState());
TestSurface surface(stream_.get());
......
......@@ -121,6 +121,9 @@ class FeedService::StreamDelegateImpl : public FeedStream::Delegate {
return service_delegate_->GetLanguageTag();
}
void ClearAll() override { service_delegate_->ClearAll(); }
void PrefetchImage(const GURL& url) override {
service_delegate_->PrefetchImage(url);
}
bool IsSignedIn() override { return identity_manager_->HasPrimaryAccount(); }
private:
......
......@@ -67,6 +67,8 @@ class FeedService : public KeyedService {
virtual DisplayMetrics GetDisplayMetrics() = 0;
// Clear all stored data.
virtual void ClearAll() = 0;
// Fetch the image and store it in the disk cache.
virtual void PrefetchImage(const GURL& url) = 0;
};
// Construct a FeedService given an already constructed FeedStream.
......
// Copyright 2020 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/v2/tasks/prefetch_images_task.h"
#include <utility>
#include "base/callback.h"
#include "base/logging.h"
#include "components/feed/core/proto/v2/wire/stream_structure.pb.h"
#include "components/feed/core/v2/config.h"
#include "components/feed/core/v2/feed_store.h"
#include "components/feed/core/v2/feed_stream.h"
#include "components/feed/core/v2/stream_model.h"
#include "components/feed/core/v2/tasks/load_stream_from_store_task.h"
namespace feed {
namespace {
// Converts a URL string into a GURL. If the string is not a valid URL, returns
// an empty GURL. Since GURL::spec() asserts on invalid URLs, this is necessary
// to scrub the incoming data from the wire.
GURL SpecToGURL(const std::string& url_string) {
GURL url(url_string);
if (!url.is_valid())
url = GURL();
return url;
}
} // namespace
PrefetchImagesTask::PrefetchImagesTask(FeedStream* stream) : stream_(stream) {
max_images_per_refresh_ =
GetFeedConfig().max_prefetch_image_requests_per_refresh;
}
PrefetchImagesTask::~PrefetchImagesTask() = default;
void PrefetchImagesTask::Run() {
if (stream_->GetModel()) {
PrefetchImagesFromModel(*stream_->GetModel());
return;
}
load_from_store_task_ = std::make_unique<LoadStreamFromStoreTask>(
LoadStreamFromStoreTask::LoadType::kFullLoad, stream_->GetStore(),
stream_->GetClock(),
base::BindOnce(&PrefetchImagesTask::LoadStreamComplete,
base::Unretained(this)));
load_from_store_task_->Execute(base::DoNothing());
}
void PrefetchImagesTask::LoadStreamComplete(
LoadStreamFromStoreTask::Result result) {
if (!result.update_request) {
TaskComplete();
return;
}
// It is a bit dangerous to retain the model loaded here. The normal
// LoadStreamTask flow has various considerations for metrics and signalling
// surfaces to update. For this reason, we're not going to retain the loaded
// model for use outside of this task.
StreamModel model;
model.Update(std::move(result.update_request));
PrefetchImagesFromModel(model);
}
void PrefetchImagesTask::PrefetchImagesFromModel(const StreamModel& model) {
for (ContentRevision rev : model.GetContentList()) {
const feedstore::Content* content = model.FindContent(rev);
if (!content)
continue;
for (const feedwire::PrefetchMetadata& metadata :
content->prefetch_metadata()) {
MaybePrefetchImage(SpecToGURL(metadata.image_url()));
MaybePrefetchImage(SpecToGURL(metadata.favicon_url()));
for (const std::string& url : metadata.additional_image_urls()) {
MaybePrefetchImage(SpecToGURL(url));
}
}
}
TaskComplete();
}
void PrefetchImagesTask::MaybePrefetchImage(const GURL& gurl) {
// If we've already fetched this url, or we've hit the max number of fetches,
// then don't send a fetch request.
if ((previously_fetched_.find(gurl.spec()) != previously_fetched_.end()) ||
previously_fetched_.size() >=
static_cast<size_t>(max_images_per_refresh_))
return;
previously_fetched_.insert(gurl.spec());
stream_->PrefetchImage(gurl);
}
} // namespace feed
// Copyright 2020 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_V2_TASKS_PREFETCH_IMAGES_TASK_H_
#define COMPONENTS_FEED_CORE_V2_TASKS_PREFETCH_IMAGES_TASK_H_
#include <memory>
#include <vector>
#include "base/memory/weak_ptr.h"
#include "components/feed/core/v2/tasks/load_stream_from_store_task.h"
#include "components/offline_pages/task/task.h"
namespace feed {
class FeedStream;
class StreamModel;
// Prefetch the images in the model.
class PrefetchImagesTask : public offline_pages::Task {
public:
explicit PrefetchImagesTask(FeedStream* stream);
~PrefetchImagesTask() override;
PrefetchImagesTask(const PrefetchImagesTask&) = delete;
PrefetchImagesTask& operator=(const PrefetchImagesTask&) = delete;
private:
base::WeakPtr<PrefetchImagesTask> GetWeakPtr() {
return weak_ptr_factory_.GetWeakPtr();
}
// offline_pages::Task.
void Run() override;
void LoadStreamComplete(LoadStreamFromStoreTask::Result result);
void PrefetchImagesFromModel(const StreamModel& model);
void MaybePrefetchImage(const GURL& gurl);
FeedStream* stream_;
std::unordered_set<std::string> previously_fetched_;
unsigned long max_images_per_refresh_;
std::unique_ptr<LoadStreamFromStoreTask> load_from_store_task_;
base::WeakPtrFactory<PrefetchImagesTask> weak_ptr_factory_{this};
};
} // namespace feed
#endif // COMPONENTS_FEED_CORE_V2_TASKS_PREFETCH_IMAGES_TASK_H_
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