commit | f74a39b612a63b117deaf8ad10e8e166af70eae1 | [log] [tgz] |
---|---|---|
author | Levi Albuquerque | Sat Jun 14 05:59:13 2025 -0700 |
committer | Gerrit Code Review | Sat Jun 14 05:59:13 2025 -0700 |
tree | 9aa210e6df4551a949975607d2100cc421ab5e8c | |
parent | 89e1f3dfb3382bffa1867d87d21cc9d81889cccd [diff] | |
parent | ad2383fb3151b0792b906c75154d8d2811e95e05 [diff] |
Merge "Implement IndirectTouchScrollable in glimmer" into androidx-main
diff --git a/activity/activity-compose-lint/build.gradle b/activity/activity-compose-lint/build.gradle index 92d6783..bde0e9d 100644 --- a/activity/activity-compose-lint/build.gradle +++ b/activity/activity-compose-lint/build.gradle
@@ -43,7 +43,7 @@ testImplementation(project(":compose:lint:common-test")) testImplementation(libs.kotlinStdlib) testRuntimeOnly(libs.kotlinReflect) - testImplementation(libs.kotlinStdlibJdk8) + testImplementation(libs.kotlinStdlib) testImplementation(libs.androidLint) testImplementation(libs.androidLintTests) testImplementation(libs.junit)
diff --git a/activity/activity-lint/build.gradle b/activity/activity-lint/build.gradle index 6e6d4dc..a6bfccf 100644 --- a/activity/activity-lint/build.gradle +++ b/activity/activity-lint/build.gradle
@@ -37,7 +37,6 @@ testImplementation(libs.kotlinStdlib) testRuntimeOnly(libs.kotlinReflect) - testImplementation(libs.kotlinStdlibJdk8) testImplementation(libs.androidLint) testImplementation(libs.androidLintTests) testImplementation(libs.junit)
diff --git a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/AppSearchImplTest.java b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/AppSearchImplTest.java index 531aa22..9958dee 100644 --- a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/AppSearchImplTest.java +++ b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/AppSearchImplTest.java
@@ -890,6 +890,193 @@ } @Test + @RequiresFlagsEnabled(Flags.FLAG_ENABLE_BLOB_STORE) + public void testReset_withBlob() throws Exception { + mAppSearchImpl = AppSearchImpl.create( + mAppSearchDir, + new AppSearchConfigImpl(new UnlimitedLimitConfig(), + new LocalStorageIcingOptionsConfig()), + /*initStatsBuilder=*/ null, + /*visibilityChecker=*/ null, + new JetpackRevocableFileDescriptorStore(mUnlimitedConfig), + /*icingSearchEngine=*/ null, + ALWAYS_OPTIMIZE); + File blobFilesDir = new File(mAppSearchDir, "blob_dir/blob_files"); + + // Insert schema + Listschemas = ImmutableList.of( + new AppSearchSchema.Builder("Type1").build(), + new AppSearchSchema.Builder("Type2").build()); + InternalSetSchemaResponse internalSetSchemaResponse = mAppSearchImpl.setSchema( + mContext.getPackageName(), + "database1", + schemas, + /*visibilityConfigs=*/ Collections.emptyList(), + /*forceOverride=*/ false, + /*version=*/ 0, + /* setSchemaStatsBuilder= */ null); + assertThat(internalSetSchemaResponse.isSuccess()).isTrue(); + + // Insert a valid doc + GenericDocument validDoc = + new GenericDocument.Builder<>("namespace1", "id1", "Type1").build(); + mAppSearchImpl.putDocument( + mContext.getPackageName(), + "database1", + validDoc, + /*sendChangeNotifications=*/ false, + /*logger=*/null); + // Query it via global query. We use the same code again later so this is to make sure we + // have our global query configured right. + SearchResultPage results = mAppSearchImpl.globalQuery( + /*queryExpression=*/ "", + new SearchSpec.Builder().addFilterSchemas("Type1").build(), + mSelfCallerAccess, + /*logger=*/ null); + assertThat(results.getResults()).hasSize(1); + assertThat(results.getResults().get(0).getGenericDocument()).isEqualTo(validDoc); + + // Put a blob + byte[] blobData = generateRandomBytes(20 * 1024); // 20 KiB + byte[] blobDigest = calculateDigest(blobData); + AppSearchBlobHandle blobHandle = AppSearchBlobHandle.createWithSha256( + blobDigest, mContext.getPackageName(), "database1", "namespace1"); + try (ParcelFileDescriptor writePfd = mAppSearchImpl.openWriteBlob( + mContext.getPackageName(), "database1", blobHandle); + OutputStream outputStream = new ParcelFileDescriptor + .AutoCloseOutputStream(writePfd)) { + outputStream.write(blobData); + outputStream.flush(); + } + // Commit and read the blob. + mAppSearchImpl.commitBlob(mContext.getPackageName(), "database1", blobHandle); + byte[] readBytes = new byte[20 * 1024]; + try (ParcelFileDescriptor readPfd = mAppSearchImpl.openReadBlob( + mContext.getPackageName(), "database1", blobHandle); + InputStream inputStream = new ParcelFileDescriptor.AutoCloseInputStream(readPfd)) { + inputStream.read(readBytes); + } + assertThat(readBytes).isEqualTo(blobData); + // Check that the blob file is created by AppSearch. + if (Flags.enableAppSearchManageBlobFiles()) { + assertThat(blobFilesDir.list()).asList().hasSize(1); + } + + // Create a doc with a malformed namespace + DocumentProto invalidDoc = DocumentProto.newBuilder() + .setNamespace("invalidNamespace") + .setUri("id2") + .setSchema(mContext.getPackageName() + "$database1/Type1") + .build(); + AppSearchException e = assertThrows( + AppSearchException.class, + () -> PrefixUtil.getPrefix(invalidDoc.getNamespace())); + assertThat(e).hasMessageThat().isEqualTo( + "The prefixed value \"invalidNamespace\" doesn't contain a valid database name"); + + // Insert the invalid doc with an invalid namespace right into icing + PutResultProto putResultProto = mAppSearchImpl.mIcingSearchEngineLocked.put(invalidDoc); + assertThat(putResultProto.getStatus().getCode()).isEqualTo(StatusProto.Code.OK); + + // Initialize AppSearchImpl. This should cause a reset. + InitializeStats.Builder initStatsBuilder = new InitializeStats.Builder(); + mAppSearchImpl.close(); + mAppSearchImpl = AppSearchImpl.create( + mAppSearchDir, + new AppSearchConfigImpl(new UnlimitedLimitConfig(), + new LocalStorageIcingOptionsConfig()), + initStatsBuilder, + /*visibilityChecker=*/ null, + new JetpackRevocableFileDescriptorStore(mUnlimitedConfig), + /*icingSearchEngine=*/ null, + ALWAYS_OPTIMIZE); + + // Check recovery state + InitializeStats initStats = initStatsBuilder.build(); + assertThat(initStats).isNotNull(); + assertThat(initStats.getStatusCode()).isEqualTo(AppSearchResult.RESULT_INTERNAL_ERROR); + assertThat(initStats.hasDeSync()).isFalse(); + assertThat(initStats.getNativeDocumentStoreRecoveryCause()) + .isEqualTo(InitializeStats.RECOVERY_CAUSE_NONE); + assertThat(initStats.getNativeIndexRestorationCause()) + .isEqualTo(InitializeStats.RECOVERY_CAUSE_NONE); + assertThat(initStats.getNativeSchemaStoreRecoveryCause()) + .isEqualTo(InitializeStats.RECOVERY_CAUSE_NONE); + assertThat(initStats.getNativeDocumentStoreDataStatus()) + .isEqualTo(InitializeStats.DOCUMENT_STORE_DATA_STATUS_NO_DATA_LOSS); + assertThat(initStats.hasReset()).isTrue(); + assertThat(initStats.getResetStatusCode()).isEqualTo(AppSearchResult.RESULT_OK); + + // Make sure all our data is gone + assertThat(mAppSearchImpl.getSchema( + /*packageName=*/mContext.getPackageName(), + /*databaseName=*/"database1", + /*callerAccess=*/mSelfCallerAccess) + .getSchemas()) + .isEmpty(); + results = mAppSearchImpl.globalQuery( + /*queryExpression=*/ "", + new SearchSpec.Builder().addFilterSchemas("Type1").build(), + mSelfCallerAccess, + /*logger=*/ null); + assertThat(results.getResults()).isEmpty(); + + // Make sure blob files are deleted. + if (Flags.enableAppSearchManageBlobFiles()) { + assertThat(blobFilesDir.list()).isEmpty(); + } + + // Make sure the index can now be used successfully + internalSetSchemaResponse = mAppSearchImpl.setSchema( + mContext.getPackageName(), + "database1", + Collections.singletonList(new AppSearchSchema.Builder("Type1").build()), + /*visibilityConfigs=*/ Collections.emptyList(), + /*forceOverride=*/ false, + /*version=*/ 0, + /* setSchemaStatsBuilder= */ null); + assertThat(internalSetSchemaResponse.isSuccess()).isTrue(); + + // Insert a valid doc + mAppSearchImpl.putDocument( + mContext.getPackageName(), + "database1", + validDoc, + /*sendChangeNotifications=*/ false, + /*logger=*/null); + // Query it via global query. + results = mAppSearchImpl.globalQuery( + /*queryExpression=*/ "", + new SearchSpec.Builder().addFilterSchemas("Type1").build(), + mSelfCallerAccess, + /*logger=*/ null); + assertThat(results.getResults()).hasSize(1); + assertThat(results.getResults().get(0).getGenericDocument()).isEqualTo(validDoc); + + // Put a blob + try (ParcelFileDescriptor writePfd = mAppSearchImpl.openWriteBlob( + mContext.getPackageName(), "database1", blobHandle); + OutputStream outputStream = new ParcelFileDescriptor + .AutoCloseOutputStream(writePfd)) { + outputStream.write(blobData); + outputStream.flush(); + } + // Commit and read the blob. + mAppSearchImpl.commitBlob(mContext.getPackageName(), "database1", blobHandle); + readBytes = new byte[20 * 1024]; + try (ParcelFileDescriptor readPfd = mAppSearchImpl.openReadBlob( + mContext.getPackageName(), "database1", blobHandle); + InputStream inputStream = new ParcelFileDescriptor.AutoCloseInputStream(readPfd)) { + inputStream.read(readBytes); + } + assertThat(readBytes).isEqualTo(blobData); + // Check that the blob file is created by AppSearch. + if (Flags.enableAppSearchManageBlobFiles()) { + assertThat(blobFilesDir.list()).asList().hasSize(1); + } + } + + @Test public void testResetWithSchemaDatabaseMigration() throws Exception { IcingSearchEngineOptions.Builder optionsBuilder = IcingSearchEngineOptions.newBuilder(mUnlimitedConfig.toIcingSearchEngineOptions(
diff --git a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/AppSearchImpl.java b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/AppSearchImpl.java index 2c12d01..d28c5ce 100644 --- a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/AppSearchImpl.java +++ b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/AppSearchImpl.java
@@ -3833,6 +3833,37 @@ } /** + * Deletes all blob files managed by AppSearch. + * + * @throws AppSearchException if an I/O error occurs. + */ + @GuardedBy("mReadWriteLock") + private void deleteBlobFilesLocked() throws AppSearchException { + if (!Flags.enableAppSearchManageBlobFiles()) { + return; + } + if (mBlobFilesDir.isFile() && !mBlobFilesDir.delete()) { + throw new AppSearchException(AppSearchResult.RESULT_IO_ERROR, + "The blob file directory is a file and cannot delete it."); + } + if (!mBlobFilesDir.exists() && !mBlobFilesDir.mkdirs()) { + throw new AppSearchException(AppSearchResult.RESULT_IO_ERROR, + "The blob file directory does not exist and cannot create a new one."); + } + File[] blobFiles = mBlobFilesDir.listFiles(); + if (blobFiles == null) { + throw new AppSearchException(AppSearchResult.RESULT_IO_ERROR, + "Cannot list the blob files."); + } + for (int i = 0; i < blobFiles.length; i++) { + File blobFile = blobFiles[i]; + if (!blobFile.delete()) { + Log.e(TAG, "Cannot delete the blob file: " + blobFile.getName()); + } + } + } + + /** * Clears documents and schema across all packages and databaseNames. * *This method belongs to mutate group.
@@ -3872,6 +3903,9 @@ } checkSuccess(resetResultProto.getStatus()); + + // Delete all blob files if AppSearch manages them. + deleteBlobFilesLocked(); } /** Wrapper around schema changes */
diff --git a/camera/camera-core/api/1.5.0-beta02.txt b/camera/camera-core/api/1.5.0-beta02.txt index be3133e..76271a5 100644 --- a/camera/camera-core/api/1.5.0-beta02.txt +++ b/camera/camera-core/api/1.5.0-beta02.txt
@@ -59,6 +59,7 @@ method public default androidx.lifecycle.LiveDatagetTorchStrengthLevel(); method public androidx.lifecycle.LiveDatagetZoomState(); method public boolean hasFlashUnit(); + method @SuppressCompatibility @androidx.camera.core.ExperimentalSessionConfig public default boolean isFeatureGroupSupported(androidx.camera.core.SessionConfig); method public default boolean isFocusMeteringSupported(androidx.camera.core.FocusMeteringAction); method public default boolean isLogicalMultiCameraSupported(); method public default boolean isLowLightBoostSupported(); @@ -638,12 +639,24 @@ ctor public SessionConfig(java.util.List extends androidx.camera.core.UseCase> useCases, optional androidx.camera.core.ViewPort? viewPort); ctor public SessionConfig(java.util.List extends androidx.camera.core.UseCase> useCases, optional androidx.camera.core.ViewPort? viewPort, optional java.util.List extends androidx.camera.core.CameraEffect> effects); ctor public SessionConfig(java.util.List extends androidx.camera.core.UseCase> useCases, optional androidx.camera.core.ViewPort? viewPort, optional java.util.List extends androidx.camera.core.CameraEffect> effects, optional android.util.RangeframeRateRange); + ctor public SessionConfig(java.util.List extends androidx.camera.core.UseCase> useCases, optional androidx.camera.core.ViewPort? viewPort, optional java.util.List extends androidx.camera.core.CameraEffect> effects, optional android.util.RangeframeRateRange, optional java.util.Set extends androidx.camera.core.featuregroup.GroupableFeature> requiredFeatureGroup); + ctor public SessionConfig(java.util.List extends androidx.camera.core.UseCase> useCases, optional androidx.camera.core.ViewPort? viewPort, optional java.util.List extends androidx.camera.core.CameraEffect> effects, optional android.util.RangeframeRateRange, optional java.util.Set extends androidx.camera.core.featuregroup.GroupableFeature> requiredFeatureGroup, optional java.util.List extends androidx.camera.core.featuregroup.GroupableFeature> preferredFeatureGroup); method public final java.util.ListgetEffects(); + method public final androidx.core.util.Consumer> getFeatureSelectionListener(); + method public final java.util.concurrent.Executor getFeatureSelectionListenerExecutor(); method public final android.util.RangegetFrameRateRange(); + method public final java.util.ListgetPreferredFeatureGroup(); + method public final java.util.SetgetRequiredFeatureGroup(); method public final java.util.ListgetUseCases(); method public final androidx.camera.core.ViewPort? getViewPort(); + method public final void setFeatureSelectionListener(androidx.core.util.Consumer> listener); + method public final void setFeatureSelectionListener(optional java.util.concurrent.Executor executor, androidx.core.util.Consumer> listener); property public final java.util.Listeffects; + property public final androidx.core.util.Consumer> featureSelectionListener; + property public final java.util.concurrent.Executor featureSelectionListenerExecutor; property public final android.util.RangeframeRateRange; + property public final java.util.ListpreferredFeatureGroup; + property public final java.util.SetrequiredFeatureGroup; property public final java.util.ListuseCases; property public final androidx.camera.core.ViewPort? viewPort; } @@ -654,6 +667,8 @@ method public androidx.camera.core.SessionConfig.Builder addEffect(androidx.camera.core.CameraEffect effect); method public androidx.camera.core.SessionConfig build(); method public androidx.camera.core.SessionConfig.Builder setFrameRateRange(android.util.RangeframeRateRange); + method public androidx.camera.core.SessionConfig.Builder setPreferredFeatureGroup(androidx.camera.core.featuregroup.GroupableFeature... features); + method public androidx.camera.core.SessionConfig.Builder setRequiredFeatureGroup(androidx.camera.core.featuregroup.GroupableFeature... features); method public androidx.camera.core.SessionConfig.Builder setViewPort(androidx.camera.core.ViewPort viewPort); } @@ -765,6 +780,34 @@ } +package @androidx.camera.core.ExperimentalSessionConfig androidx.camera.core.featuregroup { + + @SuppressCompatibility @androidx.camera.core.ExperimentalSessionConfig public abstract class GroupableFeature { + method public final int getFeatureType(); + field public static final androidx.camera.core.featuregroup.GroupableFeature.Companion Companion; + field public static final int FEATURE_TYPE_DYNAMIC_RANGE = 0; // 0x0 + field public static final int FEATURE_TYPE_FPS_RANGE = 1; // 0x1 + field public static final int FEATURE_TYPE_IMAGE_FORMAT = 3; // 0x3 + field public static final int FEATURE_TYPE_VIDEO_STABILIZATION = 2; // 0x2 + field public static final androidx.camera.core.featuregroup.GroupableFeature FPS_60; + field public static final androidx.camera.core.featuregroup.GroupableFeature HDR_HLG10; + field public static final androidx.camera.core.featuregroup.GroupableFeature IMAGE_ULTRA_HDR; + field public static final androidx.camera.core.featuregroup.GroupableFeature PREVIEW_STABILIZATION; + } + + @SuppressCompatibility @androidx.camera.core.ExperimentalSessionConfig public static final class GroupableFeature.Companion { + property public static int FEATURE_TYPE_DYNAMIC_RANGE; + property public static int FEATURE_TYPE_FPS_RANGE; + property public static int FEATURE_TYPE_IMAGE_FORMAT; + property public static int FEATURE_TYPE_VIDEO_STABILIZATION; + property public androidx.camera.core.featuregroup.GroupableFeature FPS_60; + property public androidx.camera.core.featuregroup.GroupableFeature HDR_HLG10; + property public androidx.camera.core.featuregroup.GroupableFeature IMAGE_ULTRA_HDR; + property public androidx.camera.core.featuregroup.GroupableFeature PREVIEW_STABILIZATION; + } + +} + package androidx.camera.core.resolutionselector { public final class AspectRatioStrategy {
diff --git a/camera/camera-core/api/current.txt b/camera/camera-core/api/current.txt index be3133e..76271a5 100644 --- a/camera/camera-core/api/current.txt +++ b/camera/camera-core/api/current.txt
@@ -59,6 +59,7 @@ method public default androidx.lifecycle.LiveDatagetTorchStrengthLevel(); method public androidx.lifecycle.LiveDatagetZoomState(); method public boolean hasFlashUnit(); + method @SuppressCompatibility @androidx.camera.core.ExperimentalSessionConfig public default boolean isFeatureGroupSupported(androidx.camera.core.SessionConfig); method public default boolean isFocusMeteringSupported(androidx.camera.core.FocusMeteringAction); method public default boolean isLogicalMultiCameraSupported(); method public default boolean isLowLightBoostSupported(); @@ -638,12 +639,24 @@ ctor public SessionConfig(java.util.List extends androidx.camera.core.UseCase> useCases, optional androidx.camera.core.ViewPort? viewPort); ctor public SessionConfig(java.util.List extends androidx.camera.core.UseCase> useCases, optional androidx.camera.core.ViewPort? viewPort, optional java.util.List extends androidx.camera.core.CameraEffect> effects); ctor public SessionConfig(java.util.List extends androidx.camera.core.UseCase> useCases, optional androidx.camera.core.ViewPort? viewPort, optional java.util.List extends androidx.camera.core.CameraEffect> effects, optional android.util.RangeframeRateRange); + ctor public SessionConfig(java.util.List extends androidx.camera.core.UseCase> useCases, optional androidx.camera.core.ViewPort? viewPort, optional java.util.List extends androidx.camera.core.CameraEffect> effects, optional android.util.RangeframeRateRange, optional java.util.Set extends androidx.camera.core.featuregroup.GroupableFeature> requiredFeatureGroup); + ctor public SessionConfig(java.util.List extends androidx.camera.core.UseCase> useCases, optional androidx.camera.core.ViewPort? viewPort, optional java.util.List extends androidx.camera.core.CameraEffect> effects, optional android.util.RangeframeRateRange, optional java.util.Set extends androidx.camera.core.featuregroup.GroupableFeature> requiredFeatureGroup, optional java.util.List extends androidx.camera.core.featuregroup.GroupableFeature> preferredFeatureGroup); method public final java.util.ListgetEffects(); + method public final androidx.core.util.Consumer> getFeatureSelectionListener(); + method public final java.util.concurrent.Executor getFeatureSelectionListenerExecutor(); method public final android.util.RangegetFrameRateRange(); + method public final java.util.ListgetPreferredFeatureGroup(); + method public final java.util.SetgetRequiredFeatureGroup(); method public final java.util.ListgetUseCases(); method public final androidx.camera.core.ViewPort? getViewPort(); + method public final void setFeatureSelectionListener(androidx.core.util.Consumer> listener); + method public final void setFeatureSelectionListener(optional java.util.concurrent.Executor executor, androidx.core.util.Consumer> listener); property public final java.util.Listeffects; + property public final androidx.core.util.Consumer> featureSelectionListener; + property public final java.util.concurrent.Executor featureSelectionListenerExecutor; property public final android.util.RangeframeRateRange; + property public final java.util.ListpreferredFeatureGroup; + property public final java.util.SetrequiredFeatureGroup; property public final java.util.ListuseCases; property public final androidx.camera.core.ViewPort? viewPort; } @@ -654,6 +667,8 @@ method public androidx.camera.core.SessionConfig.Builder addEffect(androidx.camera.core.CameraEffect effect); method public androidx.camera.core.SessionConfig build(); method public androidx.camera.core.SessionConfig.Builder setFrameRateRange(android.util.RangeframeRateRange); + method public androidx.camera.core.SessionConfig.Builder setPreferredFeatureGroup(androidx.camera.core.featuregroup.GroupableFeature... features); + method public androidx.camera.core.SessionConfig.Builder setRequiredFeatureGroup(androidx.camera.core.featuregroup.GroupableFeature... features); method public androidx.camera.core.SessionConfig.Builder setViewPort(androidx.camera.core.ViewPort viewPort); } @@ -765,6 +780,34 @@ } +package @androidx.camera.core.ExperimentalSessionConfig androidx.camera.core.featuregroup { + + @SuppressCompatibility @androidx.camera.core.ExperimentalSessionConfig public abstract class GroupableFeature { + method public final int getFeatureType(); + field public static final androidx.camera.core.featuregroup.GroupableFeature.Companion Companion; + field public static final int FEATURE_TYPE_DYNAMIC_RANGE = 0; // 0x0 + field public static final int FEATURE_TYPE_FPS_RANGE = 1; // 0x1 + field public static final int FEATURE_TYPE_IMAGE_FORMAT = 3; // 0x3 + field public static final int FEATURE_TYPE_VIDEO_STABILIZATION = 2; // 0x2 + field public static final androidx.camera.core.featuregroup.GroupableFeature FPS_60; + field public static final androidx.camera.core.featuregroup.GroupableFeature HDR_HLG10; + field public static final androidx.camera.core.featuregroup.GroupableFeature IMAGE_ULTRA_HDR; + field public static final androidx.camera.core.featuregroup.GroupableFeature PREVIEW_STABILIZATION; + } + + @SuppressCompatibility @androidx.camera.core.ExperimentalSessionConfig public static final class GroupableFeature.Companion { + property public static int FEATURE_TYPE_DYNAMIC_RANGE; + property public static int FEATURE_TYPE_FPS_RANGE; + property public static int FEATURE_TYPE_IMAGE_FORMAT; + property public static int FEATURE_TYPE_VIDEO_STABILIZATION; + property public androidx.camera.core.featuregroup.GroupableFeature FPS_60; + property public androidx.camera.core.featuregroup.GroupableFeature HDR_HLG10; + property public androidx.camera.core.featuregroup.GroupableFeature IMAGE_ULTRA_HDR; + property public androidx.camera.core.featuregroup.GroupableFeature PREVIEW_STABILIZATION; + } + +} + package androidx.camera.core.resolutionselector { public final class AspectRatioStrategy {
diff --git a/camera/camera-core/api/restricted_1.5.0-beta02.txt b/camera/camera-core/api/restricted_1.5.0-beta02.txt index be3133e..76271a5 100644 --- a/camera/camera-core/api/restricted_1.5.0-beta02.txt +++ b/camera/camera-core/api/restricted_1.5.0-beta02.txt
@@ -59,6 +59,7 @@ method public default androidx.lifecycle.LiveDatagetTorchStrengthLevel(); method public androidx.lifecycle.LiveDatagetZoomState(); method public boolean hasFlashUnit(); + method @SuppressCompatibility @androidx.camera.core.ExperimentalSessionConfig public default boolean isFeatureGroupSupported(androidx.camera.core.SessionConfig); method public default boolean isFocusMeteringSupported(androidx.camera.core.FocusMeteringAction); method public default boolean isLogicalMultiCameraSupported(); method public default boolean isLowLightBoostSupported(); @@ -638,12 +639,24 @@ ctor public SessionConfig(java.util.List extends androidx.camera.core.UseCase> useCases, optional androidx.camera.core.ViewPort? viewPort); ctor public SessionConfig(java.util.List extends androidx.camera.core.UseCase> useCases, optional androidx.camera.core.ViewPort? viewPort, optional java.util.List extends androidx.camera.core.CameraEffect> effects); ctor public SessionConfig(java.util.List extends androidx.camera.core.UseCase> useCases, optional androidx.camera.core.ViewPort? viewPort, optional java.util.List extends androidx.camera.core.CameraEffect> effects, optional android.util.RangeframeRateRange); + ctor public SessionConfig(java.util.List extends androidx.camera.core.UseCase> useCases, optional androidx.camera.core.ViewPort? viewPort, optional java.util.List extends androidx.camera.core.CameraEffect> effects, optional android.util.RangeframeRateRange, optional java.util.Set extends androidx.camera.core.featuregroup.GroupableFeature> requiredFeatureGroup); + ctor public SessionConfig(java.util.List extends androidx.camera.core.UseCase> useCases, optional androidx.camera.core.ViewPort? viewPort, optional java.util.List extends androidx.camera.core.CameraEffect> effects, optional android.util.RangeframeRateRange, optional java.util.Set extends androidx.camera.core.featuregroup.GroupableFeature> requiredFeatureGroup, optional java.util.List extends androidx.camera.core.featuregroup.GroupableFeature> preferredFeatureGroup); method public final java.util.ListgetEffects(); + method public final androidx.core.util.Consumer> getFeatureSelectionListener(); + method public final java.util.concurrent.Executor getFeatureSelectionListenerExecutor(); method public final android.util.RangegetFrameRateRange(); + method public final java.util.ListgetPreferredFeatureGroup(); + method public final java.util.SetgetRequiredFeatureGroup(); method public final java.util.ListgetUseCases(); method public final androidx.camera.core.ViewPort? getViewPort(); + method public final void setFeatureSelectionListener(androidx.core.util.Consumer> listener); + method public final void setFeatureSelectionListener(optional java.util.concurrent.Executor executor, androidx.core.util.Consumer> listener); property public final java.util.Listeffects; + property public final androidx.core.util.Consumer> featureSelectionListener; + property public final java.util.concurrent.Executor featureSelectionListenerExecutor; property public final android.util.RangeframeRateRange; + property public final java.util.ListpreferredFeatureGroup; + property public final java.util.SetrequiredFeatureGroup; property public final java.util.ListuseCases; property public final androidx.camera.core.ViewPort? viewPort; } @@ -654,6 +667,8 @@ method public androidx.camera.core.SessionConfig.Builder addEffect(androidx.camera.core.CameraEffect effect); method public androidx.camera.core.SessionConfig build(); method public androidx.camera.core.SessionConfig.Builder setFrameRateRange(android.util.RangeframeRateRange); + method public androidx.camera.core.SessionConfig.Builder setPreferredFeatureGroup(androidx.camera.core.featuregroup.GroupableFeature... features); + method public androidx.camera.core.SessionConfig.Builder setRequiredFeatureGroup(androidx.camera.core.featuregroup.GroupableFeature... features); method public androidx.camera.core.SessionConfig.Builder setViewPort(androidx.camera.core.ViewPort viewPort); } @@ -765,6 +780,34 @@ } +package @androidx.camera.core.ExperimentalSessionConfig androidx.camera.core.featuregroup { + + @SuppressCompatibility @androidx.camera.core.ExperimentalSessionConfig public abstract class GroupableFeature { + method public final int getFeatureType(); + field public static final androidx.camera.core.featuregroup.GroupableFeature.Companion Companion; + field public static final int FEATURE_TYPE_DYNAMIC_RANGE = 0; // 0x0 + field public static final int FEATURE_TYPE_FPS_RANGE = 1; // 0x1 + field public static final int FEATURE_TYPE_IMAGE_FORMAT = 3; // 0x3 + field public static final int FEATURE_TYPE_VIDEO_STABILIZATION = 2; // 0x2 + field public static final androidx.camera.core.featuregroup.GroupableFeature FPS_60; + field public static final androidx.camera.core.featuregroup.GroupableFeature HDR_HLG10; + field public static final androidx.camera.core.featuregroup.GroupableFeature IMAGE_ULTRA_HDR; + field public static final androidx.camera.core.featuregroup.GroupableFeature PREVIEW_STABILIZATION; + } + + @SuppressCompatibility @androidx.camera.core.ExperimentalSessionConfig public static final class GroupableFeature.Companion { + property public static int FEATURE_TYPE_DYNAMIC_RANGE; + property public static int FEATURE_TYPE_FPS_RANGE; + property public static int FEATURE_TYPE_IMAGE_FORMAT; + property public static int FEATURE_TYPE_VIDEO_STABILIZATION; + property public androidx.camera.core.featuregroup.GroupableFeature FPS_60; + property public androidx.camera.core.featuregroup.GroupableFeature HDR_HLG10; + property public androidx.camera.core.featuregroup.GroupableFeature IMAGE_ULTRA_HDR; + property public androidx.camera.core.featuregroup.GroupableFeature PREVIEW_STABILIZATION; + } + +} + package androidx.camera.core.resolutionselector { public final class AspectRatioStrategy {
diff --git a/camera/camera-core/api/restricted_current.txt b/camera/camera-core/api/restricted_current.txt index be3133e..76271a5 100644 --- a/camera/camera-core/api/restricted_current.txt +++ b/camera/camera-core/api/restricted_current.txt
@@ -59,6 +59,7 @@ method public default androidx.lifecycle.LiveDatagetTorchStrengthLevel(); method public androidx.lifecycle.LiveDatagetZoomState(); method public boolean hasFlashUnit(); + method @SuppressCompatibility @androidx.camera.core.ExperimentalSessionConfig public default boolean isFeatureGroupSupported(androidx.camera.core.SessionConfig); method public default boolean isFocusMeteringSupported(androidx.camera.core.FocusMeteringAction); method public default boolean isLogicalMultiCameraSupported(); method public default boolean isLowLightBoostSupported(); @@ -638,12 +639,24 @@ ctor public SessionConfig(java.util.List extends androidx.camera.core.UseCase> useCases, optional androidx.camera.core.ViewPort? viewPort); ctor public SessionConfig(java.util.List extends androidx.camera.core.UseCase> useCases, optional androidx.camera.core.ViewPort? viewPort, optional java.util.List extends androidx.camera.core.CameraEffect> effects); ctor public SessionConfig(java.util.List extends androidx.camera.core.UseCase> useCases, optional androidx.camera.core.ViewPort? viewPort, optional java.util.List extends androidx.camera.core.CameraEffect> effects, optional android.util.RangeframeRateRange); + ctor public SessionConfig(java.util.List extends androidx.camera.core.UseCase> useCases, optional androidx.camera.core.ViewPort? viewPort, optional java.util.List extends androidx.camera.core.CameraEffect> effects, optional android.util.RangeframeRateRange, optional java.util.Set extends androidx.camera.core.featuregroup.GroupableFeature> requiredFeatureGroup); + ctor public SessionConfig(java.util.List extends androidx.camera.core.UseCase> useCases, optional androidx.camera.core.ViewPort? viewPort, optional java.util.List extends androidx.camera.core.CameraEffect> effects, optional android.util.RangeframeRateRange, optional java.util.Set extends androidx.camera.core.featuregroup.GroupableFeature> requiredFeatureGroup, optional java.util.List extends androidx.camera.core.featuregroup.GroupableFeature> preferredFeatureGroup); method public final java.util.ListgetEffects(); + method public final androidx.core.util.Consumer> getFeatureSelectionListener(); + method public final java.util.concurrent.Executor getFeatureSelectionListenerExecutor(); method public final android.util.RangegetFrameRateRange(); + method public final java.util.ListgetPreferredFeatureGroup(); + method public final java.util.SetgetRequiredFeatureGroup(); method public final java.util.ListgetUseCases(); method public final androidx.camera.core.ViewPort? getViewPort(); + method public final void setFeatureSelectionListener(androidx.core.util.Consumer> listener); + method public final void setFeatureSelectionListener(optional java.util.concurrent.Executor executor, androidx.core.util.Consumer> listener); property public final java.util.Listeffects; + property public final androidx.core.util.Consumer> featureSelectionListener; + property public final java.util.concurrent.Executor featureSelectionListenerExecutor; property public final android.util.RangeframeRateRange; + property public final java.util.ListpreferredFeatureGroup; + property public final java.util.SetrequiredFeatureGroup; property public final java.util.ListuseCases; property public final androidx.camera.core.ViewPort? viewPort; } @@ -654,6 +667,8 @@ method public androidx.camera.core.SessionConfig.Builder addEffect(androidx.camera.core.CameraEffect effect); method public androidx.camera.core.SessionConfig build(); method public androidx.camera.core.SessionConfig.Builder setFrameRateRange(android.util.RangeframeRateRange); + method public androidx.camera.core.SessionConfig.Builder setPreferredFeatureGroup(androidx.camera.core.featuregroup.GroupableFeature... features); + method public androidx.camera.core.SessionConfig.Builder setRequiredFeatureGroup(androidx.camera.core.featuregroup.GroupableFeature... features); method public androidx.camera.core.SessionConfig.Builder setViewPort(androidx.camera.core.ViewPort viewPort); } @@ -765,6 +780,34 @@ } +package @androidx.camera.core.ExperimentalSessionConfig androidx.camera.core.featuregroup { + + @SuppressCompatibility @androidx.camera.core.ExperimentalSessionConfig public abstract class GroupableFeature { + method public final int getFeatureType(); + field public static final androidx.camera.core.featuregroup.GroupableFeature.Companion Companion; + field public static final int FEATURE_TYPE_DYNAMIC_RANGE = 0; // 0x0 + field public static final int FEATURE_TYPE_FPS_RANGE = 1; // 0x1 + field public static final int FEATURE_TYPE_IMAGE_FORMAT = 3; // 0x3 + field public static final int FEATURE_TYPE_VIDEO_STABILIZATION = 2; // 0x2 + field public static final androidx.camera.core.featuregroup.GroupableFeature FPS_60; + field public static final androidx.camera.core.featuregroup.GroupableFeature HDR_HLG10; + field public static final androidx.camera.core.featuregroup.GroupableFeature IMAGE_ULTRA_HDR; + field public static final androidx.camera.core.featuregroup.GroupableFeature PREVIEW_STABILIZATION; + } + + @SuppressCompatibility @androidx.camera.core.ExperimentalSessionConfig public static final class GroupableFeature.Companion { + property public static int FEATURE_TYPE_DYNAMIC_RANGE; + property public static int FEATURE_TYPE_FPS_RANGE; + property public static int FEATURE_TYPE_IMAGE_FORMAT; + property public static int FEATURE_TYPE_VIDEO_STABILIZATION; + property public androidx.camera.core.featuregroup.GroupableFeature FPS_60; + property public androidx.camera.core.featuregroup.GroupableFeature HDR_HLG10; + property public androidx.camera.core.featuregroup.GroupableFeature IMAGE_ULTRA_HDR; + property public androidx.camera.core.featuregroup.GroupableFeature PREVIEW_STABILIZATION; + } + +} + package androidx.camera.core.resolutionselector { public final class AspectRatioStrategy {
diff --git a/camera/camera-core/samples/src/main/java/androidx/camera/core/samples/FeatureGroupSamples.kt b/camera/camera-core/samples/src/main/java/androidx/camera/core/samples/FeatureGroupSamples.kt index 7785b97..9dca23c 100644 --- a/camera/camera-core/samples/src/main/java/androidx/camera/core/samples/FeatureGroupSamples.kt +++ b/camera/camera-core/samples/src/main/java/androidx/camera/core/samples/FeatureGroupSamples.kt
@@ -20,7 +20,9 @@ import androidx.annotation.Sampled import androidx.camera.core.CameraSelector +import androidx.camera.core.DynamicRange import androidx.camera.core.ExperimentalSessionConfig +import androidx.camera.core.Preview import androidx.camera.core.SessionConfig import androidx.camera.core.UseCase import androidx.camera.core.featuregroup.GroupableFeature @@ -31,6 +33,36 @@ import androidx.lifecycle.LifecycleOwner @Sampled +fun configureSessionConfigWithFeatureGroups() { + // Create a session config where HDR is mandatory while 60 FPS (higher priority) and preview + // stabilization are optional + SessionConfig( + useCases = listOf(Preview.Builder().build()), + requiredFeatureGroup = setOf(HDR_HLG10), + preferredFeatureGroup = listOf(FPS_60, PREVIEW_STABILIZATION), + ) + + // Creating the following SessionConfig will throw an exception as there are conflicting + // information. HDR is mentioned once as required and then again as preferred, it can't be both! + SessionConfig( + useCases = listOf(Preview.Builder().build()), + requiredFeatureGroup = setOf(HDR_HLG10), + preferredFeatureGroup = listOf(FPS_60, HDR_HLG10), + ) + + // Creating the following SessionConfig will throw an exception as a groupable feature (HDR) is + // configured with non-grouping API while using grouping API as well. HDR is configured to the + // with a non-grouping API through the Preview use case, so it can't be grouped together + // properly with the other groupable features. Instead, it should be configured like the first + // SessionConfig construction in this code snippet. + SessionConfig( + useCases = + listOf(Preview.Builder().apply { setDynamicRange(DynamicRange.HLG_10_BIT) }.build()), + preferredFeatureGroup = listOf(FPS_60, PREVIEW_STABILIZATION), + ) +} + +@Sampled fun startCameraWithSomeHighQualityFeatures( cameraProvider: ProcessCameraProvider, lifecycleOwner: LifecycleOwner,
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/CameraInfo.java b/camera/camera-core/src/main/java/androidx/camera/core/CameraInfo.java index 14a7901..19bb161 100644 --- a/camera/camera-core/src/main/java/androidx/camera/core/CameraInfo.java +++ b/camera/camera-core/src/main/java/androidx/camera/core/CameraInfo.java
@@ -556,13 +556,38 @@ * Returns if the {@link GroupableFeature} groups set to the provided {@link SessionConfig} is * supported. * + *+ * * @param sessionConfig The {@link SessionConfig} containing some required or preferred * feature groups. * @return Whether the feature group is supported or not. * @throws IllegalArgumentException If some features conflict with each other by having * different values for the same feature type and can thus never be supported together. + * @see androidx.camera.core.featuregroup.GroupableFeature */ - @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // TODO: Expose the API for public release. @ExperimentalSessionConfig default boolean isFeatureGroupSupported(@NonNull SessionConfig sessionConfig) { return false;This API can be used before calling `bindToLifecycle` API to know if binding a
+ * {@link SessionConfig} with some given combination of feature groups will work or not. + * + *The following pseudo-code shows an example of how to use this API:
+ *{@code+ * // Disable the unsupported feature options in app feature menu UI once some features have + * // already been selected and adding these features will lead to an unsupported configuration. + * void disableUnsupportedFeatures(SetselectedFeatures, + * SetappFeatureOptions) { + * for (GroupableFeature featureOption : appFeatureOptions) { + * if (selectedFeatures.contains(featureOption)) { continue; } + * + * ListcombinedFeatures = new ArrayList<>(selectedFeatures); + * combinedFeatures.add(featureOption); + * SessionConfig sessionConfig = + * new SessionConfig.Builder(useCases) + * .addRequiredFeatureGroup(combinedFeatures.toArray(new Feature[0])) + * .build(); + * + * if (!cameraInfo.isFeatureGroupSupported(sessionConfig)) { + * disableFeatureOptionInUi(featureOption); // e.g. app logic to disable a menu item + * } + * } + * }}
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/SessionConfig.kt b/camera/camera-core/src/main/java/androidx/camera/core/SessionConfig.kt index b281133..a39b8c1 100644 --- a/camera/camera-core/src/main/java/androidx/camera/core/SessionConfig.kt +++ b/camera/camera-core/src/main/java/androidx/camera/core/SessionConfig.kt
@@ -38,12 +38,26 @@ * and common properties like the field-of-view defined by [ViewPort], the [CameraEffect], frame * rate, required or preferred [GroupableFeature] groups etc. * + * When configuring a session config with `GroupableFeature`s (i.e. [requiredFeatureGroup] or + * [preferredFeatureGroup] is used), avoid using non-grouping APIs for any feature that is groupable + * (see [GroupableFeature] to know which features are groupable). Doing so can lead to conflicting + * configurations and an [IllegalArgumentException]. The following code sample explains this + * further. + * + * @sample androidx.camera.core.samples.configureSessionConfigWithFeatureGroups * @property useCases The list of [UseCase] to be attached to the camera and receive camera data. * This can't be empty. * @property viewPort The [ViewPort] to be applied on the camera session. If not set, the default is * no viewport. * @property effects The list of [CameraEffect] to be applied on the camera session. If not set, the * default is no effects. + * @property requiredFeatureGroup A set of [GroupableFeature] that are mandatory for the camera + * session configuration. If not set, the default is an empty set. See + * [SessionConfig.Builder.setRequiredFeatureGroup] for more info. + * @property preferredFeatureGroup A list of preferred [GroupableFeature] ordered according to + * priority in descending order, i.e. a feature with a lower index in the list is considered to + * have a higher priority. If not set, the default is an empty list. See + * [SessionConfig.Builder.setPreferredFeatureGroup] for more info. * @property frameRateRange The desired frame rate range for the camera session. The value must be * one of the supported frame rates queried by [CameraInfo.getSupportedFrameRateRanges] with a * specific [SessionConfig], or an [IllegalArgumentException] will be thrown during @@ -59,14 +73,7 @@ * ensures a stable frame rate crucial for **video recording**, though it can lead to darker, * noisier video in low light due to shorter exposure times. * @throws IllegalArgumentException If the combination of config options are conflicting or - * unsupported, e.g. - * - if any of the required features is not supported on the device - * - if same feature is present multiple times in [preferredFeatures] - * - if same feature is present in both [requiredFeatures] and [preferredFeatures] - * - if [ImageAnalysis] use case is added with [requiredFeatures] or [preferredFeatures] - * - if a [CameraEffect] is set with [requiredFeatures] or [preferredFeatures] - * - if the frame rate is not supported with the [SessionConfig] - * + * unsupported. * @See androidx.camera.lifecycle.ProcessCameraProvider.bindToLifecycle */ @ExperimentalSessionConfig @@ -77,46 +84,33 @@ public val viewPort: ViewPort? = null, public val effects: List= emptyList(), public val frameRateRange: Range= FRAME_RATE_RANGE_UNSPECIFIED, + public val requiredFeatureGroup: Set= emptySet(), + public val preferredFeatureGroup: List= emptyList(), ) { public val useCases: List= useCases.distinct() - @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) - public var requiredFeatureGroup: Set= emptySet() - private set - - @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) - public var preferredFeatureGroup: List= emptyList() - private set - @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) public open val isLegacy: Boolean = false @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) public open val sessionType: Int = SESSION_TYPE_REGULAR - @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + /** + * Gets the feature selection listener set to this session config. + * + * @see setFeatureSelectionListener + */ public var featureSelectionListener: Consumer> = Consumer> {} private set - @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + /** + * Gets the executor set to this session config for feature selection listener invocation. + * + * @see setFeatureSelectionListener + */ public var featureSelectionListenerExecutor: Executor = CameraXExecutors.mainThreadExecutor() private set - @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) - public constructor( - useCases: List, - viewPort: ViewPort? = null, - effects: List= emptyList(), - frameRateRange: Range= FRAME_RATE_RANGE_UNSPECIFIED, - requiredFeatureGroup: Set= emptySet(), - preferredFeatureGroup: List= emptyList(), - ) : this( - useCases = useCases, - viewPort = viewPort, - effects = effects, - frameRateRange = frameRateRange, - ) { - this.requiredFeatureGroup = requiredFeatureGroup - this.preferredFeatureGroup = preferredFeatureGroup + init { validateFeatureCombination() } @@ -175,17 +169,17 @@ * * Both the required and the selected preferred features are notified to the listener. The * listener is invoked when this session config is bound to camera (e.g. when the - * `androidx.camera.lifecycle.ProcessCameraProvider.bindToLifecycle` API is invoked). + * `androidx.camera.lifecycle.ProcessCameraProvider.bindToLifecycle` API is invoked). It is + * invoked even when no preferred features are selected, providing either the required features + * or an empty set (if no feature was set as required). * * Alternatively, the [CameraInfo.isFeatureGroupSupported] API can be used to query if a set of * features is supported before binding. * - * @param executor The executor in which the listener will be invoked, main thread by default. + * @param executor The executor in which the listener will be invoked. If not set, the main + * thread is used by default. * @param listener The consumer to accept the final set of features when they are selected. */ - // TODO: b/384404392 - Remove when feature combo impl. is ready. The feature combo params should - // be kept restricted until then. - @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) @JvmOverloads public fun setFeatureSelectionListener( executor: Executor = CameraXExecutors.mainThreadExecutor(), @@ -233,34 +227,60 @@ /** * Sets the list of [GroupableFeature] that are mandatory for the camera configuration. * - * If all the features are not supported, an [IllegalStateException] will be thrown during - * camera configuration. + * If any of the features are not supported or if the features are not supported together as + * a combination, an [IllegalArgumentException] will be thrown when the [SessionConfig] is + * bound to a lifecycle (e.g. when the + * `androidx.camera.lifecycle.ProcessCameraProvider.bindToLifecycle` API is invoked). + * + * To avoid setting an unsupported feature as required, the [setPreferredFeatureGroup] API + * can be used since the features from the preferred features are selected on a best-effort + * basis according to the priority defined by the ordering of features in the list. + * Alternatively, the [CameraInfo.isFeatureGroupSupported] API can be used before binding to + * check if the features are supported or not. + * + * Note that [CameraEffect] or [ImageAnalysis] use case is currently not supported when a + * feature is set to a session config. * * @param features The vararg of `GroupableFeature` objects to add to the required features. * @return The [Builder] instance, allowing for method chaining. - * @see androidx.camera.core.SessionConfig.requiredFeatureGroup */ - // TODO: b/384404392 - Remove when feature combo impl. is ready. - @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) public fun setRequiredFeatureGroup(vararg features: GroupableFeature): Builder { + requiredFeatureGroup.clear() requiredFeatureGroup.addAll(features) return this } /** - * Sets the list of preferred [GroupableFeature] that is ordered according to priority in - * descending order. + * Sets the list of preferred [GroupableFeature], ordered by priority in descending order. * - * These features will be selected on a best-effort basis according to the priority. The - * feature that is ordered first in the list (i.e. has a lower index) will be prioritized - * higher than a feature ordered later in the list. + * Features are evaluated for support based on this specified priority. The feature with a + * lower index (listed first) is considered higher priority. The system attempts to enable + * preferred features on a best-effort basis: + * - It starts with the highest priority feature. + * - If a feature is supported (considering device capabilities and any other already + * selected preferred features or required features), it's added to the selection. + * - If a preferred feature is *not* supported, it's skipped, and the system proceeds to + * evaluate the next feature in the preferred list. + * + * For example, consider the following scenarios where [SessionConfig.requiredFeatureGroup] + * is empty: + * + * |Preferred List |Device Support |Selected Features | + * |--------------------------------|----------------------------|------------------------| + * |`[HDR_HLG10, FPS_60, ULTRA_HDR]`|HLG10 + 60 FPS not supported|`[HDR_HLG10, ULTRA_HDR]`| + * |`[FPS_60, HDR_HLG10, ULTRA_HDR]`|HLG10 + 60 FPS not supported|`[FPS_60, ULTRA_HDR]` | + * |`[HDR_HLG10, FPS_60, ULTRA_HDR]`|HLG10 is not supported |`[FPS_60, ULTRA_HDR]` | + * |`[HDR_HLG10, FPS_60]` |Both supported together |`[HDR_HLG10, FPS_60]` | + * + * The final set of selected features will be notified to the listener set by the + * [SessionConfig.setFeatureSelectionListener] API. + * + * Note that [CameraEffect] or [ImageAnalysis] use case is currently not supported when a + * feature is set to a session config. * * @param features The list of preferred features, ordered by preference. * @return The [Builder] instance, allowing for method chaining. - * @see androidx.camera.core.SessionConfig.preferredFeatureGroup */ - // TODO: b/384404392 - Remove when feature combo impl. is ready. - @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) public fun setPreferredFeatureGroup(vararg features: GroupableFeature): Builder { preferredFeatureGroup.clear() preferredFeatureGroup.addAll(features)
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/featuregroup/GroupableFeature.kt b/camera/camera-core/src/main/java/androidx/camera/core/featuregroup/GroupableFeature.kt index 0846858..2a5ca07 100644 --- a/camera/camera-core/src/main/java/androidx/camera/core/featuregroup/GroupableFeature.kt +++ b/camera/camera-core/src/main/java/androidx/camera/core/featuregroup/GroupableFeature.kt
@@ -38,10 +38,18 @@ import androidx.camera.core.featuregroup.impl.feature.VideoStabilizationFeature.StabilizationMode /** - * Base [GroupableFeature] class for all features that can be grouped together for configuring a - * camera session. + * Represents distinct, groupable camera functionalities that can be requested for a camera session. + * + * CameraX provides various implementations of this class as objects to denote various groupable + * features, i.e. [HDR_HLG10], [FPS_60] [PREVIEW_STABILIZATION], [IMAGE_ULTRA_HDR]. These features + * can be configured as a group in a [androidx.camera.core.SessionConfig] to ensure compatibility + * when they are used together. Additionally, the + * [androidx.camera.core.CameraInfo.isFeatureGroupSupported] API can be used to check if a group of + * features is supported together on a device. * * @sample androidx.camera.core.samples.startCameraWithSomeHighQualityFeatures + * @see androidx.camera.core.SessionConfig.Builder.setRequiredFeatureGroup + * @see androidx.camera.core.SessionConfig.Builder.setPreferredFeatureGroup */ @ExperimentalSessionConfig public abstract class GroupableFeature
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/featuregroup/package-info.java b/camera/camera-core/src/main/java/androidx/camera/core/featuregroup/package-info.java index ba4f628..d9d07eb 100644 --- a/camera/camera-core/src/main/java/androidx/camera/core/featuregroup/package-info.java +++ b/camera/camera-core/src/main/java/androidx/camera/core/featuregroup/package-info.java
@@ -14,7 +14,7 @@ * limitations under the License. */ -@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // TODO: Expose the features +@ExperimentalSessionConfig package androidx.camera.core.featuregroup; -import androidx.annotation.RestrictTo; +import androidx.camera.core.ExperimentalSessionConfig; \ No newline at end of file
diff --git a/camera/camera-lifecycle/samples/src/main/java/androidx/camera/lifecycle/samples/LifecycleCameraProviderSamples.kt b/camera/camera-lifecycle/samples/src/main/java/androidx/camera/lifecycle/samples/LifecycleCameraProviderSamples.kt index 6130cbe..d1488f5 100644 --- a/camera/camera-lifecycle/samples/src/main/java/androidx/camera/lifecycle/samples/LifecycleCameraProviderSamples.kt +++ b/camera/camera-lifecycle/samples/src/main/java/androidx/camera/lifecycle/samples/LifecycleCameraProviderSamples.kt
@@ -17,6 +17,7 @@ package androidx.camera.lifecycle.samples import android.content.Context +import android.util.Log import androidx.annotation.OptIn as JavaOptIn import androidx.annotation.Sampled import androidx.camera.camera2.Camera2Config @@ -28,6 +29,9 @@ import androidx.camera.core.Preview import androidx.camera.core.SessionConfig import androidx.camera.core.UseCase +import androidx.camera.core.featuregroup.GroupableFeature.Companion.FPS_60 +import androidx.camera.core.featuregroup.GroupableFeature.Companion.HDR_HLG10 +import androidx.camera.core.featuregroup.GroupableFeature.Companion.PREVIEW_STABILIZATION import androidx.camera.lifecycle.ExperimentalCameraProviderConfiguration import androidx.camera.lifecycle.LifecycleCameraProvider import androidx.camera.lifecycle.ProcessCameraProvider @@ -109,3 +113,35 @@ sessionConfigNewEffect, ) } + +@Sampled +@OptIn(ExperimentalSessionConfig::class) +fun bindSessionConfigWithFeatureGroupsToLifecycle( + cameraProvider: ProcessCameraProvider, + lifecycleOwner: LifecycleOwner, + useCases: List, +) { + // Starts the camera with feature groups configured. + cameraProvider.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + // HDR is mandatory in this camera configuration and an exception will be thrown if it's not + // supported. 60 FPS and preview stabilization are optional and used if they are also + // supported, with the 60 FPS having higher priority over preview stabilization. + SessionConfig( + useCases = useCases, + requiredFeatureGroup = setOf(HDR_HLG10), + preferredFeatureGroup = listOf(FPS_60, PREVIEW_STABILIZATION), + ) + .apply { + setFeatureSelectionListener { features -> + Log.d( + "TAG", + "Features selected as per priority and device capabilities: $features", + ) + + // Update app UI based on the selected features if required + } + }, + ) +}
diff --git a/camera/camera-lifecycle/src/main/java/androidx/camera/lifecycle/ProcessCameraProvider.kt b/camera/camera-lifecycle/src/main/java/androidx/camera/lifecycle/ProcessCameraProvider.kt index 489e535..a3fd9c6 100644 --- a/camera/camera-lifecycle/src/main/java/androidx/camera/lifecycle/ProcessCameraProvider.kt +++ b/camera/camera-lifecycle/src/main/java/androidx/camera/lifecycle/ProcessCameraProvider.kt
@@ -271,14 +271,19 @@ * can't resolve a valid camera under the requirements, an IllegalArgumentException will be * thrown. * + * The following code example shows various aspects of binding a session config. + * + * @sample androidx.camera.lifecycle.samples.bindSessionConfigToLifecycle + * + * The following code snippet demonstrates binding a session config with feature groups. + * + * @sample androidx.camera.lifecycle.samples.bindSessionConfigWithFeatureGroupsToLifecycle * @throws UnsupportedOperationException If the camera is configured in concurrent mode. For * example, if a list of [SingleCameraConfig]s was bound to the lifecycle already. * @throws IllegalStateException if either of the following conditions is met: * - A [UseCase] or [SessionConfig] is already bound to the same [LifecycleOwner]. * - A [UseCase] contained within the [SessionConfig] is already bound to a different * [LifecycleOwner]. - * - * @sample androidx.camera.lifecycle.samples.bindSessionConfigToLifecycle */ @ExperimentalSessionConfig public fun bindToLifecycle(
diff --git a/camera/integration-tests/featurecombotestapp/lint-baseline.xml b/camera/integration-tests/featurecombotestapp/lint-baseline.xml index a3c834b..6921006 100644 --- a/camera/integration-tests/featurecombotestapp/lint-baseline.xml +++ b/camera/integration-tests/featurecombotestapp/lint-baseline.xml
@@ -3,96 +3,6 @@id="RestrictedApiAndroidX" - message="GroupableFeature can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)" - errorLine1=" val requiredFeatures: Set<GroupableFeature> = emptySet()," - errorLine2=" ~~~~~~~~~~~~~~~~"> - - -- file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/> - - id="RestrictedApiAndroidX" - message="GroupableFeature can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)" - errorLine1=" val requiredFeatures: Set<GroupableFeature> = emptySet()," - errorLine2=" ~~~~~~~~~~~~~~~~"> - - -- file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/> - - id="RestrictedApiAndroidX" - message="GroupableFeature can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)" - errorLine1=" val requiredFeatures: Set<GroupableFeature> = emptySet()," - errorLine2=" ~~~~~~~~~~~~~~~~"> - - -- file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/> - - id="RestrictedApiAndroidX" - message="GroupableFeature can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)" - errorLine1=" val requiredFeatures: Set<GroupableFeature> = emptySet()," - errorLine2=" ~~~~~~~~~~~~~~~~"> - - -- file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/> - - id="RestrictedApiAndroidX" - message="GroupableFeature can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)" - errorLine1=" val preferredFeatures: List<GroupableFeature> = emptyList()," - errorLine2=" ~~~~~~~~~~~~~~~~"> - - -- file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/> - - id="RestrictedApiAndroidX" - message="GroupableFeature can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)" - errorLine1=" val preferredFeatures: List<GroupableFeature> = emptyList()," - errorLine2=" ~~~~~~~~~~~~~~~~"> - - -- file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/> - - id="RestrictedApiAndroidX" - message="GroupableFeature can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)" - errorLine1=" val preferredFeatures: List<GroupableFeature> = emptyList()," - errorLine2=" ~~~~~~~~~~~~~~~~"> - - -- file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/> - - id="RestrictedApiAndroidX" - message="GroupableFeature can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)" - errorLine1=" val preferredFeatures: List<GroupableFeature> = emptyList()," - errorLine2=" ~~~~~~~~~~~~~~~~"> - - -- file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/> - - id="RestrictedApiAndroidX" - message="SessionConfig can only be called from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)" - errorLine1=" SessionConfig(" - errorLine2=" ~~~~~~~~~~~~~"> - - -- file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/> - - id="RestrictedApiAndroidX" - message="SessionConfig.setFeatureSelectionListener can only be called from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)" - errorLine1=" setFeatureSelectionListener { features ->" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - - -- file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/> - - id="RestrictedApiAndroidX" message="Logger.d can only be called from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)" errorLine1=" Logger.d(TAG, "Selected features: $features")" errorLine2=" ~"> @@ -111,69 +21,6 @@ id="RestrictedApiAndroidX" - message="GroupableFeature.HDR_HLG10 can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)" - errorLine1=" GroupableFeature.HDR_HLG10," - errorLine2=" ~~~~~~~~~"> - - -- file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/> - - id="RestrictedApiAndroidX" - message="GroupableFeature.FPS_60 can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)" - errorLine1=" GroupableFeature.FPS_60," - errorLine2=" ~~~~~~"> - - -- file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/> - - id="RestrictedApiAndroidX" - message="GroupableFeature.PREVIEW_STABILIZATION can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)" - errorLine1=" GroupableFeature.PREVIEW_STABILIZATION," - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> - - -- file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/> - - id="RestrictedApiAndroidX" - message="GroupableFeature.IMAGE_ULTRA_HDR can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)" - errorLine1=" GroupableFeature.IMAGE_ULTRA_HDR," - errorLine2=" ~~~~~~~~~~~~~~~"> - - -- file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/> - - id="RestrictedApiAndroidX" - message="GroupableFeature.HDR_HLG10 can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)" - errorLine1=" GroupableFeature.HDR_HLG10," - errorLine2=" ~~~~~~~~~"> - - -- file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/> - - id="RestrictedApiAndroidX" - message="GroupableFeature.PREVIEW_STABILIZATION can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)" - errorLine1=" GroupableFeature.PREVIEW_STABILIZATION," - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> - - -- file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/> - - id="RestrictedApiAndroidX" - message="GroupableFeature.FPS_60 can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)" - errorLine1=" GroupableFeature.FPS_60," - errorLine2=" ~~~~~~"> - - -- file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/> - - id="RestrictedApiAndroidX" message="Logger.d can only be called from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)" errorLine1=" Logger.d(TAG, "updateUnsupportedFeatures: duration = $duration")" errorLine2=" ~"> @@ -199,103 +46,4 @@ file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/> -- id="RestrictedApiAndroidX" - message="CameraInfo.isFeatureGroupSupported can only be called from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)" - errorLine1=" .isFeatureGroupSupported(" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~"> - - -- file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/> - - id="RestrictedApiAndroidX" - message="SessionConfig can only be called from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)" - errorLine1=" SessionConfig(useCases, requiredFeatureGroup = requiredFeatures)" - errorLine2=" ~~~~~~~~~~~~~"> - - -- file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/> - - id="RestrictedApiAndroidX" - message="GroupableFeature can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)" - errorLine1=" private fun AppFeatures.toCameraXFeatures(): Set<GroupableFeature> {" - errorLine2=" ~~~~~~~~~~~~~~~~"> - - -- file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/> - - id="RestrictedApiAndroidX" - message="GroupableFeature.IMAGE_ULTRA_HDR can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)" - errorLine1=" features.add(GroupableFeature.IMAGE_ULTRA_HDR)" - errorLine2=" ~~~~~~~~~~~~~~~"> - - -- file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/> - - id="RestrictedApiAndroidX" - message="GroupableFeature.HDR_HLG10 can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)" - errorLine1=" features.add(GroupableFeature.HDR_HLG10)" - errorLine2=" ~~~~~~~~~"> - - -- file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/> - - id="RestrictedApiAndroidX" - message="GroupableFeature.FPS_60 can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)" - errorLine1=" features.add(GroupableFeature.FPS_60)" - errorLine2=" ~~~~~~"> - - -- file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/> - - id="RestrictedApiAndroidX" - message="GroupableFeature.PREVIEW_STABILIZATION can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)" - errorLine1=" features.add(GroupableFeature.PREVIEW_STABILIZATION)" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> - - -- file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/> - - id="RestrictedApiAndroidX" - message="GroupableFeature.HDR_HLG10 can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)" - errorLine1=" GroupableFeature.HDR_HLG10 -> {" - errorLine2=" ~~~~~~~~~"> - - -- file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/> - - id="RestrictedApiAndroidX" - message="GroupableFeature.FPS_60 can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)" - errorLine1=" GroupableFeature.FPS_60 -> {" - errorLine2=" ~~~~~~"> - - -- file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/> - - id="RestrictedApiAndroidX" - message="GroupableFeature.PREVIEW_STABILIZATION can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)" - errorLine1=" GroupableFeature.PREVIEW_STABILIZATION -> {" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> - - -- file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/> - - id="RestrictedApiAndroidX" - message="GroupableFeature.IMAGE_ULTRA_HDR can only be accessed from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)" - errorLine1=" GroupableFeature.IMAGE_ULTRA_HDR -> {" - errorLine2=" ~~~~~~~~~~~~~~~"> - -- file="src/main/java/androidx/camera/integration/featurecombo/ui/CameraViewModel.kt"/> -
diff --git a/camera/viewfinder/viewfinder-compose/build.gradle b/camera/viewfinder/viewfinder-compose/build.gradle index 7d58650..255aeae 100644 --- a/camera/viewfinder/viewfinder-compose/build.gradle +++ b/camera/viewfinder/viewfinder-compose/build.gradle
@@ -33,7 +33,6 @@ dependencies { api(project(":camera:viewfinder:viewfinder-core")) implementation("androidx.compose.foundation:foundation-layout:1.6.1") - implementation("androidx.compose.foundation:foundation:1.6.1") implementation("androidx.compose.runtime:runtime:1.6.1") implementation("androidx.compose.ui:ui:1.7.8") implementation("androidx.compose.ui:ui-util:1.7.8") @@ -56,6 +55,7 @@ androidTestImplementation(libs.junit) androidTestImplementation(libs.kotlinCoroutinesCore) androidTestImplementation(libs.testExtJunit) + androidTestImplementation(project(":compose:foundation:foundation")) androidTestImplementation(project(":compose:runtime:runtime")) androidTestImplementation(project(":compose:ui:ui")) androidTestImplementation(project(":compose:ui:ui-geometry"))
diff --git a/camera/viewfinder/viewfinder-compose/src/androidTest/kotlin/androidx/camera/viewfinder/compose/ViewfinderTest.kt b/camera/viewfinder/viewfinder-compose/src/androidTest/kotlin/androidx/camera/viewfinder/compose/ViewfinderTest.kt index b9677b0..775281c 100644 --- a/camera/viewfinder/viewfinder-compose/src/androidTest/kotlin/androidx/camera/viewfinder/compose/ViewfinderTest.kt +++ b/camera/viewfinder/viewfinder-compose/src/androidTest/kotlin/androidx/camera/viewfinder/compose/ViewfinderTest.kt
@@ -18,50 +18,54 @@ import android.os.Build import android.util.Size -import android.view.Surface -import androidx.annotation.RequiresApi import androidx.camera.viewfinder.core.ImplementationMode import androidx.camera.viewfinder.core.TransformationInfo import androidx.camera.viewfinder.core.ViewfinderSurfaceRequest +import androidx.camera.viewfinder.core.ViewfinderSurfaceSessionScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.movableContentOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Matrix import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.test.junit4.createComposeRule -import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import androidx.test.filters.SdkSuppress import com.google.common.truth.Truth.assertThat -import kotlinx.coroutines.CompletableDeferred +import com.google.common.truth.TruthJUnit.assume +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update import kotlinx.coroutines.runBlocking -import org.junit.Ignore +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.junit.runners.Parameterized @MediumTest -@RunWith(AndroidJUnit4::class) -class ViewfinderTest { +@RunWith(Parameterized::class) +class ViewfinderTest(private val implementationMode: ImplementationMode) { + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "implementationMode = {0}") + fun data(): Array= + arrayOf(ImplementationMode.EXTERNAL, ImplementationMode.EMBEDDED) + } + @get:Rule val rule = createComposeRule() - @SdkSuppress(minSdkVersion = Build.VERSION_CODES.M) - @Test - fun canRetrievePerformanceSurface() = runBlocking { - assertCanRetrieveSurface(implementationMode = ImplementationMode.EXTERNAL) - } - - @SdkSuppress(minSdkVersion = Build.VERSION_CODES.M) - @Test - fun canRetrieveCompatibleSurface() = runBlocking { - assertCanRetrieveSurface(implementationMode = ImplementationMode.EMBEDDED) - } - - @SdkSuppress(minSdkVersion = Build.VERSION_CODES.M) @Test fun coordinatesTransformationSameSizeNoRotation(): Unit = runBlocking { val coordinateTransformer = MutableCoordinateTransformer() @@ -84,7 +88,6 @@ assertThat(coordinateTransformer.transformMatrix.values).isEqualTo(expectedMatrix.values) } - @SdkSuppress(minSdkVersion = Build.VERSION_CODES.M) @Test fun coordinatesTransformationSameSizeWithHalfCrop(): Unit = runBlocking { // Viewfinder size: 1080x1920 @@ -97,12 +100,6 @@ with(LocalDensity.current) { TestViewfinder( modifier = Modifier.size(540.toDp(), 960.toDp()), - surfaceRequest = - ViewfinderSurfaceRequest( - width = ViewfinderTestParams.Default.sourceResolution.width, - height = ViewfinderTestParams.Default.sourceResolution.height, - implementationMode = ImplementationMode.EXTERNAL, - ), transformationInfo = TransformationInfo( sourceRotation = 0, @@ -127,115 +124,239 @@ assertThat(coordinateTransformer.transformMatrix.values).isEqualTo(expectedMatrix.values) } + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.M) // Needed for Surface.lockHardwareCanvas() + @Test + fun canRetrieveSurface() = runBlocking { + testWithSession { + val surface = awaitSurfaceSession().surface + surface.lockHardwareCanvas().apply { + try { + assertThat(Size(width, height)) + .isEqualTo(ViewfinderTestParams.Default.sourceResolution) + } finally { + surface.unlockCanvasAndPost(this) + } + } + } + } + @Test fun verifySurfacesAreReleased_surfaceRequestReleased_thenComposableDestroyed(): Unit = runBlocking { - val surfaceDeferred = CompletableDeferred() - val sessionCompleteDeferred = CompletableDeferred() + testHideableWithSession { + val surface = awaitSurfaceSession().surface + assertThat(surface.isValid).isTrue() - val showViewfinder = mutableStateOf(true) + allowNextSessionCompletion() + rule.awaitIdle() + assertThat(surface.isValid).isTrue() - rule.setContent { - val showView by remember { showViewfinder } - TestViewfinder(showViewfinder = showView) { - onSurfaceSession { - surfaceDeferred.complete(surface) - sessionCompleteDeferred.await() - } - } + hideViewfinder() + rule.awaitIdle() + assertThat(surface.isValid).isFalse() } - - val surface = surfaceDeferred.await() - assertThat(surface.isValid).isTrue() - - sessionCompleteDeferred.complete(Unit) - rule.awaitIdle() - assertThat(surface.isValid).isTrue() - - showViewfinder.value = false - rule.awaitIdle() - assertThat(surface.isValid).isFalse() } - @Ignore("b/390508238: Surface release needs to be delayed by TextureView/SurfaceView ") @Test fun verifySurfacesAreReleased_composableDestroyed_thenSurfaceRequestReleased(): Unit = runBlocking { - val surfaceDeferred = CompletableDeferred() - val sessionCompleteDeferred = CompletableDeferred() + assume() + .withMessage( + "EXTERNAL implamentation on API < 29 is not yet able to delay surface destruction by the Viewfinder." + ) + .that( + Build.VERSION.SDK_INT >= 29 || implementationMode != ImplementationMode.EXTERNAL + ) + .isTrue() + testHideableWithSession { + val surface = awaitSurfaceSession().surface + assertThat(surface.isValid).isTrue() - val showViewfinder = mutableStateOf(true) + hideViewfinder() + rule.awaitIdle() + assertThat(surface.isValid).isTrue() - rule.setContent { - val showView by remember { showViewfinder } - TestViewfinder(showViewfinder = showView) { - onSurfaceSession { surfaceDeferred.complete(surface) } - } + allowNextSessionCompletion() + rule.awaitIdle() + assertThat(surface.isValid).isFalse() } - - val surface = surfaceDeferred.await() - assertThat(surface.isValid).isTrue() - - showViewfinder.value = false - rule.awaitIdle() - assertThat(surface.isValid).isTrue() - - sessionCompleteDeferred.complete(Unit) - rule.awaitIdle() - assertThat(surface.isValid).isFalse() } - @RequiresApi(Build.VERSION_CODES.M) // Needed for Surface.lockHardwareCanvas() - private suspend fun assertCanRetrieveSurface(implementationMode: ImplementationMode) { - val surfaceDeferred = CompletableDeferred() - val surfaceRequest = + @Test + fun movableContentOf_afterMove_validSurfaceIsAvailable(): Unit = runBlocking { + testMovableWithSession { + val surfaceSession = awaitSurfaceSession() + assertThat(surfaceSession.surface.isValid).isTrue() + + moveViewfinder() + rule.awaitIdle() + + when (implementationMode) { + ImplementationMode.EMBEDDED -> assertThat(surfaceSession.surface.isValid).isTrue() + ImplementationMode.EXTERNAL -> + if (Build.VERSION.SDK_INT >= 29) { + assertThat(surfaceSession.surface.isValid).isTrue() + } else { + // A new surface would need to be created on API 28 and lower, wait for + // the new surface session + allowNextSessionCompletion() + + val newSurfaceSession = + withTimeoutOrNull(5.seconds) { + awaitSurfaceSession { it !== surfaceSession } + } + assertThat(newSurfaceSession).isNotNull() + assertThat(newSurfaceSession!!.surface.isValid).isTrue() + } + } + } + } + + private interface SessionTestScope { + + suspend fun awaitSurfaceSession( + predicate: ((ViewfinderSurfaceSessionScope) -> Boolean)? = null + ): ViewfinderSurfaceSessionScope + + fun allowNextSessionCompletion() + } + + private interface HideableSessionTestScope : SessionTestScope { + fun hideViewfinder() + } + + private interface MovableSessionTestScope : SessionTestScope { + fun moveViewfinder() + } + + private suspend funtestWithSessionInternal( + scopeProvider: (SessionTestScope) -> T, + composeContent: @Composable (onInit: ViewfinderInitScope.() -> Unit) -> Unit, + block: suspend T.() -> Unit, + ) { + val surfaceSessionFlow = MutableStateFlow(null) + val sessionCompleteCount = MutableStateFlow(0) + + var numSessions = 0 + val onInit: ViewfinderInitScope.() -> Unit = { + onSurfaceSession { + numSessions++ + surfaceSessionFlow.value = this@onSurfaceSession + withContext(NonCancellable) { sessionCompleteCount.first { it >= numSessions } } + } + } + + rule.setContent { composeContent(onInit) } + + val baseSessionTestScope = BaseSessionTestScope(surfaceSessionFlow, sessionCompleteCount) + val specificSessionTestScope = scopeProvider(baseSessionTestScope) + + try { + block.invoke(specificSessionTestScope) + } finally { + sessionCompleteCount.value = Int.MAX_VALUE + } + } + + private suspend fun testMovableWithSession(block: suspend MovableSessionTestScope.() -> Unit) { + val moveViewfinderState = mutableStateOf(false) + + testWithSessionInternal( + scopeProvider = { baseScope -> + object : SessionTestScope by baseScope, MovableSessionTestScope { + override fun moveViewfinder() { + moveViewfinderState.value = true + } + } + }, + composeContent = { onInit -> + var moveView by remember { moveViewfinderState } + val content = remember { movableContentOf { TestViewfinder(onInit = onInit) } } + + Column { + if (moveView) { + content() + } else { + content() + } + } + }, + block = block, + ) + } + + private suspend fun testWithSession(block: suspend SessionTestScope.() -> Unit) { + testWithSessionInternal( + scopeProvider = { it }, + composeContent = { onInit -> TestViewfinder(onInit = onInit) }, + block = block, + ) + } + + private suspend fun testHideableWithSession( + block: suspend HideableSessionTestScope.() -> Unit + ) { + val showViewfinderState = mutableStateOf(true) + + testWithSessionInternal( + scopeProvider = { baseScope -> + object : SessionTestScope by baseScope, HideableSessionTestScope { + override fun hideViewfinder() { + showViewfinderState.value = false + } + } + }, + composeContent = { onInit -> + val showView by remember { showViewfinderState } + if (showView) { + TestViewfinder(onInit = onInit) + } + }, + block = block, + ) + } + + class BaseSessionTestScope( + val surfaceFlow: StateFlow, + val sessionCompletionCount: MutableStateFlow, + ) : SessionTestScope { + override suspend fun awaitSurfaceSession( + predicate: ((ViewfinderSurfaceSessionScope) -> Boolean)? + ): ViewfinderSurfaceSessionScope { + return surfaceFlow.filterNotNull().run { + if (predicate != null) { + this.first(predicate) + } else { + this.first() + } + } + } + + override fun allowNextSessionCompletion() { + sessionCompletionCount.update { it + 1 } + } + } + + @Composable + fun TestViewfinder( + modifier: Modifier = Modifier.size(ViewfinderTestParams.Default.viewfinderSize), + transformationInfo: TransformationInfo = ViewfinderTestParams.Default.transformationInfo, + surfaceRequest: ViewfinderSurfaceRequest = remember { ViewfinderSurfaceRequest( width = ViewfinderTestParams.Default.sourceResolution.width, height = ViewfinderTestParams.Default.sourceResolution.height, implementationMode = implementationMode, ) - rule.setContent { - TestViewfinder(surfaceRequest = surfaceRequest) { - onSurfaceSession { surfaceDeferred.complete(surface) } - } - } - - val surface = surfaceDeferred.await() - surface.lockHardwareCanvas().apply { - try { - assertThat(Size(width, height)) - .isEqualTo(ViewfinderTestParams.Default.sourceResolution) - } finally { - surface.unlockCanvasAndPost(this) - } - } - } -} - -@Composable -fun TestViewfinder( - modifier: Modifier = Modifier.size(ViewfinderTestParams.Default.viewfinderSize), - showViewfinder: Boolean = true, - transformationInfo: TransformationInfo = ViewfinderTestParams.Default.transformationInfo, - surfaceRequest: ViewfinderSurfaceRequest = remember { - ViewfinderSurfaceRequest( - width = ViewfinderTestParams.Default.sourceResolution.width, - height = ViewfinderTestParams.Default.sourceResolution.height, - implementationMode = ImplementationMode.EXTERNAL, + }, + coordinateTransformer: MutableCoordinateTransformer? = null, + onInit: ViewfinderInitScope.() -> Unit, + ) { + Viewfinder( + modifier = modifier, + surfaceRequest = surfaceRequest, + transformationInfo = transformationInfo, + coordinateTransformer = coordinateTransformer, + onInit = onInit, ) - }, - coordinateTransformer: MutableCoordinateTransformer? = null, - onInit: ViewfinderInitScope.() -> Unit, -) { - Column { - if (showViewfinder) { - Viewfinder( - modifier = modifier, - surfaceRequest = surfaceRequest, - transformationInfo = transformationInfo, - coordinateTransformer = coordinateTransformer, - onInit = onInit, - ) - } } }
diff --git a/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/Viewfinder.kt b/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/Viewfinder.kt index d106aa3..9092747 100644 --- a/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/Viewfinder.kt +++ b/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/Viewfinder.kt
@@ -20,6 +20,9 @@ import android.util.Size import android.util.SizeF import android.view.Surface +import androidx.camera.viewfinder.compose.internal.ViewfinderEmbeddedExternalSurface +import androidx.camera.viewfinder.compose.internal.ViewfinderExternalSurface +import androidx.camera.viewfinder.compose.internal.ViewfinderExternalSurfaceScope import androidx.camera.viewfinder.core.ImplementationMode import androidx.camera.viewfinder.core.TransformationInfo import androidx.camera.viewfinder.core.TransformationInfo.Companion.DEFAULT @@ -31,9 +34,6 @@ import androidx.camera.viewfinder.core.impl.ScaleFactorF import androidx.camera.viewfinder.core.impl.Transformations import androidx.camera.viewfinder.core.impl.ViewfinderSurfaceSessionImpl -import androidx.compose.foundation.AndroidEmbeddedExternalSurface -import androidx.compose.foundation.AndroidExternalSurface -import androidx.compose.foundation.AndroidExternalSurfaceScope import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable @@ -62,9 +62,10 @@ * [ViewfinderSurfaceSessionScope] of the callback registered via * [ViewfinderInitScope.onSurfaceSession] in [onInit]. * - * This has two underlying implementations either using an [AndroidEmbeddedExternalSurface] for - * [ImplementationMode.EMBEDDED] or an [AndroidExternalSurface] for [ImplementationMode.EXTERNAL]. - * These can be set by the [ImplementationMode] argument in the [surfaceRequest] constructor. If + * This has two underlying implementations based on + * [androidx.compose.foundation.AndroidEmbeddedExternalSurface] for [ImplementationMode.EMBEDDED] or + * on [androidx.compose.foundation.AndroidExternalSurface] for [ImplementationMode.EXTERNAL]. These + * can be set by the [ImplementationMode] argument in the [surfaceRequest] constructor. If * `implementationMode` is `null`, [ImplementationMode.EXTERNAL] is chosen by default, switching to * [ImplementationMode.EMBEDDED] on API levels 24 and below, or on devices with known compatibility * issues with the `EXTERNAL` mode. @@ -119,18 +120,11 @@ // Register callback from onInit() onInit.invoke(viewfinderInitScope) - onSurface { newSurface, _, _ -> - val refCountedSurface = RefCounted{ it.release() } - refCountedSurface.initialize(newSurface) - - // TODO(b/390508238): Stop underlying View from releasing the Surface - // automatically. It should wait for the RefCount to get to 0. - newSurface.onDestroyed { refCountedSurface.release() } - - // TODO(b/322420176): Properly handle onSurfaceChanged() - + onSurface { viewfinderSurfaceHolder -> // Dispatch surface to registered onSurfaceSession callback - viewfinderInitScope.dispatchOnSurfaceSession(refCountedSurface) + viewfinderInitScope.dispatchOnSurfaceSession( + viewfinderSurfaceHolder.refCountedSurface + ) } } } @@ -146,7 +140,7 @@ coordinateTransformer: MutableCoordinateTransformer?, alignment: Alignment, contentScale: ContentScale, - onInit: AndroidExternalSurfaceScope.() -> Unit, + onInit: ViewfinderExternalSurfaceScope.() -> Unit, ) { val layoutDirection = LocalConfiguration.current.layoutDirection val surfaceModifier = @@ -195,7 +189,7 @@ when (implementationMode) { ImplementationMode.EXTERNAL -> { - AndroidExternalSurface(modifier = surfaceModifier, onInit = onInit) + ViewfinderExternalSurface(modifier = surfaceModifier, onInit = onInit) } ImplementationMode.EMBEDDED -> { val displayRotationDegrees = @@ -218,7 +212,7 @@ ) } - AndroidEmbeddedExternalSurface( + ViewfinderEmbeddedExternalSurface( modifier = surfaceModifier, transform = correctionMatrix, onInit = onInit,
diff --git a/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/internal/BaseViewfinderExternalSurfaceState.kt b/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/internal/BaseViewfinderExternalSurfaceState.kt new file mode 100644 index 0000000..a48a9c2 --- /dev/null +++ b/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/internal/BaseViewfinderExternalSurfaceState.kt
@@ -0,0 +1,67 @@ +/* + * Copyright 2025 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.camera.viewfinder.compose.internal + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** Base class for [ViewfinderExternalSurface] and [ViewfinderEmbeddedExternalSurface] state. */ +internal abstract class BaseViewfinderExternalSurfaceState(val scope: CoroutineScope) : + ViewfinderExternalSurfaceScope { + + private var onSurface: + (suspend ViewfinderSurfaceCoroutineScope.( + viewfinderSurfaceHolder: ViewfinderSurfaceHolder + ) -> Unit)? = + null + + private var job: Job? = null + + override fun onSurface( + onSurface: + suspend ViewfinderSurfaceCoroutineScope.( + viewfinderSurfaceHolder: ViewfinderSurfaceHolder + ) -> Unit + ) { + this.onSurface = onSurface + } + + /** + * Dispatch a surface creation event by launching a new coroutine in [scope]. Any previous job + * from a previous surface creation dispatch is cancelled. + */ + fun dispatchSurfaceCreated(holder: ViewfinderSurfaceHolder) { + if (onSurface != null) { + job = + scope.launch(start = CoroutineStart.UNDISPATCHED) { + job?.apply { + cancel("Surface replaced") + join() + } + if (isActive) { + val receiver = + object : ViewfinderSurfaceCoroutineScope, CoroutineScope by this {} + onSurface?.invoke(receiver, holder) + } + } + } + } +}
diff --git a/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/internal/ViewfinderEmbeddedExternalSurface.kt b/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/internal/ViewfinderEmbeddedExternalSurface.kt new file mode 100644 index 0000000..bc43f804 --- /dev/null +++ b/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/internal/ViewfinderEmbeddedExternalSurface.kt
@@ -0,0 +1,185 @@ +/* + * Copyright 2025 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.camera.viewfinder.compose.internal + +import android.graphics.SurfaceTexture +import android.util.Log +import android.view.Surface +import android.view.TextureView +import androidx.camera.viewfinder.core.impl.RefCounted +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Matrix +import androidx.compose.ui.graphics.setFrom +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.viewinterop.AndroidView +import kotlinx.coroutines.CoroutineScope + +private const val TAG = "VfEmbeddedSurface" + +private class ViewfinderEmbeddedExternalSurfaceHolder(private val surfaceTexture: SurfaceTexture) : + ViewfinderSurfaceHolder { + override val refCountedSurface: RefCounted= RefCounted { + it.release() + surfaceTexture.release() + } + + var isDetached = false + private set + + init { + refCountedSurface.initialize(Surface(surfaceTexture)) + } + + override fun detach() { + if (!isDetached) { + refCountedSurface.release() + isDetached = true + } + } + + fun tryAttach(textureView: TextureView) { + if (isDetached) { + refCountedSurface.acquire()?.let { + textureView.setSurfaceTexture(surfaceTexture) + Log.d(TAG, "Reattached $surfaceTexture to $textureView") + isDetached = false + } + ?: run { + Log.d( + TAG, + "Unable to reattach $surfaceTexture to $textureView. Already released.", + ) + } + } else { + Log.d(TAG, "Unable to reattach $surfaceTexture to $textureView. Still attached.") + } + } +} + +private class ViewfinderEmbeddedExternalSurfaceState(scope: CoroutineScope) : + BaseViewfinderExternalSurfaceState(scope), TextureView.SurfaceTextureListener { + + var surfaceSize = IntSize.Zero + val matrix = android.graphics.Matrix() + + lateinit var viewfinderSurfaceHolder: ViewfinderEmbeddedExternalSurfaceHolder + + override fun onSurfaceTextureAvailable( + surfaceTexture: SurfaceTexture, + width: Int, + height: Int, + ) { + viewfinderSurfaceHolder = ViewfinderEmbeddedExternalSurfaceHolder(surfaceTexture) + + if (surfaceSize != IntSize.Zero) { + surfaceTexture.setDefaultBufferSize(surfaceSize.width, surfaceSize.height) + } + + dispatchSurfaceCreated(viewfinderSurfaceHolder) + } + + override fun onSurfaceTextureSizeChanged( + surfaceTexture: SurfaceTexture, + width: Int, + height: Int, + ) { + if (surfaceSize != IntSize.Zero) { + surfaceTexture.setDefaultBufferSize(surfaceSize.width, surfaceSize.height) + } + } + + override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean { + viewfinderSurfaceHolder.detach() + // If the composable hasn't yet been disposed, this surface could be reattached, so we won't + // stop the surface job here. + + // Do not release the SurfaceTexture. It will be released by the refCountedSurface when + // the ref count reaches zero. + return false + } + + override fun onSurfaceTextureUpdated(surface: SurfaceTexture) { + // onSurfaceTextureUpdated is called when the content of the SurfaceTexture + // has changed, which is not relevant to us since we are the producer here + } + + fun tryReattachViewfinderSurfaceHolder(textureView: TextureView) { + if (::viewfinderSurfaceHolder.isInitialized) { + viewfinderSurfaceHolder.tryAttach(textureView) + } + } +} + +@Composable +private fun rememberViewfinderEmbeddedExternalSurfaceState(): + ViewfinderEmbeddedExternalSurfaceState { + val scope = rememberCoroutineScope() + return remember { ViewfinderEmbeddedExternalSurfaceState(scope) } +} + +/** + * This is a modified version of the [androidx.compose.foundation.AndroidEmbeddedExternalSurface] + * composable. + * + * It has been adapted to: + * - Remove functionality not required by the Viewfinder, such as direct callbacks for + * `onSurfaceCreated` and `onSurfaceDestroyed` with raw [Surface] objects. + * - Add specific functionality to support the Viewfinder's API guarantees. Notably, it ensures that + * underlying surface resources are not released prematurely, but rather managed in coordination + * with the [androidx.camera.viewfinder.core.ViewfinderSurfaceSessionScope] to guarantee the + * surface is valid until the session scope is completed. + */ +@Composable +internal fun ViewfinderEmbeddedExternalSurface( + modifier: Modifier = Modifier, + isOpaque: Boolean = true, + surfaceSize: IntSize = IntSize.Zero, + transform: Matrix? = null, + onInit: ViewfinderExternalSurfaceScope.() -> Unit, +) { + val state = rememberViewfinderEmbeddedExternalSurfaceState() + + AndroidView( + factory = { + object : TextureView(it) { + override fun onAttachedToWindow() { + super.onAttachedToWindow() + state.tryReattachViewfinderSurfaceHolder(this) + } + } + }, + modifier = modifier, + onReset = {}, + update = { view -> + if (surfaceSize != IntSize.Zero) { + view.surfaceTexture?.setDefaultBufferSize(surfaceSize.width, surfaceSize.height) + } + state.surfaceSize = surfaceSize + if (view.surfaceTextureListener !== state) { + state.onInit() + view.surfaceTextureListener = state + } + view.isOpaque = isOpaque + // If transform is null, we'll call setTransform(null) which sets the + // identity transform on the TextureView + view.setTransform(transform?.let { state.matrix.apply { setFrom(transform) } }) + }, + ) +}
diff --git a/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/internal/ViewfinderExternalSurface.kt b/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/internal/ViewfinderExternalSurface.kt new file mode 100644 index 0000000..0a62ec6 --- /dev/null +++ b/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/internal/ViewfinderExternalSurface.kt
@@ -0,0 +1,193 @@ +/* + * Copyright 2025 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.camera.viewfinder.compose.internal + +import android.graphics.PixelFormat +import android.util.Log +import android.view.Surface +import android.view.SurfaceHolder +import android.view.SurfaceView +import androidx.camera.viewfinder.core.impl.RefCounted +import androidx.camera.viewfinder.core.impl.SurfaceControlCompat +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.viewinterop.AndroidView +import kotlinx.coroutines.CoroutineScope + +private const val TAG = "VfExternalSurface" + +private class ViewfinderExternalSurfaceHolder( + initialSurface: Surface, + width: Int, + height: Int, + initialParent: SurfaceControlCompat, +) : ViewfinderSurfaceHolder { + private val surfaceControl: SurfaceControlCompat = + SurfaceControlCompat.create( + initialParent, + width, + height, + "ViewfinderExternalSurfaceHolder-${hashCode()}", + ) + + override val refCountedSurface: RefCounted+ + var isDetached = false + private set + + init { + val surface = surfaceControl.newSurface() ?: initialSurface + refCountedSurface = RefCounted { + surfaceControl.detach() + // Only release the surface if it's not the parent surface. i.e., it's a new surface + // we've created from SurfaceControl. + // On some API levels, SurfaceControlCompat is a no-op wrapper, and we don't have + // control over the surface lifecycle and should leave it to the SurfaceView. + if (surface != initialSurface) { + surface.release() + } + } + refCountedSurface.initialize(surface) + } + + override fun detach() { + if (!isDetached) { + surfaceControl.detach() + // Release for refCountedSurface.initialize() + refCountedSurface.release() + isDetached = true + } + } + + fun tryAttach(parent: SurfaceControlCompat): Boolean { + check(isDetached) { "tryAttach() can only be called when detached" } + return refCountedSurface.acquire()?.let { + if (surfaceControl.reparent(parent)) { + Log.d(TAG, "Reattached $it to $parent") + isDetached = false + true + } else { + // In this else-condition, it's likely the API level doesn't support SurfaceControl + // Release the refcount we just acquired. + Log.d(TAG, "Unable to attach $it to $parent") + refCountedSurface.release() + false + } + } == true + } +} + +/** Implementation of [BaseViewfinderExternalSurfaceState] for [ViewfinderExternalSurface]. */ +private class ViewfinderExternalSurfaceState(scope: CoroutineScope) : + BaseViewfinderExternalSurfaceState(scope), SurfaceHolder.Callback { + + var lastWidth = -1 + var lastHeight = -1 + lateinit var surfaceView: SurfaceView + lateinit var viewfinderSurfaceHolder: ViewfinderExternalSurfaceHolder + + fun initInternal(surfaceView: SurfaceView) { + this.surfaceView = surfaceView + } + + override fun surfaceCreated(holder: SurfaceHolder) { + val frame = holder.surfaceFrame + lastWidth = frame.width() + lastHeight = frame.height() + + val parent = SurfaceControlCompat.wrap(surfaceView) + if ( + !::viewfinderSurfaceHolder.isInitialized || !viewfinderSurfaceHolder.tryAttach(parent) + ) { + viewfinderSurfaceHolder = + ViewfinderExternalSurfaceHolder(holder.surface, lastWidth, lastHeight, parent) + + dispatchSurfaceCreated(viewfinderSurfaceHolder) + } + } + + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + lastWidth = width + lastHeight = height + } + + override fun surfaceDestroyed(holder: SurfaceHolder) { + viewfinderSurfaceHolder.detach() + } +} + +@Composable +private fun rememberViewfinderExternalSurfaceState(): ViewfinderExternalSurfaceState { + val scope = rememberCoroutineScope() + return remember { ViewfinderExternalSurfaceState(scope) } +} + +/** + * This is a modified version of the [androidx.compose.foundation.AndroidExternalSurface] + * composable. + * + * It has been adapted to: + * - Remove functionality not required by the Viewfinder, such as direct callbacks for + * `onSurfaceCreated` and `onSurfaceDestroyed` with raw [Surface] objects. + * - Remove ZOrder API as it isn't used by the Viewfinder. + * - Add specific functionality to support the Viewfinder's API guarantees. Notably, it ensures that + * underlying surface resources are not released prematurely, but rather managed in coordination + * with the [androidx.camera.viewfinder.core.ViewfinderSurfaceSessionScope] to guarantee the + * surface is valid until the session scope is completed (on supported API levels). + */ +@Composable +internal fun ViewfinderExternalSurface( + modifier: Modifier = Modifier, + isOpaque: Boolean = true, + surfaceSize: IntSize = IntSize.Zero, + isSecure: Boolean = false, + onInit: ViewfinderExternalSurfaceScope.() -> Unit, +) { + val state = rememberViewfinderExternalSurfaceState() + + AndroidView( + factory = { context -> + SurfaceView(context).apply { + state.initInternal(this) + state.onInit() + holder.addCallback(state) + } + }, + modifier = modifier, + onReset = {}, + update = { view -> + if (surfaceSize != IntSize.Zero) { + view.holder.setFixedSize(surfaceSize.width, surfaceSize.height) + } else { + view.holder.setSizeFromLayout() + } + + view.holder.setFormat( + if (isOpaque) { + PixelFormat.OPAQUE + } else { + PixelFormat.TRANSLUCENT + } + ) + + view.setSecure(isSecure) + }, + ) +}
diff --git a/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/internal/ViewfinderSurfaceHolder.kt b/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/internal/ViewfinderSurfaceHolder.kt new file mode 100644 index 0000000..e60ef4c --- /dev/null +++ b/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/internal/ViewfinderSurfaceHolder.kt
@@ -0,0 +1,50 @@ +/* + * Copyright 2025 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.camera.viewfinder.compose.internal + +import android.view.Surface +import androidx.camera.viewfinder.core.impl.RefCounted + +/** + * Manages ownership and lifecycle of a [Surface] used by the Viewfinder. + * + * This interface facilitates the sharing and management of the underlying [Surface]. It provides + * access to a [RefCounted] [Surface], ensuring that the surface is released only when all clients + * have finished using it. + * + * It also allows the [Surface] to be explicitly detached from the view that originally created or + * provided it, which is important for proper resource cleanup and lifecycle management, especially + * when the view might be destroyed before the surface is no longer needed by other components. + */ +internal interface ViewfinderSurfaceHolder { + /** + * Provides the reference-counted [Surface]. + * + * Clients should increment the reference count when they start using the surface and decrement + * it when they are done. The surface will be released when its reference count drops to zero. + */ + val refCountedSurface: RefCounted+ + /** + * Detaches the [Surface] from its originating view or source. + * + * This typically involves operations like reparenting a [android.view.SurfaceControl] or + * signaling that the surface is no longer tied to the view's lifecycle. This method should be + * called to ensure proper cleanup when the view destroys the surface. + */ + fun detach() +}
diff --git a/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/internal/ViewfinderSurfaceScope.kt b/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/internal/ViewfinderSurfaceScope.kt new file mode 100644 index 0000000..8725971 --- /dev/null +++ b/camera/viewfinder/viewfinder-compose/src/main/java/androidx/camera/viewfinder/compose/internal/ViewfinderSurfaceScope.kt
@@ -0,0 +1,37 @@ +/* + * Copyright 2025 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.camera.viewfinder.compose.internal + +import kotlinx.coroutines.CoroutineScope + +internal interface ViewfinderSurfaceCoroutineScope : CoroutineScope + +internal interface ViewfinderExternalSurfaceScope { + /** + * Invokes [onSurface] when a new [ViewfinderSurfaceHolder] is created. The [onSurface] lambda + * is invoked on the main thread as part of a [ViewfinderSurfaceCoroutineScope] to provide a + * coroutine context. Always invoked on the main thread. + * + * @param onSurface Callback invoked when a new [ViewfinderSurfaceHolder] is created. + */ + fun onSurface( + onSurface: + suspend ViewfinderSurfaceCoroutineScope.( + viewfinderSurfaceHolder: ViewfinderSurfaceHolder + ) -> Unit + ) +}
diff --git a/camera/viewfinder/viewfinder-core/src/main/java/androidx/camera/viewfinder/core/impl/SurfaceControlCompat.kt b/camera/viewfinder/viewfinder-core/src/main/java/androidx/camera/viewfinder/core/impl/SurfaceControlCompat.kt index d437b554..6ef7e4a 100644 --- a/camera/viewfinder/viewfinder-core/src/main/java/androidx/camera/viewfinder/core/impl/SurfaceControlCompat.kt +++ b/camera/viewfinder/viewfinder-core/src/main/java/androidx/camera/viewfinder/core/impl/SurfaceControlCompat.kt
@@ -41,10 +41,23 @@ /** Reparent the surface control to null. */ fun detach() + /** + * Reparents this surface control to a new parent [SurfaceControlCompat]. + * + * On older API levels, this is a no-op and will return `false`. + * + * @param newParent The new parent [SurfaceControlCompat]. + * @return `true` if the reparent operation was performed, `false` otherwise. + */ + fun reparent(newParent: SurfaceControlCompat): Boolean + companion object { /** * Creates a SurfaceControl or a stub implementation. * + * This method creates a *new* SurfaceControl that is parented to the provided + * [SurfaceView]. + * * @param parent The SurfaceView to use as a parent. * @param format The format to use for the SurfaceControl (on newer APIs). * @param width The width to set on the SurfaceControl or the Surface. @@ -65,28 +78,99 @@ } else { SurfaceControlStub } + + /** + * Creates a SurfaceControl or a stub implementation. + * + * This method creates a *new* SurfaceControl that is parented to another + * [SurfaceControlCompat]. + * + * @param parent The SurfaceControlCompat to use as a parent. + * @param width The width to set on the SurfaceControl or the Surface. + * @param height The height to set on the SurfaceControl or the Surface. + * @param name The name of the SurfaceControl to create. + * @return a compat implementation of [SurfaceControlCompat]. + */ + @JvmStatic + fun create( + parent: SurfaceControlCompat, + width: Int, + height: Int, + name: String, + ): SurfaceControlCompat = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + SurfaceControlApi29Impl(parent, width, height, name) + } else { + SurfaceControlStub + } + + /** + * Wraps a SurfaceView's SurfaceControl if available. + * + * This method wraps an *existing* SurfaceControl obtained from the provided [SurfaceView]. + * It does not create a new SurfaceControl. + * + * @param surfaceView The SurfaceView to wrap the SurfaceControl from. + * @return a compat implementation of [SurfaceControlCompat] or a stub if the SurfaceView + * does not have a SurfaceControl. + */ + @JvmStatic + fun wrap(surfaceView: SurfaceView): SurfaceControlCompat = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + SurfaceControlApi29Impl(surfaceControl = surfaceView.surfaceControl) + } else { + SurfaceControlStub + } } /** API 29+ implementation of [SurfaceControlCompat]. */ @RequiresApi(Build.VERSION_CODES.Q) private class SurfaceControlApi29Impl( - parent: SurfaceView, - format: Int, - width: Int, - height: Int, - name: String, + // Primary constructor now wraps an existing SurfaceControl + private val surfaceControl: SurfaceControl ) : SurfaceControlCompat { - private val surfaceControl: SurfaceControl = + // Secondary constructor for creating a new SurfaceControl with a parent SurfaceView + constructor( + parent: SurfaceView, + format: Int, + width: Int, + height: Int, + name: String, + ) : this( // Calls the primary constructor with the newly built SurfaceControl SurfaceControl.Builder() .setName(name) .setFormat(format) .setBufferSize(width, height) .setParent(parent.surfaceControl) .build() + ) { + initializeNewSurfaceControl() + } - init { + // Secondary constructor for creating a new SurfaceControl with a parent + // SurfaceControlCompat + constructor( + parent: SurfaceControlCompat, + width: Int, + height: Int, + name: String, + ) : this( // Calls the primary constructor with the newly built SurfaceControl + SurfaceControl.Builder() + .setName(name) + .setBufferSize(width, height) + .setParent((parent as SurfaceControlApi29Impl).surfaceControl) + .build() + ) { + // Call the common initialization logic here + initializeNewSurfaceControl() + } + + /** + * Contains initialization logic that should only run when a new SurfaceControl is created. + */ + private fun initializeNewSurfaceControl() { SurfaceControl.Transaction().use { transaction -> - transaction.setVisibility(surfaceControl, true).apply() + transaction.setVisibility(this.surfaceControl, true).apply() } } @@ -109,6 +193,21 @@ transaction.reparent(surfaceControl, null).apply() } } + + override fun reparent(newParent: SurfaceControlCompat): Boolean { + if (surfaceControl.isValid) { + SurfaceControl.Transaction().use { transaction -> + transaction + .reparent( + surfaceControl, + (newParent as SurfaceControlApi29Impl).surfaceControl, + ) + .apply() + } + return true + } + return false + } } /** Stub implementation of [SurfaceControlCompat] for older APIs. */ @@ -126,5 +225,7 @@ override fun detach() { // No-op for older APIs } + + override fun reparent(newParent: SurfaceControlCompat) = false // No-op for older APIs } }
diff --git a/compose/runtime/runtime-annotation/bcv/native/current.txt b/compose/runtime/runtime-annotation/bcv/native/current.txt index 761247d..77e0b8b 100644 --- a/compose/runtime/runtime-annotation/bcv/native/current.txt +++ b/compose/runtime/runtime-annotation/bcv/native/current.txt
@@ -1,5 +1,5 @@ // Klib ABI Dump -// Targets: [iosArm64, iosSimulatorArm64, iosX64, linuxArm64, linuxX64, macosArm64, macosX64, tvosArm64, tvosSimulatorArm64, tvosX64, watchosArm32, watchosArm64, watchosDeviceArm64, watchosSimulatorArm64, watchosX64] +// Targets: [iosArm64, iosSimulatorArm64, iosX64, linuxArm64, linuxX64, macosArm64, macosX64, mingwX64, tvosArm64, tvosSimulatorArm64, tvosX64, watchosArm32, watchosArm64, watchosDeviceArm64, watchosSimulatorArm64, watchosX64] // Rendering settings: // - Signature version: 2 // - Show manifest properties: true
diff --git a/compose/runtime/runtime-annotation/build.gradle b/compose/runtime/runtime-annotation/build.gradle index cef7792..026f4bf 100644 --- a/compose/runtime/runtime-annotation/build.gradle +++ b/compose/runtime/runtime-annotation/build.gradle
@@ -36,9 +36,11 @@ jvm() mac() linux() + mingwX64() ios() watchos() tvos() + js() wasmJs() defaultPlatform(PlatformIdentifier.JVM) @@ -50,12 +52,21 @@ } } + jsMain { + dependsOn(commonMain) + dependencies { + // For KotlinWasm/Js, versions of toolchain and stdlib need to be the same (2.1): + // https://youtrack.jetbrains.com/issue/KT-71032 + implementation(libs.kotlinStdlibJs) + } + } + wasmJsMain { dependsOn(commonMain) dependencies { // For KotlinWasm/Js, versions of toolchain and stdlib need to be the same (2.1): // https://youtrack.jetbrains.com/issue/KT-71032 - api(libs.kotlinStdlibWasm) + implementation(libs.kotlinStdlibWasm) } } }
diff --git a/compose/runtime/runtime-test-utils/build.gradle b/compose/runtime/runtime-test-utils/build.gradle index 7a6c022..5cda752 100644 --- a/compose/runtime/runtime-test-utils/build.gradle +++ b/compose/runtime/runtime-test-utils/build.gradle
@@ -13,8 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import androidx.build.SoftwareType + import androidx.build.PlatformIdentifier +import androidx.build.SoftwareType +import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType +import org.jetbrains.kotlin.konan.target.Family plugins { id("AndroidXPlugin") @@ -25,8 +28,14 @@ androidLibrary { namespace = "androidx.compose.runtime.testutils" } - jvmStubs() - linuxX64Stubs() + desktop() + mingwX64() + linux() + mac() + ios() + tvos() + watchos() + js() wasmJs() defaultPlatform(PlatformIdentifier.ANDROID) @@ -36,7 +45,7 @@ dependencies { implementation(libs.kotlinStdlibCommon) implementation(project(":compose:runtime:runtime")) - implementation kotlin("test") + implementation(libs.kotlinTest) implementation(libs.kotlinCoroutinesTest) implementation(libs.kotlinReflect) } @@ -44,31 +53,65 @@ jvmMain { dependsOn(commonMain) - dependencies { - } } androidMain { dependsOn(jvmMain) + } + + desktopMain { + dependsOn(jvmMain) + } + + nativeMain { + dependsOn(commonMain) + } + + unixMain { + dependsOn(nativeMain) + } + + darwinMain { + dependsOn(unixMain) + } + + linuxMain { + dependsOn(unixMain) + } + + webMain { + dependsOn(commonMain) + } + + jsMain { + dependsOn(webMain) dependencies { + // For KotlinWasm/Js, versions of toolchain and stdlib need to be the same (2.1): + // https://youtrack.jetbrains.com/issue/KT-71032 + implementation(libs.kotlinStdlibJs) } } - commonStubsMain { - dependsOn(commonMain) - } - jvmStubsMain { - dependsOn(jvmMain) - } - linuxx64StubsMain { - dependsOn(commonStubsMain) - } wasmJsMain { - dependsOn(commonStubsMain) + dependsOn(webMain) dependencies { + // For KotlinWasm/Js, versions of toolchain and stdlib need to be the same (2.1): + // https://youtrack.jetbrains.com/issue/KT-71032 implementation(libs.kotlinStdlibWasm) } } + + targets.configureEach { target -> + if (target.platformType == KotlinPlatformType.native) { + if (target.konanTarget.family.appleFamily) { + target.compilations["main"].defaultSourceSet.dependsOn(darwinMain) + } else if (target.konanTarget.family == Family.LINUX) { + target.compilations["main"].defaultSourceSet.dependsOn(linuxMain) + } else { + target.compilations["main"].defaultSourceSet.dependsOn(nativeMain) + } + } + } } }
diff --git a/compose/runtime/runtime-test-utils/src/commonMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.kt b/compose/runtime/runtime-test-utils/src/commonMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.kt index 0337a1e..2f27586 100644 --- a/compose/runtime/runtime-test-utils/src/commonMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.kt +++ b/compose/runtime/runtime-test-utils/src/commonMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.kt
@@ -13,10 +13,48 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +@file:OptIn(ExperimentalContracts::class) package androidx.compose.runtime.mock +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract + +/** + * A [SynchronizedObject] provides a mechanism for thread coordination. Instances of this class are + * used within [synchronized] functions to establish mutual exclusion, guaranteeing that only one + * thread accesses a protected resource or code block at a time. + */ internal expect class SynchronizedObject() -@PublishedApi -internal expect inline funsynchronized(lock: SynchronizedObject, block: () -> R): R +/** + * Executes the given function [action] while holding the monitor of the given object [lock]. + * + * The implementation is platform specific: + * - JVM: implemented via `synchronized`, `ReentrantLock` is avoided for performance reasons. + * - Native: implemented via POSIX mutex with `PTHREAD_MUTEX_RECURSIVE` flag. + */ +@Suppress("LEAKED_IN_PLACE_LAMBDA", "WRONG_INVOCATION_KIND") // KT-29963 +internal inline funsynchronized(lock: SynchronizedObject, crossinline action: () -> T): T { + contract { callsInPlace(action, InvocationKind.EXACTLY_ONCE) } + return synchronizedImpl(lock, action) +} + +/** + * Executes the given function [action] while holding the monitor of the given object [lock]. + * + * The implementation is platform specific: + * - JVM: implemented via `synchronized`, `ReentrantLock` is avoided for performance reasons. + * - Native: implemented via POSIX mutex with `PTHREAD_MUTEX_RECURSIVE` flag. + * + * **This is a private API and should not be used from general code.** This function exists + * primarily as a workaround for a Kotlin issue + * ([KT-29963](https://youtrack.jetbrains.com/issue/KT-29963)). + * + * You **MUST** use [synchronized] instead. + */ +internal expect inline funsynchronizedImpl( + lock: SynchronizedObject, + crossinline action: () -> T, +): T
diff --git a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt b/compose/runtime/runtime-test-utils/src/darwinMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.darwin.kt similarity index 82% copy from compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt copy to compose/runtime/runtime-test-utils/src/darwinMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.darwin.kt index 3bf4637..ceba43f 100644 --- a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt +++ b/compose/runtime/runtime-test-utils/src/darwinMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.darwin.kt
@@ -14,6 +14,6 @@ * limitations under the License. */ -package kotlinx.test +package androidx.compose.runtime.mock -actual typealias IgnoreJsTarget = kotlin.test.Ignore +internal actual val PTHREAD_MUTEX_RECURSIVE: Int = platform.posix.PTHREAD_MUTEX_RECURSIVE
diff --git a/compose/runtime/runtime-test-utils/src/jvmMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.jvm.kt b/compose/runtime/runtime-test-utils/src/jvmMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.jvm.kt index e5733da..a5c1c26 100644 --- a/compose/runtime/runtime-test-utils/src/jvmMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.jvm.kt +++ b/compose/runtime/runtime-test-utils/src/jvmMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.jvm.kt
@@ -14,13 +14,11 @@ * limitations under the License. */ -@file:JvmName("ActualJvm_jvmKt") -@file:JvmMultifileClass - package androidx.compose.runtime.mock internal actual typealias SynchronizedObject = Any -@PublishedApi -internal actual inline funsynchronized(lock: SynchronizedObject, block: () -> R): R = - kotlin.synchronized(lock, block) +internal actual inline funsynchronizedImpl( + lock: SynchronizedObject, + crossinline action: () -> T, +): T = kotlin.synchronized(lock, action)
diff --git a/compose/runtime/runtime-test-utils/src/linuxMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.linux.kt b/compose/runtime/runtime-test-utils/src/linuxMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.linux.kt new file mode 100644 index 0000000..9c27da4 --- /dev/null +++ b/compose/runtime/runtime-test-utils/src/linuxMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.linux.kt
@@ -0,0 +1,35 @@ +/* + * Copyright 2024 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. + */ + +/* + * Copyright 2024 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.runtime.mock + +internal actual val PTHREAD_MUTEX_RECURSIVE: Int = platform.posix.PTHREAD_MUTEX_RECURSIVE.toInt()
diff --git a/compose/runtime/runtime-test-utils/src/mingwX64Main/kotlin/androidx/compose/runtime/mock/SynchronizedObject.mingwX64.kt b/compose/runtime/runtime-test-utils/src/mingwX64Main/kotlin/androidx/compose/runtime/mock/SynchronizedObject.mingwX64.kt new file mode 100644 index 0000000..4f094ed --- /dev/null +++ b/compose/runtime/runtime-test-utils/src/mingwX64Main/kotlin/androidx/compose/runtime/mock/SynchronizedObject.mingwX64.kt
@@ -0,0 +1,56 @@ +/* + * Copyright 2025 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.runtime.mock + +import kotlinx.cinterop.Arena +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.alloc +import kotlinx.cinterop.ptr +import platform.posix.pthread_mutex_destroy +import platform.posix.pthread_mutex_init +import platform.posix.pthread_mutex_lock +import platform.posix.pthread_mutex_tVar +import platform.posix.pthread_mutex_unlock +import platform.posix.pthread_mutexattr_destroy +import platform.posix.pthread_mutexattr_init +import platform.posix.pthread_mutexattr_settype +import platform.posix.pthread_mutexattr_tVar + +internal actual val PTHREAD_MUTEX_RECURSIVE: Int = platform.posix.PTHREAD_MUTEX_RECURSIVE + +@OptIn(ExperimentalForeignApi::class) +internal actual class SynchronizedObjectImpl { + private val arena = Arena() + private val attr: pthread_mutexattr_tVar = arena.alloc() + private val mutex: pthread_mutex_tVar = arena.alloc() + + init { + pthread_mutexattr_init(attr.ptr) + pthread_mutexattr_settype(attr.ptr, PTHREAD_MUTEX_RECURSIVE) + pthread_mutex_init(mutex.ptr, attr.ptr) + } + + internal actual fun lock(): Int = pthread_mutex_lock(mutex.ptr) + + internal actual fun unlock(): Int = pthread_mutex_unlock(mutex.ptr) + + internal actual fun dispose() { + pthread_mutex_destroy(mutex.ptr) + pthread_mutexattr_destroy(attr.ptr) + arena.clear() + } +}
diff --git a/compose/runtime/runtime-test-utils/src/nativeMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.native.kt b/compose/runtime/runtime-test-utils/src/nativeMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.native.kt new file mode 100644 index 0000000..c0471e6 --- /dev/null +++ b/compose/runtime/runtime-test-utils/src/nativeMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.native.kt
@@ -0,0 +1,67 @@ +/* + * Copyright 2024 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.runtime.mock + +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract +import kotlin.experimental.ExperimentalNativeApi +import kotlin.native.ref.createCleaner + +/** + * Wrapper for platform.posix.PTHREAD_MUTEX_RECURSIVE which is represented as kotlin.Int on darwin + * platforms and kotlin.UInt on linuxX64 See: https://youtrack.jetbrains.com/issue/KT-41509 + */ +internal expect val PTHREAD_MUTEX_RECURSIVE: Int + +internal expect class SynchronizedObjectImpl() { + internal fun lock(): Int + + internal fun unlock(): Int + + internal fun dispose() +} + +internal actual class SynchronizedObject actual constructor() { + private val impl = SynchronizedObjectImpl() + + @Suppress("unused") // The returned Cleaner must be assigned to a property + @OptIn(ExperimentalNativeApi::class) + private val cleaner = createCleaner(impl, SynchronizedObjectImpl::dispose) + + fun lock() { + impl.lock() + } + + fun unlock() { + impl.unlock() + } +} + +@OptIn(ExperimentalContracts::class) +internal actual inline funsynchronizedImpl( + lock: SynchronizedObject, + crossinline action: () -> T, +): T { + contract { callsInPlace(action, InvocationKind.EXACTLY_ONCE) } + lock.lock() + return try { + action() + } finally { + lock.unlock() + } +}
diff --git a/compose/runtime/runtime-test-utils/src/unixMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.unix.kt b/compose/runtime/runtime-test-utils/src/unixMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.unix.kt new file mode 100644 index 0000000..abcf94b --- /dev/null +++ b/compose/runtime/runtime-test-utils/src/unixMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.unix.kt
@@ -0,0 +1,54 @@ +/* + * Copyright 2025 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.runtime.mock + +import kotlinx.cinterop.Arena +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.alloc +import kotlinx.cinterop.ptr +import platform.posix.pthread_mutex_destroy +import platform.posix.pthread_mutex_init +import platform.posix.pthread_mutex_lock +import platform.posix.pthread_mutex_t +import platform.posix.pthread_mutex_unlock +import platform.posix.pthread_mutexattr_destroy +import platform.posix.pthread_mutexattr_init +import platform.posix.pthread_mutexattr_settype +import platform.posix.pthread_mutexattr_t + +@OptIn(ExperimentalForeignApi::class) +internal actual class SynchronizedObjectImpl actual constructor() { + private val arena: Arena = Arena() + private val attr: pthread_mutexattr_t = arena.alloc() + private val mutex: pthread_mutex_t = arena.alloc() + + init { + pthread_mutexattr_init(attr.ptr) + pthread_mutexattr_settype(attr.ptr, PTHREAD_MUTEX_RECURSIVE) + pthread_mutex_init(mutex.ptr, attr.ptr) + } + + internal actual fun lock(): Int = pthread_mutex_lock(mutex.ptr) + + internal actual fun unlock(): Int = pthread_mutex_unlock(mutex.ptr) + + internal actual fun dispose() { + pthread_mutex_destroy(mutex.ptr) + pthread_mutexattr_destroy(attr.ptr) + arena.clear() + } +}
diff --git a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt b/compose/runtime/runtime-test-utils/src/webMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.web.kt similarity index 72% copy from compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt copy to compose/runtime/runtime-test-utils/src/webMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.web.kt index 3bf4637..62b5bc5 100644 --- a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt +++ b/compose/runtime/runtime-test-utils/src/webMain/kotlin/androidx/compose/runtime/mock/SynchronizedObject.web.kt
@@ -14,6 +14,11 @@ * limitations under the License. */ -package kotlinx.test +package androidx.compose.runtime.mock -actual typealias IgnoreJsTarget = kotlin.test.Ignore +internal actual class SynchronizedObject actual constructor() + +internal actual inline funsynchronizedImpl( + lock: SynchronizedObject, + crossinline action: () -> T, +): T = action()
diff --git a/compose/runtime/runtime/bcv/native/current.txt b/compose/runtime/runtime/bcv/native/current.txt index 7e0ace3..b15feee 100644 --- a/compose/runtime/runtime/bcv/native/current.txt +++ b/compose/runtime/runtime/bcv/native/current.txt
@@ -1,5 +1,5 @@ // Klib ABI Dump -// Targets: [linuxX64.linuxx64Stubs] +// Targets: [iosArm64, iosSimulatorArm64, iosX64, linuxArm64, linuxX64, macosArm64, macosX64, mingwX64, tvosArm64, tvosSimulatorArm64, tvosX64, watchosArm32, watchosArm64, watchosDeviceArm64, watchosSimulatorArm64, watchosX64] // Rendering settings: // - Signature version: 2 // - Show manifest properties: true @@ -712,6 +712,11 @@ final fun(): kotlin/Boolean // androidx.compose.runtime/ProvidedValue.canOverride. } +final class androidx.compose.runtime.platform/SynchronizedObject { // androidx.compose.runtime.platform/SynchronizedObject|null[0] + final fun lock() // androidx.compose.runtime.platform/SynchronizedObject.lock|lock(){}[0] + final fun unlock() // androidx.compose.runtime.platform/SynchronizedObject.unlock|unlock(){}[0] +} + final class androidx.compose.runtime.snapshots/SnapshotApplyConflictException : kotlin/Exception { // androidx.compose.runtime.snapshots/SnapshotApplyConflictException|null[0] constructor| (){}[0] (androidx.compose.runtime.snapshots/Snapshot) // androidx.compose.runtime.snapshots/SnapshotApplyConflictException. @@ -1273,8 +1278,6 @@ final fun (androidx.compose.runtime/State| (androidx.compose.runtime.snapshots.Snapshot){}[0] ).androidx.compose.runtime/asFloatState(): androidx.compose.runtime/FloatState // androidx.compose.runtime/asFloatState|[email protected] final fun (androidx.compose.runtime/State(){}[0] ).androidx.compose.runtime/asIntState(): androidx.compose.runtime/IntState // androidx.compose.runtime/asIntState|[email protected] final fun (androidx.compose.runtime/State(){}[0] ).androidx.compose.runtime/asLongState(): androidx.compose.runtime/LongState // androidx.compose.runtime/asLongState|[email protected] -final fun (kotlin/Long).androidx.compose.runtime.snapshots/toInt(): kotlin/Int // androidx.compose.runtime.snapshots/toInt|[email protected](){}[0] -final fun (kotlin/Long).androidx.compose.runtime.snapshots/toLong(): kotlin/Long // androidx.compose.runtime.snapshots/toLong|[email protected](){}[0] final fun <#A: #B, #B: kotlin/Any?> (kotlinx.coroutines.flow/Flow<#A>).androidx.compose.runtime/collectAsState(#B, kotlin.coroutines/CoroutineContext?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): androidx.compose.runtime/State<#B> // androidx.compose.runtime/collectAsState|[email protected]<0:0>(0:1;kotlin.coroutines.CoroutineContext?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){0§<0:1>;1§(){}[0] }[0] final fun <#A: androidx.compose.runtime.snapshots/StateRecord> (#A).androidx.compose.runtime.snapshots/readable(androidx.compose.runtime.snapshots/StateObject): #A // androidx.compose.runtime.snapshots/readable|readable@0:0(androidx.compose.runtime.snapshots.StateObject){0§}[0] final fun <#A: androidx.compose.runtime.snapshots/StateRecord> (#A).androidx.compose.runtime.snapshots/readable(androidx.compose.runtime.snapshots/StateObject, androidx.compose.runtime.snapshots/Snapshot): #A // androidx.compose.runtime.snapshots/readable|readable@0:0(androidx.compose.runtime.snapshots.StateObject;androidx.compose.runtime.snapshots.Snapshot){0§}[0] @@ -1447,6 +1450,7 @@ final fun androidx.compose.runtime.internal/androidx_compose_runtime_internal_WeakReference$stableprop_getter(): kotlin/Int // androidx.compose.runtime.internal/androidx_compose_runtime_internal_WeakReference$stableprop_getter|androidx_compose_runtime_internal_WeakReference$stableprop_getter(){}[0] final fun androidx.compose.runtime.internal/composableLambda(androidx.compose.runtime/Composer, kotlin/Int, kotlin/Boolean, kotlin/Any): androidx.compose.runtime.internal/ComposableLambda // androidx.compose.runtime.internal/composableLambda|composableLambda(androidx.compose.runtime.Composer;kotlin.Int;kotlin.Boolean;kotlin.Any){}[0] final fun androidx.compose.runtime.internal/composableLambdaInstance(kotlin/Int, kotlin/Boolean, kotlin/Any): androidx.compose.runtime.internal/ComposableLambda // androidx.compose.runtime.internal/composableLambdaInstance|composableLambdaInstance(kotlin.Int;kotlin.Boolean;kotlin.Any){}[0] +final fun androidx.compose.runtime.internal/identityHashCode(kotlin/Any?): kotlin/Int // androidx.compose.runtime.internal/identityHashCode|identityHashCode(kotlin.Any?){}[0] final fun androidx.compose.runtime.internal/illegalDecoyCallException(kotlin/String): kotlin/Nothing // androidx.compose.runtime.internal/illegalDecoyCallException|illegalDecoyCallException(kotlin.String){}[0] final fun androidx.compose.runtime.internal/rememberComposableLambda(kotlin/Int, kotlin/Boolean, kotlin/Any, androidx.compose.runtime/Composer?, kotlin/Int): androidx.compose.runtime.internal/ComposableLambda // androidx.compose.runtime.internal/rememberComposableLambda|rememberComposableLambda(kotlin.Int;kotlin.Boolean;kotlin.Any;androidx.compose.runtime.Composer?;kotlin.Int){}[0] final fun androidx.compose.runtime.platform/androidx_compose_runtime_platform_SynchronizedObject$stableprop_getter(): kotlin/Int // androidx.compose.runtime.platform/androidx_compose_runtime_platform_SynchronizedObject$stableprop_getter|androidx_compose_runtime_platform_SynchronizedObject$stableprop_getter(){}[0] @@ -1598,6 +1602,8 @@ final inline fun (androidx.compose.runtime/MutableFloatState).androidx.compose.runtime/setValue(kotlin/Any?, kotlin.reflect/KProperty<*>, kotlin/Float) // androidx.compose.runtime/setValue|[email protected](kotlin.Any?;kotlin.reflect.KProperty<*>;kotlin.Float){}[0] final inline fun (androidx.compose.runtime/MutableIntState).androidx.compose.runtime/setValue(kotlin/Any?, kotlin.reflect/KProperty<*>, kotlin/Int) // androidx.compose.runtime/setValue|[email protected](kotlin.Any?;kotlin.reflect.KProperty<*>;kotlin.Int){}[0] final inline fun (androidx.compose.runtime/MutableLongState).androidx.compose.runtime/setValue(kotlin/Any?, kotlin.reflect/KProperty<*>, kotlin/Long) // androidx.compose.runtime/setValue|[email protected](kotlin.Any?;kotlin.reflect.KProperty<*>;kotlin.Long){}[0] +final inline fun (kotlin/Long).androidx.compose.runtime.snapshots/toInt(): kotlin/Int // androidx.compose.runtime.snapshots/toInt|[email protected](){}[0] +final inline fun (kotlin/Long).androidx.compose.runtime.snapshots/toLong(): kotlin/Long // androidx.compose.runtime.snapshots/toLong|[email protected](){}[0] final inline fun (kotlin/Long).androidx.compose.runtime/toLong(): kotlin/Long // androidx.compose.runtime/toLong|[email protected](){}[0] final inline fun (kotlin/Long).androidx.compose.runtime/toString(kotlin/Int): kotlin/String // androidx.compose.runtime/toString|[email protected](kotlin.Int){}[0] final inline fun <#A: androidx.compose.runtime.snapshots/StateRecord, #B: kotlin/Any?> (#A).androidx.compose.runtime.snapshots/withCurrent(kotlin/Function1<#A, #B>): #B // androidx.compose.runtime.snapshots/withCurrent|withCurrent@0:0(kotlin.Function1<0:0,0:1>){0§;1§ }[0]
diff --git a/compose/runtime/runtime/build.gradle b/compose/runtime/runtime/build.gradle index f648be3..fcf63ff 100644 --- a/compose/runtime/runtime/build.gradle +++ b/compose/runtime/runtime/build.gradle
@@ -25,6 +25,8 @@ import androidx.build.SoftwareType import androidx.build.PlatformIdentifier import com.android.build.api.dsl.KotlinMultiplatformAndroidHostTestCompilation +import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType +import org.jetbrains.kotlin.konan.target.Family plugins { id("AndroidXPlugin") @@ -42,8 +44,14 @@ it.returnDefaultValues = true } } - jvmStubs(/* runTests */ true) - linuxX64Stubs() + desktop() + mingwX64() + linux() + mac() + ios() + tvos() + watchos() + js() wasmJs() defaultPlatform(PlatformIdentifier.ANDROID) @@ -123,41 +131,122 @@ dependsOn(commonMain) } - jvmStubsMain { + nonAndroidTest { + dependsOn(commonTest) + } + + desktopMain { dependsOn(jvmMain) dependsOn(nonAndroidMain) } - jvmStubsTest { + desktopTest { dependsOn(jvmTest) dependsOn(nonEmulatorJvmTest) + dependsOn(nonAndroidTest) } nonJvmMain { dependsOn(nonAndroidMain) + dependencies { + implementation(libs.atomicFu) + } } - linuxx64StubsMain { + nonJvmTest { + dependsOn(nonAndroidTest) + } + + nativeMain { + dependsOn(nonAndroidMain) dependsOn(nonJvmMain) } + nativeTest { + dependsOn(nonAndroidTest) + dependsOn(nonJvmTest) + dependsOn(nonEmulatorCommonTest) + } + webMain { dependsOn(nonJvmMain) } - wasmJsMain { + webTest { + dependsOn(nonJvmTest) + dependsOn(nonEmulatorCommonTest) + } + + jsMain { dependsOn(webMain) dependencies { - implementation("org.jetbrains.kotlinx:kotlinx-browser:0.1") + // For KotlinWasm/Js, versions of toolchain and stdlib need to be the same (2.1): + // https://youtrack.jetbrains.com/issue/KT-71032 + implementation(libs.kotlinStdlibJs) } } - webTest { - dependsOn(nonEmulatorCommonTest) + jsTest { + dependsOn(webTest) + dependencies { + implementation(libs.kotlinTestJs) + } + } + + wasmJsMain { + dependsOn(webMain) + dependencies { + // For KotlinWasm/Js, versions of toolchain and stdlib need to be the same (2.1): + // https://youtrack.jetbrains.com/issue/KT-71032 + implementation(libs.kotlinStdlibWasm) + implementation(libs.kotlinXw3c) + } } wasmJsTest { dependsOn(webTest) + dependencies { + implementation(libs.kotlinTestWasm) + } + } + + unixMain { + dependsOn(nativeMain) + } + + unixTest { + dependsOn(nativeTest) + } + + darwinMain { + dependsOn(unixMain) + } + + darwinTest { + dependsOn(unixTest) + } + + linuxMain { + dependsOn(unixMain) + } + + linuxTest { + dependsOn(unixTest) + } + + targets.configureEach { target -> + if (target.platformType == KotlinPlatformType.native) { + if (target.konanTarget.family.appleFamily) { + target.compilations["main"].defaultSourceSet.dependsOn(darwinMain) + target.compilations["test"].defaultSourceSet.dependsOn(darwinTest) + } else if (target.konanTarget.family == Family.LINUX) { + target.compilations["main"].defaultSourceSet.dependsOn(linuxMain) + target.compilations["test"].defaultSourceSet.dependsOn(linuxTest) + } else { + target.compilations["main"].defaultSourceSet.dependsOn(nativeMain) + target.compilations["test"].defaultSourceSet.dependsOn(nativeTest) + } + } } } }
diff --git a/compose/runtime/runtime/lint-baseline.xml b/compose/runtime/runtime/lint-baseline.xml index 3532a6b..ede2caa 100644 --- a/compose/runtime/runtime/lint-baseline.xml +++ b/compose/runtime/runtime/lint-baseline.xml
@@ -2,6 +2,15 @@+ id="NullAnnotationGroup" + message="Could not find associated group for annotation androidx.compose.runtime.InternalComposeApi, which is used in androidx.compose.runtime." + errorLine1="@InternalComposeApi" + errorLine2="~~~~~~~~~~~~~~~~~~~"> + + ++ file="src/desktopMain/kotlin/androidx/compose/runtime/OldIdentityHashCode.desktop.kt"/> + id="BanThreadSleep" message="Uses Thread.sleep()" errorLine1=" Thread.sleep(0)"
diff --git a/compose/runtime/runtime/src/androidUnitTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.kt b/compose/runtime/runtime/src/androidUnitTest/kotlin/kotlinx/test/IgnoreTargets.android.kt similarity index 100% rename from compose/runtime/runtime/src/androidUnitTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.kt rename to compose/runtime/runtime/src/androidUnitTest/kotlin/kotlinx/test/IgnoreTargets.android.kt
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/PausableComposition.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/PausableComposition.kt index eef6899..8dc0226 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/PausableComposition.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/PausableComposition.kt
@@ -485,14 +485,14 @@ } private class ComposePausableCompositionException( - val instances: ObjectList, - val reused: ObjectList, - val operations: IntList, - val lastOperation: Int, + private val instances: ObjectList, + private val reused: ObjectList, + private val operations: IntList, + private val lastOperation: Int, cause: Throwable?, ) : Exception(cause) { - fun operations(): Sequence= sequence { + private fun operationsSequence(): Sequence= sequence { var currentOperation = 0 var currentInstance = 0 var currentReused = 0 @@ -557,7 +557,7 @@ get() = """ |Exception while applying pausable composition. Last 10 operations: - |${operations().toList().takeLast(10).joinToString("\n")} + |${operationsSequence().toList().takeLast(10).joinToString("\n")} """ .trimMargin() }
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.kt index 584556d..b3a3b39 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.kt
@@ -83,3 +83,5 @@ } internal expect fun Int.toSnapshotId(): SnapshotId + +internal expect fun Long.toSnapshotId(): SnapshotId
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/tooling/ComposeStackTraceBuilder.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/tooling/ComposeStackTraceBuilder.kt index 92f7916..0e6c318 100644 --- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/tooling/ComposeStackTraceBuilder.kt +++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/tooling/ComposeStackTraceBuilder.kt
@@ -42,14 +42,14 @@ } internal abstract class ComposeStackTraceBuilder { - private val trace = mutableListOf() + private val _trace = mutableListOf() - fun trace(): List= trace + fun trace(): List= _trace private fun appendTraceFrame(groupSourceInformation: GroupSourceInformation, child: Any?) { val frame = extractTraceFrame(groupSourceInformation, child) if (frame != null) { - trace += frame + _trace += frame } }
diff --git a/compose/runtime/runtime/src/commonTest/kotlin/androidx/compose/runtime/TestUtils.kt b/compose/runtime/runtime/src/commonTest/kotlin/androidx/compose/runtime/TestUtils.kt new file mode 100644 index 0000000..9833ae7 --- /dev/null +++ b/compose/runtime/runtime/src/commonTest/kotlin/androidx/compose/runtime/TestUtils.kt
@@ -0,0 +1,69 @@ +/* + * Copyright 2023 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.runtime + +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.reflect.KClass +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope + +private const val DEFAULT_DISPATCH_TIMEOUT_MS = 60_000L + +internal fun runTest( + context: CoroutineContext = EmptyCoroutineContext, + dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS, + timeoutMs: Long? = null, + expected: KClass? = null, + testBody: suspend TestScope.() -> Unit, +): TestResult = + kotlinx.coroutines.test.runTest(context, timeout = dispatchTimeoutMs.milliseconds) { + val testScope = this + if (timeoutMs == null) { + runTestImpl(expected) { testBody() } + } else { + testWithTimeout(timeoutMs) { + testBody(testScope) + runTestImpl(expected) { testBody(testScope) } + } + } + } + +internal expect suspend fun testWithTimeout( + timeoutMs: Long, + block: suspend CoroutineScope.() -> Unit, +) + +private inline fun runTestImpl(expected: KClass? = null, block: () -> Unit) { + if (expected != null) { + var exception: Throwable? = null + try { + block() + } catch (e: Throwable) { + exception = e + } + assertTrue( + exception != null && expected.isInstance(exception), + "Expected $expected to be thrown", + ) + } else { + block() + } +}
diff --git a/compose/runtime/runtime/src/commonTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.kt b/compose/runtime/runtime/src/commonTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.kt deleted file mode 100644 index 1a25a11..0000000 --- a/compose/runtime/runtime/src/commonTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.kt +++ /dev/null
@@ -1,19 +0,0 @@ -/* - * Copyright 2023 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 kotlinx.test - -expect annotation class IgnoreAndroidUnitTestTarget()
diff --git a/compose/runtime/runtime/src/commonTest/kotlin/kotlinx/test/IgnoreJsTarget.kt b/compose/runtime/runtime/src/commonTest/kotlin/kotlinx/test/IgnoreTargets.kt similarity index 66% rename from compose/runtime/runtime/src/commonTest/kotlin/kotlinx/test/IgnoreJsTarget.kt rename to compose/runtime/runtime/src/commonTest/kotlin/kotlinx/test/IgnoreTargets.kt index 18bb059..b8c1e98 100644 --- a/compose/runtime/runtime/src/commonTest/kotlin/kotlinx/test/IgnoreJsTarget.kt +++ b/compose/runtime/runtime/src/commonTest/kotlin/kotlinx/test/IgnoreTargets.kt
@@ -13,7 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +@file:OptIn(ExperimentalMultiplatform::class) + package kotlinx.test -// TODO(b/409723535): Differentiate between WASM and JS targets. -expect annotation class IgnoreJsTarget() +@OptionalExpectation expect annotation class IgnoreAndroidUnitTestTarget() + +@OptionalExpectation expect annotation class IgnoreNativeTarget() + +@OptionalExpectation expect annotation class IgnoreWasmTarget() + +@OptionalExpectation expect annotation class IgnoreJsTarget()
diff --git a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt b/compose/runtime/runtime/src/darwinMain/kotlin/runtime/platform/Synchronization.darwin.kt similarity index 81% copy from compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt copy to compose/runtime/runtime/src/darwinMain/kotlin/runtime/platform/Synchronization.darwin.kt index 3bf4637..68eab97 100644 --- a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt +++ b/compose/runtime/runtime/src/darwinMain/kotlin/runtime/platform/Synchronization.darwin.kt
@@ -14,6 +14,6 @@ * limitations under the License. */ -package kotlinx.test +package androidx.compose.runtime.platform -actual typealias IgnoreJsTarget = kotlin.test.Ignore +internal actual val PTHREAD_MUTEX_ERRORCHECK: Int = platform.posix.PTHREAD_MUTEX_ERRORCHECK
diff --git a/compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/ActualDesktop.desktop.kt b/compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/ActualDesktop.desktop.kt new file mode 100644 index 0000000..b4bb612 --- /dev/null +++ b/compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/ActualDesktop.desktop.kt
@@ -0,0 +1,35 @@ +/* + * Copyright 2019 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.runtime + +import kotlinx.coroutines.delay + +@Deprecated( + "MonotonicFrameClocks are not globally applicable across platforms. " + + "Use an appropriate local clock." +) +actual val DefaultMonotonicFrameClock: MonotonicFrameClock + get() = SixtyFpsMonotonicFrameClock + +private object SixtyFpsMonotonicFrameClock : MonotonicFrameClock { + private const val fps = 60 + + override suspend funwithFrameNanos(onFrame: (Long) -> R): R { + delay(1000L / fps) + return onFrame(System.nanoTime()) + } +}
diff --git a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt b/compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/OldIdentityHashCode.desktop.kt similarity index 64% copy from compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt copy to compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/OldIdentityHashCode.desktop.kt index 3bf4637..c994dc2 100644 --- a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt +++ b/compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/OldIdentityHashCode.desktop.kt
@@ -14,6 +14,15 @@ * limitations under the License. */ -package kotlinx.test +@file:JvmName("ActualJvm_jvmKt") +@file:JvmMultifileClass -actual typealias IgnoreJsTarget = kotlin.test.Ignore +package androidx.compose.runtime + +@InternalComposeApi +@Deprecated( + level = DeprecationLevel.HIDDEN, + message = "Made internal. It wasn't supposed to be public", +) +fun identityHashCode(instance: Any?): Int = + androidx.compose.runtime.internal.identityHashCode(instance)
diff --git a/compose/runtime/runtime/src/androidMain/kotlin/androidx/compose/runtime/OldSynchronized.android.kt b/compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/OldSynchronization.desktop.kt similarity index 76% copy from compose/runtime/runtime/src/androidMain/kotlin/androidx/compose/runtime/OldSynchronized.android.kt copy to compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/OldSynchronization.desktop.kt index 26722cb1..c615f66 100644 --- a/compose/runtime/runtime/src/androidMain/kotlin/androidx/compose/runtime/OldSynchronized.android.kt +++ b/compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/OldSynchronization.desktop.kt
@@ -14,19 +14,17 @@ * limitations under the License. */ -@file:JvmName("ActualJvm_jvmKt") -@file:JvmMultifileClass +@file:JvmName("SynchronizationKt") package androidx.compose.runtime -import androidx.compose.runtime.platform.SynchronizedObject -import kotlin.DeprecationLevel.HIDDEN +internal class SynchronizedObject @PublishedApi @JvmName("synchronized") @Deprecated( - level = HIDDEN, + level = DeprecationLevel.HIDDEN, message = "not expected to be referenced directly as the old version had to be inlined", ) -internal inline funoldSynchronized(lock: SynchronizedObject, block: () -> R): R = +internal inline funoldSynchronized2(lock: SynchronizedObject, block: () -> R): R = androidx.compose.runtime.platform.synchronized(lock, block)
diff --git a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt b/compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/internal/Utils.desktop.kt similarity index 77% copy from compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt copy to compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/internal/Utils.desktop.kt index 3bf4637..934f60eb 100644 --- a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt +++ b/compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/internal/Utils.desktop.kt
@@ -14,6 +14,9 @@ * limitations under the License. */ -package kotlinx.test +package androidx.compose.runtime.internal -actual typealias IgnoreJsTarget = kotlin.test.Ignore +internal actual fun logError(message: String, e: Throwable) { + System.err.println(message) + e.printStackTrace(System.err) +}
diff --git a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt b/compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/platform/Synchronization.desktop.kt similarity index 60% copy from compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt copy to compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/platform/Synchronization.desktop.kt index 3bf4637..4aef7e6 100644 --- a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt +++ b/compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/platform/Synchronization.desktop.kt
@@ -14,6 +14,13 @@ * limitations under the License. */ -package kotlinx.test +package androidx.compose.runtime.platform -actual typealias IgnoreJsTarget = kotlin.test.Ignore +internal actual typealias SynchronizedObject = androidx.compose.runtime.SynchronizedObject + +@Suppress("NOTHING_TO_INLINE") +internal actual inline fun makeSynchronizedObject(ref: Any?) = SynchronizedObject() + +@PublishedApi +internal actual inline funsynchronized(lock: SynchronizedObject, block: () -> R): R = + kotlin.synchronized(lock, block)
diff --git a/compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/MonotonicFrameClock.web.kt b/compose/runtime/runtime/src/jsMain/kotlin/androidx/compose/runtime/MonotonicFrameClock.js.kt similarity index 100% rename from compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/MonotonicFrameClock.web.kt rename to compose/runtime/runtime/src/jsMain/kotlin/androidx/compose/runtime/MonotonicFrameClock.js.kt
diff --git a/compose/runtime/runtime/src/androidInstrumentedTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.kt b/compose/runtime/runtime/src/jsMain/kotlin/androidx/compose/runtime/OldIdentityHashCode.js.kt similarity index 63% copy from compose/runtime/runtime/src/androidInstrumentedTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.kt copy to compose/runtime/runtime/src/jsMain/kotlin/androidx/compose/runtime/OldIdentityHashCode.js.kt index 488cac6..78eadd1 100644 --- a/compose/runtime/runtime/src/androidInstrumentedTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.kt +++ b/compose/runtime/runtime/src/jsMain/kotlin/androidx/compose/runtime/OldIdentityHashCode.js.kt
@@ -1,5 +1,5 @@ /* - * Copyright 2023 The Android Open Source Project + * 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. @@ -14,8 +14,12 @@ * limitations under the License. */ -package kotlinx.test +package androidx.compose.runtime -annotation class NotIgnored - -actual typealias IgnoreAndroidUnitTestTarget = NotIgnored +@InternalComposeApi +@Deprecated( + level = DeprecationLevel.HIDDEN, + message = "Made internal. It wasn't supposed to be public", +) +fun identityHashCode(instance: Any?): Int = + androidx.compose.runtime.internal.identityHashCode(instance)
diff --git a/compose/runtime/runtime/src/jsMain/kotlin/androidx/compose/runtime/internal/ComposableLambda.js.kt b/compose/runtime/runtime/src/jsMain/kotlin/androidx/compose/runtime/internal/ComposableLambda.js.kt new file mode 100644 index 0000000..92e412b --- /dev/null +++ b/compose/runtime/runtime/src/jsMain/kotlin/androidx/compose/runtime/internal/ComposableLambda.js.kt
@@ -0,0 +1,278 @@ +/* + * 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. + */ + +@file:OptIn(InternalComposeApi::class) + +package androidx.compose.runtime.internal + +import androidx.compose.runtime.ComposeCompilerApi +import androidx.compose.runtime.Composer +import androidx.compose.runtime.InternalComposeApi +import androidx.compose.runtime.Stable + +@ComposeCompilerApi +@Stable +actual interface ComposableLambda { + actual operator fun invoke(p1: Composer, p2: Int): Any? + + actual operator fun invoke(p1: Any?, p2: Composer, p3: Int): Any? + + actual operator fun invoke(p1: Any?, p2: Any?, p3: Composer, p4: Int): Any? + + actual operator fun invoke(p1: Any?, p2: Any?, p3: Any?, p4: Composer, p5: Int): Any? + + actual operator fun invoke(p1: Any?, p2: Any?, p3: Any?, p4: Any?, p5: Composer, p6: Int): Any? + + actual operator fun invoke( + p1: Any?, + p2: Any?, + p3: Any?, + p4: Any?, + p5: Any?, + p6: Composer, + p7: Int, + ): Any? + + actual operator fun invoke( + p1: Any?, + p2: Any?, + p3: Any?, + p4: Any?, + p5: Any?, + p6: Any?, + p7: Composer, + p8: Int, + ): Any? + + actual operator fun invoke( + p1: Any?, + p2: Any?, + p3: Any?, + p4: Any?, + p5: Any?, + p6: Any?, + p7: Any?, + p8: Composer, + p9: Int, + ): Any? + + actual operator fun invoke( + p1: Any?, + p2: Any?, + p3: Any?, + p4: Any?, + p5: Any?, + p6: Any?, + p7: Any?, + p8: Any?, + p9: Composer, + p10: Int, + ): Any? + + actual operator fun invoke( + p1: Any?, + p2: Any?, + p3: Any?, + p4: Any?, + p5: Any?, + p6: Any?, + p7: Any?, + p8: Any?, + p9: Any?, + p10: Composer, + p11: Int, + ): Any? + + actual operator fun invoke( + p1: Any?, + p2: Any?, + p3: Any?, + p4: Any?, + p5: Any?, + p6: Any?, + p7: Any?, + p8: Any?, + p9: Any?, + p10: Any?, + p11: Composer, + p12: Int, + p13: Int, + ): Any? + + actual operator fun invoke( + p1: Any?, + p2: Any?, + p3: Any?, + p4: Any?, + p5: Any?, + p6: Any?, + p7: Any?, + p8: Any?, + p9: Any?, + p10: Any?, + p11: Any?, + p12: Composer, + p13: Int, + p14: Int, + ): Any? + + actual operator fun invoke( + p1: Any?, + p2: Any?, + p3: Any?, + p4: Any?, + p5: Any?, + p6: Any?, + p7: Any?, + p8: Any?, + p9: Any?, + p10: Any?, + p11: Any?, + p12: Any?, + p13: Composer, + p14: Int, + p15: Int, + ): Any? + + actual operator fun invoke( + p1: Any?, + p2: Any?, + p3: Any?, + p4: Any?, + p5: Any?, + p6: Any?, + p7: Any?, + p8: Any?, + p9: Any?, + p10: Any?, + p11: Any?, + p12: Any?, + p13: Any?, + p14: Composer, + p15: Int, + p16: Int, + ): Any? + + actual operator fun invoke( + p1: Any?, + p2: Any?, + p3: Any?, + p4: Any?, + p5: Any?, + p6: Any?, + p7: Any?, + p8: Any?, + p9: Any?, + p10: Any?, + p11: Any?, + p12: Any?, + p13: Any?, + p14: Any?, + p15: Composer, + p16: Int, + p17: Int, + ): Any? + + actual operator fun invoke( + p1: Any?, + p2: Any?, + p3: Any?, + p4: Any?, + p5: Any?, + p6: Any?, + p7: Any?, + p8: Any?, + p9: Any?, + p10: Any?, + p11: Any?, + p12: Any?, + p13: Any?, + p14: Any?, + p15: Any?, + p16: Composer, + p17: Int, + p18: Int, + ): Any? + + actual operator fun invoke( + p1: Any?, + p2: Any?, + p3: Any?, + p4: Any?, + p5: Any?, + p6: Any?, + p7: Any?, + p8: Any?, + p9: Any?, + p10: Any?, + p11: Any?, + p12: Any?, + p13: Any?, + p14: Any?, + p15: Any?, + p16: Any?, + p17: Composer, + p18: Int, + p19: Int, + ): Any? + + actual operator fun invoke( + p1: Any?, + p2: Any?, + p3: Any?, + p4: Any?, + p5: Any?, + p6: Any?, + p7: Any?, + p8: Any?, + p9: Any?, + p10: Any?, + p11: Any?, + p12: Any?, + p13: Any?, + p14: Any?, + p15: Any?, + p16: Any?, + p17: Any?, + p18: Composer, + p19: Int, + p20: Int, + ): Any? + + actual operator fun invoke( + p1: Any?, + p2: Any?, + p3: Any?, + p4: Any?, + p5: Any?, + p6: Any?, + p7: Any?, + p8: Any?, + p9: Any?, + p10: Any?, + p11: Any?, + p12: Any?, + p13: Any?, + p14: Any?, + p15: Any?, + p16: Any?, + p17: Any?, + p18: Any?, + p19: Composer, + p20: Int, + p21: Int, + ): Any? +}
diff --git a/compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/internal/System.web.kt b/compose/runtime/runtime/src/jsMain/kotlin/androidx/compose/runtime/internal/System.js.kt similarity index 73% copy from compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/internal/System.web.kt copy to compose/runtime/runtime/src/jsMain/kotlin/androidx/compose/runtime/internal/System.js.kt index e6e5839..e3f0c98 100644 --- a/compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/internal/System.web.kt +++ b/compose/runtime/runtime/src/jsMain/kotlin/androidx/compose/runtime/internal/System.js.kt
@@ -1,5 +1,5 @@ /* - * Copyright 2025 The Android Open Source Project + * Copyright 2024 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. @@ -21,18 +21,18 @@ private var nextHash = 1 private external interface WeakMap { - fun set(key: JsAny, value: Int) + fun set(key: Any, value: Int) - fun get(key: JsAny): Int? + fun get(key: Any): Int? } private val weakMap: WeakMap = js("new WeakMap()") @NoLiveLiterals -private fun memoizeIdentityHashCode(instance: JsAny): Int { +private fun memoizeIdentityHashCode(instance: Any): Int { val value = nextHash++ - weakMap.set(instance.toJsReference(), value) + weakMap.set(instance, value) return value } @@ -42,6 +42,5 @@ return 0 } - val jsRef = instance.toJsReference() - return weakMap.get(jsRef) ?: memoizeIdentityHashCode(jsRef) + return weakMap.get(instance) ?: memoizeIdentityHashCode(instance) }
diff --git a/compose/runtime/runtime/src/jsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.js.kt b/compose/runtime/runtime/src/jsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.js.kt new file mode 100644 index 0000000..1a2b988 --- /dev/null +++ b/compose/runtime/runtime/src/jsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.js.kt
@@ -0,0 +1,147 @@ +/* + * Copyright 2024 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. + */ + +@file:Suppress("NOTHING_TO_INLINE", "EXTENSION_SHADOWED_BY_MEMBER") + +package androidx.compose.runtime.snapshots + +import androidx.collection.mutableDoubleListOf + +actual typealias SnapshotId = Double + +internal actual const val SnapshotIdZero: SnapshotId = 0.0 +internal actual const val SnapshotIdMax: SnapshotId = Double.MAX_VALUE +internal actual const val SnapshotIdSize: Int = Double.SIZE_BITS +internal actual const val SnapshotIdInvalidValue: SnapshotId = -1.0 + +internal actual inline operator fun SnapshotId.compareTo(other: SnapshotId): Int = + this.compareTo(other) + +internal actual inline operator fun SnapshotId.compareTo(other: Int): Int = + this.compareTo(other.toLong()) + +internal actual inline operator fun SnapshotId.plus(other: Int): SnapshotId = this + other.toLong() + +internal actual inline operator fun SnapshotId.minus(other: SnapshotId): SnapshotId = this - other + +internal actual inline operator fun SnapshotId.minus(other: Int): SnapshotId = this - other.toLong() + +internal actual inline operator fun SnapshotId.div(other: Int): SnapshotId = this / other.toLong() + +internal actual inline operator fun SnapshotId.times(other: Int): SnapshotId = this * other.toLong() + +actual inline fun SnapshotId.toInt(): Int = this.toInt() + +actual inline fun SnapshotId.toLong(): Long = this.toLong() + +actual typealias SnapshotIdArray = DoubleArray + +internal actual fun snapshotIdArrayWithCapacity(capacity: Int): SnapshotIdArray = + DoubleArray(capacity) + +internal actual inline operator fun SnapshotIdArray.get(index: Int): SnapshotId = this[index] + +internal actual inline operator fun SnapshotIdArray.set(index: Int, value: SnapshotId) { + this[index] = value +} + +internal actual inline val SnapshotIdArray.size: Int + get() = this.size + +internal actual inline fun SnapshotIdArray.copyInto(other: SnapshotIdArray) { + this.copyInto(other, 0) +} + +internal actual inline fun SnapshotIdArray.first(): SnapshotId = this[0] + +internal actual fun SnapshotIdArray.binarySearch(id: SnapshotId): Int { + var low = 0 + var high = size - 1 + + while (low <= high) { + val mid = (low + high).ushr(1) + val midVal = get(mid) + if (id > midVal) low = mid + 1 else if (id < midVal) high = mid - 1 else return mid + } + return -(low + 1) +} + +internal actual inline fun SnapshotIdArray.forEach(block: (SnapshotId) -> Unit) { + for (value in this) { + block(value) + } +} + +internal actual fun SnapshotIdArray.withIdInsertedAt(index: Int, id: SnapshotId): SnapshotIdArray { + val newSize = size + 1 + val newArray = DoubleArray(newSize) + this.copyInto(destination = newArray, destinationOffset = 0, startIndex = 0, endIndex = index) + this.copyInto( + destination = newArray, + destinationOffset = index + 1, + startIndex = index, + endIndex = newSize - 1, + ) + newArray[index] = id + return newArray +} + +internal actual fun SnapshotIdArray.withIdRemovedAt(index: Int): SnapshotIdArray? { + val newSize = this.size - 1 + if (newSize == 0) { + return null + } + val newArray = DoubleArray(newSize) + if (index > 0) { + this.copyInto( + destination = newArray, + destinationOffset = 0, + startIndex = 0, + endIndex = index, + ) + } + if (index < newSize) { + this.copyInto( + destination = newArray, + destinationOffset = index, + startIndex = index + 1, + endIndex = newSize + 1, + ) + } + return newArray +} + +internal actual class SnapshotIdArrayBuilder actual constructor(array: SnapshotIdArray?) { + private val list = array?.let { mutableDoubleListOf(*array) } ?: mutableDoubleListOf() + + actual fun add(id: SnapshotId) { + list.add(id) + } + + actual fun toArray(): SnapshotIdArray? { + val size = list.size + if (size == 0) return null + val result = DoubleArray(size) + list.forEachIndexed { index, element -> result[index] = element } + return result + } +} + +internal actual inline fun snapshotIdArrayOf(id: SnapshotId): SnapshotIdArray = doubleArrayOf(id) + +internal actual fun Int.toSnapshotId(): SnapshotId = toDouble() + +internal actual fun Long.toSnapshotId(): SnapshotId = toDouble()
diff --git a/compose/runtime/runtime/src/jsTest/kotlin/androidx/compose/runtime/Actuals.js.kt b/compose/runtime/runtime/src/jsTest/kotlin/androidx/compose/runtime/Actuals.js.kt new file mode 100644 index 0000000..236fa91 --- /dev/null +++ b/compose/runtime/runtime/src/jsTest/kotlin/androidx/compose/runtime/Actuals.js.kt
@@ -0,0 +1,31 @@ +/* + * Copyright 2025 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.runtime + +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.await +import kotlinx.coroutines.promise +import kotlinx.coroutines.test.TestResult + +internal actual suspend fun TestResult.awaitCompletion() { + await() +} + +@Suppress("UNCHECKED_CAST_TO_EXTERNAL_INTERFACE") +internal actual fun wrapTestWithCoroutine(block: suspend () -> Unit): TestResult { + return MainScope().promise { block() } +}
diff --git a/compose/runtime/runtime/src/jsTest/kotlin/androidx/compose/runtime/TestUtils.js.kt b/compose/runtime/runtime/src/jsTest/kotlin/androidx/compose/runtime/TestUtils.js.kt new file mode 100644 index 0000000..4bddc2e --- /dev/null +++ b/compose/runtime/runtime/src/jsTest/kotlin/androidx/compose/runtime/TestUtils.js.kt
@@ -0,0 +1,35 @@ +/* + * Copyright 2025 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.runtime + +import kotlin.js.Promise +import kotlinx.coroutines.* + +@OptIn(DelicateCoroutinesApi::class) +actual suspend fun testWithTimeout(timeoutMs: Long, block: suspend CoroutineScope.() -> Unit) { + Promise { resolve, reject -> + GlobalScope.launch { + try { + withTimeout(timeoutMs) { block() } + resolve(Unit) + } catch (t: Throwable) { + reject(t) + } + } + } + .await() +}
diff --git a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt b/compose/runtime/runtime/src/jsTest/kotlin/kotlinx/test/IgnoreTargets.js.kt similarity index 92% rename from compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt rename to compose/runtime/runtime/src/jsTest/kotlin/kotlinx/test/IgnoreTargets.js.kt index 3bf4637..4268c2f 100644 --- a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt +++ b/compose/runtime/runtime/src/jsTest/kotlin/kotlinx/test/IgnoreTargets.js.kt
@@ -1,5 +1,5 @@ /* - * Copyright 2024 The Android Open Source Project + * Copyright 2021 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.
diff --git a/compose/runtime/runtime/src/androidMain/kotlin/androidx/compose/runtime/OldSynchronized.android.kt b/compose/runtime/runtime/src/jvmMain/kotlin/androidx/compose/runtime/OldSynchronization.jvm.kt similarity index 94% rename from compose/runtime/runtime/src/androidMain/kotlin/androidx/compose/runtime/OldSynchronized.android.kt rename to compose/runtime/runtime/src/jvmMain/kotlin/androidx/compose/runtime/OldSynchronization.jvm.kt index 26722cb1..7844fbf 100644 --- a/compose/runtime/runtime/src/androidMain/kotlin/androidx/compose/runtime/OldSynchronized.android.kt +++ b/compose/runtime/runtime/src/jvmMain/kotlin/androidx/compose/runtime/OldSynchronization.jvm.kt
@@ -20,12 +20,11 @@ package androidx.compose.runtime import androidx.compose.runtime.platform.SynchronizedObject -import kotlin.DeprecationLevel.HIDDEN @PublishedApi @JvmName("synchronized") @Deprecated( - level = HIDDEN, + level = DeprecationLevel.HIDDEN, message = "not expected to be referenced directly as the old version had to be inlined", ) internal inline funoldSynchronized(lock: SynchronizedObject, block: () -> R): R =
diff --git a/compose/runtime/runtime/src/jvmMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.jvm.kt b/compose/runtime/runtime/src/jvmMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.jvm.kt index 41858cb..c0aa19a 100644 --- a/compose/runtime/runtime/src/jvmMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.jvm.kt +++ b/compose/runtime/runtime/src/jvmMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.jvm.kt
@@ -143,3 +143,5 @@ internal actual inline fun snapshotIdArrayOf(id: SnapshotId): SnapshotIdArray = longArrayOf(id) internal actual fun Int.toSnapshotId(): SnapshotId = toLong() + +internal actual fun Long.toSnapshotId(): SnapshotId = this
diff --git a/compose/runtime/runtime/src/jvmTest/kotlin/androidx/compose/runtime/CompositionJvmTests.kt b/compose/runtime/runtime/src/jvmTest/kotlin/androidx/compose/runtime/CompositionJvmTests.kt new file mode 100644 index 0000000..3693128 --- /dev/null +++ b/compose/runtime/runtime/src/jvmTest/kotlin/androidx/compose/runtime/CompositionJvmTests.kt
@@ -0,0 +1,54 @@ +/* + * Copyright 2025 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. + */ + +@file:OptIn(InternalComposeApi::class) + +package androidx.compose.runtime + +import androidx.compose.runtime.mock.Text +import androidx.compose.runtime.mock.compositionTest +import androidx.compose.runtime.mock.validate +import kotlin.reflect.KProperty +import kotlin.test.Test + +@Stable +@Suppress("unused") +class CompositionJvmTests { + /* TODO: Move this test back to commonTest after updating to Kotlin 2.2 + Due to a bug in Kotlin 2.1.2x https://youtrack.jetbrains.com/issue/KT-77508, compilation of + the tests for K/JS and K/Native fails with + "Wrong number of parameters in wrapper: expected: 0 bound and 2 unbound, but 0 found". + So ignoring doesn't really work for this case. + */ + @Test + fun composableDelegates() = compositionTest { + val local = compositionLocalOf { "Default" } + val delegatedLocal by local + compose { + Text(delegatedLocal) + + CompositionLocalProvider(local provides "Scoped") { Text(delegatedLocal) } + } + validate { + Text("Default") + Text("Scoped") + } + } +} + +@Composable +private operator funCompositionLocal + current.getValue(thisRef: Any?, property: KProperty<*>) =
diff --git a/compose/runtime/runtime/src/jvmTest/kotlin/kotlinx/test/IgnoreJsTarget.jvm.kt b/compose/runtime/runtime/src/jvmTest/kotlin/androidx/compose/runtime/TestUtils.jvm.kt similarity index 61% rename from compose/runtime/runtime/src/jvmTest/kotlin/kotlinx/test/IgnoreJsTarget.jvm.kt rename to compose/runtime/runtime/src/jvmTest/kotlin/androidx/compose/runtime/TestUtils.jvm.kt index bebc8a3..ca82c7d 100644 --- a/compose/runtime/runtime/src/jvmTest/kotlin/kotlinx/test/IgnoreJsTarget.jvm.kt +++ b/compose/runtime/runtime/src/jvmTest/kotlin/androidx/compose/runtime/TestUtils.jvm.kt
@@ -1,5 +1,5 @@ /* - * Copyright 2021 The Android Open Source Project + * Copyright 2025 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. @@ -14,8 +14,13 @@ * limitations under the License. */ -package kotlinx.test +package androidx.compose.runtime -annotation class DoNothing +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout -actual typealias IgnoreJsTarget = DoNothing +actual suspend fun testWithTimeout(timeoutMs: Long, block: suspend CoroutineScope.() -> Unit) = + runBlocking { + withTimeout(timeoutMs, block) + }
diff --git a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt b/compose/runtime/runtime/src/linuxMain/kotlin/androidx/compose/runtime/platform/Synchronization.linux.kt similarity index 80% copy from compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt copy to compose/runtime/runtime/src/linuxMain/kotlin/androidx/compose/runtime/platform/Synchronization.linux.kt index 3bf4637..3b75e07 100644 --- a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt +++ b/compose/runtime/runtime/src/linuxMain/kotlin/androidx/compose/runtime/platform/Synchronization.linux.kt
@@ -14,6 +14,6 @@ * limitations under the License. */ -package kotlinx.test +package androidx.compose.runtime.platform -actual typealias IgnoreJsTarget = kotlin.test.Ignore +internal actual val PTHREAD_MUTEX_ERRORCHECK: Int = platform.posix.PTHREAD_MUTEX_ERRORCHECK.toInt()
diff --git a/compose/runtime/runtime/src/mingwX64Main/kotlin/androidx/compose/runtime/platform/Synchronization.mingwX64.kt b/compose/runtime/runtime/src/mingwX64Main/kotlin/androidx/compose/runtime/platform/Synchronization.mingwX64.kt new file mode 100644 index 0000000..6db50ce --- /dev/null +++ b/compose/runtime/runtime/src/mingwX64Main/kotlin/androidx/compose/runtime/platform/Synchronization.mingwX64.kt
@@ -0,0 +1,77 @@ +/* + * Copyright 2024 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.runtime.platform + +import androidx.compose.runtime.internal.currentThreadId +import kotlinx.atomicfu.* + +@Suppress("NOTHING_TO_INLINE") +internal actual inline fun makeSynchronizedObject(ref: Any?) = SynchronizedObject() + +@PublishedApi +internal actual inline funsynchronized(lock: SynchronizedObject, block: () -> R): R { + lock.run { + lock() + return try { + block() + } finally { + unlock() + } + } +} + +/** + * Re-entrant spin lock implementation. + * + * `SynchronizedObject` from `kotlinx-atomicfu` library was used before. However, it is still + * [experimental](https://github.com/Kotlin/kotlinx-atomicfu?tab=readme-ov-file#locks) and has + * [a performance problem](https://github.com/Kotlin/kotlinx-atomicfu/issues/412) that seriously + * affects Compose. + * + * Using a posix mutex is + * [problematic for mingwX64](https://youtrack.jetbrains.com/issue/KT-70449/Posix-declarations-differ-much-for-mingwX64-and-LinuxDarwin-targets), + * so we just use a simple spin lock for mingwX64 (maybe reconsidered in case of problems). + */ +@PublishedApi +internal actual class SynchronizedObject internal constructor() { + + private companion object { + private const val NO_OWNER = -1L + } + + private val owner: AtomicLong = atomic(NO_OWNER) + private var reEnterCount: Int = 0 + + @PublishedApi + internal fun lock() { + if (owner.value == currentThreadId()) { + reEnterCount += 1 + } else { + // Busy wait + while (!owner.compareAndSet(NO_OWNER, currentThreadId())) {} + } + } + + @PublishedApi + internal fun unlock() { + require(owner.value == currentThreadId()) + if (reEnterCount > 0) { + reEnterCount -= 1 + } else { + owner.value = NO_OWNER + } + } +}
diff --git a/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/MonotonicFrameClock.native.kt b/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/MonotonicFrameClock.native.kt new file mode 100644 index 0000000..2b88ffa --- /dev/null +++ b/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/MonotonicFrameClock.native.kt
@@ -0,0 +1,38 @@ +/* + * Copyright 2024 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.runtime + +import kotlin.time.TimeSource +import kotlinx.coroutines.yield + +/** + * The [MonotonicFrameClock] used by [withFrameNanos] and [withFrameMillis] if one is not present in + * the calling [kotlin.coroutines.CoroutineContext]. + * + * This value is no longer used by compose runtime. + */ +@Deprecated( + "MonotonicFrameClocks are not globally applicable across platforms. " + + "Use an appropriate local clock." +) +actual val DefaultMonotonicFrameClock: MonotonicFrameClock = + object : MonotonicFrameClock { + override suspend funwithFrameNanos(onFrame: (Long) -> R): R { + yield() + return onFrame(TimeSource.Monotonic.markNow().elapsedNow().inWholeNanoseconds) + } + }
diff --git a/compose/runtime/runtime/src/androidInstrumentedTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.kt b/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/OldIdentityHashCode.native.kt similarity index 63% copy from compose/runtime/runtime/src/androidInstrumentedTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.kt copy to compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/OldIdentityHashCode.native.kt index 488cac6..78eadd1 100644 --- a/compose/runtime/runtime/src/androidInstrumentedTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.kt +++ b/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/OldIdentityHashCode.native.kt
@@ -1,5 +1,5 @@ /* - * Copyright 2023 The Android Open Source Project + * 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. @@ -14,8 +14,12 @@ * limitations under the License. */ -package kotlinx.test +package androidx.compose.runtime -annotation class NotIgnored - -actual typealias IgnoreAndroidUnitTestTarget = NotIgnored +@InternalComposeApi +@Deprecated( + level = DeprecationLevel.HIDDEN, + message = "Made internal. It wasn't supposed to be public", +) +fun identityHashCode(instance: Any?): Int = + androidx.compose.runtime.internal.identityHashCode(instance)
diff --git a/compose/runtime/runtime/src/nonJvmMain/kotlin/androidx/compose/runtime/internal/ComposableLambda.nonJvm.kt b/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/internal/ComposableLambda.native.kt similarity index 98% rename from compose/runtime/runtime/src/nonJvmMain/kotlin/androidx/compose/runtime/internal/ComposableLambda.nonJvm.kt rename to compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/internal/ComposableLambda.native.kt index a17fe80..ab4f393 100644 --- a/compose/runtime/runtime/src/nonJvmMain/kotlin/androidx/compose/runtime/internal/ComposableLambda.nonJvm.kt +++ b/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/internal/ComposableLambda.native.kt
@@ -1,5 +1,5 @@ /* - * Copyright 2020 The Android Open Source Project + * Copyright 2024 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.
diff --git a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.web.kt b/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/internal/System.native.kt similarity index 71% rename from compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.web.kt rename to compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/internal/System.native.kt index b07c105..695444b 100644 --- a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.web.kt +++ b/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/internal/System.native.kt
@@ -14,8 +14,10 @@ * limitations under the License. */ -package kotlinx.test +package androidx.compose.runtime.internal -annotation class NotIgnored +import kotlin.experimental.ExperimentalNativeApi +import kotlin.native.identityHashCode -actual typealias IgnoreAndroidUnitTestTarget = NotIgnored +@OptIn(ExperimentalNativeApi::class) +actual fun identityHashCode(instance: Any?): Int = instance.identityHashCode()
diff --git a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt b/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/internal/Thread.native.kt similarity index 64% copy from compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt copy to compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/internal/Thread.native.kt index 3bf4637..1cee4ae 100644 --- a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt +++ b/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/internal/Thread.native.kt
@@ -14,6 +14,14 @@ * limitations under the License. */ -package kotlinx.test +package androidx.compose.runtime.internal -actual typealias IgnoreJsTarget = kotlin.test.Ignore +import kotlinx.atomicfu.atomic + +private val threadCounter = atomic(0L) + [email protected] private var threadId: Long = threadCounter.addAndGet(1) + +internal actual fun currentThreadId(): Long = threadId + +internal actual fun currentThreadName(): String = "thread@$threadId"
diff --git a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt b/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/internal/WeakReference.native.kt similarity index 63% copy from compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt copy to compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/internal/WeakReference.native.kt index 3bf4637..631ff88 100644 --- a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt +++ b/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/internal/WeakReference.native.kt
@@ -14,6 +14,13 @@ * limitations under the License. */ -package kotlinx.test +package androidx.compose.runtime.internal -actual typealias IgnoreJsTarget = kotlin.test.Ignore +import kotlin.experimental.ExperimentalNativeApi + +@OptIn(ExperimentalNativeApi::class) +internal actual class WeakReferenceactual constructor(reference: T) { + private val kotlinNativeReference = kotlin.native.ref.WeakReference(reference) + + actual fun get(): T? = kotlinNativeReference.get() +}
diff --git a/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.native.kt b/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.native.kt new file mode 100644 index 0000000..c0aa19a --- /dev/null +++ b/compose/runtime/runtime/src/nativeMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.native.kt
@@ -0,0 +1,147 @@ +/* + * Copyright 2024 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. + */ + +@file:Suppress("NOTHING_TO_INLINE", "EXTENSION_SHADOWED_BY_MEMBER") + +package androidx.compose.runtime.snapshots + +import androidx.collection.mutableLongListOf + +actual typealias SnapshotId = Long + +internal actual const val SnapshotIdZero: SnapshotId = 0L +internal actual const val SnapshotIdMax: SnapshotId = Long.MAX_VALUE +internal actual const val SnapshotIdSize: Int = Long.SIZE_BITS +internal actual const val SnapshotIdInvalidValue: SnapshotId = -1 + +internal actual inline operator fun SnapshotId.compareTo(other: SnapshotId): Int = + this.compareTo(other) + +internal actual inline operator fun SnapshotId.compareTo(other: Int): Int = + this.compareTo(other.toLong()) + +internal actual inline operator fun SnapshotId.plus(other: Int): SnapshotId = this + other.toLong() + +internal actual inline operator fun SnapshotId.minus(other: SnapshotId): SnapshotId = this - other + +internal actual inline operator fun SnapshotId.minus(other: Int): SnapshotId = this - other.toLong() + +internal actual inline operator fun SnapshotId.div(other: Int): SnapshotId = this / other.toLong() + +internal actual inline operator fun SnapshotId.times(other: Int): SnapshotId = this * other.toLong() + +actual inline fun SnapshotId.toInt(): Int = this.toInt() + +actual inline fun SnapshotId.toLong(): Long = this + +actual typealias SnapshotIdArray = LongArray + +internal actual fun snapshotIdArrayWithCapacity(capacity: Int): SnapshotIdArray = + LongArray(capacity) + +internal actual inline operator fun SnapshotIdArray.get(index: Int): SnapshotId = this[index] + +internal actual inline operator fun SnapshotIdArray.set(index: Int, value: SnapshotId) { + this[index] = value +} + +internal actual inline val SnapshotIdArray.size: Int + get() = this.size + +internal actual inline fun SnapshotIdArray.copyInto(other: SnapshotIdArray) { + this.copyInto(other, 0) +} + +internal actual inline fun SnapshotIdArray.first(): SnapshotId = this[0] + +internal actual fun SnapshotIdArray.binarySearch(id: SnapshotId): Int { + var low = 0 + var high = size - 1 + + while (low <= high) { + val mid = (low + high).ushr(1) + val midVal = get(mid) + if (id > midVal) low = mid + 1 else if (id < midVal) high = mid - 1 else return mid + } + return -(low + 1) +} + +internal actual inline fun SnapshotIdArray.forEach(block: (SnapshotId) -> Unit) { + for (value in this) { + block(value) + } +} + +internal actual fun SnapshotIdArray.withIdInsertedAt(index: Int, id: SnapshotId): SnapshotIdArray { + val newSize = size + 1 + val newArray = LongArray(newSize) + this.copyInto(destination = newArray, destinationOffset = 0, startIndex = 0, endIndex = index) + this.copyInto( + destination = newArray, + destinationOffset = index + 1, + startIndex = index, + endIndex = newSize - 1, + ) + newArray[index] = id + return newArray +} + +internal actual fun SnapshotIdArray.withIdRemovedAt(index: Int): SnapshotIdArray? { + val newSize = this.size - 1 + if (newSize == 0) { + return null + } + val newArray = LongArray(newSize) + if (index > 0) { + this.copyInto( + destination = newArray, + destinationOffset = 0, + startIndex = 0, + endIndex = index, + ) + } + if (index < newSize) { + this.copyInto( + destination = newArray, + destinationOffset = index, + startIndex = index + 1, + endIndex = newSize + 1, + ) + } + return newArray +} + +internal actual class SnapshotIdArrayBuilder actual constructor(array: SnapshotIdArray?) { + private val list = array?.let { mutableLongListOf(*array) } ?: mutableLongListOf() + + actual fun add(id: SnapshotId) { + list.add(id) + } + + actual fun toArray(): SnapshotIdArray? { + val size = list.size + if (size == 0) return null + val result = LongArray(size) + list.forEachIndexed { index, element -> result[index] = element } + return result + } +} + +internal actual inline fun snapshotIdArrayOf(id: SnapshotId): SnapshotIdArray = longArrayOf(id) + +internal actual fun Int.toSnapshotId(): SnapshotId = toLong() + +internal actual fun Long.toSnapshotId(): SnapshotId = this
diff --git a/compose/runtime/runtime/src/nativeTest/kotlin/androidx/compose/runtime/TestUtils.native.kt b/compose/runtime/runtime/src/nativeTest/kotlin/androidx/compose/runtime/TestUtils.native.kt new file mode 100644 index 0000000..971b4ee --- /dev/null +++ b/compose/runtime/runtime/src/nativeTest/kotlin/androidx/compose/runtime/TestUtils.native.kt
@@ -0,0 +1,28 @@ +/* + * Copyright 2025 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.runtime + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout + +actual suspend fun testWithTimeout(timeoutMs: Long, block: suspend CoroutineScope.() -> Unit) = + runBlocking { + // TODO: k/native tests run in debug mode and much-much slower than jvm, + // so we adjust for it here by multiplying by 10 :( + withTimeout(timeoutMs * 10, block) + }
diff --git a/compose/runtime/runtime/src/nativeTest/kotlin/androidx/compose/runtime/platform/SynchronizationTest.kt b/compose/runtime/runtime/src/nativeTest/kotlin/androidx/compose/runtime/platform/SynchronizationTest.kt new file mode 100644 index 0000000..6d27038 --- /dev/null +++ b/compose/runtime/runtime/src/nativeTest/kotlin/androidx/compose/runtime/platform/SynchronizationTest.kt
@@ -0,0 +1,86 @@ +/* + * Copyright 2024 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.runtime.platform + +import kotlin.concurrent.AtomicInt +import kotlin.native.concurrent.* +import kotlin.test.* + +private const val iterations = 100 +private const val nWorkers = 4 +private const val increments = 500 +private const val nLocks = 5 + +private fun nest(lock: SynchronizedObject, nestedLocks: Int, count: AtomicInt) { + synchronized(lock) { + if (nestedLocks == 1) { + val oldValue = count.value + count.value = oldValue + 1 + } else { + nest(lock, nestedLocks - 1, count) + } + } +} + +/** + * Test is taken from [kotlinx-atomicfu](https://github.com/Kotlin/kotlinx-atomicfu) with a few + * modifications. + */ +@OptIn(ObsoleteWorkersApi::class) +class SynchronizedTest { + @Test + fun stressCounterTest() { + repeat(iterations) { + val workers = Array(nWorkers) { Worker.start() } + val counter = AtomicInt(0) + val so = makeSynchronizedObject() + workers.forEach { worker -> + worker.execute(TransferMode.SAFE, { counter to so }) { (count, lock) -> + repeat(increments) { + val nestedLocks = (1..3).random() + nest(lock, nestedLocks, count) + } + } + } + workers.forEach { it.requestTermination().result } + assertEquals(nWorkers * increments, counter.value) + } + } + + @Test + fun manyLocksTest() { + repeat(iterations) { + val workers = Array(nWorkers) { Worker.start() } + val counters = Array(nLocks) { AtomicInt(0) } + val locks = Array(nLocks) { makeSynchronizedObject() } + workers.forEach { worker -> + worker.execute(TransferMode.SAFE, { counters to locks }) { (counters, locks) -> + locks.forEachIndexed { i, lock -> + repeat(increments) { + synchronized(lock) { + val oldValue = counters[i].value + counters[i].value = oldValue + 1 + } + } + } + } + } + workers.forEach { it.requestTermination().result } + assertEquals(nWorkers * nLocks * increments, counters.sumOf { it.value }) + } + } +}
diff --git a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt b/compose/runtime/runtime/src/nativeTest/kotlin/kotlinx/test/IgnoreTargets.native.kt similarity index 84% copy from compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt copy to compose/runtime/runtime/src/nativeTest/kotlin/kotlinx/test/IgnoreTargets.native.kt index 3bf4637..bf10881 100644 --- a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt +++ b/compose/runtime/runtime/src/nativeTest/kotlin/kotlinx/test/IgnoreTargets.native.kt
@@ -1,5 +1,5 @@ /* - * Copyright 2024 The Android Open Source Project + * Copyright 2025 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. @@ -16,4 +16,4 @@ package kotlinx.test -actual typealias IgnoreJsTarget = kotlin.test.Ignore +actual typealias IgnoreNativeTarget = kotlin.test.Ignore
diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/BroadcastFrameClockTest.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/BroadcastFrameClockTest.kt index fac0850..3d3346b 100644 --- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/BroadcastFrameClockTest.kt +++ b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/BroadcastFrameClockTest.kt
@@ -36,6 +36,7 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.yield import kotlinx.test.IgnoreJsTarget +import kotlinx.test.IgnoreWasmTarget @ExperimentalCoroutinesApi class BroadcastFrameClockTest { @@ -91,6 +92,7 @@ // // Remove this annotation if JS adds proper multithreading support, then flee the area quickly. @IgnoreJsTarget + @IgnoreWasmTarget @OptIn(InternalCoroutinesApi::class) @Test fun locklessCancellation() =
diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/CompositionObserverTests.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/CompositionObserverTests.kt index adea6dd..43473a5 100644 --- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/CompositionObserverTests.kt +++ b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/CompositionObserverTests.kt
@@ -28,6 +28,7 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue import kotlinx.test.IgnoreJsTarget +import kotlinx.test.IgnoreWasmTarget @Stable @OptIn(ExperimentalComposeRuntimeApi::class) @@ -66,7 +67,10 @@ } @Test - @IgnoreJsTarget // b/409727436 + // TODO: b/409727436 + // TODO: https://youtrack.jetbrains.com/issue/CMP-797 + @IgnoreJsTarget + @IgnoreWasmTarget fun observeScope() { val observer = SingleScopeObserver() compositionTest { @@ -94,7 +98,10 @@ } @Test - @IgnoreJsTarget // b/409727436 + // TODO: b/409727436 + // TODO: https://youtrack.jetbrains.com/issue/CMP-797 + @IgnoreJsTarget + @IgnoreWasmTarget fun observeScope_dispose() { val observer = SingleScopeObserver() compositionTest { @@ -129,7 +136,10 @@ } @Test - @IgnoreJsTarget // b/409727436 + // TODO: b/409727436 + // TODO: https://youtrack.jetbrains.com/issue/CMP-797 + @IgnoreJsTarget + @IgnoreWasmTarget fun observeScope_scopeRemoved() { val observer = SingleScopeObserver() compositionTest {
diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/CompositionTests.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/CompositionTests.kt index 2bd40cd..31fc54f 100644 --- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/CompositionTests.kt +++ b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/CompositionTests.kt
@@ -73,6 +73,7 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withContext import kotlinx.test.IgnoreJsTarget +import kotlinx.test.IgnoreWasmTarget @Composable fun Container(content: @Composable () -> Unit) = content() @@ -2566,7 +2567,9 @@ } @Test - @IgnoreJsTarget // b/409728274 + // The test for web is properly implemented in CompositionTests.web.kt + @IgnoreJsTarget + @IgnoreWasmTarget fun testRememberObserver_Abandon_Recompose() { val abandonedObjects = mutableListOf() val observed = @@ -4390,6 +4393,12 @@ revalidate() } + /* TODO: Restore after updating to Kotlin 2.2 + Due to a bug in Kotlin 2.1.2x https://youtrack.jetbrains.com/issue/KT-77508, compilation of + the tests for K/JS and K/Native fails with + "Wrong number of parameters in wrapper: expected: 0 bound and 2 unbound, but 0 found". + So ignoring doesn't really work for this case. For now the test is moved to CompositionJvmTests + @Test fun composableDelegates() = compositionTest { val local = compositionLocalOf { "Default" } @@ -4404,6 +4413,7 @@ Text("Scoped") } } + */ @Test fun testCompositionAndRecomposerDeadlock() { @@ -5104,7 +5114,8 @@ } @Composable -operator funCompositionLocal +private operator fun.getValue(thisRef: Any?, property: KProperty<*>) = current CompositionLocal + current // for 274185312.getValue(thisRef: Any?, property: KProperty<*>) =
diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/LatchTest.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/LatchTest.kt index 4a87a12..24c0316e 100644 --- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/LatchTest.kt +++ b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/LatchTest.kt
@@ -21,30 +21,34 @@ import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withTimeout @ExperimentalCoroutinesApi class LatchTest { @Test - fun openDoesntSuspend() = runTest { - val latch = Latch() - assertTrue(latch.isOpen, "latch open after construction") + fun openDoesntSuspend() = + runTest(UnconfinedTestDispatcher()) { + val latch = Latch() + assertTrue(latch.isOpen, "latch open after construction") - val awaiter = launch(start = CoroutineStart.UNDISPATCHED) { latch.await() } - assertTrue(awaiter.isCompleted, "await did not suspend") - } + val awaiter = launch(start = CoroutineStart.UNDISPATCHED) { latch.await() } + assertTrue(awaiter.isCompleted, "await did not suspend") + } @Test - fun closedSuspendsReleasesAll() = runTest { - val latch = Latch() - latch.closeLatch() - assertTrue(!latch.isOpen, "latch.isOpen after close") + fun closedSuspendsReleasesAll() = + runTest(UnconfinedTestDispatcher()) { + val latch = Latch() + latch.closeLatch() + assertTrue(!latch.isOpen, "latch.isOpen after close") - val awaiters = (1..5).map { launch(start = CoroutineStart.UNDISPATCHED) { latch.await() } } - assertTrue("all awaiters still active") { awaiters.all { it.isActive } } + val awaiters = + (1..5).map { launch(start = CoroutineStart.UNDISPATCHED) { latch.await() } } + assertTrue("all awaiters still active") { awaiters.all { it.isActive } } - latch.openLatch() - withTimeout(500) { awaiters.map { it.join() } } - } + latch.openLatch() + withTimeout(500) { awaiters.map { it.join() } } + } }
diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/PausableCompositionTests.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/PausableCompositionTests.kt index ef91012..56bbf88 100644 --- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/PausableCompositionTests.kt +++ b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/PausableCompositionTests.kt
@@ -29,13 +29,13 @@ import kotlin.test.Ignore import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.test.runTest +@OptIn(ExperimentalCoroutinesApi::class) @Stable class PausableCompositionTests { @Test @@ -426,8 +426,8 @@ } @Test - fun pausableComposition_throwInResume() = runTest { - assertFailsWith{ + fun pausableComposition_throwInResume() = + runTest(expected = IllegalStateException::class) { val recomposer = Recomposer(coroutineContext) val pausableComposition = PausableComposition(EmptyApplier(), recomposer) @@ -440,11 +440,10 @@ recomposer.close() } } - } @Test - fun pausableComposition_throwInApply() = runTest { - assertFailsWith{ + fun pausableComposition_throwInApply() = + runTest(expected = IllegalStateException::class) { val recomposer = Recomposer(coroutineContext) val pausableComposition = PausableComposition(EmptyApplier(), recomposer) @@ -460,7 +459,6 @@ recomposer.close() } } - } @Test fun pausableComposition_isAppliedReturnsCorrectValue() = runTest {
diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/RecomposerTests.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/RecomposerTests.kt index cdca0cf..048285d 100644 --- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/RecomposerTests.kt +++ b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/RecomposerTests.kt
@@ -43,6 +43,8 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import kotlinx.test.IgnoreJsTarget +import kotlinx.test.IgnoreNativeTarget +import kotlinx.test.IgnoreWasmTarget @OptIn(ExperimentalCoroutinesApi::class) class RecomposerTests { @@ -354,8 +356,12 @@ } } + // TODO: b/409727145 + // TODO: https://youtrack.jetbrains.com/issue/CMP-7455 @Test - @IgnoreJsTarget // b/409727145 + @IgnoreJsTarget + @IgnoreWasmTarget + @IgnoreNativeTarget fun stateChangesDuringApplyChangesAreNotifiedBeforeFrameFinished() = compositionTest { val count = mutableStateOf(0) val countFromEffect = mutableStateOf(0)
diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotContextElementTests.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotContextElementTests.kt index 1c64504..5c78c83 100644 --- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotContextElementTests.kt +++ b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotContextElementTests.kt
@@ -25,11 +25,16 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withContext import kotlinx.test.IgnoreJsTarget +import kotlinx.test.IgnoreNativeTarget +import kotlinx.test.IgnoreWasmTarget @OptIn(ExperimentalCoroutinesApi::class) class SnapshotContextElementTests { + // TODO: b/409725929 @Test - @IgnoreJsTarget // b/409725929 + @IgnoreJsTarget + @IgnoreWasmTarget + @IgnoreNativeTarget fun coroutineEntersExpectedSnapshot() = runTest(UnconfinedTestDispatcher()) { val snapshot = Snapshot.takeSnapshot() @@ -43,6 +48,8 @@ } @Test + @IgnoreJsTarget + @IgnoreNativeTarget fun snapshotRestoredAfterResume() { val snapshotOne = Snapshot.takeSnapshot() val snapshotTwo = Snapshot.takeSnapshot()
diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotDoubleIndexHeapTests.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotDoubleIndexHeapTests.kt index b645d39..03d47d2 100644 --- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotDoubleIndexHeapTests.kt +++ b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotDoubleIndexHeapTests.kt
@@ -35,9 +35,9 @@ fun canAddAndRemoveNumbersInSequence() { val heap = SnapshotDoubleIndexHeap() val handles = IntArray(100) - repeat(100) { handles[it] = heap.add(it.toLong()) } + repeat(100) { handles[it] = heap.add(it.toSnapshotId()) } repeat(100) { - assertEquals(it.toLong(), heap.lowestOrDefault(-1)) + assertEquals(it.toSnapshotId(), heap.lowestOrDefault(SnapshotIdInvalidValue)) heap.remove(handles[it]) } assertEquals(0, heap.size) @@ -55,24 +55,24 @@ if (shouldAdd) { val indexToAdd = random.nextInt(toAdd.size) val value = toAdd[indexToAdd] - val handle = heap.add(value.toLong()) + val handle = heap.add(value.toSnapshotId()) toRemove.add(value to handle) toAdd.removeAt(indexToAdd) } else { val indexToRemove = random.nextInt(toRemove.size) val (value, handle) = toRemove[indexToRemove] - assertTrue(heap.lowestOrDefault(-1) <= value) + assertTrue(heap.lowestOrDefault(SnapshotIdInvalidValue) <= value) heap.remove(handle) toRemove.removeAt(indexToRemove) } heap.validate() for ((value, handle) in toRemove) { - heap.validateHandle(handle, value.toLong()) + heap.validateHandle(handle, value.toSnapshotId()) } val lowestAdded = toRemove.fold(400) { lowest, (value, _) -> if (value < lowest) value else lowest } - assertEquals(lowestAdded, heap.lowestOrDefault(400).toInt()) + assertEquals(lowestAdded, heap.lowestOrDefault(400.toSnapshotId()).toInt()) } } }
diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotIdSetTests.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotIdSetTests.kt index ee92d0c..0104afb 100644 --- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotIdSetTests.kt +++ b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotIdSetTests.kt
@@ -35,7 +35,8 @@ @Test fun shouldBeAbleToSetItems() { val times = 10000L - val set = (0..times).fold(SnapshotIdSet.EMPTY) { prev, index -> prev.set(index) } + val set = + (0..times).fold(SnapshotIdSet.EMPTY) { prev, index -> prev.set(index.toSnapshotId()) } repeat(times) { set.shouldBe(it, true) } } @@ -45,7 +46,7 @@ val times = 10000L val set = (0..times).fold(SnapshotIdSet.EMPTY) { prev, index -> - if (index % 2L == 0L) prev.set(index) else prev + if (index % 2L == 0L) prev.set(index.toSnapshotId()) else prev } repeat(times) { set.shouldBe(it, it % 2L == 0L) } @@ -56,7 +57,7 @@ val times = 10000L val set = (0..times).fold(SnapshotIdSet.EMPTY) { prev, index -> - if (index % 2L == 1L) prev.set(index) else prev + if (index % 2L == 1L) prev.set(index.toSnapshotId()) else prev } repeat(times) { set.shouldBe(it, it % 2L == 1L) } @@ -65,11 +66,12 @@ @Test fun shouldBeAbleToClearEvens() { val times = 10000L - val allSet = (0..times).fold(SnapshotIdSet.EMPTY) { prev, index -> prev.set(index) } + val allSet = + (0..times).fold(SnapshotIdSet.EMPTY) { prev, index -> prev.set(index.toSnapshotId()) } val set = (0..times).fold(allSet) { prev, index -> - if (index % 2L == 0L) prev.clear(index) else prev + if (index % 2L == 0L) prev.clear(index.toSnapshotId()) else prev } repeat(times - 1) { set.shouldBe(it, it % 2L == 1L) } @@ -79,7 +81,9 @@ fun shouldBeAbleToCrawlSet() { val times = 10000L val set = - (0..times).fold(SnapshotIdSet.EMPTY) { prev, index -> prev.clear(index - 1).set(index) } + (0..times).fold(SnapshotIdSet.EMPTY) { prev, index -> + prev.clear(index.toSnapshotId() - 1).set(index.toSnapshotId()) + } set.shouldBe(times, true) repeat(times - 1) { set.shouldBe(it, false) } @@ -90,7 +94,11 @@ val times = 10000L val set = (0..times).fold(SnapshotIdSet.EMPTY) { prev, index -> - prev.let { if ((index - 1L) % 33L != 0L) it.clear(index - 1) else it }.set(index) + prev + .let { + if ((index - 1L) % 33L != 0L) it.clear(index.toSnapshotId() - 1) else it + } + .set(index.toSnapshotId()) } set.shouldBe(times, true) @@ -98,7 +106,7 @@ // The multiples of 33 items should now be set repeat(times - 1) { set.shouldBe(it, it % 33L == 0L) } - val newSet = (0 until times).fold(set) { prev, index -> prev.clear(index) } + val newSet = (0 until times).fold(set) { prev, index -> prev.clear(index.toSnapshotId()) } newSet.shouldBe(times, true) @@ -107,19 +115,19 @@ @Test fun shouldBeAbleToInsertAndRemoveOutOfOptimalRange() { - SnapshotIdSet.EMPTY.set(1000L) - .set(1L) + SnapshotIdSet.EMPTY.set(1000L.toSnapshotId()) + .set(1L.toSnapshotId()) .shouldBe(1000L, true) .shouldBe(1L, true) - .set(10L) + .set(10L.toSnapshotId()) .shouldBe(10L, true) - .set(4L) + .set(4L.toSnapshotId()) .shouldBe(4L, true) - .clear(1L) + .clear(1L.toSnapshotId()) .shouldBe(1L, false) - .clear(4L) + .clear(4L.toSnapshotId()) .shouldBe(4L, false) - .clear(10L) + .clear(10L.toSnapshotId()) .shouldBe(1L, false) .shouldBe(4L, false) .shouldBe(10L, false) @@ -134,14 +142,14 @@ (0..100L).fold(SnapshotIdSet.EMPTY) { prev, _ -> val value = random.nextInt(0, 1000) booleans[value] = true - prev.set(value.toLong()) + prev.set(value.toSnapshotId()) } val clear = (0..100).fold(set) { prev, _ -> val value = random.nextInt(0, 1000) booleans[value] = false - prev.clear(value.toLong()) + prev.clear(value.toSnapshotId()) } repeat(1000L) { clear.shouldBe(it, booleans[it.toInt()]) } @@ -155,14 +163,14 @@ (0..100).fold(SnapshotIdSet.EMPTY) { prev, _ -> val value = random.nextInt(0, 1000) booleans[value] = true - prev.set(value.toLong()) + prev.set(value.toSnapshotId()) } val setB = (0..100).fold(SnapshotIdSet.EMPTY) { prev, _ -> val value = random.nextInt(0, 1000) booleans[value] = false - prev.set(value.toLong()) + prev.set(value.toSnapshotId()) } val set = setA.andNot(setB) @@ -178,14 +186,14 @@ (0 until size).fold(SnapshotIdSet.EMPTY) { prev, index -> if (random.nextInt(0, 1000) > 500) { booleans[index] = true - prev.set(index.toLong()) + prev.set(index.toSnapshotId()) } else prev } val setB = (0 until size).fold(SnapshotIdSet.EMPTY) { prev, index -> if (random.nextInt(0, 1000) > 500) { booleans[index] = false - prev.set(index.toLong()) + prev.set(index.toSnapshotId()) } else prev } val set = setA.andNot(setB) @@ -207,14 +215,14 @@ (0 until size).fold(SnapshotIdSet.EMPTY) { prev, index -> if (random.nextInt(0, 1000) > 500) { booleans[index] = true - prev.set(index.toLong()) + prev.set(index.toSnapshotId()) } else prev } val setB = (0 until size).fold(SnapshotIdSet.EMPTY) { prev, index -> if (random.nextInt(0, 1000) > 500) { booleans[index] = true - prev.set(index.toLong()) + prev.set(index.toSnapshotId()) } else prev } val set = setA.or(setB) @@ -236,10 +244,10 @@ (0 until size).fold(SnapshotIdSet.EMPTY) { prev, index -> if (random.nextInt(0, 1000) > 500) { values.add(index.toLong()) - prev.set(index.toLong()) + prev.set(index.toSnapshotId()) } else prev } - values.zip(set).forEach { assertEquals(it.first, it.second) } + values.zip(set).forEach { assertEquals(it.first.toSnapshotId(), it.second) } assertEquals(values.size, set.count()) } @@ -251,20 +259,20 @@ @Test // Regression b/182822837 fun shouldReportTheCorrectLowest() { - fun test(number: Long) { + fun test(number: SnapshotId) { val set = SnapshotIdSet.EMPTY.set(number) - assertEquals(number, set.lowest(-1)) + assertEquals(number, set.lowest(SnapshotIdInvalidValue)) } - repeat(64) { test(it) } + repeat(64) { test(it.toSnapshotId()) } } @Test fun shouldOverflowGracefully() { - val s = SnapshotIdSet.EMPTY.set(0).set(Long.MAX_VALUE) - assertTrue(s.get(0)) - assertTrue(s.get(Long.MAX_VALUE)) - assertFalse(s.get(1)) + val s = SnapshotIdSet.EMPTY.set(0.toSnapshotId()).set(Long.MAX_VALUE.toSnapshotId()) + assertTrue(s.get(0.toSnapshotId())) + assertTrue(s.get(Long.MAX_VALUE.toSnapshotId())) + assertFalse(s.get(1.toSnapshotId())) } @Test // Regression: b/147836978 @@ -5627,17 +5635,18 @@ .map { it.split(":").let { it[0].toInt() to it[1].toBoolean() } } operations.fold(SnapshotIdSet.EMPTY) { prev, (value, op) -> assertTrue( - prev.get(value.toLong()) != op, + prev.get(value.toSnapshotId()) != op, "Error on bit $value, expected ${!op}, received $op", ) - val result = if (op) prev.set(value.toLong()) else prev.clear(value.toLong()) + val result = + if (op) prev.set(value.toSnapshotId()) else prev.clear(value.toSnapshotId()) result } } } private fun SnapshotIdSet.shouldBe(index: Long, value: Boolean): SnapshotIdSet { - assertEquals(value, get(index), "Bit $index should be $value") + assertEquals(value, get(index.toSnapshotId()), "Bit $index should be $value") return this }
diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotStateMapTests.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotStateMapTests.kt index 20c1d86..3dca762 100644 --- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotStateMapTests.kt +++ b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotStateMapTests.kt
@@ -19,6 +19,7 @@ package androidx.compose.runtime.snapshots import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.runTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -33,6 +34,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import kotlinx.test.IgnoreJsTarget +import kotlinx.test.IgnoreNativeTarget +import kotlinx.test.IgnoreWasmTarget class SnapshotStateMapTests { @Test @@ -132,8 +135,12 @@ validateWrite { map -> map.entries.clear() } } + // TODO: b/409729875 + // test passes if the order is changed to assertEquals(entries.first, entries.second) @Test - @IgnoreJsTarget // b/409729875 + @IgnoreJsTarget + @IgnoreWasmTarget + @IgnoreNativeTarget fun validateEntriesIterator() { validateRead { map, normalMap -> for (entries in map.entries.zip(normalMap.entries)) { @@ -213,14 +220,22 @@ validateWrite { map -> map.entries.remove(map.entries.first()) } } + // TODO: b/409727470 + // TODO: https://youtrack.jetbrains.com/issue/CMP-7397 @Test - @IgnoreJsTarget // b/409727470 + @IgnoreJsTarget + @IgnoreWasmTarget + @IgnoreNativeTarget fun validateEntriesRemoveAll() { validateWrite { map -> map.entries.removeAll(map.entries.filter { it.key % 2 == 0 }) } } + // TODO: b/409727470 + // TODO: https://youtrack.jetbrains.com/issue/CMP-7397 @Test - @IgnoreJsTarget // b/409727470 + @IgnoreJsTarget + @IgnoreWasmTarget + @IgnoreNativeTarget fun validateEntriesRetainAll() { validateWrite { map -> map.entries.retainAll(map.entries.filter { it.key % 2 == 0 }) } } @@ -383,6 +398,12 @@ @Test @IgnoreJsTarget + @IgnoreWasmTarget + @IgnoreNativeTarget + // Ignored for js, wasm and native: + // SnapshotStateMap removes a correct element - entry(key=1,value=1f) + // The test fails because MutableMap (normalMap) removes entry(key=1, value=5f) + // due to an entry search by value starting from the end of an array (in native HashMap impl). fun validateValuesRemove() { validateWrite { map -> map.values.remove(1f)
diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotStateObserverTestsCommon.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotStateObserverTestsCommon.kt index eaa2cbd..59eb900 100644 --- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotStateObserverTestsCommon.kt +++ b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/SnapshotStateObserverTestsCommon.kt
@@ -30,7 +30,7 @@ @Test fun stateChangeTriggersCallback() { - val data = "Hello World" + val data = ValueWrapper("Hello World") var changes = 0 val state = mutableStateOf(0) @@ -38,7 +38,7 @@ try { stateObserver.start() - val onChangeListener: (String) -> Unit = { affected -> + val onChangeListener: (ValueWrapper) -> Unit = { affected -> assertEquals(data, affected) assertEquals(0, changes) changes++ @@ -61,9 +61,9 @@ @Test fun multipleStagesWorksTogether() { - val strStage1 = "Stage1" - val strStage2 = "Stage2" - val strStage3 = "Stage3" + val strStage1 = ValueWrapper("Stage1") + val strStage2 = ValueWrapper("Stage2") + val strStage3 = ValueWrapper("Stage3") var stage1Changes = 0 var stage2Changes = 0 var stage3Changes = 0 @@ -71,17 +71,17 @@ val stage2Model = mutableStateOf(0) val stage3Model = mutableStateOf(0) - val onChangeStage1: (String) -> Unit = { affectedData -> + val onChangeStage1: (ValueWrapper) -> Unit = { affectedData -> assertEquals(strStage1, affectedData) assertEquals(0, stage1Changes) stage1Changes++ } - val onChangeStage2: (String) -> Unit = { affectedData -> + val onChangeStage2: (ValueWrapper) -> Unit = { affectedData -> assertEquals(strStage2, affectedData) assertEquals(0, stage2Changes) stage2Changes++ } - val onChangeStage3: (String) -> Unit = { affectedData -> + val onChangeStage3: (ValueWrapper) -> Unit = { affectedData -> assertEquals(strStage3, affectedData) assertEquals(0, stage3Changes) stage3Changes++ @@ -114,9 +114,9 @@ @Test fun enclosedStagesCorrectlyObserveChanges() { - val stage1Info = "stage 1" - val stage2Info1 = "stage 1 - value 1" - val stage2Info2 = "stage 2 - value 2" + val stage1Info = ValueWrapper("stage 1") + val stage2Info1 = ValueWrapper("stage 1 - value 1") + val stage2Info2 = ValueWrapper("stage 2 - value 2") var stage1Changes = 0 var stage2Changes1 = 0 var stage2Changes2 = 0 @@ -124,12 +124,12 @@ val stage2Data1 = mutableStateOf(0) val stage2Data2 = mutableStateOf(0) - val onChangeStage1Listener: (String) -> Unit = { affected -> + val onChangeStage1Listener: (ValueWrapper) -> Unit = { affected -> assertEquals(affected, stage1Info) assertEquals(stage1Changes, 0) stage1Changes++ } - val onChangeState2Listener: (String) -> Unit = { affected -> + val onChangeState2Listener: (ValueWrapper) -> Unit = { affected -> when (affected) { stage2Info1 -> { assertEquals(0, stage2Changes1) @@ -177,11 +177,11 @@ @Test fun stateReadTriggersCallbackAfterSwitchingAdvancingGlobalWithinObserveReads() { - val info = "Hello" + val info = ValueWrapper("Hello") var changes = 0 val state = mutableStateOf(0) - val onChangeListener: (String) -> Unit = { _ -> + val onChangeListener: (ValueWrapper) -> Unit = { _ -> assertEquals(0, changes) changes++ } @@ -216,7 +216,7 @@ @Suppress("DEPRECATION") @Test fun pauseStopsObserving() { - val data = "data" + val data = ValueWrapper("data") var changes = 0 runSimpleTest { stateObserver, state -> @@ -230,7 +230,7 @@ @Test fun withoutReadObservationStopsObserving() { - val data = "data" + val data = ValueWrapper("data") var changes = 0 runSimpleTest { stateObserver, state -> @@ -244,7 +244,7 @@ @Test fun changeAfterWithoutReadObservationIsObserving() { - val data = "data" + val data = ValueWrapper("data") var changes = 0 runSimpleTest { stateObserver, state -> @@ -260,7 +260,7 @@ @Suppress("DEPRECATION") @Test fun nestedPauseStopsObserving() { - val data = "data" + val data = ValueWrapper("data") var changes = 0 runSimpleTest { stateObserver, state -> @@ -277,7 +277,7 @@ @Test fun nestedWithoutReadObservation() { - val data = "data" + val data = ValueWrapper("data") var changes = 0 runSimpleTest { stateObserver, state -> @@ -294,7 +294,7 @@ @Test fun simpleObserving() { - val data = "data" + val data = ValueWrapper("data") var changes = 0 runSimpleTest { stateObserver, state -> @@ -307,7 +307,7 @@ @Suppress("DEPRECATION") @Test fun observeWithinPause() { - val data = "data" + val data = ValueWrapper("data") var changes1 = 0 var changes2 = 0 @@ -324,7 +324,7 @@ @Test fun observeWithinWithoutReadObservation() { - val data = "data" + val data = ValueWrapper("data") var changes1 = 0 var changes2 = 0 @@ -345,8 +345,8 @@ var changes2 = 0 runSimpleTest { stateObserver, state -> - stateObserver.observeReads("scope1", { changes1++ }) { - stateObserver.observeReads("scope2", { changes2++ }) { + stateObserver.observeReads(ValueWrapper("scope1"), { changes1++ }) { + stateObserver.observeReads(ValueWrapper("scope2"), { changes2++ }) { Snapshot.withoutReadObservation { state.value } } } @@ -361,8 +361,8 @@ var changes2 = 0 runSimpleTest { stateObserver, state -> - stateObserver.observeReads("scope1", { changes1++ }) { - stateObserver.observeReads("scope2", { changes2++ }) { + stateObserver.observeReads(ValueWrapper("scope1"), { changes1++ }) { + stateObserver.observeReads(ValueWrapper("scope2"), { changes2++ }) { Snapshot.withoutReadObservation { val newSnapshot = Snapshot.takeMutableSnapshot() newSnapshot.enter { state.value } @@ -382,8 +382,8 @@ var changes2 = 0 runSimpleTest { stateObserver, state -> - stateObserver.observeReads("scope1", { changes1++ }) { - stateObserver.observeReads("scope2", { changes2++ }) { + stateObserver.observeReads(ValueWrapper("scope1"), { changes1++ }) { + stateObserver.observeReads(ValueWrapper("scope2"), { changes2++ }) { Snapshot.withoutReadObservation { val newSnapshot = Snapshot.takeSnapshot() newSnapshot.enter { state.value } @@ -401,7 +401,7 @@ var changes = 0 runSimpleTest { stateObserver, state -> - stateObserver.observeReads("scope", { changes++ }) { + stateObserver.observeReads(ValueWrapper("scope"), { changes++ }) { val newSnapshot = Snapshot.takeSnapshot() newSnapshot.enter { Snapshot.withoutReadObservation { state.value } } newSnapshot.dispose() @@ -417,7 +417,7 @@ runSimpleTest { stateObserver, state -> val derivedState = derivedStateOf { state.value } - stateObserver.observeReads("scope", { changes++ }) { + stateObserver.observeReads(ValueWrapper("scope"), { changes++ }) { // read derivedState.value } @@ -433,7 +433,7 @@ val state = mutableStateOf(mutableListOf(42), referentialEqualityPolicy()) val derivedState = derivedStateOf { state.value } - stateObserver.observeReads("scope", { changes++ }) { + stateObserver.observeReads(ValueWrapper("scope"), { changes++ }) { // read derivedState.value } @@ -451,7 +451,7 @@ val derivedState = derivedStateOf { state.value } val derivedState2 = derivedStateOf { derivedState.value } - stateObserver.observeReads("scope", { changes++ }) { + stateObserver.observeReads(ValueWrapper("scope"), { changes++ }) { // read derivedState2.value } @@ -467,7 +467,7 @@ val state = mutableStateOf(mutableListOf(1), referentialEqualityPolicy()) val derivedState = derivedStateOf(referentialEqualityPolicy()) { state.value } - stateObserver.observeReads("scope", { changes++ }) { + stateObserver.observeReads(ValueWrapper("scope"), { changes++ }) { // read derivedState.value } @@ -485,7 +485,7 @@ val state = mutableStateOf(mutableListOf(1), referentialEqualityPolicy()) val derivedState = derivedStateOf(structuralEqualityPolicy()) { state.value } - stateObserver.observeReads("scope", { changes++ }) { + stateObserver.observeReads(ValueWrapper("scope"), { changes++ }) { // read derivedState.value } @@ -502,7 +502,7 @@ runSimpleTest { stateObserver, state -> val derivedState = derivedStateOf { state.value >= 0 } - stateObserver.observeReads("scope", { changes++ }) { + stateObserver.observeReads(ValueWrapper("scope"), { changes++ }) { // read derived state derivedState.value // read dependency @@ -525,9 +525,10 @@ null } } - val onChange: (String) -> Unit = { changes++ } + val onChange: (ValueWrapper) -> Unit = { changes++ } - stateObserver.observeReads("scope", onChange) { + val scope = ValueWrapper("scope") + stateObserver.observeReads(scope, onChange) { // read derived state derivedState.value } @@ -537,7 +538,7 @@ Snapshot.sendApplyNotifications() Snapshot.notifyObjectsInitialized() - stateObserver.observeReads("scope", onChange) { + stateObserver.observeReads(scope, onChange) { // read derived state derivedState.value } @@ -552,20 +553,21 @@ runSimpleTest { stateObserver, state -> val derivedState = derivedStateOf { state.value } - val onChange: (String) -> Unit = { changes++ } - stateObserver.observeReads("scope", onChange) { + val onChange: (ValueWrapper) -> Unit = { changes++ } + stateObserver.observeReads(ValueWrapper("scope"), onChange) { // read derived state derivedState.value } + val scope2 = ValueWrapper("other scope") // read the same state in other scope - stateObserver.observeReads("other scope", onChange) { derivedState.value } + stateObserver.observeReads(scope2, onChange) { derivedState.value } // advance snapshot to invalidate reads Snapshot.notifyObjectsInitialized() // stop observing state in other scope - stateObserver.observeReads("other scope", onChange) { + stateObserver.observeReads(scope2, onChange) { /* no-op */ } } @@ -581,20 +583,20 @@ stateObserver.start() Snapshot.notifyObjectsInitialized() - val onChange: (String) -> Unit = { scope -> - if (scope == "scope" && state1.value < 2) { + val onChange: (ValueWrapper) -> Unit = { scope -> + if (scope.s == "scope" && state1.value < 2) { state1.value++ Snapshot.sendApplyNotifications() } } - stateObserver.observeReads("scope", onChange) { + stateObserver.observeReads(ValueWrapper("scope"), onChange) { state1.value state2.value } repeat(10) { - stateObserver.observeReads("scope $it", onChange) { + stateObserver.observeReads(ValueWrapper("scope $it"), onChange) { state1.value state2.value } @@ -620,8 +622,8 @@ stateObserver.start() Snapshot.notifyObjectsInitialized() - val onChange: (String) -> Unit = { scope -> - if (scope == "scope" && state1.value < 2) { + val onChange: (ValueWrapper) -> Unit = { scope -> + if (scope.s == "scope" && state1.value < 2) { state1.value++ Snapshot.sendApplyNotifications() state2.value++ @@ -633,7 +635,7 @@ } } - stateObserver.observeReads("scope", onChange) { + stateObserver.observeReads(ValueWrapper("scope"), onChange) { state1.value state2.value state3.value @@ -641,7 +643,7 @@ } repeat(10) { - stateObserver.observeReads("scope $it", onChange) { + stateObserver.observeReads(ValueWrapper("scope $it"), onChange) { state1.value state2.value state3.value @@ -667,16 +669,17 @@ runSimpleTest { stateObserver, state -> val changeBlock: (Any) -> Unit = { changes++ } // record observation - stateObserver.observeReads("scope", changeBlock) { + val s = ValueWrapper("scope") + stateObserver.observeReads(s, changeBlock) { // read state state.value } // clear scope - stateObserver.clear("scope") + stateObserver.clear(s) // record again - stateObserver.observeReads("scope", changeBlock) { + stateObserver.observeReads(s, changeBlock) { // read state state.value } @@ -765,3 +768,6 @@ } } } + +// In k/js string is a primitive type and it doesn't have identityHashCode +private class ValueWrapper(val s: String)
diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/tooling/SnapshotObserverTests.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/tooling/SnapshotObserverTests.kt index 9614994..b412d06 100644 --- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/tooling/SnapshotObserverTests.kt +++ b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/snapshots/tooling/SnapshotObserverTests.kt
@@ -27,6 +27,7 @@ import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertTrue import kotlinx.test.IgnoreJsTarget @@ -318,7 +319,7 @@ } finally { ms1.dispose() } - assertEquals( + assertContentEquals( listOf( null to "Outer: creating, readonly = true", null to "Inner: creating, readonly = true",
diff --git a/compose/runtime/runtime/src/nonJvmMain/kotlin/androidx/compose/runtime/internal/Atomic.nonJvm.kt b/compose/runtime/runtime/src/nonJvmMain/kotlin/androidx/compose/runtime/internal/Atomic.nonJvm.kt new file mode 100644 index 0000000..06591ec --- /dev/null +++ b/compose/runtime/runtime/src/nonJvmMain/kotlin/androidx/compose/runtime/internal/Atomic.nonJvm.kt
@@ -0,0 +1,47 @@ +/* + * Copyright 2024 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.runtime.internal + +import kotlinx.atomicfu.atomic + +internal actual class AtomicReferenceactual constructor(value: V) { + private val delegate = atomic(value) + + actual fun get() = delegate.value + + actual fun set(value: V) { + delegate.value = value + } + + actual fun getAndSet(value: V) = delegate.getAndSet(value) + + actual fun compareAndSet(expect: V, newValue: V) = delegate.compareAndSet(expect, newValue) +} + +internal actual class AtomicInt actual constructor(value: Int) { + private val delegate = atomic(value) + + actual fun get(): Int = delegate.value + + actual fun set(value: Int) { + delegate.value = value + } + + actual fun add(amount: Int): Int = delegate.addAndGet(amount) + + actual fun compareAndSet(expect: Int, newValue: Int) = delegate.compareAndSet(expect, newValue) +}
diff --git a/compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/internal/Utils.web.kt b/compose/runtime/runtime/src/nonJvmMain/kotlin/androidx/compose/runtime/internal/Utils.nonJvm.kt similarity index 100% rename from compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/internal/Utils.web.kt rename to compose/runtime/runtime/src/nonJvmMain/kotlin/androidx/compose/runtime/internal/Utils.nonJvm.kt
diff --git a/compose/runtime/runtime/src/unixMain/kotlin/androidx/compose/runtime/platform/Synchronization.unix.kt b/compose/runtime/runtime/src/unixMain/kotlin/androidx/compose/runtime/platform/Synchronization.unix.kt new file mode 100644 index 0000000..cdb5f14 --- /dev/null +++ b/compose/runtime/runtime/src/unixMain/kotlin/androidx/compose/runtime/platform/Synchronization.unix.kt
@@ -0,0 +1,175 @@ +/* + * Copyright 2024 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.runtime.platform + +import androidx.compose.runtime.internal.currentThreadId +import kotlin.experimental.ExperimentalNativeApi +import kotlin.native.ref.createCleaner +import kotlinx.atomicfu.AtomicInt +import kotlinx.atomicfu.AtomicLong +import kotlinx.atomicfu.atomic +import kotlinx.cinterop.Arena +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.alloc +import kotlinx.cinterop.ptr +import platform.posix.pthread_cond_destroy +import platform.posix.pthread_cond_init +import platform.posix.pthread_cond_signal +import platform.posix.pthread_cond_t +import platform.posix.pthread_cond_wait +import platform.posix.pthread_mutex_destroy +import platform.posix.pthread_mutex_init +import platform.posix.pthread_mutex_lock +import platform.posix.pthread_mutex_t +import platform.posix.pthread_mutex_unlock +import platform.posix.pthread_mutexattr_destroy +import platform.posix.pthread_mutexattr_init +import platform.posix.pthread_mutexattr_settype +import platform.posix.pthread_mutexattr_t + +/** + * Wrapper for `platform.posix.PTHREAD_MUTEX_ERRORCHECK` which is represented as `kotlin.Int` on + * darwin platforms and `kotlin.UInt` on linuxX64. + * + * See: [KT-41509](https://youtrack.jetbrains.com/issue/KT-41509) + */ +internal expect val PTHREAD_MUTEX_ERRORCHECK: Int + +/** + * A synchronized object that provides thread-safe locking and unlocking operations. + * + * `SynchronizedObject` from `kotlinx-atomicfu` library was used before. However, it is still + * [experimental](https://github.com/Kotlin/kotlinx-atomicfu?tab=readme-ov-file#locks) and has + * [a performance problem](https://github.com/Kotlin/kotlinx-atomicfu/issues/412) that seriously + * affects Compose. + * + * This implementation is optimized for a non-contention case (that is the case for the current + * state of Compose for iOS), so it does not create a posix mutex when there is no contention: using + * a posix mutex has its own performance overheads. On the other hand, it does not just spin lock in + * case of contention, protecting from an occasional battery drain. + */ +@PublishedApi +internal actual class SynchronizedObject internal constructor() { + + private companion object { + private const val NO_OWNER = -1L + } + + private val owner: AtomicLong = atomic(NO_OWNER) + private var reEnterCount: Int = 0 + private val waiters: AtomicInt = atomic(0) + + private val monitorWrapper: MonitorWrapper by lazy { MonitorWrapper() } + private val monitor: NativeMonitor + get() = monitorWrapper.monitor + + @PublishedApi + internal fun lock() { + if (owner.value == currentThreadId()) { + reEnterCount += 1 + } else if (waiters.incrementAndGet() > 1) { + waitForUnlockAndLock() + } else { + if (!owner.compareAndSet(NO_OWNER, currentThreadId())) { + waitForUnlockAndLock() + } + } + } + + private fun waitForUnlockAndLock() { + withMonitor(monitor) { + while (!owner.compareAndSet(NO_OWNER, currentThreadId())) { + wait() + } + } + } + + @PublishedApi + internal fun unlock() { + require(owner.value == currentThreadId()) + if (reEnterCount > 0) { + reEnterCount -= 1 + } else { + owner.value = NO_OWNER + if (waiters.decrementAndGet() > 0) { + withMonitor(monitor) { notify() } + } + } + } + + private inline fun withMonitor(monitor: NativeMonitor, block: NativeMonitor.() -> Unit) { + monitor.run { + enter() + return try { + block() + } finally { + exit() + } + } + } + + private class MonitorWrapper { + val monitor: NativeMonitor = NativeMonitor() + @OptIn(ExperimentalNativeApi::class) + val cleaner = createCleaner(monitor, NativeMonitor::dispose) + } + + @OptIn(ExperimentalForeignApi::class) + private class NativeMonitor { + private val arena: Arena = Arena() + private val cond: pthread_cond_t = arena.alloc() + private val mutex: pthread_mutex_t = arena.alloc() + private val attr: pthread_mutexattr_t = arena.alloc() + + init { + require(pthread_cond_init(cond.ptr, null) == 0) + require(pthread_mutexattr_init(attr.ptr) == 0) + require(pthread_mutexattr_settype(attr.ptr, PTHREAD_MUTEX_ERRORCHECK) == 0) + require(pthread_mutex_init(mutex.ptr, attr.ptr) == 0) + } + + fun enter() = require(pthread_mutex_lock(mutex.ptr) == 0) + + fun exit() = require(pthread_mutex_unlock(mutex.ptr) == 0) + + fun wait() = require(pthread_cond_wait(cond.ptr, mutex.ptr) == 0) + + fun notify() = require(pthread_cond_signal(cond.ptr) == 0) + + fun dispose() { + pthread_cond_destroy(cond.ptr) + pthread_mutex_destroy(mutex.ptr) + pthread_mutexattr_destroy(attr.ptr) + arena.clear() + } + } +} + +@Suppress("NOTHING_TO_INLINE") +internal actual inline fun makeSynchronizedObject(ref: Any?) = SynchronizedObject() + +@PublishedApi +internal actual inline funsynchronized(lock: SynchronizedObject, block: () -> R): R { + lock.run { + lock() + return try { + block() + } finally { + unlock() + } + } +}
diff --git a/compose/runtime/runtime/src/androidInstrumentedTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.kt b/compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/ActualJs.wasmJs.kt similarity index 73% rename from compose/runtime/runtime/src/androidInstrumentedTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.kt rename to compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/ActualJs.wasmJs.kt index 488cac6..46c4e2d 100644 --- a/compose/runtime/runtime/src/androidInstrumentedTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.kt +++ b/compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/ActualJs.wasmJs.kt
@@ -14,8 +14,9 @@ * limitations under the License. */ -package kotlinx.test +package androidx.compose.runtime -annotation class NotIgnored +@JsFun("(obj, index) => obj[index]") +private external fun dynamicGetInt(obj: JsAny, index: String): Int? -actual typealias IgnoreAndroidUnitTestTarget = NotIgnored +@JsFun("(obj) => typeof obj") private external fun jsTypeOf(a: JsAny?): String
diff --git a/compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/MonotonicFrameClock.web.kt b/compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/MonotonicFrameClock.wasmJs.kt similarity index 100% copy from compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/MonotonicFrameClock.web.kt copy to compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/MonotonicFrameClock.wasmJs.kt
diff --git a/compose/runtime/runtime/src/androidInstrumentedTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.kt b/compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/OldIdentityHashCode.wasmJs.kt similarity index 63% copy from compose/runtime/runtime/src/androidInstrumentedTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.kt copy to compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/OldIdentityHashCode.wasmJs.kt index 488cac6..4eee10f 100644 --- a/compose/runtime/runtime/src/androidInstrumentedTest/kotlin/kotlinx/test/IgnoreAndroidUnitTestTarget.kt +++ b/compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/OldIdentityHashCode.wasmJs.kt
@@ -1,5 +1,5 @@ /* - * Copyright 2023 The Android Open Source Project + * Copyright 2025 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. @@ -14,8 +14,12 @@ * limitations under the License. */ -package kotlinx.test +package androidx.compose.runtime -annotation class NotIgnored - -actual typealias IgnoreAndroidUnitTestTarget = NotIgnored +@InternalComposeApi +@Deprecated( + level = DeprecationLevel.HIDDEN, + message = "Made internal. It wasn't supposed to be public", +) +fun identityHashCode(instance: Any?): Int = + androidx.compose.runtime.internal.identityHashCode(instance)
diff --git a/compose/runtime/runtime/src/nonJvmMain/kotlin/androidx/compose/runtime/internal/ComposableLambda.nonJvm.kt b/compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/internal/ComposableLambda.wasmJs.kt similarity index 89% copy from compose/runtime/runtime/src/nonJvmMain/kotlin/androidx/compose/runtime/internal/ComposableLambda.nonJvm.kt copy to compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/internal/ComposableLambda.wasmJs.kt index a17fe80..d111419 100644 --- a/compose/runtime/runtime/src/nonJvmMain/kotlin/androidx/compose/runtime/internal/ComposableLambda.nonJvm.kt +++ b/compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/internal/ComposableLambda.wasmJs.kt
@@ -14,12 +14,22 @@ * limitations under the License. */ +@file:OptIn(InternalComposeApi::class) + package androidx.compose.runtime.internal import androidx.compose.runtime.ComposeCompilerApi import androidx.compose.runtime.Composer +import androidx.compose.runtime.InternalComposeApi import androidx.compose.runtime.Stable +/** + * A Restart is created to hold composable lambdas to track when they are invoked allowing the + * invocations to be invalidated when a new composable lambda is created during composition. + * + * This allows much of the call-graph to be skipped when a composable function is passed through + * multiple levels of composable functions. + */ @ComposeCompilerApi @Stable actual interface ComposableLambda :
diff --git a/compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/internal/System.web.kt b/compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/internal/Utils.wasmJs.kt similarity index 95% rename from compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/internal/System.web.kt rename to compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/internal/Utils.wasmJs.kt index e6e5839..be49779 100644 --- a/compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/internal/System.web.kt +++ b/compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/internal/Utils.wasmJs.kt
@@ -1,5 +1,5 @@ /* - * Copyright 2025 The Android Open Source Project + * Copyright 2024 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.
diff --git a/compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.wasmJs.kt b/compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.wasmJs.kt index 9e8bda2..c0aa19a 100644 --- a/compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.wasmJs.kt +++ b/compose/runtime/runtime/src/wasmJsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.wasmJs.kt
@@ -22,27 +22,31 @@ actual typealias SnapshotId = Long -actual const val SnapshotIdZero: SnapshotId = 0L -actual const val SnapshotIdMax: SnapshotId = Long.MAX_VALUE -actual const val SnapshotIdSize: Int = Long.SIZE_BITS -actual const val SnapshotIdInvalidValue: SnapshotId = -1 +internal actual const val SnapshotIdZero: SnapshotId = 0L +internal actual const val SnapshotIdMax: SnapshotId = Long.MAX_VALUE +internal actual const val SnapshotIdSize: Int = Long.SIZE_BITS +internal actual const val SnapshotIdInvalidValue: SnapshotId = -1 -actual inline operator fun SnapshotId.compareTo(other: SnapshotId): Int = this.compareTo(other) +internal actual inline operator fun SnapshotId.compareTo(other: SnapshotId): Int = + this.compareTo(other) -actual inline operator fun SnapshotId.compareTo(other: Int): Int = this.compareTo(other.toLong()) +internal actual inline operator fun SnapshotId.compareTo(other: Int): Int = + this.compareTo(other.toLong()) -actual inline operator fun SnapshotId.plus(other: Int): SnapshotId = this + other.toLong() +internal actual inline operator fun SnapshotId.plus(other: Int): SnapshotId = this + other.toLong() -actual inline operator fun SnapshotId.minus(other: SnapshotId): SnapshotId = this - other +internal actual inline operator fun SnapshotId.minus(other: SnapshotId): SnapshotId = this - other -actual inline operator fun SnapshotId.minus(other: Int): SnapshotId = this - other.toLong() +internal actual inline operator fun SnapshotId.minus(other: Int): SnapshotId = this - other.toLong() -actual inline operator fun SnapshotId.div(other: Int): SnapshotId = this / other.toLong() +internal actual inline operator fun SnapshotId.div(other: Int): SnapshotId = this / other.toLong() -actual inline operator fun SnapshotId.times(other: Int): SnapshotId = this * other.toLong() +internal actual inline operator fun SnapshotId.times(other: Int): SnapshotId = this * other.toLong() actual inline fun SnapshotId.toInt(): Int = this.toInt() +actual inline fun SnapshotId.toLong(): Long = this + actual typealias SnapshotIdArray = LongArray internal actual fun snapshotIdArrayWithCapacity(capacity: Int): SnapshotIdArray = @@ -140,4 +144,4 @@ internal actual fun Int.toSnapshotId(): SnapshotId = toLong() -actual fun SnapshotId.toLong(): Long = this +internal actual fun Long.toSnapshotId(): SnapshotId = this
diff --git a/compose/runtime/runtime/src/wasmJsTest/kotlin/androidx/compose/runtime/Actuals.wasmJs.kt b/compose/runtime/runtime/src/wasmJsTest/kotlin/androidx/compose/runtime/Actuals.wasmJs.kt new file mode 100644 index 0000000..4c0f417 --- /dev/null +++ b/compose/runtime/runtime/src/wasmJsTest/kotlin/androidx/compose/runtime/Actuals.wasmJs.kt
@@ -0,0 +1,30 @@ +/* + * Copyright 2025 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.runtime + +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.await +import kotlinx.coroutines.promise +import kotlinx.coroutines.test.TestResult + +internal actual suspend fun TestResult.awaitCompletion() { + this.await() +} + +internal actual fun wrapTestWithCoroutine(block: suspend () -> Unit): TestResult { + return MainScope().promise { block() } +}
diff --git a/compose/runtime/runtime/src/wasmJsTest/kotlin/androidx/compose/runtime/TestUtils.wasm.kt b/compose/runtime/runtime/src/wasmJsTest/kotlin/androidx/compose/runtime/TestUtils.wasm.kt new file mode 100644 index 0000000..ee8d629 --- /dev/null +++ b/compose/runtime/runtime/src/wasmJsTest/kotlin/androidx/compose/runtime/TestUtils.wasm.kt
@@ -0,0 +1,26 @@ +/* + * Copyright 2025 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.runtime + +import kotlinx.coroutines.* + +@OptIn(DelicateCoroutinesApi::class) +// TODO: Make a proper implementation with timeoutMs +// https://youtrack.jetbrains.com/issue/CMP-662 +actual suspend fun testWithTimeout(timeoutMs: Long, block: suspend CoroutineScope.() -> Unit) { + GlobalScope.launch { block() } +}
diff --git a/compose/runtime/runtime/src/wasmJsTest/kotlin/androidx/compose/runtime/internal/IdentityHashCodeTest.kt b/compose/runtime/runtime/src/wasmJsTest/kotlin/androidx/compose/runtime/internal/IdentityHashCodeTest.kt new file mode 100644 index 0000000..7218bb9 --- /dev/null +++ b/compose/runtime/runtime/src/wasmJsTest/kotlin/androidx/compose/runtime/internal/IdentityHashCodeTest.kt
@@ -0,0 +1,51 @@ +/* + * Copyright 2025 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.runtime.internal + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class IdentityHashCodeTest { + + @Test + fun smokeTest() { + val a = DefaultImpl() + val b = DefaultImpl() + + assertEquals(a, a) + assertNotEquals(a, b) + assertNotEquals(b, a) + + val set = mutableSetOf() + set.add(a) + set.add(a) + set.add(b) + + assertEquals(set.size, 2) + } +} + +private class DefaultImpl { + override fun equals(other: Any?): Boolean { + return this === other + } + + override fun hashCode(): Int { + return identityHashCode(this) + } +}
diff --git a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt b/compose/runtime/runtime/src/wasmJsTest/kotlin/kotlinx/test/IngoreJsTarget.wasmJs.kt similarity index 84% copy from compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt copy to compose/runtime/runtime/src/wasmJsTest/kotlin/kotlinx/test/IngoreJsTarget.wasmJs.kt index 3bf4637..c1ff5e8 100644 --- a/compose/runtime/runtime/src/webTest/kotlin/kotlinx/test/IgnoreJsTarget.web.kt +++ b/compose/runtime/runtime/src/wasmJsTest/kotlin/kotlinx/test/IngoreJsTarget.wasmJs.kt
@@ -1,5 +1,5 @@ /* - * Copyright 2024 The Android Open Source Project + * Copyright 2025 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. @@ -16,4 +16,4 @@ package kotlinx.test -actual typealias IgnoreJsTarget = kotlin.test.Ignore +actual typealias IgnoreWasmTarget = kotlin.test.Ignore
diff --git a/compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/internal/Atomic.web.kt b/compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/internal/Atomic.web.kt deleted file mode 100644 index f3705bc..0000000 --- a/compose/runtime/runtime/src/webMain/kotlin/androidx/compose/runtime/internal/Atomic.web.kt +++ /dev/null
@@ -1,66 +0,0 @@ -/* - * Copyright 2024 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.runtime.internal - -internal actual class AtomicReferenceactual constructor(value: V) { - private var value: V = value - - actual fun get() = value - - actual fun set(value: V) { - this.value = value - } - - actual fun getAndSet(value: V): V { - val oldValue = this.value - this.value = value - return oldValue - } - - actual fun compareAndSet(expect: V, newValue: V): Boolean { - return if (this.value == expect) { - this.value = newValue - true - } else { - false - } - } -} - -internal actual class AtomicInt actual constructor(value: Int) { - private var value: Int = value - - actual fun get(): Int = value - - actual fun set(value: Int) { - this.value = value - } - - actual fun add(amount: Int): Int { - value += amount - return value - } - - actual fun compareAndSet(expect: Int, newValue: Int): Boolean { - return if (value == expect) { - value = newValue - true - } else { - false - } - } -}
diff --git a/compose/runtime/runtime/src/webTest/kotlin/androidx/compose/runtime/CompositionTests.web.kt b/compose/runtime/runtime/src/webTest/kotlin/androidx/compose/runtime/CompositionTests.web.kt new file mode 100644 index 0000000..a4f8dc0 --- /dev/null +++ b/compose/runtime/runtime/src/webTest/kotlin/androidx/compose/runtime/CompositionTests.web.kt
@@ -0,0 +1,99 @@ +/* + * Copyright 2025 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.runtime + +import androidx.compose.runtime.mock.compositionTest +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.test.TestResult + +// This test duplicates the common CompositionTest, but makes it run properly on web. +// This test requires that compositionTest is called in the test body. +// On Web, compositionTest calls returns a Promise, and it's ignored when not returned from the Test +// method. +// Here we make sure it runs (not ignored) by wrapping it in another Promise returned from the Test +// method. +class CompositionTestWeb { + + @Test // https://youtrack.jetbrains.com/issue/CMP-7453 + fun testRememberObserver_Abandon_Recompose() = wrapTestWithCoroutine { + val abandonedObjects = mutableListOf() + val observed = + object : RememberObserver { + override fun onAbandoned() { + abandonedObjects.add(this) + } + + override fun onForgotten() { + error("Unexpected call to onForgotten") + } + + override fun onRemembered() { + error("Unexpected call to onRemembered") + } + } + + var promiseStarted = false + var promiseCompleted = false + + assertFailsWith(IllegalStateException::class, message = "Throw") { + promiseStarted = true + compositionTest { + val rememberObject = mutableStateOf(false) + + compose { + if (rememberObject.value) { + @Suppress("UNUSED_EXPRESSION") remember { observed } + error("Throw") + } + } + + assertTrue(abandonedObjects.isEmpty()) + + rememberObject.value = true + + advance(ignorePendingWork = true) + } + .awaitCompletion() + + promiseCompleted = true + } + + assertTrue(promiseStarted) + assertFalse(promiseCompleted) + assertArrayEquals(listOf(observed), abandonedObjects) + } +} + +internal expect suspend fun TestResult.awaitCompletion() + +internal expect fun wrapTestWithCoroutine(block: suspend () -> Unit): TestResult + +class TestWrapTest { + + @Test + fun t() = wrapTestWithCoroutine { + var result = false + + kotlinx.coroutines.test.runTest { result = true }.awaitCompletion() + + assertTrue(result) + println("Completed\n") + } +}
diff --git a/development/build_log_simplifier/messages.ignore b/development/build_log_simplifier/messages.ignore index 5e63f7b..716b5dc 100644 --- a/development/build_log_simplifier/messages.ignore +++ b/development/build_log_simplifier/messages.ignore
@@ -367,6 +367,7 @@ \[log\].* # b/382336155 Capture more logging for Kotlin JS Tests .*STANDARD_OUT +Completed # Warning emitted on empty Java files generated from AIDL: # https://github.com/kythe/kythe/issues/6145 .*com.google\.devtools\.kythe\.extractors\.java\.JavaCompilationUnitExtractor.* @@ -402,4 +403,4 @@ # > Task :core:core:compileReleaseAndroidTestKotlin w: file://\$SUPPORT/core/core/src/androidTest/java/androidx/core/view/WindowCompatTest\.kt:[0-9]+:[0-9]+ 'var statusBarColor: Int' is deprecated\. Deprecated in Java\. w: file://\$SUPPORT/core/core/src/androidTest/java/androidx/core/view/WindowCompatTest\.kt:[0-9]+:[0-9]+ 'var navigationBarColor: Int' is deprecated\. Deprecated in Java\. -w: file://\$SUPPORT/core/core/src/androidTest/java/androidx/core/view/WindowCompatTest\.kt:[0-9]+:[0-9]+ 'var isStatusBarContrastEnforced: Boolean' is deprecated\. Deprecated in Java\. +w: file://\$SUPPORT/core/core/src/androidTest/java/androidx/core/view/WindowCompatTest\.kt:[0-9]+:[0-9]+ 'var isStatusBarContrastEnforced: Boolean' is deprecated\. Deprecated in Java\. \ No newline at end of file
diff --git a/fragment/fragment-lint/build.gradle b/fragment/fragment-lint/build.gradle index 3c4b545..bc34169 100644 --- a/fragment/fragment-lint/build.gradle +++ b/fragment/fragment-lint/build.gradle
@@ -40,7 +40,6 @@ testImplementation(libs.kotlinStdlib) testRuntimeOnly(libs.kotlinReflect) - testImplementation(libs.kotlinStdlibJdk8) testImplementation(libs.androidLintApi) testImplementation(libs.androidLintTests) testImplementation(libs.junit)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 715f5f4..daef513 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml
@@ -223,7 +223,6 @@ kotlinGradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kotlinStdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib" } kotlinStdlibCommon = { module = "org.jetbrains.kotlin:kotlin-stdlib-common" } -kotlinStdlibJdk8 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8" } # The Kotlin/JS and Kotlin/Wasm standard library must match the compiler kotlinStdlibJs = { module = "org.jetbrains.kotlin:kotlin-stdlib-js", version.ref = "kotlin" } kotlinStdlibWasm = { module = "org.jetbrains.kotlin:kotlin-stdlib-wasm-js", version.ref = "kotlin" } @@ -233,9 +232,11 @@ kotlinTestCommon = { module = "org.jetbrains.kotlin:kotlin-test-common" } kotlinTestJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit" } kotlinTestJs = { module = "org.jetbrains.kotlin:kotlin-test-js" } +kotlinTestWasm = { module = "org.jetbrains.kotlin:kotlin-test-wasm-js" } kotlinReflect = { module = "org.jetbrains.kotlin:kotlin-reflect" } kotlinPoet = { module = "com.squareup:kotlinpoet", version = "2.1.0" } kotlinPoetJavaPoet = { module = "com.squareup:kotlinpoet-javapoet", version = "2.1.0" } +kotlinXw3c = { module = "org.jetbrains.kotlinx:kotlinx-browser", version = "0.3" } ksp = { module = "com.google.devtools.ksp:symbol-processing", version.ref = "ksp" } kspApi = { module = "com.google.devtools.ksp:symbol-processing-api", version = "2.0.10-1.0.24" } kspCommon = { module = "com.google.devtools.ksp:symbol-processing-common-deps", version.ref = "ksp" }
diff --git a/lifecycle/lifecycle-runtime-compose-lint/build.gradle b/lifecycle/lifecycle-runtime-compose-lint/build.gradle index 4c69a20..e507a4c 100644 --- a/lifecycle/lifecycle-runtime-compose-lint/build.gradle +++ b/lifecycle/lifecycle-runtime-compose-lint/build.gradle
@@ -40,7 +40,6 @@ testImplementation(project(":compose:lint:common-test")) testImplementation(libs.kotlinStdlib) testImplementation(libs.kotlinReflect) - testImplementation(libs.kotlinStdlibJdk8) testImplementation(libs.androidLint) testImplementation(libs.androidLintTests) testImplementation(libs.junit)
diff --git a/lifecycle/lifecycle-runtime-lint/build.gradle b/lifecycle/lifecycle-runtime-lint/build.gradle index bc3c278..6739d10 100644 --- a/lifecycle/lifecycle-runtime-lint/build.gradle +++ b/lifecycle/lifecycle-runtime-lint/build.gradle
@@ -34,7 +34,6 @@ testImplementation(libs.kotlinStdlib) testImplementation(libs.kotlinReflect) - testImplementation(libs.kotlinStdlibJdk8) testImplementation(libs.androidLint) testImplementation(libs.androidLintTests) testImplementation(libs.junit)
diff --git a/lifecycle/lifecycle-runtime-testing-lint/build.gradle b/lifecycle/lifecycle-runtime-testing-lint/build.gradle index 5483ba3..8d95b36 100644 --- a/lifecycle/lifecycle-runtime-testing-lint/build.gradle +++ b/lifecycle/lifecycle-runtime-testing-lint/build.gradle
@@ -40,7 +40,6 @@ testImplementation(project(":compose:lint:common-test")) testImplementation(libs.kotlinStdlib) testImplementation(libs.kotlinReflect) - testImplementation(libs.kotlinStdlibJdk8) testImplementation(libs.androidLint) testImplementation(libs.androidLintTests) testImplementation(libs.junit)
diff --git a/lifecycle/lifecycle-viewmodel-compose-lint/build.gradle b/lifecycle/lifecycle-viewmodel-compose-lint/build.gradle index a116be3..785bd67 100644 --- a/lifecycle/lifecycle-viewmodel-compose-lint/build.gradle +++ b/lifecycle/lifecycle-viewmodel-compose-lint/build.gradle
@@ -40,7 +40,6 @@ testImplementation(project(":compose:lint:common-test")) testImplementation(libs.kotlinStdlib) testImplementation(libs.kotlinReflect) - testImplementation(libs.kotlinStdlibJdk8) testImplementation(libs.androidLint) testImplementation(libs.androidLintTests) testImplementation(libs.junit)
diff --git a/navigation/navigation-compose-lint/build.gradle b/navigation/navigation-compose-lint/build.gradle index bed2ddf..4cfa553 100644 --- a/navigation/navigation-compose-lint/build.gradle +++ b/navigation/navigation-compose-lint/build.gradle
@@ -44,7 +44,6 @@ testImplementation(project(":compose:lint:common-test")) testImplementation(project(":navigation:lint:common-test")) testImplementation(libs.kotlinStdlib) - testImplementation(libs.kotlinStdlibJdk8) testImplementation(libs.androidLintApiStableAnalysis) testImplementation(libs.androidLintTests) testImplementation(libs.junit)
diff --git a/navigation3/navigation3-runtime/src/androidInstrumentedTest/kotlin/androidx/navigation3/runtime/DecoratedNavEntryProviderTest.kt b/navigation3/navigation3-runtime/src/androidInstrumentedTest/kotlin/androidx/navigation3/runtime/DecoratedNavEntryProviderTest.kt index 35f4579..30b5b3e 100644 --- a/navigation3/navigation3-runtime/src/androidInstrumentedTest/kotlin/androidx/navigation3/runtime/DecoratedNavEntryProviderTest.kt +++ b/navigation3/navigation3-runtime/src/androidInstrumentedTest/kotlin/androidx/navigation3/runtime/DecoratedNavEntryProviderTest.kt
@@ -149,6 +149,65 @@ } @Test + fun decoratorsOnPopImmutableBackStack() { + val poppedEntries = mutableListOf() + lateinit var backStackState: MutableState+ lateinit var backStack: List + + val decorator = + createTestNavEntryDecorator (onPop = { key -> poppedEntries.add(key as Int) }) { + entry -> + entry.Content() + } + composeTestRule.setContent { + backStackState = remember { mutableStateOf(2) } + backStack = + when (backStackState.value) { + 1 -> { + listOf(1) + } + 2 -> { + listOf(1, 2) + } + else -> listOf(1, 3) + } + DecoratedNavEntryProvider( + backStack = backStack, + entryDecorators = listOf(decorator), + entryProvider = { key -> + when (key) { + 1 -> NavEntry(1, 1) {} + 2 -> NavEntry(2, 2) {} + 3 -> NavEntry(3, 3) {} + else -> error("Invalid Key") + } + }, + ) { entries -> + entries.lastOrNull()?.Content() + } + } + composeTestRule.waitForIdle() + assertThat(backStack).containsExactly(1, 2).inOrder() + + backStackState.value = 1 // pop 2 + + composeTestRule.waitForIdle() + assertThat(backStack).containsExactly(1).inOrder() + assertThat(poppedEntries).containsExactly(2) + + backStackState.value = 3 // add 3 + + composeTestRule.waitForIdle() + assertThat(backStack).containsExactly(1, 3).inOrder() + + backStackState.value = 1 // pop 3 + + composeTestRule.waitForIdle() + assertThat(backStack).containsExactly(1).inOrder() + assertThat(poppedEntries).containsExactly(2, 3).inOrder() + } + + @Test fun decoratorsOnPopOrder() { var count = -1 var outerPop = -1 @@ -508,8 +567,55 @@ lateinit var backStackState: MutableStatecomposeTestRule.setContent { + val backStack1 = mutableStateListOf(key1) + val backStack2 = mutableStateListOf(key2) + backStackState = remember { mutableStateOf(1) } + val backStack = + when (backStackState.value) { + 1 -> backStack1 + else -> backStack2 + } + DecoratedNavEntryProvider( + backStack = backStack, + entryDecorators = listOf(decorator), + entryProvider = entryProvider { entry ({ it.arg }) {} }, + ) { entries -> + entries.lastOrNull()?.Content() + } + } + + composeTestRule.runOnIdle { assertThat(stateMap).containsExactly(1 to "state") } + + backStackState.value = 2 + + composeTestRule.waitForIdle() + assertThat(stateMap).containsExactly(1 to "state", 2 to "state") + + backStackState.value = 1 + + composeTestRule.waitForIdle() + assertThat(stateMap).containsExactly(1 to "state", 2 to "state") + } + + @Test + fun popDuplicateWithImmutableBackStackPreservesState() { + val key1 = DataClass(1) + val key2 = DataClass(2) + + val stateMap = mutableMapOf() + val decorator = + createTestNavEntryDecorator( + onPop = { contentKey -> stateMap.remove(contentKey) } + ) { entry -> + stateMap.put(entry.contentKey as Int, "state") + entry.Content() + } + lateinit var backStackState: MutableState+ + composeTestRule.setContent { val backStack1 = mutableStateListOf(key1, key2) - val backStack2 = mutableStateListOf(key1) + val backStack2 = mutableStateListOf(key1, key2, key2) + backStackState = remember { mutableStateOf(1) } val backStack = when (backStackState.value) { @@ -526,16 +632,63 @@ } composeTestRule.runOnIdle { assertThat(stateMap).containsExactly(2 to "state") } - - backStackState.value = 2 + backStackState.value = 2 // add 2 again composeTestRule.waitForIdle() - assertThat(stateMap).containsExactly(2 to "state", 1 to "state") + assertThat(stateMap).containsExactly(2 to "state") - backStackState.value = 1 + backStackState.value = 1 // pop duplicate 2 composeTestRule.waitForIdle() - assertThat(stateMap).containsExactly(2 to "state", 1 to "state") + assertThat(stateMap).containsExactly(2 to "state") + } + + @Test + fun popAllDuplicatesWithImmutableBackStackClearsState() { + val key1 = DataClass(1) + val key2 = DataClass(2) + + val stateMap = mutableMapOf () + val decorator = + createTestNavEntryDecorator( + onPop = { contentKey -> stateMap.remove(contentKey) } + ) { entry -> + stateMap.put(entry.contentKey as Int, "state") + entry.Content() + } + lateinit var backStackState: MutableState+ + composeTestRule.setContent { + val backStack1 = mutableStateListOf(key1, key2) + val backStack2 = mutableStateListOf(key1, key2, key2) + val backStack3 = mutableStateListOf(key1) + + backStackState = remember { mutableStateOf(1) } + val backStack = + when (backStackState.value) { + 1 -> backStack1 + 2 -> backStack2 + else -> backStack3 + } + DecoratedNavEntryProvider( + backStack = backStack, + entryDecorators = listOf(decorator), + entryProvider = entryProvider { entry ({ it.arg }) {} }, + ) { entries -> + entries.lastOrNull()?.Content() + } + } + + composeTestRule.runOnIdle { assertThat(stateMap).containsExactly(2 to "state") } + backStackState.value = 2 // add 2 again + + composeTestRule.waitForIdle() + assertThat(stateMap).containsExactly(2 to "state") + + backStackState.value = 3 // pop both 2's + + composeTestRule.waitForIdle() + assertThat(stateMap).containsExactly(1 to "state") } private data class DataClass(val arg: Int)
diff --git a/navigation3/navigation3-runtime/src/commonMain/kotlin/androidx/navigation3/runtime/DecoratedNavEntryProvider.kt b/navigation3/navigation3-runtime/src/commonMain/kotlin/androidx/navigation3/runtime/DecoratedNavEntryProvider.kt index bd30e87..8a2f4e9 100644 --- a/navigation3/navigation3-runtime/src/commonMain/kotlin/androidx/navigation3/runtime/DecoratedNavEntryProvider.kt +++ b/navigation3/navigation3-runtime/src/commonMain/kotlin/androidx/navigation3/runtime/DecoratedNavEntryProvider.kt
@@ -22,7 +22,6 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.staticCompositionLocalOf @@ -52,27 +51,16 @@ // Kotlin does not know these things are compatible so we need this explicit cast // to ensure our lambda below takes the correct type entryProvider as (T) -> NavEntry+ val entries = + backStack.mapIndexed { index, key -> + val entry = entryProvider.invoke(key) + decorateEntry(entry, entryDecorators as List >) + } - /** - * We scope the block to a backStack to support multiple backStack / backStack swapping with - * hoisted states. The DisposableEffects/onDispose for a particular backStack will only trigger - * for operations on the same backStack i.e. navigate / pop. This is so that when we swap - * between two lists, i.e. from listOf[A, B] to listOf[C, D], A and B will not be considered as - * pop, meaning their state will be preserved and recovered when returning to the first list. - */ - key(backStack) { - // Generates a list of entries that are wrapped with the given providers - val entries = - backStack.mapIndexed { index, key -> - val entry = entryProvider.invoke(key) - decorateEntry(entry, entryDecorators as List>) - } + // Provides the entire backstack to the previously wrapped entries + val initial: @Composable () -> Unit = remember(entries) { { content(entries) } } - // Provides the entire backstack to the previously wrapped entries - val initial: @Composable () -> Unit = remember(entries) { { content(entries) } } - - PrepareBackStack(entries, entryDecorators, initial) - } + PrepareBackStack(entries, entryDecorators, initial) } /** @@ -151,8 +139,10 @@ DisposableEffect(contentKey) { onDispose { + val originalRoot = entries.first().contentKey + val sameBackStack = originalRoot == latestBackStack.first() val popped = - if (!latestBackStack.contains(contentKey)) { + if (sameBackStack && !latestBackStack.contains(contentKey)) { contentKeys.remove(contentKey) } else false // run onPop callback
diff --git a/navigation3/navigation3-ui/src/androidInstrumentedTest/kotlin/androidx/navigation3/ui/AnimatedTest.kt b/navigation3/navigation3-ui/src/androidInstrumentedTest/kotlin/androidx/navigation3/ui/AnimatedTest.kt index 6a3fb6d..d5a5452 100644 --- a/navigation3/navigation3-ui/src/androidInstrumentedTest/kotlin/androidx/navigation3/ui/AnimatedTest.kt +++ b/navigation3/navigation3-ui/src/androidInstrumentedTest/kotlin/androidx/navigation3/ui/AnimatedTest.kt
@@ -29,8 +29,10 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Text import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.MutableState import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -60,7 +62,7 @@ @get:Rule val composeTestRule = createComposeRule() @Test - fun testNavHostAnimations() { + fun testNavigateAnimations() { lateinit var backStack: MutableListlateinit var firstLifecycle: Lifecycle lateinit var secondLifecycle: Lifecycle @@ -116,7 +118,73 @@ } @Test - fun testNavHostAnimationsCustom() { + fun testNavigateAnimationsImmutableBackStack() { + lateinit var backStack: List + lateinit var backStackState: MutableState + lateinit var firstLifecycle: Lifecycle + lateinit var secondLifecycle: Lifecycle + + composeTestRule.mainClock.autoAdvance = false + + composeTestRule.setContent { + backStackState = remember { mutableStateOf(1) } + backStack = + when (backStackState.value) { + 1 -> { + listOf(first) + } + else -> { + listOf(first, second) + } + } + NavDisplay(backStack) { + when (it) { + first -> + NavEntry(first) { + firstLifecycle = LocalLifecycleOwner.current.lifecycle + Text(first) + } + second -> + NavEntry(second) { + secondLifecycle = LocalLifecycleOwner.current.lifecycle + Text(second) + } + else -> error("Invalid key passed") + } + } + } + + composeTestRule.mainClock.autoAdvance = true + + composeTestRule.waitForIdle() + assertThat(composeTestRule.onNodeWithText(first).isDisplayed()).isTrue() + assertThat(firstLifecycle.currentState).isEqualTo(Lifecycle.State.RESUMED) + + composeTestRule.mainClock.autoAdvance = false + + composeTestRule.runOnIdle { backStackState.value = 2 } + + // advance half way between animations + composeTestRule.mainClock.advanceTimeBy( + DEFAULT_TRANSITION_DURATION_MILLISECOND.toLong() / 2 + ) + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(first).assertExists() + assertThat(firstLifecycle.currentState).isEqualTo(Lifecycle.State.STARTED) + composeTestRule.onNodeWithText(second).assertExists() + assertThat(secondLifecycle.currentState).isEqualTo(Lifecycle.State.STARTED) + + composeTestRule.mainClock.autoAdvance = true + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(first).assertDoesNotExist() + composeTestRule.onNodeWithText(second).assertExists() + assertThat(secondLifecycle.currentState).isEqualTo(Lifecycle.State.RESUMED) + } + + @Test + fun testNavigateAnimationsCustom() { lateinit var backStack: MutableList composeTestRule.mainClock.autoAdvance = false @@ -212,6 +280,54 @@ } @Test + fun testPopAnimationsImmutableBackStack() { + lateinit var backStack: List + lateinit var backStackState: MutableState + composeTestRule.setContent { + backStackState = remember { mutableStateOf(1) } + backStack = + when (backStackState.value) { + 1 -> { + listOf(first, second) + } + else -> { + listOf(first) + } + } + NavDisplay(backStack) { + when (it) { + first -> NavEntry(first) { Text(first) } + second -> NavEntry(second) { Text(second) } + else -> error("Invalid key passed") + } + } + } + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(second).assertIsDisplayed() + assertThat(backStack).containsExactly(first, second) + + composeTestRule.mainClock.autoAdvance = false + composeTestRule.runOnIdle { backStackState.value = 2 } + + // advance half way between animations + composeTestRule.mainClock.advanceTimeBy( + (DEFAULT_TRANSITION_DURATION_MILLISECOND / 2).toLong() + ) + + composeTestRule.waitForIdle() + // pop to first + assertThat(backStack).containsExactly(first) + composeTestRule.onNodeWithText(first).assertIsDisplayed() + composeTestRule.onNodeWithText(second).assertIsDisplayed() + + composeTestRule.mainClock.autoAdvance = true + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(first).assertIsDisplayed() + composeTestRule.onNodeWithText(second).assertDoesNotExist() + } + + @Test fun testPopMultiple() { lateinit var backStack: MutableList val testDuration = DEFAULT_TRANSITION_DURATION_MILLISECOND / 5
diff --git a/privacysandbox/tools/tools-apipackager/build.gradle b/privacysandbox/tools/tools-apipackager/build.gradle index c55dcb6..66f007ca 100644 --- a/privacysandbox/tools/tools-apipackager/build.gradle +++ b/privacysandbox/tools/tools-apipackager/build.gradle
@@ -32,7 +32,6 @@ api(project(":privacysandbox:tools:tools")) api(project(":privacysandbox:tools:tools-core")) api(libs.kotlinStdlib) - implementation(libs.kotlinStdlibJdk8) implementation(libs.asm) implementation(libs.asmCommons) implementation(libs.kotlinMetadataJvm) {
diff --git a/room/room-compiler-processing-testing/build.gradle b/room/room-compiler-processing-testing/build.gradle index f0cc0bb..20b0811 100644 --- a/room/room-compiler-processing-testing/build.gradle +++ b/room/room-compiler-processing-testing/build.gradle
@@ -33,7 +33,6 @@ dependencies { api(project(":room:room-compiler-processing")) - implementation(libs.kotlinStdlibJdk8) // For Java source compilation implementation(libs.googleCompileTesting) // For KSP processing
diff --git a/room/room-compiler-processing/build.gradle b/room/room-compiler-processing/build.gradle index 01071b4..d2f4d60 100644 --- a/room/room-compiler-processing/build.gradle +++ b/room/room-compiler-processing/build.gradle
@@ -34,7 +34,7 @@ dependencies { api(libs.jspecify) - api(libs.kotlinStdlibJdk8) + api(libs.kotlinStdlib) api(libs.kspApi) api(libs.javapoet) api("com.squareup:kotlinpoet:2.0.0")
diff --git a/savedstate/savedstate/build.gradle b/savedstate/savedstate/build.gradle index 1ed826b..b799311 100644 --- a/savedstate/savedstate/build.gradle +++ b/savedstate/savedstate/build.gradle
@@ -37,7 +37,7 @@ dependencies { api(libs.kotlinStdlib) api("androidx.annotation:annotation:1.9.1") - implementation(project(":lifecycle:lifecycle-common")) + implementation("androidx.lifecycle:lifecycle-common:2.9.2") api(libs.kotlinCoroutinesCore) api(libs.kotlinSerializationCore) } @@ -45,7 +45,7 @@ commonTest { dependencies { - implementation(project(":lifecycle:lifecycle-runtime")) + implementation("androidx.lifecycle:lifecycle-runtime:2.9.2") implementation(project(":kruth:kruth")) implementation(libs.kotlinTest) implementation(libs.kotlinTestCommon) @@ -81,7 +81,7 @@ androidInstrumentedTest { dependsOn(jvmTest) dependencies { - implementation("androidx.lifecycle:lifecycle-runtime:2.9.1") + implementation("androidx.lifecycle:lifecycle-runtime:2.9.2") implementation(libs.testExtJunit) implementation(libs.testCore) implementation(libs.testRunner)
diff --git a/slidingpanelayout/slidingpanelayout/api/current.txt b/slidingpanelayout/slidingpanelayout/api/current.txt index 0308d92..1096f5a 100644 --- a/slidingpanelayout/slidingpanelayout/api/current.txt +++ b/slidingpanelayout/slidingpanelayout/api/current.txt
@@ -13,6 +13,8 @@ method public boolean closePane(); method public final boolean closePane(int duration, android.view.animation.Interpolator interpolator); method @Deprecated @ColorInt public int getCoveredFadeColor(); + method @Px public final int getDividerVisualOffsetHorizontal(); + method @Px public final int getDividerVisualOffsetVertical(); method public final int getLockMode(); method @Px public final int getPaneSpacing(); method @Px public int getParallaxDistance(); @@ -33,6 +35,8 @@ method public void removeSlideableStateListener(androidx.slidingpanelayout.widget.SlidingPaneLayout.SlideableStateListener listener); method public final void setChildClippingToResizeDividerEnabled(boolean); method @Deprecated public void setCoveredFadeColor(int); + method public final void setDividerVisualOffsetHorizontal(int); + method public final void setDividerVisualOffsetVertical(int); method public final void setLockMode(int); method public final void setOnUserResizingDividerClickListener(android.view.View.OnClickListener? listener); method public final void setOverlappingEnabled(boolean); @@ -55,6 +59,8 @@ method @Deprecated public void smoothSlideClosed(); method @Deprecated public void smoothSlideOpen(); property @Deprecated @ColorInt public int coveredFadeColor; + property @Px public final int dividerVisualOffsetHorizontal; + property @Px public final int dividerVisualOffsetVertical; property public final boolean isChildClippingToResizeDividerEnabled; property public final boolean isDividerDragging; property public final boolean isOverlappingEnabled;
diff --git a/slidingpanelayout/slidingpanelayout/api/restricted_current.txt b/slidingpanelayout/slidingpanelayout/api/restricted_current.txt index 0308d92..1096f5a 100644 --- a/slidingpanelayout/slidingpanelayout/api/restricted_current.txt +++ b/slidingpanelayout/slidingpanelayout/api/restricted_current.txt
@@ -13,6 +13,8 @@ method public boolean closePane(); method public final boolean closePane(int duration, android.view.animation.Interpolator interpolator); method @Deprecated @ColorInt public int getCoveredFadeColor(); + method @Px public final int getDividerVisualOffsetHorizontal(); + method @Px public final int getDividerVisualOffsetVertical(); method public final int getLockMode(); method @Px public final int getPaneSpacing(); method @Px public int getParallaxDistance(); @@ -33,6 +35,8 @@ method public void removeSlideableStateListener(androidx.slidingpanelayout.widget.SlidingPaneLayout.SlideableStateListener listener); method public final void setChildClippingToResizeDividerEnabled(boolean); method @Deprecated public void setCoveredFadeColor(int); + method public final void setDividerVisualOffsetHorizontal(int); + method public final void setDividerVisualOffsetVertical(int); method public final void setLockMode(int); method public final void setOnUserResizingDividerClickListener(android.view.View.OnClickListener? listener); method public final void setOverlappingEnabled(boolean); @@ -55,6 +59,8 @@ method @Deprecated public void smoothSlideClosed(); method @Deprecated public void smoothSlideOpen(); property @Deprecated @ColorInt public int coveredFadeColor; + property @Px public final int dividerVisualOffsetHorizontal; + property @Px public final int dividerVisualOffsetVertical; property public final boolean isChildClippingToResizeDividerEnabled; property public final boolean isDividerDragging; property public final boolean isOverlappingEnabled;
diff --git a/slidingpanelayout/slidingpanelayout/src/androidTest/java/androidx/slidingpanelayout/widget/UserResizeModeTest.kt b/slidingpanelayout/slidingpanelayout/src/androidTest/java/androidx/slidingpanelayout/widget/UserResizeModeTest.kt index d489e1e..646157e 100644 --- a/slidingpanelayout/slidingpanelayout/src/androidTest/java/androidx/slidingpanelayout/widget/UserResizeModeTest.kt +++ b/slidingpanelayout/slidingpanelayout/src/androidTest/java/androidx/slidingpanelayout/widget/UserResizeModeTest.kt
@@ -42,6 +42,8 @@ import androidx.test.platform.app.InstrumentationRegistry import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertWithMessage +import kotlin.math.max +import kotlin.math.roundToInt import org.junit.Test import org.junit.runner.RunWith @@ -198,7 +200,7 @@ } @Test - fun dragDividerUpdatesUserDivierDrawableState() { + fun dragDividerUpdatesUserDividerDrawableState() { val context = InstrumentationRegistry.getInstrumentation().context val spl = createTestSpl(context, childPanesAcceptTouchEvents = true) @@ -677,6 +679,152 @@ spl.dispatchGenericMotionEvent(mouseEvent(MotionEvent.ACTION_DOWN, x, y)) assertThat(android.R.attr.state_hovered in testDrawable.drawableState).isFalse() } + + @Test + fun dividerVisualOffsetHorizontal_updatesTouchBounds() { + val context = InstrumentationRegistry.getInstrumentation().context + val touchTargetMinSize = (context.resources.displayMetrics.density * 48).roundToInt() + + val dividerWidth = max(100, touchTargetMinSize) + val dividerHeight = max(200, touchTargetMinSize) + + val spl = + createTestSpl( + context, + childPanesAcceptTouchEvents = true, + dividerWidth = dividerWidth, + dividerHeight = dividerHeight, + ) + + spl.measureAndLayoutForTest(width = 800, height = 600) + + val left = 400 - dividerWidth / 2 + val top = 300 - dividerHeight / 2 + + val initialTouchBounds = Rect(left, top, left + dividerWidth, top + dividerHeight) + // Gut check that the initial touch bounds is correct. + spl.assertDividerTouchBounds(initialTouchBounds) + + spl.dividerVisualOffsetHorizontal = 10 + spl.assertDividerTouchBounds(Rect(initialTouchBounds).apply { offset(10, 0) }) + + spl.dividerVisualOffsetHorizontal = -50 + spl.assertDividerTouchBounds(Rect(initialTouchBounds).apply { offset(-50, 0) }) + } + + @Test + fun dividerVisualOffsetVertical_updatesTouchBounds() { + val context = InstrumentationRegistry.getInstrumentation().context + val touchTargetMinSize = (context.resources.displayMetrics.density * 48).roundToInt() + + val dividerWidth = max(100, touchTargetMinSize) + val dividerHeight = max(200, touchTargetMinSize) + + val spl = + createTestSpl( + context, + childPanesAcceptTouchEvents = true, + dividerWidth = dividerWidth, + dividerHeight = dividerHeight, + ) + + spl.measureAndLayoutForTest(width = 800, height = 600) + + val left = 400 - dividerWidth / 2 + val top = 300 - dividerHeight / 2 + + val initialTouchBounds = Rect(left, top, left + dividerWidth, top + dividerHeight) + // Gut check that the initial touch bounds is correct. + spl.assertDividerTouchBounds(initialTouchBounds) + + spl.dividerVisualOffsetVertical = 10 + spl.assertDividerTouchBounds(Rect(initialTouchBounds).apply { offset(0, 10) }) + + spl.dividerVisualOffsetVertical = -50 + spl.assertDividerTouchBounds(Rect(initialTouchBounds).apply { offset(0, -50) }) + } + + @Test + fun dividerVisualOffsetHorizontal_updatesDrawBounds() { + val context = InstrumentationRegistry.getInstrumentation().context + + val spl = + createTestSpl( + context, + childPanesAcceptTouchEvents = true, + dividerWidth = 10, + dividerHeight = 20, + ) + + var drawBounds: Rect? = null + + spl.setUserResizingDividerDrawable( + TestDividerDrawable(10, 20) { left, top, right, bottom -> + drawBounds = Rect(left, top, right, bottom) + } + ) + val canvas = Canvas() + spl.draw(canvas) + + val initialDrawBounds = Rect(45, 40, 55, 60) + + assertWithMessage("Actual drawBounds: $drawBounds doesn't match expected drawBounds") + .that(drawBounds) + .isEqualTo(initialDrawBounds) + + spl.dividerVisualOffsetHorizontal = 10 + spl.draw(canvas) + assertWithMessage("Actual drawBounds: $drawBounds doesn't match expected drawBounds") + .that(drawBounds) + .isEqualTo(Rect(initialDrawBounds).apply { offset(10, 0) }) + + spl.dividerVisualOffsetHorizontal = -20 + spl.draw(canvas) + assertWithMessage("Actual drawBounds: $drawBounds doesn't match expected drawBounds") + .that(drawBounds) + .isEqualTo(Rect(initialDrawBounds).apply { offset(-20, 0) }) + } + + @Test + fun dividerVisualOffsetVertical_updatesDrawBounds() { + val context = InstrumentationRegistry.getInstrumentation().context + + val spl = + createTestSpl( + context, + childPanesAcceptTouchEvents = true, + dividerWidth = 10, + dividerHeight = 20, + ) + + var drawBounds: Rect? = null + + spl.setUserResizingDividerDrawable( + TestDividerDrawable(10, 20) { left, top, right, bottom -> + drawBounds = Rect(left, top, right, bottom) + } + ) + val canvas = Canvas() + spl.draw(canvas) + + val initialDrawBounds = Rect(45, 40, 55, 60) + + assertWithMessage("Actual drawBounds: $drawBounds doesn't match expected drawBounds") + .that(drawBounds) + .isEqualTo(initialDrawBounds) + + spl.dividerVisualOffsetVertical = 10 + spl.draw(canvas) + assertWithMessage("Actual drawBounds: $drawBounds doesn't match expected drawBounds") + .that(drawBounds) + .isEqualTo(Rect(initialDrawBounds).apply { offset(0, 10) }) + + spl.dividerVisualOffsetVertical = -5 + spl.draw(canvas) + assertWithMessage("Actual drawBounds: $drawBounds doesn't match expected drawBounds") + .that(drawBounds) + .isEqualTo(Rect(initialDrawBounds).apply { offset(0, -5) }) + } } private fun SlidingPaneLayout.leftAndRightViews(): Pair{ @@ -705,6 +853,8 @@ setDividerDrawable: Boolean = true, childPanesAcceptTouchEvents: Boolean = false, collapsibleContentViews: Boolean = false, + dividerWidth: Int = 10, + dividerHeight: Int = 20, ): SlidingPaneLayout = SlidingPaneLayout(context).apply { addView( @@ -742,7 +892,7 @@ isUserResizingEnabled = true isOverlappingEnabled = false if (setDividerDrawable) { - setUserResizingDividerDrawable(TestDividerDrawable()) + setUserResizingDividerDrawable(TestDividerDrawable(dividerWidth, dividerHeight)) } measureAndLayoutForTest() } @@ -758,6 +908,8 @@ private class TestDividerDrawable( private val intrinsicWidth: Int = 10, private val intrinsicHeight: Int = 20, + private val setBounds: (left: Int, top: Int, right: Int, bottom: Int) -> Unit = { _, _, _, _ -> + }, ) : Drawable() { override fun draw(canvas: Canvas) {} @@ -771,6 +923,11 @@ override fun getIntrinsicWidth(): Int = intrinsicWidth override fun getIntrinsicHeight(): Int = intrinsicHeight + + override fun setBounds(left: Int, top: Int, right: Int, bottom: Int) { + setBounds.invoke(left, top, right, bottom) + super.setBounds(left, top, right, bottom) + } } private class TestPaneView(context: Context) : View(context) { @@ -853,3 +1010,36 @@ private fun stylusEvent(action: Int, x: Int, y: Int) = motionEvent(action, x, y, InputDevice.SOURCE_STYLUS, MotionEvent.TOOL_TYPE_STYLUS) + +private fun SlidingPaneLayout.assertDividerTouchBounds(touchBounds: Rect) { + var wasClicked = false + setOnUserResizingDividerClickListener { wasClicked = true } + + tap(touchBounds.left.toFloat(), touchBounds.top.toFloat()) + assertWithMessage("Divider touchBounds doesn't contain the expected touchBounds $touchBounds") + .that(wasClicked) + .isTrue() + + wasClicked = false + tap(touchBounds.left - 1f, touchBounds.top - 1f) + assertWithMessage("Divider touchBounds is larger than expected touchBounds $touchBounds") + .that(wasClicked) + .isFalse() + + wasClicked = false + tap(touchBounds.right - 1f, touchBounds.bottom - 1f) + assertWithMessage("Divider touchBounds doesn't contain the expected touchBounds $touchBounds") + .that(wasClicked) + .isTrue() + + wasClicked = false + tap(touchBounds.right.toFloat(), touchBounds.bottom.toFloat()) + assertWithMessage("Divider touchBounds is larger than expected touchBounds $touchBounds") + .that(wasClicked) + .isFalse() +} + +private fun SlidingPaneLayout.tap(x: Float, y: Float) { + onTouchEvent(downEvent(x, y)) + onTouchEvent(upEvent(x, y)) +}
diff --git a/slidingpanelayout/slidingpanelayout/src/main/java/androidx/slidingpanelayout/widget/SlidingPaneLayout.kt b/slidingpanelayout/slidingpanelayout/src/main/java/androidx/slidingpanelayout/widget/SlidingPaneLayout.kt index eea16d4..e71f19a 100644 --- a/slidingpanelayout/slidingpanelayout/src/main/java/androidx/slidingpanelayout/widget/SlidingPaneLayout.kt +++ b/slidingpanelayout/slidingpanelayout/src/main/java/androidx/slidingpanelayout/widget/SlidingPaneLayout.kt
@@ -512,6 +512,21 @@ */ val visualDividerPosition: Int get() = + visualDividerPositionWithoutOffset.let { + if (it < 0) { + it + } else { + it + dividerVisualOffsetHorizontal + } + } + + /** + * The visual divider position without the [dividerVisualOffsetHorizontal] applied. It's used + * for layout and draw the child panes. And the other one with visual is used for drawing the + * divider drawable, touch gestures, a11y touch bounds, etc. + */ + private val visualDividerPositionWithoutOffset: Int + get() = when { !isUserResizable -> -1 isDividerDragging -> draggableDividerHandler.dragPositionX @@ -545,7 +560,7 @@ // paddingLeft + paddingRight >= width. val paneSpacing = paneSpacing.coerceAtMost(width - paddingLeft - paddingRight).coerceAtLeast(0) - return visualDividerPosition <= paddingLeft + paneSpacing / 2 + return visualDividerPositionWithoutOffset <= paddingLeft + paneSpacing / 2 } private val dividerAtRightEdge: Boolean @@ -554,7 +569,8 @@ // paddingLeft + paddingRight >= width. val paneSpacing = paneSpacing.coerceAtMost(width - paddingLeft - paddingRight).coerceAtLeast(0) - return visualDividerPosition >= width - paddingRight - (paneSpacing + 1) / 2 + return visualDividerPositionWithoutOffset >= + width - paddingRight - (paneSpacing + 1) / 2 } private fun createUserResizingDividerDrawableState(viewState: IntArray): IntArray { @@ -644,6 +660,38 @@ } } + /** + * The amount of pixels that the divider will be visually offset from its original horizontal + * position. A positive value moves divider rightwards and a negative value moves divider + * leftwards. Changing this value does no impact on the layout of the panes. It only affects the + * drawing and touch position of the divider. This offset is also reflected on the return value + * of [visualDividerPosition]. + */ + @get:Px + var dividerVisualOffsetHorizontal: Int = 0 + set(value) { + if (value != field) { + field = value + invalidate() + } + } + + /** + * The amount of pixels that the divider will be visually offset from its original vertical + * position. A positive value moves divider downwards and a negative value moves divider + * upwards. Changing this value does no impact on the layout of the panes. It only affects the + * drawing and touch position of the divider. This offset is also reflected on the value of + * [visualDividerPosition]. + */ + @get:Px + var dividerVisualOffsetVertical: Int = 0 + set(value) { + if (value != field) { + field = value + invalidate() + } + } + private var onUserResizingDividerClickListener: OnClickListener? = null /** @@ -732,7 +780,9 @@ val height = max(dividerHeight, touchTargetMin) val left = dividerPositionX - width / 2 val right = left + width - val top = (this.height - paddingTop - paddingBottom) / 2 + paddingTop - height / 2 + val top = + (this.height - paddingTop - paddingBottom) / 2 + paddingTop - height / 2 + + dividerVisualOffsetVertical val bottom = top + height outRect.set(left, top, right, bottom) return outRect @@ -860,7 +910,7 @@ userResizingDividerDrawable?.apply { val layoutCenterY = (height - paddingTop - paddingBottom) / 2 + paddingTop val dividerLeft = dividerPositionX - intrinsicWidth / 2 - val dividerTop = layoutCenterY - intrinsicHeight / 2 + val dividerTop = layoutCenterY - intrinsicHeight / 2 + dividerVisualOffsetVertical setBounds( dividerLeft, dividerTop, @@ -1621,7 +1671,7 @@ canvas.clipRect(tmpRect) } if (!isSlideable && isChildClippingToResizeDividerEnabled) { - val visualDividerPosition = visualDividerPosition + val visualDividerPosition = visualDividerPositionWithoutOffset val paneSpacing = paneSpacing.coerceAtMost(width - paddingLeft - paddingRight).coerceAtLeast(0) if (visualDividerPosition >= 0) {
diff --git a/wear/protolayout/protolayout-lint/build.gradle b/wear/protolayout/protolayout-lint/build.gradle index 7b7d4cf..9074899 100644 --- a/wear/protolayout/protolayout-lint/build.gradle +++ b/wear/protolayout/protolayout-lint/build.gradle
@@ -35,7 +35,6 @@ testImplementation(libs.kotlinStdlib) testImplementation(libs.kotlinReflect) - testImplementation(libs.kotlinStdlibJdk8) testImplementation(libs.androidLint) testImplementation(libs.androidLintTests) testImplementation(libs.junit)
diff --git a/xr/compose/compose/api/current.txt b/xr/compose/compose/api/current.txt index a33dbc2..cd77ede 100644 --- a/xr/compose/compose/api/current.txt +++ b/xr/compose/compose/api/current.txt
@@ -220,6 +220,19 @@ @SuppressCompatibility @kotlin.RequiresOptIn(level=kotlin.RequiresOptIn.Level.WARNING, message="This is an experimental API. It may be changed or removed in the future.") @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) public @interface ExperimentalSubspaceVolumeApi { } + public final class SceneCoreEntityKt { + method @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public staticvoid SceneCoreEntity(kotlin.jvm.functions.Function0 extends T> factory, optional androidx.xr.compose.subspace.layout.SubspaceModifier modifier, optional kotlin.jvm.functions.Function1 super T,kotlin.Unit> update, optional androidx.xr.compose.subspace.SceneCoreEntitySizeAdapter + method @BytecodeOnly @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static? sizeAdapter, optional kotlin.jvm.functions.Function0 content); void SceneCoreEntity(kotlin.jvm.functions.Function0 extends T!>, androidx.xr.compose.subspace.layout.SubspaceModifier?, kotlin.jvm.functions.Function1 super T!,kotlin.Unit!>?, androidx.xr.compose.subspace.SceneCoreEntitySizeAdapter + } + + public final class SceneCoreEntitySizeAdapter?, kotlin.jvm.functions.Function2 super androidx.compose.runtime.Composer!,? super java.lang.Integer!,kotlin.Unit!>?, androidx.compose.runtime.Composer?, int, int); { + ctor public SceneCoreEntitySizeAdapter(kotlin.jvm.functions.Function2 super T,? super androidx.xr.compose.unit.IntVolumeSize,kotlin.Unit> onLayoutSizeChanged, optional kotlin.jvm.functions.Function1 super T,androidx.xr.compose.unit.IntVolumeSize>? intrinsicSize); + method public kotlin.jvm.functions.Function1? getIntrinsicSize(); + method public kotlin.jvm.functions.Function2getOnLayoutSizeChanged(); + property public kotlin.jvm.functions.Function1? intrinsicSize; + property public kotlin.jvm.functions.Function2onLayoutSizeChanged; + } + public final class SpatialBoxKt { method @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static void SpatialBox(optional androidx.xr.compose.subspace.layout.SubspaceModifier modifier, optional androidx.xr.compose.subspace.layout.SpatialAlignment alignment, optional boolean propagateMinConstraints, kotlin.jvm.functions.Function1 super androidx.xr.compose.subspace.SpatialBoxScope,kotlin.Unit> content); method @BytecodeOnly @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static void SpatialBox(androidx.xr.compose.subspace.layout.SubspaceModifier?, androidx.xr.compose.subspace.layout.SpatialAlignment?, boolean, kotlin.jvm.functions.Function3 super androidx.xr.compose.subspace.SpatialBoxScope!,? super androidx.compose.runtime.Composer!,? super java.lang.Integer!,kotlin.Unit!>, androidx.compose.runtime.Composer?, int, int);
diff --git a/xr/compose/compose/api/restricted_current.txt b/xr/compose/compose/api/restricted_current.txt index 647f789..5aec1c2 100644 --- a/xr/compose/compose/api/restricted_current.txt +++ b/xr/compose/compose/api/restricted_current.txt
@@ -263,6 +263,19 @@ method @BytecodeOnly @androidx.compose.runtime.Composable @kotlin.PublishedApi internal static androidx.xr.compose.subspace.layout.CoreContentlessEntity rememberCoreContentlessEntity(kotlin.jvm.functions.Function1 super androidx.xr.runtime.Session!,? extends androidx.xr.scenecore.Entity!>, androidx.compose.runtime.Composer?, int); } + public final class SceneCoreEntityKt { + method @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public staticvoid SceneCoreEntity(kotlin.jvm.functions.Function0 extends T> factory, optional androidx.xr.compose.subspace.layout.SubspaceModifier modifier, optional kotlin.jvm.functions.Function1 super T,kotlin.Unit> update, optional androidx.xr.compose.subspace.SceneCoreEntitySizeAdapter + method @BytecodeOnly @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static? sizeAdapter, optional kotlin.jvm.functions.Function0 content); void SceneCoreEntity(kotlin.jvm.functions.Function0 extends T!>, androidx.xr.compose.subspace.layout.SubspaceModifier?, kotlin.jvm.functions.Function1 super T!,kotlin.Unit!>?, androidx.xr.compose.subspace.SceneCoreEntitySizeAdapter + } + + public final class SceneCoreEntitySizeAdapter?, kotlin.jvm.functions.Function2 super androidx.compose.runtime.Composer!,? super java.lang.Integer!,kotlin.Unit!>?, androidx.compose.runtime.Composer?, int, int); { + ctor public SceneCoreEntitySizeAdapter(kotlin.jvm.functions.Function2 super T,? super androidx.xr.compose.unit.IntVolumeSize,kotlin.Unit> onLayoutSizeChanged, optional kotlin.jvm.functions.Function1 super T,androidx.xr.compose.unit.IntVolumeSize>? intrinsicSize); + method public kotlin.jvm.functions.Function1? getIntrinsicSize(); + method public kotlin.jvm.functions.Function2getOnLayoutSizeChanged(); + property public kotlin.jvm.functions.Function1? intrinsicSize; + property public kotlin.jvm.functions.Function2onLayoutSizeChanged; + } + public final class SpatialBoxKt { method @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static void SpatialBox(optional androidx.xr.compose.subspace.layout.SubspaceModifier modifier, optional androidx.xr.compose.subspace.layout.SpatialAlignment alignment, optional boolean propagateMinConstraints, kotlin.jvm.functions.Function1 super androidx.xr.compose.subspace.SpatialBoxScope,kotlin.Unit> content); method @BytecodeOnly @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static void SpatialBox(androidx.xr.compose.subspace.layout.SubspaceModifier?, androidx.xr.compose.subspace.layout.SpatialAlignment?, boolean, kotlin.jvm.functions.Function3 super androidx.xr.compose.subspace.SpatialBoxScope!,? super androidx.compose.runtime.Composer!,? super java.lang.Integer!,kotlin.Unit!>, androidx.compose.runtime.Composer?, int, int);
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/SceneCoreEntity.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/SceneCoreEntity.kt new file mode 100644 index 0000000..7f47043 --- /dev/null +++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/SceneCoreEntity.kt
@@ -0,0 +1,202 @@ +/* + * Copyright 2025 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.xr.compose.subspace + +import androidx.compose.runtime.Applier +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ComposeNode +import androidx.compose.runtime.currentComposer +import androidx.compose.runtime.remember +import androidx.compose.ui.util.fastForEachIndexed +import androidx.xr.compose.subspace.layout.AdaptableCoreEntity +import androidx.xr.compose.subspace.layout.SubspaceMeasurable +import androidx.xr.compose.subspace.layout.SubspaceMeasurePolicy +import androidx.xr.compose.subspace.layout.SubspaceMeasureResult +import androidx.xr.compose.subspace.layout.SubspaceMeasureScope +import androidx.xr.compose.subspace.layout.SubspaceModifier +import androidx.xr.compose.subspace.layout.SubspacePlaceable +import androidx.xr.compose.subspace.node.ComposeSubspaceNode +import androidx.xr.compose.subspace.node.ComposeSubspaceNode.Companion.SetCompositionLocalMap +import androidx.xr.compose.subspace.node.ComposeSubspaceNode.Companion.SetCoreEntity +import androidx.xr.compose.subspace.node.ComposeSubspaceNode.Companion.SetMeasurePolicy +import androidx.xr.compose.subspace.node.ComposeSubspaceNode.Companion.SetModifier +import androidx.xr.compose.unit.IntVolumeSize +import androidx.xr.compose.unit.Meter +import androidx.xr.compose.unit.VolumeConstraints +import androidx.xr.runtime.math.Pose +import androidx.xr.scenecore.Entity +import kotlin.math.max +import kotlin.math.min + +/** + * A composable that attaches to a SceneCore entity and allow compose to size, position, reparent, + * add children, and apply modifiers to the entity. + * + * Usage of this API requires the SceneCore dependency to be added. See + * https://developer.android.com/jetpack/androidx/releases/xr-scenecore + * + * @param factory the factory method for creating the SceneCore [Entity]. + * @param modifier the [SubspaceModifier] that will be applied to this node. + * @param update a callback to be invoked on recomposition to apply any state changes to the Entity. + * This will track snapshot state reads and call [update] when they change. + * @param sizeAdapter an adapter that allows compose to integrate its layout size changes with the + * rendered entity size. This adapter implementation will likely be different for every entity and + * some SceneCore entities may not require sizing at all (this may be null). + * @param content the children of this [Entity]. + * @see SceneCoreEntitySizeAdapter for more information on how compose sizes SceneCore entities. + */ +@Composable +@SubspaceComposable +public funSceneCoreEntity( + factory: () -> T, + modifier: SubspaceModifier = SubspaceModifier, + update: (T) -> Unit = {}, + sizeAdapter: SceneCoreEntitySizeAdapter? = null, + content: @Composable @SubspaceComposable () -> Unit = {}, +) { + val compositionLocalMap = currentComposer.currentCompositionLocalMap + val entity = remember(factory) + + ComposeNode>( + factory = { + ComposeSubspaceNode.Constructor().apply { + SetCoreEntity(AdaptableCoreEntity(entity, sizeAdapter)) + SetMeasurePolicy( + SceneCoreEntityMeasurePolicy(sizeAdapter?.intrinsicSize?.invoke(entity)) + ) + } + }, + update = { + set(compositionLocalMap, SetCompositionLocalMap) + set(modifier, SetModifier) + update(sizeAdapter) { + getAdaptableCoreEntity()?.sceneCoreEntitySizeAdapter = sizeAdapter + } + update(entity) + }, + content = content, + ) +} + +/** + * The sizing strategy used by [SceneCoreEntity] to control and read the size of an entity. + * + * The developer should use [onLayoutSizeChanged] to apply compose layout size changes to the + * entity. Compose will not inherently affect the size of the [Entity]. + * + * If the developer uses [onLayoutSizeChanged] to change the size of the entity, but [intrinsicSize] + * is not provided, then the intrinsic size of the entity will be ignored and the layout size as + * determined solely by compose will be used to size the entity. If the [SceneCoreEntity] has no + * children or size modifiers then compose doesn't know how to size this node and it will be size 0, + * causing it not to render at all. In such a case, please do one of the following: (1) provide + * [intrinsicSize] so compose can infer the size from the entity, (2) add a sizing modifier to + * control the size of the entity, or (3) remove the adapter from the [SceneCoreEntity] as without + * an adapter compose will not try to control the size of this entity. + * + * Note that many SceneCore entities accept sizes in meter units instead of pixels. The [Meter] type + * may be used to convert from pixels to meters. + * + * ```kotlin + * Meter.fromPixel(px, density).toM() + * ``` + * + * @param onLayoutSizeChanged a callback that is invoked with the final layout size of the + * composable in pixels. + * @param intrinsicSize a getter method that returns the current [IntVolumeSize] in pixels of the + * entity. This isn't as critical for compose as [onLayoutSizeChanged]; however, this can help to + * inform compose of the intrinsic size of the entity. + */ +public class SceneCoreEntitySizeAdapter( + public val onLayoutSizeChanged: T.(IntVolumeSize) -> Unit, + public val intrinsicSize: (T.() -> IntVolumeSize)? = null, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SceneCoreEntitySizeAdapter<*> + + if (onLayoutSizeChanged !== other.onLayoutSizeChanged) return false + if (intrinsicSize !== other.intrinsicSize) return false + + return true + } + + override fun hashCode(): Int { + var result = onLayoutSizeChanged.hashCode() + result = 31 * result + (intrinsicSize?.hashCode() ?: 0) + return result + } +} + +private class SceneCoreEntityMeasurePolicy(private val originalSize: IntVolumeSize?) : + SubspaceMeasurePolicy { + override fun SubspaceMeasureScope.measure( + measurables: List, + constraints: VolumeConstraints, + ): SubspaceMeasureResult { + if (measurables.isEmpty()) { + return if (originalSize == null) { + layout(constraints.minWidth, constraints.minHeight, constraints.minDepth) {} + } else { + layout( + max(originalSize.width, constraints.minWidth), + max(originalSize.height, constraints.minHeight), + max(originalSize.depth, constraints.minDepth), + ) {} + } + } + + val placeables = arrayOfNulls(measurables.size) + var width = max(constraints.minWidth, min(originalSize?.width ?: 0, constraints.maxWidth)) + var height = + max(constraints.minHeight, min(originalSize?.height ?: 0, constraints.maxHeight)) + var depth = max(constraints.minDepth, min(originalSize?.depth ?: 0, constraints.maxDepth)) + measurables.fastForEachIndexed { index, measurable -> + val placeable = measurable.measure(constraints) + placeables[index] = placeable + width = max(width, placeable.measuredWidth) + height = max(height, placeable.measuredHeight) + depth = max(depth, placeable.measuredDepth) + } + + return layout(width, height, depth) { + placeables.forEachIndexed { index, placeable -> + placeable as SubspacePlaceable + placeable.place(Pose.Identity) + } + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SceneCoreEntityMeasurePolicy + + return originalSize == other.originalSize + } + + override fun hashCode(): Int { + return originalSize?.hashCode() ?: 0 + } +} + +private funComposeSubspaceNode.getAdaptableCoreEntity(): AdaptableCoreEntity + coreEntity.castTo? = >() + +private inline funAny?.castTo() = this as? T
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/CoreEntity.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/CoreEntity.kt index 6a57438..0219bcd 100644 --- a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/CoreEntity.kt +++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/CoreEntity.kt
@@ -19,6 +19,7 @@ import android.util.Log import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.unit.Density +import androidx.xr.compose.subspace.SceneCoreEntitySizeAdapter import androidx.xr.compose.subspace.SpatialPanelDefaults import androidx.xr.compose.subspace.node.SubspaceLayoutNode import androidx.xr.compose.unit.IntVolumeSize @@ -310,6 +311,23 @@ } /** + * A [CoreEntity] used in a [androidx.xr.compose.subspace.SceneCoreEntity]. The exact semantics of + * this entity are unknown to compose; however, the developer may supply information that we may use + * to set and derive the size of the entity. + */ +internal class AdaptableCoreEntity( + val coreEntity: T, + var sceneCoreEntitySizeAdapter: SceneCoreEntitySizeAdapter? = null, +) : CoreEntity(coreEntity) { + override var size: IntVolumeSize + get() = sceneCoreEntitySizeAdapter?.intrinsicSize?.invoke(coreEntity) ?: super.size + set(value) { + sceneCoreEntitySizeAdapter?.onLayoutSizeChanged?.let { coreEntity.it(value) } + super.size = value + } +} + +/** * Wrapper class for sphere-based surface entities from SceneCore. Head pose is not a dynamic * property, and should just be calculated upon instantiation to avoid head locking the sphere. */
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/CoreEntityNode.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/CoreEntityNode.kt index 76b0576..9bd2727 100644 --- a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/CoreEntityNode.kt +++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/CoreEntityNode.kt
@@ -109,7 +109,7 @@ val result = CoreEntityAccumulator() result.alpha = alpha * next.alpha result.scale = scale * next.scale - result.size = next.size + result.size = next.size ?: size return result }
diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/SubspaceLayout.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/SubspaceLayout.kt index 6265062..4c473b3 100644 --- a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/SubspaceLayout.kt +++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/SubspaceLayout.kt
@@ -57,6 +57,11 @@ modifier: SubspaceModifier = SubspaceModifier, measurePolicy: SubspaceMeasurePolicy, ) { + check(currentComposer.applier.current is ComposeSubspaceNode) { + "SubspaceComposable functions are expected to be used within the context of a " + + "Subspace composition. Please ensure that this component is in a Subspace or " + + " is a child of another SubspaceComposable." + } val compositionLocalMap = currentComposer.currentCompositionLocalMap ComposeNode>( factory = ComposeSubspaceNode.Constructor, @@ -102,6 +107,11 @@ modifier: SubspaceModifier = SubspaceModifier, measurePolicy: SubspaceMeasurePolicy, ) { + check(currentComposer.applier.current is ComposeSubspaceNode) { + "SubspaceComposable functions are expected to be used within the context of a " + + "Subspace composition. Please ensure that this component is in a Subspace or " + + " is a child of another SubspaceComposable." + } val coreEntity = rememberCoreContentlessEntity { ContentlessEntity.create(session = this, name = entityName("Entity")) } @@ -142,6 +152,11 @@ coreEntity: CoreEntity? = null, measurePolicy: SubspaceMeasurePolicy, ) { + check(currentComposer.applier.current is ComposeSubspaceNode) { + "SubspaceComposable functions are expected to be used within the context of a " + + "Subspace composition. Please ensure that this component is in a Subspace or " + + " is a child of another SubspaceComposable." + } val compositionLocalMap = currentComposer.currentCompositionLocalMap ComposeNode>( factory = ComposeSubspaceNode.Constructor, @@ -182,6 +197,12 @@ }, measurePolicy: SubspaceMeasurePolicy, ) { + check(currentComposer.applier.current is ComposeSubspaceNode) { + "SubspaceComposable functions are expected to be used within the context of a " + + "Subspace composition. Please ensure that this component is in a Subspace or " + + " is a child of another SubspaceComposable." + } + val compositionLocalMap = currentComposer.currentCompositionLocalMap ComposeNode>( factory = ComposeSubspaceNode.Constructor,
diff --git a/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/SceneCoreEntityTest.kt b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/SceneCoreEntityTest.kt new file mode 100644 index 0000000..c3ff2b7 --- /dev/null +++ b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/SceneCoreEntityTest.kt
@@ -0,0 +1,512 @@ +/* + * Copyright 2025 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.xr.compose.subspace + +import android.view.View +import androidx.compose.material3.Text +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.xr.compose.platform.LocalSession +import androidx.xr.compose.spatial.Subspace +import androidx.xr.compose.subspace.layout.SubspaceModifier +import androidx.xr.compose.subspace.layout.size +import androidx.xr.compose.subspace.layout.testTag +import androidx.xr.compose.testing.SubspaceTestingActivity +import androidx.xr.compose.testing.TestSetup +import androidx.xr.compose.testing.assertHeightIsEqualTo +import androidx.xr.compose.testing.assertPositionInRootIsEqualTo +import androidx.xr.compose.testing.assertWidthIsEqualTo +import androidx.xr.compose.testing.onSubspaceNodeWithTag +import androidx.xr.compose.unit.IntVolumeSize +import androidx.xr.runtime.math.IntSize2d +import androidx.xr.scenecore.ContentlessEntity +import androidx.xr.scenecore.PanelEntity +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** Tests for [SceneCoreEntity]. */ +@RunWith(AndroidJUnit4::class) +class SceneCoreEntityTest { + + @get:Rule val composeTestRule = createAndroidComposeRule() + + @Test + fun sceneCoreEntity_childrenAreComposed() { + composeTestRule.setContent { + TestSetup { + Subspace { + val session = checkNotNull(LocalSession.current) + SceneCoreEntity(factory = { ContentlessEntity.create(session, "TestEntity") }) { + SpatialPanel(SubspaceModifier.testTag("panel1").size(50.dp)) { + Text(text = "Panel 1") + } + SpatialPanel(SubspaceModifier.testTag("panel2").size(50.dp)) { + Text(text = "Panel 2") + } + } + } + } + } + + composeTestRule.onSubspaceNodeWithTag("panel1").assertExists() + composeTestRule.onSubspaceNodeWithTag("panel2").assertExists() + } + + @Test + fun sceneCoreEntity_childrenAreCentered() { + composeTestRule.setContent { + TestSetup { + Subspace { + val session = checkNotNull(LocalSession.current) + SceneCoreEntity(factory = { ContentlessEntity.create(session, "TestEntity") }) { + SpatialPanel(SubspaceModifier.testTag("panel1").size(50.dp)) { + Text(text = "Panel 1") + } + SpatialPanel(SubspaceModifier.testTag("panel2").size(50.dp)) { + Text(text = "Panel 2") + } + } + } + } + } + + composeTestRule + .onSubspaceNodeWithTag("panel1") + .assertPositionInRootIsEqualTo(0.dp, 0.dp, 0.dp) + .assertWidthIsEqualTo(50.dp) + .assertHeightIsEqualTo(50.dp) + + composeTestRule + .onSubspaceNodeWithTag("panel2") + .assertPositionInRootIsEqualTo(0.dp, 0.dp, 0.dp) + .assertWidthIsEqualTo(50.dp) + .assertHeightIsEqualTo(50.dp) + } + + @Test + fun sceneCoreEntity_size_modifierSizeIsAppliedToEntity() { + var testEntity by mutableStateOf(null) + var targetSize by mutableStateOf(500.dp) + + composeTestRule.setContent { + TestSetup { + Subspace { + val session = checkNotNull(LocalSession.current) + testEntity = remember { + PanelEntity.create( + session, + View(composeTestRule.activity), + IntSize2d(100, 100), + "TestPanel", + ) + } + SceneCoreEntity( + factory = { testEntity!! }, + sizeAdapter = + SceneCoreEntitySizeAdapter( + onLayoutSizeChanged = { + sizeInPixels = IntSize2d(it.width, it.height) + } + ), + modifier = SubspaceModifier.size(targetSize).testTag("mainPanel"), + ) + } + } + } + + // Subsequent tests assume that density is 1.0 + assertThat(composeTestRule.density.density).isEqualTo(1.0f) + composeTestRule + .onSubspaceNodeWithTag("mainPanel") + .assertPositionInRootIsEqualTo(0.dp, 0.dp, 0.dp) + .assertWidthIsEqualTo(500.dp) + .assertHeightIsEqualTo(500.dp) + assertThat( + (composeTestRule + .onSubspaceNodeWithTag("mainPanel") + .fetchSemanticsNode() + .semanticsEntity as PanelEntity) + .sizeInPixels + ) + .isEqualTo(IntSize2d(500, 500)) + + targetSize = 1000.dp + + composeTestRule + .onSubspaceNodeWithTag("mainPanel") + .assertPositionInRootIsEqualTo(0.dp, 0.dp, 0.dp) + .assertWidthIsEqualTo(1000.dp) + .assertHeightIsEqualTo(1000.dp) + assertThat( + (composeTestRule + .onSubspaceNodeWithTag("mainPanel") + .fetchSemanticsNode() + .semanticsEntity as PanelEntity) + .sizeInPixels + ) + .isEqualTo(IntSize2d(1000, 1000)) + } + + @Test + fun sceneCoreEntity_size_modifierSizeChangesWithDensity() { + var testEntity by mutableStateOf(null) + var targetSize by mutableStateOf(500.dp) + + composeTestRule.setContent { + CompositionLocalProvider(LocalDensity provides Density(2.0f)) { + TestSetup { + Subspace { + val session = checkNotNull(LocalSession.current) + testEntity = remember { + PanelEntity.create( + session, + View(composeTestRule.activity), + IntSize2d(100, 100), + "TestPanel", + ) + } + SceneCoreEntity( + factory = { testEntity!! }, + sizeAdapter = + SceneCoreEntitySizeAdapter({ + sizeInPixels = IntSize2d(it.width, it.height) + }), + modifier = SubspaceModifier.size(targetSize).testTag("mainPanel"), + ) + } + } + } + } + + // Subsequent tests assume that density is 2.0 + composeTestRule + .onSubspaceNodeWithTag("mainPanel") + .assertPositionInRootIsEqualTo(0.dp, 0.dp, 0.dp) + .assertWidthIsEqualTo(1000.dp) + .assertHeightIsEqualTo(1000.dp) + assertThat( + (composeTestRule + .onSubspaceNodeWithTag("mainPanel") + .fetchSemanticsNode() + .semanticsEntity as PanelEntity) + .sizeInPixels + ) + .isEqualTo(IntSize2d(1000, 1000)) + + targetSize = 1000.dp + + composeTestRule + .onSubspaceNodeWithTag("mainPanel") + .assertPositionInRootIsEqualTo(0.dp, 0.dp, 0.dp) + .assertWidthIsEqualTo(2000.dp) + .assertHeightIsEqualTo(2000.dp) + assertThat( + (composeTestRule + .onSubspaceNodeWithTag("mainPanel") + .fetchSemanticsNode() + .semanticsEntity as PanelEntity) + .sizeInPixels + ) + .isEqualTo(IntSize2d(2000, 2000)) + } + + @Test + fun sceneCoreEntity_size_usesInitialSizeIfNoModifierOrChildren() { + composeTestRule.setContent { + TestSetup { + Subspace { + val session = checkNotNull(LocalSession.current) + SceneCoreEntity( + factory = { + PanelEntity.create( + session, + View(composeTestRule.activity), + IntSize2d(100, 100), + "TestPanel", + ) + }, + sizeAdapter = + SceneCoreEntitySizeAdapter( + onLayoutSizeChanged = { + sizeInPixels = IntSize2d(it.width, it.height) + }, + intrinsicSize = { + IntVolumeSize(sizeInPixels.width, sizeInPixels.height, 0) + }, + ), + modifier = SubspaceModifier.testTag("mainPanel"), + ) + } + } + } + + composeTestRule + .onSubspaceNodeWithTag("mainPanel") + .assertPositionInRootIsEqualTo(0.dp, 0.dp, 0.dp) + .assertWidthIsEqualTo(100.dp) + .assertHeightIsEqualTo(100.dp) + } + + @Test + fun sceneCoreEntity_size_isZeroIfNoModifierOrChildrenOrIntrinsicSize() { + composeTestRule.setContent { + TestSetup { + Subspace { + val session = checkNotNull(LocalSession.current) + SceneCoreEntity( + factory = { + PanelEntity.create( + session, + View(composeTestRule.activity), + IntSize2d(100, 100), + "TestPanel", + ) + }, + sizeAdapter = + SceneCoreEntitySizeAdapter( + onLayoutSizeChanged = { + sizeInPixels = IntSize2d(it.width, it.height) + } + ), + modifier = SubspaceModifier.testTag("mainPanel"), + ) + } + } + } + + composeTestRule + .onSubspaceNodeWithTag("mainPanel") + .assertPositionInRootIsEqualTo(0.dp, 0.dp, 0.dp) + .assertWidthIsEqualTo(0.dp) + .assertHeightIsEqualTo(0.dp) + } + + @Test + fun sceneCoreEntity_size_usesInitialSizeIfChildrenAreSmaller() { + composeTestRule.setContent { + TestSetup { + Subspace { + val session = checkNotNull(LocalSession.current) + SceneCoreEntity( + factory = { + PanelEntity.create( + session, + View(composeTestRule.activity), + IntSize2d(100, 100), + "TestPanel", + ) + }, + sizeAdapter = + SceneCoreEntitySizeAdapter( + onLayoutSizeChanged = { + sizeInPixels = IntSize2d(it.width, it.height) + }, + intrinsicSize = { + IntVolumeSize(sizeInPixels.width, sizeInPixels.height, 0) + }, + ), + modifier = SubspaceModifier.testTag("mainPanel"), + ) { + SpatialPanel(SubspaceModifier.size(50.dp)) {} + } + } + } + } + + composeTestRule + .onSubspaceNodeWithTag("mainPanel") + .assertPositionInRootIsEqualTo(0.dp, 0.dp, 0.dp) + .assertWidthIsEqualTo(100.dp) + .assertHeightIsEqualTo(100.dp) + } + + @Test + fun sceneCoreEntity_size_doesNotThrowExceptionIfSetterAndGetter() { + composeTestRule.setContent { + TestSetup { + Subspace { + val session = checkNotNull(LocalSession.current) + SceneCoreEntity( + factory = { + PanelEntity.create( + session, + View(composeTestRule.activity), + IntSize2d(0, 0), + "TestPanel", + ) + }, + sizeAdapter = + SceneCoreEntitySizeAdapter( + onLayoutSizeChanged = { + sizeInPixels = IntSize2d(it.width, it.height) + }, + intrinsicSize = { + IntVolumeSize(sizeInPixels.width, sizeInPixels.height, 0) + }, + ), + modifier = SubspaceModifier.testTag("mainPanel"), + ) + } + } + } + + composeTestRule.onSubspaceNodeWithTag("mainPanel").assertExists() + } + + @Test + fun sceneCoreEntity_size_matchesSizeOfChildrenIfLarger() { + composeTestRule.setContent { + TestSetup { + Subspace { + val session = checkNotNull(LocalSession.current) + SceneCoreEntity( + factory = { + PanelEntity.create( + session, + View(composeTestRule.activity), + IntSize2d(100, 100), + "TestPanel", + ) + }, + sizeAdapter = + SceneCoreEntitySizeAdapter( + onLayoutSizeChanged = { + sizeInPixels = IntSize2d(it.width, it.height) + }, + intrinsicSize = { + IntVolumeSize(sizeInPixels.width, sizeInPixels.height, 0) + }, + ), + modifier = SubspaceModifier.testTag("mainPanel"), + ) { + SpatialPanel(SubspaceModifier.size(200.dp)) {} + } + } + } + } + + composeTestRule + .onSubspaceNodeWithTag("mainPanel") + .assertPositionInRootIsEqualTo(0.dp, 0.dp, 0.dp) + .assertWidthIsEqualTo(200.dp) + .assertHeightIsEqualTo(200.dp) + } + + @Test + fun sceneCoreEntity_factoryAndUpdate_areCalledTheAppropriateNumberOfTimes() { + var factoryCalled = 0 + var updateCalled = 0 + val cornerRadius = mutableStateOf(0.5f) + + composeTestRule.setContent { + TestSetup { + val session = LocalSession.current ?: error("No session") + Subspace { + SceneCoreEntity( + factory = { + factoryCalled += 1 + PanelEntity.create( + session, + View(composeTestRule.activity), + IntSize2d(100, 100), + "TestPanel", + ) + }, + update = { + updateCalled += 1 + it.cornerRadius = cornerRadius.value + }, + ) { + SpatialPanel(SubspaceModifier.testTag("TestPanel")) {} + } + } + } + } + + composeTestRule.waitForIdle() + assertThat(factoryCalled).isEqualTo(1) + assertThat(updateCalled).isEqualTo(1) + cornerRadius.value = 0.3f + composeTestRule.waitForIdle() + assertThat(factoryCalled).isEqualTo(1) + assertThat(updateCalled).isEqualTo(2) + cornerRadius.value = 0.2f + composeTestRule.waitForIdle() + assertThat(factoryCalled).isEqualTo(1) + assertThat(updateCalled).isEqualTo(3) + cornerRadius.value = 0.1f + composeTestRule.waitForIdle() + assertThat(factoryCalled).isEqualTo(1) + assertThat(updateCalled).isEqualTo(4) + } + + @Test + fun sceneCoreEntity_update_getsMutableStateChanges() { + val cornerRadius = mutableStateOf(0.5f) + + composeTestRule.setContent { + TestSetup { + val session = LocalSession.current ?: error("No session") + Subspace { + SceneCoreEntity( + factory = { + PanelEntity.create( + session, + View(composeTestRule.activity), + IntSize2d(100, 100), + "TestPanel", + ) + }, + update = { it.cornerRadius = cornerRadius.value }, + modifier = SubspaceModifier.testTag("TestPanel"), + ) + } + } + } + + composeTestRule.onSubspaceNodeWithTag("TestPanel").assertExists() + assertThat( + (composeTestRule + .onSubspaceNodeWithTag("TestPanel") + .fetchSemanticsNode() + .semanticsEntity as PanelEntity) + .cornerRadius + ) + .isEqualTo(0.5f) + + cornerRadius.value = 0.4f + + assertThat( + (composeTestRule + .onSubspaceNodeWithTag("TestPanel") + .fetchSemanticsNode() + .semanticsEntity as PanelEntity) + .cornerRadius + ) + .isEqualTo(0.4f) + } +}
diff --git a/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/CoreEntityNodeTest.kt b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/CoreEntityNodeTest.kt new file mode 100644 index 0000000..80f20b6 --- /dev/null +++ b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/CoreEntityNodeTest.kt
@@ -0,0 +1,102 @@ +/* + * Copyright 2025 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.xr.compose.subspace.layout + +import android.view.View +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.xr.compose.spatial.ApplicationSubspace +import androidx.xr.compose.subspace.SpatialPanel +import androidx.xr.compose.subspace.node.SubspaceModifierNodeElement +import androidx.xr.compose.testing.SubspaceTestingActivity +import androidx.xr.compose.testing.TestSetup +import androidx.xr.compose.testing.onSubspaceNodeWithTag +import androidx.xr.compose.unit.IntVolumeSize +import androidx.xr.scenecore.PanelEntity +import com.google.common.truth.Truth.assertThat +import kotlin.test.assertNotNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class CoreEntityNodeTest { + + /** + * Applies a modification to the [CoreEntity] associated with this subspace layout. + * + * This provides a generic way to apply changes to the underlying entity for testing purposes. + * + * @param modify The function literal to execute on the [CoreEntityScope]. + */ + private fun SubspaceModifier.modifyCoreEntity( + modify: CoreEntityScope.() -> Unit + ): SubspaceModifier = this.then(ModifyCoreEntityElement(modify)) + + private class ModifyCoreEntityElement(private var modify: CoreEntityScope.() -> Unit) : + SubspaceModifierNodeElement() { + override fun create(): ModifyCoreEntityNode = ModifyCoreEntityNode(modify) + + override fun update(node: ModifyCoreEntityNode) { + node.modify = modify + } + + override fun hashCode(): Int { + return modify.hashCode() + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ModifyCoreEntityElement) return false + + return modify === other.modify + } + } + + private class ModifyCoreEntityNode(var modify: CoreEntityScope.() -> Unit) : + SubspaceModifier.Node(), CoreEntityNode { + override fun CoreEntityScope.modifyCoreEntity() { + modify() + } + } + + @get:Rule val composeTestRule = createAndroidComposeRule() + + @Test + fun testRenderedSize_shouldBeApplied() { + composeTestRule.setContent { + TestSetup { + ApplicationSubspace { + SpatialPanel( + factory = { View(it) }, + SubspaceModifier.modifyCoreEntity { + setRenderedSize(IntVolumeSize(100, 100, 0)) + } + .modifyCoreEntity { setOrAppendScale(4f) } + .testTag("panel"), + ) + } + } + } + + val panelNode = composeTestRule.onSubspaceNodeWithTag("panel").fetchSemanticsNode() + val panelSceneCoreEntity = panelNode.semanticsEntity as PanelEntity? + assertNotNull(panelSceneCoreEntity) + assertThat(panelSceneCoreEntity.sizeInPixels.width).isEqualTo(100) + assertThat(panelSceneCoreEntity.sizeInPixels.height).isEqualTo(100) + } +}
diff --git a/xr/compose/integration-tests/layout/spatialcomposeapp/src/main/java/androidx/xr/compose/integration/layout/spatialcomposeapp/SpatialComposeAppActivity.kt b/xr/compose/integration-tests/layout/spatialcomposeapp/src/main/java/androidx/xr/compose/integration/layout/spatialcomposeapp/SpatialComposeAppActivity.kt index 666f174..c53186c 100644 --- a/xr/compose/integration-tests/layout/spatialcomposeapp/src/main/java/androidx/xr/compose/integration/layout/spatialcomposeapp/SpatialComposeAppActivity.kt +++ b/xr/compose/integration-tests/layout/spatialcomposeapp/src/main/java/androidx/xr/compose/integration/layout/spatialcomposeapp/SpatialComposeAppActivity.kt
@@ -58,7 +58,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.isDebugInspectorInfoEnabled import androidx.compose.ui.unit.dp -import androidx.lifecycle.lifecycleScope import androidx.xr.compose.integration.common.AnotherActivity import androidx.xr.compose.integration.layout.spatialcomposeapp.components.TestDialog import androidx.xr.compose.platform.LocalSession @@ -69,14 +68,13 @@ import androidx.xr.compose.spatial.OrbiterOffsetType import androidx.xr.compose.spatial.SpatialElevationLevel import androidx.xr.compose.spatial.Subspace -import androidx.xr.compose.subspace.ExperimentalSubspaceVolumeApi import androidx.xr.compose.subspace.MainPanel +import androidx.xr.compose.subspace.SceneCoreEntity import androidx.xr.compose.subspace.SpatialColumn import androidx.xr.compose.subspace.SpatialCurvedRow import androidx.xr.compose.subspace.SpatialLayoutSpacer import androidx.xr.compose.subspace.SpatialPanel import androidx.xr.compose.subspace.SubspaceComposable -import androidx.xr.compose.subspace.Volume import androidx.xr.compose.subspace.layout.SpatialAlignment import androidx.xr.compose.subspace.layout.SpatialRoundedCornerShape import androidx.xr.compose.subspace.layout.SubspaceModifier @@ -88,10 +86,10 @@ import androidx.xr.compose.subspace.layout.offset import androidx.xr.compose.subspace.layout.padding import androidx.xr.compose.subspace.layout.resizable +import androidx.xr.compose.subspace.layout.rotate import androidx.xr.compose.subspace.layout.size import androidx.xr.compose.subspace.layout.width import androidx.xr.compose.unit.Meter.Companion.meters -import androidx.xr.runtime.math.Pose import androidx.xr.runtime.math.Quaternion import androidx.xr.runtime.math.Vector3 import androidx.xr.scenecore.GltfModel @@ -102,7 +100,6 @@ import kotlin.math.sin import kotlinx.coroutines.delay import kotlinx.coroutines.guava.await -import kotlinx.coroutines.launch /** * Main activity for the Spatial Compose App. @@ -330,49 +327,42 @@ ) } - @OptIn(ExperimentalSubspaceVolumeApi::class) @SubspaceComposable @Composable fun XyzArrows(modifier: SubspaceModifier = SubspaceModifier) { - val session = - checkNotNull(LocalSession.current) { - "LocalSession.current was null. Session must be available." - } - var arrows by remember { mutableStateOf(null) } - val gltfEntity = arrows?.let { remember { GltfModelEntity.create(session, it) } } + val session = LocalSession.current ?: return + var rotation by remember { mutableStateOf(Quaternion.Identity) } + var gltfModel by remember { mutableStateOf(null) } LaunchedEffect(Unit) { - arrows = GltfModel.createAsync(session, Paths.get("models", "xyzArrows.glb")).await() + gltfModel = GltfModel.createAsync(session, Paths.get("models", "xyzArrows.glb")).await() + + val pi = 3.14159F + val timeSource = Clock.systemUTC() + val startTime = timeSource.millis() + val rotateTimeMs = 10000F + + while (true) { + delay(16L) + val elapsedMs = timeSource.millis() - startTime + val angle = (2 * pi) * (elapsedMs / rotateTimeMs) + + val normalized = Vector3(1.0f, 1.0f, 1.0f).toNormalized() + + val qX = normalized.x * sin(angle / 2) + val qY = normalized.y * sin(angle / 2) + val qZ = normalized.z * sin(angle / 2) + val qW = cos(angle / 2) + + rotation = Quaternion(qX, qY, qZ, qW) + } } - if (gltfEntity != null) { - Volume(modifier) { - gltfEntity.parent = it - - lifecycleScope.launch { - val pi = 3.14159F - val timeSource = Clock.systemUTC() - val startTime = timeSource.millis() - val rotateTimeMs = 10000F - - while (true) { - delay(16L) - val elapsedMs = timeSource.millis() - startTime - val angle = (2 * pi) * (elapsedMs / rotateTimeMs) - - val normalized = Vector3(1.0f, 1.0f, 1.0f).toNormalized() - - val qX = normalized.x * sin(angle / 2) - val qY = normalized.y * sin(angle / 2) - val qZ = normalized.z * sin(angle / 2) - val qW = cos(angle / 2) - - val q = Quaternion(qX, qY, qZ, qW) - - gltfEntity.setPose(Pose(rotation = q)) - } - } - } + if (gltfModel != null) { + SceneCoreEntity( + factory = { GltfModelEntity.create(session, gltfModel!!) }, + modifier = modifier.rotate(rotation), + ) } } }
diff --git a/xr/compose/integration-tests/testapp/src/main/kotlin/androidx/xr/compose/testapp/spatialcompose/SpatialCompose.kt b/xr/compose/integration-tests/testapp/src/main/kotlin/androidx/xr/compose/testapp/spatialcompose/SpatialCompose.kt index e4f4bb8..277015d 100644 --- a/xr/compose/integration-tests/testapp/src/main/kotlin/androidx/xr/compose/testapp/spatialcompose/SpatialCompose.kt +++ b/xr/compose/integration-tests/testapp/src/main/kotlin/androidx/xr/compose/testapp/spatialcompose/SpatialCompose.kt
@@ -59,7 +59,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.isDebugInspectorInfoEnabled import androidx.compose.ui.unit.dp -import androidx.lifecycle.lifecycleScope import androidx.xr.compose.platform.LocalSession import androidx.xr.compose.platform.LocalSpatialCapabilities import androidx.xr.compose.platform.LocalSpatialConfiguration @@ -69,12 +68,12 @@ import androidx.xr.compose.spatial.Subspace import androidx.xr.compose.subspace.ExperimentalSubspaceVolumeApi import androidx.xr.compose.subspace.MainPanel +import androidx.xr.compose.subspace.SceneCoreEntity import androidx.xr.compose.subspace.SpatialColumn import androidx.xr.compose.subspace.SpatialCurvedRow import androidx.xr.compose.subspace.SpatialLayoutSpacer import androidx.xr.compose.subspace.SpatialPanel import androidx.xr.compose.subspace.SubspaceComposable -import androidx.xr.compose.subspace.Volume import androidx.xr.compose.subspace.layout.SpatialAlignment import androidx.xr.compose.subspace.layout.SpatialRoundedCornerShape import androidx.xr.compose.subspace.layout.SubspaceModifier @@ -86,13 +85,13 @@ import androidx.xr.compose.subspace.layout.offset import androidx.xr.compose.subspace.layout.padding import androidx.xr.compose.subspace.layout.resizable +import androidx.xr.compose.subspace.layout.rotate import androidx.xr.compose.subspace.layout.testTag import androidx.xr.compose.subspace.layout.width import androidx.xr.compose.testapp.common.AnotherActivity import androidx.xr.compose.testapp.ui.components.CommonTestScaffold import androidx.xr.compose.testapp.ui.components.TestDialog import androidx.xr.compose.unit.Meter.Companion.meters -import androidx.xr.runtime.math.Pose import androidx.xr.runtime.math.Quaternion import androidx.xr.runtime.math.Vector3 import androidx.xr.scenecore.GltfModel @@ -103,7 +102,6 @@ import kotlin.math.sin import kotlinx.coroutines.delay import kotlinx.coroutines.guava.await -import kotlinx.coroutines.launch class SpatialCompose : ComponentActivity() { @@ -361,45 +359,38 @@ @OptIn(ExperimentalSubspaceVolumeApi::class) @Composable fun XyzArrows(modifier: SubspaceModifier = SubspaceModifier) { - val session = - checkNotNull(LocalSession.current) { - "LocalSession.current was null. Session must be available." - } - var arrows by remember { mutableStateOf(null) } - val gltfEntity = arrows?.let { remember { GltfModelEntity.create(session, it) } } + val session = LocalSession.current ?: return + var rotation by remember { mutableStateOf(Quaternion.Identity) } + var gltfModel by remember { mutableStateOf(null) } LaunchedEffect(Unit) { - arrows = GltfModel.createAsync(session, Paths.get("models", "xyzArrows.glb")).await() + gltfModel = GltfModel.createAsync(session, Paths.get("models", "xyzArrows.glb")).await() + val pi = 3.14159F + val timeSource = Clock.systemUTC() + val startTime = timeSource.millis() + val rotateTimeMs = 10000F + + while (true) { + delay(16L) + val elapsedMs = timeSource.millis() - startTime + val angle = (2 * pi) * (elapsedMs / rotateTimeMs) + + val normalized = Vector3(1.0f, 1.0f, 1.0f).toNormalized() + + val qX = normalized.x * sin(angle / 2) + val qY = normalized.y * sin(angle / 2) + val qZ = normalized.z * sin(angle / 2) + val qW = cos(angle / 2) + + rotation = Quaternion(qX, qY, qZ, qW) + } } - if (gltfEntity != null) { - Volume(modifier) { - gltfEntity.parent = it - - lifecycleScope.launch { - val pi = 3.14159F - val timeSource = Clock.systemUTC() - val startTime = timeSource.millis() - val rotateTimeMs = 10000F - - while (true) { - delay(16L) - val elapsedMs = timeSource.millis() - startTime - val angle = (2 * pi) * (elapsedMs / rotateTimeMs) - - val normalized = Vector3(1.0f, 1.0f, 1.0f).toNormalized() - - val qX = normalized.x * sin(angle / 2) - val qY = normalized.y * sin(angle / 2) - val qZ = normalized.z * sin(angle / 2) - val qW = cos(angle / 2) - - val q = Quaternion(qX, qY, qZ, qW) - - gltfEntity.setPose(Pose(rotation = q)) - } - } - } + if (gltfModel != null) { + SceneCoreEntity( + factory = { GltfModelEntity.create(session, gltfModel!!) }, + modifier = modifier.rotate(rotation), + ) } } }
diff --git a/xr/projected/projected/api/current.txt b/xr/projected/projected/api/current.txt index e6f50d0..cc00cc6 100644 --- a/xr/projected/projected/api/current.txt +++ b/xr/projected/projected/api/current.txt
@@ -1 +1,15 @@ // Signature format: 4.0 +package androidx.xr.projected { + + public final class ProjectedContext { + method public static android.content.Intent addProjectedFlags(android.content.Intent intent); + method public static android.content.Context createHostDeviceContext(android.content.Context context); + method public static android.app.ActivityOptions createProjectedActivityOptions(android.content.Context context); + method public static android.content.Context createProjectedDeviceContext(android.content.Context context); + method public static String? getProjectedDeviceName(android.content.Context context); + method public static boolean isProjectedDeviceContext(android.content.Context context); + field public static final androidx.xr.projected.ProjectedContext INSTANCE; + } + +} +
diff --git a/xr/projected/projected/api/restricted_current.txt b/xr/projected/projected/api/restricted_current.txt index e6f50d0..cc00cc6 100644 --- a/xr/projected/projected/api/restricted_current.txt +++ b/xr/projected/projected/api/restricted_current.txt
@@ -1 +1,15 @@ // Signature format: 4.0 +package androidx.xr.projected { + + public final class ProjectedContext { + method public static android.content.Intent addProjectedFlags(android.content.Intent intent); + method public static android.content.Context createHostDeviceContext(android.content.Context context); + method public static android.app.ActivityOptions createProjectedActivityOptions(android.content.Context context); + method public static android.content.Context createProjectedDeviceContext(android.content.Context context); + method public static String? getProjectedDeviceName(android.content.Context context); + method public static boolean isProjectedDeviceContext(android.content.Context context); + field public static final androidx.xr.projected.ProjectedContext INSTANCE; + } + +} +
diff --git a/xr/projected/projected/build.gradle b/xr/projected/projected/build.gradle index d7c2aab..7d99562 100644 --- a/xr/projected/projected/build.gradle +++ b/xr/projected/projected/build.gradle
@@ -31,9 +31,17 @@ dependencies { api(libs.kotlinStdlib) + + implementation("androidx.annotation:annotation:1.8.1") + + testImplementation(libs.junit) + testImplementation(libs.testExtJunit) + testImplementation(libs.testRunner) + testImplementation(libs.truth) } android { + compileSdk = 35 namespace = "androidx.xr.projected" defaultConfig { @@ -46,4 +54,5 @@ type = SoftwareType.PUBLISHED_LIBRARY inceptionYear = "2025" description = "Provides components for using backwards-compatible features on Projected XR devices." + enableRobolectric() }
diff --git a/xr/projected/projected/src/main/kotlin/androidx/xr/projected/ProjectedContext.kt b/xr/projected/projected/src/main/kotlin/androidx/xr/projected/ProjectedContext.kt new file mode 100644 index 0000000..2bf6c4a --- /dev/null +++ b/xr/projected/projected/src/main/kotlin/androidx/xr/projected/ProjectedContext.kt
@@ -0,0 +1,149 @@ +/* + * Copyright 2025 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.xr.projected + +import android.app.ActivityOptions +import android.companion.virtual.VirtualDeviceManager +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.annotation.VisibleForTesting + +/** + * Helper for accessing Projected device [Context] and its features. + * + * Projected device is an XR device connected to an Android device (host). Host can project the + * application content to the Projected device and let users interact with it. + * + * The Projected device context will ensure Projected device system services are returned, when + * queried for system services from this object. + * + * Note: The application context's deviceId can switch between the Projected and host deviceId + * depending on which activity was most recently in the foreground. Prefer using the Activity + * context to minimize the risk of running into this problem. + */ +public object ProjectedContext { + + private const val TAG = "ProjectedContext" + + @VisibleForTesting internal const val PROJECTED_DEVICE_NAME = "ProjectionDevice" + + @VisibleForTesting + internal const val REQUIRED_LAUNCH_FLAGS = + (Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or + Intent.FLAG_ACTIVITY_SINGLE_TOP) + + /** + * Explicitly create the Projected device context from any context object. It returns null if + * the projected device was not found. + */ + @JvmStatic + public fun createProjectedDeviceContext(context: Context): Context { + val deviceId = + getProjectedDeviceId(context) + ?: throw IllegalStateException("Projected device not found.") + return context.createDeviceContext(deviceId) + } + + /** + * Explicitly create the host device context from any context object. The host is the device + * that connects to a Projected device. + * + * If an application is using a Projected device context and it wants to use system services + * from the host (e.g. phone), it needs to use the host device context. + */ + @JvmStatic + public fun createHostDeviceContext(context: Context): Context = + context.createDeviceContext(Context.DEVICE_ID_DEFAULT) + + /** + * Returns the name of the Projected device or null if either virtual device wasn't found or the + * name of the virtual device wasn't set. + * + * @throws IllegalArgumentException If another context is used (e.g. the host context). + */ + @JvmStatic + public fun getProjectedDeviceName(context: Context): String? = + // TODO: b/424812882 - Turn this into a lint check with an annotation. + if (isProjectedDeviceContext(context)) { + getVirtualDevice(context)?.name + } else { + throw IllegalArgumentException( + "Provided context is not the Projected device context. Can't get the device name." + ) + } + + /** Returns whether the provided context is the Projected device context. */ + @JvmStatic + public fun isProjectedDeviceContext(context: Context): Boolean = + getVirtualDevice(context)?.name?.startsWith(PROJECTED_DEVICE_NAME) == true + + /** + * Takes an [Intent] with the description of the activity to start and returns it with added + * flags to start the activity on the Projected device. + * + * @param intent The description of the activity to start. + */ + @JvmStatic + public fun addProjectedFlags(intent: Intent): Intent = intent.addFlags(REQUIRED_LAUNCH_FLAGS) + + /** + * Creates [ActivityOptions] that should be used to start an activity on the Projected device. + * + * If the Projected device have more than one associated display, the activity will be started + * on the first one. + * + * @param context The Projected device context. + */ + @JvmStatic + public fun createProjectedActivityOptions(context: Context): ActivityOptions { + // TODO: b/424812882 - Turn this into a lint check with an annotation. + if (!isProjectedDeviceContext(context)) { + throw IllegalArgumentException("Provided context is not the Projected device context.") + } + + val displayIds = getProjectedDisplayIds(context) + + if (displayIds.isEmpty()) { + throw IllegalStateException("No projected display found.") + } + + if (displayIds.size > 1) { + // TODO: b/424812731 - Add support for multiple display IDs. + Log.w(TAG, "More than one projected display found. Selecting the first one.") + } + + return ActivityOptions.makeBasic().setLaunchDisplayId(displayIds.first()) + } + + private fun getProjectedDeviceId(context: Context) = + context + .getSystemService(VirtualDeviceManager::class.java) + .virtualDevices + // TODO: b/424824481 - Replace the name matching with a better method. + .find { it.name?.startsWith(PROJECTED_DEVICE_NAME) ?: false } + ?.deviceId + + private fun getVirtualDevice(context: Context) = + context.getSystemService(VirtualDeviceManager::class.java).virtualDevices.find { + it.deviceId == context.deviceId + } + + private fun getProjectedDisplayIds(context: Context) = + getVirtualDevice(context)?.displayIds ?: IntArray(size = 0) +}
diff --git a/xr/projected/projected/src/test/kotlin/androidx/xr/projected/ProjectedContextTest.kt b/xr/projected/projected/src/test/kotlin/androidx/xr/projected/ProjectedContextTest.kt new file mode 100644 index 0000000..fafe4c4 --- /dev/null +++ b/xr/projected/projected/src/test/kotlin/androidx/xr/projected/ProjectedContextTest.kt
@@ -0,0 +1,156 @@ +/* + * Copyright 2025 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.xr.projected + +import android.companion.virtual.VirtualDeviceManager +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.xr.projected.ProjectedContext.PROJECTED_DEVICE_NAME +import com.google.common.truth.Truth.assertThat +import org.junit.Assert.assertThrows +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.util.ReflectionHelpers +import org.robolectric.util.ReflectionHelpers.ClassParameter + +@Config(sdk = [Build.VERSION_CODES.VANILLA_ICE_CREAM]) +@RunWith(AndroidJUnit4::class) +class ProjectedContextTest { + + val context: ContextWrapper = ApplicationProvider.getApplicationContext() + val virtualDeviceManager = + context.getSystemService(Context.VIRTUAL_DEVICE_SERVICE) as VirtualDeviceManager + + val projectedDeviceContext: Context + get() = LocalContextWrapper(context, virtualDeviceManager.virtualDevices.first().deviceId) + + @Test + fun createProjectedDeviceContext_hasVirtualDevice_returnsContext() { + createVirtualDevice() + + assertThat(ProjectedContext.createProjectedDeviceContext(context)).isNotNull() + } + + @Test + fun createProjectedDeviceContext_noVirtualDevice_throwsIllegalStateException() { + assertThrows(IllegalStateException::class.java) { + assertThat(ProjectedContext.createProjectedDeviceContext(context)).isNull() + } + } + + @Test + fun createHostDeviceContext_returnsContext() { + assertThat(ProjectedContext.createHostDeviceContext(context).deviceId) + .isEqualTo(Context.DEVICE_ID_DEFAULT) + } + + @Test + fun getProjectedDeviceName_projectedDeviceContext_returnsName() { + createVirtualDevice() + + assertThat(ProjectedContext.getProjectedDeviceName(projectedDeviceContext)) + .isEqualTo(PROJECTED_DEVICE_NAME) + } + + @Test + fun getProjectedDeviceName_anotherContext_throwsIllegalArgumentException() { + createVirtualDevice() + + assertThrows(IllegalArgumentException::class.java) { + ProjectedContext.getProjectedDeviceName(context) + } + } + + @Test + fun isProjectedDeviceContext_returnsTrue() { + createVirtualDevice() + + assertThat(ProjectedContext.isProjectedDeviceContext(projectedDeviceContext)).isTrue() + } + + @Test + fun isProjectedDeviceContext_returnsFalse() { + createVirtualDevice() + + assertThat(ProjectedContext.isProjectedDeviceContext(context)).isFalse() + } + + @Test + fun addProjectedFlags_returnsIntentWithAddedFlags() { + val intent = Intent().setFlags(Intent.FLAG_ACTIVITY_NO_HISTORY) + val expectedFlags = intent.flags or ProjectedContext.REQUIRED_LAUNCH_FLAGS + + ProjectedContext.addProjectedFlags(intent) + + assertThat(intent.flags).isEqualTo(expectedFlags) + } + + @Test + @Ignore // Bring back this test once a new Robolectric version is available + fun createProjectedActivityOptions_projectedDeviceContext_returnsActivityOptionsWithLaunchDisplayId() { + createVirtualDevice() + + val activityOptions = ProjectedContext.createProjectedActivityOptions(context) + + assertThat(activityOptions.launchDisplayId).isEqualTo(DISPLAY_ID) + } + + @Test + fun createProjectedActivityOptions_anotherContext_throwsIllegalArgumentException() { + assertThrows(IllegalArgumentException::class.java) { + ProjectedContext.createProjectedActivityOptions(context) + } + } + + private fun createVirtualDevice() { + val virtualDeviceParamsBuilderClass = + Class.forName("android.companion.virtual.VirtualDeviceParams\$Builder") + val virtualDeviceParamsClass = + Class.forName("android.companion.virtual.VirtualDeviceParams") + var virtualDeviceParamsBuilder = + ReflectionHelpers.callConstructor(virtualDeviceParamsBuilderClass) + virtualDeviceParamsBuilder = + ReflectionHelpers.callInstanceMethod( + virtualDeviceParamsBuilder, + "setName", + ClassParameter(String::class.java, PROJECTED_DEVICE_NAME), + ) + virtualDeviceParamsBuilder = + ReflectionHelpers.callInstanceMethod(virtualDeviceParamsBuilder, "build") + ReflectionHelpers.callInstanceMethod( + virtualDeviceManager, + "createVirtualDevice", + ClassParameter(Int::class.javaPrimitiveType, 1), + ClassParameter(virtualDeviceParamsClass, virtualDeviceParamsBuilder), + ) + } + + companion object { + private const val DISPLAY_ID = 5 + } + + class LocalContextWrapper(context: Context, private val deviceId: Int) : + ContextWrapper(context) { + override fun getDeviceId() = deviceId + } +}