commit | 5bc7a1f9dd5d3be7b7901d3fd57a15cb54ae23d5 | [log] [tgz] |
---|---|---|
author | Aurimas Liutikas | Fri Jul 08 05:54:23 2022 +0000 |
committer | Gerrit Code Review | Fri Jul 08 05:54:23 2022 +0000 |
tree | f10eaf64ada359bd43344ab4a90e99c008eef4dd | |
parent | c7a06fecf8b4d9abc772be0173eaa348fee19454 [diff] | |
parent | b409a803bb933416c36243dc6b3ff40b7f9f257f [diff] |
Merge "Use Gradle daemons in CI." into androidx-main
diff --git a/PREUPLOAD.cfg b/PREUPLOAD.cfg index f0ae583..619fe797 100644 --- a/PREUPLOAD.cfg +++ b/PREUPLOAD.cfg
@@ -1,6 +1,6 @@ [Hook Scripts] checkstyle_hook = ${REPO_ROOT}/prebuilts/checkstyle/checkstyle.py --sha ${PREUPLOAD_COMMIT} -c ${REPO_ROOT}/frameworks/support/development/checkstyle/config/support-lib.xml -p development/checkstyle/prebuilt/com.android.support.checkstyle.jar -ktlint_hook = ${REPO_ROOT}/frameworks/support/gradlew -q -p ${REPO_ROOT}/frameworks/support --continue :ktlintCheckFile --configuration-cache --file=${PREUPLOAD_FILES_PREFIXED} +ktlint_hook = ${REPO_ROOT}/frameworks/support/development/ktlint.sh --file=${PREUPLOAD_FILES_PREFIXED} warn_check_api = ${REPO_ROOT}/frameworks/support/development/apilint.py -f ${PREUPLOAD_FILES} [Builtin Hooks]
diff --git a/buildSrc/shared.gradle b/buildSrc/shared.gradle index ae36e01..95df1b7 100644 --- a/buildSrc/shared.gradle +++ b/buildSrc/shared.gradle
@@ -20,6 +20,7 @@ // artifacts are put into these configurations than when those artifacts are put into their // corresponding configuration. cacheableApi { + canBeConsumed = false // not directly used to satisfy a project dependency attributes { attribute(Usage.USAGE_ATTRIBUTE, project.objects.named(Usage.class, Usage.JAVA_RUNTIME)); attribute(Category.CATEGORY_ATTRIBUTE, project.objects.named(Category.class, Category.LIBRARY)); @@ -29,6 +30,7 @@ } } cacheableImplementation { + canBeConsumed = false // not directly used to satisfy a project dependency attributes { attribute(Usage.USAGE_ATTRIBUTE, project.objects.named(Usage.class, Usage.JAVA_RUNTIME)); attribute(Category.CATEGORY_ATTRIBUTE, project.objects.named(Category.class, Category.LIBRARY)); @@ -38,7 +40,9 @@ } extendsFrom(project.configurations.cacheableApi) } - cacheableRuntimeOnly + cacheableRuntimeOnly { + canBeConsumed = false // not directly used to satisfy a project dependency + } } dependencies {
diff --git a/camera/camera-core/src/androidTest/java/androidx/camera/core/processing/DefaultSurfaceEffectTest.kt b/camera/camera-core/src/androidTest/java/androidx/camera/core/processing/DefaultSurfaceEffectTest.kt index 76e86a3..a7a5d44 100644 --- a/camera/camera-core/src/androidTest/java/androidx/camera/core/processing/DefaultSurfaceEffectTest.kt +++ b/camera/camera-core/src/androidTest/java/androidx/camera/core/processing/DefaultSurfaceEffectTest.kt
@@ -16,8 +16,6 @@ package androidx.camera.core.processing -import android.graphics.Matrix -import android.graphics.Rect import android.hardware.camera2.CameraDevice.TEMPLATE_PREVIEW import android.media.ImageReader import android.os.Handler @@ -28,7 +26,6 @@ import androidx.camera.core.SurfaceRequest import androidx.camera.core.impl.DeferrableSurface import androidx.camera.core.impl.ImageFormatConstants -import androidx.camera.core.impl.ImmediateSurface import androidx.camera.core.impl.utils.executor.CameraXExecutors import androidx.camera.testing.CameraUtil import androidx.camera.testing.HandlerUtil @@ -110,7 +107,6 @@ private lateinit var handler: Handler private lateinit var dispatcher: CoroutineDispatcher private val inputSurfaceRequestsToClose = mutableListOf() - private val settableSurfacesToClose = mutableMapOf() private val fakeCamera = FakeCamera() @After @@ -128,10 +124,6 @@ for (surfaceRequest in inputSurfaceRequestsToClose) { surfaceRequest.deferrableSurface.close() } - for ((settableSurface, sourceSurface) in settableSurfacesToClose) { - settableSurface.close() - sourceSurface.close() - } } @Test @@ -193,9 +185,7 @@ fun requestCloseAfterOnOutputSurface_closeSurfaceOutput() { // Arrange. createSurfaceEffect() - val sourceSurface = ImmediateSurface(mock(Surface::class.java)) - val settableSurface = createSettableSurface(sourceSurface) - val surfaceOutput = createSurfaceOutput(settableSurface) + val surfaceOutput = createSurfaceOutput() // Act. surfaceEffect.onOutputSurface(surfaceOutput) @@ -205,10 +195,6 @@ // Assert. assertThat(surfaceOutput.isClosed).isTrue() - - // Clean-up. - settableSurface.close() - sourceSurface.close() } @Test @@ -332,9 +318,7 @@ } } } - val surfaceOutput = createSurfaceOutput( - settableSurface = createSettableSurface(ImmediateSurface(imageReader.surface)) - ) + val surfaceOutput = createSurfaceOutput(imageReader.surface) surfaceEffect.onOutputSurface(surfaceOutput) // Assert. @@ -357,7 +341,8 @@ TEMPLATE_PREVIEW, listOf(surface), null, - null) + null + ) } private fun createSurfaceEffect(shaderProvider: ShaderProvider = ShaderProvider.DEFAULT) { @@ -370,24 +355,14 @@ } } - private fun createSurfaceOutput(settableSurface: SettableSurface = createSettableSurface()) = - SurfaceOutputImpl(settableSurface, IDENTITY_MATRIX) - - private fun createSettableSurface( - source: DeferrableSurface = ImmediateSurface(mock(Surface::class.java)) - ) = SettableSurface( - SurfaceEffect.PREVIEW, - Size(WIDTH, HEIGHT), - ImageFormatConstants.INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE, - Matrix(), - false, - Rect(0, 0, WIDTH, HEIGHT), - 0, - false - ).apply { - settableSurfacesToClose[this] = source - setSource(source) - } + private fun createSurfaceOutput(surface: Surface = mock(Surface::class.java)) = + SurfaceOutputImpl( + surface, + SurfaceEffect.PREVIEW, + ImageFormatConstants.INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE, + Size(WIDTH, HEIGHT), + IDENTITY_MATRIX + ) private fun getCustomFragmentShader(samplerVarName: String, fragCoordsVarName: String) = String.format(
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/impl/DeferrableSurface.java b/camera/camera-core/src/main/java/androidx/camera/core/impl/DeferrableSurface.java index 1352d7f..064420e 100644 --- a/camera/camera-core/src/main/java/androidx/camera/core/impl/DeferrableSurface.java +++ b/camera/camera-core/src/main/java/androidx/camera/core/impl/DeferrableSurface.java
@@ -256,7 +256,7 @@ *This method is idempotent. Subsequent calls after the first invocation will have no
* effect. */ - public final void close() { + public void close() { // If this gets set, then the surface will terminate CallbackToFutureAdapter.CompleterterminationCompleter = null; synchronized (mLock) {
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/processing/SettableSurface.java b/camera/camera-core/src/main/java/androidx/camera/core/processing/SettableSurface.java index ffde0f7..7df74b7 100644 --- a/camera/camera-core/src/main/java/androidx/camera/core/processing/SettableSurface.java +++ b/camera/camera-core/src/main/java/androidx/camera/core/processing/SettableSurface.java
@@ -16,29 +16,51 @@ package androidx.camera.core.processing; +import static androidx.camera.core.impl.utils.Threads.checkMainThread; +import static androidx.camera.core.impl.utils.executor.CameraXExecutors.directExecutor; +import static androidx.camera.core.impl.utils.executor.CameraXExecutors.mainThreadExecutor; + import android.graphics.Matrix; import android.graphics.Rect; +import android.media.ImageReader; import android.os.Build; import android.util.Size; import android.view.Surface; import android.view.SurfaceView; import android.view.TextureView; +import androidx.annotation.MainThread; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; +import androidx.camera.core.SurfaceEffect; +import androidx.camera.core.SurfaceOutput; import androidx.camera.core.SurfaceRequest; import androidx.camera.core.UseCase; import androidx.camera.core.impl.CameraInternal; import androidx.camera.core.impl.DeferrableSurface; import androidx.camera.core.impl.utils.futures.Futures; import androidx.concurrent.futures.CallbackToFutureAdapter; +import androidx.core.util.Preconditions; import com.google.common.util.concurrent.ListenableFuture; -import java.util.concurrent.atomic.AtomicBoolean; - /** - * A {@link DeferrableSurface} with another {@link DeferrableSurface} as its source. + * An extended {@link DeferrableSurface} that links to external sources. + * + *+ * + *This class is an extension of {@link DeferrableSurface} with additional info of the
+ * surface such as crop rect and transformation. It also provides mechanisms to link to external + * surface provider/consumer, and propagates Surface releasing/closure to linked provider/consumer. + * + *An example of how {@link SettableSurface} connects an external surface provider to
+ * an external surface consumer: + * + * {@code PreviewView}(surface provider) <--> {@link SurfaceRequest} <--> {@link SettableSurface} + * <--> {@link SurfaceOutput} --> {@link SurfaceEffect}(surface consumer) + *
For the full workflow, please see {@code SettableSurfaceTest
+ * #linkBothProviderAndConsumer_surfaceAndResultsArePropagatedE2E} */ @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) public class SettableSurface extends DeferrableSurface { @@ -54,7 +76,13 @@ private final boolean mMirroring; private final int mTargets; - private final AtomicBoolean mHasSource; + // Guarded by main thread. + @Nullable + private SurfaceOutputImpl mConsumerToNotify; + // Guarded by main thread. + private boolean mHasProvider = false; + // Guarded by main thread. + private boolean mHasConsumer = false; /** * Please see the getters to understand the parameters. @@ -75,7 +103,6 @@ mCropRect = cropRect; mRotationDegrees = rotationDegrees; mMirroring = mirroring; - mHasSource = new AtomicBoolean(false); mSurfaceFuture = CallbackToFutureAdapter.getFuture( completer -> { mCompleter = completer; @@ -90,72 +117,143 @@ } /** - * Sets the internal source of this {@link SettableSurface}. + * Sets a {@link ListenableFutureThis method is used to set a {@link DeferrableSurface} as the source of this
- * {@link SettableSurface}. This is for organizing the pipeline internally. For example, using - * the output of one {@link UseCase} as the input of another {@link UseCase} for stream sharing. + *{@link SettableSurface} uses the surface provided by the provider. This method is for
+ * organizing the pipeline internally, for example, using a {@link ImageReader} to provide + * the surface. * - *It's also useful to link a new buffer request to an existing buffer, when the pipeline
- * is rebuilt partially. Example: - * - *
- * class NodeImplementation implements NodeIt throws {@link IllegalStateException} if the current {@link SettableSurface}
- * already has a source. + *It throws {@link IllegalStateException} if the current {@link SettableSurface}
+ * already has a provider. */ - public void setSource(@NonNull DeferrableSurface source) { - // TODO(b/234174360): propagate the #close() call to the source. Usually if the current - // DeferrableSurface is closed, the downstream one has to be closed as well. However, we - // should be able to "unset" the Surface too. e.g. when the pipeline is partially rebuilt - // during front/back camera switch VideoCapture, we don't want to propagate the close() - // call to the recording Surface. - if (mHasSource.compareAndSet(false, true)) { - Futures.propagate(source.getSurface(), mCompleter); - } else { - throw new IllegalStateException("The source has already been set."); - } + @MainThread + public void setProvider(@NonNull ListenableFutureThis method is used to request a {@link Surface} from an external source such as
- * {@code PreviewView}. The {@link Surface} provided via - * {@link SurfaceRequest#provideSurface} will be used as the source of this - * {@link SettableSurface}. + *Once connected, the parent (this {@link SettableSurface}) and the provider should be
+ * in sync on the following matters: 1) surface provision, 2) ref-counting, 3) closure and 4) + * termination. See the list below for details: + *
- * SurfaceOut surfaceOut = finalNode.apply(surfaceIn);
- * SurfaceRequest surfaceRequest = surfaceOut.getSurfaces().get(0)
- * .createSurfaceRequestAsSource(camera, false);
- * mSurfaceProviderExecutor.execute(
- * () -> surfaceProvider.onSurfaceRequested(surfaceRequest));
- *
+ * This method is for organizing the pipeline internally, for example, using the output of
+ * one {@link UseCase} as the input of another {@link UseCase} for stream sharing. * *It throws {@link IllegalStateException} if the current {@link SettableSurface}
- * already has a source. + * already has a provider. + * + * @throws SurfaceClosedException when the provider is already closed. This should never + * happen. */ + @MainThread + public void setProvider(@NonNull DeferrableSurface provider) throws SurfaceClosedException { + checkMainThread(); + setProvider(provider.getSurface()); + provider.incrementUseCount(); + getTerminationFuture().addListener(() -> { + provider.decrementUseCount(); + provider.close(); + }, directExecutor()); + } + + /** + * Creates a {@link SurfaceRequest} that is linked to this {@link SettableSurface}. + * + *The {@link SurfaceRequest} is for requesting a {@link Surface} from an external source
+ * such as {@code PreviewView} or {@code VideoCapture}. {@link SettableSurface} uses the + * {@link Surface} provided by {@link SurfaceRequest#provideSurface} as its source. For how + * the ref-counting works, please see the Javadoc of {@link #setProvider}. + * + *It throws {@link IllegalStateException} if the current {@link SettableSurface}
+ * already has a provider. + */ + @MainThread @NonNull - public SurfaceRequest createSurfaceRequestAsSource(@NonNull CameraInternal cameraInternal, - boolean isRGBA8888Required) { - SurfaceRequest surfaceRequest = new SurfaceRequest(getSize(), cameraInternal, - isRGBA8888Required); - setSource(surfaceRequest.getDeferrableSurface()); + public SurfaceRequest createSurfaceRequest(@NonNull CameraInternal cameraInternal) { + checkMainThread(); + // TODO(b/238230154) figure out how to support HDR. + SurfaceRequest surfaceRequest = new SurfaceRequest(getSize(), cameraInternal, true); + try { + setProvider(surfaceRequest.getDeferrableSurface()); + } catch (SurfaceClosedException e) { + // This should never happen. We just created the SurfaceRequest. It can't be closed. + throw new AssertionError("Surface is somehow already closed", e); + } return surfaceRequest; } /** + * Creates a {@link SurfaceOutput} that is linked to this {@link SettableSurface}. + * + *The {@link SurfaceOutput} is for providing a surface to an external target such
+ * as {@link SurfaceEffect}. + * + *This method returns a {@link ListenableFuture} that completes when the
+ * {@link SettableSurface#getSurface()} completes. The {@link SurfaceOutput} contains the + * surface and ref-counts the {@link SettableSurface}. + * + *Do not provide the {@link SurfaceOutput} to external target if the
+ * {@link ListenableFuture} fails. + */ + @MainThread + @NonNull + public ListenableFuturediff --git a/camera/camera-core/src/main/java/androidx/camera/core/processing/SurfaceOutputImpl.java b/camera/camera-core/src/main/java/androidx/camera/core/processing/SurfaceOutputImpl.java index 485eed7c..a8a91fa 100644 --- a/camera/camera-core/src/main/java/androidx/camera/core/processing/SurfaceOutputImpl.java +++ b/camera/camera-core/src/main/java/androidx/camera/core/processing/SurfaceOutputImpl.java
@@ -29,10 +29,10 @@ import androidx.camera.core.Logger; import androidx.camera.core.SurfaceEffect; import androidx.camera.core.SurfaceOutput; -import androidx.camera.core.impl.DeferrableSurface; -import androidx.core.util.Preconditions; +import androidx.concurrent.futures.CallbackToFutureAdapter; -import java.util.concurrent.ExecutionException; +import com.google.common.util.concurrent.ListenableFuture; + import java.util.concurrent.Executor; import java.util.concurrent.RejectedExecutionException; @@ -41,7 +41,7 @@ * A implementation of {@link SurfaceOutput} that wraps a {@link SettableSurface}. */ @RequiresApi(21) -public final class SurfaceOutputImpl implements SurfaceOutput { +final class SurfaceOutputImpl implements SurfaceOutput { private static final String TAG = "SurfaceOutputImpl"; @@ -49,8 +49,10 @@ @NonNull private final Surface mSurface; + private final int mTargets; + private final int mFormat; @NonNull - private final SettableSurface mSettableSurface; + private final Size mSize; @NonNull private final float[] mAdditionalTransform; @@ -66,22 +68,29 @@ @GuardedBy("mLock") private boolean mIsClosed = false; - /** - * @param settableSurface the state of settableSurface.getSurface() must be complete at the - * time of calling. - */ - public SurfaceOutputImpl( - @NonNull SettableSurface settableSurface, - @NonNull float[] additionalTransform) - throws ExecutionException, InterruptedException, - DeferrableSurface.SurfaceClosedException { - mSettableSurface = settableSurface; - Preconditions.checkState(settableSurface.getSurface().isDone()); - mSurface = settableSurface.getSurface().get(); - mSettableSurface.incrementUseCount(); + @NonNull + private final ListenableFuturemCloseFuture; + private CallbackToFutureAdapter.CompletermCloseFutureCompleter; + + SurfaceOutputImpl( + @NonNull Surface surface, + // TODO(b/238222270): annotate targets with IntDef. + int targets, + int format, + @NonNull Size size, + @NonNull float[] additionalTransform) { + mSurface = surface; + mTargets = targets; + mFormat = format; + mSize = size; mAdditionalTransform = new float[16]; System.arraycopy(additionalTransform, 0, mAdditionalTransform, 0, additionalTransform.length); + mCloseFuture = CallbackToFutureAdapter.getFuture( + completer -> { + mCloseFutureCompleter = completer; + return "SurfaceOutputImpl close future complete"; + }); } /** @@ -137,7 +146,7 @@ */ @Override public int getTargets() { - return mSettableSurface.getTargets(); + return mTargets; } /** @@ -146,7 +155,7 @@ @Override @NonNull public Size getSize() { - return mSettableSurface.getSize(); + return mSize; } /** @@ -154,7 +163,7 @@ */ @Override public int getFormat() { - return mSettableSurface.getFormat(); + return mFormat; } /** @@ -166,14 +175,11 @@ @Override public void close() { synchronized (mLock) { - if (mIsClosed) { - // Return early if it's already closed. - return; - } else { + if (!mIsClosed) { mIsClosed = true; } } - mSettableSurface.decrementUseCount(); + mCloseFutureCompleter.set(null); } /** @@ -189,6 +195,14 @@ } /** + * Gets a future that completes when the {@link SurfaceOutput} is closed. + */ + @NonNull + public ListenableFuturegetCloseFuture() { + return mCloseFuture; + } + + /** * This method can be invoked by the effect implementation on any thread. */ @AnyThread
diff --git a/camera/camera-core/src/test/java/androidx/camera/core/processing/SettableSurfaceTest.kt b/camera/camera-core/src/test/java/androidx/camera/core/processing/SettableSurfaceTest.kt index eecb931..5648bea 100644 --- a/camera/camera-core/src/test/java/androidx/camera/core/processing/SettableSurfaceTest.kt +++ b/camera/camera-core/src/test/java/androidx/camera/core/processing/SettableSurfaceTest.kt
@@ -21,21 +21,28 @@ import android.graphics.Rect import android.graphics.SurfaceTexture import android.os.Build +import android.os.Looper.getMainLooper import android.util.Size import android.view.Surface import androidx.camera.core.SurfaceEffect -import androidx.camera.core.impl.DeferrableSurface -import androidx.camera.core.impl.utils.executor.CameraXExecutors +import androidx.camera.core.SurfaceOutput +import androidx.camera.core.SurfaceRequest +import androidx.camera.core.SurfaceRequest.Result.RESULT_REQUEST_CANCELLED +import androidx.camera.core.impl.DeferrableSurface.SurfaceClosedException +import androidx.camera.core.impl.DeferrableSurface.SurfaceUnavailableException +import androidx.camera.core.impl.ImmediateSurface +import androidx.camera.core.impl.utils.executor.CameraXExecutors.mainThreadExecutor +import androidx.camera.core.impl.utils.futures.FutureCallback import androidx.camera.core.impl.utils.futures.Futures import androidx.camera.testing.fakes.FakeCamera -import com.google.common.truth.Truth -import com.google.common.util.concurrent.ListenableFuture -import java.util.concurrent.ExecutionException +import androidx.concurrent.futures.CallbackToFutureAdapter +import com.google.common.truth.Truth.assertThat import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import org.robolectric.annotation.internal.DoNotInstrument @@ -46,78 +53,203 @@ @DoNotInstrument @Config(minSdk = Build.VERSION_CODES.LOLLIPOP) class SettableSurfaceTest { - private lateinit var fakeDeferrableSurface: DeferrableSurface - private lateinit var fakeSettableSurface: SettableSurface + + companion object { + private val IDENTITY_MATRIX = FloatArray(16).apply { + android.opengl.Matrix.setIdentityM(this, 0) + } + } + + private lateinit var settableSurface: SettableSurface + private lateinit var fakeSurface: Surface + private lateinit var fakeSurfaceTexture: SurfaceTexture @Before fun setUp() { - fakeDeferrableSurface = createFakeDeferrableSurface() - fakeSettableSurface = createFakeSettableSurface() + settableSurface = SettableSurface( + SurfaceEffect.PREVIEW, Size(640, 480), ImageFormat.PRIVATE, + Matrix(), true, Rect(), 0, false + ) + fakeSurfaceTexture = SurfaceTexture(0) + fakeSurface = Surface(fakeSurfaceTexture) } @After fun tearDown() { - fakeDeferrableSurface.close() - fakeSettableSurface.close() + settableSurface.close() + fakeSurfaceTexture.release() + fakeSurface.release() } @Test - @Throws(ExecutionException::class, InterruptedException::class) - fun setSource_surfaceIsPropagated() { - // Act. - fakeSettableSurface.setSource(fakeDeferrableSurface) - // Assert. - Truth.assertThat(fakeSettableSurface.surface.get()) - .isEqualTo(fakeDeferrableSurface.surface.get()) - } - - @Test - @Throws(ExecutionException::class, InterruptedException::class) - fun createSurfaceRequestAsSource_surfaceIsPropagated() { - // Act. - val surfaceRequest = fakeSettableSurface.createSurfaceRequestAsSource( - FakeCamera(), false - ) - surfaceRequest.provideSurface( - fakeDeferrableSurface.surface.get(), - CameraXExecutors.directExecutor() - ) { fakeDeferrableSurface.close() } - // Assert. - Truth.assertThat(fakeSettableSurface.surface.get()) - .isEqualTo(fakeDeferrableSurface.surface.get()) - } - - @Test(expected = IllegalStateException::class) - fun setSourceTwice_throwsException() { - fakeSettableSurface.setSource(fakeDeferrableSurface) - fakeSettableSurface.setSource(fakeDeferrableSurface) - } - - @Test(expected = IllegalStateException::class) - fun setSourceThenCreateSurfaceRequest_throwsException() { - fakeSettableSurface.setSource(fakeDeferrableSurface) - fakeSettableSurface.createSurfaceRequestAsSource(FakeCamera(), false) - } - - private fun createFakeSettableSurface(): SettableSurface { - return SettableSurface( - SurfaceEffect.PREVIEW, Size(640, 480), ImageFormat.PRIVATE, - Matrix(), true, Rect(), 0, false - ) - } - - private fun createFakeDeferrableSurface(): DeferrableSurface { - val surfaceTexture = SurfaceTexture(0) - val surface = Surface(surfaceTexture) - val deferrableSurface = object : DeferrableSurface() { - override fun provideSurface(): ListenableFuture{ - return Futures.immediateFuture(surface) - } + fun closeProviderAfterConnected_surfaceNotReleased() { + // Arrange. + val surfaceRequest = settableSurface.createSurfaceRequest(FakeCamera()) + var result: SurfaceRequest.Result? = null + surfaceRequest.provideSurface(fakeSurface, mainThreadExecutor()) { + result = it } - deferrableSurface.terminationFuture.addListener({ - surface.release() - surfaceTexture.release() - }, CameraXExecutors.directExecutor()) - return deferrableSurface + // Act: close the provider + surfaceRequest.deferrableSurface.close() + shadowOf(getMainLooper()).idle() + // Assert: the surface is not released because the parent is not closed. + assertThat(result).isNull() + } + + @Test(expected = SurfaceClosedException::class) + fun connectToClosedProvider_getsException() { + val closedDeferrableSurface = ImmediateSurface(fakeSurface).apply { + this.close() + } + settableSurface.setProvider(closedDeferrableSurface) + } + + @Test + fun createSurfaceRequestAndCancel_cancellationIsPropagated() { + // Arrange: create a SurfaceRequest. + val surfaceRequest = settableSurface.createSurfaceRequest(FakeCamera()) + var throwable: Throwable? = null + Futures.addCallback(settableSurface.surface, object : FutureCallback{ + override fun onFailure(t: Throwable) { + throwable = t + } + + override fun onSuccess(result: Surface?) { + throw IllegalStateException("Should not succeed.") + } + }, mainThreadExecutor()) + + // Act: set it as "will not provide". + surfaceRequest.willNotProvideSurface() + shadowOf(getMainLooper()).idle() + + // Assert: the DeferrableSurface returns an error. + assertThat(throwable).isInstanceOf(SurfaceUnavailableException::class.java) + } + + @Test + fun createSurfaceRequestWithClosedInstance_surfaceRequestCancelled() { + // Arrange: create a SurfaceRequest from a closed LinkableSurface + settableSurface.close() + val surfaceRequest = settableSurface.createSurfaceRequest(FakeCamera()) + + // Act: provide a Surface and get the result. + var result: SurfaceRequest.Result? = null + surfaceRequest.provideSurface(fakeSurface, mainThreadExecutor()) { + result = it + } + shadowOf(getMainLooper()).idle() + + // Assert: the Surface is never used. + assertThat(result!!.resultCode).isEqualTo(RESULT_REQUEST_CANCELLED) + } + + @Test + fun createSurfaceOutputWithClosedInstance_surfaceOutputNotCreated() { + // Arrange: create a SurfaceOutput future from a closed LinkableSurface + settableSurface.close() + val surfaceOutput = settableSurface.createSurfaceOutputFuture(IDENTITY_MATRIX) + + // Act: wait for the SurfaceOutput to return. + var successful: Boolean? = null + Futures.addCallback(surfaceOutput, object : FutureCallback{ + override fun onSuccess(result: SurfaceOutput?) { + successful = true + } + + override fun onFailure(t: Throwable) { + successful = false + } + }, mainThreadExecutor()) + shadowOf(getMainLooper()).idle() + + // Assert: the SurfaceOutput is not created. + assertThat(successful!!).isEqualTo(false) + } + + @Test + fun createSurfaceRequestAndProvide_surfaceIsPropagated() { + // Arrange: create a SurfaceRequest. + val surfaceRequest = settableSurface.createSurfaceRequest(FakeCamera()) + // Act: provide Surface. + surfaceRequest.provideSurface(fakeSurface, mainThreadExecutor()) {} + shadowOf(getMainLooper()).idle() + // Assert: the surface is received. + assertThat(settableSurface.surface.isDone).isTrue() + assertThat(settableSurface.surface.get()).isEqualTo(fakeSurface) + } + + @Test + fun setSourceSurfaceFutureAndProvide_surfaceIsPropagated() { + // Arrange: set a ListenableFutureas the source. + var completer: CallbackToFutureAdapter.Completer? = null + val surfaceFuture = CallbackToFutureAdapter.getFuture { + completer = it + return@getFuture null + } + settableSurface.setProvider(surfaceFuture) + // Act: provide Surface. + completer!!.set(fakeSurface) + shadowOf(getMainLooper()).idle() + // Assert: the surface is received. + assertThat(settableSurface.surface.isDone).isTrue() + assertThat(settableSurface.surface.get()).isEqualTo(fakeSurface) + } + + @Test + fun linkBothProviderAndConsumer_surfaceAndResultsArePropagatedE2E() { + // Arrange: link a LinkableSurface with a SurfaceRequest and a SurfaceOutput. + val surfaceRequest = settableSurface.createSurfaceRequest(FakeCamera()) + val surfaceOutputFuture = settableSurface.createSurfaceOutputFuture(IDENTITY_MATRIX) + var surfaceOutput: SurfaceOutput? = null + Futures.transform(surfaceOutputFuture, { + surfaceOutput = it + }, mainThreadExecutor()) + + // Act: provide a Surface via the SurfaceRequest. + var isSurfaceReleased = false + surfaceRequest.provideSurface(fakeSurface, mainThreadExecutor()) { + isSurfaceReleased = true + } + shadowOf(getMainLooper()).idle() + + // Assert: SurfaceOutput is received and it contains the right Surface + assertThat(surfaceOutput).isNotNull() + var surfaceOutputCloseRequested = false + val surface = surfaceOutput!!.getSurface(mainThreadExecutor()) { + surfaceOutputCloseRequested = true + } + shadowOf(getMainLooper()).idle() + assertThat(surface).isEqualTo(fakeSurface) + assertThat(isSurfaceReleased).isEqualTo(false) + + // Act: close the LinkableSurface, signaling the intention to close the Surface. + settableSurface.close() + shadowOf(getMainLooper()).idle() + + // Assert: The close is propagated to the SurfaceRequest. + assertThat(surfaceOutputCloseRequested).isEqualTo(true) + assertThat(isSurfaceReleased).isEqualTo(false) + + // Act: close the LinkableSurface, signaling it's safe to release the Surface. + surfaceOutput!!.close() + shadowOf(getMainLooper()).idle() + + // Assert: The close is propagated to the SurfaceRequest. + assertThat(isSurfaceReleased).isEqualTo(true) + } + + @Test(expected = IllegalStateException::class) + fun createSurfaceRequestTwice_throwsException() { + settableSurface.createSurfaceRequest(FakeCamera()) + settableSurface.createSurfaceRequest(FakeCamera()) + shadowOf(getMainLooper()).idle() + } + + @Test(expected = IllegalStateException::class) + fun createSurfaceOutputTwice_throwsException() { + settableSurface.createSurfaceOutputFuture(IDENTITY_MATRIX) + settableSurface.createSurfaceOutputFuture(IDENTITY_MATRIX) + shadowOf(getMainLooper()).idle() } } \ No newline at end of file
diff --git a/camera/camera-core/src/test/java/androidx/camera/core/processing/SurfaceOutputImplTest.kt b/camera/camera-core/src/test/java/androidx/camera/core/processing/SurfaceOutputImplTest.kt index b56b6f1..5e8863e 100644 --- a/camera/camera-core/src/test/java/androidx/camera/core/processing/SurfaceOutputImplTest.kt +++ b/camera/camera-core/src/test/java/androidx/camera/core/processing/SurfaceOutputImplTest.kt
@@ -16,8 +16,7 @@ package androidx.camera.core.processing -import android.graphics.ImageFormat -import android.graphics.Rect +import android.graphics.PixelFormat import android.graphics.SurfaceTexture import android.opengl.Matrix import android.os.Build @@ -49,10 +48,13 @@ Matrix.setIdentityM(this, 0) } private const val FLOAT_TOLERANCE = 1E-4 + private const val TARGET = SurfaceEffect.PREVIEW + private const val FORMAT = PixelFormat.RGBA_8888 + private val SIZE = Size(640, 480) } - lateinit var fakeSurface: Surface - lateinit var fakeSurfaceTexture: SurfaceTexture + private lateinit var fakeSurface: Surface + private lateinit var fakeSurfaceTexture: SurfaceTexture private val surfacesToCleanup = mutableListOf() private val surfaceOutputsToCleanup = mutableListOf() @@ -74,9 +76,15 @@ } } - @Test(expected = java.lang.IllegalStateException::class) - fun createWithIncompleteFuture_throwsException() { - createFakeSurfaceOutputImpl(settableSurface = createIncompleteSettableSurface()) + @Test + fun closeSurface_closeFutureIsDone() { + // Arrange. + val surfaceOutImpl = createFakeSurfaceOutputImpl() + // Act: close the SurfaceOutput. + surfaceOutImpl.close() + shadowOf(Looper.getMainLooper()).idle() + // Assert: + assertThat(surfaceOutImpl.closeFuture.isDone).isTrue() } @Test @@ -147,30 +155,8 @@ .containsExactly(floatArrayOf(2F, -1F, 0F, 1F)) } - private fun createCompleteSettableSurface(): SettableSurface { - return createFakeSettableSurface(true) - } - - private fun createIncompleteSettableSurface(): SettableSurface { - return createFakeSettableSurface(false) - } - - private fun createFakeSettableSurface(setComplete: Boolean): SettableSurface { - val settableSurface = SettableSurface( - SurfaceEffect.PREVIEW, Size(640, 480), ImageFormat.PRIVATE, - android.graphics.Matrix(), true, Rect(), 0, false - ) - if (setComplete) { - settableSurface.mCompleter.set(fakeSurface) + private fun createFakeSurfaceOutputImpl(transform: FloatArray = IDENTITY_MATRIX) = + SurfaceOutputImpl(fakeSurface, TARGET, FORMAT, SIZE, transform).apply { + surfaceOutputsToCleanup.add(this) } - surfacesToCleanup.add(settableSurface) - return settableSurface - } - - private fun createFakeSurfaceOutputImpl( - settableSurface: SettableSurface = createCompleteSettableSurface(), - transform: FloatArray = IDENTITY_MATRIX - ) = SurfaceOutputImpl(settableSurface, transform).apply { - surfaceOutputsToCleanup.add(this) - } } \ No newline at end of file
diff --git a/collection/collection/src/jvmTest/java/androidx/collection/ArrayMapTest.java b/collection/collection/src/jvmTest/java/androidx/collection/ArrayMapTest.java deleted file mode 100644 index e61138d..0000000 --- a/collection/collection/src/jvmTest/java/androidx/collection/ArrayMapTest.java +++ /dev/null
@@ -1,175 +0,0 @@ -/* - * Copyright 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 androidx.collection; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotSame; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; - -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; - -import java.util.Arrays; -import java.util.ConcurrentModificationException; -import java.util.HashMap; -import java.util.Map; - -@RunWith(JUnit4.class) -public class ArrayMapTest { - ArrayMapmMap = new ArrayMap<>(); - - /** - * Attempt to generate a ConcurrentModificationException in ArrayMap. - * - * ArrayMap is explicitly documented to be non-thread-safe, yet it's easy to accidentally screw - * this up; ArrayMap should (in the spirit of the core Java collection types) make an effort to - * catch this and throw ConcurrentModificationException instead of crashing somewhere in its - * internals. - */ - @Test - public void testConcurrentModificationException() throws Exception { - final int testLenMs = 5000; - new Thread(new Runnable() { - @Override - public void run() { - int i = 0; - while (mMap != null) { - try { - mMap.put(String.format("key %d", i++), "B_DONT_DO_THAT"); - } catch (ArrayIndexOutOfBoundsException e) { - fail(e.getMessage()); - } catch (ClassCastException e) { - fail(e.getMessage()); - } catch (ConcurrentModificationException e) { - System.out.println("[successfully caught CME at put #" + i - + " size=" + (mMap == null ? "??" : String.valueOf(mMap.size())) - + "]"); - } - } - } - }).start(); - for (int i = 0; i < (testLenMs / 100); i++) { - try { - Thread.sleep(100); - mMap.clear(); - } catch (InterruptedException e) { - } catch (ArrayIndexOutOfBoundsException e) { - fail(e.getMessage()); - } catch (ClassCastException e) { - fail(e.getMessage()); - } catch (ConcurrentModificationException e) { - System.out.println( - "[successfully caught CME at clear #" - + i + " size=" + mMap.size() + "]"); - } - } - mMap = null; // will stop other thread - System.out.println(); - } - - /** - * Check to make sure the same operations behave as expected in a single thread. - */ - @Test - public void testNonConcurrentAccesses() throws Exception { - for (int i = 0; i < 100000; i++) { - try { - mMap.put(String.format("key %d", i++), "B_DONT_DO_THAT"); - if (i % 200 == 0) { - System.out.print("."); - } - if (i % 500 == 0) { - mMap.clear(); - System.out.print("X"); - } - } catch (ConcurrentModificationException e) { - fail(e.getMessage()); - } - } - } - - @Test - public void testIsSubclassOfSimpleArrayMap() { - Object map = new ArrayMap(); - assertTrue(map instanceof SimpleArrayMap); - } - - /** - * Regression test for ensure capacity: b/224971154 - */ - @Test - public void putAll() { - ArrayMapmap = new ArrayMap<>(); - MapotherMap = new HashMap<>(); - otherMap.put("abc", "def"); - map.putAll(otherMap); - assertEquals(map.size(), 1); - assertEquals(map.keyAt(0), "abc"); - assertEquals(map.valueAt(0), "def"); - } - - @Test - public void toArray() { - Mapmap = new ArrayMap<>(); - map.put("a", 1); - String[] keys = map.keySet().toArray(new String[1]); - Integer[] values = map.values().toArray(new Integer[1]); - - assertEquals(Arrays.toString(keys), "[a]"); - assertEquals(Arrays.toString(values), "[1]"); - - // re-do again, it should re-use the same arrays - String[] keys2 = map.keySet().toArray(keys); - Integer[] values2 = map.values().toArray(values); - - assertSame(keys2, keys); - assertSame(values2, values); - assertEquals(Arrays.toString(keys2), "[a]"); - assertEquals(Arrays.toString(values2), "[1]"); - - // add new items - map.put("b", 2); - map.put("c", 3); - - // now it shouldn't re-use arrays because arrays are too small - String[] keys3 = map.keySet().toArray(keys); - Integer[] values3 = map.values().toArray(values); - - assertNotSame(values3, values); - assertNotSame(keys3, keys); - assertEquals(Arrays.toString(keys3), "[a, b, c]"); - assertEquals(Arrays.toString(values3), "[1, 2, 3]"); - - - map.remove("b"); - map.remove("c"); - - // arrays are big enough, re-use - String[] keys4 = map.keySet().toArray(keys3); - Integer[] values4 = map.values().toArray(values3); - - assertSame(values4, values3); - assertSame(keys4, keys3); - // https://docs.oracle.com/javase/8/docs/api/java/util/Set.html#toArray-T:A- - // only the next index is nulled in the array, per documentation. - assertEquals(Arrays.toString(keys4), "[a, null, c]"); - assertEquals(Arrays.toString(values4), "[1, null, 3]"); - } -}
diff --git a/collection/collection/src/jvmTest/kotlin/androidx/collection/ArrayMapTest.kt b/collection/collection/src/jvmTest/kotlin/androidx/collection/ArrayMapTest.kt new file mode 100644 index 0000000..c38514e --- /dev/null +++ b/collection/collection/src/jvmTest/kotlin/androidx/collection/ArrayMapTest.kt
@@ -0,0 +1,167 @@ +/* + * Copyright 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 androidx.collection + +import androidx.collection.internal.Lock +import androidx.collection.internal.synchronized +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.newFixedThreadPoolContext +import kotlinx.coroutines.runBlocking + +internal class ArrayMapTest { + + private val map = ArrayMap() + + /** + * Attempt to generate a ConcurrentModificationException in ArrayMap. + * + * ArrayMap is explicitly documented to be non-thread-safe, yet it's easy to accidentally screw + * this up; ArrayMap should (in the spirit of the core Java collection types) make an effort to + * catch this and throw ConcurrentModificationException instead of crashing somewhere in its + * internals. + */ + @OptIn(DelicateCoroutinesApi::class) // newFixedThreadPoolContext is delicate in jvm + @Test + fun testConcurrentModificationException() { + var error: Throwable? = null + val nThreads = 20 + var nActiveThreads = 0 + val lock = Lock() + val dispatcher = newFixedThreadPoolContext(nThreads = nThreads, name = "ArraySetTest") + val scope = CoroutineScope(dispatcher) + + repeat(nThreads) { + scope.launch { + lock.synchronized { nActiveThreads++ } + + while (isActive) { + val add = Random.nextBoolean() + try { + if (add) { + map[Random.nextLong().toString()] = "B_DONT_DO_THAT" + } else { + map.clear() + } + } catch (e: IndexOutOfBoundsException) { + if (!add) { + error = e + throw e + } // Expected exception otherwise + } catch (e: ClassCastException) { + error = e + throw e + } catch (ignored: ConcurrentModificationException) { + // Expected exception + } + } + } + } + + runBlocking(Dispatchers.Default) { + // Wait until all worker threads are started + for (i in 0 until 100) { + if (lock.synchronized { nActiveThreads == nThreads }) { + break + } else { + delay(timeMillis = 10L) + } + } + + // Allow the worker threads to run concurrently for some time + delay(timeMillis = 100L) + } + + scope.cancel() + dispatcher.close() + + error?.also { throw it } + } + + /** + * Check to make sure the same operations behave as expected in a single thread. + */ + @Test + fun testNonConcurrentAccesses() { + repeat(100_000) { i -> + map["key %i"] = "B_DONT_DO_THAT" + if (i % 500 == 0) { + map.clear() + } + } + } + + @Test + fun testIsSubclassOfSimpleArrayMap() { + @Suppress("USELESS_IS_CHECK") + assertTrue(map is SimpleArrayMap<*, *>) + } + + /** + * Regression test for ensure capacity: b/224971154 + */ + @Test + fun putAll() { + val otherMap = HashMap() + otherMap["abc"] = "def" + map.putAll(otherMap) + assertEquals(1, map.size) + assertEquals("abc", map.keyAt(0)) + assertEquals("def", map.valueAt(0)) + } + + @Test + fun keys() { + val map = ArrayMap() + + map["a"] = 1 + assertEquals(map.keys, setOf("a")) + + map["b"] = 2 + map["c"] = 3 + assertEquals(map.keys, setOf("a", "b", "c")) + + map.remove("b") + map.remove("c") + assertEquals(map.keys, setOf("a")) + } + + @Test + fun values() { + val map = ArrayMap() + + map["a"] = 1 + assertContentEquals(map.values, listOf(1)) + + map["b"] = 2 + map["c"] = 3 + assertContentEquals(map.values, listOf(1, 2, 3)) + + map.remove("b") + map.remove("c") + assertContentEquals(map.values, listOf(1)) + } +} \ No newline at end of file
diff --git a/compose/foundation/foundation/api/current.txt b/compose/foundation/foundation/api/current.txt index 199dc94..addbc86 100644 --- a/compose/foundation/foundation/api/current.txt +++ b/compose/foundation/foundation/api/current.txt
@@ -673,6 +673,9 @@ public final class LazyLayoutPrefetcher_androidKt { } + public final class LazyNearestItemsRangeKt { + } + public final class Lazy_androidKt { }
diff --git a/compose/foundation/foundation/api/public_plus_experimental_current.txt b/compose/foundation/foundation/api/public_plus_experimental_current.txt index e56d838..b97be26 100644 --- a/compose/foundation/foundation/api/public_plus_experimental_current.txt +++ b/compose/foundation/foundation/api/public_plus_experimental_current.txt
@@ -779,6 +779,9 @@ public final class LazyLayoutPrefetcher_androidKt { } + public final class LazyNearestItemsRangeKt { + } + public final class Lazy_androidKt { method @androidx.compose.foundation.ExperimentalFoundationApi public static Object getDefaultLazyLayoutKey(int index); }
diff --git a/compose/foundation/foundation/api/restricted_current.txt b/compose/foundation/foundation/api/restricted_current.txt index 199dc94..addbc86 100644 --- a/compose/foundation/foundation/api/restricted_current.txt +++ b/compose/foundation/foundation/api/restricted_current.txt
@@ -673,6 +673,9 @@ public final class LazyLayoutPrefetcher_androidKt { } + public final class LazyNearestItemsRangeKt { + } + public final class Lazy_androidKt { }
diff --git a/compose/foundation/foundation/build.gradle b/compose/foundation/foundation/build.gradle index bde17ce..9141f9a 100644 --- a/compose/foundation/foundation/build.gradle +++ b/compose/foundation/foundation/build.gradle
@@ -36,7 +36,7 @@ */ api("androidx.annotation:annotation:1.1.0") api("androidx.compose.animation:animation:1.1.1") - api("androidx.compose.runtime:runtime:1.2.0-rc02") + api(project(":compose:runtime:runtime")) api("androidx.compose.ui:ui:1.2.0-rc02") implementation(libs.kotlinStdlibCommon)
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/ScrollableFocusedChildDemo.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/ScrollableFocusedChildDemo.kt index bc7ba50..471a5f4 100644 --- a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/ScrollableFocusedChildDemo.kt +++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/ScrollableFocusedChildDemo.kt
@@ -53,14 +53,13 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.Layout -import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import kotlin.math.roundToInt -@Preview +@Preview(showBackground = true) @Composable fun ScrollableFocusedChildDemo() { val resizableState = remember { ResizableState() } @@ -71,10 +70,6 @@ "to change its size. Try adjusting size while the box inside the resizable area " + "is focused, and while it's not focused." ) - Text( - "Resizing by dragging handle quickly is broken due to b/220119990.", - fontStyle = FontStyle.Italic - ) Row { Button(
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/BrushDemo.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/BrushDemo.kt index 4dcf255..1405a4a 100644 --- a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/BrushDemo.kt +++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/BrushDemo.kt
@@ -177,21 +177,11 @@ Text( buildAnnotatedString { withStyle(ParagraphStyle(textAlign = TextAlign.Right)) { - append( - "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus condimentum" + - " rhoncus est volutpat venenatis. Fusce semper, sapien ut venenatis" + - " pellentesque, lorem dui aliquam sapien, non pharetra diam neque " + - "id mi" - ) + append(loremIpsum(wordCount = 29)) } withStyle(ParagraphStyle(textAlign = TextAlign.Left)) { - append( - "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus condimentum" + - " rhoncus est volutpat venenatis. Fusce semper, sapien ut venenatis" + - " pellentesque, lorem dui aliquam sapien, non pharetra diam neque " + - "id mi" - ) + append(loremIpsum(wordCount = 29)) } }, style = TextStyle( @@ -217,10 +207,7 @@ ) ) Text( - text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus condimentum" + - " rhoncus est volutpat venenatis. Fusce semper, sapien ut venenatis" + - " pellentesque, lorem dui aliquam sapien, non pharetra diam neque " + - "id mi", + text = loremIpsum(wordCount = 29), style = TextStyle( brush = Brush.radialGradient( *RainbowStops.zip(RainbowColors).toTypedArray(),
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/ComposeMultiParagraph.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/ComposeMultiParagraph.kt index 2b37cd3..4405d9c 100644 --- a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/ComposeMultiParagraph.kt +++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/ComposeMultiParagraph.kt
@@ -29,8 +29,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.sp -val lorem = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas fermentum non" + - " diam sed pretium." +val lorem = loremIpsum(wordCount = 14) @Preview @Composable
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/DrawTextDemo.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/DrawTextDemo.kt index aaae8f1..5424cce 100644 --- a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/DrawTextDemo.kt +++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/DrawTextDemo.kt
@@ -130,10 +130,7 @@ drawText( textMeasurer, - text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin fringilla " + - "laoreet aliquam. Aliquam ut nisl aliquet, laoreet tellus quis, sagittis enim. " + - "Sed sem dolor, tempus blandit purus suscipit, convallis tincidunt purus. Donec " + - "mattis placerat arcu sed consectetur. Pellentesque eu turpis lacus.", + text = loremIpsum(wordCount = 41), topLeft = Offset(padding, padding), style = TextStyle(fontSize = fontSize6), overflow = TextOverflow.Visible, @@ -220,7 +217,8 @@ Canvas( Modifier .fillMaxWidth() - .height(100.dp)) { + .height(100.dp) + ) { drawRect(brush = Brush.linearGradient(RainbowColors)) val padding = 16.dp.toPx()
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/FullScreenTextFieldDemo.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/FullScreenTextFieldDemo.kt new file mode 100644 index 0000000..ab5d1e1 --- /dev/null +++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/FullScreenTextFieldDemo.kt
@@ -0,0 +1,42 @@ +/* + * Copyright 2022 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 androidx.compose.foundation.demos.text + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.MaterialTheme +import androidx.compose.material.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** Demonstrates b/237190748. */ +@Composable +fun FullScreenTextFieldDemo() { + var value by remember { mutableStateOf(loremIpsum()) } + TextField( + value, + onValueChange = { value = it }, + modifier = Modifier + .border(3.dp, MaterialTheme.colors.primary) + .fillMaxSize() + ) +} \ No newline at end of file
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/LoremIpsum.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/LoremIpsum.kt new file mode 100644 index 0000000..9daf80a --- /dev/null +++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/LoremIpsum.kt
@@ -0,0 +1,61 @@ +/* + * Copyright 2022 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 androidx.compose.foundation.demos.text + +private val LoremIpsumWords = """ + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque a egestas nisi. Aenean + aliquam neque lacus, ac sollicitudin risus consequat in. Pellentesque habitant morbi tristique + senectus et netus et malesuada fames ac turpis egestas. In id nulla quam. Ut lobortis justo + purus, nec viverra diam gravida nec. Duis scelerisque feugiat ante, vitae semper leo. Donec + tempor aliquet lacus rhoncus tristique. In pulvinar sem ac velit suscipit fermentum. Donec + quis dolor ut enim sollicitudin laoreet. Morbi ac lectus pharetra, condimentum lectus + congue, euismod ante. + + Phasellus vitae varius mi. Pellentesque non lacus at erat posuere pharetra a nec lorem. Vivamus + posuere massa sed ultricies euismod. Fusce luctus tristique diam at luctus. Nulla efficitur + fermentum dolor, nec hendrerit diam rhoncus ut. Donec fermentum, enim ultrices pharetra + suscipit, ex ligula iaculis ligula, in aliquam erat felis id magna. In est dolor, consequat + sed eros non, varius consequat velit. Cras blandit urna et vulputate dapibus. Donec + efficitur laoreet condimentum. Pellentesque habitant morbi tristique senectus et netus et + malesuada fames ac turpis egestas. Nullam scelerisque vitae est id pellentesque. + + Aenean in ipsum rhoncus, dignissim lorem ac, consectetur quam. Phasellus id velit nec velit + dictum eleifend at eu nibh. Integer feugiat turpis quis dui pharetra, suscipit ornare turpis + aliquam. Phasellus tristique ullamcorper placerat. Aenean aliquet maximus tortor eu posuere. + Curabitur semper libero ut libero lacinia tempor. Duis tempor mattis nulla in malesuada. + Mauris rutrum commodo lacus. Integer pellentesque odio at eleifend vulputate. Donec eu erat + ut neque porttitor vehicula. + + Duis est nisl, consequat sed ipsum eget, cursus auctor mauris. Vestibulum mattis sem et molestie + blandit. Donec tincidunt enim nibh, nec cursus magna feugiat id. Nulla facilisi. Suspendisse + ultricies lobortis ex, at vestibulum risus. Aliquam congue viverra justo vitae mollis. In + elementum venenatis eros non tincidunt. Pellentesque vitae sem dui. Quisque magna lacus, + dignissim sed viverra ut, sagittis quis turpis. + + Aenean cursus id dolor sed convallis. Interdum et malesuada fames ac ante ipsum primis in + faucibus. Cras sodales ante et rhoncus commodo. In hac habitasse platea dictumst. Aenean + efficitur felis in elementum sollicitudin. In neque urna, tincidunt et lorem nec, commodo + maximus urna. Suspendisse tincidunt, felis ac viverra ultrices, nulla ipsum ornare nisi, + eleifend luctus tellus felis nec orci. Maecenas sed venenatis urna. Nulla tempor ultricies + lorem eget finibus. Nunc malesuada nisi id volutpat lobortis. Sed aliquet massa eu ex + eleifend efficitur. Curabitur accumsan vestibulum ligula sed aliquet. Nulla pretium dui id + nunc ultricies, id porttitor lorem pretium. Lorem ipsum dolor sit amet, consectetur + adipiscing elit. +""".trimIndent().split("""\s""".toRegex()) + +fun loremIpsum(wordCount: Int = LoremIpsumWords.size): String = + LoremIpsumWords.joinToString(separator = " ", limit = wordCount) \ No newline at end of file
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/TextDemos.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/TextDemos.kt index d510c3b..781fcef 100644 --- a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/TextDemos.kt +++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/TextDemos.kt
@@ -58,7 +58,8 @@ DialogInputFieldDemo(onNavigateUp) }, ComposableDemo("Inside scrollable") { TextFieldsInScrollableDemo() }, - ComposableDemo("Cursor configuration") { TextFieldCursorBlinkingDemo() } + ComposableDemo("Cursor configuration") { TextFieldCursorBlinkingDemo() }, + ComposableDemo("Full-screen field") { FullScreenTextFieldDemo() }, ) ), ComposableDemo("Text Accessibility") { TextAccessibilityDemo() }
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/TextFieldWIthScroller.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/TextFieldWIthScroller.kt index f94fc35..7d8c550 100644 --- a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/TextFieldWIthScroller.kt +++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/TextFieldWIthScroller.kt
@@ -27,9 +27,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -private val LongText = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam pellentesque" + - " arcu quis diam malesuada pulvinar. In id condimentum metus. Suspendisse potenti. " + - "Praesent arcu tortor, ultrices ut vehicula sit amet, accumsan id sem." +private val LongText = loremIpsum(wordCount = 32) @Preview @Composable
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/ScrollableFocusableInteractionTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/ScrollableFocusableInteractionTest.kt index 6c7f3cb..dd85516 100644 --- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/ScrollableFocusableInteractionTest.kt +++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/ScrollableFocusableInteractionTest.kt
@@ -30,6 +30,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.isDebugInspectorInfoEnabled import androidx.compose.ui.platform.testTag @@ -42,6 +43,7 @@ import androidx.compose.ui.test.assertTopPositionInRootIsEqualTo import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.test.filters.MediumTest @@ -184,6 +186,146 @@ } @Test + fun scrollsFocusedFocusableIntoView_whenViewportAnimatedQuickly() { + var viewportSize by mutableStateOf(100.dp) + + rule.setContent { + TestScrollableColumn(size = viewportSize) { + // Put a focusable in the bottom of the viewport. + Spacer(Modifier.size(90.dp)) + TestFocusable(size = 10.dp) + } + } + requestFocusAndScrollToTop() + rule.onNodeWithTag(focusableTag) + .assertScrollAxisPositionInRootIsEqualTo(90.dp) + .assertIsDisplayed() + .assertIsFocused() + + // Manually execute an "animation" that shrinks the viewport by twice the focusable's size + // on every frame, for a few frames. The underlying bug in b/230756508 would lose track + // of the focusable after the second frame. + rule.mainClock.autoAdvance = false + viewportSize = 80.dp + rule.mainClock.advanceTimeByFrame() + rule.waitForIdle() + viewportSize = 60.dp + rule.mainClock.advanceTimeByFrame() + rule.waitForIdle() + viewportSize = 40.dp + rule.mainClock.advanceTimeByFrame() + rule.waitForIdle() + + // Resume the clock. The scroll animation should finish. + rule.mainClock.autoAdvance = true + + rule.onNodeWithTag(focusableTag) + .assertIsDisplayed() + } + + @Test + fun scrollFromViewportShrink_isInterrupted_byGesture() { + var viewportSize by mutableStateOf(100.dp) + + rule.setContent { + TestScrollableColumn(size = viewportSize) { + // Put a focusable in the bottom of the viewport. + Spacer(Modifier.size(90.dp)) + TestFocusable(size = 10.dp) + } + } + requestFocusAndScrollToTop() + rule.onNodeWithTag(focusableTag) + .assertScrollAxisPositionInRootIsEqualTo(90.dp) + .assertIsDisplayed() + .assertIsFocused() + + // Shrink the viewport to start the scroll animation. + rule.mainClock.autoAdvance = false + viewportSize = 80.dp + // Run the first frame of the scroll animation. + rule.mainClock.advanceTimeByFrame() + rule.waitForIdle() + + // Interrupt the scroll by manually dragging. + rule.onNodeWithTag(scrollableAreaTag) + .performTouchInput { + down(center) + moveBy(Offset(viewConfiguration.touchSlop + 1, viewConfiguration.touchSlop + 1)) + up() + } + + // Resume the clock. The animation scroll animation should have been interrupted and not + // continue. + rule.mainClock.advanceTimeByFrame() + rule.mainClock.autoAdvance = true + + rule.onNodeWithTag(focusableTag) + .assertIsNotDisplayed() + } + + /** + * This test ensures that scrollable correctly cleans up its state when the scroll animation + * triggered by shrinking the viewport is interrupted by something other than another shrink + * and the focusable child does not change. If it's cleaned up correctly, expanding then re- + * shrinking the viewport should trigger another animation. + */ + @Test + fun scrollsFocusedFocusableIntoView_whenViewportExpandedThenReshrunk_afterInterruption() { + var viewportSize by mutableStateOf(100.dp) + + rule.setContent { + TestScrollableColumn(size = viewportSize) { + // Put a focusable in the bottom of the viewport. + Spacer(Modifier.size(90.dp)) + TestFocusable(size = 10.dp) + } + } + requestFocusAndScrollToTop() + rule.onNodeWithTag(focusableTag) + .assertScrollAxisPositionInRootIsEqualTo(90.dp) + .assertIsDisplayed() + .assertIsFocused() + + // Shrink the viewport to start the scroll animation. + rule.mainClock.autoAdvance = false + viewportSize = 80.dp + // Run the first frame of the scroll animation. + rule.mainClock.advanceTimeByFrame() + rule.waitForIdle() + + // Interrupt the scroll by manually dragging. + rule.onNodeWithTag(scrollableAreaTag) + .performTouchInput { + down(center) + moveBy(Offset(viewConfiguration.touchSlop + 1, viewConfiguration.touchSlop + 1)) + up() + } + + // Resume the clock. The animation scroll animation should have been interrupted and not + // continue. + rule.mainClock.advanceTimeByFrame() + rule.mainClock.autoAdvance = true + rule.waitForIdle() + rule.onNodeWithTag(focusableTag) + .assertIsFocused() + .assertIsNotDisplayed() + + // Expand the viewport back to its original size to bring the focusable back into view. + viewportSize = 100.dp + rule.waitForIdle() + Thread.sleep(2000) + + // Shrink the viewport again, this should trigger another scroll animation to keep the + // scrollable in view. + viewportSize = 50.dp + rule.waitForIdle() + + rule.onNodeWithTag(focusableTag) + .assertIsDisplayed() + } + + @Test fun doesNotScrollFocusedFocusableIntoView_whenNotInViewAndViewportShrunk() { var viewportSize by mutableStateOf(100.dp) @@ -269,6 +411,62 @@ .assertIsDisplayed() } + @Test + fun scrollsToNewFocusable_whenFocusedChildChangesDuringAnimation() { + var viewportSize by mutableStateOf(100.dp) + val focusRequester1 = FocusRequester() + val focusRequester2 = FocusRequester() + + rule.setContent { + TestScrollableColumn(size = viewportSize) { + Box( + Modifier + .size(10.dp) + .background(Color.Blue) + .testTag("focusable1") + .focusRequester(focusRequester1) + .focusable() + ) + Spacer(Modifier.size(100.dp)) + Box( + Modifier + .size(10.dp) + .background(Color.Blue) + .testTag("focusable2") + .focusRequester(focusRequester2) + .focusable() + ) + } + } + // When focusable2 gets focus, focusable1 should be scrolled out of view. + rule.runOnIdle { + focusRequester2.requestFocus() + } + rule.onNodeWithTag("focusable1").assertIsNotDisplayed() + rule.onNodeWithTag("focusable2").assertIsDisplayed() + // Pause the clock because we need to do some work in the middle of an animation. + rule.mainClock.autoAdvance = false + + // Shrink the viewport, which should scroll to keep focusable2 in-view. + rule.runOnIdle { + viewportSize = 20.dp + } + + // Tick the clock forward to let the animation start and run a bit. + repeat(3) { rule.mainClock.advanceTimeByFrame() } + rule.onNodeWithTag("focusable1").assertIsNotDisplayed() + rule.onNodeWithTag("focusable2").assertIsNotDisplayed() + + rule.runOnIdle { + focusRequester1.requestFocus() + } + // Resume the clock, allow animation to finish. + rule.mainClock.autoAdvance = true + + rule.onNodeWithTag("focusable1").assertIsDisplayed() + rule.onNodeWithTag("focusable2").assertIsNotDisplayed() + } + @Composable private fun TestScrollableColumn( size: Dp,
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Scrollable.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Scrollable.kt index 0b4f00c..b66d0de 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Scrollable.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Scrollable.kt
@@ -33,10 +33,12 @@ import androidx.compose.foundation.rememberOverscrollEffect import androidx.compose.runtime.Composable import androidx.compose.runtime.State +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.geometry.Offset @@ -66,6 +68,8 @@ import androidx.compose.ui.util.fastForEach import kotlin.math.abs import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch /** @@ -553,6 +557,12 @@ private var coordinates: LayoutCoordinates? = null private var oldSize: IntSize? = null + // These properties are used to detect the case where the viewport size is animated shrinking + // while the scroll animation used to keep the focused child in view is still running. + private var focusedChildBeingAnimated: LayoutCoordinates? = null + private var focusTargetBounds: Rect? by mutableStateOf(null) + private var focusAnimationJob: Job? = null + val modifier: Modifier = this .onFocusedBoundsChanged { focusedChild = it } .bringIntoViewResponder(this) @@ -581,9 +591,36 @@ } override suspend fun bringChildIntoView(localRect: Rect) { - performBringIntoView(localRect, calculateRectForParent(localRect)) + performBringIntoView( + source = localRect, + destination = calculateRectForParent(localRect) + ) } + /** + * Handles when the size of the scroll viewport changes by making sure any focused child is kept + * appropriately visible when the viewport shrinks and would otherwise hide it. + * + * One common instance of this is when a text field in a scrollable near the bottom is focused + * while the soft keyboard is hidden, causing the keyboard to show, and cover the field. + * See b/192043120 and related bugs. + * + * To future debuggers of this method, it might be helpful to add a draw modifier to the chain + * above to draw the focus target bounds: + * ``` + * .drawWithContent { + * drawContent() + * focusTargetBounds?.let { + * drawRect( + * Color.Red, + * topLeft = it.topLeft, + * size = it.size, + * style = Stroke(1.dp.toPx()) + * ) + * } + * } + * ``` + */ private fun onSizeChanged(coordinates: LayoutCoordinates, oldSize: IntSize) { val containerShrunk = if (orientation == Horizontal) { coordinates.size.width < oldSize.width @@ -594,17 +631,75 @@ // soon be _more_ visible, so don't scroll. if (!containerShrunk) return - val focusedBounds = focusedChild - ?.let { coordinates.localBoundingBoxOf(it, clipBounds = false) } - ?: return - val myOldBounds = Rect(Offset.Zero, oldSize.toSize()) - val adjustedBounds = computeDestination(focusedBounds, coordinates.size) - val wasVisible = myOldBounds.overlaps(focusedBounds) - val isFocusedChildClipped = adjustedBounds != focusedBounds + val focusedChild = focusedChild ?: return + val focusedBounds = coordinates.localBoundingBoxOf(focusedChild, clipBounds = false) - if (wasVisible && isFocusedChildClipped) { - scope.launch { - performBringIntoView(focusedBounds, adjustedBounds) + // In order to check if we need to scroll to bring the focused child into view, it's not + // enough to consider where the child actually is right now. If the viewport was recently + // shrunk, we may have already started a scroll animation to bring it into view. In that + // case, we need to compare with the target of the animation, not the current position. If + // we don't do that, then in some cases when the viewport size is being animated (e.g. when + // the keyboard insets are being animated on API 30+) we might stop trying to keep the + // focused child in view before the viewport animation is finished, and the scroll animation + // will stop short and leave the focused child out of the viewport. See b/230756508. + val eventualFocusedBounds = if (focusedChild === focusedChildBeingAnimated) { + // A previous call to this method started an animation that is still running, so compare + // with the target of that animation. + checkNotNull(focusTargetBounds) + } else { + focusedBounds + } + + val myOldBounds = Rect(Offset.Zero, oldSize.toSize()) + if (!myOldBounds.overlaps(eventualFocusedBounds)) { + // The focused child was not visible before the resize, so we don't need to keep + // it visible. + return + } + + val targetBounds = computeDestination(eventualFocusedBounds, coordinates.size) + if (targetBounds == eventualFocusedBounds) { + // The focused child is already fully visible (not clipped or hidden) after the resize, + // or will be after it finishes animating, so we don't need to do anything. + return + } + + // If execution has gotten to this point, it means the focused child was at least partially + // visible before the resize, and it is either partially clipped or completely hidden after + // the resize, so we need to adjust scroll to keep it in view. + focusedChildBeingAnimated = focusedChild + focusTargetBounds = targetBounds + scope.launch(NonCancellable) { + val job = launch { + // Animate the scroll offset to keep the focused child in view. This is a suspending + // call that will suspend until the animation is finished, and only return if it + // completes. If any other scroll operations are performed after the animation starts, + // e.g. the viewport shrinks again or the user manually scrolls, this animation will + // be cancelled and this function will throw a CancellationException. + performBringIntoView(source = focusedBounds, destination = targetBounds) + } + focusAnimationJob = job + + // If the scroll was interrupted by another viewport shrink that happens while the + // animation is running, we don't want to clear these fields since the later call to + // this onSizeChanged method will have updated the fields with its own values. + // If the animation completed, or was cancelled for any other reason, we need to clear + // them so the next viewport shrink doesn't think there's already a scroll animation in + // progress. + // Doing this wrong has a few implications: + // 1. If the fields are nulled out when another onSizeChange call happens, it will not + // use the current animation target and viewport animations will lose track of the + // focusable. + // 2. If the fields are not nulled out in other cases, the next viewport animation will + // not keep the focusable in view if the focus hasn't changed. + try { + job.join() + } finally { + if (focusAnimationJob === job) { + focusedChildBeingAnimated = null + focusTargetBounds = null + focusAnimationJob = null + } } } } @@ -612,16 +707,30 @@ /** * Compute the destination given the source rectangle and current bounds. * - * @param source The bounding box of the item that sent the request to be brought into view. + * @param childBounds The bounding box of the item that sent the request to be brought into view. * @return the destination rectangle. */ - private fun computeDestination(source: Rect, intSize: IntSize): Rect { - val size = intSize.toSize() + private fun computeDestination(childBounds: Rect, containerSize: IntSize): Rect { + val size = containerSize.toSize() return when (orientation) { Vertical -> - source.translate(0f, relocationDistance(source.top, source.bottom, size.height)) + childBounds.translate( + translateX = 0f, + translateY = -relocationDistance( + childBounds.top, + childBounds.bottom, + size.height + ) + ) Horizontal -> - source.translate(relocationDistance(source.left, source.right, size.width), 0f) + childBounds.translate( + translateX = -relocationDistance( + childBounds.left, + childBounds.right, + size.width + ), + translateY = 0f + ) } } @@ -630,8 +739,8 @@ */ private suspend fun performBringIntoView(source: Rect, destination: Rect) { val offset = when (orientation) { - Vertical -> source.top - destination.top - Horizontal -> source.left - destination.left + Vertical -> destination.top - source.top + Horizontal -> destination.left - source.left } val scrollDelta = if (reverseDirection) -offset else offset @@ -654,7 +763,8 @@ // If the item is visible but larger than the parent, we don't scroll. leadingEdge < 0 && trailingEdge > parentSize -> 0f - // Find the minimum scroll needed to make one of the edges coincide with the parent's edge. + // Find the minimum scroll needed to make one of the edges coincide with the parent's + // edge. abs(leadingEdge) < abs(trailingEdge - parentSize) -> leadingEdge else -> trailingEdge - parentSize }
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListItemProviderImpl.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListItemProviderImpl.kt index 8c013a2..2229a03 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListItemProviderImpl.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListItemProviderImpl.kt
@@ -18,16 +18,14 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.lazy.layout.IntervalList +import androidx.compose.foundation.lazy.layout.calculateNearestItemsRange import androidx.compose.foundation.lazy.layout.getDefaultLazyLayoutKey import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.snapshotFlow -import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.runtime.structuralEqualityPolicy @ExperimentalFoundationApi @Composable @@ -37,21 +35,14 @@ ): LazyListItemProvider { val latestContent = rememberUpdatedState(content) - // mutableState + LaunchedEffect below are used instead of derivedStateOf to ensure that update - // of derivedState in return expr will only happen after the state value has been changed. val nearestItemsRangeState = remember(state) { - mutableStateOf( - Snapshot.withoutReadObservation { - // State read is observed in composition, causing it to recompose 1 additional time. - calculateNearestItemsRange(state.firstVisibleItemIndex) - } - ) - } - LaunchedEffect(nearestItemsRangeState) { - snapshotFlow { calculateNearestItemsRange(state.firstVisibleItemIndex) } - // MutableState's SnapshotMutationPolicy will make sure the provider is only - // recreated when the state is updated with a new range. - .collect { nearestItemsRangeState.value = it } + derivedStateOf(structuralEqualityPolicy()) { + calculateNearestItemsRange( + slidingWindowSize = NearestItemsSlidingWindowSize, + extraItemCount = NearestItemsExtraItemCount, + firstVisibleItem = state.firstVisibleItemIndex + ) + } } return remember(nearestItemsRangeState) { @@ -108,7 +99,7 @@ override val headerIndexes: Listget() = itemsSnapshot.value.headerIndexes - override val itemCount get() = itemsSnapshot.value.itemsCount + override val itemCount get() = itemsSnapshot.value.itemsCount override fun getKey(index: Int) = itemsSnapshot.value.getKey(index) @@ -157,26 +148,12 @@ } /** - * Returns a range of indexes which contains at least [ExtraItemsNearTheSlidingWindow] items near - * the first visible item. It is optimized to return the same range for small changes in the - * firstVisibleItem value so we do not regenerate the map on each scroll. - */ -private fun calculateNearestItemsRange(firstVisibleItem: Int): IntRange { - val slidingWindowStart = VisibleItemsSlidingWindowSize * - (firstVisibleItem / VisibleItemsSlidingWindowSize) - - val start = maxOf(slidingWindowStart - ExtraItemsNearTheSlidingWindow, 0) - val end = slidingWindowStart + VisibleItemsSlidingWindowSize + ExtraItemsNearTheSlidingWindow - return start until end -} - -/** * We use the idea of sliding window as an optimization, so user can scroll up to this number of * items until we have to regenerate the key to index map. */ -private val VisibleItemsSlidingWindowSize = 30 +private const val NearestItemsSlidingWindowSize = 30 /** * The minimum amount of items near the current first visible item we want to have mapping for. */ -private val ExtraItemsNearTheSlidingWindow = 100 +private const val NearestItemsExtraItemCount = 100
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridItemProviderImpl.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridItemProviderImpl.kt index f81046e..9c2d01e 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridItemProviderImpl.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridItemProviderImpl.kt
@@ -18,16 +18,14 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.lazy.layout.IntervalList +import androidx.compose.foundation.lazy.layout.calculateNearestItemsRange import androidx.compose.foundation.lazy.layout.getDefaultLazyLayoutKey import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.snapshotFlow -import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.runtime.structuralEqualityPolicy @ExperimentalFoundationApi @Composable @@ -36,21 +34,14 @@ content: LazyGridScope.() -> Unit, ): LazyGridItemProvider { val latestContent = rememberUpdatedState(content) - // mutableState + LaunchedEffect below are used instead of derivedStateOf to ensure that update - // of derivedState in return expr will only happen after the state value has been changed. val nearestItemsRangeState = remember(state) { - mutableStateOf( - Snapshot.withoutReadObservation { - // State read is observed in composition, causing it to recompose 1 additional time. - calculateNearestItemsRange(state.firstVisibleItemIndex) - } - ) - } - LaunchedEffect(nearestItemsRangeState) { - snapshotFlow { calculateNearestItemsRange(state.firstVisibleItemIndex) } - // MutableState's SnapshotMutationPolicy will make sure the provider is only - // recreated when the state is updated with a new range. - .collect { nearestItemsRangeState.value = it } + derivedStateOf(structuralEqualityPolicy()) { + calculateNearestItemsRange( + slidingWindowSize = NearestItemsSlidingWindowSize, + extraItemCount = NearestItemsExtraItemCount, + firstVisibleItem = state.firstVisibleItemIndex + ) + } } return remember(nearestItemsRangeState) { @@ -162,26 +153,12 @@ } /** - * Returns a range of indexes which contains at least [ExtraItemsNearTheSlidingWindow] items near - * the first visible item. It is optimized to return the same range for small changes in the - * firstVisibleItem value so we do not regenerate the map on each scroll. - */ -private fun calculateNearestItemsRange(firstVisibleItem: Int): IntRange { - val slidingWindowStart = VisibleItemsSlidingWindowSize * - (firstVisibleItem / VisibleItemsSlidingWindowSize) - - val start = maxOf(slidingWindowStart - ExtraItemsNearTheSlidingWindow, 0) - val end = slidingWindowStart + VisibleItemsSlidingWindowSize + ExtraItemsNearTheSlidingWindow - return start until end -} - -/** * We use the idea of sliding window as an optimization, so user can scroll up to this number of * items until we have to regenerate the key to index map. */ -private val VisibleItemsSlidingWindowSize = 90 +private const val NearestItemsSlidingWindowSize = 90 /** * The minimum amount of items near the current first visible item we want to have mapping for. */ -private val ExtraItemsNearTheSlidingWindow = 200 +private const val NearestItemsExtraItemCount = 200
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyNearestItemsRange.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyNearestItemsRange.kt new file mode 100644 index 0000000..f34ae9f --- /dev/null +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyNearestItemsRange.kt
@@ -0,0 +1,41 @@ +/* + * Copyright 2022 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 androidx.compose.foundation.lazy.layout + +/** + * Returns a range of indexes which contains at least [extraItemCount] items near + * the first visible item. It is optimized to return the same range for small changes in the + * [firstVisibleItem] value so we do not regenerate the map on each scroll. + * + * It uses the idea of sliding window as an optimization, so user can scroll up to this number of + * items until we have to regenerate the key to index map. + * + * @param firstVisibleItem currently visible item + * @param slidingWindowSize size of the sliding window for the nearest item calculation + * @param extraItemCount minimum amount of items near the first item we want to have mapping for. + */ +internal fun calculateNearestItemsRange( + firstVisibleItem: Int, + slidingWindowSize: Int, + extraItemCount: Int +): IntRange { + val slidingWindowStart = slidingWindowSize * (firstVisibleItem / slidingWindowSize) + + val start = maxOf(slidingWindowStart - extraItemCount, 0) + val end = slidingWindowStart + slidingWindowSize + extraItemCount + return start until end +} \ No newline at end of file
diff --git a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/FloatingActionButtonTest.kt b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/FloatingActionButtonTest.kt index f999349..33b1f99 100644 --- a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/FloatingActionButtonTest.kt +++ b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/FloatingActionButtonTest.kt
@@ -420,6 +420,11 @@ assertWithinOnePixel(buttonBounds.center.y, iconBounds.center.y) assertWithinOnePixel(buttonBounds.center.y, textBounds.center.y) + + // Assert expanded fab icon has 16.dp of padding. + assertThat(iconBounds.left - buttonBounds.left) + .isEqualTo(16.dp.roundToPx().toFloat()) + val halfPadding = 6.dp.roundToPx().toFloat() assertWithinOnePixel( iconBounds.center.x + iconBounds.width / 2 + halfPadding,
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/FloatingActionButton.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/FloatingActionButton.kt index dc56032..af1cfa4 100644 --- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/FloatingActionButton.kt +++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/FloatingActionButton.kt
@@ -30,6 +30,7 @@ import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope @@ -275,7 +276,7 @@ content: @Composable RowScope.() -> Unit, ) { FloatingActionButton( - modifier = modifier.sizeIn(minWidth = ExtendedFabMinimumWidth), + modifier = modifier, onClick = onClick, interactionSource = interactionSource, shape = shape, @@ -284,7 +285,10 @@ elevation = elevation, ) { Row( - modifier = Modifier.padding(horizontal = ExtendedFabTextPadding), + modifier = Modifier + .sizeIn(minWidth = ExtendedFabMinimumWidth) + .padding(horizontal = ExtendedFabTextPadding), + horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, content = content, ) @@ -338,9 +342,7 @@ elevation: FloatingActionButtonElevation = FloatingActionButtonDefaults.elevation(), ) { FloatingActionButton( - modifier = modifier.sizeIn( - minWidth = if (expanded) ExtendedFabMinimumWidth else FabPrimaryTokens.ContainerWidth - ), + modifier = modifier, onClick = onClick, interactionSource = interactionSource, shape = shape, @@ -348,12 +350,18 @@ contentColor = contentColor, elevation = elevation, ) { - val startPadding = if (expanded) ExtendedFabPrimaryTokens.IconSize / 2 else 0.dp + val startPadding = if (expanded) ExtendedFabStartIconPadding else 0.dp val endPadding = if (expanded) ExtendedFabTextPadding else 0.dp Row( - modifier = Modifier.padding(start = startPadding, end = endPadding), - verticalAlignment = Alignment.CenterVertically + modifier = Modifier + .sizeIn( + minWidth = if (expanded) ExtendedFabMinimumWidth + else FabPrimaryTokens.ContainerWidth + ) + .padding(start = startPadding, end = endPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = if (expanded) Arrangement.Start else Arrangement.Center ) { icon() AnimatedVisibility( @@ -362,7 +370,7 @@ exit = ExtendedFabCollapseAnimation, ) { Row { - Spacer(Modifier.width(ExtendedFabIconPadding)) + Spacer(Modifier.width(ExtendedFabEndIconPadding)) text() } } @@ -578,7 +586,9 @@ } } -private val ExtendedFabIconPadding = 12.dp +private val ExtendedFabStartIconPadding = 16.dp + +private val ExtendedFabEndIconPadding = 12.dp private val ExtendedFabTextPadding = 20.dp
diff --git a/compose/runtime/runtime/api/current.txt b/compose/runtime/runtime/api/current.txt index 5654983..716e0de 100644 --- a/compose/runtime/runtime/api/current.txt +++ b/compose/runtime/runtime/api/current.txt
@@ -417,6 +417,7 @@ method @androidx.compose.runtime.Composable public staticandroidx.compose.runtime.State method @androidx.compose.runtime.Composable public staticcollectAsState(kotlinx.coroutines.flow.StateFlow extends T>, optional kotlin.coroutines.CoroutineContext context); androidx.compose.runtime.State method public staticcollectAsState(kotlinx.coroutines.flow.Flow extends T>, R? initial, optional kotlin.coroutines.CoroutineContext context); androidx.compose.runtime.State + method public staticderivedStateOf(kotlin.jvm.functions.Function0 extends T> calculation); androidx.compose.runtime.State method public static inline operatorderivedStateOf(androidx.compose.runtime.SnapshotMutationPolicy policy, kotlin.jvm.functions.Function0 extends T> calculation); T! getValue(androidx.compose.runtime.State extends T>, Object? thisObj, kotlin.reflect.KProperty> property); method public staticandroidx.compose.runtime.snapshots.SnapshotStateList method public staticmutableStateListOf(); androidx.compose.runtime.snapshots.SnapshotStateList @@ -442,6 +443,7 @@ method @androidx.compose.runtime.Composable public staticmutableStateListOf(T?... elements); androidx.compose.runtime.State method @androidx.compose.runtime.Composable public staticcollectAsState(kotlinx.coroutines.flow.StateFlow extends T>, optional kotlin.coroutines.CoroutineContext context); androidx.compose.runtime.State method public staticcollectAsState(kotlinx.coroutines.flow.Flow extends T>, R? initial, optional kotlin.coroutines.CoroutineContext context); androidx.compose.runtime.State + method public staticderivedStateOf(kotlin.jvm.functions.Function0 extends T> calculation); androidx.compose.runtime.State method public static inline operatorderivedStateOf(androidx.compose.runtime.SnapshotMutationPolicy policy, kotlin.jvm.functions.Function0 extends T> calculation); T! getValue(androidx.compose.runtime.State extends T>, Object? thisObj, kotlin.reflect.KProperty> property); method public staticandroidx.compose.runtime.snapshots.SnapshotStateList method public staticmutableStateListOf(); androidx.compose.runtime.snapshots.SnapshotStateList @@ -467,6 +469,7 @@ method @androidx.compose.runtime.Composable public staticmutableStateListOf(T?... elements); androidx.compose.runtime.State method @androidx.compose.runtime.Composable public staticcollectAsState(kotlinx.coroutines.flow.StateFlow extends T>, optional kotlin.coroutines.CoroutineContext context); androidx.compose.runtime.State method public staticcollectAsState(kotlinx.coroutines.flow.Flow extends T>, R? initial, optional kotlin.coroutines.CoroutineContext context); androidx.compose.runtime.State + method public staticderivedStateOf(kotlin.jvm.functions.Function0 extends T> calculation); androidx.compose.runtime.State method public static inline operatorderivedStateOf(androidx.compose.runtime.SnapshotMutationPolicy policy, kotlin.jvm.functions.Function0 extends T> calculation); T! getValue(androidx.compose.runtime.State extends T>, Object? thisObj, kotlin.reflect.KProperty> property); method public staticandroidx.compose.runtime.snapshots.SnapshotStateList method public staticmutableStateListOf(); androidx.compose.runtime.snapshots.SnapshotStateList @@ -492,6 +495,7 @@ method @androidx.compose.runtime.Composable public staticmutableStateListOf(T?... elements); androidx.compose.runtime.State method @androidx.compose.runtime.Composable public staticcollectAsState(kotlinx.coroutines.flow.StateFlow extends T>, optional kotlin.coroutines.CoroutineContext context); androidx.compose.runtime.State method public staticcollectAsState(kotlinx.coroutines.flow.Flow extends T>, R? initial, optional kotlin.coroutines.CoroutineContext context); androidx.compose.runtime.State + method public staticderivedStateOf(kotlin.jvm.functions.Function0 extends T> calculation); androidx.compose.runtime.State method public static inline operatorderivedStateOf(androidx.compose.runtime.SnapshotMutationPolicy policy, kotlin.jvm.functions.Function0 extends T> calculation); T! getValue(androidx.compose.runtime.State extends T>, Object? thisObj, kotlin.reflect.KProperty> property); method public staticandroidx.compose.runtime.snapshots.SnapshotStateList method public staticmutableStateListOf(); androidx.compose.runtime.snapshots.SnapshotStateList @@ -517,6 +521,7 @@ method @androidx.compose.runtime.Composable public staticmutableStateListOf(T?... elements); androidx.compose.runtime.State method @androidx.compose.runtime.Composable public staticcollectAsState(kotlinx.coroutines.flow.StateFlow extends T>, optional kotlin.coroutines.CoroutineContext context); androidx.compose.runtime.State method public staticcollectAsState(kotlinx.coroutines.flow.Flow extends T>, R? initial, optional kotlin.coroutines.CoroutineContext context); androidx.compose.runtime.State + method public staticderivedStateOf(kotlin.jvm.functions.Function0 extends T> calculation); androidx.compose.runtime.State method public static inline operatorderivedStateOf(androidx.compose.runtime.SnapshotMutationPolicy policy, kotlin.jvm.functions.Function0 extends T> calculation); T! getValue(androidx.compose.runtime.State extends T>, Object? thisObj, kotlin.reflect.KProperty> property); method public staticandroidx.compose.runtime.snapshots.SnapshotStateList method public staticmutableStateListOf(); androidx.compose.runtime.snapshots.SnapshotStateList mutableStateListOf(T?... elements);
diff --git a/compose/runtime/runtime/api/public_plus_experimental_current.txt b/compose/runtime/runtime/api/public_plus_experimental_current.txt index 75793d0..33f232d 100644 --- a/compose/runtime/runtime/api/public_plus_experimental_current.txt +++ b/compose/runtime/runtime/api/public_plus_experimental_current.txt
@@ -465,6 +465,7 @@ method @androidx.compose.runtime.Composable public staticandroidx.compose.runtime.State method @androidx.compose.runtime.Composable public staticcollectAsState(kotlinx.coroutines.flow.StateFlow extends T>, optional kotlin.coroutines.CoroutineContext context); androidx.compose.runtime.State method public staticcollectAsState(kotlinx.coroutines.flow.Flow extends T>, R? initial, optional kotlin.coroutines.CoroutineContext context); androidx.compose.runtime.State + method public staticderivedStateOf(kotlin.jvm.functions.Function0 extends T> calculation); androidx.compose.runtime.State method public static inline operatorderivedStateOf(androidx.compose.runtime.SnapshotMutationPolicy policy, kotlin.jvm.functions.Function0 extends T> calculation); T! getValue(androidx.compose.runtime.State extends T>, Object? thisObj, kotlin.reflect.KProperty> property); method public staticandroidx.compose.runtime.snapshots.SnapshotStateList method public staticmutableStateListOf(); androidx.compose.runtime.snapshots.SnapshotStateList @@ -490,6 +491,7 @@ method @androidx.compose.runtime.Composable public staticmutableStateListOf(T?... elements); androidx.compose.runtime.State method @androidx.compose.runtime.Composable public staticcollectAsState(kotlinx.coroutines.flow.StateFlow extends T>, optional kotlin.coroutines.CoroutineContext context); androidx.compose.runtime.State method public staticcollectAsState(kotlinx.coroutines.flow.Flow extends T>, R? initial, optional kotlin.coroutines.CoroutineContext context); androidx.compose.runtime.State + method public staticderivedStateOf(kotlin.jvm.functions.Function0 extends T> calculation); androidx.compose.runtime.State method public static inline operatorderivedStateOf(androidx.compose.runtime.SnapshotMutationPolicy policy, kotlin.jvm.functions.Function0 extends T> calculation); T! getValue(androidx.compose.runtime.State extends T>, Object? thisObj, kotlin.reflect.KProperty> property); method public staticandroidx.compose.runtime.snapshots.SnapshotStateList method public staticmutableStateListOf(); androidx.compose.runtime.snapshots.SnapshotStateList @@ -515,6 +517,7 @@ method @androidx.compose.runtime.Composable public staticmutableStateListOf(T?... elements); androidx.compose.runtime.State method @androidx.compose.runtime.Composable public staticcollectAsState(kotlinx.coroutines.flow.StateFlow extends T>, optional kotlin.coroutines.CoroutineContext context); androidx.compose.runtime.State method public staticcollectAsState(kotlinx.coroutines.flow.Flow extends T>, R? initial, optional kotlin.coroutines.CoroutineContext context); androidx.compose.runtime.State + method public staticderivedStateOf(kotlin.jvm.functions.Function0 extends T> calculation); androidx.compose.runtime.State method public static inline operatorderivedStateOf(androidx.compose.runtime.SnapshotMutationPolicy policy, kotlin.jvm.functions.Function0 extends T> calculation); T! getValue(androidx.compose.runtime.State extends T>, Object? thisObj, kotlin.reflect.KProperty> property); method public staticandroidx.compose.runtime.snapshots.SnapshotStateList method public staticmutableStateListOf(); androidx.compose.runtime.snapshots.SnapshotStateList @@ -540,6 +543,7 @@ method @androidx.compose.runtime.Composable public staticmutableStateListOf(T?... elements); androidx.compose.runtime.State method @androidx.compose.runtime.Composable public staticcollectAsState(kotlinx.coroutines.flow.StateFlow extends T>, optional kotlin.coroutines.CoroutineContext context); androidx.compose.runtime.State method public staticcollectAsState(kotlinx.coroutines.flow.Flow extends T>, R? initial, optional kotlin.coroutines.CoroutineContext context); androidx.compose.runtime.State + method public staticderivedStateOf(kotlin.jvm.functions.Function0 extends T> calculation); androidx.compose.runtime.State method public static inline operatorderivedStateOf(androidx.compose.runtime.SnapshotMutationPolicy policy, kotlin.jvm.functions.Function0 extends T> calculation); T! getValue(androidx.compose.runtime.State extends T>, Object? thisObj, kotlin.reflect.KProperty> property); method public staticandroidx.compose.runtime.snapshots.SnapshotStateList method public staticmutableStateListOf(); androidx.compose.runtime.snapshots.SnapshotStateList @@ -565,6 +569,7 @@ method @androidx.compose.runtime.Composable public staticmutableStateListOf(T?... elements); androidx.compose.runtime.State method @androidx.compose.runtime.Composable public staticcollectAsState(kotlinx.coroutines.flow.StateFlow extends T>, optional kotlin.coroutines.CoroutineContext context); androidx.compose.runtime.State method public staticcollectAsState(kotlinx.coroutines.flow.Flow extends T>, R? initial, optional kotlin.coroutines.CoroutineContext context); androidx.compose.runtime.State + method public staticderivedStateOf(kotlin.jvm.functions.Function0 extends T> calculation); androidx.compose.runtime.State method public static inline operatorderivedStateOf(androidx.compose.runtime.SnapshotMutationPolicy policy, kotlin.jvm.functions.Function0 extends T> calculation); T! getValue(androidx.compose.runtime.State extends T>, Object? thisObj, kotlin.reflect.KProperty> property); method public staticandroidx.compose.runtime.snapshots.SnapshotStateList method public staticmutableStateListOf(); androidx.compose.runtime.snapshots.SnapshotStateList mutableStateListOf(T?... elements);
diff --git a/compose/runtime/runtime/api/restricted_current.txt b/compose/runtime/runtime/api/restricted_current.txt index 2643f45..8d12646 100644 --- a/compose/runtime/runtime/api/restricted_current.txt +++ b/compose/runtime/runtime/api/restricted_current.txt
@@ -443,6 +443,7 @@ method @androidx.compose.runtime.Composable public staticandroidx.compose.runtime.State method @androidx.compose.runtime.Composable public staticcollectAsState(kotlinx.coroutines.flow.StateFlow extends T>, optional kotlin.coroutines.CoroutineContext context); androidx.compose.runtime.State method public staticcollectAsState(kotlinx.coroutines.flow.Flow extends T>, R? initial, optional kotlin.coroutines.CoroutineContext context); androidx.compose.runtime.State + method public staticderivedStateOf(kotlin.jvm.functions.Function0 extends T> calculation); androidx.compose.runtime.State method public static inline operatorderivedStateOf(androidx.compose.runtime.SnapshotMutationPolicy policy, kotlin.jvm.functions.Function0 extends T> calculation); T! getValue(androidx.compose.runtime.State extends T>, Object? thisObj, kotlin.reflect.KProperty> property); method public staticandroidx.compose.runtime.snapshots.SnapshotStateList method public staticmutableStateListOf(); androidx.compose.runtime.snapshots.SnapshotStateList @@ -468,6 +469,7 @@ method @androidx.compose.runtime.Composable public staticmutableStateListOf(T?... elements); androidx.compose.runtime.State method @androidx.compose.runtime.Composable public staticcollectAsState(kotlinx.coroutines.flow.StateFlow extends T>, optional kotlin.coroutines.CoroutineContext context); androidx.compose.runtime.State method public staticcollectAsState(kotlinx.coroutines.flow.Flow extends T>, R? initial, optional kotlin.coroutines.CoroutineContext context); androidx.compose.runtime.State + method public staticderivedStateOf(kotlin.jvm.functions.Function0 extends T> calculation); androidx.compose.runtime.State method public static inline operatorderivedStateOf(androidx.compose.runtime.SnapshotMutationPolicy policy, kotlin.jvm.functions.Function0 extends T> calculation); T! getValue(androidx.compose.runtime.State extends T>, Object? thisObj, kotlin.reflect.KProperty> property); method public staticandroidx.compose.runtime.snapshots.SnapshotStateList method public staticmutableStateListOf(); androidx.compose.runtime.snapshots.SnapshotStateList @@ -493,6 +495,7 @@ method @androidx.compose.runtime.Composable public staticmutableStateListOf(T?... elements); androidx.compose.runtime.State method @androidx.compose.runtime.Composable public staticcollectAsState(kotlinx.coroutines.flow.StateFlow extends T>, optional kotlin.coroutines.CoroutineContext context); androidx.compose.runtime.State method public staticcollectAsState(kotlinx.coroutines.flow.Flow extends T>, R? initial, optional kotlin.coroutines.CoroutineContext context); androidx.compose.runtime.State + method public staticderivedStateOf(kotlin.jvm.functions.Function0 extends T> calculation); androidx.compose.runtime.State method public static inline operatorderivedStateOf(androidx.compose.runtime.SnapshotMutationPolicy policy, kotlin.jvm.functions.Function0 extends T> calculation); T! getValue(androidx.compose.runtime.State extends T>, Object? thisObj, kotlin.reflect.KProperty> property); method public staticandroidx.compose.runtime.snapshots.SnapshotStateList method public staticmutableStateListOf(); androidx.compose.runtime.snapshots.SnapshotStateList @@ -518,6 +521,7 @@ method @androidx.compose.runtime.Composable public staticmutableStateListOf(T?... elements); androidx.compose.runtime.State method @androidx.compose.runtime.Composable public staticcollectAsState(kotlinx.coroutines.flow.StateFlow extends T>, optional kotlin.coroutines.CoroutineContext context); androidx.compose.runtime.State method public staticcollectAsState(kotlinx.coroutines.flow.Flow extends T>, R? initial, optional kotlin.coroutines.CoroutineContext context); androidx.compose.runtime.State + method public staticderivedStateOf(kotlin.jvm.functions.Function0 extends T> calculation); androidx.compose.runtime.State method public static inline operatorderivedStateOf(androidx.compose.runtime.SnapshotMutationPolicy policy, kotlin.jvm.functions.Function0 extends T> calculation); T! getValue(androidx.compose.runtime.State extends T>, Object? thisObj, kotlin.reflect.KProperty> property); method public staticandroidx.compose.runtime.snapshots.SnapshotStateList method public staticmutableStateListOf(); androidx.compose.runtime.snapshots.SnapshotStateList @@ -543,6 +547,7 @@ method @androidx.compose.runtime.Composable public staticmutableStateListOf(T?... elements); androidx.compose.runtime.State method @androidx.compose.runtime.Composable public staticcollectAsState(kotlinx.coroutines.flow.StateFlow extends T>, optional kotlin.coroutines.CoroutineContext context); androidx.compose.runtime.State method public staticcollectAsState(kotlinx.coroutines.flow.Flow extends T>, R? initial, optional kotlin.coroutines.CoroutineContext context); androidx.compose.runtime.State + method public staticderivedStateOf(kotlin.jvm.functions.Function0 extends T> calculation); androidx.compose.runtime.State method public static inline operatorderivedStateOf(androidx.compose.runtime.SnapshotMutationPolicy policy, kotlin.jvm.functions.Function0 extends T> calculation); T! getValue(androidx.compose.runtime.State extends T>, Object? thisObj, kotlin.reflect.KProperty> property); method public staticandroidx.compose.runtime.snapshots.SnapshotStateList method public staticmutableStateListOf(); androidx.compose.runtime.snapshots.SnapshotStateList mutableStateListOf(T?... elements);
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/DerivedState.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/DerivedState.kt index f1e5901..ee172c5 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/DerivedState.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/DerivedState.kt
@@ -23,14 +23,15 @@ import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.runtime.snapshots.StateObject import androidx.compose.runtime.snapshots.StateRecord +import androidx.compose.runtime.snapshots.current import androidx.compose.runtime.snapshots.fastForEach import androidx.compose.runtime.snapshots.newWritableRecord -import androidx.compose.runtime.snapshots.readable import androidx.compose.runtime.snapshots.sync import androidx.compose.runtime.snapshots.withCurrent // Explicit imports for these needed in common source sets. import kotlin.jvm.JvmName import kotlin.jvm.JvmMultifileClass +import kotlin.math.min /** * A [State] that is derived from one or more other states. @@ -50,25 +51,33 @@ * observer set, if the state could affect value of this derived state. */ val dependencies: Set+ + /** + * Mutation policy that controls how changes are handled after state dependencies update. + * If the policy is `null`, the derived state update is triggered regardless of the value + * produced and it is up to observer to invalidate it correctly. + */ + val policy: SnapshotMutationPolicy ? } private typealias DerivedStateObservers = Pair<(DerivedState<*>) -> Unit, (DerivedState<*>) -> Unit> private val derivedStateObservers = SnapshotThreadLocal>() -private val isCalculationBlockRunning = SnapshotThreadLocal() +private val calculationBlockNestedLevel = SnapshotThreadLocal() private class DerivedSnapshotState( - private val calculation: () -> T + private val calculation: () -> T, + override val policy: SnapshotMutationPolicy? ) : StateObject, DerivedState{ private var first: ResultRecord= ResultRecord() - private class ResultRecord: StateRecord() { + class ResultRecord: StateRecord() { companion object { val Unset = Any() } - var dependencies: HashSet? = null + var dependencies: HashMap? = null var result: Any? = Unset var resultHash: Int = 0 @@ -90,9 +99,22 @@ val dependencies = sync { dependencies } if (dependencies != null) { notifyObservers(derivedState) { - for (stateObject in dependencies) { + for ((stateObject, readLevel) in dependencies.entries) { + if (readLevel != 1) { + continue + } + + if (stateObject is DerivedSnapshotState<*>) { + // eagerly access the parent derived states without recording the + // read + // that way we can be sure derived states in deps were recalculated, + // and are updated to the last values + stateObject.refresh(stateObject.firstStateRecord, snapshot) + } + // Find the first record without triggering an observer read. - val record = stateObject.firstStateRecord.readable(stateObject, snapshot) + val record = current(stateObject.firstStateRecord, snapshot) + hash = 31 * hash + identityHashCode(record) hash = 31 * hash + record.snapshotId } @@ -102,49 +124,83 @@ } } + fun refresh(record: StateRecord, snapshot: Snapshot) { + @Suppress("UNCHECKED_CAST") + currentRecord(record as ResultRecord, snapshot, false, calculation) + } + private fun currentRecord( readable: ResultRecord, snapshot: Snapshot, + forceDependencyReads: Boolean, calculation: () -> T ): ResultRecord{ if (readable.isValid(this, snapshot)) { - @Suppress("UNCHECKED_CAST") + // If the dependency is not recalculated, emulate nested state reads + // for correct invalidation later + if (forceDependencyReads) { + notifyObservers(this) { + val dependencies = readable.dependencies ?: emptyMap() + val invalidationNestedLevel = calculationBlockNestedLevel.get() ?: 0 + for ((dependency, nestedLevel) in dependencies) { + calculationBlockNestedLevel.set(nestedLevel + invalidationNestedLevel) + snapshot.readObserver?.invoke(dependency) + } + calculationBlockNestedLevel.set(invalidationNestedLevel) + } + } return readable } - val nestedCalculationBlockCall = isCalculationBlockRunning.get() ?: false + val nestedCalculationLevel = calculationBlockNestedLevel.get() ?: 0 - val newDependencies = HashSet() + val newDependencies = HashMap() val result = notifyObservers(this) { - if (!nestedCalculationBlockCall) { - isCalculationBlockRunning.set(true) - } + calculationBlockNestedLevel.set(nestedCalculationLevel + 1) + val result = Snapshot.observe( { if (it === this) error("A derived state calculation cannot read itself") - if (it is StateObject) newDependencies.add(it) + if (it is StateObject) { + val readNestedLevel = calculationBlockNestedLevel.get()!! + newDependencies[it] = min( + readNestedLevel - nestedCalculationLevel, + newDependencies[it] ?: Int.MAX_VALUE + ) + } }, null, calculation ) - if (!nestedCalculationBlockCall) { - isCalculationBlockRunning.set(false) - } + + calculationBlockNestedLevel.set(nestedCalculationLevel) result } - val written = sync { - val writeSnapshot = Snapshot.current - val writable = first.newWritableRecord(this, writeSnapshot) - writable.dependencies = newDependencies - writable.resultHash = writable.readableHash(this, writeSnapshot) - writable.result = result - writable + val record = sync { + val currentSnapshot = Snapshot.current + + if ( + readable.result !== ResultRecord.Unset && + @Suppress("UNCHECKED_CAST") + policy?.equivalent(result, readable.result as T) == true + ) { + readable.dependencies = newDependencies + readable.resultHash = readable.readableHash(this, currentSnapshot) + readable + } else { + val writable = first.newWritableRecord(this, currentSnapshot) + writable.dependencies = newDependencies + writable.resultHash = writable.readableHash(this, currentSnapshot) + writable.result = result + writable + } } - if (!nestedCalculationBlockCall) { + + if (nestedCalculationLevel == 0) { Snapshot.notifyObjectsInitialized() } - return written + return record } override val firstStateRecord: StateRecord get() = first @@ -168,12 +224,12 @@ override val currentValue: T get() = first.withCurrent { @Suppress("UNCHECKED_CAST") - currentRecord(it, Snapshot.current, calculation).result as T + currentRecord(it, Snapshot.current, true, calculation).result as T } override val dependencies: Setget() = first.withCurrent { - currentRecord(it, Snapshot.current, calculation).dependencies ?: emptySet() + currentRecord(it, Snapshot.current, false, calculation).dependencies?.keys ?: emptySet() } override fun toString(): String = first.withCurrent { @@ -221,12 +277,35 @@ * objects that got read during the [calculation] to be read in the current [Snapshot], meaning * that this will correctly subscribe to the derived state objects if the value is being read in * an observed context such as a [Composable] function. + * Derived states without mutation policy trigger updates on each dependency change. To avoid + * invalidation on update, provide suitable [SnapshotMutationPolicy] through [derivedStateOf] + * overload. * * @sample androidx.compose.runtime.samples.DerivedStateSample * * @param calculation the calculation to create the value this state object represents. */ -fun derivedStateOf(calculation: () -> T): State +fun= DerivedSnapshotState(calculation) derivedStateOf( + calculation: () -> T, +): State= DerivedSnapshotState(calculation, null) + +/** + * Creates a [State] object whose [State.value] is the result of [calculation]. The result of + * calculation will be cached in such a way that calling [State.value] repeatedly will not cause + * [calculation] to be executed multiple times, but reading [State.value] will cause all [State] + * objects that got read during the [calculation] to be read in the current [Snapshot], meaning + * that this will correctly subscribe to the derived state objects if the value is being read in + * an observed context such as a [Composable] function. + * + * @sample androidx.compose.runtime.samples.DerivedStateSample + * + * @param policy mutation policy to control when changes to the [calculation] result trigger update. + * @param calculation the calculation to create the value this state object represents. + */ +funderivedStateOf( + policy: SnapshotMutationPolicy, + calculation: () -> T, +): State= DerivedSnapshotState(calculation, policy) /** * Observe the recalculations performed by any derived state that is recalculated during the
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/RecomposeScopeImpl.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/RecomposeScopeImpl.kt index 4914cce..2de14b5 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/RecomposeScopeImpl.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/RecomposeScopeImpl.kt
@@ -261,8 +261,12 @@ if ( instances.isNotEmpty() && instances.all { instance -> - instance is DerivedState<*> && - trackedDependencies[instance] == instance.currentValue + instance is DerivedState<*> && instance.let { + @Suppress("UNCHECKED_CAST") + it as DerivedState+ val policy = it.policy ?: structuralEqualityPolicy() + policy.equivalent(it.currentValue, trackedDependencies[it]) + } } ) return false
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/Snapshot.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/Snapshot.kt index 2e5d0da..9ee164d 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/Snapshot.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/Snapshot.kt
@@ -215,8 +215,8 @@ /** * Notify the snapshot that all objects created in this snapshot to this point should be - * considered initialized. If any state object is are modified passed this point it will - * appear as modified in the snapshot and any applicable snapshot write observer will be + * considered initialized. If any state object is modified after this point it will + * appear as modified in the snapshot. Any applicable snapshot write observer will be * called for the object and the object will be part of the a set of mutated objects sent to * any applicable snapshot apply observer. *
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotStateObserver.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotStateObserver.kt index 382f190..55240ef 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotStateObserver.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotStateObserver.kt
@@ -16,11 +16,14 @@ package androidx.compose.runtime.snapshots +import androidx.compose.runtime.DerivedState import androidx.compose.runtime.TestOnly import androidx.compose.runtime.collection.IdentityArrayMap import androidx.compose.runtime.collection.IdentityArraySet import androidx.compose.runtime.collection.IdentityScopeMap import androidx.compose.runtime.collection.mutableVectorOf +import androidx.compose.runtime.observeDerivedStateRecalculations +import androidx.compose.runtime.structuralEqualityPolicy /** * Helper class to efficiently observe snapshot state reads. See [observeReads] for more details. @@ -121,7 +124,12 @@ currentMap = scopeMap scopeMap.currentScope = scope - Snapshot.observe(readObserver, null, block) + observeDerivedStateRecalculations( + start = { scopeMap.deriveStateScopeCount++ }, + done = { scopeMap.deriveStateScopeCount-- }, + ) { + Snapshot.observe(readObserver, null, block) + } } finally { scopeMap.currentScope = oldScope currentMap = oldMap @@ -229,6 +237,8 @@ */ var currentScope: Any? = null + var deriveStateScopeCount = 0 + /** * Values that have been read during the scope's [SnapshotStateObserver.observeReads]. */ @@ -245,16 +255,34 @@ */ private val invalidated = hashSetOf() + private val dependencyToDerivedStates = IdentityScopeMap>() + + private val derivedStateToValue = HashMap, Any?>() + /** * Record that [value] was read in [currentScope]. */ fun recordRead(value: Any) { + if (deriveStateScopeCount > 0) { + // Reads coming from derivedStateOf block + return + } + val scope = currentScope!! valueToScopes.add(value, scope) + val recordedValues = scopeToValues[scope] ?: IdentityArraySet().also { scopeToValues[scope] = it } - recordedValues.add(value) + + if (value is DerivedState<*>) { + dependencyToDerivedStates.removeScope(value) + val dependencies = value.dependencies + for (dependency in dependencies) { + dependencyToDerivedStates.add(dependency, value) + } + derivedStateToValue[value] = value.currentValue + } } /** @@ -263,7 +291,7 @@ fun clearScopeObservations(scope: Any) { val recordedValues = scopeToValues[scope] ?: return recordedValues.fastForEach { - valueToScopes.remove(it, scope) + removeObservation(scope, it) } scopeToValues.remove(scope) } @@ -276,19 +304,27 @@ val willRemove = predicate(scope) if (willRemove) { valueSet.fastForEach { - valueToScopes.remove(it, scope) + removeObservation(scope, it) } } willRemove } } + private fun removeObservation(scope: Any, value: Any) { + valueToScopes.remove(value, scope) + if (value is DerivedState<*>) { + dependencyToDerivedStates.removeScope(value) + } + } + /** * Clear all observations. */ fun clear() { valueToScopes.clear() scopeToValues.clear() + dependencyToDerivedStates.clear() } /** @@ -298,6 +334,23 @@ fun recordInvalidation(changes: Set): Boolean { var hasValues = false for (value in changes) { + if (value in dependencyToDerivedStates) { + // Find derived state that is invalidated by this change + dependencyToDerivedStates.forEachScopeOf(value) { derivedState -> + derivedState as DerivedState+ val previousValue = derivedStateToValue[derivedState] + val policy = derivedState.policy ?: structuralEqualityPolicy() + + // Invalidate only if currentValue is different than observed on read + if (!policy.equivalent(derivedState.currentValue, previousValue)) { + valueToScopes.forEachScopeOf(derivedState) { scope -> + invalidated += scope + hasValues = true + } + } + } + } + valueToScopes.forEachScopeOf(value) { scope -> invalidated += scope hasValues = true
diff --git a/compose/runtime/runtime/src/commonTest/kotlin/androidx/compose/runtime/CompositionAndDerivedStateTests.kt b/compose/runtime/runtime/src/commonTest/kotlin/androidx/compose/runtime/CompositionAndDerivedStateTests.kt index 78cf889..0ed6a87 100644 --- a/compose/runtime/runtime/src/commonTest/kotlin/androidx/compose/runtime/CompositionAndDerivedStateTests.kt +++ b/compose/runtime/runtime/src/commonTest/kotlin/androidx/compose/runtime/CompositionAndDerivedStateTests.kt
@@ -104,6 +104,37 @@ } @Test + fun onlyInvalidatesIfResultIsStructurallyDifferent() = compositionTest { + var a by mutableStateOf(mutableListOf(32), referentialEqualityPolicy()) + var b by mutableStateOf(mutableListOf(10), referentialEqualityPolicy()) + val answer by derivedStateOf { a + b } + + compose { + Text("The answer is $answer") + } + + validate { + Text("The answer is ${a + b}") + } + + // A snapshot is necessary here otherwise the ui thread might see one changed but not + // the other. A snapshot ensures that both modifications will be seen together. + Snapshot.withMutableSnapshot { + a = mutableListOf(32) + b = mutableListOf(10) + } + + // Change both to different instance should not result in a change. + expectNoChanges() + revalidate() + + a = mutableListOf(32) + // Change just one should not result in a change. + expectNoChanges() + revalidate() + } + + @Test fun onlyEvaluateDerivedStatesThatAreLive() = compositionTest { var a by mutableStateOf(11) @@ -533,6 +564,134 @@ assertEquals(0, conditionalScopes.count { it.isConditional }) } + + @Test + fun derivedStateOfNestedChangesInvalidate() = compositionTest { + var a by mutableStateOf(31) + var b by mutableStateOf(10) + val transient by derivedStateOf { a + b } + val answer by derivedStateOf { transient - 1 } + + compose { + Text("The answer is $answer") + } + + validate { + Text("The answer is ${a + b - 1}") + } + + a++ + expectChanges() + + b++ + expectChanges() + + revalidate() + } + + @Test + fun derivedStateOfMutationPolicyDoesNotInvalidateNestedStates() = compositionTest { + var a by mutableStateOf(30) + var b by mutableStateOf(10) + val transient by derivedStateOf(structuralEqualityPolicy()) { (a + b) / 10 } + var invalidateCount = 0 + val answer by derivedStateOf { + invalidateCount++ + transient + } + + compose { + Text("The answer is $answer") + } + + validate { + Text("The answer is ${(a + b) / 10}") + } + + assertEquals(1, invalidateCount) + + a += 10 + expectChanges() + assertEquals(2, invalidateCount) + + b++ + expectNoChanges() + assertEquals(2, invalidateCount) + + revalidate() + } + + @Test + fun derivedStateOfStructuralMutationPolicyDoesntRecompose() = compositionTest { + var a by mutableStateOf(30) + var b by mutableStateOf(10) + val answer by derivedStateOf(structuralEqualityPolicy()) { + listOf(a >= 30, b >= 10) + } + var compositionCount = 0 + + compose { + val remembered = rememberUpdatedState(answer) + Linear { + compositionCount++ + Text("The answer is ${remembered.value}") + } + } + + validate { + Linear { + Text("The answer is ${listOf(true, true)}") + } + } + + assertEquals(1, compositionCount) + + a++ + expectNoChanges() // the equivalent list doesn't cause changes + assertEquals(1, compositionCount) + + b++ + expectNoChanges() // the equivalent list doesn't cause changes + assertEquals(1, compositionCount) + + revalidate() + } + + @Test + fun derivedStateOfReferencialMutationPolicyRecomposes() = compositionTest { + var a by mutableStateOf(30) + var b by mutableStateOf(10) + val answer by derivedStateOf(referentialEqualityPolicy()) { + listOf(a >= 30, b >= 10) + } + var compositionCount = 0 + + compose { + val remembered = rememberUpdatedState(answer) + Linear { + compositionCount++ + Text("The answer is ${remembered.value}") + } + } + + validate { + Linear { + Text("The answer is ${listOf(true, true)}") + } + } + + assertEquals(1, compositionCount) + + a++ + expectNoChanges() // the equivalent list doesn't cause changes + assertEquals(2, compositionCount) + + b++ + expectNoChanges() // the equivalent list doesn't cause changes + assertEquals(3, compositionCount) + + revalidate() + } } @Composable
diff --git a/compose/runtime/runtime/src/commonTest/kotlin/androidx/compose/runtime/snapshots/DerivedSnapshotStateTests.kt b/compose/runtime/runtime/src/commonTest/kotlin/androidx/compose/runtime/snapshots/DerivedSnapshotStateTests.kt index 45676b0..d7ec4ee 100644 --- a/compose/runtime/runtime/src/commonTest/kotlin/androidx/compose/runtime/snapshots/DerivedSnapshotStateTests.kt +++ b/compose/runtime/runtime/src/commonTest/kotlin/androidx/compose/runtime/snapshots/DerivedSnapshotStateTests.kt
@@ -201,9 +201,8 @@ assertEquals(0, result) // 1 for derived, 1 for state assertEquals(2, readStates.size) - // NOTE: the first calculation will cause two reads of all dependencies: one for the - // calculation, and one for the hash calculation, 3 reads total - assertEquals(3, readCount) + // 1 for derived, 1 for state + assertEquals(2, readCount) assertEquals(true, readStates.contains(state)) assertEquals(true, readStates.contains(derived)) } finally {
diff --git a/compose/runtime/runtime/src/commonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotStateObserverTestsCommon.kt b/compose/runtime/runtime/src/commonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotStateObserverTestsCommon.kt index 8dc0656..6b9bd48 100644 --- a/compose/runtime/runtime/src/commonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotStateObserverTestsCommon.kt +++ b/compose/runtime/runtime/src/commonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotStateObserverTestsCommon.kt
@@ -17,7 +17,10 @@ package androidx.compose.runtime.snapshots import androidx.compose.runtime.MutableState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.referentialEqualityPolicy +import androidx.compose.runtime.structuralEqualityPolicy import kotlin.test.Test import kotlin.test.assertEquals @@ -437,6 +440,108 @@ assertEquals(0, changes) } + @Test + fun derivedStateOfInvalidatesObserver() { + var changes = 0 + + runSimpleTest { stateObserver, state -> + val derivedState = derivedStateOf { state.value } + + stateObserver.observeReads("scope", { changes++ }) { + // read + derivedState.value + } + } + assertEquals(1, changes) + } + + @Test + fun derivedStateOfReferentialChangeDoesNotInvalidateObserver() { + var changes = 0 + + runSimpleTest { stateObserver, _ -> + val state = mutableStateOf(mutableListOf(42), referentialEqualityPolicy()) + val derivedState = derivedStateOf { state.value } + + stateObserver.observeReads("scope", { changes++ }) { + // read + derivedState.value + } + + state.value = mutableListOf(42) + } + assertEquals(0, changes) + } + + @Test + fun nestedDerivedStateOfInvalidatesObserver() { + var changes = 0 + + runSimpleTest { stateObserver, state -> + val derivedState = derivedStateOf { state.value } + val derivedState2 = derivedStateOf { derivedState.value } + + stateObserver.observeReads("scope", { changes++ }) { + // read + derivedState2.value + } + } + assertEquals(1, changes) + } + + @Test + fun derivedStateOfWithReferentialMutationPolicy() { + var changes = 0 + + runSimpleTest { stateObserver, _ -> + val state = mutableStateOf(mutableListOf(1), referentialEqualityPolicy()) + val derivedState = derivedStateOf(referentialEqualityPolicy()) { state.value } + + stateObserver.observeReads("scope", { changes++ }) { + // read + derivedState.value + } + + state.value = mutableListOf(1) + } + assertEquals(1, changes) + } + + @Test + fun derivedStateOfWithStructuralMutationPolicy() { + var changes = 0 + + runSimpleTest { stateObserver, _ -> + val state = mutableStateOf(mutableListOf(1), referentialEqualityPolicy()) + val derivedState = derivedStateOf(structuralEqualityPolicy()) { state.value } + + stateObserver.observeReads("scope", { changes++ }) { + // read + derivedState.value + } + + state.value = mutableListOf(1) + } + assertEquals(0, changes) + } + + @Test + fun readingDerivedStateAndDependencyInvalidates() { + var changes = 0 + + runSimpleTest { stateObserver, state -> + val derivedState = derivedStateOf { state.value >= 0 } + + stateObserver.observeReads("scope", { changes++ }) { + // read derived state + derivedState.value + // read dependency + state.value + } + } + assertEquals(1, changes) + } + private fun runSimpleTest( block: (modelObserver: SnapshotStateObserver, data: MutableState) -> Unit ) {
diff --git a/compose/runtime/runtime/src/jvmTest/kotlin/androidx/compose/runtime/snapshots/SnapshotStateObserverTestsJvm.kt b/compose/runtime/runtime/src/jvmTest/kotlin/androidx/compose/runtime/snapshots/SnapshotStateObserverTestsJvm.kt index 380f3ab..02d3ff8 100644 --- a/compose/runtime/runtime/src/jvmTest/kotlin/androidx/compose/runtime/snapshots/SnapshotStateObserverTestsJvm.kt +++ b/compose/runtime/runtime/src/jvmTest/kotlin/androidx/compose/runtime/snapshots/SnapshotStateObserverTestsJvm.kt
@@ -178,6 +178,7 @@ val stateObserver = SnapshotStateObserver { it() } try { stateObserver.start() + Snapshot.notifyObjectsInitialized() val observer = object : (Any) -> Unit { override fun invoke(affected: Any) { @@ -194,11 +195,10 @@ } } } - - state.value++ + // read with 0 observer.readWithObservation() - - Snapshot.notifyObjectsInitialized() + // increase to 1 + state.value++ Snapshot.sendApplyNotifications() assertEquals(1, changes)
diff --git a/core/core-google-shortcuts/build.gradle b/core/core-google-shortcuts/build.gradle index 2896ff4..d1da793 100644 --- a/core/core-google-shortcuts/build.gradle +++ b/core/core-google-shortcuts/build.gradle
@@ -37,7 +37,7 @@ dependencies { api("androidx.core:core:1.7.0") - implementation("com.google.firebase:firebase-appindexing:20.0.0") + implementation "com.google.android.gms:play-services-appindex:16.1.0" implementation("com.google.crypto.tink:tink-android:1.5.0") implementation projectOrArtifact(":appsearch:appsearch") implementation projectOrArtifact(":appsearch:appsearch-builtin-types")
diff --git a/core/core-google-shortcuts/src/androidTest/java/androidx/core/google/shortcuts/ShortcutInfoChangeListenerImplTest.java b/core/core-google-shortcuts/src/androidTest/java/androidx/core/google/shortcuts/ShortcutInfoChangeListenerImplTest.java index 40dc180..3fa18b1 100644 --- a/core/core-google-shortcuts/src/androidTest/java/androidx/core/google/shortcuts/ShortcutInfoChangeListenerImplTest.java +++ b/core/core-google-shortcuts/src/androidTest/java/androidx/core/google/shortcuts/ShortcutInfoChangeListenerImplTest.java
@@ -39,11 +39,11 @@ import androidx.test.filters.SdkSuppress; import androidx.test.filters.SmallTest; +import com.google.android.gms.appindex.Action; +import com.google.android.gms.appindex.AppIndex; +import com.google.android.gms.appindex.Indexable; +import com.google.android.gms.appindex.UserActions; import com.google.common.collect.ImmutableList; -import com.google.firebase.appindexing.Action; -import com.google.firebase.appindexing.FirebaseAppIndex; -import com.google.firebase.appindexing.FirebaseUserActions; -import com.google.firebase.appindexing.Indexable; import org.junit.Before; import org.junit.Test; @@ -58,15 +58,15 @@ @RunWith(AndroidJUnit4.class) @SdkSuppress(minSdkVersion = 21) // This module should only be called for version 21+. public class ShortcutInfoChangeListenerImplTest { - private FirebaseAppIndex mFirebaseAppIndex; - private FirebaseUserActions mFirebaseUserActions; + private AppIndex mFirebaseAppIndex; + private UserActions mFirebaseUserActions; private Context mContext; private ShortcutInfoChangeListenerImpl mShortcutInfoChangeListener; @Before public void setUp() { - mFirebaseAppIndex = mock(FirebaseAppIndex.class); - mFirebaseUserActions = mock(FirebaseUserActions.class); + mFirebaseAppIndex = mock(AppIndex.class); + mFirebaseUserActions = mock(UserActions.class); mContext = ApplicationProvider.getApplicationContext(); mShortcutInfoChangeListener = new ShortcutInfoChangeListenerImpl( mContext, mFirebaseAppIndex, mFirebaseUserActions, null); @@ -307,11 +307,9 @@ verify(mFirebaseUserActions, times(2)).end(actionCaptor.capture()); Action expectedAction1 = new Action.Builder(Action.Builder.VIEW_ACTION) .setObject("", ShortcutUtils.getIndexableUrl(mContext, "id1")) - .setMetadata(new Action.Metadata.Builder().setUpload(false)) .build(); Action expectedAction2 = new Action.Builder(Action.Builder.VIEW_ACTION) .setObject("", ShortcutUtils.getIndexableUrl(mContext, "id2")) - .setMetadata(new Action.Metadata.Builder().setUpload(false)) .build(); // Action has no equals comparator, so instead we compare their string forms. assertThat(convertActionsToString(actionCaptor.getAllValues())).containsExactly( @@ -404,19 +402,15 @@ Action expectedAction1 = new Action.Builder(Action.Builder.VIEW_ACTION) .setObject("", ShortcutUtils.getIndexableUrl(mContext, "item1")) - .setMetadata(new Action.Metadata.Builder().setUpload(false)) .build(); Action expectedAction2 = new Action.Builder(Action.Builder.VIEW_ACTION) .setObject("", ShortcutUtils.getIndexableUrl(mContext, "item2")) - .setMetadata(new Action.Metadata.Builder().setUpload(false)) .build(); Action expectedAction3 = new Action.Builder(Action.Builder.VIEW_ACTION) .setObject("", ShortcutUtils.getIndexableUrl(mContext, "restaurant1")) - .setMetadata(new Action.Metadata.Builder().setUpload(false)) .build(); Action expectedAction4 = new Action.Builder(Action.Builder.VIEW_ACTION) .setObject("", ShortcutUtils.getIndexableUrl(mContext, "restaurant2")) - .setMetadata(new Action.Metadata.Builder().setUpload(false)) .build();
diff --git a/core/core-google-shortcuts/src/androidTest/java/androidx/core/google/shortcuts/builders/ShortcutBuilderTest.java b/core/core-google-shortcuts/src/androidTest/java/androidx/core/google/shortcuts/builders/ShortcutBuilderTest.java index 6b4763f..92efab5 100644 --- a/core/core-google-shortcuts/src/androidTest/java/androidx/core/google/shortcuts/builders/ShortcutBuilderTest.java +++ b/core/core-google-shortcuts/src/androidTest/java/androidx/core/google/shortcuts/builders/ShortcutBuilderTest.java
@@ -22,7 +22,7 @@ import androidx.test.filters.SdkSuppress; import androidx.test.filters.SmallTest; -import com.google.firebase.appindexing.Indexable; +import com.google.android.gms.appindex.Indexable; import org.junit.Test; import org.junit.runner.RunWith;
diff --git a/core/core-google-shortcuts/src/androidTest/java/androidx/core/google/shortcuts/converters/AlarmConverterTest.java b/core/core-google-shortcuts/src/androidTest/java/androidx/core/google/shortcuts/converters/AlarmConverterTest.java index 073e4d8..8c2575e 100644 --- a/core/core-google-shortcuts/src/androidTest/java/androidx/core/google/shortcuts/converters/AlarmConverterTest.java +++ b/core/core-google-shortcuts/src/androidTest/java/androidx/core/google/shortcuts/converters/AlarmConverterTest.java
@@ -27,7 +27,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.SmallTest; -import com.google.firebase.appindexing.Indexable; +import com.google.android.gms.appindex.Indexable; import org.junit.Test; import org.junit.runner.RunWith;
diff --git a/core/core-google-shortcuts/src/androidTest/java/androidx/core/google/shortcuts/converters/AlarmInstanceConverterTest.java b/core/core-google-shortcuts/src/androidTest/java/androidx/core/google/shortcuts/converters/AlarmInstanceConverterTest.java index fbc23b5..2d60607 100644 --- a/core/core-google-shortcuts/src/androidTest/java/androidx/core/google/shortcuts/converters/AlarmInstanceConverterTest.java +++ b/core/core-google-shortcuts/src/androidTest/java/androidx/core/google/shortcuts/converters/AlarmInstanceConverterTest.java
@@ -26,7 +26,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.SmallTest; -import com.google.firebase.appindexing.Indexable; +import com.google.android.gms.appindex.Indexable; import org.junit.Test; import org.junit.runner.RunWith;
diff --git a/core/core-google-shortcuts/src/androidTest/java/androidx/core/google/shortcuts/converters/GenericDocumentConverterTest.java b/core/core-google-shortcuts/src/androidTest/java/androidx/core/google/shortcuts/converters/GenericDocumentConverterTest.java index d6b73b17..50dcec2a 100644 --- a/core/core-google-shortcuts/src/androidTest/java/androidx/core/google/shortcuts/converters/GenericDocumentConverterTest.java +++ b/core/core-google-shortcuts/src/androidTest/java/androidx/core/google/shortcuts/converters/GenericDocumentConverterTest.java
@@ -25,7 +25,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.SmallTest; -import com.google.firebase.appindexing.Indexable; +import com.google.android.gms.appindex.Indexable; import org.junit.Test; import org.junit.runner.RunWith;
diff --git a/core/core-google-shortcuts/src/androidTest/java/androidx/core/google/shortcuts/converters/TimerConverterTest.java b/core/core-google-shortcuts/src/androidTest/java/androidx/core/google/shortcuts/converters/TimerConverterTest.java index 1c8d69b..f805be4 100644 --- a/core/core-google-shortcuts/src/androidTest/java/androidx/core/google/shortcuts/converters/TimerConverterTest.java +++ b/core/core-google-shortcuts/src/androidTest/java/androidx/core/google/shortcuts/converters/TimerConverterTest.java
@@ -29,7 +29,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.SmallTest; -import com.google.firebase.appindexing.Indexable; +import com.google.android.gms.appindex.Indexable; import org.junit.Before; import org.junit.Test;
diff --git a/core/core-google-shortcuts/src/androidTest/java/androidx/core/google/shortcuts/utils/ConverterUtilsTest.java b/core/core-google-shortcuts/src/androidTest/java/androidx/core/google/shortcuts/utils/ConverterUtilsTest.java index 3feec3a..85a57e7 100644 --- a/core/core-google-shortcuts/src/androidTest/java/androidx/core/google/shortcuts/utils/ConverterUtilsTest.java +++ b/core/core-google-shortcuts/src/androidTest/java/androidx/core/google/shortcuts/utils/ConverterUtilsTest.java
@@ -26,7 +26,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.SmallTest; -import com.google.firebase.appindexing.Indexable; +import com.google.android.gms.appindex.Indexable; import org.junit.Test; import org.junit.runner.RunWith;
diff --git a/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/ShortcutInfoChangeListenerImpl.java b/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/ShortcutInfoChangeListenerImpl.java index a312b72..2067550 100644 --- a/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/ShortcutInfoChangeListenerImpl.java +++ b/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/ShortcutInfoChangeListenerImpl.java
@@ -42,11 +42,11 @@ import androidx.core.google.shortcuts.utils.ShortcutUtils; import androidx.core.graphics.drawable.IconCompat; +import com.google.android.gms.appindex.Action; +import com.google.android.gms.appindex.AppIndex; +import com.google.android.gms.appindex.Indexable; +import com.google.android.gms.appindex.UserActions; import com.google.crypto.tink.KeysetHandle; -import com.google.firebase.appindexing.Action; -import com.google.firebase.appindexing.FirebaseAppIndex; -import com.google.firebase.appindexing.FirebaseUserActions; -import com.google.firebase.appindexing.Indexable; import java.util.ArrayList; import java.util.List; @@ -59,8 +59,8 @@ @RestrictTo(LIBRARY_GROUP) public class ShortcutInfoChangeListenerImpl extends ShortcutInfoChangeListener { private final Context mContext; - private final FirebaseAppIndex mFirebaseAppIndex; - private final FirebaseUserActions mFirebaseUserActions; + private final AppIndex mFirebaseAppIndex; + private final UserActions mFirebaseUserActions; @Nullable private final KeysetHandle mKeysetHandle; /** @@ -71,14 +71,14 @@ */ @NonNull public static ShortcutInfoChangeListenerImpl getInstance(@NonNull Context context) { - return new ShortcutInfoChangeListenerImpl(context, FirebaseAppIndex.getInstance(context), - FirebaseUserActions.getInstance(context), + return new ShortcutInfoChangeListenerImpl(context, AppIndex.getInstance(context), + UserActions.getInstance(context), ShortcutUtils.getOrCreateShortcutKeysetHandle(context)); } @VisibleForTesting - ShortcutInfoChangeListenerImpl(Context context, FirebaseAppIndex firebaseAppIndex, - FirebaseUserActions firebaseUserActions, @Nullable KeysetHandle keysetHandle) { + ShortcutInfoChangeListenerImpl(Context context, AppIndex firebaseAppIndex, + UserActions firebaseUserActions, @Nullable KeysetHandle keysetHandle) { mContext = context; mFirebaseAppIndex = firebaseAppIndex; mFirebaseUserActions = firebaseUserActions; @@ -206,12 +206,9 @@ @NonNull private Action buildAction(@NonNull String url) { - // The reported action isn't uploaded to the server. - Action.Metadata.Builder metadataBuilder = new Action.Metadata.Builder().setUpload(false); return new Action.Builder(Action.Builder.VIEW_ACTION) // Empty label as placeholder. .setObject("", url) - .setMetadata(metadataBuilder) .build(); }
diff --git a/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/builders/CapabilityBuilder.java b/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/builders/CapabilityBuilder.java index c95e092..5414efd 100644 --- a/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/builders/CapabilityBuilder.java +++ b/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/builders/CapabilityBuilder.java
@@ -24,7 +24,7 @@ import androidx.annotation.Nullable; import androidx.annotation.RestrictTo; -import com.google.firebase.appindexing.builders.IndexableBuilder; +import com.google.android.gms.appindex.builders.IndexableBuilder; /** * Builder for the Capability section in the Shortcut Corpus.
diff --git a/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/builders/ParameterBuilder.java b/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/builders/ParameterBuilder.java index 0bb0078..f6751d2 100644 --- a/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/builders/ParameterBuilder.java +++ b/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/builders/ParameterBuilder.java
@@ -24,7 +24,7 @@ import androidx.annotation.Nullable; import androidx.annotation.RestrictTo; -import com.google.firebase.appindexing.builders.IndexableBuilder; +import com.google.android.gms.appindex.builders.IndexableBuilder; /** * Builder for the Parameter section in the Shortcut Corpus.
diff --git a/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/builders/ShortcutBuilder.java b/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/builders/ShortcutBuilder.java index d8317cb..07086ec 100644 --- a/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/builders/ShortcutBuilder.java +++ b/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/builders/ShortcutBuilder.java
@@ -27,7 +27,7 @@ import androidx.annotation.Nullable; import androidx.annotation.RestrictTo; -import com.google.firebase.appindexing.builders.IndexableBuilder; +import com.google.android.gms.appindex.builders.IndexableBuilder; /** * Builder for the Shortcut Corpus.
diff --git a/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/converters/AlarmConverter.java b/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/converters/AlarmConverter.java index 789e54a..e265f8b 100644 --- a/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/converters/AlarmConverter.java +++ b/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/converters/AlarmConverter.java
@@ -27,8 +27,8 @@ import androidx.core.google.shortcuts.utils.ConverterUtils; import androidx.core.util.Preconditions; -import com.google.firebase.appindexing.FirebaseAppIndexingInvalidArgumentException; -import com.google.firebase.appindexing.Indexable; +import com.google.android.gms.appindex.AppIndexInvalidArgumentException; +import com.google.android.gms.appindex.Indexable; import java.util.ArrayList; import java.util.Calendar; @@ -152,7 +152,7 @@ if (!alarmInstances.isEmpty()) { try { indexableBuilder.put(ALARM_INSTANCES_KEY, alarmInstances.toArray(new Indexable[0])); - } catch (FirebaseAppIndexingInvalidArgumentException e) { + } catch (AppIndexInvalidArgumentException e) { Log.e(TAG, "Failed to add AlarmInstances to Alarm.", e); } }
diff --git a/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/converters/AlarmInstanceConverter.java b/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/converters/AlarmInstanceConverter.java index 1fa4520..bbeab4c 100644 --- a/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/converters/AlarmInstanceConverter.java +++ b/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/converters/AlarmInstanceConverter.java
@@ -28,7 +28,7 @@ import androidx.core.google.shortcuts.utils.ConverterUtils; import androidx.core.util.Preconditions; -import com.google.firebase.appindexing.Indexable; +import com.google.android.gms.appindex.Indexable; import java.text.DateFormat; import java.text.ParseException;
diff --git a/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/converters/AppSearchDocumentConverter.java b/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/converters/AppSearchDocumentConverter.java index 54a1464..b63851e 100644 --- a/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/converters/AppSearchDocumentConverter.java +++ b/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/converters/AppSearchDocumentConverter.java
@@ -24,7 +24,7 @@ import androidx.annotation.RestrictTo; import androidx.appsearch.app.GenericDocument; -import com.google.firebase.appindexing.Indexable; +import com.google.android.gms.appindex.Indexable; /** * Interface for a converter that will convert an AppSearch Document into {@link Indexable}.
diff --git a/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/converters/GenericDocumentConverter.java b/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/converters/GenericDocumentConverter.java index 0c25217..a079c9a 100644 --- a/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/converters/GenericDocumentConverter.java +++ b/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/converters/GenericDocumentConverter.java
@@ -27,8 +27,8 @@ import androidx.core.google.shortcuts.utils.ConverterUtils; import androidx.core.util.Preconditions; -import com.google.firebase.appindexing.FirebaseAppIndexingInvalidArgumentException; -import com.google.firebase.appindexing.Indexable; +import com.google.android.gms.appindex.AppIndexInvalidArgumentException; +import com.google.android.gms.appindex.Indexable; /** * Default converter for all {@link GenericDocument}. This converter will map each property into @@ -70,7 +70,7 @@ try { indexableBuilder.put(property, convertGenericDocuments(context, (GenericDocument[]) rawProperty)); - } catch (FirebaseAppIndexingInvalidArgumentException e) { + } catch (AppIndexInvalidArgumentException e) { Log.e(TAG, "Cannot convert GenericDocument for property " + property); } } else {
diff --git a/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/converters/TimerConverter.java b/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/converters/TimerConverter.java index 779d764..db5f88a 100644 --- a/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/converters/TimerConverter.java +++ b/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/converters/TimerConverter.java
@@ -29,7 +29,7 @@ import androidx.core.google.shortcuts.utils.ConverterUtils; import androidx.core.util.Preconditions; -import com.google.firebase.appindexing.Indexable; +import com.google.android.gms.appindex.Indexable; /** * Convert for the {@link Timer} built-in type.
diff --git a/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/utils/ConverterUtils.java b/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/utils/ConverterUtils.java index cedac51..bfd4a4f 100644 --- a/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/utils/ConverterUtils.java +++ b/core/core-google-shortcuts/src/main/java/androidx/core/google/shortcuts/utils/ConverterUtils.java
@@ -28,7 +28,7 @@ import androidx.core.google.shortcuts.converters.IndexableKeys; import androidx.core.util.Preconditions; -import com.google.firebase.appindexing.Indexable; +import com.google.android.gms.appindex.Indexable; import java.text.DateFormat; import java.text.SimpleDateFormat;
diff --git a/core/settings.gradle b/core/settings.gradle index aa80db7..ffa5f61 100644 --- a/core/settings.gradle +++ b/core/settings.gradle
@@ -13,6 +13,7 @@ selectProjectsFromAndroidX({ name -> if (name.startsWith(":core")) return true if (name == ":internal-testutils-mockito") return true + if (name == ":internal-testutils-fonts") return true if (name == ":internal-testutils-runtime") return true if (name == ":internal-testutils-truth") return true if (name == ":annotation:annotation-sampled") return true
diff --git a/development/ktlint.sh b/development/ktlint.sh new file mode 100755 index 0000000..754bfaf --- /dev/null +++ b/development/ktlint.sh
@@ -0,0 +1,18 @@ +#!/bin/bash + +function usage() { + echo "usage: $0" + echo + echo "Runs the ktlint Gradle task with the provided gradle_arguments, if any of the files to check with .kt(s). Specify files to check using --file=arguments." + exit 1 +} + +if [ "$1" == "" ]; then + usage +fi + +PROJECT_ROOT=$(dirname "$0")/.. + +if echo "$@" | tr ' ' '\n' | grep -q "^--file=.\+\.kts\?$"; then + exec "$PROJECT_ROOT"/gradlew -q -p "$PROJECT_ROOT" --continue :ktlintCheckFile --configuration-cache "$@" +fi
diff --git a/development/update_studio.sh b/development/update_studio.sh index 473cc42..a3cb5fb 100755 --- a/development/update_studio.sh +++ b/development/update_studio.sh
@@ -1,7 +1,7 @@ #!/bin/bash # Get versions -AGP_VERSION=${1:-7.4.0-alpha07} -STUDIO_VERSION_STRING=${2:-"Android Studio Electric Eel (2022.1.1) Canary 7"} +AGP_VERSION=${1:-7.4.0-alpha08} +STUDIO_VERSION_STRING=${2:-"Android Studio Electric Eel (2022.1.1) Canary 8"} STUDIO_IFRAME_LINK=`curl "https://developer.android.com/studio/archive.html" | grep iframe | sed "s/.*src=\"\([a-zA-Z0-9\/\._]*\)\".*/https:\/\/android-dot-devsite-v2-prod.appspot.com\1/g"` STUDIO_LINK=`curl -s $STUDIO_IFRAME_LINK | grep -C30 "$STUDIO_VERSION_STRING" | grep Linux | tail -n 1 | sed 's/.*a href="\(.*\).*"/\1/g'` STUDIO_VERSION=`echo $STUDIO_LINK | sed "s/.*ide-zips\/\(.*\)\/android-studio-.*/\1/g"` @@ -43,4 +43,4 @@ ARTIFACTS_TO_DOWNLOAD+="com.google.testing.platform:core:$ATP_VERSION" # Download all the artifacts -./development/importMaven/importMaven.sh import-artifact --artifacts "$ARTIFACTS_TO_DOWNLOAD" \ No newline at end of file +./development/importMaven/importMaven.sh "$ARTIFACTS_TO_DOWNLOAD" \ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1095b04..630cc0f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml
@@ -2,13 +2,13 @@ # ----------------------------------------------------------------------------- # All of the following should be updated in sync. # ----------------------------------------------------------------------------- -androidGradlePlugin = "7.4.0-alpha07" +androidGradlePlugin = "7.4.0-alpha08" # NOTE: When updating the lint version we also need to update the `api` version # supported by `IssueRegistry`'s.' For e.g. r.android.com/1331903 -androidLint = "30.4.0-alpha07" +androidLint = "30.4.0-alpha08" # Once you have a chosen version of AGP to upgrade to, go to # https://developer.android.com/studio/archive and find the matching version of Studio. -androidStudio = "2022.1.1.7" +androidStudio = "2022.1.1.8" # ----------------------------------------------------------------------------- androidGradlePluginMin = "7.0.4"
diff --git a/metrics/metrics-performance/src/androidTest/java/androidx/metrics/performance/test/JankStatsTest.kt b/metrics/metrics-performance/src/androidTest/java/androidx/metrics/performance/test/JankStatsTest.kt index 7582c07..389b651 100644 --- a/metrics/metrics-performance/src/androidTest/java/androidx/metrics/performance/test/JankStatsTest.kt +++ b/metrics/metrics-performance/src/androidTest/java/androidx/metrics/performance/test/JankStatsTest.kt
@@ -20,12 +20,12 @@ import android.os.Build.VERSION_CODES.JELLY_BEAN import android.view.Choreographer import androidx.annotation.RequiresApi -import androidx.metrics.performance.PerformanceMetricsState import androidx.metrics.performance.FrameData import androidx.metrics.performance.FrameDataApi24 import androidx.metrics.performance.FrameDataApi31 import androidx.metrics.performance.JankStats import androidx.metrics.performance.JankStats.OnFrameListener +import androidx.metrics.performance.PerformanceMetricsState import androidx.metrics.performance.StateInfo import androidx.test.annotation.UiThreadTest import androidx.test.ext.junit.rules.ActivityScenarioRule @@ -34,10 +34,11 @@ import androidx.test.filters.SdkSuppress import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit +import kotlin.math.max import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asExecutor -import org.hamcrest.Matchers import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals @@ -74,6 +75,7 @@ frameInit = FrameInitCompat(this) } } + @Rule @JvmField var delayedActivityRule: ActivityScenarioRule= @@ -87,8 +89,11 @@ delayedView = delayedActivity.findViewById(R.id.delayedView) latchedListener = LatchedListener() latchedListener.latch = CountDownLatch(1) - jankStats = JankStats.createAndTrack(delayedActivity.window, - Dispatchers.Default.asExecutor(), latchedListener) + jankStats = JankStats.createAndTrack( + delayedActivity.window, + Dispatchers.Default.asExecutor(), + latchedListener + ) metricsState = PerformanceMetricsState.getForHierarchy(delayedView).state!! } } @@ -99,8 +104,35 @@ assert(PerformanceMetricsState.getForHierarchy(delayedView).state == metricsState) } + /** + * Test adding/removing listeners while inside the listener callback (on the same thread) + */ @Test - @UiThreadTest + fun testConcurrentListenerModifications() { + lateinit var jsSecond: JankStats + lateinit var jsThird: JankStats + + // We add two more JankStats object with another listener which will add/remove + // in the callback. One more listener will not necessarily trigger the issue, because if + // that listener is at the end of the internal list of listeners, then the iterator will + // not try to find another one after it has been removed. So we make the list large enough + // that we will be removing a listener in the middle. + val secondListener = OnFrameListener { + jsSecond.isTrackingEnabled = !jsSecond.isTrackingEnabled + jsThird.isTrackingEnabled = !jsThird.isTrackingEnabled + } + delayedActivityRule.scenario.onActivity { _ -> + // To repro, must add/remove on the same thread as the one used to call the listeners. + // That thread is determined by the Executor. Here's a dirty hack to force the executor + // to run on the same thread as the one iterating through the listeners. + val executor = Runnable::run + jsSecond = JankStats.createAndTrack(delayedActivity.window, executor, secondListener) + jsThird = JankStats.createAndTrack(delayedActivity.window, executor, secondListener) + } + runDelayTest(frameDelay = 0, NUM_FRAMES, latchedListener) + } + + @Test fun testEnable() { assertTrue(jankStats.isTrackingEnabled) jankStats.isTrackingEnabled = false @@ -192,8 +224,7 @@ val scenario = delayedActivityRule.scenario scenario.onActivity { _ -> jankStats2 = JankStats.createAndTrack( - delayedActivity.window, - Dispatchers.Default.asExecutor(), secondListener + delayedActivity.window, Dispatchers.Default.asExecutor(), secondListener ) } val testState = StateInfo("Testing State", "sampleState") @@ -237,9 +268,9 @@ } } listenerPostingThread.start() - runDelayTest(frameDelay, NUM_FRAMES * 100, latchedListener) // add listeners concurrently - no asserts here, just testing whether we // avoid any concurrency issues with adding and using multiple listeners + runDelayTest(frameDelay, NUM_FRAMES * 100, latchedListener) } @SdkSuppress(minSdkVersion = JELLY_BEAN) @@ -510,7 +541,7 @@ listener.numFrames = 0 } try { - latch.await(frameDelay * numFrames + 1000L, TimeUnit.MILLISECONDS) + latch.await(max(frameDelay, 1) * numFrames + 1000L, TimeUnit.MILLISECONDS) } catch (e: InterruptedException) { assert(false) }
diff --git a/metrics/metrics-performance/src/main/java/androidx/metrics/performance/JankStatsApi16Impl.kt b/metrics/metrics-performance/src/main/java/androidx/metrics/performance/JankStatsApi16Impl.kt index c325df5e..e0774cc 100644 --- a/metrics/metrics-performance/src/main/java/androidx/metrics/performance/JankStatsApi16Impl.kt +++ b/metrics/metrics-performance/src/main/java/androidx/metrics/performance/JankStatsApi16Impl.kt
@@ -64,13 +64,11 @@ override fun setupFrameTimer(enable: Boolean) { val decorView = decorViewRef.get() decorView?.let { - withDelegates(decorView) { - if (enable) { - val delegates = decorView.getOrCreateOnPreDrawListenerDelegates() - delegates.add(onFrameListenerDelegate) - } else { - decorView.removeOnPreDrawListenerDelegate(onFrameListenerDelegate) - } + if (enable) { + val delegates = decorView.getOrCreateOnPreDrawListenerDelegator() + delegates.add(onFrameListenerDelegate) + } else { + decorView.removeOnPreDrawListenerDelegate(onFrameListenerDelegate) } } } @@ -89,13 +87,7 @@ private fun View.removeOnPreDrawListenerDelegate(delegate: OnFrameListenerDelegate) { val delegator = getTag(R.id.metricsDelegator) as DelegatingOnPreDrawListener? - with(delegator?.delegates) { - this?.remove(delegate) - if (this?.size == 0) { - viewTreeObserver.removeOnPreDrawListener(delegator) - setTag(R.id.metricsDelegator, null) - } - } + delegator?.remove(delegate) } /** @@ -103,8 +95,7 @@ * If no such list exists, it will create it and add a root listener that * delegates to that list. */ - private fun View.getOrCreateOnPreDrawListenerDelegates(): - MutableList{ + private fun View.getOrCreateOnPreDrawListenerDelegator(): DelegatingOnPreDrawListener { var delegator = getTag(R.id.metricsDelegator) as DelegatingOnPreDrawListener? if (delegator == null) { val delegates = mutableListOf() @@ -112,7 +103,7 @@ viewTreeObserver.addOnPreDrawListener(delegator) setTag(R.id.metricsDelegator, delegator) } - return delegator.delegates + return delegator } internal open fun createDelegatingOnDrawListener( @@ -131,22 +122,6 @@ fun getExpectedFrameDuration(view: View?): Long { return DelegatingOnPreDrawListener.getExpectedFrameDuration(view) } - - companion object { - - /** - * Anything dealing with the delegates list must go through this function, to avoid the list - * changing while being used on a different thread. It synchronizes on the DecorView object - * to guarantee that all delegate access is similarly synchronized. - */ - fun withDelegates(decorView: View, delegateAction: () -> Unit) { - // prevent concurrent modification of delegates list by synchronizing on - // DecorView, which holds the delegator object - synchronized(decorView) { - delegateAction() - } - } - } } /** @@ -169,9 +144,32 @@ val delegates: MutableList) : ViewTreeObserver.OnPreDrawListener { + // Track whether the delegate list is being iterated, used to prevent concurrent modification + var iterating = false + + // These lists cache add/remove requests to be handled after the current iteration loop + val toBeAdded = mutableListOf () + val toBeRemoved = mutableListOf() + val decorViewRef = WeakReference(decorView) val metricsStateHolder = PerformanceMetricsState.getForHierarchy(decorView) + /** + * It is possible for the delegates list to be modified concurrently (adding/removing items + * while also iterating through the list). To prevent this, we synchronize on this instance. + * It is also possible for the same thread to do both operations, causing reentrance into + * that synchronization block. However, the only way that should happen is if the list is + * being iterated on (which is called from the UI thread) and, in any of those delegate + * listeners, the delegates list is modified + * (by calling JankStats.isTrackingEnabled()). In this case, we cache the request in one of the + * toBeAdded/Removed lists and return. When iteration is complete, we handle those requests. + * This would not be sufficient if those operations could happen randomly on the same thread, + * but the order should also be as described above (with add/remove nested inside iteration). + * + * Iteration and add/remove could also happen randomly and concurrently on different threads, + * but in that case the synchronization block around both accesses should suffice. + */ + override fun onPreDraw(): Boolean { val decorView = decorViewRef.get() decorView?.let { @@ -180,10 +178,27 @@ handler.sendMessage(Message.obtain(handler) { val now = System.nanoTime() val expectedDuration = getExpectedFrameDuration(decorView) - JankStatsApi16Impl.withDelegates(decorView) { + // prevent concurrent modification of delegates list by synchronizing on + // this delegator object while iterating and modifying + synchronized(this@DelegatingOnPreDrawListener) { + iterating = true for (delegate in delegates) { delegate.onFrame(frameStart, now - frameStart, expectedDuration) } + for (delegate in toBeAdded) { + delegates.add(delegate) + } + toBeAdded.clear() + for (delegate in toBeRemoved) { + delegates.remove(delegate) + } + toBeRemoved.clear() + if (delegates.size == 0) { + viewTreeObserver.removeOnPreDrawListener( + this@DelegatingOnPreDrawListener) + setTag(R.id.metricsDelegator, null) + } + iterating = false } metricsStateHolder.state?.cleanupSingleFrameStates() }.apply { @@ -194,6 +209,29 @@ return true } + fun add(delegate: OnFrameListenerDelegate) { + // prevent concurrent modification of delegates list by synchronizing on + // this delegator object while iterating and modifying + synchronized(this) { + if (iterating) { + toBeAdded.add(delegate) + } else { + delegates.add(delegate) + } + } + } + + fun remove(delegate: OnFrameListenerDelegate) { + // prevent concurrent modification of delegates list by synchronizing on + // this delegator object while iterating and modifying + synchronized(this) { + if (iterating) { + toBeRemoved.add(delegate) + } else { + delegates.remove(delegate) + } + } + } private fun getFrameStartTime(): Long { return choreographerLastFrameTimeField.get(choreographer) as Long }
diff --git a/metrics/metrics-performance/src/main/java/androidx/metrics/performance/JankStatsApi24Impl.kt b/metrics/metrics-performance/src/main/java/androidx/metrics/performance/JankStatsApi24Impl.kt index 7c331e7..f8658c9 100644 --- a/metrics/metrics-performance/src/main/java/androidx/metrics/performance/JankStatsApi24Impl.kt +++ b/metrics/metrics-performance/src/main/java/androidx/metrics/performance/JankStatsApi24Impl.kt
@@ -93,20 +93,18 @@ } override fun setupFrameTimer(enable: Boolean) { - window.let { - // prevent concurrent modification of delegates list by synchronizing on - // Window, whose DecorView holds the delegator object - synchronized(window) { - if (enable) { - if (listenerAddedTime == 0L) { - val delegates = window.getOrCreateFrameMetricsListenerDelegates() - delegates.add(frameMetricsAvailableListenerDelegate) - listenerAddedTime = System.nanoTime() - } - } else { - window.removeFrameMetricsListenerDelegate(frameMetricsAvailableListenerDelegate) - listenerAddedTime = 0 + // prevent concurrent modification of delegates list by synchronizing on + // Window, whose DecorView holds the delegator object + synchronized(window) { + if (enable) { + if (listenerAddedTime == 0L) { + val delegates = window.getOrCreateFrameMetricsListenerDelegator() + delegates.add(frameMetricsAvailableListenerDelegate) + listenerAddedTime = System.nanoTime() } + } else { + window.removeFrameMetricsListenerDelegate(frameMetricsAvailableListenerDelegate) + listenerAddedTime = 0 } } } @@ -123,9 +121,9 @@ ) { val delegator = decorView.getTag(R.id.metricsDelegator) as DelegatingFrameMetricsListener? - with(delegator?.delegates) { + with(delegator) { this?.remove(delegate) - if (this?.size == 0) { + if (this?.delegates?.size == 0) { removeOnFrameMetricsAvailableListener(delegator) decorView.setTag(R.id.metricsDelegator, null) } @@ -138,8 +136,8 @@ * delegates to that list. */ @RequiresApi(24) - private fun Window.getOrCreateFrameMetricsListenerDelegates(): - MutableList{ + private fun Window.getOrCreateFrameMetricsListenerDelegator(): + DelegatingFrameMetricsListener { var delegator = decorView.getTag(R.id.metricsDelegator) as DelegatingFrameMetricsListener? if (delegator == null) { @@ -155,7 +153,7 @@ addOnFrameMetricsAvailableListener(delegator, frameMetricsHandler) decorView.setTag(R.id.metricsDelegator, delegator) } - return delegator.delegates + return delegator } } @@ -169,35 +167,88 @@ private class DelegatingFrameMetricsListener( val delegates: MutableList) : Window.OnFrameMetricsAvailableListener { + + // Track whether the delegate list is being iterated, used to prevent concurrent modification + var iterating = false + + // These lists cache add/remove requests to be handled after the current iteration loop + val toBeAdded = mutableListOf () + val toBeRemoved = mutableListOf() + + /** + * It is possible for the delegates list to be modified concurrently (adding/removing items + * while also iterating through the list). To prevent this, we synchronize on this instance. + * It is also possible for the same thread to do both operations, causing reentrance into + * that synchronization block. However, the only way that should happen is if the list is + * being iterated on (which is called from the FrameMetrics thread, not accessible to the + * JankStats client) and, in any of those delegate listeners, the delegates list is modified + * (by calling JankStats.isTrackingEnabled()). In this case, we cache the request in one of the + * toBeAdded/Removed lists and return. When iteration is complete, we handle those requests. + * This would not be sufficient if those operations could happen randomly on the same thread, + * but the order should also be as described above (with add/remove nested inside iteration). + * + * Iteration and add/remove could also happen randomly and concurrently on different threads, + * but in that case the synchronization block around both accesses should suffice. + */ + override fun onFrameMetricsAvailable( window: Window?, frameMetrics: FrameMetrics?, dropCount: Int ) { - if (window != null) { - // prevent concurrent modification of delegates list by synchronizing on - // Window, whose DecorView holds the delegator object - window.withDelegates() { - for (delegate in delegates) { - delegate.onFrameMetricsAvailable(window, frameMetrics, dropCount) - } + // prevent concurrent modification of delegates list by synchronizing on + // this delegator object while iterating and modifying + synchronized(this) { + iterating = true + for (delegate in delegates) { + delegate.onFrameMetricsAvailable(window, frameMetrics, dropCount) } + if (toBeAdded.isNotEmpty()) { + for (delegate in toBeAdded) { + delegates.add(delegate) + } + toBeAdded.clear() + } + if (toBeRemoved.isNotEmpty()) { + for (delegate in toBeRemoved) { + delegates.remove(delegate) + } + toBeRemoved.clear() + } + if (delegates.size == 0) { + window?.removeOnFrameMetricsAvailableListener(this) + window?.decorView?.setTag(R.id.metricsDelegator, null) + } + iterating = false + } + if (window != null) { // Remove singleFrame states now that we are done processing this frame val holder = PerformanceMetricsState.getForHierarchy(window.decorView) holder.state?.cleanupSingleFrameStates() } } - /** - * Anything dealing with the delegates list must go through this function, to avoid the list - * changing while being used on a different thread. It synchronizes on the Window object - * to guarantee that all delegate access is similarly synchronized. - */ - fun Window.withDelegates(delegateAction: () -> Unit) { + fun add(delegate: Window.OnFrameMetricsAvailableListener) { // prevent concurrent modification of delegates list by synchronizing on - // DecorView, which holds the delegator object + // this delegator object while iterating and modifying synchronized(this) { - delegateAction() + if (iterating) { + toBeAdded.add(delegate) + } else { + delegates.add(delegate) + } + } + } + + fun remove(delegate: Window.OnFrameMetricsAvailableListener) { + // prevent concurrent modification of delegates list by synchronizing on + // this delegator object while iterating and modifying + synchronized(this) { + if (iterating) { + toBeRemoved.add(delegate) + } else { + delegates.remove(delegate) + } } } }
diff --git a/settings.gradle b/settings.gradle index 60f1d67..58fa787 100644 --- a/settings.gradle +++ b/settings.gradle
@@ -91,6 +91,7 @@ value("androidx.compose.multiplatformEnabled", isMultiplatformEnabled().toString()) value("androidx.projects", getRequestedProjectSubsetName() ?: "Unset") value("androidx.useMaxDepVersions", providers.gradleProperty("androidx.useMaxDepVersions").isPresent().toString()) + value("androidx.disallowTaskExecution", providers.environmentVariable("DISALLOW_TASK_EXECUTION").isPresent().toString()) publishAlways() publishIfAuthenticated() @@ -189,7 +190,8 @@ WEAR, GLANCE, TOOLS, - KMP + KMP, + CAMERA } private String getRequestedProjectSubsetName() { @@ -243,7 +245,10 @@ break case "KMP": filter.add(BuildType.KMP) - break; + break + case "CAMERA": + filter.add(BuildType.CAMERA) + break case "ALL": // Return null so that no filtering is done return null @@ -253,6 +258,7 @@ "We only support the following:\n" + "ALL - all androidx projects\n" + "COMPOSE - compose projects\n" + + "CAMERA - camera projects\n" + "MAIN - androidx projects that are not compose\n" + "FLAN - fragment, lifecycle, activity, and navigation projects\n" + "MEDIA - media, media2, and mediarouter projects\n" + @@ -393,30 +399,30 @@ includeProject(":buildSrc-tests:max-dep-versions:buildSrc-tests-max-dep-versions-main", [BuildType.MAIN]) } includeProject(":buildSrc-tests:project-subsets", [BuildType.MAIN]) -includeProject(":camera:camera-camera2", [BuildType.MAIN]) -includeProject(":camera:camera-camera2-pipe", [BuildType.MAIN]) -includeProject(":camera:camera-camera2-pipe-integration", [BuildType.MAIN]) -includeProject(":camera:camera-camera2-pipe-testing", [BuildType.MAIN]) -includeProject(":camera:camera-core", [BuildType.MAIN]) -includeProject(":camera:camera-effects", [BuildType.MAIN]) -includeProject(":camera:camera-extensions", [BuildType.MAIN]) -includeProject(":camera:camera-extensions-stub", [BuildType.MAIN]) -includeProject(":camera:camera-lifecycle", [BuildType.MAIN]) -includeProject(":camera:camera-mlkit-vision", [BuildType.MAIN]) -includeProject(":camera:camera-viewfinder", [BuildType.MAIN]) -includeProject(":camera:camera-testing", [BuildType.MAIN]) -includeProject(":camera:camera-video", [BuildType.MAIN]) -includeProject(":camera:camera-view", [BuildType.MAIN]) -includeProject(":camera:integration-tests:camera-testapp-avsync", "camera/integration-tests/avsynctestapp", [BuildType.MAIN]) -includeProject(":camera:integration-tests:camera-testapp-camera2-pipe", "camera/integration-tests/camerapipetestapp", [BuildType.MAIN]) -includeProject(":camera:integration-tests:camera-testapp-core", "camera/integration-tests/coretestapp", [BuildType.MAIN]) -includeProject(":camera:integration-tests:camera-testapp-diagnose", "camera/integration-tests/diagnosetestapp", [BuildType.MAIN]) -includeProject(":camera:integration-tests:camera-testapp-extensions", "camera/integration-tests/extensionstestapp", [BuildType.MAIN]) -includeProject(":camera:integration-tests:camera-testapp-viewfinder", "camera/integration-tests/viewfindertestapp", [BuildType.MAIN]) -includeProject(":camera:integration-tests:camera-testapp-timing", "camera/integration-tests/timingtestapp", [BuildType.MAIN]) -includeProject(":camera:integration-tests:camera-testapp-uiwidgets", "camera/integration-tests/uiwidgetstestapp", [BuildType.MAIN]) -includeProject(":camera:integration-tests:camera-testapp-view", "camera/integration-tests/viewtestapp", [BuildType.MAIN]) -includeProject(":camera:camera-testlib-extensions", [BuildType.MAIN]) +includeProject(":camera:camera-camera2", [BuildType.CAMERA]) +includeProject(":camera:camera-camera2-pipe", [BuildType.CAMERA]) +includeProject(":camera:camera-camera2-pipe-integration", [BuildType.CAMERA]) +includeProject(":camera:camera-camera2-pipe-testing", [BuildType.CAMERA]) +includeProject(":camera:camera-core", [BuildType.CAMERA]) +includeProject(":camera:camera-effects", [BuildType.CAMERA]) +includeProject(":camera:camera-extensions", [BuildType.CAMERA]) +includeProject(":camera:camera-extensions-stub", [BuildType.CAMERA]) +includeProject(":camera:camera-lifecycle", [BuildType.CAMERA]) +includeProject(":camera:camera-mlkit-vision", [BuildType.CAMERA]) +includeProject(":camera:camera-viewfinder", [BuildType.CAMERA]) +includeProject(":camera:camera-testing", [BuildType.CAMERA]) +includeProject(":camera:camera-video", [BuildType.CAMERA]) +includeProject(":camera:camera-view", [BuildType.CAMERA]) +includeProject(":camera:integration-tests:camera-testapp-avsync", "camera/integration-tests/avsynctestapp", [BuildType.CAMERA]) +includeProject(":camera:integration-tests:camera-testapp-camera2-pipe", "camera/integration-tests/camerapipetestapp", [BuildType.CAMERA]) +includeProject(":camera:integration-tests:camera-testapp-core", "camera/integration-tests/coretestapp", [BuildType.CAMERA]) +includeProject(":camera:integration-tests:camera-testapp-diagnose", "camera/integration-tests/diagnosetestapp", [BuildType.CAMERA]) +includeProject(":camera:integration-tests:camera-testapp-extensions", "camera/integration-tests/extensionstestapp", [BuildType.CAMERA]) +includeProject(":camera:integration-tests:camera-testapp-viewfinder", "camera/integration-tests/viewfindertestapp", [BuildType.CAMERA]) +includeProject(":camera:integration-tests:camera-testapp-timing", "camera/integration-tests/timingtestapp", [BuildType.CAMERA]) +includeProject(":camera:integration-tests:camera-testapp-uiwidgets", "camera/integration-tests/uiwidgetstestapp", [BuildType.CAMERA]) +includeProject(":camera:integration-tests:camera-testapp-view", "camera/integration-tests/viewtestapp", [BuildType.CAMERA]) +includeProject(":camera:camera-testlib-extensions", [BuildType.CAMERA]) includeProject(":car:app:app", [BuildType.MAIN]) includeProject(":car:app:app-automotive", [BuildType.MAIN]) includeProject(":car:app:app-projected", [BuildType.MAIN]) @@ -454,9 +460,9 @@ includeProject(":compose:animation:animation-graphics:animation-graphics-samples", "compose/animation/animation-graphics/samples", [BuildType.COMPOSE]) includeProject(":compose:benchmark-utils", [BuildType.COMPOSE]) includeProject(":compose:benchmark-utils:benchmark-utils-benchmark", "compose/benchmark-utils/benchmark", [BuildType.COMPOSE]) -includeProject(":compose:compiler:compiler", [BuildType.COMPOSE, BuildType.MAIN]) +includeProject(":compose:compiler:compiler", [BuildType.COMPOSE, BuildType.CAMERA]) includeProject(":compose:compiler:compiler:integration-tests", [BuildType.COMPOSE]) -includeProject(":compose:compiler:compiler-hosted", [BuildType.COMPOSE, BuildType.MAIN]) +includeProject(":compose:compiler:compiler-hosted", [BuildType.COMPOSE, BuildType.CAMERA]) includeProject(":compose:compiler:compiler-hosted:integration-tests", [BuildType.COMPOSE]) includeProject(":compose:compiler:compiler-hosted:integration-tests:kotlin-compiler-repackaged", [BuildType.COMPOSE]) includeProject(":compose:compiler:compiler-daemon", [BuildType.COMPOSE]) @@ -484,10 +490,10 @@ includeProject(":compose:integration-tests:macrobenchmark", [BuildType.COMPOSE]) includeProject(":compose:integration-tests:macrobenchmark-target", [BuildType.COMPOSE]) includeProject(":compose:integration-tests:material-catalog", [BuildType.COMPOSE]) -includeProject(":compose:lint", [BuildType.COMPOSE]) -includeProject(":compose:lint:internal-lint-checks", [BuildType.COMPOSE, BuildType.MAIN]) -includeProject(":compose:lint:common", [BuildType.COMPOSE, BuildType.MAIN]) -includeProject(":compose:lint:common-test", [BuildType.COMPOSE, BuildType.MAIN]) +includeProject(":compose:lint", [BuildType.COMPOSE, BuildType.CAMERA]) +includeProject(":compose:lint:internal-lint-checks", [BuildType.COMPOSE, BuildType.CAMERA]) +includeProject(":compose:lint:common", [BuildType.COMPOSE, BuildType.CAMERA]) +includeProject(":compose:lint:common-test", [BuildType.COMPOSE, BuildType.CAMERA]) includeProject(":compose:material", [BuildType.COMPOSE]) includeProject(":compose:material3:material3", [BuildType.COMPOSE]) includeProject(":compose:material3:material3-lint", [BuildType.COMPOSE]) @@ -512,9 +518,9 @@ includeProject(":compose:material3:material3:integration-tests:material3-catalog", [BuildType.COMPOSE]) includeProject(":compose:material:material:material-samples", "compose/material/material/samples", [BuildType.COMPOSE]) includeProject(":compose:material3:material3:material3-samples", "compose/material3/material3/samples", [BuildType.COMPOSE]) -includeProject(":compose:runtime", [BuildType.COMPOSE, BuildType.MAIN]) -includeProject(":compose:runtime:runtime", [BuildType.COMPOSE, BuildType.MAIN]) -includeProject(":compose:runtime:runtime-lint", [BuildType.COMPOSE, BuildType.MAIN]) +includeProject(":compose:runtime", [BuildType.COMPOSE, BuildType.CAMERA]) +includeProject(":compose:runtime:runtime", [BuildType.COMPOSE, BuildType.CAMERA]) +includeProject(":compose:runtime:runtime-lint", [BuildType.COMPOSE, BuildType.CAMERA]) includeProject(":compose:runtime:runtime-livedata", [BuildType.COMPOSE]) includeProject(":compose:runtime:runtime-livedata:runtime-livedata-samples", "compose/runtime/runtime-livedata/samples", [BuildType.COMPOSE]) includeProject(":compose:runtime:runtime-tracing", [BuildType.COMPOSE]) @@ -527,7 +533,7 @@ includeProject(":compose:runtime:runtime-saveable:runtime-saveable-samples", "compose/runtime/runtime-saveable/samples", [BuildType.COMPOSE]) includeProject(":compose:runtime:runtime:benchmark", "compose/runtime/runtime/compose-runtime-benchmark", [BuildType.COMPOSE]) includeProject(":compose:runtime:runtime:integration-tests", [BuildType.COMPOSE]) -includeProject(":compose:runtime:runtime:runtime-samples", "compose/runtime/runtime/samples", [BuildType.MAIN, BuildType.COMPOSE]) +includeProject(":compose:runtime:runtime:runtime-samples", "compose/runtime/runtime/samples", [BuildType.COMPOSE, BuildType.CAMERA]) includeProject(":compose:test-utils", [BuildType.COMPOSE]) includeProject(":compose:ui", [BuildType.COMPOSE]) includeProject(":compose:ui:ui", [BuildType.COMPOSE]) @@ -561,8 +567,8 @@ includeProject(":compose:ui:ui-viewbinding:ui-viewbinding-samples", "compose/ui/ui-viewbinding/samples", [BuildType.COMPOSE]) includeProject(":compose:ui:ui:integration-tests:ui-demos", [BuildType.COMPOSE]) includeProject(":compose:ui:ui:ui-samples", "compose/ui/ui/samples", [BuildType.COMPOSE]) -includeProject(":concurrent:concurrent-futures", [BuildType.MAIN]) -includeProject(":concurrent:concurrent-futures-ktx", [BuildType.MAIN]) +includeProject(":concurrent:concurrent-futures", [BuildType.MAIN, BuildType.CAMERA]) +includeProject(":concurrent:concurrent-futures-ktx", [BuildType.MAIN, BuildType.CAMERA]) includeProject(":contentpager:contentpager", [BuildType.MAIN]) includeProject(":coordinatorlayout:coordinatorlayout", [BuildType.MAIN]) includeProject(":core:core", [BuildType.MAIN, BuildType.GLANCE, BuildType.MEDIA, BuildType.FLAN, BuildType.COMPOSE, BuildType.WEAR]) @@ -631,16 +637,16 @@ includeProject(":fragment:fragment-testing-lint", [BuildType.MAIN, BuildType.FLAN]) includeProject(":fragment:fragment-truth", [BuildType.MAIN, BuildType.FLAN]) includeProject(":fragment:integration-tests:testapp", [BuildType.MAIN, BuildType.FLAN]) -includeProject(":glance:glance", [BuildType.MAIN, BuildType.GLANCE]) -includeProject(":glance:glance-appwidget", [BuildType.MAIN, BuildType.GLANCE]) -includeProject(":glance:glance-appwidget-preview", [BuildType.MAIN, BuildType.GLANCE]) -includeProject(":glance:glance-appwidget-proto", [BuildType.MAIN, BuildType.GLANCE]) -includeProject(":glance:glance-appwidget:integration-tests:demos", [BuildType.MAIN, BuildType.GLANCE]) -includeProject(":glance:glance-appwidget:integration-tests:template-demos", [BuildType.MAIN, BuildType.GLANCE]) -includeProject(":glance:glance-appwidget:glance-layout-generator", [BuildType.MAIN, BuildType.GLANCE]) -includeProject(":glance:glance-wear-tiles:integration-tests:demos", [BuildType.MAIN, BuildType.GLANCE]) -includeProject(":glance:glance-wear-tiles:integration-tests:template-demos", [BuildType.MAIN, BuildType.GLANCE]) -includeProject(":glance:glance-wear-tiles", [BuildType.MAIN, BuildType.GLANCE]) +includeProject(":glance:glance", [BuildType.GLANCE]) +includeProject(":glance:glance-appwidget", [BuildType.GLANCE]) +includeProject(":glance:glance-appwidget-preview", [BuildType.GLANCE]) +includeProject(":glance:glance-appwidget-proto", [BuildType.GLANCE]) +includeProject(":glance:glance-appwidget:integration-tests:demos", [BuildType.GLANCE]) +includeProject(":glance:glance-appwidget:integration-tests:template-demos", [BuildType.GLANCE]) +includeProject(":glance:glance-appwidget:glance-layout-generator", [BuildType.GLANCE]) +includeProject(":glance:glance-wear-tiles:integration-tests:demos", [BuildType.GLANCE]) +includeProject(":glance:glance-wear-tiles:integration-tests:template-demos", [BuildType.GLANCE]) +includeProject(":glance:glance-wear-tiles", [BuildType.GLANCE]) includeProject(":gridlayout:gridlayout", [BuildType.MAIN]) includeProject(":health:health-connect-client", [BuildType.MAIN]) includeProject(":health:health-connect-client-proto", [BuildType.MAIN]) @@ -668,8 +674,8 @@ includeProject(":lifecycle:integration-tests:incrementality", [BuildType.MAIN, BuildType.FLAN]) includeProject(":lifecycle:integration-tests:lifecycle-testapp", "lifecycle/integration-tests/testapp", [BuildType.MAIN, BuildType.FLAN]) includeProject(":lifecycle:integration-tests:lifecycle-testapp-kotlin", "lifecycle/integration-tests/kotlintestapp", [BuildType.MAIN, BuildType.FLAN]) -includeProject(":lifecycle:lifecycle-common", [BuildType.MAIN, BuildType.FLAN, BuildType.WEAR, BuildType.COMPOSE]) -includeProject(":lifecycle:lifecycle-common-java8", [BuildType.MAIN, BuildType.FLAN, BuildType.WEAR, BuildType.COMPOSE]) +includeProject(":lifecycle:lifecycle-common", [BuildType.MAIN, BuildType.FLAN, BuildType.WEAR, BuildType.COMPOSE, BuildType.CAMERA]) +includeProject(":lifecycle:lifecycle-common-java8", [BuildType.MAIN, BuildType.FLAN, BuildType.WEAR, BuildType.COMPOSE, BuildType.CAMERA]) includeProject(":lifecycle:lifecycle-compiler", [BuildType.MAIN, BuildType.FLAN]) includeProject(":lifecycle:lifecycle-extensions", [BuildType.MAIN, BuildType.FLAN]) includeProject(":lifecycle:lifecycle-livedata", [BuildType.MAIN, BuildType.FLAN]) @@ -895,15 +901,15 @@ includeProject(":wear:watchface:watchface-style", [BuildType.MAIN, BuildType.WEAR]) includeProject(":webkit:integration-tests:testapp", [BuildType.MAIN]) includeProject(":webkit:webkit", [BuildType.MAIN]) -includeProject(":window:window", [BuildType.MAIN, BuildType.COMPOSE, BuildType.FLAN]) -includeProject(":window:extensions:extensions", [BuildType.MAIN, BuildType.COMPOSE, BuildType.FLAN]) +includeProject(":window:window", [BuildType.MAIN, BuildType.COMPOSE, BuildType.FLAN, BuildType.CAMERA]) +includeProject(":window:extensions:extensions", [BuildType.MAIN, BuildType.COMPOSE, BuildType.FLAN, BuildType.CAMERA]) includeProject(":window:integration-tests:configuration-change-tests", [BuildType.MAIN]) -includeProject(":window:sidecar:sidecar", [BuildType.MAIN, BuildType.COMPOSE, BuildType.FLAN]) -includeProject(":window:window-java", [BuildType.MAIN]) +includeProject(":window:sidecar:sidecar", [BuildType.MAIN, BuildType.COMPOSE, BuildType.FLAN, BuildType.CAMERA]) +includeProject(":window:window-java", [BuildType.MAIN, BuildType.CAMERA]) includeProject(":window:window-rxjava2", [BuildType.MAIN]) includeProject(":window:window-rxjava3", [BuildType.MAIN]) -includeProject(":window:window-samples", [BuildType.MAIN, BuildType.COMPOSE, BuildType.FLAN]) -includeProject(":window:window-testing", [BuildType.MAIN, BuildType.COMPOSE, BuildType.FLAN]) +includeProject(":window:window-samples", [BuildType.MAIN, BuildType.COMPOSE, BuildType.FLAN, BuildType.CAMERA]) +includeProject(":window:window-testing", [BuildType.MAIN, BuildType.COMPOSE, BuildType.FLAN, BuildType.CAMERA]) includeProject(":work:integration-tests:testapp", [BuildType.MAIN]) includeProject(":work:work-benchmark", [BuildType.MAIN]) includeProject(":work:work-gcm", [BuildType.MAIN]) @@ -946,11 +952,11 @@ includeProject(":internal-testutils-common", "testutils/testutils-common", [BuildType.MAIN, BuildType.COMPOSE]) includeProject(":internal-testutils-datastore", "testutils/testutils-datastore", [BuildType.MAIN]) -includeProject(":internal-testutils-runtime", "testutils/testutils-runtime", [BuildType.MAIN, BuildType.FLAN, BuildType.COMPOSE, BuildType.MEDIA, BuildType.WEAR]) +includeProject(":internal-testutils-runtime", "testutils/testutils-runtime", [BuildType.MAIN, BuildType.FLAN, BuildType.COMPOSE, BuildType.MEDIA, BuildType.WEAR, BuildType.CAMERA]) includeProject(":internal-testutils-appcompat", "testutils/testutils-appcompat", [BuildType.MAIN]) includeProject(":internal-testutils-espresso", "testutils/testutils-espresso", [BuildType.MAIN, BuildType.COMPOSE]) includeProject(":internal-testutils-fonts", "testutils/testutils-fonts", [BuildType.MAIN, BuildType.GLANCE, BuildType.MEDIA, BuildType.FLAN, BuildType.COMPOSE, BuildType.WEAR]) -includeProject(":internal-testutils-truth", "testutils/testutils-truth", [BuildType.MAIN, BuildType.GLANCE, BuildType.MEDIA, BuildType.FLAN, BuildType.COMPOSE, BuildType.WEAR, BuildType.KMP]) +includeProject(":internal-testutils-truth", "testutils/testutils-truth", [BuildType.MAIN, BuildType.GLANCE, BuildType.MEDIA, BuildType.FLAN, BuildType.COMPOSE, BuildType.WEAR, BuildType.KMP, BuildType.CAMERA]) includeProject(":internal-testutils-ktx", "testutils/testutils-ktx") includeProject(":internal-testutils-kmp", "testutils/testutils-kmp", [BuildType.MAIN, BuildType.KMP]) includeProject(":internal-testutils-macrobenchmark", "testutils/testutils-macrobenchmark", [BuildType.MAIN, BuildType.COMPOSE]) @@ -995,7 +1001,7 @@ includeProject(":icing", new File(externalRoot, "icing"), [BuildType.MAIN]) includeProject(":icing:nativeLib", new File(externalRoot, "icing/nativeLib"), [BuildType.MAIN]) -includeProject(":external:libyuv", [BuildType.MAIN]) +includeProject(":external:libyuv", [BuildType.CAMERA]) includeProject(":noto-emoji-compat-font", new File(externalRoot, "noto-fonts/emoji-compat"), [BuildType.MAIN]) includeProject(":noto-emoji-compat-flatbuffers", new File(externalRoot, "noto-fonts/emoji-compat-flatbuffers"), [BuildType.MAIN])
diff --git a/studiow b/studiow index a642819..9ff41c2 100755 --- a/studiow +++ b/studiow
@@ -25,6 +25,9 @@ echo " c, compose" echo " Open the project subset compose" echo + echo " ca, camera" + echo " Open the project subset camera" + echo echo " f, flan" echo " Open the project subset flan: Fragment, Lifecycle, Activity, and Navigation" echo @@ -86,6 +89,9 @@ if [ "$subsetArg" == "c" -o "$subsetArg" == "compose" ]; then newSubset=compose fi + if [ "$subsetArg" == "ca" -o "$subsetArg" == "camera" ]; then + newSubset=camera + fi if [ "$subsetArg" == "f" -o "$subsetArg" == "flan" ]; then newSubset=flan fi
diff --git a/test/uiautomator/uiautomator/api/current.txt b/test/uiautomator/uiautomator/api/current.txt index 6b15918..90bec63 100644 --- a/test/uiautomator/uiautomator/api/current.txt +++ b/test/uiautomator/uiautomator/api/current.txt
@@ -320,32 +320,32 @@ } public class UiScrollable extends androidx.test.uiautomator.UiCollection { - ctor public UiScrollable(androidx.test.uiautomator.UiSelector!); - method protected boolean exists(androidx.test.uiautomator.UiSelector!); + ctor public UiScrollable(androidx.test.uiautomator.UiSelector); + method protected boolean exists(androidx.test.uiautomator.UiSelector); method public boolean flingBackward() throws androidx.test.uiautomator.UiObjectNotFoundException; method public boolean flingForward() throws androidx.test.uiautomator.UiObjectNotFoundException; method public boolean flingToBeginning(int) throws androidx.test.uiautomator.UiObjectNotFoundException; method public boolean flingToEnd(int) throws androidx.test.uiautomator.UiObjectNotFoundException; - method public androidx.test.uiautomator.UiObject! getChildByDescription(androidx.test.uiautomator.UiSelector!, String!, boolean) throws androidx.test.uiautomator.UiObjectNotFoundException; - method public androidx.test.uiautomator.UiObject! getChildByText(androidx.test.uiautomator.UiSelector!, String!, boolean) throws androidx.test.uiautomator.UiObjectNotFoundException; + method public androidx.test.uiautomator.UiObject getChildByDescription(androidx.test.uiautomator.UiSelector, String, boolean) throws androidx.test.uiautomator.UiObjectNotFoundException; + method public androidx.test.uiautomator.UiObject getChildByText(androidx.test.uiautomator.UiSelector, String, boolean) throws androidx.test.uiautomator.UiObjectNotFoundException; method public int getMaxSearchSwipes(); method public double getSwipeDeadZonePercentage(); method public boolean scrollBackward() throws androidx.test.uiautomator.UiObjectNotFoundException; method public boolean scrollBackward(int) throws androidx.test.uiautomator.UiObjectNotFoundException; - method public boolean scrollDescriptionIntoView(String!) throws androidx.test.uiautomator.UiObjectNotFoundException; + method public boolean scrollDescriptionIntoView(String) throws androidx.test.uiautomator.UiObjectNotFoundException; method public boolean scrollForward() throws androidx.test.uiautomator.UiObjectNotFoundException; method public boolean scrollForward(int) throws androidx.test.uiautomator.UiObjectNotFoundException; - method public boolean scrollIntoView(androidx.test.uiautomator.UiObject!) throws androidx.test.uiautomator.UiObjectNotFoundException; - method public boolean scrollIntoView(androidx.test.uiautomator.UiSelector!) throws androidx.test.uiautomator.UiObjectNotFoundException; - method public boolean scrollTextIntoView(String!) throws androidx.test.uiautomator.UiObjectNotFoundException; + method public boolean scrollIntoView(androidx.test.uiautomator.UiObject) throws androidx.test.uiautomator.UiObjectNotFoundException; + method public boolean scrollIntoView(androidx.test.uiautomator.UiSelector) throws androidx.test.uiautomator.UiObjectNotFoundException; + method public boolean scrollTextIntoView(String) throws androidx.test.uiautomator.UiObjectNotFoundException; method public boolean scrollToBeginning(int, int) throws androidx.test.uiautomator.UiObjectNotFoundException; method public boolean scrollToBeginning(int) throws androidx.test.uiautomator.UiObjectNotFoundException; method public boolean scrollToEnd(int, int) throws androidx.test.uiautomator.UiObjectNotFoundException; method public boolean scrollToEnd(int) throws androidx.test.uiautomator.UiObjectNotFoundException; - method public androidx.test.uiautomator.UiScrollable! setAsHorizontalList(); - method public androidx.test.uiautomator.UiScrollable! setAsVerticalList(); - method public androidx.test.uiautomator.UiScrollable! setMaxSearchSwipes(int); - method public androidx.test.uiautomator.UiScrollable! setSwipeDeadZonePercentage(double); + method public androidx.test.uiautomator.UiScrollable setAsHorizontalList(); + method public androidx.test.uiautomator.UiScrollable setAsVerticalList(); + method public androidx.test.uiautomator.UiScrollable setMaxSearchSwipes(int); + method public androidx.test.uiautomator.UiScrollable setSwipeDeadZonePercentage(double); } public class UiSelector {
diff --git a/test/uiautomator/uiautomator/api/public_plus_experimental_current.txt b/test/uiautomator/uiautomator/api/public_plus_experimental_current.txt index 6b15918..90bec63 100644 --- a/test/uiautomator/uiautomator/api/public_plus_experimental_current.txt +++ b/test/uiautomator/uiautomator/api/public_plus_experimental_current.txt
@@ -320,32 +320,32 @@ } public class UiScrollable extends androidx.test.uiautomator.UiCollection { - ctor public UiScrollable(androidx.test.uiautomator.UiSelector!); - method protected boolean exists(androidx.test.uiautomator.UiSelector!); + ctor public UiScrollable(androidx.test.uiautomator.UiSelector); + method protected boolean exists(androidx.test.uiautomator.UiSelector); method public boolean flingBackward() throws androidx.test.uiautomator.UiObjectNotFoundException; method public boolean flingForward() throws androidx.test.uiautomator.UiObjectNotFoundException; method public boolean flingToBeginning(int) throws androidx.test.uiautomator.UiObjectNotFoundException; method public boolean flingToEnd(int) throws androidx.test.uiautomator.UiObjectNotFoundException; - method public androidx.test.uiautomator.UiObject! getChildByDescription(androidx.test.uiautomator.UiSelector!, String!, boolean) throws androidx.test.uiautomator.UiObjectNotFoundException; - method public androidx.test.uiautomator.UiObject! getChildByText(androidx.test.uiautomator.UiSelector!, String!, boolean) throws androidx.test.uiautomator.UiObjectNotFoundException; + method public androidx.test.uiautomator.UiObject getChildByDescription(androidx.test.uiautomator.UiSelector, String, boolean) throws androidx.test.uiautomator.UiObjectNotFoundException; + method public androidx.test.uiautomator.UiObject getChildByText(androidx.test.uiautomator.UiSelector, String, boolean) throws androidx.test.uiautomator.UiObjectNotFoundException; method public int getMaxSearchSwipes(); method public double getSwipeDeadZonePercentage(); method public boolean scrollBackward() throws androidx.test.uiautomator.UiObjectNotFoundException; method public boolean scrollBackward(int) throws androidx.test.uiautomator.UiObjectNotFoundException; - method public boolean scrollDescriptionIntoView(String!) throws androidx.test.uiautomator.UiObjectNotFoundException; + method public boolean scrollDescriptionIntoView(String) throws androidx.test.uiautomator.UiObjectNotFoundException; method public boolean scrollForward() throws androidx.test.uiautomator.UiObjectNotFoundException; method public boolean scrollForward(int) throws androidx.test.uiautomator.UiObjectNotFoundException; - method public boolean scrollIntoView(androidx.test.uiautomator.UiObject!) throws androidx.test.uiautomator.UiObjectNotFoundException; - method public boolean scrollIntoView(androidx.test.uiautomator.UiSelector!) throws androidx.test.uiautomator.UiObjectNotFoundException; - method public boolean scrollTextIntoView(String!) throws androidx.test.uiautomator.UiObjectNotFoundException; + method public boolean scrollIntoView(androidx.test.uiautomator.UiObject) throws androidx.test.uiautomator.UiObjectNotFoundException; + method public boolean scrollIntoView(androidx.test.uiautomator.UiSelector) throws androidx.test.uiautomator.UiObjectNotFoundException; + method public boolean scrollTextIntoView(String) throws androidx.test.uiautomator.UiObjectNotFoundException; method public boolean scrollToBeginning(int, int) throws androidx.test.uiautomator.UiObjectNotFoundException; method public boolean scrollToBeginning(int) throws androidx.test.uiautomator.UiObjectNotFoundException; method public boolean scrollToEnd(int, int) throws androidx.test.uiautomator.UiObjectNotFoundException; method public boolean scrollToEnd(int) throws androidx.test.uiautomator.UiObjectNotFoundException; - method public androidx.test.uiautomator.UiScrollable! setAsHorizontalList(); - method public androidx.test.uiautomator.UiScrollable! setAsVerticalList(); - method public androidx.test.uiautomator.UiScrollable! setMaxSearchSwipes(int); - method public androidx.test.uiautomator.UiScrollable! setSwipeDeadZonePercentage(double); + method public androidx.test.uiautomator.UiScrollable setAsHorizontalList(); + method public androidx.test.uiautomator.UiScrollable setAsVerticalList(); + method public androidx.test.uiautomator.UiScrollable setMaxSearchSwipes(int); + method public androidx.test.uiautomator.UiScrollable setSwipeDeadZonePercentage(double); } public class UiSelector {
diff --git a/test/uiautomator/uiautomator/api/restricted_current.txt b/test/uiautomator/uiautomator/api/restricted_current.txt index 6b15918..90bec63 100644 --- a/test/uiautomator/uiautomator/api/restricted_current.txt +++ b/test/uiautomator/uiautomator/api/restricted_current.txt
@@ -320,32 +320,32 @@ } public class UiScrollable extends androidx.test.uiautomator.UiCollection { - ctor public UiScrollable(androidx.test.uiautomator.UiSelector!); - method protected boolean exists(androidx.test.uiautomator.UiSelector!); + ctor public UiScrollable(androidx.test.uiautomator.UiSelector); + method protected boolean exists(androidx.test.uiautomator.UiSelector); method public boolean flingBackward() throws androidx.test.uiautomator.UiObjectNotFoundException; method public boolean flingForward() throws androidx.test.uiautomator.UiObjectNotFoundException; method public boolean flingToBeginning(int) throws androidx.test.uiautomator.UiObjectNotFoundException; method public boolean flingToEnd(int) throws androidx.test.uiautomator.UiObjectNotFoundException; - method public androidx.test.uiautomator.UiObject! getChildByDescription(androidx.test.uiautomator.UiSelector!, String!, boolean) throws androidx.test.uiautomator.UiObjectNotFoundException; - method public androidx.test.uiautomator.UiObject! getChildByText(androidx.test.uiautomator.UiSelector!, String!, boolean) throws androidx.test.uiautomator.UiObjectNotFoundException; + method public androidx.test.uiautomator.UiObject getChildByDescription(androidx.test.uiautomator.UiSelector, String, boolean) throws androidx.test.uiautomator.UiObjectNotFoundException; + method public androidx.test.uiautomator.UiObject getChildByText(androidx.test.uiautomator.UiSelector, String, boolean) throws androidx.test.uiautomator.UiObjectNotFoundException; method public int getMaxSearchSwipes(); method public double getSwipeDeadZonePercentage(); method public boolean scrollBackward() throws androidx.test.uiautomator.UiObjectNotFoundException; method public boolean scrollBackward(int) throws androidx.test.uiautomator.UiObjectNotFoundException; - method public boolean scrollDescriptionIntoView(String!) throws androidx.test.uiautomator.UiObjectNotFoundException; + method public boolean scrollDescriptionIntoView(String) throws androidx.test.uiautomator.UiObjectNotFoundException; method public boolean scrollForward() throws androidx.test.uiautomator.UiObjectNotFoundException; method public boolean scrollForward(int) throws androidx.test.uiautomator.UiObjectNotFoundException; - method public boolean scrollIntoView(androidx.test.uiautomator.UiObject!) throws androidx.test.uiautomator.UiObjectNotFoundException; - method public boolean scrollIntoView(androidx.test.uiautomator.UiSelector!) throws androidx.test.uiautomator.UiObjectNotFoundException; - method public boolean scrollTextIntoView(String!) throws androidx.test.uiautomator.UiObjectNotFoundException; + method public boolean scrollIntoView(androidx.test.uiautomator.UiObject) throws androidx.test.uiautomator.UiObjectNotFoundException; + method public boolean scrollIntoView(androidx.test.uiautomator.UiSelector) throws androidx.test.uiautomator.UiObjectNotFoundException; + method public boolean scrollTextIntoView(String) throws androidx.test.uiautomator.UiObjectNotFoundException; method public boolean scrollToBeginning(int, int) throws androidx.test.uiautomator.UiObjectNotFoundException; method public boolean scrollToBeginning(int) throws androidx.test.uiautomator.UiObjectNotFoundException; method public boolean scrollToEnd(int, int) throws androidx.test.uiautomator.UiObjectNotFoundException; method public boolean scrollToEnd(int) throws androidx.test.uiautomator.UiObjectNotFoundException; - method public androidx.test.uiautomator.UiScrollable! setAsHorizontalList(); - method public androidx.test.uiautomator.UiScrollable! setAsVerticalList(); - method public androidx.test.uiautomator.UiScrollable! setMaxSearchSwipes(int); - method public androidx.test.uiautomator.UiScrollable! setSwipeDeadZonePercentage(double); + method public androidx.test.uiautomator.UiScrollable setAsHorizontalList(); + method public androidx.test.uiautomator.UiScrollable setAsVerticalList(); + method public androidx.test.uiautomator.UiScrollable setMaxSearchSwipes(int); + method public androidx.test.uiautomator.UiScrollable setSwipeDeadZonePercentage(double); } public class UiSelector {
diff --git a/test/uiautomator/uiautomator/src/main/java/androidx/test/uiautomator/UiScrollable.java b/test/uiautomator/uiautomator/src/main/java/androidx/test/uiautomator/UiScrollable.java index 72381f1..8983175 100644 --- a/test/uiautomator/uiautomator/src/main/java/androidx/test/uiautomator/UiScrollable.java +++ b/test/uiautomator/uiautomator/src/main/java/androidx/test/uiautomator/UiScrollable.java
@@ -16,11 +16,14 @@ package androidx.test.uiautomator; +import static androidx.annotation.RestrictTo.Scope.LIBRARY; + import android.graphics.Rect; import android.util.Log; import android.view.accessibility.AccessibilityNodeInfo; import androidx.annotation.NonNull; +import androidx.annotation.RestrictTo; /** * UiScrollable is a {@link UiCollection} and provides support for searching @@ -54,7 +57,7 @@ * layout element. * @since API Level 16 */ - public UiScrollable(UiSelector container) { + public UiScrollable(@NonNull UiSelector container) { // wrap the container selector with container so that QueryController can handle // this type of enumeration search accordingly super(container); @@ -65,6 +68,7 @@ * @return reference to itself * @since API Level 16 */ + @NonNull public UiScrollable setAsVerticalList() { Tracer.trace(); mIsVerticalList = true; @@ -76,6 +80,7 @@ * @return reference to itself * @since API Level 16 */ + @NonNull public UiScrollable setAsHorizontalList() { Tracer.trace(); mIsVerticalList = false; @@ -90,7 +95,7 @@ * @return true if found else false * @since API Level 16 */ - protected boolean exists(UiSelector selector) { + protected boolean exists(@NonNull UiSelector selector) { if(getQueryController().findAccessibilityNodeInfo(selector) != null) { return true; } @@ -139,7 +144,8 @@ * @throws UiObjectNotFoundException * @since API Level 16 */ - public UiObject getChildByDescription(UiSelector childPattern, String text, + @NonNull + public UiObject getChildByDescription(@NonNull UiSelector childPattern, @NonNull String text, boolean allowScrollSearch) throws UiObjectNotFoundException { Tracer.trace(childPattern, text, allowScrollSearch); if (text != null) { @@ -164,7 +170,7 @@ */ @NonNull @Override - public UiObject getChildByInstance(UiSelector childPattern, int instance) + public UiObject getChildByInstance(@NonNull UiSelector childPattern, int instance) throws UiObjectNotFoundException { Tracer.trace(childPattern, instance); UiSelector patternSelector = UiSelector.patternBuilder(getSelector(), @@ -211,8 +217,10 @@ * @throws UiObjectNotFoundException * @since API Level 16 */ - public UiObject getChildByText(UiSelector childPattern, String text, boolean allowScrollSearch) - throws UiObjectNotFoundException { + @NonNull + public UiObject getChildByText(@NonNull UiSelector childPattern, + @NonNull String text, + boolean allowScrollSearch) throws UiObjectNotFoundException { Tracer.trace(childPattern, text, allowScrollSearch); if (text != null) { if (allowScrollSearch) { @@ -228,11 +236,13 @@ * the content-description is found, or until swipe attempts have been exhausted. * See {@link #setMaxSearchSwipes(int)} * - * @param text content-description to find within the contents of this scrollable layout element. + * @param text content-description to find within the contents of this scrollable layout + * element. * @return true if item is found; else, false * @since API Level 16 */ - public boolean scrollDescriptionIntoView(String text) throws UiObjectNotFoundException { + public boolean scrollDescriptionIntoView(@NonNull String text) + throws UiObjectNotFoundException { Tracer.trace(text); return scrollIntoView(new UiSelector().description(text)); } @@ -245,7 +255,7 @@ * @return true if the item was found and now is in view else false * @since API Level 16 */ - public boolean scrollIntoView(UiObject obj) throws UiObjectNotFoundException { + public boolean scrollIntoView(@NonNull UiObject obj) throws UiObjectNotFoundException { Tracer.trace(obj.getSelector()); return scrollIntoView(obj.getSelector()); } @@ -260,7 +270,7 @@ * @return true if the item was found and now is in view; else, false * @since API Level 16 */ - public boolean scrollIntoView(UiSelector selector) throws UiObjectNotFoundException { + public boolean scrollIntoView(@NonNull UiSelector selector) throws UiObjectNotFoundException { Tracer.trace(selector); // if we happen to be on top of the text we want then return here UiSelector childSelector = getSelector().childSelector(selector); @@ -296,7 +306,9 @@ * @throws UiObjectNotFoundException * @hide */ - public boolean ensureFullyVisible(UiObject childObject) throws UiObjectNotFoundException { + @RestrictTo(LIBRARY) + public boolean ensureFullyVisible(@NonNull UiObject childObject) + throws UiObjectNotFoundException { Rect actual = childObject.getBounds(); Rect visible = childObject.getVisibleBounds(); if (visible.width() * visible.height() == actual.width() * actual.height()) { @@ -337,7 +349,7 @@ * @return true if item is found; else, false * @since API Level 16 */ - public boolean scrollTextIntoView(String text) throws UiObjectNotFoundException { + public boolean scrollTextIntoView(@NonNull String text) throws UiObjectNotFoundException { Tracer.trace(text); return scrollIntoView(new UiSelector().text(text)); } @@ -352,6 +364,7 @@ * @return reference to itself * @since API Level 16 */ + @NonNull public UiScrollable setMaxSearchSwipes(int swipes) { Tracer.trace(swipes); mMaxSearchSwipes = swipes; @@ -513,7 +526,7 @@ // set otherwise by setAsHorizontalContainer() if(mIsVerticalList) { int swipeAreaAdjust = (int)(rect.height() * getSwipeDeadZonePercentage()); - Log.d(LOG_TAG, "scrollToBegining() using vertical scroll"); + Log.d(LOG_TAG, "scrollToBeginning() using vertical scroll"); // scroll vertically: swipe up -> down downX = rect.centerX(); downY = rect.top + swipeAreaAdjust; @@ -521,7 +534,7 @@ upY = rect.bottom - swipeAreaAdjust; } else { int swipeAreaAdjust = (int)(rect.width() * getSwipeDeadZonePercentage()); - Log.d(LOG_TAG, "scrollToBegining() using hotizontal scroll"); + Log.d(LOG_TAG, "scrollToBeginning() using hotizontal scroll"); // scroll horizontally: swipe left -> right // TODO: Assuming device is not in right to left language downX = rect.left + swipeAreaAdjust; @@ -664,6 +677,7 @@ * @return reference to itself * @since API Level 16 */ + @NonNull public UiScrollable setSwipeDeadZonePercentage(double swipeDeadZonePercentage) { Tracer.trace(swipeDeadZonePercentage); mSwipeDeadZonePercentage = swipeDeadZonePercentage;
diff --git a/work/work-runtime/src/androidTest/java/androidx/work/WorkUpdateTest.kt b/work/work-runtime/src/androidTest/java/androidx/work/WorkUpdateTest.kt index a1ff449..c90bcc8 100644 --- a/work/work-runtime/src/androidTest/java/androidx/work/WorkUpdateTest.kt +++ b/work/work-runtime/src/androidTest/java/androidx/work/WorkUpdateTest.kt
@@ -424,6 +424,28 @@ assertThat(worker2.runAttemptCount).isEqualTo(1) } + @MediumTest + @Test + fun updateCorrectNextRunTime() { + val request = OneTimeWorkRequest.Builder(TestWorker::class.java) + .setInitialDelay(10, TimeUnit.MINUTES).build() + val enqueueTime = System.currentTimeMillis() + workManager.enqueue(request).result.get() + workManager.workDatabase.workSpecDao().setLastEnqueuedTime(request.stringId, + enqueueTime - TimeUnit.MINUTES.toMillis(5)) + val updated = OneTimeWorkRequest.Builder(TestWorker::class.java) + .setInitialDelay(20, TimeUnit.MINUTES) + .setId(request.id) + .build() + workManager.updateWork(updated).get() + val workSpec = workManager.workDatabase.workSpecDao().getWorkSpec(request.stringId)!! + val delta = workSpec.calculateNextRunTime() - enqueueTime + // enqueue time isn't very accurate but delta should be about 15 minutes, because + // enqueueTime was rewound 5 minutes back. + assertThat(delta).isGreaterThan(TimeUnit.MINUTES.toMillis(14)) + assertThat(delta).isLessThan(TimeUnit.MINUTES.toMillis(16)) + } + @After fun tearDown() { workManager.cancelAllWork()
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/WorkerUpdater.kt b/work/work-runtime/src/main/java/androidx/work/impl/WorkerUpdater.kt index ea6012d..236e52c 100644 --- a/work/work-runtime/src/main/java/androidx/work/impl/WorkerUpdater.kt +++ b/work/work-runtime/src/main/java/androidx/work/impl/WorkerUpdater.kt
@@ -54,7 +54,8 @@ // preserving run attempt count, to calculate back off correctly val updatedSpec = newWorkSpec.copy( state = oldWorkSpec.state, - runAttemptCount = oldWorkSpec.runAttemptCount + runAttemptCount = oldWorkSpec.runAttemptCount, + lastEnqueueTime = oldWorkSpec.lastEnqueueTime, ) workSpecDao.updateWorkSpec(updatedSpec) workTagDao.deleteByWorkSpecId(workSpecId)