Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 7d8968bc authored by Mark Renouf's avatar Mark Renouf
Browse files

Adds scroll capture support for RecyclerView

Test: atest RecyclerViewCaptureHelperTest

Change-Id: I2480a6761daf6e874703e8aab414edbbe8558c06
parent 8b323f22
Loading
Loading
Loading
Loading
+202 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2020 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.internal.view;

import android.animation.ValueAnimator;
import android.annotation.NonNull;
import android.graphics.Rect;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;

/**
 * ScrollCapture for RecyclerView and <i>RecyclerView-like</i> ViewGroups.
 * <p>
 * Requirements for proper operation:
 * <ul>
 * <li>at least one visible child view</li>
 * <li>scrolls by pixels in response to {@link View#scrollBy(int, int)}.
 * <li>reports ability to scroll with {@link View#canScrollVertically(int)}
 * <li>properly implements {@link ViewParent#requestChildRectangleOnScreen(View, Rect, boolean)}
 * </ul>
 *
 * @see ScrollCaptureViewSupport
 */
public class RecyclerViewCaptureHelper implements ScrollCaptureViewHelper<ViewGroup> {

    // Experiment
    private static final boolean DISABLE_ANIMATORS = false;
    // Experiment
    private static final boolean STOP_RENDER_THREAD = false;

    private static final String TAG = "RVCaptureHelper";
    private int mScrollDelta;
    private boolean mScrollBarWasEnabled;
    private int mOverScrollMode;
    private float mDurationScale;

    @Override
    public void onPrepareForStart(@NonNull ViewGroup view, Rect scrollBounds) {
        mScrollDelta = 0;

        mOverScrollMode = view.getOverScrollMode();
        view.setOverScrollMode(View.OVER_SCROLL_NEVER);

        mScrollBarWasEnabled = view.isVerticalScrollBarEnabled();
        view.setVerticalScrollBarEnabled(false);
        if (DISABLE_ANIMATORS) {
            mDurationScale = ValueAnimator.getDurationScale();
            ValueAnimator.setDurationScale(0);
        }
        if (STOP_RENDER_THREAD) {
            view.getThreadedRenderer().stop();
        }
    }

    @Override
    public ScrollResult onScrollRequested(@NonNull ViewGroup recyclerView, Rect scrollBounds,
            Rect requestRect) {
        ScrollResult result = new ScrollResult();
        result.requestedArea = new Rect(requestRect);
        result.scrollDelta = mScrollDelta;
        result.availableArea = new Rect(); // empty

        Log.d(TAG, "current scrollDelta: " + mScrollDelta);
        if (!recyclerView.isVisibleToUser() || recyclerView.getChildCount() == 0) {
            Log.w(TAG, "recyclerView is empty or not visible, cannot continue");
            return result; // result.availableArea == empty Rect
        }

        // move from scrollBounds-relative to parent-local coordinates
        Rect requestedContainerBounds = new Rect(requestRect);
        requestedContainerBounds.offset(0, -mScrollDelta);
        requestedContainerBounds.offset(scrollBounds.left, scrollBounds.top);

        // requestedContainerBounds is now in recyclerview-local coordinates
        Log.d(TAG, "requestedContainerBounds: " + requestedContainerBounds);

        // Save a copy for later
        View anchor = findChildNearestTarget(recyclerView, requestedContainerBounds);
        if (anchor == null) {
            Log.d(TAG, "Failed to locate anchor view");
            return result; // result.availableArea == null
        }

        Log.d(TAG, "Anchor view:" + anchor);
        Rect requestedContentBounds = new Rect(requestedContainerBounds);
        recyclerView.offsetRectIntoDescendantCoords(anchor, requestedContentBounds);

        Log.d(TAG, "requestedContentBounds = " + requestedContentBounds);
        int prevAnchorTop = anchor.getTop();
        // Note: requestChildRectangleOnScreen may modify rectangle, must pass pass in a copy here
        Rect input = new Rect(requestedContentBounds);
        if (recyclerView.requestChildRectangleOnScreen(anchor, input, true)) {
            int scrolled = prevAnchorTop - anchor.getTop(); // inverse of movement
            Log.d(TAG, "RecyclerView scrolled by " + scrolled + " px");
            mScrollDelta += scrolled; // view.top-- is equivalent to parent.scrollY++
            result.scrollDelta = mScrollDelta;
            Log.d(TAG, "requestedContentBounds, (post-request-rect) = " + requestedContentBounds);
        }

        requestedContainerBounds.set(requestedContentBounds);
        recyclerView.offsetDescendantRectToMyCoords(anchor, requestedContainerBounds);
        Log.d(TAG, "requestedContainerBounds, (post-scroll): " + requestedContainerBounds);

        Rect recyclerLocalVisible = new Rect(scrollBounds);
        recyclerView.getLocalVisibleRect(recyclerLocalVisible);
        Log.d(TAG, "recyclerLocalVisible: " + recyclerLocalVisible);

        if (!requestedContainerBounds.intersect(recyclerLocalVisible)) {
            // Requested area is still not visible
            Log.d(TAG, "requested bounds not visible!");
            return result;
        }
        Rect available = new Rect(requestedContainerBounds);
        available.offset(-scrollBounds.left, -scrollBounds.top);
        available.offset(0, mScrollDelta);
        result.availableArea = available;
        Log.d(TAG, "availableArea: " + result.availableArea);
        return result;
    }

    /**
     * Find a view that is located "closest" to targetRect. Returns the first view to fully
     * vertically overlap the target targetRect. If none found, returns the view with an edge
     * nearest the target targetRect.
     *
     * @param parent the parent vertical layout
     * @param targetRect a rectangle in local coordinates of <code>parent</code>
     * @return a child view within parent matching the criteria or null
     */
    static View findChildNearestTarget(ViewGroup parent, Rect targetRect) {
        View selected = null;
        int minCenterDistance = Integer.MAX_VALUE;
        int maxOverlap = 0;

        // allowable center-center distance, relative to targetRect.
        // if within this range, taller views are preferred
        final float preferredRangeFromCenterPercent = 0.25f;
        final int preferredDistance =
                (int) (preferredRangeFromCenterPercent * targetRect.height());

        Rect parentLocalVis = new Rect();
        parent.getLocalVisibleRect(parentLocalVis);
        Log.d(TAG, "findChildNearestTarget: parentVis=" + parentLocalVis
                + " targetRect=" + targetRect);

        Rect frame = new Rect();
        for (int i = 0; i < parent.getChildCount(); i++) {
            final View child = parent.getChildAt(i);
            child.getHitRect(frame);
            Log.d(TAG, "child #" + i + " hitRect=" + frame);

            if (child.getVisibility() != View.VISIBLE) {
                Log.d(TAG, "child #" + i + " is not visible");
                continue;
            }

            int centerDistance = Math.abs(targetRect.centerY() - frame.centerY());
            Log.d(TAG, "child #" + i + " : center to center: " + centerDistance + "px");

            if (centerDistance < minCenterDistance) {
                // closer to center
                minCenterDistance = centerDistance;
                selected = child;
            } else if (frame.intersect(targetRect) && (frame.height() > preferredDistance)) {
                // within X% pixels of center, but taller
                selected = child;
            }
        }
        return selected;
    }


    @Override
    public void onPrepareForEnd(@NonNull ViewGroup view) {
        // Restore original position and state
        view.scrollBy(0, mScrollDelta);
        view.setOverScrollMode(mOverScrollMode);
        view.setVerticalScrollBarEnabled(mScrollBarWasEnabled);
        if (DISABLE_ANIMATORS) {
            ValueAnimator.setDurationScale(mDurationScale);
        }
        if (STOP_RENDER_THREAD) {
            view.getThreadedRenderer().start();
        }
    }
}
+88 −3
Original line number Diff line number Diff line
@@ -17,8 +17,11 @@
package com.android.internal.view;

import android.annotation.Nullable;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Point;
import android.graphics.Rect;
import android.util.Log;
import android.view.ScrollCaptureCallback;
import android.view.View;
import android.view.ViewGroup;
@@ -29,6 +32,12 @@ import android.view.ViewGroup;
public class ScrollCaptureInternal {
    private static final String TAG = "ScrollCaptureInternal";

    // Log found scrolling views
    private static final boolean DEBUG = true;

    // Log all investigated views, as well as heuristic checks
    private static final boolean DEBUG_VERBOSE = false;

    private static final int UP = -1;
    private static final int DOWN = 1;

@@ -57,38 +66,72 @@ public class ScrollCaptureInternal {
     * This needs to be fast and not alloc memory. It's called on everything in the tree not marked
     * as excluded during scroll capture search.
     */
    public static int detectScrollingType(View view) {
    private static int detectScrollingType(View view) {
        // Must be a ViewGroup
        if (!(view instanceof ViewGroup)) {
            if (DEBUG_VERBOSE) {
                Log.v(TAG, "hint: not a subclass of ViewGroup");
            }
            return TYPE_FIXED;
        }
        if (DEBUG_VERBOSE) {
            Log.v(TAG, "hint: is a subclass of ViewGroup");
        }
        // Confirm that it can scroll.
        if (!(view.canScrollVertically(DOWN) || view.canScrollVertically(UP))) {
            // Nothing to scroll here, move along.
            if (DEBUG_VERBOSE) {
                Log.v(TAG, "hint: cannot be scrolled");
            }
            return TYPE_FIXED;
        }
        if (DEBUG_VERBOSE) {
            Log.v(TAG, "hint: can be scrolled up or down");
        }
        // ScrollViews accept only a single child.
        if (((ViewGroup) view).getChildCount() > 1) {
            if (DEBUG_VERBOSE) {
                Log.v(TAG, "hint: scrollable with multiple children");
            }
            return TYPE_RECYCLING;
        }
        if (DEBUG_VERBOSE) {
            Log.v(TAG, "hint: less than two child views");
        }
        //Because recycling containers don't use scrollY, a non-zero value means Scroll view.
        if (view.getScrollY() != 0) {
            if (DEBUG_VERBOSE) {
                Log.v(TAG, "hint: scrollY != 0");
            }
            return TYPE_SCROLLING;
        }
        Log.v(TAG, "hint: scrollY == 0");
        // Since scrollY cannot be negative, this means a Recycling view.
        if (view.canScrollVertically(UP)) {
            if (DEBUG_VERBOSE) {
                Log.v(TAG, "hint: able to scroll up");
            }
            return TYPE_RECYCLING;
        }
        // canScrollVertically(UP) == false, getScrollY() == 0, getChildCount() == 1.
        if (DEBUG_VERBOSE) {
            Log.v(TAG, "hint: cannot be scrolled up");
        }

        // canScrollVertically(UP) == false, getScrollY() == 0, getChildCount() == 1.
        // For Recycling containers, this should be a no-op (RecyclerView logs a warning)
        view.scrollTo(view.getScrollX(), 1);

        // A scrolling container would have moved by 1px.
        if (view.getScrollY() == 1) {
            view.scrollTo(view.getScrollX(), 0);
            if (DEBUG_VERBOSE) {
                Log.v(TAG, "hint: scrollTo caused scrollY to change");
            }
            return TYPE_SCROLLING;
        }
        if (DEBUG_VERBOSE) {
            Log.v(TAG, "hint: scrollTo did not cause scrollY to change");
        }
        return TYPE_RECYCLING;
    }

@@ -99,19 +142,61 @@ public class ScrollCaptureInternal {
     * @param localVisibleRect the visible area of the given view in local coordinates, as supplied
     *                         by the view parent
     * @param positionInWindow the offset of localVisibleRect within the window
     *
     * @return a new callback or null if the View isn't supported
     */
    @Nullable
    public ScrollCaptureCallback requestCallback(View view, Rect localVisibleRect,
            Point positionInWindow) {
        // Nothing to see here yet.
        if (DEBUG_VERBOSE) {
            Log.v(TAG, "scroll capture: checking " + view.getClass().getName()
                    + "[" + resolveId(view.getContext(), view.getId()) + "]");
        }
        int i = detectScrollingType(view);
        switch (i) {
            case TYPE_SCROLLING:
                if (DEBUG) {
                    Log.d(TAG, "scroll capture: FOUND " + view.getClass().getName()
                            + "[" + resolveId(view.getContext(), view.getId()) + "]"
                            + " -> TYPE_SCROLLING");
                }
                return new ScrollCaptureViewSupport<>((ViewGroup) view,
                        new ScrollViewCaptureHelper());
            case TYPE_RECYCLING:
                if (DEBUG) {
                    Log.d(TAG, "scroll capture: FOUND " + view.getClass().getName()
                            + "[" + resolveId(view.getContext(), view.getId()) + "]"
                            + " -> TYPE_RECYCLING");
                }
                return new ScrollCaptureViewSupport<>((ViewGroup) view,
                        new RecyclerViewCaptureHelper());
            case TYPE_FIXED:
                // ignore
                break;

        }
        return null;
    }

    // Lifted from ViewDebug (package protected)

    private static String formatIntToHexString(int value) {
        return "0x" + Integer.toHexString(value).toUpperCase();
    }

    static String resolveId(Context context, int id) {
        String fieldValue;
        final Resources resources = context.getResources();
        if (id >= 0) {
            try {
                fieldValue = resources.getResourceTypeName(id) + '/'
                        + resources.getResourceEntryName(id);
            } catch (Resources.NotFoundException e) {
                fieldValue = "id/" + formatIntToHexString(id);
            }
        } else {
            fieldValue = "NO_ID";
        }
        return fieldValue;
    }
}
+7 −6
Original line number Diff line number Diff line
@@ -17,7 +17,6 @@
package com.android.internal.view;

import android.annotation.NonNull;
import android.annotation.Nullable;
import android.graphics.Rect;
import android.view.View;

@@ -62,8 +61,8 @@ interface ScrollCaptureViewHelper<V extends View> {
     * @param view the view being captured
     * @return true if the callback should respond to a request with scroll bounds
     */
    default boolean onAcceptSession(@Nullable V view) {
        return view != null && view.isVisibleToUser()
    default boolean onAcceptSession(@NonNull V view) {
        return view.isVisibleToUser()
                && (view.canScrollVertically(UP) || view.canScrollVertically(DOWN));
    }

@@ -73,7 +72,7 @@ interface ScrollCaptureViewHelper<V extends View> {
     *
     * @param view the view being captured
     */
    default Rect onComputeScrollBounds(@Nullable V view) {
    @NonNull default Rect onComputeScrollBounds(@NonNull V view) {
        return new Rect(view.getPaddingLeft(), view.getPaddingTop(),
                view.getWidth() - view.getPaddingRight(),
                view.getHeight() - view.getPaddingBottom());
@@ -88,7 +87,7 @@ interface ScrollCaptureViewHelper<V extends View> {
     * @param view         the view being captured
     * @param scrollBounds the bounds within {@code view} where content scrolls
     */
    void onPrepareForStart(@NonNull V view, Rect scrollBounds);
    void onPrepareForStart(@NonNull V view, @NonNull Rect scrollBounds);

    /**
     * Map the request onto the screen.
@@ -105,7 +104,9 @@ interface ScrollCaptureViewHelper<V extends View> {
     *                     content to capture for the request
     * @return the result of the request as a {@link ScrollResult}
     */
    ScrollResult onScrollRequested(@NonNull V view, Rect scrollBounds, Rect requestRect);
    @NonNull
    ScrollResult onScrollRequested(@NonNull V view, @NonNull Rect scrollBounds,
            @NonNull Rect requestRect);

    /**
     * Restore the target after capture.
+53 −19
Original line number Diff line number Diff line
@@ -23,8 +23,8 @@ import android.graphics.Rect;
import android.graphics.RectF;
import android.graphics.RenderNode;
import android.os.Handler;
import android.os.SystemClock;
import android.util.DisplayMetrics;
import android.util.Log;
import android.view.ScrollCaptureCallback;
import android.view.ScrollCaptureSession;
import android.view.Surface;
@@ -46,8 +46,12 @@ import java.util.function.Consumer;
 */
public class ScrollCaptureViewSupport<V extends View> implements ScrollCaptureCallback {

    public static final long NO_FRAME_PRODUCED = -1;

    private static final String TAG = "ScrollCaptureViewSupport";

    private static final boolean WAIT_FOR_ANIMATION = true;

    private final WeakReference<V> mWeakView;
    private final ScrollCaptureViewHelper<V> mViewHelper;
    private ViewRenderer mRenderer;
@@ -99,12 +103,16 @@ public class ScrollCaptureViewSupport<V extends View> implements ScrollCaptureCa
        V view = mWeakView.get();
        if (view == null || !view.isVisibleToUser()) {
            // Signal to the controller that we have a problem and can't continue.
            session.notifyBufferSent(0, null);
            session.notifyBufferSent(NO_FRAME_PRODUCED, new Rect());
            return;
        }
        // Ask the view to scroll as needed to bring this area into view.
        ScrollResult scrollResult = mViewHelper.onScrollRequested(view, session.getScrollBounds(),
                requestRect);
        if (scrollResult.availableArea.isEmpty()) {
            session.notifyBufferSent(NO_FRAME_PRODUCED, scrollResult.availableArea);
            return;
        }
        view.invalidate(); // don't wait for vsync

        // For image capture, shift back by scrollDelta to arrive at the location within the view
@@ -112,8 +120,19 @@ public class ScrollCaptureViewSupport<V extends View> implements ScrollCaptureCa
        Rect viewCaptureArea = new Rect(scrollResult.availableArea);
        viewCaptureArea.offset(0, -scrollResult.scrollDelta);

        mRenderer.renderView(view, viewCaptureArea, mUiHandler,
                (frameNumber) -> session.notifyBufferSent(frameNumber, scrollResult.availableArea));
        if (WAIT_FOR_ANIMATION) {
            Log.d(TAG, "render: delaying until animation");
            view.postOnAnimation(() ->  {
                Log.d(TAG, "postOnAnimation(): rendering now");
                long resultFrame = mRenderer.renderView(view, viewCaptureArea);
                Log.d(TAG, "notifyBufferSent: " + scrollResult.availableArea);

                session.notifyBufferSent(resultFrame, new Rect(scrollResult.availableArea));
            });
        } else {
            long resultFrame = mRenderer.renderView(view, viewCaptureArea);
            session.notifyBufferSent(resultFrame, new Rect(scrollResult.availableArea));
        }
    }

    @Override
@@ -132,8 +151,7 @@ public class ScrollCaptureViewSupport<V extends View> implements ScrollCaptureCa

    /**
     * Internal helper class which assists in rendering sections of the view hierarchy relative to a
     * given view. Used by framework implementations of ScrollCaptureHandler to render and dispatch
     * image requests.
     * given view.
     */
    static final class ViewRenderer {
        // alpha, "reasonable default" from Javadoc
@@ -157,14 +175,11 @@ public class ScrollCaptureViewSupport<V extends View> implements ScrollCaptureCa
        private final Matrix mTempMatrix = new Matrix();
        private final int[] mTempLocation = new int[2];
        private long mLastRenderedSourceDrawingId = -1;


        public interface FrameCompleteListener {
            void onFrameComplete(long frameNumber);
        }
        private Surface mSurface;

        ViewRenderer() {
            mRenderer = new HardwareRenderer();
            mRenderer.setName("ScrollCapture");
            mCaptureRenderNode = new RenderNode("ScrollCaptureRoot");
            mRenderer.setContentRoot(mCaptureRenderNode);

@@ -173,6 +188,7 @@ public class ScrollCaptureViewSupport<V extends View> implements ScrollCaptureCa
        }

        public void setSurface(Surface surface) {
            mSurface = surface;
            mRenderer.setSurface(surface);
        }

@@ -223,20 +239,38 @@ public class ScrollCaptureViewSupport<V extends View> implements ScrollCaptureCa
            mCaptureRenderNode.endRecording();
        }

        public void renderView(View view, Rect sourceRect, Handler handler,
                FrameCompleteListener frameListener) {
        public long renderView(View view, Rect sourceRect) {
            if (updateForView(view)) {
                setupLighting(view);
            }
            view.invalidate();
            updateRootNode(view, sourceRect);
            HardwareRenderer.FrameRenderRequest request = mRenderer.createRenderRequest();
            request.setVsyncTime(SystemClock.elapsedRealtimeNanos());
            // private API b/c request.setFrameCommitCallback does not provide access to frameNumber
            mRenderer.setFrameCompleteCallback(
                    frameNr -> handler.post(() -> frameListener.onFrameComplete(frameNr)));
            long timestamp = System.nanoTime();
            request.setVsyncTime(timestamp);

            // Would be nice to access nextFrameNumber from HwR without having to hold on to Surface
            final long frameNumber = mSurface.getNextFrameNumber();

            // Block until a frame is presented to the Surface
            request.setWaitForPresent(true);
            request.syncAndDraw();

            switch (request.syncAndDraw()) {
                case HardwareRenderer.SYNC_OK:
                case HardwareRenderer.SYNC_REDRAW_REQUESTED:
                    return frameNumber;

                case HardwareRenderer.SYNC_FRAME_DROPPED:
                    Log.e(TAG, "syncAndDraw(): SYNC_FRAME_DROPPED !");
                    break;
                case HardwareRenderer.SYNC_LOST_SURFACE_REWARD_IF_FOUND:
                    Log.e(TAG, "syncAndDraw(): SYNC_LOST_SURFACE !");
                    break;
                case HardwareRenderer.SYNC_CONTEXT_IS_STOPPED:
                    Log.e(TAG, "syncAndDraw(): SYNC_CONTEXT_IS_STOPPED !");
                    break;
            }
            return NO_FRAME_PRODUCED;
        }

        public void trimMemory() {
@@ -244,6 +278,7 @@ public class ScrollCaptureViewSupport<V extends View> implements ScrollCaptureCa
        }

        public void destroy() {
            mSurface = null;
            mRenderer.destroy();
        }

@@ -254,6 +289,5 @@ public class ScrollCaptureViewSupport<V extends View> implements ScrollCaptureCa
            mTempMatrix.mapRect(mTempRectF);
            mTempRectF.round(outRect);
        }

    }
}
+12 −8
Original line number Diff line number Diff line
@@ -57,10 +57,6 @@ public class ScrollViewCaptureHelper implements ScrollCaptureViewHelper<ViewGrou

    public ScrollResult onScrollRequested(@NonNull ViewGroup view, Rect scrollBounds,
            Rect requestRect) {
        final View contentView = view.getChildAt(0); // returns null, does not throw IOOBE
        if (contentView == null) {
            return null;
        }
        /*
               +---------+ <----+ Content [25,25 - 275,1025] (w=250,h=1000)
               |         |
@@ -88,9 +84,6 @@ public class ScrollViewCaptureHelper implements ScrollCaptureViewHelper<ViewGrou
            \__ Requested Bounds[0,300 - 200,400] (200x100)
       */

        ScrollResult result = new ScrollResult();
        result.requestedArea = new Rect(requestRect);

        // 0) adjust the requestRect to account for scroll change since start
        //
        //  Scroll Bounds[50,50 - 250,250]  (w=200,h=200)
@@ -99,6 +92,17 @@ public class ScrollViewCaptureHelper implements ScrollCaptureViewHelper<ViewGrou
        // (y-100) (scrollY - mStartScrollY)
        int scrollDelta = view.getScrollY() - mStartScrollY;

        final ScrollResult result = new ScrollResult();
        result.requestedArea = new Rect(requestRect);
        result.scrollDelta = scrollDelta;
        result.availableArea = new Rect();

        final View contentView = view.getChildAt(0); // returns null, does not throw IOOBE
        if (contentView == null) {
            // No child view? Cannot continue.
            return result;
        }

        //  1) Translate request rect to make it relative to container view
        //
        //  Container View [0,0 - 300,300] (scrollY=200)
@@ -133,7 +137,7 @@ public class ScrollViewCaptureHelper implements ScrollCaptureViewHelper<ViewGrou
        // TODO: crop capture area to avoid occlusions/minimize scroll changes

        Point offset = new Point();
        final Rect available = new Rect(requestedContentBounds); // empty
        final Rect available = new Rect(requestedContentBounds);
        if (!view.getChildVisibleRect(contentView, available, offset)) {
            available.setEmpty();
            result.availableArea = available;
Loading