Commit bfa97409 authored by Hazem Ashmawy's avatar Hazem Ashmawy Committed by Commit Bot

[AW] Dev UI: Show error if system uses different WebView impl

Show a persistent error message at the top of the activities as well as
a dialogue when the activity is launched, to warn the user that they are
using a developer UI from a package that is different from the WebView
package selected by the system.

Bug: 981150
Test: Launch Dev UI from Trichrome, change webview implementation and switch back to the UI.
Change-Id: If3ec3f09f4d8e1632583f3448d2d6e4bbf841607
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/1912119
Commit-Queue: Hazem Ashmawy <hazems@chromium.org>
Reviewed-by: default avatarRichard Coles <torne@chromium.org>
Reviewed-by: default avatarNate Fischer <ntfschr@chromium.org>
Cr-Commit-Position: refs/heads/master@{#715958}
parent 961f6521
...@@ -39,12 +39,15 @@ android_library("devui_java") { ...@@ -39,12 +39,15 @@ android_library("devui_java") {
"java/src/org/chromium/android_webview/devui/CrashesListActivity.java", "java/src/org/chromium/android_webview/devui/CrashesListActivity.java",
"java/src/org/chromium/android_webview/devui/FlagsActivity.java", "java/src/org/chromium/android_webview/devui/FlagsActivity.java",
"java/src/org/chromium/android_webview/devui/MainActivity.java", "java/src/org/chromium/android_webview/devui/MainActivity.java",
"java/src/org/chromium/android_webview/devui/PersistentErrorView.java",
"java/src/org/chromium/android_webview/devui/WebViewPackageError.java",
"java/src/org/chromium/android_webview/devui/util/CrashInfoLoader.java", "java/src/org/chromium/android_webview/devui/util/CrashInfoLoader.java",
"java/src/org/chromium/android_webview/devui/util/NavigationMenuHelper.java", "java/src/org/chromium/android_webview/devui/util/NavigationMenuHelper.java",
"java/src/org/chromium/android_webview/devui/util/UnuploadedFilesStateLoader.java", "java/src/org/chromium/android_webview/devui/util/UnuploadedFilesStateLoader.java",
"java/src/org/chromium/android_webview/devui/util/UploadedCrashesInfoLoader.java", "java/src/org/chromium/android_webview/devui/util/UploadedCrashesInfoLoader.java",
"java/src/org/chromium/android_webview/devui/util/WebViewCrashInfoCollector.java", "java/src/org/chromium/android_webview/devui/util/WebViewCrashInfoCollector.java",
"java/src/org/chromium/android_webview/devui/util/WebViewCrashLogParser.java", "java/src/org/chromium/android_webview/devui/util/WebViewCrashLogParser.java",
"java/src/org/chromium/android_webview/devui/util/WebViewPackageHelper.java",
] ]
deps = [ deps = [
":devui_resources", ":devui_resources",
......
...@@ -7,6 +7,7 @@ ...@@ -7,6 +7,7 @@
<LinearLayout <LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/crashes_list_activity_layout"
android:orientation="vertical" android:orientation="vertical"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
......
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
<LinearLayout <LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/activity_flags" android:id="@+id/flags_activity_layout"
android:orientation="vertical" android:orientation="vertical"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
...@@ -17,7 +17,7 @@ ...@@ -17,7 +17,7 @@
<TextView <TextView
android:id="@+id/flags_warning" android:id="@+id/flags_warning"
android:text="WARNING: EXPERIMENTAL FEATURES AHEAD!" android:text="WARNING: EXPERIMENTAL FEATURES AHEAD!"
android:textColor="@color/warningText" android:textColor="@color/error_red"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingBottom="5dp" android:paddingBottom="5dp"
......
...@@ -7,6 +7,7 @@ ...@@ -7,6 +7,7 @@
<LinearLayout <LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/main_activity_layout"
android:orientation="vertical" android:orientation="vertical"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
......
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2019 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.
-->
<TextView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceMedium"
android:textStyle="italic"
android:drawableStart="@android:drawable/stat_notify_error"
android:drawablePadding="8dp"
android:paddingTop="20dp"
android:paddingBottom="20dp"
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"/>
...@@ -6,5 +6,6 @@ ...@@ -6,5 +6,6 @@
--> -->
<resources> <resources>
<color name="warningText">#C5221F</color> <color name="error_red">#C5221F</color>
<color name="warning_yellow">#FFCC00</color>
</resources> </resources>
...@@ -41,8 +41,9 @@ public class CrashesListActivity extends Activity { ...@@ -41,8 +41,9 @@ public class CrashesListActivity extends Activity {
private TextView mCrashesSummaryView; private TextView mCrashesSummaryView;
private BaseExpandableListAdapter mCrashListViewAdapter; private BaseExpandableListAdapter mCrashListViewAdapter;
private WebViewCrashInfoCollector mCrashCollector; private WebViewPackageError mDifferentPackageError;
private WebViewCrashInfoCollector mCrashCollector;
private List<CrashInfo> mCrashInfoList; private List<CrashInfo> mCrashInfoList;
private static final String CRASH_REPORT_TEMPLATE = "" private static final String CRASH_REPORT_TEMPLATE = ""
...@@ -91,6 +92,20 @@ public class CrashesListActivity extends Activity { ...@@ -91,6 +92,20 @@ public class CrashesListActivity extends Activity {
ExpandableListView crashListView = findViewById(R.id.crashes_list); ExpandableListView crashListView = findViewById(R.id.crashes_list);
crashListView.setAdapter(mCrashListViewAdapter); crashListView.setAdapter(mCrashListViewAdapter);
mDifferentPackageError =
new WebViewPackageError(this, findViewById(R.id.crashes_list_activity_layout));
// show the dialog once when the activity is created.
mDifferentPackageError.showDialogIfDifferent();
}
@Override
protected void onResume() {
super.onResume();
// Check package status in onResume() to hide/show the error message if the user
// changes WebView implementation from system settings and then returns back to the
// activity.
mDifferentPackageError.showMessageIfDifferent();
} }
/** /**
......
...@@ -42,6 +42,8 @@ public class FlagsActivity extends Activity { ...@@ -42,6 +42,8 @@ public class FlagsActivity extends Activity {
"Disabled", "Disabled",
}; };
private WebViewPackageError mDifferentPackageError;
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
...@@ -56,6 +58,20 @@ public class FlagsActivity extends Activity { ...@@ -56,6 +58,20 @@ public class FlagsActivity extends Activity {
List<Flag> flagsList = Arrays.asList(ProductionSupportedFlagList.sFlagList); List<Flag> flagsList = Arrays.asList(ProductionSupportedFlagList.sFlagList);
flagsListView.setAdapter(new FlagsListAdapter(flagsList)); flagsListView.setAdapter(new FlagsListAdapter(flagsList));
mDifferentPackageError =
new WebViewPackageError(this, findViewById(R.id.flags_activity_layout));
// show the dialog once when the activity is created.
mDifferentPackageError.showDialogIfDifferent();
}
@Override
protected void onResume() {
super.onResume();
// Check package status in onResume() to hide/show the error message if the user
// changes WebView implementation from system settings and then returns back to the
// activity.
mDifferentPackageError.showMessageIfDifferent();
} }
private class FlagStateSpinnerSelectedListener implements AdapterView.OnItemSelectedListener { private class FlagStateSpinnerSelectedListener implements AdapterView.OnItemSelectedListener {
......
...@@ -8,7 +8,6 @@ import android.content.ClipData; ...@@ -8,7 +8,6 @@ import android.content.ClipData;
import android.content.ClipboardManager; import android.content.ClipboardManager;
import android.content.Context; import android.content.Context;
import android.content.pm.PackageInfo; import android.content.pm.PackageInfo;
import android.content.pm.PackageManager.NameNotFoundException;
import android.os.Build; import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.view.Menu; import android.view.Menu;
...@@ -20,6 +19,7 @@ import android.widget.ListView; ...@@ -20,6 +19,7 @@ import android.widget.ListView;
import android.widget.TextView; import android.widget.TextView;
import org.chromium.android_webview.devui.util.NavigationMenuHelper; import org.chromium.android_webview.devui.util.NavigationMenuHelper;
import org.chromium.android_webview.devui.util.WebViewPackageHelper;
import org.chromium.ui.widget.Toast; import org.chromium.ui.widget.Toast;
import java.util.Locale; import java.util.Locale;
...@@ -30,19 +30,19 @@ import java.util.Locale; ...@@ -30,19 +30,19 @@ import java.util.Locale;
* It helps to navigate to other WebView developer tools. * It helps to navigate to other WebView developer tools.
*/ */
public class MainActivity extends Activity { public class MainActivity extends Activity {
private WebViewPackageError mDifferentPackageError;
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main); setContentView(R.layout.activity_main);
PackageInfo webViewPackage = getPackageInfo(); PackageInfo webViewPackage = WebViewPackageHelper.getContextPackageInfo(this);
InfoItem[] infoItems = new InfoItem[] { InfoItem[] infoItems = new InfoItem[] {
new InfoItem("WebView package", new InfoItem("WebView package", webViewPackage.packageName),
webViewPackage == null ? null : webViewPackage.packageName),
new InfoItem("WebView version", new InfoItem("WebView version",
String.format(Locale.US, "%s (%s)", String.format(Locale.US, "%s (%s)", webViewPackage.versionName,
webViewPackage == null ? null : webViewPackage.versionName,
webViewPackage.versionCode)), webViewPackage.versionCode)),
new InfoItem("Device info", new InfoItem("Device info",
String.format(Locale.US, "%s - %s", Build.MODEL, Build.FINGERPRINT)), String.format(Locale.US, "%s - %s", Build.MODEL, Build.FINGERPRINT)),
...@@ -62,6 +62,20 @@ public class MainActivity extends Activity { ...@@ -62,6 +62,20 @@ public class MainActivity extends Activity {
// Show a toast that the text has been copied. // Show a toast that the text has been copied.
Toast.makeText(MainActivity.this, "Copied " + item.title, Toast.LENGTH_SHORT).show(); Toast.makeText(MainActivity.this, "Copied " + item.title, Toast.LENGTH_SHORT).show();
}); });
mDifferentPackageError =
new WebViewPackageError(this, findViewById(R.id.main_activity_layout));
// show the dialog once when the activity is created.
mDifferentPackageError.showDialogIfDifferent();
}
@Override
protected void onResume() {
super.onResume();
// Check package status in onResume() to hide/show the error message if the user
// changes WebView implementation from system settings and then returns back to the
// activity.
mDifferentPackageError.showMessageIfDifferent();
} }
/** /**
...@@ -125,12 +139,4 @@ public class MainActivity extends Activity { ...@@ -125,12 +139,4 @@ public class MainActivity extends Activity {
} }
return super.onOptionsItemSelected(item); return super.onOptionsItemSelected(item);
} }
private PackageInfo getPackageInfo() {
try {
return getPackageManager().getPackageInfo(getPackageName(), 0);
} catch (NameNotFoundException e) {
return null;
}
}
} }
// Copyright 2019 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.android_webview.devui;
import android.app.Activity;
import android.app.Dialog;
import android.graphics.Color;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.LinearLayout;
import android.widget.TextView;
/**
* Shows a text message at the top of a LinearLayout to show error and warning messages.
*/
public class PersistentErrorView {
/**
* Error message type.
*/
public enum Type {
ERROR,
WARNING,
}
private TextView mTextView;
/**
* @param context The Activity where this View is shon.
* @param type View type.
*/
public PersistentErrorView(Activity context, Type type) {
mTextView = (TextView) context.getLayoutInflater().inflate(
R.layout.persistent_error_message, null);
switch (type) {
case ERROR:
mTextView.setBackgroundResource(R.color.error_red);
mTextView.setTextColor(Color.WHITE);
break;
case WARNING:
mTextView.setBackgroundResource(R.color.warning_yellow);
mTextView.setTextColor(Color.BLACK);
break;
}
}
/**
* Add the error message view at the top of the given {@link LinearLayout}
* @param layout the linear layout to add this view at the top.
* @return object reference for chaining.
*/
public PersistentErrorView prependToLinearLayout(LinearLayout layout) {
layout.addView(mTextView, 0);
return this;
}
/**
* Set click event listener of this view.
* @param listener listener object to handle the click event.
* @return object reference for chaining.
*/
public PersistentErrorView setOnClickListener(OnClickListener listener) {
mTextView.setOnClickListener(listener);
return this;
}
/**
* Set a dialog to show when the view is clicked.
* @param dialog {@link Dialog} object to to show when the view is clicked.
* @return object reference for chaining.
*/
public PersistentErrorView setDialog(Dialog dialog) {
setOnClickListener(v -> dialog.show());
return this;
}
/**
* Set view text.
* @param text text {@link String} to show in the error View.
* @return object reference for chaining.
*/
public PersistentErrorView setText(String text) {
mTextView.setText(text);
return this;
}
/**
* Show the view by setting its visibility.
*/
public void show() {
mTextView.setVisibility(View.VISIBLE);
}
/**
* Hide the view by setting its visibility.
*/
public void hide() {
mTextView.setVisibility(View.GONE);
}
}
// Copyright 2019 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.android_webview.devui;
import android.app.Activity;
import android.app.AlertDialog;
import android.app.Dialog;
import android.content.Intent;
import android.content.pm.PackageInfo;
import android.content.pm.ResolveInfo;
import android.provider.Settings;
import android.widget.LinearLayout;
import org.chromium.android_webview.devui.util.WebViewPackageHelper;
import org.chromium.base.Log;
import java.util.List;
import java.util.Locale;
/**
* A helper class to yield an error if the the UI is launched from a different WebView package other
* than the selected package by the system. It shows a persistent error message at the top of the
* activity's root linear layout as well as an alert dialogue to change WebView implementation.
*/
public class WebViewPackageError {
private static final String TAG = "WebViewDevTools";
private PersistentErrorView mErrorMessage;
private Activity mContext;
private Dialog mErrorDialog;
/**
* @param context The {@link Activity} where the error is yield.
* @param linearLayout the linearLayout to show error message at it's top.
*/
public WebViewPackageError(Activity context, LinearLayout linearLayout) {
mContext = context;
mErrorDialog = buildDifferentPackageErrorDialog(mContext.getPackageName());
mErrorMessage = new PersistentErrorView(context, PersistentErrorView.Type.WARNING)
.prependToLinearLayout(linearLayout)
.setText("Warning: different WebView provider - Tap for more info.")
.setDialog(mErrorDialog);
}
/**
* Show the persistent error message at the top of the LinearLayout, if the system uses a
* different WebView implementation. Hide it otherwise.
*/
public void showMessageIfDifferent() {
if (isWebViewPackageDifferent()) {
mErrorMessage.show();
} else {
mErrorMessage.hide();
}
}
/**
* Show an {@link AlertDialog} about the different WebView package error with an action to
* launch system settings to show and change WebView implementation.
*/
public void showDialogIfDifferent() {
if (isWebViewPackageDifferent()) {
mErrorDialog.show();
}
}
private boolean isWebViewPackageDifferent() {
PackageInfo systemWebViewPackage = WebViewPackageHelper.getCurrentWebViewPackage(mContext);
if (systemWebViewPackage == null) {
Log.e(TAG, "Could not find a valid WebView implementation");
buildNoValidWebViewPackageDialog().show();
return true;
}
return !mContext.getPackageName().equals(systemWebViewPackage.packageName);
}
private Dialog buildDifferentPackageErrorDialog(String currentWebViewPackage) {
AlertDialog.Builder builder = new AlertDialog.Builder(mContext);
builder.setTitle("Wrong WebView DevTools")
.setMessage(String.format(Locale.US,
"This app (%s) is not the selected system's WebView provider.",
currentWebViewPackage));
builder.setPositiveButton("Open the current WebView provider", (dialog, id) -> {
PackageInfo systemWebViewPackage =
WebViewPackageHelper.getCurrentWebViewPackage(mContext);
if (systemWebViewPackage == null) {
Log.e(TAG, "Could not find a valid WebView implementation");
buildNoValidWebViewPackageDialog().show();
return;
}
Intent intent = new Intent("com.android.webview.SHOW_DEV_UI");
intent.setPackage(systemWebViewPackage.packageName);
// Check if the intent is resolved, i.e current system WebView package has a developer
// UI that responds to "com.android.webview.SHOW_DEV_UI" action.
List<ResolveInfo> resolveInfo =
mContext.getPackageManager().queryIntentActivities(intent, 0);
if (resolveInfo.isEmpty()) {
buildNoDevToolsDialog(systemWebViewPackage.packageName).show();
return;
}
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
mContext.startActivity(intent);
mContext.finishAndRemoveTask();
});
builder.setNeutralButton("Change WebView provider",
(dialog, id)
-> mContext.startActivity(new Intent(Settings.ACTION_WEBVIEW_SETTINGS)));
return builder.create();
}
private Dialog buildNoValidWebViewPackageDialog() {
AlertDialog.Builder builder = new AlertDialog.Builder(mContext);
builder.setTitle("No Valid WebView")
.setMessage("Cannot find a valid WebView provider installed. "
+ "Please install a valid WebView package. Contact "
+ "android-webview-dev@chromium.org for help.");
return builder.create();
}
private Dialog buildNoDevToolsDialog(String sytemWebViewPackageName) {
AlertDialog.Builder builder = new AlertDialog.Builder(mContext);
builder.setTitle("DevTools Not Found")
.setMessage(String.format(Locale.US,
"DevTools are not available in the current "
+ "WebView provider selected by the system (%s).\n\n"
+ "Please update to a newer version or select a different WebView "
+ "provider.",
sytemWebViewPackageName));
builder.setPositiveButton("Change WebView provider",
(dialog, id)
-> mContext.startActivity(new Intent(Settings.ACTION_WEBVIEW_SETTINGS)));
return builder.create();
}
}
// Copyright 2019 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.android_webview.devui.util;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.os.Build;
import android.webkit.WebView;
import java.lang.reflect.InvocationTargetException;
/**
* A helper class to get info about WebView package.
*/
public final class WebViewPackageHelper {
/**
* If WebView has already been loaded into the current process this method will return the
* package that was used to load it. Otherwise, the package that would be used if the WebView
* was loaded right now will be returned; this does not cause WebView to be loaded, so this
* information may become outdated at any time.
* The WebView package changes either when the current WebView package is updated, disabled, or
* uninstalled. It can also be changed through a Developer Setting.
* If the WebView package changes, any app process that has loaded WebView will be killed. The
* next time the app starts and loads WebView it will use the new WebView package instead.
* @return the current WebView package, or {@code null} if there is none.
*/
// This method is copied from androidx.webkit.WebViewCompat.
// TODO(crbug.com/1020024) use androidx.webkit.WebViewCompat#getCurrentWebViewPackage instead.
public static PackageInfo getCurrentWebViewPackage(Context context) {
// There was no WebView Package before Lollipop, the WebView code was part of the framework
// back then.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
return null;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
return WebView.getCurrentWebViewPackage();
} else { // L-N
try {
PackageInfo loadedWebViewPackageInfo = getLoadedWebViewPackageInfo();
if (loadedWebViewPackageInfo != null) return loadedWebViewPackageInfo;
} catch (ClassNotFoundException | IllegalAccessException | InvocationTargetException
| NoSuchMethodException e) {
return null;
}
// If WebViewFactory.getLoadedPackageInfo() returns null then WebView hasn't been loaded
// yet, in that case we need to fetch the name of the WebView package, and fetch the
// corresponding PackageInfo through the PackageManager
return getNotYetLoadedWebViewPackageInfo(context);
}
}
/**
* Return the PackageInfo of the currently loaded WebView APK. This method uses reflection and
* propagates any exceptions thrown, to the caller.
*/
// This method is copied from androidx.webkit.WebViewCompat.
@SuppressLint("PrivateApi")
private static PackageInfo getLoadedWebViewPackageInfo()
throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException,
IllegalAccessException {
Class<?> webViewFactoryClass = Class.forName("android.webkit.WebViewFactory");
return (PackageInfo) webViewFactoryClass.getMethod("getLoadedPackageInfo").invoke(null);
}
/**
* Return the PackageInfo of the WebView APK that would have been used as WebView implementation
* if WebView was to be loaded right now.
*/
// This method is copied from androidx.webkit.WebViewCompat.
@SuppressLint("PrivateApi")
private static PackageInfo getNotYetLoadedWebViewPackageInfo(Context context) {
String webviewPackageName;
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
&& Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) {
Class<?> webViewFactoryClass = Class.forName("android.webkit.WebViewFactory");
webviewPackageName = (String) webViewFactoryClass.getMethod("getWebViewPackageName")
.invoke(null);
} else {
Class<?> webviewUpdateServiceClass =
Class.forName("android.webkit.WebViewUpdateService");
webviewPackageName =
(String) webviewUpdateServiceClass.getMethod("getCurrentWebViewPackageName")
.invoke(null);
}
} catch (ClassNotFoundException e) {
return null;
} catch (IllegalAccessException e) {
return null;
} catch (InvocationTargetException e) {
return null;
} catch (NoSuchMethodException e) {
return null;
}
if (webviewPackageName == null) return null;
PackageManager pm = context.getPackageManager();
try {
return pm.getPackageInfo(webviewPackageName, 0);
} catch (PackageManager.NameNotFoundException e) {
return null;
}
}
/**
* Get {@link PackageInfo} for the given {@link Context}.
* It uses {@link Context#getPackageName()} to look up the {@link PackageInfo} object.
*/
public static PackageInfo getContextPackageInfo(Context context) {
try {
return context.getPackageManager().getPackageInfo(context.getPackageName(), 0);
} catch (PackageManager.NameNotFoundException e) {
throw new RuntimeException(e);
}
}
// Do not instantiate this class.
private WebViewPackageHelper() {}
}
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