Merge "Create overriding mechanism for ThreePaneScaffold" into androidx-main
diff --git a/activity/integration-tests/baselineprofile/build.gradle b/activity/integration-tests/baselineprofile/build.gradle
index b340454..dd1f0be 100644
--- a/activity/integration-tests/baselineprofile/build.gradle
+++ b/activity/integration-tests/baselineprofile/build.gradle
@@ -35,7 +35,7 @@
minSdkVersion 23
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
- testOptions.managedDevices.devices {
+ testOptions.managedDevices.allDevices {
pixel6Api31(ManagedVirtualDevice) {
device = "Pixel 6"
apiLevel = 31
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 1c56469..9f47f58 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
@@ -17,21 +17,26 @@
package androidx.appsearch.localstorage;
import static androidx.appsearch.app.AppSearchResult.RESULT_INVALID_ARGUMENT;
+import static androidx.appsearch.app.AppSearchResult.RESULT_OUT_OF_SPACE;
import static androidx.appsearch.localstorage.util.PrefixUtil.addPrefixToDocument;
import static androidx.appsearch.localstorage.util.PrefixUtil.createPrefix;
import static androidx.appsearch.localstorage.util.PrefixUtil.removePrefixesFromDocument;
import static androidx.appsearch.localstorage.visibilitystore.VisibilityStore.ANDROID_V_OVERLAY_DATABASE_NAME;
import static androidx.appsearch.localstorage.visibilitystore.VisibilityStore.VISIBILITY_DATABASE_NAME;
import static androidx.appsearch.localstorage.visibilitystore.VisibilityStore.VISIBILITY_PACKAGE_NAME;
+import static androidx.appsearch.testutil.AppSearchTestUtils.calculateDigest;
import static androidx.appsearch.testutil.AppSearchTestUtils.createMockVisibilityChecker;
+import static androidx.appsearch.testutil.AppSearchTestUtils.generateRandomBytes;
import static com.google.common.truth.Truth.assertThat;
import static org.junit.Assert.assertThrows;
import android.content.Context;
+import android.os.ParcelFileDescriptor;
import androidx.annotation.NonNull;
+import androidx.appsearch.app.AppSearchBlobHandle;
import androidx.appsearch.app.AppSearchResult;
import androidx.appsearch.app.AppSearchSchema;
import androidx.appsearch.app.GenericDocument;
@@ -95,6 +100,9 @@
import org.junit.rules.TemporaryFolder;
import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@@ -117,18 +125,20 @@
private final CallerAccess mSelfCallerAccess = new CallerAccess(mContext.getPackageName());
private AppSearchImpl mAppSearchImpl;
+ private AppSearchConfig mUnlimitedConfig = new AppSearchConfigImpl(
+ new UnlimitedLimitConfig(),
+ new LocalStorageIcingOptionsConfig()
+ );
@Before
public void setUp() throws Exception {
mAppSearchDir = mTemporaryFolder.newFolder();
mAppSearchImpl = AppSearchImpl.create(
mAppSearchDir,
- new AppSearchConfigImpl(
- new UnlimitedLimitConfig(),
- new LocalStorageIcingOptionsConfig()
- ),
+ mUnlimitedConfig,
/*initStatsBuilder=*/ null,
/*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
ALWAYS_OPTIMIZE);
}
@@ -513,7 +523,10 @@
mAppSearchImpl = AppSearchImpl.create(
mAppSearchDir, new AppSearchConfigImpl(new UnlimitedLimitConfig(),
new LocalStorageIcingOptionsConfig()),
- initStatsBuilder, /*visibilityChecker=*/ null, ALWAYS_OPTIMIZE);
+ initStatsBuilder,
+ /*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
+ ALWAYS_OPTIMIZE);
// Check recovery state
InitializeStats initStats = initStatsBuilder.build();
@@ -746,7 +759,8 @@
new LocalStorageIcingOptionsConfig()
),
/*initStatsBuilder=*/ null,
- mockVisibilityChecker, ALWAYS_OPTIMIZE
+ mockVisibilityChecker, /*revocableFileDescriptorStore=*/ null,
+ ALWAYS_OPTIMIZE
);
// Insert package1 schema
@@ -921,7 +935,8 @@
new LocalStorageIcingOptionsConfig()
),
/*initStatsBuilder=*/ null,
- mockVisibilityChecker, ALWAYS_OPTIMIZE
+ mockVisibilityChecker, /*revocableFileDescriptorStore=*/ null,
+ ALWAYS_OPTIMIZE
);
AppSearchSchema.StringPropertyConfig personField =
@@ -2238,7 +2253,168 @@
}
@Test
- public void testClearPackageData() throws AppSearchException {
+ public void testWriteAndReadBlob() throws Exception {
+ mAppSearchImpl = AppSearchImpl.create(
+ mAppSearchDir,
+ new AppSearchConfigImpl(new UnlimitedLimitConfig(),
+ new LocalStorageIcingOptionsConfig()),
+ /*initStatsBuilder=*/ null,
+ /*visibilityChecker=*/ null,
+ new JetpackRevocableFileDescriptorStore(mUnlimitedConfig),
+ ALWAYS_OPTIMIZE);
+ byte[] data = generateRandomBytes(20 * 1024); // 20 KiB
+ byte[] digest = calculateDigest(data);
+ AppSearchBlobHandle handle = AppSearchBlobHandle.createWithSha256(
+ digest, "package", "db1", "ns");
+ try (ParcelFileDescriptor writePfd = mAppSearchImpl.openWriteBlob("package", "db1", handle);
+ OutputStream outputStream = new ParcelFileDescriptor
+ .AutoCloseOutputStream(writePfd)) {
+ outputStream.write(data);
+ outputStream.flush();
+ }
+
+ // commit the change and read the blob.
+ mAppSearchImpl.commitBlob("package", "db1", handle);
+ byte[] readBytes = new byte[20 * 1024];
+ try (ParcelFileDescriptor readPfd = mAppSearchImpl.openReadBlob("package", "db1", handle);
+ InputStream inputStream = new ParcelFileDescriptor.AutoCloseInputStream(readPfd)) {
+ inputStream.read(readBytes);
+ }
+ assertThat(readBytes).isEqualTo(data);
+ }
+
+ @Test
+ public void testOpenReadForWrite_notAllowed() throws Exception {
+ mAppSearchImpl = AppSearchImpl.create(
+ mAppSearchDir,
+ new AppSearchConfigImpl(new UnlimitedLimitConfig(),
+ new LocalStorageIcingOptionsConfig()),
+ /*initStatsBuilder=*/ null,
+ /*visibilityChecker=*/ null,
+ new JetpackRevocableFileDescriptorStore(mUnlimitedConfig),
+ ALWAYS_OPTIMIZE);
+ byte[] data = generateRandomBytes(20); // 20 Bytes
+ byte[] digest = calculateDigest(data);
+ AppSearchBlobHandle handle = AppSearchBlobHandle.createWithSha256(
+ digest, "package", "db1", "ns");
+ try (ParcelFileDescriptor writePfd = mAppSearchImpl.openWriteBlob("package", "db1", handle);
+ OutputStream outputStream = new ParcelFileDescriptor
+ .AutoCloseOutputStream(writePfd)) {
+ outputStream.write(data);
+ outputStream.flush();
+ }
+
+ // commit the change and read the blob.
+ mAppSearchImpl.commitBlob("package", "db1", handle);
+
+ // Open output stream on read-only pfd.
+ assertThrows(IOException.class, () -> {
+ try (ParcelFileDescriptor readPfd =
+ mAppSearchImpl.openReadBlob("package", "db1", handle);
+ OutputStream outputStream = new ParcelFileDescriptor
+ .AutoCloseOutputStream(readPfd)) {
+ outputStream.write(data);
+ }
+ });
+ }
+
+ @Test
+ public void testOpenWriteForRead_allowed() throws Exception {
+ mAppSearchImpl = AppSearchImpl.create(
+ mAppSearchDir,
+ new AppSearchConfigImpl(new UnlimitedLimitConfig(),
+ new LocalStorageIcingOptionsConfig()),
+ /*initStatsBuilder=*/ null,
+ /*visibilityChecker=*/ null,
+ new JetpackRevocableFileDescriptorStore(mUnlimitedConfig),
+ ALWAYS_OPTIMIZE);
+ byte[] data = generateRandomBytes(20); // 20 Bytes
+ byte[] digest = calculateDigest(data);
+ AppSearchBlobHandle handle = AppSearchBlobHandle.createWithSha256(
+ digest, "package", "db1", "ns");
+ // openWriteBlob returns read and write fd.
+ try (ParcelFileDescriptor writePfd = mAppSearchImpl.openWriteBlob("package", "db1", handle);
+ InputStream inputStream = new ParcelFileDescriptor
+ .AutoCloseInputStream(writePfd)) {
+ inputStream.read(new byte[10]);
+ }
+ }
+
+ @Test
+ public void testRevokeFileDescriptor() throws Exception {
+ mAppSearchImpl = AppSearchImpl.create(
+ mAppSearchDir,
+ new AppSearchConfigImpl(new UnlimitedLimitConfig(),
+ new LocalStorageIcingOptionsConfig()),
+ /*initStatsBuilder=*/ null,
+ /*visibilityChecker=*/ null,
+ new JetpackRevocableFileDescriptorStore(mUnlimitedConfig),
+ ALWAYS_OPTIMIZE);
+ byte[] data = generateRandomBytes(20 * 1024); // 20 KiB
+ byte[] digest = calculateDigest(data);
+ AppSearchBlobHandle handle = AppSearchBlobHandle.createWithSha256(
+ digest, "package", "db1", "ns");
+ try (ParcelFileDescriptor writePfd =
+ mAppSearchImpl.openWriteBlob("package", "db1", handle)) {
+ // Clear package data and all file descriptor to that package will be revoked.
+ mAppSearchImpl.clearPackageData("package");
+
+ assertThrows(IOException.class, () -> {
+ try (OutputStream outputStream = new ParcelFileDescriptor
+ .AutoCloseOutputStream(writePfd)) {
+ outputStream.write(data);
+ }
+ });
+ }
+
+ // reopen file descriptor could work.
+ try (ParcelFileDescriptor writePfd2 =
+ mAppSearchImpl.openWriteBlob("package", "db1", handle)) {
+ try (OutputStream outputStream = new ParcelFileDescriptor
+ .AutoCloseOutputStream(writePfd2)) {
+ outputStream.write(data);
+ }
+ // close the AppSearchImpl will revoke all sent fds.
+ mAppSearchImpl.close();
+ assertThrows(IOException.class, () -> {
+ try (OutputStream outputStream = new ParcelFileDescriptor
+ .AutoCloseOutputStream(writePfd2)) {
+ outputStream.write(data);
+ }
+ });
+ }
+ }
+
+ @Test
+ public void testInvalidBlobHandle() throws Exception {
+ mAppSearchImpl = AppSearchImpl.create(
+ mAppSearchDir,
+ new AppSearchConfigImpl(new UnlimitedLimitConfig(),
+ new LocalStorageIcingOptionsConfig()),
+ /*initStatsBuilder=*/ null,
+ /*visibilityChecker=*/ null,
+ new JetpackRevocableFileDescriptorStore(mUnlimitedConfig),
+ ALWAYS_OPTIMIZE);
+ byte[] data = generateRandomBytes(20 * 1024); // 20 KiB
+ byte[] digest = calculateDigest(data);
+ AppSearchBlobHandle handle = AppSearchBlobHandle.createWithSha256(
+ digest, "package", "db1", "ns");
+
+ AppSearchException e = assertThrows(AppSearchException.class,
+ () -> mAppSearchImpl.openWriteBlob("wrongPackageName", "db1", handle));
+ assertThat(e.getResultCode()).isEqualTo(RESULT_INVALID_ARGUMENT);
+ assertThat(e.getMessage()).contains("Blob package doesn't match calling package, "
+ + "calling package: wrongPackageName, blob package: package");
+
+ e = assertThrows(AppSearchException.class,
+ () -> mAppSearchImpl.openWriteBlob("package", "wrongDb", handle));
+ assertThat(e.getResultCode()).isEqualTo(RESULT_INVALID_ARGUMENT);
+ assertThat(e.getMessage()).contains("Blob database doesn't match calling database, "
+ + "calling database: wrongDb, blob database: db1");
+ }
+
+ @Test
+ public void testClearPackageData() throws Exception {
List<SchemaTypeConfigProto> existingSchemas =
mAppSearchImpl.getSchemaProtoLocked().getTypesList();
Map<String, Set<String>> existingDatabases = mAppSearchImpl.getPackageToDatabases();
@@ -2911,6 +3087,7 @@
),
/*initStatsBuilder=*/ null,
/*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
ALWAYS_OPTIMIZE);
getResult = appSearchImpl2.getDocument("package", "database", "namespace1",
"id1",
@@ -2983,6 +3160,7 @@
),
/*initStatsBuilder=*/ null,
/*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
ALWAYS_OPTIMIZE);
assertThrows(AppSearchException.class, () -> appSearchImpl2.getDocument("package",
"database",
@@ -3062,6 +3240,7 @@
),
/*initStatsBuilder=*/ null,
/*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
ALWAYS_OPTIMIZE);
assertThrows(AppSearchException.class, () -> appSearchImpl2.getDocument("package",
"database",
@@ -3190,8 +3369,14 @@
public int getMaxSuggestionCount() {
return Integer.MAX_VALUE;
}
+
+ @Override
+ public int getMaxOpenBlobCount() {
+ return Integer.MAX_VALUE;
+ }
}, new LocalStorageIcingOptionsConfig()),
/*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
ALWAYS_OPTIMIZE);
// Insert schema
@@ -3274,8 +3459,14 @@
public int getMaxSuggestionCount() {
return Integer.MAX_VALUE;
}
+
+ @Override
+ public int getMaxOpenBlobCount() {
+ return Integer.MAX_VALUE;
+ }
}, new LocalStorageIcingOptionsConfig()),
/*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
ALWAYS_OPTIMIZE);
// Insert schema
@@ -3336,8 +3527,14 @@
public int getMaxSuggestionCount() {
return Integer.MAX_VALUE;
}
+
+ @Override
+ public int getMaxOpenBlobCount() {
+ return Integer.MAX_VALUE;
+ }
}, new LocalStorageIcingOptionsConfig()),
/*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
ALWAYS_OPTIMIZE);
// Make sure the limit is maintained
@@ -3378,8 +3575,14 @@
public int getMaxSuggestionCount() {
return Integer.MAX_VALUE;
}
+
+ @Override
+ public int getMaxOpenBlobCount() {
+ return Integer.MAX_VALUE;
+ }
}, new LocalStorageIcingOptionsConfig()),
/*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
ALWAYS_OPTIMIZE);
// Insert schema
@@ -3496,8 +3699,14 @@
public int getMaxSuggestionCount() {
return Integer.MAX_VALUE;
}
+
+ @Override
+ public int getMaxOpenBlobCount() {
+ return Integer.MAX_VALUE;
+ }
}, new LocalStorageIcingOptionsConfig()),
/*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
ALWAYS_OPTIMIZE);
// Insert schema
@@ -3597,8 +3806,14 @@
public int getMaxSuggestionCount() {
return Integer.MAX_VALUE;
}
+
+ @Override
+ public int getMaxOpenBlobCount() {
+ return Integer.MAX_VALUE;
+ }
}, new LocalStorageIcingOptionsConfig()),
/*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
ALWAYS_OPTIMIZE);
// package1 should still be out of space
@@ -3659,8 +3874,14 @@
public int getMaxSuggestionCount() {
return Integer.MAX_VALUE;
}
+
+ @Override
+ public int getMaxOpenBlobCount() {
+ return Integer.MAX_VALUE;
+ }
}, new LocalStorageIcingOptionsConfig()),
/*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
ALWAYS_OPTIMIZE);
// Insert schema
@@ -3817,8 +4038,14 @@
public int getMaxSuggestionCount() {
return Integer.MAX_VALUE;
}
+
+ @Override
+ public int getMaxOpenBlobCount() {
+ return Integer.MAX_VALUE;
+ }
}, new LocalStorageIcingOptionsConfig()),
/*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
ALWAYS_OPTIMIZE);
// Insert schema
@@ -3905,8 +4132,14 @@
public int getMaxSuggestionCount() {
return Integer.MAX_VALUE;
}
+
+ @Override
+ public int getMaxOpenBlobCount() {
+ return Integer.MAX_VALUE;
+ }
}, new LocalStorageIcingOptionsConfig()),
/*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
ALWAYS_OPTIMIZE);
// Insert schema
@@ -3967,8 +4200,14 @@
public int getMaxSuggestionCount() {
return Integer.MAX_VALUE;
}
+
+ @Override
+ public int getMaxOpenBlobCount() {
+ return Integer.MAX_VALUE;
+ }
}, new LocalStorageIcingOptionsConfig()),
/*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
ALWAYS_OPTIMIZE);
// Index id2. This should pass but only because we check for replacements.
@@ -4019,8 +4258,14 @@
public int getMaxSuggestionCount() {
return 2;
}
+
+ @Override
+ public int getMaxOpenBlobCount() {
+ return Integer.MAX_VALUE;
+ }
}, new LocalStorageIcingOptionsConfig()),
/*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
ALWAYS_OPTIMIZE);
AppSearchException e = assertThrows(AppSearchException.class, () ->
@@ -4060,8 +4305,14 @@
public int getMaxSuggestionCount() {
return Integer.MAX_VALUE;
}
+
+ @Override
+ public int getMaxOpenBlobCount() {
+ return Integer.MAX_VALUE;
+ }
}, new LocalStorageIcingOptionsConfig()),
/*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
ALWAYS_OPTIMIZE);
// Insert schema
@@ -4120,8 +4371,14 @@
public int getMaxSuggestionCount() {
return Integer.MAX_VALUE;
}
+
+ @Override
+ public int getMaxOpenBlobCount() {
+ return Integer.MAX_VALUE;
+ }
}, new LocalStorageIcingOptionsConfig()),
/*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
ALWAYS_OPTIMIZE);
// Insert schemas for thress packages
@@ -4236,8 +4493,14 @@
public int getMaxSuggestionCount() {
return Integer.MAX_VALUE;
}
+
+ @Override
+ public int getMaxOpenBlobCount() {
+ return Integer.MAX_VALUE;
+ }
}, new LocalStorageIcingOptionsConfig()),
/*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
ALWAYS_OPTIMIZE);
// Insert schema
@@ -4307,8 +4570,14 @@
public int getMaxSuggestionCount() {
return Integer.MAX_VALUE;
}
+
+ @Override
+ public int getMaxOpenBlobCount() {
+ return Integer.MAX_VALUE;
+ }
}, new LocalStorageIcingOptionsConfig()),
/*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
ALWAYS_OPTIMIZE);
// Insert schema
@@ -4424,8 +4693,14 @@
public int getMaxSuggestionCount() {
return Integer.MAX_VALUE;
}
+
+ @Override
+ public int getMaxOpenBlobCount() {
+ return Integer.MAX_VALUE;
+ }
}, new LocalStorageIcingOptionsConfig()),
/*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
ALWAYS_OPTIMIZE);
// Insert schema
@@ -4530,6 +4805,68 @@
/*logger=*/ null);
}
+ @Test
+ public void testLimitConfig_activeFds() throws Exception {
+ mAppSearchImpl.close();
+ File tempFolder = mTemporaryFolder.newFolder();
+ AppSearchConfig config = new AppSearchConfigImpl(new LimitConfig() {
+ @Override
+ public int getMaxDocumentSizeBytes() {
+ return Integer.MAX_VALUE;
+ }
+
+ @Override
+ public int getPerPackageDocumentCountLimit() {
+ return Integer.MAX_VALUE;
+ }
+
+ @Override
+ public int getDocumentCountLimitStartThreshold() {
+ return Integer.MAX_VALUE;
+ }
+
+ @Override
+ public int getMaxSuggestionCount() {
+ return Integer.MAX_VALUE;
+ }
+
+ @Override
+ public int getMaxOpenBlobCount() {
+ return 2;
+ }
+ }, new LocalStorageIcingOptionsConfig());
+ mAppSearchImpl = AppSearchImpl.create(
+ tempFolder,
+ config,
+ /*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+ new JetpackRevocableFileDescriptorStore(config),
+ ALWAYS_OPTIMIZE);
+ // We could open only 2 fds per package.
+ byte[] data1 = generateRandomBytes(20 * 1024); // 20 KiB
+ byte[] digest1 = calculateDigest(data1);
+ AppSearchBlobHandle handle1 = AppSearchBlobHandle.createWithSha256(
+ digest1, "package", "db1", "ns");
+ mAppSearchImpl.openWriteBlob("package", "db1", handle1);
+
+ byte[] data2 = generateRandomBytes(20 * 1024); // 20 KiB
+ byte[] digest2 = calculateDigest(data2);
+ AppSearchBlobHandle handle2 = AppSearchBlobHandle.createWithSha256(
+ digest2, "package", "db1", "ns");
+ mAppSearchImpl.openWriteBlob("package", "db1", handle2);
+
+ // Open 3rd fd will fail.
+ byte[] data3 = generateRandomBytes(20 * 1024); // 20 KiB
+ byte[] digest3 = calculateDigest(data3);
+ AppSearchBlobHandle handle3 = AppSearchBlobHandle.createWithSha256(
+ digest3, "package", "db1", "ns");
+ AppSearchException e = assertThrows(AppSearchException.class,
+ () -> mAppSearchImpl.openWriteBlob("package", "db1", handle3));
+ assertThat(e.getResultCode()).isEqualTo(RESULT_OUT_OF_SPACE);
+ assertThat(e).hasMessageThat().contains(
+ "Package \"package\" exceeded limit of 2 opened file descriptors. "
+ + "Some file descriptors must be closed to open additional ones.");
+ }
+
/**
* Ensure that it is okay to register the same observer for multiple packages and that removing
* the observer for one package doesn't remove it for the other.
@@ -4620,8 +4957,8 @@
new LocalStorageIcingOptionsConfig()
),
/*initStatsBuilder=*/ null,
- mockVisibilityChecker, ALWAYS_OPTIMIZE
- );
+ mockVisibilityChecker, /*revocableFileDescriptorStore=*/ null,
+ ALWAYS_OPTIMIZE);
InternalSetSchemaResponse internalSetSchemaResponse = mAppSearchImpl.setSchema(
"package",
@@ -4672,8 +5009,8 @@
new LocalStorageIcingOptionsConfig()
),
/*initStatsBuilder=*/ null,
- mockVisibilityChecker, ALWAYS_OPTIMIZE
- );
+ mockVisibilityChecker, /*revocableFileDescriptorStore=*/ null,
+ ALWAYS_OPTIMIZE);
InternalSetSchemaResponse internalSetSchemaResponse = mAppSearchImpl.setSchema(
"package",
@@ -4722,8 +5059,8 @@
new LocalStorageIcingOptionsConfig()
),
/*initStatsBuilder=*/ null,
- mockVisibilityChecker, ALWAYS_OPTIMIZE
- );
+ mockVisibilityChecker, /*revocableFileDescriptorStore=*/ null,
+ ALWAYS_OPTIMIZE);
InternalSetSchemaResponse internalSetSchemaResponse = mAppSearchImpl.setSchema(
"package",
@@ -4786,8 +5123,8 @@
new LocalStorageIcingOptionsConfig()
),
/*initStatsBuilder=*/ null,
- mockVisibilityChecker, ALWAYS_OPTIMIZE
- );
+ mockVisibilityChecker, /*revocableFileDescriptorStore=*/ null,
+ ALWAYS_OPTIMIZE);
InternalSetSchemaResponse internalSetSchemaResponse = mAppSearchImpl.setSchema(
"package",
@@ -5149,6 +5486,7 @@
),
/*initStatsBuilder=*/ null,
/*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
ALWAYS_OPTIMIZE);
String prefix = PrefixUtil.createPrefix("packageName", "databaseName");
@@ -5194,6 +5532,7 @@
),
/*initStatsBuilder=*/ null,
/*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
ALWAYS_OPTIMIZE);
assertThat(mAppSearchImpl.mVisibilityStoreLocked.getVisibility(prefix + "Email")).isNull();
@@ -5225,8 +5564,8 @@
new LocalStorageIcingOptionsConfig()
),
/*initStatsBuilder=*/ null,
- mockVisibilityChecker, ALWAYS_OPTIMIZE
- );
+ mockVisibilityChecker, /*revocableFileDescriptorStore=*/ null,
+ ALWAYS_OPTIMIZE);
// Add a schema type that is not displayed by the system
InternalSetSchemaResponse internalSetSchemaResponse = mAppSearchImpl.setSchema(
@@ -5334,8 +5673,8 @@
new LocalStorageIcingOptionsConfig()
),
/*initStatsBuilder=*/ null,
- mockVisibilityChecker, ALWAYS_OPTIMIZE
- );
+ mockVisibilityChecker, /*revocableFileDescriptorStore=*/ null,
+ ALWAYS_OPTIMIZE);
// Add two schema types that are not displayed by the system.
InternalSetSchemaResponse internalSetSchemaResponse = mAppSearchImpl.setSchema(
@@ -5409,8 +5748,8 @@
new LocalStorageIcingOptionsConfig()
),
/*initStatsBuilder=*/ null,
- publicAclMockChecker, ALWAYS_OPTIMIZE
- );
+ publicAclMockChecker, /*revocableFileDescriptorStore=*/ null,
+ ALWAYS_OPTIMIZE);
List<InternalVisibilityConfig> visibilityConfigs = ImmutableList.of(
new InternalVisibilityConfig.Builder("PublicTypeA")
@@ -5504,8 +5843,8 @@
new LocalStorageIcingOptionsConfig()
),
/*initStatsBuilder=*/ null,
- publicAclMockChecker, ALWAYS_OPTIMIZE
- );
+ publicAclMockChecker, /*revocableFileDescriptorStore=*/ null,
+ ALWAYS_OPTIMIZE);
List<InternalVisibilityConfig> visibilityConfigs = ImmutableList.of(
new InternalVisibilityConfig.Builder("PublicTypeA")
@@ -5667,8 +6006,8 @@
new LocalStorageIcingOptionsConfig()
),
/*initStatsBuilder=*/null,
- rejectChecker, ALWAYS_OPTIMIZE
- );
+ rejectChecker, /*revocableFileDescriptorStore=*/ null,
+ ALWAYS_OPTIMIZE);
// Add a schema type
InternalSetSchemaResponse internalSetSchemaResponse = mAppSearchImpl.setSchema(
@@ -5781,8 +6120,8 @@
new LocalStorageIcingOptionsConfig()
),
/*initStatsBuilder=*/null,
- visibilityChecker, ALWAYS_OPTIMIZE
- );
+ visibilityChecker, /*revocableFileDescriptorStore=*/ null,
+ ALWAYS_OPTIMIZE);
// Add a schema type
InternalSetSchemaResponse internalSetSchemaResponse = mAppSearchImpl.setSchema(
@@ -5842,8 +6181,8 @@
new LocalStorageIcingOptionsConfig()
),
/*initStatsBuilder=*/null,
- rejectChecker, ALWAYS_OPTIMIZE
- );
+ rejectChecker, /*revocableFileDescriptorStore=*/ null,
+ ALWAYS_OPTIMIZE);
// Add a schema type
InternalSetSchemaResponse internalSetSchemaResponse = mAppSearchImpl.setSchema(
@@ -6184,8 +6523,8 @@
new LocalStorageIcingOptionsConfig()
),
/*initStatsBuilder=*/null,
- visibilityChecker, ALWAYS_OPTIMIZE
- );
+ visibilityChecker, /*revocableFileDescriptorStore=*/ null,
+ ALWAYS_OPTIMIZE);
// Register an observer
TestObserverCallback observer = new TestObserverCallback();
@@ -6349,8 +6688,8 @@
new LocalStorageIcingOptionsConfig()
),
/*initStatsBuilder=*/null,
- visibilityChecker, ALWAYS_OPTIMIZE
- );
+ visibilityChecker, /*revocableFileDescriptorStore=*/ null,
+ ALWAYS_OPTIMIZE);
// Add a schema.
InternalSetSchemaResponse internalSetSchemaResponse = mAppSearchImpl.setSchema(
@@ -6448,8 +6787,8 @@
new LocalStorageIcingOptionsConfig()
),
/*initStatsBuilder=*/null,
- visibilityChecker, ALWAYS_OPTIMIZE
- );
+ visibilityChecker, /*revocableFileDescriptorStore=*/ null,
+ ALWAYS_OPTIMIZE);
// Add a schema.
InternalSetSchemaResponse internalSetSchemaResponse = mAppSearchImpl.setSchema(
@@ -6549,8 +6888,8 @@
new LocalStorageIcingOptionsConfig()
),
/*initStatsBuilder=*/null,
- visibilityChecker, ALWAYS_OPTIMIZE
- );
+ visibilityChecker, /*revocableFileDescriptorStore=*/ null,
+ ALWAYS_OPTIMIZE);
// Add a schema.
InternalSetSchemaResponse internalSetSchemaResponse = mAppSearchImpl.setSchema(
diff --git a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/AppSearchLoggerTest.java b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/AppSearchLoggerTest.java
index aa4c96a..4fe47c6 100644
--- a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/AppSearchLoggerTest.java
+++ b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/AppSearchLoggerTest.java
@@ -70,17 +70,19 @@
public TemporaryFolder mTemporaryFolder = new TemporaryFolder();
private AppSearchImpl mAppSearchImpl;
private SimpleTestLogger mLogger;
+ private AppSearchConfig mConfig = new AppSearchConfigImpl(
+ new UnlimitedLimitConfig(),
+ new LocalStorageIcingOptionsConfig()
+ );
@Before
public void setUp() throws Exception {
mAppSearchImpl = AppSearchImpl.create(
mTemporaryFolder.newFolder(),
- new AppSearchConfigImpl(
- new UnlimitedLimitConfig(),
- new LocalStorageIcingOptionsConfig()
- ),
+ mConfig,
/*initStatsBuilder=*/ null,
/*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
ALWAYS_OPTIMIZE);
mLogger = new SimpleTestLogger();
}
@@ -368,12 +370,10 @@
InitializeStats.Builder initStatsBuilder = new InitializeStats.Builder();
AppSearchImpl appSearchImpl = AppSearchImpl.create(
mTemporaryFolder.newFolder(),
- new AppSearchConfigImpl(
- new UnlimitedLimitConfig(),
- new LocalStorageIcingOptionsConfig()
- ),
+ mConfig,
initStatsBuilder,
/*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
ALWAYS_OPTIMIZE);
InitializeStats iStats = initStatsBuilder.build();
appSearchImpl.close();
@@ -401,12 +401,10 @@
AppSearchImpl appSearchImpl = AppSearchImpl.create(
folder,
- new AppSearchConfigImpl(
- new UnlimitedLimitConfig(),
- new LocalStorageIcingOptionsConfig()
- ),
+ mConfig,
/*initStatsBuilder=*/ null,
/*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
ALWAYS_OPTIMIZE);
List<AppSearchSchema> schemas = ImmutableList.of(
new AppSearchSchema.Builder("Type1").build(),
@@ -440,10 +438,10 @@
// Create another appsearchImpl on the same folder
InitializeStats.Builder initStatsBuilder = new InitializeStats.Builder();
- appSearchImpl = AppSearchImpl.create(
- folder, new AppSearchConfigImpl(new UnlimitedLimitConfig(),
- new LocalStorageIcingOptionsConfig()),
- initStatsBuilder, /*visibilityChecker=*/ null, ALWAYS_OPTIMIZE);
+ appSearchImpl = AppSearchImpl.create(folder, mConfig,
+ initStatsBuilder, /*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
+ ALWAYS_OPTIMIZE);
InitializeStats iStats = initStatsBuilder.build();
assertThat(iStats).isNotNull();
@@ -469,10 +467,10 @@
final String testDatabase = "testDatabase";
final File folder = mTemporaryFolder.newFolder();
- AppSearchImpl appSearchImpl = AppSearchImpl.create(
- folder, new AppSearchConfigImpl(new UnlimitedLimitConfig(),
- new LocalStorageIcingOptionsConfig()),
- /*initStatsBuilder=*/ null, /*visibilityChecker=*/ null, ALWAYS_OPTIMIZE);
+ AppSearchImpl appSearchImpl = AppSearchImpl.create(folder, mConfig,
+ /*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
+ ALWAYS_OPTIMIZE);
List<AppSearchSchema> schemas = ImmutableList.of(
new AppSearchSchema.Builder("Type1").build(),
@@ -509,10 +507,10 @@
// Create another appsearchImpl on the same folder
InitializeStats.Builder initStatsBuilder = new InitializeStats.Builder();
- appSearchImpl = AppSearchImpl.create(
- folder, new AppSearchConfigImpl(new UnlimitedLimitConfig(),
- new LocalStorageIcingOptionsConfig()),
- initStatsBuilder, /*visibilityChecker=*/ null, ALWAYS_OPTIMIZE);
+ appSearchImpl = AppSearchImpl.create(folder, mConfig,
+ initStatsBuilder, /*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
+ ALWAYS_OPTIMIZE);
InitializeStats iStats = initStatsBuilder.build();
// Some of other fields are already covered by AppSearchImplTest#testReset()
diff --git a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/SearchResultsImplTest.java b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/SearchResultsImplTest.java
index f189baf..a83134e6 100644
--- a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/SearchResultsImplTest.java
+++ b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/SearchResultsImplTest.java
@@ -49,13 +49,16 @@
@Before
public void setUp() throws Exception {
+ AppSearchConfig config = new AppSearchConfigImpl(
+ new UnlimitedLimitConfig(),
+ new LocalStorageIcingOptionsConfig()
+ );
mAppSearchImpl = AppSearchImpl.create(
mTemporaryFolder.newFolder(),
- new AppSearchConfigImpl(
- new UnlimitedLimitConfig(),
- new LocalStorageIcingOptionsConfig()
- ),
- /*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+ config,
+ /*initStatsBuilder=*/ null,
+ /*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
ALWAYS_OPTIMIZE);
}
diff --git a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/converter/BlobHandleToProtoConverterTest.java b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/converter/BlobHandleToProtoConverterTest.java
new file mode 100644
index 0000000..65f252b
--- /dev/null
+++ b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/converter/BlobHandleToProtoConverterTest.java
@@ -0,0 +1,64 @@
+/*
+ * 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.appsearch.localstorage.converter;
+
+import static androidx.appsearch.testutil.AppSearchTestUtils.calculateDigest;
+import static androidx.appsearch.testutil.AppSearchTestUtils.generateRandomBytes;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.appsearch.app.AppSearchBlobHandle;
+import androidx.appsearch.localstorage.util.PrefixUtil;
+
+import com.google.android.icing.proto.PropertyProto;
+import com.google.android.icing.protobuf.ByteString;
+
+import org.junit.Test;
+
+public class BlobHandleToProtoConverterTest {
+
+ @Test
+ public void testToBlobHandleProto() throws Exception {
+ byte[] data = generateRandomBytes(20 * 1024); // 20 KiB
+ byte[] digest = calculateDigest(data);
+ AppSearchBlobHandle handle = AppSearchBlobHandle.createWithSha256(
+ digest, "package", "db1", "ns");
+
+ PropertyProto.BlobHandleProto proto = BlobHandleToProtoConverter.toBlobHandleProto(handle);
+
+ assertThat(proto.getDigest().toByteArray()).isEqualTo(digest);
+ assertThat(proto.getNamespace()).isEqualTo(
+ PrefixUtil.createPrefix("package", "db1") + "ns");
+ }
+
+ @Test
+ public void testToBlobHandle() throws Exception {
+ byte[] data = generateRandomBytes(20 * 1024); // 20 KiB
+ byte[] digest = calculateDigest(data);
+
+ PropertyProto.BlobHandleProto proto = PropertyProto.BlobHandleProto.newBuilder()
+ .setNamespace(PrefixUtil.createPrefix("package", "db1") + "ns")
+ .setDigest(ByteString.copyFrom(digest))
+ .build();
+ AppSearchBlobHandle handle = BlobHandleToProtoConverter.toAppSearchBlobHandle(proto);
+
+ assertThat(handle.getPackageName()).isEqualTo("package");
+ assertThat(handle.getDatabaseName()).isEqualTo("db1");
+ assertThat(handle.getNamespace()).isEqualTo("ns");
+ assertThat(handle.getSha256Digest()).isEqualTo(digest);
+ }
+}
diff --git a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/converter/SearchSpecToProtoConverterTest.java b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/converter/SearchSpecToProtoConverterTest.java
index dbb8f58..11d0a21 100644
--- a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/converter/SearchSpecToProtoConverterTest.java
+++ b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/converter/SearchSpecToProtoConverterTest.java
@@ -25,6 +25,7 @@
import androidx.appsearch.app.JoinSpec;
import androidx.appsearch.app.SearchSpec;
+import androidx.appsearch.localstorage.AppSearchConfig;
import androidx.appsearch.localstorage.AppSearchConfigImpl;
import androidx.appsearch.localstorage.AppSearchImpl;
import androidx.appsearch.localstorage.IcingOptionsConfig;
@@ -72,14 +73,16 @@
@Before
public void setUp() throws Exception {
+ AppSearchConfig config = new AppSearchConfigImpl(
+ new UnlimitedLimitConfig(),
+ mLocalStorageIcingOptionsConfig
+ );
mAppSearchImpl = AppSearchImpl.create(
mTemporaryFolder.newFolder(),
- new AppSearchConfigImpl(
- new UnlimitedLimitConfig(),
- mLocalStorageIcingOptionsConfig
- ),
+ config,
/*initStatsBuilder=*/ null,
/*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
ALWAYS_OPTIMIZE);
}
diff --git a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/visibilitystore/VisibilityStoreMigrationFromV2Test.java b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/visibilitystore/VisibilityStoreMigrationFromV2Test.java
index e50274b..a7d2fd9 100644
--- a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/visibilitystore/VisibilityStoreMigrationFromV2Test.java
+++ b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/visibilitystore/VisibilityStoreMigrationFromV2Test.java
@@ -30,6 +30,7 @@
import androidx.appsearch.app.SetSchemaRequest;
import androidx.appsearch.app.VisibilityPermissionConfig;
import androidx.appsearch.exceptions.AppSearchException;
+import androidx.appsearch.localstorage.AppSearchConfig;
import androidx.appsearch.localstorage.AppSearchConfigImpl;
import androidx.appsearch.localstorage.AppSearchImpl;
import androidx.appsearch.localstorage.LocalStorageIcingOptionsConfig;
@@ -60,6 +61,8 @@
@Rule
public TemporaryFolder mTemporaryFolder = new TemporaryFolder();
private File mFile;
+ private AppSearchConfig mConfig = new AppSearchConfigImpl(new UnlimitedLimitConfig(),
+ new LocalStorageIcingOptionsConfig());
@Before
public void setUp() throws Exception {
@@ -86,9 +89,10 @@
// Create AppSearchImpl with visibility document version 2;
AppSearchImpl appSearchImplInV2 = AppSearchImpl.create(mFile,
- new AppSearchConfigImpl(new UnlimitedLimitConfig(),
- new LocalStorageIcingOptionsConfig()), /*initStatsBuilder=*/ null,
+ mConfig,
+ /*initStatsBuilder=*/ null,
/*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
ALWAYS_OPTIMIZE);
// Erase overlay schemas since it doesn't exist in released V2 schema.
@@ -160,9 +164,10 @@
// Persist to disk and re-open the AppSearchImpl
appSearchImplInV2.close();
AppSearchImpl appSearchImpl = AppSearchImpl.create(mFile,
- new AppSearchConfigImpl(new UnlimitedLimitConfig(),
- new LocalStorageIcingOptionsConfig()), /*initStatsBuilder=*/ null,
+ mConfig,
+ /*initStatsBuilder=*/ null,
/*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
ALWAYS_OPTIMIZE);
InternalVisibilityConfig actualConfig =
diff --git a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/visibilitystore/VisibilityStoreMigrationHelperFromV0Test.java b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/visibilitystore/VisibilityStoreMigrationHelperFromV0Test.java
index c91f51c..eedd6a6 100644
--- a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/visibilitystore/VisibilityStoreMigrationHelperFromV0Test.java
+++ b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/visibilitystore/VisibilityStoreMigrationHelperFromV0Test.java
@@ -31,6 +31,7 @@
import androidx.appsearch.app.InternalSetSchemaResponse;
import androidx.appsearch.app.InternalVisibilityConfig;
import androidx.appsearch.app.PackageIdentifier;
+import androidx.appsearch.localstorage.AppSearchConfig;
import androidx.appsearch.localstorage.AppSearchConfigImpl;
import androidx.appsearch.localstorage.AppSearchImpl;
import androidx.appsearch.localstorage.LocalStorageIcingOptionsConfig;
@@ -58,6 +59,8 @@
@Rule
public TemporaryFolder mTemporaryFolder = new TemporaryFolder();
private File mFile;
+ private AppSearchConfig mConfig = new AppSearchConfigImpl(new UnlimitedLimitConfig(),
+ new LocalStorageIcingOptionsConfig());
@Before
public void setUp() throws Exception {
@@ -131,9 +134,10 @@
// Persist to disk and re-open the AppSearchImpl
appSearchImplInV0.close();
AppSearchImpl appSearchImpl = AppSearchImpl.create(mFile,
- new AppSearchConfigImpl(new UnlimitedLimitConfig(),
- new LocalStorageIcingOptionsConfig()), /*initStatsBuilder=*/ null,
+ mConfig,
+ /*initStatsBuilder=*/ null,
/*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
ALWAYS_OPTIMIZE);
GenericDocument actualDocument1 =
@@ -204,9 +208,10 @@
.build();
// Set deprecated visibility schema version 0 into AppSearchImpl.
AppSearchImpl appSearchImpl = AppSearchImpl.create(mFile,
- new AppSearchConfigImpl(new UnlimitedLimitConfig(),
- new LocalStorageIcingOptionsConfig()), /*initStatsBuilder=*/ null,
+ mConfig,
+ /*initStatsBuilder=*/ null,
/*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
ALWAYS_OPTIMIZE);
InternalSetSchemaResponse internalSetSchemaResponse = appSearchImpl.setSchema(
VisibilityStore.VISIBILITY_PACKAGE_NAME,
diff --git a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/visibilitystore/VisibilityStoreMigrationHelperFromV1Test.java b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/visibilitystore/VisibilityStoreMigrationHelperFromV1Test.java
index 4b837ae..0018ea3 100644
--- a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/visibilitystore/VisibilityStoreMigrationHelperFromV1Test.java
+++ b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/visibilitystore/VisibilityStoreMigrationHelperFromV1Test.java
@@ -26,6 +26,7 @@
import androidx.appsearch.app.InternalVisibilityConfig;
import androidx.appsearch.app.PackageIdentifier;
import androidx.appsearch.app.SetSchemaRequest;
+import androidx.appsearch.localstorage.AppSearchConfig;
import androidx.appsearch.localstorage.AppSearchConfigImpl;
import androidx.appsearch.localstorage.AppSearchImpl;
import androidx.appsearch.localstorage.LocalStorageIcingOptionsConfig;
@@ -54,6 +55,8 @@
@Rule
public TemporaryFolder mTemporaryFolder = new TemporaryFolder();
private File mFile;
+ private AppSearchConfig mConfig = new AppSearchConfigImpl(new UnlimitedLimitConfig(),
+ new LocalStorageIcingOptionsConfig());
@Before
public void setUp() throws Exception {
@@ -77,9 +80,10 @@
// Create AppSearchImpl with visibility document version 1;
AppSearchImpl appSearchImplInV1 = AppSearchImpl.create(mFile,
- new AppSearchConfigImpl(new UnlimitedLimitConfig(),
- new LocalStorageIcingOptionsConfig()), /*initStatsBuilder=*/ null,
+ mConfig,
+ /*initStatsBuilder=*/ null,
/*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
ALWAYS_OPTIMIZE);
InternalSetSchemaResponse internalSetSchemaResponse = appSearchImplInV1.setSchema(
VisibilityStore.VISIBILITY_PACKAGE_NAME,
@@ -127,9 +131,10 @@
// Persist to disk and re-open the AppSearchImpl
appSearchImplInV1.close();
AppSearchImpl appSearchImpl = AppSearchImpl.create(mFile,
- new AppSearchConfigImpl(new UnlimitedLimitConfig(),
- new LocalStorageIcingOptionsConfig()), /*initStatsBuilder=*/ null,
+ mConfig,
+ /*initStatsBuilder=*/ null,
/*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
ALWAYS_OPTIMIZE);
InternalVisibilityConfig actualConfig =
diff --git a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/visibilitystore/VisibilityStoreTest.java b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/visibilitystore/VisibilityStoreTest.java
index 5ac5bda..7139dcc 100644
--- a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/visibilitystore/VisibilityStoreTest.java
+++ b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/visibilitystore/VisibilityStoreTest.java
@@ -29,6 +29,7 @@
import androidx.appsearch.app.SchemaVisibilityConfig;
import androidx.appsearch.app.VisibilityPermissionConfig;
import androidx.appsearch.exceptions.AppSearchException;
+import androidx.appsearch.localstorage.AppSearchConfig;
import androidx.appsearch.localstorage.AppSearchConfigImpl;
import androidx.appsearch.localstorage.AppSearchImpl;
import androidx.appsearch.localstorage.LocalStorageIcingOptionsConfig;
@@ -62,14 +63,16 @@
@Before
public void setUp() throws Exception {
File appSearchDir = mTemporaryFolder.newFolder();
+ AppSearchConfig config = new AppSearchConfigImpl(
+ new UnlimitedLimitConfig(),
+ new LocalStorageIcingOptionsConfig()
+ );
mAppSearchImpl = AppSearchImpl.create(
appSearchDir,
- new AppSearchConfigImpl(
- new UnlimitedLimitConfig(),
- new LocalStorageIcingOptionsConfig()
- ),
+ config,
/*initStatsBuilder=*/ null,
/*visibilityChecker=*/ null,
+ /*revocableFileDescriptorStore=*/ null,
ALWAYS_OPTIMIZE);
mVisibilityStore = new VisibilityStore(mAppSearchImpl);
}
diff --git a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/AppSearchConfigImpl.java b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/AppSearchConfigImpl.java
index 2537e6f..b8f5e22 100644
--- a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/AppSearchConfigImpl.java
+++ b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/AppSearchConfigImpl.java
@@ -124,6 +124,11 @@
}
@Override
+ public boolean getEnableBlobStore() {
+ return mIcingOptionsConfig.getEnableBlobStore();
+ }
+
+ @Override
public int getMaxDocumentSizeBytes() {
return mLimitConfig.getMaxDocumentSizeBytes();
}
@@ -144,6 +149,11 @@
}
@Override
+ public int getMaxOpenBlobCount() {
+ return mLimitConfig.getMaxOpenBlobCount();
+ }
+
+ @Override
public boolean shouldStoreParentInfoAsSyntheticProperty() {
return mStoreParentInfoAsSyntheticProperty;
}
@@ -152,4 +162,9 @@
public boolean shouldRetrieveParentInfo() {
return mShouldRetrieveParentInfo;
}
+
+ @Override
+ public long getOrphanBlobTimeToLiveMs() {
+ return mIcingOptionsConfig.getOrphanBlobTimeToLiveMs();
+ }
}
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 34b1d87..299f123 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
@@ -26,6 +26,7 @@
import static androidx.appsearch.localstorage.util.PrefixUtil.getPrefix;
import static androidx.appsearch.localstorage.util.PrefixUtil.removePrefixesFromDocument;
+import android.os.ParcelFileDescriptor;
import android.os.SystemClock;
import android.util.Log;
@@ -35,8 +36,11 @@
import androidx.annotation.RestrictTo;
import androidx.annotation.VisibleForTesting;
import androidx.annotation.WorkerThread;
+import androidx.appsearch.app.AppSearchBlobHandle;
import androidx.appsearch.app.AppSearchResult;
import androidx.appsearch.app.AppSearchSchema;
+import androidx.appsearch.app.ExperimentalAppSearchApi;
+import androidx.appsearch.app.Features;
import androidx.appsearch.app.GenericDocument;
import androidx.appsearch.app.GetByDocumentIdRequest;
import androidx.appsearch.app.GetSchemaResponse;
@@ -53,6 +57,7 @@
import androidx.appsearch.app.StorageInfo;
import androidx.appsearch.exceptions.AppSearchException;
import androidx.appsearch.flags.Flags;
+import androidx.appsearch.localstorage.converter.BlobHandleToProtoConverter;
import androidx.appsearch.localstorage.converter.GenericDocumentToProtoConverter;
import androidx.appsearch.localstorage.converter.ResultCodeToProtoConverter;
import androidx.appsearch.localstorage.converter.SchemaToProtoConverter;
@@ -81,6 +86,7 @@
import androidx.core.util.Preconditions;
import com.google.android.icing.IcingSearchEngine;
+import com.google.android.icing.proto.BlobProto;
import com.google.android.icing.proto.DebugInfoProto;
import com.google.android.icing.proto.DebugInfoResultProto;
import com.google.android.icing.proto.DebugInfoVerbosity;
@@ -120,6 +126,7 @@
import java.io.Closeable;
import java.io.File;
+import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
@@ -240,6 +247,9 @@
@GuardedBy("mReadWriteLock")
private int mOptimizeIntervalCountLocked = 0;
+ @Nullable
+ private AppSearchRevocableFileDescriptorStore mRevocableFileDescriptorStore;
+
/** Whether this instance has been closed, and therefore unusable. */
@GuardedBy("mReadWriteLock")
private boolean mClosedLocked = false;
@@ -266,10 +276,11 @@
@NonNull AppSearchConfig config,
@Nullable InitializeStats.Builder initStatsBuilder,
@Nullable VisibilityChecker visibilityChecker,
+ @Nullable AppSearchRevocableFileDescriptorStore revocableFileDescriptorStore,
@NonNull OptimizeStrategy optimizeStrategy)
throws AppSearchException {
- return new AppSearchImpl(icingDir, config, initStatsBuilder, optimizeStrategy,
- visibilityChecker);
+ return new AppSearchImpl(icingDir, config, initStatsBuilder, visibilityChecker,
+ revocableFileDescriptorStore, optimizeStrategy);
}
/**
@@ -279,13 +290,15 @@
@NonNull File icingDir,
@NonNull AppSearchConfig config,
@Nullable InitializeStats.Builder initStatsBuilder,
- @NonNull OptimizeStrategy optimizeStrategy,
- @Nullable VisibilityChecker visibilityChecker)
+ @Nullable VisibilityChecker visibilityChecker,
+ @Nullable AppSearchRevocableFileDescriptorStore revocableFileDescriptorStore,
+ @NonNull OptimizeStrategy optimizeStrategy)
throws AppSearchException {
Preconditions.checkNotNull(icingDir);
mConfig = Preconditions.checkNotNull(config);
mOptimizeStrategy = Preconditions.checkNotNull(optimizeStrategy);
mVisibilityCheckerLocked = visibilityChecker;
+ mRevocableFileDescriptorStore = revocableFileDescriptorStore;
mReadWriteLock.writeLock().lock();
try {
@@ -311,6 +324,8 @@
.setUseNewQualifiedIdJoinIndex(mConfig.getUseNewQualifiedIdJoinIndex())
.setBuildPropertyExistenceMetadataHits(
mConfig.getBuildPropertyExistenceMetadataHits())
+ .setEnableBlobStore(mConfig.getEnableBlobStore())
+ .setOrphanBlobTimeToLiveMs(mConfig.getOrphanBlobTimeToLiveMs())
.build();
LogUtil.piiTrace(TAG, "Constructing IcingSearchEngine, request", options);
mIcingSearchEngineLocked = new IcingSearchEngine(options);
@@ -451,8 +466,11 @@
LogUtil.piiTrace(TAG, "icingSearchEngine.close, request");
mIcingSearchEngineLocked.close();
LogUtil.piiTrace(TAG, "icingSearchEngine.close, response");
+ if (mRevocableFileDescriptorStore != null) {
+ mRevocableFileDescriptorStore.revokeAll();
+ }
mClosedLocked = true;
- } catch (AppSearchException e) {
+ } catch (AppSearchException | IOException e) {
Log.w(TAG, "Error when closing AppSearchImpl.", e);
} finally {
mReadWriteLock.writeLock().unlock();
@@ -1108,6 +1126,110 @@
}
}
+
+ /**
+ * Gets the {@link ParcelFileDescriptor} for write purpose of the given
+ * {@link AppSearchBlobHandle}.
+ *
+ * @param handle The {@link AppSearchBlobHandle} represent the blob.
+ */
+ @NonNull
+ @ExperimentalAppSearchApi
+ public ParcelFileDescriptor openWriteBlob(
+ @NonNull String packageName,
+ @NonNull String databaseName,
+ @NonNull AppSearchBlobHandle handle)
+ throws AppSearchException, IOException {
+ if (mRevocableFileDescriptorStore == null) {
+ throw new UnsupportedOperationException(Features.BLOB_STORAGE
+ + " is not available on this AppSearch implementation.");
+ }
+ mReadWriteLock.writeLock().lock();
+ try {
+ throwIfClosedLocked();
+ verifyCallingBlobHandle(packageName, databaseName, handle);
+ mRevocableFileDescriptorStore.checkBlobStoreLimit(packageName);
+ BlobProto result = mIcingSearchEngineLocked.openWriteBlob(
+ BlobHandleToProtoConverter.toBlobHandleProto(handle));
+
+ checkSuccess(result.getStatus());
+ ParcelFileDescriptor pfd = ParcelFileDescriptor.fromFd(result.getFileDescriptor());
+
+ return mRevocableFileDescriptorStore
+ .wrapToRevocableFileDescriptor(handle.getPackageName(), pfd);
+ } finally {
+ mReadWriteLock.writeLock().unlock();
+ }
+ }
+
+ /**
+ * Commits and seals the blob represented by the given {@link AppSearchBlobHandle}.
+ *
+ * <p>After this call, the blob is readable via {@link #openReadBlob}. And any rewrite is not
+ * allowed.
+ *
+ * @param handle The {@link AppSearchBlobHandle} represent the blob.
+ */
+ @ExperimentalAppSearchApi
+ public void commitBlob(
+ @NonNull String packageName,
+ @NonNull String databaseName,
+ @NonNull AppSearchBlobHandle handle) throws AppSearchException {
+ if (mRevocableFileDescriptorStore == null) {
+ throw new UnsupportedOperationException(Features.BLOB_STORAGE
+ + " is not available on this AppSearch implementation.");
+ }
+ mReadWriteLock.writeLock().lock();
+ try {
+ throwIfClosedLocked();
+ verifyCallingBlobHandle(packageName, databaseName, handle);
+ BlobProto result = mIcingSearchEngineLocked.commitBlob(
+ BlobHandleToProtoConverter.toBlobHandleProto(handle));
+
+ checkSuccess(result.getStatus());
+ } finally {
+ mReadWriteLock.writeLock().unlock();
+ }
+ }
+
+ /**
+ * Gets the {@link ParcelFileDescriptor} for read only purpose of the given
+ * {@link AppSearchBlobHandle}.
+ *
+ * <p>The target must be committed via {@link #commitBlob};
+ *
+ * @param handle The {@link AppSearchBlobHandle} represent the blob.
+ */
+ @NonNull
+ @ExperimentalAppSearchApi
+ public ParcelFileDescriptor openReadBlob(
+ @NonNull String packageName,
+ @NonNull String databaseName,
+ @NonNull AppSearchBlobHandle handle)
+ throws AppSearchException, IOException {
+ if (mRevocableFileDescriptorStore == null) {
+ throw new UnsupportedOperationException(Features.BLOB_STORAGE
+ + " is not available on this AppSearch implementation.");
+ }
+
+ mReadWriteLock.readLock().lock();
+ try {
+ throwIfClosedLocked();
+ verifyCallingBlobHandle(packageName, databaseName, handle);
+ mRevocableFileDescriptorStore.checkBlobStoreLimit(packageName);
+ BlobProto result = mIcingSearchEngineLocked.openReadBlob(
+ BlobHandleToProtoConverter.toBlobHandleProto(handle));
+
+ checkSuccess(result.getStatus());
+
+ ParcelFileDescriptor pfd = ParcelFileDescriptor.fromFd(result.getFileDescriptor());
+ return mRevocableFileDescriptorStore
+ .wrapToRevocableFileDescriptor(handle.getPackageName(), pfd);
+ } finally {
+ mReadWriteLock.readLock().unlock();
+ }
+ }
+
/**
* Checks that a new document can be added to the given packageName with the given serialized
* size without violating our {@link LimitConfig}.
@@ -2246,7 +2368,8 @@
* @param packageName The name of package to be removed.
* @throws AppSearchException if we cannot remove the data.
*/
- public void clearPackageData(@NonNull String packageName) throws AppSearchException {
+ public void clearPackageData(@NonNull String packageName) throws AppSearchException,
+ IOException {
mReadWriteLock.writeLock().lock();
try {
throwIfClosedLocked();
@@ -2263,6 +2386,9 @@
existingPackages.remove(packageName);
prunePackageData(existingPackages);
}
+ if (mRevocableFileDescriptorStore != null) {
+ mRevocableFileDescriptorStore.revokeForPackage(packageName);
+ }
} finally {
mReadWriteLock.writeLock().unlock();
}
@@ -2775,4 +2901,23 @@
@NonNull StatusProto statusProto) {
return ResultCodeToProtoConverter.toResultCode(statusProto.getCode());
}
+
+ @ExperimentalAppSearchApi
+ private static void verifyCallingBlobHandle(@NonNull String callingPackageName,
+ @NonNull String callingDatabaseName, @NonNull AppSearchBlobHandle blobHandle)
+ throws AppSearchException {
+ if (!blobHandle.getPackageName().equals(callingPackageName)) {
+ throw new AppSearchException(AppSearchResult.RESULT_INVALID_ARGUMENT,
+ "Blob package doesn't match calling package, calling package: "
+ + callingPackageName + ", blob package: "
+ + blobHandle.getPackageName());
+ }
+ if (!blobHandle.getDatabaseName().equals(callingDatabaseName)) {
+ throw new AppSearchException(AppSearchResult.RESULT_INVALID_ARGUMENT,
+ "Blob database doesn't match calling database, calling database: "
+ + callingDatabaseName + ", blob database: "
+ + blobHandle.getDatabaseName());
+ }
+ }
+
}
diff --git a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/AppSearchRevocableFileDescriptorStore.java b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/AppSearchRevocableFileDescriptorStore.java
new file mode 100644
index 0000000..86bcc64
--- /dev/null
+++ b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/AppSearchRevocableFileDescriptorStore.java
@@ -0,0 +1,74 @@
+/*
+ * 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.appsearch.localstorage;
+
+import android.os.ParcelFileDescriptor;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+import androidx.appsearch.exceptions.AppSearchException;
+
+import java.io.IOException;
+
+/**
+ * Interface for revocable file descriptors storage.
+ *
+ * <p>This store allows wrapping {@link ParcelFileDescriptor} instances into revocable file
+ * descriptors, enabling the ability to close and revoke it's access to the file even if the
+ * {@link ParcelFileDescriptor} has been sent to the client side.
+ *
+ * <p> Implementations of this interface can provide controlled access to resources by associating
+ * each file descriptor with a package and allowing them to be individually revoked by package
+ * or revoked all at once.
+ *
+ * @exportToFramework:hide
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+public interface AppSearchRevocableFileDescriptorStore {
+
+ /**
+ * Wraps the provided ParcelFileDescriptor into a revocable file descriptor.
+ * This allows for controlled access to the file descriptor, making it revocable by the store.
+ *
+ * @param packageName The package name requesting the revocable file descriptor.
+ * @param parcelFileDescriptor The original ParcelFileDescriptor to be wrapped.
+ * @return A ParcelFileDescriptor that can be revoked by the store.
+ */
+ @NonNull
+ ParcelFileDescriptor wrapToRevocableFileDescriptor(@NonNull String packageName,
+ @NonNull ParcelFileDescriptor parcelFileDescriptor) throws IOException;
+
+ /**
+ * Revokes all revocable file descriptors previously issued by the store.
+ * After calling this method, any access to these file descriptors will fail.
+ *
+ * @throws IOException If an I/O error occurs while revoking file descriptors.
+ */
+ void revokeAll() throws IOException;
+
+ /**
+ * Revokes all revocable file descriptors for a specified package.
+ * Only file descriptors associated with the given package name will be revoked.
+ *
+ * @param packageName The package name whose file descriptors should be revoked.
+ * @throws IOException If an I/O error occurs while revoking file descriptors.
+ */
+ void revokeForPackage(@NonNull String packageName) throws IOException;
+
+ /** Checks if the specified package has reached its blob storage limit. */
+ void checkBlobStoreLimit(@NonNull String packageName) throws AppSearchException;
+}
diff --git a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/IcingOptionsConfig.java b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/IcingOptionsConfig.java
index d3246bf..9f25acb 100644
--- a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/IcingOptionsConfig.java
+++ b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/IcingOptionsConfig.java
@@ -74,6 +74,8 @@
boolean DEFAULT_BUILD_PROPERTY_EXISTENCE_METADATA_HITS = false;
+ long DEFAULT_ORPHAN_BLOB_TIME_TO_LIVE_MS = 7 * 24 * 60 * 60 * 1000L; // 1 week.
+
/**
* The maximum allowable token length. All tokens in excess of this size will be truncated to
* max_token_length before being indexed.
@@ -225,4 +227,22 @@
* to support the hasProperty function in advanced query.
*/
boolean getBuildPropertyExistenceMetadataHits();
+
+
+ /**
+ * Flag for {@link com.google.android.icing.proto.IcingSearchEngineOptions}.
+ *
+ * <p>Whether to enable the blob store. If set to true, the BlobStore will be created to store
+ * and retrieve blobs.
+ */
+ boolean getEnableBlobStore();
+
+
+ /**
+ * Config for {@link com.google.android.icing.proto.IcingSearchEngineOptions}.
+ *
+ * <p>The maximum time in millisecond for a orphan blob to get recycled and deleted if there is
+ * no reference document linked to it.
+ */
+ long getOrphanBlobTimeToLiveMs();
}
diff --git a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/JetpackRevocableFileDescriptorStore.java b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/JetpackRevocableFileDescriptorStore.java
new file mode 100644
index 0000000..70d78aa
--- /dev/null
+++ b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/JetpackRevocableFileDescriptorStore.java
@@ -0,0 +1,193 @@
+/*
+ * 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.
+ */
+
+// @exportToFramework:copyToPath(../../../cts/tests/appsearch/testutils/src/android/app/appsearch/testutil/external/JetpackRevocableFileDescriptorStore.java)
+package androidx.appsearch.localstorage;
+
+import android.os.ParcelFileDescriptor;
+
+import androidx.annotation.GuardedBy;
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+import androidx.appsearch.app.AppSearchResult;
+import androidx.appsearch.exceptions.AppSearchException;
+import androidx.collection.ArrayMap;
+import androidx.core.util.Preconditions;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * The local storage implementation of {@link AppSearchRevocableFileDescriptorStore}.
+ *
+ * <p> The {@link ParcelFileDescriptor} sent to the client side from the local storage won't cross
+ * the binder, we could revoke the {@link ParcelFileDescriptor} in the client side by directly close
+ * the one in AppSearch side. This won't work in the framework, since even if we close the
+ * {@link ParcelFileDescriptor} in the server side, the one in the client side could still work.
+ *
+ * <p>This class is thread safety.
+ *
+ * @exportToFramework:hide
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+public class JetpackRevocableFileDescriptorStore implements
+ AppSearchRevocableFileDescriptorStore {
+
+ private final Object mLock = new Object();
+ private final AppSearchConfig mConfig;
+
+ public JetpackRevocableFileDescriptorStore(@NonNull AppSearchConfig config) {
+ mConfig = Preconditions.checkNotNull(config);
+ }
+
+ @GuardedBy("mLock")
+ // <package, List<sent rfds> map to tracking all sent rfds.
+ private final Map<String, List<JetpackRevocableFileDescriptor>>
+ mSentAppSearchParcelFileDescriptorsLocked = new ArrayMap<>();
+
+ @Override
+ @NonNull
+ public ParcelFileDescriptor wrapToRevocableFileDescriptor(@NonNull String packageName,
+ @NonNull ParcelFileDescriptor parcelFileDescriptor) {
+ JetpackRevocableFileDescriptor revocableFileDescriptor =
+ new JetpackRevocableFileDescriptor(parcelFileDescriptor);
+ setCloseListenerToFd(revocableFileDescriptor, packageName);
+ addToSentAppSearchParcelFileDescriptorMap(revocableFileDescriptor, packageName);
+ return revocableFileDescriptor;
+ }
+
+ @Override
+ public void revokeAll() throws IOException {
+ synchronized (mLock) {
+ for (String packageName : mSentAppSearchParcelFileDescriptorsLocked.keySet()) {
+ revokeForPackage(packageName);
+ }
+ }
+ }
+
+ @Override
+ public void revokeForPackage(@NonNull String packageName) throws IOException {
+ synchronized (mLock) {
+ List<JetpackRevocableFileDescriptor> rfds =
+ mSentAppSearchParcelFileDescriptorsLocked.remove(packageName);
+ if (rfds != null) {
+ for (int i = rfds.size() - 1; i >= 0; i--) {
+ rfds.get(i).closeSuperDirectly();
+ }
+ }
+ }
+ }
+
+ @Override
+ public void checkBlobStoreLimit(@NonNull String packageName) throws AppSearchException {
+ synchronized (mLock) {
+ List<JetpackRevocableFileDescriptor> rfdsForPackage =
+ mSentAppSearchParcelFileDescriptorsLocked.get(packageName);
+ if (rfdsForPackage == null) {
+ return;
+ }
+ if (rfdsForPackage.size() >= mConfig.getMaxOpenBlobCount()) {
+ throw new AppSearchException(AppSearchResult.RESULT_OUT_OF_SPACE,
+ "Package \"" + packageName + "\" exceeded limit of "
+ + mConfig.getMaxOpenBlobCount()
+ + " opened file descriptors. Some file descriptors "
+ + "must be closed to open additional ones.");
+ }
+ }
+ }
+
+ private void setCloseListenerToFd(
+ @NonNull JetpackRevocableFileDescriptor revocableFileDescriptor,
+ @NonNull String packageName) {
+ revocableFileDescriptor.setCloseListener(e -> {
+ synchronized (mLock) {
+ List<JetpackRevocableFileDescriptor> fdsForPackage =
+ mSentAppSearchParcelFileDescriptorsLocked.get(packageName);
+ if (fdsForPackage != null) {
+ fdsForPackage.remove(revocableFileDescriptor);
+ if (fdsForPackage.isEmpty()) {
+ mSentAppSearchParcelFileDescriptorsLocked.remove(packageName);
+ }
+ }
+ }
+ });
+ }
+
+ private void addToSentAppSearchParcelFileDescriptorMap(
+ @NonNull JetpackRevocableFileDescriptor revocableFileDescriptor,
+ @NonNull String packageName) {
+ synchronized (mLock) {
+ List<JetpackRevocableFileDescriptor> rfdsForPackage =
+ mSentAppSearchParcelFileDescriptorsLocked.get(packageName);
+ if (rfdsForPackage == null) {
+ rfdsForPackage = new ArrayList<>();
+ mSentAppSearchParcelFileDescriptorsLocked.put(packageName, rfdsForPackage);
+ }
+ rfdsForPackage.add(revocableFileDescriptor);
+ }
+ }
+
+ /**
+ * A custom {@link ParcelFileDescriptor} that provides an additional mechanism to register
+ * a {@link ParcelFileDescriptor.OnCloseListener} which will be invoked when the file
+ * descriptor is closed.
+ *
+ * <p> Since the {@link ParcelFileDescriptor} sent to the client side from the local storage
+ * won't cross the binder, we could revoke the {@link ParcelFileDescriptor} in the client side
+ * by directly close the one in AppSearch side. This class just adding close listener to the
+ * inner {@link ParcelFileDescriptor}.
+ */
+ static class JetpackRevocableFileDescriptor extends ParcelFileDescriptor {
+ private ParcelFileDescriptor.OnCloseListener mOnCloseListener;
+
+ /**
+ * Create a new ParcelFileDescriptor wrapped around another descriptor. By
+ * default all method calls are delegated to the wrapped descriptor.
+ */
+ JetpackRevocableFileDescriptor(@NonNull ParcelFileDescriptor parcelFileDescriptor) {
+ super(parcelFileDescriptor);
+ }
+
+ void setCloseListener(
+ @NonNull ParcelFileDescriptor.OnCloseListener onCloseListener) {
+ Preconditions.checkState(mOnCloseListener == null,
+ "The close listener has already been set.");
+ mOnCloseListener = Preconditions.checkNotNull(onCloseListener);
+ }
+
+ /** Close the super {@link ParcelFileDescriptor} without invoke close listener. */
+ void closeSuperDirectly() throws IOException {
+ super.close();
+ }
+
+ @Override
+ public void close() throws IOException {
+ try {
+ super.close();
+ if (mOnCloseListener != null) {
+ mOnCloseListener.onClose(null);
+ }
+ } catch (IOException e) {
+ if (mOnCloseListener != null) {
+ mOnCloseListener.onClose(e);
+ }
+ throw e;
+ }
+ }
+ }
+}
diff --git a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/LimitConfig.java b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/LimitConfig.java
index 0844b3f..9c2d10f 100644
--- a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/LimitConfig.java
+++ b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/LimitConfig.java
@@ -70,4 +70,11 @@
* from being overwhelmed by a single app.
*/
int getMaxSuggestionCount();
+
+
+ /**
+ * Returns the maximum number of {@link android.os.ParcelFileDescriptor} that a single app could
+ * open for read and write blob from AppSearch.
+ */
+ int getMaxOpenBlobCount();
}
diff --git a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/LocalStorage.java b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/LocalStorage.java
index 44f894b..4263ec1 100644
--- a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/LocalStorage.java
+++ b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/LocalStorage.java
@@ -30,6 +30,7 @@
import androidx.appsearch.app.AppSearchSession;
import androidx.appsearch.app.GlobalSearchSession;
import androidx.appsearch.exceptions.AppSearchException;
+import androidx.appsearch.flags.Flags;
import androidx.appsearch.localstorage.stats.InitializeStats;
import androidx.appsearch.localstorage.stats.OptimizeStats;
import androidx.appsearch.localstorage.util.FutureUtil;
@@ -353,16 +354,22 @@
// Syncing the current logging level to Icing before creating the AppSearch object, so that
// the correct logging level will cover the period of Icing initialization.
AppSearchImpl.syncLoggingLevelToIcing();
+ AppSearchConfig config = new AppSearchConfigImpl(
+ new UnlimitedLimitConfig(),
+ new LocalStorageIcingOptionsConfig(),
+ /* storeParentInfoAsSyntheticProperty= */ false,
+ /* shouldRetrieveParentInfo= */ true
+ );
+ AppSearchRevocableFileDescriptorStore revocableFileDescriptorStore = null;
+ if (Flags.enableBlobStore()) {
+ revocableFileDescriptorStore = new JetpackRevocableFileDescriptorStore(config);
+ }
mAppSearchImpl = AppSearchImpl.create(
icingDir,
- new AppSearchConfigImpl(
- new UnlimitedLimitConfig(),
- new LocalStorageIcingOptionsConfig(),
- /* storeParentInfoAsSyntheticProperty= */ false,
- /* shouldRetrieveParentInfo= */ true
- ),
+ config,
initStatsBuilder,
/*visibilityChecker=*/ null,
+ revocableFileDescriptorStore,
new JetpackOptimizeStrategy());
if (logger != null) {
diff --git a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/LocalStorageIcingOptionsConfig.java b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/LocalStorageIcingOptionsConfig.java
index 11b530e..51e1125 100644
--- a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/LocalStorageIcingOptionsConfig.java
+++ b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/LocalStorageIcingOptionsConfig.java
@@ -98,4 +98,14 @@
public boolean getBuildPropertyExistenceMetadataHits() {
return true;
}
+
+ @Override
+ public boolean getEnableBlobStore() {
+ return true;
+ }
+
+ @Override
+ public long getOrphanBlobTimeToLiveMs() {
+ return DEFAULT_ORPHAN_BLOB_TIME_TO_LIVE_MS;
+ }
}
diff --git a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/UnlimitedLimitConfig.java b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/UnlimitedLimitConfig.java
index eee5583..8c8a342 100644
--- a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/UnlimitedLimitConfig.java
+++ b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/UnlimitedLimitConfig.java
@@ -44,4 +44,9 @@
public int getMaxSuggestionCount() {
return Integer.MAX_VALUE;
}
+
+ @Override
+ public int getMaxOpenBlobCount() {
+ return Integer.MAX_VALUE;
+ }
}
diff --git a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/converter/BlobHandleToProtoConverter.java b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/converter/BlobHandleToProtoConverter.java
new file mode 100644
index 0000000..095aa0b
--- /dev/null
+++ b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/converter/BlobHandleToProtoConverter.java
@@ -0,0 +1,63 @@
+/*
+ * 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.appsearch.localstorage.converter;
+
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+import androidx.appsearch.app.AppSearchBlobHandle;
+import androidx.appsearch.app.ExperimentalAppSearchApi;
+import androidx.appsearch.exceptions.AppSearchException;
+import androidx.appsearch.localstorage.util.PrefixUtil;
+
+import com.google.android.icing.proto.PropertyProto;
+import com.google.android.icing.protobuf.ByteString;
+
+/**
+ * Translates a {@link android.app.blob.BlobHandle} into {@link PropertyProto.BlobHandleProto}.
+
+ * @exportToFramework:hide
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+@ExperimentalAppSearchApi
+public final class BlobHandleToProtoConverter {
+ private BlobHandleToProtoConverter() {}
+
+ /** Converters a {@link AppSearchBlobHandle} into {@link PropertyProto.BlobHandleProto}. */
+ @NonNull
+ public static PropertyProto.BlobHandleProto toBlobHandleProto(
+ @NonNull AppSearchBlobHandle blobHandle) {
+ return PropertyProto.BlobHandleProto.newBuilder()
+ .setNamespace(PrefixUtil.createPrefix(
+ blobHandle.getPackageName(), blobHandle.getDatabaseName())
+ + blobHandle.getNamespace())
+ .setDigest(ByteString.copyFrom(blobHandle.getSha256Digest()))
+ .build();
+ }
+
+ /** Converters a {@link PropertyProto.BlobHandleProto} into {@link AppSearchBlobHandle}. */
+ @NonNull
+ public static AppSearchBlobHandle toAppSearchBlobHandle(
+ @NonNull PropertyProto.BlobHandleProto proto) throws AppSearchException {
+ String prefix = PrefixUtil.getPrefix(proto.getNamespace());
+ return AppSearchBlobHandle.createWithSha256(
+ proto.getDigest().toByteArray(),
+ PrefixUtil.getPackageName(prefix),
+ PrefixUtil.getDatabaseName(prefix),
+ PrefixUtil.removePrefix(proto.getNamespace()));
+ }
+}
diff --git a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/NegationNodeCtsTest.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/NegationNodeCtsTest.java
index 0a691a7..0ed9372 100644
--- a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/NegationNodeCtsTest.java
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/NegationNodeCtsTest.java
@@ -39,6 +39,15 @@
public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
@Test
+ public void testEquals_identical() {
+ NegationNode nodeOne = new NegationNode(new TextNode("foo"));
+ NegationNode nodeTwo = new NegationNode(new TextNode("foo"));
+
+ assertThat(nodeOne).isEqualTo(nodeTwo);
+ assertThat(nodeOne.hashCode()).isEqualTo(nodeTwo.hashCode());
+ }
+
+ @Test
public void testSetChildren_throwsOnNullNode() {
TextNode textNode = new TextNode("foo");
diff --git a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/TextNodeCtsTest.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/TextNodeCtsTest.java
index b727b5a..574a49f 100644
--- a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/TextNodeCtsTest.java
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/TextNodeCtsTest.java
@@ -35,6 +35,18 @@
public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
@Test
+ public void testEquals_identical() {
+ TextNode nodeOne = new TextNode("foo");
+ nodeOne.setPrefix(true);
+
+ TextNode nodeTwo = new TextNode("foo");
+ nodeTwo.setPrefix(true);
+
+ assertThat(nodeOne).isEqualTo(nodeTwo);
+ assertThat(nodeOne.hashCode()).isEqualTo(nodeTwo.hashCode());
+ }
+
+ @Test
public void testConstructor_prefixVerbatimFalseByDefault() {
TextNode defaultTextNode = new TextNode("foo");
diff --git a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/AndNodeCtsTest.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/AndNodeCtsTest.java
index d603a06..c61a6fe 100644
--- a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/AndNodeCtsTest.java
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/AndNodeCtsTest.java
@@ -42,6 +42,20 @@
public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
@Test
+ public void testEquals_identical() {
+ TextNode foo = new TextNode("foo");
+ TextNode bar = new TextNode("bar");
+ AndNode andNodeOne = new AndNode(List.of(foo, bar));
+
+ TextNode fooTwo = new TextNode("foo");
+ TextNode barTwo = new TextNode("bar");
+ AndNode andNodeTwo = new AndNode(List.of(fooTwo, barTwo));
+
+ assertThat(andNodeOne).isEqualTo(andNodeTwo);
+ assertThat(andNodeOne.hashCode()).isEqualTo(andNodeTwo.hashCode());
+ }
+
+ @Test
public void testConstructor_buildsAndNode() {
TextNode foo = new TextNode("foo");
TextNode bar = new TextNode("bar");
diff --git a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/OrNodeCtsTest.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/OrNodeCtsTest.java
index e2b3341..574134e 100644
--- a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/OrNodeCtsTest.java
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/OrNodeCtsTest.java
@@ -42,6 +42,20 @@
public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
@Test
+ public void testEquals_identical() {
+ TextNode foo = new TextNode("foo");
+ TextNode bar = new TextNode("bar");
+ OrNode orNodeOne = new OrNode(List.of(foo, bar));
+
+ TextNode fooTwo = new TextNode("foo");
+ TextNode barTwo = new TextNode("bar");
+ OrNode orNodeTwo = new OrNode(List.of(fooTwo, barTwo));
+
+ assertThat(orNodeOne).isEqualTo(orNodeTwo);
+ assertThat(orNodeOne.hashCode()).isEqualTo(orNodeTwo.hashCode());
+ }
+
+ @Test
public void testConstructor_buildsOrNode() {
TextNode foo = new TextNode("foo");
TextNode bar = new TextNode("bar");
diff --git a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/NegationNode.java b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/NegationNode.java
index 13e9686..645d312 100644
--- a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/NegationNode.java
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/NegationNode.java
@@ -25,6 +25,7 @@
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
+import java.util.Objects;
/**
* {@link Node} that stores a child node to be logically negated with a negative sign ("-")
@@ -118,4 +119,17 @@
public String toString() {
return "NOT " + getChild();
}
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ NegationNode that = (NegationNode) o;
+ return Objects.equals(mChildren, that.mChildren);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(mChildren);
+ }
}
diff --git a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/TextNode.java b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/TextNode.java
index 3eefa56..473ee94 100644
--- a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/TextNode.java
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/TextNode.java
@@ -22,6 +22,8 @@
import androidx.appsearch.flags.Flags;
import androidx.core.util.Preconditions;
+import java.util.Objects;
+
/**
* {@link Node} that stores text.
*
@@ -259,4 +261,18 @@
private boolean isLatinLetter(char c) {
return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z');
}
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ TextNode textNode = (TextNode) o;
+ return mPrefix == textNode.mPrefix && mVerbatim == textNode.mVerbatim
+ && Objects.equals(mValue, textNode.mValue);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(mValue, mPrefix, mVerbatim);
+ }
}
diff --git a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/AndNode.java b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/AndNode.java
index 8774fab..82f1bbd 100644
--- a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/AndNode.java
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/AndNode.java
@@ -28,6 +28,7 @@
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
+import java.util.Objects;
/**
* {@link Node} that represents logical AND of nodes.
@@ -135,4 +136,17 @@
public String toString() {
return "(" + TextUtils.join(" AND ", mChildren) + ")";
}
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ AndNode andNode = (AndNode) o;
+ return Objects.equals(mChildren, andNode.mChildren);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(mChildren);
+ }
}
diff --git a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/OrNode.java b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/OrNode.java
index c073122..fe87dbf 100644
--- a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/OrNode.java
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/OrNode.java
@@ -28,6 +28,7 @@
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
+import java.util.Objects;
/**
* {@link Node} that represents logical OR of nodes.
@@ -134,4 +135,17 @@
public String toString() {
return "(" + TextUtils.join(" OR ", mChildren) + ")";
}
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ OrNode orNode = (OrNode) o;
+ return Objects.equals(mChildren, orNode.mChildren);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(mChildren);
+ }
}
diff --git a/benchmark/baseline-profile-gradle-plugin/src/main/kotlin/androidx/baselineprofile/gradle/producer/BaselineProfileProducerExtension.kt b/benchmark/baseline-profile-gradle-plugin/src/main/kotlin/androidx/baselineprofile/gradle/producer/BaselineProfileProducerExtension.kt
index 571621c..48fced5 100644
--- a/benchmark/baseline-profile-gradle-plugin/src/main/kotlin/androidx/baselineprofile/gradle/producer/BaselineProfileProducerExtension.kt
+++ b/benchmark/baseline-profile-gradle-plugin/src/main/kotlin/androidx/baselineprofile/gradle/producer/BaselineProfileProducerExtension.kt
@@ -44,7 +44,7 @@
* managed devices. For example, in the following configuration, the name is `pixel6Api31`.
*
* ```
- * testOptions.managedDevices.devices {
+ * testOptions.managedDevices.allDevices {
* pixel6Api31(ManagedVirtualDevice) {
* device = "Pixel 6"
* apiLevel = 31
diff --git a/benchmark/baseline-profile-gradle-plugin/src/main/kotlin/androidx/baselineprofile/gradle/producer/BaselineProfileProducerPlugin.kt b/benchmark/baseline-profile-gradle-plugin/src/main/kotlin/androidx/baselineprofile/gradle/producer/BaselineProfileProducerPlugin.kt
index aa184ec..bfdecd6 100644
--- a/benchmark/baseline-profile-gradle-plugin/src/main/kotlin/androidx/baselineprofile/gradle/producer/BaselineProfileProducerPlugin.kt
+++ b/benchmark/baseline-profile-gradle-plugin/src/main/kotlin/androidx/baselineprofile/gradle/producer/BaselineProfileProducerPlugin.kt
@@ -398,7 +398,7 @@
existing gradle managed devices. Example:
android {
- testOptions.managedDevices.devices {
+ testOptions.managedDevices.allDevices {
pixel6Api31(ManagedVirtualDevice) {
device = "Pixel 6"
apiLevel = 31
diff --git a/benchmark/baseline-profile-gradle-plugin/src/test/kotlin/androidx/baselineprofile/gradle/utils/BaselineProfileProjectSetupRule.kt b/benchmark/baseline-profile-gradle-plugin/src/test/kotlin/androidx/baselineprofile/gradle/utils/BaselineProfileProjectSetupRule.kt
index fb29f65..1021c7c 100644
--- a/benchmark/baseline-profile-gradle-plugin/src/test/kotlin/androidx/baselineprofile/gradle/utils/BaselineProfileProjectSetupRule.kt
+++ b/benchmark/baseline-profile-gradle-plugin/src/test/kotlin/androidx/baselineprofile/gradle/utils/BaselineProfileProjectSetupRule.kt
@@ -72,13 +72,23 @@
rule = producerSetupRule,
name = producerName,
tempFolder = tempFolder,
- consumer = consumer
+ consumer = consumer,
+ managedDeviceContainerName = managedDeviceContainerName,
)
}
/** Represents a simple java library dependency module. */
val dependency by lazy { DependencyModule(name = dependencyName) }
+ /** The managed device container name to use in the build.gradle file. */
+ val managedDeviceContainerName: String
+ get() =
+ if (forcedTestAgpVersion.isAtLeast(TestAgpVersion.TEST_AGP_VERSION_8_1_0)) {
+ "allDevices"
+ } else {
+ "devices"
+ }
+
// Temp folder for temp generated files that need to be referenced by a module.
private val tempFolder by lazy { File(rootFolder.root, "temp").apply { mkdirs() } }
@@ -371,6 +381,7 @@
override val name: String,
private val tempFolder: File,
private val consumer: Module,
+ private val managedDeviceContainerName: String,
) : Module {
fun setupWithFreeAndPaidFlavors(
@@ -494,7 +505,7 @@
if (managedDevices.isEmpty()) ""
else
"""
- testOptions.managedDevices.devices {
+ testOptions.managedDevices.$managedDeviceContainerName {
${
managedDevices.joinToString("\n") {
"""
diff --git a/benchmark/benchmark-common/src/main/java/androidx/benchmark/Arguments.kt b/benchmark/benchmark-common/src/main/java/androidx/benchmark/Arguments.kt
index 93abd81..fae211b 100644
--- a/benchmark/benchmark-common/src/main/java/androidx/benchmark/Arguments.kt
+++ b/benchmark/benchmark-common/src/main/java/androidx/benchmark/Arguments.kt
@@ -91,6 +91,7 @@
internal val cpuEventCounterMask: Int
internal val requireAot: Boolean
internal val requireJitDisabledIfRooted: Boolean
+ val throwOnMainThreadMeasureRepeated: Boolean // non-internal, used in BenchmarkRule
val runOnMainDeadlineSeconds: Long // non-internal, used in BenchmarkRule
internal var error: String? = null
@@ -341,6 +342,9 @@
requireJitDisabledIfRooted =
arguments.getBenchmarkArgument("requireJitDisabledIfRooted")?.toBoolean() ?: false
+ throwOnMainThreadMeasureRepeated =
+ arguments.getBenchmarkArgument("throwOnMainThreadMeasureRepeated")?.toBoolean() ?: false
+
if (arguments.getString("orchestratorService") != null) {
InstrumentationResults.scheduleIdeWarningOnNextReport(
"""
diff --git a/benchmark/benchmark-common/src/main/java/androidx/benchmark/DeviceInfo.kt b/benchmark/benchmark-common/src/main/java/androidx/benchmark/DeviceInfo.kt
index 9cfbbd5..3083747 100644
--- a/benchmark/benchmark-common/src/main/java/androidx/benchmark/DeviceInfo.kt
+++ b/benchmark/benchmark-common/src/main/java/androidx/benchmark/DeviceInfo.kt
@@ -269,7 +269,7 @@
*
* See b/292294133
*/
- const val ART_MAINLINE_MIN_VERSION_CLASS_INIT_TRACING = 341511000L
+ const val ART_MAINLINE_MIN_VERSION_CLASS_LOAD_TRACING = 341511000L
/**
* Starting with an API 34 change cherry-picked to mainline, when `verify`-compiled, ART will
@@ -324,14 +324,14 @@
Build.VERSION.SDK_INT in 26..30 || // b/313868903
artMainlineVersion in ART_MAINLINE_VERSIONS_AFFECTING_METHOD_TRACING // b/303660864
- fun isClassInitTracingAvailable(targetApiLevel: Int, targetArtMainlineVersion: Long?): Boolean =
+ fun isClassLoadTracingAvailable(targetApiLevel: Int, targetArtMainlineVersion: Long?): Boolean =
targetApiLevel >= 35 ||
(targetApiLevel >= 31 &&
(targetArtMainlineVersion == null ||
- targetArtMainlineVersion >= ART_MAINLINE_MIN_VERSION_CLASS_INIT_TRACING))
+ targetArtMainlineVersion >= ART_MAINLINE_MIN_VERSION_CLASS_LOAD_TRACING))
- val supportsClassInitTracing =
- isClassInitTracingAvailable(Build.VERSION.SDK_INT, artMainlineVersion)
+ val supportsClassLoadTracing =
+ isClassLoadTracingAvailable(Build.VERSION.SDK_INT, artMainlineVersion)
val supportsRuntimeImages =
Build.VERSION.SDK_INT >= 34 || artMainlineVersion >= ART_MAINLINE_MIN_VERSION_RUNTIME_IMAGE
diff --git a/benchmark/benchmark-junit4/src/androidTest/java/androidx/benchmark/junit4/ActivityBenchmarkTests.kt b/benchmark/benchmark-junit4/src/androidTest/java/androidx/benchmark/junit4/ActivityBenchmarkTests.kt
index 4bcfcdb..27219fe 100644
--- a/benchmark/benchmark-junit4/src/androidTest/java/androidx/benchmark/junit4/ActivityBenchmarkTests.kt
+++ b/benchmark/benchmark-junit4/src/androidTest/java/androidx/benchmark/junit4/ActivityBenchmarkTests.kt
@@ -17,8 +17,8 @@
package androidx.benchmark.junit4
import android.app.Activity
+import androidx.annotation.WorkerThread
import androidx.benchmark.IsolationActivity
-import androidx.test.annotation.UiThreadTest
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -30,11 +30,17 @@
import org.junit.Test
import org.junit.runner.RunWith
+@WorkerThread
fun BenchmarkRule.validateRunWithIsolationActivityHidden() {
- // isolation activity *not* on top
- assertFalse(IsolationActivity.resumed)
+ var first = true
- measureRepeated {}
+ measureRepeatedOnMainThread {
+ if (first) {
+ // isolation activity *not* on top
+ assertFalse(IsolationActivity.resumed)
+ first = false
+ }
+ }
}
@LargeTest
@@ -51,7 +57,7 @@
@Test
fun verifyActivityLaunched() {
- activityScenario.onActivity { benchmarkRule.validateRunWithIsolationActivityHidden() }
+ benchmarkRule.validateRunWithIsolationActivityHidden()
}
}
@@ -63,7 +69,6 @@
@get:Rule val activityRule = ActivityScenarioRule(Activity::class.java)
@FlakyTest(bugId = 187106319)
- @UiThreadTest
@Test
fun verifyActivityLaunched() {
benchmarkRule.validateRunWithIsolationActivityHidden()
@@ -79,7 +84,6 @@
@get:Rule
val activityRule = androidx.test.rule.ActivityTestRule(Activity::class.java)
- @UiThreadTest
@Test
fun verifyActivityLaunched() {
benchmarkRule.validateRunWithIsolationActivityHidden()
diff --git a/benchmark/benchmark-junit4/src/main/java/androidx/benchmark/junit4/BenchmarkRule.kt b/benchmark/benchmark-junit4/src/main/java/androidx/benchmark/junit4/BenchmarkRule.kt
index 86dec73..149df7a 100644
--- a/benchmark/benchmark-junit4/src/main/java/androidx/benchmark/junit4/BenchmarkRule.kt
+++ b/benchmark/benchmark-junit4/src/main/java/androidx/benchmark/junit4/BenchmarkRule.kt
@@ -317,6 +317,13 @@
public inline fun BenchmarkRule.measureRepeated(crossinline block: BenchmarkRule.Scope.() -> Unit) {
// Note: this is an extension function to discourage calling from Java.
+ if (Arguments.throwOnMainThreadMeasureRepeated) {
+ check(Looper.myLooper() != Looper.getMainLooper()) {
+ "Cannot invoke measureRepeated from the main thread. Instead use" +
+ " measureRepeatedOnMainThread()"
+ }
+ }
+
// Extract members to locals, to ensure we check #applied, and we don't hit accessors
val localState = getState()
val localScope = scope
diff --git a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/ArtMetricTest.kt b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/ArtMetricTest.kt
index 6c36c4f..0b31597 100644
--- a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/ArtMetricTest.kt
+++ b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/ArtMetricTest.kt
@@ -39,17 +39,17 @@
apiLevel = 35,
artMainlineVersion = null, // unknown, but not important on 35
expectedJit = SubMetric(177, 433.488508),
- expectedClassInit = SubMetric(2013, 147.052337),
+ expectedClassLoad = SubMetric(2013, 147.052337),
expectedClassVerify = SubMetric(0, 0.0)
)
@Test
- fun filterOutClassInit() =
+ fun filterOutClassLoad() =
verifyArtMetrics(
apiLevel = 31,
- artMainlineVersion = DeviceInfo.ART_MAINLINE_MIN_VERSION_CLASS_INIT_TRACING - 1,
+ artMainlineVersion = DeviceInfo.ART_MAINLINE_MIN_VERSION_CLASS_LOAD_TRACING - 1,
expectedJit = SubMetric(177, 433.488508),
- expectedClassInit = null, // drops class init
+ expectedClassLoad = null, // drops class load
expectedClassVerify = SubMetric(0, 0.0)
)
@@ -57,9 +57,9 @@
fun oldVersionMainline() =
verifyArtMetrics(
apiLevel = 31,
- artMainlineVersion = DeviceInfo.ART_MAINLINE_MIN_VERSION_CLASS_INIT_TRACING,
+ artMainlineVersion = DeviceInfo.ART_MAINLINE_MIN_VERSION_CLASS_LOAD_TRACING,
expectedJit = SubMetric(177, 433.488508),
- expectedClassInit = SubMetric(2013, 147.052337),
+ expectedClassLoad = SubMetric(2013, 147.052337),
expectedClassVerify = SubMetric(0, 0.0)
)
@@ -68,7 +68,7 @@
apiLevel: Int,
artMainlineVersion: Long?,
expectedJit: SubMetric,
- expectedClassInit: SubMetric?,
+ expectedClassLoad: SubMetric?,
expectedClassVerify: SubMetric
) {
val tracePath =
@@ -107,12 +107,12 @@
Metric.Measurement("artVerifyClassSumMs", expectedClassVerify.sum),
Metric.Measurement("artVerifyClassCount", expectedClassVerify.count.toDouble()),
) +
- if (expectedClassInit != null) {
+ if (expectedClassLoad != null) {
listOf(
- Metric.Measurement("artClassInitSumMs", expectedClassInit.sum),
+ Metric.Measurement("artClassLoadSumMs", expectedClassLoad.sum),
Metric.Measurement(
- "artClassInitCount",
- expectedClassInit.count.toDouble()
+ "artClassLoadCount",
+ expectedClassLoad.count.toDouble()
),
)
} else {
diff --git a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/RuntimeImageTest.kt b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/RuntimeImageTest.kt
index 86fb1c3..5251431 100644
--- a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/RuntimeImageTest.kt
+++ b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/RuntimeImageTest.kt
@@ -58,19 +58,19 @@
@LargeTest
@Test
- fun classInitCount() {
+ fun classLoadCount() {
assumeTrue("Test requires runtime image support", DeviceInfo.supportsRuntimeImages)
- assumeTrue("Test requires class init tracing", DeviceInfo.supportsClassInitTracing)
+ assumeTrue("Test requires class load tracing", DeviceInfo.supportsClassLoadTracing)
- val testName = RuntimeImageTest::classInitCount.name
+ val testName = RuntimeImageTest::classLoadCount.name
val results = captureRecyclerViewListStartupMetrics(testName)
- val classInitCount = results["artClassInitCount"]!!.runs
+ val classLoadCount = results["artClassLoadCount"]!!.runs
// observed >700 in practice, lower threshold used to be resilient
assertTrue(
- classInitCount.all { it > 500 },
- "too few class inits seen, observed: $classInitCount"
+ classLoadCount.all { it > 500 },
+ "too few class loads seen, observed: $classLoadCount"
)
}
}
diff --git a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/Metric.kt b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/Metric.kt
index 05f8a1c..db9bb44 100644
--- a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/Metric.kt
+++ b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/Metric.kt
@@ -638,15 +638,15 @@
/**
* Captures metrics about ART method/class compilation and initialization.
*
- * JIT Compilation, Class Verification, and (on supported devices) Class Initialization.
+ * JIT Compilation, Class Verification, and (on supported devices) Class Loading.
*
* For more information on how ART compilation works, see
* [ART Runtime docs](https://source.android.com/docs/core/runtime/configure).
*
* ## JIT Compilation
- * As interpreted (uncompiled) dex code from your APK is run, methods will be Just-In-Time (JIT)
- * compiled, and this compilation is traced by ART. This does not apply to code AOT compiled either
- * from Baseline Profiles, Warmup Profiles, or Full AOT.
+ * As interpreted (uncompiled) dex code from the APK is run, some methods will be Just-In-Time (JIT)
+ * compiled, and this compilation is traced by ART. This does not apply to methods AOT compiled
+ * either from Baseline Profiles, Warmup Profiles, or Full AOT.
*
* The number of traces and total duration (reported as `artJitCount` and `artJitSumMs`) indicate
* how many uncompiled methods were considered hot by the runtime, and were JITted during
@@ -655,38 +655,45 @@
* Note that framework code on the system image that is not AOT compiled on the system image may
* also be JITted, and will also show up in this metric. If you see this metric reporting non-zero
* values when compiled with [CompilationMode.Full] or [CompilationMode.Partial], this may be the
- * reason. See also "Class Verification" below.
+ * reason.
*
- * ## Class Initialization
- * Class Initialization tracing requires either API 35, or API 31+ with ART mainline version >=
+ * Some methods can't be AOTed or JIT compiled. Generally these are either methods too large for the
+ * Android runtime compiler, or due to a malformed class definition.
+ *
+ * ## Class Loading
+ * Class Loading tracing requires either API 35, or API 31+ with ART mainline version >=
* `341511000`. If a device doesn't support these tracepoints, the measurements will not be reported
* in Studio UI or in JSON results. You can check your device's ART mainline version with:
* ```
* adb shell cmd package list packages --show-versioncode --apex-only art
* ```
*
- * Classes must be initialized by ART in order to be used at runtime. In [CompilationMode.None]
- * (with `warmupRuntimeImageEnabled=false`) and [CompilationMode.Full], this is deferred until
- * runtime, and the cost of this can significantly slow down scenarios where code is run for the
- * first time, such as startup. In [CompilationMode.Partial], this is done at compile time if the
- * class is `trivial` (that is, has no static initializers).
+ * Classes must be loaded by ART in order to be used at runtime. In [CompilationMode.None] and
+ * [CompilationMode.Full], this is deferred until runtime, and the cost of this can significantly
+ * slow down scenarios where code is run for the first time, such as startup.
*
- * The number of traces and total duration (reported as `artClassInitCount` and `artClassInitSumMs`)
- * indicate how many classes were initialized during measurement, at runtime, without
- * pre-initialization at compile time (or in the case of `CompilationMode.None(true), a previous app
- * launch)`.
+ * In `CompilationMode.Partial(warmupIterations=...)` classes captured in the warmup profile (used
+ * during the warmup iterations) are persisted into the `.art` file at compile time to allow them to
+ * be preloaded during app start, before app code begins to execute. If a class is preloaded by the
+ * runtime, it will not appear in traces.
+ *
+ * Even if a class is captured in the warmup profile, it will not be persisted at compile time if
+ * any of the superclasses are not in the app's profile (extremely unlikely) or the Boot Image
+ * profile (for Boot Image classes).
+ *
+ * The number of traces and total duration (reported as `artClassLoadCount` and `artClassLoadSumMs`)
+ * indicate how many classes were loaded during measurement, at runtime, without preloading at
+ * compile time.
*
* These tracepoints are slices of the form `Lcom/example/MyClassName;` for a class named
* `com.example.MyClassName`.
*
- * Even using `CompilationMode.Partial(warmupIterations=...)`, this number will often be non-zero,
- * even if every class is captured in the profile. This can be caused by a static initializer in the
- * class, preventing it from being compile-time initialized.
+ * Class loading is not affected by class verification.
*
* ## Class Verification
- *
- * Before initialization, classes must be verified by the runtime. Typically all classes in a
- * release APK are verified at install time, regardless of [CompilationMode].
+ * Most usages of a class require classes to be verified by the runtime (some usage only require
+ * loading). Typically all classes in a release APK are verified at install time, regardless of
+ * [CompilationMode].
*
* The number of traces and total duration (reported as `artVerifyClass` and `artVerifyClassSumMs`)
* indicate how many classes were verified during measurement, at runtime.
@@ -695,14 +702,13 @@
* 1) If install-time verification fails for a class, it will remain unverified, and be verified at
* runtime.
* 2) Debuggable=true apps are not verified at install time, to save on iteration speed at the cost
- * of runtime performance. This results in runtime verification of each class as its loaded which
- * is the source of much of the slowdown between a debug app and a release app (assuming you're
- * not using a compile-time optimizing dexer, like R8). This is only relevant in macrobenchmark
- * if suppressing warnings about measuring debug app performance.
+ * of runtime performance. This results in runtime verification of each class as it's loaded
+ * which is the source of much of the slowdown between a debug app and a release app. As
+ * Macrobenchmark treats `debuggable=true` as a measurement error, this won't be the case for
+ * `ArtMetric` measurements unless you suppress that error.
*
- * Class Verification at runtime prevents both install-time class initialization, and install-time
- * method compilation. If you see JIT from classes in your apk despite using [CompilationMode.Full],
- * install-time verification failures could be the cause, and would show up in this metric.
+ * Some classes will be verified at runtime rather than install time due to limitations in the
+ * compiler and runtime or due to being malformed.
*/
@RequiresApi(24)
class ArtMetric : Metric() {
@@ -717,14 +723,14 @@
.querySlices("VerifyClass %", packageName = captureInfo.targetPackageName)
.asMeasurements("artVerifyClass") +
if (
- DeviceInfo.isClassInitTracingAvailable(
+ DeviceInfo.isClassLoadTracingAvailable(
targetApiLevel = captureInfo.apiLevel,
targetArtMainlineVersion = captureInfo.artMainlineVersion
)
) {
traceSession
- .querySlices("L%;", packageName = captureInfo.targetPackageName)
- .asMeasurements("artClassInit")
+ .querySlices("L%/%;", packageName = captureInfo.targetPackageName)
+ .asMeasurements("artClassLoad")
} else emptyList()
}
diff --git a/benchmark/integration-tests/baselineprofile-flavors-producer/build.gradle b/benchmark/integration-tests/baselineprofile-flavors-producer/build.gradle
index 25b403d..a86b882 100644
--- a/benchmark/integration-tests/baselineprofile-flavors-producer/build.gradle
+++ b/benchmark/integration-tests/baselineprofile-flavors-producer/build.gradle
@@ -35,7 +35,7 @@
minSdkVersion 23
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
- testOptions.managedDevices.devices {
+ testOptions.managedDevices.allDevices {
pixel6Api31(ManagedVirtualDevice) {
device = "Pixel 6"
apiLevel = 31
diff --git a/benchmark/integration-tests/baselineprofile-library-producer/build.gradle b/benchmark/integration-tests/baselineprofile-library-producer/build.gradle
index 45e81a0..a5432f1 100644
--- a/benchmark/integration-tests/baselineprofile-library-producer/build.gradle
+++ b/benchmark/integration-tests/baselineprofile-library-producer/build.gradle
@@ -35,7 +35,7 @@
minSdkVersion 23
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
- testOptions.managedDevices.devices {
+ testOptions.managedDevices.allDevices {
pixel6Api31(ManagedVirtualDevice) {
device = "Pixel 6"
apiLevel = 31
diff --git a/benchmark/integration-tests/baselineprofile-producer/build.gradle b/benchmark/integration-tests/baselineprofile-producer/build.gradle
index 3a320b1..b4132ee 100644
--- a/benchmark/integration-tests/baselineprofile-producer/build.gradle
+++ b/benchmark/integration-tests/baselineprofile-producer/build.gradle
@@ -35,7 +35,7 @@
minSdkVersion 24
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
- testOptions.managedDevices.devices {
+ testOptions.managedDevices.allDevices {
pixel6Api31(ManagedVirtualDevice) {
device = "Pixel 6"
apiLevel = 31
diff --git a/browser/browser/api/current.txt b/browser/browser/api/current.txt
index 8e2c2e1..96677b3 100644
--- a/browser/browser/api/current.txt
+++ b/browser/browser/api/current.txt
@@ -1,7 +1,22 @@
// Signature format: 4.0
package androidx.browser.auth {
+ public final class AuthTabColorSchemeParams {
+ method @ColorInt public Integer? getNavigationBarColor();
+ method @ColorInt public Integer? getNavigationBarDividerColor();
+ method @ColorInt public Integer? getToolbarColor();
+ }
+
+ public static final class AuthTabColorSchemeParams.Builder {
+ ctor public AuthTabColorSchemeParams.Builder();
+ method public androidx.browser.auth.AuthTabColorSchemeParams build();
+ method public androidx.browser.auth.AuthTabColorSchemeParams.Builder setNavigationBarColor(@ColorInt int);
+ method public androidx.browser.auth.AuthTabColorSchemeParams.Builder setNavigationBarDividerColor(@ColorInt int);
+ method public androidx.browser.auth.AuthTabColorSchemeParams.Builder setToolbarColor(@ColorInt int);
+ }
+
@SuppressCompatibility @androidx.browser.auth.ExperimentalAuthTab public class AuthTabIntent {
+ method public static androidx.browser.auth.AuthTabColorSchemeParams getColorSchemeParams(android.content.Intent, @IntRange(from=androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_LIGHT, to=androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_DARK) int);
method @SuppressCompatibility @androidx.browser.customtabs.ExperimentalEphemeralBrowsing public boolean isEphemeralBrowsingEnabled();
method public void launch(androidx.activity.result.ActivityResultLauncher<android.content.Intent!>, android.net.Uri, String);
method public void launch(androidx.activity.result.ActivityResultLauncher<android.content.Intent!>, android.net.Uri, String, String);
@@ -26,6 +41,9 @@
public static final class AuthTabIntent.Builder {
ctor public AuthTabIntent.Builder();
method public androidx.browser.auth.AuthTabIntent build();
+ method public androidx.browser.auth.AuthTabIntent.Builder setColorScheme(@IntRange(from=androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_SYSTEM, to=androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_DARK) int);
+ method public androidx.browser.auth.AuthTabIntent.Builder setColorSchemeParams(@IntRange(from=androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_LIGHT, to=androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_DARK) int, androidx.browser.auth.AuthTabColorSchemeParams);
+ method public androidx.browser.auth.AuthTabIntent.Builder setDefaultColorSchemeParams(androidx.browser.auth.AuthTabColorSchemeParams);
method @SuppressCompatibility @androidx.browser.customtabs.ExperimentalEphemeralBrowsing public androidx.browser.auth.AuthTabIntent.Builder setEphemeralBrowsingEnabled(boolean);
}
diff --git a/browser/browser/api/restricted_current.txt b/browser/browser/api/restricted_current.txt
index 90363d2..0ead376 100644
--- a/browser/browser/api/restricted_current.txt
+++ b/browser/browser/api/restricted_current.txt
@@ -1,7 +1,22 @@
// Signature format: 4.0
package androidx.browser.auth {
+ public final class AuthTabColorSchemeParams {
+ method @ColorInt public Integer? getNavigationBarColor();
+ method @ColorInt public Integer? getNavigationBarDividerColor();
+ method @ColorInt public Integer? getToolbarColor();
+ }
+
+ public static final class AuthTabColorSchemeParams.Builder {
+ ctor public AuthTabColorSchemeParams.Builder();
+ method public androidx.browser.auth.AuthTabColorSchemeParams build();
+ method public androidx.browser.auth.AuthTabColorSchemeParams.Builder setNavigationBarColor(@ColorInt int);
+ method public androidx.browser.auth.AuthTabColorSchemeParams.Builder setNavigationBarDividerColor(@ColorInt int);
+ method public androidx.browser.auth.AuthTabColorSchemeParams.Builder setToolbarColor(@ColorInt int);
+ }
+
@SuppressCompatibility @androidx.browser.auth.ExperimentalAuthTab public class AuthTabIntent {
+ method public static androidx.browser.auth.AuthTabColorSchemeParams getColorSchemeParams(android.content.Intent, @IntRange(from=androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_LIGHT, to=androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_DARK) int);
method @SuppressCompatibility @androidx.browser.customtabs.ExperimentalEphemeralBrowsing public boolean isEphemeralBrowsingEnabled();
method public void launch(androidx.activity.result.ActivityResultLauncher<android.content.Intent!>, android.net.Uri, String);
method public void launch(androidx.activity.result.ActivityResultLauncher<android.content.Intent!>, android.net.Uri, String, String);
@@ -26,6 +41,9 @@
public static final class AuthTabIntent.Builder {
ctor public AuthTabIntent.Builder();
method public androidx.browser.auth.AuthTabIntent build();
+ method public androidx.browser.auth.AuthTabIntent.Builder setColorScheme(@IntRange(from=androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_SYSTEM, to=androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_DARK) int);
+ method public androidx.browser.auth.AuthTabIntent.Builder setColorSchemeParams(@IntRange(from=androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_LIGHT, to=androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_DARK) int, androidx.browser.auth.AuthTabColorSchemeParams);
+ method public androidx.browser.auth.AuthTabIntent.Builder setDefaultColorSchemeParams(androidx.browser.auth.AuthTabColorSchemeParams);
method @SuppressCompatibility @androidx.browser.customtabs.ExperimentalEphemeralBrowsing public androidx.browser.auth.AuthTabIntent.Builder setEphemeralBrowsingEnabled(boolean);
}
diff --git a/browser/browser/src/main/java/androidx/browser/auth/AuthTabColorSchemeParams.java b/browser/browser/src/main/java/androidx/browser/auth/AuthTabColorSchemeParams.java
new file mode 100644
index 0000000..14df0bb
--- /dev/null
+++ b/browser/browser/src/main/java/androidx/browser/auth/AuthTabColorSchemeParams.java
@@ -0,0 +1,194 @@
+/*
+ * 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.browser.auth;
+
+import static androidx.browser.customtabs.CustomTabsIntent.EXTRA_NAVIGATION_BAR_COLOR;
+import static androidx.browser.customtabs.CustomTabsIntent.EXTRA_NAVIGATION_BAR_DIVIDER_COLOR;
+import static androidx.browser.customtabs.CustomTabsIntent.EXTRA_TOOLBAR_COLOR;
+
+import android.os.Bundle;
+
+import androidx.annotation.ColorInt;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+/**
+ * Contains visual parameters of an Auth Tab that may depend on the color scheme.
+ *
+ * @see AuthTabIntent.Builder#setColorSchemeParams(int, AuthTabColorSchemeParams)
+ */
+public final class AuthTabColorSchemeParams {
+ /** Toolbar color. */
+ @Nullable
+ @ColorInt
+ private final Integer mToolbarColor;
+
+ /** Navigation bar color. */
+ @Nullable
+ @ColorInt
+ private final Integer mNavigationBarColor;
+
+ /** Navigation bar divider color. */
+ @Nullable
+ @ColorInt
+ private final Integer mNavigationBarDividerColor;
+
+ private AuthTabColorSchemeParams(@Nullable @ColorInt Integer toolbarColor,
+ @Nullable @ColorInt Integer navigationBarColor,
+ @Nullable @ColorInt Integer navigationBarDividerColor) {
+ mToolbarColor = toolbarColor;
+ mNavigationBarColor = navigationBarColor;
+ mNavigationBarDividerColor = navigationBarDividerColor;
+ }
+
+ @SuppressWarnings("AutoBoxing")
+ @Nullable
+ @ColorInt
+ public Integer getToolbarColor() {
+ return mToolbarColor;
+ }
+
+ @SuppressWarnings("AutoBoxing")
+ @Nullable
+ @ColorInt
+ public Integer getNavigationBarColor() {
+ return mNavigationBarColor;
+ }
+
+ @SuppressWarnings("AutoBoxing")
+ @Nullable
+ @ColorInt
+ public Integer getNavigationBarDividerColor() {
+ return mNavigationBarDividerColor;
+ }
+
+ /**
+ * Packs the parameters into a {@link Bundle}.
+ * For backward compatibility and ease of use, the names of keys and the structure of the Bundle
+ * are the same as that of Intent extras in {@link CustomTabsIntent}.
+ */
+ @NonNull
+ Bundle toBundle() {
+ Bundle bundle = new Bundle();
+ if (mToolbarColor != null) {
+ bundle.putInt(EXTRA_TOOLBAR_COLOR, mToolbarColor);
+ }
+ if (mNavigationBarColor != null) {
+ bundle.putInt(EXTRA_NAVIGATION_BAR_COLOR, mNavigationBarColor);
+ }
+ if (mNavigationBarDividerColor != null) {
+ bundle.putInt(EXTRA_NAVIGATION_BAR_DIVIDER_COLOR, mNavigationBarDividerColor);
+ }
+ return bundle;
+ }
+
+ /**
+ * Unpacks parameters from a {@link Bundle}. Sets all parameters to null if provided bundle is
+ * null.
+ */
+ @NonNull
+ @SuppressWarnings("deprecation")
+ static AuthTabColorSchemeParams fromBundle(@Nullable Bundle bundle) {
+ if (bundle == null) {
+ bundle = new Bundle(0);
+ }
+ // Using bundle.get() instead of bundle.getInt() to default to null without calling
+ // bundle.containsKey().
+ return new AuthTabColorSchemeParams((Integer) bundle.get(EXTRA_TOOLBAR_COLOR),
+ (Integer) bundle.get(EXTRA_NAVIGATION_BAR_COLOR),
+ (Integer) bundle.get(EXTRA_NAVIGATION_BAR_DIVIDER_COLOR));
+ }
+
+ /**
+ * Returns a new {@link AuthTabColorSchemeParams} with the null fields replaced with the
+ * provided defaults.
+ */
+ @NonNull
+ AuthTabColorSchemeParams withDefaults(@NonNull AuthTabColorSchemeParams defaults) {
+ return new AuthTabColorSchemeParams(
+ mToolbarColor == null ? defaults.mToolbarColor : mToolbarColor,
+ mNavigationBarColor == null ? defaults.mNavigationBarColor : mNavigationBarColor,
+ mNavigationBarDividerColor == null ? defaults.mNavigationBarDividerColor
+ : mNavigationBarDividerColor);
+ }
+
+ /**
+ * Builder class for {@link AuthTabColorSchemeParams} objects.
+ * The browser's default colors will be used for any unset value.
+ */
+ public static final class Builder {
+ @Nullable
+ @ColorInt
+ private Integer mToolbarColor;
+ @Nullable
+ @ColorInt
+ private Integer mNavigationBarColor;
+ @Nullable
+ @ColorInt
+ private Integer mNavigationBarDividerColor;
+
+ /**
+ * Sets the toolbar color.
+ *
+ * This color is also applied to the status bar. To ensure good contrast between status bar
+ * icons and the background, Auth Tab implementations may use
+ * {@link WindowInsetsController#APPEARANCE_LIGHT_STATUS_BARS}.
+ *
+ * @param color The color integer. The alpha value will be ignored.
+ */
+ @NonNull
+ public Builder setToolbarColor(@ColorInt int color) {
+ mToolbarColor = color | 0xff000000;
+ return this;
+ }
+
+ /**
+ * Sets the navigation bar color.
+ *
+ * To ensure good contrast between navigation bar icons and the background, Auth Tab
+ * implementations may use {@link WindowInsetsController#APPEARANCE_LIGHT_NAVIGATION_BARS}.
+ *
+ * @param color The color integer. The alpha value will be ignored.
+ */
+ @NonNull
+ public Builder setNavigationBarColor(@ColorInt int color) {
+ mNavigationBarColor = color | 0xff000000;
+ return this;
+ }
+
+ /**
+ * Sets the navigation bar divider color.
+ *
+ * @param color The color integer.
+ */
+ @NonNull
+ public Builder setNavigationBarDividerColor(@ColorInt int color) {
+ mNavigationBarDividerColor = color;
+ return this;
+ }
+
+ /**
+ * Combines all the options that have been set and returns a new
+ * {@link AuthTabColorSchemeParams} object.
+ */
+ @NonNull
+ public AuthTabColorSchemeParams build() {
+ return new AuthTabColorSchemeParams(mToolbarColor, mNavigationBarColor,
+ mNavigationBarDividerColor);
+ }
+ }
+}
diff --git a/browser/browser/src/main/java/androidx/browser/auth/AuthTabIntent.java b/browser/browser/src/main/java/androidx/browser/auth/AuthTabIntent.java
index d1c4338..cd20b6e 100644
--- a/browser/browser/src/main/java/androidx/browser/auth/AuthTabIntent.java
+++ b/browser/browser/src/main/java/androidx/browser/auth/AuthTabIntent.java
@@ -16,6 +16,11 @@
package androidx.browser.auth;
+import static androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_DARK;
+import static androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_LIGHT;
+import static androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_SYSTEM;
+import static androidx.browser.customtabs.CustomTabsIntent.EXTRA_COLOR_SCHEME;
+import static androidx.browser.customtabs.CustomTabsIntent.EXTRA_COLOR_SCHEME_PARAMS;
import static androidx.browser.customtabs.CustomTabsIntent.EXTRA_ENABLE_EPHEMERAL_BROWSING;
import static androidx.browser.customtabs.CustomTabsIntent.EXTRA_SESSION;
@@ -24,17 +29,20 @@
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
+import android.util.SparseArray;
import androidx.activity.result.ActivityResultCallback;
import androidx.activity.result.ActivityResultCaller;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContract;
import androidx.annotation.IntDef;
+import androidx.annotation.IntRange;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
import androidx.browser.customtabs.CustomTabsIntent;
import androidx.browser.customtabs.ExperimentalEphemeralBrowsing;
+import androidx.core.os.BundleCompat;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@@ -136,7 +144,8 @@
public static final int RESULT_UNKNOWN_CODE = -2;
/** An {@link Intent} used to start the Auth Tab Activity. */
- @NonNull public final Intent intent;
+ @NonNull
+ public final Intent intent;
/**
* Launches an Auth Tab Activity. Must be used for flows that result in a redirect with a custom
@@ -182,6 +191,36 @@
return intent.getBooleanExtra(EXTRA_ENABLE_EPHEMERAL_BROWSING, false);
}
+ /**
+ * Retrieves the instance of {@link AuthTabColorSchemeParams} from an {@link Intent} for a given
+ * color scheme.
+ *
+ * @param intent {@link Intent} to retrieve the color scheme params from.
+ * @param colorScheme A constant representing a color scheme. Must not be
+ * {@link #COLOR_SCHEME_SYSTEM}.
+ * @return An instance of {@link AuthTabColorSchemeParams} with retrieved params.
+ */
+ @NonNull
+ public static AuthTabColorSchemeParams getColorSchemeParams(@NonNull Intent intent,
+ @CustomTabsIntent.ColorScheme @IntRange(from = COLOR_SCHEME_LIGHT, to =
+ COLOR_SCHEME_DARK) int colorScheme) {
+ Bundle extras = intent.getExtras();
+ if (extras == null) {
+ return AuthTabColorSchemeParams.fromBundle(null);
+ }
+
+ AuthTabColorSchemeParams defaults = AuthTabColorSchemeParams.fromBundle(extras);
+ SparseArray<Bundle> paramBundles = BundleCompat.getSparseParcelableArray(extras,
+ EXTRA_COLOR_SCHEME_PARAMS, Bundle.class);
+ if (paramBundles != null) {
+ Bundle bundleForScheme = paramBundles.get(colorScheme);
+ if (bundleForScheme != null) {
+ return AuthTabColorSchemeParams.fromBundle(bundleForScheme).withDefaults(defaults);
+ }
+ }
+ return defaults;
+ }
+
private AuthTabIntent(@NonNull Intent intent) {
this.intent = intent;
}
@@ -191,6 +230,12 @@
*/
public static final class Builder {
private final Intent mIntent = new Intent(Intent.ACTION_VIEW);
+ private final AuthTabColorSchemeParams.Builder mDefaultColorSchemeBuilder =
+ new AuthTabColorSchemeParams.Builder();
+ @Nullable
+ private SparseArray<Bundle> mColorSchemeParamBundles;
+ @Nullable
+ private Bundle mDefaultColorSchemeBundle;
public Builder() {
}
@@ -211,6 +256,94 @@
}
/**
+ * Sets the color scheme that should be applied to the user interface in the Auth Tab.
+ *
+ * @param colorScheme Desired color scheme.
+ * @see CustomTabsIntent#COLOR_SCHEME_SYSTEM
+ * @see CustomTabsIntent#COLOR_SCHEME_LIGHT
+ * @see CustomTabsIntent#COLOR_SCHEME_DARK
+ */
+ @SuppressWarnings("MissingGetterMatchingBuilder")
+ @NonNull
+ public Builder setColorScheme(
+ @CustomTabsIntent.ColorScheme @IntRange(from = COLOR_SCHEME_SYSTEM, to =
+ COLOR_SCHEME_DARK) int colorScheme) {
+ mIntent.putExtra(EXTRA_COLOR_SCHEME, colorScheme);
+ return this;
+ }
+
+ /**
+ * Sets {@link AuthTabColorSchemeParams} for the given color scheme.
+ *
+ * This allows specifying two different toolbar colors for light and dark schemes.
+ * It can be useful if {@link CustomTabsIntent#COLOR_SCHEME_SYSTEM} is set: the Auth Tab
+ * will follow the system settings and apply the corresponding
+ * {@link AuthTabColorSchemeParams} "on the fly" when the settings change.
+ *
+ * If there is no {@link AuthTabColorSchemeParams} for the current scheme, or a particular
+ * field of it is null, the Auth Tab will fall back to the defaults provided via
+ * {@link #setDefaultColorSchemeParams}.
+ *
+ * Example:
+ * <pre><code>
+ * AuthTabColorSchemeParams darkParams = new AuthTabColorSchemeParams.Builder()
+ * .setToolbarColor(darkColor)
+ * .build();
+ * AuthTabColorSchemeParams otherParams = new AuthTabColorSchemeParams.Builder()
+ * .setNavigationBarColor(otherColor)
+ * .build();
+ * AuthTabIntent intent = new AuthTabIntent.Builder()
+ * .setColorScheme(COLOR_SCHEME_SYSTEM)
+ * .setColorSchemeParams(COLOR_SCHEME_DARK, darkParams)
+ * .setDefaultColorSchemeParams(otherParams)
+ * .build();
+ *
+ * // Setting colors independently of color scheme
+ * AuthTabColorSchemeParams params = new AuthTabColorSchemeParams.Builder()
+ * .setToolbarColor(color)
+ * .setNavigationBarColor(color)
+ * .build();
+ * AuthTabIntent intent = new AuthTabIntent.Builder()
+ * .setDefaultColorSchemeParams(params)
+ * .build();
+ * </code></pre>
+ *
+ * @param colorScheme A constant representing a color scheme (see {@link #setColorScheme}).
+ * It should not be {@link #COLOR_SCHEME_SYSTEM}, because that represents
+ * a behavior rather than a particular color scheme.
+ * @param params An instance of {@link AuthTabColorSchemeParams}.
+ */
+ @SuppressWarnings("MissingGetterMatchingBuilder")
+ @NonNull
+ public AuthTabIntent.Builder setColorSchemeParams(
+ @CustomTabsIntent.ColorScheme @IntRange(from = COLOR_SCHEME_LIGHT, to =
+ COLOR_SCHEME_DARK) int colorScheme,
+ @NonNull AuthTabColorSchemeParams params) {
+ if (mColorSchemeParamBundles == null) {
+ mColorSchemeParamBundles = new SparseArray<>();
+ }
+ mColorSchemeParamBundles.put(colorScheme, params.toBundle());
+ return this;
+ }
+
+ /**
+ * Sets the default {@link AuthTabColorSchemeParams}.
+ *
+ * This will set a default color scheme that applies when no
+ * {@link AuthTabColorSchemeParams} specified for current color scheme via
+ * {@link #setColorSchemeParams}.
+ *
+ * @param params An instance of {@link AuthTabColorSchemeParams}.
+ */
+ @SuppressWarnings("MissingGetterMatchingBuilder")
+ @NonNull
+ public AuthTabIntent.Builder setDefaultColorSchemeParams(
+ @NonNull AuthTabColorSchemeParams params) {
+ mDefaultColorSchemeBundle = params.toBundle();
+ return this;
+ }
+
+ /**
* Combines all the options that have been set and returns a new {@link AuthTabIntent}
* object.
*/
@@ -220,9 +353,23 @@
// Put a null EXTRA_SESSION as a fallback so that this is interpreted as a Custom Tab
// intent by browser implementations that don't support Auth Tab.
- Bundle bundle = new Bundle();
- bundle.putBinder(EXTRA_SESSION, null);
- mIntent.putExtras(bundle);
+ {
+ Bundle bundle = new Bundle();
+ bundle.putBinder(EXTRA_SESSION, null);
+ mIntent.putExtras(bundle);
+ }
+
+ mIntent.putExtras(mDefaultColorSchemeBuilder.build().toBundle());
+ if (mDefaultColorSchemeBundle != null) {
+ mIntent.putExtras(mDefaultColorSchemeBundle);
+ }
+
+ if (mColorSchemeParamBundles != null) {
+ Bundle bundle = new Bundle();
+ bundle.putSparseParcelableArray(EXTRA_COLOR_SCHEME_PARAMS,
+ mColorSchemeParamBundles);
+ mIntent.putExtras(bundle);
+ }
return new AuthTabIntent(mIntent);
}
diff --git a/browser/browser/src/test/java/androidx/browser/auth/AuthTabColorSchemeParamsTest.java b/browser/browser/src/test/java/androidx/browser/auth/AuthTabColorSchemeParamsTest.java
new file mode 100644
index 0000000..90e084a
--- /dev/null
+++ b/browser/browser/src/test/java/androidx/browser/auth/AuthTabColorSchemeParamsTest.java
@@ -0,0 +1,145 @@
+/*
+ * 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.browser.auth;
+
+import static androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_DARK;
+import static androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_LIGHT;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+import android.content.Intent;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.internal.DoNotInstrument;
+
+/** Tests for {@link AuthTabColorSchemeParams}. */
+@RunWith(RobolectricTestRunner.class)
+@DoNotInstrument
+public class AuthTabColorSchemeParamsTest {
+ @Test
+ public void testParamsForBothSchemes() {
+ AuthTabColorSchemeParams lightParams = new AuthTabColorSchemeParams.Builder()
+ .setToolbarColor(0x0000ff)
+ .setNavigationBarColor(0xff0000)
+ .setNavigationBarDividerColor(0x00ff00)
+ .build();
+
+ AuthTabColorSchemeParams darkParams = new AuthTabColorSchemeParams.Builder()
+ .setToolbarColor(0xff0000)
+ .setNavigationBarColor(0xffaa00)
+ .setNavigationBarDividerColor(0xffffff)
+ .build();
+
+ Intent intent = new AuthTabIntent.Builder()
+ .setColorSchemeParams(COLOR_SCHEME_LIGHT, lightParams)
+ .setColorSchemeParams(COLOR_SCHEME_DARK, darkParams)
+ .build().intent;
+
+ AuthTabColorSchemeParams lightParamsFromIntent = AuthTabIntent.getColorSchemeParams(intent,
+ COLOR_SCHEME_LIGHT);
+
+ AuthTabColorSchemeParams darkParamsFromIntent = AuthTabIntent.getColorSchemeParams(intent,
+ COLOR_SCHEME_DARK);
+
+ assertSchemeParamsEqual(lightParams, lightParamsFromIntent);
+ assertSchemeParamsEqual(darkParams, darkParamsFromIntent);
+ }
+
+ @Test
+ public void testWithDefaultsForOneScheme() {
+ int defaultToolbarColor = 0x0000ff;
+ int defaultNavigationBarColor = 0xaabbcc;
+ int defaultNavigationBarDividerColor = 0xdddddd;
+
+ AuthTabColorSchemeParams darkParams = new AuthTabColorSchemeParams.Builder()
+ .setToolbarColor(0xff0000)
+ .setNavigationBarColor(0xccbbaa)
+ .setNavigationBarDividerColor(0xffffff)
+ .build();
+
+ AuthTabColorSchemeParams defaultParams = new AuthTabColorSchemeParams.Builder()
+ .setToolbarColor(defaultToolbarColor)
+ .setNavigationBarColor(defaultNavigationBarColor)
+ .setNavigationBarDividerColor(defaultNavigationBarDividerColor)
+ .build();
+
+ Intent intent = new AuthTabIntent.Builder()
+ .setDefaultColorSchemeParams(defaultParams)
+ .setColorSchemeParams(COLOR_SCHEME_DARK, darkParams)
+ .build()
+ .intent;
+
+ AuthTabColorSchemeParams lightParamsFromIntent = AuthTabIntent.getColorSchemeParams(intent,
+ COLOR_SCHEME_LIGHT);
+
+ AuthTabColorSchemeParams darkParamsFromIntent = AuthTabIntent.getColorSchemeParams(intent,
+ COLOR_SCHEME_DARK);
+
+ assertSchemeParamsEqual(defaultParams, lightParamsFromIntent);
+ assertSchemeParamsEqual(darkParams, darkParamsFromIntent);
+ }
+
+ @Test
+ public void testParamsNotProvided() {
+ Intent intent = new AuthTabIntent.Builder().build().intent;
+
+ AuthTabColorSchemeParams lightParamsFromIntent = AuthTabIntent.getColorSchemeParams(intent,
+ COLOR_SCHEME_LIGHT);
+
+ AuthTabColorSchemeParams darkParamsFromIntent = AuthTabIntent.getColorSchemeParams(intent,
+ COLOR_SCHEME_DARK);
+
+ assertNull(lightParamsFromIntent.getToolbarColor());
+ assertNull(lightParamsFromIntent.getNavigationBarColor());
+ assertNull(lightParamsFromIntent.getNavigationBarDividerColor());
+
+ assertNull(darkParamsFromIntent.getToolbarColor());
+ assertNull(darkParamsFromIntent.getNavigationBarColor());
+ assertNull(darkParamsFromIntent.getNavigationBarDividerColor());
+ }
+
+ @Test
+ public void testColorsAreSolid() {
+ AuthTabColorSchemeParams params = new AuthTabColorSchemeParams.Builder()
+ .setToolbarColor(0x610000ff)
+ .setNavigationBarColor(0x88ff0000)
+ .setNavigationBarDividerColor(0x00ff00)
+ .build();
+
+ Intent intent = new AuthTabIntent.Builder()
+ .setDefaultColorSchemeParams(params)
+ .build()
+ .intent;
+
+ AuthTabColorSchemeParams paramsFromIntent = AuthTabIntent.getColorSchemeParams(intent,
+ COLOR_SCHEME_LIGHT);
+
+ assertEquals(0xff0000ff, paramsFromIntent.getToolbarColor().intValue());
+ assertEquals(0xffff0000, paramsFromIntent.getNavigationBarColor().intValue());
+ }
+
+ private void assertSchemeParamsEqual(AuthTabColorSchemeParams params1,
+ AuthTabColorSchemeParams params2) {
+ assertEquals(params1.getToolbarColor(), params2.getToolbarColor());
+ assertEquals(params1.getNavigationBarColor(), params2.getNavigationBarColor());
+ assertEquals(params1.getNavigationBarDividerColor(),
+ params2.getNavigationBarDividerColor());
+ }
+}
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/AndroidXImplPlugin.kt b/buildSrc/private/src/main/kotlin/androidx/build/AndroidXImplPlugin.kt
index 5dc8ca3a..fe6521f 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/AndroidXImplPlugin.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/AndroidXImplPlugin.kt
@@ -28,6 +28,7 @@
import androidx.build.docs.CheckTipOfTreeDocsTask.Companion.setUpCheckDocsTask
import androidx.build.gitclient.getHeadShaProvider
import androidx.build.gradle.isRoot
+import androidx.build.kythe.configureProjectForKzipTasks
import androidx.build.license.addLicensesToPublishedArtifacts
import androidx.build.resources.CopyPublicResourcesDirTask
import androidx.build.resources.configurePublicResourcesStub
@@ -694,6 +695,7 @@
project.disableStrictVersionConstraints()
project.configureProjectForApiTasks(AndroidMultiplatformApiTaskConfig, androidXExtension)
+ project.configureProjectForKzipTasks(AndroidMultiplatformApiTaskConfig, androidXExtension)
kotlinMultiplatformAndroidComponentsExtension.onVariant { it.configureTests() }
@@ -933,6 +935,10 @@
LibraryApiTaskConfig(variant),
androidXExtension
)
+ project.configureProjectForKzipTasks(
+ LibraryApiTaskConfig(variant),
+ androidXExtension
+ )
}
if (variant.name == DEFAULT_PUBLISH_CONFIG) {
project.configureSourceJarForAndroid(variant, androidXExtension.samplesProjects)
@@ -973,22 +979,6 @@
SdkResourceGenerator.generateForHostTest(project)
}
- private fun getDefaultTargetJavaVersion(
- libraryType: LibraryType,
- projectName: String? = null,
- targetName: String? = null
- ): JavaVersion {
- return when {
- // TODO(b/353328300): Move room-compiler-processing to Java 17 once Dagger is ready.
- projectName != null && projectName.contains("room-compiler-processing") -> VERSION_11
- projectName != null && projectName.contains("desktop") -> VERSION_11
- targetName != null && (targetName == "desktop" || targetName == "jvmStubs") ->
- VERSION_11
- libraryType.compilationTarget == CompilationTarget.HOST -> VERSION_17
- else -> VERSION_1_8
- }
- }
-
private fun configureWithJavaPlugin(project: Project, androidXExtension: AndroidXExtension) {
if (
project.multiplatformExtension != null &&
@@ -1035,6 +1025,7 @@
}
project.configureProjectForApiTasks(apiTaskConfig, androidXExtension)
+ project.configureProjectForKzipTasks(apiTaskConfig, androidXExtension)
project.setUpCheckDocsTask(androidXExtension)
if (project.multiplatformExtension == null) {
@@ -1451,6 +1442,21 @@
}
}
+internal fun getDefaultTargetJavaVersion(
+ libraryType: LibraryType,
+ projectName: String? = null,
+ targetName: String? = null
+): JavaVersion {
+ return when {
+ // TODO(b/353328300): Move room-compiler-processing to Java 17 once Dagger is ready.
+ projectName != null && projectName.contains("room-compiler-processing") -> VERSION_11
+ projectName != null && projectName.contains("desktop") -> VERSION_11
+ targetName != null && (targetName == "desktop" || targetName == "jvmStubs") -> VERSION_11
+ libraryType.compilationTarget == CompilationTarget.HOST -> VERSION_17
+ else -> VERSION_1_8
+ }
+}
+
private fun Project.validateLintVersionTestExists(androidXExtension: AndroidXExtension) {
if (!androidXExtension.type.isLint()) {
return
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/binarycompatibilityvalidator/BinaryCompatibilityValidation.kt b/buildSrc/private/src/main/kotlin/androidx/build/binarycompatibilityvalidator/BinaryCompatibilityValidation.kt
index e11cf28..220083b 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/binarycompatibilityvalidator/BinaryCompatibilityValidation.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/binarycompatibilityvalidator/BinaryCompatibilityValidation.kt
@@ -60,12 +60,14 @@
private const val UPDATE_NAME = "updateAbi"
private const val EXTRACT_NAME = "extractAbi"
private const val EXTRACT_RELEASE_NAME = "extractAbiRelease"
+private const val IGNORE_CHANGES_NAME = "ignoreAbiChanges"
private const val KLIB_DUMPS_DIRECTORY = "klib"
private const val KLIB_MERGE_DIRECTORY = "merged"
private const val KLIB_EXTRACTED_DIRECTORY = "extracted"
private const val NATIVE_SUFFIX = "native"
internal const val CURRENT_API_FILE_NAME = "current.txt"
+private const val IGNORE_FILE_NAME = "current.ignore"
private const val ABI_GROUP_NAME = "abi"
class BinaryCompatibilityValidation(
@@ -104,6 +106,7 @@
}
val projectVersion: Version = project.version()
val projectAbiDir = project.getBcvFileDirectory().dir(NATIVE_SUFFIX)
+ val currentIgnoreFile = projectAbiDir.file(IGNORE_FILE_NAME)
val buildAbiDir = project.getBuiltBcvFileDirectory().map { it.dir(NATIVE_SUFFIX) }
val klibDumpDir = project.layout.buildDirectory.dir(KLIB_DUMPS_DIRECTORY)
@@ -128,7 +131,8 @@
project.checkKlibAbiReleaseTask(
generatedAndMergedApiFile,
projectAbiDir,
- klibExtractedFileDir
+ klibExtractedFileDir,
+ currentIgnoreFile
)
updateKlibAbi.configure { update ->
@@ -164,7 +168,8 @@
private fun Project.checkKlibAbiReleaseTask(
mergedApiFile: Provider<RegularFileProperty>,
klibApiDir: Directory,
- klibExtractDir: Provider<Directory>
+ klibExtractDir: Provider<Directory>,
+ ignoreFile: RegularFile,
) =
project.getRequiredCompatibilityAbiLocation(NATIVE_SUFFIX)?.let { requiredCompatFile ->
val extractReleaseTask =
@@ -181,6 +186,13 @@
it.outputAbiFile.set(klibExtractDir.map { it.file(requiredCompatFile.name) })
(it as DefaultTask).group = ABI_GROUP_NAME
}
+ project.tasks.register(IGNORE_CHANGES_NAME, IgnoreAbiChangesTask::class.java) {
+ it.currentApiDump.set(mergedApiFile.map { fileProperty -> fileProperty.get() })
+ it.previousApiDump.set(
+ extractReleaseTask.map { extract -> extract.outputAbiFile.get() }
+ )
+ it.ignoreFile.set(ignoreFile)
+ }
project.tasks
.register(CHECK_RELEASE_NAME, CheckAbiIsCompatibleTask::class.java) {
it.currentApiDump.set(mergedApiFile.map { fileProperty -> fileProperty.get() })
@@ -192,6 +204,7 @@
extractReleaseTask.map { extract ->
extract.outputAbiFile.get().asFile.nameWithoutExtension
}
+ it.ignoreFile.set(ignoreFile)
it.group = ABI_GROUP_NAME
}
.also { checkRelease ->
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/binarycompatibilityvalidator/CheckAbiIsCompatibleTask.kt b/buildSrc/private/src/main/kotlin/androidx/build/binarycompatibilityvalidator/CheckAbiIsCompatibleTask.kt
index c5c6509..2a61e7e 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/binarycompatibilityvalidator/CheckAbiIsCompatibleTask.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/binarycompatibilityvalidator/CheckAbiIsCompatibleTask.kt
@@ -22,6 +22,7 @@
import androidx.build.Version
import androidx.build.metalava.shouldFreezeApis
import androidx.build.metalava.summarizeDiff
+import java.io.File
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.file.RegularFileProperty
@@ -29,6 +30,8 @@
import org.gradle.api.tasks.CacheableTask
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFile
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.Optional
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
@@ -38,6 +41,9 @@
@OptIn(ExperimentalLibraryAbiReader::class)
abstract class CheckAbiIsCompatibleTask : DefaultTask() {
+ // Input annotation is handled by getIgnoreFile
+ @get:Internal abstract val ignoreFile: RegularFileProperty
+
/** Text file from which API signatures will be read. */
@get:PathSensitive(PathSensitivity.RELATIVE)
@get:InputFile
@@ -51,6 +57,11 @@
@get:Input abstract var projectVersion: Provider<String>
+ @PathSensitive(PathSensitivity.RELATIVE)
+ @InputFile
+ @Optional
+ fun getBaseline(): File? = ignoreFile.get().asFile.takeIf { it.exists() }
+
@TaskAction
fun execute() {
val (previousApiPath, previousApiDumpText) =
@@ -67,7 +78,11 @@
val currentDump = KlibDumpParser(currentApiDumpText, currentApiPath).parse()
try {
- BinaryCompatibilityChecker.checkAllBinariesAreCompatible(currentDump, previousDump)
+ BinaryCompatibilityChecker.checkAllBinariesAreCompatible(
+ currentDump,
+ previousDump,
+ getBaseline()
+ )
} catch (e: ValidationException) {
throw GradleException(compatErrorMessage(e), e)
}
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/binarycompatibilityvalidator/IgnoreAbiChangesTask.kt b/buildSrc/private/src/main/kotlin/androidx/build/binarycompatibilityvalidator/IgnoreAbiChangesTask.kt
new file mode 100644
index 0000000..073de55
--- /dev/null
+++ b/buildSrc/private/src/main/kotlin/androidx/build/binarycompatibilityvalidator/IgnoreAbiChangesTask.kt
@@ -0,0 +1,71 @@
+/*
+ * 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.build.binarycompatibilityvalidator
+
+import androidx.binarycompatibilityvalidator.BinaryCompatibilityChecker
+import androidx.binarycompatibilityvalidator.KlibDumpParser
+import org.gradle.api.DefaultTask
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.tasks.CacheableTask
+import org.gradle.api.tasks.InputFile
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.jetbrains.kotlin.library.abi.ExperimentalLibraryAbiReader
+
+@OptIn(ExperimentalLibraryAbiReader::class)
+@CacheableTask
+abstract class IgnoreAbiChangesTask : DefaultTask() {
+
+ /** Text file from which API signatures will be read. */
+ @get:PathSensitive(PathSensitivity.RELATIVE)
+ @get:InputFile
+ abstract val previousApiDump: RegularFileProperty
+
+ @get:PathSensitive(PathSensitivity.RELATIVE)
+ @get:InputFile
+ abstract val currentApiDump: RegularFileProperty
+
+ @get:OutputFile abstract val ignoreFile: RegularFileProperty
+
+ @TaskAction
+ fun execute() {
+ val previousDump = KlibDumpParser(previousApiDump.get().asFile).parse()
+ val currentDump = KlibDumpParser(currentApiDump.get().asFile).parse()
+ val ignoredErrors =
+ BinaryCompatibilityChecker.checkAllBinariesAreCompatible(
+ currentDump,
+ previousDump,
+ null,
+ validate = false
+ )
+ .map { it.toString() }
+ .toSet()
+ ignoreFile.get().asFile.apply {
+ if (!exists()) {
+ createNewFile()
+ }
+ writeText(formatString + "\n" + ignoredErrors.joinToString("\n"))
+ }
+ }
+
+ private companion object {
+ const val BASELINE_FORMAT_VERSION = "1.0"
+ const val formatString = "// Baseline format: $BASELINE_FORMAT_VERSION"
+ }
+}
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/checkapi/ApiTasks.kt b/buildSrc/private/src/main/kotlin/androidx/build/checkapi/ApiTasks.kt
index 73726b5..17d960d 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/checkapi/ApiTasks.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/checkapi/ApiTasks.kt
@@ -30,10 +30,14 @@
import androidx.build.stableaidl.setupWithStableAidlPlugin
import androidx.build.version
import com.android.build.api.artifact.SingleArtifact
+import com.android.build.api.attributes.BuildTypeAttr
import com.android.build.api.variant.LibraryVariant
import java.io.File
import org.gradle.api.GradleException
import org.gradle.api.Project
+import org.gradle.api.artifacts.Configuration
+import org.gradle.api.artifacts.type.ArtifactTypeDefinition
+import org.gradle.api.attributes.Usage
import org.gradle.api.file.RegularFile
import org.gradle.api.plugins.JavaPluginExtension
import org.gradle.api.provider.Provider
@@ -158,37 +162,15 @@
listOf(currentApiLocation)
}
- val javaInputs: JavaCompileInputs
- val androidManifest: Provider<RegularFile>?
- when (config) {
- is LibraryApiTaskConfig -> {
- if (config.variant.name != Release.DEFAULT_PUBLISH_CONFIG) {
- return@afterEvaluate
- }
- javaInputs = JavaCompileInputs.fromLibraryVariant(config.variant, project)
- androidManifest = config.variant.artifacts.get(SingleArtifact.MERGED_MANIFEST)
- }
- is AndroidMultiplatformApiTaskConfig -> {
- javaInputs = JavaCompileInputs.fromKmpAndroidTarget(project)
- androidManifest = null
- }
- is KmpApiTaskConfig -> {
- javaInputs = JavaCompileInputs.fromKmpJvmTarget(project)
- androidManifest = null
- }
- is JavaApiTaskConfig -> {
- val javaExtension = extensions.getByType<JavaPluginExtension>()
- val mainSourceSet = javaExtension.sourceSets.getByName("main")
- javaInputs = JavaCompileInputs.fromSourceSet(mainSourceSet, this)
- androidManifest = null
- }
- }
-
+ val (javaInputs, androidManifest) =
+ configureJavaInputsAndManifest(config) ?: return@afterEvaluate
val baselinesApiLocation = ApiBaselinesLocation.fromApiLocation(currentApiLocation)
+ val generateApiDependencies = createReleaseApiConfiguration()
MetalavaTasks.setupProject(
project,
javaInputs,
+ generateApiDependencies,
extension,
androidManifest,
baselinesApiLocation,
@@ -222,6 +204,53 @@
}
}
+internal fun Project.configureJavaInputsAndManifest(
+ config: ApiTaskConfig
+): Pair<JavaCompileInputs, Provider<RegularFile>?>? {
+ return when (config) {
+ is LibraryApiTaskConfig -> {
+ if (config.variant.name != Release.DEFAULT_PUBLISH_CONFIG) {
+ return null
+ }
+ JavaCompileInputs.fromLibraryVariant(config.variant, project) to
+ config.variant.artifacts.get(SingleArtifact.MERGED_MANIFEST)
+ }
+ is AndroidMultiplatformApiTaskConfig -> {
+ JavaCompileInputs.fromKmpAndroidTarget(project) to null
+ }
+ is KmpApiTaskConfig -> {
+ JavaCompileInputs.fromKmpJvmTarget(project) to null
+ }
+ is JavaApiTaskConfig -> {
+ val javaExtension = extensions.getByType<JavaPluginExtension>()
+ val mainSourceSet = javaExtension.sourceSets.getByName("main")
+ JavaCompileInputs.fromSourceSet(mainSourceSet, this) to null
+ }
+ }
+}
+
+internal fun Project.createReleaseApiConfiguration(): Configuration {
+ return configurations.findByName("ReleaseApiDependencies")
+ ?: configurations
+ .create("ReleaseApiDependencies") {
+ it.isCanBeConsumed = false
+ it.isTransitive = false
+ it.attributes.attribute(
+ BuildTypeAttr.ATTRIBUTE,
+ project.objects.named(BuildTypeAttr::class.java, "release")
+ )
+ it.attributes.attribute(
+ Usage.USAGE_ATTRIBUTE,
+ objects.named(Usage::class.java, Usage.JAVA_API)
+ )
+ it.attributes.attribute(
+ ArtifactTypeDefinition.ARTIFACT_TYPE_ATTRIBUTE,
+ ArtifactTypeDefinition.JAR_TYPE
+ )
+ }
+ .apply { project.dependencies.add(name, project.project(path)) }
+}
+
internal class BlankApiRegularFile(project: Project) : RegularFile {
val file = File(project.getSupportRootFolder(), "buildSrc/blank-res-api/public.txt")
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/kythe/GenerateKotlinKzipTask.kt b/buildSrc/private/src/main/kotlin/androidx/build/kythe/GenerateKotlinKzipTask.kt
new file mode 100644
index 0000000..5a60bf6
--- /dev/null
+++ b/buildSrc/private/src/main/kotlin/androidx/build/kythe/GenerateKotlinKzipTask.kt
@@ -0,0 +1,197 @@
+/*
+ * 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.build.kythe
+
+import androidx.build.KotlinTarget
+import androidx.build.OperatingSystem
+import androidx.build.addToBuildOnServer
+import androidx.build.getCheckoutRoot
+import androidx.build.getOperatingSystem
+import androidx.build.getPrebuiltsRoot
+import androidx.build.getSupportRootFolder
+import androidx.build.java.JavaCompileInputs
+import java.io.File
+import javax.inject.Inject
+import org.gradle.api.DefaultTask
+import org.gradle.api.JavaVersion
+import org.gradle.api.Project
+import org.gradle.api.artifacts.Configuration
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.provider.Property
+import org.gradle.api.tasks.CacheableTask
+import org.gradle.api.tasks.Classpath
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.InputFile
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.gradle.process.ExecOperations
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+/** Generates kzip files that are used to index the Kotlin source code in Kythe. */
+@CacheableTask
+abstract class GenerateKotlinKzipTask
+@Inject
+constructor(private val execOperations: ExecOperations) : DefaultTask() {
+
+ @get:InputFile
+ @get:PathSensitive(PathSensitivity.NONE)
+ abstract val kotlincExtractorBin: RegularFileProperty
+
+ /** Must be run in the checkout root so as to be free of relative markers */
+ @get:Internal val checkoutRoot: File = project.getCheckoutRoot()
+
+ @get:InputFiles
+ @get:PathSensitive(PathSensitivity.RELATIVE)
+ abstract val sourcePaths: ConfigurableFileCollection
+
+ @get:InputFiles
+ @get:PathSensitive(PathSensitivity.RELATIVE)
+ abstract val commonModuleSourcePaths: ConfigurableFileCollection
+
+ /** Path to `vnames.json` file, used for name mappings within Kythe. */
+ @get:InputFiles
+ @get:PathSensitive(PathSensitivity.NONE)
+ abstract val vnamesJson: RegularFileProperty
+
+ @get:Classpath abstract val dependencyClasspath: ConfigurableFileCollection
+
+ @get:Classpath abstract val compiledSources: ConfigurableFileCollection
+
+ @get:Input abstract val kotlinTarget: Property<KotlinTarget>
+
+ @get:Input abstract val jvmTarget: Property<JvmTarget>
+
+ @get:OutputFile abstract val kzipOutputFile: RegularFileProperty
+
+ @TaskAction
+ fun exec() {
+ val sourceFiles =
+ sourcePaths.asFileTree.files
+ .takeIf { files -> files.any { it.extension == "kt" } }
+ ?.filter { it.extension == "kt" || it.extension == "java" }
+ ?.map { it.relativeTo(checkoutRoot) }
+ .orEmpty()
+
+ if (sourceFiles.isEmpty()) {
+ return
+ }
+
+ val args =
+ listOf(
+ "-jvm-target",
+ jvmTarget.get().target,
+ "-Xjdk-release",
+ jvmTarget.get().target,
+ "-Xjvm-default=all",
+ "-language-version",
+ kotlinTarget.get().apiVersion.version,
+ "-api-version",
+ kotlinTarget.get().apiVersion.version,
+ "-no-reflect",
+ "-no-stdlib",
+ "-opt-in=androidx.room.compiler.processing.ExperimentalProcessingApi",
+ "-opt-in=com.squareup.kotlinpoet.javapoet.KotlinPoetJavaPoetPreview",
+ "-opt-in=kotlin.contracts.ExperimentalContracts",
+ )
+
+ val commonSourceFiles =
+ commonModuleSourcePaths.asFileTree.files
+ .filter { it.extension == "kt" || it.extension == "java" }
+ .map { it.relativeTo(checkoutRoot) }
+
+ val commonSourcesArgs =
+ if (commonSourceFiles.isNotEmpty()) {
+ listOf("-Xmulti-platform", "-Xexpect-actual-classes")
+ } else emptyList()
+
+ val command = buildList {
+ add(kotlincExtractorBin.get().asFile)
+ addAll(
+ listOf(
+ "-corpus",
+ "android.googlesource.com/platform/frameworks/support//androidx-main",
+ "-kotlin_out",
+ compiledSources.singleFile.relativeTo(checkoutRoot).path,
+ "-o",
+ kzipOutputFile.get().asFile.relativeTo(checkoutRoot).path,
+ "-vnames",
+ vnamesJson.get().asFile.relativeTo(checkoutRoot).path,
+ "-args",
+ (args + commonSourcesArgs).joinToString(" ")
+ )
+ )
+ sourceFiles.forEach { file -> addAll(listOf("-srcs", file.path)) }
+ commonSourceFiles.forEach { file -> addAll(listOf("-common_srcs", file.path)) }
+ dependencyClasspath.files
+ .map { it.relativeTo(checkoutRoot).path }
+ .forEach { file -> addAll(listOf("-cp", file)) }
+ }
+
+ execOperations.exec {
+ it.commandLine(command)
+ it.workingDir = checkoutRoot
+ }
+ }
+
+ companion object {
+ fun setupProject(
+ project: Project,
+ javaInputs: JavaCompileInputs,
+ compiledSources: Configuration,
+ kotlinTarget: Property<KotlinTarget>,
+ javaVersion: JavaVersion,
+ ) {
+ project.tasks
+ .register("generateKotlinKzip", GenerateKotlinKzipTask::class.java) { task ->
+ task.apply {
+ kotlincExtractorBin.set(
+ File(
+ project.getPrebuiltsRoot(),
+ "build-tools/${osName()}/bin/kotlinc_extractor"
+ )
+ )
+ sourcePaths.setFrom(javaInputs.sourcePaths)
+ commonModuleSourcePaths.from(javaInputs.commonModuleSourcePaths)
+ vnamesJson.set(File(project.getSupportRootFolder(), "buildSrc/vnames.json"))
+ dependencyClasspath.setFrom(javaInputs.dependencyClasspath)
+ this.compiledSources.setFrom(compiledSources)
+ this.kotlinTarget.set(kotlinTarget)
+ jvmTarget.set(JvmTarget.fromTarget(javaVersion.toString()))
+ kzipOutputFile.set(
+ File(
+ project.layout.buildDirectory.get().asFile,
+ "kzips/${project.group}-${project.name}.kotlin.kzip"
+ )
+ )
+ }
+ }
+ .also { project.addToBuildOnServer(it) }
+ }
+ }
+}
+
+private fun osName() =
+ when (getOperatingSystem()) {
+ OperatingSystem.LINUX -> "linux-x86"
+ OperatingSystem.MAC -> "darwin-x86"
+ OperatingSystem.WINDOWS -> error("Kzip generation not supported in Windows")
+ }
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/kythe/KzipTasks.kt b/buildSrc/private/src/main/kotlin/androidx/build/kythe/KzipTasks.kt
new file mode 100644
index 0000000..e9a90d0
--- /dev/null
+++ b/buildSrc/private/src/main/kotlin/androidx/build/kythe/KzipTasks.kt
@@ -0,0 +1,39 @@
+/*
+ * 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.build.kythe
+
+import androidx.build.AndroidXExtension
+import androidx.build.checkapi.ApiTaskConfig
+import androidx.build.checkapi.configureJavaInputsAndManifest
+import androidx.build.checkapi.createReleaseApiConfiguration
+import androidx.build.getDefaultTargetJavaVersion
+import org.gradle.api.Project
+
+fun Project.configureProjectForKzipTasks(config: ApiTaskConfig, extension: AndroidXExtension) =
+ // afterEvaluate required to read extension properties
+ afterEvaluate {
+ val (javaInputs, _) = configureJavaInputsAndManifest(config) ?: return@afterEvaluate
+ val generateApiDependencies = createReleaseApiConfiguration()
+
+ GenerateKotlinKzipTask.setupProject(
+ project,
+ javaInputs,
+ generateApiDependencies,
+ extension.kotlinTarget,
+ getDefaultTargetJavaVersion(extension.type, project.name)
+ )
+ }
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/metalava/MetalavaTasks.kt b/buildSrc/private/src/main/kotlin/androidx/build/metalava/MetalavaTasks.kt
index 1f32b4e..f385c0f 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/metalava/MetalavaTasks.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/metalava/MetalavaTasks.kt
@@ -26,11 +26,8 @@
import androidx.build.java.JavaCompileInputs
import androidx.build.uptodatedness.cacheEvenIfNoOutputs
import androidx.build.version
-import com.android.build.api.attributes.BuildTypeAttr
import org.gradle.api.Project
import org.gradle.api.artifacts.Configuration
-import org.gradle.api.artifacts.type.ArtifactTypeDefinition
-import org.gradle.api.attributes.Usage
import org.gradle.api.file.RegularFile
import org.gradle.api.provider.Provider
import org.gradle.api.tasks.TaskProvider
@@ -41,31 +38,13 @@
fun setupProject(
project: Project,
javaCompileInputs: JavaCompileInputs,
+ generateApiDependencies: Configuration,
extension: AndroidXExtension,
androidManifest: Provider<RegularFile>?,
baselinesApiLocation: ApiBaselinesLocation,
builtApiLocation: ApiLocation,
outputApiLocations: List<ApiLocation>
) {
- val generateApiDependencies =
- project.configurations.create("GenerateApiDependencies") {
- it.isCanBeConsumed = false
- it.isTransitive = false
- it.attributes.attribute(
- BuildTypeAttr.ATTRIBUTE,
- project.objects.named(BuildTypeAttr::class.java, "debug")
- )
- it.attributes.attribute(
- Usage.USAGE_ATTRIBUTE,
- project.objects.named(Usage::class.java, Usage.JAVA_API)
- )
- it.attributes.attribute(
- ArtifactTypeDefinition.ARTIFACT_TYPE_ATTRIBUTE,
- ArtifactTypeDefinition.JAR_TYPE
- )
- }
- project.dependencies.add(generateApiDependencies.name, project.project(project.path))
-
val metalavaClasspath = project.getMetalavaClasspath()
val version = project.version()
diff --git a/buildSrc/vnames.json b/buildSrc/vnames.json
new file mode 100644
index 0000000..f570a8e
--- /dev/null
+++ b/buildSrc/vnames.json
@@ -0,0 +1,35 @@
+[
+ {
+ "pattern": "out/(.*)",
+ "vname": {
+ "root": "out",
+ "path": "@1@"
+ }
+ },
+ {
+ "pattern": "prebuilts/androidx/external/(.*)",
+ "vname": {
+ "corpus": "android.googlesource.com/platform/prebuilts/androidx/external//androidx-main",
+ "path": "@1@"
+ }
+ },
+ {
+ "pattern": "prebuilts/androidx/internal/(.*)",
+ "vname": {
+ "corpus": "android.googlesource.com/platform/prebuilts/androidx/internal//androidx-main",
+ "path": "@1@"
+ }
+ },
+ {
+ "pattern": "frameworks/support/(.*)",
+ "vname": {
+ "path": "@1@"
+ }
+ },
+ {
+ "pattern": "(.*)",
+ "vname": {
+ "path": "@1@"
+ }
+ }
+]
\ No newline at end of file
diff --git a/busytown/androidx.sh b/busytown/androidx.sh
index 81e85ab..8c59791 100755
--- a/busytown/androidx.sh
+++ b/busytown/androidx.sh
@@ -25,6 +25,9 @@
-Pandroidx.constraints=true \
--no-daemon "$@"; then
EXIT_VALUE=1
+ else
+ # Run merge-kzips only if Gradle succeeds. Script merges kzips outputted by bOS task
+ busytown/impl/merge-kzips.sh || EXIT_VALUE=1
fi
# Parse performance profile reports (generated with the --profile option) and re-export
diff --git a/busytown/impl/merge-kzips.sh b/busytown/impl/merge-kzips.sh
new file mode 100755
index 0000000..100f04f
--- /dev/null
+++ b/busytown/impl/merge-kzips.sh
@@ -0,0 +1,60 @@
+#!/bin/bash
+#
+# 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.
+#
+#
+# Merge kzip files (source files for the indexing pipeline) for the given configuration, and place
+# the resulting all.kzip into $DIST_DIR.
+# Most code from:
+# https://cs.android.com/android/platform/superproject/main/+/main:build/soong/build_kzip.bash;
+
+set -e
+
+# Absolute path of the directory where this script lives
+SCRIPT_DIR="$(cd $(dirname $0) && pwd)"
+
+PREBUILTS_DIR=$SCRIPT_DIR/../../../../prebuilts
+
+cd "$SCRIPT_DIR/../.."
+if [ "$OUT_DIR" == "" ]; then
+ OUT_DIR="../../out"
+fi
+mkdir -p "$OUT_DIR"
+export OUT_DIR="$(cd $OUT_DIR && pwd)"
+if [ "$DIST_DIR" == "" ]; then
+ DIST_DIR="$OUT_DIR/dist"
+fi
+mkdir -p "$DIST_DIR"
+export DIST_DIR="$DIST_DIR"
+
+# Default KZIP_NAME to the latest Git commit hash
+: ${KZIP_NAME:=$(git rev-parse HEAD)}
+
+# Fallback to a UUID if Git commit hash is not there
+: ${KZIP_NAME:=$(uuidgen)}
+
+
+rm -rf $DIST_DIR/*.kzip
+declare -r allkzip="$KZIP_NAME.kzip"
+echo "Merging Kzips..."
+
+# Determine the directory based on OS
+if [[ "$(uname)" == "Darwin" ]]; then
+ BUILD_TOOLS_DIR="$PREBUILTS_DIR/build-tools/darwin-x86/bin"
+else
+ BUILD_TOOLS_DIR="$PREBUILTS_DIR/build-tools/linux-x86/bin"
+fi
+
+"$BUILD_TOOLS_DIR/merge_zips" "$DIST_DIR/$allkzip" @<(find "$OUT_DIR/androidx" -name '*.kzip')
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/CloseCameraDeviceOnCameraGraphCloseQuirk.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/CloseCameraDeviceOnCameraGraphCloseQuirk.kt
index 1d037c7..d63e7df 100644
--- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/CloseCameraDeviceOnCameraGraphCloseQuirk.kt
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/CloseCameraDeviceOnCameraGraphCloseQuirk.kt
@@ -21,13 +21,13 @@
import androidx.camera.core.impl.Quirk
/**
- * Quirk needed on devices where not closing the camera device before creating a new capture session
- * can lead to undesirable behaviors, such as native camera HAL crashes. On Exynos7870 platforms for
- * example, once their 3A pipeline times out, recreating a capture session has a high chance of
- * triggering use-after-free crashes.
+ * Quirk needed on devices where not closing the camera device can lead to undesirable behaviors,
+ * such as switching to a new session without closing the camera device may cause native camera HAL
+ * crashes, or the app getting "frozen" while CameraPipe awaits on a 1s cooldown to finally close
+ * the camera device.
*
* QuirkSummary
- * - Bug Id: 282871038
+ * - Bug Id: 282871038, 369300443
* - Description: Instructs CameraPipe to close the camera device before creating a new capture
* session to avoid undesirable behaviors
*
@@ -38,7 +38,21 @@
public companion object {
@JvmStatic
public fun isEnabled(): Boolean {
- return Build.HARDWARE == "samsungexynos7870"
+ if (Build.HARDWARE == "samsungexynos7870") {
+ // On Exynos7870 platforms, when their 3A pipeline times out, recreating a capture
+ // session has a high chance of triggering use-after-free crashes. Closing the
+ // camera device helps reduce the likelihood of this happening.
+ return true
+ } else if (
+ Build.VERSION.SDK_INT in Build.VERSION_CODES.R..Build.VERSION_CODES.TIRAMISU &&
+ (Device.isOppoDevice() || Device.isOnePlusDevice() || Device.isRealmeDevice())
+ ) {
+ // On Oppo-family devices from Android 11 to Android 13, a process called
+ // OplusHansManager actively "freezes" app processes, which means we cannot delay
+ // closing the camera device for any amount of time.
+ return true
+ }
+ return false
}
}
}
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/Device.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/Device.kt
index 908088f..9903ea5 100644
--- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/Device.kt
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/Device.kt
@@ -39,6 +39,8 @@
public fun isPositivoDevice(): Boolean = isDeviceFrom("Positivo")
+ public fun isRealmeDevice(): Boolean = isDeviceFrom("Realme")
+
public fun isRedmiDevice(): Boolean = isDeviceFrom("Redmi")
public fun isSamsungDevice(): Boolean = isDeviceFrom("Samsung")
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/workaround/SizeFilteredEncoderProfilesProvider.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/workaround/SizeFilteredEncoderProfilesProvider.kt
new file mode 100644
index 0000000..255a475
--- /dev/null
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/workaround/SizeFilteredEncoderProfilesProvider.kt
@@ -0,0 +1,105 @@
+/*
+ * 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.camera.camera2.pipe.integration.compat.workaround
+
+import android.media.CamcorderProfile.QUALITY_HIGH
+import android.media.CamcorderProfile.QUALITY_LOW
+import android.util.Size
+import androidx.camera.core.impl.EncoderProfilesProvider
+import androidx.camera.core.impl.EncoderProfilesProvider.QUALITY_HIGH_TO_LOW
+import androidx.camera.core.impl.EncoderProfilesProxy
+
+/**
+ * An [EncoderProfilesProvider] that filters profiles based on supported sizes.
+ *
+ * This class wraps another [EncoderProfilesProvider] and filters its output to only include
+ * profiles with resolutions that are supported by the camera, as indicated by the provided list of
+ * [supportedSizes].
+ */
+public class SizeFilteredEncoderProfilesProvider(
+ /** The original [EncoderProfilesProvider] to wrap. */
+ private val provider: EncoderProfilesProvider,
+
+ /** The list of supported sizes. */
+ private val supportedSizes: List<Size>
+) : EncoderProfilesProvider {
+
+ private val encoderProfilesCache = mutableMapOf<Int, EncoderProfilesProxy?>()
+
+ override fun hasProfile(quality: Int): Boolean {
+ return getAll(quality) != null
+ }
+
+ override fun getAll(quality: Int): EncoderProfilesProxy? {
+ if (!provider.hasProfile(quality)) {
+ return null
+ }
+
+ if (encoderProfilesCache.containsKey(quality)) {
+ return encoderProfilesCache[quality]
+ }
+
+ var profiles = provider.getAll(quality)
+ if (profiles != null && !isResolutionSupported(profiles)) {
+ profiles =
+ when (quality) {
+ QUALITY_HIGH -> findFirstAvailableProfile(QUALITY_HIGH_TO_LOW)
+ QUALITY_LOW -> findFirstAvailableProfile(QUALITY_HIGH_TO_LOW.reversed())
+ else -> null
+ }
+ }
+
+ encoderProfilesCache[quality] = profiles
+ return profiles
+ }
+
+ /**
+ * Checks if the resolution of the given [EncoderProfilesProxy] is supported.
+ *
+ * @param profiles The [EncoderProfilesProxy] to check.
+ * @return `true` if the resolution is supported, `false` otherwise.
+ */
+ private fun isResolutionSupported(profiles: EncoderProfilesProxy): Boolean {
+ if (supportedSizes.isEmpty() || profiles.videoProfiles.isEmpty()) {
+ return false
+ }
+
+ // cts/CamcorderProfileTest.java ensures all video profiles have the same size so we just
+ // need to check the first video profile.
+ val videoProfile = profiles.videoProfiles[0]
+ return supportedSizes.contains(Size(videoProfile.width, videoProfile.height))
+ }
+
+ /**
+ * Finds the first available profile based on the given quality order.
+ *
+ * This method iterates through the provided [qualityOrder] and returns the first profile that
+ * is available.
+ *
+ * @param qualityOrder The order of qualities to search.
+ * @return The first available [EncoderProfilesProxy], or `null` if no suitable profile is
+ * found.
+ */
+ private fun findFirstAvailableProfile(qualityOrder: List<Int>): EncoderProfilesProxy? {
+ for (quality in qualityOrder) {
+ getAll(quality)?.let {
+ return it
+ }
+ }
+ return null
+ }
+}
diff --git a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/compat/workaround/SizeFilteredEncoderProfilesProviderTest.kt b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/compat/workaround/SizeFilteredEncoderProfilesProviderTest.kt
new file mode 100644
index 0000000..ceccd91
--- /dev/null
+++ b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/compat/workaround/SizeFilteredEncoderProfilesProviderTest.kt
@@ -0,0 +1,83 @@
+/*
+ * 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.camera.camera2.pipe.integration.compat.workaround
+
+import android.media.CamcorderProfile.QUALITY_1080P
+import android.media.CamcorderProfile.QUALITY_2160P
+import android.media.CamcorderProfile.QUALITY_480P
+import android.media.CamcorderProfile.QUALITY_720P
+import android.media.CamcorderProfile.QUALITY_HIGH
+import android.media.CamcorderProfile.QUALITY_LOW
+import android.os.Build
+import androidx.camera.testing.impl.EncoderProfilesUtil.PROFILES_1080P
+import androidx.camera.testing.impl.EncoderProfilesUtil.PROFILES_2160P
+import androidx.camera.testing.impl.EncoderProfilesUtil.PROFILES_480P
+import androidx.camera.testing.impl.EncoderProfilesUtil.PROFILES_720P
+import androidx.camera.testing.impl.EncoderProfilesUtil.RESOLUTION_1080P
+import androidx.camera.testing.impl.EncoderProfilesUtil.RESOLUTION_720P
+import androidx.camera.testing.impl.fakes.FakeEncoderProfilesProvider
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.internal.DoNotInstrument
+
+@RunWith(RobolectricTestRunner::class)
+@DoNotInstrument
+@Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
+class SizeFilteredEncoderProfilesProviderTest {
+
+ private val profilesProvider =
+ FakeEncoderProfilesProvider.Builder()
+ .add(QUALITY_HIGH, PROFILES_2160P)
+ .add(QUALITY_2160P, PROFILES_2160P)
+ .add(QUALITY_1080P, PROFILES_1080P)
+ .add(QUALITY_720P, PROFILES_720P)
+ .add(QUALITY_480P, PROFILES_480P)
+ .add(QUALITY_LOW, PROFILES_480P)
+ .build()
+
+ private val supportedSizes = listOf(RESOLUTION_1080P, RESOLUTION_720P)
+
+ private val sizeFilteredProvider =
+ SizeFilteredEncoderProfilesProvider(profilesProvider, supportedSizes)
+
+ @Test
+ fun quality_shouldBeFilteredBySupportedSizes() {
+ assertThat(sizeFilteredProvider.hasProfile(QUALITY_2160P)).isFalse()
+ assertThat(sizeFilteredProvider.getAll(QUALITY_2160P)).isNull()
+
+ assertThat(sizeFilteredProvider.hasProfile(QUALITY_2160P)).isFalse()
+ assertThat(sizeFilteredProvider.getAll(QUALITY_2160P)).isNull()
+ assertThat(sizeFilteredProvider.hasProfile(QUALITY_1080P)).isTrue()
+ assertThat(sizeFilteredProvider.getAll(QUALITY_1080P)).isSameInstanceAs(PROFILES_1080P)
+ assertThat(sizeFilteredProvider.hasProfile(QUALITY_720P)).isTrue()
+ assertThat(sizeFilteredProvider.getAll(QUALITY_720P)).isSameInstanceAs(PROFILES_720P)
+ assertThat(sizeFilteredProvider.hasProfile(QUALITY_480P)).isFalse()
+ assertThat(sizeFilteredProvider.getAll(QUALITY_480P)).isNull()
+ }
+
+ @Test
+ fun qualityHighLow_shouldMapToCorrectProfiles() {
+ assertThat(sizeFilteredProvider.hasProfile(QUALITY_HIGH)).isTrue()
+ assertThat(sizeFilteredProvider.getAll(QUALITY_HIGH)).isSameInstanceAs(PROFILES_1080P)
+
+ assertThat(sizeFilteredProvider.hasProfile(QUALITY_LOW)).isTrue()
+ assertThat(sizeFilteredProvider.getAll(QUALITY_LOW)).isSameInstanceAs(PROFILES_720P)
+ }
+}
diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/compat/workaround/SizeFilteredEncoderProfilesProvider.kt b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/compat/workaround/SizeFilteredEncoderProfilesProvider.kt
new file mode 100644
index 0000000..1380215
--- /dev/null
+++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/compat/workaround/SizeFilteredEncoderProfilesProvider.kt
@@ -0,0 +1,105 @@
+/*
+ * 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.camera.camera2.internal.compat.workaround
+
+import android.media.CamcorderProfile.QUALITY_HIGH
+import android.media.CamcorderProfile.QUALITY_LOW
+import android.util.Size
+import androidx.camera.core.impl.EncoderProfilesProvider
+import androidx.camera.core.impl.EncoderProfilesProvider.QUALITY_HIGH_TO_LOW
+import androidx.camera.core.impl.EncoderProfilesProxy
+
+/**
+ * An [EncoderProfilesProvider] that filters profiles based on supported sizes.
+ *
+ * This class wraps another [EncoderProfilesProvider] and filters its output to only include
+ * profiles with resolutions that are supported by the camera, as indicated by the provided list of
+ * [supportedSizes].
+ */
+public class SizeFilteredEncoderProfilesProvider(
+ /** The original [EncoderProfilesProvider] to wrap. */
+ private val provider: EncoderProfilesProvider,
+
+ /** The list of supported sizes. */
+ private val supportedSizes: List<Size>
+) : EncoderProfilesProvider {
+
+ private val encoderProfilesCache = mutableMapOf<Int, EncoderProfilesProxy?>()
+
+ override fun hasProfile(quality: Int): Boolean {
+ return getAll(quality) != null
+ }
+
+ override fun getAll(quality: Int): EncoderProfilesProxy? {
+ if (!provider.hasProfile(quality)) {
+ return null
+ }
+
+ if (encoderProfilesCache.containsKey(quality)) {
+ return encoderProfilesCache[quality]
+ }
+
+ var profiles = provider.getAll(quality)
+ if (profiles != null && !isResolutionSupported(profiles)) {
+ profiles =
+ when (quality) {
+ QUALITY_HIGH -> findFirstAvailableProfile(QUALITY_HIGH_TO_LOW)
+ QUALITY_LOW -> findFirstAvailableProfile(QUALITY_HIGH_TO_LOW.reversed())
+ else -> null
+ }
+ }
+
+ encoderProfilesCache[quality] = profiles
+ return profiles
+ }
+
+ /**
+ * Checks if the resolution of the given [EncoderProfilesProxy] is supported.
+ *
+ * @param profiles The [EncoderProfilesProxy] to check.
+ * @return `true` if the resolution is supported, `false` otherwise.
+ */
+ private fun isResolutionSupported(profiles: EncoderProfilesProxy): Boolean {
+ if (supportedSizes.isEmpty() || profiles.videoProfiles.isEmpty()) {
+ return false
+ }
+
+ // cts/CamcorderProfileTest.java ensures all video profiles have the same size so we just
+ // need to check the first video profile.
+ val videoProfile = profiles.videoProfiles[0]
+ return supportedSizes.contains(Size(videoProfile.width, videoProfile.height))
+ }
+
+ /**
+ * Finds the first available profile based on the given quality order.
+ *
+ * This method iterates through the provided [qualityOrder] and returns the first profile that
+ * is available.
+ *
+ * @param qualityOrder The order of qualities to search.
+ * @return The first available [EncoderProfilesProxy], or `null` if no suitable profile is
+ * found.
+ */
+ private fun findFirstAvailableProfile(qualityOrder: List<Int>): EncoderProfilesProxy? {
+ for (quality in qualityOrder) {
+ getAll(quality)?.let {
+ return it
+ }
+ }
+ return null
+ }
+}
diff --git a/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/compat/workaround/SizeFilteredEncoderProfilesProviderTest.kt b/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/compat/workaround/SizeFilteredEncoderProfilesProviderTest.kt
new file mode 100644
index 0000000..7f7ae02
--- /dev/null
+++ b/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/compat/workaround/SizeFilteredEncoderProfilesProviderTest.kt
@@ -0,0 +1,83 @@
+/*
+ * 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.camera.camera2.internal.compat.workaround
+
+import android.media.CamcorderProfile.QUALITY_1080P
+import android.media.CamcorderProfile.QUALITY_2160P
+import android.media.CamcorderProfile.QUALITY_480P
+import android.media.CamcorderProfile.QUALITY_720P
+import android.media.CamcorderProfile.QUALITY_HIGH
+import android.media.CamcorderProfile.QUALITY_LOW
+import android.os.Build
+import androidx.camera.testing.impl.EncoderProfilesUtil.PROFILES_1080P
+import androidx.camera.testing.impl.EncoderProfilesUtil.PROFILES_2160P
+import androidx.camera.testing.impl.EncoderProfilesUtil.PROFILES_480P
+import androidx.camera.testing.impl.EncoderProfilesUtil.PROFILES_720P
+import androidx.camera.testing.impl.EncoderProfilesUtil.RESOLUTION_1080P
+import androidx.camera.testing.impl.EncoderProfilesUtil.RESOLUTION_720P
+import androidx.camera.testing.impl.fakes.FakeEncoderProfilesProvider
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.internal.DoNotInstrument
+
+@RunWith(RobolectricTestRunner::class)
+@DoNotInstrument
+@Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
+class SizeFilteredEncoderProfilesProviderTest {
+
+ private val profilesProvider =
+ FakeEncoderProfilesProvider.Builder()
+ .add(QUALITY_HIGH, PROFILES_2160P)
+ .add(QUALITY_2160P, PROFILES_2160P)
+ .add(QUALITY_1080P, PROFILES_1080P)
+ .add(QUALITY_720P, PROFILES_720P)
+ .add(QUALITY_480P, PROFILES_480P)
+ .add(QUALITY_LOW, PROFILES_480P)
+ .build()
+
+ private val supportedSizes = listOf(RESOLUTION_1080P, RESOLUTION_720P)
+
+ private val sizeFilteredProvider =
+ SizeFilteredEncoderProfilesProvider(profilesProvider, supportedSizes)
+
+ @Test
+ fun quality_shouldBeFilteredBySupportedSizes() {
+ assertThat(sizeFilteredProvider.hasProfile(QUALITY_2160P)).isFalse()
+ assertThat(sizeFilteredProvider.getAll(QUALITY_2160P)).isNull()
+
+ assertThat(sizeFilteredProvider.hasProfile(QUALITY_2160P)).isFalse()
+ assertThat(sizeFilteredProvider.getAll(QUALITY_2160P)).isNull()
+ assertThat(sizeFilteredProvider.hasProfile(QUALITY_1080P)).isTrue()
+ assertThat(sizeFilteredProvider.getAll(QUALITY_1080P)).isSameInstanceAs(PROFILES_1080P)
+ assertThat(sizeFilteredProvider.hasProfile(QUALITY_720P)).isTrue()
+ assertThat(sizeFilteredProvider.getAll(QUALITY_720P)).isSameInstanceAs(PROFILES_720P)
+ assertThat(sizeFilteredProvider.hasProfile(QUALITY_480P)).isFalse()
+ assertThat(sizeFilteredProvider.getAll(QUALITY_480P)).isNull()
+ }
+
+ @Test
+ fun qualityHighLow_shouldMapToCorrectProfiles() {
+ assertThat(sizeFilteredProvider.hasProfile(QUALITY_HIGH)).isTrue()
+ assertThat(sizeFilteredProvider.getAll(QUALITY_HIGH)).isSameInstanceAs(PROFILES_1080P)
+
+ assertThat(sizeFilteredProvider.hasProfile(QUALITY_LOW)).isTrue()
+ assertThat(sizeFilteredProvider.getAll(QUALITY_LOW)).isSameInstanceAs(PROFILES_720P)
+ }
+}
diff --git a/camera/integration-tests/coretestapp/src/main/java/androidx/camera/integration/core/CameraXActivity.java b/camera/integration-tests/coretestapp/src/main/java/androidx/camera/integration/core/CameraXActivity.java
index 0dc50be..46ef6e5 100644
--- a/camera/integration-tests/coretestapp/src/main/java/androidx/camera/integration/core/CameraXActivity.java
+++ b/camera/integration-tests/coretestapp/src/main/java/androidx/camera/integration/core/CameraXActivity.java
@@ -71,12 +71,12 @@
import android.provider.MediaStore;
import android.util.DisplayMetrics;
import android.util.Log;
-import android.util.Pair;
import android.util.Range;
import android.util.Rational;
import android.view.Display;
import android.view.GestureDetector;
import android.view.Menu;
+import android.view.MenuItem;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
@@ -130,9 +130,11 @@
import androidx.camera.core.ViewPort;
import androidx.camera.core.impl.CameraInfoInternal;
import androidx.camera.core.impl.Quirks;
+import androidx.camera.core.impl.StreamSpec;
import androidx.camera.core.impl.utils.AspectRatioUtil;
import androidx.camera.core.impl.utils.executor.CameraXExecutors;
import androidx.camera.lifecycle.ProcessCameraProvider;
+import androidx.camera.testing.impl.StreamSharingForceEnabledEffect;
import androidx.camera.video.ExperimentalPersistentRecording;
import androidx.camera.video.FileOutputOptions;
import androidx.camera.video.MediaStoreOutputOptions;
@@ -166,10 +168,12 @@
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collections;
+import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
+import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ExecutorService;
@@ -192,7 +196,12 @@
private static final String TAG = "CameraXActivity";
private static final String[] REQUIRED_PERMISSIONS;
private static final List<DynamicRangeUiData> DYNAMIC_RANGE_UI_DATA = new ArrayList<>();
- private static final List<Pair<Range<Integer>, String>> FPS_OPTIONS = new ArrayList<>();
+
+ // StreamSpec.FRAME_RATE_RANGE_UNSPECIFIED is not public
+ @SuppressLint("RestrictedApiAndroidX")
+ private static final Range<Integer> FPS_UNSPECIFIED = StreamSpec.FRAME_RATE_RANGE_UNSPECIFIED;
+ private static final Map<Integer, Range<Integer>> ID_TO_FPS_RANGE_MAP = new HashMap<>();
+ private static final Map<Integer, Integer> ID_TO_ASPECT_RATIO_MAP = new HashMap<>();
static {
// From Android T, skips the permission check of WRITE_EXTERNAL_STORAGE since it won't be
@@ -243,10 +252,14 @@
// `CameraInfo.getSupportedFrameRateRanges()`, but we may want to try unsupported cases too
// sometimes for testing, so the unsupported ones still should be options (perhaps greyed
// out or struck-through).
- FPS_OPTIONS.add(new Pair<>(new Range<>(0, 0), "Unspecified"));
- FPS_OPTIONS.add(new Pair<>(new Range<>(15, 15), "15"));
- FPS_OPTIONS.add(new Pair<>(new Range<>(30, 30), "30"));
- FPS_OPTIONS.add(new Pair<>(new Range<>(60, 60), "60"));
+ ID_TO_FPS_RANGE_MAP.put(R.id.fps_unspecified, FPS_UNSPECIFIED);
+ ID_TO_FPS_RANGE_MAP.put(R.id.fps_15, new Range<>(15, 15));
+ ID_TO_FPS_RANGE_MAP.put(R.id.fps_30, new Range<>(30, 30));
+ ID_TO_FPS_RANGE_MAP.put(R.id.fps_60, new Range<>(60, 60));
+
+ ID_TO_ASPECT_RATIO_MAP.put(R.id.aspect_ratio_default, AspectRatio.RATIO_DEFAULT);
+ ID_TO_ASPECT_RATIO_MAP.put(R.id.aspect_ratio_4_3, AspectRatio.RATIO_4_3);
+ ID_TO_ASPECT_RATIO_MAP.put(R.id.aspect_ratio_16_9, AspectRatio.RATIO_16_9);
}
//Use this activity title when Camera Pipe configuration is used by core test app
@@ -364,7 +377,6 @@
private Button mZoomIn2XToggle;
private Button mZoomResetToggle;
private Button mButtonImageOutputFormat;
- private Button mButtonFps;
private Toast mEvToast = null;
private Toast mPSToast = null;
private ToggleButton mPreviewStabilizationToggle;
@@ -381,7 +393,9 @@
private final Set<DynamicRange> mSelectableDynamicRanges = new HashSet<>();
private int mVideoMirrorMode = MIRROR_MODE_ON_FRONT_ONLY;
private boolean mIsPreviewStabilizationOn = false;
- private int mFpsMenuId = 0;
+ private Range<Integer> mFpsRange = FPS_UNSPECIFIED;
+ private boolean mForceEnableStreamSharing;
+ private boolean mDisableViewPort;
SessionMediaUriSet mSessionImagesUriSet = new SessionMediaUriSet();
SessionMediaUriSet mSessionVideosUriSet = new SessionMediaUriSet();
@@ -1288,7 +1302,6 @@
mZoomSeekBar.setVisibility(View.GONE);
mZoomRatioLabel.setVisibility(View.GONE);
mTextView.setVisibility(View.GONE);
- mButtonFps.setVisibility(View.GONE);
if (testCase.equals(PREVIEW_TEST_CASE) || testCase.equals(SWITCH_TEST_CASE)) {
mTorchButton.setVisibility(View.GONE);
@@ -1401,10 +1414,11 @@
mPlusEV.setEnabled(isExposureCompensationSupported());
mDecEV.setEnabled(isExposureCompensationSupported());
mZoomIn2XToggle.setEnabled(is2XZoomSupported());
- mButtonFps.setEnabled(mPreviewToggle.isChecked() || mVideoToggle.isChecked());
// this function may make some view visible again, so need to update for E2E tests again
updateAppUIForE2ETest();
+
+ invalidateOptionsMenu();
}
// Set or reset content description for e2e testing.
@@ -1605,42 +1619,6 @@
findViewById(R.id.video_mute),
(newState) -> updateDynamicRangeUiState()
);
- mButtonFps = findViewById(R.id.fps);
- if (mFpsMenuId == 0) {
- mButtonFps.setText("FPS\nUnsp.");
- } else {
- mButtonFps.setText("FPS\n" + FPS_OPTIONS.get(mFpsMenuId).second);
- }
- mButtonFps.setOnClickListener(view -> {
- PopupMenu popup = new PopupMenu(this, view);
- Menu menu = popup.getMenu();
-
- for (int i = 0; i < FPS_OPTIONS.size(); i++) {
- menu.add(0, i, Menu.NONE, FPS_OPTIONS.get(i).second);
- }
-
- menu.findItem(mFpsMenuId).setChecked(true);
-
- // Make menu single checkable
- menu.setGroupCheckable(0, true, true);
-
- popup.setOnMenuItemClickListener(item -> {
- int itemId = item.getItemId();
- if (itemId != mFpsMenuId) {
- mFpsMenuId = itemId;
- if (mFpsMenuId == 0) {
- mButtonFps.setText("FPS\nUnsp.");
- } else {
- mButtonFps.setText("FPS\n" + FPS_OPTIONS.get(mFpsMenuId).second);
- }
- // FPS changed, rebind UseCases
- tryBindUseCases();
- }
- return true;
- });
-
- popup.show();
- });
setUpButtonEvents();
setupViewFinderGestureControls();
@@ -1758,6 +1736,83 @@
setupPermissions();
}
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ getMenuInflater().inflate(R.menu.actionbar_menu, menu);
+ return true;
+ }
+
+ @Override
+ public boolean onPrepareOptionsMenu(Menu menu) {
+ updateMenuItems(menu);
+ return true;
+ }
+
+ private void updateMenuItems(Menu menu) {
+ menu.findItem(requireNonNull(getKeyByValue(ID_TO_FPS_RANGE_MAP, mFpsRange))).setChecked(
+ true);
+ menu.findItem(R.id.fps).setEnabled(mPreviewToggle.isChecked() || mVideoToggle.isChecked());
+
+ menu.findItem(requireNonNull(
+ getKeyByValue(ID_TO_ASPECT_RATIO_MAP, mTargetAspectRatio))).setChecked(true);
+
+ menu.findItem(R.id.stream_sharing).setChecked(mForceEnableStreamSharing);
+ // StreamSharing requires both Preview & VideoCapture use cases in core-test-app
+ // (since ImageCapture can't be added due to lack of effect)
+ menu.findItem(R.id.stream_sharing).setEnabled(
+ mPreviewToggle.isChecked() && mVideoToggle.isChecked());
+
+ menu.findItem(R.id.view_port).setChecked(mDisableViewPort);
+ }
+
+ private static <T, E> T getKeyByValue(Map<T, E> map, E value) {
+ for (Map.Entry<T, E> entry : map.entrySet()) {
+ if (Objects.equals(value, entry.getValue())) {
+ return entry.getKey();
+ }
+ }
+ return null; // No key found for the given value
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(@NonNull MenuItem item) {
+ // Handle item selection.
+ Log.d(TAG, "onOptionsItemSelected: item = " + item);
+
+ int groupId = item.getGroupId();
+ int itemId = item.getItemId();
+
+ if (groupId == R.id.fps_group) {
+ if (ID_TO_FPS_RANGE_MAP.containsKey(itemId)) {
+ mFpsRange = ID_TO_FPS_RANGE_MAP.get(itemId);
+ } else {
+ Log.e(TAG, "Unknown item " + item.getTitle());
+ return super.onOptionsItemSelected(item);
+ }
+ } else if (groupId == R.id.aspect_ratio_group) {
+ if (ID_TO_ASPECT_RATIO_MAP.containsKey(itemId)) {
+ mTargetAspectRatio = requireNonNull(ID_TO_ASPECT_RATIO_MAP.get(itemId));
+ } else {
+ Log.e(TAG, "Unknown item " + item.getTitle());
+ return super.onOptionsItemSelected(item);
+ }
+ } else if (itemId == R.id.stream_sharing) {
+ mForceEnableStreamSharing = !mForceEnableStreamSharing;
+ } else if (itemId == R.id.view_port) {
+ mDisableViewPort = !mDisableViewPort;
+ } else {
+ Log.d(TAG, "Not handling item " + item.getTitle());
+ return super.onOptionsItemSelected(item);
+ }
+
+ item.setChecked(!item.isChecked());
+
+ // Some configuration option may be changed, rebind UseCases
+ tryBindUseCases();
+
+ return super.onOptionsItemSelected(item);
+ }
+
/**
* Writes text data to a file in public external directory for reading during tests.
*/
@@ -1960,7 +2015,7 @@
.setPreviewStabilizationEnabled(mIsPreviewStabilizationOn)
.setDynamicRange(
mVideoToggle.isChecked() ? DynamicRange.UNSPECIFIED : mDynamicRange)
- .setTargetFrameRate(FPS_OPTIONS.get(mFpsMenuId).first)
+ .setTargetFrameRate(mFpsRange)
.build();
resetViewIdlingResource();
// Use the listener of the future to make sure the Preview setup the new surface.
@@ -1987,6 +2042,7 @@
if (mAnalysisToggle.isChecked()) {
ImageAnalysis imageAnalysis = new ImageAnalysis.Builder()
.setTargetName("ImageAnalysis")
+ .setTargetAspectRatio(mTargetAspectRatio)
.build();
useCases.add(imageAnalysis);
// Make the analysis idling resource non-idle, until the required frames received.
@@ -2005,11 +2061,11 @@
if (mVideoQuality != QUALITY_AUTO) {
builder.setQualitySelector(QualitySelector.from(mVideoQuality));
}
- mRecorder = builder.build();
+ mRecorder = builder.setAspectRatio(mTargetAspectRatio).build();
mVideoCapture = new VideoCapture.Builder<>(mRecorder)
.setMirrorMode(mVideoMirrorMode)
.setDynamicRange(mDynamicRange)
- .setTargetFrameRate(FPS_OPTIONS.get(mFpsMenuId).first)
+ .setTargetFrameRate(mFpsRange)
.build();
}
useCases.add(mVideoCapture);
@@ -2122,15 +2178,29 @@
* Binds use cases to the current lifecycle.
*/
private Camera bindToLifecycleSafely(List<UseCase> useCases) {
- ViewPort viewPort = new ViewPort.Builder(new Rational(mViewFinder.getWidth(),
- mViewFinder.getHeight()),
- mViewFinder.getDisplay().getRotation())
- .setScaleType(ViewPort.FILL_CENTER).build();
- UseCaseGroup.Builder useCaseGroupBuilder = new UseCaseGroup.Builder().setViewPort(
- viewPort);
+ Log.d(TAG, "bindToLifecycleSafely: mDisableViewPort = " + mDisableViewPort
+ + ", mForceEnableStreamSharing = " + mForceEnableStreamSharing);
+
+ UseCaseGroup.Builder useCaseGroupBuilder = new UseCaseGroup.Builder();
for (UseCase useCase : useCases) {
useCaseGroupBuilder.addUseCase(useCase);
}
+
+ if (!mDisableViewPort) {
+ ViewPort viewPort = new ViewPort.Builder(new Rational(mViewFinder.getWidth(),
+ mViewFinder.getHeight()),
+ mViewFinder.getDisplay().getRotation())
+ .setScaleType(ViewPort.FILL_CENTER).build();
+ useCaseGroupBuilder.setViewPort(viewPort);
+ }
+
+ // Force-enable stream sharing
+ if (mForceEnableStreamSharing) {
+ @SuppressLint("RestrictedApiAndroidX")
+ StreamSharingForceEnabledEffect effect = new StreamSharingForceEnabledEffect();
+ useCaseGroupBuilder.addEffect(effect);
+ }
+
mCamera = mCameraProvider.bindToLifecycle(this, mCurrentCameraSelector,
useCaseGroupBuilder.build());
setupZoomSeeker();
diff --git a/camera/integration-tests/coretestapp/src/main/res/layout/activity_camera_xmain.xml b/camera/integration-tests/coretestapp/src/main/res/layout/activity_camera_xmain.xml
index bc7e3db..c65182e 100644
--- a/camera/integration-tests/coretestapp/src/main/res/layout/activity_camera_xmain.xml
+++ b/camera/integration-tests/coretestapp/src/main/res/layout/activity_camera_xmain.xml
@@ -437,20 +437,6 @@
app:layout_constraintStart_toEndOf="@+id/seekBar"
app:layout_constraintTop_toTopOf="@+id/seekBar" />
- <Button
- android:id="@+id/fps"
- android:layout_width="46dp"
- android:layout_height="wrap_content"
- android:layout_marginStart="5dp"
- android:layout_marginTop="1dp"
- android:background="@android:drawable/btn_default"
- android:text="FPS"
- android:textSize="7dp"
- android:translationZ="1dp"
- app:layout_constraintTop_toBottomOf="@id/preview_stabilization"
- app:layout_constraintLeft_toLeftOf="parent"
- />
-
<androidx.camera.view.ScreenFlashView
android:id="@+id/screen_flash_view"
android:layout_width="match_parent"
diff --git a/camera/integration-tests/coretestapp/src/main/res/menu/actionbar_menu.xml b/camera/integration-tests/coretestapp/src/main/res/menu/actionbar_menu.xml
new file mode 100644
index 0000000..6e2c5fb
--- /dev/null
+++ b/camera/integration-tests/coretestapp/src/main/res/menu/actionbar_menu.xml
@@ -0,0 +1,82 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+ 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.
+ -->
+
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto">
+ <item
+ android:id="@+id/fps"
+ android:title="@string/fps"
+ app:showAsAction="never">
+ <menu>
+ <group
+ android:id="@+id/fps_group"
+ android:checkableBehavior="single">
+ <item
+ android:id="@+id/fps_unspecified"
+ android:checked="true"
+ android:title="Unspecified"
+ app:showAsAction="never" />
+ <item
+ android:id="@+id/fps_15"
+ android:title="15"
+ app:showAsAction="never" />
+ <item
+ android:id="@+id/fps_30"
+ android:title="30"
+ app:showAsAction="never" />
+ <item
+ android:id="@+id/fps_60"
+ android:title="60"
+ app:showAsAction="never" />
+ </group>
+ </menu>
+ </item>
+ <item
+ android:id="@+id/aspect_ratio"
+ android:title="@string/aspect_ratio"
+ app:showAsAction="never">
+ <menu>
+ <group
+ android:id="@+id/aspect_ratio_group"
+ android:checkableBehavior="single">
+ <item
+ android:id="@+id/aspect_ratio_default"
+ android:checked="true"
+ android:title="Default"
+ app:showAsAction="never" />
+ <item
+ android:id="@+id/aspect_ratio_4_3"
+ android:title="4:3"
+ app:showAsAction="never" />
+ <item
+ android:id="@+id/aspect_ratio_16_9"
+ android:title="16:9"
+ app:showAsAction="never" />
+ </group>
+ </menu>
+ </item>
+ <item
+ android:id="@+id/stream_sharing"
+ android:checkable="true"
+ android:title="@string/force_stream_sharing"
+ app:showAsAction="never" />
+ <item
+ android:id="@+id/view_port"
+ android:checkable="true"
+ android:title="@string/disable_view_port"
+ app:showAsAction="never" />
+
+</menu>
diff --git a/camera/integration-tests/coretestapp/src/main/res/values/donottranslate-strings.xml b/camera/integration-tests/coretestapp/src/main/res/values/donottranslate-strings.xml
index 949bfd4..34828bd 100644
--- a/camera/integration-tests/coretestapp/src/main/res/values/donottranslate-strings.xml
+++ b/camera/integration-tests/coretestapp/src/main/res/values/donottranslate-strings.xml
@@ -47,5 +47,9 @@
<string name="toggle_video_dyn_rng_hdr_dolby_vision_10">Dlby\n10bit</string>
<string name="toggle_video_dyn_rng_hdr_dolby_vision_8">Dlby\n8bit</string>
<string name="toggle_video_dyn_rng_unknown">\?</string>
+ <string name="fps">FPS</string>
+ <string name="aspect_ratio">Aspect Ratio</string>
+ <string name="force_stream_sharing">Force Stream Sharing</string>
+ <string name="disable_view_port">Disable View Port</string>
</resources>
\ No newline at end of file
diff --git a/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/templatelayouts/listtemplates/SecondaryActionsAndDecorationDemoScreen.java b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/templatelayouts/listtemplates/SecondaryActionsAndDecorationDemoScreen.java
index 87d420b..5d57c7c 100644
--- a/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/templatelayouts/listtemplates/SecondaryActionsAndDecorationDemoScreen.java
+++ b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/templatelayouts/listtemplates/SecondaryActionsAndDecorationDemoScreen.java
@@ -58,6 +58,11 @@
12,
action));
+ listBuilder.addItem(buildRowForTemplate(
+ R.string.secondary_actions_decoration_test_title_long,
+ 9,
+ action));
+
return new ListTemplate.Builder()
.setSingleList(listBuilder.build())
.setHeader(new Header.Builder()
diff --git a/car/app/app-samples/showcase/common/src/main/res/values/strings.xml b/car/app/app-samples/showcase/common/src/main/res/values/strings.xml
index f8c1adf3..5ea9f40 100644
--- a/car/app/app-samples/showcase/common/src/main/res/values/strings.xml
+++ b/car/app/app-samples/showcase/common/src/main/res/values/strings.xml
@@ -408,6 +408,7 @@
<string name="secondary_actions_test_subtitle">Only the secondary action can be selected</string>
<string name="decoration_test_title">Decoration Test</string>
<string name="secondary_actions_decoration_test_title">Secondary Actions and Decoration</string>
+ <string name="secondary_actions_decoration_test_title_long">Row with Secondary Actions and Decoration with a really long title</string>
<string name="secondary_actions_decoration_test_subtitle">The row can also be selected</string>
<string name="secondary_action_toast">Secondary Action is selected</string>
<string name="row_primary_action_toast">Row primary action is selected</string>
diff --git a/compose/animation/animation/src/androidInstrumentedTest/kotlin/androidx/compose/animation/SharedTransitionTest.kt b/compose/animation/animation/src/androidInstrumentedTest/kotlin/androidx/compose/animation/SharedTransitionTest.kt
index f4967da..c02221fe 100644
--- a/compose/animation/animation/src/androidInstrumentedTest/kotlin/androidx/compose/animation/SharedTransitionTest.kt
+++ b/compose/animation/animation/src/androidInstrumentedTest/kotlin/androidx/compose/animation/SharedTransitionTest.kt
@@ -77,6 +77,7 @@
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.ScaleFactor
+import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.layout.approachLayout
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onPlaced
@@ -116,6 +117,7 @@
@LargeTest
class SharedTransitionTest {
val rule = createComposeRule()
+
// Detect leaks BEFORE and AFTER compose rule work
@get:Rule
val ruleChain: RuleChain = RuleChain.outerRule(DetectLeaksAfterTestSuccess()).around(rule)
@@ -2862,6 +2864,38 @@
// Transition into a Red box
clickAndAssertColorDuringTransition(Color.Red)
}
+
+ @Test
+ fun foundMatchedElementButNeverMeasured() {
+ var target by mutableStateOf(true)
+ rule.setContent {
+ SharedTransitionLayout {
+ AnimatedContent(target) {
+ SubcomposeLayout {
+ subcompose(0) {
+ Box(
+ Modifier.sharedBounds(
+ rememberSharedContentState("test"),
+ animatedVisibilityScope = this@AnimatedContent
+ )
+ .size(200.dp)
+ .background(Color.Red)
+ )
+ }
+ // Skip measure and return size
+ layout(200, 200) {}
+ }
+ Box(Modifier.size(200.dp).background(Color.Black))
+ }
+ }
+ }
+
+ rule.waitForIdle()
+ rule.mainClock.autoAdvance = false
+ target = !target
+ rule.mainClock.advanceTimeByFrame()
+ rule.waitForIdle()
+ }
}
private fun assertEquals(a: IntSize, b: IntSize, delta: IntSize) {
diff --git a/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SharedContentNode.kt b/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SharedContentNode.kt
index 8d6b5e6..8022fd5 100644
--- a/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SharedContentNode.kt
+++ b/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SharedContentNode.kt
@@ -160,7 +160,7 @@
}
}
- private fun MeasureScope.place(placeable: Placeable): MeasureResult {
+ private fun MeasureScope.approachPlace(placeable: Placeable): MeasureResult {
if (!sharedElement.foundMatch) {
// No match
return layout(placeable.width, placeable.height) {
@@ -231,7 +231,7 @@
} ?: constraints
}
val placeable = measurable.measure(resolvedConstraints)
- return place(placeable)
+ return approachPlace(placeable)
}
private fun LayoutCoordinates.updateCurrentBounds() {
@@ -243,6 +243,7 @@
}
override fun ContentDrawScope.draw() {
+ state.firstFrameDrawn = true
// Update clipPath
state.clipPathInOverlay =
state.overlayClip.getClipPath(
diff --git a/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SharedElement.kt b/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SharedElement.kt
index afe96a9..36de972 100644
--- a/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SharedElement.kt
+++ b/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SharedElement.kt
@@ -170,6 +170,7 @@
zIndex: Float
) : LayerRenderer, RememberObserver {
+ internal var firstFrameDrawn: Boolean = false
override var zIndex: Float by mutableFloatStateOf(zIndex)
var renderInOverlayDuringTransition: Boolean by mutableStateOf(renderInOverlayDuringTransition)
@@ -184,7 +185,10 @@
override fun drawInOverlay(drawScope: DrawScope) {
val layer = layer ?: return
- if (shouldRenderInOverlay) {
+ // It is important to check that the first frame is drawn. In some cases shared content may
+ // be composed, but never measured, placed or drawn. In those cases, we will not have
+ // valid content to draw, therefore we need to skip drawing in overlay.
+ if (firstFrameDrawn && shouldRenderInOverlay) {
with(drawScope) {
requireNotNull(sharedElement.currentBounds) { "Error: current bounds not set yet." }
val (x, y) = sharedElement.currentBounds?.topLeft!!
diff --git a/compose/foundation/foundation/api/1.8.0-beta01.txt b/compose/foundation/foundation/api/1.8.0-beta01.txt
index a5dc1dc..4ea18b7 100644
--- a/compose/foundation/foundation/api/1.8.0-beta01.txt
+++ b/compose/foundation/foundation/api/1.8.0-beta01.txt
@@ -1695,21 +1695,9 @@
field public static final androidx.compose.foundation.text.AutoSizeDefaults INSTANCE;
}
- @kotlin.jvm.JvmInline public final value class AutofillHighlight {
- ctor public AutofillHighlight(long autofillHighlightColor);
- method public long getAutofillHighlightColor();
- property public final long autofillHighlightColor;
- field public static final androidx.compose.foundation.text.AutofillHighlight.Companion Companion;
- }
-
- public static final class AutofillHighlight.Companion {
- method public long getDefault();
- property public final long Default;
- }
-
public final class AutofillHighlightKt {
- method public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.foundation.text.AutofillHighlight> getLocalAutofillHighlight();
- property public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.foundation.text.AutofillHighlight> LocalAutofillHighlight;
+ method public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.graphics.Color> getLocalAutofillHighlightColor();
+ property public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.graphics.Color> LocalAutofillHighlightColor;
}
public final class BasicSecureTextFieldKt {
diff --git a/compose/foundation/foundation/api/current.ignore b/compose/foundation/foundation/api/current.ignore
index 0bfc96d..ac64bb7 100644
--- a/compose/foundation/foundation/api/current.ignore
+++ b/compose/foundation/foundation/api/current.ignore
@@ -1,7 +1,11 @@
// Baseline format: 1.0
-AddedMethod: androidx.compose.foundation.OverscrollKt#withoutVisualEffect(androidx.compose.foundation.OverscrollEffect):
- Added method androidx.compose.foundation.OverscrollKt.withoutVisualEffect(androidx.compose.foundation.OverscrollEffect)
+AddedMethod: androidx.compose.foundation.text.AutofillHighlightKt#getLocalAutofillHighlightColor():
+ Added method androidx.compose.foundation.text.AutofillHighlightKt.getLocalAutofillHighlightColor()
-RemovedMethod: androidx.compose.foundation.OverscrollKt#withoutDrawing(androidx.compose.foundation.OverscrollEffect):
- Removed method androidx.compose.foundation.OverscrollKt.withoutDrawing(androidx.compose.foundation.OverscrollEffect)
+RemovedClass: androidx.compose.foundation.text.AutofillHighlight_androidKt:
+ Removed class androidx.compose.foundation.text.AutofillHighlight_androidKt
+
+
+RemovedMethod: androidx.compose.foundation.text.AutofillHighlightKt#getLocalAutofillHighlight():
+ Removed method androidx.compose.foundation.text.AutofillHighlightKt.getLocalAutofillHighlight()
diff --git a/compose/foundation/foundation/api/current.txt b/compose/foundation/foundation/api/current.txt
index a5dc1dc..4ea18b7 100644
--- a/compose/foundation/foundation/api/current.txt
+++ b/compose/foundation/foundation/api/current.txt
@@ -1695,21 +1695,9 @@
field public static final androidx.compose.foundation.text.AutoSizeDefaults INSTANCE;
}
- @kotlin.jvm.JvmInline public final value class AutofillHighlight {
- ctor public AutofillHighlight(long autofillHighlightColor);
- method public long getAutofillHighlightColor();
- property public final long autofillHighlightColor;
- field public static final androidx.compose.foundation.text.AutofillHighlight.Companion Companion;
- }
-
- public static final class AutofillHighlight.Companion {
- method public long getDefault();
- property public final long Default;
- }
-
public final class AutofillHighlightKt {
- method public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.foundation.text.AutofillHighlight> getLocalAutofillHighlight();
- property public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.foundation.text.AutofillHighlight> LocalAutofillHighlight;
+ method public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.graphics.Color> getLocalAutofillHighlightColor();
+ property public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.graphics.Color> LocalAutofillHighlightColor;
}
public final class BasicSecureTextFieldKt {
diff --git a/compose/foundation/foundation/api/restricted_1.8.0-beta01.txt b/compose/foundation/foundation/api/restricted_1.8.0-beta01.txt
index 1b03797..b5e271b 100644
--- a/compose/foundation/foundation/api/restricted_1.8.0-beta01.txt
+++ b/compose/foundation/foundation/api/restricted_1.8.0-beta01.txt
@@ -1697,21 +1697,9 @@
field public static final androidx.compose.foundation.text.AutoSizeDefaults INSTANCE;
}
- @kotlin.jvm.JvmInline public final value class AutofillHighlight {
- ctor public AutofillHighlight(long autofillHighlightColor);
- method public long getAutofillHighlightColor();
- property public final long autofillHighlightColor;
- field public static final androidx.compose.foundation.text.AutofillHighlight.Companion Companion;
- }
-
- public static final class AutofillHighlight.Companion {
- method public long getDefault();
- property public final long Default;
- }
-
public final class AutofillHighlightKt {
- method public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.foundation.text.AutofillHighlight> getLocalAutofillHighlight();
- property public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.foundation.text.AutofillHighlight> LocalAutofillHighlight;
+ method public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.graphics.Color> getLocalAutofillHighlightColor();
+ property public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.graphics.Color> LocalAutofillHighlightColor;
}
public final class BasicSecureTextFieldKt {
diff --git a/compose/foundation/foundation/api/restricted_current.ignore b/compose/foundation/foundation/api/restricted_current.ignore
index 0bfc96d..ac64bb7 100644
--- a/compose/foundation/foundation/api/restricted_current.ignore
+++ b/compose/foundation/foundation/api/restricted_current.ignore
@@ -1,7 +1,11 @@
// Baseline format: 1.0
-AddedMethod: androidx.compose.foundation.OverscrollKt#withoutVisualEffect(androidx.compose.foundation.OverscrollEffect):
- Added method androidx.compose.foundation.OverscrollKt.withoutVisualEffect(androidx.compose.foundation.OverscrollEffect)
+AddedMethod: androidx.compose.foundation.text.AutofillHighlightKt#getLocalAutofillHighlightColor():
+ Added method androidx.compose.foundation.text.AutofillHighlightKt.getLocalAutofillHighlightColor()
-RemovedMethod: androidx.compose.foundation.OverscrollKt#withoutDrawing(androidx.compose.foundation.OverscrollEffect):
- Removed method androidx.compose.foundation.OverscrollKt.withoutDrawing(androidx.compose.foundation.OverscrollEffect)
+RemovedClass: androidx.compose.foundation.text.AutofillHighlight_androidKt:
+ Removed class androidx.compose.foundation.text.AutofillHighlight_androidKt
+
+
+RemovedMethod: androidx.compose.foundation.text.AutofillHighlightKt#getLocalAutofillHighlight():
+ Removed method androidx.compose.foundation.text.AutofillHighlightKt.getLocalAutofillHighlight()
diff --git a/compose/foundation/foundation/api/restricted_current.txt b/compose/foundation/foundation/api/restricted_current.txt
index 1b03797..b5e271b 100644
--- a/compose/foundation/foundation/api/restricted_current.txt
+++ b/compose/foundation/foundation/api/restricted_current.txt
@@ -1697,21 +1697,9 @@
field public static final androidx.compose.foundation.text.AutoSizeDefaults INSTANCE;
}
- @kotlin.jvm.JvmInline public final value class AutofillHighlight {
- ctor public AutofillHighlight(long autofillHighlightColor);
- method public long getAutofillHighlightColor();
- property public final long autofillHighlightColor;
- field public static final androidx.compose.foundation.text.AutofillHighlight.Companion Companion;
- }
-
- public static final class AutofillHighlight.Companion {
- method public long getDefault();
- property public final long Default;
- }
-
public final class AutofillHighlightKt {
- method public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.foundation.text.AutofillHighlight> getLocalAutofillHighlight();
- property public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.foundation.text.AutofillHighlight> LocalAutofillHighlight;
+ method public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.graphics.Color> getLocalAutofillHighlightColor();
+ property public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.graphics.Color> LocalAutofillHighlightColor;
}
public final class BasicSecureTextFieldKt {
diff --git a/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/grid/LazyGridPrefetcherTest.kt b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/grid/LazyGridPrefetcherTest.kt
index d9870be..eaddcce 100644
--- a/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/grid/LazyGridPrefetcherTest.kt
+++ b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/grid/LazyGridPrefetcherTest.kt
@@ -104,7 +104,6 @@
rule.runOnIdle { runBlocking { state.scrollBy(5f) } }
waitForPrefetch()
- waitForPrefetch()
rule.onNodeWithTag("4").assertExists()
rule.onNodeWithTag("5").assertExists()
@@ -118,7 +117,6 @@
rule.runOnIdle { runBlocking { state.scrollBy(-5f) } }
waitForPrefetch()
- waitForPrefetch()
rule.onNodeWithTag("2").assertExists()
rule.onNodeWithTag("3").assertExists()
@@ -132,7 +130,6 @@
rule.runOnIdle { runBlocking { state.scrollBy(5f) } }
waitForPrefetch()
- waitForPrefetch()
rule.onNodeWithTag("6").assertExists()
rule.onNodeWithTag("7").assertExists()
@@ -146,7 +143,6 @@
}
waitForPrefetch()
- waitForPrefetch()
rule.onNodeWithTag("0").assertExists()
rule.onNodeWithTag("1").assertExists()
diff --git a/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/layout/TestPrefetchScheduler.kt b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/layout/TestPrefetchScheduler.kt
index c490175..1916efc 100644
--- a/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/layout/TestPrefetchScheduler.kt
+++ b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/layout/TestPrefetchScheduler.kt
@@ -28,8 +28,11 @@
}
fun executeActiveRequests() {
- activeRequests.forEach { with(it) { scope.execute() } }
- activeRequests.clear()
+ while (activeRequests.isNotEmpty()) {
+ val request = activeRequests[0]
+ val hasMoreWorkToDo = with(request) { scope.execute() }
+ if (!hasMoreWorkToDo) activeRequests.removeAt(0)
+ }
}
private val scope =
diff --git a/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridPrefetcherTest.kt b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridPrefetcherTest.kt
index 192ec6a..77687db 100644
--- a/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridPrefetcherTest.kt
+++ b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridPrefetcherTest.kt
@@ -448,7 +448,6 @@
}
waitForPrefetch()
- waitForPrefetch()
// ┌─┬─┐
// │2├─┤
diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/AutofillHighlight.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/AutofillHighlight.android.kt
index 14c0130..29cb6c1 100644
--- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/AutofillHighlight.android.kt
+++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/AutofillHighlight.android.kt
@@ -19,20 +19,8 @@
import androidx.compose.ui.graphics.Color
/**
- * Represents the colors used for text selection by text and text field components.
- *
- * See [LocalAutofillHighlight] to provide new values for this throughout the hierarchy.
- *
- * @property autofillHighlightColor the color used to draw the background behind autofilled
- * elements.
+ * Returns the color used to indicate Autofill has been performed on fillable components on Android.
*/
-@JvmInline
-actual value class AutofillHighlight actual constructor(actual val autofillHighlightColor: Color) {
- actual companion object {
- /** Default color used is framework's "autofilled_highlight" color. */
- private val DefaultAutofillColor = Color(0x4dffeb3b)
-
- /** Default instance of [AutofillHighlight]. */
- actual val Default = AutofillHighlight(DefaultAutofillColor)
- }
+internal actual fun autofillHighlightColor(): Color {
+ return Color(0x4dffeb3b)
}
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/AutofillHighlight.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/AutofillHighlight.kt
index 7672491..47afc00c 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/AutofillHighlight.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/AutofillHighlight.kt
@@ -18,23 +18,15 @@
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color
-import kotlin.jvm.JvmInline
/**
- * Represents the colors used for text selection by text and text field components.
+ * Returns the color used to indicate Autofill has been performed on fillable components.
*
- * See [LocalAutofillHighlight] to provide new values for this throughout the hierarchy.
- *
- * @property autofillHighlightColor the color used to draw the background behind autofilled
- * elements.
+ * See [LocalAutofillHighlightColor] to provide new values for this throughout the hierarchy.
*/
-@JvmInline
-expect value class AutofillHighlight(val autofillHighlightColor: Color) {
- companion object {
- /** Default instance of [AutofillHighlight]. */
- val Default: AutofillHighlight
- }
-}
+internal expect fun autofillHighlightColor(): Color
-/** CompositionLocal used to change the [AutofillHighlight] used by components in the hierarchy. */
-val LocalAutofillHighlight = compositionLocalOf { AutofillHighlight.Default }
+/**
+ * CompositionLocal used to change the [autofillHighlightColor] used by components in the hierarchy.
+ */
+val LocalAutofillHighlightColor = compositionLocalOf { autofillHighlightColor() }
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt
index 2f2be03..509d360 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt
@@ -673,11 +673,11 @@
}
}
- val autofillHighlight = LocalAutofillHighlight.current
+ val autofillHighlightColor = LocalAutofillHighlightColor.current
val drawDecorationModifier =
Modifier.drawBehind {
if (state.autofillHighlightOn || state.justAutofilled) {
- drawRect(color = autofillHighlight.autofillHighlightColor)
+ drawRect(color = autofillHighlightColor)
}
}
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldDecoratorModifier.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldDecoratorModifier.kt
index af7ade2..2f590be 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldDecoratorModifier.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldDecoratorModifier.kt
@@ -29,7 +29,7 @@
import androidx.compose.foundation.text.Handle
import androidx.compose.foundation.text.KeyboardActionScope
import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.foundation.text.LocalAutofillHighlight
+import androidx.compose.foundation.text.LocalAutofillHighlightColor
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.KeyboardActionHandler
import androidx.compose.foundation.text.input.internal.selection.TextFieldSelectionState
@@ -386,7 +386,7 @@
// Autofill highlight is drawn on top of the content — this way the coloring appears over
// any Material background applied.
if (autofillHighlightOn) {
- drawRect(color = currentValueOf(LocalAutofillHighlight).autofillHighlightColor)
+ drawRect(color = currentValueOf(LocalAutofillHighlightColor))
}
}
diff --git a/compose/foundation/foundation/src/commonStubsMain/kotlin/androidx/compose/foundation/text/AutofillHighlight.commonStubs.kt b/compose/foundation/foundation/src/commonStubsMain/kotlin/androidx/compose/foundation/text/AutofillHighlight.commonStubs.kt
index b423b1f..178f0ec 100644
--- a/compose/foundation/foundation/src/commonStubsMain/kotlin/androidx/compose/foundation/text/AutofillHighlight.commonStubs.kt
+++ b/compose/foundation/foundation/src/commonStubsMain/kotlin/androidx/compose/foundation/text/AutofillHighlight.commonStubs.kt
@@ -16,22 +16,8 @@
package androidx.compose.foundation.text
+import androidx.compose.foundation.implementedInJetBrainsFork
import androidx.compose.ui.graphics.Color
-import kotlin.jvm.JvmInline
-/**
- * Represents the colors used for text selection by text and text field components.
- *
- * See [LocalAutofillHighlight] to provide new values for this throughout the hierarchy.
- *
- * @property autofillHighlightColor the color used to draw the background behind autofilled
- * elements.
- */
-@JvmInline
-actual value class AutofillHighlight actual constructor(actual val autofillHighlightColor: Color) {
- actual companion object {
- /** Default instance of [AutofillHighlight]. */
- actual val Default: AutofillHighlight
- get() = TODO("Not yet implemented")
- }
-}
+/** Returns the color used to indicate Autofill has been performed on fillable components. */
+internal actual fun autofillHighlightColor(): Color = implementedInJetBrainsFork()
diff --git a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/ColorUtilTest.kt b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/ColorUtilTest.kt
index c308447..ea61501 100644
--- a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/ColorUtilTest.kt
+++ b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/ColorUtilTest.kt
@@ -20,9 +20,10 @@
import androidx.compose.material3.internal.colorUtil.CamUtils.lstarFromInt
import androidx.compose.material3.internal.colorUtil.CamUtils.yFromLstar
import androidx.compose.material3.internal.colorUtil.Frame
+import androidx.compose.material3.tokens.ColorLightTokens
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.filters.LargeTest
import androidx.test.filters.SmallTest
import com.google.common.truth.Truth.assertThat
import org.junit.Assert.assertEquals
@@ -330,10 +331,31 @@
assertEquals(0.789f, vc.flRoot, 0.001f)
}
- @LargeTest
@Test
fun testHctReflexivity() {
- for (i in 0..0x00ffffff) {
+ val baseline =
+ listOf(
+ SURFACE,
+ SURFACE_CONTAINER,
+ ON_SURFACE,
+ PRIMARY,
+ ON_PRIMARY,
+ SECONDARY,
+ ON_SECONDARY,
+ TERTIARY,
+ ON_TERTIARY,
+ PRIMARY_CONTAINER,
+ ON_PRIMARY_CONTAINER,
+ SECONDARY_CONTAINER,
+ ON_SECONDARY_CONTAINER,
+ TERTIARY_CONTAINER,
+ ON_TERTIARY_CONTAINER,
+ ERROR,
+ ON_ERROR,
+ ERROR_CONTAINER,
+ ON_ERROR_CONTAINER,
+ )
+ for (i in baseline) {
val color = -0x1000000 or i
val hct: Cam = Cam.fromInt(color)
val reconstructedFromHct: Int = Cam.getInt(hct.hue, hct.chroma, lstarFromInt(color))
@@ -357,6 +379,26 @@
const val RED: Int = -0x10000
const val GREEN: Int = -0xff0100
const val BLUE: Int = -0xffff01
+
+ val SURFACE: Int = ColorLightTokens.Surface.toArgb()
+ val SURFACE_CONTAINER: Int = ColorLightTokens.SurfaceContainer.toArgb()
+ val ON_SURFACE: Int = ColorLightTokens.OnSurface.toArgb()
+ val PRIMARY: Int = ColorLightTokens.Primary.toArgb()
+ val ON_PRIMARY: Int = ColorLightTokens.OnPrimary.toArgb()
+ val SECONDARY: Int = ColorLightTokens.Secondary.toArgb()
+ val ON_SECONDARY: Int = ColorLightTokens.OnSecondary.toArgb()
+ val TERTIARY: Int = ColorLightTokens.Tertiary.toArgb()
+ val ON_TERTIARY: Int = ColorLightTokens.OnTertiary.toArgb()
+ val PRIMARY_CONTAINER: Int = ColorLightTokens.PrimaryContainer.toArgb()
+ val ON_PRIMARY_CONTAINER: Int = ColorLightTokens.OnPrimaryContainer.toArgb()
+ val SECONDARY_CONTAINER: Int = ColorLightTokens.SecondaryContainer.toArgb()
+ val ON_SECONDARY_CONTAINER: Int = ColorLightTokens.OnSecondaryContainer.toArgb()
+ val TERTIARY_CONTAINER: Int = ColorLightTokens.TertiaryContainer.toArgb()
+ val ON_TERTIARY_CONTAINER: Int = ColorLightTokens.OnTertiaryContainer.toArgb()
+ val ERROR: Int = ColorLightTokens.Error.toArgb()
+ val ON_ERROR: Int = ColorLightTokens.OnError.toArgb()
+ val ERROR_CONTAINER: Int = ColorLightTokens.ErrorContainer.toArgb()
+ val ON_ERROR_CONTAINER: Int = ColorLightTokens.OnErrorContainer.toArgb()
}
private fun assertColorWithinTolerance(expected: Color, actual: Color, tolerance: Float = 1f) {
diff --git a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/DateInputTest.kt b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/DateInputTest.kt
index 08375be..14f5347 100644
--- a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/DateInputTest.kt
+++ b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/DateInputTest.kt
@@ -130,7 +130,7 @@
delayCompleted = true
}
}
- rule.waitUntil("Waiting for focus", 3_000L) { delayCompleted }
+ rule.waitUntil("Waiting for focus", 5_000L) { delayCompleted }
rule.onNodeWithText("05/11/2010").assertIsFocused()
}
@@ -155,7 +155,7 @@
delayCompleted = true
}
}
- rule.waitUntil("Waiting for delay completion", 3_000L) { delayCompleted }
+ rule.waitUntil("Waiting for delay completion", 5_000L) { delayCompleted }
rule.onNodeWithText("05/11/2010").assertIsNotFocused()
}
diff --git a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/DateRangeInputTest.kt b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/DateRangeInputTest.kt
index b6665d9..5085715 100644
--- a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/DateRangeInputTest.kt
+++ b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/DateRangeInputTest.kt
@@ -147,7 +147,7 @@
delayCompleted = true
}
}
- rule.waitUntil("Waiting for focus", 3_000L) { delayCompleted }
+ rule.waitUntil("Waiting for focus", 5_000L) { delayCompleted }
rule.onNodeWithText("05/11/2010").assertIsFocused()
rule.onNodeWithText("10/20/2020").assertIsNotFocused()
}
@@ -177,7 +177,7 @@
delayCompleted = true
}
}
- rule.waitUntil("Waiting for delay completion", 3_000L) { delayCompleted }
+ rule.waitUntil("Waiting for delay completion", 5_000L) { delayCompleted }
rule.onNodeWithText("05/11/2010").assertIsNotFocused()
rule.onNodeWithText("10/20/2020").assertIsNotFocused()
}
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ModalBottomSheet.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ModalBottomSheet.kt
index 995fe3f..a37aae1 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ModalBottomSheet.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ModalBottomSheet.kt
@@ -277,62 +277,11 @@
Surface(
modifier =
- modifier
+ Modifier.bottomSheetNestedScroll(sheetGesturesEnabled, sheetState, settleToDismiss)
+ .bottomSheetDraggableAnchors(sheetGesturesEnabled, sheetState, settleToDismiss)
.align(Alignment.TopCenter)
.widthIn(max = sheetMaxWidth)
.fillMaxWidth()
- .then(
- if (sheetGesturesEnabled)
- Modifier.nestedScroll(
- remember(sheetState) {
- ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
- sheetState = sheetState,
- orientation = Orientation.Vertical,
- onFling = settleToDismiss
- )
- }
- )
- else Modifier
- )
- .draggableAnchors(sheetState.anchoredDraggableState, Orientation.Vertical) {
- sheetSize,
- constraints ->
- val fullHeight = constraints.maxHeight.toFloat()
- val newAnchors = DraggableAnchors {
- Hidden at fullHeight
- if (
- sheetSize.height > (fullHeight / 2) && !sheetState.skipPartiallyExpanded
- ) {
- PartiallyExpanded at fullHeight / 2f
- }
- if (sheetSize.height != 0) {
- Expanded at max(0f, fullHeight - sheetSize.height)
- }
- }
- val newTarget =
- when (sheetState.anchoredDraggableState.targetValue) {
- Hidden -> Hidden
- PartiallyExpanded -> {
- val hasPartiallyExpandedState =
- newAnchors.hasAnchorFor(PartiallyExpanded)
- val newTarget =
- if (hasPartiallyExpandedState) PartiallyExpanded
- else if (newAnchors.hasAnchorFor(Expanded)) Expanded else Hidden
- newTarget
- }
- Expanded -> {
- if (newAnchors.hasAnchorFor(Expanded)) Expanded else Hidden
- }
- }
- return@draggableAnchors newAnchors to newTarget
- }
- .draggable(
- state = sheetState.anchoredDraggableState.draggableState,
- orientation = Orientation.Vertical,
- enabled = sheetGesturesEnabled && sheetState.isVisible,
- startDragImmediately = sheetState.anchoredDraggableState.isAnimationRunning,
- onDragStopped = { settleToDismiss(it) }
- )
.semantics {
paneTitle = bottomSheetPaneTitle
traversalIndex = 0f
@@ -352,7 +301,8 @@
// min anchor. This is done to avoid showing a gap when the sheet opens and bounces
// when it's applied with a bouncy motion. Note that the content inside the Surface
// is scaled back down to maintain its aspect ratio (see below).
- .verticalScaleUp(sheetState),
+ .verticalScaleUp(sheetState)
+ .then(modifier),
shape = shape,
color = containerColor,
contentColor = contentColor,
@@ -536,6 +486,80 @@
}
}
+/**
+ * Provides custom bottom sheet [nestedScroll] behavior between the sheet's [draggable] modifier and
+ * the sheets scrollable content.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun Modifier.bottomSheetNestedScroll(
+ sheetGesturesEnabled: Boolean,
+ sheetState: SheetState,
+ settleToDismiss: (velocity: Float) -> Unit,
+): Modifier {
+ return if (!sheetGesturesEnabled) {
+ this
+ } else {
+ this.nestedScroll(
+ remember(sheetState) {
+ ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
+ sheetState = sheetState,
+ orientation = Orientation.Vertical,
+ onFling = settleToDismiss
+ )
+ }
+ )
+ }
+}
+
+/**
+ * Provides the bottom sheet's anchor points on the screen and [draggable] behavior between them.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun Modifier.bottomSheetDraggableAnchors(
+ sheetGesturesEnabled: Boolean,
+ sheetState: SheetState,
+ settleToDismiss: (velocity: Float) -> Unit
+): Modifier {
+ return this.draggableAnchors(sheetState.anchoredDraggableState, Orientation.Vertical) {
+ sheetSize,
+ constraints ->
+ val fullHeight = constraints.maxHeight.toFloat()
+ val newAnchors = DraggableAnchors {
+ Hidden at fullHeight
+ if (sheetSize.height > (fullHeight / 2) && !sheetState.skipPartiallyExpanded) {
+ PartiallyExpanded at fullHeight / 2f
+ }
+ if (sheetSize.height != 0) {
+ Expanded at max(0f, fullHeight - sheetSize.height)
+ }
+ }
+ val newTarget =
+ when (sheetState.anchoredDraggableState.targetValue) {
+ Hidden -> Hidden
+ PartiallyExpanded -> {
+ val hasPartiallyExpandedState = newAnchors.hasAnchorFor(PartiallyExpanded)
+ val newTarget =
+ if (hasPartiallyExpandedState) PartiallyExpanded
+ else if (newAnchors.hasAnchorFor(Expanded)) Expanded else Hidden
+ newTarget
+ }
+ Expanded -> {
+ if (newAnchors.hasAnchorFor(Expanded)) Expanded else Hidden
+ }
+ }
+ return@draggableAnchors newAnchors to newTarget
+ }
+ .draggable(
+ state = sheetState.anchoredDraggableState.draggableState,
+ orientation = Orientation.Vertical,
+ enabled = sheetGesturesEnabled && sheetState.isVisible,
+ startDragImmediately = sheetState.anchoredDraggableState.isAnimationRunning,
+ onDragStopped = { settleToDismiss(it) }
+ )
+}
+
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal expect fun ModalBottomSheetDialog(
diff --git a/compose/runtime/runtime/api/1.8.0-beta01.txt b/compose/runtime/runtime/api/1.8.0-beta01.txt
index 8842558..c20e335 100644
--- a/compose/runtime/runtime/api/1.8.0-beta01.txt
+++ b/compose/runtime/runtime/api/1.8.0-beta01.txt
@@ -974,22 +974,8 @@
}
public final class SnapshotId_jvmKt {
- method public static inline operator int compareTo(long, int other);
- method public static inline operator int compareTo(long, long other);
- method public static inline operator long div(long, int other);
- method public static inline operator long minus(long, int other);
- method public static inline operator long minus(long, long other);
- method public static inline operator long plus(long, int other);
- method public static inline operator long times(long, int other);
method public static inline int toInt(long);
- property public static final long SnapshotIdInvalidValue;
- property public static final long SnapshotIdMax;
- property public static final int SnapshotIdSize;
- property public static final long SnapshotIdZero;
- field public static final long SnapshotIdInvalidValue = -1L; // 0xffffffffffffffffL
- field public static final long SnapshotIdMax = 9223372036854775807L; // 0x7fffffffffffffffL
- field public static final int SnapshotIdSize = 64; // 0x40
- field public static final long SnapshotIdZero = 0L; // 0x0L
+ method public static inline long toLong(long);
}
public final class SnapshotKt {
diff --git a/compose/runtime/runtime/api/current.ignore b/compose/runtime/runtime/api/current.ignore
index 75715a1..c57a7e7 100644
--- a/compose/runtime/runtime/api/current.ignore
+++ b/compose/runtime/runtime/api/current.ignore
@@ -1,11 +1,29 @@
// Baseline format: 1.0
-AddedAbstractMethod: androidx.compose.runtime.ControlledComposition#getAndSetShouldPauseCallback(androidx.compose.runtime.ShouldPauseCallback):
- Added method androidx.compose.runtime.ControlledComposition.getAndSetShouldPauseCallback(androidx.compose.runtime.ShouldPauseCallback)
+AddedMethod: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#toLong(long):
+ Added method androidx.compose.runtime.snapshots.SnapshotId_jvmKt.toLong(long)
-BecameUnchecked: androidx.compose.runtime.Composer#compoundKeyHash:
- Removed property Composer.compoundKeyHash from compatibility checked API surface
-BecameUnchecked: androidx.compose.runtime.Composer#recomposeScope:
- Removed property Composer.recomposeScope from compatibility checked API surface
-BecameUnchecked: androidx.compose.runtime.internal.LiveLiteralKt#isLiveLiteralsEnabled:
- Removed property LiveLiteralKt.isLiveLiteralsEnabled from compatibility checked API surface
+RemovedField: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#SnapshotIdInvalidValue:
+ Removed field androidx.compose.runtime.snapshots.SnapshotId_jvmKt.SnapshotIdInvalidValue
+RemovedField: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#SnapshotIdMax:
+ Removed field androidx.compose.runtime.snapshots.SnapshotId_jvmKt.SnapshotIdMax
+RemovedField: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#SnapshotIdSize:
+ Removed field androidx.compose.runtime.snapshots.SnapshotId_jvmKt.SnapshotIdSize
+RemovedField: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#SnapshotIdZero:
+ Removed field androidx.compose.runtime.snapshots.SnapshotId_jvmKt.SnapshotIdZero
+
+
+RemovedMethod: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#compareTo(long, int):
+ Removed method androidx.compose.runtime.snapshots.SnapshotId_jvmKt.compareTo(long,int)
+RemovedMethod: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#compareTo(long, long):
+ Removed method androidx.compose.runtime.snapshots.SnapshotId_jvmKt.compareTo(long,long)
+RemovedMethod: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#div(long, int):
+ Removed method androidx.compose.runtime.snapshots.SnapshotId_jvmKt.div(long,int)
+RemovedMethod: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#minus(long, int):
+ Removed method androidx.compose.runtime.snapshots.SnapshotId_jvmKt.minus(long,int)
+RemovedMethod: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#minus(long, long):
+ Removed method androidx.compose.runtime.snapshots.SnapshotId_jvmKt.minus(long,long)
+RemovedMethod: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#plus(long, int):
+ Removed method androidx.compose.runtime.snapshots.SnapshotId_jvmKt.plus(long,int)
+RemovedMethod: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#times(long, int):
+ Removed method androidx.compose.runtime.snapshots.SnapshotId_jvmKt.times(long,int)
diff --git a/compose/runtime/runtime/api/current.txt b/compose/runtime/runtime/api/current.txt
index 8842558..c20e335 100644
--- a/compose/runtime/runtime/api/current.txt
+++ b/compose/runtime/runtime/api/current.txt
@@ -974,22 +974,8 @@
}
public final class SnapshotId_jvmKt {
- method public static inline operator int compareTo(long, int other);
- method public static inline operator int compareTo(long, long other);
- method public static inline operator long div(long, int other);
- method public static inline operator long minus(long, int other);
- method public static inline operator long minus(long, long other);
- method public static inline operator long plus(long, int other);
- method public static inline operator long times(long, int other);
method public static inline int toInt(long);
- property public static final long SnapshotIdInvalidValue;
- property public static final long SnapshotIdMax;
- property public static final int SnapshotIdSize;
- property public static final long SnapshotIdZero;
- field public static final long SnapshotIdInvalidValue = -1L; // 0xffffffffffffffffL
- field public static final long SnapshotIdMax = 9223372036854775807L; // 0x7fffffffffffffffL
- field public static final int SnapshotIdSize = 64; // 0x40
- field public static final long SnapshotIdZero = 0L; // 0x0L
+ method public static inline long toLong(long);
}
public final class SnapshotKt {
diff --git a/compose/runtime/runtime/api/restricted_1.8.0-beta01.txt b/compose/runtime/runtime/api/restricted_1.8.0-beta01.txt
index 45e66b0..a5104eee 100644
--- a/compose/runtime/runtime/api/restricted_1.8.0-beta01.txt
+++ b/compose/runtime/runtime/api/restricted_1.8.0-beta01.txt
@@ -1037,22 +1037,8 @@
}
public final class SnapshotId_jvmKt {
- method public static inline operator int compareTo(long, int other);
- method public static inline operator int compareTo(long, long other);
- method public static inline operator long div(long, int other);
- method public static inline operator long minus(long, int other);
- method public static inline operator long minus(long, long other);
- method public static inline operator long plus(long, int other);
- method public static inline operator long times(long, int other);
method public static inline int toInt(long);
- property public static final long SnapshotIdInvalidValue;
- property public static final long SnapshotIdMax;
- property public static final int SnapshotIdSize;
- property public static final long SnapshotIdZero;
- field public static final long SnapshotIdInvalidValue = -1L; // 0xffffffffffffffffL
- field public static final long SnapshotIdMax = 9223372036854775807L; // 0x7fffffffffffffffL
- field public static final int SnapshotIdSize = 64; // 0x40
- field public static final long SnapshotIdZero = 0L; // 0x0L
+ method public static inline long toLong(long);
}
public final class SnapshotKt {
diff --git a/compose/runtime/runtime/api/restricted_current.ignore b/compose/runtime/runtime/api/restricted_current.ignore
index 75715a1..c57a7e7 100644
--- a/compose/runtime/runtime/api/restricted_current.ignore
+++ b/compose/runtime/runtime/api/restricted_current.ignore
@@ -1,11 +1,29 @@
// Baseline format: 1.0
-AddedAbstractMethod: androidx.compose.runtime.ControlledComposition#getAndSetShouldPauseCallback(androidx.compose.runtime.ShouldPauseCallback):
- Added method androidx.compose.runtime.ControlledComposition.getAndSetShouldPauseCallback(androidx.compose.runtime.ShouldPauseCallback)
+AddedMethod: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#toLong(long):
+ Added method androidx.compose.runtime.snapshots.SnapshotId_jvmKt.toLong(long)
-BecameUnchecked: androidx.compose.runtime.Composer#compoundKeyHash:
- Removed property Composer.compoundKeyHash from compatibility checked API surface
-BecameUnchecked: androidx.compose.runtime.Composer#recomposeScope:
- Removed property Composer.recomposeScope from compatibility checked API surface
-BecameUnchecked: androidx.compose.runtime.internal.LiveLiteralKt#isLiveLiteralsEnabled:
- Removed property LiveLiteralKt.isLiveLiteralsEnabled from compatibility checked API surface
+RemovedField: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#SnapshotIdInvalidValue:
+ Removed field androidx.compose.runtime.snapshots.SnapshotId_jvmKt.SnapshotIdInvalidValue
+RemovedField: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#SnapshotIdMax:
+ Removed field androidx.compose.runtime.snapshots.SnapshotId_jvmKt.SnapshotIdMax
+RemovedField: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#SnapshotIdSize:
+ Removed field androidx.compose.runtime.snapshots.SnapshotId_jvmKt.SnapshotIdSize
+RemovedField: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#SnapshotIdZero:
+ Removed field androidx.compose.runtime.snapshots.SnapshotId_jvmKt.SnapshotIdZero
+
+
+RemovedMethod: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#compareTo(long, int):
+ Removed method androidx.compose.runtime.snapshots.SnapshotId_jvmKt.compareTo(long,int)
+RemovedMethod: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#compareTo(long, long):
+ Removed method androidx.compose.runtime.snapshots.SnapshotId_jvmKt.compareTo(long,long)
+RemovedMethod: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#div(long, int):
+ Removed method androidx.compose.runtime.snapshots.SnapshotId_jvmKt.div(long,int)
+RemovedMethod: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#minus(long, int):
+ Removed method androidx.compose.runtime.snapshots.SnapshotId_jvmKt.minus(long,int)
+RemovedMethod: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#minus(long, long):
+ Removed method androidx.compose.runtime.snapshots.SnapshotId_jvmKt.minus(long,long)
+RemovedMethod: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#plus(long, int):
+ Removed method androidx.compose.runtime.snapshots.SnapshotId_jvmKt.plus(long,int)
+RemovedMethod: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#times(long, int):
+ Removed method androidx.compose.runtime.snapshots.SnapshotId_jvmKt.times(long,int)
diff --git a/compose/runtime/runtime/api/restricted_current.txt b/compose/runtime/runtime/api/restricted_current.txt
index 45e66b0..a5104eee 100644
--- a/compose/runtime/runtime/api/restricted_current.txt
+++ b/compose/runtime/runtime/api/restricted_current.txt
@@ -1037,22 +1037,8 @@
}
public final class SnapshotId_jvmKt {
- method public static inline operator int compareTo(long, int other);
- method public static inline operator int compareTo(long, long other);
- method public static inline operator long div(long, int other);
- method public static inline operator long minus(long, int other);
- method public static inline operator long minus(long, long other);
- method public static inline operator long plus(long, int other);
- method public static inline operator long times(long, int other);
method public static inline int toInt(long);
- property public static final long SnapshotIdInvalidValue;
- property public static final long SnapshotIdMax;
- property public static final int SnapshotIdSize;
- property public static final long SnapshotIdZero;
- field public static final long SnapshotIdInvalidValue = -1L; // 0xffffffffffffffffL
- field public static final long SnapshotIdMax = 9223372036854775807L; // 0x7fffffffffffffffL
- field public static final int SnapshotIdSize = 64; // 0x40
- field public static final long SnapshotIdZero = 0L; // 0x0L
+ method public static inline long toLong(long);
}
public final class SnapshotKt {
diff --git a/compose/runtime/runtime/compose-runtime-benchmark/src/androidTest/java/androidx/compose/runtime/benchmark/SnapshotStateObserverBenchmark.kt b/compose/runtime/runtime/compose-runtime-benchmark/src/androidTest/java/androidx/compose/runtime/benchmark/SnapshotStateObserverBenchmark.kt
index e75b154..4b6d834 100644
--- a/compose/runtime/runtime/compose-runtime-benchmark/src/androidTest/java/androidx/compose/runtime/benchmark/SnapshotStateObserverBenchmark.kt
+++ b/compose/runtime/runtime/compose-runtime-benchmark/src/androidTest/java/androidx/compose/runtime/benchmark/SnapshotStateObserverBenchmark.kt
@@ -19,7 +19,7 @@
import android.os.Build
import android.os.Handler
import android.os.Looper
-import androidx.benchmark.junit4.measureRepeated
+import androidx.benchmark.junit4.measureRepeatedOnMainThread
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateOf
@@ -77,59 +77,51 @@
@Test
fun modelObservation() {
assumeTrue(Build.VERSION.SDK_INT != 29)
- runOnUiThread {
- benchmarkRule.measureRepeated {
- runWithTimingDisabled {
- nodes.forEach { node -> stateObserver.clear(node) }
- random = Random(0)
- }
- setupObservations()
+ benchmarkRule.measureRepeatedOnMainThread {
+ runWithTimingDisabled {
+ nodes.forEach { node -> stateObserver.clear(node) }
+ random = Random(0)
}
+ setupObservations()
}
}
@Test
fun nestedModelObservation() {
assumeTrue(Build.VERSION.SDK_INT != 29)
- runOnUiThread {
- val list = mutableListOf<Any>()
- repeat(10) { list += nodes[random.nextInt(ScopeCount)] }
- benchmarkRule.measureRepeated {
- runWithTimingDisabled {
- random = Random(0)
- nodes.forEach { node -> stateObserver.clear(node) }
- }
- stateObserver.observeReads(nodes[0], doNothing) {
- list.forEach { node -> observeForNode(node) }
- }
+ val list = mutableListOf<Any>()
+ repeat(10) { list += nodes[random.nextInt(ScopeCount)] }
+ benchmarkRule.measureRepeatedOnMainThread {
+ runWithTimingDisabled {
+ random = Random(0)
+ nodes.forEach { node -> stateObserver.clear(node) }
+ }
+ stateObserver.observeReads(nodes[0], doNothing) {
+ list.forEach { node -> observeForNode(node) }
}
}
}
@Test
fun derivedStateObservation() {
+ val node = Any()
+ val states = models.take(3)
+ val derivedState = derivedStateOf { states[0].value + states[1].value + states[2].value }
runOnUiThread {
- val node = Any()
- val states = models.take(3)
- val derivedState = derivedStateOf {
- states[0].value + states[1].value + states[2].value
+ stateObserver.observeReads(node, doNothing) {
+ // read derived state a few times
+ repeat(10) { derivedState.value }
}
-
+ }
+ benchmarkRule.measureRepeatedOnMainThread {
stateObserver.observeReads(node, doNothing) {
// read derived state a few times
repeat(10) { derivedState.value }
}
- benchmarkRule.measureRepeated {
- stateObserver.observeReads(node, doNothing) {
- // read derived state a few times
- repeat(10) { derivedState.value }
- }
-
- runWithTimingDisabled {
- states.forEach { it.value += 1 }
- Snapshot.sendApplyNotifications()
- }
+ runWithTimingDisabled {
+ states.forEach { it.value += 1 }
+ Snapshot.sendApplyNotifications()
}
}
}
@@ -137,71 +129,60 @@
@Test
fun deeplyNestedModelObservations() {
assumeTrue(Build.VERSION.SDK_INT != 29)
- runOnUiThread {
- val list = mutableListOf<Any>()
- repeat(100) { list += nodes[random.nextInt(ScopeCount)] }
+ val list = mutableListOf<Any>()
+ repeat(100) { list += nodes[random.nextInt(ScopeCount)] }
- fun observeRecursive(index: Int) {
- if (index == 100) return
- val node = list[index]
- stateObserver.observeReads(node, doNothing) {
- observeForNode(node)
- observeRecursive(index + 1)
- }
+ fun observeRecursive(index: Int) {
+ if (index == 100) return
+ val node = list[index]
+ stateObserver.observeReads(node, doNothing) {
+ observeForNode(node)
+ observeRecursive(index + 1)
}
+ }
- benchmarkRule.measureRepeated {
- runWithTimingDisabled {
- random = Random(0)
- nodes.forEach { node -> stateObserver.clear(node) }
- }
- observeRecursive(0)
+ benchmarkRule.measureRepeatedOnMainThread {
+ runWithTimingDisabled {
+ random = Random(0)
+ nodes.forEach { node -> stateObserver.clear(node) }
}
+ observeRecursive(0)
}
}
@Test
fun modelClear() {
assumeTrue(Build.VERSION.SDK_INT != 29)
- runOnUiThread {
- val nodeSet = hashSetOf<Any>()
- nodeSet.addAll(nodes)
-
- benchmarkRule.measureRepeated {
- stateObserver.clearIf { node -> node in nodeSet }
- random = Random(0)
- runWithTimingDisabled { setupObservations() }
- }
+ val nodeSet = hashSetOf<Any>()
+ nodeSet.addAll(nodes)
+ benchmarkRule.measureRepeatedOnMainThread {
+ stateObserver.clearIf { node -> node in nodeSet }
+ random = Random(0)
+ runWithTimingDisabled { setupObservations() }
}
}
@Test
fun modelIncrementalClear() {
assumeTrue(Build.VERSION.SDK_INT != 29)
- runOnUiThread {
- benchmarkRule.measureRepeated {
- for (i in 0 until nodes.size) {
- stateObserver.clearIf { node -> (node as Int) < i }
- }
- runWithTimingDisabled { setupObservations() }
- }
+ benchmarkRule.measureRepeatedOnMainThread {
+ repeat(nodes.size) { i -> stateObserver.clearIf { node -> (node as Int) < i } }
+ runWithTimingDisabled { setupObservations() }
}
}
@Test
fun notifyChanges() {
assumeTrue(Build.VERSION.SDK_INT != 29)
- runOnUiThread {
- val states = mutableSetOf<Int>()
- repeat(50) { states += random.nextInt(StateCount) }
- val snapshot: Snapshot = Snapshot.current
- benchmarkRule.measureRepeated {
- random = Random(0)
- stateObserver.notifyChanges(states, snapshot)
- runWithTimingDisabled {
- stateObserver.clear()
- setupObservations()
- }
+ val states = mutableSetOf<Int>()
+ repeat(50) { states += random.nextInt(StateCount) }
+ val snapshot: Snapshot = Snapshot.current
+ benchmarkRule.measureRepeatedOnMainThread {
+ random = Random(0)
+ stateObserver.notifyChanges(states, snapshot)
+ runWithTimingDisabled {
+ stateObserver.clear()
+ setupObservations()
}
}
}
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composer.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composer.kt
index c8fc0af..abd9693 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composer.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composer.kt
@@ -17,6 +17,7 @@
@file:OptIn(
InternalComposeApi::class,
)
+@file:Suppress("NOTHING_TO_INLINE", "KotlinRedundantDiagnosticSuppress")
package androidx.compose.runtime
@@ -4106,7 +4107,6 @@
}
}
- @Suppress("NOTHING_TO_INLINE")
private inline fun updateCompoundKeyWhenWeEnterGroup(
groupKey: Int,
rGroupIndex: Int,
@@ -4121,12 +4121,10 @@
else updateCompoundKeyWhenWeEnterGroupKeyHash(dataKey.hashCode(), 0)
}
- @Suppress("NOTHING_TO_INLINE")
private inline fun updateCompoundKeyWhenWeEnterGroupKeyHash(keyHash: Int, rGroupIndex: Int) {
compoundKeyHash = (((compoundKeyHash rol 3) xor keyHash) rol 3) xor rGroupIndex
}
- @Suppress("NOTHING_TO_INLINE")
private inline fun updateCompoundKeyWhenWeExitGroup(
groupKey: Int,
rGroupIndex: Int,
@@ -4141,7 +4139,6 @@
else updateCompoundKeyWhenWeExitGroupKeyHash(dataKey.hashCode(), 0)
}
- @Suppress("NOTHING_TO_INLINE")
private inline fun updateCompoundKeyWhenWeExitGroupKeyHash(groupKey: Int, rGroupIndex: Int) {
compoundKeyHash = (((compoundKeyHash xor rGroupIndex) ror 3) xor groupKey.hashCode()) ror 3
}
@@ -4645,7 +4642,9 @@
}
}
-internal fun runtimeCheck(value: Boolean) = runtimeCheck(value) { "Check failed" }
+internal inline fun debugRuntimeCheck(value: Boolean) = debugRuntimeCheck(value) { "Check failed" }
+
+internal inline fun runtimeCheck(value: Boolean) = runtimeCheck(value) { "Check failed" }
internal fun composeRuntimeError(message: String): Nothing {
throw ComposeRuntimeError(
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SlotTable.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SlotTable.kt
index 01080e7..cdfa183 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SlotTable.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SlotTable.kt
@@ -3429,21 +3429,27 @@
}
}
+private val EmptyLongArray = LongArray(0)
+
internal class BitVector {
private var first: Long = 0
private var second: Long = 0
- private var others: LongArray? = null
+ private var others: LongArray = EmptyLongArray
val size
- get() = others.let { if (it != null) (it.size + 2) * 64 else 128 }
+ get() = (others.size + 2) * 64
operator fun get(index: Int): Boolean {
- if (index < 0 || index >= size) error("Index $index out of bound")
if (index < 64) return first and (1L shl index) != 0L
if (index < 128) return second and (1L shl (index - 64)) != 0L
- val others = others ?: return false
+
+ val others = others
+ val size = others.size
+ if (size == 0) return false
+
val address = (index / 64) - 2
- if (address >= others.size) return false
+ if (address >= size) return false
+
val bit = index % 64
return (others[address] and (1L shl bit)) != 0L
}
@@ -3451,48 +3457,101 @@
operator fun set(index: Int, value: Boolean) {
if (index < 64) {
val mask = 1L shl index
- first = if (value) first or mask else first and mask.inv()
+ first = (first and mask.inv()) or (value.toBit().toLong() shl index)
return
}
+
if (index < 128) {
val mask = 1L shl (index - 64)
- second = if (value) second or mask else second and mask.inv()
+ second = (second and mask.inv()) or (value.toBit().toLong() shl index)
return
}
+
val address = (index / 64) - 2
- val mask = 1L shl (index % 64)
- var others =
- others
- ?: run {
- val others = LongArray(address + 1)
- this.others = others
- others
- }
+ val newIndex = index % 64
+ val mask = 1L shl newIndex
+ var others = others
if (address >= others.size) {
others = others.copyOf(address + 1)
this.others = others
}
+
val bits = others[address]
- others[address] = if (value) bits or mask else bits and mask.inv()
+ others[address] = (bits and mask.inv()) or (value.toBit().toLong() shl newIndex)
}
- fun nextSet(index: Int): Int {
- val size = size
- for (bit in index until size) {
- if (this[bit]) return bit
+ fun nextSet(index: Int) = nextBit(index) { it }
+
+ fun nextClear(index: Int) = nextBit(index) { it.inv() }
+
+ /**
+ * Returns the index of the next bit in this bit vector, starting at index. The [valueSelector]
+ * lets the caller modify the value before finding its first bit set.
+ */
+ @Suppress("NAME_SHADOWING")
+ private inline fun nextBit(index: Int, valueSelector: (Long) -> Long): Int {
+ if (index < 64) {
+ // We shift right (unsigned) then back left to drop the first "index"
+ // bits. This will set them all to 0, thus guaranteeing that the search
+ // performed by [firstBitSet] will start at index
+ val bit = (valueSelector(first) ushr index shl index).firstBitSet
+ if (bit < 64) return bit
}
+
+ if (index < 128) {
+ val index = index - 64
+ val bit = (valueSelector(second) ushr index shl index).firstBitSet
+ if (bit < 64) return 64 + bit
+ }
+
+ val index = max(index, 128)
+ val start = (index / 64) - 2
+ val others = others
+
+ for (i in start until others.size) {
+ var value = valueSelector(others[i])
+ // For the first element, the start index may be in the middle of the
+ // 128 bit word, so we apply the same shift trick as for [first] and
+ // [second] to start at the right spot in the bit field.
+ if (i == start) {
+ val shift = index % 64
+ value = value ushr shift shl shift
+ }
+ val bit = value.firstBitSet
+ if (bit < 64) return 128 + i * 64 + bit
+ }
+
return Int.MAX_VALUE
}
- fun nextClear(index: Int): Int {
- val size = size
- for (bit in index until size) {
- if (!this[bit]) return bit
- }
- return Int.MAX_VALUE
- }
-
+ @Suppress("NAME_SHADOWING")
fun setRange(start: Int, end: Int) {
+ var start = start
+
+ // If the range is valid we will use ~0L as our mask to create strings of 1s below,
+ // otherwise we use 0 so we don't set any bits. We could return when start >= end
+ // but this won't be a common case, so skip the branch
+ val bits = if (start < end) -1L else 0L
+
+ // Set the bits to 0 if we don't need to set any bit in the first word
+ var selector = bits * (start < 64).toBit()
+ // Take our selector (either all 0s or all 1s), perform an unsigned shift to the
+ // right to create a new word with "clampedEnd - start" bits, then shift it back
+ // left to where the range begins. This lets us set up to 64 bits at a time without
+ // doing an expensive loop that calls set()
+ val firstValue = (selector ushr (64 - (min(64, end) - start))) shl start
+ first = first or firstValue
+ // If we need to set bits in the second word, clamp our start otherwise return now
+ if (end > 64) start = max(start, 64) else return
+
+ // Set the bits to 0 if we don't need to set any bit in the second word
+ selector = bits * (start < 128).toBit()
+ // See firstValue above
+ val secondValue = (selector ushr (128 - (min(128, end) - start))) shl start
+ second = second or secondValue
+ // If we need to set bits in the remainder array, clamp our start otherwise return now
+ if (end > 128) start = max(start, 128) else return
+
for (bit in start until end) this[bit] = true
}
@@ -3510,6 +3569,9 @@
}
}
+private val Long.firstBitSet
+ inline get() = this.countTrailingZeroBits()
+
private class SourceInformationGroupIterator(
val table: SlotTable,
val parent: Int,
@@ -3664,7 +3726,7 @@
private fun IntArray.updateNodeCount(address: Int, value: Int) {
@Suppress("ConvertTwoComparisonsToRangeCheck")
- runtimeCheck(value >= 0 && value < NodeCount_Mask)
+ debugRuntimeCheck(value >= 0 && value < NodeCount_Mask)
this[address * Group_Fields_Size + GroupInfo_Offset] =
(this[address * Group_Fields_Size + GroupInfo_Offset] and NodeCount_Mask.inv()) or value
}
@@ -3687,7 +3749,7 @@
private fun IntArray.groupSize(address: Int) = this[address * Group_Fields_Size + Size_Offset]
private fun IntArray.updateGroupSize(address: Int, value: Int) {
- runtimeCheck(value >= 0)
+ debugRuntimeCheck(value >= 0)
this[address * Group_Fields_Size + Size_Offset] = value
}
@@ -3828,7 +3890,7 @@
// Remove a de-duplicated value from the heap
fun takeMax(): Int {
- runtimeCheck(list.size > 0) { "Set is empty" }
+ debugRuntimeCheck(list.size > 0) { "Set is empty" }
val value = list[0]
// Skip duplicates. It is not time efficient to remove duplicates from the list while
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 2d2c9f6..584556d 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
@@ -18,30 +18,40 @@
package androidx.compose.runtime.snapshots
+/**
+ * The type of [Snapshot.snapshotId]. On most platforms this is a [Long] but may be a different type
+ * if the platform target does not support [Long] efficiently (such as JavaScript).
+ */
expect class SnapshotId
-expect val SnapshotIdZero: SnapshotId
-expect val SnapshotIdMax: SnapshotId
-expect val SnapshotIdInvalidValue: SnapshotId
+internal expect val SnapshotIdZero: SnapshotId
+internal expect val SnapshotIdMax: SnapshotId
+internal expect val SnapshotIdInvalidValue: SnapshotId
-expect val SnapshotIdSize: Int
+internal expect val SnapshotIdSize: Int
-expect operator fun SnapshotId.compareTo(other: SnapshotId): Int
+internal expect operator fun SnapshotId.compareTo(other: SnapshotId): Int
-expect operator fun SnapshotId.compareTo(other: Int): Int
+internal expect operator fun SnapshotId.compareTo(other: Int): Int
-expect operator fun SnapshotId.plus(other: Int): SnapshotId
+internal expect operator fun SnapshotId.plus(other: Int): SnapshotId
-expect operator fun SnapshotId.minus(other: SnapshotId): SnapshotId
+internal expect operator fun SnapshotId.minus(other: SnapshotId): SnapshotId
-expect operator fun SnapshotId.minus(other: Int): SnapshotId
+internal expect operator fun SnapshotId.minus(other: Int): SnapshotId
-expect operator fun SnapshotId.div(other: Int): SnapshotId
+internal expect operator fun SnapshotId.div(other: Int): SnapshotId
-expect operator fun SnapshotId.times(other: Int): SnapshotId
+internal expect operator fun SnapshotId.times(other: Int): SnapshotId
expect fun SnapshotId.toInt(): Int
+expect fun SnapshotId.toLong(): Long
+
+/**
+ * An array of [SnapshotId]. On most platforms this is an array of [Long] but may be a different
+ * type if the platform target does not support [Long] efficiently (such as JavaScript).
+ */
expect class SnapshotIdArray
internal expect fun snapshotIdArrayWithCapacity(capacity: Int): SnapshotIdArray
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 4ba29a4..21a4643 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
@@ -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 =
diff --git a/compose/runtime/runtime/src/linuxx64StubsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.linuxx64Stubs.kt b/compose/runtime/runtime/src/linuxx64StubsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.linuxx64Stubs.kt
index e87f933..c09eb4f 100644
--- a/compose/runtime/runtime/src/linuxx64StubsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.linuxx64Stubs.kt
+++ b/compose/runtime/runtime/src/linuxx64StubsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.linuxx64Stubs.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 operator fun SnapshotId.compareTo(other: SnapshotId): Int = implementedInJetBrainsFork()
+internal actual operator fun SnapshotId.compareTo(other: SnapshotId): Int =
+ implementedInJetBrainsFork()
-actual operator fun SnapshotId.compareTo(other: Int): Int = implementedInJetBrainsFork()
+internal actual operator fun SnapshotId.compareTo(other: Int): Int = implementedInJetBrainsFork()
-actual operator fun SnapshotId.plus(other: Int): SnapshotId = implementedInJetBrainsFork()
+internal actual operator fun SnapshotId.plus(other: Int): SnapshotId = implementedInJetBrainsFork()
-actual operator fun SnapshotId.minus(other: SnapshotId): SnapshotId = implementedInJetBrainsFork()
+internal actual operator fun SnapshotId.minus(other: SnapshotId): SnapshotId =
+ implementedInJetBrainsFork()
-actual operator fun SnapshotId.minus(other: Int): SnapshotId = implementedInJetBrainsFork()
+internal actual operator fun SnapshotId.minus(other: Int): SnapshotId = implementedInJetBrainsFork()
-actual operator fun SnapshotId.div(other: Int): SnapshotId = implementedInJetBrainsFork()
+internal actual operator fun SnapshotId.div(other: Int): SnapshotId = implementedInJetBrainsFork()
-actual operator fun SnapshotId.times(other: Int): SnapshotId = implementedInJetBrainsFork()
+internal actual operator fun SnapshotId.times(other: Int): SnapshotId = implementedInJetBrainsFork()
actual fun SnapshotId.toInt(): Int = implementedInJetBrainsFork()
+actual fun SnapshotId.toLong(): Long = implementedInJetBrainsFork()
+
actual typealias SnapshotIdArray = LongArray
internal actual fun snapshotIdArrayWithCapacity(capacity: Int): SnapshotIdArray =
diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/collection/BitVectorTests.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/collection/BitVectorTests.kt
index 9531177..019265d 100644
--- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/collection/BitVectorTests.kt
+++ b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/collection/BitVectorTests.kt
@@ -50,10 +50,36 @@
@Test
fun canSetARange() {
- val vector = BitVector()
- vector.setRange(2, 30)
- for (bit in 0 until vector.size) {
- assertEquals(bit in 2 until 30, vector[bit])
+ val ranges =
+ listOf(
+ // Empty or inverted ranges
+ 0 to 0,
+ 66 to 66,
+ 130 to 130,
+ 4 to 2,
+ 66 to 60,
+ 130 to 128,
+ // 1 item ranges
+ 5 to 6,
+ 71 to 72,
+ 132 to 132,
+ // Larger ranges that fit in a single word
+ 2 to 30,
+ 70 to 83,
+ 130 to 150,
+ // Larger ranges that cross word boundaries
+ 60 to 80,
+ 120 to 140,
+ 60 to 140
+ )
+
+ for (range in ranges) {
+ val vector = BitVector()
+ val (start, end) = range
+ vector.setRange(start, end)
+ for (bit in 0 until vector.size) {
+ assertEquals(bit in start until end, vector[bit])
+ }
}
}
@@ -62,27 +88,87 @@
val vector = BitVector()
vector.setRange(2, 5)
vector.setRange(10, 12)
+ vector.setRange(80, 82)
+ vector.setRange(130, 132)
+ vector.setRange(260, 262)
+ vector.setRange(1030, 1032)
+
val received = mutableListOf<Int>()
var current = vector.nextSet(0)
while (current < vector.size) {
received.add(current)
current = vector.nextSet(current + 1)
}
- assertEquals(listOf(2, 3, 4, 10, 11), received)
+
+ assertEquals(listOf(2, 3, 4, 10, 11, 80, 81, 130, 131, 260, 261, 1030, 1031), received)
}
@Test
fun canFindTheNextClearBit() {
- val max = 15
val vector = BitVector()
vector.setRange(2, 5)
vector.setRange(10, 12)
+
+ var max = 15
val received = mutableListOf<Int>()
var current = vector.nextClear(0)
while (current < max) {
received.add(current)
current = vector.nextClear(current + 1)
}
+
assertEquals(listOf(0, 1, 5, 6, 7, 8, 9, 12, 13, 14), received)
+
+ received.clear()
+ val vector2 = BitVector()
+ vector2.setRange(70, 72)
+
+ max = 74
+ current = vector2.nextClear(64)
+ while (current < max) {
+ received.add(current)
+ current = vector2.nextClear(current + 1)
+ }
+
+ assertEquals(listOf(64, 65, 66, 67, 68, 69, 72, 73), received)
+
+ received.clear()
+ val vector3 = BitVector()
+ vector3.setRange(128, 130)
+
+ max = 132
+ current = vector3.nextClear(126)
+ while (current < max) {
+ received.add(current)
+ current = vector3.nextClear(current + 1)
+ }
+
+ assertEquals(listOf(126, 127, 130, 131), received)
+
+ received.clear()
+ val vector4 = BitVector()
+ vector4.setRange(0, 256)
+
+ max = 260
+ current = vector4.nextClear(0)
+ while (current < max) {
+ received.add(current)
+ current = vector4.nextClear(current + 1)
+ }
+
+ assertTrue(received.isEmpty())
+
+ received.clear()
+ val vector5 = BitVector()
+ vector5.setRange(384, 512)
+
+ max = 260
+ current = vector5.nextClear(256)
+ while (current < max) {
+ received.add(current)
+ current = vector5.nextClear(current + 1)
+ }
+
+ assertEquals(listOf(256, 257, 258, 259), received)
}
}
diff --git a/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/LayoutNodeModifierBenchmark.kt b/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/LayoutNodeModifierBenchmark.kt
index 9f92539..e6e9eb3 100644
--- a/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/LayoutNodeModifierBenchmark.kt
+++ b/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/LayoutNodeModifierBenchmark.kt
@@ -21,7 +21,7 @@
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.benchmark.junit4.BenchmarkRule
-import androidx.benchmark.junit4.measureRepeated
+import androidx.benchmark.junit4.measureRepeatedOnMainThread
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
@@ -59,9 +59,9 @@
@get:Rule val rule = SimpleAndroidBenchmarkRule()
- var modifiers = emptyList<Modifier>()
- var combinedModifier: Modifier = Modifier
- lateinit var testModifierUpdater: TestModifierUpdater
+ private var modifiers = emptyList<Modifier>()
+ private var combinedModifier: Modifier = Modifier
+ private lateinit var testModifierUpdater: TestModifierUpdater
@Before
fun setup() {
@@ -92,24 +92,19 @@
@Test
fun setAndClearModifiers() {
- rule.activityTestRule.runOnUiThread {
- rule.benchmarkRule.measureRepeated {
- testModifierUpdater.updateModifier(combinedModifier)
- testModifierUpdater.updateModifier(Modifier)
- }
+ rule.benchmarkRule.measureRepeatedOnMainThread {
+ testModifierUpdater.updateModifier(combinedModifier)
+ testModifierUpdater.updateModifier(Modifier)
}
}
@Test
fun smallModifierChange() {
- rule.activityTestRule.runOnUiThread {
- val altModifier = Modifier.padding(10.dp).then(combinedModifier)
+ val altModifier = Modifier.padding(10.dp).then(combinedModifier)
+ rule.activityTestRule.runOnUiThread { testModifierUpdater.updateModifier(altModifier) }
+ rule.benchmarkRule.measureRepeatedOnMainThread {
+ testModifierUpdater.updateModifier(combinedModifier)
testModifierUpdater.updateModifier(altModifier)
-
- rule.benchmarkRule.measureRepeated {
- testModifierUpdater.updateModifier(combinedModifier)
- testModifierUpdater.updateModifier(altModifier)
- }
}
}
@@ -133,15 +128,13 @@
}
}
- rule.activityTestRule.runOnUiThread {
- rule.benchmarkRule.measureRepeated {
- testModifierUpdater.updateModifier(combinedModifier)
- testModifierUpdater.updateModifier(altModifier)
- }
+ rule.benchmarkRule.measureRepeatedOnMainThread {
+ testModifierUpdater.updateModifier(combinedModifier)
+ testModifierUpdater.updateModifier(altModifier)
}
}
- class SimpleAndroidBenchmarkRule() : TestRule {
+ class SimpleAndroidBenchmarkRule : TestRule {
@Suppress("DEPRECATION")
val activityTestRule = androidx.test.rule.ActivityTestRule(ComponentActivity::class.java)
diff --git a/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/LifecycleAwareWindowRecomposerBenchmark.kt b/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/LifecycleAwareWindowRecomposerBenchmark.kt
index 0da80e7..96bfb2b 100644
--- a/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/LifecycleAwareWindowRecomposerBenchmark.kt
+++ b/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/LifecycleAwareWindowRecomposerBenchmark.kt
@@ -20,11 +20,10 @@
import android.view.ViewGroup
import androidx.activity.ComponentActivity
import androidx.benchmark.junit4.BenchmarkRule
-import androidx.benchmark.junit4.measureRepeated
+import androidx.benchmark.junit4.measureRepeatedOnMainThread
import androidx.compose.ui.platform.createLifecycleAwareWindowRecomposer
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.testing.TestLifecycleOwner
-import androidx.test.annotation.UiThreadTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.junit.Rule
@@ -42,21 +41,20 @@
@get:Rule val rule = CombinedActivityBenchmarkRule()
@Test
- @UiThreadTest
fun createRecomposer() {
- val rootView = rule.activityTestRule.activity.window.decorView.rootView
+ var rootView: View? = null
+ rule.activityTestRule.runOnUiThread {
+ rootView = rule.activityTestRule.activity.window.decorView.rootView
+ }
val lifecycleOwner = TestLifecycleOwner(Lifecycle.State.CREATED)
- var view: View? = null
- rule.benchmarkRule.measureRepeated {
+ rule.benchmarkRule.measureRepeatedOnMainThread {
+ var view: View? = null
runWithTimingDisabled {
view = View(rule.activityTestRule.activity)
(rootView as ViewGroup).addView(view)
}
view!!.createLifecycleAwareWindowRecomposer(lifecycle = lifecycleOwner.lifecycle)
- runWithTimingDisabled {
- (rootView as ViewGroup).removeAllViews()
- view = null
- }
+ runWithTimingDisabled { (rootView as ViewGroup).removeAllViews() }
}
}
diff --git a/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/autofill/AndroidAutofillBenchmark.kt b/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/autofill/AndroidAutofillBenchmark.kt
index 964171d..465fc6b 100644
--- a/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/autofill/AndroidAutofillBenchmark.kt
+++ b/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/autofill/AndroidAutofillBenchmark.kt
@@ -20,7 +20,7 @@
import android.view.View
import android.view.autofill.AutofillValue
import androidx.benchmark.junit4.BenchmarkRule
-import androidx.benchmark.junit4.measureRepeated
+import androidx.benchmark.junit4.measureRepeatedOnMainThread
import androidx.compose.ui.autofill.AutofillNode
import androidx.compose.ui.autofill.AutofillTree
import androidx.compose.ui.autofill.AutofillType
@@ -28,7 +28,6 @@
import androidx.compose.ui.platform.LocalAutofillTree
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.test.junit4.createComposeRule
-import androidx.test.annotation.UiThreadTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.filters.SdkSuppress
@@ -57,24 +56,26 @@
}
@Test
- @UiThreadTest
@SdkSuppress(minSdkVersion = 26)
fun provideAutofillVirtualStructure_performAutofill() {
-
- // Arrange.
- val autofillNode =
- AutofillNode(
- onFill = {},
- autofillTypes = listOf(AutofillType.PersonFullName),
- boundingBox = Rect(0f, 0f, 0f, 0f)
- )
val autofillValues =
- SparseArray<AutofillValue>().apply {
- append(autofillNode.id, AutofillValue.forText("Name"))
+ composeTestRule.runOnUiThread {
+ // Arrange.
+ val autofillNode =
+ AutofillNode(
+ onFill = {},
+ autofillTypes = listOf(AutofillType.PersonFullName),
+ boundingBox = Rect(0f, 0f, 0f, 0f)
+ )
+
+ autofillTree += autofillNode
+
+ SparseArray<AutofillValue>().apply {
+ append(autofillNode.id, AutofillValue.forText("Name"))
+ }
}
- autofillTree += autofillNode
// Assess.
- benchmarkRule.measureRepeated { composeView.autofill(autofillValues) }
+ benchmarkRule.measureRepeatedOnMainThread { composeView.autofill(autofillValues) }
}
}
diff --git a/compose/ui/ui/integration-tests/ui-demos/build.gradle b/compose/ui/ui/integration-tests/ui-demos/build.gradle
index 5dba54b..0f9cc3a 100644
--- a/compose/ui/ui/integration-tests/ui-demos/build.gradle
+++ b/compose/ui/ui/integration-tests/ui-demos/build.gradle
@@ -23,6 +23,7 @@
implementation(project(":compose:integration-tests:demos:common"))
implementation(project(":compose:ui:ui:ui-samples"))
implementation(project(":compose:material:material"))
+ implementation(project(":compose:material3:material3"))
implementation("androidx.compose.material:material-icons-core:1.6.7")
implementation(project(":navigation:navigation-compose"))
implementation(project(":compose:runtime:runtime"))
diff --git a/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/UiDemos.kt b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/UiDemos.kt
index 39f577e..3492d49 100644
--- a/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/UiDemos.kt
+++ b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/UiDemos.kt
@@ -27,6 +27,7 @@
import androidx.compose.ui.demos.accessibility.LinearProgressIndicatorDemo
import androidx.compose.ui.demos.accessibility.NestedContainersFalseDemo
import androidx.compose.ui.demos.accessibility.NestedContainersTrueDemo
+import androidx.compose.ui.demos.accessibility.SampleScrollingTooltipScreen
import androidx.compose.ui.demos.accessibility.ScaffoldSampleDemo
import androidx.compose.ui.demos.accessibility.ScaffoldSampleScrollDemo
import androidx.compose.ui.demos.accessibility.ScrollingColumnDemo
@@ -299,7 +300,8 @@
ComposableDemo("Nested Containers—True") { NestedContainersTrueDemo() },
ComposableDemo("Nested Containers—False") { NestedContainersFalseDemo() },
ComposableDemo("Linear Progress Indicator") { LinearProgressIndicatorDemo() },
- ComposableDemo("Dual LTR and RTL Scene") { SimpleRtlLayoutDemo() }
+ ComposableDemo("Dual LTR and RTL Scene") { SimpleRtlLayoutDemo() },
+ ComposableDemo("Scrolling Tooltip scene") { SampleScrollingTooltipScreen() }
)
)
diff --git a/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/accessibility/ScrollingUIDemo.kt b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/accessibility/ScrollingUIDemo.kt
new file mode 100644
index 0000000..e14dd24
--- /dev/null
+++ b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/accessibility/ScrollingUIDemo.kt
@@ -0,0 +1,103 @@
+/*
+ * 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.ui.demos.accessibility
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Menu
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.PlainTooltip
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TooltipBox
+import androidx.compose.material3.TooltipDefaults.rememberTooltipPositionProvider
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.rememberTooltipState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+
+@Preview
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SampleScrollingTooltipScreen() {
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Sample Screen") },
+ navigationIcon = {
+ TooltipBox(
+ positionProvider = rememberTooltipPositionProvider(),
+ tooltip = { PlainTooltip { Text(text = "Navigation icon") } },
+ state = rememberTooltipState()
+ ) {
+ IconButton(onClick = {}) {
+ Icon(
+ imageVector = Icons.Default.Menu,
+ contentDescription = "Navigation icon"
+ )
+ }
+ }
+ },
+ actions = {
+ TooltipBox(
+ positionProvider = rememberTooltipPositionProvider(),
+ tooltip = { PlainTooltip { Text(text = "Search") } },
+ state = rememberTooltipState()
+ ) {
+ IconButton(onClick = {}) {
+ Icon(
+ imageVector = Icons.Default.Search,
+ contentDescription = "Search icon"
+ )
+ }
+ }
+ TooltipBox(
+ positionProvider = rememberTooltipPositionProvider(),
+ tooltip = { PlainTooltip { Text(text = "Settings") } },
+ state = rememberTooltipState()
+ ) {
+ IconButton(onClick = {}) {
+ Icon(
+ imageVector = Icons.Default.Settings,
+ contentDescription = "Settings icon"
+ )
+ }
+ }
+ }
+ )
+ }
+ ) { paddingValues ->
+ LazyColumn(
+ modifier = Modifier.padding(paddingValues).fillMaxSize().padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ items(40) { index ->
+ Text(text = "Item ${index + 1}", style = MaterialTheme.typography.bodyLarge)
+ }
+ }
+ }
+}
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/AndroidAutoFillTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/AndroidAutoFillTest.kt
index 3de5c0b..939dfd0 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/AndroidAutoFillTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/AndroidAutoFillTest.kt
@@ -21,7 +21,6 @@
import android.view.ViewStructure
import android.view.autofill.AutofillValue
import androidx.autofill.HintConstants.AUTOFILL_HINT_PERSON_NAME
-import androidx.compose.ui.ComposeUiFlags.isSemanticAutofillEnabled
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.platform.LocalAutofill
@@ -77,8 +76,6 @@
@SdkSuppress(minSdkVersion = 26)
@Test
fun onProvideAutofillVirtualStructure_populatesViewStructure() {
- if (isSemanticAutofillEnabled) return
-
// Arrange.
val viewStructure: ViewStructure = FakeViewStructure()
val autofillNode =
@@ -113,8 +110,6 @@
@SdkSuppress(minSdkVersion = 26)
@Test
fun autofill_triggersOnFill() {
- if (isSemanticAutofillEnabled) return
-
// Arrange.
val expectedValue = "PersonName"
var autofilledValue = ""
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/TextFieldsSemanticAutofillTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/TextFieldsSemanticAutofillTest.kt
index b6aa107..75bc772 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/TextFieldsSemanticAutofillTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/autofill/TextFieldsSemanticAutofillTest.kt
@@ -22,9 +22,8 @@
import android.view.autofill.AutofillValue
import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.text.AutofillHighlight
import androidx.compose.foundation.text.BasicTextField
-import androidx.compose.foundation.text.LocalAutofillHighlight
+import androidx.compose.foundation.text.LocalAutofillHighlightColor
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.material.TextField
@@ -67,7 +66,7 @@
@get:Rule val rule = createAndroidComposeRule<TestActivity>()
private lateinit var androidComposeView: AndroidComposeView
private lateinit var composeView: View
- private var autofillHighlight: AutofillHighlight? = null
+ private var autofillHighlight: Color? = null
// ============================================================================================
// Tests to verify legacy TextField populating and filling.
@@ -194,9 +193,7 @@
val customHighlightColor = Color.Red
rule.setContentWithAutofillEnabled {
- CompositionLocalProvider(
- LocalAutofillHighlight provides AutofillHighlight(customHighlightColor)
- ) {
+ CompositionLocalProvider(LocalAutofillHighlightColor provides customHighlightColor) {
Column {
TextField(
value = usernameInput,
@@ -245,7 +242,7 @@
isSemanticAutofillEnabled = true
composeView = LocalView.current
- autofillHighlight = LocalAutofillHighlight.current
+ autofillHighlight = LocalAutofillHighlightColor.current
LaunchedEffect(Unit) {
// Make sure the delay between batches of events is set to zero.
(composeView as RootForTest).setAccessibilityEventBatchIntervalMillis(0L)
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/HitPathTrackerTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/HitPathTrackerTest.kt
index d2d8f53..b72e759 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/HitPathTrackerTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/HitPathTrackerTest.kt
@@ -20,7 +20,6 @@
import android.view.MotionEvent.ACTION_HOVER_ENTER
import android.view.MotionEvent.ACTION_HOVER_EXIT
-import androidx.collection.IntObjectMap
import androidx.compose.ui.InternalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.Autofill
@@ -62,7 +61,6 @@
import androidx.compose.ui.platform.TextToolbar
import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.platform.WindowInfo
-import androidx.compose.ui.semantics.SemanticsOwner
import androidx.compose.ui.spatial.RectManager
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
@@ -3417,9 +3415,6 @@
override val focusOwner: FocusOwner
get() = TODO("Not yet implemented")
- override val semanticsOwner: SemanticsOwner
- get() = TODO("Not yet implemented")
-
override val windowInfo: WindowInfo
get() = TODO("Not yet implemented")
@@ -3565,12 +3560,8 @@
}
override var measureIteration: Long = 0
-
override val viewConfiguration: ViewConfiguration
get() = TODO("Not yet implemented")
- override val layoutNodes: IntObjectMap<LayoutNode>
- get() = TODO("Not yet implemented")
-
override val sharedDrawScope = LayoutNodeDrawScope()
}
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/PointerInputEventProcessorTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/PointerInputEventProcessorTest.kt
index cda5678..df6e720 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/PointerInputEventProcessorTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/PointerInputEventProcessorTest.kt
@@ -21,8 +21,6 @@
import android.view.InputDevice
import android.view.KeyEvent as AndroidKeyEvent
import android.view.MotionEvent
-import androidx.collection.IntObjectMap
-import androidx.collection.intObjectMapOf
import androidx.compose.ui.InternalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.Autofill
@@ -58,8 +56,6 @@
import androidx.compose.ui.platform.TextToolbar
import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.platform.WindowInfo
-import androidx.compose.ui.semantics.EmptySemanticsModifier
-import androidx.compose.ui.semantics.SemanticsOwner
import androidx.compose.ui.spatial.RectManager
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
@@ -2850,9 +2846,6 @@
override val rootForTest: RootForTest
get() = TODO("Not yet implemented")
- override val layoutNodes: IntObjectMap<LayoutNode>
- get() = TODO("Not yet implemented")
-
override val hapticFeedBack: HapticFeedback
get() = TODO("Not yet implemented")
@@ -2912,9 +2905,6 @@
override val focusOwner: FocusOwner
get() = TODO("Not yet implemented")
- override val semanticsOwner: SemanticsOwner =
- SemanticsOwner(root, EmptySemanticsModifier(), intObjectMapOf())
-
override val windowInfo: WindowInfo
get() = TODO("Not yet implemented")
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/Helpers.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/Helpers.kt
index 8295452..ca6ec66 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/Helpers.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/Helpers.kt
@@ -18,8 +18,6 @@
package androidx.compose.ui.layout
-import androidx.collection.IntObjectMap
-import androidx.collection.intObjectMapOf
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.InternalComposeUiApi
import androidx.compose.ui.autofill.Autofill
@@ -54,8 +52,6 @@
import androidx.compose.ui.platform.TextToolbar
import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.platform.WindowInfo
-import androidx.compose.ui.semantics.EmptySemanticsModifier
-import androidx.compose.ui.semantics.SemanticsOwner
import androidx.compose.ui.spatial.RectManager
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
@@ -165,12 +161,7 @@
override fun onDetach(node: LayoutNode) {}
- override val root: LayoutNode = LayoutNode()
-
- override val semanticsOwner: SemanticsOwner =
- SemanticsOwner(root, EmptySemanticsModifier(), intObjectMapOf())
-
- override val layoutNodes: IntObjectMap<LayoutNode>
+ override val root: LayoutNode
get() = TODO("Not yet implemented")
override val sharedDrawScope: LayoutNodeDrawScope
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/node/LayoutNodeMappingTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/node/LayoutNodeMappingTest.kt
deleted file mode 100644
index 77e187c..0000000
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/node/LayoutNodeMappingTest.kt
+++ /dev/null
@@ -1,163 +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.ui.node
-
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.LazyItemScope
-import androidx.compose.foundation.lazy.LazyListState
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.Row
-import androidx.compose.ui.platform.LocalView
-import androidx.compose.ui.test.junit4.ComposeContentTestRule
-import androidx.compose.ui.test.junit4.createComposeRule
-import androidx.compose.ui.unit.dp
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.filters.MediumTest
-import kotlin.test.assertEquals
-import org.junit.Rule
-import org.junit.Test
-import org.junit.runner.RunWith
-
-@MediumTest
-@RunWith(AndroidJUnit4::class)
-class LayoutNodeMappingTest {
- @get:Rule val rule = createComposeRule()
-
- private lateinit var owner: Owner
-
- @Test
- fun basicTest() {
- rule.setTestContent { Column { repeat(5) { Box(Modifier.size(10.dp)) } } }
- rule.waitForIdle()
-
- assertMappingConsistency(7)
- }
-
- @Test
- fun countAfterRemovingContent() {
- var showAll by mutableStateOf(true)
-
- rule.setTestContent {
- Column {
- repeat(5) { Box(Modifier.size(10.dp)) }
- if (showAll) {
- repeat(5) { Box(Modifier.size(10.dp)) }
- }
- }
- }
- rule.waitForIdle()
- assertMappingConsistency(12)
-
- // Remove content, the mapping should reflect the change
- showAll = false
- rule.waitForIdle()
- assertMappingConsistency(7)
- }
-
- /**
- * Test case for when several LayoutNodes are re-used.
- *
- * We test that the mapping size after removing the LazyList is consistent.
- */
- @Test
- fun cleanupCount_afterScrollingLazyList() =
- with(rule.density) {
- val rootSizePx = 600f
- val viewPortCount = 4
- val pages = 3
-
- val listItemHeight = rootSizePx / viewPortCount
-
- var showAll by mutableStateOf(true)
-
- @Composable
- fun LazyItemScope.MyItem() {
- Row(Modifier.fillParentMaxWidth().height(listItemHeight.toDp())) {
- repeat(3) {
- Box(Modifier.size((rootSizePx / 3f).toDp(), listItemHeight.toDp()))
- }
- }
- }
-
- val lazyListState = LazyListState()
-
- rule.setTestContent {
- Box {
- if (showAll) {
- LazyColumn(
- state = lazyListState,
- modifier = Modifier.size(rootSizePx.toDp())
- ) {
- items(viewPortCount * pages) { MyItem() }
- }
- }
- }
- }
-
- // Reproducible set of calls that re-use content on the screen
- var itemToScroll = pages * viewPortCount - viewPortCount
- // Scroll to last visible set of items
- rule.runOnIdle { lazyListState.requestScrollToItem(itemToScroll) }
-
- // Scroll two items back
- repeat(2) { rule.runOnIdle { lazyListState.requestScrollToItem(--itemToScroll) } }
-
- // Scroll two items forward
- repeat(2) { rule.runOnIdle { lazyListState.requestScrollToItem(++itemToScroll) } }
- rule.waitForIdle()
-
- // Note that not every registered LayoutNode is an 'active' LayoutNode
- assertMappingConsistency(43)
-
- // Remove LazyList, and assert that the mapping is still representative of what's on
- // screen
- showAll = false
- rule.waitForIdle()
- assertMappingConsistency(2)
- }
-
- /** Sets the test [owner] property. */
- private fun ComposeContentTestRule.setTestContent(content: @Composable () -> Unit) {
- setContent {
- owner = LocalView.current as Owner
- content()
- }
- }
-
- /**
- * Asserts the `id to LayoutNode` map has the [expectedSize] and that the keys match their
- * corresponding [LayoutNode.semanticsId] value.
- */
- private fun assertMappingConsistency(expectedSize: Int) {
- assertEquals(
- expected = expectedSize,
- actual = owner.layoutNodes.size,
- message = "Unexpected Map size"
- )
- owner.layoutNodes.forEach { key, value ->
- assertEquals(key, value.semanticsId, "Non-matching keys")
- }
- }
-}
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/node/NodeChainTester.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/node/NodeChainTester.kt
index 67bc5d7..5488402 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/node/NodeChainTester.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/node/NodeChainTester.kt
@@ -18,8 +18,6 @@
package androidx.compose.ui.node
-import androidx.collection.IntObjectMap
-import androidx.collection.intObjectMapOf
import androidx.compose.ui.InternalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.Autofill
@@ -49,8 +47,6 @@
import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.platform.WindowInfo
import androidx.compose.ui.platform.invertTo
-import androidx.compose.ui.semantics.EmptySemanticsModifier
-import androidx.compose.ui.semantics.SemanticsOwner
import androidx.compose.ui.spatial.RectManager
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
@@ -388,9 +384,6 @@
override val density: Density
get() = Density(1f)
- override val layoutNodes: IntObjectMap<LayoutNode>
- get() = TODO("Not yet implemented")
-
override val layoutDirection: LayoutDirection
get() = LayoutDirection.Ltr
@@ -427,9 +420,6 @@
override val focusOwner: FocusOwner
get() = TODO("Not yet implemented")
- override val semanticsOwner: SemanticsOwner =
- SemanticsOwner(root, EmptySemanticsModifier(), intObjectMapOf())
-
override val windowInfo: WindowInfo
get() = TODO("Not yet implemented")
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsInfoTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsInfoTest.kt
deleted file mode 100644
index 2792c5d..0000000
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsInfoTest.kt
+++ /dev/null
@@ -1,276 +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.ui.semantics
-
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.lazy.LazyListState
-import androidx.compose.foundation.lazy.LazyRow
-import androidx.compose.foundation.lazy.rememberLazyListState
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.node.RootForTest
-import androidx.compose.ui.platform.LocalView
-import androidx.compose.ui.platform.testTag
-import androidx.compose.ui.semantics.SemanticsProperties.TestTag
-import androidx.compose.ui.test.junit4.ComposeContentTestRule
-import androidx.compose.ui.test.junit4.createComposeRule
-import androidx.compose.ui.test.onNodeWithTag
-import androidx.compose.ui.unit.dp
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.filters.MediumTest
-import com.google.common.truth.Correspondence
-import com.google.common.truth.Truth.assertThat
-import kotlin.test.Test
-import org.junit.Rule
-import org.junit.runner.RunWith
-
-@MediumTest
-@RunWith(AndroidJUnit4::class)
-class SemanticsInfoTest {
-
- @get:Rule val rule = createComposeRule()
-
- lateinit var semanticsOwner: SemanticsOwner
-
- @Test
- fun contentWithNoSemantics() {
- // Arrange.
- rule.setTestContent { Box {} }
- rule.waitForIdle()
-
- // Act.
- val rootSemantics = semanticsOwner.rootInfo
-
- // Assert.
- assertThat(rootSemantics).isNotNull()
- assertThat(rootSemantics.parentInfo).isNull()
- assertThat(rootSemantics.childrenInfo.size).isEqualTo(1)
-
- // Assert extension Functions.
- assertThat(rootSemantics.findSemanticsParent()).isNull()
- assertThat(rootSemantics.findMergingSemanticsParent()).isNull()
- assertThat(rootSemantics.findSemanticsChildren()).isEmpty()
- }
-
- @Test
- fun singleSemanticsModifier() {
- // Arrange.
- rule.setTestContent { Box(Modifier.semantics { this.testTag = "testTag" }) }
- rule.waitForIdle()
-
- // Act.
- val rootSemantics = semanticsOwner.rootInfo
- val semantics = rule.getSemanticsInfoForTag("testTag")!!
-
- // Assert.
- assertThat(rootSemantics.parentInfo).isNull()
- assertThat(rootSemantics.childrenInfo.asMutableList()).containsExactly(semantics)
-
- assertThat(semantics.parentInfo).isEqualTo(rootSemantics)
- assertThat(semantics.childrenInfo.size).isEqualTo(0)
-
- // Assert extension Functions.
- assertThat(rootSemantics.findSemanticsParent()).isNull()
- assertThat(rootSemantics.findMergingSemanticsParent()).isNull()
- assertThat(rootSemantics.findSemanticsChildren().map { it.semanticsConfiguration })
- .comparingElementsUsing(SemanticsConfigurationComparator)
- .containsExactly(SemanticsConfiguration().apply { testTag = "testTag" })
-
- assertThat(semantics.findSemanticsParent()).isEqualTo(rootSemantics)
- assertThat(semantics.findMergingSemanticsParent()).isNull()
- assertThat(semantics.findSemanticsChildren()).isEmpty()
- }
-
- @Test
- fun twoSemanticsModifiers() {
- // Arrange.
- rule.setTestContent {
- Box(Modifier.semantics { this.testTag = "item1" })
- Box(Modifier.semantics { this.testTag = "item2" })
- }
- rule.waitForIdle()
-
- // Act.
- val rootSemantics: SemanticsInfo = semanticsOwner.rootInfo
- val semantics1 = rule.getSemanticsInfoForTag("item1")
- val semantics2 = rule.getSemanticsInfoForTag("item2")
-
- // Assert.
- assertThat(rootSemantics.parentInfo).isNull()
- assertThat(rootSemantics.childrenInfo.map { it.semanticsConfiguration }.toList())
- .comparingElementsUsing(SemanticsConfigurationComparator)
- .containsExactly(
- SemanticsConfiguration().apply { testTag = "item1" },
- SemanticsConfiguration().apply { testTag = "item2" }
- )
- .inOrder()
-
- assertThat(rootSemantics.findSemanticsChildren().map { it.semanticsConfiguration })
- .comparingElementsUsing(SemanticsConfigurationComparator)
- .containsExactly(
- SemanticsConfiguration().apply { testTag = "item1" },
- SemanticsConfiguration().apply { testTag = "item2" }
- )
- .inOrder()
-
- checkNotNull(semantics1)
- assertThat(semantics1.parentInfo).isEqualTo(rootSemantics)
- assertThat(semantics1.childrenInfo.size).isEqualTo(0)
-
- checkNotNull(semantics2)
- assertThat(semantics2.parentInfo).isEqualTo(rootSemantics)
- assertThat(semantics2.childrenInfo.size).isEqualTo(0)
-
- // Assert extension Functions.
- assertThat(rootSemantics.findSemanticsParent()).isNull()
- assertThat(rootSemantics.findMergingSemanticsParent()).isNull()
- assertThat(rootSemantics.findSemanticsChildren().map { it.semanticsConfiguration })
- .comparingElementsUsing(SemanticsConfigurationComparator)
- .containsExactly(
- SemanticsConfiguration().apply { testTag = "item1" },
- SemanticsConfiguration().apply { testTag = "item2" }
- )
- .inOrder()
-
- assertThat(semantics1.findSemanticsParent()).isEqualTo(rootSemantics)
- assertThat(semantics1.findMergingSemanticsParent()).isNull()
- assertThat(semantics1.findSemanticsChildren()).isEmpty()
-
- assertThat(semantics2.findSemanticsParent()).isEqualTo(rootSemantics)
- assertThat(semantics2.findMergingSemanticsParent()).isNull()
- assertThat(semantics2.findSemanticsChildren()).isEmpty()
- }
-
- // TODO(ralu): Split this into multiple tests.
- @Test
- fun nodeDeepInHierarchy() {
- // Arrange.
- rule.setTestContent {
- Column(Modifier.semantics(mergeDescendants = true) { testTag = "outerColumn" }) {
- Row(Modifier.semantics { testTag = "outerRow" }) {
- Column(Modifier.semantics(mergeDescendants = true) { testTag = "column" }) {
- Row(Modifier.semantics { testTag = "row" }) {
- Column {
- Box(Modifier.semantics { testTag = "box" })
- Row(
- Modifier.semantics {}
- .semantics { testTag = "testTarget" }
- .semantics { testTag = "extra modifier2" }
- ) {
- Box { Box(Modifier.semantics { testTag = "child1" }) }
- Box(Modifier.semantics { testTag = "child2" }) {
- Box(Modifier.semantics { testTag = "grandChild" })
- }
- Box {}
- Row {
- Box {}
- Box {}
- }
- Box { Box(Modifier.semantics { testTag = "child3" }) }
- }
- }
- }
- }
- }
- }
- }
- rule.waitForIdle()
- val row = rule.getSemanticsInfoForTag(tag = "row", useUnmergedTree = true)
- val column = rule.getSemanticsInfoForTag("column")
-
- // Act.
- val testTarget = rule.getSemanticsInfoForTag(tag = "testTarget", useUnmergedTree = true)
-
- // Assert.
- checkNotNull(testTarget)
- assertThat(testTarget.parentInfo).isNotEqualTo(row)
- assertThat(testTarget.findSemanticsParent()).isEqualTo(row)
- assertThat(testTarget.findMergingSemanticsParent()).isEqualTo(column)
- assertThat(testTarget.childrenInfo.size).isEqualTo(5)
- assertThat(testTarget.findSemanticsChildren().map { it.semanticsConfiguration })
- .comparingElementsUsing(SemanticsConfigurationComparator)
- .containsExactly(
- SemanticsConfiguration().apply { testTag = "child1" },
- SemanticsConfiguration().apply { testTag = "child2" },
- SemanticsConfiguration().apply { testTag = "child3" }
- )
- .inOrder()
- assertThat(testTarget.semanticsConfiguration?.getOrNull(TestTag)).isEqualTo("testTarget")
- }
-
- @Test
- fun readingSemanticsConfigurationOfDeactivatedNode() {
- // Arrange.
- lateinit var lazyListState: LazyListState
- lateinit var rootForTest: RootForTest
- rule.setContent {
- rootForTest = LocalView.current as RootForTest
- lazyListState = rememberLazyListState()
- LazyRow(state = lazyListState, modifier = Modifier.size(10.dp)) {
- items(2) { index -> Box(Modifier.size(10.dp).testTag("$index")) }
- }
- }
- val semanticsId = rule.onNodeWithTag("0").semanticsId()
- val semanticsInfo = checkNotNull(rootForTest.semanticsOwner[semanticsId])
-
- // Act.
- rule.runOnIdle { lazyListState.requestScrollToItem(1) }
- val semanticsConfiguration = rule.runOnIdle { semanticsInfo.semanticsConfiguration }
-
- // Assert.
- rule.runOnIdle {
- assertThat(semanticsInfo.isDeactivated).isTrue()
- assertThat(semanticsConfiguration).isNull()
- }
- }
-
- private fun ComposeContentTestRule.setTestContent(composable: @Composable () -> Unit) {
- setContent {
- semanticsOwner = (LocalView.current as RootForTest).semanticsOwner
- composable()
- }
- }
-
- /** Helper function that returns a list of children that is easier to assert on in tests. */
- private fun SemanticsInfo.findSemanticsChildren(): List<SemanticsInfo> {
- val children = mutableListOf<SemanticsInfo>()
- [email protected] { children.add(it) }
- return children
- }
-
- private fun ComposeContentTestRule.getSemanticsInfoForTag(
- tag: String,
- useUnmergedTree: Boolean = true
- ): SemanticsInfo? {
- return semanticsOwner[onNodeWithTag(tag, useUnmergedTree).semanticsId()]
- }
-
- companion object {
- private val SemanticsConfigurationComparator =
- Correspondence.from<SemanticsConfiguration, SemanticsConfiguration>(
- { actual, expected ->
- actual != null &&
- expected != null &&
- actual.getOrNull(TestTag) == expected.getOrNull(TestTag)
- },
- "has same test tag as "
- )
- }
-}
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsListenerTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsListenerTest.kt
deleted file mode 100644
index 77965fe..0000000
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsListenerTest.kt
+++ /dev/null
@@ -1,556 +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.ui.semantics
-
-import androidx.compose.foundation.border
-import androidx.compose.foundation.focusable
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.BoxScope
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.size
-import androidx.compose.material.Text
-import androidx.compose.material.TextField
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.ComposeUiFlags
-import androidx.compose.ui.ExperimentalComposeUiApi
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.focus.isExactly
-import androidx.compose.ui.focus.onFocusChanged
-import androidx.compose.ui.graphics.Color.Companion.Black
-import androidx.compose.ui.graphics.Color.Companion.Red
-import androidx.compose.ui.node.RootForTest
-import androidx.compose.ui.node.SemanticsModifierNode
-import androidx.compose.ui.node.invalidateSemantics
-import androidx.compose.ui.platform.LocalView
-import androidx.compose.ui.platform.testTag
-import androidx.compose.ui.test.SemanticsNodeInteraction
-import androidx.compose.ui.test.junit4.ComposeContentTestRule
-import androidx.compose.ui.test.junit4.createComposeRule
-import androidx.compose.ui.test.onNodeWithTag
-import androidx.compose.ui.test.requestFocus
-import androidx.compose.ui.text.AnnotatedString
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.util.fastJoinToString
-import androidx.test.filters.MediumTest
-import com.google.common.truth.Truth.assertThat
-import kotlin.test.Test
-import org.junit.Before
-import org.junit.Rule
-import org.junit.runner.RunWith
-import org.junit.runners.Parameterized
-
-@MediumTest
-@RunWith(Parameterized::class)
-class SemanticsListenerTest(private val isSemanticAutofillEnabled: Boolean) {
-
- @get:Rule val rule = createComposeRule()
-
- private lateinit var semanticsOwner: SemanticsOwner
-
- companion object {
- @JvmStatic
- @Parameterized.Parameters(name = "isSemanticAutofillEnabled = {0}")
- fun initParameters() = listOf(false, true)
- }
-
- @Before
- fun setup() {
- @OptIn(ExperimentalComposeUiApi::class)
- ComposeUiFlags.isSemanticAutofillEnabled = isSemanticAutofillEnabled
- }
-
- // Initial layout does not trigger listeners. Users have to detect the initial semantics
- // values by detecting first layout (You can get the bounds from RectManager.RectList).
- @Test
- fun initialComposition_doesNotTriggerListeners() {
- // Arrange.
- val events = mutableListOf<Event<String>>()
- rule.setTestContent(
- onSemanticsChange = { info, prev ->
- events.add(Event(info.semanticsId, prev?.Text, info.semanticsConfiguration?.Text))
- }
- ) {
- Text(text = "text")
- }
-
- // Assert.
- rule.runOnIdle { assertThat(events).isEmpty() }
- }
-
- @Test
- fun addingNonSemanticsModifier() {
- // Arrange.
- val events = mutableListOf<Event<String>>()
- var addModifier by mutableStateOf(false)
- val text = AnnotatedString("text")
- rule.setTestContent(
- onSemanticsChange = { info, prev ->
- events.add(Event(info.semanticsId, prev?.Text, info.semanticsConfiguration?.Text))
- }
- ) {
- Box(
- modifier =
- Modifier.then(if (addModifier) Modifier.size(1000.dp) else Modifier)
- .semantics { this.text = text }
- .testTag("item")
- )
- }
-
- // Act.
- rule.runOnIdle { addModifier = true }
-
- // Assert.
- rule.runOnIdle { assertThat(events).isEmpty() }
- }
-
- @Test
- fun removingNonSemanticsModifier() {
- // Arrange.
- val events = mutableListOf<Event<String>>()
- var removeModifier by mutableStateOf(false)
- val text = AnnotatedString("text")
- rule.setTestContent(
- onSemanticsChange = { info, prev ->
- events.add(Event(info.semanticsId, prev?.Text, info.semanticsConfiguration?.Text))
- }
- ) {
- Box(
- modifier =
- Modifier.then(if (removeModifier) Modifier else Modifier.size(1000.dp))
- .semantics { this.text = text }
- .testTag("item")
- )
- }
-
- // Act.
- rule.runOnIdle { removeModifier = true }
-
- // Assert.
- rule.runOnIdle { assertThat(events).isEmpty() }
- }
-
- @Test
- fun addingSemanticsModifier() {
- // Arrange.
- val events = mutableListOf<Event<String>>()
- var addModifier by mutableStateOf(false)
- val text = AnnotatedString("text")
- rule.setTestContent(
- onSemanticsChange = { info, prev ->
- events.add(Event(info.semanticsId, prev?.Text, info.semanticsConfiguration?.Text))
- }
- ) {
- Box(
- modifier =
- Modifier.size(100.dp)
- .then(
- if (addModifier) Modifier.semantics { this.text = text } else Modifier
- )
- .testTag("item")
- )
- }
-
- // Act.
- rule.runOnIdle { addModifier = true }
-
- // Assert.
- val semanticsId = rule.onNodeWithTag("item").semanticsId
- rule.runOnIdle {
- if (isSemanticAutofillEnabled) {
- assertThat(events)
- .isExactly(Event(semanticsId, prevSemantics = null, newSemantics = "text"))
- } else {
- assertThat(events).isEmpty()
- }
- }
- }
-
- @Test
- fun removingSemanticsModifier() {
- // Arrange.
- val events = mutableListOf<Event<String>>()
- var removeModifier by mutableStateOf(false)
- val text = AnnotatedString("text")
- rule.setTestContent(
- onSemanticsChange = { info, prev ->
- events.add(Event(info.semanticsId, prev?.Text, info.semanticsConfiguration?.Text))
- }
- ) {
- Box(
- modifier =
- Modifier.size(1000.dp)
- .then(
- if (removeModifier) Modifier
- else Modifier.semantics { this.text = text }
- )
- .testTag("item")
- )
- }
-
- // Act.
- rule.runOnIdle { removeModifier = true }
-
- // Assert.
- val semanticsId = rule.onNodeWithTag("item").semanticsId
- rule.runOnIdle {
- if (isSemanticAutofillEnabled) {
- assertThat(events)
- .isExactly(Event(semanticsId, prevSemantics = "text", newSemantics = null))
- } else {
- assertThat(events).isEmpty()
- }
- }
- }
-
- @Test
- fun changingMutableSemanticsProperty() {
- // Arrange.
- val events = mutableListOf<Event<String>>()
- var text by mutableStateOf(AnnotatedString("text1"))
- rule.setTestContent(
- onSemanticsChange = { info, prev ->
- events.add(Event(info.semanticsId, prev?.Text, info.semanticsConfiguration?.Text))
- }
- ) {
- Box(modifier = Modifier.semantics { this.text = text }.testTag("item"))
- }
-
- // Act.
- rule.runOnIdle { text = AnnotatedString("text2") }
-
- // Assert.
- val semanticsId = rule.onNodeWithTag("item").semanticsId
- rule.runOnIdle {
- if (isSemanticAutofillEnabled) {
- assertThat(events)
- .isExactly(Event(semanticsId, prevSemantics = "text1", newSemantics = "text2"))
- } else {
- assertThat(events).isEmpty()
- }
- }
- }
-
- @Test
- fun changingMutableSemanticsProperty_alongWithRecomposition() {
- // Arrange.
- val events = mutableListOf<Event<String>>()
- var text by mutableStateOf(AnnotatedString("text1"))
- rule.setTestContent(
- onSemanticsChange = { info, prev ->
- events.add(Event(info.semanticsId, prev?.Text, info.semanticsConfiguration?.Text))
- }
- ) {
- Box(
- modifier =
- Modifier.border(2.dp, if (text.text == "text1") Red else Black)
- .semantics { this.text = text }
- .testTag("item")
- )
- }
-
- // Act.
- rule.runOnIdle { text = AnnotatedString("text2") }
-
- // Assert.
- val semanticsId = rule.onNodeWithTag("item").semanticsId
- rule.runOnIdle {
- if (isSemanticAutofillEnabled) {
- assertThat(events)
- .isExactly(Event(semanticsId, prevSemantics = "text1", newSemantics = "text2"))
- } else {
- assertThat(events).isEmpty()
- }
- }
- }
-
- @Test
- fun changingSemanticsProperty_andCallingInvalidateSemantics() {
- // Arrange.
- val events = mutableListOf<Event<String>>()
- val modifierNode =
- object : SemanticsModifierNode, Modifier.Node() {
- override fun SemanticsPropertyReceiver.applySemantics() {}
- }
- var text = AnnotatedString("text1")
- rule.setTestContent(
- onSemanticsChange = { info, prev ->
- events.add(Event(info.semanticsId, prev?.Text, info.semanticsConfiguration?.Text))
- }
- ) {
- Box(
- modifier =
- Modifier.elementFor(modifierNode).semantics { this.text = text }.testTag("item")
- )
- }
-
- // Act.
- rule.runOnIdle {
- text = AnnotatedString("text2")
- modifierNode.invalidateSemantics()
- }
-
- // Assert.
- val semanticsId = rule.onNodeWithTag("item").semanticsId
- rule.runOnIdle {
- if (isSemanticAutofillEnabled) {
- assertThat(events)
- .isExactly(Event(semanticsId, prevSemantics = "text1", newSemantics = "text2"))
- } else {
- assertThat(events).isEmpty()
- }
- }
- }
-
- @Test
- fun textChange() {
- // Arrange.
- val events = mutableListOf<Event<String>>()
- var text by mutableStateOf("text1")
- rule.setTestContent(
- onSemanticsChange = { info, prev ->
- events.add(Event(info.semanticsId, prev?.Text, info.semanticsConfiguration?.Text))
- }
- ) {
- Text(text = text, modifier = Modifier.testTag("item"))
- }
-
- // Act.
- rule.runOnIdle { text = "text2" }
-
- // Assert.
- val semanticsId = rule.onNodeWithTag("item").semanticsId
- rule.runOnIdle {
- if (isSemanticAutofillEnabled) {
- assertThat(events)
- .isExactly(Event(semanticsId, prevSemantics = "text1", newSemantics = "text2"))
- } else {
- assertThat(events).isEmpty()
- }
- }
- }
-
- @Test
- fun multipleTextChanges() {
- // Arrange.
- val events = mutableListOf<Event<String>>()
- var text by mutableStateOf("text1")
- rule.setTestContent(
- onSemanticsChange = { info, prev ->
- events.add(Event(info.semanticsId, prev?.Text, info.semanticsConfiguration?.Text))
- }
- ) {
- Text(text = text, modifier = Modifier.testTag("item"))
- }
-
- // Act.
- rule.runOnIdle { text = "text2" }
- rule.runOnIdle { text = "text3" }
-
- // Assert.
- val semanticsId = rule.onNodeWithTag("item").semanticsId
- rule.runOnIdle {
- if (isSemanticAutofillEnabled) {
- assertThat(events)
- .isExactly(
- Event(semanticsId, prevSemantics = "text1", newSemantics = "text2"),
- Event(semanticsId, prevSemantics = "text2", newSemantics = "text3")
- )
- } else {
- assertThat(events).isEmpty()
- }
- }
- }
-
- @Test
- fun EditTextChange() {
- // Arrange.
- val events = mutableListOf<Event<String>>()
- var text by mutableStateOf("text1")
- rule.setTestContent(
- onSemanticsChange = { info, prev ->
- events.add(
- Event(
- info.semanticsId,
- prev?.EditableText,
- info.semanticsConfiguration?.EditableText
- )
- )
- }
- ) {
- TextField(
- value = text,
- onValueChange = { text = it },
- modifier = Modifier.testTag("item")
- )
- }
-
- // Act.
- rule.runOnIdle { text = "text2" }
-
- // Assert.
- val semanticsId = rule.onNodeWithTag("item").semanticsId
- rule.runOnIdle {
- if (isSemanticAutofillEnabled) {
- assertThat(events)
- .isExactly(Event(semanticsId, prevSemantics = "text1", newSemantics = "text2"))
- } else {
- assertThat(events).isEmpty()
- }
- }
- }
-
- @Test
- fun FocusChange_withNoRecomposition() {
- // Arrange.
- val events = mutableListOf<Event<Boolean>>()
- rule.setTestContent(
- onSemanticsChange = { info, prev ->
- events.add(
- Event(
- info.semanticsId,
- prev?.getOrNull(SemanticsProperties.Focused),
- info.semanticsConfiguration?.getOrNull(SemanticsProperties.Focused)
- )
- )
- }
- ) {
- Column {
- Box(Modifier.testTag("item1").size(100.dp).focusable())
- Box(Modifier.testTag("item2").size(100.dp).focusable())
- }
- }
- rule.onNodeWithTag("item1").requestFocus()
- rule.runOnIdle { events.clear() }
-
- // Act.
- rule.onNodeWithTag("item2").requestFocus()
-
- // Assert.
- val item1 = rule.onNodeWithTag("item1").semanticsId
- val item2 = rule.onNodeWithTag("item2").semanticsId
- rule.runOnIdle {
- if (isSemanticAutofillEnabled) {
- assertThat(events)
- .isExactly(
- Event(item1, prevSemantics = true, newSemantics = false),
- Event(item2, prevSemantics = false, newSemantics = true)
- )
- } else {
- assertThat(events).isEmpty()
- }
- }
- }
-
- @Test
- fun FocusChange_thatCausesRecomposition() {
- // Arrange.
- val events = mutableListOf<Event<Boolean>>()
- rule.setTestContent(
- onSemanticsChange = { info, prev ->
- events.add(
- Event(
- info.semanticsId,
- prev?.getOrNull(SemanticsProperties.Focused),
- info.semanticsConfiguration?.getOrNull(SemanticsProperties.Focused)
- )
- )
- }
- ) {
- Column {
- FocusableBox(Modifier.testTag("item1"))
- FocusableBox(Modifier.testTag("item2"))
- }
- }
- rule.onNodeWithTag("item1").requestFocus()
- rule.runOnIdle { events.clear() }
-
- // Act.
- rule.onNodeWithTag("item2").requestFocus()
-
- // Assert.
- val item1 = rule.onNodeWithTag("item1").semanticsId
- val item2 = rule.onNodeWithTag("item2").semanticsId
- rule.runOnIdle {
- if (isSemanticAutofillEnabled) {
- assertThat(events)
- .isExactly(
- Event(item1, prevSemantics = true, newSemantics = false),
- Event(item2, prevSemantics = false, newSemantics = true)
- )
- } else {
- assertThat(events).isEmpty()
- }
- }
- }
-
- private val SemanticsConfiguration.Text
- get() = getOrNull(SemanticsProperties.Text)?.fastJoinToString()
-
- private val SemanticsConfiguration.EditableText
- get() = getOrNull(SemanticsProperties.EditableText)?.toString()
-
- private fun ComposeContentTestRule.setTestContent(
- onSemanticsChange: (SemanticsInfo, SemanticsConfiguration?) -> Unit,
- composable: @Composable () -> Unit
- ) {
- val semanticsListener =
- object : SemanticsListener {
- override fun onSemanticsChanged(
- semanticsInfo: SemanticsInfo,
- previousSemanticsConfiguration: SemanticsConfiguration?
- ) {
- onSemanticsChange(semanticsInfo, previousSemanticsConfiguration)
- }
- }
- setContent {
- semanticsOwner = (LocalView.current as RootForTest).semanticsOwner
- DisposableEffect(semanticsOwner) {
- semanticsOwner.listeners.add(semanticsListener)
- onDispose { semanticsOwner.listeners.remove(semanticsListener) }
- }
- composable()
- }
- }
-
- data class Event<T>(val semanticsId: Int, val prevSemantics: T?, val newSemantics: T?)
-
- // TODO(b/272068594): Add api to fetch the semantics id from SemanticsNodeInteraction directly.
- private val SemanticsNodeInteraction.semanticsId: Int
- get() = fetchSemanticsNode().id
-
- @Composable
- private fun FocusableBox(
- modifier: Modifier = Modifier,
- content: @Composable BoxScope.() -> Unit = {}
- ) {
- var borderColor by remember { mutableStateOf(Black) }
- Box(
- modifier =
- modifier
- .size(100.dp)
- .onFocusChanged { borderColor = if (it.isFocused) Red else Black }
- .border(2.dp, borderColor)
- .focusable(),
- content = content
- )
- }
-}
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsModifierNodeTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsModifierNodeTest.kt
deleted file mode 100644
index 5147cbe..0000000
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsModifierNodeTest.kt
+++ /dev/null
@@ -1,211 +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.ui.semantics
-
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.lazy.LazyListState
-import androidx.compose.foundation.lazy.LazyRow
-import androidx.compose.foundation.lazy.rememberLazyListState
-import androidx.compose.ui.ComposeUiFlags
-import androidx.compose.ui.ExperimentalComposeUiApi
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.node.RootForTest
-import androidx.compose.ui.node.SemanticsModifierNode
-import androidx.compose.ui.node.elementOf
-import androidx.compose.ui.node.invalidateSemantics
-import androidx.compose.ui.platform.LocalView
-import androidx.compose.ui.platform.testTag
-import androidx.compose.ui.test.junit4.createComposeRule
-import androidx.compose.ui.test.onNodeWithTag
-import androidx.compose.ui.text.AnnotatedString
-import androidx.compose.ui.unit.dp
-import androidx.test.filters.SmallTest
-import com.google.common.truth.Truth.assertThat
-import org.junit.Before
-import org.junit.Rule
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.junit.runners.Parameterized
-
-@SmallTest
-@RunWith(Parameterized::class)
-class SemanticsModifierNodeTest(private val precomputedSemantics: Boolean) {
- @get:Rule val rule = createComposeRule()
-
- companion object {
- @JvmStatic
- @Parameterized.Parameters(name = "pre-computed semantics = {0}")
- fun initParameters() = listOf(false, true)
- }
-
- @Before
- fun setup() {
- @OptIn(ExperimentalComposeUiApi::class)
- ComposeUiFlags.isSemanticAutofillEnabled = precomputedSemantics
- }
-
- @Test
- fun applySemantics_calledWhenSemanticsIsRead() {
- // Arrange.
- var applySemanticsInvoked = false
- rule.setContent {
- Box(
- Modifier.elementOf(
- TestSemanticsModifier {
- testTag = "TestTag"
- applySemanticsInvoked = true
- }
- )
- )
- }
-
- // Act.
- rule.onNodeWithTag("TestTag").fetchSemanticsNode()
-
- // Assert.
- rule.runOnIdle { assertThat(applySemanticsInvoked).isTrue() }
- }
-
- @Test
- fun invalidateSemantics_applySemanticsIsCalled() {
- // Arrange.
- var applySemanticsInvoked: Boolean
- val semanticsModifier = TestSemanticsModifier {
- testTag = "TestTag"
- applySemanticsInvoked = true
- }
- rule.setContent { Box(Modifier.elementOf(semanticsModifier)) }
- applySemanticsInvoked = false
-
- // Act.
- rule.runOnIdle { semanticsModifier.invalidateSemantics() }
-
- // Assert - Apply semantics is not called when we calculate semantics lazily.
- if (precomputedSemantics) {
- assertThat(applySemanticsInvoked).isTrue()
- } else {
- assertThat(applySemanticsInvoked).isFalse()
- }
- }
-
- @Test
- fun invalidateSemantics_applySemanticsNotCalledAgain_whenSemanticsConfigurationIsRead() {
- // Arrange.
- lateinit var rootForTest: RootForTest
- var applySemanticsInvoked = false
- var invocationCount = 0
- val semanticsModifier = TestSemanticsModifier {
- testTag = "TestTag"
- text = AnnotatedString("Text ${invocationCount++}")
- applySemanticsInvoked = true
- }
- rule.setContent {
- rootForTest = LocalView.current as RootForTest
- Box(Modifier.elementOf(semanticsModifier))
- }
- val semanticsId = rule.onNodeWithTag("TestTag").semanticsId()
- rule.runOnIdle {
- semanticsModifier.invalidateSemantics()
- applySemanticsInvoked = false
- }
-
- // Act.
- val semanticsInfo = checkNotNull(rootForTest.semanticsOwner[semanticsId])
- val semanticsConfiguration = semanticsInfo.semanticsConfiguration
-
- // Assert - Configuration recalculated when we calculate semantics lazily.
- if (precomputedSemantics) {
- assertThat(applySemanticsInvoked).isFalse()
- } else {
- assertThat(applySemanticsInvoked).isTrue()
- }
- assertThat(semanticsConfiguration?.text()).containsExactly("Text 2")
- }
-
- @Test
- fun readingSemanticsConfigurationOfDeactivatedNode() {
- // Arrange.
- lateinit var lazyListState: LazyListState
- lateinit var rootForTest: RootForTest
- rule.setContent {
- rootForTest = LocalView.current as RootForTest
- lazyListState = rememberLazyListState()
- LazyRow(state = lazyListState, modifier = Modifier.size(10.dp)) {
- items(2) { index ->
- Box(Modifier.size(10.dp).testTag("$index").elementOf(TestSemanticsModifier {}))
- }
- }
- }
- val semanticsId = rule.onNodeWithTag("0").semanticsId()
- val semanticsInfo = checkNotNull(rootForTest.semanticsOwner[semanticsId])
-
- // Act.
- rule.runOnIdle { lazyListState.requestScrollToItem(1) }
- val semanticsConfiguration = rule.runOnIdle { semanticsInfo.semanticsConfiguration }
-
- // Assert.
- rule.runOnIdle {
- assertThat(semanticsInfo.isDeactivated).isTrue()
- assertThat(semanticsConfiguration).isNull()
- }
- }
-
- @Test
- fun readingSemanticsConfigurationOfDeactivatedNode_afterCallingInvalidate() {
- // Arrange.
- lateinit var lazyListState: LazyListState
- lateinit var rootForTest: RootForTest
- val semanticsModifierNodes = List(2) { TestSemanticsModifier {} }
- rule.setContent {
- rootForTest = LocalView.current as RootForTest
- lazyListState = rememberLazyListState()
- LazyRow(state = lazyListState, modifier = Modifier.size(10.dp)) {
- items(2) { index ->
- Box(
- Modifier.size(10.dp)
- .testTag("$index")
- .elementOf(semanticsModifierNodes[index])
- )
- }
- }
- }
- val semanticsId = rule.onNodeWithTag("0").semanticsId()
- val semanticsInfo = checkNotNull(rootForTest.semanticsOwner[semanticsId])
-
- // Act.
- rule.runOnIdle { lazyListState.requestScrollToItem(1) }
- semanticsModifierNodes[0].invalidateSemantics()
- val semanticsConfiguration = rule.runOnIdle { semanticsInfo.semanticsConfiguration }
-
- // Assert.
- rule.runOnIdle {
- assertThat(semanticsInfo.isDeactivated).isTrue()
- assertThat(semanticsConfiguration).isNull()
- }
- }
-
- fun SemanticsConfiguration.text() = getOrNull(SemanticsProperties.Text)?.map { it.text }
-
- class TestSemanticsModifier(
- private val onApplySemantics: SemanticsPropertyReceiver.() -> Unit
- ) : SemanticsModifierNode, Modifier.Node() {
- override fun SemanticsPropertyReceiver.applySemantics() {
- onApplySemantics.invoke(this)
- }
- }
-}
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/AndroidAutofillManager.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/AndroidAutofillManager.android.kt
index f1f0567..2546cb2 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/AndroidAutofillManager.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/AndroidAutofillManager.android.kt
@@ -187,8 +187,7 @@
private fun notifyAutofillValueChanged(semanticsId: Int, newAutofillValue: Any) {
val currSemanticsNode = currentSemanticsNodes[semanticsId]?.semanticsNode
- val currDataType =
- currSemanticsNode?.unmergedConfig?.getOrNull(SemanticsContentDataType) ?: return
+ val currDataType = currSemanticsNode?.unmergedConfig?.getOrNull(SemanticsContentDataType)
when (currDataType) {
ContentDataType.Text ->
@@ -296,8 +295,6 @@
SemanticsContentDataType
)
}
- // TODO(b/138549623): Instead of creating a flattened tree by using the nodes from the map, we
- // can use SemanticsOwner to get the root SemanticsInfo and create a more representative tree.
var index = AutofillApi26Helper.addChildCount(root, count)
// Iterate through currentSemanticsNodes, finding autofill-related nodes
@@ -477,7 +474,7 @@
}
@RequiresApi(Build.VERSION_CODES.O)
-private class AutofillManagerWrapperImpl(val view: View) : AutofillManagerWrapper {
+private class AutofillManagerWrapperImpl(val view: AndroidComposeView) : AutofillManagerWrapper {
override val autofillManager =
view.context.getSystemService(PlatformAndroidManager::class.java)
?: error("Autofill service could not be located.")
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeView.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeView.android.kt
index 6e59d0c..459b567 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeView.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeView.android.kt
@@ -64,8 +64,6 @@
import androidx.annotation.DoNotInline
import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting
-import androidx.collection.MutableIntObjectMap
-import androidx.collection.mutableIntObjectMapOf
import androidx.compose.runtime.collection.mutableVectorOf
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -432,12 +430,9 @@
.then(dragAndDropManager.modifier)
}
- override val layoutNodes: MutableIntObjectMap<LayoutNode> = mutableIntObjectMapOf()
-
override val rootForTest: RootForTest = this
- override val semanticsOwner: SemanticsOwner =
- SemanticsOwner(root, rootSemanticsNode, layoutNodes)
+ override val semanticsOwner: SemanticsOwner = SemanticsOwner(root, rootSemanticsNode)
private val composeAccessibilityDelegate = AndroidComposeViewAccessibilityDelegateCompat(this)
internal var contentCaptureManager =
AndroidContentCaptureManager(
@@ -1031,12 +1026,9 @@
composeAccessibilityDelegate.SendRecurringAccessibilityEventsIntervalMillis = intervalMillis
}
- override fun onAttach(node: LayoutNode) {
- layoutNodes[node.semanticsId] = node
- }
+ override fun onAttach(node: LayoutNode) {}
override fun onDetach(node: LayoutNode) {
- layoutNodes.remove(node.semanticsId)
measureAndLayoutDelegate.onNodeDetached(node)
requestClearInvalidObservations()
@OptIn(ExperimentalComposeUiApi::class)
@@ -1616,12 +1608,6 @@
}
}
- override fun onLayoutNodeIdChange(layoutNode: LayoutNode, oldSemanticsId: Int) {
- // Keep the mapping up to date when the semanticsId changes
- layoutNodes.remove(oldSemanticsId)
- layoutNodes[layoutNode.semanticsId] = layoutNode
- }
-
override fun onInteropViewLayoutChange(view: InteropView) {
isPendingInteropViewLayoutChangeDispatch = true
}
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeViewAccessibilityDelegateCompat.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeViewAccessibilityDelegateCompat.android.kt
index af22fff..7036817 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeViewAccessibilityDelegateCompat.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeViewAccessibilityDelegateCompat.android.kt
@@ -18,7 +18,6 @@
import android.accessibilityservice.AccessibilityServiceInfo.FEEDBACK_ALL_MASK
import android.content.Context
-import android.content.res.Resources
import android.graphics.RectF
import android.os.Build
import android.os.Bundle
@@ -183,20 +182,6 @@
}
}
-private val semanticComparators: Array<Comparator<SemanticsNode>> =
- Array(2) { index ->
- val comparator =
- when (index) {
- 0 -> RtlBoundsComparator
- else -> LtrBoundsComparator
- }
- comparator
- // then compare by layoutNode's zIndex and placement order
- .thenBy(LayoutNode.ZComparator) { it.layoutNode }
- // then compare by semanticsId to break the tie somehow
- .thenBy { it.id }
- }
-
// Kotlin `sortWith` should just pull out the highest traversal indices, but keep everything
// else in place. If the element does not have a `traversalIndex` then `0f` will be used.
private val UnmergedConfigComparator: (SemanticsNode, SemanticsNode) -> Int = { a, b ->
@@ -385,7 +370,7 @@
currentSemanticsNodesInvalidated = false
field = view.semanticsOwner.getAllUncoveredSemanticsNodesToIntObjectMap()
if (isEnabled) {
- setTraversalValues(field, idToBeforeMap, idToAfterMap, view.context.resources)
+ setTraversalValues()
}
}
return field
@@ -555,13 +540,226 @@
)
}
+ private val semanticComparators: Array<Comparator<SemanticsNode>> =
+ Array(2) { index ->
+ val comparator =
+ when (index) {
+ 0 -> RtlBoundsComparator
+ else -> LtrBoundsComparator
+ }
+ comparator
+ // then compare by layoutNode's zIndex and placement order
+ .thenBy(LayoutNode.ZComparator) { it.layoutNode }
+ // then compare by semanticsId to break the tie somehow
+ .thenBy { it.id }
+ }
+
+ @Suppress("NOTHING_TO_INLINE")
+ private inline fun semanticComparator(layoutIsRtl: Boolean): Comparator<SemanticsNode> {
+ return semanticComparators[if (layoutIsRtl) 0 else 1]
+ }
+
+ // check to see if this entry overlaps with any groupings in rowGroupings
+ private fun placedEntryRowOverlaps(
+ rowGroupings: ArrayList<Pair<Rect, MutableList<SemanticsNode>>>,
+ node: SemanticsNode
+ ): Boolean {
+ // Conversion to long is needed in order to utilize `until`, which has no float ver
+ val entryTopCoord = node.boundsInWindow.top
+ val entryBottomCoord = node.boundsInWindow.bottom
+ val entryIsEmpty = entryTopCoord >= entryBottomCoord
+
+ for (currIndex in 0..rowGroupings.lastIndex) {
+ val currRect = rowGroupings[currIndex].first
+ val groupIsEmpty = currRect.top >= currRect.bottom
+ val groupOverlapsEntry =
+ !entryIsEmpty &&
+ !groupIsEmpty &&
+ max(entryTopCoord, currRect.top) < min(entryBottomCoord, currRect.bottom)
+
+ // If it overlaps with this row group, update cover and add node
+ if (groupOverlapsEntry) {
+ val newRect =
+ currRect.intersect(0f, entryTopCoord, Float.POSITIVE_INFINITY, entryBottomCoord)
+ // Replace the cover rectangle, copying over the old list of nodes
+ rowGroupings[currIndex] = Pair(newRect, rowGroupings[currIndex].second)
+ // Add current node
+ rowGroupings[currIndex].second.add(node)
+ // We've found an overlapping group, return true
+ return true
+ }
+ }
+
+ // If we've made it here, then there are no groups our entry overlaps with
+ return false
+ }
+
+ /**
+ * Returns the results of geometry groupings, which is determined from 1) grouping nodes into
+ * distinct, non-overlapping rows based on their top/bottom coordinates, then 2) sorting nodes
+ * within each row with the semantics comparator.
+ *
+ * This method approaches traversal order with more nuance than an approach considering only
+ * just hierarchy or only just an individual node's bounds.
+ *
+ * If [containerChildrenMapping] exists, there are additional children to add, as well as the
+ * sorted parent itself
+ */
+ private fun sortByGeometryGroupings(
+ layoutIsRtl: Boolean,
+ parentListToSort: ArrayList<SemanticsNode>,
+ containerChildrenMapping: MutableIntObjectMap<MutableList<SemanticsNode>> =
+ mutableIntObjectMapOf()
+ ): MutableList<SemanticsNode> {
+ // RowGroupings list consists of pairs, first = a rectangle of the bounds of the row
+ // and second = the list of nodes in that row
+ val rowGroupings =
+ ArrayList<Pair<Rect, MutableList<SemanticsNode>>>(parentListToSort.size / 2)
+
+ for (entryIndex in 0..parentListToSort.lastIndex) {
+ val currEntry = parentListToSort[entryIndex]
+ // If this is the first entry, or vertical groups don't overlap
+ if (entryIndex == 0 || !placedEntryRowOverlaps(rowGroupings, currEntry)) {
+ val newRect = currEntry.boundsInWindow
+ rowGroupings.add(Pair(newRect, mutableListOf(currEntry)))
+ } // otherwise, we've already iterated through, found and placed it in a matching group
+ }
+
+ // Sort the rows from top to bottom
+ rowGroupings.sortWith(TopBottomBoundsComparator)
+
+ val returnList = ArrayList<SemanticsNode>()
+ val comparator = semanticComparator(layoutIsRtl)
+ rowGroupings.fastForEach { row ->
+ // Sort each individual row's parent nodes
+ row.second.sortWith(comparator)
+ returnList.addAll(row.second)
+ }
+
+ returnList.sortWith(UnmergedConfigComparator)
+
+ var i = 0
+ // Afterwards, go in and add the containers' children.
+ while (i <= returnList.lastIndex) {
+ val currNodeId = returnList[i].id
+ // If a parent node is a container, then add its children.
+ // Add all container's children after the container itself.
+ // Because we've already recursed on the containers children, the children should
+ // also be sorted by their traversal index
+ val containersChildrenList = containerChildrenMapping[currNodeId]
+ if (containersChildrenList != null) {
+ val containerIsScreenReaderFocusable = isScreenReaderFocusable(returnList[i])
+ if (!containerIsScreenReaderFocusable) {
+ // Container is removed if it is not screenreader-focusable
+ returnList.removeAt(i)
+ } else {
+ // Increase counter if the container was not removed
+ i += 1
+ }
+ // Add all the container's children and increase counter by the number of children
+ returnList.addAll(i, containersChildrenList)
+ i += containersChildrenList.size
+ } else {
+ // Advance to the next item
+ i += 1
+ }
+ }
+ return returnList
+ }
+
+ private fun geometryDepthFirstSearch(
+ currNode: SemanticsNode,
+ geometryList: ArrayList<SemanticsNode>,
+ containerMapToChildren: MutableIntObjectMap<MutableList<SemanticsNode>>
+ ) {
+ val currRTL = currNode.isRtl
+ // We only want to add children that are either traversalGroups or are
+ // screen reader focusable. The child must also be in the current pruned semantics tree.
+ val isTraversalGroup =
+ currNode.unmergedConfig.getOrElse(SemanticsProperties.IsTraversalGroup) { false }
+
+ if (
+ (isTraversalGroup || isScreenReaderFocusable(currNode)) &&
+ currentSemanticsNodes.containsKey(currNode.id)
+ ) {
+ geometryList.add(currNode)
+ }
+ if (isTraversalGroup) {
+ // Recurse and record the container's children, sorted
+ containerMapToChildren[currNode.id] =
+ subtreeSortedByGeometryGrouping(currRTL, currNode.children)
+ } else {
+ // Otherwise, continue adding children to the list that'll be sorted regardless of
+ // hierarchy
+ currNode.children.fastForEach { child ->
+ geometryDepthFirstSearch(child, geometryList, containerMapToChildren)
+ }
+ }
+ }
+
+ /**
+ * This function prepares a subtree for `sortByGeometryGroupings` by retrieving all
+ * non-container nodes and adding them to the list to be geometrically sorted. We recurse on
+ * containers (if they exist) and add their sorted children to an optional mapping. The list to
+ * be sorted and child mapping is passed into `sortByGeometryGroupings`.
+ */
+ private fun subtreeSortedByGeometryGrouping(
+ layoutIsRtl: Boolean,
+ listToSort: List<SemanticsNode>
+ ): MutableList<SemanticsNode> {
+ // This should be mapping of [containerID: listOfSortedChildren], only populated if there
+ // are container nodes in this level. If there are container nodes, `containerMapToChildren`
+ // would look like {containerId: [sortedChild, sortedChild], containerId: [sortedChild]}
+ val containerMapToChildren = mutableIntObjectMapOf<MutableList<SemanticsNode>>()
+ val geometryList = ArrayList<SemanticsNode>()
+
+ listToSort.fastForEach { node ->
+ geometryDepthFirstSearch(node, geometryList, containerMapToChildren)
+ }
+
+ return sortByGeometryGroupings(layoutIsRtl, geometryList, containerMapToChildren)
+ }
+
+ private fun setTraversalValues() {
+ idToBeforeMap.clear()
+ idToAfterMap.clear()
+
+ val hostSemanticsNode =
+ currentSemanticsNodes[AccessibilityNodeProviderCompat.HOST_VIEW_ID]?.semanticsNode!!
+ val hostLayoutIsRtl = hostSemanticsNode.isRtl
+
+ val semanticsOrderList =
+ subtreeSortedByGeometryGrouping(hostLayoutIsRtl, listOf(hostSemanticsNode))
+
+ // Iterate through our ordered list, and creating a mapping of current node to next node ID
+ // We'll later read through this and set traversal order with IdToBeforeMap
+ for (i in 1..semanticsOrderList.lastIndex) {
+ val prevId = semanticsOrderList[i - 1].id
+ val currId = semanticsOrderList[i].id
+ idToBeforeMap[prevId] = currId
+ idToAfterMap[currId] = prevId
+ }
+ }
+
+ private fun isScreenReaderFocusable(node: SemanticsNode): Boolean {
+ val nodeContentDescriptionOrNull =
+ node.unmergedConfig.getOrNull(SemanticsProperties.ContentDescription)?.firstOrNull()
+ val isSpeakingNode =
+ nodeContentDescriptionOrNull != null ||
+ getInfoText(node) != null ||
+ getInfoStateDescriptionOrNull(node) != null ||
+ getInfoIsCheckable(node)
+
+ return !node.isHidden &&
+ (node.unmergedConfig.isMergingSemanticsOfDescendants ||
+ node.isUnmergedLeafNode && isSpeakingNode)
+ }
+
private fun populateAccessibilityNodeInfoProperties(
virtualViewId: Int,
info: AccessibilityNodeInfoCompat,
semanticsNode: SemanticsNode
) {
- val resources = view.context.resources
-
// set classname
info.className = ClassName
@@ -577,9 +775,9 @@
role?.let {
if (semanticsNode.isFake || semanticsNode.replacedChildren.isEmpty()) {
if (role == Role.Tab) {
- info.roleDescription = resources.getString(R.string.tab)
+ info.roleDescription = view.context.resources.getString(R.string.tab)
} else if (role == Role.Switch) {
- info.roleDescription = resources.getString(R.string.switch_role)
+ info.roleDescription = view.context.resources.getString(R.string.switch_role)
} else {
val className = role.toLegacyClassName()
// Images are often minor children of larger widgets, so we only want to
@@ -634,8 +832,8 @@
setText(semanticsNode, info)
setContentInvalid(semanticsNode, info)
- info.stateDescription = getInfoStateDescriptionOrNull(semanticsNode, resources)
- info.isCheckable = getInfoIsCheckable(semanticsNode)
+ setStateDescription(semanticsNode, info)
+ setIsCheckable(semanticsNode, info)
val toggleState =
semanticsNode.unmergedConfig.getOrNull(SemanticsProperties.ToggleableState)
@@ -1029,7 +1227,7 @@
}
}
- info.isScreenReaderFocusable = isScreenReaderFocusable(semanticsNode, resources)
+ info.isScreenReaderFocusable = isScreenReaderFocusable(semanticsNode)
// `beforeId` refers to the semanticsId that should be read before this `virtualViewId`.
val beforeId = idToBeforeMap.getOrDefault(virtualViewId, -1)
@@ -1077,6 +1275,143 @@
}
}
+ private fun getInfoStateDescriptionOrNull(node: SemanticsNode): String? {
+ var stateDescription = node.unmergedConfig.getOrNull(SemanticsProperties.StateDescription)
+ val toggleState = node.unmergedConfig.getOrNull(SemanticsProperties.ToggleableState)
+ val role = node.unmergedConfig.getOrNull(SemanticsProperties.Role)
+
+ // Check toggle state and retrieve description accordingly
+ toggleState?.let {
+ when (it) {
+ ToggleableState.On -> {
+ // Unfortunately, talkback has a bug of using "checked", so we set state
+ // description here
+ if (role == Role.Switch && stateDescription == null) {
+ stateDescription = view.context.resources.getString(R.string.state_on)
+ }
+ }
+ ToggleableState.Off -> {
+ // Unfortunately, talkback has a bug of using "not checked", so we set state
+ // description here
+ if (role == Role.Switch && stateDescription == null) {
+ stateDescription = view.context.resources.getString(R.string.state_off)
+ }
+ }
+ ToggleableState.Indeterminate -> {
+ if (stateDescription == null) {
+ stateDescription = view.context.resources.getString(R.string.indeterminate)
+ }
+ }
+ }
+ }
+
+ // Check Selected property and retrieve description accordingly
+ node.unmergedConfig.getOrNull(SemanticsProperties.Selected)?.let {
+ if (role != Role.Tab) {
+ if (stateDescription == null) {
+ // If a radio entry (radio button + text) is selectable, it won't have the role
+ // RadioButton, so if we use info.isCheckable/info.isChecked, talkback will say
+ // "checked/not checked" instead "selected/note selected".
+ stateDescription =
+ if (it) {
+ view.context.resources.getString(R.string.selected)
+ } else {
+ view.context.resources.getString(R.string.not_selected)
+ }
+ }
+ }
+ }
+
+ // Check if a node has progress bar range info and retrieve description accordingly
+ val rangeInfo = node.unmergedConfig.getOrNull(SemanticsProperties.ProgressBarRangeInfo)
+ rangeInfo?.let {
+ // let's set state description here and use state description change events.
+ // otherwise, we need to send out type_view_selected event, as the old android
+ // versions do. But the support for type_view_selected event for progress bars
+ // maybe deprecated in talkback in the future.
+ if (rangeInfo !== ProgressBarRangeInfo.Indeterminate) {
+ if (stateDescription == null) {
+ val valueRange = rangeInfo.range
+ val progress =
+ (if (valueRange.endInclusive - valueRange.start == 0f) 0f
+ else
+ (rangeInfo.current - valueRange.start) /
+ (valueRange.endInclusive - valueRange.start))
+ .fastCoerceIn(0f, 1f)
+
+ // We only display 0% or 100% when it is exactly 0% or 100%.
+ val percent =
+ when (progress) {
+ 0f -> 0
+ 1f -> 100
+ else -> (progress * 100).fastRoundToInt().coerceIn(1, 99)
+ }
+ stateDescription =
+ view.context.resources.getString(R.string.template_percent, percent)
+ }
+ } else if (stateDescription == null) {
+ stateDescription = view.context.resources.getString(R.string.in_progress)
+ }
+ }
+
+ if (node.unmergedConfig.contains(SemanticsProperties.EditableText)) {
+ stateDescription = createStateDescriptionForTextField(node)
+ }
+
+ return stateDescription
+ }
+
+ /**
+ * Empty text field should not be ignored by the TB so we set a state description. When there is
+ * a speakable child, like a label or a placeholder text, setting this state description is
+ * redundant
+ */
+ private fun createStateDescriptionForTextField(node: SemanticsNode): String? {
+ val mergedConfig = node.copyWithMergingEnabled().config
+ val mergedNodeIsUnspeakable =
+ mergedConfig.getOrNull(SemanticsProperties.ContentDescription).isNullOrEmpty() &&
+ mergedConfig.getOrNull(SemanticsProperties.Text).isNullOrEmpty() &&
+ mergedConfig.getOrNull(SemanticsProperties.EditableText).isNullOrEmpty()
+ return if (mergedNodeIsUnspeakable) {
+ view.context.resources.getString(R.string.state_empty)
+ } else null
+ }
+
+ private fun setStateDescription(
+ node: SemanticsNode,
+ info: AccessibilityNodeInfoCompat,
+ ) {
+ info.stateDescription = getInfoStateDescriptionOrNull(node)
+ }
+
+ private fun getInfoIsCheckable(node: SemanticsNode): Boolean {
+ var isCheckable = false
+ val toggleState = node.unmergedConfig.getOrNull(SemanticsProperties.ToggleableState)
+ val role = node.unmergedConfig.getOrNull(SemanticsProperties.Role)
+
+ toggleState?.let { isCheckable = true }
+
+ node.unmergedConfig.getOrNull(SemanticsProperties.Selected)?.let {
+ if (role != Role.Tab) {
+ isCheckable = true
+ }
+ }
+
+ return isCheckable
+ }
+
+ private fun setIsCheckable(node: SemanticsNode, info: AccessibilityNodeInfoCompat) {
+ info.isCheckable = getInfoIsCheckable(node)
+ }
+
+ // This needs to be here instead of around line 3000 because we need access to the `view`
+ // that is inside the `AndroidComposeViewAccessibilityDelegateCompat` class
+ private fun getInfoText(node: SemanticsNode): AnnotatedString? {
+ val editableTextToAssign = node.unmergedConfig.getTextForTextField()
+ val textToAssign = node.unmergedConfig.getOrNull(SemanticsProperties.Text)?.firstOrNull()
+ return editableTextToAssign ?: textToAssign
+ }
+
@OptIn(InternalTextApi::class)
private fun AnnotatedString.toSpannableString(): SpannableString? {
val fontFamilyResolver: FontFamily.Resolver = view.fontFamilyResolver
@@ -2020,11 +2355,11 @@
if (layoutNode.nodes.has(Nodes.Semantics)) layoutNode
else layoutNode.findClosestParentNode { it.nodes.has(Nodes.Semantics) }
- val config = semanticsNode?.semanticsConfiguration ?: return
+ val config = semanticsNode?.collapsedSemantics ?: return
if (!config.isMergingSemanticsOfDescendants) {
semanticsNode
.findClosestParentNode {
- it.semanticsConfiguration?.isMergingSemanticsOfDescendants == true
+ it.collapsedSemantics?.isMergingSemanticsOfDescendants == true
}
?.let { semanticsNode = it }
}
@@ -2901,353 +3236,6 @@
}
}
-private fun setTraversalValues(
- currentSemanticsNodes: IntObjectMap<SemanticsNodeWithAdjustedBounds>,
- idToBeforeMap: MutableIntIntMap,
- idToAfterMap: MutableIntIntMap,
- resources: Resources
-) {
- idToBeforeMap.clear()
- idToAfterMap.clear()
-
- val hostSemanticsNode =
- currentSemanticsNodes[AccessibilityNodeProviderCompat.HOST_VIEW_ID]?.semanticsNode!!
- val hostLayoutIsRtl = hostSemanticsNode.isRtl
-
- val semanticsOrderList =
- subtreeSortedByGeometryGrouping(
- hostLayoutIsRtl,
- listOf(hostSemanticsNode),
- currentSemanticsNodes,
- resources
- )
-
- // Iterate through our ordered list, and creating a mapping of current node to next node ID
- // We'll later read through this and set traversal order with IdToBeforeMap
- for (i in 1..semanticsOrderList.lastIndex) {
- val prevId = semanticsOrderList[i - 1].id
- val currId = semanticsOrderList[i].id
- idToBeforeMap[prevId] = currId
- idToAfterMap[currId] = prevId
- }
-}
-
-/**
- * This function prepares a subtree for `sortByGeometryGroupings` by retrieving all non-container
- * nodes and adding them to the list to be geometrically sorted. We recurse on containers (if they
- * exist) and add their sorted children to an optional mapping. The list to be sorted and child
- * mapping is passed into `sortByGeometryGroupings`.
- */
-private fun subtreeSortedByGeometryGrouping(
- layoutIsRtl: Boolean,
- listToSort: List<SemanticsNode>,
- currentSemanticsNodes: IntObjectMap<SemanticsNodeWithAdjustedBounds>,
- resources: Resources
-): MutableList<SemanticsNode> {
- // This should be mapping of [containerID: listOfSortedChildren], only populated if there
- // are container nodes in this level. If there are container nodes, `containerMapToChildren`
- // would look like {containerId: [sortedChild, sortedChild], containerId: [sortedChild]}
- val containerMapToChildren = mutableIntObjectMapOf<MutableList<SemanticsNode>>()
- val geometryList = ArrayList<SemanticsNode>()
-
- listToSort.fastForEach { node ->
- geometryDepthFirstSearch(
- node,
- geometryList,
- containerMapToChildren,
- currentSemanticsNodes,
- resources
- )
- }
-
- return sortByGeometryGroupings(layoutIsRtl, geometryList, resources, containerMapToChildren)
-}
-
-private fun geometryDepthFirstSearch(
- currNode: SemanticsNode,
- geometryList: ArrayList<SemanticsNode>,
- containerMapToChildren: MutableIntObjectMap<MutableList<SemanticsNode>>,
- currentSemanticsNodes: IntObjectMap<SemanticsNodeWithAdjustedBounds>,
- resources: Resources
-) {
- val currRTL = currNode.isRtl
- // We only want to add children that are either traversalGroups or are
- // screen reader focusable. The child must also be in the current pruned semantics tree.
- val isTraversalGroup =
- currNode.unmergedConfig.getOrElse(SemanticsProperties.IsTraversalGroup) { false }
-
- if (
- (isTraversalGroup || isScreenReaderFocusable(currNode, resources)) &&
- currentSemanticsNodes.containsKey(currNode.id)
- ) {
- geometryList.add(currNode)
- }
- if (isTraversalGroup) {
- // Recurse and record the container's children, sorted
- containerMapToChildren[currNode.id] =
- subtreeSortedByGeometryGrouping(
- currRTL,
- currNode.children,
- currentSemanticsNodes,
- resources
- )
- } else {
- // Otherwise, continue adding children to the list that'll be sorted regardless of
- // hierarchy
- currNode.children.fastForEach { child ->
- geometryDepthFirstSearch(
- child,
- geometryList,
- containerMapToChildren,
- currentSemanticsNodes,
- resources
- )
- }
- }
-}
-
-/**
- * Returns the results of geometry groupings, which is determined from 1) grouping nodes into
- * distinct, non-overlapping rows based on their top/bottom coordinates, then 2) sorting nodes
- * within each row with the semantics comparator.
- *
- * This method approaches traversal order with more nuance than an approach considering only just
- * hierarchy or only just an individual node's bounds.
- *
- * If [containerChildrenMapping] exists, there are additional children to add, as well as the sorted
- * parent itself
- */
-private fun sortByGeometryGroupings(
- layoutIsRtl: Boolean,
- parentListToSort: ArrayList<SemanticsNode>,
- resources: Resources,
- containerChildrenMapping: MutableIntObjectMap<MutableList<SemanticsNode>> =
- mutableIntObjectMapOf()
-): MutableList<SemanticsNode> {
- // RowGroupings list consists of pairs, first = a rectangle of the bounds of the row
- // and second = the list of nodes in that row
- val rowGroupings = ArrayList<Pair<Rect, MutableList<SemanticsNode>>>(parentListToSort.size / 2)
-
- for (entryIndex in 0..parentListToSort.lastIndex) {
- val currEntry = parentListToSort[entryIndex]
- // If this is the first entry, or vertical groups don't overlap
- if (entryIndex == 0 || !placedEntryRowOverlaps(rowGroupings, currEntry)) {
- val newRect = currEntry.boundsInWindow
- rowGroupings.add(Pair(newRect, mutableListOf(currEntry)))
- } // otherwise, we've already iterated through, found and placed it in a matching group
- }
-
- // Sort the rows from top to bottom
- rowGroupings.sortWith(TopBottomBoundsComparator)
-
- val returnList = ArrayList<SemanticsNode>()
- val comparator = semanticComparators[if (layoutIsRtl) 0 else 1]
- rowGroupings.fastForEach { row ->
- // Sort each individual row's parent nodes
- row.second.sortWith(comparator)
- returnList.addAll(row.second)
- }
-
- returnList.sortWith(UnmergedConfigComparator)
-
- var i = 0
- // Afterwards, go in and add the containers' children.
- while (i <= returnList.lastIndex) {
- val currNodeId = returnList[i].id
- // If a parent node is a container, then add its children.
- // Add all container's children after the container itself.
- // Because we've already recursed on the containers children, the children should
- // also be sorted by their traversal index
- val containersChildrenList = containerChildrenMapping[currNodeId]
- if (containersChildrenList != null) {
- val containerIsScreenReaderFocusable = isScreenReaderFocusable(returnList[i], resources)
- if (!containerIsScreenReaderFocusable) {
- // Container is removed if it is not screenreader-focusable
- returnList.removeAt(i)
- } else {
- // Increase counter if the container was not removed
- i += 1
- }
- // Add all the container's children and increase counter by the number of children
- returnList.addAll(i, containersChildrenList)
- i += containersChildrenList.size
- } else {
- // Advance to the next item
- i += 1
- }
- }
- return returnList
-}
-
-private fun isScreenReaderFocusable(node: SemanticsNode, resources: Resources): Boolean {
- val nodeContentDescriptionOrNull =
- node.unmergedConfig.getOrNull(SemanticsProperties.ContentDescription)?.firstOrNull()
- val isSpeakingNode =
- nodeContentDescriptionOrNull != null ||
- getInfoText(node) != null ||
- getInfoStateDescriptionOrNull(node, resources) != null ||
- getInfoIsCheckable(node)
-
- return !node.isHidden &&
- (node.unmergedConfig.isMergingSemanticsOfDescendants ||
- node.isUnmergedLeafNode && isSpeakingNode)
-}
-
-private fun getInfoText(node: SemanticsNode): AnnotatedString? {
- val editableTextToAssign = node.unmergedConfig.getOrNull(SemanticsProperties.EditableText)
- val textToAssign = node.unmergedConfig.getOrNull(SemanticsProperties.Text)?.firstOrNull()
- return editableTextToAssign ?: textToAssign
-}
-
-private fun getInfoStateDescriptionOrNull(node: SemanticsNode, resources: Resources): String? {
- var stateDescription = node.unmergedConfig.getOrNull(SemanticsProperties.StateDescription)
- val toggleState = node.unmergedConfig.getOrNull(SemanticsProperties.ToggleableState)
- val role = node.unmergedConfig.getOrNull(SemanticsProperties.Role)
-
- // Check toggle state and retrieve description accordingly
- toggleState?.let {
- when (it) {
- ToggleableState.On -> {
- // Unfortunately, talkback has a bug of using "checked", so we set state
- // description here
- if (role == Role.Switch && stateDescription == null) {
- stateDescription = resources.getString(R.string.state_on)
- }
- }
- ToggleableState.Off -> {
- // Unfortunately, talkback has a bug of using "not checked", so we set state
- // description here
- if (role == Role.Switch && stateDescription == null) {
- stateDescription = resources.getString(R.string.state_off)
- }
- }
- ToggleableState.Indeterminate -> {
- if (stateDescription == null) {
- stateDescription = resources.getString(R.string.indeterminate)
- }
- }
- }
- }
-
- // Check Selected property and retrieve description accordingly
- node.unmergedConfig.getOrNull(SemanticsProperties.Selected)?.let {
- if (role != Role.Tab) {
- if (stateDescription == null) {
- // If a radio entry (radio button + text) is selectable, it won't have the role
- // RadioButton, so if we use info.isCheckable/info.isChecked, talkback will say
- // "checked/not checked" instead "selected/note selected".
- stateDescription =
- if (it) {
- resources.getString(R.string.selected)
- } else {
- resources.getString(R.string.not_selected)
- }
- }
- }
- }
-
- // Check if a node has progress bar range info and retrieve description accordingly
- val rangeInfo = node.unmergedConfig.getOrNull(SemanticsProperties.ProgressBarRangeInfo)
- rangeInfo?.let {
- // let's set state description here and use state description change events.
- // otherwise, we need to send out type_view_selected event, as the old android
- // versions do. But the support for type_view_selected event for progress bars
- // maybe deprecated in talkback in the future.
- if (rangeInfo !== ProgressBarRangeInfo.Indeterminate) {
- if (stateDescription == null) {
- val valueRange = rangeInfo.range
- val progress =
- (if (valueRange.endInclusive - valueRange.start == 0f) 0f
- else
- (rangeInfo.current - valueRange.start) /
- (valueRange.endInclusive - valueRange.start))
- .fastCoerceIn(0f, 1f)
-
- // We only display 0% or 100% when it is exactly 0% or 100%.
- val percent =
- when (progress) {
- 0f -> 0
- 1f -> 100
- else -> (progress * 100).fastRoundToInt().coerceIn(1, 99)
- }
- stateDescription = resources.getString(R.string.template_percent, percent)
- }
- } else if (stateDescription == null) {
- stateDescription = resources.getString(R.string.in_progress)
- }
- }
-
- if (node.unmergedConfig.contains(SemanticsProperties.EditableText)) {
- stateDescription = createStateDescriptionForTextField(node, resources)
- }
-
- return stateDescription
-}
-
-/**
- * Empty text field should not be ignored by the TB so we set a state description. When there is a
- * speakable child, like a label or a placeholder text, setting this state description is redundant
- */
-private fun createStateDescriptionForTextField(node: SemanticsNode, resources: Resources): String? {
- val mergedConfig = node.copyWithMergingEnabled().config
- val mergedNodeIsUnspeakable =
- mergedConfig.getOrNull(SemanticsProperties.ContentDescription).isNullOrEmpty() &&
- mergedConfig.getOrNull(SemanticsProperties.Text).isNullOrEmpty() &&
- mergedConfig.getOrNull(SemanticsProperties.EditableText).isNullOrEmpty()
- return if (mergedNodeIsUnspeakable) resources.getString(R.string.state_empty) else null
-}
-
-private fun getInfoIsCheckable(node: SemanticsNode): Boolean {
- var isCheckable = false
- val toggleState = node.unmergedConfig.getOrNull(SemanticsProperties.ToggleableState)
- val role = node.unmergedConfig.getOrNull(SemanticsProperties.Role)
-
- toggleState?.let { isCheckable = true }
-
- node.unmergedConfig.getOrNull(SemanticsProperties.Selected)?.let {
- if (role != Role.Tab) {
- isCheckable = true
- }
- }
-
- return isCheckable
-}
-
-// check to see if this entry overlaps with any groupings in rowGroupings
-private fun placedEntryRowOverlaps(
- rowGroupings: ArrayList<Pair<Rect, MutableList<SemanticsNode>>>,
- node: SemanticsNode
-): Boolean {
- // Conversion to long is needed in order to utilize `until`, which has no float ver
- val entryTopCoord = node.boundsInWindow.top
- val entryBottomCoord = node.boundsInWindow.bottom
- val entryIsEmpty = entryTopCoord >= entryBottomCoord
-
- for (currIndex in 0..rowGroupings.lastIndex) {
- val currRect = rowGroupings[currIndex].first
- val groupIsEmpty = currRect.top >= currRect.bottom
- val groupOverlapsEntry =
- !entryIsEmpty &&
- !groupIsEmpty &&
- max(entryTopCoord, currRect.top) < min(entryBottomCoord, currRect.bottom)
-
- // If it overlaps with this row group, update cover and add node
- if (groupOverlapsEntry) {
- val newRect =
- currRect.intersect(0f, entryTopCoord, Float.POSITIVE_INFINITY, entryBottomCoord)
- // Replace the cover rectangle, copying over the old list of nodes
- rowGroupings[currIndex] = Pair(newRect, rowGroupings[currIndex].second)
- // Add current node
- rowGroupings[currIndex].second.add(node)
- // We've found an overlapping group, return true
- return true
- }
- }
-
- // If we've made it here, then there are no groups our entry overlaps with
- return false
-}
-
// TODO(mnuzen): Move common semantics logic into `SemanticsUtils` file to make a11y delegate
// shorter and more readable.
private fun SemanticsNode.enabled() = (!config.contains(SemanticsProperties.Disabled))
@@ -3276,12 +3264,12 @@
val ancestor =
layoutNode.findClosestParentNode {
// looking for text field merging node
- val ancestorSemanticsConfiguration = it.semanticsConfiguration
+ val ancestorSemanticsConfiguration = it.collapsedSemantics
ancestorSemanticsConfiguration?.isMergingSemanticsOfDescendants == true &&
ancestorSemanticsConfiguration.contains(SemanticsProperties.EditableText)
}
return ancestor != null &&
- ancestor.semanticsConfiguration?.getOrNull(SemanticsProperties.Focused) != true
+ ancestor.collapsedSemantics?.getOrNull(SemanticsProperties.Focused) != true
}
private fun AccessibilityAction<*>.accessibilityEquals(other: Any?): Boolean {
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/Wrapper.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/Wrapper.android.kt
index 88590f8..87dd475 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/Wrapper.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/Wrapper.android.kt
@@ -17,7 +17,7 @@
import android.view.View
import android.view.ViewGroup
-import androidx.annotation.MainThread
+import androidx.compose.runtime.AbstractApplier
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Composition
import androidx.compose.runtime.CompositionContext
@@ -26,7 +26,6 @@
import androidx.compose.runtime.CompositionServices
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Recomposer
-import androidx.compose.runtime.ReusableComposition
import androidx.compose.runtime.currentComposer
import androidx.compose.runtime.tooling.CompositionData
import androidx.compose.runtime.tooling.LocalInspectionTables
@@ -39,11 +38,8 @@
import java.util.Collections
import java.util.WeakHashMap
-@MainThread
-internal actual fun createSubcomposition(
- container: LayoutNode,
- parent: CompositionContext
-): ReusableComposition = ReusableComposition(UiApplier(container), parent)
+internal actual fun createApplier(container: LayoutNode): AbstractApplier<LayoutNode> =
+ UiApplier(container)
/**
* Composes the given composable into the given view.
diff --git a/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/LayoutNodeTest.kt b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/LayoutNodeTest.kt
index e7d573d..0a54858 100644
--- a/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/LayoutNodeTest.kt
+++ b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/LayoutNodeTest.kt
@@ -17,8 +17,6 @@
package androidx.compose.ui.node
-import androidx.collection.IntObjectMap
-import androidx.collection.intObjectMapOf
import androidx.compose.testutils.TestViewConfiguration
import androidx.compose.ui.InternalComposeUiApi
import androidx.compose.ui.Modifier
@@ -67,10 +65,8 @@
import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.platform.WindowInfo
import androidx.compose.ui.platform.invertTo
-import androidx.compose.ui.semantics.EmptySemanticsModifier
import androidx.compose.ui.semantics.SemanticsConfiguration
import androidx.compose.ui.semantics.SemanticsModifier
-import androidx.compose.ui.semantics.SemanticsOwner
import androidx.compose.ui.semantics.SemanticsPropertyReceiver
import androidx.compose.ui.spatial.RectManager
import androidx.compose.ui.text.font.Font
@@ -2317,8 +2313,6 @@
internal class MockOwner(
private val position: IntOffset = IntOffset.Zero,
override val root: LayoutNode = LayoutNode(),
- override val semanticsOwner: SemanticsOwner =
- SemanticsOwner(root, EmptySemanticsModifier(), intObjectMapOf()),
override val coroutineContext: CoroutineContext =
Executors.newFixedThreadPool(3).asCoroutineDispatcher()
) : Owner {
@@ -2551,9 +2545,6 @@
override val viewConfiguration: ViewConfiguration
get() = TODO("Not yet implemented")
- override val layoutNodes: IntObjectMap<LayoutNode>
- get() = TODO("Not yet implemented")
-
override val sharedDrawScope = LayoutNodeDrawScope()
}
diff --git a/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/ModifierLocalConsumerEntityTest.kt b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/ModifierLocalConsumerEntityTest.kt
index 7ff7ee0..b95e433 100644
--- a/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/ModifierLocalConsumerEntityTest.kt
+++ b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/ModifierLocalConsumerEntityTest.kt
@@ -18,8 +18,6 @@
package androidx.compose.ui.node
-import androidx.collection.IntObjectMap
-import androidx.collection.intObjectMapOf
import androidx.compose.runtime.collection.mutableVectorOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -51,8 +49,6 @@
import androidx.compose.ui.platform.TextToolbar
import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.platform.WindowInfo
-import androidx.compose.ui.semantics.EmptySemanticsModifier
-import androidx.compose.ui.semantics.SemanticsOwner
import androidx.compose.ui.spatial.RectManager
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
@@ -336,9 +332,7 @@
override fun onDetach(node: LayoutNode) {}
- override val root: LayoutNode = LayoutNode()
-
- override val layoutNodes: IntObjectMap<LayoutNode>
+ override val root: LayoutNode
get() = TODO("Not yet implemented")
override val sharedDrawScope: LayoutNodeDrawScope
@@ -380,9 +374,6 @@
override val focusOwner: FocusOwner
get() = TODO("Not yet implemented")
- override val semanticsOwner: SemanticsOwner =
- SemanticsOwner(root, EmptySemanticsModifier(), intObjectMapOf())
-
override val windowInfo: WindowInfo
get() = TODO("Not yet implemented")
@@ -449,7 +440,7 @@
override fun forceMeasureTheSubtree(layoutNode: LayoutNode, affectsLookahead: Boolean) =
TODO("Not yet implemented")
- override fun onSemanticsChange() {}
+ override fun onSemanticsChange() = TODO("Not yet implemented")
override fun onLayoutChange(layoutNode: LayoutNode) = TODO("Not yet implemented")
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTargetModifierNode.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTargetModifierNode.kt
index 6ef0a80..30f1c82 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTargetModifierNode.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTargetModifierNode.kt
@@ -87,8 +87,7 @@
level = DeprecationLevel.HIDDEN
)
@JsName("funFocusTargetModifierNode")
-fun FocusTargetModifierNode(): FocusTargetModifierNode =
- FocusTargetNode(onDispatchEventsCompleted = InvalidateSemantics::onDispatchEventsCompleted)
+fun FocusTargetModifierNode(): FocusTargetModifierNode = FocusTargetNode()
/**
* Create a [FocusTargetModifierNode] that can be delegated to in order to create a modifier that
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTargetNode.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTargetNode.kt
index 10ee1a9..95d2b93 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTargetNode.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTargetNode.kt
@@ -46,8 +46,7 @@
internal class FocusTargetNode(
focusability: Focusability = Focusability.Always,
- private val onFocusChange: ((previous: FocusState, current: FocusState) -> Unit)? = null,
- private val onDispatchEventsCompleted: ((FocusTargetNode) -> Unit)? = null
+ private val onFocusChange: ((previous: FocusState, current: FocusState) -> Unit)? = null
) :
CompositionLocalConsumerModifierNode,
FocusTargetModifierNode,
@@ -276,7 +275,7 @@
val focusState = focusState
// Avoid invoking callback when we initialize the state (from `null` to Inactive) or
// if we are detached and go from Inactive to `null` - there isn't a conceptual focus
- // state change here.
+ // state change here
if (previousOrInactive != focusState) {
onFocusChange?.invoke(previousOrInactive, focusState)
}
@@ -284,8 +283,6 @@
// TODO(251833873): Consider caching it.getFocusState().
it.onFocusEvent(it.getFocusState())
}
-
- onDispatchEventsCompleted?.invoke(this)
}
internal object FocusTargetElement : ModifierNodeElement<FocusTargetNode>() {
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt
index ad9e5cb..2da7de0 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt
@@ -19,8 +19,6 @@
import androidx.compose.runtime.CompositionLocalMap
import androidx.compose.runtime.collection.MutableVector
import androidx.compose.runtime.collection.mutableVectorOf
-import androidx.compose.ui.ComposeUiFlags
-import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.InternalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
@@ -56,7 +54,6 @@
import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.platform.simpleIdentityToString
import androidx.compose.ui.semantics.SemanticsConfiguration
-import androidx.compose.ui.semantics.SemanticsInfo
import androidx.compose.ui.semantics.generateSemanticsId
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
@@ -91,7 +88,6 @@
Remeasurement,
OwnerScope,
LayoutInfo,
- SemanticsInfo,
ComposeUiNode,
InteroperableComposeUiNode,
Owner.OnLayoutCompletedListener {
@@ -398,62 +394,43 @@
invalidateMeasurements()
}
- private var _semanticsConfiguration: SemanticsConfiguration? = null
- override val semanticsConfiguration: SemanticsConfiguration?
- get() {
- // This is needed until we completely move to the new world where we always pre-compute
- // the semantics configuration. At that point, this can be replaced by
- // check(!isSemanticsInvalidated) or remove this custom getter.
- if (isSemanticsInvalidated) {
- _semanticsConfiguration = calculateSemanticsConfiguration()
- }
- return _semanticsConfiguration
- }
-
- private fun calculateSemanticsConfiguration(): SemanticsConfiguration? {
- if (!nodes.has(Nodes.Semantics)) return null
-
- var config = SemanticsConfiguration()
- requireOwner().snapshotObserver.observeSemanticsReads(this) {
- nodes.tailToHead(Nodes.Semantics) {
- if (it.shouldClearDescendantSemantics) {
- config = SemanticsConfiguration()
- config.isClearingSemantics = true
- }
- if (it.shouldMergeDescendantSemantics) {
- config.isMergingSemanticsOfDescendants = true
- }
- with(config) { with(it) { applySemantics() } }
- }
- }
- return config
- }
-
- private var isSemanticsInvalidated = false
+ private var _collapsedSemantics: SemanticsConfiguration? = null
internal fun invalidateSemantics() {
- if (
- @OptIn(ExperimentalComposeUiApi::class) !ComposeUiFlags.isSemanticAutofillEnabled ||
- nodes.isUpdating ||
- applyingModifierOnAttach
- ) {
- // We are currently updating the modifier, so just schedule an invalidation. After
- // applying the modifier, we will notify listeners of semantics changes.
- isSemanticsInvalidated = true
- } else {
- // We are not currently updating the modifier, so instead of scheduling invalidation,
- // we update the semantics configuration and send the notification event right away.
- val prev = _semanticsConfiguration
- _semanticsConfiguration = calculateSemanticsConfiguration()
- requireOwner().semanticsOwner.notifySemanticsChange(this, prev)
- }
-
+ _collapsedSemantics = null
// TODO(lmr): this ends up scheduling work that diffs the entire tree, but we should
// eventually move to marking just this node as invalidated since we are invalidating
// on a per-node level. This should preserve current behavior for now.
requireOwner().onSemanticsChange()
}
+ internal val collapsedSemantics: SemanticsConfiguration?
+ get() {
+ // TODO: investigate if there's a better way to approach "half attached" state and
+ // whether or not deactivated nodes should be considered removed or not.
+ if (!isAttached || isDeactivated) return null
+
+ if (!nodes.has(Nodes.Semantics) || _collapsedSemantics != null) {
+ return _collapsedSemantics
+ }
+
+ var config = SemanticsConfiguration()
+ requireOwner().snapshotObserver.observeSemanticsReads(this) {
+ nodes.tailToHead(Nodes.Semantics) {
+ if (it.shouldClearDescendantSemantics) {
+ config = SemanticsConfiguration()
+ config.isClearingSemantics = true
+ }
+ if (it.shouldMergeDescendantSemantics) {
+ config.isMergingSemanticsOfDescendants = true
+ }
+ with(config) { with(it) { applySemantics() } }
+ }
+ }
+ _collapsedSemantics = config
+ return config
+ }
+
/**
* Set the [Owner] of this LayoutNode. This LayoutNode must not already be attached. [owner]
* must match its [parent].[owner].
@@ -486,11 +463,9 @@
pendingModifier?.let { applyModifier(it) }
pendingModifier = null
- @OptIn(ExperimentalComposeUiApi::class)
- if (!ComposeUiFlags.isSemanticAutofillEnabled && nodes.has(Nodes.Semantics)) {
+ if (nodes.has(Nodes.Semantics)) {
invalidateSemantics()
}
-
owner.onAttach(this)
// Update lookahead root when attached. For nested cases, we'll always use the
@@ -509,12 +484,6 @@
if (!isDeactivated) {
nodes.markAsAttached()
}
-
- @OptIn(ExperimentalComposeUiApi::class)
- if (ComposeUiFlags.isSemanticAutofillEnabled && nodes.has(Nodes.Semantics)) {
- invalidateSemantics()
- }
-
_foldedChildren.forEach { child -> child.attach(owner) }
if (!isDeactivated) {
nodes.runAttachLifecycle()
@@ -548,10 +517,9 @@
}
layoutDelegate.resetAlignmentLines()
onDetach?.invoke(owner)
+
if (nodes.has(Nodes.Semantics)) {
- _semanticsConfiguration = null
- isSemanticsInvalidated = false
- requireOwner().onSemanticsChange()
+ invalidateSemantics()
}
nodes.runDetachLifecycle()
ignoreRemeasureRequests { _foldedChildren.forEach { child -> child.detach() } }
@@ -587,10 +555,6 @@
return _zSortedChildren
}
- @Suppress("UNCHECKED_CAST")
- override val childrenInfo: MutableVector<SemanticsInfo>
- get() = zSortedChildren as MutableVector<SemanticsInfo>
-
override val isValidOwnerScope: Boolean
get() = isAttached
@@ -902,14 +866,6 @@
if (lookaheadRoot == null && nodes.has(Nodes.ApproachMeasure)) {
lookaheadRoot = this
}
- // Notify semantics listeners if semantics was invalidated.
- @OptIn(ExperimentalComposeUiApi::class)
- if (ComposeUiFlags.isSemanticAutofillEnabled && isSemanticsInvalidated) {
- val prev = _semanticsConfiguration
- _semanticsConfiguration = calculateSemanticsConfiguration()
- isSemanticsInvalidated = false
- requireOwner().semanticsOwner.notifySemanticsChange(this, prev)
- }
}
private fun resetModifierState() {
@@ -1314,7 +1270,7 @@
}
}
- override val parentInfo: SemanticsInfo?
+ override val parentInfo: LayoutInfo?
get() = parent
override var isDeactivated = false
@@ -1326,17 +1282,15 @@
subcompositionsState?.onReuse()
if (isDeactivated) {
isDeactivated = false
+ invalidateSemantics()
// we don't need to reset state as it was done when deactivated
} else {
resetModifierState()
}
- val oldSemanticsId = semanticsId
- semanticsId = generateSemanticsId()
- owner?.onLayoutNodeIdChange(this, oldSemanticsId)
// resetModifierState detaches all nodes, so we need to re-attach them upon reuse.
+ semanticsId = generateSemanticsId()
nodes.markAsAttached()
nodes.runAttachLifecycle()
- if (nodes.has(Nodes.Semantics)) invalidateSemantics()
rescheduleRemeasureOrRelayout(this)
}
@@ -1345,8 +1299,10 @@
subcompositionsState?.onDeactivate()
isDeactivated = true
resetModifierState()
- _semanticsConfiguration = null
- isSemanticsInvalidated = false
+ // if the node is detached the semantics were already updated without this node.
+ if (isAttached) {
+ invalidateSemantics()
+ }
owner?.onLayoutNodeDeactivated(this)
}
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeChain.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeChain.kt
index c08a929..ff19036 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeChain.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeChain.kt
@@ -40,8 +40,8 @@
internal var head: Modifier.Node = tail
private set
- internal val isUpdating: Boolean
- get() = head.parent != null
+ private val isUpdating: Boolean
+ get() = head === SentinelHead
private val aggregateChildKindSet: Int
get() = head.aggregateChildKindSet
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeCoordinator.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeCoordinator.kt
index f102b02..0ce80dd 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeCoordinator.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeCoordinator.kt
@@ -1512,7 +1512,7 @@
override fun interceptOutOfBoundsChildEvents(node: Modifier.Node) = false
override fun shouldHitTestChildren(parentLayoutNode: LayoutNode) =
- parentLayoutNode.semanticsConfiguration?.isClearingSemantics != true
+ parentLayoutNode.collapsedSemantics?.isClearingSemantics != true
override fun childHitTest(
layoutNode: LayoutNode,
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/Owner.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/Owner.kt
index 040c537..3137f68 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/Owner.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/Owner.kt
@@ -18,7 +18,6 @@
package androidx.compose.ui.node
import androidx.annotation.RestrictTo
-import androidx.collection.IntObjectMap
import androidx.compose.runtime.Applier
import androidx.compose.ui.InternalComposeUiApi
import androidx.compose.ui.autofill.Autofill
@@ -47,7 +46,6 @@
import androidx.compose.ui.platform.TextToolbar
import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.platform.WindowInfo
-import androidx.compose.ui.semantics.SemanticsOwner
import androidx.compose.ui.spatial.RectManager
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
@@ -69,9 +67,6 @@
/** The root layout node in the component tree. */
val root: LayoutNode
- /** A mapping of semantic id to LayoutNode. */
- val layoutNodes: IntObjectMap<LayoutNode>
-
/** Draw scope reused for drawing speed up. */
val sharedDrawScope: LayoutNodeDrawScope
@@ -133,8 +128,6 @@
val pointerIconService: PointerIconService
- val semanticsOwner: SemanticsOwner
-
/** Provide a focus owner that controls focus within Compose. */
val focusOwner: FocusOwner
@@ -271,8 +264,6 @@
fun onLayoutNodeDeactivated(layoutNode: LayoutNode)
- fun onLayoutNodeIdChange(layoutNode: LayoutNode, oldSemanticsId: Int) {}
-
/**
* The position and/or size of an interop view (typically, an android.view.View) has changed. On
* Android, this schedules view tree layout observer callback to be invoked for the underlying
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/SemanticsModifierNode.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/SemanticsModifierNode.kt
index 002be43..dd3e2f8 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/SemanticsModifierNode.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/SemanticsModifierNode.kt
@@ -87,14 +87,7 @@
fun SemanticsPropertyReceiver.applySemantics()
}
-/**
- * Invalidate semantics associated with this node. This will reset the [SemanticsConfiguration]
- * associated with the layout node backing this modifier node, and will re-calculate it the next
- * time the [SemanticsConfiguration] is read.
- */
-fun SemanticsModifierNode.invalidateSemantics() {
- requireLayoutNode().invalidateSemantics()
-}
+fun SemanticsModifierNode.invalidateSemantics() = requireLayoutNode().invalidateSemantics()
internal val SemanticsConfiguration.useMinimumTouchTarget: Boolean
get() = getOrNull(SemanticsActions.OnClick) != null
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/platform/Subcomposition.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/platform/Subcomposition.kt
index fda825d..9ba947e 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/platform/Subcomposition.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/platform/Subcomposition.kt
@@ -15,12 +15,15 @@
*/
package androidx.compose.ui.platform
+import androidx.compose.runtime.AbstractApplier
import androidx.compose.runtime.CompositionContext
import androidx.compose.runtime.ReusableComposition
import androidx.compose.ui.node.LayoutNode
+internal expect fun createApplier(container: LayoutNode): AbstractApplier<LayoutNode>
+
/*@MainThread*/
-internal expect fun createSubcomposition(
+internal fun createSubcomposition(
container: LayoutNode,
parent: CompositionContext
-): ReusableComposition
+): ReusableComposition = ReusableComposition(createApplier(container), parent)
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsInfo.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsInfo.kt
deleted file mode 100644
index 1f2cc5d..0000000
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsInfo.kt
+++ /dev/null
@@ -1,85 +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.ui.semantics
-
-import androidx.compose.runtime.collection.MutableVector
-import androidx.compose.ui.layout.LayoutInfo
-import androidx.compose.ui.layout.Placeable
-import androidx.compose.ui.node.LayoutNode
-
-/**
- * This is an internal interface that can be used by [SemanticsListener]s to read semantic
- * information from layout nodes. The root [SemanticsInfo] can be accessed using
- * [SemanticsOwner.rootInfo], and particular [SemanticsInfo] can be looked up by their [semanticsId]
- * by using [SemanticsOwner.get].
- */
-internal interface SemanticsInfo : LayoutInfo {
- /** The semantics configuration (Semantic properties and actions) associated with this node. */
- val semanticsConfiguration: SemanticsConfiguration?
-
- /**
- * The [SemanticsInfo] of the parent.
- *
- * This includes parents that do not have any semantics modifiers.
- */
- override val parentInfo: SemanticsInfo?
-
- /**
- * Returns the children list sorted by their [LayoutNode.zIndex] first (smaller first) and the
- * order they were placed via [Placeable.placeAt] by parent (smaller first). Please note that
- * this list contains not placed items as well, so you have to manually filter them.
- *
- * Note that the object is reused so you shouldn't save it for later.
- */
- val childrenInfo: MutableVector<SemanticsInfo>
-}
-
-/** The semantics parent (nearest ancestor which has semantic properties). */
-internal fun SemanticsInfo.findSemanticsParent(): SemanticsInfo? {
- var parent = parentInfo
- while (parent != null) {
- if (parent.semanticsConfiguration != null) return parent
- parent = parent.parentInfo
- }
- return null
-}
-
-/** The nearest semantics ancestor that is merging descendants. */
-internal fun SemanticsInfo.findMergingSemanticsParent(): SemanticsInfo? {
- var parent = parentInfo
- while (parent != null) {
- if (parent.semanticsConfiguration?.isMergingSemanticsOfDescendants == true) return parent
- parent = parent.parentInfo
- }
- return null
-}
-
-internal inline fun SemanticsInfo.findSemanticsChildren(
- includeDeactivated: Boolean = false,
- block: (SemanticsInfo) -> Unit
-) {
- val unvisitedStack = MutableVector<SemanticsInfo>(childrenInfo.size)
- childrenInfo.forEachReversed { unvisitedStack += it }
- while (unvisitedStack.isNotEmpty()) {
- val child = unvisitedStack.removeAt(unvisitedStack.lastIndex)
- when {
- child.isDeactivated && !includeDeactivated -> continue
- child.semanticsConfiguration != null -> block(child)
- else -> child.childrenInfo.forEachReversed { unvisitedStack += it }
- }
- }
-}
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsListener.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsListener.kt
deleted file mode 100644
index b51d7c8..0000000
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsListener.kt
+++ /dev/null
@@ -1,34 +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.ui.semantics
-
-/** A listener that can be used to observe semantic changes. */
-internal interface SemanticsListener {
-
- /**
- * [onSemanticsChanged] is called when the [SemanticsConfiguration] of a LayoutNode changes, or
- * when a node calls SemanticsModifierNode.invalidateSemantics.
- *
- * @param semanticsInfo the current [SemanticsInfo] of the layout node that has changed.
- * @param previousSemanticsConfiguration the previous [SemanticsConfiguration] associated with
- * the layout node.
- */
- fun onSemanticsChanged(
- semanticsInfo: SemanticsInfo,
- previousSemanticsConfiguration: SemanticsConfiguration?
- )
-}
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsNode.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsNode.kt
index 84ae0fc..35478d6 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsNode.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsNode.kt
@@ -45,7 +45,7 @@
layoutNode.nodes.head(Nodes.Semantics)!!.node,
mergingEnabled,
layoutNode,
- layoutNode.semanticsConfiguration ?: SemanticsConfiguration()
+ layoutNode.collapsedSemantics!!
)
internal fun SemanticsNode(
@@ -70,7 +70,7 @@
outerSemanticsNode.node,
mergingEnabled,
layoutNode,
- layoutNode.semanticsConfiguration ?: SemanticsConfiguration()
+ layoutNode.collapsedSemantics ?: SemanticsConfiguration()
)
/**
@@ -99,7 +99,7 @@
!isFake &&
replacedChildren.isEmpty() &&
layoutNode.findClosestParentNode {
- it.semanticsConfiguration?.isMergingSemanticsOfDescendants == true
+ it.collapsedSemantics?.isMergingSemanticsOfDescendants == true
} == null
/** The [LayoutInfo] that this is associated with. */
@@ -345,7 +345,7 @@
if (mergingEnabled) {
node =
this.layoutNode.findClosestParentNode {
- it.semanticsConfiguration?.isMergingSemanticsOfDescendants == true
+ it.collapsedSemantics?.isMergingSemanticsOfDescendants == true
}
}
@@ -474,9 +474,7 @@
* Executes [selector] on every parent of this [LayoutNode] and returns the closest [LayoutNode] to
* return `true` from [selector] or null if [selector] returns false for all ancestors.
*/
-internal inline fun LayoutNode.findClosestParentNode(
- selector: (LayoutNode) -> Boolean
-): LayoutNode? {
+internal fun LayoutNode.findClosestParentNode(selector: (LayoutNode) -> Boolean): LayoutNode? {
var currentParent = this.parent
while (currentParent != null) {
if (selector(currentParent)) {
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsOwner.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsOwner.kt
index b987155..dffed0f 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsOwner.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsOwner.kt
@@ -16,8 +16,6 @@
package androidx.compose.ui.semantics
-import androidx.collection.IntObjectMap
-import androidx.collection.MutableObjectList
import androidx.compose.ui.node.LayoutNode
import androidx.compose.ui.util.fastForEach
@@ -25,8 +23,7 @@
class SemanticsOwner
internal constructor(
private val rootNode: LayoutNode,
- private val outerSemanticsNode: EmptySemanticsModifier,
- private val nodes: IntObjectMap<LayoutNode>
+ private val outerSemanticsNode: EmptySemanticsModifier
) {
/**
* The root node of the semantics tree. Does not contain any unmerged data. May contain merged
@@ -50,22 +47,6 @@
unmergedConfig = SemanticsConfiguration()
)
}
-
- internal val listeners = MutableObjectList<SemanticsListener>(2)
-
- internal val rootInfo: SemanticsInfo
- get() = rootNode
-
- internal operator fun get(semanticsId: Int): SemanticsInfo? {
- return nodes[semanticsId]
- }
-
- internal fun notifySemanticsChange(
- semanticsInfo: SemanticsInfo,
- previousSemanticsConfiguration: SemanticsConfiguration?
- ) {
- listeners.forEach { it.onSemanticsChanged(semanticsInfo, previousSemanticsConfiguration) }
- }
}
/**
@@ -89,7 +70,6 @@
.toList()
}
-@Suppress("unused")
@Deprecated(message = "Use a new overload instead", level = DeprecationLevel.HIDDEN)
fun SemanticsOwner.getAllSemanticsNodes(mergingEnabled: Boolean) =
getAllSemanticsNodes(mergingEnabled, true)
diff --git a/compose/ui/ui/src/commonStubsMain/kotlin/androidx/compose/ui/platform/Wrapper.commonStubs.kt b/compose/ui/ui/src/commonStubsMain/kotlin/androidx/compose/ui/platform/Wrapper.commonStubs.kt
index 5150de3..77eb09a 100644
--- a/compose/ui/ui/src/commonStubsMain/kotlin/androidx/compose/ui/platform/Wrapper.commonStubs.kt
+++ b/compose/ui/ui/src/commonStubsMain/kotlin/androidx/compose/ui/platform/Wrapper.commonStubs.kt
@@ -15,12 +15,9 @@
*/
package androidx.compose.ui.platform
-import androidx.compose.runtime.CompositionContext
-import androidx.compose.runtime.ReusableComposition
+import androidx.compose.runtime.AbstractApplier
import androidx.compose.ui.implementedInJetBrainsFork
import androidx.compose.ui.node.LayoutNode
-internal actual fun createSubcomposition(
- container: LayoutNode,
- parent: CompositionContext
-): ReusableComposition = implementedInJetBrainsFork()
+internal actual fun createApplier(container: LayoutNode): AbstractApplier<LayoutNode> =
+ implementedInJetBrainsFork()
diff --git a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/E2EExtensionTests.kt b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/E2EExtensionTests.kt
index e19740a..582b073 100644
--- a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/E2EExtensionTests.kt
+++ b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/E2EExtensionTests.kt
@@ -58,7 +58,6 @@
import org.junit.Assert.fail
import org.junit.Assume.assumeTrue
import org.junit.Before
-import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -414,21 +413,16 @@
* This is an end to end test that verifies a VoIP application and InCallService can add the
* LocalCallSilenceExtension and toggle the value.
*/
- @SdkSuppress(
- minSdkVersion = VERSION_CODES.O,
- maxSdkVersion = VERSION_CODES.TIRAMISU
- ) // TODO:: b/377707977
@LargeTest
- @Ignore("b/377706280")
@Test(timeout = 10000)
fun testVoipAndIcsTogglingTheLocalCallSilenceExtension(): Unit = runBlocking {
usingIcs { ics ->
- val globalMuteStateReceiver = TestMuteStateReceiver(this)
- mContext.registerReceiver(
- globalMuteStateReceiver,
- IntentFilter(AudioManager.ACTION_MICROPHONE_MUTE_CHANGED)
- )
+ val globalMuteStateReceiver = TestMuteStateReceiver()
try {
+ mContext.registerReceiver(
+ globalMuteStateReceiver,
+ IntentFilter(AudioManager.ACTION_MICROPHONE_MUTE_CHANGED)
+ )
val voipAppControl = bindToVoipAppWithExtensions()
val callback = TestCallCallbackListener(this)
voipAppControl.setCallback(callback)
@@ -457,12 +451,18 @@
// the mute is called. Otherwise, telecom will unmute during the
// call setup
delay(500)
+ Log.i("LCS_Test", "manually muting the mic")
am.setMicrophoneMute(true)
assertTrue(am.isMicrophoneMute)
- globalMuteStateReceiver.waitForGlobalMuteState(true, "1")
+ waitForGlobalMuteState(true, "1", callback, globalMuteStateReceiver)
// LocalCallSilenceExtensionImpl handles globally unmuting the
// microphone
- globalMuteStateReceiver.waitForGlobalMuteState(false, "2")
+ waitForGlobalMuteState(
+ false,
+ "2",
+ callback,
+ globalMuteStateReceiver
+ )
}
// VoIP --> ICS
@@ -478,11 +478,16 @@
callback.waitForIsLocalSilenced(voipCallId, false)
// set the call state via voip app control
- if (VERSION.SDK_INT >= VERSION_CODES.P) {
+ if (VERSION.SDK_INT >= VERSION_CODES.R) {
call.hold()
- globalMuteStateReceiver.waitForGlobalMuteState(true, "3")
+ waitForGlobalMuteState(true, "3", callback, globalMuteStateReceiver)
call.unhold()
- globalMuteStateReceiver.waitForGlobalMuteState(false, "4")
+ waitForGlobalMuteState(
+ false,
+ "4",
+ callback,
+ globalMuteStateReceiver
+ )
}
call.disconnect()
}
@@ -495,6 +500,19 @@
}
}
+ private suspend fun waitForGlobalMuteState(
+ expectedValue: Boolean,
+ tag: String,
+ cb: TestCallCallbackListener,
+ receiver: TestMuteStateReceiver
+ ) {
+ if (VERSION.SDK_INT >= VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ cb.waitForGlobalMuteState(expectedValue, tag)
+ } else if (VERSION.SDK_INT >= VERSION_CODES.P) {
+ receiver.waitForGlobalMuteState(expectedValue, tag)
+ }
+ }
+
/**
* Create a VOIP call with a participants extension and attach participant Call extensions.
* Verify kick participant functionality works as expected
diff --git a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/utils/TestMuteStateReceiver.kt b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/utils/TestMuteStateReceiver.kt
new file mode 100644
index 0000000..dc5983d
--- /dev/null
+++ b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/utils/TestMuteStateReceiver.kt
@@ -0,0 +1,60 @@
+/*
+ * 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.core.telecom.test.utils
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.media.AudioManager
+import android.util.Log
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.withTimeoutOrNull
+import org.junit.Assert.assertEquals
+
+class TestMuteStateReceiver : BroadcastReceiver() {
+ private val isMutedFlow: MutableStateFlow<Boolean> = MutableStateFlow(false)
+
+ companion object {
+ private val TAG: String = TestMuteStateReceiver::class.java.simpleName.toString()
+ }
+
+ suspend fun waitForGlobalMuteState(isMuted: Boolean, id: String = "") {
+ Log.i(TAG, "waitForGlobalMuteState: v=[$isMuted], id=[$id]")
+ val result =
+ withTimeoutOrNull(5000) {
+ isMutedFlow
+ .filter {
+ Log.i(TAG, "it=[$isMuted], isMuted=[$isMuted]")
+ it == isMuted
+ }
+ .firstOrNull()
+ }
+ Log.i(TAG, "asserting id=[$id], result=$result")
+ assertEquals("Global Mute State {$id} never reached the expected state", isMuted, result)
+ }
+
+ override fun onReceive(context: Context, intent: Intent) {
+ if (AudioManager.ACTION_MICROPHONE_MUTE_CHANGED == intent.action) {
+ val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
+ val isMicGloballyMuted = audioManager.isMicrophoneMute
+ Log.i(TAG, "onReceive: isMicGloballyMuted=[${isMicGloballyMuted}]")
+ isMutedFlow.value = isMicGloballyMuted
+ }
+ }
+}
diff --git a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/utils/TestUtils.kt b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/utils/TestUtils.kt
index af9dec8..36a49d80 100644
--- a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/utils/TestUtils.kt
+++ b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/utils/TestUtils.kt
@@ -16,9 +16,7 @@
package androidx.core.telecom.test.utils
-import android.content.BroadcastReceiver
import android.content.Context
-import android.content.Intent
import android.media.AudioManager
import android.net.Uri
import android.os.Build
@@ -49,6 +47,7 @@
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@@ -414,8 +413,12 @@
private val callAddedFlow: MutableSharedFlow<Pair<Int, String>> = MutableSharedFlow(replay = 1)
private val isMutedFlow: MutableStateFlow<Boolean> = MutableStateFlow(false)
+ companion object {
+ private val TAG: String = TestCallCallbackListener::class.java.simpleName.toString()
+ }
+
override fun onGlobalMuteStateChanged(isMuted: Boolean) {
- Log.i("TestCallCallbackListener", "onGlobalMuteStateChanged: isMuted: $isMuted")
+ Log.i(TAG, "onGlobalMuteStateChanged: isMuted: $isMuted")
scope.launch { isMutedFlow.emit(isMuted) }
}
@@ -463,9 +466,19 @@
assertEquals("<LOCAL CALL SILENCE> never received", expectedState, result?.second)
}
- suspend fun waitForGlobalMuteState(isMuted: Boolean) {
- val result = withTimeoutOrNull(5000) { isMutedFlow.filter { it == isMuted }.first() }
- assertEquals("Global mute state never reached the expected state", isMuted, result)
+ suspend fun waitForGlobalMuteState(isMuted: Boolean, id: String = "") {
+ Log.i(TAG, "waitForGlobalMuteState: v=[$isMuted], id=[$id]")
+ val result =
+ withTimeoutOrNull(5000) {
+ isMutedFlow
+ .filter {
+ Log.i(TAG, "it=[$isMuted], isMuted=[$isMuted]")
+ it == isMuted
+ }
+ .firstOrNull()
+ }
+ Log.i(TAG, "asserting id=[$id], result=$result")
+ assertEquals("Global Mute State {$id} never reached the expected state", isMuted, result)
}
suspend fun waitForKickParticipant(callId: String, expectedParticipant: Participant?) {
@@ -478,27 +491,3 @@
assertEquals("kick participant action never received", expectedParticipant, result?.second)
}
}
-
-class TestMuteStateReceiver(private val scope: CoroutineScope) : BroadcastReceiver() {
- private val isMutedFlow: MutableStateFlow<Boolean> = MutableStateFlow(false)
-
- suspend fun waitForGlobalMuteState(isMuted: Boolean, id: String = "") {
- val result =
- withTimeoutOrNull(5000) {
- isMutedFlow
- .filter {
- Log.i("TestMuteStateReceiver", "received $isMuted")
- it == isMuted
- }
- .first()
- }
- assertEquals("Global Mute State {$id} never reached the expected state", isMuted, result)
- }
-
- override fun onReceive(context: Context, intent: Intent) {
- if (AudioManager.ACTION_MICROPHONE_MUTE_CHANGED == intent.action) {
- val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
- scope.launch { isMutedFlow.emit(audioManager.isMicrophoneMute) }
- }
- }
-}
diff --git a/core/core-viewtree/build.gradle b/core/core-viewtree/build.gradle
index 9fe5bca..45281e8 100644
--- a/core/core-viewtree/build.gradle
+++ b/core/core-viewtree/build.gradle
@@ -48,7 +48,7 @@
androidx {
name = "androidx.core:core-viewtree"
type = LibraryType.PUBLISHED_LIBRARY
- mavenVersion = LibraryVersions.CORE
+ mavenVersion = LibraryVersions.CORE_VIEWTREE
inceptionYear = "2024"
description = "Provides ViewTree extensions packaged for use by other core androidx libraries"
}
diff --git a/core/core/src/androidTest/java/androidx/core/view/inputmethod/ImeViewCompatMultiWindowTest.java b/core/core/src/androidTest/java/androidx/core/view/inputmethod/ImeViewCompatMultiWindowTest.java
index 8cd0379..7dfdeef 100644
--- a/core/core/src/androidTest/java/androidx/core/view/inputmethod/ImeViewCompatMultiWindowTest.java
+++ b/core/core/src/androidTest/java/androidx/core/view/inputmethod/ImeViewCompatMultiWindowTest.java
@@ -49,7 +49,7 @@
@RunWith(AndroidJUnit4.class)
@LargeTest
-@SdkSuppress(minSdkVersion = 30)
+@SdkSuppress(minSdkVersion = 31)
public class ImeViewCompatMultiWindowTest {
@Rule
@@ -73,13 +73,11 @@
/**
* This test is using a deprecated codepath that doesn't support the workaround, so it is
- * expected to fail hiding the IME.
- * If this test begins failing on a new API version (that is, an assertion error is no longer
- * being thrown), it is likely that the workaround is no longer needed on that API version:
- * b/280532442
+ * expected to fail hiding the IME. The workaround is no longer needed on API version 35.
+ * See b/280532442 for details.
*/
@Test(expected = AssertionError.class)
- @SdkSuppress(minSdkVersion = 30, excludedSdks = { 30 }) // Excluded due to flakes (b/324889554)
+ @SdkSuppress(minSdkVersion = 31, maxSdkVersion = 35)
public void testImeShowAndHide_splitScreen() {
if (Build.VERSION.SDK_INT < 32) {
// FLAG_ACTIVITY_LAUNCH_ADJACENT is not support before Sdk 32, using the
diff --git a/core/uwb/uwb/api/current.txt b/core/uwb/uwb/api/current.txt
index 782a9f8..b5981af 100644
--- a/core/uwb/uwb/api/current.txt
+++ b/core/uwb/uwb/api/current.txt
@@ -110,6 +110,12 @@
property public abstract androidx.core.uwb.UwbDevice device;
}
+ public static final class RangingResult.RangingResultInitialized extends androidx.core.uwb.RangingResult {
+ ctor public RangingResult.RangingResultInitialized(androidx.core.uwb.UwbDevice device);
+ method public androidx.core.uwb.UwbDevice getDevice();
+ property public androidx.core.uwb.UwbDevice device;
+ }
+
public static final class RangingResult.RangingResultPeerDisconnected extends androidx.core.uwb.RangingResult {
ctor public RangingResult.RangingResultPeerDisconnected(androidx.core.uwb.UwbDevice device);
method public androidx.core.uwb.UwbDevice getDevice();
diff --git a/core/uwb/uwb/api/restricted_current.txt b/core/uwb/uwb/api/restricted_current.txt
index 782a9f8..b5981af 100644
--- a/core/uwb/uwb/api/restricted_current.txt
+++ b/core/uwb/uwb/api/restricted_current.txt
@@ -110,6 +110,12 @@
property public abstract androidx.core.uwb.UwbDevice device;
}
+ public static final class RangingResult.RangingResultInitialized extends androidx.core.uwb.RangingResult {
+ ctor public RangingResult.RangingResultInitialized(androidx.core.uwb.UwbDevice device);
+ method public androidx.core.uwb.UwbDevice getDevice();
+ property public androidx.core.uwb.UwbDevice device;
+ }
+
public static final class RangingResult.RangingResultPeerDisconnected extends androidx.core.uwb.RangingResult {
ctor public RangingResult.RangingResultPeerDisconnected(androidx.core.uwb.UwbDevice device);
method public androidx.core.uwb.UwbDevice getDevice();
diff --git a/core/uwb/uwb/src/main/java/androidx/core/uwb/RangingResult.kt b/core/uwb/uwb/src/main/java/androidx/core/uwb/RangingResult.kt
index bb26126..f310bb4 100644
--- a/core/uwb/uwb/src/main/java/androidx/core/uwb/RangingResult.kt
+++ b/core/uwb/uwb/src/main/java/androidx/core/uwb/RangingResult.kt
@@ -33,4 +33,7 @@
/** A ranging result with peer disconnected status update. */
public class RangingResultPeerDisconnected(override val device: UwbDevice) : RangingResult()
+
+ /** A ranging result when a ranging session is initialized with peer device. */
+ public class RangingResultInitialized(override val device: UwbDevice) : RangingResult()
}
diff --git a/core/uwb/uwb/src/main/java/androidx/core/uwb/impl/UwbClientSessionScopeAospImpl.kt b/core/uwb/uwb/src/main/java/androidx/core/uwb/impl/UwbClientSessionScopeAospImpl.kt
index c7c07f2..9479357 100644
--- a/core/uwb/uwb/src/main/java/androidx/core/uwb/impl/UwbClientSessionScopeAospImpl.kt
+++ b/core/uwb/uwb/src/main/java/androidx/core/uwb/impl/UwbClientSessionScopeAospImpl.kt
@@ -16,7 +16,6 @@
package androidx.core.uwb.impl
-import android.util.Log
import androidx.core.uwb.RangingCapabilities
import androidx.core.uwb.RangingMeasurement
import androidx.core.uwb.RangingParameters
@@ -135,7 +134,11 @@
val callback =
object : IRangingSessionCallback.Stub() {
override fun onRangingInitialized(device: UwbDevice) {
- Log.i(TAG, "Started UWB ranging.")
+ trySend(
+ RangingResult.RangingResultInitialized(
+ androidx.core.uwb.UwbDevice(UwbAddress(device.address?.address!!))
+ )
+ )
}
override fun onRangingResult(device: UwbDevice, position: RangingPosition) {
diff --git a/core/uwb/uwb/src/main/java/androidx/core/uwb/impl/UwbClientSessionScopeImpl.kt b/core/uwb/uwb/src/main/java/androidx/core/uwb/impl/UwbClientSessionScopeImpl.kt
index e7e6fcb..62a8407 100644
--- a/core/uwb/uwb/src/main/java/androidx/core/uwb/impl/UwbClientSessionScopeImpl.kt
+++ b/core/uwb/uwb/src/main/java/androidx/core/uwb/impl/UwbClientSessionScopeImpl.kt
@@ -16,10 +16,10 @@
package androidx.core.uwb.impl
-import android.util.Log
import androidx.core.uwb.RangingCapabilities
import androidx.core.uwb.RangingMeasurement
import androidx.core.uwb.RangingParameters
+import androidx.core.uwb.RangingResult.RangingResultInitialized
import androidx.core.uwb.RangingResult.RangingResultPeerDisconnected
import androidx.core.uwb.RangingResult.RangingResultPosition
import androidx.core.uwb.UwbAddress
@@ -139,7 +139,11 @@
val callback =
object : RangingSessionCallback {
override fun onRangingInitialized(device: UwbDevice) {
- Log.i(TAG, "Started UWB ranging.")
+ trySend(
+ RangingResultInitialized(
+ androidx.core.uwb.UwbDevice(UwbAddress(device.address.address))
+ )
+ )
}
override fun onRangingResult(device: UwbDevice, position: RangingPosition) {
diff --git a/credentials/registry/registry-provider/src/main/java/androidx/credentials/registry/provider/ProviderGetCredentialRequest.kt b/credentials/registry/registry-provider/src/main/java/androidx/credentials/registry/provider/ProviderGetCredentialRequest.kt
index 89a12f3..65815eb 100644
--- a/credentials/registry/registry-provider/src/main/java/androidx/credentials/registry/provider/ProviderGetCredentialRequest.kt
+++ b/credentials/registry/registry-provider/src/main/java/androidx/credentials/registry/provider/ProviderGetCredentialRequest.kt
@@ -35,6 +35,9 @@
* A null return means that entry ID isn't supported for the given type of the use case at all. For
* example, a [androidx.credentials.provider.PasswordCredentialEntry] does not have an id property
* and so this getter will return null if the selected entry was a password credential.
+ *
+ * For how to handle a user selection and extract the [ProviderGetCredentialRequest] containing the
+ * selection information, see [RegistryManager.ACTION_GET_CREDENTIAL].
*/
@get:JvmName("getSelectedEntryId")
public val ProviderGetCredentialRequest.selectedEntryId: String?
diff --git a/credentials/registry/registry-provider/src/main/java/androidx/credentials/registry/provider/RegistryManager.kt b/credentials/registry/registry-provider/src/main/java/androidx/credentials/registry/provider/RegistryManager.kt
index c601099..4d85bb0 100644
--- a/credentials/registry/registry-provider/src/main/java/androidx/credentials/registry/provider/RegistryManager.kt
+++ b/credentials/registry/registry-provider/src/main/java/androidx/credentials/registry/provider/RegistryManager.kt
@@ -44,10 +44,13 @@
* when the user selects a credential that belongs to your application. Your activity will
* be launched and you should use the
* [androidx.credentials.provider.PendingIntentHandler.retrieveProviderGetCredentialRequest]
- * API to retrieve information about the user selection and the verifier request contained
- * in [androidx.credentials.provider.ProviderGetCredentialRequest]. Next, perform the
- * necessary steps (e.g. consent collection, credential lookup) to generate a response for
- * the given request. Pass the result back using one of the
+ * API to retrieve information about the user selection (you can do this through
+ * [androidx.credentials.registry.provider.selectedEntryId]), the verifier request, and
+ * other caller app information contained in
+ * [androidx.credentials.provider.ProviderGetCredentialRequest].
+ *
+ * Next, perform the necessary steps (e.g. consent collection, credential lookup) to
+ * generate a response for the given request. Pass the result back using one of the
* [androidx.credentials.provider.PendingIntentHandler.setGetCredentialResponse] and
* [androidx.credentials.provider.PendingIntentHandler.setGetCredentialException] APIs.
*/
diff --git a/datastore/datastore-core/bcv/native/current.txt b/datastore/datastore-core/bcv/native/current.txt
index 4e63344..6fe6e1a 100644
--- a/datastore/datastore-core/bcv/native/current.txt
+++ b/datastore/datastore-core/bcv/native/current.txt
@@ -6,6 +6,10 @@
// - Show declarations: true
// Library unique name: <androidx.datastore:datastore-core>
+abstract interface <#A: kotlin/Any?> androidx.datastore.core/CurrentDataProviderStore : androidx.datastore.core/DataStore<#A> { // androidx.datastore.core/CurrentDataProviderStore|null[0]
+ abstract suspend fun currentData(): #A // androidx.datastore.core/CurrentDataProviderStore.currentData|currentData(){}[0]
+}
+
abstract interface <#A: kotlin/Any?> androidx.datastore.core/DataMigration { // androidx.datastore.core/DataMigration|null[0]
abstract suspend fun cleanUp() // androidx.datastore.core/DataMigration.cleanUp|cleanUp(){}[0]
abstract suspend fun migrate(#A): #A // androidx.datastore.core/DataMigration.migrate|migrate(1:0){}[0]
diff --git a/datastore/datastore-core/src/commonMain/kotlin/androidx/datastore/core/CurrentDataProviderStore.kt b/datastore/datastore-core/src/commonMain/kotlin/androidx/datastore/core/CurrentDataProviderStore.kt
new file mode 100644
index 0000000..54a82bc
--- /dev/null
+++ b/datastore/datastore-core/src/commonMain/kotlin/androidx/datastore/core/CurrentDataProviderStore.kt
@@ -0,0 +1,24 @@
+/*
+ * 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.datastore.core
+
+import androidx.annotation.RestrictTo
+
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+public interface CurrentDataProviderStore<T> : DataStore<T> {
+ public suspend fun currentData(): T
+}
diff --git a/datastore/datastore-core/src/commonMain/kotlin/androidx/datastore/core/DataStoreImpl.kt b/datastore/datastore-core/src/commonMain/kotlin/androidx/datastore/core/DataStoreImpl.kt
index 0dfcb46..3a61af0 100644
--- a/datastore/datastore-core/src/commonMain/kotlin/androidx/datastore/core/DataStoreImpl.kt
+++ b/datastore/datastore-core/src/commonMain/kotlin/androidx/datastore/core/DataStoreImpl.kt
@@ -59,7 +59,7 @@
*/
private val corruptionHandler: CorruptionHandler<T> = NoOpCorruptionHandler(),
private val scope: CoroutineScope = CoroutineScope(ioDispatcher() + SupervisorJob())
-) : DataStore<T> {
+) : CurrentDataProviderStore<T> {
/**
* The actual values of DataStore. This is exposed in the API via [data] to be able to combine
@@ -114,6 +114,17 @@
)
}
+ override suspend fun currentData(): T {
+ val startState = readState(requireLock = false)
+ when (startState) {
+ is Data<T> -> return startState.value
+ is UnInitialized -> error(BUG_MESSAGE)
+ is ReadException<T> -> throw startState.readException
+ // TODO(b/273990827): decide the contract of accessing when state is Final
+ is Final -> throw startState.finalException
+ }
+ }
+
private val collectorMutex = Mutex()
private var collectorCounter = 0
/**
diff --git a/datastore/datastore-guava/src/main/java/androidx/datastore/guava/GuavaDataStore.kt b/datastore/datastore-guava/src/main/java/androidx/datastore/guava/GuavaDataStore.kt
index b653051..3a95248 100644
--- a/datastore/datastore-guava/src/main/java/androidx/datastore/guava/GuavaDataStore.kt
+++ b/datastore/datastore-guava/src/main/java/androidx/datastore/guava/GuavaDataStore.kt
@@ -18,8 +18,8 @@
import android.content.Context
import androidx.concurrent.futures.SuspendToFutureAdapter.launchFuture
+import androidx.datastore.core.CurrentDataProviderStore
import androidx.datastore.core.DataMigration
-import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.Serializer
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
@@ -34,7 +34,6 @@
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asCoroutineDispatcher
-import kotlinx.coroutines.flow.first
/**
* The class that wraps around [DataStore] to provide an interface that returns [ListenableFuture]s
@@ -43,7 +42,7 @@
public class GuavaDataStore<T : Any>
internal constructor(
/** The delegate DataStore. */
- private val dataStore: DataStore<T>,
+ private val dataStore: CurrentDataProviderStore<T>,
/** The [CoroutineContext] that holds a dispatcher. */
private val coroutineContext: CoroutineContext,
) {
@@ -52,7 +51,7 @@
* ongoing updates.
*/
public fun getDataAsync(): ListenableFuture<T> {
- return launchFuture(coroutineContext) { dataStore.data.first() }
+ return launchFuture(coroutineContext) { dataStore.currentData() }
}
/**
@@ -156,7 +155,10 @@
corruptionHandler = corruptionHandler,
migrations = dataMigrations,
scope = CoroutineScope(coroutineDispatcher),
- ),
+ ) as? CurrentDataProviderStore
+ ?: error(
+ "Unexpected DataStore object that does not implement CurrentDataStore"
+ ),
coroutineDispatcher,
)
}
diff --git a/datastore/datastore-preferences-core/api/current.txt b/datastore/datastore-preferences-core/api/current.txt
index 1de9eb3..6f9afe6 100644
--- a/datastore/datastore-preferences-core/api/current.txt
+++ b/datastore/datastore-preferences-core/api/current.txt
@@ -47,6 +47,14 @@
method public static androidx.datastore.preferences.core.MutablePreferences createMutable(androidx.datastore.preferences.core.Preferences.Pair<? extends java.lang.Object?>... pairs);
}
+ public final class PreferencesFileSerializer implements androidx.datastore.core.Serializer<androidx.datastore.preferences.core.Preferences> {
+ method public androidx.datastore.preferences.core.Preferences getDefaultValue();
+ method @kotlin.jvm.Throws(exceptionClasses={IOException::class, CorruptionException::class}) public suspend Object? readFrom(java.io.InputStream input, kotlin.coroutines.Continuation<? super androidx.datastore.preferences.core.Preferences>) throws androidx.datastore.core.CorruptionException, java.io.IOException;
+ method @kotlin.jvm.Throws(exceptionClasses={IOException::class, CorruptionException::class}) public suspend Object? writeTo(androidx.datastore.preferences.core.Preferences t, java.io.OutputStream output, kotlin.coroutines.Continuation<? super kotlin.Unit>) throws androidx.datastore.core.CorruptionException, java.io.IOException;
+ property public androidx.datastore.preferences.core.Preferences defaultValue;
+ field public static final androidx.datastore.preferences.core.PreferencesFileSerializer INSTANCE;
+ }
+
public final class PreferencesKeys {
method public static androidx.datastore.preferences.core.Preferences.Key<java.lang.Boolean> booleanKey(String name);
method public static androidx.datastore.preferences.core.Preferences.Key<byte[]> byteArrayKey(String name);
diff --git a/datastore/datastore-preferences-core/api/restricted_current.txt b/datastore/datastore-preferences-core/api/restricted_current.txt
index 1de9eb3..6f9afe6 100644
--- a/datastore/datastore-preferences-core/api/restricted_current.txt
+++ b/datastore/datastore-preferences-core/api/restricted_current.txt
@@ -47,6 +47,14 @@
method public static androidx.datastore.preferences.core.MutablePreferences createMutable(androidx.datastore.preferences.core.Preferences.Pair<? extends java.lang.Object?>... pairs);
}
+ public final class PreferencesFileSerializer implements androidx.datastore.core.Serializer<androidx.datastore.preferences.core.Preferences> {
+ method public androidx.datastore.preferences.core.Preferences getDefaultValue();
+ method @kotlin.jvm.Throws(exceptionClasses={IOException::class, CorruptionException::class}) public suspend Object? readFrom(java.io.InputStream input, kotlin.coroutines.Continuation<? super androidx.datastore.preferences.core.Preferences>) throws androidx.datastore.core.CorruptionException, java.io.IOException;
+ method @kotlin.jvm.Throws(exceptionClasses={IOException::class, CorruptionException::class}) public suspend Object? writeTo(androidx.datastore.preferences.core.Preferences t, java.io.OutputStream output, kotlin.coroutines.Continuation<? super kotlin.Unit>) throws androidx.datastore.core.CorruptionException, java.io.IOException;
+ property public androidx.datastore.preferences.core.Preferences defaultValue;
+ field public static final androidx.datastore.preferences.core.PreferencesFileSerializer INSTANCE;
+ }
+
public final class PreferencesKeys {
method public static androidx.datastore.preferences.core.Preferences.Key<java.lang.Boolean> booleanKey(String name);
method public static androidx.datastore.preferences.core.Preferences.Key<byte[]> byteArrayKey(String name);
diff --git a/datastore/datastore-preferences-core/src/jvmMain/kotlin/androidx/datastore/preferences/core/PreferencesFileSerializer.jvm.kt b/datastore/datastore-preferences-core/src/jvmMain/kotlin/androidx/datastore/preferences/core/PreferencesFileSerializer.jvm.kt
new file mode 100644
index 0000000..0460111
--- /dev/null
+++ b/datastore/datastore-preferences-core/src/jvmMain/kotlin/androidx/datastore/preferences/core/PreferencesFileSerializer.jvm.kt
@@ -0,0 +1,113 @@
+/*
+ * 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.datastore.preferences.core
+
+import androidx.datastore.core.CorruptionException
+import androidx.datastore.core.Serializer
+import androidx.datastore.preferences.PreferencesMapCompat
+import androidx.datastore.preferences.PreferencesProto.PreferenceMap
+import androidx.datastore.preferences.PreferencesProto.StringSet
+import androidx.datastore.preferences.PreferencesProto.Value
+import androidx.datastore.preferences.protobuf.ByteString
+import java.io.IOException
+import java.io.InputStream
+import java.io.OutputStream
+import kotlin.jvm.Throws
+
+/**
+ * Proto based serializer for Preferences. Can be used to manually create
+ * [DataStore][androidx.datastore.core.DataStore] using the
+ * [DataStoreFactory#create][androidx.datastore.core.DataStoreFactory.create] function.
+ */
+object PreferencesFileSerializer : Serializer<Preferences> {
+ internal const val fileExtension = "preferences_pb"
+
+ override val defaultValue: Preferences
+ get() {
+ return emptyPreferences()
+ }
+
+ @Throws(IOException::class, CorruptionException::class)
+ override suspend fun readFrom(input: InputStream): Preferences {
+ val preferencesProto = PreferencesMapCompat.readFrom(input)
+
+ val mutablePreferences = mutablePreferencesOf()
+
+ preferencesProto.preferencesMap.forEach { (name, value) ->
+ addProtoEntryToPreferences(name, value, mutablePreferences)
+ }
+
+ return mutablePreferences.toPreferences()
+ }
+
+ @Suppress("InvalidNullabilityOverride") // Remove after b/232460179 is fixed
+ @Throws(IOException::class, CorruptionException::class)
+ override suspend fun writeTo(t: Preferences, output: OutputStream) {
+ val preferences = t.asMap()
+ val protoBuilder = PreferenceMap.newBuilder()
+
+ for ((key, value) in preferences) {
+ protoBuilder.putPreferences(key.name, getValueProto(value))
+ }
+
+ protoBuilder.build().writeTo(output)
+ }
+
+ private fun getValueProto(value: Any): Value {
+ return when (value) {
+ is Boolean -> Value.newBuilder().setBoolean(value).build()
+ is Float -> Value.newBuilder().setFloat(value).build()
+ is Double -> Value.newBuilder().setDouble(value).build()
+ is Int -> Value.newBuilder().setInteger(value).build()
+ is Long -> Value.newBuilder().setLong(value).build()
+ is String -> Value.newBuilder().setString(value).build()
+ is Set<*> ->
+ @Suppress("UNCHECKED_CAST")
+ Value.newBuilder()
+ .setStringSet(StringSet.newBuilder().addAllStrings(value as Set<String>))
+ .build()
+ is ByteArray -> Value.newBuilder().setBytes(ByteString.copyFrom(value)).build()
+ else ->
+ throw IllegalStateException(
+ "PreferencesSerializer does not support type: ${value.javaClass.name}"
+ )
+ }
+ }
+
+ private fun addProtoEntryToPreferences(
+ name: String,
+ value: Value,
+ mutablePreferences: MutablePreferences
+ ) {
+ return when (value.valueCase) {
+ Value.ValueCase.BOOLEAN ->
+ mutablePreferences[booleanPreferencesKey(name)] = value.boolean
+ Value.ValueCase.FLOAT -> mutablePreferences[floatPreferencesKey(name)] = value.float
+ Value.ValueCase.DOUBLE -> mutablePreferences[doublePreferencesKey(name)] = value.double
+ Value.ValueCase.INTEGER -> mutablePreferences[intPreferencesKey(name)] = value.integer
+ Value.ValueCase.LONG -> mutablePreferences[longPreferencesKey(name)] = value.long
+ Value.ValueCase.STRING -> mutablePreferences[stringPreferencesKey(name)] = value.string
+ Value.ValueCase.STRING_SET ->
+ mutablePreferences[stringSetPreferencesKey(name)] =
+ value.stringSet.stringsList.toSet()
+ Value.ValueCase.BYTES ->
+ mutablePreferences[byteArrayPreferencesKey(name)] = value.bytes.toByteArray()
+ Value.ValueCase.VALUE_NOT_SET -> throw CorruptionException("Value not set.")
+ null -> throw CorruptionException("Value case is null.")
+ }
+ }
+}
diff --git a/datastore/datastore-preferences-core/src/jvmTest/kotlin/androidx/datastore/preferences/core/PreferencesFileSerializerTest.kt b/datastore/datastore-preferences-core/src/jvmTest/kotlin/androidx/datastore/preferences/core/PreferencesFileSerializerTest.kt
new file mode 100644
index 0000000..69dddc3
--- /dev/null
+++ b/datastore/datastore-preferences-core/src/jvmTest/kotlin/androidx/datastore/preferences/core/PreferencesFileSerializerTest.kt
@@ -0,0 +1,127 @@
+/*
+ * 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.datastore.preferences.core
+
+import androidx.datastore.FileTestIO
+import androidx.datastore.JavaIOFile
+import androidx.datastore.core.CorruptionException
+import androidx.datastore.core.Serializer
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertTrue
+import kotlin.time.Duration.Companion.milliseconds
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+
+// TODO(b/375233479): try to dedup the tests between Okio and File
[email protected]
[email protected]
[email protected]
+class PreferencesFileSerializerTest {
+
+ private val testIO = FileTestIO()
+
+ private lateinit var testFile: JavaIOFile
+ private val preferencesSerializer: Serializer<Preferences> = PreferencesFileSerializer
+
+ @BeforeTest
+ fun setUp() {
+ testFile = testIO.newTempFile()
+ }
+
+ fun doTest(test: suspend TestScope.() -> Unit) {
+ runTest(timeout = 10000.milliseconds) { test(this) }
+ }
+
+ @Test
+ fun testThrowsCorruptionException() = doTest {
+ // Not a valid proto - protos cannot start with a 0 byte.
+ testFile.protectedWrite(byteArrayOf(0, 1, 2, 3, 4))
+
+ assertFailsWith<CorruptionException> {
+ testFile.file.inputStream().use { preferencesSerializer.readFrom(it) }
+ }
+ }
+
+ @Test
+ @Suppress("UNCHECKED_CAST")
+ fun testGetAllCantMutateInternalState() {
+ val intKey = intPreferencesKey("int_key")
+ val stringSetKey = stringSetPreferencesKey("string_set_key")
+
+ val prefs = preferencesOf(intKey to 123, stringSetKey to setOf("1", "2", "3"))
+
+ val mutableAllPreferences = prefs.asMap() as MutableMap
+ assertFailsWith<UnsupportedOperationException> { mutableAllPreferences[intKey] = 99999 }
+ assertFailsWith<UnsupportedOperationException> {
+ (mutableAllPreferences[stringSetKey] as MutableSet<String>).clear()
+ }
+
+ assertEquals(123, prefs[intKey])
+ assertEquals(setOf("1", "2", "3"), prefs[stringSetKey])
+ }
+
+ @Test
+ fun testModifyingStringSetDoesntModifyInternalState() {
+ val stringSetKey = stringSetPreferencesKey("string_set_key")
+
+ val stringSet = mutableSetOf("1", "2", "3")
+
+ val prefs = preferencesOf(stringSetKey to stringSet)
+
+ stringSet.add("4") // modify the set passed into preferences
+
+ // modify the returned set.
+ val returnedSet: Set<String> = prefs[stringSetKey]!!
+ val mutableReturnedSet: MutableSet<String> = returnedSet as MutableSet<String>
+
+ assertFailsWith<UnsupportedOperationException> { mutableReturnedSet.clear() }
+ assertFailsWith<UnsupportedOperationException> {
+ mutableReturnedSet.add("Original set does not contain this string")
+ }
+
+ assertEquals(setOf("1", "2", "3"), prefs[stringSetKey])
+ }
+
+ // TODO: This doesn't pass on native: https://youtrack.jetbrains.com/issue/KT-42903
+ @Test
+ @Suppress("UNUSED_VARIABLE")
+ fun testWrongTypeThrowsClassCastException() {
+ val stringKey = stringPreferencesKey("string_key")
+ val intKey = intPreferencesKey("string_key") // long key of the same name as stringKey!
+ val longKey = longPreferencesKey("string_key")
+
+ val prefs = preferencesOf(intKey to 123456)
+
+ assertTrue { prefs.contains(intKey) }
+ assertTrue { prefs.contains(stringKey) } // TODO: I don't think we can prevent this
+
+ // Trying to get a long where there is an Int value throws a ClassCastException.
+ assertFailsWith<ClassCastException> {
+ var unused = prefs[stringKey] // This only throws if it's assigned to a
+ // variable
+ }
+
+ // Trying to get a Long where there is an Int value throws a ClassCastException.
+ assertFailsWith<ClassCastException> {
+ var unused = prefs[longKey] // This only throws if it's assigned to a
+ // variable
+ }
+ }
+}
diff --git a/development/build_log_simplifier/messages.ignore b/development/build_log_simplifier/messages.ignore
index a83629c..bd140da 100644
--- a/development/build_log_simplifier/messages.ignore
+++ b/development/build_log_simplifier/messages.ignore
@@ -342,3 +342,6 @@
# > Task :wasmJsBrowserTest, known problem and Jetbrains will filter out in following releases:
# https://slack-chats.kotlinlang.org/t/21024041/any-suggestiong-how-to-solve-uncaught-in-promise-linkerror-i
Critical dependency: Accessing import\.meta directly is unsupported.*
+# Comes up after test execution or after Karma attempts to kill the browser and it takes too long
+# Can be ignored as the test is complete by the time the message is printed
+ChromeHeadless was not killed in.*
diff --git a/exifinterface/exifinterface/src/androidTest/java/androidx/exifinterface/media/ExifInterfaceTest.java b/exifinterface/exifinterface/src/androidTest/java/androidx/exifinterface/media/ExifInterfaceTest.java
index b138827..1e85fb5 100644
--- a/exifinterface/exifinterface/src/androidTest/java/androidx/exifinterface/media/ExifInterfaceTest.java
+++ b/exifinterface/exifinterface/src/androidTest/java/androidx/exifinterface/media/ExifInterfaceTest.java
@@ -325,12 +325,13 @@
@Test
@LargeTest
- public void testPngWithExif() throws Throwable {
+ public void testPngWithExifAndXmp() throws Throwable {
File imageFile =
copyFromResourceToFile(
- R.raw.png_with_exif_byte_order_ii, "png_with_exif_byte_order_ii.png");
- readFromFilesWithExif(imageFile, ExpectedAttributes.PNG_WITH_EXIF_BYTE_ORDER_II);
- testWritingExif(imageFile, ExpectedAttributes.PNG_WITH_EXIF_BYTE_ORDER_II);
+ R.raw.png_with_exif_and_xmp_byte_order_ii,
+ "png_with_exif_and_xmp_byte_order_ii.png");
+ readFromFilesWithExif(imageFile, ExpectedAttributes.PNG_WITH_EXIF_AND_XMP_BYTE_ORDER_II);
+ testWritingExif(imageFile, ExpectedAttributes.PNG_WITH_EXIF_AND_XMP_BYTE_ORDER_II);
}
@Test
diff --git a/exifinterface/exifinterface/src/androidTest/java/androidx/exifinterface/media/ExpectedAttributes.java b/exifinterface/exifinterface/src/androidTest/java/androidx/exifinterface/media/ExpectedAttributes.java
index 0a51698..8b39aa1 100644
--- a/exifinterface/exifinterface/src/androidTest/java/androidx/exifinterface/media/ExpectedAttributes.java
+++ b/exifinterface/exifinterface/src/androidTest/java/androidx/exifinterface/media/ExpectedAttributes.java
@@ -151,13 +151,15 @@
.setXmpOffsetAndLength(1809, 13197)
.build();
- /** Expected attributes for {@link R.raw#png_with_exif_byte_order_ii}. */
- public static final ExpectedAttributes PNG_WITH_EXIF_BYTE_ORDER_II =
+ /** Expected attributes for {@link R.raw#png_with_exif_and_xmp_byte_order_ii}. */
+ public static final ExpectedAttributes PNG_WITH_EXIF_AND_XMP_BYTE_ORDER_II =
JPEG_WITH_EXIF_BYTE_ORDER_II
.buildUpon()
.setThumbnailOffset(212271)
.setMakeOffset(211525)
.setFocalLength("41/10")
+ // TODO: b/332793608 - Add expected XMP values and offset/length when
+ // ExifInterface can parse the iTXt chunk.
.build();
/** Expected attributes for {@link R.raw#webp_with_exif}. */
diff --git a/exifinterface/exifinterface/src/androidTest/res/raw/png_with_exif_byte_order_ii.png b/exifinterface/exifinterface/src/androidTest/res/raw/png_with_exif_and_xmp_byte_order_ii.png
similarity index 100%
rename from exifinterface/exifinterface/src/androidTest/res/raw/png_with_exif_byte_order_ii.png
rename to exifinterface/exifinterface/src/androidTest/res/raw/png_with_exif_and_xmp_byte_order_ii.png
Binary files differ
diff --git a/exifinterface/exifinterface/src/main/java/androidx/exifinterface/media/ExifInterface.java b/exifinterface/exifinterface/src/main/java/androidx/exifinterface/media/ExifInterface.java
index f52cec7..4ed56d3 100644
--- a/exifinterface/exifinterface/src/main/java/androidx/exifinterface/media/ExifInterface.java
+++ b/exifinterface/exifinterface/src/main/java/androidx/exifinterface/media/ExifInterface.java
@@ -3101,9 +3101,9 @@
(byte) 0x47, (byte) 0x0d, (byte) 0x0a, (byte) 0x1a, (byte) 0x0a};
// See "Extensions to the PNG 1.2 Specification, Version 1.5.0",
// 3.7. eXIf Exchangeable Image File (Exif) Profile
- private static final int PNG_CHUNK_TYPE_EXIF = intFromBytes('e', 'X', 'I', 'f');
- private static final int PNG_CHUNK_TYPE_IHDR = intFromBytes('I', 'H', 'D', 'R');
- private static final int PNG_CHUNK_TYPE_IEND = intFromBytes('I', 'E', 'N', 'D');
+ private static final int PNG_CHUNK_TYPE_EXIF = 'e' << 24 | 'X' << 16 | 'I' << 8 | 'f';
+ private static final int PNG_CHUNK_TYPE_IHDR = 'I' << 24 | 'H' << 16 | 'D' << 8 | 'R';
+ private static final int PNG_CHUNK_TYPE_IEND = 'I' << 24 | 'E' << 16 | 'N' << 8 | 'D';
private static final int PNG_CHUNK_TYPE_BYTE_LENGTH = 4;
private static final int PNG_CHUNK_CRC_BYTE_LENGTH = 4;
@@ -8328,12 +8328,4 @@
}
return false;
}
-
- /*
- * Combines the lower eight bits of each parameter into a 32-bit int. {@code b1} is the highest
- * byte of the result, {@code b4} is the lowest.
- */
- private static int intFromBytes(int b1, int b2, int b3, int b4) {
- return ((b1 & 0xFF) << 24) | ((b2 & 0xFF) << 16) | ((b3 & 0xFF) << 8) | (b4 & 0xFF);
- }
}
diff --git a/fragment/integration-tests/testapp/build.gradle b/fragment/integration-tests/testapp/build.gradle
index 5447bc6..42604a1 100644
--- a/fragment/integration-tests/testapp/build.gradle
+++ b/fragment/integration-tests/testapp/build.gradle
@@ -22,6 +22,7 @@
android {
namespace "androidx.fragment.testapp"
+ compileSdk 35
}
dependencies {
@@ -34,6 +35,8 @@
implementation("androidx.recyclerview:recyclerview:1.1.0")
debugImplementation(project(":fragment:fragment-testing-manifest"))
+ androidTestImplementation(project(":core:core-ktx"))
+ androidTestImplementation(project(":core:core"))
androidTestImplementation(project(":fragment:fragment-testing"))
androidTestImplementation("androidx.lifecycle:lifecycle-common:2.6.2")
androidTestImplementation(project(":fragment:fragment-testing-manifest"))
diff --git a/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/HealthConnectClientUpsideDownImplTest.kt b/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/HealthConnectClientUpsideDownImplTest.kt
index e1f40ab..14dde25 100644
--- a/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/HealthConnectClientUpsideDownImplTest.kt
+++ b/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/HealthConnectClientUpsideDownImplTest.kt
@@ -25,8 +25,9 @@
import androidx.health.connect.client.changes.DeletionChange
import androidx.health.connect.client.changes.UpsertionChange
import androidx.health.connect.client.feature.ExperimentalFeatureAvailabilityApi
-import androidx.health.connect.client.impl.converters.datatype.RECORDS_CLASS_NAME_MAP
import androidx.health.connect.client.impl.platform.aggregate.AGGREGATE_METRICS_ADDED_IN_SDK_EXT_10
+import androidx.health.connect.client.impl.platform.records.SDK_TO_PLATFORM_RECORD_CLASS
+import androidx.health.connect.client.impl.platform.records.SDK_TO_PLATFORM_RECORD_CLASS_EXT_13
import androidx.health.connect.client.permission.HealthPermission.Companion.PERMISSION_PREFIX
import androidx.health.connect.client.readRecord
import androidx.health.connect.client.records.BloodPressureRecord
@@ -109,9 +110,15 @@
@After
fun tearDown() = runTest {
- for (recordType in RECORDS_CLASS_NAME_MAP.keys) {
+ for (recordType in SDK_TO_PLATFORM_RECORD_CLASS.keys) {
healthConnectClient.deleteRecords(recordType, TimeRangeFilter.none())
}
+
+ if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 13) {
+ for (recordType in SDK_TO_PLATFORM_RECORD_CLASS_EXT_13.keys) {
+ healthConnectClient.deleteRecords(recordType, TimeRangeFilter.none())
+ }
+ }
}
@Test
diff --git a/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/platform/aggregate/HealthConnectClientAggregationExtensionsTest.kt b/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/platform/aggregate/HealthConnectClientAggregationExtensionsTest.kt
index 653ea0c..b48b88b 100644
--- a/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/platform/aggregate/HealthConnectClientAggregationExtensionsTest.kt
+++ b/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/platform/aggregate/HealthConnectClientAggregationExtensionsTest.kt
@@ -23,7 +23,8 @@
import android.os.ext.SdkExtensions
import androidx.health.connect.client.HealthConnectClient
import androidx.health.connect.client.impl.HealthConnectClientUpsideDownImpl
-import androidx.health.connect.client.impl.converters.datatype.RECORDS_CLASS_NAME_MAP
+import androidx.health.connect.client.impl.platform.records.SDK_TO_PLATFORM_RECORD_CLASS
+import androidx.health.connect.client.impl.platform.records.SDK_TO_PLATFORM_RECORD_CLASS_EXT_13
import androidx.health.connect.client.permission.HealthPermission.Companion.PERMISSION_PREFIX
import androidx.health.connect.client.records.BloodPressureRecord
import androidx.health.connect.client.records.CyclingPedalingCadenceRecord
@@ -87,9 +88,15 @@
@After
fun tearDown() = runTest {
- for (recordType in RECORDS_CLASS_NAME_MAP.keys) {
+ for (recordType in SDK_TO_PLATFORM_RECORD_CLASS.keys) {
healthConnectClient.deleteRecords(recordType, TimeRangeFilter.none())
}
+
+ if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 13) {
+ for (recordType in SDK_TO_PLATFORM_RECORD_CLASS_EXT_13.keys) {
+ healthConnectClient.deleteRecords(recordType, TimeRangeFilter.none())
+ }
+ }
}
@Test
diff --git a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/datatype/RecordsTypeNameMap.kt b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/datatype/RecordsTypeNameMap.kt
index cf92c55..e7a4dd8 100644
--- a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/datatype/RecordsTypeNameMap.kt
+++ b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/datatype/RecordsTypeNameMap.kt
@@ -49,6 +49,7 @@
import androidx.health.connect.client.records.RespiratoryRateRecord
import androidx.health.connect.client.records.RestingHeartRateRecord
import androidx.health.connect.client.records.SexualActivityRecord
+import androidx.health.connect.client.records.SkinTemperatureRecord
import androidx.health.connect.client.records.SleepSessionRecord
import androidx.health.connect.client.records.SpeedRecord
import androidx.health.connect.client.records.StepsCadenceRecord
@@ -91,6 +92,7 @@
"RespiratoryRate" to RespiratoryRateRecord::class,
"RestingHeartRate" to RestingHeartRateRecord::class,
"SexualActivity" to SexualActivityRecord::class,
+ "SkinTemperature" to SkinTemperatureRecord::class,
"SleepSession" to SleepSessionRecord::class,
"SpeedSeries" to SpeedRecord::class, // Keep legacy Series suffix
"IntermenstrualBleeding" to IntermenstrualBleedingRecord::class,
diff --git a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/records/ProtoToRecordConverters.kt b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/records/ProtoToRecordConverters.kt
index 5b61ecc..25d642fa8 100644
--- a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/records/ProtoToRecordConverters.kt
+++ b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/records/ProtoToRecordConverters.kt
@@ -55,6 +55,7 @@
import androidx.health.connect.client.records.RespiratoryRateRecord
import androidx.health.connect.client.records.RestingHeartRateRecord
import androidx.health.connect.client.records.SexualActivityRecord
+import androidx.health.connect.client.records.SkinTemperatureRecord
import androidx.health.connect.client.records.SleepSessionRecord
import androidx.health.connect.client.records.SpeedRecord
import androidx.health.connect.client.records.StepsCadenceRecord
@@ -523,6 +524,22 @@
endZoneOffset = endZoneOffset,
metadata = metadata
)
+ "SkinTemperature" ->
+ SkinTemperatureRecord(
+ baseline = valuesMap["baseline"]?.doubleVal?.celsius,
+ measurementLocation =
+ mapEnum(
+ "measurementLocation",
+ SkinTemperatureRecord.MEASUREMENT_LOCATION_STRING_TO_INT_MAP,
+ SkinTemperatureRecord.MEASUREMENT_LOCATION_UNKNOWN,
+ ),
+ startTime = startTime,
+ startZoneOffset = startZoneOffset,
+ endTime = endTime,
+ endZoneOffset = endZoneOffset,
+ deltas = subTypeDataListsMap["deltas"]?.toDeltasList() ?: emptyList(),
+ metadata = metadata
+ )
"SleepSession" ->
SleepSessionRecord(
title = getString("title"),
diff --git a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/records/ProtoToRecordUtils.kt b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/records/ProtoToRecordUtils.kt
index e460b00..566a61b 100644
--- a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/records/ProtoToRecordUtils.kt
+++ b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/records/ProtoToRecordUtils.kt
@@ -22,11 +22,13 @@
import androidx.health.connect.client.records.ExerciseRoute
import androidx.health.connect.client.records.ExerciseSegment
import androidx.health.connect.client.records.ExerciseSegment.Companion.EXERCISE_SEGMENT_TYPE_UNKNOWN
+import androidx.health.connect.client.records.SkinTemperatureRecord
import androidx.health.connect.client.records.SleepSessionRecord
import androidx.health.connect.client.records.SleepSessionRecord.Companion.STAGE_TYPE_STRING_TO_INT_MAP
import androidx.health.connect.client.records.metadata.DataOrigin
import androidx.health.connect.client.records.metadata.Device
import androidx.health.connect.client.records.metadata.Metadata
+import androidx.health.connect.client.units.TemperatureDelta
import androidx.health.connect.client.units.meters
import androidx.health.platform.client.proto.DataProto
import androidx.health.platform.client.proto.DataProto.DataPointOrBuilder
@@ -113,6 +115,15 @@
)
}
+internal fun DataProto.DataPoint.SubTypeDataList.toDeltasList(): List<SkinTemperatureRecord.Delta> {
+ return valuesList.map {
+ SkinTemperatureRecord.Delta(
+ time = Instant.ofEpochMilli(it.startTimeMillis),
+ delta = TemperatureDelta.celsius(it.valuesMap["delta"]?.doubleVal ?: 0.0),
+ )
+ }
+}
+
internal fun DataProto.DataPoint.SubTypeDataList.toStageList(): List<SleepSessionRecord.Stage> {
return valuesList.map {
SleepSessionRecord.Stage(
diff --git a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/records/RecordToProtoConverters.kt b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/records/RecordToProtoConverters.kt
index 8438aa2..1f75aa8 100644
--- a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/records/RecordToProtoConverters.kt
+++ b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/records/RecordToProtoConverters.kt
@@ -55,6 +55,7 @@
import androidx.health.connect.client.records.RestingHeartRateRecord
import androidx.health.connect.client.records.SeriesRecord
import androidx.health.connect.client.records.SexualActivityRecord
+import androidx.health.connect.client.records.SkinTemperatureRecord
import androidx.health.connect.client.records.SleepSessionRecord
import androidx.health.connect.client.records.SpeedRecord
import androidx.health.connect.client.records.StepsCadenceRecord
@@ -481,6 +482,28 @@
name?.let { putValues("name", stringVal(it)) }
}
.build()
+ is SkinTemperatureRecord ->
+ intervalProto()
+ .setDataType(protoDataType("SkinTemperature"))
+ .apply {
+ if (baseline != null) {
+ putValues("baseline", doubleVal(baseline.inCelsius))
+ }
+ if (deltas.isNotEmpty()) {
+ putSubTypeDataLists(
+ "deltas",
+ DataProto.DataPoint.SubTypeDataList.newBuilder()
+ .addAllValues(deltas.map { it.toProto() })
+ .build(),
+ )
+ }
+ enumValFromInt(
+ measurementLocation,
+ SkinTemperatureRecord.MEASUREMENT_LOCATION_INT_TO_STRING_MAP,
+ )
+ ?.let { putValues("measurementLocation", it) }
+ }
+ .build()
is SleepSessionRecord ->
intervalProto()
.setDataType(protoDataType("SleepSession"))
diff --git a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/records/RecordToProtoUtils.kt b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/records/RecordToProtoUtils.kt
index 3e5fa01..8ce8f01 100644
--- a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/records/RecordToProtoUtils.kt
+++ b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/records/RecordToProtoUtils.kt
@@ -23,6 +23,7 @@
import androidx.health.connect.client.records.ExerciseSegment
import androidx.health.connect.client.records.InstantaneousRecord
import androidx.health.connect.client.records.IntervalRecord
+import androidx.health.connect.client.records.SkinTemperatureRecord
import androidx.health.connect.client.records.SleepSessionRecord
import androidx.health.connect.client.records.metadata.Device
import androidx.health.connect.client.records.metadata.DeviceTypes
@@ -92,6 +93,14 @@
.build()
}
+internal fun SkinTemperatureRecord.Delta.toProto(): DataProto.SubTypeDataValue {
+ return DataProto.SubTypeDataValue.newBuilder()
+ .setStartTimeMillis(time.toEpochMilli())
+ .setEndTimeMillis(time.toEpochMilli())
+ .putValues("delta", doubleVal(delta.inCelsius))
+ .build()
+}
+
internal fun SleepSessionRecord.Stage.toProto(): DataProto.SubTypeDataValue {
return DataProto.SubTypeDataValue.newBuilder()
.setStartTimeMillis(startTime.toEpochMilli())
diff --git a/health/connect/connect-client/src/test/java/androidx/health/connect/client/impl/converters/records/AllRecordsConverterTest.kt b/health/connect/connect-client/src/test/java/androidx/health/connect/client/impl/converters/records/AllRecordsConverterTest.kt
index 24dac4c..d9fb1f2 100644
--- a/health/connect/connect-client/src/test/java/androidx/health/connect/client/impl/converters/records/AllRecordsConverterTest.kt
+++ b/health/connect/connect-client/src/test/java/androidx/health/connect/client/impl/converters/records/AllRecordsConverterTest.kt
@@ -54,6 +54,7 @@
import androidx.health.connect.client.records.RespiratoryRateRecord
import androidx.health.connect.client.records.RestingHeartRateRecord
import androidx.health.connect.client.records.SexualActivityRecord
+import androidx.health.connect.client.records.SkinTemperatureRecord
import androidx.health.connect.client.records.SleepSessionRecord
import androidx.health.connect.client.records.SpeedRecord
import androidx.health.connect.client.records.StepsCadenceRecord
@@ -67,6 +68,7 @@
import androidx.health.connect.client.records.metadata.Metadata
import androidx.health.connect.client.units.BloodGlucose
import androidx.health.connect.client.units.Length
+import androidx.health.connect.client.units.TemperatureDelta
import androidx.health.connect.client.units.celsius
import androidx.health.connect.client.units.grams
import androidx.health.connect.client.units.kilocalories
@@ -805,6 +807,46 @@
}
@Test
+ fun testSkinTemperature() {
+ val data =
+ SkinTemperatureRecord(
+ baseline = 34.3.celsius,
+ measurementLocation = SkinTemperatureRecord.MEASUREMENT_LOCATION_WRIST,
+ startTime = START_TIME,
+ startZoneOffset = START_ZONE_OFFSET,
+ endTime = END_TIME,
+ endZoneOffset = END_ZONE_OFFSET,
+ metadata = TEST_METADATA,
+ deltas =
+ listOf(
+ SkinTemperatureRecord.Delta(
+ time = Instant.ofEpochMilli(1234L),
+ delta = TemperatureDelta.celsius(1.2),
+ )
+ )
+ )
+
+ checkProtoAndRecordTypeNameMatch(data)
+ assertThat(toRecord(data.toProto())).isEqualTo(data)
+ }
+
+ @Test
+ fun testSkinTemperatureWithEmptyDeltasList() {
+ val data =
+ SkinTemperatureRecord(
+ startTime = START_TIME,
+ startZoneOffset = START_ZONE_OFFSET,
+ endTime = END_TIME,
+ endZoneOffset = END_ZONE_OFFSET,
+ metadata = TEST_METADATA,
+ deltas = emptyList()
+ )
+
+ checkProtoAndRecordTypeNameMatch(data)
+ assertThat(toRecord(data.toProto())).isEqualTo(data)
+ }
+
+ @Test
fun testSleepSession() {
val data =
SleepSessionRecord(
diff --git a/health/health-services-client-external-protobuf/OWNERS b/health/health-services-client-external-protobuf/OWNERS
new file mode 100644
index 0000000..ff2eed8
--- /dev/null
+++ b/health/health-services-client-external-protobuf/OWNERS
@@ -0,0 +1,5 @@
+# Bug component: 1126127
[email protected]
[email protected]
[email protected]
[email protected]
diff --git a/health/health-services-client-proto/OWNERS b/health/health-services-client-proto/OWNERS
new file mode 100644
index 0000000..ff2eed8
--- /dev/null
+++ b/health/health-services-client-proto/OWNERS
@@ -0,0 +1,5 @@
+# Bug component: 1126127
[email protected]
[email protected]
[email protected]
[email protected]
diff --git a/health/health-services-client-proto/src/main/proto/data.proto b/health/health-services-client-proto/src/main/proto/data.proto
index 3da3cca..c12c497 100644
--- a/health/health-services-client-proto/src/main/proto/data.proto
+++ b/health/health-services-client-proto/src/main/proto/data.proto
@@ -224,13 +224,15 @@
repeated BatchingMode batching_mode_overrides = 11 [packed = true];
repeated ExerciseEventType exercise_event_types = 12 [packed = true];
repeated DebouncedGoal debounced_goals = 15;
- reserved 9, 13, 14, 16 to max;
+ reserved 9, 13, 14, 16, 17, 18, 19;
+ reserved 20 to max; // Next ID
}
message ExerciseInfo {
optional ExerciseTrackedStatus exercise_tracked_status = 1;
optional ExerciseType exercise_type = 2;
- reserved 3 to max; // Next ID
+ reserved 3;
+ reserved 4 to max; // Next ID
}
message ExerciseGoal {
@@ -490,7 +492,8 @@
optional ExerciseEndReason exercise_end_reason = 11;
repeated DebouncedGoal latest_achieved_debounced_goals = 13;
- reserved 12, 14 to max;
+ reserved 12, 14;
+ reserved 15 to max; // Next ID
}
enum ExerciseEndReason {
@@ -561,7 +564,8 @@
message WarmUpConfig {
optional ExerciseType exercise_type = 1;
repeated DataType data_types = 2;
- reserved 3 to max; // Next ID
+ reserved 3, 4, 5, 6;
+ reserved 7 to max; // Next ID
}
message PassiveGoal {
diff --git a/libraryversions.toml b/libraryversions.toml
index 057ae7b..082fb04 100644
--- a/libraryversions.toml
+++ b/libraryversions.toml
@@ -1,12 +1,12 @@
[versions]
-ACTIVITY = "1.10.0-beta01"
+ACTIVITY = "1.10.0-rc01"
ANNOTATION = "1.9.0-rc01"
ANNOTATION_EXPERIMENTAL = "1.5.0-alpha01"
APPCOMPAT = "1.8.0-alpha01"
APPSEARCH = "1.1.0-alpha07"
ARCH_CORE = "2.3.0-alpha01"
ASYNCLAYOUTINFLATER = "1.1.0-alpha02"
-AUTOFILL = "1.3.0-beta01"
+AUTOFILL = "1.3.0-rc01"
BENCHMARK = "1.4.0-alpha05"
BIOMETRIC = "1.4.0-alpha02"
BLUETOOTH = "1.0.0-alpha02"
@@ -45,6 +45,7 @@
CORE_SPLASHSCREEN = "1.2.0-alpha02"
CORE_TELECOM = "1.0.0-alpha4"
CORE_UWB = "1.0.0-alpha09"
+CORE_VIEWTREE = "1.0.0-alpha01"
CREDENTIALS = "1.5.0-beta01"
CREDENTIALS_E2EE_QUARANTINE = "1.0.0-alpha02"
CREDENTIALS_FIDO_QUARANTINE = "1.0.0-alpha02"
@@ -92,14 +93,14 @@
LEANBACK_TAB = "1.1.0-beta01"
LEGACY = "1.1.0-alpha01"
LIBYUV = "0.1.0-dev01"
-LIFECYCLE = "2.9.0-alpha07"
+LIFECYCLE = "2.9.0-alpha08"
LIFECYCLE_EXTENSIONS = "2.2.0"
-LINT = "1.0.0-alpha02"
+LINT = "1.0.0-alpha03"
LOADER = "1.2.0-alpha01"
MEDIA = "1.7.0-rc01"
MEDIAROUTER = "1.8.0-alpha01"
METRICS = "1.0.0-beta02"
-NAVIGATION = "2.9.0-alpha03"
+NAVIGATION = "2.9.0-alpha04"
NAVIGATION3 = "0.1.0-dev01"
PAGING = "3.4.0-alpha01"
PALETTE = "1.1.0-alpha01"
@@ -121,7 +122,7 @@
RESOURCEINSPECTION = "1.1.0-alpha01"
ROOM = "2.7.0-alpha12"
SAFEPARCEL = "1.0.0-alpha01"
-SAVEDSTATE = "1.3.0-alpha05"
+SAVEDSTATE = "1.3.0-alpha06"
SECURITY = "1.1.0-alpha07"
SECURITY_APP_AUTHENTICATOR = "1.0.0-rc01"
SECURITY_APP_AUTHENTICATOR_TESTING = "1.0.0-rc01"
@@ -157,8 +158,8 @@
VIEWPAGER = "1.1.0-rc01"
VIEWPAGER2 = "1.2.0-alpha01"
WEAR = "1.4.0-alpha01"
-WEAR_COMPOSE = "1.5.0-alpha06"
-WEAR_COMPOSE_MATERIAL3 = "1.0.0-alpha29"
+WEAR_COMPOSE = "1.5.0-alpha07"
+WEAR_COMPOSE_MATERIAL3 = "1.0.0-alpha30"
WEAR_CORE = "1.0.0-alpha01"
WEAR_INPUT = "1.2.0-alpha03"
WEAR_INPUT_TESTING = "1.2.0-alpha03"
diff --git a/lifecycle/integration-tests/incrementality/build.gradle b/lifecycle/integration-tests/incrementality/build.gradle
index 7a8cfcf..2ab870e 100644
--- a/lifecycle/integration-tests/incrementality/build.gradle
+++ b/lifecycle/integration-tests/incrementality/build.gradle
@@ -44,6 +44,7 @@
":lifecycle:lifecycle-compiler:publish",
":lifecycle:lifecycle-common:publish",
":lifecycle:lifecycle-runtime:publish",
+ ":core:core-viewtree:publish",
":annotation:annotation:publish",
":arch:core:core-common:publish",
":arch:core:core-runtime:publish"
diff --git a/lifecycle/lifecycle-viewmodel-navigation3/build.gradle b/lifecycle/lifecycle-viewmodel-navigation3/build.gradle
index 90b6646..4f4b58b 100644
--- a/lifecycle/lifecycle-viewmodel-navigation3/build.gradle
+++ b/lifecycle/lifecycle-viewmodel-navigation3/build.gradle
@@ -41,11 +41,12 @@
sourceSets {
commonMain {
dependencies {
+ api("androidx.lifecycle:lifecycle-viewmodel:2.8.7")
+ api("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
+
implementation(libs.kotlinStdlib)
implementation("androidx.compose.runtime:runtime:1.7.5")
implementation("androidx.compose.runtime:runtime-saveable:1.7.5")
- implementation("androidx.lifecycle:lifecycle-viewmodel:2.8.7")
- implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
}
}
diff --git a/mediarouter/mediarouter/api/current.txt b/mediarouter/mediarouter/api/current.txt
index 45aff505..6ed85777 100644
--- a/mediarouter/mediarouter/api/current.txt
+++ b/mediarouter/mediarouter/api/current.txt
@@ -514,6 +514,7 @@
public class MediaRouterParams {
method public int getDialogType();
method public boolean isMediaTransferReceiverEnabled();
+ method public boolean isMediaTransferRestrictedToSelfProviders();
method public boolean isOutputSwitcherEnabled();
method public boolean isTransferToLocalEnabled();
field public static final int DIALOG_TYPE_DEFAULT = 1; // 0x1
@@ -527,6 +528,7 @@
method public androidx.mediarouter.media.MediaRouterParams build();
method public androidx.mediarouter.media.MediaRouterParams.Builder setDialogType(int);
method public androidx.mediarouter.media.MediaRouterParams.Builder setMediaTransferReceiverEnabled(boolean);
+ method public androidx.mediarouter.media.MediaRouterParams.Builder setMediaTransferRestrictedToSelfProviders(boolean);
method public androidx.mediarouter.media.MediaRouterParams.Builder setOutputSwitcherEnabled(boolean);
method public androidx.mediarouter.media.MediaRouterParams.Builder setTransferToLocalEnabled(boolean);
}
diff --git a/mediarouter/mediarouter/api/restricted_current.txt b/mediarouter/mediarouter/api/restricted_current.txt
index 45aff505..6ed85777 100644
--- a/mediarouter/mediarouter/api/restricted_current.txt
+++ b/mediarouter/mediarouter/api/restricted_current.txt
@@ -514,6 +514,7 @@
public class MediaRouterParams {
method public int getDialogType();
method public boolean isMediaTransferReceiverEnabled();
+ method public boolean isMediaTransferRestrictedToSelfProviders();
method public boolean isOutputSwitcherEnabled();
method public boolean isTransferToLocalEnabled();
field public static final int DIALOG_TYPE_DEFAULT = 1; // 0x1
@@ -527,6 +528,7 @@
method public androidx.mediarouter.media.MediaRouterParams build();
method public androidx.mediarouter.media.MediaRouterParams.Builder setDialogType(int);
method public androidx.mediarouter.media.MediaRouterParams.Builder setMediaTransferReceiverEnabled(boolean);
+ method public androidx.mediarouter.media.MediaRouterParams.Builder setMediaTransferRestrictedToSelfProviders(boolean);
method public androidx.mediarouter.media.MediaRouterParams.Builder setOutputSwitcherEnabled(boolean);
method public androidx.mediarouter.media.MediaRouterParams.Builder setTransferToLocalEnabled(boolean);
}
diff --git a/mediarouter/mediarouter/src/androidTest/AndroidManifest.xml b/mediarouter/mediarouter/src/androidTest/AndroidManifest.xml
index 7961fb1..787c415 100644
--- a/mediarouter/mediarouter/src/androidTest/AndroidManifest.xml
+++ b/mediarouter/mediarouter/src/androidTest/AndroidManifest.xml
@@ -54,7 +54,8 @@
android:exported="true"
android:enabled="true">
<intent-filter>
- <action android:name="aandroid.media.MediaRouteProviderService"/>
+ <action android:name="android.media.MediaRouteProviderService"/>
+ <action android:name="android.media.MediaRoute2ProviderService"/>
</intent-filter>
</service>
</application>
diff --git a/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouteProviderServiceTest.java b/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouteProviderServiceTest.java
index 9fd3cc8..032e7b9 100644
--- a/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouteProviderServiceTest.java
+++ b/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouteProviderServiceTest.java
@@ -25,6 +25,7 @@
import android.content.Context;
import android.content.Intent;
+import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.IBinder;
@@ -37,6 +38,7 @@
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest;
+import androidx.test.filters.SdkSuppress;
import androidx.test.filters.SmallTest;
import androidx.test.rule.ServiceTestRule;
@@ -192,8 +194,10 @@
@LargeTest
@Test
- public void testSetEmptyPassiveDiscoveryRequest_shouldNotRequestScan() throws Exception {
- sendDiscoveryRequest(mReceiveMessenger1,
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.S)
+ public void setEmptyPassiveDiscoveryRequest_shouldNotScan() throws Exception {
+ sendDiscoveryRequest(
+ mReceiveMessenger1,
new MediaRouteDiscoveryRequest(MediaRouteSelector.EMPTY, false));
Thread.sleep(TIME_OUT_MS);
diff --git a/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouterParamsTest.java b/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouterParamsTest.java
new file mode 100644
index 0000000..98bf620
--- /dev/null
+++ b/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouterParamsTest.java
@@ -0,0 +1,120 @@
+/*
+ * 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.mediarouter.media;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertSame;
+
+import android.os.Build;
+import android.os.Bundle;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Test {@link MediaRouterParams}. */
+@RunWith(AndroidJUnit4.class)
+public class MediaRouterParamsTest {
+
+ private static final String TEST_KEY = "test_key";
+ private static final String TEST_VALUE = "test_value";
+
+ @Test
+ @SmallTest
+ @SdkSuppress(maxSdkVersion = Build.VERSION_CODES.Q)
+ public void mediaRouterParamsBuilder_androidQOrBelow() {
+ verifyMediaRouterParamsBuilder(/* isAndroidROrAbove= */ false);
+ }
+
+ @Test
+ @SmallTest
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.R)
+ public void mediaRouterParamsBuilder_androidROrAbove() {
+ verifyMediaRouterParamsBuilder(/* isAndroidROrAbove= */ true);
+ }
+
+ private void verifyMediaRouterParamsBuilder(boolean isAndroidROrAbove) {
+ final int dialogType = MediaRouterParams.DIALOG_TYPE_DYNAMIC_GROUP;
+ final boolean isOutputSwitcherEnabled = true;
+ final boolean transferToLocalEnabled = true;
+ final boolean transferReceiverEnabled = false;
+ final boolean mediaTransferRestrictedToSelfProviders = true;
+ final Bundle extras = new Bundle();
+ extras.putString(TEST_KEY, TEST_VALUE);
+
+ MediaRouterParams params =
+ new MediaRouterParams.Builder()
+ .setDialogType(dialogType)
+ .setOutputSwitcherEnabled(isOutputSwitcherEnabled)
+ .setTransferToLocalEnabled(transferToLocalEnabled)
+ .setMediaTransferReceiverEnabled(transferReceiverEnabled)
+ .setMediaTransferRestrictedToSelfProviders(
+ mediaTransferRestrictedToSelfProviders)
+ .setExtras(extras)
+ .build();
+
+ assertEquals(dialogType, params.getDialogType());
+
+ if (isAndroidROrAbove) {
+ assertEquals(isOutputSwitcherEnabled, params.isOutputSwitcherEnabled());
+ assertEquals(transferToLocalEnabled, params.isTransferToLocalEnabled());
+ assertEquals(transferReceiverEnabled, params.isMediaTransferReceiverEnabled());
+ assertEquals(
+ mediaTransferRestrictedToSelfProviders,
+ params.isMediaTransferRestrictedToSelfProviders());
+ } else {
+ // Earlier than Android R, output switcher cannot be enabled.
+ // Same for transfer to local.
+ assertFalse(params.isOutputSwitcherEnabled());
+ assertFalse(params.isTransferToLocalEnabled());
+ assertFalse(params.isMediaTransferReceiverEnabled());
+ assertFalse(params.isMediaTransferRestrictedToSelfProviders());
+ }
+
+ extras.remove(TEST_KEY);
+ assertEquals(TEST_VALUE, params.getExtras().getString(TEST_KEY));
+
+ // Tests copy constructor of builder
+ MediaRouterParams copiedParams = new MediaRouterParams.Builder(params).build();
+ assertEquals(params.getDialogType(), copiedParams.getDialogType());
+ assertEquals(params.isOutputSwitcherEnabled(), copiedParams.isOutputSwitcherEnabled());
+ assertEquals(params.isTransferToLocalEnabled(), copiedParams.isTransferToLocalEnabled());
+ assertEquals(
+ params.isMediaTransferReceiverEnabled(),
+ copiedParams.isMediaTransferReceiverEnabled());
+ assertEquals(
+ params.isMediaTransferRestrictedToSelfProviders(),
+ copiedParams.isMediaTransferRestrictedToSelfProviders());
+ assertBundleEquals(params.getExtras(), copiedParams.getExtras());
+ }
+
+ /** Asserts that two Bundles are equal. */
+ @SuppressWarnings("deprecation")
+ public static void assertBundleEquals(Bundle expected, Bundle observed) {
+ if (expected == null || observed == null) {
+ assertSame(expected, observed);
+ }
+ assertEquals(expected.size(), observed.size());
+ for (String key : expected.keySet()) {
+ assertEquals(expected.get(key), observed.get(key));
+ }
+ }
+}
diff --git a/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouterTest.java b/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouterTest.java
index 6f7b964..6a54716 100644
--- a/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouterTest.java
+++ b/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouterTest.java
@@ -24,7 +24,6 @@
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
-import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import android.content.Context;
@@ -37,6 +36,7 @@
import androidx.test.annotation.UiThreadTest;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest;
+import androidx.test.filters.SdkSuppress;
import androidx.test.filters.SmallTest;
import org.junit.After;
@@ -70,7 +70,7 @@
private CountDownLatch mPassiveScanCountDownLatch;
@Before
- public void setUp() throws Exception {
+ public void setUp() {
resetActiveAndPassiveScanCountDownLatches();
getInstrumentation()
.runOnMainSync(
@@ -80,10 +80,11 @@
mSession = new MediaSessionCompat(mContext, SESSION_TAG);
mProvider = new MediaRouteProviderImpl(mContext);
});
+ assertTrue(MediaTransferReceiver.isDeclared(mContext));
}
@After
- public void tearDown() throws Exception {
+ public void tearDown() {
mSession.release();
getInstrumentation().runOnMainSync(() -> MediaRouterTestHelper.resetMediaRouter());
}
@@ -119,67 +120,26 @@
@Test
@SmallTest
- public void mediaRouterParamsBuilder() {
- final int dialogType = MediaRouterParams.DIALOG_TYPE_DYNAMIC_GROUP;
- final boolean isOutputSwitcherEnabled = true;
- final boolean transferToLocalEnabled = true;
- final boolean transferReceiverEnabled = false;
- final Bundle extras = new Bundle();
- extras.putString(TEST_KEY, TEST_VALUE);
-
- MediaRouterParams params = new MediaRouterParams.Builder()
- .setDialogType(dialogType)
- .setOutputSwitcherEnabled(isOutputSwitcherEnabled)
- .setTransferToLocalEnabled(transferToLocalEnabled)
- .setMediaTransferReceiverEnabled(transferReceiverEnabled)
- .setExtras(extras)
- .build();
-
- assertEquals(dialogType, params.getDialogType());
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
- assertEquals(isOutputSwitcherEnabled, params.isOutputSwitcherEnabled());
- assertEquals(transferToLocalEnabled, params.isTransferToLocalEnabled());
- assertEquals(transferReceiverEnabled, params.isMediaTransferReceiverEnabled());
- } else {
- // Earlier than Android R, output switcher cannot be enabled.
- // Same for transfer to local.
- assertFalse(params.isOutputSwitcherEnabled());
- assertFalse(params.isTransferToLocalEnabled());
- assertFalse(params.isMediaTransferReceiverEnabled());
- }
-
- extras.remove(TEST_KEY);
- assertEquals(TEST_VALUE, params.getExtras().getString(TEST_KEY));
-
- // Tests copy constructor of builder
- MediaRouterParams copiedParams = new MediaRouterParams.Builder(params).build();
- assertEquals(params.getDialogType(), copiedParams.getDialogType());
- assertEquals(params.isOutputSwitcherEnabled(), copiedParams.isOutputSwitcherEnabled());
- assertEquals(params.isTransferToLocalEnabled(), copiedParams.isTransferToLocalEnabled());
- assertEquals(params.isMediaTransferReceiverEnabled(),
- copiedParams.isMediaTransferReceiverEnabled());
- assertBundleEquals(params.getExtras(), copiedParams.getExtras());
- }
-
- @Test
- @SmallTest
@UiThreadTest
public void getRouterParams_afterSetRouterParams_returnsSetParams() {
final int dialogType = MediaRouterParams.DIALOG_TYPE_DYNAMIC_GROUP;
final boolean isOutputSwitcherEnabled = true;
final boolean transferToLocalEnabled = true;
final boolean transferReceiverEnabled = false;
+ final boolean mediaTransferRestrictedToSelfProviders = true;
final Bundle paramExtras = new Bundle();
paramExtras.putString(TEST_KEY, TEST_VALUE);
- MediaRouterParams expectedParams = new MediaRouterParams.Builder()
- .setDialogType(dialogType)
- .setOutputSwitcherEnabled(isOutputSwitcherEnabled)
- .setTransferToLocalEnabled(transferToLocalEnabled)
- .setMediaTransferReceiverEnabled(transferReceiverEnabled)
- .setExtras(paramExtras)
- .build();
+ MediaRouterParams expectedParams =
+ new MediaRouterParams.Builder()
+ .setDialogType(dialogType)
+ .setOutputSwitcherEnabled(isOutputSwitcherEnabled)
+ .setTransferToLocalEnabled(transferToLocalEnabled)
+ .setMediaTransferReceiverEnabled(transferReceiverEnabled)
+ .setMediaTransferRestrictedToSelfProviders(
+ mediaTransferRestrictedToSelfProviders)
+ .setExtras(paramExtras)
+ .build();
paramExtras.remove(TEST_KEY);
mRouter.setRouterParams(expectedParams);
@@ -188,6 +148,29 @@
assertEquals(expectedParams, actualParams);
}
+ @SmallTest
+ @Test
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.R)
+ public void setRouterParams_shouldSetMediaTransferRestrictToSelfProviders() {
+ MediaRouterParams params =
+ new MediaRouterParams.Builder()
+ .setMediaTransferRestrictedToSelfProviders(true)
+ .build();
+ getInstrumentation()
+ .runOnMainSync(
+ () -> {
+ mRouter.setRouterParams(params);
+ });
+ assertTrue(
+ MediaRouter.getGlobalRouter()
+ .mRegisteredProviderWatcher
+ .isMediaTransferRestrictedToSelfProvidersForTesting());
+ assertTrue(
+ MediaRouter.getGlobalRouter()
+ .getMediaRoute2ProviderForTesting()
+ .isMediaTransferRestrictedToSelfProviders());
+ }
+
@Test
@LargeTest
public void testRegisterActiveScanCallback_suppressActiveScanAfter30Seconds() throws Exception {
@@ -289,20 +272,6 @@
assertFalse(newInstance.getRoutes().isEmpty());
}
- /**
- * Asserts that two Bundles are equal.
- */
- @SuppressWarnings("deprecation")
- public static void assertBundleEquals(Bundle expected, Bundle observed) {
- if (expected == null || observed == null) {
- assertSame(expected, observed);
- }
- assertEquals(expected.size(), observed.size());
- for (String key : expected.keySet()) {
- assertEquals(expected.get(key), observed.get(key));
- }
- }
-
private class MediaSessionCallback extends MediaSessionCompat.Callback {
private boolean mOnPlayCalled;
private boolean mOnPauseCalled;
@@ -348,7 +317,6 @@
}
}
}
-
}
private void resetActiveAndPassiveScanCountDownLatches() {
diff --git a/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/RegisteredMediaRouteProviderWatcherTest.java b/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/RegisteredMediaRouteProviderWatcherTest.java
new file mode 100644
index 0000000..a132e52
--- /dev/null
+++ b/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/RegisteredMediaRouteProviderWatcherTest.java
@@ -0,0 +1,100 @@
+/*
+ * 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.mediarouter.media;
+
+import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import android.content.Context;
+import android.content.pm.ServiceInfo;
+import android.os.Build;
+import android.text.TextUtils;
+
+import androidx.annotation.NonNull;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.List;
+
+/** Test {@link RegisteredMediaRouteProviderWatcher}. */
+@RunWith(AndroidJUnit4.class)
+public class RegisteredMediaRouteProviderWatcherTest {
+ private Context mContext;
+ private RegisteredMediaRouteProviderWatcher mProviderWatcher;
+
+ @Before
+ public void setUp() {
+ mContext = ApplicationProvider.getApplicationContext();
+ RegisteredMediaRouteProviderWatcher.Callback callback =
+ new RegisteredMediaRouteProviderWatcher.Callback() {
+ @Override
+ public void addProvider(@NonNull MediaRouteProvider provider) {}
+
+ @Override
+ public void removeProvider(@NonNull MediaRouteProvider provider) {}
+
+ @Override
+ public void releaseProviderController(
+ @NonNull RegisteredMediaRouteProvider provider,
+ @NonNull MediaRouteProvider.RouteController controller) {}
+ };
+
+ getInstrumentation()
+ .runOnMainSync(
+ () -> {
+ mProviderWatcher =
+ new RegisteredMediaRouteProviderWatcher(mContext, callback);
+ });
+ }
+
+ @SmallTest
+ @Test
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.R)
+ public void getMediaRoute2ProviderServices_restrictedToSelfProviders_shouldGetSelfProviders() {
+ mProviderWatcher.setMediaTransferRestrictedToSelfProviders(true);
+ assertTrue(mProviderWatcher.isMediaTransferRestrictedToSelfProvidersForTesting());
+ List<ServiceInfo> serviceInfos = mProviderWatcher.getMediaRoute2ProviderServices();
+ assertTrue(isSelfProvidersContained(serviceInfos));
+ }
+
+ @SmallTest
+ @Test
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.R)
+ public void getMediaRoute2ProviderServices_notRestrictedToSelfProviders_shouldGetAllProvider() {
+ mProviderWatcher.setMediaTransferRestrictedToSelfProviders(false);
+ assertFalse(mProviderWatcher.isMediaTransferRestrictedToSelfProvidersForTesting());
+ List<ServiceInfo> serviceInfos = mProviderWatcher.getMediaRoute2ProviderServices();
+ assertTrue(isSelfProvidersContained(serviceInfos));
+ }
+
+ private boolean isSelfProvidersContained(List<ServiceInfo> serviceInfos) {
+ for (ServiceInfo serviceInfo : serviceInfos) {
+ if (TextUtils.equals(serviceInfo.packageName, mContext.getPackageName())) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/GlobalMediaRouter.java b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/GlobalMediaRouter.java
index f95695e..f24905e 100644
--- a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/GlobalMediaRouter.java
+++ b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/GlobalMediaRouter.java
@@ -285,8 +285,13 @@
addProvider(mMr2Provider, /* treatRouteDescriptorIdsAsUnique= */ true);
// Make sure mDiscoveryRequestForMr2Provider is updated
updateDiscoveryRequest();
- mRegisteredProviderWatcher.rescan();
}
+ boolean mediaTransferRestrictedToSelfProviders =
+ params != null && params.isMediaTransferRestrictedToSelfProviders();
+ mMr2Provider.setMediaTransferRestrictedToSelfProviders(
+ mediaTransferRestrictedToSelfProviders);
+ mRegisteredProviderWatcher.setMediaTransferRestrictedToSelfProviders(
+ mediaTransferRestrictedToSelfProviders);
boolean oldTransferToLocalEnabled =
oldParams != null && oldParams.isTransferToLocalEnabled();
@@ -1343,9 +1348,13 @@
}
}
+ @VisibleForTesting
+ /* package */ MediaRoute2Provider getMediaRoute2ProviderForTesting() {
+ return mMr2Provider;
+ }
+
private final class ProviderCallback extends MediaRouteProvider.Callback {
- ProviderCallback() {
- }
+ ProviderCallback() {}
@Override
public void onDescriptorChanged(
diff --git a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRoute2Provider.java b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRoute2Provider.java
index eb1009c..654df9b 100644
--- a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRoute2Provider.java
+++ b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRoute2Provider.java
@@ -47,6 +47,7 @@
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
+import androidx.annotation.VisibleForTesting;
import androidx.mediarouter.R;
import androidx.mediarouter.media.MediaRouteProvider.DynamicGroupRouteController.DynamicRouteDescriptor;
import androidx.mediarouter.media.MediaRouter.ControlRequestCallback;
@@ -67,6 +68,7 @@
class MediaRoute2Provider extends MediaRouteProvider {
static final String TAG = "MR2Provider";
static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
+ private static final String PACKAGE_NAME_SEPARATOR = "/";
final MediaRouter2 mMediaRouter2;
final Callback mCallback;
@@ -77,9 +79,10 @@
private final MediaRouter2.ControllerCallback mControllerCallback = new ControllerCallback();
private final Handler mHandler;
private final Executor mHandlerExecutor;
-
+ private boolean mMediaTransferRestrictedToSelfProviders;
private List<MediaRoute2Info> mRoutes = new ArrayList<>();
private Map<String, String> mRouteIdToOriginalRouteIdMap = new ArrayMap<>();
+
@SuppressWarnings({"SyntheticAccessor"})
MediaRoute2Provider(@NonNull Context context, @NonNull Callback callback) {
super(context);
@@ -153,6 +156,17 @@
return null;
}
+ /* package */ void setMediaTransferRestrictedToSelfProviders(
+ boolean mediaTransferRestrictedToSelfProviders) {
+ mMediaTransferRestrictedToSelfProviders = mediaTransferRestrictedToSelfProviders;
+ refreshRoutes();
+ }
+
+ @VisibleForTesting
+ /* package */ boolean isMediaTransferRestrictedToSelfProviders() {
+ return mMediaTransferRestrictedToSelfProviders;
+ }
+
public void transferTo(@NonNull String routeId) {
MediaRoute2Info route = getRouteById(routeId);
if (route == null) {
@@ -171,6 +185,17 @@
if (route == null || route2InfoSet.contains(route) || route.isSystemRoute()) {
continue;
}
+
+ if (mMediaTransferRestrictedToSelfProviders) {
+ // The routeId is created by Android framework with the provider's package name.
+ boolean isRoutePublishedBySelfProviders =
+ route.getId()
+ .startsWith(getContext().getPackageName() + PACKAGE_NAME_SEPARATOR);
+ if (!isRoutePublishedBySelfProviders) {
+ continue;
+ }
+ }
+
route2InfoSet.add(route);
// Not using new ArrayList(route2InfoSet) here for preserving the order.
@@ -374,8 +399,9 @@
}
abstract static class Callback {
- public abstract void onSelectRoute(@NonNull String routeDescriptorId,
- @MediaRouter.UnselectReason int reason);
+ public abstract void onSelectRoute(
+ @NonNull String routeDescriptorId, @MediaRouter.UnselectReason int reason);
+
public abstract void onSelectFallbackRoute(@MediaRouter.UnselectReason int reason);
public abstract void onReleaseController(@NonNull RouteController controller);
diff --git a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouterParams.java b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouterParams.java
index a799b00..7883ff3 100644
--- a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouterParams.java
+++ b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouterParams.java
@@ -72,11 +72,11 @@
public static final String EXTRAS_KEY_FIXED_CAST_ICON =
"androidx.mediarouter.media.MediaRouterParams.FIXED_CAST_ICON";
- @DialogType
- final int mDialogType;
+ @DialogType final int mDialogType;
final boolean mMediaTransferReceiverEnabled;
final boolean mOutputSwitcherEnabled;
final boolean mTransferToLocalEnabled;
+ final boolean mMediaTransferRestrictedToSelfProviders;
final Bundle mExtras;
MediaRouterParams(@NonNull Builder builder) {
@@ -84,6 +84,7 @@
mMediaTransferReceiverEnabled = builder.mMediaTransferEnabled;
mOutputSwitcherEnabled = builder.mOutputSwitcherEnabled;
mTransferToLocalEnabled = builder.mTransferToLocalEnabled;
+ mMediaTransferRestrictedToSelfProviders = builder.mMediaTransferRestrictedToSelfProviders;
Bundle extras = builder.mExtras;
mExtras = extras == null ? Bundle.EMPTY : new Bundle(extras);
@@ -120,8 +121,8 @@
/**
* Returns whether transferring media from remote to local is enabled.
- * <p>
- * Note that it always returns {@code false} for Android versions earlier than Android R.
+ *
+ * <p>Note that it always returns {@code false} for Android versions earlier than Android R.
*
* @see Builder#setTransferToLocalEnabled(boolean)
*/
@@ -130,7 +131,16 @@
}
/**
+ * Returns whether the declared {@link MediaTransferReceiver} feature is restricted to the app's
+ * own media route providers.
+ *
+ * @see Builder#setMediaTransferRestrictedToSelfProviders(boolean)
*/
+ public boolean isMediaTransferRestrictedToSelfProviders() {
+ return mMediaTransferRestrictedToSelfProviders;
+ }
+
+ /** */
@NonNull
@RestrictTo(RestrictTo.Scope.LIBRARY)
public Bundle getExtras() {
@@ -141,21 +151,19 @@
* Builder class for {@link MediaRouterParams}.
*/
public static final class Builder {
- @DialogType
- int mDialogType = DIALOG_TYPE_DEFAULT;
+ @DialogType int mDialogType = DIALOG_TYPE_DEFAULT;
boolean mMediaTransferEnabled = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R;
boolean mOutputSwitcherEnabled;
boolean mTransferToLocalEnabled;
+ boolean mMediaTransferRestrictedToSelfProviders;
Bundle mExtras;
- /**
- * Constructor for builder to create {@link MediaRouterParams}.
- */
+ /** Constructor for builder to create {@link MediaRouterParams}. */
public Builder() {}
/**
- * Constructor for builder to create {@link MediaRouterParams} with existing
- * {@link MediaRouterParams} instance.
+ * Constructor for builder to create {@link MediaRouterParams} with existing {@link
+ * MediaRouterParams} instance.
*
* @param params the existing instance to copy data from.
*/
@@ -168,15 +176,17 @@
mOutputSwitcherEnabled = params.mOutputSwitcherEnabled;
mTransferToLocalEnabled = params.mTransferToLocalEnabled;
mMediaTransferEnabled = params.mMediaTransferReceiverEnabled;
+ mMediaTransferRestrictedToSelfProviders =
+ params.mMediaTransferRestrictedToSelfProviders;
mExtras = params.mExtras == null ? null : new Bundle(params.mExtras);
}
/**
- * Sets the media route controller dialog type. Default value is
- * {@link #DIALOG_TYPE_DEFAULT}.
- * <p>
- * Note that from Android R, output switcher will be used rather than the dialog type set by
- * this method if both {@link #setOutputSwitcherEnabled(boolean)} output switcher} and
+ * Sets the media route controller dialog type. Default value is {@link
+ * #DIALOG_TYPE_DEFAULT}.
+ *
+ * <p>Note that from Android R, output switcher will be used rather than the dialog type set
+ * by this method if both {@link #setOutputSwitcherEnabled(boolean)} output switcher} and
* {@link MediaTransferReceiver media transfer feature} are enabled.
*
* @param dialogType the dialog type
@@ -255,9 +265,30 @@
}
/**
- * Set extras. Default value is {@link Bundle#EMPTY} if not set.
+ * Sets whether the declared {@link MediaTransferReceiver} feature is restricted to {@link
+ * MediaRouteProviderService} provider services that handle the action {@code
+ * android.media.MediaRoute2ProviderService} declared by this app.
*
+ * <p>If this app restricts the {@link MediaTransferReceiver} feature to its own {@link
+ * MediaRouteProviderService} provider service that handles the action {@code
+ * android.media.MediaRoute2ProviderService}, then all other media route providers that
+ * declare both the {@code android.media.MediaRouteProviderService} action and the {@code
+ * android.media.MediaRoute2ProviderService} action would be treated as {@link
+ * MediaRouteProviderService} provider services with only the action {@code
+ * android.media.MediaRouteProviderService}.
+ *
+ * <p>For {@link MediaRouteProviderService} provider services that only handle the action
+ * {@code android.media.MediaRouteProviderService}, they are not affected by this flag.
*/
+ @NonNull
+ public Builder setMediaTransferRestrictedToSelfProviders(boolean enabled) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ mMediaTransferRestrictedToSelfProviders = enabled;
+ }
+ return this;
+ }
+
+ /** Set extras. Default value is {@link Bundle#EMPTY} if not set. */
@RestrictTo(RestrictTo.Scope.LIBRARY)
@NonNull
public Builder setExtras(@Nullable Bundle extras) {
@@ -265,9 +296,7 @@
return this;
}
- /**
- * Builds the {@link MediaRouterParams} instance.
- */
+ /** Builds the {@link MediaRouterParams} instance. */
@NonNull
public MediaRouterParams build() {
return new MediaRouterParams(this);
diff --git a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/RegisteredMediaRouteProviderWatcher.java b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/RegisteredMediaRouteProviderWatcher.java
index f80c24e0..82c5ca3 100644
--- a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/RegisteredMediaRouteProviderWatcher.java
+++ b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/RegisteredMediaRouteProviderWatcher.java
@@ -27,9 +27,11 @@
import android.media.MediaRoute2ProviderService;
import android.os.Build;
import android.os.Handler;
+import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
+import androidx.annotation.VisibleForTesting;
import java.util.ArrayList;
import java.util.Collections;
@@ -48,6 +50,7 @@
private final PackageManager mPackageManager;
private final ArrayList<RegisteredMediaRouteProvider> mProviders = new ArrayList<>();
+ private boolean mMediaTransferRestrictedToSelfProviders;
private boolean mRunning;
RegisteredMediaRouteProviderWatcher(Context context, Callback callback) {
@@ -97,6 +100,17 @@
}
}
+ /* package */ void setMediaTransferRestrictedToSelfProviders(
+ boolean mediaTransferRestrictedToSelfProviders) {
+ mMediaTransferRestrictedToSelfProviders = mediaTransferRestrictedToSelfProviders;
+ rescan();
+ }
+
+ @VisibleForTesting
+ /* package */ boolean isMediaTransferRestrictedToSelfProvidersForTesting() {
+ return mMediaTransferRestrictedToSelfProviders;
+ }
+
void scanPackages() {
if (!mRunning) {
return;
@@ -171,7 +185,13 @@
List<ServiceInfo> serviceInfoList = new ArrayList<>();
for (ResolveInfo resolveInfo : mPackageManager.queryIntentServices(intent, 0)) {
- serviceInfoList.add(resolveInfo.serviceInfo);
+ ServiceInfo serviceInfo = resolveInfo.serviceInfo;
+ if (mMediaTransferRestrictedToSelfProviders
+ && !TextUtils.equals(mContext.getPackageName(), serviceInfo.packageName)) {
+ // The app only allows its own Media Router provider to be a MediaRoute2 provider.
+ continue;
+ }
+ serviceInfoList.add(serviceInfo);
}
return serviceInfoList;
}
diff --git a/metrics/metrics-benchmark/src/androidTest/java/androidx/metrics/performance/benchmark/JankStatsBenchmark.kt b/metrics/metrics-benchmark/src/androidTest/java/androidx/metrics/performance/benchmark/JankStatsBenchmark.kt
index 74fbd4b..27f3fbf 100644
--- a/metrics/metrics-benchmark/src/androidTest/java/androidx/metrics/performance/benchmark/JankStatsBenchmark.kt
+++ b/metrics/metrics-benchmark/src/androidTest/java/androidx/metrics/performance/benchmark/JankStatsBenchmark.kt
@@ -25,12 +25,12 @@
import androidx.annotation.RequiresApi
import androidx.benchmark.junit4.BenchmarkRule
import androidx.benchmark.junit4.measureRepeated
+import androidx.benchmark.junit4.measureRepeatedOnMainThread
import androidx.metrics.performance.FrameData
import androidx.metrics.performance.JankStats
import androidx.metrics.performance.JankStatsInternalsForTesting
import androidx.metrics.performance.PerformanceMetricsState
import androidx.metrics.performance.benchmark.test.R
-import androidx.test.annotation.UiThreadTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.filters.SdkSuppress
@@ -76,26 +76,25 @@
}
}
- @UiThreadTest
@Test
fun setNewState() {
var iteration = 0
- benchmarkRule.measureRepeated {
+ benchmarkRule.measureRepeatedOnMainThread {
iteration++
metricsStateHolder.state?.putState("Activity$iteration", "activity")
}
}
- @UiThreadTest
@Test
fun setStateOverAndOver() {
- benchmarkRule.measureRepeated { metricsStateHolder.state?.putState("Activity", "activity") }
+ benchmarkRule.measureRepeatedOnMainThread {
+ metricsStateHolder.state?.putState("Activity", "activity")
+ }
}
- @UiThreadTest
@Test
fun setAndRemoveState() {
- benchmarkRule.measureRepeated {
+ benchmarkRule.measureRepeatedOnMainThread {
// Simply calling removeState() on the public API is not sufficient for benchmarking
// allocations, because it will not actually be removed until later, when JankStats
// issues data for a frame after the time the state was removed. Thus we call
diff --git a/navigation/integration-tests/testapp/build.gradle b/navigation/integration-tests/testapp/build.gradle
index a3b7ee4..03d2eac 100644
--- a/navigation/integration-tests/testapp/build.gradle
+++ b/navigation/integration-tests/testapp/build.gradle
@@ -45,6 +45,7 @@
android {
namespace "androidx.navigation.testapp"
+ compileSdk 35
}
tasks["check"].dependsOn(tasks["connectedCheck"])
diff --git a/navigation/navigation-common/build.gradle b/navigation/navigation-common/build.gradle
index 52382c7..a506467 100644
--- a/navigation/navigation-common/build.gradle
+++ b/navigation/navigation-common/build.gradle
@@ -41,11 +41,11 @@
dependencies {
api("androidx.annotation:annotation:1.8.1")
- api("androidx.lifecycle:lifecycle-common:2.6.2")
- api("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
- api("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2")
- api("androidx.savedstate:savedstate-ktx:1.2.1")
- api("androidx.lifecycle:lifecycle-viewmodel-savedstate:2.6.2")
+ api("androidx.lifecycle:lifecycle-common:2.9.0-alpha07")
+ api("androidx.lifecycle:lifecycle-runtime-ktx:2.9.0-alpha07")
+ api("androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.0-alpha07")
+ api("androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.0-alpha07")
+ api("androidx.savedstate:savedstate-ktx:1.3.0-alpha05")
api(libs.kotlinStdlib)
implementation("androidx.core:core-ktx:1.1.0")
diff --git a/navigation/navigation-common/src/main/java/androidx/navigation/NavGraph.kt b/navigation/navigation-common/src/main/java/androidx/navigation/NavGraph.kt
index 5fbe69d..f1454d9 100644
--- a/navigation/navigation-common/src/main/java/androidx/navigation/NavGraph.kt
+++ b/navigation/navigation-common/src/main/java/androidx/navigation/NavGraph.kt
@@ -238,16 +238,32 @@
* Searches all children and parents recursively.
*
* Does not revisit graphs (whether it's a child or parent) if it has already been visited.
+ *
+ * @param resId the [NavDestination.id]
+ * @param lastVisited the previously visited node
+ * @param searchChildren searches the graph's children for the node when true
+ * @param matchingDest an optional NavDestination that the node should match with. This is
+ * because [resId] is only unique to a local graph. Nodes in sibling graphs can have the same
+ * id.
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
public fun findNodeComprehensive(
@IdRes resId: Int,
lastVisited: NavDestination?,
- searchChildren: Boolean
+ searchChildren: Boolean,
+ matchingDest: NavDestination? = null,
): NavDestination? {
// first search direct children
var destination = nodes[resId]
- if (destination != null) return destination
+ when {
+ matchingDest != null ->
+ // check parent in case of duplicated destinations to ensure it finds the correct
+ // nested destination
+ if (destination == matchingDest && destination.parent == matchingDest.parent)
+ return destination
+ else destination = null
+ else -> if (destination != null) return destination
+ }
if (searchChildren) {
// then dfs through children. Avoid re-visiting children that were recursing up this
@@ -255,7 +271,7 @@
destination =
nodes.valueIterator().asSequence().firstNotNullOfOrNull { child ->
if (child is NavGraph && child != lastVisited) {
- child.findNodeComprehensive(resId, this, true)
+ child.findNodeComprehensive(resId, this, true, matchingDest)
} else null
}
}
@@ -264,7 +280,7 @@
// this way.
return destination
?: if (parent != null && parent != lastVisited) {
- parent!!.findNodeComprehensive(resId, this, searchChildren)
+ parent!!.findNodeComprehensive(resId, this, searchChildren, matchingDest)
} else null
}
diff --git a/navigation/navigation-runtime/src/androidTest/java/androidx/navigation/NavControllerRouteTest.kt b/navigation/navigation-runtime/src/androidTest/java/androidx/navigation/NavControllerRouteTest.kt
index 22c047f..fb3748f 100644
--- a/navigation/navigation-runtime/src/androidTest/java/androidx/navigation/NavControllerRouteTest.kt
+++ b/navigation/navigation-runtime/src/androidTest/java/androidx/navigation/NavControllerRouteTest.kt
@@ -1076,6 +1076,32 @@
@UiThreadTest
@Test
+ fun testNavigateNestedDuplicateDestination() {
+ val navController = createNavController()
+ navController.graph =
+ navController.createGraph(route = "root", startDestination = "start") {
+ test("start")
+ navigation(route = "second", startDestination = "duplicate") { test("duplicate") }
+ navigation(route = "duplicate", startDestination = "third") { test("third") }
+ }
+ assertThat(navController.currentDestination?.route).isEqualTo("start")
+
+ navController.navigate("second")
+ assertThat(navController.currentBackStack.value.map { it.destination.route })
+ .containsExactly("root", "start", "second", "duplicate")
+
+ navController.navigate("third")
+ assertThat(navController.currentBackStack.value.map { it.destination.route })
+ .containsExactly("root", "start", "second", "duplicate", "duplicate", "third")
+ val duplicateNode =
+ navController.currentBackStack.value
+ .last { it.destination.route == "duplicate" }
+ .destination
+ assertThat(duplicateNode.parent?.route).isEqualTo("root")
+ }
+
+ @UiThreadTest
+ @Test
fun testNavigateWithObject() {
val navController = createNavController()
navController.graph =
diff --git a/navigation/navigation-runtime/src/main/java/androidx/navigation/NavController.kt b/navigation/navigation-runtime/src/main/java/androidx/navigation/NavController.kt
index 4f43515..8d9d678 100644
--- a/navigation/navigation-runtime/src/main/java/androidx/navigation/NavController.kt
+++ b/navigation/navigation-runtime/src/main/java/androidx/navigation/NavController.kt
@@ -1713,34 +1713,72 @@
return currentBackStackEntry?.destination
}
- /** Recursively searches through parents */
+ /**
+ * Recursively searches through parents
+ *
+ * @param destinationId the [NavDestination.id]
+ * @param matchingDest an optional NavDestination that the node should match with. This is
+ * because [destinationId] is only unique to a local graph. Nodes in sibling graphs can have
+ * the same id.
+ */
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- public fun findDestination(@IdRes destinationId: Int): NavDestination? {
+ public fun findDestination(
+ @IdRes destinationId: Int,
+ matchingDest: NavDestination? = null,
+ ): NavDestination? {
if (_graph == null) {
return null
}
+
if (_graph!!.id == destinationId) {
- return _graph
+ when {
+ /**
+ * if the search expected a specific NavDestination (i.e. a duplicated destination
+ * within a specific graph), we need to make sure the result matches it to ensure
+ * this search returns the correct duplicate.
+ */
+ matchingDest != null ->
+ if (_graph == matchingDest && matchingDest.parent == null) return _graph
+ else -> return _graph
+ }
}
+
val currentNode = backQueue.lastOrNull()?.destination ?: _graph!!
- return currentNode.findDestinationComprehensive(destinationId, false)
+ return currentNode.findDestinationComprehensive(destinationId, false, matchingDest)
}
/**
* Recursively searches through parents. If [searchChildren] is true, also recursively searches
* children.
+ *
+ * @param destinationId the [NavDestination.id]
+ * @param searchChildren recursively searches children when true
+ * @param matchingDest an optional NavDestination that the node should match with. This is
+ * because [destinationId] is only unique to a local graph. Nodes in sibling graphs can have
+ * the same id.
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
public fun NavDestination.findDestinationComprehensive(
@IdRes destinationId: Int,
- searchChildren: Boolean
+ searchChildren: Boolean,
+ matchingDest: NavDestination? = null,
): NavDestination? {
-
if (id == destinationId) {
- return this
+ when {
+ // check parent in case of duplicated destinations to ensure it finds the correct
+ // nested destination
+ matchingDest != null ->
+ if (this == matchingDest && this.parent == matchingDest.parent) return this
+ else -> return this
+ }
}
val currentGraph = if (this is NavGraph) this else parent!!
- return currentGraph.findNodeComprehensive(destinationId, currentGraph, searchChildren)
+ return currentGraph.findNodeComprehensive(
+ destinationId,
+ currentGraph,
+ searchChildren,
+ matchingDest
+ )
}
/** Recursively searches through parents */
@@ -2352,7 +2390,9 @@
// equality to ensure that same destinations with a parent that is not this _graph
// will also have their parents added to the hierarchy.
destination = if (hierarchy.isEmpty()) newDest else hierarchy.first().destination
- while (destination != null && findDestination(destination.id) !== destination) {
+ while (
+ destination != null && findDestination(destination.id, destination) !== destination
+ ) {
val parent = destination.parent
if (parent != null) {
val args = if (finalArgs?.isEmpty == true) null else finalArgs
diff --git a/navigation3/navigation3/api/current.txt b/navigation3/navigation3/api/current.txt
index 17c13b7..b41230e 100644
--- a/navigation3/navigation3/api/current.txt
+++ b/navigation3/navigation3/api/current.txt
@@ -1,6 +1,16 @@
// Signature format: 4.0
package androidx.navigation3 {
+ public final class AnimatedNavDisplay {
+ method public java.util.Map<java.lang.String,java.lang.Object> isDialog(boolean boolean);
+ method public java.util.Map<java.lang.String,java.lang.Object> transition(androidx.compose.animation.EnterTransition? enter, androidx.compose.animation.ExitTransition? exit);
+ field public static final androidx.navigation3.AnimatedNavDisplay INSTANCE;
+ }
+
+ public final class AnimatedNavDisplay_androidKt {
+ method @androidx.compose.runtime.Composable public static void AnimatedNavDisplay(java.util.List<?> backstack, androidx.navigation3.NavWrapperManager wrapperManager, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.Alignment contentAlignment, optional androidx.compose.animation.SizeTransform? sizeTransform, optional kotlin.jvm.functions.Function0<kotlin.Unit> onBack, kotlin.jvm.functions.Function1<java.lang.Object,androidx.navigation3.Record> recordProvider);
+ }
+
public interface NavContentWrapper {
method public default void WrapBackStack(java.util.List<?> backStack);
method public void WrapContent(androidx.navigation3.Record record);
diff --git a/navigation3/navigation3/api/restricted_current.txt b/navigation3/navigation3/api/restricted_current.txt
index 17c13b7..b41230e 100644
--- a/navigation3/navigation3/api/restricted_current.txt
+++ b/navigation3/navigation3/api/restricted_current.txt
@@ -1,6 +1,16 @@
// Signature format: 4.0
package androidx.navigation3 {
+ public final class AnimatedNavDisplay {
+ method public java.util.Map<java.lang.String,java.lang.Object> isDialog(boolean boolean);
+ method public java.util.Map<java.lang.String,java.lang.Object> transition(androidx.compose.animation.EnterTransition? enter, androidx.compose.animation.ExitTransition? exit);
+ field public static final androidx.navigation3.AnimatedNavDisplay INSTANCE;
+ }
+
+ public final class AnimatedNavDisplay_androidKt {
+ method @androidx.compose.runtime.Composable public static void AnimatedNavDisplay(java.util.List<?> backstack, androidx.navigation3.NavWrapperManager wrapperManager, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.Alignment contentAlignment, optional androidx.compose.animation.SizeTransform? sizeTransform, optional kotlin.jvm.functions.Function0<kotlin.Unit> onBack, kotlin.jvm.functions.Function1<java.lang.Object,androidx.navigation3.Record> recordProvider);
+ }
+
public interface NavContentWrapper {
method public default void WrapBackStack(java.util.List<?> backStack);
method public void WrapContent(androidx.navigation3.Record record);
diff --git a/navigation3/navigation3/build.gradle b/navigation3/navigation3/build.gradle
index f61edaa..1911ba9 100644
--- a/navigation3/navigation3/build.gradle
+++ b/navigation3/navigation3/build.gradle
@@ -66,6 +66,7 @@
dependencies {
implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.annotation:annotation:1.8.0")
+ implementation("androidx.compose.animation:animation:1.7.5")
implementation("androidx.compose.foundation:foundation:1.7.5")
implementation("androidx.compose.ui:ui:1.7.5")
implementation("androidx.lifecycle:lifecycle-runtime:2.8.7")
diff --git a/navigation3/navigation3/samples/build.gradle b/navigation3/navigation3/samples/build.gradle
index b2b8be8..8e42ee7 100644
--- a/navigation3/navigation3/samples/build.gradle
+++ b/navigation3/navigation3/samples/build.gradle
@@ -37,6 +37,7 @@
compileOnly(project(":annotation:annotation-sampled"))
implementation(libs.kotlinStdlib)
+ implementation("androidx.compose.animation:animation:1.7.5")
implementation("androidx.compose.foundation:foundation:1.7.5")
implementation("androidx.compose.foundation:foundation-layout:1.7.5")
implementation("androidx.compose.material:material:1.7.5")
diff --git a/navigation3/navigation3/samples/src/main/kotlin/androidx/navigation3/samples/NavDisplaySamples.kt b/navigation3/navigation3/samples/src/main/kotlin/androidx/navigation3/samples/NavDisplaySamples.kt
index f7adfa2..a6364e4 100644
--- a/navigation3/navigation3/samples/src/main/kotlin/androidx/navigation3/samples/NavDisplaySamples.kt
+++ b/navigation3/navigation3/samples/src/main/kotlin/androidx/navigation3/samples/NavDisplaySamples.kt
@@ -17,11 +17,14 @@
package androidx.navigation3.samples
import androidx.annotation.Sampled
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.navigation3.ViewModelStoreNavContentWrapper
+import androidx.navigation3.AnimatedNavDisplay
import androidx.navigation3.NavDisplay
import androidx.navigation3.Record
import androidx.navigation3.SavedStateNavContentWrapper
@@ -71,3 +74,64 @@
class ProfileViewModel : ViewModel() {
val name = "no user"
}
+
+@Sampled
+@Composable
+fun AnimatedNav() {
+ val backStack = rememberMutableStateListOf(Profile)
+ val manager =
+ rememberNavWrapperManager(
+ listOf(SavedStateNavContentWrapper, ViewModelStoreNavContentWrapper)
+ )
+ AnimatedNavDisplay(
+ backstack = backStack,
+ wrapperManager = manager,
+ onBack = { backStack.removeLast() },
+ ) { key ->
+ when (key) {
+ Profile -> {
+ Record(
+ Profile,
+ AnimatedNavDisplay.transition(
+ slideInHorizontally { it },
+ slideOutHorizontally { it }
+ )
+ ) {
+ val viewModel = viewModel<ProfileViewModel>()
+ Profile(viewModel, { backStack.add(it) }) { backStack.removeLast() }
+ }
+ }
+ Scrollable -> {
+ Record(
+ Scrollable,
+ AnimatedNavDisplay.transition(
+ slideInHorizontally { it },
+ slideOutHorizontally { it }
+ )
+ ) {
+ Scrollable({ backStack.add(it) }) { backStack.removeLast() }
+ }
+ }
+ Dialog -> {
+ Record(Dialog, featureMap = NavDisplay.isDialog(true)) {
+ DialogContent { backStack.removeLast() }
+ }
+ }
+ Dashboard -> {
+ Record(
+ Dashboard,
+ AnimatedNavDisplay.transition(
+ slideInHorizontally { it },
+ slideOutHorizontally { it }
+ )
+ ) { dashboardArgs ->
+ val userId = (dashboardArgs as Dashboard).userId
+ Dashboard(userId, onBack = { backStack.removeLast() })
+ }
+ }
+ else -> {
+ Record(Unit) { Text(text = "Invalid Key") }
+ }
+ }
+ }
+}
diff --git a/navigation3/navigation3/src/androidInstrumentedTest/kotlin/androidx/navigation3/AnimatedNavDisplayTest.kt b/navigation3/navigation3/src/androidInstrumentedTest/kotlin/androidx/navigation3/AnimatedNavDisplayTest.kt
new file mode 100644
index 0000000..bd42647
--- /dev/null
+++ b/navigation3/navigation3/src/androidInstrumentedTest/kotlin/androidx/navigation3/AnimatedNavDisplayTest.kt
@@ -0,0 +1,81 @@
+/*
+ * 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.navigation3
+
+import androidx.compose.animation.core.AnimationConstants.DefaultDurationMillis
+import androidx.compose.material3.Text
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.test.isDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.kruth.assertThat
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import kotlin.test.Test
+import org.junit.Rule
+import org.junit.runner.RunWith
+
+@LargeTest
+@RunWith(AndroidJUnit4::class)
+class AnimatedNavDisplayTest {
+ @get:Rule val composeTestRule = createComposeRule()
+
+ @Test
+ fun testNavHostAnimations() {
+ lateinit var backstack: MutableList<Any>
+
+ composeTestRule.mainClock.autoAdvance = false
+
+ composeTestRule.setContent {
+ backstack = remember { mutableStateListOf(first) }
+ val manager = rememberNavWrapperManager(emptyList())
+ AnimatedNavDisplay(backstack, wrapperManager = manager) {
+ when (it) {
+ first -> Record(first) { Text(first) }
+ second -> Record(second) { Text(second) }
+ else -> error("Invalid key passed")
+ }
+ }
+ }
+
+ composeTestRule.mainClock.autoAdvance = true
+
+ composeTestRule.waitForIdle()
+ assertThat(composeTestRule.onNodeWithText(first).isDisplayed()).isTrue()
+
+ composeTestRule.mainClock.autoAdvance = false
+
+ composeTestRule.runOnIdle { backstack.add(second) }
+
+ // advance half way between animations
+ composeTestRule.mainClock.advanceTimeBy(DefaultDurationMillis.toLong() / 2)
+
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithText(first).assertExists()
+ composeTestRule.onNodeWithText(second).assertExists()
+
+ composeTestRule.mainClock.autoAdvance = true
+
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithText(first).assertDoesNotExist()
+ composeTestRule.onNodeWithText(second).assertExists()
+ }
+}
+
+private const val first = "first"
+private const val second = "second"
diff --git a/navigation3/navigation3/src/androidMain/kotlin/androidx/navigation3/AnimatedNavDisplay.android.kt b/navigation3/navigation3/src/androidMain/kotlin/androidx/navigation3/AnimatedNavDisplay.android.kt
new file mode 100644
index 0000000..4699ac1
--- /dev/null
+++ b/navigation3/navigation3/src/androidMain/kotlin/androidx/navigation3/AnimatedNavDisplay.android.kt
@@ -0,0 +1,125 @@
+/*
+ * 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.navigation3
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.ContentTransform
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
+import androidx.compose.animation.SizeTransform
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.core.updateTransition
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.window.Dialog
+
+/** Object that indicates the features that can be handled by the [AnimatedNavDisplay] */
+public object AnimatedNavDisplay {
+ /**
+ * Function to be called on the [Record.featureMap] to notify the [AnimatedNavDisplay] that the
+ * content should be animated using the provided transitions.
+ */
+ public fun transition(enter: EnterTransition?, exit: ExitTransition?): Map<String, Any> =
+ if (enter == null || exit == null) emptyMap()
+ else mapOf(ENTER_TRANSITION_KEY to enter, EXIT_TRANSITION_KEY to exit)
+
+ /**
+ * Function to be called on the [Record.featureMap] to notify the [NavDisplay] that the content
+ * should be displayed inside of a [Dialog]
+ */
+ public fun isDialog(boolean: Boolean): Map<String, Any> =
+ if (!boolean) emptyMap() else mapOf(DIALOG_KEY to true)
+
+ internal const val ENTER_TRANSITION_KEY = "enterTransition"
+ internal const val EXIT_TRANSITION_KEY = "exitTransition"
+ internal const val DIALOG_KEY = "dialog"
+}
+
+/**
+ * Display for Composable content that displays a single pane of content at a time, but can move
+ * that content in and out with customized transitions.
+ *
+ * The AnimatedNavDisplay displays the content associated with the last key on the back stack in
+ * most circumstances. If that content wants to be displayed as a dialog, as communicated by adding
+ * [NavDisplay.isDialog] to a [Record.featureMap], then the last key's content is a dialog and the
+ * second to last key is a displayed in the background.
+ *
+ * @param backstack the collection of keys that represents the state that needs to be handled
+ * @param wrapperManager the manager that combines all of the [NavContentWrapper]s
+ * @param modifier the modifier to be applied to the layout.
+ * @param contentAlignment The [Alignment] of the [AnimatedContent]
+ * @param onBack a callback for handling system back presses
+ * @param recordProvider lambda used to construct each possible [Record]
+ * @sample androidx.navigation3.samples.AnimatedNav
+ */
+@Composable
+public fun AnimatedNavDisplay(
+ backstack: List<Any>,
+ wrapperManager: NavWrapperManager,
+ modifier: Modifier = Modifier,
+ contentAlignment: Alignment = Alignment.TopStart,
+ sizeTransform: SizeTransform? = null,
+ onBack: () -> Unit = {},
+ recordProvider: (key: Any) -> Record
+) {
+ BackHandler(backstack.size > 1, onBack)
+ wrapperManager.PrepareBackStack(backStack = backstack)
+ val key = backstack.last()
+ val record = recordProvider.invoke(key)
+
+ // Incoming record defines transitions, otherwise it defaults to a fade
+ val enterTransition =
+ record.featureMap[AnimatedNavDisplay.ENTER_TRANSITION_KEY] as? EnterTransition
+ ?: fadeIn(animationSpec = tween(700))
+ val exitTransition =
+ record.featureMap[AnimatedNavDisplay.EXIT_TRANSITION_KEY] as? ExitTransition
+ ?: fadeOut(animationSpec = tween(700))
+
+ // if there is a dialog, we should create a transition with the next to last entry instead.
+ val transition =
+ if (record.featureMap[AnimatedNavDisplay.DIALOG_KEY] == true) {
+ if (backstack.size > 1) {
+ val previousKey = backstack[backstack.size - 2]
+ updateTransition(targetState = previousKey, label = previousKey.toString())
+ } else {
+ null
+ }
+ } else {
+ updateTransition(targetState = key, label = key.toString())
+ }
+
+ transition?.AnimatedContent(
+ modifier = modifier,
+ transitionSpec = {
+ ContentTransform(
+ targetContentEnter = enterTransition,
+ initialContentExit = exitTransition,
+ sizeTransform = sizeTransform
+ )
+ },
+ contentAlignment = contentAlignment
+ ) { innerKey ->
+ wrapperManager.ContentForRecord(recordProvider.invoke(innerKey))
+ }
+
+ if (record.featureMap[AnimatedNavDisplay.DIALOG_KEY] == true) {
+ Dialog(onBack) { wrapperManager.ContentForRecord(record) }
+ }
+}
diff --git a/pdf/integration-tests/testapp/src/androidTest/kotlin/androidx/pdf/PdfViewerFragmentTestSuite.kt b/pdf/integration-tests/testapp/src/androidTest/kotlin/androidx/pdf/PdfViewerFragmentTestSuite.kt
index 9d4d1fe..1469558 100644
--- a/pdf/integration-tests/testapp/src/androidTest/kotlin/androidx/pdf/PdfViewerFragmentTestSuite.kt
+++ b/pdf/integration-tests/testapp/src/androidTest/kotlin/androidx/pdf/PdfViewerFragmentTestSuite.kt
@@ -16,10 +16,8 @@
package androidx.pdf
-import android.annotation.SuppressLint
import android.content.pm.ActivityInfo
import android.os.Build
-import android.view.KeyEvent
import androidx.annotation.RequiresExtension
import androidx.fragment.app.testing.FragmentScenario
import androidx.fragment.app.testing.launchFragmentInContainer
@@ -31,7 +29,6 @@
import androidx.test.espresso.IdlingRegistry
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.longClick
-import androidx.test.espresso.action.ViewActions.pressKey
import androidx.test.espresso.action.ViewActions.swipeDown
import androidx.test.espresso.action.ViewActions.swipeUp
import androidx.test.espresso.action.ViewActions.typeText
@@ -50,7 +47,6 @@
import org.junit.Test
import org.junit.runner.RunWith
-@SuppressLint("BanThreadSleep")
@LargeTest
@RunWith(AndroidJUnit4::class)
@SdkSuppress(minSdkVersion = 35)
@@ -239,38 +235,6 @@
.check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE)))
}
- fun testPdfViewerFragment_setDocumentUri_passwordProtected_portrait() {
- val scenario =
- scenarioLoadDocument(
- TEST_PROTECTED_DOCUMENT_FILE,
- Lifecycle.State.RESUMED,
- ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
- )
-
- // Delay required for password dialog to come up
- // TODO: Implement callback based delay and remove Thread.sleep
- Thread.sleep(DELAY_TIME_MS)
- onView(withId(R.id.password)).perform(typeText(PROTECTED_DOCUMENT_PASSWORD))
- onView(withId(R.id.password)).perform(pressKey(KeyEvent.KEYCODE_ENTER))
-
- // Delay required for the PDF to load
- // TODO: Implement callback based delay and remove Thread.sleep
- Thread.sleep(DELAY_TIME_MS)
- onView(withId(R.id.loadingView))
- .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE)))
- scenario.onFragment {
- Preconditions.checkArgument(
- it.documentLoaded,
- "Unable to load document due to ${it.documentError?.message}"
- )
- }
-
- // Swipe actions
- onView(withId(R.id.parent_pdf_container)).perform(swipeUp())
- onView(withId(R.id.parent_pdf_container)).perform(swipeDown())
- scenario.close()
- }
-
@Test
fun testPdfViewerFragment_onLoadDocumentError_corruptPdf() {
val scenario =
diff --git a/pdf/pdf-viewer-fragment/src/main/java/androidx/pdf/viewer/fragment/PdfViewerFragment.kt b/pdf/pdf-viewer-fragment/src/main/java/androidx/pdf/viewer/fragment/PdfViewerFragment.kt
index f5a5059..11a7754a 100644
--- a/pdf/pdf-viewer-fragment/src/main/java/androidx/pdf/viewer/fragment/PdfViewerFragment.kt
+++ b/pdf/pdf-viewer-fragment/src/main/java/androidx/pdf/viewer/fragment/PdfViewerFragment.kt
@@ -339,7 +339,6 @@
fastScrollView = fastScrollView!!,
zoomView = zoomView!!,
paginatedView = paginatedView!!,
- loadingView = loadingView!!,
findInFileView = findInFileView!!,
isTextSearchActive = isTextSearchActive,
viewState = viewState,
@@ -363,6 +362,9 @@
onRequestImmersiveMode(false)
}
}
+
+ hideSpinner()
+ showPdfView()
},
onDocumentLoadFailure = { exception, showErrorView ->
// Update state to reflect document load failure.
@@ -430,6 +432,14 @@
delayedContentsAvailable?.run()
super.onStart()
started = true
+
+ // Check if the document file exists, return early if not
+ documentUri?.let {
+ val fileExist = checkAndFetchFile(it, false)
+ if (!fileExist) {
+ return
+ }
+ }
if (delayedEnter || onScreen) {
onEnter()
delayedEnter = false
@@ -833,6 +843,16 @@
}
}
+ private fun resetViewsAndModels(fileUri: Uri) {
+ if (pdfLoader != null) {
+ pdfLoaderCallbacks?.uri = fileUri
+ paginatedView?.resetModels()
+ destroyContentModel()
+ }
+ fastScrollView?.resetContents()
+ findInFileView?.resetFindInFile()
+ }
+
private fun loadFile(fileUri: Uri) {
// Early return if fragment is not in STARTED state
if (!lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
@@ -848,38 +868,56 @@
putParcelable(KEY_DOCUMENT_URI, fileUri)
putBoolean(KEY_TEXT_SEARCH_ACTIVE, false)
}
- if (pdfLoader != null) {
- pdfLoaderCallbacks?.uri = fileUri
- paginatedView?.resetModels()
- destroyContentModel()
- }
+
+ // Reset UI components and models before loading the file
+ resetViewsAndModels(fileUri)
detachViewsAndObservers()
- fastScrollView?.resetContents()
- findInFileView?.resetFindInFile()
- try {
- validateFileUri(fileUri)
- fetchFile(fileUri)
- } catch (error: Exception) {
- when (error) {
- is IOException,
- is SecurityException,
- is NullPointerException -> handleError(error)
- else -> throw error
- }
- }
+
+ // Validate the file URI and attempt to load the file contents
+ checkAndFetchFile(fileUri, true)
+
if (localUri != null && localUri != fileUri) {
onRequestImmersiveMode(true)
}
localUri = fileUri
}
+ private fun checkAndFetchFile(fileUri: Uri, performLoad: Boolean): Boolean {
+ try {
+ validateFileUri(fileUri)
+ fetchFile(fileUri, performLoad)
+ return true
+ } catch (error: Exception) {
+ when (error) {
+ is IOException,
+ is SecurityException,
+ is NullPointerException -> handleFileNotAvailable(fileUri, error)
+ else -> {
+ throw error
+ }
+ }
+ return false
+ }
+ }
+
+ private fun handleFileNotAvailable(fileUri: Uri, error: Throwable) {
+ // Reset views and models when file error occurs
+ resetViewsAndModels(fileUri)
+
+ // Hide fast scroll and show loading view to display error message
+ fastScrollView?.visibility = View.GONE
+ loadingView?.visibility = View.VISIBLE
+
+ handleError(error)
+ }
+
private fun validateFileUri(fileUri: Uri) {
if (!Uris.isContentUri(fileUri) && !Uris.isFileUri(fileUri)) {
throw IllegalArgumentException("Only content and file uri is supported")
}
}
- private fun fetchFile(fileUri: Uri) {
+ private fun fetchFile(fileUri: Uri, performLoad: Boolean) {
Preconditions.checkNotNull(fileUri)
val fileName: String = getFileName(fileUri)
val openable: FutureValue<Openable> = fetcher?.loadLocal(fileUri)!!
@@ -887,7 +925,10 @@
openable[
object : FutureValue.Callback<Openable> {
override fun available(value: Openable) {
- viewerAvailable(fileUri, fileName, value)
+ // If loading is required, notify the viewer with the available content.
+ if (performLoad) {
+ viewerAvailable(fileUri, fileName, value)
+ }
}
override fun failed(thrown: Throwable) {
@@ -951,10 +992,23 @@
intent.setData(localUri)
intent.putExtra(EXTRA_PDF_FILE_NAME, getFileName(localUri!!))
// TODO: Pass current page number to restore it in edit mode.
- intent.putExtra(EXTRA_STARTING_PAGE, 0)
+ intent.putExtra(EXTRA_STARTING_PAGE, getStartingPageNumber())
startActivity(intent)
}
+ private fun getStartingPageNumber(): Int {
+ // Return the page that is centered in the view.
+ return paginationModel?.midPage ?: 0
+ }
+
+ private fun hideSpinner() {
+ loadingView?.visibility = View.GONE
+ }
+
+ private fun showPdfView() {
+ fastScrollView?.visibility = View.VISIBLE
+ }
+
private companion object {
/** Key for saving page layout reach in bundles. */
private const val KEY_LAYOUT_REACH: String = "plr"
diff --git a/pdf/pdf-viewer-fragment/src/main/kotlin/androidx/pdf/viewer/fragment/PdfViewerFragmentV2.kt b/pdf/pdf-viewer-fragment/src/main/kotlin/androidx/pdf/viewer/fragment/PdfViewerFragmentV2.kt
index b0ba835..4a544c5 100644
--- a/pdf/pdf-viewer-fragment/src/main/kotlin/androidx/pdf/viewer/fragment/PdfViewerFragmentV2.kt
+++ b/pdf/pdf-viewer-fragment/src/main/kotlin/androidx/pdf/viewer/fragment/PdfViewerFragmentV2.kt
@@ -22,20 +22,20 @@
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
-import android.widget.FrameLayout
+import android.view.ViewGroup.LayoutParams
import androidx.annotation.RestrictTo
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
-import androidx.pdf.R
import androidx.pdf.exceptions.PdfPasswordException
+import androidx.pdf.view.PdfView
import kotlinx.coroutines.launch
@RestrictTo(RestrictTo.Scope.LIBRARY)
public class PdfViewerFragmentV2 : Fragment() {
private val documentViewModel: PdfDocumentViewModel by
viewModels() { PdfDocumentViewModel.Factory }
- private lateinit var pdfViewer: FrameLayout
+ private lateinit var pdfView: PdfView
public var documentUri: Uri? = null
set(value) {
@@ -48,8 +48,11 @@
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
- pdfViewer = inflater.inflate(R.layout.pdf_viewer_container, container, false) as FrameLayout
- return pdfViewer
+ pdfView =
+ PdfView(requireContext()).apply {
+ layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
+ }
+ return pdfView
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -60,6 +63,7 @@
if (result != null) {
if (result.isSuccess) {
// Document loaded
+ pdfView.pdfDocument = result.getOrNull()
Log.i("DDDDD", "Document has been loaded successfully")
} else if (result.exceptionOrNull() is PdfPasswordException) {
// Display password prompt
diff --git a/pdf/pdf-viewer/build.gradle b/pdf/pdf-viewer/build.gradle
index 7ed8998..c40df24 100644
--- a/pdf/pdf-viewer/build.gradle
+++ b/pdf/pdf-viewer/build.gradle
@@ -56,6 +56,8 @@
androidTestImplementation(libs.dexmakerMockito)
androidTestImplementation(libs.truth)
androidTestImplementation(libs.espressoCore)
+ androidTestImplementation(libs.kotlinCoroutinesTest)
+ androidTestImplementation(libs.mockitoKotlin4)
}
android {
diff --git a/pdf/pdf-viewer/src/androidTest/AndroidManifest.xml b/pdf/pdf-viewer/src/androidTest/AndroidManifest.xml
index fc250fb..d2d4b0d 100644
--- a/pdf/pdf-viewer/src/androidTest/AndroidManifest.xml
+++ b/pdf/pdf-viewer/src/androidTest/AndroidManifest.xml
@@ -21,5 +21,9 @@
android:name="androidx.pdf.TestActivity"
android:exported="false"
android:theme="@style/Theme.Material3.DynamicColors.DayNight"/>
+ <activity
+ android:name="androidx.pdf.view.PdfViewTestActivity"
+ android:exported="false"
+ android:theme="@style/Theme.Material3.DynamicColors.DayNight"/>
</application>
</manifest>
\ No newline at end of file
diff --git a/pdf/pdf-viewer/src/androidTest/java/androidx/pdf/viewer/loader/PdfLoaderTest.java b/pdf/pdf-viewer/src/androidTest/java/androidx/pdf/viewer/loader/PdfLoaderTest.java
index bdba879..30461c7 100644
--- a/pdf/pdf-viewer/src/androidTest/java/androidx/pdf/viewer/loader/PdfLoaderTest.java
+++ b/pdf/pdf-viewer/src/androidTest/java/androidx/pdf/viewer/loader/PdfLoaderTest.java
@@ -59,6 +59,7 @@
import com.google.common.base.Objects;
import com.google.common.collect.Iterables;
+import org.junit.After;
import org.junit.Before;
import org.junit.Ignore;
import org.junit.Test;
@@ -105,10 +106,11 @@
/** {@link PdfTaskExecutor} waits 10 seconds if it doesn't have any tasks, so we use 12. */
private static final int LATCH_TIMEOUT_MS = 12000;
+ private AutoCloseable mCloseable;
@Before
public void setUp() throws Exception {
- MockitoAnnotations.initMocks(this);
+ mCloseable = MockitoAnnotations.openMocks(this);
mContext = ApplicationProvider.getApplicationContext();
when(mConnection.isLoaded()).thenReturn(true);
@@ -132,6 +134,15 @@
mFileOutputStream = new FileOutputStream(file);
}
+ @After
+ public void cleanUp() {
+ try {
+ mCloseable.close();
+ } catch (Exception e) {
+ // No-op
+ }
+ }
+
@Test
@UiThreadTest
public void testLoadDimensions() {
diff --git a/pdf/pdf-viewer/src/androidTest/java/androidx/pdf/viewer/loader/PdfTaskExecutorTest.java b/pdf/pdf-viewer/src/androidTest/java/androidx/pdf/viewer/loader/PdfTaskExecutorTest.java
index fe945ea..c131023 100644
--- a/pdf/pdf-viewer/src/androidTest/java/androidx/pdf/viewer/loader/PdfTaskExecutorTest.java
+++ b/pdf/pdf-viewer/src/androidTest/java/androidx/pdf/viewer/loader/PdfTaskExecutorTest.java
@@ -29,6 +29,7 @@
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.MediumTest;
+import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
@@ -58,10 +59,11 @@
private CountDownLatch mStartTaskLatch;
private CountDownLatch mFinishedTaskLatch;
+ private AutoCloseable mCloseable;
@Before
public void setUp() {
- MockitoAnnotations.initMocks(this);
+ mCloseable = MockitoAnnotations.openMocks(this);
when(mPdfLoader.getCallbacks()).thenReturn(mCallbacks);
when(mPdfLoader.getLoadedPdfDocument(isA(String.class))).thenReturn(mPdfDocument);
mExecutor = new PdfTaskExecutor();
@@ -71,6 +73,15 @@
mFinishedTaskResults = new ArrayList<>();
}
+ @After
+ public void cleanUp() {
+ try {
+ mCloseable.close();
+ } catch (Exception e) {
+ // No-op
+ }
+ }
+
@Test
public void testSchedule() throws Exception {
mFinishedTaskLatch = new CountDownLatch(6);
diff --git a/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/FakePdfDocument.kt b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/FakePdfDocument.kt
new file mode 100644
index 0000000..5cd2d49
--- /dev/null
+++ b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/FakePdfDocument.kt
@@ -0,0 +1,243 @@
+/*
+ * 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.pdf.view
+
+import android.graphics.Bitmap
+import android.graphics.Color
+import android.graphics.Point
+import android.graphics.PointF
+import android.graphics.Rect
+import android.net.Uri
+import android.os.Build
+import android.util.Size
+import android.util.SparseArray
+import androidx.annotation.OpenForTesting
+import androidx.annotation.RequiresExtension
+import androidx.pdf.PdfDocument
+import androidx.pdf.content.PageMatchBounds
+import androidx.pdf.content.PageSelection
+import androidx.pdf.content.SelectionBoundary
+import kotlin.random.Random
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeout
+
+/**
+ * Fake implementation of [PdfDocument], for testing
+ *
+ * Provides an implementation of [getPageInfo] and [getPageInfos] that produces the dimensions
+ * provided as [pages]. Provides an implementation of [getPageBitmapSource] that produces a random
+ * solid RGB color bitmap for each page in [pages]. All other methods are fulfilled with no-op
+ * implementations that return empty values.
+ *
+ * Requests made against an instance can be tracked:
+ * - Using [layoutReach] to detect the maximum page whose dimensions have been requested
+ * - Using [renderReach] to detect the maximum page for which any bitmap has been requested from the
+ * corresponding [PdfDocument.BitmapSource]
+ * - Using [bitmapRequests] to examine the type of bitmaps that have been requested for any page
+ *
+ * @param pages a list of [Point] defining the number of pages in the fake PDF and their dimensions
+ * @param formType one of [PDF_FORM_TYPE_ACRO_FORM], [PDF_FORM_TYPE_XFA_FULL],
+ * [PDF_FORM_TYPE_XFA_FOREGROUND], or [PDF_FORM_TYPE_NONE] depending on the type of PDF form this
+ * fake PDF should represent
+ * @param isLinearized true if this fake PDF is linearized
+ */
+@OpenForTesting
+internal open class FakePdfDocument(
+ /** A list of (x, y) page dimensions in content coordinates */
+ private val pages: List<Point>,
+ override val formType: Int = PDF_FORM_TYPE_NONE,
+ override val isLinearized: Boolean = false,
+) : PdfDocument {
+ override val pageCount: Int = pages.size
+
+ override val uri: Uri
+ get() = Uri.parse("content://test.app/document.pdf")
+
+ @get:Synchronized @set:Synchronized internal var layoutReach: Int = 0
+
+ @get:Synchronized @set:Synchronized internal var renderReach: Int = 0
+
+ private val bitmapRequestsLock = Object()
+ private val _bitmapRequests = mutableMapOf<Int, SizeParams>()
+ internal val bitmapRequests
+ get() = _bitmapRequests
+
+ override fun getPageBitmapSource(pageNumber: Int): PdfDocument.BitmapSource {
+ return FakeBitmapSource(pageNumber)
+ }
+
+ override suspend fun getPageLinks(pageNumber: Int): PdfDocument.PdfPageLinks {
+ // TODO(b/376136907) provide a useful implementation when it's needed for testing
+ return PdfDocument.PdfPageLinks(listOf(), listOf())
+ }
+
+ @RequiresExtension(extension = Build.VERSION_CODES.S, version = 13)
+ override suspend fun getPageContent(pageNumber: Int): PdfDocument.PdfPageContent {
+ // TODO(b/376136746) provide a useful implementation when it's needed for testing
+ return PdfDocument.PdfPageContent(listOf(), listOf())
+ }
+
+ @RequiresExtension(extension = Build.VERSION_CODES.S, version = 13)
+ override suspend fun getSelectionBounds(
+ pageNumber: Int,
+ start: PointF,
+ stop: PointF
+ ): PageSelection {
+ // TODO(b/376136631) provide a useful implementation when it's needed for testing
+ return PageSelection(0, SelectionBoundary(0), SelectionBoundary(0), listOf())
+ }
+
+ override suspend fun searchDocument(
+ query: String,
+ pageRange: IntRange
+ ): SparseArray<List<PageMatchBounds>> {
+ // TODO - provide a useful implementation when it's needed for testing
+ return SparseArray()
+ }
+
+ override suspend fun getPageInfos(pageRange: IntRange): List<PdfDocument.PageInfo> {
+ return pageRange.map { getPageInfo(it) }
+ }
+
+ override suspend fun getPageInfo(pageNumber: Int): PdfDocument.PageInfo {
+ layoutReach = maxOf(pageNumber, layoutReach)
+ val size = pages[pageNumber]
+ return PdfDocument.PageInfo(pageNumber, size.y, size.x)
+ }
+
+ override fun close() {
+ // No-op, fake
+ }
+
+ /**
+ * A fake [PdfDocument.BitmapSource] that produces random RGB [Bitmap]s of the requested size
+ */
+ private inner class FakeBitmapSource(override val pageNumber: Int) : PdfDocument.BitmapSource {
+
+ override suspend fun getBitmap(scaledPageSizePx: Size, tileRegion: Rect?): Bitmap {
+ renderReach = maxOf(renderReach, pageNumber)
+ logRequest(scaledPageSizePx, tileRegion)
+ // Generate a solid random RGB bitmap at the requested size
+ val size =
+ if (tileRegion != null) Size(tileRegion.width(), tileRegion.height())
+ else scaledPageSizePx
+ val bitmap = Bitmap.createBitmap(size.width, size.height, Bitmap.Config.ARGB_8888)
+ bitmap.apply {
+ val colorRng = Random(System.currentTimeMillis())
+ eraseColor(
+ Color.argb(
+ 255,
+ colorRng.nextInt(256),
+ colorRng.nextInt(256),
+ colorRng.nextInt(256)
+ )
+ )
+ }
+ return bitmap
+ }
+
+ /**
+ * Logs the nature of a bitmap request to [bitmapRequests], so that testing code can examine
+ * the total set of bitmap requests observed during a test
+ */
+ private fun logRequest(scaledPageSizePx: Size, tileRegion: Rect?) {
+ synchronized(bitmapRequestsLock) {
+ val requestedSize = _bitmapRequests[pageNumber]
+ // Not tiling, log a full bitmap request
+ if (tileRegion == null) {
+ _bitmapRequests[pageNumber] = FullBitmap(scaledPageSizePx)
+ // Tiling, and this is a new rect for a tile board we're already tracking
+ } else if (requestedSize != null && requestedSize is TileBoard) {
+ requestedSize.withTile(tileRegion)
+ // Tiling, and this is the first rect requested
+ } else {
+ _bitmapRequests[pageNumber] =
+ TileBoard(scaledPageSizePx).apply { withTile(tileRegion) }
+ }
+ }
+ }
+
+ override fun close() {
+ /* No-op, fake */
+ }
+ }
+}
+
+/** Represents the size and scale of a [Bitmap] requested from [PdfDocument.BitmapSource] */
+internal sealed class SizeParams(val scaledPageSizePx: Size)
+
+/** Represents a full page [Bitmap] requested from [PdfDocument.BitmapSource] */
+internal class FullBitmap(scaledPageSizePx: Size) : SizeParams(scaledPageSizePx)
+
+/** Represents a set of tile region [Bitmap] requested from [PdfDocument.BitmapSource] */
+internal class TileBoard(scaledPageSizePx: Size) : SizeParams(scaledPageSizePx) {
+ private val _tiles = mutableListOf<Rect>()
+ val tiles: List<Rect>
+ get() = _tiles
+
+ fun withTile(region: Rect) = _tiles.add(region)
+}
+
+// Duplicated from PdfRenderer to avoid a hard dependency on SDK 35
+/** Represents a PDF with no form fields */
+internal const val PDF_FORM_TYPE_NONE = 0
+
+/** Represents a PDF with form fields specified using the AcroForm spec */
+internal const val PDF_FORM_TYPE_ACRO_FORM = 1
+
+/** Represents a PDF with form fields specified using the entire XFA spec */
+internal const val PDF_FORM_TYPE_XFA_FULL = 2
+
+/** Represents a PDF with form fields specified using the XFAF subset of the XFA spec */
+internal const val PDF_FORM_TYPE_XFA_FOREGROUND = 3
+
+/**
+ * Laying out pages involves waiting for multiple coroutines that are started sequentially. It is
+ * not possible to use TestScheduler alone to wait for a certain amount of layout to happen. This
+ * uses a polling loop to wait for a certain number of pages to be laid out, up to [timeoutMillis]
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+internal suspend fun FakePdfDocument.waitForLayout(untilPage: Int, timeoutMillis: Long = 1000) {
+ // Jump to Dispatchers.Default, as TestDispatcher will skip delays and timeouts
+ withContext(Dispatchers.Default.limitedParallelism(1)) {
+ withTimeout(timeoutMillis) {
+ while (layoutReach < untilPage) {
+ delay(100)
+ }
+ }
+ }
+}
+
+/**
+ * Rendering pages involves waiting for multiple coroutines that are started sequentially. It is not
+ * possible to use TestScheduler alone to wait for a certain amount of rendering to happen. This
+ * uses a polling loop to wait for a certain number of pages to be rendered, up to [timeoutMillis]
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+internal suspend fun FakePdfDocument.waitForRender(untilPage: Int, timeoutMillis: Long = 1000) {
+ // Jump to Dispatchers.Default, as TestDispatcher will skip delays and timeouts
+ withContext(Dispatchers.Default.limitedParallelism(1)) {
+ withTimeout(timeoutMillis) {
+ while (renderReach < untilPage) {
+ delay(100)
+ }
+ }
+ }
+}
diff --git a/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/PdfViewGestureTest.kt b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/PdfViewGestureTest.kt
new file mode 100644
index 0000000..b84d2b7
--- /dev/null
+++ b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/PdfViewGestureTest.kt
@@ -0,0 +1,82 @@
+/*
+ * 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.pdf.view
+
+import android.graphics.Point
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.test.core.app.ActivityScenario
+import androidx.test.espresso.Espresso
+import androidx.test.espresso.action.ViewActions
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.After
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@LargeTest
+class PdfViewGestureTest {
+ @After
+ fun tearDown() {
+ PdfViewTestActivity.onCreateCallback = {}
+ }
+
+ @Test
+ fun testScrollGesture() {
+ val fakePdfDocument = FakePdfDocument(List(100) { Point(500, 1000) })
+ PdfViewTestActivity.onCreateCallback = { activity ->
+ val container = FrameLayout(activity)
+ container.addView(
+ PdfView(activity).apply {
+ pdfDocument = fakePdfDocument
+ id = PDF_VIEW_ID
+ },
+ ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.MATCH_PARENT
+ )
+ )
+ activity.setContentView(container)
+ }
+
+ var scrollBefore = Int.MAX_VALUE
+ var scrollAfter = Int.MIN_VALUE
+ with(ActivityScenario.launch(PdfViewTestActivity::class.java)) {
+ Espresso.onView(withId(PDF_VIEW_ID))
+ .check { view, noViewFoundException ->
+ view ?: throw noViewFoundException
+ scrollBefore = view.scrollY
+ }
+ .perform(ViewActions.swipeUp())
+ .check { view, noViewFoundException ->
+ view ?: throw noViewFoundException
+ scrollAfter = view.scrollY
+ }
+ close()
+ }
+
+ assertThat(scrollAfter).isGreaterThan(scrollBefore)
+ }
+
+ @Test fun testZoomGesture() {}
+}
+
+/** Arbitrary fixed ID for PdfView */
+private const val PDF_VIEW_ID = 123456789
diff --git a/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/PdfViewPaginationTest.kt b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/PdfViewPaginationTest.kt
new file mode 100644
index 0000000..551a72a
--- /dev/null
+++ b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/PdfViewPaginationTest.kt
@@ -0,0 +1,140 @@
+/*
+ * 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.pdf.view
+
+import android.content.Context
+import android.graphics.Point
+import android.os.Looper
+import android.view.View
+import android.view.View.MeasureSpec
+import androidx.pdf.PdfDocument
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.withContext
+import org.junit.BeforeClass
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@LargeTest
+class PdfViewPaginationTest {
+ private val context = ApplicationProvider.getApplicationContext<Context>()
+ private val testScope = TestScope()
+
+ companion object {
+ @BeforeClass
+ @JvmStatic
+ fun setup() {
+ // PdfView creates a ScaleGestureDetector internally, which can only be done from Looper
+ // threads, so let's make sure we're executing on one
+ if (Looper.myLooper() == null) {
+ Looper.prepare()
+ }
+ }
+ }
+
+ @Test
+ fun testPageVisibility() = runTest {
+ // Layout at 500x1000, and expect to see pages [0, 4] at 100x200
+ val pdfDocument = FakePdfDocument(List(10) { Point(100, 200) })
+ val pdfView = setupPdfView(500, 1000, pdfDocument)
+
+ pdfDocument.waitForLayout(untilPage = 4)
+
+ assertThat(pdfView.firstVisiblePage).isEqualTo(0)
+ assertThat(pdfView.visiblePagesCount).isEqualTo(5)
+ }
+
+ @Test
+ fun testPageVisibility_withoutPdfDocument() {
+ val pdfView = setupPdfViewOnMain(500, 1000, null)
+
+ assertThat(pdfView.firstVisiblePage).isEqualTo(0)
+ // Default visible pages is [0, 1]
+ assertThat(pdfView.visiblePagesCount).isEqualTo(2)
+ }
+
+ @Test
+ fun testPageVisibility_onSizeDecreased() = runTest {
+ // Layout at 500x1000 initially, and expect to see pages [0, 3] at 100x300
+ val pdfDocument = FakePdfDocument(List(10) { Point(100, 300) })
+ val pdfView = setupPdfView(500, 1000, pdfDocument)
+ pdfDocument.waitForLayout(untilPage = 3)
+ assertThat(pdfView.firstVisiblePage).isEqualTo(0)
+ assertThat(pdfView.visiblePagesCount).isEqualTo(4)
+
+ // Reduce size to 100x200, and expect to see only page 0
+ pdfView.layoutAndMeasure(100, 200)
+ assertThat(pdfView.firstVisiblePage).isEqualTo(0)
+ assertThat(pdfView.visiblePagesCount).isEqualTo(1)
+ }
+
+ @Test
+ fun testPageVisibility_onScrollChanged() = runTest {
+ // Layout at 1000x2000 initially, and expect to see pages [0, 3] at 200x500
+ val pdfDocument = FakePdfDocument(List(10) { Point(200, 500) })
+ val pdfView = setupPdfView(1000, 2000, pdfDocument)
+ pdfDocument.waitForLayout(untilPage = 3)
+ assertThat(pdfView.firstVisiblePage).isEqualTo(0)
+ assertThat(pdfView.visiblePagesCount).isEqualTo(4)
+
+ // Scroll until the viewport spans [500, 2500] vertically and expect to see pages [1, 4]
+ pdfView.scrollBy(0, 500)
+ pdfDocument.waitForLayout(untilPage = 4)
+ assertThat(pdfView.firstVisiblePage).isEqualTo(1)
+ assertThat(pdfView.visiblePagesCount).isEqualTo(4)
+ }
+
+ @Test
+ fun testPageVisibility_onZoomChanged() = runTest {
+ // Layout at 100x500 initially, and expect to see pages [0, 5] at 30x80
+ val pdfDocument = FakePdfDocument(List(10) { Point(30, 80) })
+ val pdfView = setupPdfView(100, 500, pdfDocument)
+ pdfDocument.waitForLayout(untilPage = 5)
+ assertThat(pdfView.firstVisiblePage).isEqualTo(0)
+ assertThat(pdfView.visiblePagesCount).isEqualTo(6)
+
+ // Set zoom to 2f and expect to see pages [0, 2]
+ withContext(Dispatchers.Main) { pdfView.zoom = 2f }
+ assertThat(pdfView.firstVisiblePage).isEqualTo(0)
+ assertThat(pdfView.visiblePagesCount).isEqualTo(3)
+ }
+
+ /** Create, measure, and layout a [PdfView] at the specified [width] and [height] */
+ private suspend fun setupPdfView(width: Int, height: Int, pdfDocument: FakePdfDocument?) =
+ withContext(Dispatchers.Main) { setupPdfViewOnMain(width, height, pdfDocument) }
+
+ private fun setupPdfViewOnMain(width: Int, height: Int, pdfDocument: PdfDocument?): PdfView {
+ val pdfView = PdfView(context)
+ pdfView.layoutAndMeasure(width, height)
+ pdfDocument?.let { pdfView.pdfDocument = it }
+ return pdfView
+ }
+}
+
+private fun View.layoutAndMeasure(width: Int, height: Int) {
+ measure(
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
+ MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
+ )
+ layout(0, 0, measuredWidth, measuredHeight)
+}
diff --git a/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/PdfViewRenderingTest.kt b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/PdfViewRenderingTest.kt
new file mode 100644
index 0000000..b8503c3
--- /dev/null
+++ b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/PdfViewRenderingTest.kt
@@ -0,0 +1,137 @@
+/*
+ * 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.pdf.view
+
+import android.content.Context
+import android.graphics.Point
+import android.os.Looper
+import android.view.View
+import android.view.View.MeasureSpec
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.withContext
+import org.junit.BeforeClass
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@LargeTest
+class PdfViewRenderingTest {
+ private val context = ApplicationProvider.getApplicationContext<Context>()
+
+ companion object {
+ @BeforeClass
+ @JvmStatic
+ fun setup() {
+ // PdfView creates a ScaleGestureDetector internally, which can only be done from Looper
+ // threads, so let's make sure we're executing on one
+ if (Looper.myLooper() == null) {
+ Looper.prepare()
+ }
+ }
+ }
+
+ @Test
+ fun testPageRendering() = runTest {
+ // Layout at 500x1000, and expect to render pages [0, 4] at 100x200
+ val pdfDocument = FakePdfDocument(List(10) { Point(100, 200) })
+ setupPdfView(500, 1000, pdfDocument)
+
+ pdfDocument.waitForRender(untilPage = 4)
+
+ val requestedBitmaps = pdfDocument.bitmapRequests
+ for (i in 0..4) {
+ assertThat((requestedBitmaps[i] as? FullBitmap)?.scaledPageSizePx?.width).isEqualTo(100)
+ assertThat((requestedBitmaps[i] as? FullBitmap)?.scaledPageSizePx?.height)
+ .isEqualTo(200)
+ }
+ }
+
+ @Test
+ fun testPageRendering_renderNewPagesOnScroll() = runTest {
+ // Layout at 500x1000, and expect to render pages [0, 4] at 100x200
+ val pdfDocument = FakePdfDocument(List(10) { Point(100, 200) })
+ val pdfView = setupPdfView(500, 1000, pdfDocument)
+ pdfDocument.waitForRender(untilPage = 4)
+
+ // Scroll until the viewport spans [1000, 2000] vertically and expect to render pages
+ // [5, 9] at 200x400
+ pdfView.scrollTo(0, 1000)
+ pdfDocument.waitForRender(untilPage = 9)
+ val requestedBitmaps = pdfDocument.bitmapRequests
+ for (i in 0..4) {
+ assertThat((requestedBitmaps[i] as? FullBitmap)?.scaledPageSizePx?.width).isEqualTo(100)
+ assertThat((requestedBitmaps[i] as? FullBitmap)?.scaledPageSizePx?.height)
+ .isEqualTo(200)
+ }
+ }
+
+ @Test
+ fun testPageRendering_renderNewBitmapsOnZoom() = runTest {
+ // Layout at 500x1000, and expect to render pages [0, 4] at 100x200
+ val pdfDocument = FakePdfDocument(List(10) { Point(100, 200) })
+ val pdfView = setupPdfView(500, 1000, pdfDocument)
+ pdfDocument.waitForRender(untilPage = 4)
+
+ // Set zoom to 2f, and expect to render pages [0, 2] at 200x400
+ // Reset render reach, as we expect to start rendering new Bitmaps for already-rendered
+ // pages
+ pdfDocument.renderReach = 0
+ withContext(Dispatchers.Main) { pdfView.zoom = 2f }
+ pdfDocument.waitForRender(2)
+ val requestedBitmaps = pdfDocument.bitmapRequests
+ for (i in 0..2) {
+ assertThat((requestedBitmaps[i] as? FullBitmap)?.scaledPageSizePx?.width).isEqualTo(200)
+ assertThat((requestedBitmaps[i] as? FullBitmap)?.scaledPageSizePx?.height)
+ .isEqualTo(400)
+ }
+ }
+
+ /** Create, measure, and layout a [PdfView] at the specified [width] and [height] */
+ private suspend fun setupPdfView(
+ width: Int,
+ height: Int,
+ pdfDocument: FakePdfDocument?
+ ): PdfView {
+ return withContext(Dispatchers.Main) {
+ setupPdfViewOnMainThread(width, height, pdfDocument)
+ }
+ }
+
+ private fun setupPdfViewOnMainThread(
+ width: Int,
+ height: Int,
+ pdfDocument: FakePdfDocument?
+ ): PdfView {
+ val pdfView = PdfView(context)
+ pdfView.layoutAndMeasure(width, height)
+ pdfDocument?.let { pdfView.pdfDocument = it }
+ return pdfView
+ }
+}
+
+private fun View.layoutAndMeasure(width: Int, height: Int) {
+ measure(
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
+ MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
+ )
+ layout(0, 0, measuredWidth, measuredHeight)
+}
diff --git a/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/PdfViewTestActivity.kt b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/PdfViewTestActivity.kt
new file mode 100644
index 0000000..6a38570
--- /dev/null
+++ b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/PdfViewTestActivity.kt
@@ -0,0 +1,40 @@
+/*
+ * 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.pdf.view
+
+import android.app.Activity
+import android.os.Bundle
+
+/** Bare bones test helper [Activity] for [PdfView] integration tests */
+class PdfViewTestActivity : Activity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ onCreateCallback(this)
+ // disable enter animation.
+ @Suppress("Deprecation") overridePendingTransition(0, 0)
+ }
+
+ override fun finish() {
+ super.finish()
+ // disable exit animation.
+ @Suppress("Deprecation") overridePendingTransition(0, 0)
+ }
+
+ companion object {
+ var onCreateCallback: ((PdfViewTestActivity) -> Unit) = {}
+ }
+}
diff --git a/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/PaginationModel.java b/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/PaginationModel.java
index 3279dfe..d519aa3 100644
--- a/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/PaginationModel.java
+++ b/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/PaginationModel.java
@@ -71,6 +71,9 @@
/** The maximum number of pages this model can accommodate. */
private int mMaxPages = -1;
+ /** The page centered in the view. */
+ private int mMidPage = -1;
+
/** The Dimensions of each page in the list. */
private Dimensions[] mPages;
@@ -264,12 +267,14 @@
int bottomResult = Collections.binarySearch(endList, intervalPx.getLast());
int rangeEnd = Math.abs(bottomResult + 1) - 1; // Before insertion point.
+ int midPoint = (intervalPx.getFirst() + intervalPx.getLast()) / 2;
+ int midResult = Collections.binarySearch(mPageTops, midPoint);
+
+ mMidPage = Math.max(Math.abs(midResult + 1) - 1, 0); // Before insertion point.
+
if (rangeEnd < rangeStart) {
// No page is entirely visible.
- int midPoint = (intervalPx.getFirst() + intervalPx.getLast()) / 2;
- int midResult = Collections.binarySearch(mPageTops, midPoint);
- int page = Math.max(Math.abs(midResult + 1) - 1, 0); // Before insertion point.
- return new Range(page, page);
+ return new Range(mMidPage, mMidPage);
}
return new Range(rangeStart, rangeEnd);
@@ -352,6 +357,11 @@
return mSize;
}
+ /** Returns the centered page */
+ public int getMidPage() {
+ return mMidPage;
+ }
+
/**
* Returns the number of pages in the document.
*
diff --git a/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/loader/PdfLoaderCallbacksImpl.kt b/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/loader/PdfLoaderCallbacksImpl.kt
index 3a40bdb..8f446be 100644
--- a/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/loader/PdfLoaderCallbacksImpl.kt
+++ b/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/loader/PdfLoaderCallbacksImpl.kt
@@ -23,7 +23,6 @@
import android.os.Bundle
import android.view.View
import androidx.annotation.RestrictTo
-import androidx.annotation.UiThread
import androidx.core.os.OperationCanceledException
import androidx.fragment.app.FragmentManager
import androidx.pdf.R
@@ -44,7 +43,6 @@
import androidx.pdf.util.ThreadUtils
import androidx.pdf.util.TileBoard
import androidx.pdf.viewer.LayoutHandler
-import androidx.pdf.viewer.LoadingView
import androidx.pdf.viewer.PageViewFactory
import androidx.pdf.viewer.PaginatedView
import androidx.pdf.viewer.PdfPasswordDialog
@@ -63,7 +61,6 @@
private var fastScrollView: FastScrollView,
private var zoomView: ZoomView,
private var paginatedView: PaginatedView,
- private var loadingView: LoadingView,
private var findInFileView: FindInFileView,
private var isTextSearchActive: Boolean,
private var viewState: ExposedValue<ViewState>,
@@ -109,11 +106,6 @@
onDocumentLoadFailure(thrown, true)
}
- @UiThread
- public fun hideSpinner() {
- loadingView.visibility = View.GONE
- }
-
private fun lookAtSelection(selection: SelectedMatch?) {
if (selection == null || selection.isEmpty) {
return
@@ -188,7 +180,6 @@
}
onDocumentLoaded()
- hideSpinner()
// Assume we see at least the first page
paginatedView.pageRangeHandler.maxPage = 1
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/GestureTracker.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/GestureTracker.kt
new file mode 100644
index 0000000..9858e87
--- /dev/null
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/GestureTracker.kt
@@ -0,0 +1,397 @@
+/*
+ * 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.pdf.view
+
+import android.content.Context
+import android.graphics.PointF
+import android.view.GestureDetector
+import android.view.GestureDetector.SimpleOnGestureListener
+import android.view.MotionEvent
+import android.view.ScaleGestureDetector
+import android.view.ScaleGestureDetector.OnScaleGestureListener
+import android.view.ViewConfiguration
+import androidx.pdf.view.GestureTracker.Gesture
+import androidx.pdf.view.GestureTracker.GestureHandler
+import kotlin.math.abs
+import kotlin.math.sqrt
+
+/**
+ * Processes [MotionEvent] and interprets them as [Gesture]s. Intended to be plugged in to
+ * [android.view.View.onTouchEvent]. Provides callbacks via the [GestureHandler] listener, which
+ * includes the signals from [SimpleOnGestureListener] and [OnScaleGestureListener] as well as start
+ * and end signals for all detected [Gesture]s.
+ */
+internal class GestureTracker(context: Context) {
+
+ /** Minimal identifier for a [MotionEvent]. */
+ internal class EventId(event: MotionEvent) {
+ /** Returns the [MotionEvent.getEventTime] of the event. */
+ val eventTimeMs: Long = event.eventTime
+
+ /** Returns the [MotionEvent.getAction] code for the event. */
+ val eventAction: Int = event.actionMasked
+
+ fun matches(other: MotionEvent?): Boolean {
+ return other != null &&
+ eventTimeMs == other.eventTime &&
+ eventAction == other.actionMasked
+ }
+ }
+
+ /** A recognized user gesture. */
+ internal enum class Gesture {
+ /** First touch event, usually [MotionEvent.ACTION_DOWN] */
+ TOUCH,
+
+ /** First tap, to be confirmed as [SINGLE_TAP], or superseded by another gesture */
+ FIRST_TAP,
+
+ /**
+ * [FIRST_TAP] after it's confirmed to be a single tap and not the start of a more complex
+ * gesture.
+ */
+ SINGLE_TAP,
+
+ /**
+ * Two consecutive [MotionEvent.ACTION_DOWN] events within
+ * [ViewConfiguration.getDoubleTapTimeout]
+ */
+ DOUBLE_TAP,
+
+ /** Press and hold for [ViewConfiguration.getLongPressTimeout] or longer */
+ LONG_PRESS,
+
+ /** Touch and drag, not aligned on any one axis */
+ DRAG,
+
+ /** Touch and drag along the X axis */
+ DRAG_X,
+
+ /** Touch and drag along the Y axis */
+ DRAG_Y,
+
+ /** Touch, quickly drag, and release */
+ FLING,
+
+ /** Either pinch-to-zoom or a quick scale gesture, as detected by [ScaleGestureDetector] */
+ ZOOM;
+
+ /** True if this [Gesture] is a better guess than [other] in the case of ambiguity */
+ fun supersedes(other: Gesture?): Boolean {
+ if (other == this) {
+ return false
+ }
+ if (other == null || other == TOUCH) {
+ // Every Gesture is finer than nothing or a TOUCH.
+ return true
+ }
+ if (other == FIRST_TAP) {
+ // TAP is overridden by any other Gesture except TOUCH.
+ return this != TOUCH
+ }
+ if (other == DOUBLE_TAP) {
+ // A Double tap is overridden by any drag while on the second tap, or a zoom (quick
+ // scale) gesture
+ return this == DRAG || (this == DRAG_X) || (this == DRAG_Y) || (this == ZOOM)
+ }
+ return when (this) {
+ FLING,
+ ZOOM -> true
+ else -> other == LONG_PRESS
+ }
+ }
+ }
+
+ private val doubleTapTimeout = ViewConfiguration.getDoubleTapTimeout()
+ private val moveSlop = ViewConfiguration.get(context).scaledTouchSlop
+
+ private val listener = DetectorListener()
+ private val zoomDetector = ScaleGestureDetector(context, listener)
+ private val moveDetector =
+ GestureDetector(context, listener).apply {
+ // Detection of double tap on the main detector messes up with everything else, so
+ // divert it on a secondary detector:
+ setOnDoubleTapListener(null)
+ }
+ private val doubleTapDetector =
+ GestureDetector(context, SimpleOnGestureListener()).apply {
+ setOnDoubleTapListener(listener)
+ }
+
+ var delegate: GestureHandler? = null
+
+ /**
+ * Whether we are currently tracking a gesture in progress, i.e. between the initial ACTION_DOWN
+ * and the end of the gesture.
+ */
+ private var tracking = false
+
+ private val touchDown = PointF()
+ private var lastEvent: EventId? = null
+ private var detectedGesture: Gesture? = null
+
+ /**
+ * Feed an event into this tracker. To be plugged in a [android.view.View.onTouchEvent]
+ *
+ * @param event The event.
+ * @return true if the event was recorded, false if it was discarded as a duplicate
+ */
+ fun feed(event: MotionEvent): Boolean {
+ if (lastEvent?.matches(event) == true) {
+ // We have already processed this event in this way (handling or non-handling).
+ return false
+ }
+
+ if (!tracking) {
+ initTracking(event.x, event.y)
+ delegate?.onGestureStart()
+ }
+
+ moveDetector.onTouchEvent(event)
+ if (!shouldSkipZoomDetector(event)) {
+ zoomDetector.onTouchEvent(event)
+ }
+ doubleTapDetector.onTouchEvent(event)
+
+ if (event.actionMasked == MotionEvent.ACTION_UP) {
+ if (detectedGesture == Gesture.DOUBLE_TAP && delegate != null) {
+ // Delayed from detection which happens too early.
+ delegate?.onDoubleTap(event)
+ }
+ if (detectedGesture != Gesture.FIRST_TAP) {
+ // All gestures but FIRST_TAP are final, should end gesture here.
+ endGesture()
+ }
+ }
+
+ if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
+ endGesture()
+ }
+
+ lastEvent = EventId(event)
+ return true
+ }
+
+ private fun endGesture() {
+ tracking = false
+ if (delegate != null) {
+ delegate?.onGestureEnd(detectedGesture)
+ }
+ }
+
+ /** Returns whether the currently detected gesture matches any of [gestures]. */
+ fun matches(vararg gestures: Gesture): Boolean {
+ for (g in gestures) {
+ if (detectedGesture == g) {
+ return true
+ }
+ }
+ return false
+ }
+
+ private fun getDistance(event: MotionEvent, axis: Int): Float {
+ when (axis) {
+ MotionEvent.AXIS_X -> return abs(event.x - touchDown.x)
+ MotionEvent.AXIS_Y -> return abs(event.y - touchDown.y)
+ NO_AXIS -> {
+ val x = event.x - touchDown.x
+ val y = event.y - touchDown.y
+ return sqrt(x * x + y * y)
+ }
+ else -> throw IllegalArgumentException("Wrong axis value $axis")
+ }
+ }
+
+ private fun detected(gesture: Gesture) {
+ if (gesture.supersedes(detectedGesture)) {
+ detectedGesture = gesture
+ }
+ }
+
+ private fun initTracking(x: Float, y: Float) {
+ tracking = true
+ touchDown.set(x, y)
+ detectedGesture = Gesture.TOUCH
+ }
+
+ /**
+ * Returns whether to skip passing [event] to the [zoomDetector].
+ *
+ * [ScaleGestureDetector] sometimes misinterprets scroll gestures performed in quick succession
+ * for a quick scale (double-tap-and-drag to zoom) gesture. This is because [GestureDetector]'s
+ * double tap detection logic compares the position of the first [MotionEvent.ACTION_DOWN] event
+ * to the second [MotionEvent.ACTION_DOWN] event, but ignores where the first gesture's
+ * [MotionEvent.ACTION_UP] event took place. In a drag/fling gesture, the up event happens far
+ * from the down event, but if a second drag/fling has its down event near the previous
+ * gesture's down event (and occurs within [doubleTapTimeout] of the previous up event), a quick
+ * scale will be detected.
+ */
+ private fun shouldSkipZoomDetector(event: MotionEvent): Boolean {
+ if (lastEvent == null || lastEvent?.eventAction != MotionEvent.ACTION_UP) {
+ return false
+ }
+ if (!SCROLL_GESTURES.contains(detectedGesture)) {
+ return false
+ }
+ val lastEventTime = lastEvent?.eventTimeMs ?: Int.MAX_VALUE.toLong()
+ val deltaTime = event.eventTime - lastEventTime
+
+ return deltaTime < doubleTapTimeout
+ }
+
+ /** A recipient for all gesture handling. */
+ open class GestureHandler : SimpleOnGestureListener(), OnScaleGestureListener {
+ override fun onScale(detector: ScaleGestureDetector): Boolean {
+ return true
+ }
+
+ override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
+ return true
+ }
+
+ override fun onScaleEnd(detector: ScaleGestureDetector) {}
+
+ /** Called at the start of any gesture, before any other callback */
+ open fun onGestureStart() {}
+
+ /**
+ * Called at the end of any gesture, after any other callback
+ *
+ * @param gesture The detected gesture that just ended
+ */
+ open fun onGestureEnd(gesture: Gesture?) {}
+ }
+
+ /** The listener used for detecting various gestures. */
+ private inner class DetectorListener : SimpleOnGestureListener(), OnScaleGestureListener {
+ override fun onShowPress(e: MotionEvent) {
+ if (delegate != null) {
+ delegate?.onShowPress(e)
+ }
+ }
+
+ override fun onSingleTapUp(e: MotionEvent): Boolean {
+ detected(Gesture.FIRST_TAP)
+ if (delegate != null) {
+ delegate?.onSingleTapUp(e)
+ }
+ return true
+ }
+
+ override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
+ detected(Gesture.SINGLE_TAP)
+ if (delegate != null) {
+ delegate?.onSingleTapConfirmed(e)
+ }
+ // This comes from a delayed call from the doubleTapDetector, not an event
+ endGesture()
+ return true
+ }
+
+ override fun onDoubleTap(e: MotionEvent): Boolean {
+ // Double-taps are only valid if the first gesture was just a FIRST_TAP, nothing else.
+ if (detectedGesture == Gesture.FIRST_TAP) {
+ detected(Gesture.DOUBLE_TAP)
+ // The delegate is called on the corresponding UP event, because we can be not
+ // handling the event yet
+ }
+ return true
+ }
+
+ override fun onScroll(
+ e1: MotionEvent?,
+ e2: MotionEvent,
+ distanceX: Float,
+ distanceY: Float,
+ ): Boolean {
+ val dx = getDistance(e2, MotionEvent.AXIS_X)
+ val dy = getDistance(e2, MotionEvent.AXIS_Y)
+ if (dx > moveSlop && dx > DRAG_X_MULTIPLIER * dy) {
+ detected(Gesture.DRAG_X)
+ } else if (dy > moveSlop && dy > DRAG_Y_MULTIPLIER * dx) {
+ detected(Gesture.DRAG_Y)
+ } else if (getDistance(e2, NO_AXIS) > moveSlop) {
+ detected(Gesture.DRAG)
+ }
+ if (delegate != null) {
+ delegate?.onScroll(e1, e2, distanceX, distanceY)
+ }
+ return false
+ }
+
+ override fun onLongPress(e: MotionEvent) {
+ detected(Gesture.LONG_PRESS)
+ if (delegate != null) {
+ delegate?.onLongPress(e)
+ }
+ }
+
+ override fun onFling(
+ e1: MotionEvent?,
+ e2: MotionEvent,
+ velocityX: Float,
+ velocityY: Float,
+ ): Boolean {
+ detected(Gesture.FLING)
+ if (delegate != null) {
+ return delegate?.onFling(e1, e2, velocityX, velocityY) != false
+ }
+ return false
+ }
+
+ override fun onScale(detector: ScaleGestureDetector): Boolean {
+ if (delegate != null) {
+ return delegate?.onScale(detector) != false
+ }
+ // Return true is required to keep the gesture detector happy (and the events flowing).
+ return true
+ }
+
+ override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
+ detected(Gesture.ZOOM)
+ if (delegate != null) {
+ return delegate?.onScaleBegin(detector) != false
+ }
+ // Return true is required to keep the gesture detector happy (and the events flowing).
+ return true
+ }
+
+ override fun onScaleEnd(detector: ScaleGestureDetector) {
+ if (delegate != null) {
+ delegate?.onScaleEnd(detector)
+ }
+ }
+ }
+}
+
+/** The set of [Gesture]s considered to constitute scrolling */
+private val SCROLL_GESTURES: Set<Gesture> =
+ setOf(Gesture.DRAG, Gesture.DRAG_X, Gesture.DRAG_Y, Gesture.FLING)
+
+/**
+ * The factor by which the swipe needs to be bigger horizontally than vertically to be considered
+ * DRAG_X.
+ */
+private const val DRAG_X_MULTIPLIER = 1f
+
+/**
+ * The factor by which the swipe needs to be bigger vertically than horizontally to be considered
+ * DRAG_Y.
+ */
+private const val DRAG_Y_MULTIPLIER = 3f
+
+private const val NO_AXIS: Int = -1
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/Page.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/Page.kt
new file mode 100644
index 0000000..c9b3435
--- /dev/null
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/Page.kt
@@ -0,0 +1,107 @@
+/*
+ * 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.pdf.view
+
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.Rect
+import android.util.Size
+import androidx.annotation.RestrictTo
+import androidx.core.view.doOnDetach
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/** A single PDF page that knows how to render and draw itself */
+@RestrictTo(RestrictTo.Scope.LIBRARY)
+internal class Page(
+ val pageNum: Int,
+ val size: Size,
+ private val pdfView: PdfView,
+) {
+
+ init {
+ require(pageNum >= 0) { "Invalid negative page" }
+ pdfView.doOnDetach { setInvisible() }
+ }
+
+ var isVisible: Boolean = false
+ set(value) {
+ field = value
+ if (field) maybeRender() else setInvisible()
+ }
+
+ private var renderedZoom: Float? = null
+ private var renderBitmapJob: Job? = null
+ private var bitmap: Bitmap? = null
+
+ fun maybeRender() {
+ // If we're actively rendering or have rendered a bitmap for the current zoom level, there's
+ // no need to refresh bitmaps
+ if (
+ renderedZoom?.equals(pdfView.zoom) == true &&
+ (bitmap != null || renderBitmapJob != null)
+ ) {
+ return
+ }
+ renderBitmapJob?.cancel()
+ fetchNewBitmap()
+ }
+
+ fun draw(canvas: Canvas, locationInView: Rect) {
+ bitmap?.let {
+ canvas.drawBitmap(it, /* src= */ null, locationInView, BMP_PAINT)
+ return
+ }
+ canvas.drawRect(locationInView, BLANK_PAINT)
+ }
+
+ private fun fetchNewBitmap() {
+ val bitmapSource =
+ pdfView.pdfDocument?.getPageBitmapSource(pageNum)
+ ?: throw IllegalStateException("No PDF document to render")
+ renderBitmapJob =
+ pdfView.coroutineScope.launch {
+ ensureActive()
+ val zoom = pdfView.zoom
+ val width = (size.width * zoom).toInt()
+ val height = (size.height * zoom).toInt()
+ renderedZoom = zoom
+ bitmap = bitmapSource.getBitmap(Size(width, height))
+ withContext(Dispatchers.Main) { pdfView.invalidate() }
+ }
+ renderBitmapJob?.invokeOnCompletion { renderBitmapJob = null }
+ }
+
+ private fun setInvisible() {
+ renderBitmapJob?.cancel()
+ renderBitmapJob = null
+ bitmap = null
+ renderedZoom = null
+ }
+}
+
+private val BMP_PAINT = Paint(Paint.FILTER_BITMAP_FLAG)
+private val BLANK_PAINT =
+ Paint().apply {
+ color = Color.WHITE
+ style = Paint.Style.FILL
+ }
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/PaginationModel.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/PaginationModel.kt
new file mode 100644
index 0000000..1668f42
--- /dev/null
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/PaginationModel.kt
@@ -0,0 +1,249 @@
+/*
+ * 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.pdf.view
+
+import android.graphics.Point
+import android.graphics.Rect
+import android.os.Parcel
+import android.os.Parcelable
+import android.util.Range
+import androidx.annotation.MainThread
+import androidx.annotation.RestrictTo
+import java.util.Collections
+import kotlin.math.abs
+import kotlin.math.max
+import kotlin.math.min
+
+/**
+ * Stores the size and position of PDF pages. All dimensions and coordinates should be assumed to be
+ * content coordinates and not View coordinates, unless symbol names and / or comments indicate
+ * otherwise. Not thread safe; access only from the main thread.
+ *
+ * TODO(b/376135419) - Adapt implementation to work when page dimensions are not loaded sequentially
+ */
+@MainThread
+@Suppress("BanParcelableUsage")
+@RestrictTo(RestrictTo.Scope.LIBRARY)
+internal class PaginationModel(val pageSpacingPx: Int, val numPages: Int) : Parcelable {
+
+ init {
+ require(numPages >= 0) { "Empty PDF!" }
+ require(pageSpacingPx >= 0) { "Invalid spacing!" }
+ }
+
+ /** The estimated total height of this PDF */
+ val totalEstimatedHeight: Int
+ get() = computeEstimatedHeight()
+
+ /** The maximum width of any page known to this model */
+ var maxWidth: Int = 0
+
+ private var _reach: Int = 0
+
+ /** The last page whose dimensions are known to this model, 0-indexed */
+ val reach: Int
+ get() = _reach
+
+ /** The dimensions of all pages known to this model, as [Point] */
+ private val pages = Array(numPages) { UNKNOWN_SIZE }
+
+ /** The top position of each page known to this model */
+ private val pagePositions = IntArray(numPages) { -1 }.apply { this[0] = 0 }
+
+ /**
+ * The estimated height of any page not known to this model, i.e. the average height of all
+ * pages known to this model
+ */
+ private var averagePageHeight = 0
+
+ /** The total height, excluding page spacing, of all pages known to this model. */
+ private var accumulatedPageHeight = 0
+
+ /**
+ * Pre-allocated Rect to avoid mutating [Rect] passed to us, without allocating a new one.
+ * Notably this class is used on the drawing path and should avoid excessive allocations.
+ */
+ private val tmpVisibleArea = Rect()
+
+ /**
+ * The top position of each page known to this model, as a synthetic [List] to conveniently use
+ * with APIs like [Collections.binarySearch]
+ */
+ private val pageTops: List<Int> =
+ object : AbstractList<Int>() {
+ override val size: Int
+ get() = reach
+
+ override fun get(index: Int): Int {
+ return pagePositions[index]
+ }
+ }
+
+ /**
+ * The bottom position of each page known to this model, as a synthetic [List] to conveniently
+ * use with APIs like [Collections.binarySearch]
+ */
+ private val pageBottoms: List<Int> =
+ object : AbstractList<Int>() {
+ override val size: Int
+ get() = reach
+
+ override fun get(index: Int): Int {
+ return pagePositions[index] + pages[index].y
+ }
+ }
+
+ constructor(parcel: Parcel) : this(parcel.readInt(), parcel.readInt()) {
+ _reach = parcel.readInt()
+ averagePageHeight = parcel.readInt()
+ accumulatedPageHeight = parcel.readInt()
+ maxWidth = parcel.readInt()
+ parcel.readIntArray(pagePositions)
+ parcel.readTypedArray(pages, Point.CREATOR)
+ }
+
+ override fun writeToParcel(parcel: Parcel, flags: Int) {
+ parcel.writeInt(pageSpacingPx)
+ parcel.writeInt(numPages)
+ parcel.writeInt(_reach)
+ parcel.writeInt(averagePageHeight)
+ parcel.writeInt(accumulatedPageHeight)
+ parcel.writeInt(maxWidth)
+ parcel.writeIntArray(pagePositions)
+ parcel.writeTypedArray(pages, flags)
+ }
+
+ /** Adds [pageNum] to this model at [pageSize] */
+ fun addPage(pageNum: Int, pageSize: Point) {
+ require(pageNum in 0 until numPages) { "Page out of range" }
+ require(pageSize.y >= 0 && pageSize.x >= 0) { "Negative size page" }
+ // Edge case: missing pages. This model expects pages to be added sequentially
+ for (i in _reach until pageNum) {
+ if (pages[i] == UNKNOWN_SIZE) {
+ pages[i] = pageSize
+ }
+ }
+ if (pageSize.x > maxWidth) {
+ maxWidth = pageSize.x
+ }
+ pages[pageNum] = pageSize
+ // Defensive: never set _reach to a smaller value, if pages are loaded out of order
+ _reach = max(_reach, pageNum + 1)
+ accumulatedPageHeight += pageSize.y
+ averagePageHeight = accumulatedPageHeight / _reach
+
+ if (pageNum > 0) {
+ pagePositions[pageNum] =
+ pagePositions[pageNum - 1] + pages[pageNum - 1].y + pageSpacingPx
+ }
+ }
+
+ /** Returns the size of [pageNum] in content coordinates */
+ fun getPageSize(pageNum: Int): Point {
+ require(pageNum in 0 until numPages) { "Page out of range" }
+ return pages[pageNum]
+ }
+
+ /**
+ * Returns a [Range] between the first and last pages that should be visible between
+ * [viewportTop] and [viewportBottom], which are expected to be the top and bottom content
+ * coordinates of the viewport.
+ */
+ fun getPagesInViewport(viewportTop: Int, viewportBottom: Int): Range<Int> {
+ // If the viewport is below all pages, return an empty range at the bottom of this model
+ if (reach > 0 && viewportTop > pageBottoms[reach - 1]) {
+ return Range(min(reach, numPages - 1), min(reach, numPages - 1))
+ }
+ // If the viewport is above all pages, return an empty range at the top of this model
+ if (viewportBottom < pageTops[0]) {
+ return Range(0, 0)
+ }
+ val rangeStart = abs(Collections.binarySearch(pageBottoms, viewportTop) + 1)
+ val rangeEnd = abs(Collections.binarySearch(pageTops, viewportBottom) + 1) - 1
+
+ if (rangeEnd < rangeStart) {
+ val midPoint = Collections.binarySearch(pageTops, (viewportTop + viewportBottom) / 2)
+ val page = maxOf(abs(midPoint + 1) - 1, 0)
+ return Range(page, page)
+ }
+
+ return Range(rangeStart, rangeEnd)
+ }
+
+ /** Returns the location of the page in content coordinates */
+ fun getPageLocation(pageNum: Int, viewport: Rect): Rect {
+ // We care about the intersection between what's visible and the coordinates of this model
+ tmpVisibleArea.set(viewport)
+ tmpVisibleArea.intersect(0, 0, maxWidth, totalEstimatedHeight)
+ val page = pages[pageNum]
+ var left = 0
+ var right: Int = maxWidth
+ val top = pagePositions[pageNum]
+ val bottom = top + page.y
+ // this page is smaller than the view's width, it may slide left or right.
+ if (page.x < tmpVisibleArea.width()) {
+ // page is smaller than the view: center (but respect min left margin)
+ left = Math.max(left, tmpVisibleArea.left + (tmpVisibleArea.width() - page.x) / 2)
+ } else {
+ // page is larger than view: scroll proportionally between the margins.
+ if (tmpVisibleArea.right > right) {
+ left = right - page.x
+ } else if (tmpVisibleArea.left > left) {
+ left = tmpVisibleArea.left * (right - page.x) / (right - tmpVisibleArea.width())
+ }
+ }
+ right = left + page.x
+
+ val ret = Rect(left, top, right, bottom)
+ return ret
+ }
+
+ private fun computeEstimatedHeight(): Int {
+ return if (_reach == 0) {
+ 0
+ } else if (_reach == numPages) {
+ val lastPageHeight = pages[_reach - 1].y
+ pagePositions[_reach - 1] + lastPageHeight + (pageSpacingPx)
+ // Otherwise, we have to guess
+ } else {
+ val totalKnownHeight = pagePositions[_reach - 1] + pages[_reach - 1].y
+ val estimatedRemainingHeight = (averagePageHeight + pageSpacingPx) * (numPages - _reach)
+ totalKnownHeight + estimatedRemainingHeight
+ }
+ }
+
+ companion object {
+ /** Sentinel value for the size of a page unknown to this model */
+ val UNKNOWN_SIZE = Point(-1, -1)
+
+ @JvmField
+ val CREATOR: Parcelable.Creator<PaginationModel> =
+ object : Parcelable.Creator<PaginationModel> {
+ override fun createFromParcel(parcel: Parcel): PaginationModel {
+ return PaginationModel(parcel)
+ }
+
+ override fun newArray(size: Int): Array<PaginationModel?> {
+ return arrayOfNulls(size)
+ }
+ }
+ }
+
+ override fun describeContents(): Int {
+ return 0
+ }
+}
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/PdfView.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/PdfView.kt
new file mode 100644
index 0000000..fa60206
--- /dev/null
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/PdfView.kt
@@ -0,0 +1,339 @@
+/*
+ * 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.pdf.view
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Point
+import android.graphics.Rect
+import android.os.Looper
+import android.util.AttributeSet
+import android.util.Range
+import android.util.Size
+import android.util.SparseArray
+import android.view.MotionEvent
+import android.view.View
+import androidx.annotation.RestrictTo
+import androidx.core.util.keyIterator
+import androidx.pdf.PdfDocument
+import java.util.concurrent.Executors
+import kotlin.math.max
+import kotlin.math.round
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.asCoroutineDispatcher
+import kotlinx.coroutines.cancelChildren
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+
+/**
+ * A [View] for presenting PDF content, represented by [PdfDocument].
+ *
+ * This View supports zooming, scrolling, and flinging. Zooming is supported via pinch gesture,
+ * quick scale gesture, and double tap to zoom in or snap back to fitting the page width inside its
+ * bounds. Zoom can be changed using the [zoom] property, which is notably distinct from
+ * [View.getScaleX] / [View.getScaleY]. Scroll position is based on the [View.getScrollX] /
+ * [View.getScrollY] properties.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY)
+public open class PdfView
+@JvmOverloads
+constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) :
+ View(context, attrs, defStyle) {
+ /** Supply a [PdfDocument] to process the PDF content for rendering */
+ public var pdfDocument: PdfDocument? = null
+ set(value) {
+ checkMainThread()
+ value?.let {
+ val reset = field != null && field?.uri != value.uri
+ field = it
+ if (reset) reset()
+ onDocumentSet()
+ }
+ }
+
+ /**
+ * The [CoroutineScope] used to make suspending calls to [PdfDocument]. The size of the fixed
+ * thread pool is arbitrary and subject to tuning.
+ */
+ internal val coroutineScope: CoroutineScope =
+ CoroutineScope(Executors.newFixedThreadPool(5).asCoroutineDispatcher())
+
+ /** The maximum scaling factor that can be applied to this View using the [zoom] property */
+ // TODO(b/376299551) - Make maxZoom configurable via XML attribute
+ public var maxZoom: Float = DEFAULT_MAX_ZOOM
+
+ /** The minimum scaling factor that can be applied to this View using the [zoom] property */
+ // TODO(b/376299551) - Make minZoom configurable via XML attribute
+ public var minZoom: Float = DEFAULT_MIN_ZOOM
+
+ /**
+ * The zoom level of this view, as a factor of the content's natural size with when 1 pixel is
+ * equal to 1 PDF point. Will always be clamped within ([minZoom], [maxZoom])
+ */
+ public var zoom: Float = DEFAULT_INIT_ZOOM
+ set(value) {
+ checkMainThread()
+ field = value
+ updateVisibleContent()
+ invalidate()
+ }
+
+ /**
+ * The radius of pages around the current viewport for which dimensions and other metadata will
+ * be loaded
+ */
+ // TODO(b/376299551) - Make page prefetch radius configurable via XML attribute
+ public var pagePrefetchRadius: Int = 1
+
+ private var visiblePages: Range<Int> = Range(0, 1)
+ set(value) {
+ // Debounce setting the range to the same value
+ if (field == value) return
+ field = value
+ onVisiblePagesChanged()
+ }
+
+ /** The first page in the viewport, including partially-visible pages. 0-indexed. */
+ public val firstVisiblePage: Int
+ get() = visiblePages.lower
+
+ /** The number of pages visible in the viewport, including partially visible pages */
+ public val visiblePagesCount: Int
+ get() = visiblePages.upper - visiblePages.lower + 1
+
+ /**
+ * [PdfDocument] is backed by a single-threaded PDF parser, so only allow one thread to access
+ * at a time
+ */
+ private var pdfDocumentMutex = Mutex()
+ private var paginationModel: PaginationModel? = null
+
+ private val pages = SparseArray<Page>()
+
+ private val gestureHandler = ZoomScrollGestureHandler(this@PdfView)
+ private val gestureTracker = GestureTracker(context).apply { delegate = gestureHandler }
+
+ // To avoid allocations during drawing
+ private val visibleAreaRect = Rect()
+
+ override fun onDraw(canvas: Canvas) {
+ super.onDraw(canvas)
+ val localPaginationModel = paginationModel ?: return
+ canvas.scale(zoom, zoom)
+ for (i in visiblePages.lower..visiblePages.upper) {
+ pages[i]?.draw(
+ canvas,
+ localPaginationModel.getPageLocation(i, getVisibleAreaInContentCoords())
+ )
+ }
+ }
+
+ override fun onTouchEvent(event: MotionEvent?): Boolean {
+ val handled = event?.let { gestureTracker.feed(it) } ?: false
+ return handled || super.onTouchEvent(event)
+ }
+
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
+ super.onSizeChanged(w, h, oldw, oldh)
+ updateVisibleContent()
+ }
+
+ override fun onScrollChanged(l: Int, t: Int, oldl: Int, oldt: Int) {
+ super.onScrollChanged(l, t, oldl, oldt)
+ updateVisibleContent()
+ }
+
+ /** Start using the [PdfDocument] to present PDF content */
+ private fun onDocumentSet() {
+ val localPdfDocument = pdfDocument ?: return
+ // TODO(b/376299551) - Make page margin configurable via XML attribute
+ paginationModel = PaginationModel(DEFAULT_PAGE_SPACING_PX, localPdfDocument.pageCount)
+ updateVisibleContent()
+ }
+
+ /**
+ * Compute what content is visible from the current position of this View. Generally invoked on
+ * position or size changes.
+ */
+ internal fun updateVisibleContent() {
+ val localPaginationModel = paginationModel ?: return
+
+ val contentTop = round(scrollY / zoom).toInt()
+ val contentBottom = round((height + scrollY) / zoom).toInt()
+ visiblePages = localPaginationModel.getPagesInViewport(contentTop, contentBottom)
+
+ // If scale changed, update already-visible pages so they can re-render and redraw
+ // themselves accordingly
+ if (!gestureHandler.scaleInProgress && !gestureHandler.scrollInProgress) {
+ for (i in visiblePages.lower..visiblePages.upper) {
+ pages[i]?.maybeRender()
+ }
+ }
+ }
+
+ private fun reset() {
+ scrollTo(0, 0)
+ zoom = DEFAULT_INIT_ZOOM
+ pages.clear()
+ coroutineScope.coroutineContext.cancelChildren()
+ }
+
+ /** React to a change in visible pages (load new pages and clean up old ones) */
+ private fun onVisiblePagesChanged() {
+ val localPaginationModel = paginationModel ?: return
+ val nearPages =
+ Range(
+ maxOf(0, visiblePages.lower - pagePrefetchRadius),
+ minOf(visiblePages.upper + pagePrefetchRadius, localPaginationModel.numPages - 1),
+ )
+
+ // Fetch dimensions for near pages
+ for (i in max(localPaginationModel.reach - 1, nearPages.lower)..nearPages.upper) {
+ loadPageDimensions(i)
+ }
+
+ for (i in visiblePages.lower..visiblePages.upper) {
+ pages[i]?.isVisible = true
+ }
+
+ // Clean up pages that are no longer visible
+ for (pageIndex in pages.keyIterator()) {
+ if (pageIndex < nearPages.lower || pageIndex > nearPages.upper) {
+ pages[pageIndex]?.isVisible = false
+ }
+ }
+ }
+
+ /** Loads dimensions for a single page */
+ private fun loadPageDimensions(pageNum: Int) {
+ coroutineScope.launch {
+ val pageMetadata = withPdfDocument { it.getPageInfo(pageNum) }
+ // Update mutable state on the main thread
+ withContext(Dispatchers.Main) {
+ val localPaginationModel =
+ paginationModel ?: throw IllegalStateException("No PdfDocument")
+ localPaginationModel.addPage(
+ pageNum,
+ Point(pageMetadata.width, pageMetadata.height)
+ )
+ val page =
+ Page(pageNum, Size(pageMetadata.width, pageMetadata.height), this@PdfView)
+ pages[pageNum] = page
+ if (pageNum >= visiblePages.lower && pageNum <= visiblePages.upper) {
+ // Make the page visible if it is, so it starts to render itself
+ page.isVisible = true
+ }
+ // Learning the dimensions of a page might affect our understanding of which pages
+ // are visible
+ updateVisibleContent()
+ }
+ }
+ }
+
+ /** Set the zoom, using the given point as a pivot point to zoom in or out of */
+ internal fun zoomTo(zoom: Float, pivotX: Float, pivotY: Float) {
+ // TODO(b/376299551) - Restore to developer-configured initial zoom value once that API is
+ // implemented
+ val newZoom = if (Float.NaN.equals(zoom)) DEFAULT_INIT_ZOOM else zoom
+ val deltaX = scrollDeltaNeededForZoomChange(this.zoom, newZoom, pivotX, scrollX)
+ val deltaY = scrollDeltaNeededForZoomChange(this.zoom, newZoom, pivotY, scrollY)
+
+ this.zoom = newZoom
+ scrollBy(deltaX, deltaY)
+ }
+
+ private fun scrollDeltaNeededForZoomChange(
+ oldZoom: Float,
+ newZoom: Float,
+ pivot: Float,
+ scroll: Int,
+ ): Int {
+ // Find where the given pivot point would move to when we change the zoom, and return the
+ // delta.
+ val contentPivot = toContentCoord(pivot, oldZoom, scroll)
+ val movedZoomViewPivot: Float = toViewCoord(contentPivot, newZoom, scroll)
+ return (movedZoomViewPivot - pivot).toInt()
+ }
+
+ /**
+ * Computes the part of the content visible within the outer part of this view (including this
+ * view's padding) in co-ordinates of the content.
+ */
+ private fun getVisibleAreaInContentCoords(): Rect {
+ visibleAreaRect.set(
+ toContentX(-paddingLeft.toFloat()).toInt(),
+ toContentY(-paddingTop.toFloat()).toInt(),
+ toContentX(viewportWidth.toFloat() + paddingRight).toInt(),
+ toContentY(viewportHeight.toFloat() + paddingBottom).toInt(),
+ )
+ return visibleAreaRect
+ }
+
+ /** The height of the viewport, minus padding */
+ private val viewportHeight: Int
+ get() = bottom - top - paddingBottom - paddingTop
+
+ /** The width of the viewport, minus padding */
+ private val viewportWidth: Int
+ get() = right - left - paddingRight - paddingLeft
+
+ /** Converts an X coordinate in View space to an X coordinate in content space */
+ private fun toContentX(viewX: Float): Float {
+ return toContentCoord(viewX, zoom, scrollX)
+ }
+
+ /** Converts a Y coordinate in View space to a Y coordinate in content space */
+ private fun toContentY(viewY: Float): Float {
+ return toContentCoord(viewY, zoom, scrollY)
+ }
+
+ private fun toViewCoord(contentCoord: Float, zoom: Float, scroll: Int): Float {
+ return (contentCoord * zoom) - scroll
+ }
+
+ /**
+ * Converts a one-dimensional coordinate in View space to a one-dimensional coordinate in
+ * content space
+ */
+ private fun toContentCoord(viewCoord: Float, zoom: Float, scroll: Int): Float {
+ return (viewCoord + scroll) / zoom
+ }
+
+ /** Helper to use [PdfDocument] behind a mutex to ensure FIFO semantics for requests */
+ private suspend fun <T> withPdfDocument(block: suspend (PdfDocument) -> T): T {
+ pdfDocumentMutex.withLock {
+ val localPdfDocument = pdfDocument ?: throw IllegalStateException("No PdfDocument")
+ return block(localPdfDocument)
+ }
+ }
+
+ public companion object {
+ public const val DEFAULT_PAGE_SPACING_PX: Int = 20
+ public const val DEFAULT_INIT_ZOOM: Float = 1.0f
+ public const val DEFAULT_MAX_ZOOM: Float = 25.0f
+ public const val DEFAULT_MIN_ZOOM: Float = 0.1f
+
+ private fun checkMainThread() {
+ check(Looper.myLooper() == Looper.getMainLooper()) {
+ "Property must be set on the main thread"
+ }
+ }
+ }
+}
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/ZoomScrollGestureHandler.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/ZoomScrollGestureHandler.kt
new file mode 100644
index 0000000..42b4127
--- /dev/null
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/ZoomScrollGestureHandler.kt
@@ -0,0 +1,170 @@
+/*
+ * 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.pdf.view
+
+import android.graphics.PointF
+import android.view.MotionEvent
+import android.view.ScaleGestureDetector
+import java.util.LinkedList
+import java.util.Queue
+import kotlin.math.abs
+
+/** Adjusts the position of [PdfView] in response to gestures detected by [GestureTracker] */
+internal class ZoomScrollGestureHandler(private val pdfView: PdfView) :
+ GestureTracker.GestureHandler() {
+ internal var scrollInProgress = false
+ internal var scaleInProgress = false
+
+ /**
+ * The multiplier to convert from a scale gesture's delta span, in pixels, to scale factor.
+ *
+ * [ScaleGestureDetector] returns scale factors proportional to the ratio of `currentSpan /
+ * prevSpan`. This is problematic because it results in scale factors that are very large for
+ * small pixel spans, which is particularly problematic for quickScale gestures, where the span
+ * pixel values can be small, but the ratio can yield very large scale factors.
+ *
+ * Instead, we use this to ensure that pinching or quick scale dragging a certain number of
+ * pixels always corresponds to a certain change in zoom. The equation that we've found to work
+ * well is a delta span of the larger screen dimension should result in a zoom change of 2x.
+ */
+ private val linearScaleSpanMultiplier: Float =
+ 2f /
+ maxOf(
+ pdfView.resources.displayMetrics.heightPixels,
+ pdfView.resources.displayMetrics.widthPixels
+ )
+ /** The maximum scroll distance used to determine if the direction is vertical. */
+ private val maxScrollWindow =
+ (pdfView.resources.displayMetrics.density * MAX_SCROLL_WINDOW_DP).toInt()
+
+ /** The smallest scroll distance that can switch mode to "free scrolling". */
+ private val minScrollToSwitch =
+ (pdfView.resources.displayMetrics.density * MIN_SCROLL_TO_SWITCH_DP).toInt()
+
+ /** Remember recent scroll events so we can examine the general direction. */
+ private val scrollQueue: Queue<PointF> = LinkedList()
+
+ /** Are we correcting vertical scroll for the current gesture? */
+ private var straightenCurrentVerticalScroll = true
+
+ private var totalX = 0f
+ private var totalY = 0f
+
+ private val totalScrollLength
+ // No need for accuracy of correct hypotenuse calculation
+ get() = abs(totalX) + abs(totalY)
+
+ override fun onScroll(
+ e1: MotionEvent?,
+ e2: MotionEvent,
+ distanceX: Float,
+ distanceY: Float,
+ ): Boolean {
+ scrollInProgress = true
+ var dx = Math.round(distanceX)
+ val dy = Math.round(distanceY)
+
+ if (straightenCurrentVerticalScroll) {
+ // Remember a window of recent scroll events.
+ scrollQueue.offer(PointF(distanceX, distanceY))
+ totalX += distanceX
+ totalY += distanceY
+
+ // Only consider scroll direction for a certain window of scroll events.
+ while (totalScrollLength > maxScrollWindow && scrollQueue.size > 1) {
+ // Remove the oldest scroll event - it is too far away to determine scroll
+ // direction.
+ val oldest = scrollQueue.poll()
+ oldest?.let {
+ totalY -= oldest.y
+ totalX -= oldest.x
+ }
+ }
+
+ if (
+ totalScrollLength > minScrollToSwitch &&
+ abs((totalY / totalX).toDouble()) < SCROLL_CORRECTION_RATIO
+ ) {
+ straightenCurrentVerticalScroll = false
+ } else {
+ // Ignore the horizontal component of the scroll.
+ dx = 0
+ }
+ }
+
+ pdfView.scrollBy(dx, dy)
+ return true
+ }
+
+ override fun onFling(
+ e1: MotionEvent?,
+ e2: MotionEvent,
+ velocityX: Float,
+ velocityY: Float
+ ): Boolean {
+ return super.onFling(e1, e2, velocityX, velocityY)
+ // TODO(b/376136621) Animate scroll position during a fling
+ }
+
+ override fun onDoubleTap(e: MotionEvent): Boolean {
+ return super.onDoubleTap(e)
+ // TODO(b/376136331) Toggle between fit-to-page and zoomed-in on double tap gestures
+ }
+
+ override fun onScale(detector: ScaleGestureDetector): Boolean {
+ scaleInProgress = true
+ val rawScaleFactor = detector.scaleFactor
+ val deltaSpan = abs(detector.currentSpan - detector.previousSpan)
+ val scaleDelta = deltaSpan * linearScaleSpanMultiplier
+ val linearScaleFactor = if (rawScaleFactor >= 1f) 1f + scaleDelta else 1f - scaleDelta
+ val newZoom = (pdfView.zoom * linearScaleFactor).coerceIn(pdfView.minZoom, pdfView.maxZoom)
+
+ pdfView.zoomTo(newZoom, detector.focusX, detector.focusY)
+ return true
+ }
+
+ override fun onGestureEnd(gesture: GestureTracker.Gesture?) {
+ when (gesture) {
+ GestureTracker.Gesture.ZOOM -> {
+ scaleInProgress = false
+ pdfView.updateVisibleContent()
+ }
+ GestureTracker.Gesture.DRAG,
+ GestureTracker.Gesture.DRAG_Y,
+ GestureTracker.Gesture.DRAG_X -> {
+ scrollInProgress = false
+ pdfView.updateVisibleContent()
+ }
+ else -> {
+ /* no-op */
+ }
+ }
+ totalX = 0f
+ totalY = 0f
+ straightenCurrentVerticalScroll = true
+ scrollQueue.clear()
+ }
+
+ companion object {
+ /** The ratio of vertical to horizontal scroll that is assumed to be vertical only */
+ private const val SCROLL_CORRECTION_RATIO = 1.5f
+ /** The maximum scroll distance used to determine if the direction is vertical */
+ private const val MAX_SCROLL_WINDOW_DP = 70
+ /** The smallest scroll distance that can switch mode to "free scrolling" */
+ private const val MIN_SCROLL_TO_SWITCH_DP = 30
+ }
+}
diff --git a/pdf/pdf-viewer/src/test/kotlin/androidx/pdf/view/GestureTrackerTest.kt b/pdf/pdf-viewer/src/test/kotlin/androidx/pdf/view/GestureTrackerTest.kt
new file mode 100644
index 0000000..c40037c
--- /dev/null
+++ b/pdf/pdf-viewer/src/test/kotlin/androidx/pdf/view/GestureTrackerTest.kt
@@ -0,0 +1,609 @@
+/*
+ * 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.pdf.view
+
+import android.content.Context
+import android.graphics.Point
+import android.graphics.PointF
+import android.view.MotionEvent
+import android.view.ViewConfiguration
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import java.util.concurrent.TimeUnit
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.atLeastOnce
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.verifyNoMoreInteractions
+import org.mockito.kotlin.whenever
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+
+@SmallTest
+@RunWith(RobolectricTestRunner::class)
+class GestureTrackerTest {
+ private val gestureHandlerSpy =
+ mock<GestureTracker.GestureHandler>().apply {
+ // We must return true from these listeners or GestureDetector won't continue sending
+ // us callbacks
+ whenever(onScaleBegin(any())).thenReturn(true)
+ whenever(onScale(any())).thenReturn(true)
+ }
+
+ private val context = ApplicationProvider.getApplicationContext<Context>()
+ private lateinit var gestureTracker: GestureTracker
+
+ @Before
+ fun setup() {
+ // Initialize GestureTracker before each case to avoid state leaking between tests
+ gestureTracker = GestureTracker(context).apply { delegate = gestureHandlerSpy }
+ }
+
+ @Test
+ fun testSingleTap() {
+ gestureTracker.feed(down(PointF(50f, 50f)))
+ gestureTracker.feed(up(PointF(50f, 50f)))
+ // We expected to issue onGestureStart and onSingleTapUp callbacks, and we expect the
+ // current detected gesture to be FIRST_TAP (single tap that still may become a double tap)
+ verify(gestureHandlerSpy).onGestureStart()
+ verify(gestureHandlerSpy).onSingleTapUp(any())
+ assertThat(gestureTracker.matches(GestureTracker.Gesture.FIRST_TAP)).isTrue()
+
+ // Advance time by the double tap timeout
+ Robolectric.getForegroundThreadScheduler()
+ .advanceBy(ViewConfiguration.getDoubleTapTimeout().toLong(), TimeUnit.MILLISECONDS)
+
+ // We expect to issue onSingleTapConfirmed and onGestureEnd callbacks, and we expect the
+ // current detect gesture to be a SINGLE_TAP (i.e. confirmed *not* to be a double tap)
+ verify(gestureHandlerSpy).onSingleTapConfirmed(any())
+ verify(gestureHandlerSpy).onGestureEnd(eq(GestureTracker.Gesture.SINGLE_TAP))
+ assertThat(gestureTracker.matches(GestureTracker.Gesture.SINGLE_TAP)).isTrue()
+
+ verifyNoMoreInteractions(gestureHandlerSpy)
+ }
+
+ @Test
+ fun testDoubleTap() {
+ val downTime = Robolectric.getForegroundThreadScheduler().currentTime
+ gestureTracker.feed(down(PointF(50f, 50f), time = downTime))
+ gestureTracker.feed(up(PointF(50f, 50f), downTime = downTime))
+ // We expected to issue onGestureStart and onSingleTapUp callbacks, and we expect the
+ // current detected gesture to be FIRST_TAP (single tap that still may become a double tap)
+ verify(gestureHandlerSpy).onGestureStart()
+ verify(gestureHandlerSpy).onSingleTapUp(any())
+ assertThat(gestureTracker.matches(GestureTracker.Gesture.FIRST_TAP)).isTrue()
+
+ // Advance time by less than the double tap timeout, and issue another up / down sequence.
+ // The minimum time between down events to detect a double tap is a hidden API in
+ // ViewConfiguration, so use half the maximum time.
+ Robolectric.getForegroundThreadScheduler()
+ .advanceBy(ViewConfiguration.getDoubleTapTimeout().toLong() / 2, TimeUnit.MILLISECONDS)
+ gestureTracker.feed(down(PointF(50f, 50f)))
+ gestureTracker.feed(up(PointF(50f, 50f), downTime = downTime))
+
+ // We expect to issue onDoubleTap and onGestureEnd callbacks; we expect to *not* have issued
+ // an onSingleTapConfirmed callback; and we expect the current detected gesture to be a
+ // DOUBLE_TAP
+ verify(gestureHandlerSpy, times(2)).onSingleTapUp(any())
+ verify(gestureHandlerSpy, never()).onSingleTapConfirmed(any())
+ verify(gestureHandlerSpy).onDoubleTap(any())
+ verify(gestureHandlerSpy).onGestureEnd(eq(GestureTracker.Gesture.DOUBLE_TAP))
+ assertThat(gestureTracker.matches(GestureTracker.Gesture.DOUBLE_TAP)).isTrue()
+
+ verifyNoMoreInteractions(gestureHandlerSpy)
+ }
+
+ @Test
+ fun testLongPress() {
+ val downTime = Robolectric.getForegroundThreadScheduler().currentTime
+ gestureTracker.feed(down(PointF(50f, 50f), time = downTime))
+ // We expected to issue an onGestureStart and onSingleTapUp callback, and we expect the
+ // current detected gesture to be TOUCH (Down with no Up yet)
+ verify(gestureHandlerSpy).onGestureStart()
+ assertThat(gestureTracker.matches(GestureTracker.Gesture.TOUCH)).isTrue()
+
+ // Advance time by the long press timeout
+ Robolectric.getForegroundThreadScheduler()
+ .advanceBy(ViewConfiguration.getLongPressTimeout().toLong() + 1, TimeUnit.MILLISECONDS)
+ gestureTracker.feed(up(PointF(50f, 50f), downTime = downTime))
+
+ // We shouldn't have issued these callbacks
+ verify(gestureHandlerSpy, never()).onSingleTapConfirmed(any())
+ verify(gestureHandlerSpy, never()).onSingleTapUp(any())
+ // We expect to have issued onShowPress, onLongPress, and onGestureEnd callbacks, and we
+ // expect the current detected gesture to be a LONG_PRESS
+ verify(gestureHandlerSpy).onShowPress(any())
+ verify(gestureHandlerSpy).onLongPress(any())
+ verify(gestureHandlerSpy).onGestureEnd(eq(GestureTracker.Gesture.LONG_PRESS))
+ assertThat(gestureTracker.matches(GestureTracker.Gesture.LONG_PRESS)).isTrue()
+
+ verifyNoMoreInteractions(gestureHandlerSpy)
+ }
+
+ @Test
+ fun testDragX() {
+ for (event in
+ oneFingerDrag(
+ start = PointF(50f, 50f),
+ velocity = Point(ViewConfiguration.get(context).scaledMinimumFlingVelocity / 2, 0)
+ )) {
+ gestureTracker.feed(event)
+ }
+
+ verify(gestureHandlerSpy).onGestureStart()
+ verify(gestureHandlerSpy, atLeastOnce()).onScroll(any(), any(), any(), any())
+ verify(gestureHandlerSpy).onGestureEnd(eq(GestureTracker.Gesture.DRAG_X))
+ assertThat(gestureTracker.matches(GestureTracker.Gesture.DRAG_X)).isTrue()
+
+ verifyNoMoreInteractions(gestureHandlerSpy)
+ }
+
+ @Test
+ fun testDragY() {
+ for (event in
+ oneFingerDrag(
+ start = PointF(50f, 50f),
+ velocity = Point(0, ViewConfiguration.get(context).scaledMinimumFlingVelocity / 2),
+ )) {
+ gestureTracker.feed(event)
+ }
+
+ verify(gestureHandlerSpy).onGestureStart()
+ verify(gestureHandlerSpy, atLeastOnce()).onScroll(any(), any(), any(), any())
+ verify(gestureHandlerSpy).onGestureEnd(eq(GestureTracker.Gesture.DRAG_Y))
+ assertThat(gestureTracker.matches(GestureTracker.Gesture.DRAG_Y)).isTrue()
+
+ verifyNoMoreInteractions(gestureHandlerSpy)
+ }
+
+ @Test
+ fun testDrag() {
+ val velocity = ViewConfiguration.get(context).scaledMinimumFlingVelocity / 4
+ for (event in
+ oneFingerDrag(
+ start = PointF(50f, 50f),
+ velocity = Point(velocity, velocity),
+ )) {
+ gestureTracker.feed(event)
+ }
+
+ verify(gestureHandlerSpy).onGestureStart()
+ verify(gestureHandlerSpy, atLeastOnce()).onScroll(any(), any(), any(), any())
+ verify(gestureHandlerSpy).onGestureEnd(eq(GestureTracker.Gesture.DRAG))
+ assertThat(gestureTracker.matches(GestureTracker.Gesture.DRAG)).isTrue()
+
+ verifyNoMoreInteractions(gestureHandlerSpy)
+ }
+
+ @Test
+ fun testFling() {
+ val velocity = ViewConfiguration.get(context).scaledMinimumFlingVelocity * 2
+ for (event in
+ oneFingerDrag(
+ start = PointF(50f, 50f),
+ velocity = Point(velocity, velocity),
+ )) {
+ gestureTracker.feed(event)
+ }
+
+ verify(gestureHandlerSpy).onGestureStart()
+ verify(gestureHandlerSpy, atLeastOnce()).onFling(any(), any(), any(), any())
+ verify(gestureHandlerSpy, atLeastOnce()).onScroll(any(), any(), any(), any())
+ verify(gestureHandlerSpy).onGestureEnd(eq(GestureTracker.Gesture.FLING))
+ assertThat(gestureTracker.matches(GestureTracker.Gesture.FLING)).isTrue()
+
+ verifyNoMoreInteractions(gestureHandlerSpy)
+ }
+
+ @Test
+ fun testZoomIn_pinch() {
+ // Drag pointer 1 in the positive X direction from (50, 50) at the same time as dragging
+ // pointer 2 in the negative X direction from (500, 500)
+ val velocity = ViewConfiguration.get(context).scaledMinimumFlingVelocity / 2
+ val start1 = PointF(50f, 50f)
+ val start2 = PointF(500f, 500f)
+ val velocity1 = Point(velocity, 0)
+ val velocity2 = Point(-velocity, 0)
+ for (event in twoFingerDrag(start1, start2, velocity1, velocity2)) {
+ gestureTracker.feed(event)
+ }
+
+ verify(gestureHandlerSpy).onGestureStart()
+ verify(gestureHandlerSpy, atLeastOnce()).onScaleBegin(any())
+ verify(gestureHandlerSpy, atLeastOnce()).onScale(any())
+ verify(gestureHandlerSpy, atLeastOnce()).onScaleEnd(any())
+ assertThat(gestureTracker.matches(GestureTracker.Gesture.ZOOM)).isTrue()
+
+ verifyNoMoreInteractions(gestureHandlerSpy)
+ }
+
+ @Test
+ fun testZoomOut_pinch() {
+ // Drag pointer 1 in the negative Y direction from (500, 500) at the same time as dragging
+ // pointer 2 in the positive Y direction from (500, 500)
+ val velocity = ViewConfiguration.get(context).scaledMinimumFlingVelocity / 2
+ val start1 = PointF(500f, 500f)
+ val start2 = PointF(500f, 500f)
+ val velocity1 = Point(0, -velocity)
+ val velocity2 = Point(0, velocity)
+ for (event in twoFingerDrag(start1, start2, velocity1, velocity2)) {
+ gestureTracker.feed(event)
+ }
+
+ verify(gestureHandlerSpy).onGestureStart()
+ verify(gestureHandlerSpy, atLeastOnce()).onScaleBegin(any())
+ verify(gestureHandlerSpy, atLeastOnce()).onScale(any())
+ verify(gestureHandlerSpy, atLeastOnce()).onScaleEnd(any())
+ assertThat(gestureTracker.matches(GestureTracker.Gesture.ZOOM)).isTrue()
+
+ verifyNoMoreInteractions(gestureHandlerSpy)
+ }
+
+ @Test
+ fun testZoom_quickScale() {
+ // First, send a single tap
+ val startPoint = PointF(50f, 50f)
+ val downTime = Robolectric.getForegroundThreadScheduler().currentTime
+ gestureTracker.feed(down(startPoint, time = downTime))
+ gestureTracker.feed(up(startPoint, downTime = downTime))
+ // Then, advance time by less than the double tap timeout, and tap again, but don't release
+ // the pointer
+ // The minimum time between down events to detect a double tap is a hidden API in
+ // ViewConfiguration, so use a fraction of the maximum time.
+ Robolectric.getForegroundThreadScheduler()
+ .advanceBy(ViewConfiguration.getDoubleTapTimeout().toLong() / 5, TimeUnit.MILLISECONDS)
+ gestureTracker.feed(down(startPoint))
+ // Finally, tap and drag in the +y direction from same point
+ val velocity = ViewConfiguration.get(context).scaledMinimumFlingVelocity / 2
+ for (event in oneFingerDrag(startPoint, Point(0, velocity), skipDown = true)) {
+ gestureTracker.feed(event)
+ }
+
+ // These are intermediate callbacks we expect to receive while the quick scale gesture is
+ // being formed
+ verify(gestureHandlerSpy).onSingleTapUp(any())
+ verify(gestureHandlerSpy).onShowPress(any())
+ verify(gestureHandlerSpy).onLongPress(any())
+ // These are the callbacks we really want to receive after the quick scale gesture is made
+ verify(gestureHandlerSpy).onGestureStart()
+ verify(gestureHandlerSpy, atLeastOnce()).onScaleBegin(any())
+ verify(gestureHandlerSpy, atLeastOnce()).onScale(any())
+ verify(gestureHandlerSpy, atLeastOnce()).onScaleEnd(any())
+ verify(gestureHandlerSpy).onGestureEnd(eq(GestureTracker.Gesture.ZOOM))
+ assertThat(gestureTracker.matches(GestureTracker.Gesture.ZOOM)).isTrue()
+
+ verifyNoMoreInteractions(gestureHandlerSpy)
+ }
+
+ @Test
+ fun testTap_thenDrag_noDoubleTap() {
+ // First, issue a single tap and release the pointer
+ val point = PointF(50f, 50f)
+ gestureTracker.feed(down(point))
+ gestureTracker.feed(up(point))
+ // We expected to issue onGestureStart and onSingleTapUp callbacks, and we expect the
+ // current detected gesture to be FIRST_TAP (single tap that still may become a double tap)
+ verify(gestureHandlerSpy).onGestureStart()
+ verify(gestureHandlerSpy).onSingleTapUp(any())
+ assertThat(gestureTracker.matches(GestureTracker.Gesture.FIRST_TAP)).isTrue()
+
+ // Then, drag from the same point
+ for (event in
+ oneFingerDrag(
+ point,
+ velocity = Point(ViewConfiguration.get(context).scaledMinimumFlingVelocity / 2, 0)
+ )) {
+ gestureTracker.feed(event)
+ }
+
+ // These are the terminal callbacks from the completion of the single tap
+ verify(gestureHandlerSpy).onSingleTapConfirmed(any())
+ verify(gestureHandlerSpy).onGestureEnd(eq(GestureTracker.Gesture.SINGLE_TAP))
+ // These are the callbacks we expect to receive as part of the scroll gesture
+ verify(gestureHandlerSpy, times(2)).onGestureStart()
+ verify(gestureHandlerSpy, atLeastOnce()).onScroll(any(), any(), any(), any())
+ verify(gestureHandlerSpy).onGestureEnd(GestureTracker.Gesture.DRAG_X)
+ assertThat(gestureTracker.matches(GestureTracker.Gesture.DRAG_X)).isTrue()
+ // And we should never have detected a double tap
+ verify(gestureHandlerSpy, never()).onDoubleTap(any())
+
+ verifyNoMoreInteractions(gestureHandlerSpy)
+ }
+
+ @Test
+ fun testDrag_thenTap_noDoubleTap() {
+ val point = PointF(50f, 50f)
+ // First, drag
+ for (event in
+ oneFingerDrag(
+ point,
+ velocity = Point(ViewConfiguration.get(context).scaledMinimumFlingVelocity / 2, 0)
+ )) {
+ gestureTracker.feed(event)
+ }
+ // These are the callbacks we expect to receive as part of the scroll
+ verify(gestureHandlerSpy).onGestureStart()
+ verify(gestureHandlerSpy, atLeastOnce()).onScroll(any(), any(), any(), any())
+ verify(gestureHandlerSpy).onGestureEnd(GestureTracker.Gesture.DRAG_X)
+ assertThat(gestureTracker.matches(GestureTracker.Gesture.DRAG_X)).isTrue()
+
+ // Then, issue a single tap from the same point
+ gestureTracker.feed(down(point))
+ gestureTracker.feed(up(point))
+ // Advance time by the double tap timeout
+ Robolectric.getForegroundThreadScheduler()
+ .advanceBy(ViewConfiguration.getDoubleTapTimeout().toLong(), TimeUnit.MILLISECONDS)
+
+ // These are the callbacks we expect to receive as part of the single tap
+ verify(gestureHandlerSpy, times(2)).onGestureStart()
+ verify(gestureHandlerSpy).onSingleTapUp(any())
+ verify(gestureHandlerSpy).onSingleTapConfirmed(any())
+ verify(gestureHandlerSpy).onGestureEnd(eq(GestureTracker.Gesture.SINGLE_TAP))
+ assertThat(gestureTracker.matches(GestureTracker.Gesture.SINGLE_TAP)).isTrue()
+ // And we should never have detected a double tap
+ verify(gestureHandlerSpy, never()).onDoubleTap(any())
+
+ verifyNoMoreInteractions(gestureHandlerSpy)
+ }
+
+ @Test
+ fun testTwoQuickDrags_sameDownPosition_noQuickScale() {
+ // Drag once
+ val point = PointF(50f, 50f)
+ for (event in
+ oneFingerDrag(
+ start = point,
+ velocity = Point(ViewConfiguration.get(context).scaledMinimumFlingVelocity / 2, 0)
+ )) {
+ gestureTracker.feed(event)
+ }
+ // Make sure we detect one set of drag / scroll callbacks
+ verify(gestureHandlerSpy).onGestureStart()
+ verify(gestureHandlerSpy, atLeastOnce()).onScroll(any(), any(), any(), any())
+ verify(gestureHandlerSpy).onGestureEnd(eq(GestureTracker.Gesture.DRAG_X))
+ assertThat(gestureTracker.matches(GestureTracker.Gesture.DRAG_X)).isTrue()
+
+ // Then, advance time by less than the double tap timeout
+ Robolectric.getForegroundThreadScheduler()
+ .advanceBy(ViewConfiguration.getDoubleTapTimeout().toLong() / 2, TimeUnit.MILLISECONDS)
+ // Finally, drag again, from the same point
+ for (event in
+ oneFingerDrag(
+ start = point,
+ velocity = Point(ViewConfiguration.get(context).scaledMinimumFlingVelocity / 2, 0)
+ )) {
+ gestureTracker.feed(event)
+ }
+
+ // Make sure we detected a second set of drag callbacks
+ verify(gestureHandlerSpy, times(2)).onGestureStart()
+ verify(gestureHandlerSpy, atLeastOnce()).onScroll(any(), any(), any(), any())
+ verify(gestureHandlerSpy, times(2)).onGestureEnd(eq(GestureTracker.Gesture.DRAG_X))
+ assertThat(gestureTracker.matches(GestureTracker.Gesture.DRAG_X)).isTrue()
+ // And that we never detected a zoom / quick scale
+ verify(gestureHandlerSpy, never()).onScale(any())
+
+ verifyNoMoreInteractions(gestureHandlerSpy)
+ }
+}
+
+/**
+ * Returns a [MotionEvent] with [action] at [downTime] and [location], and with [eventTime] as the
+ * time the corresponding [MotionEvent.ACTION_DOWN] was issued
+ */
+private fun motionEvent(
+ action: Int,
+ location: PointF,
+ downTime: Long = Robolectric.getForegroundThreadScheduler().currentTime,
+ eventTime: Long = Robolectric.getForegroundThreadScheduler().currentTime,
+) = MotionEvent.obtain(downTime, eventTime, action, location.x, location.y, 0)
+
+/** Returns a [MotionEvent] with [MotionEvent.ACTION_DOWN] at [time] and [location] */
+private fun down(
+ location: PointF,
+ time: Long = Robolectric.getForegroundThreadScheduler().currentTime,
+) = motionEvent(MotionEvent.ACTION_DOWN, location, downTime = time, eventTime = time)
+
+/**
+ * Returns a [MotionEvent] with [MotionEvent.ACTION_MOVE] at [eventTime] and [location] and with
+ * [downTime] as the time the corresponding [MotionEvent.ACTION_DOWN] was issued
+ */
+private fun move(
+ location: PointF,
+ downTime: Long = Robolectric.getForegroundThreadScheduler().currentTime,
+ eventTime: Long = Robolectric.getForegroundThreadScheduler().currentTime,
+) = motionEvent(MotionEvent.ACTION_MOVE, location, downTime, eventTime)
+
+/**
+ * Returns a [MotionEvent] with [MotionEvent.ACTION_UP] at [eventTime] and [location] and with
+ * [downTime] as the time the corresponding [MotionEvent.ACTION_DOWN] was issued
+ */
+private fun up(
+ location: PointF,
+ downTime: Long = Robolectric.getForegroundThreadScheduler().currentTime,
+ eventTime: Long = Robolectric.getForegroundThreadScheduler().currentTime,
+) = motionEvent(MotionEvent.ACTION_UP, location, downTime, eventTime)
+
+/**
+ * Returns a [List] of [MotionEvent] simulating a user dragging one pointers, from [start] at
+ * [velocity]
+ */
+private fun oneFingerDrag(
+ start: PointF,
+ velocity: Point,
+ downTime: Long = Robolectric.getForegroundThreadScheduler().currentTime,
+ skipDown: Boolean = false
+): List<MotionEvent> {
+ val sequence = mutableListOf<MotionEvent>()
+ if (!skipDown) sequence.add(down(start, time = downTime))
+ var x = start.x
+ var y = start.y
+ // Record 1 move event every 10ms
+ for (i in 0..1000 step 10) {
+ // Pixels per second, 10ms step
+ x += 0.01f * velocity.x
+ y += 0.01f * velocity.y
+ Robolectric.getForegroundThreadScheduler().advanceBy(10, TimeUnit.MILLISECONDS)
+ sequence.add(move(PointF(x, y), downTime = downTime))
+ }
+ sequence.add(up(PointF(x, y), downTime = downTime))
+ return sequence.toList()
+}
+
+/**
+ * Returns a [List] of [MotionEvent] simulating a user dragging 2 pointers simultaneously, from
+ * [start1] and [start2] at [velocity1] and [velocity2], for 1 second
+ */
+private fun twoFingerDrag(
+ start1: PointF,
+ start2: PointF,
+ velocity1: Point,
+ velocity2: Point
+): List<MotionEvent> {
+ // Specify the touch properties for the finger events.
+ val pp1 = MotionEvent.PointerProperties()
+ pp1.id = 0
+ pp1.toolType = MotionEvent.TOOL_TYPE_FINGER
+ val pp2 = MotionEvent.PointerProperties()
+ pp2.id = 1
+ pp2.toolType = MotionEvent.TOOL_TYPE_FINGER
+ val pointerProperties = arrayOf(pp1, pp2)
+
+ // Specify the motion properties of the two touch points.
+ val pc1 = MotionEvent.PointerCoords()
+ pc1.x = start1.x
+ pc1.y = start1.y
+ pc1.pressure = 1f
+ pc1.size = 1f
+ val pc2 = MotionEvent.PointerCoords()
+ pc2.x = start2.x
+ pc2.y = start2.y
+ pc2.pressure = 1f
+ pc2.size = 1f
+ val pointerCoords = arrayOf(pc1, pc2)
+
+ // Two down events, 1 for each pointer
+ val downTime = Robolectric.getForegroundThreadScheduler().currentTime
+ val firstFingerEvent =
+ MotionEvent.obtain(
+ downTime,
+ Robolectric.getForegroundThreadScheduler().currentTime,
+ MotionEvent.ACTION_DOWN,
+ 1,
+ pointerProperties,
+ pointerCoords,
+ 0,
+ 0,
+ 1f,
+ 1f,
+ 0,
+ 0,
+ 0,
+ 0
+ )
+ val secondFingerEvent =
+ MotionEvent.obtain(
+ downTime,
+ Robolectric.getForegroundThreadScheduler().currentTime,
+ MotionEvent.ACTION_POINTER_DOWN + (pp2.id shl MotionEvent.ACTION_POINTER_INDEX_SHIFT),
+ 2,
+ pointerProperties,
+ pointerCoords,
+ 0,
+ 0,
+ 1f,
+ 1f,
+ 0,
+ 0,
+ 0,
+ 0
+ )
+ val sequence = mutableListOf(firstFingerEvent, secondFingerEvent)
+ // Compute a series of ACTION_MOVE events with interpolated coordinates for each pointer
+ for (i in 0..1000 step 10) {
+ pc1.x += 0.01f * velocity1.x
+ pc1.y += 0.01f * velocity1.y
+ pc2.x += 0.01f * velocity2.x
+ pc2.y += 0.01f * velocity2.y
+
+ Robolectric.getForegroundThreadScheduler().advanceBy(10, TimeUnit.MILLISECONDS)
+ val twoPointerMove =
+ MotionEvent.obtain(
+ downTime,
+ Robolectric.getForegroundThreadScheduler().currentTime,
+ MotionEvent.ACTION_MOVE,
+ 2,
+ pointerProperties,
+ pointerCoords,
+ 0,
+ 0,
+ 1f,
+ 1f,
+ 0,
+ 0,
+ 0,
+ 0
+ )
+ sequence.add(twoPointerMove)
+ }
+ // Send 2 up events, one for each pointer
+ val secondFingerUpEvent =
+ MotionEvent.obtain(
+ downTime,
+ Robolectric.getForegroundThreadScheduler().currentTime,
+ MotionEvent.ACTION_POINTER_UP,
+ 2,
+ pointerProperties,
+ pointerCoords,
+ 0,
+ 0,
+ 1f,
+ 1f,
+ 0,
+ 0,
+ 0,
+ 0
+ )
+ val firstFingerUpEvent =
+ MotionEvent.obtain(
+ downTime,
+ Robolectric.getForegroundThreadScheduler().currentTime,
+ MotionEvent.ACTION_POINTER_UP,
+ 1,
+ pointerProperties,
+ pointerCoords,
+ 0,
+ 0,
+ 1f,
+ 1f,
+ 0,
+ 0,
+ 0,
+ 0
+ )
+ sequence.add(secondFingerUpEvent)
+ sequence.add(firstFingerUpEvent)
+
+ return sequence.toList()
+}
diff --git a/pdf/pdf-viewer/src/test/kotlin/androidx/pdf/view/PaginationModelTest.kt b/pdf/pdf-viewer/src/test/kotlin/androidx/pdf/view/PaginationModelTest.kt
new file mode 100644
index 0000000..2d98b58
--- /dev/null
+++ b/pdf/pdf-viewer/src/test/kotlin/androidx/pdf/view/PaginationModelTest.kt
@@ -0,0 +1,329 @@
+/*
+ * 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.pdf.view
+
+import android.graphics.Point
+import android.graphics.Rect
+import android.os.Parcel
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import kotlin.math.max
+import kotlin.random.Random
+import org.junit.Assert.assertThrows
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@SmallTest
+@RunWith(RobolectricTestRunner::class)
+class PaginationModelTest {
+ private val NUM_PAGES = 250
+ private val PAGE_SPACING_PX = 5
+ private lateinit var paginationModel: PaginationModel
+
+ @Before
+ fun setup() {
+ paginationModel = PaginationModel(pageSpacingPx = PAGE_SPACING_PX, numPages = NUM_PAGES)
+ }
+
+ @Test
+ fun invalidConstructorArguments() {
+ assertThrows(IllegalArgumentException::class.java) {
+ PaginationModel(pageSpacingPx = -1, numPages = 10)
+ }
+ assertThrows(IllegalArgumentException::class.java) {
+ PaginationModel(pageSpacingPx = 10, numPages = -1)
+ }
+ }
+
+ @Test
+ fun propertyDefaults_withNoPagesAdded() {
+ assertThat(paginationModel.reach).isEqualTo(0)
+ assertThat(paginationModel.maxWidth).isEqualTo(0)
+ assertThat(paginationModel.totalEstimatedHeight).isEqualTo(0)
+ }
+
+ @Test
+ fun propertyValues_withSomePagesAdded() {
+ var totalHeight = 0
+ var maxWidth = 0
+ val rng = Random(System.currentTimeMillis())
+ val knownPages = NUM_PAGES / 2
+
+ for (i in 0 until knownPages) {
+ val pageSize = Point(rng.nextInt(50, 100), rng.nextInt(100, 200))
+ maxWidth = max(maxWidth, pageSize.x)
+ totalHeight += pageSize.y
+ paginationModel.addPage(i, pageSize)
+ }
+
+ assertThat(paginationModel.reach).isEqualTo(knownPages)
+ // Accumulated height of all added pages, plus 2 x page spacing * known pages
+ val totalKnownHeight = totalHeight + PAGE_SPACING_PX * (knownPages - 1)
+ // (Average height of all known pages + 2 x page spacing) * unknown pages
+ val estimatedRemainingHeight =
+ ((totalHeight / knownPages) + PAGE_SPACING_PX) * (NUM_PAGES - knownPages)
+ assertThat(paginationModel.totalEstimatedHeight)
+ .isEqualTo(estimatedRemainingHeight + totalKnownHeight)
+ assertThat(paginationModel.maxWidth).isEqualTo(maxWidth)
+ }
+
+ @Test
+ fun propertyValues_withAllPagesAdded() {
+ var totalHeight = 0
+ var maxWidth = 0
+ val rng = Random(System.currentTimeMillis())
+ for (i in 0 until NUM_PAGES) {
+ val pageSize = Point(rng.nextInt(50, 100), rng.nextInt(100, 200))
+ maxWidth = max(maxWidth, pageSize.x)
+ totalHeight += pageSize.y
+ paginationModel.addPage(i, pageSize)
+ }
+
+ assertThat(paginationModel.reach).isEqualTo(NUM_PAGES)
+ assertThat(paginationModel.totalEstimatedHeight)
+ .isEqualTo(totalHeight + PAGE_SPACING_PX * NUM_PAGES)
+ assertThat(paginationModel.maxWidth).isEqualTo(maxWidth)
+ }
+
+ @Test
+ fun rejectInvalidPage() {
+ assertThrows(IllegalArgumentException::class.java) {
+ paginationModel.addPage(0, Point(100, -1))
+ }
+ assertThrows(IllegalArgumentException::class.java) {
+ paginationModel.addPage(0, Point(-1, 100))
+ }
+ assertThrows(IllegalArgumentException::class.java) {
+ paginationModel.addPage(-1, Point(100, 200))
+ }
+ assertThrows(IllegalArgumentException::class.java) {
+ paginationModel.addPage(NUM_PAGES + 10, Point(100, 200))
+ }
+ }
+
+ @Test
+ fun getPageSize() {
+ val sizeRng = Random(System.currentTimeMillis())
+ val sizes =
+ List(size = 3) { _ -> Point(sizeRng.nextInt(100, 200), sizeRng.nextInt(100, 200)) }
+
+ sizes.forEachIndexed { pageNum, size -> paginationModel.addPage(pageNum, size) }
+
+ sizes.forEachIndexed { pageNum, size ->
+ assertThat(paginationModel.getPageSize(pageNum)).isEqualTo(size)
+ }
+ }
+
+ @Test
+ fun getPageSize_invalidPageNum() {
+ assertThrows(IllegalArgumentException::class.java) { paginationModel.getPageSize(-1) }
+ assertThrows(IllegalArgumentException::class.java) {
+ paginationModel.getPageSize(NUM_PAGES + 10)
+ }
+ }
+
+ @Test
+ fun getPagesInViewport_viewportAboveAllPages() {
+ val pageSize = Point(100, 200)
+ paginationModel.addPage(0, pageSize)
+ paginationModel.addPage(1, pageSize)
+ paginationModel.addPage(2, pageSize)
+
+ val visiblePages =
+ paginationModel.getPagesInViewport(viewportTop = -100, viewportBottom = 0)
+
+ // When the viewport is above the top of this model, we expect an empty range at the
+ // beginning of this model
+ assertThat(visiblePages.upper).isEqualTo(0)
+ assertThat(visiblePages.lower).isEqualTo(0)
+ }
+
+ @Test
+ fun getPagesInViewport_viewportBelowAllPages() {
+ val pageSize = Point(100, 200)
+ paginationModel.addPage(0, pageSize)
+ paginationModel.addPage(1, pageSize)
+ paginationModel.addPage(2, pageSize)
+ val contentBottom = pageSize.y * 3 + PAGE_SPACING_PX * 5
+
+ val visiblePages =
+ paginationModel.getPagesInViewport(
+ viewportTop = contentBottom + 10,
+ viewportBottom = contentBottom + 100
+ )
+
+ // When the viewport is below the end of this model, we expect an empty range just past
+ // the last known page
+ assertThat(visiblePages.upper).isEqualTo(3)
+ assertThat(visiblePages.lower).isEqualTo(3)
+ }
+
+ @Test
+ fun getPagesInViewport_allPagesVisible() {
+ val pageSize = Point(100, 200)
+ paginationModel.addPage(0, pageSize)
+ paginationModel.addPage(1, pageSize)
+ paginationModel.addPage(2, pageSize)
+ val contentBottom = pageSize.y * 3 + PAGE_SPACING_PX * 5
+
+ val visiblePages =
+ paginationModel.getPagesInViewport(viewportTop = 0, viewportBottom = contentBottom + 10)
+
+ assertThat(visiblePages.upper).isEqualTo(2)
+ assertThat(visiblePages.lower).isEqualTo(0)
+ }
+
+ @Test
+ fun getPagesInViewport_onePagePartiallyVisible() {
+ val pageSize = Point(100, 200)
+ paginationModel.addPage(0, pageSize)
+ paginationModel.addPage(1, pageSize)
+ paginationModel.addPage(2, pageSize)
+
+ val visiblePages =
+ paginationModel.getPagesInViewport(viewportTop = 235, viewportBottom = 335)
+
+ assertThat(visiblePages.upper).isEqualTo(1)
+ assertThat(visiblePages.lower).isEqualTo(1)
+ }
+
+ @Test
+ fun getPagesInViewport_twoPagesPartiallyVisible() {
+ val pageSize = Point(100, 200)
+ paginationModel.addPage(0, pageSize)
+ paginationModel.addPage(1, pageSize)
+ paginationModel.addPage(2, pageSize)
+
+ val visiblePages =
+ paginationModel.getPagesInViewport(viewportTop = 235, viewportBottom = 455)
+
+ assertThat(visiblePages.upper).isEqualTo(2)
+ assertThat(visiblePages.lower).isEqualTo(1)
+ }
+
+ @Test
+ fun getPagesInViewport_multiplePagesVisible() {
+ val pageSize = Point(100, 200)
+ paginationModel.addPage(0, pageSize)
+ paginationModel.addPage(1, pageSize)
+ paginationModel.addPage(2, pageSize)
+ paginationModel.addPage(3, pageSize)
+
+ val visiblePages =
+ paginationModel.getPagesInViewport(viewportTop = 210, viewportBottom = 840)
+
+ assertThat(visiblePages.upper).isEqualTo(3)
+ assertThat(visiblePages.lower).isEqualTo(1)
+ }
+
+ /**
+ * Add 3 pages of differing sizes to the model. Set the visible area to cover the whole model.
+ * Largest page should span (0, model width). Smaller pages should be placed in the middle of
+ * the model horizontally. Pages should get consistent vertical spacing.
+ */
+ @Test
+ fun getPageLocation_viewportCoversAllPages() {
+ val smallSize = Point(200, 100)
+ val mediumSize = Point(400, 200)
+ val largeSize = Point(800, 400)
+ paginationModel.addPage(0, smallSize)
+ paginationModel.addPage(1, mediumSize)
+ paginationModel.addPage(2, largeSize)
+ val viewport = Rect(0, 0, 800, 800)
+
+ val expectedSmLocation = Rect(300, 0, 500, 100)
+ assertThat(paginationModel.getPageLocation(0, viewport)).isEqualTo(expectedSmLocation)
+
+ val expectedMdLocation = Rect(200, 100 + PAGE_SPACING_PX, 600, 300 + PAGE_SPACING_PX)
+ assertThat(paginationModel.getPageLocation(1, viewport)).isEqualTo(expectedMdLocation)
+
+ val expectedLgLocation =
+ Rect(0, 300 + (PAGE_SPACING_PX * 2), 800, 700 + (PAGE_SPACING_PX * 2))
+ assertThat(paginationModel.getPageLocation(2, viewport)).isEqualTo(expectedLgLocation)
+ }
+
+ /**
+ * Add 3 pages of differing sizes to the model. Set the visible area to the bottom left corner
+ * of this model. Page 0 is not visible, page 1 should shift left to fit the maximum amount of
+ * content on-screen, and page 2 should span [0, model width]
+ */
+ @Test
+ fun getPageLocation_shiftPagesLargerThanViewportLeft() {
+ val smallSize = Point(200, 100)
+ val mediumSize = Point(400, 200)
+ val largeSize = Point(800, 400)
+ paginationModel.addPage(0, smallSize)
+ paginationModel.addPage(1, mediumSize)
+ paginationModel.addPage(2, largeSize)
+ // A 300x200 section in the bottom-left corner of this model
+ val viewport = Rect(0, 250, 200, 800)
+
+ val expectedMdLocation = Rect(0, 100 + PAGE_SPACING_PX, 400, 300 + PAGE_SPACING_PX)
+ assertThat(paginationModel.getPageLocation(1, viewport)).isEqualTo(expectedMdLocation)
+
+ val expectedLgLocation =
+ Rect(0, 300 + (PAGE_SPACING_PX * 2), 800, 700 + (PAGE_SPACING_PX * 2))
+ assertThat(paginationModel.getPageLocation(2, viewport)).isEqualTo(expectedLgLocation)
+ }
+
+ /**
+ * Add 3 pages of differing sizes to the model. Set the visible area to the bottom right corner
+ * of this model. Page 0 is not visible, page 1 should shift right to fit the maximum amount of
+ * content on-screen, and page 2 should span [0, model width]
+ */
+ @Test
+ fun getPageLocation_shiftPagesLargerThanViewportRight() {
+ val smallSize = Point(200, 100)
+ val mediumSize = Point(400, 200)
+ val largeSize = Point(800, 400)
+ paginationModel.addPage(0, smallSize)
+ paginationModel.addPage(1, mediumSize)
+ paginationModel.addPage(2, largeSize)
+ // A 300x200 section in the bottom-right corner of this model
+ val viewport = Rect(600, 250, 800, 800)
+
+ val expectedMdLocation = Rect(400, 100 + PAGE_SPACING_PX, 800, 300 + PAGE_SPACING_PX)
+ assertThat(paginationModel.getPageLocation(1, viewport)).isEqualTo(expectedMdLocation)
+
+ val expectedLgLocation =
+ Rect(0, 300 + (PAGE_SPACING_PX * 2), 800, 700 + (PAGE_SPACING_PX * 2))
+ assertThat(paginationModel.getPageLocation(2, viewport)).isEqualTo(expectedLgLocation)
+ }
+
+ @Test
+ fun parcelable() {
+ val sizeRng = Random(System.currentTimeMillis())
+ val sizes =
+ List(size = 3) { _ -> Point(sizeRng.nextInt(100, 200), sizeRng.nextInt(100, 200)) }
+ sizes.forEachIndexed { pageNum, size -> paginationModel.addPage(pageNum, size) }
+
+ val parcel = Parcel.obtain()
+ paginationModel.writeToParcel(parcel, 0)
+ parcel.setDataPosition(0)
+ val newPaginationModel = PaginationModel.CREATOR.createFromParcel(parcel)
+
+ assertThat(newPaginationModel.totalEstimatedHeight)
+ .isEqualTo(paginationModel.totalEstimatedHeight)
+ assertThat(newPaginationModel.reach).isEqualTo(paginationModel.reach)
+ assertThat(newPaginationModel.maxWidth).isEqualTo(paginationModel.maxWidth)
+
+ for (i in 0 until paginationModel.reach) {
+ assertThat(newPaginationModel.getPageSize(i)).isEqualTo(paginationModel.getPageSize(i))
+ }
+ }
+}
diff --git a/privacysandbox/ui/ui-provider/src/androidTest/kotlin/androidx/privacysandbox/ui/provider/test/BinderAdapterDelegateTest.kt b/privacysandbox/ui/ui-provider/src/androidTest/kotlin/androidx/privacysandbox/ui/provider/test/BinderAdapterDelegateTest.kt
index 8dcfa86..caec996 100644
--- a/privacysandbox/ui/ui-provider/src/androidTest/kotlin/androidx/privacysandbox/ui/provider/test/BinderAdapterDelegateTest.kt
+++ b/privacysandbox/ui/ui-provider/src/androidTest/kotlin/androidx/privacysandbox/ui/provider/test/BinderAdapterDelegateTest.kt
@@ -23,8 +23,8 @@
import android.view.Display
import android.view.MotionEvent
import android.view.SurfaceControlViewHost
-import android.view.SurfaceView
import android.view.View
+import android.widget.LinearLayout
import androidx.privacysandbox.ui.provider.TouchFocusTransferringView
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
@@ -32,6 +32,7 @@
import androidx.test.espresso.action.ViewActions.swipeLeft
import androidx.test.espresso.action.ViewActions.swipeUp
import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.espresso.matcher.ViewMatchers.withParent
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
@@ -45,7 +46,6 @@
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import org.junit.Before
-import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -58,7 +58,7 @@
const val TIMEOUT_MILLIS: Long = 2000
const val WIDTH = 500
const val HEIGHT = 500
- const val SURFACE_VIEW_RES = "androidx.privacysandbox.ui.provider.test:id/surface_view"
+ const val MAIN_LAYOUT_RES = "androidx.privacysandbox.ui.provider.test:id/main_layout"
}
@get:Rule val activityScenarioRule = ActivityScenarioRule(MainActivity::class.java)
@@ -70,8 +70,7 @@
val context = InstrumentationRegistry.getInstrumentation().targetContext
val activity = activityScenarioRule.withActivity { this }
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
- activity.runOnUiThread {
- val surfaceView = activity.findViewById<SurfaceView>(R.id.surface_view)
+ activityScenarioRule.withActivity {
val surfaceControlViewHost =
GestureTransferringSurfaceControlViewHost(
activity,
@@ -82,48 +81,49 @@
val touchFocusTransferringView =
TouchFocusTransferringView(context, surfaceControlViewHost)
touchFocusTransferringView.addView(TestView(context))
- surfaceControlViewHost.setView(touchFocusTransferringView, WIDTH, HEIGHT)
- surfaceView.setChildSurfacePackage(surfaceControlViewHost.surfacePackage!!)
- surfaceView.setZOrderOnTop(true)
+ activity
+ .findViewById<LinearLayout>(R.id.main_layout)
+ .addView(touchFocusTransferringView, WIDTH, HEIGHT)
}
}
- @Ignore // b/328282434
@Test
fun touchFocusTransferredForSwipeUp() {
- onView(withId(R.id.surface_view)).perform(swipeUp())
+ onView(withParent(withId(R.id.main_layout))).perform(swipeUp())
assertThat(transferTouchFocusLatch.await(TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)).isTrue()
}
@Test
fun touchFocusNotTransferredForSwipeLeft() {
- onView(withId(R.id.surface_view)).perform(swipeLeft())
+ onView(withParent(withId(R.id.main_layout))).perform(swipeLeft())
assertThat(transferTouchFocusLatch.await(TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)).isFalse()
}
@Test
fun touchFocusNotTransferredForSlowSwipeLeft() {
- onView(withId(R.id.surface_view)).perform(slowSwipeLeft())
+ onView(withParent(withId(R.id.main_layout))).perform(slowSwipeLeft())
assertThat(transferTouchFocusLatch.await(TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)).isFalse()
}
@Test
fun touchFocusNotTransferredForClicks() {
- onView(withId(R.id.surface_view)).perform(click())
+ onView(withParent(withId(R.id.main_layout))).perform(click())
assertThat(transferTouchFocusLatch.await(TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)).isFalse()
}
@Test
fun touchFocusTransferredForFlingForward() {
- val scrollView = UiScrollable(UiSelector().resourceId(SURFACE_VIEW_RES))
- scrollView.flingForward()
+ val parentSelector = UiSelector().resourceId(MAIN_LAYOUT_RES)
+ val testView = UiScrollable(parentSelector.childSelector(UiSelector().index(0)))
+ testView.flingForward()
assertThat(transferTouchFocusLatch.await(TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)).isTrue()
}
@Test
fun touchFocusTransferredForFlingBackward() {
- val scrollView = UiScrollable(UiSelector().resourceId(SURFACE_VIEW_RES))
- scrollView.flingBackward()
+ val parentSelector = UiSelector().resourceId(MAIN_LAYOUT_RES)
+ val testView = UiScrollable(parentSelector.childSelector(UiSelector().index(0)))
+ testView.flingBackward()
assertThat(transferTouchFocusLatch.await(TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)).isTrue()
}
diff --git a/privacysandbox/ui/ui-provider/src/androidTest/res/layout/activity_main.xml b/privacysandbox/ui/ui-provider/src/androidTest/res/layout/activity_main.xml
index 74929d1..ddb420c 100644
--- a/privacysandbox/ui/ui-provider/src/androidTest/res/layout/activity_main.xml
+++ b/privacysandbox/ui/ui-provider/src/androidTest/res/layout/activity_main.xml
@@ -19,8 +19,4 @@
android:layout_height="match_parent"
android:orientation="vertical"
android:id="@+id/main_layout">
- <SurfaceView
- android:layout_width="500px"
- android:layout_height="500px"
- android:id="@+id/surface_view"/>
</LinearLayout>
\ No newline at end of file
diff --git a/recyclerview/recyclerview-benchmark/src/androidTest/java/androidx/recyclerview/benchmark/ScrollBenchmark.kt b/recyclerview/recyclerview-benchmark/src/androidTest/java/androidx/recyclerview/benchmark/ScrollBenchmark.kt
index 0234d42..e87711c 100644
--- a/recyclerview/recyclerview-benchmark/src/androidTest/java/androidx/recyclerview/benchmark/ScrollBenchmark.kt
+++ b/recyclerview/recyclerview-benchmark/src/androidTest/java/androidx/recyclerview/benchmark/ScrollBenchmark.kt
@@ -22,10 +22,9 @@
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.benchmark.junit4.BenchmarkRule
-import androidx.benchmark.junit4.measureRepeated
+import androidx.benchmark.junit4.measureRepeatedOnMainThread
import androidx.recyclerview.benchmark.test.R
import androidx.recyclerview.widget.RecyclerView
-import androidx.test.annotation.UiThreadTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.junit.Before
@@ -58,23 +57,21 @@
}
}
- @UiThreadTest
@Test
fun offset() {
val rv = activityRule.activity.recyclerView
var offset = 10
- benchmarkRule.measureRepeated {
+ benchmarkRule.measureRepeatedOnMainThread {
// keep scrolling up and down - no new item should be revealed
rv.scrollBy(0, offset)
offset *= -1
}
}
- @UiThreadTest
@Test
fun bindOffset() {
val rv = activityRule.activity.recyclerView
- benchmarkRule.measureRepeated {
+ benchmarkRule.measureRepeatedOnMainThread {
// each scroll should reveal a new item
rv.scrollBy(0, 100)
}
@@ -88,7 +85,6 @@
}
}
- @UiThreadTest
@Test
fun createBindOffset() {
forceInflate {
@@ -97,24 +93,22 @@
}
}
val rv = activityRule.activity.recyclerView
- benchmarkRule.measureRepeated {
+ benchmarkRule.measureRepeatedOnMainThread {
// each scroll should reveal a new item that must be inflated
rv.scrollBy(0, 100)
}
}
- @UiThreadTest
@Test
fun inflateBindOffset() {
forceInflate()
val rv = activityRule.activity.recyclerView
- benchmarkRule.measureRepeated {
+ benchmarkRule.measureRepeatedOnMainThread {
// each scroll should reveal a new item that must be inflated
rv.scrollBy(0, 100)
}
}
- @UiThreadTest
@Test
fun complexItems() {
@@ -139,7 +133,7 @@
}
val rv = activityRule.activity.recyclerView
- benchmarkRule.measureRepeated {
+ benchmarkRule.measureRepeatedOnMainThread {
// each scroll should reveal a new item that must be inflated
rv.scrollBy(0, 500)
}
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/XParameterSpec.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/XParameterSpec.kt
index c6021ea..a4dc87c 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/XParameterSpec.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/XParameterSpec.kt
@@ -22,11 +22,12 @@
import androidx.room.compiler.codegen.java.NULLABLE_ANNOTATION
import androidx.room.compiler.codegen.kotlin.KotlinParameterSpec
import androidx.room.compiler.processing.XNullability
-import javax.lang.model.element.Modifier
interface XParameterSpec {
val name: String
+ val type: XTypeName
+
interface Builder {
fun addAnnotation(annotation: XAnnotationSpec): Builder
@@ -38,19 +39,18 @@
}
companion object {
- @JvmStatic
- fun of(name: String, typeName: XTypeName) = builder(XName.of(name), typeName).build()
-
- @JvmStatic fun of(name: XName, typeName: XTypeName) = builder(name, typeName).build()
+ @JvmStatic fun of(name: String, typeName: XTypeName) = builder(name, typeName).build()
@JvmStatic
- fun builder(name: String, typeName: XTypeName) = builder(XName.of(name), typeName)
-
- @JvmStatic
- fun builder(name: XName, typeName: XTypeName): Builder {
+ fun builder(name: String, typeName: XTypeName): Builder {
return XParameterSpecImpl.Builder(
+ name,
+ typeName,
JavaParameterSpec.Builder(
- JParameterSpec.builder(typeName.java, name.java, Modifier.FINAL).apply {
+ name,
+ typeName,
+ JParameterSpec.builder(typeName.java, name).apply {
+ addModifiers(JModifier.FINAL)
// Adding nullability annotation to primitive parameters is redundant as
// primitives can never be null.
if (!typeName.isPrimitive) {
@@ -62,7 +62,11 @@
}
}
),
- KotlinParameterSpec.Builder(KParameterSpec.builder(name.kotlin, typeName.kotlin))
+ KotlinParameterSpec.Builder(
+ name,
+ typeName,
+ KParameterSpec.builder(name, typeName.kotlin)
+ )
)
}
}
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/XPropertySpec.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/XPropertySpec.kt
index fb991b4..bef31732 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/XPropertySpec.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/XPropertySpec.kt
@@ -29,6 +29,8 @@
val name: String
+ val type: XTypeName
+
interface Builder {
fun addAnnotation(annotation: XAnnotationSpec): Builder
@@ -66,10 +68,15 @@
isMutable: Boolean = false,
): Builder =
XPropertySpecImpl.Builder(
+ name,
+ typeName,
JavaPropertySpec.Builder(
+ name,
+ typeName,
JPropertySpec.builder(typeName.java, name).apply {
val visibilityModifier = visibility.toJavaVisibilityModifier()
- // TODO(b/247242374) Add nullability annotations for non-private fields
+ addModifiers(visibilityModifier)
+ // TODO(b/247242374) Add nullability annotations for private fields
if (visibilityModifier != JModifier.PRIVATE) {
if (typeName.nullability == XNullability.NULLABLE) {
addAnnotation(NULLABLE_ANNOTATION)
@@ -77,13 +84,14 @@
addAnnotation(NONNULL_ANNOTATION)
}
}
- addModifiers(visibilityModifier)
if (!isMutable) {
addModifiers(JModifier.FINAL)
}
}
),
KotlinPropertySpec.Builder(
+ name,
+ typeName,
KPropertySpec.builder(name, typeName.kotlin).apply {
mutable(isMutable)
addModifiers(visibility.toKotlinVisibilityModifier())
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/compat/XConverters.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/compat/XConverters.kt
index f858dac..e506c6c 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/compat/XConverters.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/compat/XConverters.kt
@@ -24,9 +24,7 @@
import androidx.room.compiler.codegen.JFileSpecBuilder
import androidx.room.compiler.codegen.JFunSpec
import androidx.room.compiler.codegen.JFunSpecBuilder
-import androidx.room.compiler.codegen.JParameterSpec
import androidx.room.compiler.codegen.JParameterSpecBuilder
-import androidx.room.compiler.codegen.JPropertySpec
import androidx.room.compiler.codegen.JPropertySpecBuilder
import androidx.room.compiler.codegen.JTypeSpecBuilder
import androidx.room.compiler.codegen.KAnnotationSpecBuilder
@@ -36,9 +34,7 @@
import androidx.room.compiler.codegen.KFileSpecBuilder
import androidx.room.compiler.codegen.KFunSpec
import androidx.room.compiler.codegen.KFunSpecBuilder
-import androidx.room.compiler.codegen.KParameterSpec
import androidx.room.compiler.codegen.KParameterSpecBuilder
-import androidx.room.compiler.codegen.KPropertySpec
import androidx.room.compiler.codegen.KPropertySpecBuilder
import androidx.room.compiler.codegen.KTypeSpecBuilder
import androidx.room.compiler.codegen.XAnnotationSpec
@@ -391,34 +387,6 @@
)
@JvmStatic
- fun toXPoet(jParameterSpec: JParameterSpec, kParameterSpec: KParameterSpec): XParameterSpec =
- XParameterSpecImpl(JavaParameterSpec(jParameterSpec), KotlinParameterSpec(kParameterSpec))
-
- @JvmStatic
- fun toXPoet(
- jParameterSpecBuilder: JParameterSpecBuilder,
- kParameterSpecBuilder: KParameterSpecBuilder
- ): XParameterSpec.Builder =
- XParameterSpecImpl.Builder(
- JavaParameterSpec.Builder(jParameterSpecBuilder),
- KotlinParameterSpec.Builder(kParameterSpecBuilder)
- )
-
- @JvmStatic
- fun toXPoet(jPropertySpec: JPropertySpec, kPropertySpec: KPropertySpec): XPropertySpec =
- XPropertySpecImpl(JavaPropertySpec(jPropertySpec), KotlinPropertySpec(kPropertySpec))
-
- @JvmStatic
- fun toXPoet(
- jPropertySpecBuilder: JPropertySpecBuilder,
- kPropertySpecBuilder: KPropertySpecBuilder
- ): XPropertySpec.Builder =
- XPropertySpecImpl.Builder(
- JavaPropertySpec.Builder(jPropertySpecBuilder),
- KotlinPropertySpec.Builder(kPropertySpecBuilder)
- )
-
- @JvmStatic
fun toXPoet(jTypeSpec: JTypeSpec, kTypeSpec: KTypeSpec): XTypeSpec =
XTypeSpecImpl(JavaTypeSpec(jTypeSpec), KotlinTypeSpec(kTypeSpec))
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/impl/XParameterSpecImpl.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/impl/XParameterSpecImpl.kt
index ff65bc9..dfb58c4 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/impl/XParameterSpecImpl.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/impl/XParameterSpecImpl.kt
@@ -19,22 +19,22 @@
import androidx.room.compiler.codegen.XAnnotationSpec
import androidx.room.compiler.codegen.XParameterSpec
import androidx.room.compiler.codegen.XSpec
+import androidx.room.compiler.codegen.XTypeName
import androidx.room.compiler.codegen.java.JavaParameterSpec
import androidx.room.compiler.codegen.kotlin.KotlinParameterSpec
internal class XParameterSpecImpl(
- val java: JavaParameterSpec,
- val kotlin: KotlinParameterSpec,
+ override val name: String,
+ override val type: XTypeName,
+ internal val java: JavaParameterSpec,
+ internal val kotlin: KotlinParameterSpec,
) : XSpec(), XParameterSpec {
- override val name: String by lazy {
- check(java.name == kotlin.name)
- java.name
- }
-
internal class Builder(
- val java: JavaParameterSpec.Builder,
- val kotlin: KotlinParameterSpec.Builder,
+ private val name: String,
+ private val type: XTypeName,
+ internal val java: JavaParameterSpec.Builder,
+ internal val kotlin: KotlinParameterSpec.Builder
) : XSpec.Builder(), XParameterSpec.Builder {
private val delegates: List<XParameterSpec.Builder> = listOf(java, kotlin)
@@ -42,6 +42,6 @@
delegates.forEach { it.addAnnotation(annotation) }
}
- override fun build() = XParameterSpecImpl(java.build(), kotlin.build())
+ override fun build() = XParameterSpecImpl(name, type, java.build(), kotlin.build())
}
}
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/impl/XPropertySpecImpl.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/impl/XPropertySpecImpl.kt
index 45c4e65..da1f0dc 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/impl/XPropertySpecImpl.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/impl/XPropertySpecImpl.kt
@@ -20,22 +20,22 @@
import androidx.room.compiler.codegen.XCodeBlock
import androidx.room.compiler.codegen.XPropertySpec
import androidx.room.compiler.codegen.XSpec
+import androidx.room.compiler.codegen.XTypeName
import androidx.room.compiler.codegen.java.JavaPropertySpec
import androidx.room.compiler.codegen.kotlin.KotlinPropertySpec
internal class XPropertySpecImpl(
- val java: JavaPropertySpec,
- val kotlin: KotlinPropertySpec,
+ override val name: String,
+ override val type: XTypeName,
+ internal val java: JavaPropertySpec,
+ internal val kotlin: KotlinPropertySpec,
) : XSpec(), XPropertySpec {
- override val name: String by lazy {
- check(java.name == kotlin.name)
- java.name
- }
-
internal class Builder(
- val java: JavaPropertySpec.Builder,
- val kotlin: KotlinPropertySpec.Builder,
+ private val name: String,
+ private val type: XTypeName,
+ internal val java: JavaPropertySpec.Builder,
+ internal val kotlin: KotlinPropertySpec.Builder
) : XSpec.Builder(), XPropertySpec.Builder {
private val delegates: List<XPropertySpec.Builder> = listOf(java, kotlin)
@@ -47,6 +47,6 @@
delegates.forEach { it.initializer(initExpr) }
}
- override fun build() = XPropertySpecImpl(java.build(), kotlin.build())
+ override fun build() = XPropertySpecImpl(name, type, java.build(), kotlin.build())
}
}
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/java/JavaParameterSpec.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/java/JavaParameterSpec.kt
index e240a0d..3b7c6c5 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/java/JavaParameterSpec.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/java/JavaParameterSpec.kt
@@ -21,18 +21,26 @@
import androidx.room.compiler.codegen.XAnnotationSpec
import androidx.room.compiler.codegen.XParameterSpec
import androidx.room.compiler.codegen.XSpec
+import androidx.room.compiler.codegen.XTypeName
import androidx.room.compiler.codegen.impl.XAnnotationSpecImpl
-internal class JavaParameterSpec(internal val actual: JParameterSpec) : XSpec(), XParameterSpec {
- override val name = actual.name
+internal class JavaParameterSpec(
+ override val name: String,
+ override val type: XTypeName,
+ internal val actual: JParameterSpec
+) : XSpec(), XParameterSpec {
- internal class Builder(internal val actual: JParameterSpecBuilder) :
- XSpec.Builder(), XParameterSpec.Builder {
+ internal class Builder(
+ private val name: String,
+ private val type: XTypeName,
+ internal val actual: JParameterSpecBuilder = JParameterSpec.builder(type.java, name)
+ ) : XSpec.Builder(), XParameterSpec.Builder {
+
override fun addAnnotation(annotation: XAnnotationSpec) = apply {
require(annotation is XAnnotationSpecImpl)
actual.addAnnotation(annotation.java.actual)
}
- override fun build() = JavaParameterSpec(actual.build())
+ override fun build() = JavaParameterSpec(name, type, actual.build())
}
}
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/java/JavaPropertySpec.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/java/JavaPropertySpec.kt
index cb739b9..aa21856 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/java/JavaPropertySpec.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/java/JavaPropertySpec.kt
@@ -22,15 +22,21 @@
import androidx.room.compiler.codegen.XCodeBlock
import androidx.room.compiler.codegen.XPropertySpec
import androidx.room.compiler.codegen.XSpec
+import androidx.room.compiler.codegen.XTypeName
import androidx.room.compiler.codegen.impl.XAnnotationSpecImpl
import androidx.room.compiler.codegen.impl.XCodeBlockImpl
-internal class JavaPropertySpec(internal val actual: JPropertySpec) : XSpec(), XPropertySpec {
+internal class JavaPropertySpec(
+ override val name: String,
+ override val type: XTypeName,
+ internal val actual: JPropertySpec
+) : XSpec(), XPropertySpec {
- override val name = actual.name
-
- internal class Builder(internal val actual: JPropertySpecBuilder) :
- XSpec.Builder(), XPropertySpec.Builder {
+ internal class Builder(
+ private val name: String,
+ private val type: XTypeName,
+ internal val actual: JPropertySpecBuilder
+ ) : XSpec.Builder(), XPropertySpec.Builder {
override fun addAnnotation(annotation: XAnnotationSpec) = apply {
require(annotation is XAnnotationSpecImpl)
@@ -42,6 +48,6 @@
actual.initializer(initExpr.java.actual)
}
- override fun build() = JavaPropertySpec(actual.build())
+ override fun build() = JavaPropertySpec(name, type, actual.build())
}
}
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/java/JavaTypeSpec.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/java/JavaTypeSpec.kt
index fe1c35a..8e6ae0e 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/java/JavaTypeSpec.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/java/JavaTypeSpec.kt
@@ -36,6 +36,7 @@
import javax.lang.model.element.Modifier
internal class JavaTypeSpec(internal val actual: JTypeSpec) : XSpec(), XTypeSpec {
+
override val name = actual.name?.let { XName.of(it) }
internal class Builder(internal val actual: JTypeSpecBuilder) :
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/kotlin/KotlinFunSpec.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/kotlin/KotlinFunSpec.kt
index bd628dc..9d6a905 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/kotlin/KotlinFunSpec.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/kotlin/KotlinFunSpec.kt
@@ -33,6 +33,7 @@
import com.squareup.kotlinpoet.javapoet.KTypeVariableName
internal class KotlinFunSpec(internal val actual: KFunSpec) : XSpec(), XFunSpec {
+
override val name: XName = XName.of(actual.name)
internal class Builder(internal val actual: KFunSpecBuilder) :
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/kotlin/KotlinParameterSpec.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/kotlin/KotlinParameterSpec.kt
index 3ca970a..3d39a48 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/kotlin/KotlinParameterSpec.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/kotlin/KotlinParameterSpec.kt
@@ -21,18 +21,26 @@
import androidx.room.compiler.codegen.XAnnotationSpec
import androidx.room.compiler.codegen.XParameterSpec
import androidx.room.compiler.codegen.XSpec
+import androidx.room.compiler.codegen.XTypeName
import androidx.room.compiler.codegen.impl.XAnnotationSpecImpl
-internal class KotlinParameterSpec(internal val actual: KParameterSpec) : XSpec(), XParameterSpec {
- override val name = actual.name
+internal class KotlinParameterSpec(
+ override val name: String,
+ override val type: XTypeName,
+ internal val actual: KParameterSpec
+) : XSpec(), XParameterSpec {
- internal class Builder(internal val actual: KParameterSpecBuilder) :
- XSpec.Builder(), XParameterSpec.Builder {
+ internal class Builder(
+ private val name: String,
+ private val type: XTypeName,
+ internal val actual: KParameterSpecBuilder
+ ) : XSpec.Builder(), XParameterSpec.Builder {
+
override fun addAnnotation(annotation: XAnnotationSpec) = apply {
require(annotation is XAnnotationSpecImpl)
actual.addAnnotation(annotation.kotlin.actual)
}
- override fun build() = KotlinParameterSpec(actual.build())
+ override fun build() = KotlinParameterSpec(name, type, actual.build())
}
}
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/kotlin/KotlinPropertySpec.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/kotlin/KotlinPropertySpec.kt
index 1ffd824..1e5a3dd 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/kotlin/KotlinPropertySpec.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/kotlin/KotlinPropertySpec.kt
@@ -22,15 +22,21 @@
import androidx.room.compiler.codegen.XCodeBlock
import androidx.room.compiler.codegen.XPropertySpec
import androidx.room.compiler.codegen.XSpec
+import androidx.room.compiler.codegen.XTypeName
import androidx.room.compiler.codegen.impl.XAnnotationSpecImpl
import androidx.room.compiler.codegen.impl.XCodeBlockImpl
-internal class KotlinPropertySpec(internal val actual: KPropertySpec) : XSpec(), XPropertySpec {
+internal class KotlinPropertySpec(
+ override val name: String,
+ override val type: XTypeName,
+ internal val actual: KPropertySpec
+) : XSpec(), XPropertySpec {
- override val name: String = actual.name
-
- internal class Builder(internal val actual: KPropertySpecBuilder) :
- XSpec.Builder(), XPropertySpec.Builder {
+ internal class Builder(
+ private val name: String,
+ private val type: XTypeName,
+ internal val actual: KPropertySpecBuilder
+ ) : XSpec.Builder(), XPropertySpec.Builder {
override fun addAnnotation(annotation: XAnnotationSpec) = apply {
require(annotation is XAnnotationSpecImpl)
@@ -42,6 +48,6 @@
actual.initializer(initExpr.kotlin.actual)
}
- override fun build() = KotlinPropertySpec(actual.build())
+ override fun build() = KotlinPropertySpec(name, type, actual.build())
}
}
diff --git a/savedstate/savedstate/api/current.txt b/savedstate/savedstate/api/current.txt
index 3e3d6f9..c1e51e3 100644
--- a/savedstate/savedstate/api/current.txt
+++ b/savedstate/savedstate/api/current.txt
@@ -11,6 +11,8 @@
method public inline operator boolean contains(String key);
method public boolean contentDeepEquals(android.os.Bundle other);
method public int contentDeepHashCode();
+ method public inline android.os.IBinder getBinder(String key);
+ method public inline android.os.IBinder getBinderOrElse(String key, kotlin.jvm.functions.Function0<? extends android.os.IBinder> defaultValue);
method public inline boolean getBoolean(String key);
method public inline boolean[] getBooleanArray(String key);
method public inline boolean[] getBooleanArrayOrElse(String key, kotlin.jvm.functions.Function0<boolean[]> defaultValue);
@@ -19,6 +21,12 @@
method public inline char[] getCharArray(String key);
method public inline char[] getCharArrayOrElse(String key, kotlin.jvm.functions.Function0<char[]> defaultValue);
method public inline char getCharOrElse(String key, kotlin.jvm.functions.Function0<java.lang.Character> defaultValue);
+ method public inline CharSequence getCharSequence(String key);
+ method public inline CharSequence[] getCharSequenceArray(String key);
+ method public inline CharSequence[] getCharSequenceArrayOrElse(String key, kotlin.jvm.functions.Function0<java.lang.CharSequence[]> defaultValue);
+ method public inline java.util.List<java.lang.CharSequence> getCharSequenceList(String key);
+ method public inline java.util.List<java.lang.CharSequence> getCharSequenceListOrElse(String key, kotlin.jvm.functions.Function0<? extends java.util.List<? extends java.lang.CharSequence>> defaultValue);
+ method public inline CharSequence getCharSequenceOrElse(String key, kotlin.jvm.functions.Function0<? extends java.lang.CharSequence> defaultValue);
method public inline double getDouble(String key);
method public inline double[] getDoubleArray(String key);
method public inline double[] getDoubleArrayOrElse(String key, kotlin.jvm.functions.Function0<double[]> defaultValue);
@@ -38,11 +46,21 @@
method public inline long[] getLongArrayOrElse(String key, kotlin.jvm.functions.Function0<long[]> defaultValue);
method public inline long getLongOrElse(String key, kotlin.jvm.functions.Function0<java.lang.Long> defaultValue);
method public inline <reified T extends android.os.Parcelable> T getParcelable(String key);
+ method public inline <reified T extends android.os.Parcelable> T[] getParcelableArray(String key);
+ method public inline <reified T extends android.os.Parcelable> T[] getParcelableArrayOrElse(String key, kotlin.jvm.functions.Function0<T[]> defaultValue);
method public inline <reified T extends android.os.Parcelable> java.util.List<T> getParcelableList(String key);
method public inline <reified T extends android.os.Parcelable> java.util.List<T> getParcelableListOrElse(String key, kotlin.jvm.functions.Function0<? extends java.util.List<? extends T>> defaultValue);
method public inline <reified T extends android.os.Parcelable> T getParcelableOrElse(String key, kotlin.jvm.functions.Function0<? extends T> defaultValue);
method public inline android.os.Bundle getSavedState(String key);
method public inline android.os.Bundle getSavedStateOrElse(String key, kotlin.jvm.functions.Function0<android.os.Bundle> defaultValue);
+ method public inline <reified T extends java.io.Serializable> T getSerializable(String key);
+ method public inline <reified T extends java.io.Serializable> T getSerializableOrElse(String key, kotlin.jvm.functions.Function0<? extends T> defaultValue);
+ method public inline android.util.Size getSize(String key);
+ method public inline android.util.SizeF getSizeF(String key);
+ method public inline android.util.SizeF getSizeFOrElse(String key, kotlin.jvm.functions.Function0<android.util.SizeF> defaultValue);
+ method public inline android.util.Size getSizeOrElse(String key, kotlin.jvm.functions.Function0<android.util.Size> defaultValue);
+ method public inline <reified T extends android.os.Parcelable> android.util.SparseArray<T> getSparseParcelableArray(String key);
+ method public inline <reified T extends android.os.Parcelable> android.util.SparseArray<T> getSparseParcelableArrayOrElse(String key, kotlin.jvm.functions.Function0<? extends android.util.SparseArray<T>> defaultValue);
method public inline String getString(String key);
method public inline String[] getStringArray(String key);
method public inline String[] getStringArrayOrElse(String key, kotlin.jvm.functions.Function0<java.lang.String[]> defaultValue);
@@ -95,10 +113,14 @@
@kotlin.jvm.JvmInline public final value class SavedStateWriter {
method public inline void clear();
method public inline void putAll(android.os.Bundle values);
+ method public inline void putBinder(String key, android.os.IBinder value);
method public inline void putBoolean(String key, boolean value);
method public inline void putBooleanArray(String key, boolean[] values);
method public inline void putChar(String key, char value);
method public inline void putCharArray(String key, char[] values);
+ method public inline void putCharSequence(String key, CharSequence value);
+ method public inline void putCharSequenceArray(String key, CharSequence[] values);
+ method public inline void putCharSequenceList(String key, java.util.List<? extends java.lang.CharSequence> values);
method public inline void putDouble(String key, double value);
method public inline void putDoubleArray(String key, double[] values);
method public inline void putFloat(String key, float value);
@@ -110,8 +132,13 @@
method public inline void putLongArray(String key, long[] values);
method public inline void putNull(String key);
method public inline <reified T extends android.os.Parcelable> void putParcelable(String key, T value);
+ method public inline <reified T extends android.os.Parcelable> void putParcelableArray(String key, T[] values);
method public inline <reified T extends android.os.Parcelable> void putParcelableList(String key, java.util.List<? extends T> values);
method public inline void putSavedState(String key, android.os.Bundle value);
+ method public inline <reified T extends java.io.Serializable> void putSerializable(String key, T value);
+ method public inline void putSize(String key, android.util.Size value);
+ method public inline void putSizeF(String key, android.util.SizeF value);
+ method public inline <reified T extends android.os.Parcelable> void putSparseParcelableArray(String key, android.util.SparseArray<T> values);
method public inline void putString(String key, String value);
method public inline void putStringArray(String key, String[] values);
method public inline void putStringList(String key, java.util.List<java.lang.String> values);
diff --git a/savedstate/savedstate/api/restricted_current.txt b/savedstate/savedstate/api/restricted_current.txt
index 94ea74b..eb9ae61 100644
--- a/savedstate/savedstate/api/restricted_current.txt
+++ b/savedstate/savedstate/api/restricted_current.txt
@@ -12,6 +12,8 @@
method public inline operator boolean contains(String key);
method public boolean contentDeepEquals(android.os.Bundle other);
method public int contentDeepHashCode();
+ method public inline android.os.IBinder getBinder(String key);
+ method public inline android.os.IBinder getBinderOrElse(String key, kotlin.jvm.functions.Function0<? extends android.os.IBinder> defaultValue);
method public inline boolean getBoolean(String key);
method public inline boolean[] getBooleanArray(String key);
method public inline boolean[] getBooleanArrayOrElse(String key, kotlin.jvm.functions.Function0<boolean[]> defaultValue);
@@ -20,6 +22,12 @@
method public inline char[] getCharArray(String key);
method public inline char[] getCharArrayOrElse(String key, kotlin.jvm.functions.Function0<char[]> defaultValue);
method public inline char getCharOrElse(String key, kotlin.jvm.functions.Function0<java.lang.Character> defaultValue);
+ method public inline CharSequence getCharSequence(String key);
+ method public inline CharSequence[] getCharSequenceArray(String key);
+ method public inline CharSequence[] getCharSequenceArrayOrElse(String key, kotlin.jvm.functions.Function0<java.lang.CharSequence[]> defaultValue);
+ method public inline java.util.List<java.lang.CharSequence> getCharSequenceList(String key);
+ method public inline java.util.List<java.lang.CharSequence> getCharSequenceListOrElse(String key, kotlin.jvm.functions.Function0<? extends java.util.List<? extends java.lang.CharSequence>> defaultValue);
+ method public inline CharSequence getCharSequenceOrElse(String key, kotlin.jvm.functions.Function0<? extends java.lang.CharSequence> defaultValue);
method public inline double getDouble(String key);
method public inline double[] getDoubleArray(String key);
method public inline double[] getDoubleArrayOrElse(String key, kotlin.jvm.functions.Function0<double[]> defaultValue);
@@ -39,11 +47,21 @@
method public inline long[] getLongArrayOrElse(String key, kotlin.jvm.functions.Function0<long[]> defaultValue);
method public inline long getLongOrElse(String key, kotlin.jvm.functions.Function0<java.lang.Long> defaultValue);
method public inline <reified T extends android.os.Parcelable> T getParcelable(String key);
+ method public inline <reified T extends android.os.Parcelable> T[] getParcelableArray(String key);
+ method public inline <reified T extends android.os.Parcelable> T[] getParcelableArrayOrElse(String key, kotlin.jvm.functions.Function0<T[]> defaultValue);
method public inline <reified T extends android.os.Parcelable> java.util.List<T> getParcelableList(String key);
method public inline <reified T extends android.os.Parcelable> java.util.List<T> getParcelableListOrElse(String key, kotlin.jvm.functions.Function0<? extends java.util.List<? extends T>> defaultValue);
method public inline <reified T extends android.os.Parcelable> T getParcelableOrElse(String key, kotlin.jvm.functions.Function0<? extends T> defaultValue);
method public inline android.os.Bundle getSavedState(String key);
method public inline android.os.Bundle getSavedStateOrElse(String key, kotlin.jvm.functions.Function0<android.os.Bundle> defaultValue);
+ method public inline <reified T extends java.io.Serializable> T getSerializable(String key);
+ method public inline <reified T extends java.io.Serializable> T getSerializableOrElse(String key, kotlin.jvm.functions.Function0<? extends T> defaultValue);
+ method public inline android.util.Size getSize(String key);
+ method public inline android.util.SizeF getSizeF(String key);
+ method public inline android.util.SizeF getSizeFOrElse(String key, kotlin.jvm.functions.Function0<android.util.SizeF> defaultValue);
+ method public inline android.util.Size getSizeOrElse(String key, kotlin.jvm.functions.Function0<android.util.Size> defaultValue);
+ method public inline <reified T extends android.os.Parcelable> android.util.SparseArray<T> getSparseParcelableArray(String key);
+ method public inline <reified T extends android.os.Parcelable> android.util.SparseArray<T> getSparseParcelableArrayOrElse(String key, kotlin.jvm.functions.Function0<? extends android.util.SparseArray<T>> defaultValue);
method public inline String getString(String key);
method public inline String[] getStringArray(String key);
method public inline String[] getStringArrayOrElse(String key, kotlin.jvm.functions.Function0<java.lang.String[]> defaultValue);
@@ -115,10 +133,14 @@
ctor @kotlin.PublishedApi internal SavedStateWriter(@kotlin.PublishedApi android.os.Bundle source);
method public inline void clear();
method public inline void putAll(android.os.Bundle values);
+ method public inline void putBinder(String key, android.os.IBinder value);
method public inline void putBoolean(String key, boolean value);
method public inline void putBooleanArray(String key, boolean[] values);
method public inline void putChar(String key, char value);
method public inline void putCharArray(String key, char[] values);
+ method public inline void putCharSequence(String key, CharSequence value);
+ method public inline void putCharSequenceArray(String key, CharSequence[] values);
+ method public inline void putCharSequenceList(String key, java.util.List<? extends java.lang.CharSequence> values);
method public inline void putDouble(String key, double value);
method public inline void putDoubleArray(String key, double[] values);
method public inline void putFloat(String key, float value);
@@ -130,8 +152,13 @@
method public inline void putLongArray(String key, long[] values);
method public inline void putNull(String key);
method public inline <reified T extends android.os.Parcelable> void putParcelable(String key, T value);
+ method public inline <reified T extends android.os.Parcelable> void putParcelableArray(String key, T[] values);
method public inline <reified T extends android.os.Parcelable> void putParcelableList(String key, java.util.List<? extends T> values);
method public inline void putSavedState(String key, android.os.Bundle value);
+ method public inline <reified T extends java.io.Serializable> void putSerializable(String key, T value);
+ method public inline void putSize(String key, android.util.Size value);
+ method public inline void putSizeF(String key, android.util.SizeF value);
+ method public inline <reified T extends android.os.Parcelable> void putSparseParcelableArray(String key, android.util.SparseArray<T> values);
method public inline void putString(String key, String value);
method public inline void putStringArray(String key, String[] values);
method public inline void putStringList(String key, java.util.List<java.lang.String> values);
diff --git a/savedstate/savedstate/bcv/native/current.txt b/savedstate/savedstate/bcv/native/current.txt
index e9f880e..2491285 100644
--- a/savedstate/savedstate/bcv/native/current.txt
+++ b/savedstate/savedstate/bcv/native/current.txt
@@ -66,6 +66,12 @@
final inline fun getCharArray(kotlin/String): kotlin/CharArray // androidx.savedstate/SavedStateReader.getCharArray|getCharArray(kotlin.String){}[0]
final inline fun getCharArrayOrElse(kotlin/String, kotlin/Function0<kotlin/CharArray>): kotlin/CharArray // androidx.savedstate/SavedStateReader.getCharArrayOrElse|getCharArrayOrElse(kotlin.String;kotlin.Function0<kotlin.CharArray>){}[0]
final inline fun getCharOrElse(kotlin/String, kotlin/Function0<kotlin/Char>): kotlin/Char // androidx.savedstate/SavedStateReader.getCharOrElse|getCharOrElse(kotlin.String;kotlin.Function0<kotlin.Char>){}[0]
+ final inline fun getCharSequence(kotlin/String): kotlin/CharSequence // androidx.savedstate/SavedStateReader.getCharSequence|getCharSequence(kotlin.String){}[0]
+ final inline fun getCharSequenceArray(kotlin/String): kotlin/Array<kotlin/CharSequence> // androidx.savedstate/SavedStateReader.getCharSequenceArray|getCharSequenceArray(kotlin.String){}[0]
+ final inline fun getCharSequenceArrayOrElse(kotlin/String, kotlin/Function0<kotlin/Array<kotlin/CharSequence>>): kotlin/Array<kotlin/CharSequence> // androidx.savedstate/SavedStateReader.getCharSequenceArrayOrElse|getCharSequenceArrayOrElse(kotlin.String;kotlin.Function0<kotlin.Array<kotlin.CharSequence>>){}[0]
+ final inline fun getCharSequenceList(kotlin/String): kotlin.collections/List<kotlin/CharSequence> // androidx.savedstate/SavedStateReader.getCharSequenceList|getCharSequenceList(kotlin.String){}[0]
+ final inline fun getCharSequenceListOrElse(kotlin/String, kotlin/Function0<kotlin.collections/List<kotlin/CharSequence>>): kotlin.collections/List<kotlin/CharSequence> // androidx.savedstate/SavedStateReader.getCharSequenceListOrElse|getCharSequenceListOrElse(kotlin.String;kotlin.Function0<kotlin.collections.List<kotlin.CharSequence>>){}[0]
+ final inline fun getCharSequenceOrElse(kotlin/String, kotlin/Function0<kotlin/CharSequence>): kotlin/CharSequence // androidx.savedstate/SavedStateReader.getCharSequenceOrElse|getCharSequenceOrElse(kotlin.String;kotlin.Function0<kotlin.CharSequence>){}[0]
final inline fun getDouble(kotlin/String): kotlin/Double // androidx.savedstate/SavedStateReader.getDouble|getDouble(kotlin.String){}[0]
final inline fun getDoubleArray(kotlin/String): kotlin/DoubleArray // androidx.savedstate/SavedStateReader.getDoubleArray|getDoubleArray(kotlin.String){}[0]
final inline fun getDoubleArrayOrElse(kotlin/String, kotlin/Function0<kotlin/DoubleArray>): kotlin/DoubleArray // androidx.savedstate/SavedStateReader.getDoubleArrayOrElse|getDoubleArrayOrElse(kotlin.String;kotlin.Function0<kotlin.DoubleArray>){}[0]
@@ -112,6 +118,9 @@
final inline fun putBooleanArray(kotlin/String, kotlin/BooleanArray) // androidx.savedstate/SavedStateWriter.putBooleanArray|putBooleanArray(kotlin.String;kotlin.BooleanArray){}[0]
final inline fun putChar(kotlin/String, kotlin/Char) // androidx.savedstate/SavedStateWriter.putChar|putChar(kotlin.String;kotlin.Char){}[0]
final inline fun putCharArray(kotlin/String, kotlin/CharArray) // androidx.savedstate/SavedStateWriter.putCharArray|putCharArray(kotlin.String;kotlin.CharArray){}[0]
+ final inline fun putCharSequence(kotlin/String, kotlin/CharSequence) // androidx.savedstate/SavedStateWriter.putCharSequence|putCharSequence(kotlin.String;kotlin.CharSequence){}[0]
+ final inline fun putCharSequenceArray(kotlin/String, kotlin/Array<kotlin/CharSequence>) // androidx.savedstate/SavedStateWriter.putCharSequenceArray|putCharSequenceArray(kotlin.String;kotlin.Array<kotlin.CharSequence>){}[0]
+ final inline fun putCharSequenceList(kotlin/String, kotlin.collections/List<kotlin/CharSequence>) // androidx.savedstate/SavedStateWriter.putCharSequenceList|putCharSequenceList(kotlin.String;kotlin.collections.List<kotlin.CharSequence>){}[0]
final inline fun putDouble(kotlin/String, kotlin/Double) // androidx.savedstate/SavedStateWriter.putDouble|putDouble(kotlin.String;kotlin.Double){}[0]
final inline fun putDoubleArray(kotlin/String, kotlin/DoubleArray) // androidx.savedstate/SavedStateWriter.putDoubleArray|putDoubleArray(kotlin.String;kotlin.DoubleArray){}[0]
final inline fun putFloat(kotlin/String, kotlin/Float) // androidx.savedstate/SavedStateWriter.putFloat|putFloat(kotlin.String;kotlin.Float){}[0]
diff --git a/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateReader.android.kt b/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateReader.android.kt
index b291dac..d3d59e0 100644
--- a/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateReader.android.kt
+++ b/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateReader.android.kt
@@ -20,9 +20,17 @@
package androidx.savedstate
+import android.os.IBinder
import android.os.Parcelable
+import android.util.Size
+import android.util.SizeF
+import android.util.SparseArray
import androidx.core.os.BundleCompat.getParcelable
+import androidx.core.os.BundleCompat.getParcelableArray
import androidx.core.os.BundleCompat.getParcelableArrayList
+import androidx.core.os.BundleCompat.getSerializable
+import androidx.core.os.BundleCompat.getSparseParcelableArray
+import java.io.Serializable
@JvmInline
actual value class SavedStateReader
@@ -31,6 +39,33 @@
@PublishedApi internal actual val source: SavedState,
) {
+ /**
+ * Retrieves a [IBinder] object associated with the specified key. Throws an
+ * [IllegalStateException] if the key doesn't exist.
+ *
+ * @param key The key to retrieve the value for.
+ * @return The [IBinder] object associated with the key.
+ * @throws IllegalStateException If the key is not found.
+ */
+ inline fun getBinder(key: String): IBinder {
+ if (key !in this) keyNotFoundError(key)
+ return source.getBinder(key) ?: valueNotFoundError(key)
+ }
+
+ /**
+ * Retrieves a [IBinder] object associated with the specified key, or a default value if the key
+ * doesn't exist.
+ *
+ * @param key The key to retrieve the value for.
+ * @param defaultValue A function providing the default [IBinder] if the key is not found.
+ * @return The [IBinder] object associated with the key, or the default value if the key is not
+ * found.
+ */
+ inline fun getBinderOrElse(key: String, defaultValue: () -> IBinder): IBinder {
+ if (key !in this) defaultValue()
+ return source.getBinder(key) ?: defaultValue()
+ }
+
actual inline fun getBoolean(key: String): Boolean {
if (key !in this) keyNotFoundError(key)
return source.getBoolean(key, DEFAULT_BOOLEAN)
@@ -46,6 +81,19 @@
return source.getChar(key, DEFAULT_CHAR)
}
+ actual inline fun getCharSequence(key: String): CharSequence {
+ if (key !in this) keyNotFoundError(key)
+ return source.getCharSequence(key) ?: valueNotFoundError(key)
+ }
+
+ actual inline fun getCharSequenceOrElse(
+ key: String,
+ defaultValue: () -> CharSequence
+ ): CharSequence {
+ if (key !in this) defaultValue()
+ return source.getCharSequence(key) ?: defaultValue()
+ }
+
actual inline fun getCharOrElse(key: String, defaultValue: () -> Char): Char {
if (key !in this) defaultValue()
return source.getChar(key, defaultValue())
@@ -118,6 +166,90 @@
return getParcelable(source, key, T::class.java) ?: defaultValue()
}
+ /**
+ * Retrieves a [Serializable] object associated with the specified key. Throws an
+ * [IllegalStateException] if the key doesn't exist.
+ *
+ * @param key The key to retrieve the value for.
+ * @return The [Serializable] object associated with the key.
+ * @throws IllegalStateException If the key is not found.
+ */
+ inline fun <reified T : Serializable> getSerializable(key: String): T {
+ if (key !in this) keyNotFoundError(key)
+ return getSerializable(source, key, T::class.java) ?: valueNotFoundError(key)
+ }
+
+ /**
+ * Retrieves a [Serializable] object associated with the specified key, or a default value if
+ * the key doesn't exist.
+ *
+ * @param key The key to retrieve the value for.
+ * @param defaultValue A function providing the default [Serializable] if the key is not found.
+ * @return The [Serializable] object associated with the key, or the default value if the key is
+ * not found.
+ */
+ inline fun <reified T : Serializable> getSerializableOrElse(
+ key: String,
+ defaultValue: () -> T
+ ): T {
+ if (key !in this) defaultValue()
+ return getSerializable(source, key, T::class.java) ?: defaultValue()
+ }
+
+ /**
+ * Retrieves a [Size] object associated with the specified key. Throws an
+ * [IllegalStateException] if the key doesn't exist.
+ *
+ * @param key The key to retrieve the value for.
+ * @return The [Size] object associated with the key.
+ * @throws IllegalStateException If the key is not found.
+ */
+ inline fun getSize(key: String): Size {
+ if (key !in this) keyNotFoundError(key)
+ return source.getSize(key) ?: valueNotFoundError(key)
+ }
+
+ /**
+ * Retrieves a [Size] object associated with the specified key, or a default value if the key
+ * doesn't exist.
+ *
+ * @param key The key to retrieve the value for.
+ * @param defaultValue A function providing the default [Size] if the key is not found.
+ * @return The [Size] object associated with the key, or the default value if the key is not
+ * found.
+ */
+ inline fun getSizeOrElse(key: String, defaultValue: () -> Size): Size {
+ if (key !in this) defaultValue()
+ return source.getSize(key) ?: defaultValue()
+ }
+
+ /**
+ * Retrieves a [SizeF] object associated with the specified key. Throws an
+ * [IllegalStateException] if the key doesn't exist.
+ *
+ * @param key The key to retrieve the value for.
+ * @return The [SizeF] object associated with the key.
+ * @throws IllegalStateException If the key is not found.
+ */
+ inline fun getSizeF(key: String): SizeF {
+ if (key !in this) keyNotFoundError(key)
+ return source.getSizeF(key) ?: valueNotFoundError(key)
+ }
+
+ /**
+ * Retrieves a [SizeF] object associated with the specified key, or a default value if the key
+ * doesn't exist.
+ *
+ * @param key The key to retrieve the value for.
+ * @param defaultValue A function providing the default [SizeF] if the key is not found.
+ * @return The [SizeF] object associated with the key, or the default value if the key is not
+ * found.
+ */
+ inline fun getSizeFOrElse(key: String, defaultValue: () -> SizeF): SizeF {
+ if (key !in this) defaultValue()
+ return source.getSizeF(key) ?: defaultValue()
+ }
+
actual inline fun getString(key: String): String {
if (key !in this) keyNotFoundError(key)
return source.getString(key) ?: valueNotFoundError(key)
@@ -138,6 +270,19 @@
return source.getIntegerArrayList(key) ?: defaultValue()
}
+ actual inline fun getCharSequenceList(key: String): List<CharSequence> {
+ if (key !in this) keyNotFoundError(key)
+ return source.getCharSequenceArrayList(key) ?: valueNotFoundError(key)
+ }
+
+ actual inline fun getCharSequenceListOrElse(
+ key: String,
+ defaultValue: () -> List<CharSequence>
+ ): List<CharSequence> {
+ if (key !in this) defaultValue()
+ return source.getCharSequenceArrayList(key) ?: defaultValue()
+ }
+
actual inline fun getStringList(key: String): List<String> {
if (key !in this) keyNotFoundError(key)
return source.getStringArrayList(key) ?: valueNotFoundError(key)
@@ -170,7 +315,7 @@
*
* @param key The [key] to retrieve the value for.
* @param defaultValue A function providing the default value if the [key] is not found or the
- * retrieved value is not a list of [Parcelable].
+ * retrieved value is not a [List] of [Parcelable].
* @return The list of elements of [Parcelable] associated with the [key], or the default value
* if the [key] is not found.
*/
@@ -205,6 +350,21 @@
return source.getCharArray(key) ?: defaultValue()
}
+ @Suppress("ArrayReturn")
+ actual inline fun getCharSequenceArray(key: String): Array<CharSequence> {
+ if (key !in this) keyNotFoundError(key)
+ return source.getCharSequenceArray(key) ?: valueNotFoundError(key)
+ }
+
+ @Suppress("ArrayReturn")
+ actual inline fun getCharSequenceArrayOrElse(
+ key: String,
+ defaultValue: () -> Array<CharSequence>
+ ): Array<CharSequence> {
+ if (key !in this) defaultValue()
+ return source.getCharSequenceArray(key) ?: defaultValue()
+ }
+
actual inline fun getDoubleArray(key: String): DoubleArray {
if (key !in this) keyNotFoundError(key)
return source.getDoubleArray(key) ?: valueNotFoundError(key)
@@ -261,6 +421,75 @@
return source.getStringArray(key) ?: defaultValue()
}
+ /**
+ * Retrieves an [Array] of elements of [Parcelable] associated with the specified [key]. Throws
+ * an [IllegalStateException] if the [key] doesn't exist.
+ *
+ * @param key The [key] to retrieve the value for.
+ * @return The [Array] of elements of [Parcelable] associated with the [key].
+ * @throws IllegalStateException If the [key] is not found.
+ */
+ @Suppress("ArrayReturn")
+ inline fun <reified T : Parcelable> getParcelableArray(key: String): Array<T> {
+ if (key !in this) keyNotFoundError(key)
+ @Suppress("UNCHECKED_CAST")
+ return getParcelableArray(source, key, T::class.java) as? Array<T>
+ ?: valueNotFoundError(key)
+ }
+
+ /**
+ * Retrieves a [Array] of elements of [Parcelable] associated with the specified [key], or a
+ * default value if the [key] doesn't exist.
+ *
+ * @param key The [key] to retrieve the value for.
+ * @param defaultValue A function providing the default value if the [key] is not found or the
+ * retrieved value is not a [Array] of [Parcelable].
+ * @return The [Array] of elements of [Parcelable] associated with the [key], or the default
+ * value if the [key] is not found.
+ */
+ @Suppress("ArrayReturn")
+ inline fun <reified T : Parcelable> getParcelableArrayOrElse(
+ key: String,
+ defaultValue: () -> Array<T>
+ ): Array<T> {
+ if (key !in this) defaultValue()
+ @Suppress("UNCHECKED_CAST")
+ return getParcelableArray(source, key, T::class.java) as? Array<T> ?: defaultValue()
+ }
+
+ /**
+ * Retrieves an [SparseArray] of elements of [Parcelable] associated with the specified [key].
+ * Throws an [IllegalStateException] if the [key] doesn't exist.
+ *
+ * @param key The [key] to retrieve the value for.
+ * @return The [SparseArray] of elements of [Parcelable] associated with the [key].
+ * @throws IllegalStateException If the [key] is not found.
+ */
+ inline fun <reified T : Parcelable> getSparseParcelableArray(key: String): SparseArray<T> {
+ if (key !in this) keyNotFoundError(key)
+ return getSparseParcelableArray(source, key, T::class.java) as? SparseArray<T>
+ ?: valueNotFoundError(key)
+ }
+
+ /**
+ * Retrieves a [SparseArray] of elements of [Parcelable] associated with the specified [key], or
+ * a default value if the [key] doesn't exist.
+ *
+ * @param key The [key] to retrieve the value for.
+ * @param defaultValue A function providing the default value if the [key] is not found or the
+ * retrieved value is not a [SparseArray] of [Parcelable].
+ * @return The [SparseArray] of elements of [Parcelable] associated with the [key], or the
+ * default value if the [key] is not found.
+ */
+ inline fun <reified T : Parcelable> getSparseParcelableArrayOrElse(
+ key: String,
+ defaultValue: () -> SparseArray<T>
+ ): SparseArray<T> {
+ if (key !in this) defaultValue()
+ return getSparseParcelableArray(source, key, T::class.java) as? SparseArray<T>
+ ?: defaultValue()
+ }
+
actual inline fun getSavedState(key: String): SavedState {
if (key !in this) keyNotFoundError(key)
return source.getBundle(key) ?: valueNotFoundError(key)
diff --git a/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateWriter.android.kt b/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateWriter.android.kt
index db7d120..a2795d0 100644
--- a/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateWriter.android.kt
+++ b/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateWriter.android.kt
@@ -20,7 +20,12 @@
package androidx.savedstate
+import android.os.IBinder
import android.os.Parcelable
+import android.util.Size
+import android.util.SizeF
+import android.util.SparseArray
+import java.io.Serializable
@JvmInline
actual value class SavedStateWriter
@@ -29,6 +34,16 @@
@PublishedApi internal actual val source: SavedState,
) {
+ /**
+ * Stores an [IBinder] value associated with the specified key in the [IBinder].
+ *
+ * @param key The key to associate the value with.
+ * @param value The [IBinder] value to store.
+ */
+ inline fun putBinder(key: String, value: IBinder) {
+ source.putBinder(key, value)
+ }
+
actual inline fun putBoolean(key: String, value: Boolean) {
source.putBoolean(key, value)
}
@@ -37,6 +52,10 @@
source.putChar(key, value)
}
+ actual inline fun putCharSequence(key: String, value: CharSequence) {
+ source.putCharSequence(key, value)
+ }
+
actual inline fun putDouble(key: String, value: Double) {
source.putDouble(key, value)
}
@@ -67,6 +86,36 @@
source.putParcelable(key, value)
}
+ /**
+ * Stores an [Serializable] value associated with the specified key in the [Serializable].
+ *
+ * @param key The key to associate the value with.
+ * @param value The [Serializable] value to store.
+ */
+ inline fun <reified T : Serializable> putSerializable(key: String, value: T) {
+ source.putSerializable(key, value)
+ }
+
+ /**
+ * Stores an [Size] value associated with the specified key in the [Size].
+ *
+ * @param key The key to associate the value with.
+ * @param value The [Size] value to store.
+ */
+ inline fun putSize(key: String, value: Size) {
+ source.putSize(key, value)
+ }
+
+ /**
+ * Stores an [SizeF] value associated with the specified key in the [SizeF].
+ *
+ * @param key The key to associate the value with.
+ * @param value The [SizeF] value to store.
+ */
+ inline fun putSizeF(key: String, value: SizeF) {
+ source.putSizeF(key, value)
+ }
+
actual inline fun putString(key: String, value: String) {
source.putString(key, value)
}
@@ -75,16 +124,20 @@
source.putIntegerArrayList(key, values.toArrayListUnsafe())
}
+ actual inline fun putCharSequenceList(key: String, values: List<CharSequence>) {
+ source.putCharSequenceArrayList(key, values.toArrayListUnsafe())
+ }
+
actual inline fun putStringList(key: String, values: List<String>) {
source.putStringArrayList(key, values.toArrayListUnsafe())
}
/**
- * Stores a list of elements of [Parcelable] associated with the specified key in the
+ * Stores a [List] of elements of [Parcelable] associated with the specified key in the
* [SavedState].
*
* @param key The key to associate the value with.
- * @param values The list of elements to store.
+ * @param values The [List] of elements to store.
*/
inline fun <reified T : Parcelable> putParcelableList(key: String, values: List<T>) {
source.putParcelableArrayList(key, values.toArrayListUnsafe())
@@ -98,6 +151,13 @@
source.putCharArray(key, values)
}
+ actual inline fun putCharSequenceArray(
+ key: String,
+ @Suppress("ArrayReturn") values: Array<CharSequence>
+ ) {
+ source.putCharSequenceArray(key, values)
+ }
+
actual inline fun putDoubleArray(key: String, values: DoubleArray) {
source.putDoubleArray(key, values)
}
@@ -118,6 +178,34 @@
source.putStringArray(key, values)
}
+ /**
+ * Stores a [Array] of elements of [Parcelable] associated with the specified key in the
+ * [SavedState].
+ *
+ * @param key The key to associate the value with.
+ * @param values The [Array] of elements to store.
+ */
+ inline fun <reified T : Parcelable> putParcelableArray(
+ key: String,
+ @Suppress("ArrayReturn") values: Array<T>
+ ) {
+ source.putParcelableArray(key, values)
+ }
+
+ /**
+ * Stores a [SparseArray] of elements of [Parcelable] associated with the specified key in the
+ * [SavedState].
+ *
+ * @param key The key to associate the value with.
+ * @param values The [SparseArray] of elements to store.
+ */
+ inline fun <reified T : Parcelable> putSparseParcelableArray(
+ key: String,
+ values: SparseArray<T>
+ ) {
+ source.putSparseParcelableArray(key, values)
+ }
+
actual inline fun putSavedState(key: String, value: SavedState) {
source.putBundle(key, value)
}
diff --git a/savedstate/savedstate/src/androidUnitTest/kotlin/androidx/savedstate/ParcelableSavedStateTest.android.kt b/savedstate/savedstate/src/androidUnitTest/kotlin/androidx/savedstate/ParcelableSavedStateTest.android.kt
deleted file mode 100644
index 0a0dd72..0000000
--- a/savedstate/savedstate/src/androidUnitTest/kotlin/androidx/savedstate/ParcelableSavedStateTest.android.kt
+++ /dev/null
@@ -1,153 +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.savedstate
-
-import android.os.Parcel
-import android.os.Parcelable
-import androidx.kruth.assertThat
-import androidx.kruth.assertThrows
-import kotlin.test.Test
-
-internal class ParcelableSavedStateTest : RobolectricTest() {
-
- @Test
- fun getParcelable_whenSet_returns() {
- val underTest = savedState { putParcelable(KEY_1, PARCELABLE_VALUE_1) }
- val actual = underTest.read { getParcelable<TestParcelable>(KEY_1) }
-
- assertThat(actual).isEqualTo(PARCELABLE_VALUE_1)
- }
-
- @Test
- fun getParcelable_whenNotSet_throws() {
- assertThrows<IllegalArgumentException> {
- savedState().read { getParcelable<TestParcelable>(KEY_1) }
- }
- }
-
- @Test
- fun getParcelable_whenSet_differentType_returnsDefault() {
- val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
-
- assertThrows<IllegalStateException> {
- underTest.read { getParcelable<TestParcelable>(KEY_1) }
- }
- }
-
- @Test
- fun getParcelableOrElse_whenSet_returns() {
- val underTest = savedState { putParcelable(KEY_1, PARCELABLE_VALUE_1) }
- val actual = underTest.read { getParcelableOrElse(KEY_1) { PARCELABLE_VALUE_2 } }
-
- assertThat(actual).isEqualTo(PARCELABLE_VALUE_1)
- }
-
- @Test
- fun getParcelableOrElse_whenNotSet_returnsElse() {
- val actual = savedState().read { getParcelableOrElse(KEY_1) { PARCELABLE_VALUE_1 } }
-
- assertThat(actual).isEqualTo(PARCELABLE_VALUE_1)
- }
-
- @Test
- fun getParcelableOrElse_whenSet_differentType_returnsDefault() {
- val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
- val actual = underTest.read { getParcelableOrElse(KEY_1) { PARCELABLE_VALUE_1 } }
-
- assertThat(actual).isEqualTo(PARCELABLE_VALUE_1)
- }
-
- @Test
- fun getParcelableList_whenSet_returns() {
- val expected = List(size = 5) { idx -> TestParcelable(idx) }
-
- val underTest = savedState { putParcelableList(KEY_1, expected) }
- val actual = underTest.read { getParcelableList<TestParcelable>(KEY_1) }
-
- assertThat(actual).isEqualTo(expected)
- }
-
- @Test
- fun getList_ofParcelable_whenNotSet_throws() {
- assertThrows<IllegalArgumentException> {
- savedState().read { getParcelableList<TestParcelable>(KEY_1) }
- }
- }
-
- @Test
- fun getList_whenSet_differentType_throws() {
- val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
-
- assertThrows<IllegalStateException> {
- underTest.read { getParcelableList<TestParcelable>(KEY_1) }
- }
- }
-
- @Test
- fun getListOrElse_ofParcelable_whenSet_returns() {
- val expected = List(size = 5) { idx -> TestParcelable(idx) }
-
- val underTest = savedState { putParcelableList(KEY_1, expected) }
- val actual =
- underTest.read { getParcelableListOrElse<TestParcelable>(KEY_1) { emptyList() } }
-
- assertThat(actual).isEqualTo(expected)
- }
-
- @Test
- fun getListOrElse_ofParcelable_whenNotSet_returnsElse() {
- val actual =
- savedState().read { getParcelableListOrElse<TestParcelable>(KEY_1) { emptyList() } }
-
- assertThat(actual).isEqualTo(emptyList<TestParcelable>())
- }
-
- @Test
- fun getListOrElse_whenSet_differentType_throws() {
- val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
- val actual = underTest.read { getParcelableListOrElse(KEY_1) { emptyList() } }
-
- assertThat(actual).isEqualTo(emptyList<Parcelable>())
- }
-
- private companion object {
- const val KEY_1 = "KEY_1"
- val PARCELABLE_VALUE_1 = TestParcelable(value = Int.MIN_VALUE)
- val PARCELABLE_VALUE_2 = TestParcelable(value = Int.MAX_VALUE)
- }
-
- internal data class TestParcelable(val value: Int) : Parcelable {
-
- override fun describeContents(): Int = 0
-
- override fun writeToParcel(dest: Parcel, flags: Int) {
- dest.writeInt(value)
- }
-
- companion object {
- @Suppress("unused")
- @JvmField
- val CREATOR =
- object : Parcelable.Creator<TestParcelable> {
- override fun createFromParcel(source: Parcel) =
- TestParcelable(value = source.readInt())
-
- override fun newArray(size: Int) = arrayOfNulls<TestParcelable>(size)
- }
- }
- }
-}
diff --git a/savedstate/savedstate/src/androidUnitTest/kotlin/androidx/savedstate/SavedStateAndroidTest.android.kt b/savedstate/savedstate/src/androidUnitTest/kotlin/androidx/savedstate/SavedStateAndroidTest.android.kt
new file mode 100644
index 0000000..91f3395
--- /dev/null
+++ b/savedstate/savedstate/src/androidUnitTest/kotlin/androidx/savedstate/SavedStateAndroidTest.android.kt
@@ -0,0 +1,483 @@
+/*
+ * 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.savedstate
+
+import android.os.IBinder
+import android.os.Parcel
+import android.os.Parcelable
+import android.util.Size
+import android.util.SizeF
+import android.util.SparseArray
+import androidx.kruth.assertThat
+import androidx.kruth.assertThrows
+import java.io.FileDescriptor
+import java.io.Serializable
+import kotlin.test.Test
+
+internal class ParcelableSavedStateTest : RobolectricTest() {
+
+ @Test
+ fun getBinder_whenSet_returns() {
+ val underTest = savedState { putBinder(KEY_1, BINDER_VALUE_1) }
+ val actual = underTest.read { getBinder(KEY_1) }
+
+ assertThat(actual).isEqualTo(BINDER_VALUE_1)
+ }
+
+ @Test
+ fun getBinder_whenNotSet_throws() {
+ assertThrows<IllegalArgumentException> { savedState().read { getBinder(KEY_1) } }
+ }
+
+ @Test
+ fun getBinder_whenSet_differentType_returnsDefault() {
+ val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+
+ assertThrows<IllegalStateException> { underTest.read { getBinder(KEY_1) } }
+ }
+
+ @Test
+ fun getBinderOrElse_whenSet_returns() {
+ val underTest = savedState { putBinder(KEY_1, BINDER_VALUE_1) }
+ val actual = underTest.read { getBinderOrElse(KEY_1) { BINDER_VALUE_2 } }
+
+ assertThat(actual).isEqualTo(BINDER_VALUE_1)
+ }
+
+ @Test
+ fun getBinderOrElse_whenNotSet_returnsElse() {
+ val actual = savedState().read { getBinderOrElse(KEY_1) { BINDER_VALUE_2 } }
+
+ assertThat(actual).isEqualTo(BINDER_VALUE_2)
+ }
+
+ @Test
+ fun getBinderOrElse_whenSet_differentType_returnsDefault() {
+ val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+ val actual = underTest.read { getBinderOrElse(KEY_1) { BINDER_VALUE_2 } }
+
+ assertThat(actual).isEqualTo(BINDER_VALUE_2)
+ }
+
+ @Test
+ fun getSize_whenSet_returns() {
+ val underTest = savedState { putSize(KEY_1, SIZE_IN_PIXEL_VALUE_1) }
+ val actual = underTest.read { getSize(KEY_1) }
+
+ assertThat(actual).isEqualTo(SIZE_IN_PIXEL_VALUE_1)
+ }
+
+ @Test
+ fun getSize_whenNotSet_throws() {
+ assertThrows<IllegalArgumentException> { savedState().read { getSize(KEY_1) } }
+ }
+
+ @Test
+ fun getSize_whenSet_differentType_throws() {
+ val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+
+ assertThrows<IllegalStateException> { underTest.read { getSize(KEY_1) } }
+ }
+
+ @Test
+ fun getSizeOrElse_whenSet_returns() {
+ val underTest = savedState { putSize(KEY_1, SIZE_IN_PIXEL_VALUE_1) }
+ val actual = underTest.read { getSizeOrElse(KEY_1) { SIZE_IN_PIXEL_VALUE_2 } }
+
+ assertThat(actual).isEqualTo(SIZE_IN_PIXEL_VALUE_1)
+ }
+
+ @Test
+ fun getSizeOrElse_whenNotSet_returnsElse() {
+ val actual = savedState().read { getSizeOrElse(KEY_1) { SIZE_IN_PIXEL_VALUE_2 } }
+
+ assertThat(actual).isEqualTo(SIZE_IN_PIXEL_VALUE_2)
+ }
+
+ @Test
+ fun getSizeOrElse_whenSet_differentType_returnsDefault() {
+ val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+ val actual = underTest.read { getSizeOrElse(KEY_1) { SIZE_IN_PIXEL_VALUE_2 } }
+
+ assertThat(actual).isEqualTo(SIZE_IN_PIXEL_VALUE_2)
+ }
+
+ @Test
+ fun getSizeF_whenSet_returns() {
+ val underTest = savedState { putSizeF(KEY_1, SIZE_IN_FLOAT_VALUE_1) }
+ val actual = underTest.read { getSizeF(KEY_1) }
+
+ assertThat(actual).isEqualTo(SIZE_IN_FLOAT_VALUE_1)
+ }
+
+ @Test
+ fun getSizeF_whenNotSet_throws() {
+ assertThrows<IllegalArgumentException> { savedState().read { getSizeF(KEY_1) } }
+ }
+
+ @Test
+ fun getSizeF_whenSet_differentType_throws() {
+ val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+
+ assertThrows<IllegalStateException> { underTest.read { getSizeF(KEY_1) } }
+ }
+
+ @Test
+ fun getSizeFOrElse_whenSet_returns() {
+ val underTest = savedState { putSizeF(KEY_1, SIZE_IN_FLOAT_VALUE_1) }
+ val actual = underTest.read { getSizeFOrElse(KEY_1) { SIZE_IN_FLOAT_VALUE_2 } }
+
+ assertThat(actual).isEqualTo(SIZE_IN_FLOAT_VALUE_1)
+ }
+
+ @Test
+ fun getSizeFOrElse_whenNotSet_returnsElse() {
+ val actual = savedState().read { getSizeFOrElse(KEY_1) { SIZE_IN_FLOAT_VALUE_2 } }
+
+ assertThat(actual).isEqualTo(SIZE_IN_FLOAT_VALUE_2)
+ }
+
+ @Test
+ fun getSizeFOrElse_whenSet_differentType_returnsDefault() {
+ val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+ val actual = underTest.read { getSizeFOrElse(KEY_1) { SIZE_IN_FLOAT_VALUE_2 } }
+
+ assertThat(actual).isEqualTo(SIZE_IN_FLOAT_VALUE_2)
+ }
+
+ @Test
+ fun getParcelable_whenSet_returns() {
+ val underTest = savedState { putParcelable(KEY_1, PARCELABLE_VALUE_1) }
+ val actual = underTest.read { getParcelable<TestParcelable>(KEY_1) }
+
+ assertThat(actual).isEqualTo(PARCELABLE_VALUE_1)
+ }
+
+ @Test
+ fun getParcelable_whenNotSet_throws() {
+ assertThrows<IllegalArgumentException> {
+ savedState().read { getParcelable<TestParcelable>(KEY_1) }
+ }
+ }
+
+ @Test
+ fun getParcelable_whenSet_differentType_throws() {
+ val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+
+ assertThrows<IllegalStateException> {
+ underTest.read { getParcelable<TestParcelable>(KEY_1) }
+ }
+ }
+
+ @Test
+ fun getParcelableOrElse_whenSet_returns() {
+ val underTest = savedState { putParcelable(KEY_1, PARCELABLE_VALUE_1) }
+ val actual = underTest.read { getParcelableOrElse(KEY_1) { PARCELABLE_VALUE_2 } }
+
+ assertThat(actual).isEqualTo(PARCELABLE_VALUE_1)
+ }
+
+ @Test
+ fun getParcelableOrElse_whenNotSet_returnsElse() {
+ val actual = savedState().read { getParcelableOrElse(KEY_1) { PARCELABLE_VALUE_1 } }
+
+ assertThat(actual).isEqualTo(PARCELABLE_VALUE_1)
+ }
+
+ @Test
+ fun getParcelableOrElse_whenSet_differentType_returnsDefault() {
+ val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+ val actual = underTest.read { getParcelableOrElse(KEY_1) { PARCELABLE_VALUE_1 } }
+
+ assertThat(actual).isEqualTo(PARCELABLE_VALUE_1)
+ }
+
+ @Test
+ fun getParcelableList_whenSet_returns() {
+ val expected = List(size = 5) { idx -> TestParcelable(idx) }
+
+ val underTest = savedState { putParcelableList(KEY_1, expected) }
+ val actual = underTest.read { getParcelableList<TestParcelable>(KEY_1) }
+
+ assertThat(actual).isEqualTo(expected)
+ }
+
+ @Test
+ fun getParcelableList_whenNotSet_throws() {
+ assertThrows<IllegalArgumentException> {
+ savedState().read { getParcelableList<TestParcelable>(KEY_1) }
+ }
+ }
+
+ @Test
+ fun getParcelableList_whenSet_differentType_throws() {
+ val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+
+ assertThrows<IllegalStateException> {
+ underTest.read { getParcelableList<TestParcelable>(KEY_1) }
+ }
+ }
+
+ @Test
+ fun getParcelableListOrElse_whenSet_returns() {
+ val expected = List(size = 5) { idx -> TestParcelable(idx) }
+
+ val underTest = savedState { putParcelableList(KEY_1, expected) }
+ val actual =
+ underTest.read { getParcelableListOrElse<TestParcelable>(KEY_1) { emptyList() } }
+
+ assertThat(actual).isEqualTo(expected)
+ }
+
+ @Test
+ fun getParcelableListOrElse_whenNotSet_returnsElse() {
+ val actual =
+ savedState().read { getParcelableListOrElse<TestParcelable>(KEY_1) { emptyList() } }
+
+ assertThat(actual).isEqualTo(emptyList<TestParcelable>())
+ }
+
+ @Test
+ fun getParcelableListOrElse_whenSet_differentType_throws() {
+ val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+ val actual = underTest.read { getParcelableListOrElse(KEY_1) { emptyList() } }
+
+ assertThat(actual).isEqualTo(emptyList<Parcelable>())
+ }
+
+ @Test
+ fun getParcelableArray_whenSet_returns() {
+ val expected = Array(size = 5) { idx -> TestParcelable(idx) }
+
+ val underTest = savedState { putParcelableArray(KEY_1, expected) }
+ val actual = underTest.read { getParcelableArray<TestParcelable>(KEY_1) }
+
+ assertThat(actual).isEqualTo(expected)
+ }
+
+ @Test
+ fun getParcelableArray_ofParcelable_whenNotSet_throws() {
+ assertThrows<IllegalArgumentException> {
+ savedState().read { getParcelableArray<TestParcelable>(KEY_1) }
+ }
+ }
+
+ @Test
+ fun getParcelableArray_whenSet_differentType_throws() {
+ val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+
+ assertThrows<IllegalStateException> {
+ underTest.read { getParcelableArray<TestParcelable>(KEY_1) }
+ }
+ }
+
+ @Test
+ fun getParcelableArrayOrElse_whenSet_returns() {
+ val expected = Array(size = 5) { idx -> TestParcelable(idx) }
+
+ val underTest = savedState { putParcelableArray(KEY_1, expected) }
+ val actual =
+ underTest.read { getParcelableArrayOrElse<TestParcelable>(KEY_1) { emptyArray() } }
+
+ assertThat(actual).isEqualTo(expected)
+ }
+
+ @Test
+ fun getParcelableArrayOrElse_ofParcelable_whenNotSet_returnsElse() {
+ val actual =
+ savedState().read { getParcelableArrayOrElse<TestParcelable>(KEY_1) { emptyArray() } }
+
+ assertThat(actual).isEqualTo(emptyArray<TestParcelable>())
+ }
+
+ @Test
+ fun getParcelableArrayOrElse_whenSet_differentType_throws() {
+ val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+ val actual = underTest.read { getParcelableArrayOrElse(KEY_1) { emptyArray() } }
+
+ assertThat(actual).isEqualTo(emptyArray<Parcelable>())
+ }
+
+ @Test
+ fun getSparseParcelableArray_whenSet_returns() {
+ val expected = SPARSE_PARCELABLE_ARRAY
+
+ val underTest = savedState { putSparseParcelableArray(KEY_1, expected) }
+ val actual = underTest.read { getSparseParcelableArray<TestParcelable>(KEY_1) }
+
+ assertThat(actual).isEqualTo(expected)
+ }
+
+ @Test
+ fun getSparseParcelableArray_ofParcelable_whenNotSet_throws() {
+ assertThrows<IllegalArgumentException> {
+ savedState().read { getSparseParcelableArray<TestParcelable>(KEY_1) }
+ }
+ }
+
+ @Test
+ fun getSparseParcelableArray_whenSet_differentType_throws() {
+ val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+
+ assertThrows<IllegalStateException> {
+ underTest.read { getSparseParcelableArray<TestParcelable>(KEY_1) }
+ }
+ }
+
+ @Test
+ fun getSparseParcelableArrayOrElse_whenSet_returns() {
+ val expected = SPARSE_PARCELABLE_ARRAY
+
+ val underTest = savedState { putSparseParcelableArray(KEY_1, expected) }
+ val actual =
+ underTest.read {
+ getSparseParcelableArrayOrElse<TestParcelable>(KEY_1) { SparseArray() }
+ }
+
+ assertThat(actual).isEqualTo(expected)
+ }
+
+ @Test
+ fun getSparseParcelableArrayOrElse_ofParcelable_whenNotSet_returnsElse() {
+ val expected = SPARSE_PARCELABLE_ARRAY
+
+ val actual =
+ savedState().read { getSparseParcelableArrayOrElse<TestParcelable>(KEY_1) { expected } }
+
+ assertThat(actual).isEqualTo(expected)
+ }
+
+ @Test
+ fun getSparseParcelableArrayOrElse_whenSet_differentType_throws() {
+ val expected = SPARSE_PARCELABLE_ARRAY
+
+ val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+ val actual = underTest.read { getSparseParcelableArrayOrElse(KEY_1) { expected } }
+
+ assertThat(actual).isEqualTo(expected)
+ }
+
+ @Test
+ fun getSerializable_whenSet_returns() {
+ val underTest = savedState { putSerializable(KEY_1, SERIALIZABLE_VALUE_1) }
+ val actual = underTest.read { getSerializable<TestSerializable>(KEY_1) }
+
+ assertThat(actual).isEqualTo(SERIALIZABLE_VALUE_1)
+ }
+
+ @Test
+ fun getSerializable_whenNotSet_throws() {
+ assertThrows<IllegalArgumentException> {
+ savedState().read { getSerializable<TestSerializable>(KEY_1) }
+ }
+ }
+
+ @Test
+ fun getSerializable_whenSet_differentType_throws() {
+ val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+
+ assertThrows<IllegalStateException> {
+ underTest.read { getSerializable<TestSerializable>(KEY_1) }
+ }
+ }
+
+ @Test
+ fun getSerializableOrElse_whenSet_returns() {
+ val underTest = savedState { putSerializable(KEY_1, SERIALIZABLE_VALUE_1) }
+ val actual = underTest.read { getSerializableOrElse(KEY_1) { SERIALIZABLE_VALUE_2 } }
+
+ assertThat(actual).isEqualTo(SERIALIZABLE_VALUE_1)
+ }
+
+ @Test
+ fun getSerializableOrElse_whenNotSet_returnsElse() {
+ val actual = savedState().read { getSerializableOrElse(KEY_1) { SERIALIZABLE_VALUE_2 } }
+
+ assertThat(actual).isEqualTo(SERIALIZABLE_VALUE_2)
+ }
+
+ @Test
+ fun getSerializableOrElse_whenSet_differentType_returnsDefault() {
+ val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+ val actual = underTest.read { getSerializableOrElse(KEY_1) { SERIALIZABLE_VALUE_1 } }
+
+ assertThat(actual).isEqualTo(SERIALIZABLE_VALUE_1)
+ }
+
+ private companion object {
+ const val KEY_1 = "KEY_1"
+ val SIZE_IN_PIXEL_VALUE_1 = Size(/* width= */ Int.MIN_VALUE, /* height */ Int.MIN_VALUE)
+ val SIZE_IN_PIXEL_VALUE_2 = Size(/* width= */ Int.MAX_VALUE, /* height */ Int.MAX_VALUE)
+ val SIZE_IN_FLOAT_VALUE_1 =
+ SizeF(/* width= */ Float.MIN_VALUE, /* height */ Float.MIN_VALUE)
+ val SIZE_IN_FLOAT_VALUE_2 =
+ SizeF(/* width= */ Float.MAX_VALUE, /* height */ Float.MAX_VALUE)
+ val BINDER_VALUE_1 = TestBinder(value = Int.MIN_VALUE)
+ val BINDER_VALUE_2 = TestBinder(value = Int.MAX_VALUE)
+ val PARCELABLE_VALUE_1 = TestParcelable(value = Int.MIN_VALUE)
+ val PARCELABLE_VALUE_2 = TestParcelable(value = Int.MAX_VALUE)
+ val SERIALIZABLE_VALUE_1 = TestSerializable(value = Int.MIN_VALUE)
+ val SERIALIZABLE_VALUE_2 = TestSerializable(value = Int.MAX_VALUE)
+ val SPARSE_PARCELABLE_ARRAY =
+ SparseArray<TestParcelable>(/* initialCapacity= */ 5).apply {
+ repeat(times = 5) { idx -> put(idx, TestParcelable(idx)) }
+ }
+ }
+
+ internal data class TestBinder(val value: Int) : IBinder {
+ override fun getInterfaceDescriptor() = error("")
+
+ override fun pingBinder() = error("")
+
+ override fun isBinderAlive() = error("")
+
+ override fun queryLocalInterface(descriptor: String) = error("")
+
+ override fun dump(fd: FileDescriptor, args: Array<out String>?) = error("")
+
+ override fun dumpAsync(fd: FileDescriptor, args: Array<out String>?) = error("")
+
+ override fun transact(code: Int, data: Parcel, reply: Parcel?, flags: Int) = error("")
+
+ override fun linkToDeath(recipient: IBinder.DeathRecipient, flags: Int) = error("")
+
+ override fun unlinkToDeath(recipient: IBinder.DeathRecipient, flags: Int) = error("")
+ }
+
+ internal data class TestParcelable(val value: Int) : Parcelable {
+
+ override fun describeContents(): Int = 0
+
+ override fun writeToParcel(dest: Parcel, flags: Int) {
+ dest.writeInt(value)
+ }
+
+ companion object {
+ @Suppress("unused")
+ @JvmField
+ val CREATOR =
+ object : Parcelable.Creator<TestParcelable> {
+ override fun createFromParcel(source: Parcel) =
+ TestParcelable(value = source.readInt())
+
+ override fun newArray(size: Int) = arrayOfNulls<TestParcelable>(size)
+ }
+ }
+ }
+
+ internal data class TestSerializable(val value: Int) : Serializable
+}
diff --git a/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateReader.kt b/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateReader.kt
index 5249fa0..a18e93e 100644
--- a/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateReader.kt
+++ b/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateReader.kt
@@ -25,10 +25,15 @@
import kotlin.jvm.JvmName
@PublishedApi internal const val DEFAULT_BOOLEAN: Boolean = false
+
@PublishedApi internal const val DEFAULT_CHAR: Char = 0.toChar()
+
@PublishedApi internal const val DEFAULT_FLOAT: Float = 0F
+
@PublishedApi internal const val DEFAULT_DOUBLE: Double = 0.0
+
@PublishedApi internal const val DEFAULT_INT: Int = 0
+
@PublishedApi internal const val DEFAULT_LONG: Long = 0L
/**
@@ -87,6 +92,30 @@
public inline fun getCharOrElse(key: String, defaultValue: () -> Char): Char
/**
+ * Retrieves a [CharSequence] value associated with the specified [key]. Throws an
+ * [IllegalStateException] if the [key] doesn't exist.
+ *
+ * @param key The [key] to retrieve the value for.
+ * @return The [CharSequence] value associated with the [key].
+ * @throws IllegalStateException If the [key] is not found.
+ */
+ public inline fun getCharSequence(key: String): CharSequence
+
+ /**
+ * Retrieves a [CharSequence] value associated with the specified [key], or a default value if
+ * the [key] doesn't exist.
+ *
+ * @param key The [key] to retrieve the value for.
+ * @param defaultValue A function providing the default value if the [key] is not found.
+ * @return The [CharSequence] value associated with the [key], or the default value if the [key]
+ * is not found.
+ */
+ public inline fun getCharSequenceOrElse(
+ key: String,
+ defaultValue: () -> CharSequence
+ ): CharSequence
+
+ /**
* Retrieves a [Double] value associated with the specified [key]. Throws an
* [IllegalStateException] if the [key] doesn't exist.
*
@@ -239,6 +268,31 @@
): List<String>
/**
+ * Retrieves a [List] of elements of [CharArray] associated with the specified [key]. Throws an
+ * [IllegalStateException] if the [key] doesn't exist.
+ *
+ * @param key The [key] to retrieve the value for.
+ * @return The [List] of elements of [CharArray] associated with the [key].
+ * @throws IllegalStateException If the [key] is not found.
+ */
+ public inline fun getCharSequenceList(key: String): List<CharSequence>
+
+ /**
+ * Retrieves a [List] of elements of [CharSequence] associated with the specified [key], or a
+ * default value if the [key] doesn't exist.
+ *
+ * @param key The [key] to retrieve the value for.
+ * @param defaultValue A function providing the default value if the [key] is not found or the
+ * retrieved value is not a list of [CharSequence].
+ * @return The list of elements of [CharSequence] associated with the [key], or the default
+ * value if the [key] is not found.
+ */
+ public inline fun getCharSequenceListOrElse(
+ key: String,
+ defaultValue: () -> List<CharSequence>
+ ): List<CharSequence>
+
+ /**
* Retrieves a [BooleanArray] value associated with the specified [key]. Throws an
* [IllegalStateException] if the [key] doesn't exist.
*
@@ -284,6 +338,30 @@
public inline fun getCharArrayOrElse(key: String, defaultValue: () -> CharArray): CharArray
/**
+ * Retrieves a [CharArray] value associated with the specified [key]. Throws an
+ * [IllegalStateException] if the [key] doesn't exist.
+ *
+ * @param key The [key] to retrieve the value for.
+ * @return The [CharArray] value associated with the [key].
+ * @throws IllegalStateException If the [key] is not found.
+ */
+ public inline fun getCharSequenceArray(key: String): Array<CharSequence>
+
+ /**
+ * Retrieves a [CharArray] value associated with the specified [key], or a default value if the
+ * [key] doesn't exist.
+ *
+ * @param key The [key] to retrieve the value for.
+ * @param defaultValue A function providing the default value if the [key] is not found.
+ * @return The [CharArray] value associated with the [key], or the default value if the [key] is
+ * not found.
+ */
+ public inline fun getCharSequenceArrayOrElse(
+ key: String,
+ defaultValue: () -> Array<CharSequence>
+ ): Array<CharSequence>
+
+ /**
* Retrieves a [DoubleArray] value associated with the specified [key]. Throws an
* [IllegalStateException] if the [key] doesn't exist.
*
diff --git a/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateWriter.kt b/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateWriter.kt
index 13b5c8f..11cd15c04 100644
--- a/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateWriter.kt
+++ b/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateWriter.kt
@@ -45,9 +45,23 @@
*/
public inline fun putBoolean(key: String, value: Boolean)
+ /**
+ * Stores a char value associated with the specified key in the [SavedState].
+ *
+ * @param key The key to associate the value with.
+ * @param value The char value to store.
+ */
public inline fun putChar(key: String, value: Char)
/**
+ * Stores a char sequence value associated with the specified key in the [SavedState].
+ *
+ * @param key The key to associate the value with.
+ * @param value The char sequence value to store.
+ */
+ public inline fun putCharSequence(key: String, value: CharSequence)
+
+ /**
* Stores a double value associated with the specified key in the [SavedState].
*
* @param key The key to associate the value with.
@@ -103,6 +117,15 @@
public inline fun putIntList(key: String, values: List<Int>)
/**
+ * Stores a list of elements of [CharSequence] associated with the specified key in the
+ * [SavedState].
+ *
+ * @param key The key to associate the value with.
+ * @param values The list of elements to store.
+ */
+ public inline fun putCharSequenceList(key: String, values: List<CharSequence>)
+
+ /**
* Stores a list of elements of [String] associated with the specified key in the [SavedState].
*
* @param key The key to associate the value with.
@@ -129,6 +152,15 @@
public inline fun putCharArray(key: String, values: CharArray)
/**
+ * Stores an [Array] of elements of [CharSequence] associated with the specified key in the
+ * [SavedState].
+ *
+ * @param key The key to associate the value with.
+ * @param values The array of elements to store.
+ */
+ public inline fun putCharSequenceArray(key: String, values: Array<CharSequence>)
+
+ /**
* Stores an [Array] of elements of [Double] associated with the specified key in the
* [SavedState].
*
diff --git a/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/SavedStateTest.kt b/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/SavedStateTest.kt
index fc57a73..d19506c 100644
--- a/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/SavedStateTest.kt
+++ b/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/SavedStateTest.kt
@@ -332,6 +332,49 @@
}
@Test
+ fun getCharSequence_whenSet_returns() {
+ val underTest = savedState { putCharSequence(KEY_1, CHAR_SEQUENCE_VALUE_1) }
+ val actual = underTest.read { getCharSequence(KEY_1) }
+
+ assertThat(actual).isEqualTo(CHAR_SEQUENCE_VALUE_1)
+ }
+
+ @Test
+ fun getCharSequence_whenNotSet_throws() {
+ assertThrows<IllegalArgumentException> { savedState().read { getCharSequence(KEY_1) } }
+ }
+
+ @Test
+ fun getCharSequence_whenSet_differentType_throws() {
+ val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+
+ assertThrows<IllegalStateException> { underTest.read { getString(KEY_1) } }
+ }
+
+ @Test
+ fun getCharSequenceOrElse_whenSet_returns() {
+ val underTest = savedState { putCharSequence(KEY_1, CHAR_SEQUENCE_VALUE_1) }
+ val actual = underTest.read { getCharSequenceOrElse(KEY_1) { CHAR_SEQUENCE_VALUE_2 } }
+
+ assertThat(actual).isEqualTo(CHAR_SEQUENCE_VALUE_1)
+ }
+
+ @Test
+ fun getCharSequenceOrElse_whenNotSet_returnsElse() {
+ val actual = savedState().read { getCharSequenceOrElse(KEY_1) { CHAR_SEQUENCE_VALUE_2 } }
+
+ assertThat(actual).isEqualTo(CHAR_SEQUENCE_VALUE_2)
+ }
+
+ @Test
+ fun getCharSequenceOrElse_whenSet_differentType_returnsElse() {
+ val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+ val actual = underTest.read { getCharSequenceOrElse(KEY_1) { CHAR_SEQUENCE_VALUE_2 } }
+
+ assertThat(actual).isEqualTo(CHAR_SEQUENCE_VALUE_2)
+ }
+
+ @Test
fun getDouble_whenSet_returns() {
val underTest = savedState { putDouble(KEY_1, Double.MAX_VALUE) }
val actual = underTest.read { getDouble(KEY_1) }
@@ -625,6 +668,53 @@
}
@Test
+ fun getCharSequenceList_whenSet_returns() {
+ val underTest = savedState { putCharSequenceList(KEY_1, CHAR_SEQUENCE_LIST) }
+ val actual = underTest.read { getCharSequenceList(KEY_1) }
+
+ assertThat(actual).isEqualTo(CHAR_SEQUENCE_LIST)
+ }
+
+ @Test
+ fun getCharSequenceList_whenNotSet_throws() {
+ assertThrows<IllegalArgumentException> { savedState().read { getCharSequenceList(KEY_1) } }
+ }
+
+ @Test
+ fun getCharSequenceList_whenSet_differentType_throws() {
+ val expected = Int.MAX_VALUE
+
+ val underTest = savedState { putInt(KEY_1, expected) }
+
+ assertThrows<IllegalStateException> { underTest.read { getCharSequenceList(KEY_1) } }
+ }
+
+ @Test
+ fun getCharSequenceListOrElse_whenSet_returns() {
+ val underTest = savedState { putCharSequenceList(KEY_1, CHAR_SEQUENCE_LIST) }
+ val actual = underTest.read { getCharSequenceListOrElse(KEY_1) { emptyList() } }
+
+ assertThat(actual).isEqualTo(CHAR_SEQUENCE_LIST)
+ }
+
+ @Test
+ fun getCharSequenceListOrElse_whenNotSet_returnsElse() {
+ val actual = savedState().read { getCharSequenceListOrElse(KEY_1) { emptyList() } }
+
+ assertThat(actual).isEqualTo(emptyList<CharSequence>())
+ }
+
+ @Test
+ fun getCharSequenceListOrElse_whenSet_differentType_returnsElse() {
+ val expected = Int.MAX_VALUE
+
+ val underTest = savedState { putInt(KEY_1, expected) }
+ val actual = underTest.read { getCharSequenceListOrElse(KEY_1) { emptyList() } }
+
+ assertThat(actual).isEqualTo(emptyList<CharSequence>())
+ }
+
+ @Test
fun getStringList_whenSet_returns() {
val underTest = savedState { putStringList(KEY_1, LIST_STRING_VALUE) }
val actual = underTest.read { getStringList(KEY_1) }
@@ -778,6 +868,59 @@
}
@Test
+ fun getCharSequenceArray_whenSet_returns() {
+ val expected = Array<CharSequence>(size = 5) { idx -> idx.toString() }
+
+ val underTest = savedState { putCharSequenceArray(KEY_1, expected) }
+ val actual = underTest.read { getCharSequenceArray(KEY_1) }
+
+ assertThat(actual).isEqualTo(expected)
+ }
+
+ @Test
+ fun getCharSequenceArray_whenNotSet_throws() {
+ assertThrows<IllegalArgumentException> { savedState().read { getCharSequenceArray(KEY_1) } }
+ }
+
+ @Test
+ fun getCharSequenceArray_whenSet_differentType_throws() {
+ val expected = Int.MAX_VALUE
+
+ val underTest = savedState { putInt(KEY_1, expected) }
+
+ assertThrows<IllegalStateException> { underTest.read { getCharSequenceArray(KEY_1) } }
+ }
+
+ @Test
+ fun getCharSequenceArrayOrElse_whenSet_returns() {
+ val expected = CHAR_SEQUENCE_ARRAY
+
+ val underTest = savedState { putCharSequenceArray(KEY_1, expected) }
+ val actual = underTest.read { getCharSequenceArrayOrElse(KEY_1) { emptyArray() } }
+
+ assertThat(actual).isEqualTo(expected)
+ }
+
+ @Test
+ fun getCharSequenceArrayOrElse_whenNotSet_returnsElse() {
+ val expected = CHAR_SEQUENCE_ARRAY
+
+ val actual = savedState().read { getCharSequenceArrayOrElse(KEY_1) { expected } }
+
+ assertThat(actual).isEqualTo(expected)
+ }
+
+ @Test
+ fun getCharSequenceArrayOrElse_whenSet_differentType_returnsElse() {
+ val expected = CHAR_SEQUENCE_ARRAY
+
+ val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+ val actual = underTest.read { getCharSequenceArrayOrElse(KEY_1) { expected } }
+
+ assertThat(actual).isEqualTo(expected)
+ }
+
+ @Test
fun getDoubleArray_whenSet_returns() {
val expected = DoubleArray(size = 5) { idx -> idx.toDouble() }
@@ -1109,6 +1252,10 @@
val LIST_INT_VALUE = List(size = 5) { idx -> idx }
val LIST_STRING_VALUE = List(size = 5) { idx -> "index=$idx" }
val SAVED_STATE_VALUE = savedState()
+ val CHAR_SEQUENCE_VALUE_1: CharSequence = Int.MIN_VALUE.toString()
+ val CHAR_SEQUENCE_VALUE_2: CharSequence = Int.MAX_VALUE.toString()
+ val CHAR_SEQUENCE_ARRAY = Array<CharSequence>(size = 5) { idx -> "index=$idx" }
+ val CHAR_SEQUENCE_LIST = List<CharSequence>(size = 5) { idx -> "index=$idx" }
private fun createDefaultSavedState(): SavedState {
var key = 0
diff --git a/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateReader.nonAndroid.kt b/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateReader.nonAndroid.kt
index 5a36f36..b18561a 100644
--- a/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateReader.nonAndroid.kt
+++ b/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateReader.nonAndroid.kt
@@ -51,6 +51,19 @@
return source.map[key] as? Char ?: defaultValue()
}
+ actual inline fun getCharSequence(key: String): CharSequence {
+ if (key !in this) keyNotFoundError(key)
+ return source.map[key] as? CharSequence ?: valueNotFoundError(key)
+ }
+
+ actual inline fun getCharSequenceOrElse(
+ key: String,
+ defaultValue: () -> CharSequence
+ ): CharSequence {
+ if (key !in this) defaultValue()
+ return source.map[key] as? CharSequence ?: defaultValue()
+ }
+
actual inline fun getDouble(key: String): Double {
if (key !in this) keyNotFoundError(key)
return source.map[key] as? Double ?: DEFAULT_DOUBLE
@@ -101,31 +114,42 @@
return source.map[key] as? String ?: defaultValue()
}
- @Suppress("UNCHECKED_CAST")
+ actual inline fun getCharSequenceList(key: String): List<CharSequence> {
+ if (key !in this) keyNotFoundError(key)
+ @Suppress("UNCHECKED_CAST")
+ return source.map[key] as? List<CharSequence> ?: valueNotFoundError(key)
+ }
+
+ actual inline fun getCharSequenceListOrElse(
+ key: String,
+ defaultValue: () -> List<CharSequence>
+ ): List<CharSequence> {
+ if (key !in this) defaultValue()
+ @Suppress("UNCHECKED_CAST") return source.map[key] as? List<CharSequence> ?: defaultValue()
+ }
+
actual inline fun getIntList(key: String): List<Int> {
if (key !in this) keyNotFoundError(key)
- return source.map[key] as? List<Int> ?: valueNotFoundError(key)
+ @Suppress("UNCHECKED_CAST") return source.map[key] as? List<Int> ?: valueNotFoundError(key)
}
- @Suppress("UNCHECKED_CAST")
actual inline fun getIntListOrElse(key: String, defaultValue: () -> List<Int>): List<Int> {
if (key !in this) defaultValue()
- return source.map[key] as? List<Int> ?: defaultValue()
+ @Suppress("UNCHECKED_CAST") return source.map[key] as? List<Int> ?: defaultValue()
}
- @Suppress("UNCHECKED_CAST")
actual inline fun getStringList(key: String): List<String> {
if (key !in this) keyNotFoundError(key)
+ @Suppress("UNCHECKED_CAST")
return source.map[key] as? List<String> ?: valueNotFoundError(key)
}
- @Suppress("UNCHECKED_CAST")
actual inline fun getStringListOrElse(
key: String,
defaultValue: () -> List<String>
): List<String> {
if (key !in this) defaultValue()
- return source.map[key] as? List<String> ?: defaultValue()
+ @Suppress("UNCHECKED_CAST") return source.map[key] as? List<String> ?: defaultValue()
}
actual inline fun getCharArray(key: String): CharArray {
@@ -138,6 +162,20 @@
return source.map[key] as? CharArray ?: defaultValue()
}
+ actual inline fun getCharSequenceArray(key: String): Array<CharSequence> {
+ if (key !in this) keyNotFoundError(key)
+ @Suppress("UNCHECKED_CAST")
+ return source.map[key] as? Array<CharSequence> ?: valueNotFoundError(key)
+ }
+
+ actual inline fun getCharSequenceArrayOrElse(
+ key: String,
+ defaultValue: () -> Array<CharSequence>
+ ): Array<CharSequence> {
+ if (key !in this) defaultValue()
+ @Suppress("UNCHECKED_CAST") return source.map[key] as? Array<CharSequence> ?: defaultValue()
+ }
+
actual inline fun getBooleanArray(key: String): BooleanArray {
if (key !in this) keyNotFoundError(key)
return source.map[key] as? BooleanArray ?: valueNotFoundError(key)
diff --git a/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateWriter.nonAndroid.kt b/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateWriter.nonAndroid.kt
index 95cecc4..88fac06 100644
--- a/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateWriter.nonAndroid.kt
+++ b/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateWriter.nonAndroid.kt
@@ -39,6 +39,10 @@
source.map[key] = value
}
+ actual inline fun putCharSequence(key: String, value: CharSequence) {
+ source.map[key] = value
+ }
+
actual inline fun putDouble(key: String, value: Double) {
source.map[key] = value
}
@@ -63,6 +67,10 @@
source.map[key] = value
}
+ actual inline fun putCharSequenceList(key: String, values: List<CharSequence>) {
+ source.map[key] = values
+ }
+
actual inline fun putIntList(key: String, values: List<Int>) {
source.map[key] = values
}
@@ -79,6 +87,10 @@
source.map[key] = values
}
+ actual inline fun putCharSequenceArray(key: String, values: Array<CharSequence>) {
+ source.map[key] = values
+ }
+
actual inline fun putDoubleArray(key: String, values: DoubleArray) {
source.map[key] = values
}
diff --git a/security/security-state/build.gradle b/security/security-state/build.gradle
index 3ed2917..027680e 100644
--- a/security/security-state/build.gradle
+++ b/security/security-state/build.gradle
@@ -27,13 +27,13 @@
id("AndroidXPlugin")
id("com.android.library")
id("org.jetbrains.kotlin.android")
+ alias(libs.plugins.kotlinSerialization)
}
dependencies {
api("androidx.annotation:annotation:1.8.1")
- api(libs.kotlinStdlib)
api(libs.kotlinCoroutinesCore)
- implementation("com.google.code.gson:gson:2.10.1")
+ implementation(libs.kotlinSerializationJson)
implementation("androidx.core:core:1.12.0")
implementation("androidx.webkit:webkit:1.11.0")
@@ -56,9 +56,6 @@
android {
compileSdk 35
namespace "androidx.security.state"
- defaultConfig {
- consumerProguardFiles 'proguard-rules.pro'
- }
}
androidx {
diff --git a/security/security-state/proguard-rules.pro b/security/security-state/proguard-rules.pro
deleted file mode 100644
index be33693..0000000
--- a/security/security-state/proguard-rules.pro
+++ /dev/null
@@ -1 +0,0 @@
--keepclassmembers class androidx.security.state.SecurityPatchState$** { *; }
\ No newline at end of file
diff --git a/security/security-state/src/main/java/androidx/security/state/SecurityPatchState.kt b/security/security-state/src/main/java/androidx/security/state/SecurityPatchState.kt
index 4496a2a..d45c24b 100644
--- a/security/security-state/src/main/java/androidx/security/state/SecurityPatchState.kt
+++ b/security/security-state/src/main/java/androidx/security/state/SecurityPatchState.kt
@@ -24,15 +24,16 @@
import androidx.security.state.SecurityStateManager.Companion.KEY_KERNEL_VERSION
import androidx.security.state.SecurityStateManager.Companion.KEY_SYSTEM_SPL
import androidx.security.state.SecurityStateManager.Companion.KEY_VENDOR_SPL
-import com.google.gson.Gson
-import com.google.gson.JsonSyntaxException
-import com.google.gson.annotations.SerializedName
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import java.util.regex.Pattern
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.json.Json
/**
* Provides methods to access and manage security state information for various components within a
@@ -308,20 +309,21 @@
public fun getBuildVersion(): Int = buildVersion
}
+ @Serializable
private data class VulnerabilityReport(
/* Key is the SPL date yyyy-MM-dd */
- @SerializedName("vulnerabilities")
val vulnerabilities: Map<String, List<VulnerabilityGroup>>,
/* Key is the SPL date yyyy-MM-dd, values are kernel versions */
- @SerializedName("kernel_lts_versions") val kernelLtsVersions: Map<String, List<String>>
+ @SerialName("kernel_lts_versions") val kernelLtsVersions: Map<String, List<String>>
)
+ @Serializable
private data class VulnerabilityGroup(
- @SerializedName("cve_identifiers") val cveIdentifiers: List<String>,
- @SerializedName("asb_identifiers") val asbIdentifiers: List<String>,
- @SerializedName("severity") val severity: String,
- @SerializedName("components") val components: List<String>
+ @SerialName("cve_identifiers") val cveIdentifiers: List<String>,
+ @SerialName("asb_identifiers") val asbIdentifiers: List<String>,
+ val severity: String,
+ val components: List<String>
)
/**
@@ -354,8 +356,9 @@
val result: VulnerabilityReport
try {
- result = Gson().fromJson(jsonString, VulnerabilityReport::class.java)
- } catch (e: JsonSyntaxException) {
+ val json = Json { ignoreUnknownKeys = true }
+ result = json.decodeFromString<VulnerabilityReport>(jsonString)
+ } catch (e: SerializationException) {
throw IllegalArgumentException("Malformed JSON input: ${e.message}")
}
@@ -794,7 +797,6 @@
val updates = mutableListOf<UpdateInfo>()
val contentResolver = context.contentResolver
- val gson = Gson()
updateInfoProviders.forEach { providerUri ->
val cursor = contentResolver.query(providerUri, arrayOf("json"), null, null, null)
@@ -802,7 +804,9 @@
while (it.moveToNext()) {
val json = it.getString(it.getColumnIndexOrThrow("json"))
try {
- val updateInfo = gson.fromJson(json, UpdateInfo::class.java) ?: continue
+ val serializableUpdateInfo =
+ Json.decodeFromString<SerializableUpdateInfo>(json)
+ val updateInfo: UpdateInfo = serializableUpdateInfo.toUpdateInfo()
val component = updateInfo.component
val deviceSpl = getDeviceSecurityPatchLevel(component)
diff --git a/security/security-state/src/main/java/androidx/security/state/UpdateInfo.kt b/security/security-state/src/main/java/androidx/security/state/UpdateInfo.kt
index 8ef484f..49b29b45 100644
--- a/security/security-state/src/main/java/androidx/security/state/UpdateInfo.kt
+++ b/security/security-state/src/main/java/androidx/security/state/UpdateInfo.kt
@@ -16,8 +16,39 @@
package androidx.security.state
+import java.text.SimpleDateFormat
import java.util.Date
import java.util.Objects
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+
+private object DateSerializer : KSerializer<Date> {
+ override val descriptor: SerialDescriptor =
+ PrimitiveSerialDescriptor("Date", PrimitiveKind.STRING)
+
+ private val dateFormat: SimpleDateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'")
+
+ override fun serialize(encoder: Encoder, value: Date): Unit =
+ encoder.encodeString(dateFormat.format(value))
+
+ override fun deserialize(decoder: Decoder): Date = dateFormat.parse(decoder.decodeString())!!
+}
+
+@Serializable
+internal class SerializableUpdateInfo(
+ private val uri: String,
+ private val component: String,
+ private val securityPatchLevel: String,
+ @Serializable(with = DateSerializer::class) private val publishedDate: Date
+) {
+ internal fun toUpdateInfo(): UpdateInfo =
+ UpdateInfo(uri, component, securityPatchLevel, publishedDate)
+}
/** Represents information about an available update for a component. */
public class UpdateInfo(
@@ -33,6 +64,10 @@
/** Date when the available update was published. */
public val publishedDate: Date
) {
+
+ internal fun toSerializableUpdateInfo(): SerializableUpdateInfo =
+ SerializableUpdateInfo(uri, component, securityPatchLevel, publishedDate)
+
/**
* Returns a string representation of the update information.
*
diff --git a/security/security-state/src/main/java/androidx/security/state/UpdateInfoProvider.kt b/security/security-state/src/main/java/androidx/security/state/UpdateInfoProvider.kt
index 7dec3e4..1c6c6e6 100644
--- a/security/security-state/src/main/java/androidx/security/state/UpdateInfoProvider.kt
+++ b/security/security-state/src/main/java/androidx/security/state/UpdateInfoProvider.kt
@@ -22,7 +22,7 @@
import android.database.Cursor
import android.database.MatrixCursor
import android.net.Uri
-import com.google.gson.Gson
+import kotlinx.serialization.json.Json
/**
* A content provider for managing and serving update information for system components. This class
@@ -179,7 +179,11 @@
val sharedPreferences = context.getSharedPreferences(updateInfoPrefs, Context.MODE_PRIVATE)
val editor = sharedPreferences?.edit()
val key = getKeyForUpdateInfo(updateInfo)
- val json = Gson().toJson(updateInfo)
+ val json =
+ Json.encodeToString(
+ SerializableUpdateInfo.serializer(),
+ updateInfo.toSerializableUpdateInfo()
+ )
editor?.putString(key, json)
editor?.apply()
}
@@ -218,7 +222,8 @@
for ((_, value) in allEntries) {
val json = value as? String
if (json != null) {
- val updateInfo: UpdateInfo = Gson().fromJson(json, UpdateInfo::class.java)
+ val serializableUpdateInfo: SerializableUpdateInfo = Json.decodeFromString(json)
+ val updateInfo: UpdateInfo = serializableUpdateInfo.toUpdateInfo()
allUpdates.add(updateInfo)
}
}
diff --git a/security/security-state/src/test/java/androidx/security/state/SecurityPatchStateTest.kt b/security/security-state/src/test/java/androidx/security/state/SecurityPatchStateTest.kt
index d3a8f6fd..7ab74c2 100644
--- a/security/security-state/src/test/java/androidx/security/state/SecurityPatchStateTest.kt
+++ b/security/security-state/src/test/java/androidx/security/state/SecurityPatchStateTest.kt
@@ -28,10 +28,10 @@
import android.os.Bundle
import androidx.annotation.RequiresApi
import androidx.test.ext.junit.runners.AndroidJUnit4
-import com.google.gson.Gson
import java.time.LocalDate
import java.time.ZoneOffset
import java.util.Date
+import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
@@ -56,6 +56,11 @@
.setSecurityPatchLevel("2022-01-01")
.setPublishedDate(Date.from(LocalDate.now().atStartOfDay().toInstant(ZoneOffset.UTC)))
.build()
+ private val updateInfoJson =
+ Json.encodeToString(
+ SerializableUpdateInfo.serializer(),
+ updateInfo.toSerializableUpdateInfo()
+ )
private val mockEmptyEditor: SharedPreferences.Editor = mock<SharedPreferences.Editor> {}
private val mockEditor: SharedPreferences.Editor =
mock<SharedPreferences.Editor> {
@@ -65,7 +70,7 @@
private val mockPrefs: SharedPreferences =
mock<SharedPreferences> {
on { edit() } doReturn mockEditor
- on { all } doReturn mapOf(Pair("key", Gson().toJson(updateInfo)))
+ on { all } doReturn mapOf(Pair("key", updateInfoJson))
}
private val mockPackageManager: PackageManager =
mock<PackageManager> {
@@ -77,7 +82,7 @@
mock<Cursor> {
on { moveToNext() } doReturn true doReturn false doReturn true doReturn false
on { getColumnIndexOrThrow(Mockito.eq("json")) } doReturn 123
- on { getString(Mockito.eq(123)) } doReturn Gson().toJson(updateInfo)
+ on { getString(Mockito.eq(123)) } doReturn updateInfoJson
}
private val mockContentResolver: ContentResolver =
mock<ContentResolver> {
diff --git a/security/security-state/src/test/java/androidx/security/state/UpdateInfoProviderTest.kt b/security/security-state/src/test/java/androidx/security/state/UpdateInfoProviderTest.kt
index ec7c5f8..6d3aff3 100644
--- a/security/security-state/src/test/java/androidx/security/state/UpdateInfoProviderTest.kt
+++ b/security/security-state/src/test/java/androidx/security/state/UpdateInfoProviderTest.kt
@@ -23,10 +23,10 @@
import android.net.Uri
import androidx.security.state.SecurityPatchState.Companion.COMPONENT_SYSTEM
import androidx.test.ext.junit.runners.AndroidJUnit4
-import com.google.gson.Gson
import java.time.LocalDate
import java.time.ZoneOffset
import java.util.Date
+import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
@@ -63,7 +63,11 @@
.setSecurityPatchLevel("2022-01-01")
.setPublishedDate(publishedDate)
.build()
- private val expectedJson = Gson().toJson(updateInfo)
+ private val expectedJson =
+ Json.encodeToString(
+ SerializableUpdateInfo.serializer(),
+ updateInfo.toSerializableUpdateInfo()
+ )
private val mockEmptyEditor: SharedPreferences.Editor = mock<SharedPreferences.Editor> {}
private val mockEditor: SharedPreferences.Editor =
mock<SharedPreferences.Editor> {
@@ -73,7 +77,7 @@
private val mockPrefs: SharedPreferences =
mock<SharedPreferences> {
on { edit() } doReturn mockEditor
- on { all } doReturn mapOf(Pair("key", Gson().toJson(updateInfo)))
+ on { all } doReturn mapOf(Pair("key", expectedJson))
}
private val mockContext: Context =
mock<Context> {
diff --git a/settings.gradle b/settings.gradle
index 010367a..a6fe1d8 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -463,8 +463,8 @@
includeProject(":car:app:app-testing", [BuildType.MAIN])
includeProject(":cardview:cardview", [BuildType.MAIN])
includeProject(":collection:collection", [BuildType.MAIN, BuildType.INFRAROGUE, BuildType.KMP])
-includeProject(":collection:collection-benchmark", [BuildType.MAIN, BuildType.INFRAROGUE, BuildType.KMP])
-includeProject(":collection:collection-benchmark-kmp", [BuildType.MAIN, BuildType.INFRAROGUE, BuildType.KMP])
+includeProject(":collection:collection-benchmark", [BuildType.INFRAROGUE, BuildType.KMP])
+includeProject(":collection:collection-benchmark-kmp", [BuildType.INFRAROGUE, BuildType.KMP])
includeProject(":collection:collection-ktx", [BuildType.MAIN, BuildType.INFRAROGUE, BuildType.KMP])
includeProject(":collection:integration-tests:testapp", [BuildType.MAIN, BuildType.INFRAROGUE, BuildType.KMP])
includeProject(":compose:animation", [BuildType.COMPOSE])
diff --git a/slidingpanelayout/slidingpanelayout/api/current.txt b/slidingpanelayout/slidingpanelayout/api/current.txt
index e49a46f..b591f76 100644
--- a/slidingpanelayout/slidingpanelayout/api/current.txt
+++ b/slidingpanelayout/slidingpanelayout/api/current.txt
@@ -46,6 +46,7 @@
method public final void setUserResizeBehavior(androidx.slidingpanelayout.widget.SlidingPaneLayout.UserResizeBehavior userResizeBehavior);
method public final void setUserResizingDividerDrawable(android.graphics.drawable.Drawable? drawable);
method public final void setUserResizingDividerDrawable(@DrawableRes int resId);
+ method public final void setUserResizingDividerTint(android.content.res.ColorStateList? colorStateList);
method public final void setUserResizingEnabled(boolean);
method @Deprecated public void smoothSlideClosed();
method @Deprecated public void smoothSlideOpen();
diff --git a/slidingpanelayout/slidingpanelayout/api/res-current.txt b/slidingpanelayout/slidingpanelayout/api/res-current.txt
index 0d72e80..88c866a 100644
--- a/slidingpanelayout/slidingpanelayout/api/res-current.txt
+++ b/slidingpanelayout/slidingpanelayout/api/res-current.txt
@@ -3,3 +3,4 @@
attr isUserResizingEnabled
attr userResizeBehavior
attr userResizingDividerDrawable
+attr userResizingDividerTint
diff --git a/slidingpanelayout/slidingpanelayout/api/restricted_current.txt b/slidingpanelayout/slidingpanelayout/api/restricted_current.txt
index e49a46f..b591f76 100644
--- a/slidingpanelayout/slidingpanelayout/api/restricted_current.txt
+++ b/slidingpanelayout/slidingpanelayout/api/restricted_current.txt
@@ -46,6 +46,7 @@
method public final void setUserResizeBehavior(androidx.slidingpanelayout.widget.SlidingPaneLayout.UserResizeBehavior userResizeBehavior);
method public final void setUserResizingDividerDrawable(android.graphics.drawable.Drawable? drawable);
method public final void setUserResizingDividerDrawable(@DrawableRes int resId);
+ method public final void setUserResizingDividerTint(android.content.res.ColorStateList? colorStateList);
method public final void setUserResizingEnabled(boolean);
method @Deprecated public void smoothSlideClosed();
method @Deprecated public void smoothSlideOpen();
diff --git a/slidingpanelayout/slidingpanelayout/src/androidTest/java/androidx/slidingpanelayout/widget/UserResizeDividerTintTest.kt b/slidingpanelayout/slidingpanelayout/src/androidTest/java/androidx/slidingpanelayout/widget/UserResizeDividerTintTest.kt
new file mode 100644
index 0000000..fe06388
--- /dev/null
+++ b/slidingpanelayout/slidingpanelayout/src/androidTest/java/androidx/slidingpanelayout/widget/UserResizeDividerTintTest.kt
@@ -0,0 +1,94 @@
+/*
+ * 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.slidingpanelayout.widget
+
+import android.content.res.ColorStateList
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.ColorFilter
+import android.graphics.PixelFormat
+import android.graphics.drawable.Drawable
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import androidx.test.platform.app.InstrumentationRegistry
+import com.google.common.truth.Truth.assertWithMessage
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+class UserResizeDividerTintTest {
+ @Test
+ fun userResizingDividerTint_tintIsSetToDrawable() {
+ val context = InstrumentationRegistry.getInstrumentation().context
+ val view = SlidingPaneLayout(context)
+ val drawable = TestDrawable()
+
+ view.setUserResizingDividerDrawable(drawable)
+ val tint = ColorStateList.valueOf(Color.RED)
+ view.setUserResizingDividerTint(tint)
+
+ assertWithMessage("userResizingDividerTint is set to drawable")
+ .that(drawable.tint)
+ .isEqualTo(tint)
+ }
+
+ @Test
+ fun userResizingDividerTint_setDrawableAfterTint_tintIsNotSetToDrawable() {
+ val context = InstrumentationRegistry.getInstrumentation().context
+ val view = SlidingPaneLayout(context)
+ val drawable = TestDrawable()
+
+ val tint = ColorStateList.valueOf(Color.RED)
+ view.setUserResizingDividerTint(tint)
+
+ view.setUserResizingDividerDrawable(drawable)
+ assertWithMessage("userResizingDividerTint is not set to drawable")
+ .that(drawable.tint)
+ .isNull()
+ }
+
+ @Test
+ fun userResizingDividerTint_setTintToNull() {
+ val context = InstrumentationRegistry.getInstrumentation().context
+ val view = SlidingPaneLayout(context)
+ val drawable = TestDrawable().apply { setTintList(ColorStateList.valueOf(Color.RED)) }
+ view.setUserResizingDividerDrawable(drawable)
+
+ view.setUserResizingDividerTint(null)
+ assertWithMessage("userResizingDividerTint is set to null").that(drawable.tint).isNull()
+ }
+}
+
+private class TestDrawable : Drawable() {
+ var tint: ColorStateList? = null
+ private set
+
+ override fun draw(canvas: Canvas) {}
+
+ override fun setAlpha(alpha: Int) {}
+
+ override fun setColorFilter(colorFilter: ColorFilter?) {}
+
+ @Suppress("DeprecatedCallableAddReplaceWith")
+ @Deprecated("Deprecated in Java")
+ override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
+
+ override fun setTintList(tint: ColorStateList?) {
+ this.tint = tint
+ }
+}
diff --git a/slidingpanelayout/slidingpanelayout/src/androidTest/res/layout/user_resize_divider_tint.xml b/slidingpanelayout/slidingpanelayout/src/androidTest/res/layout/user_resize_divider_tint.xml
new file mode 100644
index 0000000..4d7a361
--- /dev/null
+++ b/slidingpanelayout/slidingpanelayout/src/androidTest/res/layout/user_resize_divider_tint.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+ 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.
+ -->
+
+<androidx.slidingpanelayout.widget.SlidingPaneLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ app:isUserResizingEnabled="true"
+ app:userResizingDividerDrawable="@android:drawable/ic_menu_add"
+ app:userResizingDividerTint="@android:color/primary_text_dark">
+
+</androidx.slidingpanelayout.widget.SlidingPaneLayout>
\ No newline at end of file
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 5b023fe..729b2b9 100644
--- a/slidingpanelayout/slidingpanelayout/src/main/java/androidx/slidingpanelayout/widget/SlidingPaneLayout.kt
+++ b/slidingpanelayout/slidingpanelayout/src/main/java/androidx/slidingpanelayout/widget/SlidingPaneLayout.kt
@@ -17,6 +17,7 @@
package androidx.slidingpanelayout.widget
import android.content.Context
+import android.content.res.ColorStateList
import android.graphics.Canvas
import android.graphics.Rect
import android.graphics.drawable.Drawable
@@ -427,6 +428,16 @@
setUserResizingDividerDrawable(ContextCompat.getDrawable(context, resId))
}
+ /**
+ * The tint color for the resizing divider [Drawable] which is set by
+ * [setUserResizingDividerDrawable]. This may also be set from `userResizingDividerTint` XML
+ * attribute during the view inflation. Note: the tint is not retained after calling
+ * [setUserResizingDividerDrawable].
+ */
+ fun setUserResizingDividerTint(colorStateList: ColorStateList?) {
+ userResizingDividerDrawable?.apply { setTintList(colorStateList) }
+ }
+
/** `true` if the user is currently dragging the [user resizing divider][isUserResizable] */
val isDividerDragging: Boolean
get() = draggableDividerHandler.isDragging
@@ -581,6 +592,11 @@
getBoolean(R.styleable.SlidingPaneLayout_isUserResizingEnabled, false)
userResizingDividerDrawable =
getDrawable(R.styleable.SlidingPaneLayout_userResizingDividerDrawable)
+ // It won't override the tint on drawable if userResizingDividerTint is not specified.
+ getColorStateList(R.styleable.SlidingPaneLayout_userResizingDividerTint)?.apply {
+ setUserResizingDividerTint(this)
+ }
+
isChildClippingToResizeDividerEnabled =
getBoolean(
R.styleable.SlidingPaneLayout_isChildClippingToResizeDividerEnabled,
diff --git a/slidingpanelayout/slidingpanelayout/src/main/res/drawable/slidingpanelayout_divider.xml b/slidingpanelayout/slidingpanelayout/src/main/res/drawable/slidingpanelayout_divider.xml
index c5fd33b..f50ac30 100644
--- a/slidingpanelayout/slidingpanelayout/src/main/res/drawable/slidingpanelayout_divider.xml
+++ b/slidingpanelayout/slidingpanelayout/src/main/res/drawable/slidingpanelayout_divider.xml
@@ -15,10 +15,12 @@
limitations under the License.
-->
-<shape
- xmlns:android="http://schemas.android.com/apk/res/android"
- android:shape="rectangle">
- <solid android:color="#c0555555"/>
- <size android:width="8dp" android:height="80dp"/>
- <corners android:radius="4dp"/>
-</shape>
+<selector xmlns:android="http://schemas.android.com/apk/res/android">
+ <!-- Pressed state -->
+ <item
+ android:state_pressed="true"
+ android:drawable="@drawable/slidingpanelayout_divider_pressed" />
+ <!-- Default state -->
+ <item
+ android:drawable="@drawable/slidingpanelayout_divider_default"/>
+</selector>
\ No newline at end of file
diff --git a/slidingpanelayout/slidingpanelayout/src/main/res/drawable/slidingpanelayout_divider_default.xml b/slidingpanelayout/slidingpanelayout/src/main/res/drawable/slidingpanelayout_divider_default.xml
new file mode 100644
index 0000000..cdcc39b1
--- /dev/null
+++ b/slidingpanelayout/slidingpanelayout/src/main/res/drawable/slidingpanelayout_divider_default.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+ 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.
+ -->
+
+<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
+ <size android:height="48dp" android:width="4dp" />
+ <corners android:radius="2dp" />
+ <solid android:color="#ff444746" />
+</shape>
\ No newline at end of file
diff --git a/slidingpanelayout/slidingpanelayout/src/main/res/drawable/slidingpanelayout_divider_pressed.xml b/slidingpanelayout/slidingpanelayout/src/main/res/drawable/slidingpanelayout_divider_pressed.xml
new file mode 100644
index 0000000..4612c86
--- /dev/null
+++ b/slidingpanelayout/slidingpanelayout/src/main/res/drawable/slidingpanelayout_divider_pressed.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+ 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.
+ -->
+
+<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
+ <size android:height="52dp" android:width="12dp" />
+ <corners android:radius="6dp" />
+ <solid android:color="#ff1f1f1f" />
+</shape>
\ No newline at end of file
diff --git a/slidingpanelayout/slidingpanelayout/src/main/res/values/attrs.xml b/slidingpanelayout/slidingpanelayout/src/main/res/values/attrs.xml
index 59f02be..14c032e 100644
--- a/slidingpanelayout/slidingpanelayout/src/main/res/values/attrs.xml
+++ b/slidingpanelayout/slidingpanelayout/src/main/res/values/attrs.xml
@@ -19,6 +19,7 @@
<attr name="isOverlappingEnabled" format="boolean"/>
<attr name="isUserResizingEnabled" format="boolean"/>
<attr name="userResizingDividerDrawable" format="reference"/>
+ <attr name="userResizingDividerTint" format="color"/>
<attr name="isChildClippingToResizeDividerEnabled" format="boolean"/>
<attr name="userResizeBehavior" format="enum">
<enum name="relayoutWhenComplete" value="0"/>
diff --git a/slidingpanelayout/slidingpanelayout/src/main/res/values/public.xml b/slidingpanelayout/slidingpanelayout/src/main/res/values/public.xml
index 497b058..c8e7a8f 100644
--- a/slidingpanelayout/slidingpanelayout/src/main/res/values/public.xml
+++ b/slidingpanelayout/slidingpanelayout/src/main/res/values/public.xml
@@ -18,6 +18,7 @@
<public type="attr" name="isOverlappingEnabled"/>
<public type="attr" name="isUserResizingEnabled"/>
<public type="attr" name="userResizingDividerDrawable"/>
+ <public type="attr" name="userResizingDividerTint"/>
<public type="attr" name="isChildClippingToResizeDividerEnabled"/>
<public type="attr" name="userResizeBehavior"/>
</resources>
diff --git a/transition/transition-ktx/build.gradle b/transition/transition-ktx/build.gradle
index f6609db..7f2bb69 100644
--- a/transition/transition-ktx/build.gradle
+++ b/transition/transition-ktx/build.gradle
@@ -49,4 +49,5 @@
android {
namespace "androidx.transition.ktx"
+ compileSdk 35
}
diff --git a/transition/transition/build.gradle b/transition/transition/build.gradle
index ba98363..ebdf43b 100644
--- a/transition/transition/build.gradle
+++ b/transition/transition/build.gradle
@@ -15,7 +15,7 @@
dependencies {
api("androidx.annotation:annotation:1.8.1")
- api("androidx.core:core:1.13.0")
+ api(project(":core:core"))
implementation("androidx.collection:collection:1.4.2")
compileOnly("androidx.fragment:fragment:1.7.0")
compileOnly("androidx.appcompat:appcompat:1.0.1")
@@ -39,6 +39,7 @@
}
android {
+ compileSdk 35
buildTypes.configureEach {
consumerProguardFiles "proguard-rules.pro"
}
diff --git a/transition/transition/src/main/java/androidx/transition/GhostViewHolder.java b/transition/transition/src/main/java/androidx/transition/GhostViewHolder.java
index 95667f4..bd9137b 100644
--- a/transition/transition/src/main/java/androidx/transition/GhostViewHolder.java
+++ b/transition/transition/src/main/java/androidx/transition/GhostViewHolder.java
@@ -25,6 +25,7 @@
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
+import androidx.core.view.ViewCompat;
import java.util.ArrayList;
@@ -40,7 +41,7 @@
setClipChildren(false);
mParent = parent;
mParent.setTag(R.id.ghost_view_holder, this);
- mParent.getOverlay().add(this);
+ ViewCompat.addOverlayView(mParent, this);
mAttached = true;
}
diff --git a/transition/transition/src/main/java/androidx/transition/TransitionUtils.java b/transition/transition/src/main/java/androidx/transition/TransitionUtils.java
index f4359b2..5896e52 100644
--- a/transition/transition/src/main/java/androidx/transition/TransitionUtils.java
+++ b/transition/transition/src/main/java/androidx/transition/TransitionUtils.java
@@ -31,6 +31,7 @@
import android.widget.ImageView;
import androidx.annotation.RequiresApi;
+import androidx.core.view.ViewCompat;
class TransitionUtils {
@@ -99,8 +100,7 @@
}
parent = (ViewGroup) view.getParent();
indexInParent = parent.indexOfChild(view);
- ViewGroupOverlay result = sceneRoot.getOverlay();
- result.add(view);
+ ViewCompat.addOverlayView(sceneRoot, view);
}
Bitmap bitmap = null;
int bitmapWidth = Math.round(bounds.width());
diff --git a/transition/transition/src/main/java/androidx/transition/Visibility.java b/transition/transition/src/main/java/androidx/transition/Visibility.java
index 543541a..9d87ee2 100644
--- a/transition/transition/src/main/java/androidx/transition/Visibility.java
+++ b/transition/transition/src/main/java/androidx/transition/Visibility.java
@@ -33,6 +33,7 @@
import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
import androidx.core.content.res.TypedArrayUtils;
+import androidx.core.view.ViewCompat;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@@ -426,7 +427,7 @@
sceneRoot.getLocationOnScreen(loc);
overlayView.offsetLeftAndRight((screenX - loc[0]) - overlayView.getLeft());
overlayView.offsetTopAndBottom((screenY - loc[1]) - overlayView.getTop());
- sceneRoot.getOverlay().add(overlayView);
+ ViewCompat.addOverlayView(sceneRoot, overlayView);
}
Animator animator = onDisappear(sceneRoot, overlayView, startValues, endValues);
if (!reusingOverlayView) {
@@ -628,7 +629,7 @@
@Override
public void onAnimationResume(Animator animation) {
if (mOverlayView.getParent() == null) {
- mOverlayHost.getOverlay().add(mOverlayView);
+ ViewCompat.addOverlayView(mOverlayHost, mOverlayView);
} else {
cancel();
}
@@ -638,7 +639,7 @@
public void onAnimationStart(@NonNull Animator animation, boolean isReverse) {
if (isReverse) {
mStartView.setTag(R.id.save_overlay_view, mOverlayView);
- mOverlayHost.getOverlay().add(mOverlayView);
+ ViewCompat.addOverlayView(mOverlayHost, mOverlayView);
mHasOverlay = true;
}
}
diff --git a/wear/compose/compose-material3/api/current.txt b/wear/compose/compose-material3/api/current.txt
index 0acff54..82db331 100644
--- a/wear/compose/compose-material3/api/current.txt
+++ b/wear/compose/compose-material3/api/current.txt
@@ -701,6 +701,7 @@
public final class LinearProgressIndicatorKt {
method @androidx.compose.runtime.Composable public static void LinearProgressIndicator(kotlin.jvm.functions.Function0<java.lang.Float> progress, optional androidx.compose.ui.Modifier modifier, optional androidx.wear.compose.material3.ProgressIndicatorColors colors, optional float strokeWidth, optional boolean enabled);
+ method @androidx.compose.runtime.Composable public static void LinearProgressIndicatorContent(kotlin.jvm.functions.Function0<java.lang.Float> progress, optional androidx.compose.ui.Modifier modifier, optional androidx.wear.compose.material3.ProgressIndicatorColors colors, optional float strokeWidth, optional boolean enabled);
}
public final class ListHeaderDefaults {
diff --git a/wear/compose/compose-material3/api/restricted_current.txt b/wear/compose/compose-material3/api/restricted_current.txt
index 0acff54..82db331 100644
--- a/wear/compose/compose-material3/api/restricted_current.txt
+++ b/wear/compose/compose-material3/api/restricted_current.txt
@@ -701,6 +701,7 @@
public final class LinearProgressIndicatorKt {
method @androidx.compose.runtime.Composable public static void LinearProgressIndicator(kotlin.jvm.functions.Function0<java.lang.Float> progress, optional androidx.compose.ui.Modifier modifier, optional androidx.wear.compose.material3.ProgressIndicatorColors colors, optional float strokeWidth, optional boolean enabled);
+ method @androidx.compose.runtime.Composable public static void LinearProgressIndicatorContent(kotlin.jvm.functions.Function0<java.lang.Float> progress, optional androidx.compose.ui.Modifier modifier, optional androidx.wear.compose.material3.ProgressIndicatorColors colors, optional float strokeWidth, optional boolean enabled);
}
public final class ListHeaderDefaults {
diff --git a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/ButtonDemo.kt b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/ButtonDemo.kt
index 219dc41e..cbef4a3 100644
--- a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/ButtonDemo.kt
+++ b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/ButtonDemo.kt
@@ -444,7 +444,13 @@
item {
ChildButton(
onClick = { /* Do something */ },
- label = { Text("Child Button") },
+ label = {
+ Text(
+ "Child Button",
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth()
+ )
+ },
enabled = false,
modifier = Modifier.fillMaxWidth(),
)
@@ -554,7 +560,7 @@
}
}
item { ListHeader { Text("Icon and Label") } }
- item { CompactButtonSample() }
+ item { CompactButtonSample(modifier = Modifier.fillMaxWidth()) }
item {
CompactButton(
onClick = { /* Do something */ },
@@ -628,12 +634,13 @@
item { ListHeader { Text("Long Click") } }
item {
CompactButtonWithOnLongClickSample(
+ modifier = Modifier.fillMaxWidth(),
onClickHandler = { showOnClickToast(context) },
onLongClickHandler = { showOnLongClickToast(context) }
)
}
item { ListHeader { Text("Expandable") } }
- item { OutlinedCompactButtonSample() }
+ item { OutlinedCompactButtonSample(modifier = Modifier.fillMaxWidth()) }
}
}
diff --git a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/ProgressIndicatorDemo.kt b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/ProgressIndicatorDemo.kt
index 6c4cb3a..65e49e1 100644
--- a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/ProgressIndicatorDemo.kt
+++ b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/ProgressIndicatorDemo.kt
@@ -47,12 +47,14 @@
import androidx.wear.compose.material3.CircularProgressIndicator
import androidx.wear.compose.material3.CircularProgressIndicatorDefaults
import androidx.wear.compose.material3.IconButtonDefaults
+import androidx.wear.compose.material3.LinearProgressIndicator
import androidx.wear.compose.material3.ListHeader
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.ProgressIndicatorDefaults
import androidx.wear.compose.material3.SegmentedCircularProgressIndicator
import androidx.wear.compose.material3.Slider
import androidx.wear.compose.material3.SliderDefaults
+import androidx.wear.compose.material3.Stepper
import androidx.wear.compose.material3.SwitchButton
import androidx.wear.compose.material3.Text
import androidx.wear.compose.material3.samples.CircularProgressIndicatorContentSample
@@ -136,7 +138,17 @@
},
)
),
- ComposableDemo("Linear progress") { Centralize { LinearProgressIndicatorSamples() } },
+ Material3DemoCategory(
+ title = "Linear progress",
+ listOf(
+ ComposableDemo("Linear Samples") {
+ Centralize { LinearProgressIndicatorSamples() }
+ },
+ ComposableDemo("Animation") {
+ Centralize { LinearProgressIndicatorAnimatedDemo() }
+ },
+ )
+ ),
Material3DemoCategory(
title = "Arc Progress Indicator",
listOf(
@@ -556,3 +568,32 @@
}
}
}
+
+@Composable
+fun LinearProgressIndicatorAnimatedDemo() {
+ var progress by remember { mutableFloatStateOf(0.25f) }
+ val valueRange = 0f..1f
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ Stepper(
+ value = progress,
+ onValueChange = { progress = it },
+ valueRange = valueRange,
+ steps = 3,
+ ) {
+ ScalingLazyColumn(
+ modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ item { Text(String.format("Progress: %.0f%%", progress * 100)) }
+ item {
+ LinearProgressIndicator(
+ modifier = Modifier.padding(top = 8.dp),
+ progress = { progress }
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/TransformingLazyColumnDemo.kt b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/TransformingLazyColumnDemo.kt
index 4a0c611..4523210 100644
--- a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/TransformingLazyColumnDemo.kt
+++ b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/TransformingLazyColumnDemo.kt
@@ -125,7 +125,7 @@
ListHeader { Text("Buttons", style = MaterialTheme.typography.labelLarge) }
}
- item { SimpleButtonSample() }
+ item { SimpleButtonSample(modifier = Modifier.fillMaxWidth()) }
item { ButtonSample() }
item { ButtonLargeIconSample() }
item { ButtonExtraLargeIconSample() }
@@ -137,9 +137,9 @@
item { OutlinedButtonSample() }
item { SimpleChildButtonSample() }
item { ChildButtonSample() }
- item { CompactButtonSample() }
- item { FilledTonalCompactButtonSample() }
- item { OutlinedCompactButtonSample() }
+ item { CompactButtonSample(modifier = Modifier.fillMaxWidth()) }
+ item { FilledTonalCompactButtonSample(modifier = Modifier.fillMaxWidth()) }
+ item { OutlinedCompactButtonSample(modifier = Modifier.fillMaxWidth()) }
item { ButtonBackgroundImage(painterResource(R.drawable.backgroundimage), enabled = true) }
item { ButtonBackgroundImage(painterResource(R.drawable.backgroundimage), enabled = false) }
item { ListHeader { Text("Complex Buttons") } }
diff --git a/wear/compose/compose-material3/macrobenchmark-common/src/main/java/androidx/wear/compose/material3/macrobenchmark/common/DatePickerBenchmark.kt b/wear/compose/compose-material3/macrobenchmark-common/src/main/java/androidx/wear/compose/material3/macrobenchmark/common/DatePickerBenchmark.kt
new file mode 100644
index 0000000..96f4cce
--- /dev/null
+++ b/wear/compose/compose-material3/macrobenchmark-common/src/main/java/androidx/wear/compose/material3/macrobenchmark/common/DatePickerBenchmark.kt
@@ -0,0 +1,117 @@
+/*
+ * 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.wear.compose.material3.macrobenchmark.common
+
+import android.os.Build
+import android.os.SystemClock
+import androidx.annotation.RequiresApi
+import androidx.benchmark.macro.MacrobenchmarkScope
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.Until
+import androidx.wear.compose.material3.Button
+import androidx.wear.compose.material3.DatePicker
+import androidx.wear.compose.material3.DatePickerType
+import androidx.wear.compose.material3.Icon
+import androidx.wear.compose.material3.Text
+import java.time.LocalDate
+import java.time.format.DateTimeFormatter
+import java.time.format.FormatStyle
+
+@RequiresApi(Build.VERSION_CODES.O)
+object DatePickerBenchmark : MacrobenchmarkScreen {
+ override val content: @Composable (BoxScope.() -> Unit)
+ get() = {
+ var showDatePicker by remember { mutableStateOf(false) }
+ var datePickerDate by remember { mutableStateOf(LocalDate.of(2024, 9, 2)) }
+ val formatter =
+ DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
+ .withLocale(LocalConfiguration.current.locales[0])
+ val minDate = LocalDate.of(2022, 10, 30)
+ val maxDate = LocalDate.of(2025, 2, 4)
+ if (showDatePicker) {
+ DatePicker(
+ initialDate = datePickerDate, // Initialize with last picked date on reopen
+ onDatePicked = {
+ datePickerDate = it
+ showDatePicker = false
+ },
+ minDate = minDate,
+ maxDate = maxDate,
+ datePickerType = DatePickerType.YearMonthDay
+ )
+ } else {
+ Button(
+ onClick = { showDatePicker = true },
+ modifier = Modifier.semantics { contentDescription = CONTENT_DESCRIPTION },
+ label = { Text("Selected Date") },
+ secondaryLabel = { Text(datePickerDate.format(formatter)) },
+ icon = { Icon(imageVector = Icons.Filled.Edit, contentDescription = "Edit") },
+ )
+ }
+ }
+
+ override val exercise: MacrobenchmarkScope.() -> Unit
+ get() = {
+ device
+ .wait(Until.findObject(By.desc(CONTENT_DESCRIPTION)), FIND_OBJECT_TIMEOUT_MS)
+ .click()
+ device.waitForIdle()
+ SystemClock.sleep(500)
+ repeat(3) { columnIndex ->
+ repeat(2) { i ->
+ val endY =
+ if (i % 2 == 0) {
+ device.displayHeight / 10 // scroll up
+ } else {
+ device.displayHeight * 9 / 10 // scroll down
+ }
+ device.swipe(
+ device.displayWidth / 2,
+ device.displayHeight / 2,
+ device.displayWidth / 2,
+ endY,
+ 10
+ )
+ device.waitForIdle()
+ SystemClock.sleep(500)
+ }
+ if (columnIndex < 2) {
+ device.wait(Until.findObject(By.desc("Next")), FIND_OBJECT_TIMEOUT_MS).click()
+ device.waitForIdle()
+ SystemClock.sleep(500)
+ } else {
+ device
+ .wait(Until.findObject(By.desc("Confirm")), FIND_OBJECT_TIMEOUT_MS)
+ .click()
+ device.waitForIdle()
+ SystemClock.sleep(500)
+ }
+ }
+ }
+}
diff --git a/wear/compose/compose-material3/macrobenchmark-common/src/main/java/androidx/wear/compose/material3/macrobenchmark/common/TimePickerBenchmark.kt b/wear/compose/compose-material3/macrobenchmark-common/src/main/java/androidx/wear/compose/material3/macrobenchmark/common/TimePickerBenchmark.kt
new file mode 100644
index 0000000..7d15ce7
--- /dev/null
+++ b/wear/compose/compose-material3/macrobenchmark-common/src/main/java/androidx/wear/compose/material3/macrobenchmark/common/TimePickerBenchmark.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.wear.compose.material3.macrobenchmark.common
+
+import android.os.Build
+import android.os.SystemClock
+import androidx.annotation.RequiresApi
+import androidx.benchmark.macro.MacrobenchmarkScope
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.runtime.Composable
+import androidx.wear.compose.material3.TimePicker
+import androidx.wear.compose.material3.TimePickerType
+import java.time.LocalTime
+
+@RequiresApi(Build.VERSION_CODES.O)
+object TimePickerBenchmark : MacrobenchmarkScreen {
+ override val content: @Composable (BoxScope.() -> Unit)
+ get() = {
+ TimePicker(
+ onTimePicked = {},
+ timePickerType = TimePickerType.HoursMinutesAmPm12H,
+ initialTime = LocalTime.of(11, 30)
+ )
+ }
+
+ override val exercise: MacrobenchmarkScope.() -> Unit
+ get() = {
+ repeat(4) { i ->
+ val startY = device.displayHeight / 2
+ val endY =
+ if (i % 2 == 0) {
+ device.displayHeight / 10 // scroll up
+ } else {
+ device.displayHeight * 9 / 10 // scroll down
+ }
+
+ val hourX = device.displayWidth / 4
+ device.swipe(hourX, startY, hourX, endY, 10)
+ device.waitForIdle()
+ SystemClock.sleep(500)
+
+ val minutesX = device.displayWidth / 2
+ device.swipe(minutesX, startY, minutesX, endY, 10)
+ device.waitForIdle()
+ SystemClock.sleep(500)
+
+ val amPmX = device.displayWidth * 3 / 4
+ device.swipe(amPmX, startY, amPmX, endY, 10)
+ device.waitForIdle()
+ SystemClock.sleep(500)
+ }
+ }
+}
diff --git a/wear/compose/compose-material3/macrobenchmark-target/src/main/AndroidManifest.xml b/wear/compose/compose-material3/macrobenchmark-target/src/main/AndroidManifest.xml
index ba97ae9..f93d560 100644
--- a/wear/compose/compose-material3/macrobenchmark-target/src/main/AndroidManifest.xml
+++ b/wear/compose/compose-material3/macrobenchmark-target/src/main/AndroidManifest.xml
@@ -57,7 +57,8 @@
<activity
android:name=".AnimatedTextActivity"
android:exported="true"
- android:theme="@style/AppTheme">
+ android:theme="@style/AppTheme"
+ tools:targetApi="s">
<intent-filter>
<action android:name="androidx.wear.compose.material3.macrobenchmark.target.ANIMATED_TEXT_ACTIVITY" />
<category android:name="android.intent.category.DEFAULT" />
@@ -117,6 +118,18 @@
</activity>
<activity
+ android:name=".DatePickerActivity"
+ android:theme="@style/AppTheme"
+ android:exported="true"
+ tools:targetApi="o">
+ <intent-filter>
+ <action android:name=
+ "androidx.wear.compose.material3.macrobenchmark.target.DATE_PICKER_ACTIVITY" />
+ <category android:name="android.intent.category.DEFAULT" />
+ </intent-filter>
+ </activity>
+
+ <activity
android:name=".IndeterminateCircularProgressIndicatorActivity"
android:theme="@style/AppTheme"
android:exported="true">
@@ -223,6 +236,18 @@
</activity>
<activity
+ android:name=".TimePickerActivity"
+ android:theme="@style/AppTheme"
+ android:exported="true"
+ tools:targetApi="o">
+ <intent-filter>
+ <action android:name=
+ "androidx.wear.compose.material3.macrobenchmark.target.TIME_PICKER_ACTIVITY" />
+ <category android:name="android.intent.category.DEFAULT" />
+ </intent-filter>
+ </activity>
+
+ <activity
android:name=".TransformingLazyColumnActivity"
android:theme="@style/AppTheme"
android:exported="true">
diff --git a/wear/compose/compose-material3/macrobenchmark-target/src/main/java/androidx/wear/compose/material3/macrobenchmark/target/AnimatedTextActivity.kt b/wear/compose/compose-material3/macrobenchmark-target/src/main/java/androidx/wear/compose/material3/macrobenchmark/target/AnimatedTextActivity.kt
index 7709258..fd40622 100644
--- a/wear/compose/compose-material3/macrobenchmark-target/src/main/java/androidx/wear/compose/material3/macrobenchmark/target/AnimatedTextActivity.kt
+++ b/wear/compose/compose-material3/macrobenchmark-target/src/main/java/androidx/wear/compose/material3/macrobenchmark/target/AnimatedTextActivity.kt
@@ -16,6 +16,9 @@
package androidx.wear.compose.material3.macrobenchmark.target
+import android.os.Build
+import androidx.annotation.RequiresApi
import androidx.wear.compose.material3.macrobenchmark.common.AnimatedTextBenchmark
+@RequiresApi(Build.VERSION_CODES.S)
class AnimatedTextActivity : BenchmarkBaseActivity(AnimatedTextBenchmark)
diff --git a/wear/compose/compose-material3/macrobenchmark-target/src/main/java/androidx/wear/compose/material3/macrobenchmark/target/DatePickerActivity.kt b/wear/compose/compose-material3/macrobenchmark-target/src/main/java/androidx/wear/compose/material3/macrobenchmark/target/DatePickerActivity.kt
new file mode 100644
index 0000000..d18d12d
--- /dev/null
+++ b/wear/compose/compose-material3/macrobenchmark-target/src/main/java/androidx/wear/compose/material3/macrobenchmark/target/DatePickerActivity.kt
@@ -0,0 +1,24 @@
+/*
+ * 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.wear.compose.material3.macrobenchmark.target
+
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.wear.compose.material3.macrobenchmark.common.DatePickerBenchmark
+
+@RequiresApi(Build.VERSION_CODES.O)
+class DatePickerActivity : BenchmarkBaseActivity(DatePickerBenchmark)
diff --git a/wear/compose/compose-material3/macrobenchmark-target/src/main/java/androidx/wear/compose/material3/macrobenchmark/target/TimePickerActivity.kt b/wear/compose/compose-material3/macrobenchmark-target/src/main/java/androidx/wear/compose/material3/macrobenchmark/target/TimePickerActivity.kt
new file mode 100644
index 0000000..e134065
--- /dev/null
+++ b/wear/compose/compose-material3/macrobenchmark-target/src/main/java/androidx/wear/compose/material3/macrobenchmark/target/TimePickerActivity.kt
@@ -0,0 +1,24 @@
+/*
+ * 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.wear.compose.material3.macrobenchmark.target
+
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.wear.compose.material3.macrobenchmark.common.TimePickerBenchmark
+
+@RequiresApi(Build.VERSION_CODES.O)
+class TimePickerActivity : BenchmarkBaseActivity(TimePickerBenchmark)
diff --git a/wear/compose/compose-material3/macrobenchmark/src/main/java/androidx/wear/compose/material3/macrobenchmark/DatePickerBenchmarkTest.kt b/wear/compose/compose-material3/macrobenchmark/src/main/java/androidx/wear/compose/material3/macrobenchmark/DatePickerBenchmarkTest.kt
new file mode 100644
index 0000000..3316732
--- /dev/null
+++ b/wear/compose/compose-material3/macrobenchmark/src/main/java/androidx/wear/compose/material3/macrobenchmark/DatePickerBenchmarkTest.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.
+ */
+
+package androidx.wear.compose.material3.macrobenchmark
+
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.benchmark.macro.CompilationMode
+import androidx.test.filters.LargeTest
+import androidx.wear.compose.material3.macrobenchmark.common.DatePickerBenchmark
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+
+@RequiresApi(Build.VERSION_CODES.O)
+@LargeTest
+@RunWith(Parameterized::class)
+class DatePickerBenchmarkTest(compilationMode: CompilationMode) :
+ BenchmarkTestBase(
+ compilationMode = compilationMode,
+ macrobenchmarkScreen = DatePickerBenchmark,
+ actionSuffix = "DATE_PICKER_ACTIVITY"
+ )
diff --git a/wear/compose/compose-material3/macrobenchmark/src/main/java/androidx/wear/compose/material3/macrobenchmark/TimePickerBenchmarkTest.kt b/wear/compose/compose-material3/macrobenchmark/src/main/java/androidx/wear/compose/material3/macrobenchmark/TimePickerBenchmarkTest.kt
new file mode 100644
index 0000000..52a5a38
--- /dev/null
+++ b/wear/compose/compose-material3/macrobenchmark/src/main/java/androidx/wear/compose/material3/macrobenchmark/TimePickerBenchmarkTest.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.
+ */
+
+package androidx.wear.compose.material3.macrobenchmark
+
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.benchmark.macro.CompilationMode
+import androidx.test.filters.LargeTest
+import androidx.wear.compose.material3.macrobenchmark.common.TimePickerBenchmark
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+
+@RequiresApi(Build.VERSION_CODES.O)
+@LargeTest
+@RunWith(Parameterized::class)
+class TimePickerBenchmarkTest(compilationMode: CompilationMode) :
+ BenchmarkTestBase(
+ compilationMode = compilationMode,
+ macrobenchmarkScreen = TimePickerBenchmark,
+ actionSuffix = "TIME_PICKER_ACTIVITY"
+ )
diff --git a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/AlertDialogSample.kt b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/AlertDialogSample.kt
index 6308c19..43fb374 100644
--- a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/AlertDialogSample.kt
+++ b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/AlertDialogSample.kt
@@ -36,6 +36,7 @@
import androidx.wear.compose.material3.FilledTonalButton
import androidx.wear.compose.material3.Icon
import androidx.wear.compose.material3.MaterialTheme
+import androidx.wear.compose.material3.SwitchButton
import androidx.wear.compose.material3.Text
@Sampled
@@ -122,6 +123,8 @@
@Composable
fun AlertDialogWithContentGroupsSample() {
var showDialog by remember { mutableStateOf(false) }
+ var weatherEnabled by remember { mutableStateOf(false) }
+ var calendarEnabled by remember { mutableStateOf(false) }
Box(Modifier.fillMaxSize()) {
FilledTonalButton(
@@ -147,16 +150,18 @@
}
) {
item {
- FilledTonalButton(
+ SwitchButton(
modifier = Modifier.fillMaxWidth(),
- onClick = {},
+ checked = weatherEnabled,
+ onCheckedChange = { weatherEnabled = it },
label = { Text("Weather") }
)
}
item {
- FilledTonalButton(
+ SwitchButton(
modifier = Modifier.fillMaxWidth(),
- onClick = {},
+ checked = calendarEnabled,
+ onCheckedChange = { calendarEnabled = it },
label = { Text("Calendar") }
)
}
diff --git a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/AnimatedTextSample.kt b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/AnimatedTextSample.kt
index e3389e9..3d10692 100644
--- a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/AnimatedTextSample.kt
+++ b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/AnimatedTextSample.kt
@@ -19,13 +19,17 @@
import androidx.annotation.Sampled
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontVariation
+import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.wear.compose.material3.AnimatedText
import androidx.wear.compose.material3.Button
@@ -87,39 +91,34 @@
FontVariation.weight(500),
),
startFontSize = 30.sp,
- endFontSize = 40.sp,
+ endFontSize = 30.sp,
)
- val firstNumber = remember { mutableIntStateOf(0) }
- val firstAnimatable = remember { Animatable(0f) }
- val secondNumber = remember { mutableIntStateOf(0) }
- val secondAnimatable = remember { Animatable(0f) }
- Column(horizontalAlignment = Alignment.CenterHorizontally) {
- AnimatedText(
- text = "${firstNumber.value}",
- fontRegistry = animatedTextFontRegistry,
- progressFraction = { firstAnimatable.value },
- )
+ val number = remember { mutableIntStateOf(0) }
+ val textAnimatable = remember { Animatable(0f) }
+ Row(verticalAlignment = Alignment.CenterVertically) {
Button(
+ modifier = Modifier.padding(horizontal = 16.dp),
onClick = {
- firstNumber.value += 1
+ number.value -= 1
scope.launch {
- firstAnimatable.animateTo(1f)
- firstAnimatable.animateTo(0f)
+ textAnimatable.animateTo(1f)
+ textAnimatable.animateTo(0f)
}
},
- label = { Text("+") }
+ label = { Text("-") }
)
AnimatedText(
- text = "${secondNumber.value}",
+ text = "${number.value}",
fontRegistry = animatedTextFontRegistry,
- progressFraction = { secondAnimatable.value },
+ progressFraction = { textAnimatable.value },
)
Button(
+ modifier = Modifier.padding(horizontal = 16.dp),
onClick = {
- secondNumber.value += 1
+ number.value += 1
scope.launch {
- secondAnimatable.animateTo(1f)
- secondAnimatable.animateTo(0f)
+ textAnimatable.animateTo(1f)
+ textAnimatable.animateTo(0f)
}
},
label = { Text("+") }
@@ -132,17 +131,17 @@
fun AnimatedTextSampleSharedFontRegistry() {
val animatedTextFontRegistry =
rememberAnimatedTextFontRegistry(
- // Variation axes at the start of the animation, width 10, weight 200
+ // Variation axes at the start of the animation, width 50, weight 300
startFontVariationSettings =
FontVariation.Settings(
- FontVariation.width(10f),
- FontVariation.weight(200),
+ FontVariation.width(50f),
+ FontVariation.weight(300),
),
- // Variation axes at the end of the animation, width 100, weight 500
+ // Variation axes at the end of the animation are the same as the start axes
endFontVariationSettings =
FontVariation.Settings(
- FontVariation.width(100f),
- FontVariation.weight(500),
+ FontVariation.width(50f),
+ FontVariation.weight(300),
),
startFontSize = 15.sp,
endFontSize = 25.sp,
diff --git a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ButtonGroupSample.kt b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ButtonGroupSample.kt
index b328822..a467fe1 100644
--- a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ButtonGroupSample.kt
+++ b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ButtonGroupSample.kt
@@ -40,16 +40,12 @@
ButtonGroup(Modifier.fillMaxWidth()) {
buttonGroupItem(interactionSource = interactionSourceLeft) {
Button(onClick = {}, interactionSource = interactionSourceLeft) {
- Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- Text("Left")
- }
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("L") }
}
}
buttonGroupItem(interactionSource = interactionSourceRight) {
Button(onClick = {}, interactionSource = interactionSourceRight) {
- Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- Text("Right")
- }
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("R") }
}
}
}
diff --git a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ButtonSample.kt b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ButtonSample.kt
index 40d5816..39798a1 100644
--- a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ButtonSample.kt
+++ b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ButtonSample.kt
@@ -24,6 +24,7 @@
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.wear.compose.material3.Button
import androidx.wear.compose.material3.ButtonDefaults
@@ -37,7 +38,7 @@
@Sampled
@Composable
fun SimpleButtonSample(modifier: Modifier = Modifier) {
- Button(onClick = { /* Do something */ }, label = { Text("Button") }, modifier = modifier)
+ Button(onClick = { /* Do something */ }, label = { Text("Simple Button") }, modifier = modifier)
}
@Sampled
@@ -196,7 +197,9 @@
fun SimpleChildButtonSample(modifier: Modifier = Modifier.fillMaxWidth()) {
ChildButton(
onClick = { /* Do something */ },
- label = { Text("Child Button") },
+ label = {
+ Text("Child Button", textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth())
+ },
modifier = modifier,
)
}
@@ -221,7 +224,7 @@
@Sampled
@Composable
-fun CompactButtonSample(modifier: Modifier = Modifier.fillMaxWidth()) {
+fun CompactButtonSample(modifier: Modifier = Modifier) {
CompactButton(
onClick = { /* Do something */ },
icon = {
@@ -242,7 +245,7 @@
fun CompactButtonWithOnLongClickSample(
onClickHandler: () -> Unit,
onLongClickHandler: () -> Unit,
- modifier: Modifier = Modifier.fillMaxWidth()
+ modifier: Modifier = Modifier
) {
CompactButton(
onClick = onClickHandler,
@@ -255,7 +258,7 @@
@Sampled
@Composable
-fun FilledTonalCompactButtonSample(modifier: Modifier = Modifier.fillMaxWidth()) {
+fun FilledTonalCompactButtonSample(modifier: Modifier = Modifier) {
CompactButton(
onClick = { /* Do something */ },
icon = {
@@ -274,7 +277,7 @@
@Sampled
@Composable
-fun OutlinedCompactButtonSample(modifier: Modifier = Modifier.fillMaxWidth()) {
+fun OutlinedCompactButtonSample(modifier: Modifier = Modifier) {
CompactButton(
onClick = { /* Do something */ },
icon = {
diff --git a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/CardSample.kt b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/CardSample.kt
index d553884..f30ea5d 100644
--- a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/CardSample.kt
+++ b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/CardSample.kt
@@ -77,7 +77,7 @@
onClick = { /* Do something */ },
appName = { Text("App name") },
title = { Text("Card title") },
- time = { Text("now") },
+ time = { Text("Now") },
) {
Text("Card content")
}
@@ -96,10 +96,11 @@
modifier =
Modifier.size(CardDefaults.AppImageSize)
.wrapContentSize(align = Alignment.Center),
+ tint = MaterialTheme.colorScheme.primary
)
},
title = { Text("Card title") },
- time = { Text("now") },
+ time = { Text("Now") },
) {
Text("Card content")
}
@@ -122,10 +123,11 @@
modifier =
Modifier.size(CardDefaults.AppImageSize)
.wrapContentSize(align = Alignment.Center),
+ tint = MaterialTheme.colorScheme.primary
)
},
title = { Text("With image") },
- time = { Text("now") },
+ time = { Text("Now") },
) {
Spacer(modifier = Modifier.height(4.dp))
Row(modifier = Modifier.fillMaxWidth()) {
@@ -147,7 +149,7 @@
TitleCard(
onClick = { /* Do something */ },
title = { Text("Title card") },
- time = { Text("now") },
+ time = { Text("Now") },
) {
Text("Card content")
}
@@ -158,7 +160,7 @@
fun TitleCardWithSubtitleAndTimeSample() {
TitleCard(
onClick = { /* Do something */ },
- time = { Text("now") },
+ time = { Text("Now") },
title = { Text("Title card") },
subtitle = { Text("Subtitle") }
)
@@ -170,7 +172,7 @@
TitleCard(
onClick = { /* Do something */ },
title = { Text("Title card") },
- time = { Text("now") },
+ time = { Text("Now") },
modifier = Modifier.semantics { contentDescription = "Background image" }
) {
Spacer(Modifier.height(4.dp))
@@ -206,7 +208,7 @@
TitleCard(
onClick = { /* Do something */ },
title = { Text("Card title") },
- time = { Text("now") },
+ time = { Text("Now") },
colors =
CardDefaults.imageCardColors(
containerPainter =
@@ -247,7 +249,7 @@
)
},
title = { Text("App card") },
- time = { Text("now") },
+ time = { Text("Now") },
colors = CardDefaults.outlinedCardColors(),
border = CardDefaults.outlinedCardBorder(),
) {
@@ -261,7 +263,7 @@
TitleCard(
onClick = { /* Do something */ },
title = { Text("Title card") },
- time = { Text("now") },
+ time = { Text("Now") },
colors = CardDefaults.outlinedCardColors(),
border = CardDefaults.outlinedCardBorder(),
) {
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/LinearProgressIndicatorScreenshotTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/LinearProgressIndicatorScreenshotTest.kt
index cd4f141..498ce2f 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/LinearProgressIndicatorScreenshotTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/LinearProgressIndicatorScreenshotTest.kt
@@ -21,6 +21,7 @@
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.testutils.assertAgainstGolden
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -59,6 +60,14 @@
}
@Test
+ fun linear_progress_indicator_1_percent() = linear_progress_indicator_test {
+ LinearProgressIndicator(
+ progress = { 0.01f },
+ modifier = Modifier.aspectRatio(1f).testTag(TEST_TAG),
+ )
+ }
+
+ @Test
fun linear_progress_indicator_50_percent() = linear_progress_indicator_test {
LinearProgressIndicator(
progress = { 0.5f },
@@ -67,6 +76,14 @@
}
@Test
+ fun linear_progress_indicator_95_percent() = linear_progress_indicator_test {
+ LinearProgressIndicator(
+ progress = { 0.95f },
+ modifier = Modifier.aspectRatio(1f).testTag(TEST_TAG),
+ )
+ }
+
+ @Test
fun linear_progress_indicator_full() = linear_progress_indicator_test {
LinearProgressIndicator(
progress = { 1f },
@@ -105,16 +122,41 @@
)
}
+ @Test
+ fun linear_progress_indicator_animated_progress() {
+ rule.mainClock.autoAdvance = false
+ val progress = mutableFloatStateOf(0f)
+
+ rule.setContentWithTheme {
+ ScreenConfiguration(ScreenSize.LARGE.size) {
+ LinearProgressIndicator(
+ progress = { progress.value },
+ modifier = Modifier.aspectRatio(1f).testTag(TEST_TAG),
+ )
+ }
+ }
+
+ rule.runOnIdle { progress.value = 1f }
+ rule.mainClock.advanceTimeBy(100)
+
+ rule
+ .onNodeWithTag(TEST_TAG)
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, testName.goldenIdentifier())
+ }
+
private fun linear_progress_indicator_test(
isLtr: Boolean = true,
content: @Composable () -> Unit
) {
rule.setContentWithTheme(modifier = Modifier.background(Color.Black)) {
- val layoutDirection = if (isLtr) LayoutDirection.Ltr else LayoutDirection.Rtl
- CompositionLocalProvider(
- LocalLayoutDirection provides layoutDirection,
- content = content
- )
+ ScreenConfiguration(ScreenSize.LARGE.size) {
+ val layoutDirection = if (isLtr) LayoutDirection.Ltr else LayoutDirection.Rtl
+ CompositionLocalProvider(
+ LocalLayoutDirection provides layoutDirection,
+ content = content
+ )
+ }
}
rule
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ListHeaderTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ListHeaderTest.kt
index 3a34438..eaa7353 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ListHeaderTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ListHeaderTest.kt
@@ -119,7 +119,7 @@
var expectedTextStyle = TextStyle.Default
rule.setContentWithTheme {
- expectedTextStyle = MaterialTheme.typography.titleMedium
+ expectedTextStyle = MaterialTheme.typography.titleSmall
ListSubHeader { actualTextStyle = LocalTextStyle.current }
}
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/LinearProgressIndicator.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/LinearProgressIndicator.kt
index 8a30e1a..4862818 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/LinearProgressIndicator.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/LinearProgressIndicator.kt
@@ -16,21 +16,30 @@
package androidx.wear.compose.material3
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.AnimationSpec
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.flow.collectLatest
/**
* Material Design linear progress indicator.
@@ -43,13 +52,15 @@
* The indicator also includes a small dot at the end of the progress line. This dot serves as an
* accessibility feature to show the range of the indicator.
*
- * Small progress values that are larger than zero will be rounded up to at least the stroke width.
+ * Progress updates will be animated. Small progress values that are larger than zero will be
+ * rounded up to at least the stroke width.
*
* [LinearProgressIndicator] sample:
*
* @sample androidx.wear.compose.material3.samples.LinearProgressIndicatorSample
* @param progress The progress of this progress indicator where 0.0 represents no progress and 1.0
- * represents completion. Values outside of this range are coerced into the range 0..1.
+ * represents completion. Values outside of this range are coerced into the range 0..1. Progress
+ * value changes will be animated.
* @param modifier Modifier to be applied to the [LinearProgressIndicator].
* @param colors [ProgressIndicatorColors] that will be used to resolve the indicator and track
* colors for this progress indicator in different states.
@@ -72,7 +83,52 @@
"Stroke width cannot be less than ${LinearProgressIndicatorDefaults.StrokeWidthSmall}"
}
- val coercedProgress = { progress().coerceIn(0f, 1f) }
+ val progressAnimationSpec: AnimationSpec<Float> = linearProgressAnimationSpec
+ val updatedProgress by rememberUpdatedState(progress)
+ val animatedProgress = remember { Animatable(updatedProgress().coerceIn(0f, 1f)) }
+
+ LaunchedEffect(Unit) {
+ snapshotFlow(updatedProgress).collectLatest {
+ val currentProgress = it.coerceIn(0f, 1f)
+ animatedProgress.animateTo(currentProgress, progressAnimationSpec)
+ }
+ }
+
+ LinearProgressIndicatorContent(
+ progress = animatedProgress::value,
+ modifier = modifier,
+ colors = colors,
+ strokeWidth = strokeWidth,
+ enabled = enabled,
+ )
+}
+
+/**
+ * Linear progress indicator content with no progress animations.
+ *
+ * @param progress The progress of this progress indicator where 0.0 represents no progress and 1.0
+ * represents completion. Values outside of this range are coerced into the range 0..1.
+ * @param modifier Modifier to be applied to the [LinearProgressIndicator].
+ * @param colors [ProgressIndicatorColors] that will be used to resolve the indicator and track
+ * colors for this progress indicator in different states.
+ * @param strokeWidth The stroke width for the progress indicator. The minimum value is
+ * [LinearProgressIndicatorDefaults.StrokeWidthSmall] to ensure that the dot drawn at the end of
+ * the range can be distinguished.
+ * @param enabled controls the enabled state. Although this component is not clickable, it can be
+ * contained within a clickable component. When enabled is `false`, this component will appear
+ * visually disabled.
+ */
+@Composable
+fun LinearProgressIndicatorContent(
+ progress: () -> Float,
+ modifier: Modifier = Modifier,
+ colors: ProgressIndicatorColors = ProgressIndicatorDefaults.colors(),
+ strokeWidth: Dp = LinearProgressIndicatorDefaults.StrokeWidthLarge,
+ enabled: Boolean = true,
+) {
+ require(strokeWidth >= LinearProgressIndicatorDefaults.StrokeWidthSmall) {
+ "Stroke width cannot be less than ${LinearProgressIndicatorDefaults.StrokeWidthSmall}"
+ }
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
Canvas(
@@ -83,36 +139,45 @@
.padding(LinearProgressIndicatorDefaults.OuterHorizontalMargin)
.scale(scaleX = if (isRtl) -1f else 1f, scaleY = 1f), // Flip X axis for RTL layouts
) {
- val progressPx = coercedProgress() * size.width
+ val progressPx = progress() * (size.width - strokeWidth.toPx())
+ val strokeCapOffset = strokeWidth.toPx() / 2f
// Draw the background
drawLinearIndicator(
- start = 0f,
- end = size.width,
+ start = strokeCapOffset,
+ end = size.width - strokeCapOffset,
brush = colors.trackBrush(enabled),
strokeWidth = strokeWidth.toPx()
)
- if (progressPx > 0) {
+ if (progressPx > 0f) {
// Draw the indicator
drawLinearIndicator(
- start = 0f,
- end = progressPx,
+ start = strokeCapOffset,
+ end = strokeCapOffset + progressPx,
brush = colors.indicatorBrush(enabled),
strokeWidth = strokeWidth.toPx(),
)
}
- // Draw the dot at the end of the line. The dot will be hidden when progress plus margin
- // would touch the dot
+ // Draw a dot at the end of the line.
val dotRadius = LinearProgressIndicatorDefaults.DotRadius.toPx()
val dotMargin = LinearProgressIndicatorDefaults.DotMargin.toPx()
+ val dotCenterX = size.width - dotRadius - dotMargin
+ val dotCenterY = size.height / 2f
+ val distanceFromProgressToDot = dotCenterX - dotRadius - progressPx - strokeCapOffset * 2f
- if (progressPx + dotMargin * 2 + dotRadius * 2 < size.width) {
+ // The dot will be hidden when the progress line would touch it.
+ if (distanceFromProgressToDot > 0f) {
+ // The dot will be scaled down when distance from progress line to dot is smaller than
+ // the margin.
+ val scaleFraction = (distanceFromProgressToDot / dotMargin).coerceAtMost(1f)
+
drawLinearIndicatorDot(
brush = colors.indicatorBrush(enabled),
radius = dotRadius,
- offset = dotMargin
+ center = Offset(dotCenterX, dotCenterY),
+ scaleFraction = scaleFraction
)
}
}
@@ -155,28 +220,15 @@
) {
// Start drawing from the vertical center of the stroke
val yOffset = size.height / 2
-
- // need to adjust barStart and barEnd for the stroke caps
- val strokeCapOffset = strokeWidth / 2
- val adjustedBarStart = start + strokeCapOffset
- val adjustedBarEnd = end - strokeCapOffset
-
- if (adjustedBarEnd > adjustedBarStart) {
+ if (end > start) {
// Draw progress line
drawLine(
brush = brush,
- start = Offset(adjustedBarStart, yOffset),
- end = Offset(adjustedBarEnd, yOffset),
+ start = Offset(start, yOffset),
+ end = Offset(end, yOffset),
strokeWidth = strokeWidth,
cap = StrokeCap.Round,
)
- } else {
- // For small values, draw a circle with diameter equal of stroke width
- drawCircle(
- brush = brush,
- radius = strokeCapOffset,
- center = Offset(strokeCapOffset, size.height / 2)
- )
}
}
@@ -184,11 +236,24 @@
private fun DrawScope.drawLinearIndicatorDot(
brush: Brush,
radius: Float,
- offset: Float,
+ center: Offset,
+ scaleFraction: Float = 1f
) {
- drawCircle(
- brush = brush,
- radius = radius,
- center = Offset(size.width - offset - radius, size.height / 2)
- )
+ // Scale down the dot by the scale fraction.
+ val scaledDotRadius = radius * scaleFraction
+ // Apply the scale fraction alpha to the dot color.
+ val alpha = scaleFraction.coerceAtMost(1f)
+ val brushWithAlpha =
+ if (brush is SolidColor && alpha < 1f) {
+ SolidColor(brush.value.copy(alpha = brush.value.alpha * alpha))
+ } else {
+ brush
+ }
+
+ // Draw the dot with scaled down radius and alpha color.
+ drawCircle(brush = brushWithAlpha, radius = scaledDotRadius, center = center)
}
+
+/** Progress animation spec for [LinearProgressIndicator] */
+internal val linearProgressAnimationSpec: AnimationSpec<Float>
+ @Composable get() = MaterialTheme.motionScheme.defaultEffectsSpec()
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/CardTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/CardTokens.kt
index 21233ee..9e6fb7e 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/CardTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/CardTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/CheckboxButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/CheckboxButtonTokens.kt
index f04d8b7..7242277 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/CheckboxButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/CheckboxButtonTokens.kt
@@ -14,13 +14,13 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
internal object CheckboxButtonTokens {
val CheckedBoxColor = ColorSchemeKeyTokens.Primary
- val CheckedCheckmarkColor = ColorSchemeKeyTokens.OnPrimary
+ val CheckedCheckmarkColor = ColorSchemeKeyTokens.PrimaryContainer
val CheckedContainerColor = ColorSchemeKeyTokens.PrimaryContainer
val CheckedContentColor = ColorSchemeKeyTokens.OnPrimaryContainer
val CheckedIconColor = ColorSchemeKeyTokens.Primary
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ChildButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ChildButtonTokens.kt
index 5b31022..89f2076 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ChildButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ChildButtonTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ColorSchemeKeyTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ColorSchemeKeyTokens.kt
index 3ab4aad..1bf715d 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ColorSchemeKeyTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ColorSchemeKeyTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ColorTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ColorTokens.kt
index 6a241e1..fc55742 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ColorTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ColorTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/CompactButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/CompactButtonTokens.kt
index 773d0c0..cd254fe 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/CompactButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/CompactButtonTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/DatePickerTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/DatePickerTokens.kt
index ddb27ca..abfd822 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/DatePickerTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/DatePickerTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledButtonTokens.kt
index b79f7de..f7e31cc 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledButtonTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledIconButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledIconButtonTokens.kt
index e9a1f12..a786e29 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledIconButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledIconButtonTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTextButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTextButtonTokens.kt
index 02386f9..2279987 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTextButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTextButtonTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTonalButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTonalButtonTokens.kt
index 7ada2ff..ca9db63 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTonalButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTonalButtonTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTonalIconButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTonalIconButtonTokens.kt
index 8b2e912..b631443 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTonalIconButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTonalIconButtonTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTonalTextButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTonalTextButtonTokens.kt
index a53e857..fe44c99 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTonalTextButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTonalTextButtonTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/IconButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/IconButtonTokens.kt
index 01a974d..97f2a8d 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/IconButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/IconButtonTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/IconToggleButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/IconToggleButtonTokens.kt
index 2e722e6..7104056 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/IconToggleButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/IconToggleButtonTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ImageButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ImageButtonTokens.kt
index 4fe245a..5bfdf2e 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ImageButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ImageButtonTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ImageCardTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ImageCardTokens.kt
index c86f5a7..31cc500 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ImageCardTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ImageCardTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ListHeaderTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ListHeaderTokens.kt
index 2997f123..df3bd7c 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ListHeaderTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ListHeaderTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
@@ -22,7 +22,7 @@
import androidx.compose.ui.unit.dp
internal object ListHeaderTokens {
- val ContentColor = ColorSchemeKeyTokens.OnSurfaceVariant
+ val ContentColor = ColorSchemeKeyTokens.OnBackground
val ContentTypography = TypographyKeyTokens.TitleMedium
val Height = 48.0.dp
}
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ListSubHeaderTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ListSubHeaderTokens.kt
index d412653..f2c2ba6 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ListSubHeaderTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ListSubHeaderTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
@@ -22,7 +22,7 @@
import androidx.compose.ui.unit.dp
internal object ListSubHeaderTokens {
- val ContentColor = ColorSchemeKeyTokens.OnBackground
- val ContentTypography = TypographyKeyTokens.TitleMedium
+ val ContentColor = ColorSchemeKeyTokens.OnSurface
+ val ContentTypography = TypographyKeyTokens.TitleSmall
val Height = 48.0.dp
}
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/OutlinedButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/OutlinedButtonTokens.kt
index 5fefd10..150ed56 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/OutlinedButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/OutlinedButtonTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/OutlinedCardTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/OutlinedCardTokens.kt
index ee033d8..7129bd2 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/OutlinedCardTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/OutlinedCardTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/OutlinedIconButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/OutlinedIconButtonTokens.kt
index ce53ef2..b72d68c 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/OutlinedIconButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/OutlinedIconButtonTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/OutlinedTextButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/OutlinedTextButtonTokens.kt
index fd6c03c..e63aa6c 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/OutlinedTextButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/OutlinedTextButtonTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/PaletteTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/PaletteTokens.kt
index d09bffb..00e2ee4 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/PaletteTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/PaletteTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/RadioButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/RadioButtonTokens.kt
index 84b2672..5d021cb 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/RadioButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/RadioButtonTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ShapeKeyTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ShapeKeyTokens.kt
index fafc112..f4736c7 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ShapeKeyTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ShapeKeyTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ShapeTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ShapeTokens.kt
index c74138d..56de250 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ShapeTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ShapeTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SliderTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SliderTokens.kt
index 6aea0f8..454ef3d 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SliderTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SliderTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SplitCheckboxButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SplitCheckboxButtonTokens.kt
index a9f9bf9..5266b86 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SplitCheckboxButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SplitCheckboxButtonTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SplitRadioButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SplitRadioButtonTokens.kt
index 017aa10..15f0fb8 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SplitRadioButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SplitRadioButtonTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SplitSwitchButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SplitSwitchButtonTokens.kt
index 3e4ce32..8d2f206 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SplitSwitchButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SplitSwitchButtonTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SwipeToRevealTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SwipeToRevealTokens.kt
index 2326886..f5419d2 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SwipeToRevealTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SwipeToRevealTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SwitchButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SwitchButtonTokens.kt
index 516998c..03744de 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SwitchButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SwitchButtonTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
@@ -24,7 +24,7 @@
val CheckedIconColor = ColorSchemeKeyTokens.Primary
val CheckedSecondaryLabelColor = ColorSchemeKeyTokens.OnPrimaryContainer
val CheckedSecondaryLabelOpacity = 0.9f
- val CheckedThumbColor = ColorSchemeKeyTokens.OnPrimary
+ val CheckedThumbColor = ColorSchemeKeyTokens.PrimaryContainer
val CheckedThumbIconColor = ColorSchemeKeyTokens.Primary
val CheckedTrackBorderColor = ColorSchemeKeyTokens.Primary
val CheckedTrackColor = ColorSchemeKeyTokens.Primary
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TextButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TextButtonTokens.kt
index 76ebdb2..2fe783f 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TextButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TextButtonTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TextToggleButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TextToggleButtonTokens.kt
index c9fd23a..e322ac7 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TextToggleButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TextToggleButtonTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TimePickerTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TimePickerTokens.kt
index 9a81220..352dc79 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TimePickerTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TimePickerTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypeScaleTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypeScaleTokens.kt
index 0c221f7..c23d721 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypeScaleTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypeScaleTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypefaceTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypefaceTokens.kt
index 75e8112..260235c 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypefaceTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypefaceTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypographyKeyTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypographyKeyTokens.kt
index 88a0830..531e688 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypographyKeyTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypographyKeyTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypographyTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypographyTokens.kt
index edff1b9..f9626c2 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypographyTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypographyTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypographyVariableFontsTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypographyVariableFontsTokens.kt
index cf23b2c..9f13848 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypographyVariableFontsTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypographyVariableFontsTokens.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-// VERSION: v0_100
+// VERSION: v0_103
// GENERATED CODE - DO NOT MODIFY BY HAND
package androidx.wear.compose.material3.tokens
@@ -34,13 +34,13 @@
)
val ArcSmallVariationSettings =
FontVariation.Settings(
- FontVariation.Setting("wght", TypeScaleTokens.ArcSmallWeight),
FontVariation.Setting("wdth", TypeScaleTokens.ArcSmallWidth),
+ FontVariation.Setting("wght", TypeScaleTokens.ArcSmallWeight),
)
val BodyExtraSmallVariationSettings =
FontVariation.Settings(
- FontVariation.Setting("wdth", TypeScaleTokens.BodyExtraSmallWidth),
FontVariation.Setting("wght", TypeScaleTokens.BodyExtraSmallWeight),
+ FontVariation.Setting("wdth", TypeScaleTokens.BodyExtraSmallWidth),
)
val BodyLargeVariationSettings =
FontVariation.Settings(
@@ -54,13 +54,13 @@
)
val BodySmallVariationSettings =
FontVariation.Settings(
- FontVariation.Setting("wdth", TypeScaleTokens.BodySmallWidth),
FontVariation.Setting("wght", TypeScaleTokens.BodySmallWeight),
+ FontVariation.Setting("wdth", TypeScaleTokens.BodySmallWidth),
)
val DisplayLargeVariationSettings =
FontVariation.Settings(
- FontVariation.Setting("wdth", TypeScaleTokens.DisplayLargeWidth),
FontVariation.Setting("wght", TypeScaleTokens.DisplayLargeWeight),
+ FontVariation.Setting("wdth", TypeScaleTokens.DisplayLargeWidth),
)
val DisplayMediumVariationSettings =
FontVariation.Settings(
@@ -69,8 +69,8 @@
)
val DisplaySmallVariationSettings =
FontVariation.Settings(
- FontVariation.Setting("wdth", TypeScaleTokens.DisplaySmallWidth),
FontVariation.Setting("wght", TypeScaleTokens.DisplaySmallWeight),
+ FontVariation.Setting("wdth", TypeScaleTokens.DisplaySmallWidth),
)
val LabelLargeVariationSettings =
FontVariation.Settings(
@@ -79,13 +79,13 @@
)
val LabelMediumVariationSettings =
FontVariation.Settings(
- FontVariation.Setting("wght", TypeScaleTokens.LabelMediumWeight),
FontVariation.Setting("wdth", TypeScaleTokens.LabelMediumWidth),
+ FontVariation.Setting("wght", TypeScaleTokens.LabelMediumWeight),
)
val LabelSmallVariationSettings =
FontVariation.Settings(
- FontVariation.Setting("wdth", TypeScaleTokens.LabelSmallWidth),
FontVariation.Setting("wght", TypeScaleTokens.LabelSmallWeight),
+ FontVariation.Setting("wdth", TypeScaleTokens.LabelSmallWidth),
)
val NumeralExtraLargeVariationSettings =
FontVariation.Settings(
@@ -99,13 +99,13 @@
)
val NumeralLargeVariationSettings =
FontVariation.Settings(
- FontVariation.Setting("wght", TypeScaleTokens.NumeralLargeWeight),
FontVariation.Setting("wdth", TypeScaleTokens.NumeralLargeWidth),
+ FontVariation.Setting("wght", TypeScaleTokens.NumeralLargeWeight),
)
val NumeralMediumVariationSettings =
FontVariation.Settings(
- FontVariation.Setting("wght", TypeScaleTokens.NumeralMediumWeight),
FontVariation.Setting("wdth", TypeScaleTokens.NumeralMediumWidth),
+ FontVariation.Setting("wght", TypeScaleTokens.NumeralMediumWeight),
)
val NumeralSmallVariationSettings =
FontVariation.Settings(
@@ -119,12 +119,12 @@
)
val TitleMediumVariationSettings =
FontVariation.Settings(
- FontVariation.Setting("wght", TypeScaleTokens.TitleMediumWeight),
FontVariation.Setting("wdth", TypeScaleTokens.TitleMediumWidth),
+ FontVariation.Setting("wght", TypeScaleTokens.TitleMediumWeight),
)
val TitleSmallVariationSettings =
FontVariation.Settings(
- FontVariation.Setting("wght", TypeScaleTokens.TitleSmallWeight),
FontVariation.Setting("wdth", TypeScaleTokens.TitleSmallWidth),
+ FontVariation.Setting("wght", TypeScaleTokens.TitleSmallWeight),
)
}
diff --git a/webkit/OWNERS b/webkit/OWNERS
index 93f399a..25b44cd 100644
--- a/webkit/OWNERS
+++ b/webkit/OWNERS
@@ -2,5 +2,4 @@
[email protected]
[email protected]
[email protected]
[email protected]
[email protected]
diff --git a/webkit/webkit/src/main/java/androidx/webkit/WebViewFeature.java b/webkit/webkit/src/main/java/androidx/webkit/WebViewFeature.java
index 4282944..9243ab1 100644
--- a/webkit/webkit/src/main/java/androidx/webkit/WebViewFeature.java
+++ b/webkit/webkit/src/main/java/androidx/webkit/WebViewFeature.java
@@ -109,6 +109,8 @@
MUTE_AUDIO,
PROFILE_URL_PREFETCH,
WEB_AUTHENTICATION,
+ SPECULATIVE_LOADING,
+ BACK_FORWARD_CACHE,
})
@Retention(RetentionPolicy.SOURCE)
@Target({ElementType.PARAMETER, ElementType.METHOD})
diff --git a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/MainActivity.kt b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/MainActivity.kt
index 6e55ac3..18f87d9 100644
--- a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/MainActivity.kt
+++ b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/MainActivity.kt
@@ -437,6 +437,7 @@
)
return OneTimeWorkRequest.Builder(RemoteWorker::class.java)
.setInputData(data)
+ .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED))
.build()
}
diff --git a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/RemoteWorker.kt b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/RemoteWorker.kt
index 48a5429..bc312d9 100644
--- a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/RemoteWorker.kt
+++ b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/RemoteWorker.kt
@@ -30,8 +30,8 @@
import androidx.core.app.NotificationCompat
import androidx.work.Data
import androidx.work.ForegroundInfo
+import androidx.work.ListenableWorker
import androidx.work.WorkerParameters
-import androidx.work.multiprocess.RemoteListenableWorker
import androidx.work.workDataOf
import com.google.common.util.concurrent.ListenableFuture
import kotlinx.coroutines.CoroutineScope
@@ -41,7 +41,7 @@
import kotlinx.coroutines.launch
class RemoteWorker(private val context: Context, private val parameters: WorkerParameters) :
- RemoteListenableWorker(context, parameters) {
+ ListenableWorker(context, parameters) {
private val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -49,7 +49,7 @@
private var job: Job? = null
private var progress: Data = Data.EMPTY
- override fun startRemoteWork(): ListenableFuture<Result> {
+ override fun startWork(): ListenableFuture<Result> {
return CallbackToFutureAdapter.getFuture { completer ->
Log.d(TAG, "Starting Remote Worker.")
if (Looper.getMainLooper().thread != Thread.currentThread()) {
@@ -80,6 +80,14 @@
job?.cancel()
}
+ override fun getForegroundInfoAsync(): ListenableFuture<ForegroundInfo> {
+ return CallbackToFutureAdapter.getFuture { completer ->
+ val scope = CoroutineScope(Dispatchers.Default)
+ scope.launch { completer.set(getForegroundInfo(NotificationId)) }
+ return@getFuture "getForegroundInfoAsync"
+ }
+ }
+
private fun getForegroundInfo(notificationId: Int): ForegroundInfo {
val percent = progress.getInt(Progress, 0)
val id = applicationContext.getString(R.string.channel_id)
diff --git a/work/work-multiprocess/src/androidTest/java/androidx/work/multiprocess/ParcelableForegroundInfoTest.kt b/work/work-multiprocess/src/androidTest/java/androidx/work/multiprocess/ParcelableForegroundInfoTest.kt
new file mode 100644
index 0000000..c435456
--- /dev/null
+++ b/work/work-multiprocess/src/androidTest/java/androidx/work/multiprocess/ParcelableForegroundInfoTest.kt
@@ -0,0 +1,91 @@
+/*
+ * 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.work.multiprocess
+
+import android.app.Notification
+import android.content.Context
+import android.content.pm.ServiceInfo
+import androidx.core.app.NotificationCompat
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import androidx.test.filters.SmallTest
+import androidx.work.ForegroundInfo
+import androidx.work.multiprocess.parcelable.ParcelConverters
+import androidx.work.multiprocess.parcelable.ParcelableForegroundInfo
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = 27)
+class ParcelableForegroundInfoTest {
+
+ lateinit var context: Context
+
+ @Before
+ public fun setUp() {
+ context = ApplicationProvider.getApplicationContext()
+ }
+
+ @Test
+ @SmallTest
+ public fun converterTest1() {
+ val channelId = "channelId"
+ val notificationId = 10
+ val title = "Some title"
+ val description = "Some description"
+ val notification =
+ NotificationCompat.Builder(context, channelId)
+ .setContentTitle(title)
+ .setContentText(description)
+ .setTicker(title)
+ .setSmallIcon(android.R.drawable.ic_dialog_info)
+ .build()
+
+ val foregroundInfo =
+ ForegroundInfo(
+ notificationId,
+ notification,
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
+ )
+
+ assertOn(foregroundInfo)
+ }
+
+ private fun assertOn(foregroundInfo: ForegroundInfo) {
+ val parcelable = ParcelableForegroundInfo(foregroundInfo)
+ val parcelled: ParcelableForegroundInfo =
+ ParcelConverters.unmarshall(
+ ParcelConverters.marshall(parcelable),
+ ParcelableForegroundInfo.CREATOR
+ )
+ equal(foregroundInfo, parcelled.foregroundInfo)
+ }
+
+ private fun equal(first: ForegroundInfo, second: ForegroundInfo) {
+ assertThat(first.notificationId).isEqualTo(second.notificationId)
+ assertThat(first.foregroundServiceType).isEqualTo(second.foregroundServiceType)
+ equal(first.notification, second.notification)
+ }
+
+ private fun equal(first: Notification, second: Notification) {
+ assertThat(first.channelId).isEqualTo(second.channelId)
+ assertThat(first.tickerText).isEqualTo(second.tickerText)
+ }
+}
diff --git a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/ListenableWorkerImpl.java b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/ListenableWorkerImpl.java
index 1f1b8e2..36eeb32 100644
--- a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/ListenableWorkerImpl.java
+++ b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/ListenableWorkerImpl.java
@@ -23,9 +23,9 @@
import android.content.Context;
import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
import androidx.work.Configuration;
+import androidx.work.ForegroundInfo;
import androidx.work.ForegroundUpdater;
import androidx.work.ListenableWorker;
import androidx.work.Logger;
@@ -33,6 +33,7 @@
import androidx.work.WorkerParameters;
import androidx.work.impl.utils.taskexecutor.TaskExecutor;
import androidx.work.multiprocess.parcelable.ParcelConverters;
+import androidx.work.multiprocess.parcelable.ParcelableForegroundInfo;
import androidx.work.multiprocess.parcelable.ParcelableInterruptRequest;
import androidx.work.multiprocess.parcelable.ParcelableRemoteWorkRequest;
import androidx.work.multiprocess.parcelable.ParcelableResult;
@@ -40,6 +41,8 @@
import com.google.common.util.concurrent.ListenableFuture;
+import java.util.HashMap;
+import java.util.Map;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ExecutionException;
@@ -49,7 +52,7 @@
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
public class ListenableWorkerImpl extends IListenableWorkerImpl.Stub {
// Synthetic access
- static final String TAG = Logger.tagWithPrefix("WM-RemoteWorker ListenableWorkerImpl");
+ static final String TAG = Logger.tagWithPrefix("ListenableWorkerImpl");
// Synthetic access
static byte[] sEMPTY = new byte[0];
// Synthetic access
@@ -65,15 +68,10 @@
final ProgressUpdater mProgressUpdater;
// Synthetic access
final ForegroundUpdater mForegroundUpdater;
-
- // Additional state to keep track of, when creating underlying instances of ListenableWorkers.
- // If the instance of ListenableWorker is null, then the corresponding throwable will always be
- // non-null.
- @Nullable
- ListenableWorker mWorker;
-
- @Nullable
- Throwable mThrowable;
+ // Synthetic access
+ final Map<String, ListenableWorker> mListenableWorkerMap;
+ // Synthetic access
+ final Map<String, Throwable> mThrowableMap;
ListenableWorkerImpl(@NonNull Context context) {
mContext = context.getApplicationContext();
@@ -82,6 +80,12 @@
mTaskExecutor = remoteInfo.getTaskExecutor();
mProgressUpdater = remoteInfo.getProgressUpdater();
mForegroundUpdater = remoteInfo.getForegroundUpdater();
+ // We need to track the actual workers and exceptions when creating instances of workers
+ // using the WorkerFactory. The service is longer lived than the workers, and therefore
+ // needs to be cognizant of attributing state to the right workerClassName. The keys
+ // to both the maps are the unique work request ids.
+ mListenableWorkerMap = new HashMap<>();
+ mThrowableMap = new HashMap<>();
}
@Override
@@ -125,6 +129,11 @@
} catch (CancellationException cancellationException) {
Logger.get().debug(TAG, "Worker (" + id + ") was cancelled");
reportFailure(callback, cancellationException);
+ } finally {
+ synchronized (sLock) {
+ mListenableWorkerMap.remove(id);
+ mThrowableMap.remove(id);
+ }
}
}
}, mTaskExecutor.getSerialTaskExecutor());
@@ -143,11 +152,13 @@
final String id = interruptRequest.getId();
final int stopReason = interruptRequest.getStopReason();
Logger.get().debug(TAG, "Interrupting work with id (" + id + ")");
-
- if (mWorker != null) {
+ // No need to remove the ListenableWorker from the map here, given after interruption
+ // the future gets notified and the cleanup happens automatically.
+ final ListenableWorker worker = mListenableWorkerMap.get(id);
+ if (worker != null) {
mTaskExecutor.getSerialTaskExecutor()
.execute(() -> {
- mWorker.stop(stopReason);
+ worker.stop(stopReason);
reportSuccess(callback, sEMPTY);
});
} else {
@@ -159,21 +170,110 @@
}
}
+ @Override
+ public void getForegroundInfoAsync(
+ @NonNull final byte[] request,
+ @NonNull final IWorkManagerImplCallback callback) {
+ try {
+ ParcelableRemoteWorkRequest parcelableRemoteWorkRequest =
+ ParcelConverters.unmarshall(request, ParcelableRemoteWorkRequest.CREATOR);
+
+ ParcelableWorkerParameters parcelableWorkerParameters =
+ parcelableRemoteWorkRequest.getParcelableWorkerParameters();
+
+ WorkerParameters workerParameters =
+ parcelableWorkerParameters.toWorkerParameters(
+ mConfiguration,
+ mTaskExecutor,
+ mProgressUpdater,
+ mForegroundUpdater
+ );
+
+ final String id = workerParameters.getId().toString();
+ final String workerClassName = parcelableRemoteWorkRequest.getWorkerClassName();
+
+ // Only instantiate the Worker if necessary.
+ createWorker(id, workerClassName, workerParameters);
+ ListenableWorker worker = mListenableWorkerMap.get(id);
+ Throwable throwable = mThrowableMap.get(id);
+
+ if (throwable != null) {
+ reportFailure(callback, throwable);
+ } else if (worker != null) {
+ mTaskExecutor.getSerialTaskExecutor().execute(new Runnable() {
+ @Override
+ public void run() {
+ final ListenableFuture<ForegroundInfo> futureResult =
+ worker.getForegroundInfoAsync();
+ futureResult.addListener(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ ForegroundInfo foregroundInfo = futureResult.get();
+ ParcelableForegroundInfo parcelableForegroundInfo =
+ new ParcelableForegroundInfo(foregroundInfo);
+ byte[] response = ParcelConverters.marshall(
+ parcelableForegroundInfo
+ );
+ reportSuccess(callback, response);
+ } catch (Throwable throwable) {
+ reportFailure(callback, throwable);
+ }
+ }
+ }, mTaskExecutor.getSerialTaskExecutor());
+ }
+ });
+ } else {
+ reportFailure(callback, new IllegalStateException("Should never happen."));
+ }
+ } catch (Throwable throwable) {
+ reportFailure(callback, throwable);
+ }
+ }
+
@NonNull
private ListenableFuture<ListenableWorker.Result> executeWorkRequest(
@NonNull String workerClassName,
@NonNull WorkerParameters workerParameters) {
- try {
- mWorker = mConfiguration.getWorkerFactory().createWorkerWithDefaultFallback(
- mContext, workerClassName, workerParameters
- );
- } catch (Throwable throwable) {
- mThrowable = throwable;
- }
+ String id = workerParameters.getId().toString();
+ // Only instantiate the Worker if necessary.
+ createWorker(id, workerClassName, workerParameters);
+
+ ListenableWorker worker = mListenableWorkerMap.get(id);
+ Throwable throwable = mThrowableMap.get(id);
+
return executeRemoteWorker(
- mConfiguration, workerClassName, workerParameters, mWorker, mThrowable,
- mTaskExecutor
- );
+ mConfiguration, workerClassName, workerParameters, worker, throwable,
+ mTaskExecutor);
+ }
+
+ private void createWorker(
+ @NonNull String id,
+ @NonNull String workerClassName,
+ @NonNull WorkerParameters workerParameters) {
+
+ // Use the id to keep track of the underlying instances. This is because the same worker
+ // could be concurrently being executed with a different set of inputs.
+ ListenableWorker worker = mListenableWorkerMap.get(id);
+ Throwable throwable = mThrowableMap.get(id);
+ // Check before we acquire a lock here, to make things as cheap as possible.
+ if (worker == null && throwable == null) {
+ synchronized (sLock) {
+ worker = mListenableWorkerMap.get(id);
+ throwable = mThrowableMap.get(id);
+ if (worker == null && throwable == null) {
+ try {
+ worker = mConfiguration.getWorkerFactory()
+ .createWorkerWithDefaultFallback(
+ mContext, workerClassName, workerParameters
+ );
+ mListenableWorkerMap.put(id, worker);
+ } catch (Throwable workerThrowable) {
+ mThrowableMap.put(id, workerThrowable);
+ }
+ }
+ }
+ }
}
}
diff --git a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteListenableDelegatingWorker.kt b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteListenableDelegatingWorker.kt
index b1269e2..7fa32d6 100644
--- a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteListenableDelegatingWorker.kt
+++ b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteListenableDelegatingWorker.kt
@@ -21,6 +21,7 @@
import android.content.Context
import androidx.annotation.RestrictTo
import androidx.concurrent.futures.SuspendToFutureAdapter.launchFuture
+import androidx.work.ForegroundInfo
import androidx.work.ListenableWorker
import androidx.work.Logger
import androidx.work.WorkerParameters
@@ -29,6 +30,7 @@
import androidx.work.multiprocess.RemoteListenableWorker.ARGUMENT_CLASS_NAME
import androidx.work.multiprocess.RemoteListenableWorker.ARGUMENT_PACKAGE_NAME
import androidx.work.multiprocess.parcelable.ParcelConverters
+import androidx.work.multiprocess.parcelable.ParcelableForegroundInfo
import androidx.work.multiprocess.parcelable.ParcelableInterruptRequest
import androidx.work.multiprocess.parcelable.ParcelableRemoteWorkRequest
import androidx.work.multiprocess.parcelable.ParcelableResult
@@ -49,36 +51,43 @@
@Suppress("AsyncSuffixFuture") // Implementing a ListenableWorker
override fun startWork(): ListenableFuture<Result> {
- val workManager = WorkManagerImpl.getInstance(context.applicationContext)
- val dispatcher = workManager.workTaskExecutor.taskCoroutineDispatcher
- return launchFuture(context = dispatcher) {
- val servicePackageName = inputData.getString(ARGUMENT_PACKAGE_NAME)
- val serviceClassName = inputData.getString(ARGUMENT_CLASS_NAME)
- val workerClassName = inputData.getString(ARGUMENT_REMOTE_LISTENABLE_WORKER_NAME)
- requireNotNull(servicePackageName) {
- "Need to specify a package name for the Remote Service."
+ return executeRemote(
+ block = { iListenableWorkerImpl, callback ->
+ val workerClassName = inputData.getString(ARGUMENT_REMOTE_LISTENABLE_WORKER_NAME)
+ requireNotNull(workerClassName) {
+ "Need to specify a class name for the RemoteListenableWorker to delegate to."
+ }
+ val remoteWorkRequest =
+ ParcelableRemoteWorkRequest(workerClassName, workerParameters)
+ val requestPayload = ParcelConverters.marshall(remoteWorkRequest)
+ iListenableWorkerImpl.startWork(requestPayload, callback)
+ },
+ transformation = { response ->
+ val parcelableResult =
+ ParcelConverters.unmarshall(response, ParcelableResult.CREATOR)
+ parcelableResult.result
}
- requireNotNull(serviceClassName) {
- "Need to specify a class name for the Remote Service."
+ )
+ }
+
+ override fun getForegroundInfoAsync(): ListenableFuture<ForegroundInfo> {
+ return executeRemote(
+ block = { iListenableWorkerImpl, callback ->
+ val workerClassName = inputData.getString(ARGUMENT_REMOTE_LISTENABLE_WORKER_NAME)
+ requireNotNull(workerClassName) {
+ "Need to specify a class name for the RemoteListenableWorker to delegate to."
+ }
+ val remoteWorkRequest =
+ ParcelableRemoteWorkRequest(workerClassName, workerParameters)
+ val requestPayload = ParcelConverters.marshall(remoteWorkRequest)
+ iListenableWorkerImpl.getForegroundInfoAsync(requestPayload, callback)
+ },
+ transformation = { response ->
+ val parcelableResult =
+ ParcelConverters.unmarshall(response, ParcelableForegroundInfo.CREATOR)
+ parcelableResult.foregroundInfo
}
- requireNotNull(workerClassName) {
- "Need to specify a class name for the RemoteListenableWorker to delegate to."
- }
- componentName = ComponentName(servicePackageName, serviceClassName)
- val response =
- client
- .execute(componentName!!) { iListenableWorkerImpl, callback ->
- val remoteWorkRequest =
- ParcelableRemoteWorkRequest(workerClassName, workerParameters)
- val requestPayload = ParcelConverters.marshall(remoteWorkRequest)
- iListenableWorkerImpl.startWork(requestPayload, callback)
- }
- .awaitWithin(this@RemoteListenableDelegatingWorker)
- val parcelableResult = ParcelConverters.unmarshall(response, ParcelableResult.CREATOR)
- Logger.get().debug(TAG, "Cleaning up")
- client.unbindService()
- parcelableResult.result
- }
+ )
}
@SuppressLint("NewApi") // stopReason is actually a safe method to call.
@@ -94,6 +103,38 @@
}
}
+ private inline fun <T> executeRemote(
+ crossinline block:
+ (
+ iListenableWorkerImpl: IListenableWorkerImpl, callback: IWorkManagerImplCallback
+ ) -> Unit,
+ crossinline transformation: (input: ByteArray) -> T
+ ): ListenableFuture<T> {
+ val workManager = WorkManagerImpl.getInstance(context.applicationContext)
+ val dispatcher = workManager.workTaskExecutor.taskCoroutineDispatcher
+ return launchFuture(context = dispatcher) {
+ val servicePackageName = inputData.getString(ARGUMENT_PACKAGE_NAME)
+ val serviceClassName = inputData.getString(ARGUMENT_CLASS_NAME)
+ requireNotNull(servicePackageName) {
+ "Need to specify a package name for the Remote Service."
+ }
+ requireNotNull(serviceClassName) {
+ "Need to specify a class name for the Remote Service."
+ }
+ componentName = ComponentName(servicePackageName, serviceClassName)
+ val response =
+ client
+ .execute(componentName!!) { iListenableWorkerImpl, callback ->
+ block(iListenableWorkerImpl, callback)
+ }
+ .awaitWithin(this@RemoteListenableDelegatingWorker)
+ val result = transformation(response)
+ Logger.get().debug(TAG, "Cleaning up")
+ client.unbindService()
+ result
+ }
+ }
+
companion object {
private const val TAG = "RemoteListenableDelegatingWorker"
diff --git a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelUtils.java b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelUtils.java
index 6110d9a..949bb5ef 100644
--- a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelUtils.java
+++ b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelUtils.java
@@ -16,12 +16,15 @@
package androidx.work.multiprocess.parcelable;
+import android.os.Build;
import android.os.Parcel;
+import android.os.Parcelable;
import androidx.annotation.NonNull;
import androidx.annotation.RestrictTo;
/**
+ *
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
public final class ParcelUtils {
@@ -43,4 +46,23 @@
public static void writeBooleanValue(@NonNull Parcel parcel, boolean value) {
parcel.writeInt(value ? 1 : 0);
}
+
+ /**
+ * Reads a parcelable from a parcel.
+ * <p>
+ * It's safe for us to suppress warnings here, given the correct API is being used starting
+ * API 33.
+ */
+ @SuppressWarnings("deprecation")
+ public static <T extends Parcelable> T readParcelable(
+ @NonNull Parcel parcel,
+ @NonNull Class<T> klass
+ ) {
+ ClassLoader classLoader = klass.getClassLoader();
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ return parcel.readParcelable(classLoader, klass);
+ } else {
+ return parcel.readParcelable(classLoader);
+ }
+ }
}
diff --git a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableForegroundInfo.kt b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableForegroundInfo.kt
new file mode 100644
index 0000000..05ee7ae
--- /dev/null
+++ b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableForegroundInfo.kt
@@ -0,0 +1,65 @@
+/*
+ * 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.work.multiprocess.parcelable
+
+import android.annotation.SuppressLint
+import android.app.Notification
+import android.os.Parcel
+import android.os.Parcelable
+import androidx.annotation.RestrictTo
+import androidx.work.ForegroundInfo
+
+/** [androidx.work.ForegroundInfo] but [android.os.Parcelable]. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+@SuppressLint("BanParcelableUsage")
+data class ParcelableForegroundInfo(val foregroundInfo: ForegroundInfo) : Parcelable {
+
+ constructor(
+ parcel: Parcel
+ ) : this(
+ foregroundInfo =
+ ForegroundInfo(
+ parcel.readInt(),
+ ParcelUtils.readParcelable(parcel, Notification::class.java),
+ parcel.readInt()
+ )
+ )
+
+ override fun describeContents(): Int {
+ return 0
+ }
+
+ override fun writeToParcel(parcel: Parcel, flags: Int) {
+ parcel.writeInt(foregroundInfo.notificationId)
+ parcel.writeParcelable(foregroundInfo.notification, flags)
+ parcel.writeInt(foregroundInfo.foregroundServiceType)
+ }
+
+ companion object {
+ @JvmField
+ val CREATOR: Parcelable.Creator<ParcelableForegroundInfo> =
+ object : Parcelable.Creator<ParcelableForegroundInfo> {
+ override fun createFromParcel(parcel: Parcel): ParcelableForegroundInfo {
+ return ParcelableForegroundInfo(parcel)
+ }
+
+ override fun newArray(size: Int): Array<ParcelableForegroundInfo?> {
+ return arrayOfNulls(size)
+ }
+ }
+ }
+}
diff --git a/work/work-runtime/api/aidlRelease/current/androidx/work/multiprocess/IListenableWorkerImpl.aidl b/work/work-runtime/api/aidlRelease/current/androidx/work/multiprocess/IListenableWorkerImpl.aidl
index 9fa0b41..0b86802 100644
--- a/work/work-runtime/api/aidlRelease/current/androidx/work/multiprocess/IListenableWorkerImpl.aidl
+++ b/work/work-runtime/api/aidlRelease/current/androidx/work/multiprocess/IListenableWorkerImpl.aidl
@@ -36,4 +36,5 @@
interface IListenableWorkerImpl {
oneway void startWork(in byte[] request, androidx.work.multiprocess.IWorkManagerImplCallback callback);
oneway void interrupt(in byte[] request, androidx.work.multiprocess.IWorkManagerImplCallback callback);
+ oneway void getForegroundInfoAsync(in byte[] request, androidx.work.multiprocess.IWorkManagerImplCallback callback);
}
diff --git a/work/work-runtime/src/main/stableAidl/androidx/work/multiprocess/IListenableWorkerImpl.aidl b/work/work-runtime/src/main/stableAidl/androidx/work/multiprocess/IListenableWorkerImpl.aidl
index 7d798ed..3a75f7a5 100644
--- a/work/work-runtime/src/main/stableAidl/androidx/work/multiprocess/IListenableWorkerImpl.aidl
+++ b/work/work-runtime/src/main/stableAidl/androidx/work/multiprocess/IListenableWorkerImpl.aidl
@@ -29,6 +29,11 @@
// interrupt request.
// request is a ParcelableWorkerParameters instance.
- // callback gets an empty result
+ // callback gets an empty result.
oneway void interrupt(in byte[] request, IWorkManagerImplCallback callback);
+
+ // getForegroundInfoAsync request.
+ // request is a ParcelablelRemoteRequest instance.
+ // callback gets a parcelized representation of ForegroundInfo.
+ oneway void getForegroundInfoAsync(in byte[] request, IWorkManagerImplCallback callback);
}