Commit 6474dff1 authored by David Maunder's avatar David Maunder Committed by Commit Bot

Add ability to re-acquire ShoppingPersistedTabData after timeout

We want to be able to identify and display price updates
in the Tab Grid Switcher. This change to the infrastructure
will provide us with the capability to do that.

Bug: 1139459
Change-Id: I7588b377bc1fd1532a67373835c4204201723fdf
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2481010Reviewed-by: default avatarTommy Nyquist <nyquist@chromium.org>
Reviewed-by: default avatarYusuf Ozuysal <yusufo@chromium.org>
Commit-Queue: David Maunder <davidjm@chromium.org>
Cr-Commit-Position: refs/heads/master@{#821019}
parent 4da6ac01
......@@ -1023,6 +1023,7 @@ android_library("chrome_test_java") {
"//chrome/browser/contextmenu:java",
"//chrome/browser/device:java",
"//chrome/browser/download/android:java",
"//chrome/browser/endpoint_fetcher:java",
"//chrome/browser/engagement/android:java",
"//chrome/browser/enterprise/util:java",
"//chrome/browser/flags:java",
......
......@@ -11,6 +11,7 @@ include_rules = [
"+chrome/browser/tabmodel/android/java",
"+chrome/browser/tabpersistence/android/java",
"+chrome/browser/thumbnail/generator/android/java",
"+chrome/browser/endpoint_fetcher",
"+chrome/browser/ui/android/appmenu",
"-chrome/browser/ui/android/appmenu/internal",
"+chrome/browser/ui/messages/android/java",
......
......@@ -4,18 +4,41 @@
package org.chromium.chrome.browser.tab.state;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import android.support.test.filters.SmallTest;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.stubbing.Answer;
import org.chromium.base.Callback;
import org.chromium.base.ThreadUtils;
import org.chromium.base.test.BaseJUnit4ClassRunner;
import org.chromium.base.test.util.JniMocker;
import org.chromium.chrome.browser.endpoint_fetcher.EndpointFetcher;
import org.chromium.chrome.browser.endpoint_fetcher.EndpointFetcherJni;
import org.chromium.chrome.browser.endpoint_fetcher.EndpointResponse;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.tab.MockTab;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.test.ChromeBrowserTestRule;
import org.chromium.content_public.browser.test.util.TestThreadUtils;
import java.util.Locale;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicReference;
/**
* Test relating to {@link ShoppingPersistedTabData}
......@@ -25,21 +48,203 @@ public class ShoppingPersistedTabDataTest {
@Rule
public final ChromeBrowserTestRule mBrowserTestRule = new ChromeBrowserTestRule();
@Rule
public JniMocker mMocker = new JniMocker();
private static final int TAB_ID = 1;
private static final boolean IS_INCOGNITO = false;
private static final String PRICE_STRING = "$2.87";
@Mock
EndpointFetcher.Natives mEndpointFetcherJniMock;
// Tracks if the endpoint fetcher has been called once or not
private boolean mCalledOnce;
private static final String PRICE = "3.14";
private static final String UPDATED_PRICE = "2.87";
private static final String PRICE_FORMATTED = String.format(Locale.US, "$%s", PRICE);
private static final String UPDATED_PRICE_FORMATTED =
String.format(Locale.US, "$%s", UPDATED_PRICE);
private static final String EMPTY_PRICE = "";
private static final String ENDPOINT_RESPONSE_INITIAL =
"{\"representations\" : [{\"type\" : \"SHOPPING\", \"productTitle\" : \"Book of Pie\","
+ String.format(Locale.US, "\"price\" : %s, \"currency\" : \"USD\"}]}", PRICE);
private static final String ENDPOINT_RESPONSE_UPDATE =
"{\"representations\" : [{\"type\" : \"SHOPPING\", \"productTitle\" : \"Book of Pie\","
+ String.format(Locale.US, "\"price\" : %s, \"currency\" : \"USD\"}]}", UPDATED_PRICE);
@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
mMocker.mock(EndpointFetcherJni.TEST_HOOKS, mEndpointFetcherJniMock);
}
@SmallTest
@Test
public void testShoppingProto() {
ThreadUtils.runOnUiThreadBlocking(() -> {
Tab tab = new MockTab(TAB_ID, IS_INCOGNITO);
ShoppingPersistedTabData shoppingPersistedTabData = new ShoppingPersistedTabData(tab);
shoppingPersistedTabData.setPriceString(PRICE_STRING);
shoppingPersistedTabData.setPriceString(PRICE_STRING, null);
byte[] serialized = shoppingPersistedTabData.serialize();
ShoppingPersistedTabData deserialized = new ShoppingPersistedTabData(tab);
deserialized.deserialize(serialized);
Assert.assertEquals(PRICE_STRING, deserialized.getPriceString());
Assert.assertEquals(EMPTY_PRICE, deserialized.getPreviousPriceString());
});
}
@SmallTest
@Test
public void testShoppingPriceChange() {
shoppingPriceChange(createTabOnUiThread(TAB_ID, IS_INCOGNITO));
}
@SmallTest
@Test
public void testShoppingPriceChangeExtraFetchAfterChange() {
Tab tab = createTabOnUiThread(TAB_ID, IS_INCOGNITO);
long mLastPriceChangeTimeMs = shoppingPriceChange(tab);
final Semaphore updateTtlSemaphore = new Semaphore(0);
// Set TimeToLive such that a refetch will be forced
TestThreadUtils.runOnUiThreadBlocking(() -> {
PersistedTabData.from(tab, ShoppingPersistedTabData.class).setTimeToLiveMs(-1000);
updateTtlSemaphore.release();
});
acquireSemaphore(updateTtlSemaphore);
final Semaphore semaphore = new Semaphore(0);
TestThreadUtils.runOnUiThreadBlocking(() -> {
ShoppingPersistedTabData.from(tab, (shoppingPersistedTabData) -> {
verifyEndpointFetcherCalled(3);
Assert.assertEquals(
UPDATED_PRICE_FORMATTED, shoppingPersistedTabData.getPriceString());
Assert.assertEquals(
PRICE_FORMATTED, shoppingPersistedTabData.getPreviousPriceString());
Assert.assertEquals(mLastPriceChangeTimeMs,
shoppingPersistedTabData.getLastPriceChangeTimeMs());
semaphore.release();
});
});
acquireSemaphore(semaphore);
}
private long shoppingPriceChange(Tab tab) {
final Semaphore initialSemaphore = new Semaphore(0);
final Semaphore updateSemaphore = new Semaphore(0);
mockEndpointResponse();
TestThreadUtils.runOnUiThreadBlocking(() -> {
ShoppingPersistedTabData.from(tab, (shoppingPersistedTabData) -> {
verifyEndpointFetcherCalled(1);
Assert.assertEquals(PRICE_FORMATTED, shoppingPersistedTabData.getPriceString());
Assert.assertEquals(EMPTY_PRICE, shoppingPersistedTabData.getPreviousPriceString());
Assert.assertEquals(ShoppingPersistedTabData.NO_TRANSITIONS_OCCURRED,
shoppingPersistedTabData.getLastPriceChangeTimeMs());
// By setting time to live to be a negative number, an update
// will be forced in the subsequent call
shoppingPersistedTabData.setTimeToLiveMs(-1000);
initialSemaphore.release();
});
});
acquireSemaphore(initialSemaphore);
long firstUpdateTime = getTimeLastUpdatedOnUiThread(tab);
TestThreadUtils.runOnUiThreadBlocking(() -> {
ShoppingPersistedTabData.from(tab, (updatedShoppingPersistedTabData) -> {
verifyEndpointFetcherCalled(2);
Assert.assertEquals(
UPDATED_PRICE_FORMATTED, updatedShoppingPersistedTabData.getPriceString());
Assert.assertEquals(
PRICE_FORMATTED, updatedShoppingPersistedTabData.getPreviousPriceString());
Assert.assertTrue(firstUpdateTime
< updatedShoppingPersistedTabData.getLastPriceChangeTimeMs());
updateSemaphore.release();
});
});
acquireSemaphore(updateSemaphore);
return getTimeLastUpdatedOnUiThread(tab);
}
@SmallTest
@Test
public void testNoRefetch() {
final Semaphore initialSemaphore = new Semaphore(0);
final Semaphore updateSemaphore = new Semaphore(0);
Tab tab = createTabOnUiThread(TAB_ID, IS_INCOGNITO);
mockEndpointResponse();
TestThreadUtils.runOnUiThreadBlocking(() -> {
ShoppingPersistedTabData.from(tab, (shoppingPersistedTabData) -> {
Assert.assertEquals(PRICE_FORMATTED, shoppingPersistedTabData.getPriceString());
Assert.assertEquals(EMPTY_PRICE, shoppingPersistedTabData.getPreviousPriceString());
// By setting time to live to be a negative number, an update
// will be forced in the subsequent call
initialSemaphore.release();
});
});
acquireSemaphore(initialSemaphore);
verifyEndpointFetcherCalled(1);
TestThreadUtils.runOnUiThreadBlocking(() -> {
ShoppingPersistedTabData.from(tab, (shoppingPersistedTabData) -> {
Assert.assertEquals(PRICE_FORMATTED, shoppingPersistedTabData.getPriceString());
Assert.assertEquals(EMPTY_PRICE, shoppingPersistedTabData.getPreviousPriceString());
// By setting time to live to be a negative number, an update
// will be forced in the subsequent call
updateSemaphore.release();
});
});
acquireSemaphore(updateSemaphore);
// EndpointFetcher should not have been called a second time - because we haven't passed the
// time to live
verifyEndpointFetcherCalled(1);
}
private void verifyEndpointFetcherCalled(int numTimes) {
verify(mEndpointFetcherJniMock, times(numTimes))
.nativeFetchOAuth(any(Profile.class), anyString(), anyString(), anyString(),
anyString(), any(String[].class), anyString(), anyLong(),
any(Callback.class));
}
private static Tab createTabOnUiThread(int tabId, boolean isIncognito) {
AtomicReference<Tab> res = new AtomicReference<>();
TestThreadUtils.runOnUiThreadBlocking(
() -> { res.set(MockTab.createAndInitialize(TAB_ID, IS_INCOGNITO)); });
return res.get();
}
private static long getTimeLastUpdatedOnUiThread(Tab tab) {
AtomicReference<Long> res = new AtomicReference<>();
TestThreadUtils.runOnUiThreadBlocking(() -> {
res.set(PersistedTabData.from(tab, ShoppingPersistedTabData.class)
.getLastPriceChangeTimeMs());
});
return res.get();
}
private void mockEndpointResponse() {
doAnswer(new Answer<Void>() {
@Override
public Void answer(InvocationOnMock invocation) {
Callback callback = (Callback) invocation.getArguments()[8];
String res = mCalledOnce ? ENDPOINT_RESPONSE_UPDATE : ENDPOINT_RESPONSE_INITIAL;
mCalledOnce = true;
callback.onResult(new EndpointResponse(res));
return null;
}
})
.when(mEndpointFetcherJniMock)
.nativeFetchOAuth(any(Profile.class), anyString(), anyString(), anyString(),
anyString(), any(String[].class), anyString(), anyLong(),
any(Callback.class));
}
private static void acquireSemaphore(Semaphore semaphore) {
try {
semaphore.acquire();
} catch (InterruptedException e) {
// Throw Runtime exception to make catching InterruptedException unnecessary
throw new RuntimeException(e);
}
}
}
include_rules = [
"+chrome/browser/endpoint_fetcher",
]
......@@ -4,6 +4,8 @@
package org.chromium.chrome.browser.tab.state;
import android.os.SystemClock;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
......@@ -36,9 +38,11 @@ import java.util.Map;
public abstract class PersistedTabData implements UserData {
private static final String TAG = "PTD";
private static final Map<String, List<Callback>> sCachedCallbacks = new HashMap<>();
private static final long NEEDS_UPDATE_DISABLED = Long.MAX_VALUE;
protected final Tab mTab;
private final PersistedTabDataStorage mPersistedTabDataStorage;
private final String mPersistedTabDataId;
private long mLastUpdatedMs;
/**
* @param tab {@link Tab} {@link PersistedTabData} is being stored for
......@@ -102,8 +106,19 @@ public abstract class PersistedTabData implements UserData {
// TODO(crbug.com/1059602) cache callbacks
T persistedTabDataFromTab = getUserData(tab, clazz);
if (persistedTabDataFromTab != null) {
if (persistedTabDataFromTab.needsUpdate()) {
supplier.onAvailable((tabData) -> {
updateLastUpdatedMs(tabData);
if (tabData != null) {
setUserData(tab, clazz, tabData);
}
PostTask.runOrPostTask(
UiThreadTaskTraits.DEFAULT, () -> { callback.onResult(tabData); });
});
} else {
PostTask.runOrPostTask(UiThreadTaskTraits.DEFAULT,
() -> { callback.onResult(persistedTabDataFromTab); });
}
return;
}
String key = String.format(Locale.ENGLISH, "%d-%s", tab.getId(), clazz.toString());
......@@ -113,16 +128,41 @@ public abstract class PersistedTabData implements UserData {
PersistedTabDataConfiguration config =
PersistedTabDataConfiguration.get(clazz, tab.isIncognito());
config.storage.restore(tab.getId(), config.id, (data) -> {
T persistedTabData;
if (data == null) {
supplier.onAvailable((ptd) -> { onPersistedTabDataResult(ptd, tab, clazz, key); });
supplier.onAvailable((tabData) -> {
updateLastUpdatedMs(tabData);
onPersistedTabDataResult(tabData, tab, clazz, key);
});
} else {
T persistedTabDataFromStorage = factory.create(data, config.storage, config.id);
if (persistedTabDataFromStorage.needsUpdate()) {
supplier.onAvailable((tabData) -> {
updateLastUpdatedMs(tabData);
onPersistedTabDataResult(tabData, tab, clazz, key);
});
} else {
persistedTabData = factory.create(data, config.storage, config.id);
onPersistedTabDataResult(persistedTabData, tab, clazz, key);
onPersistedTabDataResult(persistedTabDataFromStorage, tab, clazz, key);
}
}
});
}
private static void updateLastUpdatedMs(PersistedTabData persistedTabData) {
if (persistedTabData != null) {
persistedTabData.setLastUpdatedMs(SystemClock.uptimeMillis());
}
}
/**
* @return if the {@link PersistedTabData} should be refetched.
*/
protected boolean needsUpdate() {
if (getTimeToLiveMs() == NEEDS_UPDATE_DISABLED) {
return false;
}
return mLastUpdatedMs + getTimeToLiveMs() < SystemClock.uptimeMillis();
}
private static <T extends PersistedTabData> void onPersistedTabDataResult(
T persistedTabData, Tab tab, Class<T> clazz, String key) {
if (persistedTabData != null) {
......@@ -146,14 +186,22 @@ public abstract class PersistedTabData implements UserData {
*/
protected static <T extends PersistedTabData> T from(
Tab tab, Class<T> userDataKey, Supplier<T> supplier) {
UserDataHost host = tab.getUserDataHost();
T persistedTabData = host.getUserData(userDataKey);
T persistedTabData = from(tab, userDataKey);
if (persistedTabData == null) {
persistedTabData = host.setUserData(userDataKey, supplier.get());
persistedTabData = tab.getUserDataHost().setUserData(userDataKey, supplier.get());
}
return persistedTabData;
}
/**
* Acquire {@link PersistedTabData} from a {@link Tab} using a {@link UserData} key
* @param tab the {@link PersistedTabData} will be acquired from
* @param userDataKey the {@link UserData} object to be acquired from the {@link Tab}
*/
protected static <T extends PersistedTabData> T from(Tab tab, Class<T> userDataKey) {
return tab.getUserDataHost().getUserData(userDataKey);
}
private static <T extends PersistedTabData> void addCallback(String key, Callback<T> callback) {
if (!sCachedCallbacks.containsKey(key)) {
sCachedCallbacks.put(key, new LinkedList<>());
......@@ -242,4 +290,29 @@ public abstract class PersistedTabData implements UserData {
* @return unique tag for logging in Uma
*/
public abstract String getUmaTag();
/**
* @return length of time before data should be refetched from endpoint
* The default value is NEEDS_UPDATE_DISABLED (Long.MAX_VALUE) indicating
* the PersistedTabData will never be refetched. Subclasses can override
* this value if they need to make use of the time to live functionality.
*/
public long getTimeToLiveMs() {
return NEEDS_UPDATE_DISABLED;
}
/**
* Set last time the {@link PersistedTabData} was updated
* @param lastUpdatedMs time last updated in milliseconds
*/
protected void setLastUpdatedMs(long lastUpdatedMs) {
mLastUpdatedMs = lastUpdatedMs;
}
/**
* @return time the {@link PersistedTabDAta} was last updated in milliseconds
*/
protected long getLastUpdatedMs() {
return mLastUpdatedMs;
}
}
......@@ -4,7 +4,11 @@
package org.chromium.chrome.browser.tab.state;
import android.os.SystemClock;
import android.text.TextUtils;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import com.google.protobuf.InvalidProtocolBufferException;
......@@ -21,6 +25,7 @@ import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.proto.ShoppingPersistedTabData.ShoppingPersistedTabDataProto;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
/**
* {@link PersistedTabData} for Shopping related websites
......@@ -43,7 +48,18 @@ public class ShoppingPersistedTabData extends PersistedTabData {
private static final String TYPE_KEY = "type";
private static final String SHOPPING_ID = "SHOPPING";
private String mPriceString;
private static final Class<ShoppingPersistedTabData> USER_DATA_KEY =
ShoppingPersistedTabData.class;
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
public static final long ONE_HOUR_MS = TimeUnit.HOURS.toMillis(1);
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
public static final long NO_TRANSITIONS_OCCURRED = -1;
private long mTimeToLiveMs = ONE_HOUR_MS;
private String mPriceString = "";
private String mPreviousPriceString = "";
public long mLastPriceChangeTimeMs = NO_TRANSITIONS_OCCURRED;
protected ShoppingPersistedTabData(Tab tab) {
super(tab,
......@@ -70,11 +86,14 @@ public class ShoppingPersistedTabData extends PersistedTabData {
new OneshotSupplierImpl<ShoppingPersistedTabData>() {
@Override
public void onAvailable(Callback<ShoppingPersistedTabData> supplierCallback) {
ShoppingPersistedTabData previousShoppingPersistedTabData =
PersistedTabData.from(tab, USER_DATA_KEY);
EndpointFetcher.fetchUsingOAuth(
(endpointResponse)
-> {
supplierCallback.onResult(
build(tab, endpointResponse.getResponseString()));
build(tab, endpointResponse.getResponseString(),
previousShoppingPersistedTabData));
},
Profile.getLastUsedRegularProfile(), OAUTH_NAME,
String.format(Locale.US, ENDPOINT, tab.getUrlString()),
......@@ -84,7 +103,8 @@ public class ShoppingPersistedTabData extends PersistedTabData {
ShoppingPersistedTabData.class, callback);
}
private static ShoppingPersistedTabData build(Tab tab, String responseString) {
private static ShoppingPersistedTabData build(Tab tab, String responseString,
ShoppingPersistedTabData previousShoppingPersistedTabData) {
ShoppingPersistedTabData res = new ShoppingPersistedTabData(tab);
try {
JSONObject jsonObject = new JSONObject(responseString);
......@@ -94,7 +114,8 @@ public class ShoppingPersistedTabData extends PersistedTabData {
// TODO(crbug.com/1130068) support all currencies
if (SHOPPING_ID.equals(representation.getString(TYPE_KEY))) {
res.setPriceString(
String.format(Locale.US, "$%.2f", representation.getDouble(PRICE_KEY)));
String.format(Locale.US, "$%.2f", representation.getDouble(PRICE_KEY)),
previousShoppingPersistedTabData);
break;
}
}
......@@ -112,9 +133,21 @@ public class ShoppingPersistedTabData extends PersistedTabData {
/**
* Set the price string
* @param priceString a string representing the price of the shopping offer
* @param previousShoppingPersistedTabData {@link ShoppingPersistedTabData} from previous fetch
*/
protected void setPriceString(String priceString) {
protected void setPriceString(
String priceString, ShoppingPersistedTabData previousShoppingPersistedTabData) {
mPriceString = priceString;
// Detect price transition
if (previousShoppingPersistedTabData != null && !TextUtils.isEmpty(priceString)
&& !TextUtils.isEmpty(previousShoppingPersistedTabData.getPriceString())
&& !priceString.equals(previousShoppingPersistedTabData.getPriceString())) {
mPreviousPriceString = previousShoppingPersistedTabData.getPriceString();
mLastPriceChangeTimeMs = SystemClock.uptimeMillis();
} else if (previousShoppingPersistedTabData != null) {
mPreviousPriceString = previousShoppingPersistedTabData.getPreviousPriceString();
mLastPriceChangeTimeMs = previousShoppingPersistedTabData.getLastPriceChangeTimeMs();
}
save();
}
......@@ -128,10 +161,22 @@ public class ShoppingPersistedTabData extends PersistedTabData {
return mPriceString;
}
/**
* @return the price string of the {@link ShoppingPersistedTabDta}
* before the refetch occurred because of a timeout. This enables
* the consumer to determine if the price changed during the fetch.
*/
public String getPreviousPriceString() {
return mPreviousPriceString;
}
@Override
public byte[] serialize() {
return ShoppingPersistedTabDataProto.newBuilder()
.setPriceString(mPriceString)
.setPreviousPriceString(mPreviousPriceString)
.setLastUpdatedMs(getLastUpdatedMs())
.setLastPriceChangeTimeMs(mLastPriceChangeTimeMs)
.build()
.toByteArray();
}
......@@ -143,6 +188,9 @@ public class ShoppingPersistedTabData extends PersistedTabData {
ShoppingPersistedTabDataProto shoppingPersistedTabDataProto =
ShoppingPersistedTabDataProto.parseFrom(bytes);
mPriceString = shoppingPersistedTabDataProto.getPriceString();
mPreviousPriceString = shoppingPersistedTabDataProto.getPreviousPriceString();
setLastUpdatedMs(shoppingPersistedTabDataProto.getLastUpdatedMs());
mLastPriceChangeTimeMs = shoppingPersistedTabDataProto.getLastPriceChangeTimeMs();
return true;
} catch (InvalidProtocolBufferException e) {
Log.e(TAG,
......@@ -161,4 +209,19 @@ public class ShoppingPersistedTabData extends PersistedTabData {
public String getUmaTag() {
return "SPTD";
}
@Override
public long getTimeToLiveMs() {
return mTimeToLiveMs;
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
public void setTimeToLiveMs(long timeToLiveMs) {
mTimeToLiveMs = timeToLiveMs;
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
public long getLastPriceChangeTimeMs() {
return mLastPriceChangeTimeMs;
}
}
\ No newline at end of file
......@@ -9,6 +9,17 @@ package org.chromium.chrome.browser.tab.proto;
option java_package = "org.chromium.chrome.browser.tab.proto";
message ShoppingPersistedTabDataProto {
// String representing the price of the offer
// String representing the price of the offer.
optional string price_string = 1;
// String representing previous price of the offer.
optional string previous_price_string = 2;
// Time the ShoppingPersistedTabData was fetched
// measured in the number of seconds since the epoch.
optional int64 last_updated_ms = 3;
// Time the price changed measured in the number of
// seconds since the epoch.
optional int64 last_price_change_time_ms = 4;
}
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