Commit 2ed20c91 authored by Vaclav Brozek's avatar Vaclav Brozek Committed by Commit Bot

[Android password settings] Separate timer for export

The user needs to reauthenticate both to view/copy and to export
passwords. Once reauthenticated, the authentication is skipped for the
next 60 seconds.

Through authentication, the user grants an easy access to anybody
holding their device in the next 60 seconds to the passwords on their
device.

The explanation message of the reauth prompt includes the scope of the
approval (e.g., "to view your passwords" or "to export your
passwords") of the _first_ reason to reauthenticate.

To protect the privacy of the user, if they grant the access for a
one-at-a-time access (e.g., viewing passwords) but then a bulk access
(e.g., export of all passwords) is requested within the grace period
of 60 seconds, Chrome ignores the grace period and requests the reauth
again.

Bug: 800686
Change-Id: Icc96bf490b13ba7ba172bc88fdef0ffdefaf97f2
Reviewed-on: https://chromium-review.googlesource.com/883525
Commit-Queue: Vaclav Brozek <vabr@chromium.org>
Reviewed-by: default avatarBernhard Bauer <bauerb@chromium.org>
Reviewed-by: default avatarTheresa <twellington@chromium.org>
Cr-Commit-Position: refs/heads/master@{#532254}
parent 615e458c
......@@ -175,7 +175,8 @@ public class PasswordEntryEditor extends Fragment {
@Override
public void onResume() {
super.onResume();
if (ReauthenticationManager.authenticationStillValid()) {
if (ReauthenticationManager.authenticationStillValid(
ReauthenticationManager.REAUTH_SCOPE_ONE_AT_A_TIME)) {
if (mViewButtonPressed) displayPassword();
if (mCopyButtonPressed) copyPassword();
......@@ -356,13 +357,15 @@ public class PasswordEntryEditor extends Fragment {
Toast.makeText(getActivity().getApplicationContext(),
R.string.password_entry_editor_set_lock_screen, Toast.LENGTH_LONG)
.show();
} else if (ReauthenticationManager.authenticationStillValid()) {
} else if (ReauthenticationManager.authenticationStillValid(
ReauthenticationManager.REAUTH_SCOPE_ONE_AT_A_TIME)) {
copyPassword();
} else {
mCopyButtonPressed = true;
ReauthenticationManager.displayReauthenticationFragment(
R.string.lockscreen_description_copy,
R.id.password_entry_editor_interactive, getFragmentManager());
R.id.password_entry_editor_interactive, getFragmentManager(),
ReauthenticationManager.REAUTH_SCOPE_ONE_AT_A_TIME);
}
});
viewPasswordButton.setOnClickListener(v -> {
......@@ -375,13 +378,15 @@ public class PasswordEntryEditor extends Fragment {
& InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD)
== InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD) {
hidePassword();
} else if (ReauthenticationManager.authenticationStillValid()) {
} else if (ReauthenticationManager.authenticationStillValid(
ReauthenticationManager.REAUTH_SCOPE_ONE_AT_A_TIME)) {
displayPassword();
} else {
mViewButtonPressed = true;
ReauthenticationManager.displayReauthenticationFragment(
R.string.lockscreen_description_view,
R.id.password_entry_editor_interactive, getFragmentManager());
R.id.password_entry_editor_interactive, getFragmentManager(),
ReauthenticationManager.REAUTH_SCOPE_ONE_AT_A_TIME);
}
});
}
......
......@@ -18,10 +18,20 @@ import org.chromium.base.VisibleForTesting;
/** Show the lock screen confirmation and lock the screen. */
public class PasswordReauthenticationFragment extends Fragment {
// The key for the description argument, which is used to retrieve an explanation of the
// reauthentication prompt to the user.
/**
* The key for the description argument, which is used to retrieve an explanation of the
* reauthentication prompt to the user.
*/
public static final String DESCRIPTION_ID = "description";
/**
* The key for the scope, with values from {@link ReauthenticationManager.ReauthScope}. The
* scope enum value corresponds to what is indicated in the description message for the user
* (e.g., if the message mentions "export passwords", the scope should be BULK, but for "view
* password" it should be ONE_AT_A_TIME).
*/
public static final String SCOPE_ID = "scope";
protected static final int CONFIRM_DEVICE_CREDENTIAL_REQUEST_CODE = 2;
protected static final String HAS_BEEN_SUSPENDED_KEY = "has_been_suspended";
......@@ -54,7 +64,8 @@ public class PasswordReauthenticationFragment extends Fragment {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == CONFIRM_DEVICE_CREDENTIAL_REQUEST_CODE) {
if (resultCode == getActivity().RESULT_OK) {
ReauthenticationManager.setLastReauthTimeMillis(System.currentTimeMillis());
ReauthenticationManager.recordLastReauth(
System.currentTimeMillis(), getArguments().getInt(SCOPE_ID));
}
mFragmentManager.popBackStack();
}
......
......@@ -11,10 +11,15 @@ import android.app.KeyguardManager;
import android.content.Context;
import android.os.Build;
import android.os.Bundle;
import android.support.annotation.IntDef;
import android.support.annotation.Nullable;
import android.view.View;
import org.chromium.base.VisibleForTesting;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
/**
* This collection of static methods provides reauthentication primitives for passwords
* settings UI.
......@@ -23,6 +28,14 @@ public final class ReauthenticationManager {
// Used for various ways to override checks provided by this class.
public enum OverrideState { NOT_OVERRIDDEN, AVAILABLE, UNAVAILABLE }
// Used to specify the scope of the reauthentication -- either to grant bulk access like, e.g.,
// exporting passwords, or just one-at-a-time, like, e.g., viewing a single password.
@Retention(RetentionPolicy.SOURCE)
@IntDef({REAUTH_SCOPE_ONE_AT_A_TIME, REAUTH_SCOPE_BULK})
public @interface ReauthScope {}
public static final int REAUTH_SCOPE_ONE_AT_A_TIME = 0;
public static final int REAUTH_SCOPE_BULK = 1;
// Useful for retrieving the fragment in tests.
@VisibleForTesting
public static final String FRAGMENT_TAG = "reauthentication-manager-fragment";
......@@ -30,9 +43,14 @@ public final class ReauthenticationManager {
// Defines how long a successful reauthentication remains valid.
private static final int VALID_REAUTHENTICATION_TIME_INTERVAL_MILLIS = 60000;
// Used for verifying if the last successful reauthentication is still valid. The value of 0
// Used for verifying if the last successful reauthentication is still valid. The null value
// means there was no successful reauthentication yet.
private static long sLastReauthTimeMillis;
@Nullable
private static Long sLastReauthTimeMillis;
// Stores the reauth scope used when |sLastReauthTimeMillis| was reset last time.
@ReauthScope
private static int sLastReauthScope = REAUTH_SCOPE_ONE_AT_A_TIME;
// Used in tests to override the result of checking for screen lock set-up. This allows the
// tests to be independent of a particular device configuration.
......@@ -45,11 +63,23 @@ public final class ReauthenticationManager {
// Used in tests to avoid displaying the OS reauth dialog.
private static boolean sSkipSystemReauth = false;
/**
* Clears the record of the last reauth so that a call to authenticationStillValid will return
* false.
*/
public static void resetLastReauth() {
sLastReauthTimeMillis = null;
sLastReauthScope = REAUTH_SCOPE_ONE_AT_A_TIME;
}
/**
* Stores the timestamp of last reauthentication of the user.
* @param timeStampMillis The time of the most recent successful user reauthentication.
* @param scope The scope of the reauthentication as advertised to the user via UI.
*/
public static void setLastReauthTimeMillis(long value) {
sLastReauthTimeMillis = value;
public static void recordLastReauth(long timeStampMillis, @ReauthScope int scope) {
sLastReauthTimeMillis = timeStampMillis;
sLastReauthScope = scope;
}
@VisibleForTesting
......@@ -99,13 +129,14 @@ public final class ReauthenticationManager {
* reauthentication prompt. It may be equal to View.NO_ID in tests.
* @param fragmentManager For putting the lock screen on the transaction stack.
*/
public static void displayReauthenticationFragment(
int descriptionId, int containerViewId, FragmentManager fragmentManager) {
public static void displayReauthenticationFragment(int descriptionId, int containerViewId,
FragmentManager fragmentManager, @ReauthScope int scope) {
if (sSkipSystemReauth) return;
Fragment passwordReauthentication = new PasswordReauthenticationFragment();
Bundle args = new Bundle();
args.putInt(PasswordReauthenticationFragment.DESCRIPTION_ID, descriptionId);
args.putInt(PasswordReauthenticationFragment.SCOPE_ID, scope);
passwordReauthentication.setArguments(args);
FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction();
......@@ -118,9 +149,15 @@ public final class ReauthenticationManager {
fragmentTransaction.commit();
}
/** Checks whether authentication is recent enought to be valid. */
public static boolean authenticationStillValid() {
return sLastReauthTimeMillis != 0
/** Checks whether authentication is recent enough to be valid. The authentication is valid as
* long as the user authenticated less than {@code VALID_REAUTHENTICATION_TIME_INTERVAL_MILLIS}
* milliseconds ago, for a scope including the passed {@code scope} argument. The {@code BULK}
* scope includes the {@code ONE_AT_A_TIME} scope.
* @param scope The scope the reauth should be valid for. */
public static boolean authenticationStillValid(@ReauthScope int scope) {
final boolean scopeIncluded =
scope == sLastReauthScope || sLastReauthScope == REAUTH_SCOPE_BULK;
return sLastReauthTimeMillis != null && scopeIncluded
&& (System.currentTimeMillis() - sLastReauthTimeMillis)
< VALID_REAUTHENTICATION_TIME_INTERVAL_MILLIS;
}
......
......@@ -277,13 +277,14 @@ public class SavePasswordsPreferences
.show();
// Re-enable exporting, the current one was cancelled by Chrome.
mExportOptionSuspended = false;
} else if (ReauthenticationManager.authenticationStillValid()) {
} else if (ReauthenticationManager.authenticationStillValid(
ReauthenticationManager.REAUTH_SCOPE_BULK)) {
exportAfterReauth();
} else {
mExportRequested = true;
ReauthenticationManager.displayReauthenticationFragment(
R.string.lockscreen_description_export, getView().getId(),
getFragmentManager());
getFragmentManager(), ReauthenticationManager.REAUTH_SCOPE_BULK);
}
return true;
}
......@@ -411,7 +412,7 @@ public class SavePasswordsPreferences
@Override
public void onDetach() {
super.onDetach();
ReauthenticationManager.setLastReauthTimeMillis(0);
ReauthenticationManager.resetLastReauth();
}
void rebuildPasswordLists() {
......@@ -550,7 +551,8 @@ public class SavePasswordsPreferences
mExportRequested = false;
// Depending on the authentication result, either carry on with exporting or re-enable
// the export menu for future attempts.
if (ReauthenticationManager.authenticationStillValid()) {
if (ReauthenticationManager.authenticationStillValid(
ReauthenticationManager.REAUTH_SCOPE_BULK)) {
exportAfterReauth();
} else {
mExportOptionSuspended = false;
......
......@@ -471,7 +471,8 @@ public class SavePasswordsPreferencesTest {
InstrumentationRegistry.getInstrumentation().getTargetContext());
// Before tapping the menu item for export, pretend that the last successful
// reauthentication just happened. This will allow the export flow to continue.
ReauthenticationManager.setLastReauthTimeMillis(System.currentTimeMillis());
ReauthenticationManager.recordLastReauth(
System.currentTimeMillis(), ReauthenticationManager.REAUTH_SCOPE_BULK);
Espresso.onView(withText(R.string.save_password_preferences_export_action_title))
.perform(click());
......@@ -501,7 +502,8 @@ public class SavePasswordsPreferencesTest {
InstrumentationRegistry.getInstrumentation().getTargetContext());
// Before tapping the menu item for export, pretend that the last successful
// reauthentication just happened. This will allow the export flow to continue.
ReauthenticationManager.setLastReauthTimeMillis(System.currentTimeMillis());
ReauthenticationManager.recordLastReauth(
System.currentTimeMillis(), ReauthenticationManager.REAUTH_SCOPE_BULK);
Espresso.onView(withText(R.string.save_password_preferences_export_action_title))
.perform(click());
......@@ -633,7 +635,8 @@ public class SavePasswordsPreferencesTest {
InstrumentationRegistry.getInstrumentation().getTargetContext());
// Before exporting, pretend that the last successful reauthentication just
// happened. This will allow the export flow to continue.
ReauthenticationManager.setLastReauthTimeMillis(System.currentTimeMillis());
ReauthenticationManager.recordLastReauth(
System.currentTimeMillis(), ReauthenticationManager.REAUTH_SCOPE_BULK);
Espresso.onView(withText(R.string.save_password_preferences_export_action_title))
.perform(click());
......@@ -701,7 +704,8 @@ public class SavePasswordsPreferencesTest {
// Before tapping the view button, pretend that the last successful reauthentication just
// happened. This will allow showing the password.
ReauthenticationManager.setLastReauthTimeMillis(System.currentTimeMillis());
ReauthenticationManager.recordLastReauth(
System.currentTimeMillis(), ReauthenticationManager.REAUTH_SCOPE_ONE_AT_A_TIME);
Espresso.onView(withContentDescription(R.string.password_entry_editor_view_stored_password))
.perform(click());
Espresso.onView(withText("test password")).check(matches(isDisplayed()));
......@@ -985,8 +989,10 @@ public class SavePasswordsPreferencesTest {
Espresso.onView(withText(PHOBOS_AT_OLYMP.getUserName())).check(doesNotExist());
Espresso.onView(withText(HADES_AT_UNDERWORLD.getUrl())).check(doesNotExist());
// Click "Zeus" to open edit field and verify the password. Pretend we had
ReauthenticationManager.setLastReauthTimeMillis(System.currentTimeMillis());
// Click "Zeus" to open edit field and verify the password. Pretend the user just passed the
// reauthentication challenge.
ReauthenticationManager.recordLastReauth(
System.currentTimeMillis(), ReauthenticationManager.REAUTH_SCOPE_ONE_AT_A_TIME);
Espresso.onView(withText(ZEUS_ON_EARTH.getUserName())).perform(click());
Espresso.onView(withContentDescription(R.string.password_entry_editor_view_stored_password))
.perform(click());
......
......@@ -11,6 +11,7 @@ import android.app.Fragment;
import android.app.FragmentManager;
import android.app.FragmentTransaction;
import android.content.Intent;
import android.os.Bundle;
import org.junit.Test;
import org.junit.runner.RunWith;
......@@ -34,6 +35,11 @@ public class PasswordReauthenticationFragmentTest {
private void checkPopFromBackStackOnResult(int resultCode) {
PasswordReauthenticationFragment passwordReauthentication =
new PasswordReauthenticationFragment();
Bundle args = new Bundle();
args.putInt(PasswordReauthenticationFragment.DESCRIPTION_ID, 0);
args.putSerializable(PasswordReauthenticationFragment.SCOPE_ID,
ReauthenticationManager.REAUTH_SCOPE_ONE_AT_A_TIME);
passwordReauthentication.setArguments(args);
// Replacement fragment for PasswordEntryEditor, which is the fragment that
// replaces PasswordReauthentication after popBackStack is called.
......
......@@ -15,6 +15,7 @@ import android.app.FragmentTransaction;
import android.content.Intent;
import android.view.View;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
......@@ -29,6 +30,27 @@ import org.chromium.testing.local.LocalRobolectricTestRunner;
@RunWith(LocalRobolectricTestRunner.class)
@Config(manifest = Config.NONE)
public class ReauthenticationManagerTest {
private FragmentManager mFragmentManager;
private Activity mTestActivity;
@Before
public void setUp() {
mTestActivity = Robolectric.setupActivity(Activity.class);
PasswordReauthenticationFragment.preventLockingForTesting();
mFragmentManager = mTestActivity.getFragmentManager();
// Prepare a dummy Fragment and commit a FragmentTransaction with it.
FragmentTransaction fragmentTransaction = mFragmentManager.beginTransaction();
// Replacement fragment for PasswordEntryEditor, which is the fragment that
// replaces PasswordReauthentication after popBackStack is called.
Fragment mockPasswordEntryEditor = new Fragment();
fragmentTransaction.add(mockPasswordEntryEditor, "password_entry_editor");
fragmentTransaction.addToBackStack(null);
fragmentTransaction.commit();
}
/**
* Prepares a dummy Intent to pass to PasswordReauthenticationFragment as a fake result of the
* reauthentication screen.
......@@ -40,47 +62,32 @@ public class ReauthenticationManagerTest {
return data;
}
/**
* Prepares a dummy Fragment and commits a FragmentTransaction with it.
* @param fragmentManager To be used for creating the transaction.
*/
private void addDummyPasswordEntryEditor(FragmentManager fragmentManager) {
FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction();
// Replacement fragment for PasswordEntryEditor, which is the fragment that
// replaces PasswordReauthentication after popBackStack is called.
Fragment mockPasswordEntryEditor = new Fragment();
fragmentTransaction.add(mockPasswordEntryEditor, "password_entry_editor");
fragmentTransaction.addToBackStack(null);
fragmentTransaction.commit();
}
/**
* Ensure that displayReauthenticationFragment puts the reauthentication fragment on the
* transaction stack and updates the validity of the reauth when reauth passed.
*/
@Test
public void testDisplayReauthenticationFragment_Passed() {
Activity testActivity = Robolectric.setupActivity(Activity.class);
PasswordReauthenticationFragment.preventLockingForTesting();
FragmentManager fragmentManager = testActivity.getFragmentManager();
addDummyPasswordEntryEditor(fragmentManager);
ReauthenticationManager.setLastReauthTimeMillis(0);
assertFalse(ReauthenticationManager.authenticationStillValid());
ReauthenticationManager.resetLastReauth();
assertFalse(ReauthenticationManager.authenticationStillValid(
ReauthenticationManager.REAUTH_SCOPE_ONE_AT_A_TIME));
assertFalse(ReauthenticationManager.authenticationStillValid(
ReauthenticationManager.REAUTH_SCOPE_BULK));
ReauthenticationManager.displayReauthenticationFragment(
R.string.lockscreen_description_view, View.NO_ID, fragmentManager);
R.string.lockscreen_description_view, View.NO_ID, mFragmentManager,
ReauthenticationManager.REAUTH_SCOPE_ONE_AT_A_TIME);
Fragment reauthFragment =
fragmentManager.findFragmentByTag(ReauthenticationManager.FRAGMENT_TAG);
mFragmentManager.findFragmentByTag(ReauthenticationManager.FRAGMENT_TAG);
assertNotNull(reauthFragment);
reauthFragment.onActivityResult(
PasswordReauthenticationFragment.CONFIRM_DEVICE_CREDENTIAL_REQUEST_CODE,
testActivity.RESULT_OK, prepareDummyDataForActivityResult());
fragmentManager.executePendingTransactions();
Activity.RESULT_OK, prepareDummyDataForActivityResult());
mFragmentManager.executePendingTransactions();
assertTrue(ReauthenticationManager.authenticationStillValid());
assertTrue(ReauthenticationManager.authenticationStillValid(
ReauthenticationManager.REAUTH_SCOPE_ONE_AT_A_TIME));
}
/**
......@@ -89,26 +96,89 @@ public class ReauthenticationManagerTest {
*/
@Test
public void testDisplayReauthenticationFragment_Failed() {
Activity testActivity = Robolectric.setupActivity(Activity.class);
PasswordReauthenticationFragment.preventLockingForTesting();
ReauthenticationManager.resetLastReauth();
assertFalse(ReauthenticationManager.authenticationStillValid(
ReauthenticationManager.REAUTH_SCOPE_ONE_AT_A_TIME));
assertFalse(ReauthenticationManager.authenticationStillValid(
ReauthenticationManager.REAUTH_SCOPE_BULK));
ReauthenticationManager.displayReauthenticationFragment(
R.string.lockscreen_description_view, View.NO_ID, mFragmentManager,
ReauthenticationManager.REAUTH_SCOPE_ONE_AT_A_TIME);
Fragment reauthFragment =
mFragmentManager.findFragmentByTag(ReauthenticationManager.FRAGMENT_TAG);
assertNotNull(reauthFragment);
FragmentManager fragmentManager = testActivity.getFragmentManager();
addDummyPasswordEntryEditor(fragmentManager);
reauthFragment.onActivityResult(
PasswordReauthenticationFragment.CONFIRM_DEVICE_CREDENTIAL_REQUEST_CODE,
Activity.RESULT_CANCELED, prepareDummyDataForActivityResult());
mFragmentManager.executePendingTransactions();
assertFalse(ReauthenticationManager.authenticationStillValid(
ReauthenticationManager.REAUTH_SCOPE_ONE_AT_A_TIME));
assertFalse(ReauthenticationManager.authenticationStillValid(
ReauthenticationManager.REAUTH_SCOPE_BULK));
}
ReauthenticationManager.setLastReauthTimeMillis(0);
assertFalse(ReauthenticationManager.authenticationStillValid());
/**
* Ensure that displayReauthenticationFragment considers BULK scope to cover the ONE_AT_A_TIME
* scope as well.
*/
@Test
public void testDisplayReauthenticationFragment_OneAtATimeCovered() {
ReauthenticationManager.resetLastReauth();
assertFalse(ReauthenticationManager.authenticationStillValid(
ReauthenticationManager.REAUTH_SCOPE_ONE_AT_A_TIME));
assertFalse(ReauthenticationManager.authenticationStillValid(
ReauthenticationManager.REAUTH_SCOPE_BULK));
ReauthenticationManager.displayReauthenticationFragment(
R.string.lockscreen_description_view, View.NO_ID, fragmentManager);
R.string.lockscreen_description_export, View.NO_ID, mFragmentManager,
ReauthenticationManager.REAUTH_SCOPE_BULK);
Fragment reauthFragment =
fragmentManager.findFragmentByTag(ReauthenticationManager.FRAGMENT_TAG);
mFragmentManager.findFragmentByTag(ReauthenticationManager.FRAGMENT_TAG);
assertNotNull(reauthFragment);
reauthFragment.onActivityResult(
PasswordReauthenticationFragment.CONFIRM_DEVICE_CREDENTIAL_REQUEST_CODE,
testActivity.RESULT_CANCELED, prepareDummyDataForActivityResult());
fragmentManager.executePendingTransactions();
Activity.RESULT_OK, prepareDummyDataForActivityResult());
mFragmentManager.executePendingTransactions();
// Both BULK and ONE_AT_A_TIME scopes should be covered by the BULK request above.
assertTrue(ReauthenticationManager.authenticationStillValid(
ReauthenticationManager.REAUTH_SCOPE_ONE_AT_A_TIME));
assertTrue(ReauthenticationManager.authenticationStillValid(
ReauthenticationManager.REAUTH_SCOPE_BULK));
}
/**
* Ensure that displayReauthenticationFragment does not consider ONE_AT_A_TIME scope to cover
* the BULK scope.
*/
@Test
public void testDisplayReauthenticationFragment_BulkNotCovered() {
ReauthenticationManager.resetLastReauth();
assertFalse(ReauthenticationManager.authenticationStillValid(
ReauthenticationManager.REAUTH_SCOPE_ONE_AT_A_TIME));
assertFalse(ReauthenticationManager.authenticationStillValid(
ReauthenticationManager.REAUTH_SCOPE_BULK));
assertFalse(ReauthenticationManager.authenticationStillValid());
ReauthenticationManager.displayReauthenticationFragment(
R.string.lockscreen_description_view, View.NO_ID, mFragmentManager,
ReauthenticationManager.REAUTH_SCOPE_ONE_AT_A_TIME);
Fragment reauthFragment =
mFragmentManager.findFragmentByTag(ReauthenticationManager.FRAGMENT_TAG);
assertNotNull(reauthFragment);
reauthFragment.onActivityResult(
PasswordReauthenticationFragment.CONFIRM_DEVICE_CREDENTIAL_REQUEST_CODE,
Activity.RESULT_OK, prepareDummyDataForActivityResult());
mFragmentManager.executePendingTransactions();
// Only ONE_AT_A_TIME scope should be covered by the ONE_AT_A_TIME request above.
assertTrue(ReauthenticationManager.authenticationStillValid(
ReauthenticationManager.REAUTH_SCOPE_ONE_AT_A_TIME));
assertFalse(ReauthenticationManager.authenticationStillValid(
ReauthenticationManager.REAUTH_SCOPE_BULK));
}
}
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