Commit b2e8a52c authored by Mehran Mahmoudi's avatar Mehran Mahmoudi Committed by Commit Bot

[Paint Preview] Player: Implement UI for frames + scrolling

This implements the player UI using a hierarchy of Views. This enables
displaying the main the entire hierarchy of sub-frames. It also adds
support for scrolling in both directions for each frame.

Each Paint Preview frame is represented a PlayerFrame* sub-component
(everything in the org.chromium.components.paintpreview.player.frame
package). The approach is explained in more details on the design
doc linked below.

Internal design doc: http://go/fdt-player-2

Bug: 1021202,1020700
Change-Id: I7cc5310016c614f57105105a10d8db7a98e97654
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/1925052Reviewed-by: default avatarTed Choc <tedchoc@chromium.org>
Reviewed-by: default avatarMatthew Jones <mdjones@chromium.org>
Commit-Queue: Mehran Mahmoudi <mahmoudi@chromium.org>
Cr-Commit-Position: refs/heads/master@{#721246}
parent 5218be19
...@@ -54,17 +54,29 @@ android_library("java") { ...@@ -54,17 +54,29 @@ android_library("java") {
"java/src/org/chromium/components/paintpreview/player/PlayerCompositorDelegate.java", "java/src/org/chromium/components/paintpreview/player/PlayerCompositorDelegate.java",
"java/src/org/chromium/components/paintpreview/player/PlayerCompositorDelegateImpl.java", "java/src/org/chromium/components/paintpreview/player/PlayerCompositorDelegateImpl.java",
"java/src/org/chromium/components/paintpreview/player/PlayerManager.java", "java/src/org/chromium/components/paintpreview/player/PlayerManager.java",
"java/src/org/chromium/components/paintpreview/player/frame/PlayerFrameBitmapPainter.java",
"java/src/org/chromium/components/paintpreview/player/frame/PlayerFrameCoordinator.java", "java/src/org/chromium/components/paintpreview/player/frame/PlayerFrameCoordinator.java",
"java/src/org/chromium/components/paintpreview/player/frame/PlayerFrameGestureDetector.java",
"java/src/org/chromium/components/paintpreview/player/frame/PlayerFrameMediator.java",
"java/src/org/chromium/components/paintpreview/player/frame/PlayerFrameProperties.java",
"java/src/org/chromium/components/paintpreview/player/frame/PlayerFrameView.java",
"java/src/org/chromium/components/paintpreview/player/frame/PlayerFrameViewBinder.java",
"java/src/org/chromium/components/paintpreview/player/frame/PlayerFrameViewDelegate.java",
] ]
deps = [ deps = [
"//base:base_java", "//base:base_java",
"//base:jni_java", "//base:jni_java",
"//ui/android:ui_java",
] ]
} }
junit_binary("paint_preview_junit_tests") { junit_binary("paint_preview_junit_tests") {
java_files = [ "junit/src/org/chromium/components/paintpreview/player/PlayerManagerTest.java" ] java_files = [
"junit/src/org/chromium/components/paintpreview/player/PlayerManagerTest.java",
"junit/src/org/chromium/components/paintpreview/player/frame/PlayerFrameBitmapPainterTest.java",
"junit/src/org/chromium/components/paintpreview/player/frame/PlayerFrameMediatorTest.java",
]
deps = [ deps = [
":java", ":java",
"//base:base_java", "//base:base_java",
......
include_rules = [
"+ui/android",
]
// 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.components.paintpreview.player.frame;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Rect;
import javax.annotation.Nonnull;
/**
* Given a viewport {@link Rect} and a matrix of {@link Bitmap} tiles, this class draws the bitmaps
* on a {@link Canvas}.
*/
class PlayerFrameBitmapPainter {
private Bitmap[][] mBitmapMatrix;
private Rect mViewPort = new Rect();
private Rect mDrawBitmapSrc = new Rect();
private Rect mDrawBitmapDst = new Rect();
private Runnable mInvalidateCallback;
PlayerFrameBitmapPainter(@Nonnull Runnable invalidateCallback) {
mInvalidateCallback = invalidateCallback;
}
void updateViewPort(int left, int top, int right, int bottom) {
mViewPort.set(left, top, right, bottom);
mInvalidateCallback.run();
}
void updateBitmapMatrix(Bitmap[][] bitmapMatrix) {
mBitmapMatrix = bitmapMatrix;
mInvalidateCallback.run();
}
/**
* Draws bitmaps on a given {@link Canvas} for the current viewport.
*/
void onDraw(Canvas canvas) {
if (mBitmapMatrix == null) {
return;
}
if (mViewPort.isEmpty()) {
return;
}
final int tileHeight = mViewPort.height();
final int tileWidth = mViewPort.width();
final int rowStart = mViewPort.top / tileHeight;
final int rowEnd = (int) Math.ceil((double) mViewPort.bottom / tileHeight);
final int colStart = mViewPort.left / tileWidth;
final int colEnd = (int) Math.ceil((double) mViewPort.right / tileWidth);
if (rowEnd > mBitmapMatrix.length || colEnd > mBitmapMatrix[rowEnd - 1].length) {
return;
}
for (int row = rowStart; row < rowEnd; row++) {
for (int col = colStart; col < colEnd; col++) {
Bitmap tileBitmap = mBitmapMatrix[row][col];
// Calculate the portion of this tileBitmap that is visible in mViewPort.
int bitmapLeft = Math.max(mViewPort.left - (col * tileWidth), 0);
int bitmapTop = Math.max(mViewPort.top - (row * tileHeight), 0);
int bitmapRight =
Math.min(tileWidth, bitmapLeft + mViewPort.right - (col * tileWidth));
int bitmapBottom =
Math.min(tileHeight, bitmapTop + mViewPort.bottom - (row * tileHeight));
mDrawBitmapSrc.set(bitmapLeft, bitmapTop, bitmapRight, bitmapBottom);
// Calculate the portion of the canvas that tileBitmap is gonna be drawn on.
int canvasLeft = Math.max((col * tileWidth) - mViewPort.left, 0);
int canvasTop = Math.max((row * tileHeight) - mViewPort.top, 0);
int canvasRight = canvasLeft + mDrawBitmapSrc.width();
int canvasBottom = canvasTop + mDrawBitmapSrc.height();
mDrawBitmapDst.set(canvasLeft, canvasTop, canvasRight, canvasBottom);
canvas.drawBitmap(tileBitmap, mDrawBitmapSrc, mDrawBitmapDst, null);
}
}
}
}
...@@ -9,26 +9,42 @@ import android.graphics.Rect; ...@@ -9,26 +9,42 @@ import android.graphics.Rect;
import android.view.View; import android.view.View;
import org.chromium.components.paintpreview.player.PlayerCompositorDelegate; import org.chromium.components.paintpreview.player.PlayerCompositorDelegate;
import org.chromium.ui.modelutil.PropertyModel;
import org.chromium.ui.modelutil.PropertyModelChangeProcessor;
/** /**
* Sets up the view and the logic behind it for a Paint Preview frame. * Sets up the view and the logic behind it for a Paint Preview frame.
*/ */
public class PlayerFrameCoordinator { public class PlayerFrameCoordinator {
private PlayerFrameMediator mMediator;
private PlayerFrameView mView;
/** /**
* Creates a {@link PlayerFrameMediator} and {@link PlayerFrameView} for this component and * Creates a {@link PlayerFrameMediator} and {@link PlayerFrameView} for this component and
* binds them together. * binds them together.
*/ */
public PlayerFrameCoordinator(Context context, PlayerCompositorDelegate compositorDelegate, public PlayerFrameCoordinator(Context context, PlayerCompositorDelegate compositorDelegate,
long frameGuid, int contentWidth, int contentHeight, boolean canDetectZoom) {} long frameGuid, int contentWidth, int contentHeight, boolean canDetectZoom) {
PropertyModel model = new PropertyModel.Builder(PlayerFrameProperties.ALL_KEYS).build();
mMediator = new PlayerFrameMediator(
model, compositorDelegate, frameGuid, contentWidth, contentHeight);
mView = new PlayerFrameView(context, canDetectZoom, mMediator);
PropertyModelChangeProcessor.create(model, mView, PlayerFrameViewBinder::bind);
}
/** /**
* Adds a child {@link PlayerFrameCoordinator} to this class. * Adds a child {@link PlayerFrameCoordinator} to this class.
* @param subFrame The sub-frame's {@link PlayerFrameCoordinator}. * @param subFrame The sub-frame's {@link PlayerFrameCoordinator}.
* @param clipRect The {@link Rect} in which this sub-frame should be shown in. * @param clipRect The {@link Rect} in which this sub-frame should be shown in.
*/ */
public void addSubFrame(PlayerFrameCoordinator subFrame, Rect clipRect) {} public void addSubFrame(PlayerFrameCoordinator subFrame, Rect clipRect) {
mMediator.addSubFrame(subFrame.getView(), clipRect);
}
/**
* @return The view associated with this component.
*/
public View getView() { public View getView() {
return null; return mView;
} }
} }
// 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.components.paintpreview.player.frame;
import android.content.Context;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.ScaleGestureDetector;
/**
* Detects scroll, fling, and scale gestures on calls to {@link #onTouchEvent} and reports back to
* the provided {@link PlayerFrameViewDelegate}.
*/
class PlayerFrameGestureDetector
implements GestureDetector.OnGestureListener, ScaleGestureDetector.OnScaleGestureListener {
private GestureDetector mGestureDetector;
private ScaleGestureDetector mScaleGestureDetector;
private boolean mCanDetectZoom;
private PlayerFrameViewDelegate mPlayerFrameViewDelegate;
/**
* @param context Used for initializing {@link GestureDetector} and
* {@link ScaleGestureDetector}.
* @param canDetectZoom Whether this {@link PlayerFrameGestureDetector} should detect scale
* gestures.
* @param playerFrameViewDelegate The delegate used when desired gestured are detected.
*/
PlayerFrameGestureDetector(Context context, boolean canDetectZoom,
PlayerFrameViewDelegate playerFrameViewDelegate) {
mGestureDetector = new GestureDetector(context, this);
mScaleGestureDetector = new ScaleGestureDetector(context, this);
mCanDetectZoom = canDetectZoom;
mPlayerFrameViewDelegate = playerFrameViewDelegate;
}
/**
* This should be called on every touch event.
* @return Whether the event was consumed.
*/
boolean onTouchEvent(MotionEvent event) {
if (mCanDetectZoom) {
mScaleGestureDetector.onTouchEvent(event);
}
return mGestureDetector.onTouchEvent(event);
}
@Override
public boolean onDown(MotionEvent e) {
return false;
}
@Override
public void onShowPress(MotionEvent e) {}
@Override
public boolean onSingleTapUp(MotionEvent e) {
mPlayerFrameViewDelegate.onClick((int) e.getX(), (int) e.getY());
return true;
}
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
return mPlayerFrameViewDelegate.scrollBy(distanceX, distanceY);
}
@Override
public void onLongPress(MotionEvent e) {}
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
return false;
}
@Override
public boolean onScale(ScaleGestureDetector detector) {
assert mCanDetectZoom;
return mPlayerFrameViewDelegate.scaleBy(
detector.getScaleFactor(), detector.getFocusX(), detector.getFocusY());
}
@Override
public boolean onScaleBegin(ScaleGestureDetector detector) {
assert mCanDetectZoom;
return true;
}
@Override
public void onScaleEnd(ScaleGestureDetector detector) {}
}
// 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.components.paintpreview.player.frame;
import android.graphics.Bitmap;
import android.graphics.Rect;
import android.util.Pair;
import android.view.View;
import org.chromium.ui.modelutil.PropertyKey;
import org.chromium.ui.modelutil.PropertyModel;
import java.util.List;
/**
* Contains all properties that a player frame {@link PropertyModel} can have.
*/
class PlayerFrameProperties {
/** A matrix of bitmap tiles that collectively make the entire content. */
static final PropertyModel.WritableObjectPropertyKey<Bitmap[][]> BITMAP_MATRIX =
new PropertyModel.WritableObjectPropertyKey<>();
/**
* Contains the current user-visible content window. The view should use this to draw the
* appropriate bitmap tiles from {@link #BITMAP_MATRIX}.
*/
static final PropertyModel.WritableObjectPropertyKey<Rect> VIEWPORT =
new PropertyModel.WritableObjectPropertyKey<>();
/**
* A list of sub-frames that are currently visible. Each element in the list is a {@link Pair}
* consists of a {@link View}, that displays the sub-frame's content, and a {@link Rect}, that
* contains its location.
*/
static final PropertyModel.WritableObjectPropertyKey<List<Pair<View, Rect>>> SUBFRAME_VIEWS =
new PropertyModel.WritableObjectPropertyKey<>();
static final PropertyKey[] ALL_KEYS = {BITMAP_MATRIX, VIEWPORT, SUBFRAME_VIEWS};
}
// 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.components.paintpreview.player.frame;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Rect;
import android.support.annotation.NonNull;
import android.util.Pair;
import android.view.MotionEvent;
import android.view.View;
import android.widget.FrameLayout;
import java.util.List;
/**
* Responsible for detecting touch gestures, displaying the content of a frame and its sub-frames.
* {@link PlayerFrameBitmapPainter} is used for drawing the contents.
* Sub-frames are represented with individual {@link View}s. {@link #mSubFrames} contains the list
* of all sub-frames and their relative positions.
*/
class PlayerFrameView extends FrameLayout {
private PlayerFrameBitmapPainter mBitmapPainter;
private PlayerFrameGestureDetector mGestureDetector;
private PlayerFrameViewDelegate mDelegate;
private List<Pair<View, Rect>> mSubFrames;
/**
* @param context Used for initialization.
* @param canDetectZoom Whether this {@link View} should detect zoom (scale) gestures.
* @param playerFrameViewDelegate The interface used for forwarding events.
*/
PlayerFrameView(@NonNull Context context, boolean canDetectZoom,
PlayerFrameViewDelegate playerFrameViewDelegate) {
super(context);
mDelegate = playerFrameViewDelegate;
mBitmapPainter = new PlayerFrameBitmapPainter(this::invalidate);
mGestureDetector =
new PlayerFrameGestureDetector(context, canDetectZoom, playerFrameViewDelegate);
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
mDelegate.setLayoutDimensions(getWidth(), getHeight());
for (int i = 0; i < mSubFrames.size(); i++) {
View childView = getChildAt(i);
if (childView == null) {
continue;
}
Rect childRect = mSubFrames.get(i).second;
childView.layout(childRect.left, childRect.top, childRect.right, childRect.bottom);
}
}
/**
* Updates the sub-frames that this {@link PlayerFrameView} should display, along with their
* coordinates.
* @param subFrames List of all sub-frames, along with their coordinates.
*/
void updateSubFrames(List<Pair<View, Rect>> subFrames) {
// TODO(mahmoudi): Removing all views every time is not smart. Only remove the views that
// are not in subFrames.first.
mSubFrames = subFrames;
removeAllViews();
for (int i = 0; i < subFrames.size(); i++) {
addView(subFrames.get(i).first, i);
}
}
void updateViewPort(int left, int top, int right, int bottom) {
mBitmapPainter.updateViewPort(left, top, right, bottom);
}
void updateBitmapMatrix(Bitmap[][] bitmapMatrix) {
mBitmapPainter.updateBitmapMatrix(bitmapMatrix);
}
@Override
protected void onDraw(Canvas canvas) {
mBitmapPainter.onDraw(canvas);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
return mGestureDetector.onTouchEvent(event) || super.onTouchEvent(event);
}
}
// 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.components.paintpreview.player.frame;
import android.graphics.Rect;
import org.chromium.ui.modelutil.PropertyKey;
import org.chromium.ui.modelutil.PropertyModel;
/**
* Binds property changes in {@link PropertyModel} to {@link PlayerFrameView}.
*/
class PlayerFrameViewBinder {
static void bind(PropertyModel model, PlayerFrameView view, PropertyKey key) {
if (key.equals(PlayerFrameProperties.BITMAP_MATRIX)) {
view.updateBitmapMatrix(model.get(PlayerFrameProperties.BITMAP_MATRIX));
} else if (key.equals(PlayerFrameProperties.VIEWPORT)) {
Rect viewPort = model.get(PlayerFrameProperties.VIEWPORT);
view.updateViewPort(viewPort.left, viewPort.top, viewPort.right, viewPort.bottom);
} else if (key.equals(PlayerFrameProperties.SUBFRAME_VIEWS)) {
view.updateSubFrames(model.get(PlayerFrameProperties.SUBFRAME_VIEWS));
}
}
}
// 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.components.paintpreview.player.frame;
/**
* Used by {@link PlayerFrameView} to delegate view events to {@link PlayerFrameMediator}.
*/
interface PlayerFrameViewDelegate {
/**
* Called on layout with the attributed width and height.
*/
void setLayoutDimensions(int width, int height);
/**
* Called when a scroll gesture is performed.
* @param distanceX Horizontal scroll values in pixels.
* @param distanceY Vertical scroll values in pixels.
* @return Whether this scroll event was consumed.
*/
boolean scrollBy(float distanceX, float distanceY);
/**
* Called when a scale gesture is performed.
* @return Whether this scale event was consumed.
*/
boolean scaleBy(float scaleFactor, float focalPointX, float focalPointY);
/**
* Called when a single tap gesture is performed.
* @param x X coordinate of the point clicked.
* @param y Y coordinate of the point clicked.
*/
void onClick(int x, int y);
}
// 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.components.paintpreview.player.frame;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mockito;
import org.chromium.base.test.BaseRobolectricTestRunner;
import java.util.ArrayList;
import java.util.List;
/**
* Tests for the {@link PlayerFrameBitmapPainter} class.
*/
@RunWith(BaseRobolectricTestRunner.class)
public class PlayerFrameBitmapPainterTest {
/**
* Mocks {@link Canvas} and holds all calls to
* {@link Canvas#drawBitmap(Bitmap, Rect, Rect, Paint)}.
*/
private class MockCanvas extends Canvas {
private List<DrawnBitmap> mDrawnBitmaps = new ArrayList<>();
private class DrawnBitmap {
private final Bitmap mBitmap;
private final Rect mSrc;
private final Rect mDst;
private DrawnBitmap(Bitmap bitmap, Rect src, Rect dst) {
mBitmap = bitmap;
mSrc = new Rect(src);
mDst = new Rect(dst);
}
@Override
public boolean equals(Object o) {
if (o == null) return false;
if (this == o) return true;
if (getClass() != o.getClass()) return false;
DrawnBitmap od = (DrawnBitmap) o;
return mBitmap.equals(od.mBitmap) && mSrc.equals(od.mSrc) && mDst.equals(od.mDst);
}
}
@Override
public void drawBitmap(@NonNull Bitmap bitmap, @Nullable Rect src, @NonNull Rect dst,
@Nullable Paint paint) {
mDrawnBitmaps.add(new DrawnBitmap(bitmap, src, dst));
}
/**
* Asserts if a portion of a given bitmap has been drawn on this canvas.
*/
private void assertDrawBitmap(
@NonNull Bitmap bitmap, @Nullable Rect src, @NonNull Rect dst) {
Assert.assertTrue(bitmap + " has not been drawn from " + src + " to " + dst,
mDrawnBitmaps.contains(new DrawnBitmap(bitmap, src, dst)));
}
/**
* Asserts the number of bitmap draw operations on this canvas.
*/
private void assertNumberOfBitmapDraws(int expected) {
Assert.assertEquals(expected, mDrawnBitmaps.size());
}
}
/**
* Verifies no draw operations are performed on the canvas if the view port is invalid.
*/
@Test
public void testDrawFaultyViewPort() {
PlayerFrameBitmapPainter painter =
new PlayerFrameBitmapPainter(Mockito.mock(Runnable.class));
painter.updateBitmapMatrix(new Bitmap[2][3]);
painter.updateViewPort(0, 5, 10, -10);
MockCanvas canvas = new MockCanvas();
painter.onDraw(canvas);
canvas.assertNumberOfBitmapDraws(0);
// Update the view port so it is covered by 2 bitmap tiles.
painter.updateViewPort(0, 5, 10, 15);
painter.onDraw(canvas);
canvas.assertNumberOfBitmapDraws(2);
}
/**
* Verifies no draw operations are performed on the canvas if the bitmap matrix is invalid.
*/
@Test
public void testDrawFaultyBitmapMatrix() {
PlayerFrameBitmapPainter painter =
new PlayerFrameBitmapPainter(Mockito.mock(Runnable.class));
painter.updateBitmapMatrix(new Bitmap[0][0]);
// This view port is covered by 2 bitmap tiles, so there should be 2 draw operations on
// the canvas.
painter.updateViewPort(0, 5, 10, 15);
MockCanvas canvas = new MockCanvas();
painter.onDraw(canvas);
canvas.assertNumberOfBitmapDraws(0);
painter.updateBitmapMatrix(new Bitmap[2][1]);
painter.onDraw(canvas);
canvas.assertNumberOfBitmapDraws(2);
}
/**
* Verified {@link PlayerFrameBitmapPainter#onDraw} draws the right bitmap tiles, at the correct
* coordinates, for the given view port.
*/
@Test
public void testDraw() {
Runnable invalidator = Mockito.mock(Runnable.class);
PlayerFrameBitmapPainter painter = new PlayerFrameBitmapPainter(invalidator);
// Prepare the bitmap matrix.
Bitmap[][] bitmaps = new Bitmap[2][2];
Bitmap bitmap00 = Mockito.mock(Bitmap.class);
Bitmap bitmap10 = Mockito.mock(Bitmap.class);
Bitmap bitmap01 = Mockito.mock(Bitmap.class);
Bitmap bitmap11 = Mockito.mock(Bitmap.class);
bitmaps[0][0] = bitmap00;
bitmaps[1][0] = bitmap10;
bitmaps[0][1] = bitmap01;
bitmaps[1][1] = bitmap11;
painter.updateBitmapMatrix(bitmaps);
painter.updateViewPort(5, 10, 15, 25);
// Make sure the invalidator was called after updating the bitmap matrix and the view port.
Mockito.verify(invalidator, Mockito.times(2)).run();
MockCanvas canvas = new MockCanvas();
painter.onDraw(canvas);
// Verify that the correct portions of each bitmap tiles is painted in the correct
// positions of in the canvas.
canvas.assertNumberOfBitmapDraws(4);
canvas.assertDrawBitmap(bitmap00, new Rect(5, 10, 10, 15), new Rect(0, 0, 5, 5));
canvas.assertDrawBitmap(bitmap10, new Rect(5, 0, 10, 10), new Rect(0, 5, 5, 15));
canvas.assertDrawBitmap(bitmap01, new Rect(0, 10, 5, 15), new Rect(5, 0, 10, 5));
canvas.assertDrawBitmap(bitmap11, new Rect(0, 0, 5, 10), new Rect(5, 5, 10, 15));
}
}
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