Merge "Add ComplexDifferentTypes benchmarks for LazyColumn and RV" into androidx-main
diff --git a/activity/activity-compose-lint/build.gradle b/activity/activity-compose-lint/build.gradle
index 354b82a..7415d8d 100644
--- a/activity/activity-compose-lint/build.gradle
+++ b/activity/activity-compose-lint/build.gradle
@@ -44,7 +44,7 @@
testImplementation(libs.kotlinStdlib)
testRuntimeOnly(libs.kotlinReflect)
testImplementation(libs.kotlinStdlibJdk8)
- testImplementation(libs.androidLintPrevApi)
+ testImplementation(libs.androidLintApiPrevAnalysis)
testImplementation(libs.androidLintTests)
testImplementation(libs.junit)
testImplementation(libs.truth)
diff --git a/activity/activity-lint/build.gradle b/activity/activity-lint/build.gradle
index e181339..19b682c 100644
--- a/activity/activity-lint/build.gradle
+++ b/activity/activity-lint/build.gradle
@@ -38,7 +38,7 @@
testImplementation(libs.kotlinStdlib)
testRuntimeOnly(libs.kotlinReflect)
testImplementation(libs.kotlinStdlibJdk8)
- testImplementation(libs.androidLintPrevApi)
+ testImplementation(libs.androidLintApiPrevAnalysis)
testImplementation(libs.androidLintTests)
testImplementation(libs.junit)
testImplementation(libs.truth)
diff --git a/appsearch/appsearch/api/current.txt b/appsearch/appsearch/api/current.txt
index 29645b5..35ebbf46 100644
--- a/appsearch/appsearch/api/current.txt
+++ b/appsearch/appsearch/api/current.txt
@@ -946,7 +946,7 @@
method public void setChildren(java.util.List<androidx.appsearch.ast.Node!>);
}
- @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public class ComparatorNode implements androidx.appsearch.ast.Node {
+ @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public final class ComparatorNode implements androidx.appsearch.ast.Node {
ctor public ComparatorNode(int, androidx.appsearch.app.PropertyPath, long);
method public int getComparator();
method public androidx.appsearch.app.PropertyPath getPropertyPath();
@@ -970,7 +970,7 @@
method public void setChildren(java.util.List<androidx.appsearch.ast.Node!>);
}
- @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public class PropertyRestrictNode implements androidx.appsearch.ast.Node {
+ @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public final class PropertyRestrictNode implements androidx.appsearch.ast.Node {
ctor public PropertyRestrictNode(androidx.appsearch.app.PropertyPath, androidx.appsearch.ast.Node);
method public androidx.appsearch.ast.Node getChild();
method public androidx.appsearch.app.PropertyPath getProperty();
diff --git a/appsearch/appsearch/api/restricted_current.txt b/appsearch/appsearch/api/restricted_current.txt
index 29645b5..35ebbf46 100644
--- a/appsearch/appsearch/api/restricted_current.txt
+++ b/appsearch/appsearch/api/restricted_current.txt
@@ -946,7 +946,7 @@
method public void setChildren(java.util.List<androidx.appsearch.ast.Node!>);
}
- @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public class ComparatorNode implements androidx.appsearch.ast.Node {
+ @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public final class ComparatorNode implements androidx.appsearch.ast.Node {
ctor public ComparatorNode(int, androidx.appsearch.app.PropertyPath, long);
method public int getComparator();
method public androidx.appsearch.app.PropertyPath getPropertyPath();
@@ -970,7 +970,7 @@
method public void setChildren(java.util.List<androidx.appsearch.ast.Node!>);
}
- @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public class PropertyRestrictNode implements androidx.appsearch.ast.Node {
+ @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public final class PropertyRestrictNode implements androidx.appsearch.ast.Node {
ctor public PropertyRestrictNode(androidx.appsearch.app.PropertyPath, androidx.appsearch.ast.Node);
method public androidx.appsearch.ast.Node getChild();
method public androidx.appsearch.app.PropertyPath getProperty();
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/ComparatorNodeCtsTest.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/ComparatorNodeCtsTest.java
index 4098926..5c2d32c 100644
--- a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/ComparatorNodeCtsTest.java
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/ComparatorNodeCtsTest.java
@@ -38,6 +38,22 @@
public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
@Test
+ public void testEquals_identical() {
+ PropertyPath propertyPathOne = new PropertyPath("example.property.path");
+ int valueOne = 5;
+ ComparatorNode equalsNodeOne =
+ new ComparatorNode(ComparatorNode.EQUALS, propertyPathOne, valueOne);
+
+ PropertyPath propertyPathTwo = new PropertyPath("example.property.path");
+ int valueTwo = 5;
+ ComparatorNode equalsNodeTwo =
+ new ComparatorNode(ComparatorNode.EQUALS, propertyPathTwo, valueTwo);
+
+ assertThat(equalsNodeOne).isEqualTo(equalsNodeTwo);
+ assertThat(equalsNodeOne.hashCode()).isEqualTo(equalsNodeTwo.hashCode());
+ }
+
+ @Test
public void testConstructor_correctConstruction() {
List<PropertyPath.PathSegment> pathSegmentList = List.of(
PropertyPath.PathSegment.create("example"),
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/androidTest/java/androidx/appsearch/cts/ast/operators/PropertyRestrictNodeTest.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/PropertyRestrictNodeTest.java
index f78abb9..806e317 100644
--- a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/PropertyRestrictNodeTest.java
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/PropertyRestrictNodeTest.java
@@ -40,6 +40,23 @@
public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
@Test
+ public void testEquals_identical() {
+ PropertyPath propertyPathOne = new PropertyPath("example.property.segment");
+ TextNode textNodeOne = new TextNode("foo");
+ PropertyRestrictNode propertyRestrictNodeOne =
+ new PropertyRestrictNode(propertyPathOne, textNodeOne);
+
+ PropertyPath propertyPathTwo = new PropertyPath("example.property.segment");
+ TextNode textNodeTwo = new TextNode("foo");
+ PropertyRestrictNode propertyRestrictNodeTwo =
+ new PropertyRestrictNode(propertyPathTwo, textNodeTwo);
+
+ assertThat(propertyRestrictNodeOne).isEqualTo(propertyRestrictNodeTwo);
+ assertThat(propertyRestrictNodeOne.hashCode())
+ .isEqualTo(propertyRestrictNodeTwo.hashCode());
+ }
+
+ @Test
public void testConstructor_takesPropertyPath() {
List<PropertyPath.PathSegment> pathSegmentList =
List.of(PropertyPath.PathSegment.create("example"),
diff --git a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/GetSearchStringParameterNodeCtsTest.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/GetSearchStringParameterNodeCtsTest.java
index d6c07a5..3628fe2 100644
--- a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/GetSearchStringParameterNodeCtsTest.java
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/GetSearchStringParameterNodeCtsTest.java
@@ -35,6 +35,18 @@
public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
@Test
+ public void testEquals_identical() {
+ GetSearchStringParameterNode getSearchStringParameterNodeOne =
+ new GetSearchStringParameterNode(1);
+ GetSearchStringParameterNode getSearchStringParameterNodeTwo =
+ new GetSearchStringParameterNode(1);
+
+ assertThat(getSearchStringParameterNodeOne).isEqualTo(getSearchStringParameterNodeTwo);
+ assertThat(getSearchStringParameterNodeOne.hashCode())
+ .isEqualTo(getSearchStringParameterNodeTwo.hashCode());
+ }
+
+ @Test
public void testConstructor_throwsOnNegativeIndex() {
IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class,
() -> new GetSearchStringParameterNode(-1));
diff --git a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/HasPropertyNodeCtsTest.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/HasPropertyNodeCtsTest.java
index 978a00e..345081f 100644
--- a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/HasPropertyNodeCtsTest.java
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/HasPropertyNodeCtsTest.java
@@ -38,6 +38,17 @@
public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
@Test
+ public void testEquals_identical() {
+ HasPropertyNode hasPropertyOne =
+ new HasPropertyNode(new PropertyPath("example.property.path"));
+ HasPropertyNode hasPropertyTwo =
+ new HasPropertyNode(new PropertyPath("example.property.path"));
+
+ assertThat(hasPropertyOne).isEqualTo(hasPropertyTwo);
+ assertThat(hasPropertyOne.hashCode()).isEqualTo(hasPropertyTwo.hashCode());
+ }
+
+ @Test
public void testConstructor_throwsOnNullPointer() {
assertThrows(NullPointerException.class, () -> new HasPropertyNode(null));
}
diff --git a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/PropertyDefinedNodeCtsTest.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/PropertyDefinedNodeCtsTest.java
index 6577fcc..a4cd6ba 100644
--- a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/PropertyDefinedNodeCtsTest.java
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/PropertyDefinedNodeCtsTest.java
@@ -38,6 +38,17 @@
public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
@Test
+ public void testEquals_identical() {
+ PropertyDefinedNode propertyDefinedOne = new PropertyDefinedNode(
+ new PropertyPath("example.property.path"));
+ PropertyDefinedNode propertyDefinedTwo = new PropertyDefinedNode(
+ new PropertyPath("example.property.path"));
+
+ assertThat(propertyDefinedOne).isEqualTo(propertyDefinedTwo);
+ assertThat(propertyDefinedOne.hashCode()).isEqualTo(propertyDefinedTwo.hashCode());
+ }
+
+ @Test
public void testConstructor_throwsOnNullPointer() {
assertThrows(NullPointerException.class, () -> new PropertyDefinedNode(null));
}
diff --git a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/SearchNodeCtsTest.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/SearchNodeCtsTest.java
index 9f9219e..041af4e 100644
--- a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/SearchNodeCtsTest.java
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/SearchNodeCtsTest.java
@@ -42,6 +42,17 @@
public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
@Test
+ public void testEquals_identical() {
+ SearchNode searchNodeOne = new SearchNode(
+ new TextNode("foo"), List.of(new PropertyPath("example.property.path")));
+ SearchNode searchNodeTwo = new SearchNode(
+ new TextNode("foo"), List.of(new PropertyPath("example.property.path")));
+
+ assertThat(searchNodeOne).isEqualTo(searchNodeTwo);
+ assertThat(searchNodeOne.hashCode()).isEqualTo(searchNodeTwo.hashCode());
+ }
+
+ @Test
public void testConstructor_defaultValues() {
TextNode node = new TextNode("foo");
SearchNode searchNode = new SearchNode(node);
diff --git a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/SemanticSearchNodeCtsTest.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/SemanticSearchNodeCtsTest.java
index 41c8eb3..b76f036 100644
--- a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/SemanticSearchNodeCtsTest.java
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/SemanticSearchNodeCtsTest.java
@@ -36,6 +36,17 @@
public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
@Test
+ public void testEquals_identical() {
+ SemanticSearchNode semanticSearchNodeOne = new SemanticSearchNode(0, -1.5f, 3,
+ SearchSpec.EMBEDDING_SEARCH_METRIC_TYPE_DOT_PRODUCT);
+ SemanticSearchNode semanticSearchNodeTwo = new SemanticSearchNode(0, -1.5f, 3,
+ SearchSpec.EMBEDDING_SEARCH_METRIC_TYPE_DOT_PRODUCT);
+
+ assertThat(semanticSearchNodeOne).isEqualTo(semanticSearchNodeTwo);
+ assertThat(semanticSearchNodeOne.hashCode()).isEqualTo(semanticSearchNodeTwo.hashCode());
+ }
+
+ @Test
public void testConstructor_throwsOnNegativeIndex() {
assertThrows(IllegalArgumentException.class, () -> new SemanticSearchNode(-1));
}
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/ComparatorNode.java b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/ComparatorNode.java
index df4c8f2..a7e032a 100644
--- a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/ComparatorNode.java
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/ComparatorNode.java
@@ -28,6 +28,7 @@
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
+import java.util.Objects;
/**
* {@link Node} that represents a numeric search expression between a property and a numeric value.
@@ -43,7 +44,7 @@
*/
@ExperimentalAppSearchApi
@FlaggedApi(Flags.FLAG_ENABLE_ABSTRACT_SYNTAX_TREES)
-public class ComparatorNode implements Node {
+public final class ComparatorNode implements Node {
/**
* Enums representing different comparators for numeric search expressions in the query
* language.
@@ -171,4 +172,18 @@
// String.format("(%s %s %s)", mPropertyPath, comparatorString, mValue);
return "(" + mPropertyPath + " " + comparatorString + " " + mValue + ")";
}
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof ComparatorNode)) return false;
+ ComparatorNode that = (ComparatorNode) o;
+ return mComparator == that.mComparator && mValue == that.mValue && Objects.equals(
+ mPropertyPath, that.mPropertyPath);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(mComparator, mPropertyPath, mValue);
+ }
}
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/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/PropertyRestrictNode.java b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/PropertyRestrictNode.java
index 42c7902..f0efef2 100644
--- a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/PropertyRestrictNode.java
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/PropertyRestrictNode.java
@@ -27,6 +27,7 @@
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
+import java.util.Objects;
/**
* {@link Node} that represents a property restrict.
@@ -42,7 +43,7 @@
*/
@ExperimentalAppSearchApi
@FlaggedApi(Flags.FLAG_ENABLE_ABSTRACT_SYNTAX_TREES)
-public class PropertyRestrictNode implements Node {
+public final class PropertyRestrictNode implements Node {
private PropertyPath mProperty;
private final List<Node> mChildren = new ArrayList<>(1);
@@ -115,4 +116,18 @@
public String toString() {
return "(" + mProperty + ":" + getChild() + ")";
}
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof PropertyRestrictNode)) return false;
+ PropertyRestrictNode that = (PropertyRestrictNode) o;
+ return Objects.equals(mProperty, that.mProperty) && Objects.equals(
+ mChildren, that.mChildren);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(mProperty, mChildren);
+ }
}
diff --git a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/GetSearchStringParameterNode.java b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/GetSearchStringParameterNode.java
index e427d02..c336dfa 100644
--- a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/GetSearchStringParameterNode.java
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/GetSearchStringParameterNode.java
@@ -104,4 +104,17 @@
return FunctionNode.FUNCTION_NAME_GET_SEARCH_STRING_PARAMETER
+ "(" + mSearchStringIndex + ")";
}
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ GetSearchStringParameterNode that = (GetSearchStringParameterNode) o;
+ return mSearchStringIndex == that.mSearchStringIndex;
+ }
+
+ @Override
+ public int hashCode() {
+ return Integer.hashCode(mSearchStringIndex);
+ }
}
diff --git a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/HasPropertyNode.java b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/HasPropertyNode.java
index fef437b..1207f74 100644
--- a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/HasPropertyNode.java
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/HasPropertyNode.java
@@ -24,6 +24,8 @@
import androidx.appsearch.flags.Flags;
import androidx.core.util.Preconditions;
+import java.util.Objects;
+
/**
* {@link FunctionNode} representing the `hasProperty` query function.
*
@@ -84,4 +86,17 @@
public String toString() {
return FunctionNode.FUNCTION_NAME_HAS_PROPERTY + "(\"" + mProperty + "\")";
}
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ HasPropertyNode that = (HasPropertyNode) o;
+ return Objects.equals(mProperty, that.mProperty);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(mProperty);
+ }
}
diff --git a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/PropertyDefinedNode.java b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/PropertyDefinedNode.java
index 4423a38..ccfdd96 100644
--- a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/PropertyDefinedNode.java
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/PropertyDefinedNode.java
@@ -24,6 +24,8 @@
import androidx.appsearch.flags.Flags;
import androidx.core.util.Preconditions;
+import java.util.Objects;
+
/**
* {@link FunctionNode} representing the `propertyDefined` query function.
*
@@ -85,4 +87,17 @@
public String toString() {
return FUNCTION_NAME_PROPERTY_DEFINED + "(\"" + mProperty + "\")";
}
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ PropertyDefinedNode that = (PropertyDefinedNode) o;
+ return Objects.equals(mProperty, that.mProperty);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(mProperty);
+ }
}
diff --git a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/SearchNode.java b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/SearchNode.java
index 6ed85b4..1bf086c 100644
--- a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/SearchNode.java
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/SearchNode.java
@@ -28,6 +28,7 @@
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
+import java.util.Objects;
/**
* {@link FunctionNode} that represents the search function.
@@ -219,4 +220,18 @@
}
return stringBuilder.toString();
}
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ SearchNode that = (SearchNode) o;
+ return Objects.equals(mChildren, that.mChildren) && Objects.equals(
+ mProperties, that.mProperties);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(mChildren, mProperties);
+ }
}
diff --git a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/SemanticSearchNode.java b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/SemanticSearchNode.java
index 7f9f07a..8fe088d 100644
--- a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/SemanticSearchNode.java
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/SemanticSearchNode.java
@@ -24,6 +24,8 @@
import androidx.appsearch.flags.Flags;
import androidx.core.util.Preconditions;
+import java.util.Objects;
+
/**
* {@link FunctionNode} that represents the semanticSearch function.
*
@@ -297,4 +299,19 @@
}
throw new IllegalStateException("Invalid Metric Type");
}
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ SemanticSearchNode that = (SemanticSearchNode) o;
+ return mVectorIndex == that.mVectorIndex && Float.compare(mLowerBound,
+ that.mLowerBound) == 0 && Float.compare(mUpperBound, that.mUpperBound) == 0
+ && mDistanceMetric == that.mDistanceMetric;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(mVectorIndex, mLowerBound, mUpperBound, mDistanceMetric);
+ }
}
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-common/src/main/java/androidx/benchmark/Insight.kt b/benchmark/benchmark-common/src/main/java/androidx/benchmark/Insight.kt
index 6fa030b..adaf085 100644
--- a/benchmark/benchmark-common/src/main/java/androidx/benchmark/Insight.kt
+++ b/benchmark/benchmark-common/src/main/java/androidx/benchmark/Insight.kt
@@ -19,25 +19,77 @@
import androidx.annotation.RestrictTo
/**
- * Represents an insight into performance issues detected during a benchmark.
+ * Individual case of a problem identified in a given trace (from a specific iteration).
*
- * Provides details about the specific criterion that was violated, along with information about
- * where and how the violation was observed.
- *
- * @param criterion A description of the performance issue, including the expected behavior and any
- * relevant thresholds.
- * @param observedV2 Specific details about when and how the violation occurred, such as the
- * iterations where it was observed and any associated values. Uses [LinkFormat.V2].
- * @param observedV3 Specific details about when and how the violation occurred, such as the
- * iterations where it was observed and any associated values. Uses [LinkFormat.V3] to link more
- * precisely into traces.
- *
- * TODO(364598145): generalize
- * TODO: Defer string construction until InstrumentationResults output step
+ * Benchmark output will summarize multiple insights, for example:
+ * ```Markdown
+ * [JIT Activity](https://d.android.com/test#JIT_ACTIVITY) (expected: < 100000000ns)
+ * seen in iterations: [6](file:///path/to/trace.perfetto-trace)(328462261ns),
+ * ```
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
data class Insight(
- val criterion: String,
- val observedV2: String,
- val observedV3: String,
-)
+ /**
+ * Category of the Insight, representing the type of problem.
+ *
+ * Every similar insight must identify equal [Category]s to enable macrobenchmark to group them
+ * across iterations.
+ */
+ val category: Category,
+
+ /** suffix after the deeplink to convey problem severity, e.g. "(100000000ns)" */
+ val observedLabel: String,
+
+ /**
+ * Link to content within a trace to highlight for the given problem.
+ *
+ * Insights should specify [TraceDeepLink.SelectionParams] so that the correct process, thread,
+ * and time range is highlighted. In addition, the optional
+ * [TraceDeepLink.SelectionParams.query] can be specified to highlight specific slices or other
+ * events of interest in the trace.
+ */
+ val deepLink: TraceDeepLink,
+
+ /** Macrobenchmark iteration in which this insight was observed. */
+ val iterationIndex: Int,
+) {
+ /**
+ * Category represents general expectations that have been violated by an insight - e.g. JIT
+ * shouldn't take longer than XX ms, or trampoline activities shouldn't be used.
+ *
+ * Every Insight that shares the same conceptual category must share an equal [Category] so that
+ * macrobenchmark can group them when summarizing observed patterns across multiple iterations.
+ *
+ * In Studio versions supporting web links, will produce an output like the following:
+ * ```Markdown
+ * [JIT compiled methods](https://d.android.com/test#JIT_COMPILED_METHODS) (expected: < 65 count)
+ * ```
+ *
+ * In the above example:
+ * - [title] is `"JIT compiled methods"`
+ * - [titleUrl] is `"https://d.android.com/test#JIT_COMPILED_METHODS"`
+ * - [postTitleLabel] is " (expected: < 65 count)"
+ *
+ * In Studio versions not supporting web links, [titleUrl] is ignored.
+ */
+ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+ data class Category(
+ /** Title of the Insight category, for example "JIT compiled methods" */
+ val title: String,
+ /** Optional web URL which, if non-null, will make the [title] a link. */
+ val titleUrl: String?,
+ /**
+ * Suffix after the title, generally specifying expected values.
+ *
+ * For example, " (expected: < 65 count)"
+ */
+ val postTitleLabel: String,
+ ) {
+ fun header(linkFormat: LinkFormat): String =
+ if (linkFormat == LinkFormat.V3 && titleUrl != null) {
+ Markdown.createLink(title, titleUrl)
+ } else {
+ title
+ } + postTitleLabel
+ }
+}
diff --git a/benchmark/benchmark-common/src/main/java/androidx/benchmark/InsightSummary.kt b/benchmark/benchmark-common/src/main/java/androidx/benchmark/InsightSummary.kt
new file mode 100644
index 0000000..ebfd7ce
--- /dev/null
+++ b/benchmark/benchmark-common/src/main/java/androidx/benchmark/InsightSummary.kt
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) 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.benchmark
+
+import androidx.annotation.RestrictTo
+
+private fun List<Insight>.toObserved(linkFormat: LinkFormat): String {
+ return this.joinToString(separator = " ", prefix = "seen in iterations: ") { insight ->
+ insight.deepLink.createMarkdownLink(insight.iterationIndex.toString(), linkFormat) +
+ "(${insight.observedLabel})"
+ }
+}
+
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+fun List<Insight>.createInsightSummaries(): List<InsightSummary> {
+ return this.groupBy { it.category }
+ .map { (category, insights) ->
+ InsightSummary(category, insights.sortedBy { it.iterationIndex })
+ }
+}
+
+/**
+ * Represents an insight into performance issues detected during a benchmark.
+ *
+ * Provides details about the specific criterion that was violated, along with information about
+ * where and how the violation was observed.
+ *
+ * @param category A description of the performance issue, including the expected behavior and any
+ * relevant thresholds.
+ * @param observedV2 Specific details about when and how the violation occurred, such as the
+ * iterations where it was observed and any associated values. Uses [LinkFormat.V2].
+ * @param observedV3 Specific details about when and how the violation occurred, such as the
+ * iterations where it was observed and any associated values. Uses [LinkFormat.V3] to link more
+ * precisely into traces.
+ *
+ * TODO(364598145): generalize
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+data class InsightSummary(
+ val category: String,
+ val observedV2: String,
+ val observedV3: String,
+) {
+ constructor(
+ category: Insight.Category,
+ insights: List<Insight>
+ ) : this(
+ category = category.header(LinkFormat.V3), // TODO: avoid this format hard coding!
+ observedV2 = insights.toObserved(LinkFormat.V2),
+ observedV3 = insights.toObserved(LinkFormat.V3)
+ ) {
+ require(insights.isNotEmpty())
+ require(insights.all { it.category == category })
+ }
+}
diff --git a/benchmark/benchmark-common/src/main/java/androidx/benchmark/InstrumentationResults.kt b/benchmark/benchmark-common/src/main/java/androidx/benchmark/InstrumentationResults.kt
index 0cfa638..b4ebdff 100644
--- a/benchmark/benchmark-common/src/main/java/androidx/benchmark/InstrumentationResults.kt
+++ b/benchmark/benchmark-common/src/main/java/androidx/benchmark/InstrumentationResults.kt
@@ -79,7 +79,7 @@
measurements: Measurements? = null,
iterationTracePaths: List<String>? = null,
profilerResults: List<Profiler.ResultFile> = emptyList(),
- insights: List<Insight> = emptyList(),
+ insightSummaries: List<InsightSummary> = emptyList(),
useTreeDisplayFormat: Boolean = false
) {
if (warningMessage != null) {
@@ -92,7 +92,7 @@
measurements = measurements,
iterationTracePaths = iterationTracePaths,
profilerResults = profilerResults,
- insights = insights,
+ insightSummaries = insightSummaries,
useTreeDisplayFormat = useTreeDisplayFormat
)
reportIdeSummary(summaryV2 = summaryPair.summaryV2, summaryV3 = summaryPair.summaryV3)
@@ -192,7 +192,7 @@
measurements: Measurements? = null,
iterationTracePaths: List<String>? = null,
profilerResults: List<Profiler.ResultFile> = emptyList(),
- insights: List<Insight> = emptyList(),
+ insightSummaries: List<InsightSummary> = emptyList(),
useTreeDisplayFormat: Boolean = false,
): IdeSummaryPair {
val warningMessage = ideWarningPrefix.ifEmpty { null }
@@ -317,10 +317,10 @@
tree.append("Metrics", 0)
for (metric in v2metricLines) tree.append(metric, 1)
}
- if (insights.isNotEmpty()) {
+ if (insightSummaries.isNotEmpty()) {
tree.append("App Startup Insights", 0)
- for (insight in insights) {
- tree.append(insight.criterion, 1)
+ for (insight in insightSummaries) {
+ tree.append(insight.category, 1)
val observed =
when (linkFormat) {
LinkFormat.V2 -> insight.observedV2
diff --git a/benchmark/benchmark-common/src/main/java/androidx/benchmark/TraceDeepLink.kt b/benchmark/benchmark-common/src/main/java/androidx/benchmark/TraceDeepLink.kt
index 05e68eb..009a190 100644
--- a/benchmark/benchmark-common/src/main/java/androidx/benchmark/TraceDeepLink.kt
+++ b/benchmark/benchmark-common/src/main/java/androidx/benchmark/TraceDeepLink.kt
@@ -23,7 +23,7 @@
import java.util.zip.Deflater
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-class TraceDeepLink(
+data class TraceDeepLink(
/** Output relative path of trace file */
private val outputRelativePath: String,
private val selectionParams: SelectionParams?
@@ -46,7 +46,7 @@
}
}
- class SelectionParams(
+ data class SelectionParams(
val pid: Long,
val tid: Long?,
val ts: Long,
diff --git a/benchmark/benchmark-macro/build.gradle b/benchmark/benchmark-macro/build.gradle
index 5dd32d4..3b9fbf5 100644
--- a/benchmark/benchmark-macro/build.gradle
+++ b/benchmark/benchmark-macro/build.gradle
@@ -150,7 +150,6 @@
prune 'perfetto.protos.AndroidMemoryUnaggregatedMetric'
prune 'perfetto.protos.AndroidMultiuserMetric'
prune 'perfetto.protos.AndroidNetworkMetric'
- prune 'perfetto.protos.AndroidOtherTracesMetric'
prune 'perfetto.protos.AndroidPackageList'
prune 'perfetto.protos.AndroidPowerRails'
prune 'perfetto.protos.AndroidProcessMetadata'
@@ -159,7 +158,6 @@
prune 'perfetto.protos.AndroidSurfaceflingerMetric'
prune 'perfetto.protos.AndroidTaskNames'
prune 'perfetto.protos.AndroidTraceQualityMetric'
- prune 'perfetto.protos.AndroidTrustyWorkqueues'
prune 'perfetto.protos.G2dMetrics'
prune 'perfetto.protos.JavaHeapHistogram'
prune 'perfetto.protos.JavaHeapStats'
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/InsightTest.kt b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/InsightTest.kt
new file mode 100644
index 0000000..2fad674
--- /dev/null
+++ b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/InsightTest.kt
@@ -0,0 +1,146 @@
+/*
+ * 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.benchmark.macro
+
+import androidx.benchmark.Insight
+import androidx.benchmark.InsightSummary
+import androidx.benchmark.TraceDeepLink
+import androidx.benchmark.createInsightSummaries
+import androidx.benchmark.macro.perfetto.queryStartupInsights
+import androidx.benchmark.perfetto.PerfettoTraceProcessor
+import androidx.test.filters.MediumTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class InsightTest {
+ private val api35ColdStart =
+ createTempFileFromAsset(prefix = "api35_startup_cold_classinit", suffix = ".perfetto-trace")
+ .absolutePath
+
+ // TODO (b/377581661) use deeplink from metric
+ private val deepLink =
+ TraceDeepLink(
+ outputRelativePath = "/fake/output/relative/path.perfetto-trace",
+ selectionParams =
+ TraceDeepLink.SelectionParams(
+ pid = 27246,
+ tid = 27246,
+ ts = 351868459760497,
+ dur = 24573541,
+ query = "SELECT 🐲\nFROM 🐉\nWHERE \ud83d\udc09.NAME = 'ハク'"
+ )
+ )
+
+ private val canonicalTraceInsights =
+ listOf(
+ Insight(
+ observedLabel = "123305107ns",
+ deepLink = deepLink,
+ iterationIndex = 6,
+ category =
+ Insight.Category(
+ titleUrl =
+ "https://d.android.com/test#POTENTIAL_CPU_CONTENTION_WITH_ANOTHER_PROCESS",
+ title = "Potential CPU contention with another process",
+ postTitleLabel = " (expected: < 100000000ns)"
+ )
+ ),
+ Insight(
+ observedLabel = "328462261ns",
+ deepLink = deepLink,
+ iterationIndex = 6,
+ category =
+ Insight.Category(
+ titleUrl = "https://d.android.com/test#JIT_ACTIVITY",
+ title = "JIT Activity",
+ postTitleLabel = " (expected: < 100000000ns)"
+ )
+ ),
+ Insight(
+ observedLabel = "150 count",
+ deepLink = deepLink,
+ iterationIndex = 6,
+ category =
+ Insight.Category(
+ titleUrl = "https://d.android.com/test#JIT_COMPILED_METHODS",
+ title = "JIT compiled methods",
+ postTitleLabel = " (expected: < 65 count)"
+ )
+ )
+ )
+
+ private val canonicalTraceInsightSummary =
+ listOf(
+ InsightSummary(
+ category =
+ "[Potential CPU contention with another process](https://d.android.com/test#POTENTIAL_CPU_CONTENTION_WITH_ANOTHER_PROCESS) (expected: < 100000000ns)",
+ observedV2 =
+ "seen in iterations: [6](file:///fake/output/relative/path.perfetto-trace)(123305107ns)",
+ observedV3 =
+ "seen in iterations: [6](uri:///fake/output/relative/path.perfetto-trace?selectionParams=eNoryEyxNTI3MjFTK0Gwim2NTQ0tzCxMTC3NzQxMLM3VUkqLbI1MTM2NTU0M1QpLU4sqbYNdfVydQ7RV3QxULd1ULQ1UnYxUDRzdgvx9kcQsLIFi4R6uQa4ognp-jr5AEWMXbVUjc1VXY1ULIHIDM4xUHd2AggBTViPI)(123305107ns)"
+ ),
+ InsightSummary(
+ category =
+ "[JIT Activity](https://d.android.com/test#JIT_ACTIVITY) (expected: < 100000000ns)",
+ observedV2 =
+ "seen in iterations: [6](file:///fake/output/relative/path.perfetto-trace)(328462261ns)",
+ observedV3 =
+ "seen in iterations: [6](uri:///fake/output/relative/path.perfetto-trace?selectionParams=eNoryEyxNTI3MjFTK0Gwim2NTQ0tzCxMTC3NzQxMLM3VUkqLbI1MTM2NTU0M1QpLU4sqbYNdfVydQ7RV3QxULd1ULQ1UnYxUDRzdgvx9kcQsLIFi4R6uQa4ognp-jr5AEWMXbVUjc1VXY1ULIHIDM4xUHd2AggBTViPI)(328462261ns)"
+ ),
+ InsightSummary(
+ category =
+ "[JIT compiled methods](https://d.android.com/test#JIT_COMPILED_METHODS) (expected: < 65 count)",
+ observedV2 =
+ "seen in iterations: [6](file:///fake/output/relative/path.perfetto-trace)(150 count)",
+ observedV3 =
+ "seen in iterations: [6](uri:///fake/output/relative/path.perfetto-trace?selectionParams=eNoryEyxNTI3MjFTK0Gwim2NTQ0tzCxMTC3NzQxMLM3VUkqLbI1MTM2NTU0M1QpLU4sqbYNdfVydQ7RV3QxULd1ULQ1UnYxUDRzdgvx9kcQsLIFi4R6uQa4ognp-jr5AEWMXbVUjc1VXY1ULIHIDM4xUHd2AggBTViPI)(150 count)"
+ ),
+ )
+
+ @MediumTest
+ @Test
+ fun queryStartupInsights() {
+ PerfettoTraceProcessor.runSingleSessionServer(api35ColdStart) {
+ assertThat(
+ queryStartupInsights(
+ helpUrlBase = "https://d.android.com/test#",
+ traceOutputRelativePath = "/fake/output/relative/path.perfetto-trace",
+ iteration = 6,
+ packageName = Packages.MISSING
+ )
+ )
+ .isEmpty()
+
+ assertThat(
+ queryStartupInsights(
+ helpUrlBase = "https://d.android.com/test#",
+ traceOutputRelativePath = "/fake/output/relative/path.perfetto-trace",
+ iteration = 6,
+ packageName = "androidx.compose.integration.hero.macrobenchmark.target"
+ )
+ )
+ .isEqualTo(canonicalTraceInsights)
+ }
+ }
+
+ @MediumTest
+ @Test
+ fun createInsightSummaries() {
+ assertThat(canonicalTraceInsights.createInsightSummaries())
+ .isEqualTo(canonicalTraceInsightSummary)
+ }
+}
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/InsightExtensions.kt b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/InsightExtensions.kt
deleted file mode 100644
index 6c7ca50..0000000
--- a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/InsightExtensions.kt
+++ /dev/null
@@ -1,198 +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.benchmark.macro
-
-import androidx.benchmark.Insight
-import androidx.benchmark.LinkFormat
-import androidx.benchmark.Markdown
-import androidx.benchmark.Outputs
-import androidx.benchmark.StartupInsightsConfig
-import androidx.benchmark.TraceDeepLink
-import androidx.benchmark.inMemoryTrace
-import androidx.benchmark.perfetto.PerfettoTraceProcessor
-import androidx.benchmark.perfetto.Row
-import java.net.URLEncoder
-import perfetto.protos.AndroidStartupMetric.SlowStartReason
-import perfetto.protos.AndroidStartupMetric.ThresholdValue.ThresholdUnit
-
-/**
- * Aggregates raw SlowStartReason results into a list of [Insight]s - in a format easier to display
- * in the IDE as a summary.
- *
- * TODO(353692849): add unit tests
- */
-internal fun createInsightsIdeSummary(
- startupInsightsConfig: StartupInsightsConfig?,
- iterationResults: List<IterationResult>
-): List<Insight> {
- fun createInsightString(
- criterion: SlowStartReason,
- observed: List<IndexedValue<SlowStartReason>>
- ): Insight {
- observed.forEach {
- require(it.value.reason_id == criterion.reason_id)
- require(it.value.expected_value == criterion.expected_value)
- }
-
- val expectedValue = requireNotNull(criterion.expected_value)
- val thresholdUnit = requireNotNull(expectedValue.unit)
- require(thresholdUnit != ThresholdUnit.THRESHOLD_UNIT_UNSPECIFIED)
- val unitSuffix =
- when (thresholdUnit) {
- ThresholdUnit.NS -> "ns"
- ThresholdUnit.PERCENTAGE -> "%"
- ThresholdUnit.COUNT -> " count"
- ThresholdUnit.TRUE_OR_FALSE -> ""
- else -> " ${thresholdUnit.toString().lowercase()}"
- }
-
- val criterionString = buildString {
- val reasonHelpUrlBase = startupInsightsConfig?.reasonHelpUrlBase
- if (reasonHelpUrlBase != null) {
- append("[")
- append(requireNotNull(criterion.reason).replace("]", "\\]"))
- append("]")
- append("(")
- append(reasonHelpUrlBase.replace(")", "\\)")) // base url
- val reasonId = requireNotNull(criterion.reason_id).name
- append(URLEncoder.encode(reasonId, Charsets.UTF_8.name())) // reason id as a suffix
- append(")")
- } else {
- append(requireNotNull(criterion.reason))
- }
-
- val thresholdValue = requireNotNull(expectedValue.value_)
- append(" (expected: ")
- if (thresholdUnit == ThresholdUnit.TRUE_OR_FALSE) {
- require(thresholdValue in 0L..1L)
- if (thresholdValue == 0L) append("false")
- if (thresholdValue == 1L) append("true")
- } else {
- if (expectedValue.higher_expected == true) append("> ")
- if (expectedValue.higher_expected == false) append("< ")
- append(thresholdValue)
- append(unitSuffix)
- }
- append(")")
- }
-
- val observedMap =
- listOf(LinkFormat.V2, LinkFormat.V3).associate { linkFormat ->
- val observedString =
- observed.joinToString(" ", "seen in iterations: ") {
- val observedValue = requireNotNull(it.value.actual_value?.value_)
- val observedString: String =
- if (thresholdUnit == ThresholdUnit.TRUE_OR_FALSE) {
- require(observedValue in 0L..1L)
- if (observedValue == 0L) "false" else "true"
- } else {
- "$observedValue$unitSuffix"
- }
-
- // TODO(364590575): implement zoom-in on relevant parts of the trace and
- // then make
- // the 'actualString' also part of the link.
- val tracePath = iterationResults.getOrNull(it.index)?.tracePath
- if (tracePath == null) "${it.index}($observedString)"
- else
- when (linkFormat) {
- LinkFormat.V2 -> {
- val relativePath = Outputs.relativePathFor(tracePath)
- val link = Markdown.createFileLink("${it.index}", relativePath)
- "$link($observedString)"
- }
- LinkFormat.V3 -> {
- TraceDeepLink(
- outputRelativePath = Outputs.relativePathFor(tracePath),
- selectionParams =
- iterationResults[it.index]
- .defaultStartupInsightSelectionParams
- )
- .createMarkdownLink(
- label = "${it.index}($observedString)",
- linkFormat = LinkFormat.V3
- )
- }
- }
- }
- Pair(linkFormat, observedString)
- }
-
- return Insight(
- criterion = criterionString,
- observedV2 = observedMap[LinkFormat.V2]!!,
- observedV3 = observedMap[LinkFormat.V3]!!
- )
- }
-
- // Pivot from List<iteration_id -> insight_list> to List<insight -> iteration_list>
- // and convert to a format expected in Studio text output.
- return iterationResults
- .map { it.insights }
- .flatMapIndexed { iterationId, insights -> insights.map { IndexedValue(iterationId, it) } }
- .groupBy { it.value.reason_id }
- .values
- .map { createInsightString(it.first().value, it) }
-}
-
-/**
- * Sets [TraceDeepLink.SelectionParams] based on the last `Startup` slice. Temporary hack until we
- * get this information directly from [SlowStartReason].
- */
-internal fun PerfettoTraceProcessor.Session.extractStartupSliceSelectionParams(
- packageName: String
-): TraceDeepLink.SelectionParams? {
- inMemoryTrace("extractStartupSliceSelectionParams") {
- // note: not using utid, upid as not stable between trace processor releases
- // also note: https://perfetto.dev/docs/analysis/sql-tables#process
- // also note: https://perfetto.dev/docs/analysis/sql-tables#thread
- val query =
- """
- select
- process.pid as pid,
- thread.tid as tid,
- slice.ts,
- slice.dur,
- slice.name, -- left for debugging, can be removed
- process.name as process_name -- left for debugging, can be removed
- from slice
- join thread_track on thread_track.id = slice.track_id
- join thread using(utid)
- join process using(upid)
- where slice.name = 'Startup' and process.name like '${packageName}%'
- """
- .trimIndent()
-
- val events = query(query).toList()
- if (events.isEmpty()) {
- return null
- } else {
- val queryResult: Row = events.first()
- return TraceDeepLink.SelectionParams(
- pid = queryResult.long("pid"),
- tid = queryResult.long("tid"),
- ts = queryResult.long("ts"),
- dur = queryResult.long("dur"),
- // query belongs in the [SlowStartReason] object (to be added there)
- // we can't do anything here now, so setting it to something that will test
- // that we're handling Unicode correctly
- // see: http://shortn/_yWn9yR2OHr
- query = "SELECT 🐲\nFROM 🐉\nWHERE \ud83d\udc09.NAME = 'ハク'"
- )
- }
- }
-}
diff --git a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/Macrobenchmark.kt b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/Macrobenchmark.kt
index 249431aa..2f1aa74 100644
--- a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/Macrobenchmark.kt
+++ b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/Macrobenchmark.kt
@@ -34,6 +34,7 @@
import androidx.benchmark.Shell
import androidx.benchmark.checkAndGetSuppressionState
import androidx.benchmark.conditionalError
+import androidx.benchmark.createInsightSummaries
import androidx.benchmark.inMemoryTrace
import androidx.benchmark.json.BenchmarkData
import androidx.benchmark.macro.MacrobenchmarkScope.KillFlushMode
@@ -308,20 +309,6 @@
}
}
}
- /*
- val tracePaths = mutableListOf<String>()
- val profilerResults = mutableListOf<Profiler.ResultFile>()
- val measurementsList = mutableListOf<List<Metric.Measurement>>()
- val insightsList = mutableListOf<List<AndroidStartupMetric.SlowStartReason>>()
-
- iterationResults.forEach {
- tracePaths += it.tracePaths
- profilerResults += it.profilerResults
- measurementsList += it.measurements
- insightsList += it.insights
- }
-
- */
// Merge measurements
val measurements = iterationResults.map { it.measurements }.mergeMultiIterResults()
@@ -342,11 +329,7 @@
warningMessage = warningMessage,
testName = uniqueName,
measurements = measurements,
- insights =
- createInsightsIdeSummary(
- experimentalConfig?.startupInsightsConfig,
- iterationResults
- ),
+ insightSummaries = iterationResults.flatMap { it.insights }.createInsightSummaries(),
iterationTracePaths = iterationTracePaths,
profilerResults = profilerResults,
useTreeDisplayFormat = experimentalConfig?.startupInsightsConfig?.isEnabled == true
diff --git a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/MacrobenchmarkPhase.kt b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/MacrobenchmarkPhase.kt
index c36c6d2..c146870 100644
--- a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/MacrobenchmarkPhase.kt
+++ b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/MacrobenchmarkPhase.kt
@@ -18,11 +18,14 @@
import android.os.Build
import android.util.Log
+import androidx.benchmark.Arguments
import androidx.benchmark.ExperimentalBenchmarkConfigApi
import androidx.benchmark.ExperimentalConfig
+import androidx.benchmark.Insight
+import androidx.benchmark.Outputs
import androidx.benchmark.Profiler
-import androidx.benchmark.TraceDeepLink
import androidx.benchmark.inMemoryTrace
+import androidx.benchmark.macro.perfetto.queryStartupInsights
import androidx.benchmark.perfetto.PerfettoCapture
import androidx.benchmark.perfetto.PerfettoCaptureWrapper
import androidx.benchmark.perfetto.PerfettoConfig
@@ -32,8 +35,6 @@
import androidx.benchmark.perfetto.appendUiState
import androidx.tracing.trace
import java.io.File
-import perfetto.protos.AndroidStartupMetric
-import perfetto.protos.TraceMetrics
/** A Profiler being used during a Macro Benchmark Phase. */
internal interface PhaseProfiler {
@@ -59,8 +60,7 @@
val tracePath: String,
val profilerResultFiles: List<Profiler.ResultFile>,
val measurements: List<Metric.Measurement>,
- val insights: List<AndroidStartupMetric.SlowStartReason>,
- val defaultStartupInsightSelectionParams: TraceDeepLink.SelectionParams?,
+ val insights: List<Insight>
)
/** Run a Macrobenchmark Phase and collect a list of [IterationResult]. */
@@ -90,7 +90,7 @@
try {
// Configure metrics in the Phase.
metrics.forEach { it.configure(captureInfo) }
- return List<IterationResult>(iterations) { iteration ->
+ return List(iterations) { iteration ->
// Wake the device to ensure it stays awake with large iteration count
inMemoryTrace("wake device") { scope.device.wakeUp() }
@@ -162,8 +162,6 @@
IterationResult(
tracePath = tracePath,
profilerResultFiles = profilerResultFiles,
-
- // Extracts the metrics using the perfetto trace processor
measurements =
inMemoryTrace("extract metrics") {
metrics
@@ -173,24 +171,17 @@
.reduceOrNull() { sum, element -> sum.merge(element) }
?: emptyList()
},
-
- // Extracts the insights using the perfetto trace processor
insights =
if (experimentalConfig?.startupInsightsConfig?.isEnabled == true) {
- inMemoryTrace("extract insights") {
- TraceMetrics.ADAPTER.decode(
- queryMetricsProtoBinary(listOf("android_startup"))
- )
- .android_startup
- ?.startup
- ?.flatMap { it.slow_start_reason_with_details } ?: emptyList()
- }
- } else emptyList(),
-
- // Extracts a default startup selection param for deep link construction
- // Eventually, this should be removed in favor of extracting info from insights
- defaultStartupInsightSelectionParams =
- extractStartupSliceSelectionParams(packageName = packageName)
+ queryStartupInsights(
+ helpUrlBase = Arguments.startupInsightsHelpUrlBase,
+ traceOutputRelativePath = Outputs.relativePathFor(tracePath),
+ iteration = iteration,
+ packageName = packageName
+ )
+ } else {
+ emptyList()
+ }
)
}
}
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..762383a 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
@@ -77,13 +77,10 @@
init {
val expectedArtMainlineVersion = expectedArtMainlineVersion(apiLevel)
if (expectedArtMainlineVersion != null) {
+ // require exact match
require(artMainlineVersion == expectedArtMainlineVersion) {
"For API level $apiLevel, expected artMainlineVersion to be $expectedArtMainlineVersion, observed $artMainlineVersion"
}
- } else if (artMainlineVersion != null) {
- require(artMainlineVersion > 1L) {
- "For API level $apiLevel, expected artMainlineVersion to be > 1, observed $artMainlineVersion"
- }
}
}
@@ -92,6 +89,8 @@
when {
apiLevel == 30 -> 1L
apiLevel < 30 -> -1
+ // can't reason about other levels, since low ram go devices
+ // may not have mainline updates enabled at all, e.g. wembley
else -> null
}
@@ -638,15 +637,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 +654,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 +701,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 +722,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/benchmark-macro/src/main/java/androidx/benchmark/macro/perfetto/StartupInsights.kt b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/perfetto/StartupInsights.kt
new file mode 100644
index 0000000..601b5c3
--- /dev/null
+++ b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/perfetto/StartupInsights.kt
@@ -0,0 +1,171 @@
+/*
+ * 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.benchmark.macro.perfetto
+
+import androidx.benchmark.Insight
+import androidx.benchmark.TraceDeepLink
+import androidx.benchmark.inMemoryTrace
+import androidx.benchmark.perfetto.PerfettoTraceProcessor
+import androidx.benchmark.perfetto.Row
+import perfetto.protos.AndroidStartupMetric.SlowStartReason
+import perfetto.protos.AndroidStartupMetric.ThresholdValue.ThresholdUnit
+import perfetto.protos.TraceMetrics
+
+/**
+ * Convert the SlowStartReason concept from Perfetto's android_startup metric to the Macrobenchmark
+ * generic Insight format
+ */
+private fun SlowStartReason.toInsight(
+ helpUrlBase: String?,
+ traceOutputRelativePath: String,
+ iterationIndex: Int,
+ defaultStartupInsightSelectionParams: TraceDeepLink.SelectionParams?
+): Insight {
+ val thresholdUnit = expected_value!!.unit!!
+ val unitSuffix =
+ when (thresholdUnit) {
+ ThresholdUnit.NS -> "ns"
+ ThresholdUnit.PERCENTAGE -> "%"
+ ThresholdUnit.COUNT -> " count"
+ ThresholdUnit.TRUE_OR_FALSE -> ""
+ else -> " ${thresholdUnit.toString().lowercase()}"
+ }
+
+ val thresholdValue = expected_value.value_!!
+
+ val thresholdString =
+ StringBuilder()
+ .apply {
+ append(" (expected: ")
+ if (thresholdUnit == ThresholdUnit.TRUE_OR_FALSE) {
+ when (thresholdValue) {
+ 0L -> append("false")
+ 1L -> append("true")
+ else ->
+ throw IllegalArgumentException(
+ "Unexpected boolean value $thresholdValue"
+ )
+ }
+ } else {
+ if (expected_value.higher_expected == true) append("> ")
+ if (expected_value.higher_expected == false) append("< ")
+ append(thresholdValue)
+ append(unitSuffix)
+ }
+ append(")")
+ }
+ .toString()
+
+ val category =
+ Insight.Category(
+ titleUrl = helpUrlBase?.plus(reason_id!!.name),
+ title = reason!!,
+ postTitleLabel = thresholdString
+ )
+
+ val observedValue = requireNotNull(actual_value?.value_)
+ return Insight(
+ observedLabel =
+ if (thresholdUnit == ThresholdUnit.TRUE_OR_FALSE) {
+ require(observedValue in 0L..1L)
+ if (observedValue == 0L) "false" else "true"
+ } else {
+ "$observedValue$unitSuffix"
+ },
+ deepLink =
+ TraceDeepLink(
+ outputRelativePath = traceOutputRelativePath,
+ selectionParams = defaultStartupInsightSelectionParams
+ ),
+ iterationIndex = iterationIndex,
+ category = category,
+ )
+}
+
+internal fun PerfettoTraceProcessor.Session.queryStartupInsights(
+ helpUrlBase: String?,
+ traceOutputRelativePath: String,
+ iteration: Int,
+ packageName: String
+): List<Insight> =
+ inMemoryTrace("extract insights") {
+ val defaultStartupInsightSelectionParams =
+ extractStartupSliceSelectionParams(packageName = packageName)
+ TraceMetrics.ADAPTER.decode(queryMetricsProtoBinary(listOf("android_startup")))
+ .android_startup
+ ?.startup
+ ?.filter { it.package_name == packageName } // TODO: fuzzy match?
+ ?.flatMap { it.slow_start_reason_with_details }
+ ?.map {
+ it.toInsight(
+ helpUrlBase = helpUrlBase,
+ traceOutputRelativePath = traceOutputRelativePath,
+ defaultStartupInsightSelectionParams = defaultStartupInsightSelectionParams,
+ iterationIndex = iteration
+ )
+ } ?: emptyList()
+ }
+
+/**
+ * Construct's [TraceDeepLink.SelectionParams] based on the last `Startup` slice. Temporary hack
+ * until we get this information directly from [SlowStartReason].
+ *
+ * TODO (b/377581661) remove, and instead construct deeplink from [SlowStartReason]
+ */
+internal fun PerfettoTraceProcessor.Session.extractStartupSliceSelectionParams(
+ packageName: String
+): TraceDeepLink.SelectionParams? {
+ inMemoryTrace("extractStartupSliceSelectionParams") {
+ // note: not using utid, upid as not stable between trace processor releases
+ // also note: https://perfetto.dev/docs/analysis/sql-tables#process
+ // also note: https://perfetto.dev/docs/analysis/sql-tables#thread
+ val query =
+ """
+ select
+ process.pid as pid,
+ thread.tid as tid,
+ slice.ts,
+ slice.dur,
+ slice.name, -- left for debugging, can be removed
+ process.name as process_name -- left for debugging, can be removed
+ from slice
+ join thread_track on thread_track.id = slice.track_id
+ join thread using(utid)
+ join process using(upid)
+ where slice.name = 'Startup' and process.name like '${packageName}%'
+ """
+ .trimIndent()
+
+ val events = query(query).toList()
+ if (events.isEmpty()) {
+ return null
+ } else {
+ val queryResult: Row = events.first()
+ return TraceDeepLink.SelectionParams(
+ pid = queryResult.long("pid"),
+ tid = queryResult.long("tid"),
+ ts = queryResult.long("ts"),
+ dur = queryResult.long("dur"),
+ // query belongs in the [SlowStartReason] object (to be added there)
+ // we can't do anything here now, so setting it to something that will test
+ // that we're handling Unicode correctly
+ // see: http://shortn/_yWn9yR2OHr
+ query = "SELECT 🐲\nFROM 🐉\nWHERE \ud83d\udc09.NAME = 'ハク'"
+ )
+ }
+ }
+}
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/AndroidXComposeImplPlugin.kt b/buildSrc/private/src/main/kotlin/androidx/build/AndroidXComposeImplPlugin.kt
index e8ecf60..2d2a654 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/AndroidXComposeImplPlugin.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/AndroidXComposeImplPlugin.kt
@@ -113,11 +113,6 @@
// If a project has opted-out of Compose compiler plugin, don't add it
if (!extension.composeCompilerPluginEnabled) return@afterEvaluate
- val androidXExtension =
- project.extensions.findByType(AndroidXExtension::class.java)
- ?: throw Exception("You have applied AndroidXComposePlugin without AndroidXPlugin")
- val shouldPublish = androidXExtension.shouldPublish()
-
// Create configuration that we'll use to load Compose compiler plugin
val configuration =
project.configurations.create(COMPILER_PLUGIN_CONFIGURATION) {
@@ -200,9 +195,8 @@
compile.enableFeatureFlag(ComposeFeatureFlag.OptimizeNonSkippingGroups)
compile.enableFeatureFlag(ComposeFeatureFlag.PausableComposition)
}
- if (shouldPublish) {
- compile.addPluginOption(ComposeCompileOptions.SourceOption, "true")
- }
+
+ compile.addPluginOption(ComposeCompileOptions.SourceOption, "true")
}
if (enableMetrics) {
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/AndroidXImplPlugin.kt b/buildSrc/private/src/main/kotlin/androidx/build/AndroidXImplPlugin.kt
index caddc82..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)
@@ -1019,6 +1025,7 @@
}
project.configureProjectForApiTasks(apiTaskConfig, androidXExtension)
+ project.configureProjectForKzipTasks(apiTaskConfig, androidXExtension)
project.setUpCheckDocsTask(androidXExtension)
if (project.multiplatformExtension == null) {
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 7d70637..17d960d 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/checkapi/ApiTasks.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/checkapi/ApiTasks.kt
@@ -21,11 +21,9 @@
import androidx.build.RunApiTasks
import androidx.build.Version
import androidx.build.binarycompatibilityvalidator.BinaryCompatibilityValidation
-import androidx.build.getDefaultTargetJavaVersion
import androidx.build.getSupportRootFolder
import androidx.build.isWriteVersionedApiFilesEnabled
import androidx.build.java.JavaCompileInputs
-import androidx.build.kythe.GenerateKotlinKzipTask
import androidx.build.metalava.MetalavaTasks
import androidx.build.multiplatformExtension
import androidx.build.resources.ResourceTasks
@@ -37,6 +35,7 @@
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
@@ -163,61 +162,10 @@
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 =
- configurations.create("GenerateApiDependencies") {
- 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
- )
- }
-
- dependencies.add(generateApiDependencies.name, project.project(project.path))
-
- GenerateKotlinKzipTask.setupProject(
- project,
- javaInputs,
- generateApiDependencies,
- extension.kotlinTarget,
- getDefaultTargetJavaVersion(extension.type, project.name)
- )
+ val generateApiDependencies = createReleaseApiConfiguration()
MetalavaTasks.setupProject(
project,
@@ -256,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
index d97d94f..5a60bf6 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/kythe/GenerateKotlinKzipTask.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/kythe/GenerateKotlinKzipTask.kt
@@ -99,14 +99,18 @@
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",
- "-Xjvm-default=all",
- "-opt-in=kotlin.contracts.ExperimentalContracts"
+ "-opt-in=androidx.room.compiler.processing.ExperimentalProcessingApi",
+ "-opt-in=com.squareup.kotlinpoet.javapoet.KotlinPoetJavaPoetPreview",
+ "-opt-in=kotlin.contracts.ExperimentalContracts",
)
val commonSourceFiles =
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/busytown/impl/merge-kzips.sh b/busytown/impl/merge-kzips.sh
index cf65f9d..36825097 100755
--- a/busytown/impl/merge-kzips.sh
+++ b/busytown/impl/merge-kzips.sh
@@ -39,17 +39,27 @@
mkdir -p "$DIST_DIR"
export DIST_DIR="$DIST_DIR"
+REVISION=$(grep 'path="frameworks/support"' "$MANIFEST" | sed -n 's/.*revision="\([^"]*\).*/\1/p')
-# If the SUPERPROJECT_REVISION is defined as a sha, use this as the default value
-if [[ ${SUPERPROJECT_REVISION:-} =~ [0-9a-f]{40} ]]; then
- : ${KZIP_NAME:=${SUPERPROJECT_REVISION:-}}
-fi
+# Default KZIP_NAME to the revision value from the XML file
+: ${KZIP_NAME:=$REVISION}
-: ${KZIP_NAME:=${BUILD_NUMBER:-}}
+# Fallback to the latest Git commit hash if revision is not found
+: ${KZIP_NAME:=$(git rev-parse HEAD)}
+
+# Fallback to a UUID if both the revision and Git commit hash are not there
: ${KZIP_NAME:=$(uuidgen)}
rm -rf $DIST_DIR/*.kzip
declare -r allkzip="$KZIP_NAME.kzip"
echo "Merging Kzips..."
-"$PREBUILTS_DIR/build-tools/linux-x86/bin/merge_zips" "$DIST_DIR/$allkzip" @<(find "$OUT_DIR/androidx" -name '*.kzip')
+
+# 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..457881a 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,10 +39,14 @@
public fun isPositivoDevice(): Boolean = isDeviceFrom("Positivo")
+ public fun isRealmeDevice(): Boolean = isDeviceFrom("Realme")
+
public fun isRedmiDevice(): Boolean = isDeviceFrom("Redmi")
public fun isSamsungDevice(): Boolean = isDeviceFrom("Samsung")
+ public fun isTecnoDevice(): Boolean = isDeviceFrom("Tecno") || isDeviceFrom("Tecno-mobile")
+
public fun isXiaomiDevice(): Boolean = isDeviceFrom("Xiaomi")
public fun isVivoDevice(): Boolean = isDeviceFrom("Vivo")
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/DeviceQuirksLoader.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/DeviceQuirksLoader.kt
index 7a1560f..425db10 100644
--- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/DeviceQuirksLoader.kt
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/DeviceQuirksLoader.kt
@@ -64,6 +64,14 @@
}
if (
quirkSettings.shouldEnableQuirk(
+ DisableAbortCapturesOnStopQuirk::class.java,
+ DisableAbortCapturesOnStopQuirk.isEnabled()
+ )
+ ) {
+ quirks.add(DisableAbortCapturesOnStopQuirk())
+ }
+ if (
+ quirkSettings.shouldEnableQuirk(
DisableAbortCapturesOnStopWithSessionProcessorQuirk::class.java,
DisableAbortCapturesOnStopWithSessionProcessorQuirk.isEnabled()
)
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/DisableAbortCapturesOnStopQuirk.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/DisableAbortCapturesOnStopQuirk.kt
new file mode 100644
index 0000000..7579a63
--- /dev/null
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/DisableAbortCapturesOnStopQuirk.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.camera.camera2.pipe.integration.compat.quirk
+
+import android.annotation.SuppressLint
+import androidx.camera.core.impl.Quirk
+
+/**
+ * Quirk needed on devices where faulty implementations of abortCaptures can lead to undesirable
+ * behaviors such as camera HAL crashing.
+ *
+ * QuirkSummary
+ * - Bug Id: 356792947
+ * - Description: Instructs CameraPipe to not abort captures when stopping.
+ *
+ * TODO(b/270421716): enable CameraXQuirksClassDetector lint check when kotlin is supported.
+ */
+@SuppressLint("CameraXQuirksClassDetector")
+public class DisableAbortCapturesOnStopQuirk : Quirk {
+ public companion object {
+ @JvmStatic
+ public fun isEnabled(): Boolean {
+ return Device.isTecnoDevice()
+ }
+ }
+}
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/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseManager.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseManager.kt
index fa61309..c5fd744 100644
--- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseManager.kt
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseManager.kt
@@ -52,6 +52,7 @@
import androidx.camera.camera2.pipe.integration.compat.quirk.CloseCaptureSessionOnDisconnectQuirk
import androidx.camera.camera2.pipe.integration.compat.quirk.CloseCaptureSessionOnVideoQuirk
import androidx.camera.camera2.pipe.integration.compat.quirk.DeviceQuirks
+import androidx.camera.camera2.pipe.integration.compat.quirk.DisableAbortCapturesOnStopQuirk
import androidx.camera.camera2.pipe.integration.compat.quirk.DisableAbortCapturesOnStopWithSessionProcessorQuirk
import androidx.camera.camera2.pipe.integration.compat.quirk.FinalizeSessionOnCloseQuirk
import androidx.camera.camera2.pipe.integration.compat.quirk.QuickSuccessiveImageCaptureFailsRepeatingRequestQuirk
@@ -1102,6 +1103,7 @@
DeviceQuirks[
DisableAbortCapturesOnStopWithSessionProcessorQuirk::class.java] !=
null -> false
+ DeviceQuirks[DisableAbortCapturesOnStopQuirk::class.java] != null -> false
/** @see [CameraGraph.Flags.abortCapturesOnStop] */
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> true
else -> false
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/androidTest/java/androidx/camera/integration/core/FocusMeteringDeviceTest.kt b/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/FocusMeteringDeviceTest.kt
index cd0524c..126ca11 100644
--- a/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/FocusMeteringDeviceTest.kt
+++ b/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/FocusMeteringDeviceTest.kt
@@ -36,6 +36,7 @@
import androidx.camera.testing.impl.CameraPipeConfigTestRule
import androidx.camera.testing.impl.CameraUtil
import androidx.camera.testing.impl.LabTestRule.Companion.isLensFacingEnabledInLabTest
+import androidx.camera.testing.impl.WakelockEmptyActivityRule
import androidx.camera.testing.impl.fakes.FakeLifecycleOwner
import androidx.test.core.app.ApplicationProvider
import androidx.test.filters.LargeTest
@@ -77,6 +78,8 @@
CameraUtil.PreTestCameraIdList(cameraXConfig)
)
+ @get:Rule val wakelockEmptyActivityRule = WakelockEmptyActivityRule()
+
companion object {
@JvmStatic
@Parameterized.Parameters(name = "selector={0},config={2}")
diff --git a/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/ImageAnalysisTest.kt b/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/ImageAnalysisTest.kt
index f022a29..3742fc9 100644
--- a/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/ImageAnalysisTest.kt
+++ b/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/ImageAnalysisTest.kt
@@ -49,6 +49,7 @@
import androidx.camera.testing.impl.CameraUtil
import androidx.camera.testing.impl.CameraUtil.PreTestCameraIdList
import androidx.camera.testing.impl.LabTestRule
+import androidx.camera.testing.impl.WakelockEmptyActivityRule
import androidx.camera.testing.impl.fakes.FakeLifecycleOwner
import androidx.test.core.app.ApplicationProvider
import androidx.test.filters.LargeTest
@@ -87,6 +88,8 @@
@get:Rule val labTest: LabTestRule = LabTestRule()
+ @get:Rule val wakelockEmptyActivityRule = WakelockEmptyActivityRule()
+
companion object {
private val DEFAULT_RESOLUTION = Size(640, 480)
diff --git a/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/ImageCaptureLatencyTest.kt b/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/ImageCaptureLatencyTest.kt
index 4821840..621b8c3 100644
--- a/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/ImageCaptureLatencyTest.kt
+++ b/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/ImageCaptureLatencyTest.kt
@@ -29,6 +29,7 @@
import androidx.camera.testing.impl.CameraPipeConfigTestRule
import androidx.camera.testing.impl.CameraUtil
import androidx.camera.testing.impl.LabTestRule
+import androidx.camera.testing.impl.WakelockEmptyActivityRule
import androidx.camera.testing.impl.fakes.FakeLifecycleOwner
import androidx.test.core.app.ApplicationProvider
import androidx.test.filters.LargeTest
@@ -78,6 +79,8 @@
@get:Rule val labTest = LabTestRule()
+ @get:Rule val wakelockEmptyActivityRule = WakelockEmptyActivityRule()
+
private val context = ApplicationProvider.getApplicationContext<Context>()
private lateinit var camera: CameraUseCaseAdapter
private lateinit var cameraProvider: ProcessCameraProvider
diff --git a/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/OpenCloseCameraStressTest.kt b/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/OpenCloseCameraStressTest.kt
index fc3e660..94a9abb 100644
--- a/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/OpenCloseCameraStressTest.kt
+++ b/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/OpenCloseCameraStressTest.kt
@@ -38,6 +38,7 @@
import androidx.camera.testing.impl.LabTestRule
import androidx.camera.testing.impl.StressTestRule
import androidx.camera.testing.impl.SurfaceTextureProvider
+import androidx.camera.testing.impl.WakelockEmptyActivityRule
import androidx.camera.testing.impl.fakes.FakeLifecycleOwner
import androidx.camera.video.Recorder
import androidx.camera.video.VideoCapture
@@ -82,6 +83,8 @@
@get:Rule val repeatRule = RepeatRule()
+ @get:Rule val wakelockEmptyActivityRule = WakelockEmptyActivityRule()
+
private val context = ApplicationProvider.getApplicationContext<Context>()
private lateinit var cameraProvider: ProcessCameraProvider
@@ -109,7 +112,7 @@
preview = createPreviewWithDeviceStateMonitor(implName, cameraDeviceStateMonitor)
withContext(Dispatchers.Main) {
- preview.setSurfaceProvider(SurfaceTextureProvider.createSurfaceTextureProvider())
+ preview.surfaceProvider = SurfaceTextureProvider.createSurfaceTextureProvider()
}
imageCapture = ImageCapture.Builder().build()
}
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/car/app/app/api/1.7.0-beta03.txt b/car/app/app/api/1.7.0-beta03.txt
index 79be8d8..d578a0c 100644
--- a/car/app/app/api/1.7.0-beta03.txt
+++ b/car/app/app/api/1.7.0-beta03.txt
@@ -921,6 +921,7 @@
field public static final String KEY_EXCLUDE_MEDIA_ITEM_FROM_MIXED_APP_LIST = "androidx.car.app.mediaextensions.KEY_EXCLUDE_MEDIA_ITEM_FROM_MIXED_APP_LIST";
field public static final String KEY_IMMERSIVE_AUDIO = "androidx.car.app.mediaextensions.KEY_IMMERSIVE_AUDIO";
field public static final String KEY_SUBTITLE_LINK_MEDIA_ID = "androidx.car.app.mediaextensions.KEY_SUBTITLE_LINK_MEDIA_ID";
+ field public static final String KEY_TINTABLE_INDICATOR_ICON_URI_LIST = "androidx.car.app.mediaextensions.KEY_TINTABLE_INDICATOR_ICON_URI_LIST";
}
}
diff --git a/car/app/app/api/current.ignore b/car/app/app/api/current.ignore
index 3ef9479..e089e19 100644
--- a/car/app/app/api/current.ignore
+++ b/car/app/app/api/current.ignore
@@ -1,3 +1,3 @@
// Baseline format: 1.0
-AddedMethod: androidx.car.app.notification.CarAppExtender#extend(android.app.Notification.Builder):
- Added method androidx.car.app.notification.CarAppExtender.extend(android.app.Notification.Builder)
+AddedField: androidx.car.app.mediaextensions.MetadataExtras#KEY_TINTABLE_INDICATOR_ICON_URI_LIST:
+ Added field androidx.car.app.mediaextensions.MetadataExtras.KEY_TINTABLE_INDICATOR_ICON_URI_LIST
diff --git a/car/app/app/api/current.txt b/car/app/app/api/current.txt
index 79be8d8..d578a0c 100644
--- a/car/app/app/api/current.txt
+++ b/car/app/app/api/current.txt
@@ -921,6 +921,7 @@
field public static final String KEY_EXCLUDE_MEDIA_ITEM_FROM_MIXED_APP_LIST = "androidx.car.app.mediaextensions.KEY_EXCLUDE_MEDIA_ITEM_FROM_MIXED_APP_LIST";
field public static final String KEY_IMMERSIVE_AUDIO = "androidx.car.app.mediaextensions.KEY_IMMERSIVE_AUDIO";
field public static final String KEY_SUBTITLE_LINK_MEDIA_ID = "androidx.car.app.mediaextensions.KEY_SUBTITLE_LINK_MEDIA_ID";
+ field public static final String KEY_TINTABLE_INDICATOR_ICON_URI_LIST = "androidx.car.app.mediaextensions.KEY_TINTABLE_INDICATOR_ICON_URI_LIST";
}
}
diff --git a/car/app/app/api/restricted_1.7.0-beta03.txt b/car/app/app/api/restricted_1.7.0-beta03.txt
index 79be8d8..d578a0c 100644
--- a/car/app/app/api/restricted_1.7.0-beta03.txt
+++ b/car/app/app/api/restricted_1.7.0-beta03.txt
@@ -921,6 +921,7 @@
field public static final String KEY_EXCLUDE_MEDIA_ITEM_FROM_MIXED_APP_LIST = "androidx.car.app.mediaextensions.KEY_EXCLUDE_MEDIA_ITEM_FROM_MIXED_APP_LIST";
field public static final String KEY_IMMERSIVE_AUDIO = "androidx.car.app.mediaextensions.KEY_IMMERSIVE_AUDIO";
field public static final String KEY_SUBTITLE_LINK_MEDIA_ID = "androidx.car.app.mediaextensions.KEY_SUBTITLE_LINK_MEDIA_ID";
+ field public static final String KEY_TINTABLE_INDICATOR_ICON_URI_LIST = "androidx.car.app.mediaextensions.KEY_TINTABLE_INDICATOR_ICON_URI_LIST";
}
}
diff --git a/car/app/app/api/restricted_current.ignore b/car/app/app/api/restricted_current.ignore
index 3ef9479..e089e19 100644
--- a/car/app/app/api/restricted_current.ignore
+++ b/car/app/app/api/restricted_current.ignore
@@ -1,3 +1,3 @@
// Baseline format: 1.0
-AddedMethod: androidx.car.app.notification.CarAppExtender#extend(android.app.Notification.Builder):
- Added method androidx.car.app.notification.CarAppExtender.extend(android.app.Notification.Builder)
+AddedField: androidx.car.app.mediaextensions.MetadataExtras#KEY_TINTABLE_INDICATOR_ICON_URI_LIST:
+ Added field androidx.car.app.mediaextensions.MetadataExtras.KEY_TINTABLE_INDICATOR_ICON_URI_LIST
diff --git a/car/app/app/api/restricted_current.txt b/car/app/app/api/restricted_current.txt
index 79be8d8..d578a0c 100644
--- a/car/app/app/api/restricted_current.txt
+++ b/car/app/app/api/restricted_current.txt
@@ -921,6 +921,7 @@
field public static final String KEY_EXCLUDE_MEDIA_ITEM_FROM_MIXED_APP_LIST = "androidx.car.app.mediaextensions.KEY_EXCLUDE_MEDIA_ITEM_FROM_MIXED_APP_LIST";
field public static final String KEY_IMMERSIVE_AUDIO = "androidx.car.app.mediaextensions.KEY_IMMERSIVE_AUDIO";
field public static final String KEY_SUBTITLE_LINK_MEDIA_ID = "androidx.car.app.mediaextensions.KEY_SUBTITLE_LINK_MEDIA_ID";
+ field public static final String KEY_TINTABLE_INDICATOR_ICON_URI_LIST = "androidx.car.app.mediaextensions.KEY_TINTABLE_INDICATOR_ICON_URI_LIST";
}
}
diff --git a/car/app/app/src/main/java/androidx/car/app/mediaextensions/MetadataExtras.java b/car/app/app/src/main/java/androidx/car/app/mediaextensions/MetadataExtras.java
index 0cb3fc4..0d5166b 100644
--- a/car/app/app/src/main/java/androidx/car/app/mediaextensions/MetadataExtras.java
+++ b/car/app/app/src/main/java/androidx/car/app/mediaextensions/MetadataExtras.java
@@ -137,4 +137,22 @@
*/
public static final String KEY_CONTENT_FORMAT_TINTABLE_SMALL_ICON_URI =
"androidx.car.app.mediaextensions.KEY_CONTENT_FORMAT_TINTABLE_SMALL_ICON_URI";
+
+ /**
+ * {@link Bundle} key used in the extras of a media item to indicate a list of tintable vector
+ * drawables where each drawable represents a property or a state of the media item.
+ * These drawables may be rendered in small views showing information about a media item,
+ * in an area roughly equivalent to 2 characters of the media item's subtitle.<br/>
+ *
+ * <b>Note:</b> when specifying this extra, the "android.media.extra.DOWNLOAD_STATUS" and
+ * "android.media.IS_EXPLICIT" extras will be ignored, so 3p apps should add their own
+ * downloaded and explicit icon uris to this extra's list. This way all these icons can use
+ * the same drawing style.
+ *
+ * <p>TYPE: {@code ArrayList<Uri>}, list of Uris - with each uri pointing to local content
+ * using either ContentResolver.SCHEME_CONTENT or ContentResolver.SCHEME_ANDROID_RESOURCE
+ * (ie not on the web) that can be parsed into a android.graphics.drawable.Drawable</p>
+ */
+ public static final String KEY_TINTABLE_INDICATOR_ICON_URI_LIST =
+ "androidx.car.app.mediaextensions.KEY_TINTABLE_INDICATOR_ICON_URI_LIST";
}
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/integration-tests/macrobenchmark/src/main/java/androidx/compose/integration/macrobenchmark/SmallListStartupBenchmark.kt b/compose/integration-tests/macrobenchmark/src/main/java/androidx/compose/integration/macrobenchmark/SmallListStartupBenchmark.kt
index cc2bb08..2275c38 100644
--- a/compose/integration-tests/macrobenchmark/src/main/java/androidx/compose/integration/macrobenchmark/SmallListStartupBenchmark.kt
+++ b/compose/integration-tests/macrobenchmark/src/main/java/androidx/compose/integration/macrobenchmark/SmallListStartupBenchmark.kt
@@ -36,19 +36,12 @@
) {
@get:Rule val benchmarkRule = MacrobenchmarkRule()
- /**
- * Temporary, tracking for b/231455742
- *
- * Note that this tracing only exists on more recent API levels
- */
- private val metrics = getStartupMetrics()
-
@Test
fun startup() =
benchmarkRule.measureStartup(
compilationMode = compilationMode,
startupMode = startupMode,
- metrics = metrics,
+ metrics = getStartupMetrics(),
packageName = "androidx.compose.integration.macrobenchmark.target"
) {
action = "androidx.compose.integration.macrobenchmark.target.LAZY_COLUMN_ACTIVITY"
diff --git a/compose/lint/common-test/build.gradle b/compose/lint/common-test/build.gradle
index 1b5afba..ee51a95 100644
--- a/compose/lint/common-test/build.gradle
+++ b/compose/lint/common-test/build.gradle
@@ -30,8 +30,8 @@
dependencies {
implementation(libs.kotlinStdlib)
- api(libs.androidLintPrev)
- api(libs.androidLintPrevTests)
+ api(libs.androidLintPrevAnalysis)
+ api(libs.androidLintTestsPrevAnalysis)
api(libs.junit)
api(libs.truth)
}
diff --git a/compose/material3/adaptive/adaptive-layout/api/current.txt b/compose/material3/adaptive/adaptive-layout/api/current.txt
index fbb8549..22f9744 100644
--- a/compose/material3/adaptive/adaptive-layout/api/current.txt
+++ b/compose/material3/adaptive/adaptive-layout/api/current.txt
@@ -314,6 +314,46 @@
property public final androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole pane;
}
+ @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi @androidx.compose.runtime.Immutable public final class ThreePaneScaffoldHorizontalOrder {
+ method public void forEach(kotlin.jvm.functions.Function1<? super androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole,kotlin.Unit> action);
+ method public void forEachIndexed(kotlin.jvm.functions.Function2<? super java.lang.Integer,? super androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole,kotlin.Unit> action);
+ method public void forEachIndexedReversed(kotlin.jvm.functions.Function2<? super java.lang.Integer,? super androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole,kotlin.Unit> action);
+ method public operator androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole get(int index);
+ method public int getSize();
+ method public int indexOf(androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole role);
+ property public int size;
+ }
+
+ public final class ThreePaneScaffoldKt {
+ method @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.material3.adaptive.layout.ThreePaneScaffoldOverride> getLocalThreePaneScaffoldOverride();
+ property @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.material3.adaptive.layout.ThreePaneScaffoldOverride> LocalThreePaneScaffoldOverride;
+ }
+
+ @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public interface ThreePaneScaffoldOverride {
+ method @androidx.compose.runtime.Composable public void ThreePaneScaffold(androidx.compose.material3.adaptive.layout.ThreePaneScaffoldOverrideContext);
+ }
+
+ @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public final class ThreePaneScaffoldOverrideContext {
+ method public androidx.compose.ui.Modifier getModifier();
+ method public kotlin.jvm.functions.Function1<androidx.compose.material3.adaptive.layout.PaneExpansionState,kotlin.Unit>? getPaneExpansionDragHandle();
+ method public androidx.compose.material3.adaptive.layout.PaneExpansionState getPaneExpansionState();
+ method public androidx.compose.material3.adaptive.layout.ThreePaneScaffoldHorizontalOrder getPaneOrder();
+ method public kotlin.jvm.functions.Function0<kotlin.Unit> getPrimaryPane();
+ method public androidx.compose.material3.adaptive.layout.PaneScaffoldDirective getScaffoldDirective();
+ method public androidx.compose.material3.adaptive.layout.ThreePaneScaffoldState getScaffoldState();
+ method public kotlin.jvm.functions.Function0<kotlin.Unit> getSecondaryPane();
+ method public kotlin.jvm.functions.Function0<kotlin.Unit>? getTertiaryPane();
+ property public final androidx.compose.ui.Modifier modifier;
+ property public final kotlin.jvm.functions.Function1<androidx.compose.material3.adaptive.layout.PaneExpansionState,kotlin.Unit>? paneExpansionDragHandle;
+ property public final androidx.compose.material3.adaptive.layout.PaneExpansionState paneExpansionState;
+ property public final androidx.compose.material3.adaptive.layout.ThreePaneScaffoldHorizontalOrder paneOrder;
+ property public final kotlin.jvm.functions.Function0<kotlin.Unit> primaryPane;
+ property public final androidx.compose.material3.adaptive.layout.PaneScaffoldDirective scaffoldDirective;
+ property public final androidx.compose.material3.adaptive.layout.ThreePaneScaffoldState scaffoldState;
+ property public final kotlin.jvm.functions.Function0<kotlin.Unit> secondaryPane;
+ property public final kotlin.jvm.functions.Function0<kotlin.Unit>? tertiaryPane;
+ }
+
@SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public sealed interface ThreePaneScaffoldPaneScope extends androidx.compose.material3.adaptive.layout.ThreePaneScaffoldScope androidx.compose.material3.adaptive.layout.ExtendedPaneScaffoldPaneScope<androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole,androidx.compose.material3.adaptive.layout.ThreePaneScaffoldValue> {
}
diff --git a/compose/material3/adaptive/adaptive-layout/api/restricted_current.txt b/compose/material3/adaptive/adaptive-layout/api/restricted_current.txt
index fbb8549..22f9744 100644
--- a/compose/material3/adaptive/adaptive-layout/api/restricted_current.txt
+++ b/compose/material3/adaptive/adaptive-layout/api/restricted_current.txt
@@ -314,6 +314,46 @@
property public final androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole pane;
}
+ @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi @androidx.compose.runtime.Immutable public final class ThreePaneScaffoldHorizontalOrder {
+ method public void forEach(kotlin.jvm.functions.Function1<? super androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole,kotlin.Unit> action);
+ method public void forEachIndexed(kotlin.jvm.functions.Function2<? super java.lang.Integer,? super androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole,kotlin.Unit> action);
+ method public void forEachIndexedReversed(kotlin.jvm.functions.Function2<? super java.lang.Integer,? super androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole,kotlin.Unit> action);
+ method public operator androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole get(int index);
+ method public int getSize();
+ method public int indexOf(androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole role);
+ property public int size;
+ }
+
+ public final class ThreePaneScaffoldKt {
+ method @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.material3.adaptive.layout.ThreePaneScaffoldOverride> getLocalThreePaneScaffoldOverride();
+ property @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.material3.adaptive.layout.ThreePaneScaffoldOverride> LocalThreePaneScaffoldOverride;
+ }
+
+ @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public interface ThreePaneScaffoldOverride {
+ method @androidx.compose.runtime.Composable public void ThreePaneScaffold(androidx.compose.material3.adaptive.layout.ThreePaneScaffoldOverrideContext);
+ }
+
+ @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public final class ThreePaneScaffoldOverrideContext {
+ method public androidx.compose.ui.Modifier getModifier();
+ method public kotlin.jvm.functions.Function1<androidx.compose.material3.adaptive.layout.PaneExpansionState,kotlin.Unit>? getPaneExpansionDragHandle();
+ method public androidx.compose.material3.adaptive.layout.PaneExpansionState getPaneExpansionState();
+ method public androidx.compose.material3.adaptive.layout.ThreePaneScaffoldHorizontalOrder getPaneOrder();
+ method public kotlin.jvm.functions.Function0<kotlin.Unit> getPrimaryPane();
+ method public androidx.compose.material3.adaptive.layout.PaneScaffoldDirective getScaffoldDirective();
+ method public androidx.compose.material3.adaptive.layout.ThreePaneScaffoldState getScaffoldState();
+ method public kotlin.jvm.functions.Function0<kotlin.Unit> getSecondaryPane();
+ method public kotlin.jvm.functions.Function0<kotlin.Unit>? getTertiaryPane();
+ property public final androidx.compose.ui.Modifier modifier;
+ property public final kotlin.jvm.functions.Function1<androidx.compose.material3.adaptive.layout.PaneExpansionState,kotlin.Unit>? paneExpansionDragHandle;
+ property public final androidx.compose.material3.adaptive.layout.PaneExpansionState paneExpansionState;
+ property public final androidx.compose.material3.adaptive.layout.ThreePaneScaffoldHorizontalOrder paneOrder;
+ property public final kotlin.jvm.functions.Function0<kotlin.Unit> primaryPane;
+ property public final androidx.compose.material3.adaptive.layout.PaneScaffoldDirective scaffoldDirective;
+ property public final androidx.compose.material3.adaptive.layout.ThreePaneScaffoldState scaffoldState;
+ property public final kotlin.jvm.functions.Function0<kotlin.Unit> secondaryPane;
+ property public final kotlin.jvm.functions.Function0<kotlin.Unit>? tertiaryPane;
+ }
+
@SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public sealed interface ThreePaneScaffoldPaneScope extends androidx.compose.material3.adaptive.layout.ThreePaneScaffoldScope androidx.compose.material3.adaptive.layout.ExtendedPaneScaffoldPaneScope<androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole,androidx.compose.material3.adaptive.layout.ThreePaneScaffoldValue> {
}
diff --git a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffold.kt b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffold.kt
index 2da0e85..df52d0c 100644
--- a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffold.kt
+++ b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffold.kt
@@ -19,6 +19,8 @@
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.ProvidableCompositionLocal
+import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -46,6 +48,54 @@
import kotlin.math.max
import kotlin.math.min
+/** Interface that allows libraries to override the behavior of [ThreePaneScaffold]. */
+@ExperimentalMaterial3AdaptiveApi
+interface ThreePaneScaffoldOverride {
+ /** Behavior function that is called by the [ThreePaneScaffold] composable. */
+ @Composable fun ThreePaneScaffoldOverrideContext.ThreePaneScaffold()
+}
+
+/**
+ * Parameters available to [ThreePaneScaffold].
+ *
+ * @property modifier The modifier to be applied to the layout.
+ * @property scaffoldDirective The top-level directives about how the scaffold should arrange its
+ * panes.
+ * @property scaffoldState The current state of the scaffold, containing information about the
+ * adapted value of each pane of the scaffold and the transitions/animations in progress.
+ * @property paneOrder The horizontal order of the panes from start to end in the scaffold.
+ * @property secondaryPane The content of the secondary pane that has a priority lower then the
+ * primary pane but higher than the tertiary pane.
+ * @property tertiaryPane The content of the tertiary pane that has the lowest priority.
+ * @property primaryPane The content of the primary pane that has the highest priority.
+ * @property paneExpansionDragHandle the pane expansion drag handle to allow users to drag to change
+ * pane expansion state, `null` by default.
+ * @property paneExpansionState the state object of pane expansion state.
+ */
+@ExperimentalMaterial3AdaptiveApi
+class ThreePaneScaffoldOverrideContext
+internal constructor(
+ val modifier: Modifier,
+ val scaffoldDirective: PaneScaffoldDirective,
+ val scaffoldState: ThreePaneScaffoldState,
+ val paneOrder: ThreePaneScaffoldHorizontalOrder,
+ val primaryPane: @Composable () -> Unit,
+ val secondaryPane: @Composable () -> Unit,
+ val tertiaryPane: (@Composable () -> Unit)?,
+ val paneExpansionState: PaneExpansionState,
+ val paneExpansionDragHandle: (@Composable (PaneExpansionState) -> Unit)?,
+ internal val motionScopeImpl: ThreePaneScaffoldMotionScopeImpl
+)
+
+/** CompositionLocal containing the currently-selected [ThreePaneScaffoldOverride]. */
+@Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
+@get:ExperimentalMaterial3AdaptiveApi
+@ExperimentalMaterial3AdaptiveApi
+val LocalThreePaneScaffoldOverride: ProvidableCompositionLocal<ThreePaneScaffoldOverride> =
+ compositionLocalOf {
+ DefaultThreePaneScaffoldOverride
+ }
+
/**
* A pane scaffold composable that can display up to three panes according to the instructions
* provided by [ThreePaneScaffoldValue] in the order that [ThreePaneScaffoldHorizontalOrder]
@@ -139,47 +189,69 @@
remember(currentTransition, this) {
ThreePaneScaffoldScopeImpl(motionScope, transitionScope, this)
}
- // Create PaneWrappers for each of the panes and map the transitions according to each pane
- // role and order.
+ with(LocalThreePaneScaffoldOverride.current) {
+ ThreePaneScaffoldOverrideContext(
+ modifier = modifier,
+ scaffoldDirective = scaffoldDirective,
+ scaffoldState = scaffoldState,
+ paneOrder = paneOrder,
+ primaryPane = {
+ rememberThreePaneScaffoldPaneScope(
+ ThreePaneScaffoldRole.Primary,
+ scaffoldScope,
+ paneMotions[ThreePaneScaffoldRole.Primary]
+ )
+ .primaryPane()
+ },
+ secondaryPane = {
+ rememberThreePaneScaffoldPaneScope(
+ ThreePaneScaffoldRole.Secondary,
+ scaffoldScope,
+ paneMotions[ThreePaneScaffoldRole.Secondary]
+ )
+ .secondaryPane()
+ },
+ tertiaryPane =
+ if (tertiaryPane == null) null
+ else {
+ {
+ rememberThreePaneScaffoldPaneScope(
+ ThreePaneScaffoldRole.Tertiary,
+ scaffoldScope,
+ paneMotions[ThreePaneScaffoldRole.Tertiary]
+ )
+ .tertiaryPane()
+ }
+ },
+ paneExpansionState = expansionState,
+ paneExpansionDragHandle =
+ if (paneExpansionDragHandle == null) null
+ else {
+ { paneExpansionState ->
+ scaffoldScope.paneExpansionDragHandle(paneExpansionState)
+ }
+ },
+ motionScopeImpl = motionScope
+ )
+ .ThreePaneScaffold()
+ }
+ }
+}
+
+/** [ThreePaneScaffoldOverride] used when no override is specified. */
+@ExperimentalMaterial3AdaptiveApi
+private object DefaultThreePaneScaffoldOverride : ThreePaneScaffoldOverride {
+ @Composable
+ override fun ThreePaneScaffoldOverrideContext.ThreePaneScaffold() {
+ val layoutDirection = LocalLayoutDirection.current
+ val ltrPaneOrder =
+ remember(paneOrder, layoutDirection) { paneOrder.toLtrOrder(layoutDirection) }
val contents =
listOf<@Composable () -> Unit>(
- {
- remember(scaffoldScope) {
- ThreePaneScaffoldPaneScopeImpl(
- ThreePaneScaffoldRole.Primary,
- scaffoldScope
- )
- }
- .apply { updatePaneMotion(paneMotions) }
- .primaryPane()
- },
- {
- remember(scaffoldScope) {
- ThreePaneScaffoldPaneScopeImpl(
- ThreePaneScaffoldRole.Secondary,
- scaffoldScope
- )
- }
- .apply { updatePaneMotion(paneMotions) }
- .secondaryPane()
- },
- {
- if (tertiaryPane != null) {
- remember(scaffoldScope) {
- ThreePaneScaffoldPaneScopeImpl(
- ThreePaneScaffoldRole.Tertiary,
- scaffoldScope
- )
- }
- .apply { updatePaneMotion(paneMotions) }
- .tertiaryPane()
- }
- },
- {
- if (paneExpansionDragHandle != null) {
- scaffoldScope.paneExpansionDragHandle(expansionState)
- }
- }
+ primaryPane,
+ secondaryPane,
+ tertiaryPane ?: {},
+ { paneExpansionDragHandle?.invoke(paneExpansionState) }
)
val measurePolicy =
@@ -187,9 +259,9 @@
ThreePaneContentMeasurePolicy(
scaffoldDirective,
scaffoldState.targetState,
- expansionState,
+ paneExpansionState,
ltrPaneOrder,
- motionScope
+ motionScopeImpl
)
}
.apply {
diff --git a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldHorizontalOrder.kt b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldHorizontalOrder.kt
index 0081f7b..f24f7fa 100644
--- a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldHorizontalOrder.kt
+++ b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldHorizontalOrder.kt
@@ -20,22 +20,14 @@
import androidx.compose.runtime.Immutable
import androidx.compose.ui.unit.LayoutDirection
-/**
- * Represents the horizontal order of panes in a [ThreePaneScaffold] from start to end. Note that
- * the values of [firstPane], [secondPane] and [thirdPane] have to be different, otherwise
- * [IllegalArgumentException] will be thrown.
- *
- * @param firstPane The first pane from the start of the [ThreePaneScaffold]
- * @param secondPane The second pane from the start of the [ThreePaneScaffold]
- * @param thirdPane The third pane from the start of the [ThreePaneScaffold]
- * @constructor create an instance of [ThreePaneScaffoldHorizontalOrder]
- */
+/** Represents the horizontal order of panes in a [ThreePaneScaffold] from start to end. */
@ExperimentalMaterial3AdaptiveApi
@Immutable
-internal class ThreePaneScaffoldHorizontalOrder(
- val firstPane: ThreePaneScaffoldRole,
- val secondPane: ThreePaneScaffoldRole,
- val thirdPane: ThreePaneScaffoldRole
+class ThreePaneScaffoldHorizontalOrder
+internal constructor(
+ internal val firstPane: ThreePaneScaffoldRole,
+ internal val secondPane: ThreePaneScaffoldRole,
+ internal val thirdPane: ThreePaneScaffoldRole
) : PaneScaffoldHorizontalOrder<ThreePaneScaffoldRole> {
init {
require(firstPane != secondPane && secondPane != thirdPane && firstPane != thirdPane) {
diff --git a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldScope.kt b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldScope.kt
index 4ba5bd0..e3f7625 100644
--- a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldScope.kt
+++ b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldScope.kt
@@ -21,8 +21,10 @@
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
+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.layout.LookaheadScope
@@ -73,16 +75,21 @@
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
+@Composable
+internal fun rememberThreePaneScaffoldPaneScope(
+ paneRole: ThreePaneScaffoldRole,
+ scaffoldScope: ThreePaneScaffoldScope,
+ paneMotion: PaneMotion
+): ThreePaneScaffoldPaneScope =
+ remember(scaffoldScope) { ThreePaneScaffoldPaneScopeImpl(paneRole, scaffoldScope) }
+ .apply { this.paneMotion = paneMotion }
+
+@OptIn(ExperimentalMaterial3AdaptiveApi::class)
internal class ThreePaneScaffoldPaneScopeImpl(
override val paneRole: ThreePaneScaffoldRole,
scaffoldScope: ThreePaneScaffoldScope,
) : ThreePaneScaffoldPaneScope, ThreePaneScaffoldScope by scaffoldScope {
override var paneMotion: PaneMotion by mutableStateOf(PaneMotion.ExitToLeft)
- private set
-
- fun updatePaneMotion(paneMotions: ThreePaneMotion) {
- paneMotion = paneMotions[paneRole]
- }
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
diff --git a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/TimePickerTest.kt b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/TimePickerTest.kt
index 29da824..4fffa57 100644
--- a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/TimePickerTest.kt
+++ b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/TimePickerTest.kt
@@ -671,10 +671,15 @@
rule
.onNodeWithTimeValue(number, TimePickerSelectionMode.Hour, is24Hour = true)
.performClick()
+
rule.runOnIdle {
state.selection = TimePickerSelectionMode.Hour
assertThat(state.hour).isEqualTo(number)
}
+
+ rule
+ .onNodeWithTimeValue(number, TimePickerSelectionMode.Hour, is24Hour = true)
+ .assertIsSelected()
}
}
@@ -701,6 +706,10 @@
state.selection = TimePickerSelectionMode.Hour
assertThat(state.hour).isEqualTo(number)
}
+
+ rule
+ .onNodeWithTimeValue(hour, TimePickerSelectionMode.Hour, is24Hour = false)
+ .assertIsSelected()
}
}
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/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TimePicker.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TimePicker.kt
index ca50be8..5226452 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TimePicker.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TimePicker.kt
@@ -24,7 +24,7 @@
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationSpec
-import androidx.compose.animation.core.FiniteAnimationSpec
+import androidx.compose.animation.core.SnapSpec
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.MutatePriority.PreventUserInput
@@ -98,6 +98,7 @@
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.Stable
+import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@@ -113,6 +114,7 @@
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.center
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
@@ -162,6 +164,7 @@
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset
@@ -1677,9 +1680,11 @@
autoSwitchToMinute: Boolean
) {
val style = ClockDialLabelTextFont.value
- val maxDist = with(LocalDensity.current) { MaxDistance.toPx() }
+ val density: Density = LocalDensity.current
+ val maxDist = with(density) { MaxDistance.toPx() }
var center by remember { mutableStateOf(Offset.Zero) }
var parentCenter by remember { mutableStateOf(IntOffset.Zero) }
+ var boundsInParent by remember { mutableStateOf(Rect.Zero) }
val scope = rememberCoroutineScope()
val contentDescription =
numberContentDescription(
@@ -1689,22 +1694,24 @@
)
val text = value.toLocalString()
- val selected =
- if (state.selection == TimePickerSelectionMode.Minute) {
- state.minute.toLocalString() == text
- } else {
- state.hour.toLocalString() == text
+ val selected by
+ remember(state) {
+ derivedStateOf {
+ val selectorPos = state.selectorPos
+ val offset = with(density) { Offset(selectorPos.x.toPx(), selectorPos.y.toPx()) }
+ boundsInParent.contains(offset)
+ }
}
// TODO Load the motionScheme tokens from the component tokens file
- val animationSpec: FiniteAnimationSpec<Float> = MotionSchemeKeyTokens.DefaultSpatial.value()
Box(
contentAlignment = Alignment.Center,
modifier =
modifier
.onGloballyPositioned {
parentCenter = it.parentCoordinates?.size?.center ?: IntOffset.Zero
- center = it.boundsInParent().center
+ boundsInParent = it.boundsInParent()
+ center = boundsInParent.center
}
.minimumInteractiveComponentSize()
.size(MinimumInteractiveSize)
@@ -1718,7 +1725,7 @@
maxDist,
autoSwitchToMinute,
parentCenter,
- animationSpec
+ SnapSpec()
)
}
true
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/WavyProgressIndicator.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/WavyProgressIndicator.kt
index 3debdfc..31664b6 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/WavyProgressIndicator.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/WavyProgressIndicator.kt
@@ -625,13 +625,14 @@
}
}
}
+ val coercedAmplitude = amplitude.coerceIn(0f, 1f)
PathProgressIndicator(
modifier = modifier.size(WavyProgressIndicatorDefaults.CircularContainerSize),
// Resolves the Path from a RoundedPolygon that represents the active indicator.
progressPath = { _, progressWavelength, strokeWidth, size, supportMotion, path ->
circularShapes.update(size, progressWavelength, strokeWidth)
circularShapes.activeIndicatorMorph!!.toPath(
- progress = amplitude,
+ progress = coercedAmplitude,
path = path,
repeatPath = supportMotion,
rotationPivotX = 0.5f,
@@ -647,7 +648,7 @@
trackColor = trackColor,
stroke = stroke,
trackStroke = trackStroke,
- amplitude = amplitude.coerceIn(0f, 1f),
+ amplitude = coercedAmplitude,
waveOffset = { lastOffsetValue.floatValue },
wavelength = wavelength,
gapSize = gapSize,
@@ -733,7 +734,7 @@
// Animate changes in the amplitude. As this requires a progress value, we do it
// inside the Spacer to avoid redundant recompositions.
- val amplitudeForProgress = amplitude(progressValue)
+ val amplitudeForProgress = amplitude(progressValue).coerceIn(0f, 1f)
val animatedAmplitude =
amplitudeAnimatable
?: Animatable(amplitudeForProgress, Float.VectorConverter).also {
diff --git a/compose/runtime/runtime-lint/build.gradle b/compose/runtime/runtime-lint/build.gradle
index 6a73e68..71ad3f5 100644
--- a/compose/runtime/runtime-lint/build.gradle
+++ b/compose/runtime/runtime-lint/build.gradle
@@ -33,14 +33,14 @@
BundleInsideHelper.forInsideLintJar(project)
dependencies {
- compileOnly(libs.androidLintPrevApi)
+ compileOnly(libs.androidLintApiPrevAnalysis)
compileOnly(libs.kotlinStdlib)
bundleInside(project(":compose:lint:common"))
testImplementation(project(":compose:lint:common-test"))
testImplementation(libs.kotlinStdlib)
- testImplementation(libs.androidLintPrev)
- testImplementation(libs.androidLintPrevTests)
+ testImplementation(libs.androidLintPrevAnalysis)
+ testImplementation(libs.androidLintTestsPrevAnalysis)
testImplementation(libs.junit)
testImplementation(libs.truth)
}
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/integration-tests/build.gradle b/compose/runtime/runtime/integration-tests/build.gradle
index 791773c..b611fc7 100644
--- a/compose/runtime/runtime/integration-tests/build.gradle
+++ b/compose/runtime/runtime/integration-tests/build.gradle
@@ -110,8 +110,6 @@
incremental = false
freeCompilerArgs += [
"-Xcontext-receivers",
- "-P",
- "plugin:androidx.compose.compiler.plugins.kotlin:sourceInformation=true"
]
}
}
diff --git a/compose/ui/ui-inspection/build.gradle b/compose/ui/ui-inspection/build.gradle
index cfbe90d..3423082 100644
--- a/compose/ui/ui-inspection/build.gradle
+++ b/compose/ui/ui-inspection/build.gradle
@@ -101,14 +101,6 @@
namespace "androidx.compose.ui.inspection"
}
-tasks.withType(KotlinCompile).configureEach {
- kotlinOptions {
- freeCompilerArgs += [
- "-P", "plugin:androidx.compose.compiler.plugins.kotlin:sourceInformation=true"
- ]
- }
-}
-
inspection {
name = "compose-ui-inspection.jar"
}
diff --git a/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/AlternateViewHelper.kt b/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/AlternateViewHelper.kt
index a902d36..86d3154 100644
--- a/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/AlternateViewHelper.kt
+++ b/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/AlternateViewHelper.kt
@@ -29,10 +29,13 @@
private const val PANEL_ENTITY_CLASS = "com.google.vr.androidx.xr.core.PanelEntity"
private const val PANEL_ENTITY_IMPL_CLASS =
"com.google.vr.realitycore.runtime.androidxr.PanelEntityImpl"
+private const val MAIN_PANEL_ENTITY_CLASS =
+ "com.google.vr.realitycore.runtime.androidxr.MainPanelEntityImpl"
private const val JXR_CORE_SESSION_CLASS = "com.google.vr.androidx.xr.core.Session"
private const val PANEL_ENTITY_CLASS_ANDROIDX = "androidx.xr.scenecore.PanelEntity"
private const val PANEL_ENTITY_IMPL_CLASS_ANDROIDX = "androidx.xr.scenecore.PanelEntityImpl"
+private const val MAIN_PANEL_ENTITY_CLASS_ANDROIDX = "androidx.xr.scenecore.MainPanelEntityImpl"
private const val JXR_CORE_SESSION_CLASS_ANDROIDX = "androidx.xr.scenecore.Session"
private const val GET_ENTITIES_OF_TYPE_METHOD = "getEntitiesOfType"
@@ -40,6 +43,7 @@
private const val SURFACE_CONTROL_VIEW_HOST_FIELD = "surfaceControlViewHost"
private const val RT_PANEL_ENTITY_FIELD = "rtPanelEntity"
+private const val RUNTIME_ACTIVITY_FIELD = "runtimeActivity"
class AlternateViewHelper(private val environment: InspectorEnvironment) {
private val activity = environment.artTooling().findInstances(Activity::class.java).first()
@@ -77,7 +81,7 @@
return entity
.mapAllFields { field ->
if (field.name == RT_PANEL_ENTITY_FIELD) {
- getRuntimeEntityView(field.get(entity)!!)
+ getEntityView(field.get(entity)!!)
} else {
null
}
@@ -103,12 +107,22 @@
}
@RequiresApi(Build.VERSION_CODES.R)
+ private fun getEntityView(instance: Any): View? {
+ val clazz = instance.javaClass
+ return when (clazz.name) {
+ PANEL_ENTITY_IMPL_CLASS,
+ PANEL_ENTITY_IMPL_CLASS_ANDROIDX -> getRuntimeEntityView(instance)
+ MAIN_PANEL_ENTITY_CLASS,
+ MAIN_PANEL_ENTITY_CLASS_ANDROIDX -> getMainPanelEntityImplView(instance)
+ else -> null
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.R)
private fun getRuntimeEntityView(instance: Any): View? {
val clazz = instance.javaClass
- if (clazz.name !in listOf(PANEL_ENTITY_IMPL_CLASS, PANEL_ENTITY_IMPL_CLASS_ANDROIDX)) {
- return null
- }
- val surfaceControlViewHostField = loadField(clazz, SURFACE_CONTROL_VIEW_HOST_FIELD)
+ val surfaceControlViewHostField =
+ runCatching { clazz.getDeclaredField(SURFACE_CONTROL_VIEW_HOST_FIELD) }.getOrNull()
if (surfaceControlViewHostField != null) {
surfaceControlViewHostField.isAccessible = true
val surfaceControlViewHost =
@@ -119,6 +133,19 @@
}
}
+ private fun getMainPanelEntityImplView(instance: Any): View? {
+ val clazz = instance.javaClass
+ val runtimeActivityField =
+ runCatching { clazz.getDeclaredField(RUNTIME_ACTIVITY_FIELD) }.getOrNull()
+ return if (runtimeActivityField != null) {
+ runtimeActivityField.isAccessible = true
+ val runtimeActivityInstance = runtimeActivityField.get(instance) as Activity
+ runtimeActivityInstance.window.decorView
+ } else {
+ null
+ }
+ }
+
@Suppress("UnnecessaryLambdaCreation")
private fun <T> Any.mapAllFields(block: (filed: Field) -> T): List<T> {
var clazz: Class<*>? = javaClass
diff --git a/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/ComposeLayoutInspector.kt b/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/ComposeLayoutInspector.kt
index 9364f6e..8f37b41 100644
--- a/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/ComposeLayoutInspector.kt
+++ b/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/ComposeLayoutInspector.kt
@@ -26,7 +26,6 @@
import androidx.collection.mutableLongObjectMapOf
import androidx.compose.ui.inspection.compose.AndroidComposeViewWrapper
import androidx.compose.ui.inspection.compose.convertToParameterGroup
-import androidx.compose.ui.inspection.compose.flatten
import androidx.compose.ui.inspection.framework.addSlotTable
import androidx.compose.ui.inspection.framework.flatten
import androidx.compose.ui.inspection.framework.hasSlotTable
@@ -516,7 +515,7 @@
/** Add a slot table to all AndroidComposeViews that doesn't already have one. */
private fun addSlotTableToComposeViews() =
ThreadUtils.runOnMainThread {
- val roots = rootsDetector.getRoots()
+ val roots = rootsDetector.getAllRoots()
val composeViews =
roots.flatMap { it.flatten() }.filter { it.isAndroidComposeView() }
diff --git a/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/RootsDetector.kt b/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/RootsDetector.kt
index 5619d3c..a3ff163 100644
--- a/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/RootsDetector.kt
+++ b/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/RootsDetector.kt
@@ -29,6 +29,10 @@
return alternateViews.ifEmpty { getAndroidViews() }
}
+ fun getAllRoots(): List<View> {
+ return alternateViewHelper.getAlternateViews() + getAndroidViews()
+ }
+
private fun getAndroidViews(): List<View> {
ThreadUtils.assertOnMainThread()
val views = WindowInspector.getGlobalWindowViews()
diff --git a/compose/ui/ui-lint/build.gradle b/compose/ui/ui-lint/build.gradle
index a33faf7..ed5073c 100644
--- a/compose/ui/ui-lint/build.gradle
+++ b/compose/ui/ui-lint/build.gradle
@@ -33,15 +33,15 @@
BundleInsideHelper.forInsideLintJar(project)
dependencies {
- compileOnly(libs.androidLintPrevApi)
+ compileOnly(libs.androidLintApiPrevAnalysis)
compileOnly(libs.kotlinStdlib)
bundleInside(project(":compose:lint:common"))
testImplementation(project(":compose:lint:common-test"))
testImplementation(libs.kotlinStdlib)
- testImplementation(libs.androidLintPrev)
- testImplementation(libs.androidLintPrevTests)
+ testImplementation(libs.androidLintPrevAnalysis)
+ testImplementation(libs.androidLintTestsPrevAnalysis)
testImplementation(libs.junit)
testImplementation(libs.truth)
}
diff --git a/compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/AndroidInputDispatcher.android.kt b/compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/AndroidInputDispatcher.android.kt
index 1fd427d..1f618df 100644
--- a/compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/AndroidInputDispatcher.android.kt
+++ b/compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/AndroidInputDispatcher.android.kt
@@ -16,6 +16,8 @@
package androidx.compose.ui.test
+import android.content.Context
+import android.hardware.input.InputManager
import android.view.InputEvent
import android.view.KeyCharacterMap
import android.view.KeyEvent
@@ -477,7 +479,7 @@
/* buttonState = */ 0,
/* xPrecision = */ 1f,
/* yPrecision = */ 1f,
- /* deviceId = */ 0,
+ /* deviceId = */ findInputDevice(root.view.context, SOURCE_ROTARY_ENCODER),
/* edgeFlags = */ 0,
/* source = */ SOURCE_ROTARY_ENCODER,
/* flags = */ 0
@@ -597,4 +599,19 @@
private fun recycleEventIfPossible(event: InputEvent) {
(event as? MotionEvent)?.recycle()
}
+
+ private fun findInputDevice(context: Context, source: Int): Int {
+ with(context.getSystemService(Context.INPUT_SERVICE) as InputManager) {
+ inputDeviceIds.forEach { deviceId ->
+ getInputDevice(deviceId)?.apply {
+ motionRanges
+ .find { it.source == source }
+ ?.let {
+ return deviceId
+ }
+ }
+ }
+ }
+ return 0
+ }
}
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/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/FocusManagerCompositionLocalTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/FocusManagerCompositionLocalTest.kt
index 08b702e..3c2313f 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/FocusManagerCompositionLocalTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/FocusManagerCompositionLocalTest.kt
@@ -33,6 +33,7 @@
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
+import androidx.test.filters.SdkSuppress
import com.google.common.truth.Truth.assertThat
import org.junit.Ignore
import org.junit.Rule
@@ -48,6 +49,7 @@
private lateinit var inputModeManager: InputModeManager
private val focusStates = mutableListOf<FocusState>()
+ @SdkSuppress(minSdkVersion = 28)
@Test
fun clearFocus_singleLayout_focusIsRestoredAfterClear() {
// Arrange.
@@ -164,6 +166,7 @@
}
}
+ @SdkSuppress(minSdkVersion = 28)
@Test
fun releaseFocus_whenOwnerFocusIsCleared() {
// Arrange.
@@ -236,6 +239,7 @@
}
}
+ @SdkSuppress(minSdkVersion = 28)
@Test
fun clearFocus_whenRootIsActiveParent() {
// Arrange.
@@ -301,6 +305,7 @@
}
}
+ @SdkSuppress(minSdkVersion = 28)
@Test
fun clearFocus_forced_whenHierarchyHasCapturedFocus() {
// Arrange.
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/rotary/RotaryScrollEventTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/rotary/RotaryScrollEventTest.kt
index be72fd0..aa17a438 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/rotary/RotaryScrollEventTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/rotary/RotaryScrollEventTest.kt
@@ -16,6 +16,8 @@
package androidx.compose.ui.input.rotary
+import android.content.Context
+import android.hardware.input.InputManager
import android.view.MotionEvent
import android.view.MotionEvent.ACTION_SCROLL
import android.view.View
@@ -47,6 +49,7 @@
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.google.common.truth.Truth.assertThat
+import org.junit.Assume
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@@ -276,6 +279,54 @@
}
@Test
+ fun verticalRotaryEventContainsDeviceId() {
+ // Arrange.
+ ContentWithInitialFocus {
+ Box(
+ modifier =
+ Modifier.onRotaryScrollEvent {
+ receivedEvent = it
+ true
+ }
+ .focusable(initiallyFocused = true)
+ )
+ }
+ // Ignore on all devices which doesn't have rotary input.
+ Assume.assumeTrue(hasRotaryInputDevice())
+
+ // Act.
+ @OptIn(ExperimentalTestApi::class)
+ rule.onRoot().performRotaryScrollInput { rotateToScrollVertically(3.0f) }
+
+ // Assert.
+ rule.runOnIdle { assertThat(receivedEvent?.inputDeviceId).isGreaterThan(1) }
+ }
+
+ @Test
+ fun horizontalRotaryEventContainsDeviceId() {
+ // Arrange.
+ ContentWithInitialFocus {
+ Box(
+ modifier =
+ Modifier.onRotaryScrollEvent {
+ receivedEvent = it
+ true
+ }
+ .focusable(initiallyFocused = true)
+ )
+ }
+ // Ignore on all devices which doesn't have rotary input.
+ Assume.assumeTrue(hasRotaryInputDevice())
+
+ // Act.
+ @OptIn(ExperimentalTestApi::class)
+ rule.onRoot().performRotaryScrollInput { rotateToScrollHorizontally(3.0f) }
+
+ // Assert.
+ rule.runOnIdle { assertThat(receivedEvent?.inputDeviceId).isGreaterThan(1) }
+ }
+
+ @Test
fun rotaryEventHasTime() {
val TIME = 1234567890L
@@ -511,4 +562,19 @@
private val verticalScrollFactor: Float
get() =
getScaledVerticalScrollFactor(ViewConfiguration.get(rootView.context), rootView.context)
+
+ private fun hasRotaryInputDevice(): Boolean {
+ with(rootView.context.getSystemService(Context.INPUT_SERVICE) as InputManager) {
+ inputDeviceIds.forEach { deviceId ->
+ getInputDevice(deviceId)?.apply {
+ motionRanges
+ .find { it.source == SOURCE_ROTARY_ENCODER }
+ ?.let {
+ return true
+ }
+ }
+ }
+ }
+ return false
+ }
}
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/AutofillManager.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/AutofillManager.kt
index 79f4666..f22e94ab 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/AutofillManager.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/AutofillManager.kt
@@ -25,18 +25,19 @@
interface AutofillManager {
/**
- * Indicate the autofill context should be committed.
+ * Indicate the autofill session should be committed.
*
- * Call this function to notify the Autofill framework that the current context should be
- * committed. After calling this function, the framework considers the form submitted, and the
- * credentials entered will be processed.
+ * Call this function to notify the Autofill framework that the current session should be
+ * committed and so the entered credentials might be saved or updated. After calling this
+ * function, the framework considers the form submitted, and any relevant dialog will appear to
+ * notify the user of the data processed.
*/
fun commit()
/**
* Indicate the autofill context should be canceled.
*
- * Call this function to notify the Autofill framework that the current context should be
+ * Call this function to notify the Autofill framework that the current session should be
* canceled. After calling this function, the framework will stop the current autofill session
* without processing any information entered in the autofillable field.
*/
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/api/current.txt b/core/core/api/current.txt
index d140c6a..41a187f 100644
--- a/core/core/api/current.txt
+++ b/core/core/api/current.txt
@@ -2948,6 +2948,13 @@
method @Deprecated public static void setQuickScaleEnabled(Object!, boolean);
}
+ public class ScrollFeedbackProviderCompat {
+ method public static androidx.core.view.ScrollFeedbackProviderCompat createProvider(android.view.View);
+ method public void onScrollLimit(int, int, int, boolean);
+ method public void onScrollProgress(int, int, int, int);
+ method public void onSnapToItem(int, int, int);
+ }
+
public interface ScrollingView {
method public int computeHorizontalScrollExtent();
method public int computeHorizontalScrollOffset();
diff --git a/core/core/api/restricted_current.txt b/core/core/api/restricted_current.txt
index c33f186..ede0127 100644
--- a/core/core/api/restricted_current.txt
+++ b/core/core/api/restricted_current.txt
@@ -3453,6 +3453,13 @@
method @Deprecated public static void setQuickScaleEnabled(Object!, boolean);
}
+ public class ScrollFeedbackProviderCompat {
+ method public static androidx.core.view.ScrollFeedbackProviderCompat createProvider(android.view.View);
+ method public void onScrollLimit(int, int, int, boolean);
+ method public void onScrollProgress(int, int, int, int);
+ method public void onSnapToItem(int, int, int);
+ }
+
public interface ScrollingView {
method public int computeHorizontalScrollExtent();
method public int computeHorizontalScrollOffset();
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/core/src/main/java/androidx/core/view/ScrollFeedbackProviderCompat.java b/core/core/src/main/java/androidx/core/view/ScrollFeedbackProviderCompat.java
new file mode 100644
index 0000000..07be72b
--- /dev/null
+++ b/core/core/src/main/java/androidx/core/view/ScrollFeedbackProviderCompat.java
@@ -0,0 +1,151 @@
+/*
+ * 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.view;
+
+import android.os.Build;
+import android.view.InputDevice;
+import android.view.MotionEvent;
+import android.view.ScrollFeedbackProvider;
+import android.view.View;
+import android.view.ViewConfiguration;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+
+/** Compat to access {@link ScrollFeedbackProvider} across different build versions. */
+public class ScrollFeedbackProviderCompat {
+
+ private final ScrollFeedbackProviderImpl mImpl;
+
+ private ScrollFeedbackProviderCompat(@NonNull View view) {
+ if (Build.VERSION.SDK_INT >= 35) {
+ mImpl = new ScrollFeedbackProviderApi35Impl(view);
+ } else {
+ mImpl = new ScrollFeedbackProviderBaseImpl();
+ }
+ }
+
+ /** Creates an instance of {@link ScrollFeedbackProviderCompat}. */
+ @NonNull
+ public static ScrollFeedbackProviderCompat createProvider(@NonNull View view) {
+ return new ScrollFeedbackProviderCompat(view);
+ }
+
+ /**
+ * Call this when the view has snapped to an item.
+ *
+ * @param inputDeviceId the ID of the {@link InputDevice} that generated the motion triggering
+ * the snap.
+ * @param source the input source of the motion causing the snap.
+ * @param axis the axis of {@code event} that caused the item to snap.
+ */
+ public void onSnapToItem(int inputDeviceId, int source, int axis) {
+ mImpl.onSnapToItem(inputDeviceId, source, axis);
+ }
+
+ /**
+ * Call this when the view has reached the scroll limit.
+ *
+ * <p>Note that a feedback may not be provided on every call to this method. This interface, for
+ * instance, may provide feedback on every `N`th scroll limit event. For the interface to
+ * properly provide feedback when needed, call this method for each scroll limit event that you
+ * want to be accounted to scroll limit feedback.
+ *
+ * @param inputDeviceId the ID of the {@link InputDevice} that caused scrolling to hit limit.
+ * @param source the input source of the motion that caused scrolling to hit the limit.
+ * @param axis the axis of {@code event} that caused scrolling to hit the limit.
+ * @param isStart {@code true} if scrolling hit limit at the start of the scrolling list, and
+ * {@code false} if the scrolling hit limit at the end of the scrolling list.
+ * <i>start</i> and <i>end</i> in this context are not geometrical references.
+ * Instead, they refer to the start and end of a scrolling experience. As such,
+ * "start" for some views may be at the bottom of a scrolling list, while it may
+ * be at the top of scrolling list for others.
+ */
+ public void onScrollLimit(int inputDeviceId, int source, int axis, boolean isStart) {
+ mImpl.onScrollLimit(inputDeviceId, source, axis, isStart);
+ }
+
+ /**
+ * Call this when the view has scrolled.
+ *
+ * <p>Different axes have different ways to map their raw axis values to pixels for scrolling.
+ * When calling this method, use the scroll values in pixels by which the view was scrolled; do
+ * not use the raw axis values. That is, use whatever value is passed to one of View's scrolling
+ * methods (example: {@link View#scrollBy(int, int)}). For example, for vertical scrolling on
+ * {@link MotionEvent#AXIS_SCROLL}, convert the raw axis value to the equivalent pixels by using
+ * {@link ViewConfiguration#getScaledVerticalScrollFactor()}, and use that value for this method
+ * call.
+ *
+ * <p>Note that a feedback may not be provided on every call to this method. This interface, for
+ * instance, may provide feedback for every `x` pixels scrolled. For the interface to properly
+ * track scroll progress and provide feedback when needed, call this method for each scroll
+ * event that you want to be accounted to scroll feedback.
+ *
+ * @param inputDeviceId the ID of the {@link InputDevice} that caused scroll progress.
+ * @param source the input source of the motion that caused scroll progress.
+ * @param axis the axis of {@code event} that caused scroll progress.
+ * @param deltaInPixels the amount of scroll progress, in pixels.
+ */
+ public void onScrollProgress(int inputDeviceId, int source, int axis, int deltaInPixels) {
+ mImpl.onScrollProgress(inputDeviceId, source, axis, deltaInPixels);
+ }
+
+ @RequiresApi(35)
+ private static class ScrollFeedbackProviderApi35Impl implements ScrollFeedbackProviderImpl {
+ private final ScrollFeedbackProvider mProvider;
+
+ ScrollFeedbackProviderApi35Impl(View view) {
+ mProvider = ScrollFeedbackProvider.createProvider(view);
+ }
+
+ @Override
+ public void onSnapToItem(int inputDeviceId, int source, int axis) {
+ mProvider.onSnapToItem(inputDeviceId, source, axis);
+ }
+
+ @Override
+ public void onScrollLimit(int inputDeviceId, int source, int axis, boolean isStart) {
+ mProvider.onScrollLimit(inputDeviceId, source, axis, isStart);
+ }
+
+ @Override
+ public void onScrollProgress(int inputDeviceId, int source, int axis, int deltaInPixels) {
+ mProvider.onScrollProgress(inputDeviceId, source, axis, deltaInPixels);
+ }
+ }
+
+ private static class ScrollFeedbackProviderBaseImpl implements ScrollFeedbackProviderImpl {
+ @Override
+ public void onSnapToItem(int inputDeviceId, int source, int axis) {}
+
+ @Override
+ public void onScrollLimit(int inputDeviceId, int source, int axis, boolean isStart) {}
+
+ @Override
+ public void onScrollProgress(int inputDeviceId, int source, int axis, int deltaInPixels) {}
+ }
+
+ /**
+ * An interface parallel to {@link ScrollFeedbackProvider}, to allow different compat
+ * implementations based on Build SDK version.
+ */
+ private interface ScrollFeedbackProviderImpl {
+ void onSnapToItem(int inputDeviceId, int source, int axis);
+ void onScrollLimit(int inputDeviceId, int source, int axis, boolean isStart);
+ void onScrollProgress(int inputDeviceId, int source, int axis, int deltaInPixels);
+ }
+}
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/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/gradle/libs.versions.toml b/gradle/libs.versions.toml
index c6259a9..e9f5ba4 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -12,7 +12,9 @@
# -----------------------------------------------------------------------------
androidLintMin = "31.1.0"
-androidLintPrev = "31.7.0-alpha02"
+# Lint version that uses the old analysis API. Updating projects using this version to the new
+# analysis API would make those lint checks unavailable for users of previous AGP versions.
+androidLintPrevAnalysis = "31.7.0-alpha02"
androidxTestRunner = "1.6.1"
androidxTestRules = "1.6.1"
androidxTestMonitor = "1.7.1"
@@ -81,14 +83,14 @@
androidLayoutlibApi = { module = "com.android.tools.layoutlib:layoutlib-api", version.ref = "androidLint" }
androidLint = { module = "com.android.tools.lint:lint", version.ref = "androidLint" }
androidLintMin = { module = "com.android.tools.lint:lint", version.ref = "androidLintMin" }
-androidLintPrev = { module = "com.android.tools.lint:lint", version.ref = "androidLintPrev" }
-androidLintPrevApi = { module = "com.android.tools.lint:lint-api", version.ref = "androidLintPrev" }
+androidLintPrevAnalysis = { module = "com.android.tools.lint:lint", version.ref = "androidLintPrevAnalysis" }
+androidLintApiPrevAnalysis = { module = "com.android.tools.lint:lint-api", version.ref = "androidLintPrevAnalysis" }
androidLintApi = { module = "com.android.tools.lint:lint-api", version.ref = "androidLint" }
androidLintMinApi = { module = "com.android.tools.lint:lint-api", version.ref = "androidLintMin" }
androidLintChecks = { module = "com.android.tools.lint:lint-checks", version.ref = "androidLint" }
androidLintChecksMin = { module = "com.android.tools.lint:lint-checks", version.ref = "androidLintMin" }
androidLintTests = { module = "com.android.tools.lint:lint-tests", version.ref = "androidLint" }
-androidLintPrevTests = { module = "com.android.tools.lint:lint-tests", version.ref = "androidLintPrev" }
+androidLintTestsPrevAnalysis = { module = "com.android.tools.lint:lint-tests", version.ref = "androidLintPrevAnalysis" }
androidToolsCommon = { module = "com.android.tools:common", version.ref = "androidLint" }
androidToolsRepository= { module = "com.android.tools:repository", version.ref = "androidLint" }
androidToolsSdkCommon = { module = "com.android.tools:sdk-common", version.ref = "androidLint" }
diff --git a/health/connect/connect-client/src/androidTest/AndroidManifest.xml b/health/connect/connect-client/src/androidTest/AndroidManifest.xml
index e235b8d..47aac45 100644
--- a/health/connect/connect-client/src/androidTest/AndroidManifest.xml
+++ b/health/connect/connect-client/src/androidTest/AndroidManifest.xml
@@ -32,6 +32,7 @@
<uses-permission android:name="android.permission.health.READ_ELEVATION_GAINED"/>
<uses-permission android:name="android.permission.health.READ_EXERCISE"/>
<uses-permission android:name="android.permission.health.READ_FLOORS_CLIMBED"/>
+ <uses-permission android:name="android.permission.health.READ_PLANNED_EXERCISE"/>
<uses-permission android:name="android.permission.health.READ_STEPS"/>
<uses-permission android:name="android.permission.health.READ_TOTAL_CALORIES_BURNED"/>
<uses-permission android:name="android.permission.health.READ_VO2_MAX"/>
@@ -50,6 +51,7 @@
<!-- Read permissions for CYCLE_TRACKING. -->
<uses-permission android:name="android.permission.health.READ_CERVICAL_MUCUS"/>
+ <uses-permission android:name="android.permission.health.READ_INTERMENSTRUAL_BLEEDING"/>
<uses-permission android:name="android.permission.health.READ_MENSTRUATION"/>
<uses-permission android:name="android.permission.health.READ_OVULATION_TEST"/>
<uses-permission android:name="android.permission.health.READ_SEXUAL_ACTIVITY"/>
@@ -71,6 +73,7 @@
<uses-permission android:name="android.permission.health.READ_OXYGEN_SATURATION"/>
<uses-permission android:name="android.permission.health.READ_RESPIRATORY_RATE"/>
<uses-permission android:name="android.permission.health.READ_RESTING_HEART_RATE"/>
+ <uses-permission android:name="android.permission.health.READ_SKIN_TEMPERATURE"/>
<!-- Write permissions for ACTIVITY. -->
<uses-permission android:name="android.permission.health.WRITE_ACTIVE_CALORIES_BURNED"/>
@@ -78,6 +81,7 @@
<uses-permission android:name="android.permission.health.WRITE_ELEVATION_GAINED"/>
<uses-permission android:name="android.permission.health.WRITE_EXERCISE"/>
<uses-permission android:name="android.permission.health.WRITE_FLOORS_CLIMBED"/>
+ <uses-permission android:name="android.permission.health.WRITE_PLANNED_EXERCISE"/>
<uses-permission android:name="android.permission.health.WRITE_STEPS"/>
<uses-permission android:name="android.permission.health.WRITE_TOTAL_CALORIES_BURNED"/>
<uses-permission android:name="android.permission.health.WRITE_VO2_MAX"/>
@@ -118,4 +122,5 @@
<uses-permission android:name="android.permission.health.WRITE_OXYGEN_SATURATION"/>
<uses-permission android:name="android.permission.health.WRITE_RESPIRATORY_RATE"/>
<uses-permission android:name="android.permission.health.WRITE_RESTING_HEART_RATE"/>
+ <uses-permission android:name="android.permission.health.WRITE_SKIN_TEMPERATURE"/>
</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..d93b15c 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
@@ -17,7 +17,6 @@
package androidx.health.connect.client.impl
import android.content.Context
-import android.content.pm.PackageManager
import android.os.Build
import android.os.ext.SdkExtensions
import androidx.health.connect.client.HealthConnectClient
@@ -25,9 +24,10 @@
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.permission.HealthPermission.Companion.PERMISSION_PREFIX
+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
import androidx.health.connect.client.readRecord
import androidx.health.connect.client.records.BloodPressureRecord
import androidx.health.connect.client.records.HeartRateRecord
@@ -84,21 +84,31 @@
LocalDate.now().minusDays(5).atStartOfDay().toInstant(ZoneOffset.UTC)
private val ZONE_OFFSET = ZoneOffset.UTC
private val ZONE_ID = ZoneId.of(ZONE_OFFSET.id)
+
+ fun getAllRecordPermissions(): Array<String> {
+ val permissions: HashSet<String> = HashSet()
+
+ for (recordType in SDK_TO_PLATFORM_RECORD_CLASS.keys) {
+ permissions.add(HealthPermission.getReadPermission(recordType))
+ permissions.add(HealthPermission.getWritePermission(recordType))
+ }
+
+ if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 13) {
+ for (recordType in SDK_TO_PLATFORM_RECORD_CLASS_EXT_13.keys) {
+ permissions.add(HealthPermission.getReadPermission(recordType))
+ permissions.add(HealthPermission.getWritePermission(recordType))
+ }
+ }
+
+ return permissions.toTypedArray()
+ }
}
private val context: Context = ApplicationProvider.getApplicationContext()
- private val allHealthPermissions =
- context.packageManager
- .getPackageInfo(
- context.packageName,
- PackageManager.PackageInfoFlags.of(PackageManager.GET_PERMISSIONS.toLong())
- )
- .requestedPermissions!!
- .filter { it.startsWith(PERMISSION_PREFIX) }
- .toTypedArray()
@get:Rule
- val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(*allHealthPermissions)
+ val grantPermissionRule: GrantPermissionRule =
+ GrantPermissionRule.grant(*getAllRecordPermissions())
private lateinit var healthConnectClient: HealthConnectClient
@@ -109,9 +119,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
@@ -837,7 +853,7 @@
@Test
fun getGrantedPermissions() = runTest {
assertThat(healthConnectClient.permissionController.getGrantedPermissions())
- .containsExactlyElementsIn(allHealthPermissions)
+ .containsExactlyElementsIn(getAllRecordPermissions())
}
private fun <A, E> assertEquals(vararg assertions: Pair<A, E>) {
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..ff31f66 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
@@ -18,13 +18,13 @@
import android.annotation.TargetApi
import android.content.Context
-import android.content.pm.PackageManager
import android.os.Build
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.permission.HealthPermission.Companion.PERMISSION_PREFIX
+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
import androidx.health.connect.client.records.BloodPressureRecord
import androidx.health.connect.client.records.CyclingPedalingCadenceRecord
import androidx.health.connect.client.records.NutritionRecord
@@ -70,26 +70,41 @@
private companion object {
private val START_TIME =
LocalDate.now().minusDays(5).atStartOfDay().toInstant(ZoneOffset.UTC)
+
+ fun getAllRecordPermissions(): Array<String> {
+ val permissions: HashSet<String> = HashSet()
+
+ for (recordType in SDK_TO_PLATFORM_RECORD_CLASS.keys) {
+ permissions.add(HealthPermission.getReadPermission(recordType))
+ permissions.add(HealthPermission.getWritePermission(recordType))
+ }
+
+ if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 13) {
+ for (recordType in SDK_TO_PLATFORM_RECORD_CLASS_EXT_13.keys) {
+ permissions.add(HealthPermission.getReadPermission(recordType))
+ permissions.add(HealthPermission.getWritePermission(recordType))
+ }
+ }
+
+ return permissions.toTypedArray()
+ }
}
- private val allHealthPermissions =
- context.packageManager
- .getPackageInfo(
- context.packageName,
- PackageManager.PackageInfoFlags.of(PackageManager.GET_PERMISSIONS.toLong())
- )
- .requestedPermissions!!
- .filter { it.startsWith(PERMISSION_PREFIX) }
- .toTypedArray()
-
@get:Rule
- val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(*allHealthPermissions)
+ val grantPermissionRule: GrantPermissionRule =
+ GrantPermissionRule.grant(*getAllRecordPermissions())
@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/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 b7dbc73..082fb04 100644
--- a/libraryversions.toml
+++ b/libraryversions.toml
@@ -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"
@@ -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-livedata-core-lint/build.gradle b/lifecycle/lifecycle-livedata-core-lint/build.gradle
index ac05ab8..4884d3c 100644
--- a/lifecycle/lifecycle-livedata-core-lint/build.gradle
+++ b/lifecycle/lifecycle-livedata-core-lint/build.gradle
@@ -29,12 +29,12 @@
}
dependencies {
- compileOnly(libs.androidLintPrevApi)
+ compileOnly(libs.androidLintApiPrevAnalysis)
compileOnly(libs.kotlinStdlib)
testImplementation(libs.kotlinStdlib)
- testImplementation(libs.androidLintPrev)
- testImplementation(libs.androidLintPrevTests)
+ testImplementation(libs.androidLintPrevAnalysis)
+ testImplementation(libs.androidLintTestsPrevAnalysis)
testImplementation(libs.junit)
}
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/navigation/navigation-common-lint/build.gradle b/navigation/navigation-common-lint/build.gradle
index 46b47fd..afa7352 100644
--- a/navigation/navigation-common-lint/build.gradle
+++ b/navigation/navigation-common-lint/build.gradle
@@ -35,14 +35,14 @@
dependencies {
compileOnly(libs.kotlinCompiler)
compileOnly(libs.kotlinStdlib)
- compileOnly(libs.androidLintPrevApi)
+ compileOnly(libs.androidLintApiPrevAnalysis)
compileOnly(libs.intellijCore)
compileOnly(libs.uast)
bundleInside(project(":navigation:navigation-lint-common"))
- testImplementation(libs.androidLintPrev)
- testImplementation(libs.androidLintPrevTests)
+ testImplementation(libs.androidLintPrevAnalysis)
+ testImplementation(libs.androidLintTestsPrevAnalysis)
testImplementation(libs.junit)
testImplementation(libs.truth)
}
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-compose-lint/build.gradle b/navigation/navigation-compose-lint/build.gradle
index d321692..b4bde72 100644
--- a/navigation/navigation-compose-lint/build.gradle
+++ b/navigation/navigation-compose-lint/build.gradle
@@ -44,8 +44,8 @@
testImplementation(project(":compose:lint:common-test"))
testImplementation(libs.kotlinStdlib)
testImplementation(libs.kotlinStdlibJdk8)
- testImplementation(libs.androidLintPrevApi)
- testImplementation(libs.androidLintPrevTests)
+ testImplementation(libs.androidLintApiPrevAnalysis)
+ testImplementation(libs.androidLintTestsPrevAnalysis)
testImplementation(libs.junit)
}
diff --git a/navigation/navigation-runtime-lint/build.gradle b/navigation/navigation-runtime-lint/build.gradle
index 575793e..c7b9d8e 100644
--- a/navigation/navigation-runtime-lint/build.gradle
+++ b/navigation/navigation-runtime-lint/build.gradle
@@ -41,8 +41,8 @@
bundleInside(project(":navigation:navigation-lint-common"))
testImplementation(libs.kotlinStdlib)
- testImplementation(libs.androidLintPrevApi)
- testImplementation(libs.androidLintPrevTests)
+ testImplementation(libs.androidLintApiPrevAnalysis)
+ testImplementation(libs.androidLintTestsPrevAnalysis)
testImplementation(libs.intellijCore)
testImplementation(libs.junit)
testImplementation(libs.truth)
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/main/res/layout/fragment_host.xml b/pdf/integration-tests/testapp/src/main/res/layout/fragment_host.xml
index e6deb4d..9621c5c 100644
--- a/pdf/integration-tests/testapp/src/main/res/layout/fragment_host.xml
+++ b/pdf/integration-tests/testapp/src/main/res/layout/fragment_host.xml
@@ -28,7 +28,7 @@
android:layout_height="wrap_content"
android:layout_marginBottom="80dp"
android:layout_marginEnd="16dp"
- android:contentDescription="@string/action_edit"
+ android:contentDescription="@string/action_search"
android:layout_gravity="bottom|end"
android:visibility="gone"
android:src="@drawable/ic_action_search"
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
index 551a72a..3413f8c 100644
--- a/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/PdfViewPaginationTest.kt
+++ b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/PdfViewPaginationTest.kt
@@ -22,6 +22,7 @@
import android.view.View
import android.view.View.MeasureSpec
import androidx.pdf.PdfDocument
+import androidx.test.annotation.UiThreadTest
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
@@ -65,6 +66,7 @@
}
@Test
+ @UiThreadTest
fun testPageVisibility_withoutPdfDocument() {
val pdfView = setupPdfViewOnMain(500, 1000, null)
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/ToolBoxView.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/ToolBoxView.kt
new file mode 100644
index 0000000..8aab598
--- /dev/null
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/ToolBoxView.kt
@@ -0,0 +1,85 @@
+/*
+ * 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.drawable.Drawable
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.view.View
+import androidx.annotation.RestrictTo
+import androidx.pdf.PdfDocument
+import androidx.pdf.R
+import androidx.pdf.util.AnnotationUtils
+import androidx.pdf.util.Intents.startActivity
+import androidx.pdf.util.Uris
+import com.google.android.material.floatingactionbutton.FloatingActionButton
+
+@RestrictTo(RestrictTo.Scope.LIBRARY)
+public open class ToolBoxView
+@JvmOverloads
+constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
+ View(context, attrs, defStyleAttr) {
+
+ private val editButton: FloatingActionButton
+ private var pdfDocument: PdfDocument? = null
+ private var editClickListener: OnClickListener? = null
+
+ init {
+ LayoutInflater.from(context).inflate(R.layout.toolbox_view, null, false)
+ editButton = findViewById(R.id.edit_fab)
+
+ editButton.setOnClickListener {
+ handleEditFabClick()
+ editClickListener?.onClick(this)
+ }
+ }
+
+ public fun setPdfDocument(pdfDocument: PdfDocument?) {
+ this.pdfDocument = pdfDocument
+ }
+
+ public fun setEditIconDrawable(drawable: Drawable?) {
+ editButton.setImageDrawable(drawable)
+ }
+
+ public fun setOnEditClickListener(listener: OnClickListener) {
+ editClickListener = listener
+ }
+
+ private fun handleEditFabClick() {
+
+ pdfDocument?.let {
+ val uri = it.uri
+
+ val intent =
+ AnnotationUtils.getAnnotationIntent(uri).apply {
+ setData(uri)
+ putExtra(EXTRA_PDF_FILE_NAME, Uris.extractName(uri, context.contentResolver))
+ putExtra(EXTRA_STARTING_PAGE, 0)
+ }
+ startActivity(context, "", intent)
+ }
+ }
+
+ public companion object {
+ public const val EXTRA_PDF_FILE_NAME: String =
+ "androidx.pdf.viewer.fragment.extra.PDF_FILE_NAME"
+ public const val EXTRA_STARTING_PAGE: String =
+ "androidx.pdf.viewer.fragment.extra.STARTING_PAGE"
+ }
+}
diff --git a/pdf/pdf-viewer/src/main/res/layout/fastscroll_handle.xml b/pdf/pdf-viewer/src/main/res/layout/fastscroll_handle.xml
index 4bc4ceb..900bc7f 100644
--- a/pdf/pdf-viewer/src/main/res/layout/fastscroll_handle.xml
+++ b/pdf/pdf-viewer/src/main/res/layout/fastscroll_handle.xml
@@ -19,7 +19,9 @@
android:layout_height="40dp"
android:background="@drawable/fastscroll_background"
android:elevation="4dp"
- android:importantForAccessibility="no"
+ android:focusable="true"
+ android:clickable="true"
+ android:contentDescription="@string/scrollbar_description"
android:scaleType="center"
android:src="@drawable/drag_indicator"
android:translationX="8dp" />
\ No newline at end of file
diff --git a/pdf/pdf-viewer/src/main/res/layout/page_indicator.xml b/pdf/pdf-viewer/src/main/res/layout/page_indicator.xml
index 06d456f..2f9004d 100644
--- a/pdf/pdf-viewer/src/main/res/layout/page_indicator.xml
+++ b/pdf/pdf-viewer/src/main/res/layout/page_indicator.xml
@@ -24,6 +24,8 @@
android:background="@drawable/page_indicator_background"
android:elevation="1dp"
android:focusableInTouchMode="true"
+ android:focusable="true"
+ android:clickable="true"
android:gravity="center"
android:paddingLeft="12dp"
android:paddingRight="12dp"
diff --git a/pdf/pdf-viewer/src/main/res/layout/toolbox_view.xml b/pdf/pdf-viewer/src/main/res/layout/toolbox_view.xml
new file mode 100644
index 0000000..661a3dd
--- /dev/null
+++ b/pdf/pdf-viewer/src/main/res/layout/toolbox_view.xml
@@ -0,0 +1,39 @@
+<?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.
+ -->
+
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:gravity="center">
+
+ <com.google.android.material.floatingactionbutton.FloatingActionButton
+ android:id="@+id/edit_fab"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:contentDescription="@string/action_edit"
+ android:layout_gravity="bottom|end"
+ android:visibility="gone"
+ android:src="@drawable/edit_fab"
+ app:backgroundTint="?attr/colorPrimaryContainer"
+ app:tint="?attr/colorOnPrimaryContainer" />
+
+
+</LinearLayout>
+
+
diff --git a/pdf/pdf-viewer/src/main/res/values/strings.xml b/pdf/pdf-viewer/src/main/res/values/strings.xml
index 5387396..f4d7e82 100644
--- a/pdf/pdf-viewer/src/main/res/values/strings.xml
+++ b/pdf/pdf-viewer/src/main/res/values/strings.xml
@@ -125,6 +125,12 @@
<!-- Content description for edit fab button -->
<string name="action_edit">Edit file</string>
+ <!-- Content description for search fab button -->
+ <string name="action_search">Search file</string>
+
+ <!-- Content description for scroll bar -->
+ <string name="scrollbar_description">Scrollbar</string>
+
<!-- Text for an error indicator that is displayed when the file is password protected and the
password dialog has been dismissed without entering a correct password. [CHAR LIMIT=100] -->
<string name="password_not_entered">Enter password to unlock</string>
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/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/wear/compose/compose-foundation/src/androidTest/kotlin/androidx/wear/compose/foundation/lazy/TransformingLazyColumnTest.kt b/wear/compose/compose-foundation/src/androidTest/kotlin/androidx/wear/compose/foundation/lazy/TransformingLazyColumnTest.kt
index 09b2995..5dbd105 100644
--- a/wear/compose/compose-foundation/src/androidTest/kotlin/androidx/wear/compose/foundation/lazy/TransformingLazyColumnTest.kt
+++ b/wear/compose/compose-foundation/src/androidTest/kotlin/androidx/wear/compose/foundation/lazy/TransformingLazyColumnTest.kt
@@ -393,6 +393,19 @@
testTransformingLazyColumnRotary(false, 0)
}
+ @Test
+ fun supportsEmptyItems() {
+ rule.setContent {
+ TransformingLazyColumn(
+ state = rememberTransformingLazyColumnState(),
+ modifier = Modifier.testTag(lazyListTag),
+ ) {
+ items(10) {}
+ }
+ }
+ rule.onNodeWithTag(lazyListTag).assertIsDisplayed()
+ }
+
@OptIn(ExperimentalTestApi::class)
private fun testTransformingLazyColumnRotary(
userScrollEnabled: Boolean,
diff --git a/wear/compose/compose-foundation/src/androidTest/kotlin/androidx/wear/compose/foundation/rotary/HapticsTest.kt b/wear/compose/compose-foundation/src/androidTest/kotlin/androidx/wear/compose/foundation/rotary/HapticsTest.kt
new file mode 100644
index 0000000..df23e98
--- /dev/null
+++ b/wear/compose/compose-foundation/src/androidTest/kotlin/androidx/wear/compose/foundation/rotary/HapticsTest.kt
@@ -0,0 +1,303 @@
+/*
+ * 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.foundation.rotary
+
+import android.os.Build
+import android.view.ScrollFeedbackProvider
+import android.view.ViewConfiguration
+import androidx.compose.foundation.gestures.ScrollableDefaults
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performRotaryScrollInput
+import androidx.compose.ui.unit.dp
+import androidx.test.filters.SdkSuppress
+import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
+import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
+import androidx.wear.compose.foundation.rotary.RotaryScrollTest.Companion.TEST_TAG
+import com.google.common.truth.Truth
+import org.junit.Rule
+import org.junit.Test
+
+@SdkSuppress(minSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
+@OptIn(ExperimentalTestApi::class)
+class HapticsTest {
+ @get:Rule val rule = createComposeRule()
+ private val focusRequester = FocusRequester()
+
+ @Test
+ fun platformHaptics_scrollProgressCalled_once() {
+
+ val mockedScrollFeedbackProvider = MockedScrollFeedbackProvider()
+ rule.setContent { SLCRotaryFling(mockedScrollFeedbackProvider) }
+ rule.runOnIdle { focusRequester.requestFocus() }
+
+ rule.onNodeWithTag(TEST_TAG).performRotaryScrollInput { rotateToScrollVertically(10f) }
+
+ Truth.assertThat(mockedScrollFeedbackProvider.onScrollProgressCounter).isEqualTo(1)
+ Truth.assertThat(mockedScrollFeedbackProvider.onSnapToItemCounter).isEqualTo(0)
+ Truth.assertThat(mockedScrollFeedbackProvider.onScrollLimitCounter).isEqualTo(0)
+ }
+
+ @Test
+ fun platformHaptics_scrollProgressCalled_multipleTimes() {
+
+ val mockedScrollFeedbackProvider = MockedScrollFeedbackProvider()
+ rule.setContent { SLCRotaryFling(mockedScrollFeedbackProvider) }
+ rule.runOnIdle { focusRequester.requestFocus() }
+
+ rule.onNodeWithTag(TEST_TAG).performRotaryScrollInput {
+ rotateToScrollVertically(10f)
+ advanceEventTime(10)
+ rotateToScrollVertically(10f)
+ advanceEventTime(10)
+ rotateToScrollVertically(10f)
+ }
+
+ Truth.assertThat(mockedScrollFeedbackProvider.onScrollProgressCounter).isEqualTo(3)
+ Truth.assertThat(mockedScrollFeedbackProvider.onSnapToItemCounter).isEqualTo(0)
+ Truth.assertThat(mockedScrollFeedbackProvider.onScrollLimitCounter).isEqualTo(0)
+ }
+
+ @Test
+ fun platformHaptics_scrollLimitCalled() {
+
+ val mockedScrollFeedbackProvider = MockedScrollFeedbackProvider()
+ rule.setContent { SLCRotaryFling(mockedScrollFeedbackProvider) }
+ rule.runOnIdle { focusRequester.requestFocus() }
+
+ rule.onNodeWithTag(TEST_TAG).performRotaryScrollInput {
+ // Scroll the rotary forwards and then backwards so that we'll reach the edge of the
+ // list.
+ rotateToScrollVertically(10f)
+ advanceEventTime(10)
+ rotateToScrollVertically(-11f)
+ }
+
+ Truth.assertThat(mockedScrollFeedbackProvider.onScrollLimitCounter).isEqualTo(1)
+ }
+
+ @Test
+ fun platformHaptics_snapToItemCalled_once_highRes() {
+
+ val mockedScrollFeedbackProvider = MockedScrollFeedbackProvider()
+ rule.setContent { SLCHighResRotarySnap(mockedScrollFeedbackProvider) }
+ rule.runOnIdle { focusRequester.requestFocus() }
+
+ rule.onNodeWithTag(TEST_TAG).performRotaryScrollInput { rotateToScrollVertically(100f) }
+ Truth.assertThat(mockedScrollFeedbackProvider.onSnapToItemCounter).isEqualTo(1)
+ }
+
+ @Test
+ fun platformHaptics_snapToItemCalled_multipleTimes_highRes() {
+
+ val mockedScrollFeedbackProvider = MockedScrollFeedbackProvider()
+ rule.setContent { SLCHighResRotarySnap(mockedScrollFeedbackProvider) }
+ rule.runOnIdle { focusRequester.requestFocus() }
+
+ rule.onNodeWithTag(TEST_TAG).performRotaryScrollInput {
+ rotateToScrollVertically(100f)
+ advanceEventTime(10)
+ rotateToScrollVertically(100f)
+ advanceEventTime(10)
+ rotateToScrollVertically(100f)
+ }
+ Truth.assertThat(mockedScrollFeedbackProvider.onSnapToItemCounter).isEqualTo(3)
+ }
+
+ @Test
+ fun platformHaptics_snapToItemCalled_once_lowRes() {
+
+ val mockedScrollFeedbackProvider = MockedScrollFeedbackProvider()
+ rule.setContent { SLCLowResRotarySnap(mockedScrollFeedbackProvider) }
+ rule.runOnIdle { focusRequester.requestFocus() }
+
+ rule.onNodeWithTag(TEST_TAG).performRotaryScrollInput { rotateToScrollVertically(100f) }
+ Truth.assertThat(mockedScrollFeedbackProvider.onSnapToItemCounter).isEqualTo(1)
+ }
+
+ @Test
+ fun platformHaptics_snapToItemCalled_multipleTimes_lowRes() {
+
+ val mockedScrollFeedbackProvider = MockedScrollFeedbackProvider()
+ rule.setContent { SLCLowResRotarySnap(mockedScrollFeedbackProvider) }
+ rule.runOnIdle { focusRequester.requestFocus() }
+
+ rule.onNodeWithTag(TEST_TAG).performRotaryScrollInput {
+ rotateToScrollVertically(100f)
+ advanceEventTime(10)
+ rotateToScrollVertically(100f)
+ advanceEventTime(10)
+ rotateToScrollVertically(100f)
+ }
+ Truth.assertThat(mockedScrollFeedbackProvider.onSnapToItemCounter).isEqualTo(3)
+ }
+
+ @Composable
+ private fun SLCRotaryFling(
+ scrollFeedbackProvider: ScrollFeedbackProvider,
+ ) {
+ val scrollableState = rememberScalingLazyListState()
+ val viewConfiguration = ViewConfiguration.get(LocalContext.current)
+ val flingBehavior = ScrollableDefaults.flingBehavior()
+ ScalingLazyColumn(
+ state = scrollableState,
+ rotaryScrollableBehavior = null,
+ modifier =
+ Modifier.size(200.dp)
+ .testTag(TEST_TAG)
+ .rotaryScrollable(
+ behavior =
+ FlingRotaryScrollableBehavior(
+ isLowRes = false,
+ rotaryHaptics =
+ PlatformRotaryHapticHandler(
+ scrollableState,
+ scrollFeedbackProvider
+ ),
+ rotaryFlingHandlerFactory = {
+ RotaryFlingHandler(
+ scrollableState = scrollableState,
+ flingBehavior = flingBehavior,
+ viewConfiguration = viewConfiguration,
+ flingTimeframe = 20
+ )
+ },
+ scrollHandlerFactory = { RotaryScrollHandler(scrollableState) }
+ ),
+ focusRequester = focusRequester,
+ reverseDirection = false
+ )
+ ) {
+ items(300) { BasicText(text = "Item #$it") }
+ }
+ }
+
+ @Composable
+ private fun SLCHighResRotarySnap(
+ scrollFeedbackProvider: ScrollFeedbackProvider,
+ ) {
+ val scrollableState = rememberScalingLazyListState()
+ val layoutInfoProvider =
+ remember(scrollableState) {
+ ScalingLazyColumnRotarySnapLayoutInfoProvider(scrollableState)
+ }
+ ScalingLazyColumn(
+ state = scrollableState,
+ // We need to switch off default rotary behavior
+ rotaryScrollableBehavior = null,
+ modifier =
+ Modifier.size(200.dp)
+ .testTag(TEST_TAG)
+ .rotaryScrollable(
+ behavior =
+ HighResSnapRotaryScrollableBehavior(
+ rotaryHaptics =
+ PlatformRotaryHapticHandler(
+ scrollableState,
+ scrollFeedbackProvider
+ ),
+ scrollDistanceDivider =
+ RotarySnapSensitivity.DEFAULT.resistanceFactor,
+ thresholdHandlerFactory = {
+ ThresholdHandler(
+ RotarySnapSensitivity.DEFAULT.minThresholdDivider,
+ RotarySnapSensitivity.DEFAULT.maxThresholdDivider
+ ) {
+ 50f
+ }
+ },
+ snapHandlerFactory = {
+ RotarySnapHandler(scrollableState, layoutInfoProvider, 0)
+ },
+ scrollHandlerFactory = { RotaryScrollHandler(scrollableState) },
+ ),
+ focusRequester = focusRequester,
+ reverseDirection = false
+ )
+ ) {
+ items(300) { BasicText(text = "Item #$it") }
+ }
+ }
+
+ @Composable
+ private fun SLCLowResRotarySnap(
+ scrollFeedbackProvider: ScrollFeedbackProvider,
+ ) {
+ val scrollableState = rememberScalingLazyListState()
+ val layoutInfoProvider =
+ remember(scrollableState) {
+ ScalingLazyColumnRotarySnapLayoutInfoProvider(scrollableState)
+ }
+ ScalingLazyColumn(
+ state = scrollableState,
+ // We need to switch off default rotary behavior
+ rotaryScrollableBehavior = null,
+ modifier =
+ Modifier.size(200.dp)
+ .testTag(TEST_TAG)
+ .rotaryScrollable(
+ behavior =
+ LowResSnapRotaryScrollableBehavior(
+ rotaryHaptics =
+ PlatformRotaryHapticHandler(
+ scrollableState,
+ scrollFeedbackProvider
+ ),
+ snapHandlerFactory = {
+ RotarySnapHandler(scrollableState, layoutInfoProvider, 0)
+ },
+ ),
+ focusRequester = focusRequester,
+ reverseDirection = false
+ )
+ ) {
+ items(300) { BasicText(text = "Item #$it") }
+ }
+ }
+
+ class MockedScrollFeedbackProvider() : ScrollFeedbackProvider {
+ var onSnapToItemCounter = 0
+ var onScrollLimitCounter = 0
+ var onScrollProgressCounter = 0
+
+ override fun onSnapToItem(inputDeviceId: Int, source: Int, axis: Int) {
+ onSnapToItemCounter++
+ }
+
+ override fun onScrollLimit(inputDeviceId: Int, source: Int, axis: Int, isStart: Boolean) {
+ onScrollLimitCounter++
+ }
+
+ override fun onScrollProgress(
+ inputDeviceId: Int,
+ source: Int,
+ axis: Int,
+ deltaInPixels: Int
+ ) {
+ onScrollProgressCounter++
+ }
+ }
+}
diff --git a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/BasicSwipeToDismissBox.kt b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/BasicSwipeToDismissBox.kt
index 4ef9228..6e73e61 100644
--- a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/BasicSwipeToDismissBox.kt
+++ b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/BasicSwipeToDismissBox.kt
@@ -483,65 +483,69 @@
swipeToDismissBoxState: SwipeToDismissBoxState,
edgeWidth: Dp = SwipeToDismissBoxDefaults.EdgeWidth
): Modifier =
- composed(
- inspectorInfo =
- debugInspectorInfo {
- name = "edgeSwipeToDismiss"
- properties["swipeToDismissBoxState"] = swipeToDismissBoxState
- properties["edgeWidth"] = edgeWidth
- }
- ) {
- // Tracks the current swipe status
- val edgeSwipeState = remember { mutableStateOf(EdgeSwipeState.WaitingForTouch) }
- val nestedScrollConnection =
- remember(swipeToDismissBoxState) {
- swipeToDismissBoxState.edgeNestedScrollConnection(edgeSwipeState)
- }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
+ this // Edge swipe to dismiss doesn't work on API >= 35 for now
+ } else
+ composed(
+ inspectorInfo =
+ debugInspectorInfo {
+ name = "edgeSwipeToDismiss"
+ properties["swipeToDismissBoxState"] = swipeToDismissBoxState
+ properties["edgeWidth"] = edgeWidth
+ }
+ ) {
+ // Tracks the current swipe status
+ val edgeSwipeState = remember { mutableStateOf(EdgeSwipeState.WaitingForTouch) }
+ val nestedScrollConnection =
+ remember(swipeToDismissBoxState) {
+ swipeToDismissBoxState.edgeNestedScrollConnection(edgeSwipeState)
+ }
- val nestedPointerInput: suspend PointerInputScope.() -> Unit = {
- coroutineScope {
- awaitPointerEventScope {
- while (isActive) {
- awaitPointerEvent(PointerEventPass.Initial).changes.fastForEach { change ->
- // By default swipeState is WaitingForTouch.
- // If it is in this state and a first touch hit an edge area, we
- // set swipeState to EdgeClickedWaitingForDirection.
- // After that to track which direction the swipe will go, we check
- // the next touch. If it lands to the left of the first, we consider
- // it as a swipe left and set the state to SwipingToPage. Otherwise,
- // set the state to SwipingToDismiss
- when (edgeSwipeState.value) {
- EdgeSwipeState.SwipeToDismissInProgress,
- EdgeSwipeState.WaitingForTouch -> {
- edgeSwipeState.value =
- if (change.position.x < edgeWidth.toPx())
- EdgeSwipeState.EdgeClickedWaitingForDirection
- else EdgeSwipeState.SwipingToPage
+ val nestedPointerInput: suspend PointerInputScope.() -> Unit = {
+ coroutineScope {
+ awaitPointerEventScope {
+ while (isActive) {
+ awaitPointerEvent(PointerEventPass.Initial).changes.fastForEach { change
+ ->
+ // By default swipeState is WaitingForTouch.
+ // If it is in this state and a first touch hit an edge area, we
+ // set swipeState to EdgeClickedWaitingForDirection.
+ // After that to track which direction the swipe will go, we check
+ // the next touch. If it lands to the left of the first, we consider
+ // it as a swipe left and set the state to SwipingToPage. Otherwise,
+ // set the state to SwipingToDismiss
+ when (edgeSwipeState.value) {
+ EdgeSwipeState.SwipeToDismissInProgress,
+ EdgeSwipeState.WaitingForTouch -> {
+ edgeSwipeState.value =
+ if (change.position.x < edgeWidth.toPx())
+ EdgeSwipeState.EdgeClickedWaitingForDirection
+ else EdgeSwipeState.SwipingToPage
+ }
+ EdgeSwipeState.EdgeClickedWaitingForDirection -> {
+ edgeSwipeState.value =
+ if (change.position.x < change.previousPosition.x)
+ EdgeSwipeState.SwipingToPage
+ else EdgeSwipeState.SwipingToDismiss
+ }
+ else -> {} // Do nothing
}
- EdgeSwipeState.EdgeClickedWaitingForDirection -> {
+ // When finger is up - reset swipeState to WaitingForTouch
+ // or to SwipeToDismissInProgress if current
+ // state is SwipingToDismiss
+ if (change.changedToUp()) {
edgeSwipeState.value =
- if (change.position.x < change.previousPosition.x)
- EdgeSwipeState.SwipingToPage
- else EdgeSwipeState.SwipingToDismiss
+ if (edgeSwipeState.value == EdgeSwipeState.SwipingToDismiss)
+ EdgeSwipeState.SwipeToDismissInProgress
+ else EdgeSwipeState.WaitingForTouch
}
- else -> {} // Do nothing
- }
- // When finger is up - reset swipeState to WaitingForTouch
- // or to SwipeToDismissInProgress if current
- // state is SwipingToDismiss
- if (change.changedToUp()) {
- edgeSwipeState.value =
- if (edgeSwipeState.value == EdgeSwipeState.SwipingToDismiss)
- EdgeSwipeState.SwipeToDismissInProgress
- else EdgeSwipeState.WaitingForTouch
}
}
}
}
}
+ pointerInput(edgeWidth, nestedPointerInput).nestedScroll(nestedScrollConnection)
}
- pointerInput(edgeWidth, nestedPointerInput).nestedScroll(nestedScrollConnection)
- }
/** An enum which represents a current state of swipe action. */
internal enum class EdgeSwipeState {
diff --git a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/lazy/TransformingLazyColumnContentPaddingMeasurementStrategy.kt b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/lazy/TransformingLazyColumnContentPaddingMeasurementStrategy.kt
index 4333e24..7077c64 100644
--- a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/lazy/TransformingLazyColumnContentPaddingMeasurementStrategy.kt
+++ b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/lazy/TransformingLazyColumnContentPaddingMeasurementStrategy.kt
@@ -248,7 +248,7 @@
bottomItemScrollProgress(
// TODO: artemiy - Investigate why this is needed.
if (idx == 0) previousOffset - itemSpacing else previousOffset,
- item.placeable.height,
+ item.measuredHeight,
containerConstraints.maxHeight
)
item.offset = previousOffset
@@ -267,7 +267,7 @@
topItemScrollProgress(
// TODO: artemiy - Investigate why this is needed.
if (idx == 0) bottomLineOffset + 2 * itemSpacing else bottomLineOffset,
- visibleItems[idx].placeable.height,
+ visibleItems[idx].measuredHeight,
containerConstraints.maxHeight
)
visibleItems[idx].offset = bottomLineOffset - visibleItems[idx].transformedHeight
diff --git a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/lazy/TransformingLazyColumnMeasuredItem.kt b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/lazy/TransformingLazyColumnMeasuredItem.kt
index a560127..ff074c1 100644
--- a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/lazy/TransformingLazyColumnMeasuredItem.kt
+++ b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/lazy/TransformingLazyColumnMeasuredItem.kt
@@ -26,8 +26,8 @@
internal data class TransformingLazyColumnMeasuredItem(
/** The index of the item in the list. */
override val index: Int,
- /** The [Placeable] representing the content of the item. */
- val placeable: Placeable,
+ /** The [Placeable] representing the content of the item, or null if no composable is inside. */
+ val placeable: Placeable?,
/** The constraints of the container holding the item. */
val containerConstraints: Constraints,
/** The vertical offset of the item from the top of the list after transformations applied. */
@@ -55,15 +55,17 @@
/** The height of the item after transformations applied. */
override val transformedHeight: Int
get() =
- (placeable.parentData as? HeightProviderParentData)?.let {
- it.heightProvider(placeable.height, scrollProgress)
- } ?: placeable.height
+ placeable?.let { p ->
+ (p.parentData as? HeightProviderParentData)?.let {
+ it.heightProvider(p.height, scrollProgress)
+ } ?: p.height
+ } ?: 0
- override val measuredHeight = placeable.height
+ override val measuredHeight = placeable?.height ?: 0
fun place(scope: Placeable.PlacementScope) =
with(scope) {
- placeable.placeWithLayer(
+ placeable?.placeWithLayer(
x =
leftPadding +
horizontalAlignment.align(
@@ -78,8 +80,8 @@
fun pinToCenter() {
scrollProgress =
bottomItemScrollProgress(
- containerConstraints.maxHeight / 2 - placeable.height / 2,
- placeable.height,
+ containerConstraints.maxHeight / 2 - measuredHeight / 2,
+ measuredHeight,
containerConstraints.maxHeight
)
offset = containerConstraints.maxHeight / 2 - transformedHeight / 2
diff --git a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/lazy/TransformingLazyColumnMeasurement.kt b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/lazy/TransformingLazyColumnMeasurement.kt
index 9196266..78373d9 100644
--- a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/lazy/TransformingLazyColumnMeasurement.kt
+++ b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/lazy/TransformingLazyColumnMeasurement.kt
@@ -76,13 +76,13 @@
val measuredItemProvider = MeasuredItemProvider { index, offset, scrollProgress ->
val placeables = measure(index, childConstraints)
// TODO(artemiy): Add support for multiple items.
- val placeable = placeables.last()
+ val placeable = placeables.lastOrNull()
TransformingLazyColumnMeasuredItem(
index = index,
placeable = placeable,
offset = offset,
containerConstraints = containerConstraints,
- scrollProgress = scrollProgress(placeable.height),
+ scrollProgress = scrollProgress(placeable?.height ?: 0),
horizontalAlignment = horizontalAlignment,
layoutDirection = layoutDirection,
key = itemProvider.getKey(index),
diff --git a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/rotary/Haptics.kt b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/rotary/Haptics.kt
index d5515df..28bce2c 100644
--- a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/rotary/Haptics.kt
+++ b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/rotary/Haptics.kt
@@ -20,7 +20,10 @@
import android.content.pm.PackageManager
import android.os.Build
import android.provider.Settings
+import android.view.InputDevice
+import android.view.ScrollFeedbackProvider
import android.view.View
+import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.gestures.ScrollableState
import androidx.compose.runtime.Composable
@@ -29,6 +32,7 @@
import androidx.compose.ui.platform.LocalView
import com.google.wear.input.WearHapticFeedbackConstants
import kotlin.math.abs
+import kotlin.math.roundToInt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
@@ -43,13 +47,13 @@
internal interface RotaryHapticHandler {
/** Handles haptics when scroll is used */
- fun handleScrollHaptic(timestamp: Long, deltaInPixels: Float)
+ fun handleScrollHaptic(timestamp: Long, deltaInPixels: Float, inputDeviceId: Int, axis: Int)
/** Handles haptics when scroll with snap is used */
- fun handleSnapHaptic(timestamp: Long, deltaInPixels: Float)
+ fun handleSnapHaptic(timestamp: Long, deltaInPixels: Float, inputDeviceId: Int, axis: Int)
/** Handles haptics when edge of the list is reached */
- fun handleLimitHaptic(isStart: Boolean)
+ fun handleLimitHaptic(isStart: Boolean, inputDeviceId: Int, axis: Int)
}
@Composable
@@ -58,11 +62,10 @@
hapticsEnabled: Boolean
): RotaryHapticHandler =
if (hapticsEnabled) {
- // TODO(b/319103162): Add platform haptics once AndroidX updates to Android VanillaIceCream
- rememberCustomRotaryHapticHandler(scrollableState)
- } else {
- rememberDisabledRotaryHapticHandler()
- }
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+ rememberCustomRotaryHapticHandler(scrollableState)
+ else rememberPlatformRotaryHapticHandler(scrollableState)
+ } else rememberDisabledRotaryHapticHandler()
/**
* Remembers custom rotary haptic handler.
@@ -178,9 +181,14 @@
private var currScrollPosition = 0f
private var prevHapticsPosition = 0f
- override fun handleScrollHaptic(timestamp: Long, deltaInPixels: Float) {
+ override fun handleScrollHaptic(
+ timestamp: Long,
+ deltaInPixels: Float,
+ inputDeviceId: Int,
+ axis: Int
+ ) {
if (scrollableState.reachedTheLimit(deltaInPixels)) {
- handleLimitHaptic(scrollableState.canScrollBackward)
+ handleLimitHaptic(scrollableState.canScrollBackward, inputDeviceId, axis)
} else {
overscrollHapticTriggered = false
currScrollPosition += deltaInPixels
@@ -193,16 +201,21 @@
}
}
- override fun handleSnapHaptic(timestamp: Long, deltaInPixels: Float) {
+ override fun handleSnapHaptic(
+ timestamp: Long,
+ deltaInPixels: Float,
+ inputDeviceId: Int,
+ axis: Int
+ ) {
if (scrollableState.reachedTheLimit(deltaInPixels)) {
- handleLimitHaptic(scrollableState.canScrollBackward)
+ handleLimitHaptic(scrollableState.canScrollBackward, inputDeviceId, axis)
} else {
overscrollHapticTriggered = false
hapticsChannel.trySend(RotaryHapticsType.ScrollItemFocus)
}
}
- override fun handleLimitHaptic(isStart: Boolean) {
+ override fun handleLimitHaptic(isStart: Boolean, inputDeviceId: Int, axis: Int) {
if (!overscrollHapticTriggered) {
hapticsChannel.trySend(RotaryHapticsType.ScrollLimit)
overscrollHapticTriggered = true
@@ -240,20 +253,85 @@
@Composable
private fun rememberDisabledRotaryHapticHandler(): RotaryHapticHandler = remember {
object : RotaryHapticHandler {
- override fun handleScrollHaptic(timestamp: Long, deltaInPixels: Float) {
+ override fun handleScrollHaptic(
+ timestamp: Long,
+ deltaInPixels: Float,
+ inputDeviceId: Int,
+ axis: Int
+ ) {
// Do nothing
}
- override fun handleSnapHaptic(timestamp: Long, deltaInPixels: Float) {
+ override fun handleSnapHaptic(
+ timestamp: Long,
+ deltaInPixels: Float,
+ inputDeviceId: Int,
+ axis: Int
+ ) {
// Do nothing
}
- override fun handleLimitHaptic(isStart: Boolean) {
+ override fun handleLimitHaptic(isStart: Boolean, inputDeviceId: Int, axis: Int) {
// Do nothing
}
}
}
+@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+@Composable
+private fun rememberPlatformRotaryHapticHandler(
+ scrollableState: ScrollableState,
+): RotaryHapticHandler {
+ val view = LocalView.current
+ return remember(scrollableState, view) {
+ PlatformRotaryHapticHandler(scrollableState, ScrollFeedbackProvider.createProvider(view))
+ }
+}
+
+@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+@VisibleForTesting
+internal class PlatformRotaryHapticHandler(
+ private val scrollableState: ScrollableState,
+ private val scrollFeedbackProvider: ScrollFeedbackProvider,
+) : RotaryHapticHandler {
+
+ override fun handleScrollHaptic(
+ timestamp: Long,
+ deltaInPixels: Float,
+ inputDeviceId: Int,
+ axis: Int
+ ) {
+ if (scrollableState.reachedTheLimit(deltaInPixels)) {
+ handleLimitHaptic(scrollableState.canScrollBackward, inputDeviceId, axis)
+ } else {
+ scrollFeedbackProvider.onScrollProgress(
+ inputDeviceId,
+ InputDevice.SOURCE_ROTARY_ENCODER,
+ axis,
+ deltaInPixels.roundToInt()
+ )
+ }
+ }
+
+ override fun handleSnapHaptic(
+ timestamp: Long,
+ deltaInPixels: Float,
+ inputDeviceId: Int,
+ axis: Int
+ ) {
+ scrollFeedbackProvider.onSnapToItem(inputDeviceId, InputDevice.SOURCE_ROTARY_ENCODER, axis)
+ }
+
+ override fun handleLimitHaptic(isStart: Boolean, inputDeviceId: Int, axis: Int) {
+ scrollFeedbackProvider.onScrollLimit(
+ inputDeviceId,
+ InputDevice.SOURCE_ROTARY_ENCODER,
+ axis,
+ isStart
+ )
+ }
+}
+
/** Rotary haptic feedback */
private class RotaryHapticFeedbackProvider(
private val view: View,
diff --git a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/rotary/RotaryScrollable.kt b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/rotary/RotaryScrollable.kt
index aabef5a..0fa7b85d 100644
--- a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/rotary/RotaryScrollable.kt
+++ b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/rotary/RotaryScrollable.kt
@@ -16,6 +16,7 @@
package androidx.wear.compose.foundation.rotary
+import android.view.MotionEvent
import android.view.ViewConfiguration
import androidx.compose.animation.core.AnimationState
import androidx.compose.animation.core.CubicBezierEasing
@@ -852,7 +853,7 @@
rotaryScrollDistance += delta
debugLog { "Rotary scroll distance: $rotaryScrollDistance" }
- rotaryHaptics.handleScrollHaptic(timestampMillis, delta)
+ rotaryHaptics.handleScrollHaptic(timestampMillis, delta, inputDeviceId, AxisScroll)
previousScrollEventTime = timestampMillis
scrollHandler.scrollToTarget(this, rotaryScrollDistance)
@@ -863,7 +864,9 @@
debugLog { "Calling beforeFling section" }
resetScrolling()
},
- edgeReached = { velocity -> rotaryHaptics.handleLimitHaptic(velocity > 0f) }
+ edgeReached = { velocity ->
+ rotaryHaptics.handleLimitHaptic(velocity > 0f, inputDeviceId, AxisScroll)
+ }
)
}
@@ -969,7 +972,7 @@
"Accumulated snap delta: $accumulatedSnapDelta"
}
if (edgeNotReached(snapDistanceInItems)) {
- rotaryHaptics.handleSnapHaptic(timestampMillis, delta)
+ rotaryHaptics.handleSnapHaptic(timestampMillis, delta, inputDeviceId, AxisScroll)
}
snapHandler.updateSnapTarget(snapDistanceInItems, sequentialSnap)
@@ -1077,7 +1080,7 @@
if (abs(accumulatedSnapDelta) > 1f) {
val snapDistanceInItems = sign(accumulatedSnapDelta).toInt()
- rotaryHaptics.handleSnapHaptic(timestampMillis, delta)
+ rotaryHaptics.handleSnapHaptic(timestampMillis, delta, inputDeviceId, AxisScroll)
val sequentialSnap = snapJob.isActive
debugLog {
"Snap threshold reached: snapDistanceInItems:$snapDistanceInItems, " +
@@ -1299,6 +1302,8 @@
HIGH(5f, 7.5f, 5f),
}
+private const val AxisScroll = MotionEvent.AXIS_SCROLL
+
/** Debug logging that can be enabled. */
private const val DEBUG = false
diff --git a/wear/compose/compose-material3/api/current.txt b/wear/compose/compose-material3/api/current.txt
index 82db331..8a2e7dc 100644
--- a/wear/compose/compose-material3/api/current.txt
+++ b/wear/compose/compose-material3/api/current.txt
@@ -495,6 +495,19 @@
method public static boolean isDynamicColorSchemeEnabled(android.content.Context context);
}
+ public final class EdgeButtonDefaults {
+ method public float getExtraSmallIconSize();
+ method public float getLargeIconSize();
+ method public float getMediumIconSize();
+ method public float getSmallIconSize();
+ method public float iconSizeFor(float edgeButtonSize);
+ property public final float ExtraSmallIconSize;
+ property public final float LargeIconSize;
+ property public final float MediumIconSize;
+ property public final float SmallIconSize;
+ field public static final androidx.wear.compose.material3.EdgeButtonDefaults INSTANCE;
+ }
+
public final class EdgeButtonKt {
method @androidx.compose.runtime.Composable public static void EdgeButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional float buttonSize, optional boolean enabled, optional androidx.wear.compose.material3.ButtonColors colors, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
}
diff --git a/wear/compose/compose-material3/api/restricted_current.txt b/wear/compose/compose-material3/api/restricted_current.txt
index 82db331..8a2e7dc 100644
--- a/wear/compose/compose-material3/api/restricted_current.txt
+++ b/wear/compose/compose-material3/api/restricted_current.txt
@@ -495,6 +495,19 @@
method public static boolean isDynamicColorSchemeEnabled(android.content.Context context);
}
+ public final class EdgeButtonDefaults {
+ method public float getExtraSmallIconSize();
+ method public float getLargeIconSize();
+ method public float getMediumIconSize();
+ method public float getSmallIconSize();
+ method public float iconSizeFor(float edgeButtonSize);
+ property public final float ExtraSmallIconSize;
+ property public final float LargeIconSize;
+ property public final float MediumIconSize;
+ property public final float SmallIconSize;
+ field public static final androidx.wear.compose.material3.EdgeButtonDefaults INSTANCE;
+ }
+
public final class EdgeButtonKt {
method @androidx.compose.runtime.Composable public static void EdgeButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional float buttonSize, optional boolean enabled, optional androidx.wear.compose.material3.ButtonColors colors, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
}
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/EdgeButtonDemo.kt b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/EdgeButtonDemo.kt
index f690adf..d5cc344 100644
--- a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/EdgeButtonDemo.kt
+++ b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/EdgeButtonDemo.kt
@@ -44,6 +44,7 @@
import androidx.wear.compose.material3.ButtonDefaults
import androidx.wear.compose.material3.Card
import androidx.wear.compose.material3.EdgeButton
+import androidx.wear.compose.material3.EdgeButtonDefaults
import androidx.wear.compose.material3.EdgeButtonSize
import androidx.wear.compose.material3.RadioButton
import androidx.wear.compose.material3.ScreenScaffold
@@ -86,7 +87,7 @@
verticalArrangement = Arrangement.spacedBy(4.dp),
contentPadding =
ScreenScaffoldDefaults.contentPaddingWithEdgeButton(
- edgeButtonSize = EdgeButtonSize.Medium,
+ edgeButtonSize = EdgeButtonSize.Large,
10.dp,
20.dp,
10.dp,
@@ -237,7 +238,7 @@
ButtonDefaults.outlinedButtonBorder(enabled = true)
else null
) {
- CheckIcon()
+ CheckIcon(modifier = Modifier.size(EdgeButtonDefaults.iconSizeFor(sizes[size])))
}
}
}
@@ -281,7 +282,12 @@
enabled = colors[selectedColor].first != "Disabled"
) {
if (selectedType == 0) {
- CheckIcon()
+ CheckIcon(
+ modifier =
+ Modifier.size(
+ EdgeButtonDefaults.iconSizeFor(sizes[selectedSize].second)
+ )
+ )
} else {
Text("Ok")
}
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/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/samples/src/main/java/androidx/wear/compose/material3/samples/EdgeButtonSample.kt b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/EdgeButtonSample.kt
index b744aa9..197cce5 100644
--- a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/EdgeButtonSample.kt
+++ b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/EdgeButtonSample.kt
@@ -31,10 +31,10 @@
import androidx.compose.ui.unit.dp
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
-import androidx.wear.compose.material3.ButtonDefaults
import androidx.wear.compose.material3.ButtonDefaults.buttonColors
import androidx.wear.compose.material3.Card
import androidx.wear.compose.material3.EdgeButton
+import androidx.wear.compose.material3.EdgeButtonDefaults
import androidx.wear.compose.material3.EdgeButtonSize
import androidx.wear.compose.material3.Icon
import androidx.wear.compose.material3.ScreenScaffold
@@ -54,7 +54,7 @@
Icon(
Icons.Filled.Check,
contentDescription = "Check icon",
- modifier = Modifier.size(ButtonDefaults.IconSize)
+ modifier = Modifier.size(EdgeButtonDefaults.MediumIconSize)
)
}
}
diff --git a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/icons/SampleIcons.kt b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/icons/SampleIcons.kt
index c7def02..636377e 100644
--- a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/icons/SampleIcons.kt
+++ b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/icons/SampleIcons.kt
@@ -44,11 +44,11 @@
}
@Composable
-fun CheckIcon() {
+fun CheckIcon(modifier: Modifier = Modifier.size(ButtonDefaults.IconSize)) {
Icon(
painter = painterResource(R.drawable.ic_check_rounded),
contentDescription = "Check",
- modifier = Modifier.size(ButtonDefaults.IconSize)
+ modifier = modifier
)
}
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/EdgeButtonScreenshotTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/EdgeButtonScreenshotTest.kt
index 9409890..5b45fb1 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/EdgeButtonScreenshotTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/EdgeButtonScreenshotTest.kt
@@ -20,6 +20,7 @@
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -68,26 +69,49 @@
}
@Test
- fun edge_button_small() =
- verifyScreenshot() { BasicEdgeButton(buttonSize = EdgeButtonSize.Small) }
+ fun edge_button_small() = verifyScreenshot {
+ BasicEdgeButton(buttonSize = EdgeButtonSize.Small)
+ }
@Test
- fun edge_button_medium() =
- verifyScreenshot() { BasicEdgeButton(buttonSize = EdgeButtonSize.Medium) }
+ fun edge_button_medium() = verifyScreenshot {
+ BasicEdgeButton(buttonSize = EdgeButtonSize.Medium)
+ }
@Test
- fun edge_button_large() =
- verifyScreenshot() { BasicEdgeButton(buttonSize = EdgeButtonSize.Large) }
+ fun edge_button_large() = verifyScreenshot {
+ BasicEdgeButton(buttonSize = EdgeButtonSize.Large)
+ }
@Test
- fun edge_button_disabled() =
- verifyScreenshot() { BasicEdgeButton(buttonSize = EdgeButtonSize.Medium, enabled = false) }
+ fun edge_button_xsmall_icon() = verifyScreenshot {
+ BasicEdgeButtonWithIcon(buttonSize = EdgeButtonSize.ExtraSmall)
+ }
@Test
- fun edge_button_small_space_very_limited() =
- verifyScreenshot() {
- BasicEdgeButton(buttonSize = EdgeButtonSize.Small, constrainedHeight = 10.dp)
- }
+ fun edge_button_small_icon() = verifyScreenshot {
+ BasicEdgeButtonWithIcon(buttonSize = EdgeButtonSize.Small)
+ }
+
+ @Test
+ fun edge_button_medium_icon() = verifyScreenshot {
+ BasicEdgeButtonWithIcon(buttonSize = EdgeButtonSize.Medium)
+ }
+
+ @Test
+ fun edge_button_large_icon() = verifyScreenshot {
+ BasicEdgeButtonWithIcon(buttonSize = EdgeButtonSize.Large)
+ }
+
+ @Test
+ fun edge_button_disabled() = verifyScreenshot {
+ BasicEdgeButton(buttonSize = EdgeButtonSize.Medium, enabled = false)
+ }
+
+ @Test
+ fun edge_button_small_space_very_limited() = verifyScreenshot {
+ BasicEdgeButton(buttonSize = EdgeButtonSize.Small, constrainedHeight = 10.dp)
+ }
@Test
fun edge_button_small_space_limited() = verifyScreenshot {
@@ -135,6 +159,23 @@
}
}
+ @Composable
+ private fun BasicEdgeButtonWithIcon(
+ buttonSize: EdgeButtonSize,
+ enabled: Boolean = true,
+ ) {
+ Box(Modifier.fillMaxSize()) {
+ EdgeButton(
+ onClick = { /* Do something */ },
+ enabled = enabled,
+ buttonSize = buttonSize,
+ modifier = Modifier.align(Alignment.BottomEnd).testTag(TEST_TAG)
+ ) {
+ TestIcon(modifier = Modifier.size(EdgeButtonDefaults.iconSizeFor(buttonSize)))
+ }
+ }
+ }
+
private fun verifyScreenshot(
layoutDirection: LayoutDirection = LayoutDirection.Ltr,
content: @Composable () -> Unit
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/Material3Test.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/Material3Test.kt
index adb74f9..ebfcca8 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/Material3Test.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/Material3Test.kt
@@ -63,8 +63,8 @@
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
-import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performSemanticsAction
+import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
@@ -418,7 +418,10 @@
}
mainClock.autoAdvance = false
- onNodeWithTag(TEST_TAG).performClick()
+ onNodeWithTag(TEST_TAG).performTouchInput {
+ down(center)
+ up()
+ }
/**
* We are manually advancing by a fixed amount of frames since
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ScrollIndicatorTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ScrollIndicatorTest.kt
index dd140ab..0aa297a 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ScrollIndicatorTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ScrollIndicatorTest.kt
@@ -159,7 +159,19 @@
@Test
fun scalingLazyColumnStateAdapter_shortContent_scrolled() {
- verifySlcScrollToCenter(expectedIndicatorSize = 0.75f, itemsCount = 4)
+ val itemsCount = 4
+
+ // By default in this test we use SLC with AutoCentering at 0th item. Last item will also be
+ // centered. Knowing that, the size of the screen and size of the item, we can say that the
+ // top and bottom paddings should be equal to itemSizeDp + itemSpacingDp.
+ val autoCenteringPadding = itemSizeDp + itemSpacingDp
+ // We can get an approximate indicator size by dividing viewPort size by the length of all
+ // items - including visible (top) auto centering padding.
+ val expectedIndicatorSize =
+ viewportSizeDp /
+ (itemSizeDp * itemsCount + itemSpacingDp * (itemsCount - 1) + autoCenteringPadding)
+
+ verifySlcScrollToCenter(expectedIndicatorSize = expectedIndicatorSize, itemsCount = 4)
}
@Test
@@ -325,9 +337,10 @@
expectedIndicatorSize = {
Truth.assertThat(it).isWithin(0.05f).of(expectedIndicatorSize)
},
- // Scrolling by half of the list height, minus original central position of the list,
- // which is 1.5th item.
- scrollByItems = itemsCount / 2f - 1.5f,
+ initialCenterItemIndex = 0,
+ // Scrolling by half of the total list height, minus original central position of the
+ // list, which is 0.5th item.
+ scrollByItems = itemsCount / 2f - 0.5f,
itemsCount = itemsCount
)
}
@@ -356,8 +369,9 @@
verticalArrangement: Arrangement.Vertical =
Arrangement.spacedBy(space = itemSpacingDp, alignment = Alignment.Bottom),
reverseLayout: Boolean = false,
- autoCentering: AutoCenteringParams? = AutoCenteringParams(),
initialCenterItemIndex: Int = 1,
+ autoCentering: AutoCenteringParams? =
+ AutoCenteringParams(itemIndex = initialCenterItemIndex),
contentPaddingDp: Dp = 0.dp,
scrollByItems: Float = 0f,
itemsCount: Int = 0,
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/AnimatedToggleRoundedCornerShape.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/AnimatedToggleRoundedCornerShape.kt
index a4a8ebc..f39a9e0 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/AnimatedToggleRoundedCornerShape.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/AnimatedToggleRoundedCornerShape.kt
@@ -18,11 +18,12 @@
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FiniteAnimationSpec
+import androidx.compose.foundation.interaction.InteractionSource
+import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
-import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.geometry.RoundRect
@@ -73,51 +74,45 @@
*/
@Composable
internal fun rememberAnimatedToggleRoundedCornerShape(
+ interactionSource: InteractionSource,
uncheckedCornerSize: CornerSize,
checkedCornerSize: CornerSize,
pressedCornerSize: CornerSize,
- pressed: Boolean,
checked: Boolean,
onPressAnimationSpec: FiniteAnimationSpec<Float>,
onReleaseAnimationSpec: FiniteAnimationSpec<Float>,
): Shape {
- val toggleState =
- when {
- pressed -> ToggleState.Pressed
- checked -> ToggleState.Checked
- else -> ToggleState.Unchecked
- }
-
- val previous = remember { mutableStateOf(toggleState) }
val scope = rememberCoroutineScope()
- val progress = remember { Animatable(1f) }
-
- val toggledCornerSize =
- toggleState.cornerSize(uncheckedCornerSize, checkedCornerSize, pressedCornerSize)
- val animationSpec = if (pressed) onPressAnimationSpec else onReleaseAnimationSpec
+ val progress = remember { Animatable(0f) }
val animatedShape = remember {
AnimatedToggleRoundedCornerShape(
- startCornerSize = toggledCornerSize,
- endCornerSize = toggledCornerSize,
+ startCornerSize = if (checked) checkedCornerSize else uncheckedCornerSize,
+ endCornerSize = pressedCornerSize,
progress = { progress.value },
)
}
- LaunchedEffect(toggleState) {
- // Allow the press up animation to finish its minimum duration before starting the next
- if (!pressed) {
- waitUntil { !progress.isRunning || progress.value > MIN_REQUIRED_ANIMATION_PROGRESS }
- }
+ LaunchedEffect(interactionSource) {
+ interactionSource.interactions.collect { interaction ->
+ when (interaction) {
+ is PressInteraction.Press -> {
+ scope.launch { progress.animateTo(1f, animationSpec = onPressAnimationSpec) }
+ }
+ is PressInteraction.Cancel,
+ is PressInteraction.Release -> {
+ waitUntil {
+ !progress.isRunning || progress.value > MIN_REQUIRED_ANIMATION_PROGRESS
+ }
- if (toggleState != previous.value) {
- animatedShape.startCornerSize = animatedShape.endCornerSize
- animatedShape.endCornerSize = toggledCornerSize
- previous.value = toggleState
+ // The end of the animation was the pressed shape. Reverse the animation back
+ // to zero and set the start depending on the button's pressed state.
+ animatedShape.startCornerSize =
+ if (animatedShape.startCornerSize == uncheckedCornerSize) checkedCornerSize
+ else uncheckedCornerSize
- scope.launch {
- progress.snapTo(1f - progress.value)
- progress.animateTo(1f, animationSpec = animationSpec)
+ scope.launch { progress.animateTo(0f, animationSpec = onReleaseAnimationSpec) }
+ }
}
}
}
@@ -125,21 +120,4 @@
return animatedShape
}
-private fun ToggleState.cornerSize(
- uncheckedCornerSize: CornerSize,
- checkedCornerSize: CornerSize,
- pressedCornerSize: CornerSize,
-) =
- when (this) {
- ToggleState.Unchecked -> uncheckedCornerSize
- ToggleState.Checked -> checkedCornerSize
- ToggleState.Pressed -> pressedCornerSize
- }
-
-internal enum class ToggleState {
- Unchecked,
- Checked,
- Pressed,
-}
-
private const val MIN_REQUIRED_ANIMATION_PROGRESS = 0.75f
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/EdgeButton.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/EdgeButton.kt
index 2644b08..67c9de7 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/EdgeButton.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/EdgeButton.kt
@@ -247,7 +247,7 @@
interactionSource = interactionSource,
)
.sizeAndOffset { containerShapeHelper.contentWindow }
- .scaleAndAlignContent()
+ .scaleAndAlignContent(buttonSize)
// Limit the content size to the expected width for the button size.
.requiredSizeIn(
maxWidth = contentShapeHelper.contentWidthDp(),
@@ -276,6 +276,15 @@
value class EdgeButtonSize internal constructor(internal val maximumHeight: Dp) {
internal fun maximumHeightPlusPadding() = maximumHeight + VERTICAL_PADDING * 2
+ internal fun verticalPadding() =
+ when (this) {
+ ExtraSmall -> Pair(10.dp, 12.dp)
+ Small -> Pair(8.dp, 12.dp)
+ Medium -> Pair(14.dp, 20.dp)
+ Large -> Pair(18.dp, 22.dp)
+ else -> Pair(14.dp, 20.dp)
+ }
+
companion object {
/** The Size to be applied for an extra small [EdgeButton]. */
val ExtraSmall = EdgeButtonSize(46.dp)
@@ -291,6 +300,35 @@
}
}
+/** Contains the default values used by [EdgeButton]. */
+object EdgeButtonDefaults {
+ /** The recommended icon size when used with [EdgeButtonSize.ExtraSmall]. */
+ val ExtraSmallIconSize = 24.dp
+
+ /** The recommended icon size when used with [EdgeButtonSize.Small]. */
+ val SmallIconSize = 32.dp
+
+ /** The recommended icon size when used with [EdgeButtonSize.Medium]. */
+ val MediumIconSize = 32.dp
+
+ /** The recommended icon size when used with [EdgeButtonSize.Large]. */
+ val LargeIconSize = 36.dp
+
+ /**
+ * Recommended icon size for a given edge button size.
+ *
+ * @param edgeButtonSize The size of the edge button
+ */
+ fun iconSizeFor(edgeButtonSize: EdgeButtonSize): Dp =
+ when (edgeButtonSize) {
+ EdgeButtonSize.ExtraSmall -> ExtraSmallIconSize
+ EdgeButtonSize.Small -> SmallIconSize
+ EdgeButtonSize.Medium -> MediumIconSize
+ EdgeButtonSize.Large -> LargeIconSize
+ else -> MediumIconSize
+ }
+}
+
private fun Modifier.sizeAndOffset(rectFn: (Constraints) -> Rect) =
layout { measurable, constraints ->
val rect = rectFn(constraints)
@@ -424,44 +462,55 @@
// Scales the content if it doesn't fit horizontally, horizontally center if there is room.
// Vertically centers the content if there is room, otherwise aligns to the top.
-private fun Modifier.scaleAndAlignContent() = this.then(ScaleAndAlignContentElement())
+private fun Modifier.scaleAndAlignContent(buttonSize: EdgeButtonSize) =
+ this.then(ScaleAndAlignContentElement(buttonSize))
-private class ScaleAndAlignContentElement : ModifierNodeElement<ScaleAndAlignContentNode>() {
- override fun create(): ScaleAndAlignContentNode = ScaleAndAlignContentNode()
+private class ScaleAndAlignContentElement(val buttonSize: EdgeButtonSize) :
+ ModifierNodeElement<ScaleAndAlignContentNode>() {
+ override fun create(): ScaleAndAlignContentNode = ScaleAndAlignContentNode(buttonSize)
- override fun update(node: ScaleAndAlignContentNode) {}
+ override fun update(node: ScaleAndAlignContentNode) {
+ node.buttonSize = buttonSize
+ }
override fun InspectorInfo.inspectableProperties() {
name = "ScaleAndAlignContentElement"
+ properties["buttonSize"] = buttonSize
}
// All instances are equivalent
- override fun equals(other: Any?) = other is ScaleAndAlignContentElement
+ override fun equals(other: Any?) =
+ other is ScaleAndAlignContentElement && buttonSize == other.buttonSize
- override fun hashCode() = 42
+ override fun hashCode() = buttonSize.hashCode()
}
-private class ScaleAndAlignContentNode : LayoutModifierNode, Modifier.Node() {
+private class ScaleAndAlignContentNode(var buttonSize: EdgeButtonSize) :
+ LayoutModifierNode, Modifier.Node() {
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints
): MeasureResult {
val placeable = measurable.measure(Constraints())
- val topPadding = INTERNAL_TOP_PADDING.roundToPx()
-
val wrapperWidth = placeable.width.coerceIn(constraints.minWidth, constraints.maxWidth)
val wrapperHeight = placeable.height.coerceIn(constraints.minHeight, constraints.maxHeight)
val scale = (wrapperWidth.toFloat() / placeable.width.coerceAtLeast(1)).coerceAtMost(1f)
+ val verticalPadding = buttonSize.verticalPadding()
+ val topPadding = verticalPadding.top().roundToPx()
+ val bottomPadding = verticalPadding.bottom().roundToPx()
+
return layout(wrapperWidth, wrapperHeight) {
- // If there is enough vertical space, we align like 'Center', otherwise like 'TopCenter'
+ // If there is enough vertical space, we align like 'Center', with a slight vertical
+ // offset. Otherwise like 'TopCenter'
val position =
IntOffset(
x = (wrapperWidth - placeable.width) / 2, // Always center horizontally
y =
- ((wrapperHeight - placeable.height * scale) / 2)
+ ((wrapperHeight - placeable.height * scale + topPadding - bottomPadding) /
+ 2)
.roundToInt()
.coerceAtLeast(topPadding)
)
@@ -475,6 +524,11 @@
}
}
+// Syntactic sugar for Pair<Dp, Dp> when used to extra values for top and bottom vertical padding.
+private fun Pair<Dp, Dp>.top() = first
+
+private fun Pair<Dp, Dp>.bottom() = second
+
// Sizes at which the container will start and end fading away.
private val CONTAINER_FADE_START_DP = 30.dp
private val CONTAINER_FADE_END_DP = 4.dp
@@ -496,6 +550,3 @@
// Padding around the Edge Button on it's top and bottom.
private val VERTICAL_PADDING = 3.dp
-
-// Padding between the top edge of the button and the content.
-private val INTERNAL_TOP_PADDING = 6.dp
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/LevelIndicator.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/LevelIndicator.kt
index 75ab770..4f6163c 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/LevelIndicator.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/LevelIndicator.kt
@@ -59,8 +59,7 @@
val paddingHorizontal = LevelIndicatorDefaults.edgePadding
val radius = screenWidthDp / 2 - paddingHorizontal.value - strokeWidth.value / 2
// Calculate indicator height based on a triangle of the top half of the sweep angle
- // and subtract the end caps
- val indicatorHeight = 2f * sin((0.5f * sweepAngle).toRadians()) * radius - strokeWidth.value
+ val indicatorHeight = 2f * sin((0.5f * sweepAngle).toRadians()) * radius
IndicatorImpl(
state =
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/RoundButton.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/RoundButton.kt
index 890f102..cafd4be 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/RoundButton.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/RoundButton.kt
@@ -26,7 +26,7 @@
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.foundation.interaction.MutableInteractionSource
-import androidx.compose.foundation.interaction.collectIsPressedAsState
+import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.shape.CornerBasedShape
@@ -113,18 +113,21 @@
onPressAnimationSpec: FiniteAnimationSpec<Float>,
onReleaseAnimationSpec: FiniteAnimationSpec<Float>,
): Shape {
- val pressed = interactionSource.collectIsPressedAsState()
val progress = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
- LaunchedEffect(pressed.value) {
- when (pressed.value) {
- true -> scope.launch { progress.animateTo(1f, animationSpec = onPressAnimationSpec) }
- false -> {
- waitUntil {
- !progress.isRunning || progress.value > MIN_REQUIRED_ANIMATION_PROGRESS
+ LaunchedEffect(interactionSource) {
+ interactionSource.interactions.collect { interaction ->
+ when (interaction) {
+ is PressInteraction.Press ->
+ scope.launch { progress.animateTo(1f, animationSpec = onPressAnimationSpec) }
+ is PressInteraction.Cancel,
+ is PressInteraction.Release -> {
+ waitUntil {
+ !progress.isRunning || progress.value > MIN_REQUIRED_ANIMATION_PROGRESS
+ }
+ scope.launch { progress.animateTo(0f, animationSpec = onReleaseAnimationSpec) }
}
- scope.launch { progress.animateTo(0f, animationSpec = onReleaseAnimationSpec) }
}
}
}
@@ -200,14 +203,12 @@
val finalInteractionSource = interactionSource ?: remember { MutableInteractionSource() }
- val pressed = finalInteractionSource.collectIsPressedAsState()
-
val finalShape =
rememberAnimatedToggleRoundedCornerShape(
+ interactionSource = finalInteractionSource,
uncheckedCornerSize = uncheckedShape.topEnd,
checkedCornerSize = checkedShape.topEnd,
pressedCornerSize = pressedShape.topEnd,
- pressed = pressed.value,
checked = checked,
onPressAnimationSpec = onPressAnimationSpec,
onReleaseAnimationSpec = onReleaseAnimationSpec,
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/ScrollIndicator.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/ScrollIndicator.kt
index 2fed7f5..1047dbc 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/ScrollIndicator.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/ScrollIndicator.kt
@@ -597,7 +597,10 @@
val isLastItem = lastVisibleItem.index == layoutInfo.totalItemsCount - 1
// If our visible item is last in the list, we add afterContentPadding to its size
val lastVisibleItemSize =
- lastVisibleItem.size + (if (isLastItem) layoutInfo.afterContentPadding else 0)
+ lastVisibleItem.size +
+ (if (isLastItem)
+ layoutInfo.afterContentPadding + layoutInfo.afterAutoCenteringPadding
+ else 0)
// This is the offset of the last item w.r.t. the ScalingLazyColumn coordinate system where
// 0 in the center of the visible viewport and +/-(state.viewportHeightPx / 2f) are the
// start and end of the viewport.
@@ -633,7 +636,9 @@
val isFirstItem = firstVisibleItem.index == 0
// If our visible item is first in the list, we set beforeFirstItemPadding to the padding
// before the first item. Then we add it to the size of the first item in our calculations.
- val beforeFirstItemPadding = (if (isFirstItem) layoutInfo.beforeContentPadding else 0)
+ val beforeFirstItemPadding =
+ if (isFirstItem) layoutInfo.beforeContentPadding + layoutInfo.beforeAutoCenteringPadding
+ else 0
val firstItemStartOffset =
firstVisibleItem.startOffset(layoutInfo.anchorType) - beforeFirstItemPadding
diff --git a/wear/compose/compose-navigation/build.gradle b/wear/compose/compose-navigation/build.gradle
index 9f4b0a2..8273ef7 100644
--- a/wear/compose/compose-navigation/build.gradle
+++ b/wear/compose/compose-navigation/build.gradle
@@ -31,11 +31,12 @@
}
dependencies {
+ api("androidx.compose.animation:animation:1.7.4")
api("androidx.compose.ui:ui:1.7.4")
api("androidx.compose.runtime:runtime:1.7.4")
api("androidx.navigation:navigation-runtime:2.6.0")
api(project(":wear:compose:compose-material"))
- api("androidx.activity:activity-compose:1.7.0")
+ api("androidx.activity:activity-compose:1.9.0")
api("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
implementation("androidx.navigation:navigation-common:2.6.0")
diff --git a/wear/compose/compose-navigation/src/androidTest/kotlin/androidx/wear/compose/navigation/SwipeDismissableNavHostSampleTest.kt b/wear/compose/compose-navigation/src/androidTest/kotlin/androidx/wear/compose/navigation/SwipeDismissableNavHostSampleTest.kt
index 78e5dd1..eeed354 100644
--- a/wear/compose/compose-navigation/src/androidTest/kotlin/androidx/wear/compose/navigation/SwipeDismissableNavHostSampleTest.kt
+++ b/wear/compose/compose-navigation/src/androidTest/kotlin/androidx/wear/compose/navigation/SwipeDismissableNavHostSampleTest.kt
@@ -15,12 +15,14 @@
*/
package androidx.wear.compose.navigation
+import android.os.Build
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeRight
+import androidx.test.filters.SdkSuppress
import androidx.wear.compose.navigation.samples.NavHostWithNamedArgument
import androidx.wear.compose.navigation.samples.SimpleNavHost
import org.junit.Rule
@@ -31,7 +33,7 @@
@Test
fun toggles_between_destinations_in_simplenavhost() {
- rule.setContentWithTheme { SimpleNavHost() }
+ rule.setContent { SimpleNavHost() }
rule.onNodeWithText("On").performClick()
rule.onNodeWithText("Off").performClick()
@@ -41,7 +43,7 @@
@Test
fun navigates_to_named_arguments() {
- rule.setContentWithTheme { NavHostWithNamedArgument() }
+ rule.setContent { NavHostWithNamedArgument() }
rule.onNodeWithText("Item 1").performClick()
@@ -49,8 +51,10 @@
}
@Test
+ @SdkSuppress(maxSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
fun swipes_back_from_named_arguments() {
- rule.setContentWithTheme { NavHostWithNamedArgument() }
+
+ rule.setContent { NavHostWithNamedArgument() }
rule.onNodeWithText("Item 1").performClick()
rule.onRoot().performTouchInput { swipeRight() }
diff --git a/wear/compose/compose-navigation/src/androidTest/kotlin/androidx/wear/compose/navigation/SwipeDismissableNavHostTest.kt b/wear/compose/compose-navigation/src/androidTest/kotlin/androidx/wear/compose/navigation/SwipeDismissableNavHostTest.kt
index 08f6c25..7bed2df 100644
--- a/wear/compose/compose-navigation/src/androidTest/kotlin/androidx/wear/compose/navigation/SwipeDismissableNavHostTest.kt
+++ b/wear/compose/compose-navigation/src/androidTest/kotlin/androidx/wear/compose/navigation/SwipeDismissableNavHostTest.kt
@@ -15,6 +15,9 @@
*/
package androidx.wear.compose.navigation
+import android.os.Build
+import android.window.BackEvent
+import androidx.activity.BackEventCompat
import androidx.activity.OnBackPressedDispatcher
import androidx.activity.OnBackPressedDispatcherOwner
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
@@ -52,9 +55,9 @@
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavHostController
import androidx.navigation.testing.TestNavHostController
+import androidx.test.filters.SdkSuppress
import androidx.wear.compose.material.Button
import androidx.wear.compose.material.CompactChip
-import androidx.wear.compose.material.MaterialTheme
import androidx.wear.compose.material.Text
import androidx.wear.compose.material.ToggleButton
import com.google.common.truth.Truth.assertThat
@@ -64,16 +67,18 @@
class SwipeDismissableNavHostTest {
@get:Rule val rule = createComposeRule()
+ private lateinit var backPressedDispatcher: OnBackPressedDispatcher
+
@Test
fun supports_testtag() {
- rule.setContentWithTheme { SwipeDismissWithNavigation() }
+ rule.setContentWithBackPressedDispatcher { SwipeDismissWithNavigation() }
rule.onNodeWithTag(TEST_TAG).assertExists()
}
@Test
fun navigates_to_next_level() {
- rule.setContentWithTheme { SwipeDismissWithNavigation() }
+ rule.setContentWithBackPressedDispatcher { SwipeDismissWithNavigation() }
// Click to move to next destination.
rule.onNodeWithText(START).performClick()
@@ -83,8 +88,10 @@
}
@Test
+ @SdkSuppress(maxSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
fun navigates_back_to_previous_level_after_swipe() {
- rule.setContentWithTheme { SwipeDismissWithNavigation() }
+
+ rule.setContentWithBackPressedDispatcher { SwipeDismissWithNavigation() }
// Click to move to next destination then swipe to dismiss.
rule.onNodeWithText(START).performClick()
@@ -95,8 +102,11 @@
}
@Test
+ @SdkSuppress(maxSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
fun does_not_navigate_back_to_previous_level_when_swipe_disabled() {
- rule.setContentWithTheme { SwipeDismissWithNavigation(userSwipeEnabled = false) }
+ rule.setContentWithBackPressedDispatcher {
+ SwipeDismissWithNavigation(userSwipeEnabled = false)
+ }
// Click to move to next destination then swipe to dismiss.
rule.onNodeWithText(START).performClick()
@@ -109,24 +119,17 @@
@Test
fun navigates_back_to_previous_level_with_back_button() {
- val onBackPressedDispatcher = OnBackPressedDispatcher()
- val dispatcherOwner =
- object : OnBackPressedDispatcherOwner, LifecycleOwner by TestLifecycleOwner() {
- override val onBackPressedDispatcher = onBackPressedDispatcher
- }
lateinit var navController: NavHostController
- rule.setContentWithTheme {
- CompositionLocalProvider(LocalOnBackPressedDispatcherOwner provides dispatcherOwner) {
- navController = rememberSwipeDismissableNavController()
- SwipeDismissWithNavigation(navController)
- }
+ rule.setContentWithBackPressedDispatcher {
+ navController = rememberSwipeDismissableNavController()
+ SwipeDismissWithNavigation(navController)
}
// Move to next destination.
rule.onNodeWithText(START).performClick()
// Now trigger the back button
- rule.runOnIdle { onBackPressedDispatcher.onBackPressed() }
+ rule.runOnIdle { backPressedDispatcher.onBackPressed() }
rule.waitForIdle()
// Should now display "start".
@@ -136,7 +139,7 @@
@Test
fun hides_previous_level_when_not_swiping() {
- rule.setContentWithTheme { SwipeDismissWithNavigation() }
+ rule.setContentWithBackPressedDispatcher { SwipeDismissWithNavigation() }
// Click to move to next destination then swipe to dismiss.
rule.onNodeWithText(START).performClick()
@@ -147,12 +150,15 @@
@ExperimentalTestApi
@Test
+ @SdkSuppress(maxSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
fun displays_previous_screen_during_swipe_gesture() {
- rule.setContentWithTheme { WithTouchSlop(0f) { SwipeDismissWithNavigation() } }
+ rule.setContentWithBackPressedDispatcher {
+ WithTouchSlop(0f) { SwipeDismissWithNavigation() }
+ }
// Click to move to next destination.
rule.onNodeWithText(START).performClick()
- // Click and drag to being a swipe gesture, but do not release the finger.
+ // Click and drag to begin a swipe gesture, but do not release the finger.
rule
.onNodeWithTag(TEST_TAG)
.performTouchInput({
@@ -165,9 +171,46 @@
}
@Test
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
+ fun does_not_navigate_back_to_previous_level_after_swipe_api_35() {
+
+ rule.setContentWithBackPressedDispatcher { SwipeDismissWithNavigation() }
+
+ // Click to move to next destination then swipe to dismiss.
+ rule.onNodeWithText(START).performClick()
+ rule.onNodeWithTag(TEST_TAG).performTouchInput { swipeRight() }
+
+ // Should now display "start".
+ rule.onNodeWithText(START).assertDoesNotExist()
+ }
+
+ @ExperimentalTestApi
+ @Test
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
+ fun displays_previous_screen_during_predictive_back_api_35() {
+
+ rule.setContentWithBackPressedDispatcher { SwipeDismissWithNavigation() }
+
+ // Click to move to next destination.
+ rule.onNodeWithText(START).performClick()
+ // Click and drag to begin a back event gesture, but do not release the finger.
+ rule.runOnIdle {
+ backPressedDispatcher.dispatchOnBackStarted(
+ BackEventCompat(0.1F, 0.1F, 0.1F, BackEvent.EDGE_LEFT)
+ )
+ backPressedDispatcher.dispatchOnBackProgressed(
+ BackEventCompat(0.1F, 0.1F, 0.5F, BackEvent.EDGE_LEFT)
+ )
+ }
+
+ // As the finger is still 'down', the background should be visible.
+ rule.onNodeWithText(START).assertExists()
+ }
+
+ @Test
fun destinations_keep_saved_state() {
val screenId = mutableStateOf(START)
- rule.setContentWithTheme {
+ rule.setContentWithBackPressedDispatcher {
val holder = rememberSaveableStateHolder()
holder.SaveableStateProvider(screenId) {
val navController = rememberSwipeDismissableNavController()
@@ -208,16 +251,14 @@
rule.onNodeWithText("Off").performClick()
rule.onNodeWithText("Go").performClick()
- rule.waitForIdle()
- rule.onNodeWithTag(TEST_TAG).performTouchInput({ swipeRight() })
- rule.waitForIdle()
+ goBack()
rule.onNodeWithText("On").assertExists()
}
@Test
fun remembers_saved_state_on_two_screens() {
val screenId = mutableStateOf(START)
- rule.setContentWithTheme {
+ rule.setContentWithBackPressedDispatcher {
val holder = rememberSaveableStateHolder()
val navController = rememberSwipeDismissableNavController()
SwipeDismissableNavHost(
@@ -283,10 +324,10 @@
rule.onNodeWithText("Jump").performClick()
rule.waitForIdle()
rule.onNodeWithText("Off").assertExists()
- rule.onNodeWithTag(TEST_TAG).performTouchInput({ swipeRight() })
+ goBack()
// Next screen should still display the incremented counter.
rule.onNodeWithText("1").assertExists()
- rule.onNodeWithTag(TEST_TAG).performTouchInput({ swipeRight() })
+ goBack()
// Start screen should still display 'On'
rule.waitForIdle()
rule.onNodeWithText("On").assertExists()
@@ -300,7 +341,7 @@
fun updates_lifecycle_for_initial_destination() {
lateinit var navController: NavHostController
rule.mainClock.autoAdvance = false
- rule.setContentWithTheme {
+ rule.setContentWithBackPressedDispatcher {
navController = rememberSwipeDismissableNavController()
SwipeDismissWithNavigation(navController)
}
@@ -315,7 +356,7 @@
@Test
fun updates_lifecycle_after_navigation() {
lateinit var navController: NavHostController
- rule.setContentWithTheme {
+ rule.setContentWithBackPressedDispatcher {
navController = rememberSwipeDismissableNavController()
SwipeDismissWithNavigation(navController)
}
@@ -332,7 +373,7 @@
@Test
fun updates_lifecycle_after_navigation_and_swipe_back() {
lateinit var navController: NavHostController
- rule.setContentWithTheme {
+ rule.setContentWithBackPressedDispatcher {
navController = rememberSwipeDismissableNavController()
SwipeDismissWithNavigation(navController)
}
@@ -350,7 +391,7 @@
@Test
fun updates_lifecycle_after_popping_back_stack() {
lateinit var navController: NavHostController
- rule.setContentWithTheme {
+ rule.setContentWithBackPressedDispatcher {
navController = rememberSwipeDismissableNavController()
SwipeDismissWithNavigation(navController)
}
@@ -370,7 +411,7 @@
fun provides_access_to_current_backstack_entry_state() {
lateinit var navController: NavHostController
lateinit var backStackEntry: State<NavBackStackEntry?>
- rule.setContentWithTheme {
+ rule.setContentWithBackPressedDispatcher {
navController = rememberSwipeDismissableNavController()
backStackEntry = navController.currentBackStackEntryAsState()
SwipeDismissWithNavigation(navController)
@@ -385,7 +426,7 @@
fun testNavHostController_starts_at_default_destination() {
lateinit var navController: TestNavHostController
- rule.setContentWithTheme {
+ rule.setContentWithBackPressedDispatcher {
navController = TestNavHostController(LocalContext.current)
navController.navigatorProvider.addNavigator(WearNavigator())
@@ -399,7 +440,7 @@
fun testNavHostController_sets_current_destination() {
lateinit var navController: TestNavHostController
- rule.setContentWithTheme {
+ rule.setContentWithBackPressedDispatcher {
navController = TestNavHostController(LocalContext.current)
navController.navigatorProvider.addNavigator(WearNavigator())
@@ -436,10 +477,32 @@
}
}
}
-}
-fun ComposeContentTestRule.setContentWithTheme(composable: @Composable () -> Unit) {
- setContent { MaterialTheme { composable() } }
+ /**
+ * Depending on API level, either swipes right on the view with TEST_TAG, or presses back button
+ */
+ private fun goBack() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
+ rule.runOnIdle { backPressedDispatcher.onBackPressed() }
+ } else {
+ rule.onNodeWithTag(TEST_TAG).performTouchInput { swipeRight() }
+ }
+ }
+
+ private fun ComposeContentTestRule.setContentWithBackPressedDispatcher(
+ composable: @Composable () -> Unit
+ ) {
+ backPressedDispatcher = OnBackPressedDispatcher()
+ val dispatcherOwner =
+ object : OnBackPressedDispatcherOwner, LifecycleOwner by TestLifecycleOwner() {
+ override val onBackPressedDispatcher = backPressedDispatcher
+ }
+ setContent {
+ CompositionLocalProvider(LocalOnBackPressedDispatcherOwner provides dispatcherOwner) {
+ composable()
+ }
+ }
+ }
}
private const val NEXT = "next"
diff --git a/wear/compose/compose-navigation/src/main/java/androidx/wear/compose/navigation/BasicSwipeToDismissBoxNavHost.kt b/wear/compose/compose-navigation/src/main/java/androidx/wear/compose/navigation/BasicSwipeToDismissBoxNavHost.kt
new file mode 100644
index 0000000..f2c3ff6
--- /dev/null
+++ b/wear/compose/compose-navigation/src/main/java/androidx/wear/compose/navigation/BasicSwipeToDismissBoxNavHost.kt
@@ -0,0 +1,274 @@
+/*
+ * 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.navigation
+
+import android.util.Log
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.AnimationVector1D
+import androidx.compose.animation.core.CubicBezierEasing
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.SaveableStateHolder
+import androidx.compose.runtime.saveable.rememberSaveableStateHolder
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.util.lerp
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
+import androidx.navigation.NavBackStackEntry
+import androidx.navigation.NavDestination
+import androidx.navigation.NavGraph
+import androidx.navigation.NavHostController
+import androidx.navigation.Navigator
+import androidx.navigation.compose.LocalOwnersProvider
+import androidx.navigation.get
+import androidx.wear.compose.foundation.BasicSwipeToDismissBox
+import androidx.wear.compose.foundation.LocalReduceMotion
+import androidx.wear.compose.foundation.SwipeToDismissKeys
+
+@Composable
+internal fun BasicSwipeToDismissBoxNavHost(
+ navController: NavHostController,
+ graph: NavGraph,
+ modifier: Modifier = Modifier,
+ userSwipeEnabled: Boolean = true,
+ state: SwipeDismissableNavHostState = rememberSwipeDismissableNavHostState(),
+) {
+ val lifecycleOwner = LocalLifecycleOwner.current
+ val viewModelStoreOwner =
+ checkNotNull(LocalViewModelStoreOwner.current) {
+ "SwipeDismissableNavHost requires a ViewModelStoreOwner to be provided " +
+ "via LocalViewModelStoreOwner"
+ }
+
+ navController.setViewModelStore(viewModelStoreOwner.viewModelStore)
+
+ // Then set the graph
+ navController.graph = graph
+
+ // Find the WearNavigator, returning early if it isn't found
+ // (such as is the case when using TestNavHostController).
+ val wearNavigator =
+ navController.navigatorProvider.get<Navigator<out NavDestination>>(WearNavigator.NAME)
+ as? WearNavigator ?: return
+
+ val backStack by wearNavigator.backStack.collectAsState()
+
+ val navigateBack: () -> Unit = { navController.popBackStack() }
+
+ val transitionsInProgress by wearNavigator.transitionsInProgress.collectAsState()
+
+ DisposableEffect(lifecycleOwner) {
+ // Setup the navController with proper owners
+ navController.setLifecycleOwner(lifecycleOwner)
+ onDispose {}
+ }
+
+ val stateHolder = rememberSaveableStateHolder()
+
+ val previous = if (backStack.size <= 1) null else backStack[backStack.lastIndex - 1]
+ // Get the current navigation backstack entry. If the backstack is empty, it could be because
+ // no WearNavigator.Destinations were added to the navigation backstack (be sure to build
+ // the NavGraph using androidx.wear.compose.navigation.composable) or because the last entry
+ // was popped prior to navigating (instead, use navigate with popUpTo).
+ // If the activity is using FLAG_ACTIVITY_NEW_TASK then it also needs to set
+ // FLAG_ACTIVITY_CLEAR_TASK, otherwise the activity will be created twice,
+ // the first of these with an empty backstack.
+ val current = backStack.lastOrNull()
+
+ if (current == null) {
+ val warningText =
+ "Current backstack entry is empty. Please ensure: \n" +
+ "1. The current WearNavigator navigation backstack is not empty (e.g. by using " +
+ "androidx.wear.compose.navigation.composable to build your nav graph). \n" +
+ "2. The last entry is not popped prior to navigation " +
+ "(instead, use navigate with popUpTo). \n" +
+ "3. If the activity uses FLAG_ACTIVITY_NEW_TASK you should also set " +
+ "FLAG_ACTIVITY_CLEAR_TASK to maintain the backstack consistency."
+
+ Log.w(TAG, warningText)
+ // There's nothing to draw here, so we can return early to make sure "current" is always
+ // available below this line
+ return
+ }
+
+ val isRoundDevice = isRoundDevice()
+ val reduceMotionEnabled = LocalReduceMotion.current.enabled()
+
+ var initialContent by remember { mutableStateOf(true) }
+ val swipeState = state.swipeToDismissBoxState
+
+ LaunchedEffect(swipeState.isAnimationRunning) {
+ // This effect marks the transitions completed when swipe animations finish,
+ // so that the navigation backstack entries can go to Lifecycle.State.RESUMED.
+ if (!swipeState.isAnimationRunning) {
+ transitionsInProgress.forEach { entry -> wearNavigator.onTransitionComplete(entry) }
+ }
+ }
+
+ val animationProgress =
+ remember(current) {
+ if (!wearNavigator.isPop.value) {
+ Animatable(0f)
+ } else {
+ Animatable(1f)
+ }
+ }
+
+ LaunchedEffect(animationProgress.isRunning) {
+ if (!animationProgress.isRunning) {
+ transitionsInProgress.forEach { entry -> wearNavigator.onTransitionComplete(entry) }
+ }
+ }
+
+ BackHandler(enabled = backStack.size > 1, onBack = navigateBack)
+
+ BasicSwipeToDismissBox(
+ onDismissed = navigateBack,
+ state = swipeState,
+ modifier = Modifier,
+ userSwipeEnabled = userSwipeEnabled && previous != null,
+ backgroundKey = previous?.id ?: SwipeToDismissKeys.Background,
+ contentKey = current.id,
+ ) { isBackground ->
+ BoxedStackEntryContent(
+ entry = if (isBackground) previous else current,
+ saveableStateHolder = stateHolder,
+ modifier =
+ if (isBackground) {
+ modifier
+ } else {
+ modifier.graphicsLayer {
+ val scaleProgression =
+ NAV_HOST_ENTER_TRANSITION_EASING_STANDARD.transform(
+ (animationProgress.value / 0.75f)
+ )
+ val alphaProgression =
+ NAV_HOST_ENTER_TRANSITION_EASING_STANDARD.transform(
+ (animationProgress.value / 0.25f)
+ )
+ val scale = lerp(0.75f, 1f, scaleProgression).coerceAtMost(1f)
+ val alpha = lerp(0.1f, 1f, alphaProgression).coerceIn(0f, 1f)
+ scaleX = scale
+ scaleY = scale
+ this.alpha = alpha
+ clip = true
+ shape = if (isRoundDevice) CircleShape else RectangleShape
+ }
+ },
+ layerColor =
+ if (isBackground || wearNavigator.isPop.value) {
+ Color.Unspecified
+ } else FLASH_COLOR,
+ animatable = animationProgress
+ )
+ }
+
+ LaunchedEffect(current) {
+ if (!wearNavigator.isPop.value) {
+ if (reduceMotionEnabled) {
+ animationProgress.snapTo(1f)
+ } else {
+ animationProgress.animateTo(
+ targetValue = 1f,
+ animationSpec =
+ tween(
+ durationMillis =
+ NAV_HOST_ENTER_TRANSITION_DURATION_MEDIUM +
+ NAV_HOST_ENTER_TRANSITION_DURATION_SHORT,
+ easing = LinearEasing
+ )
+ )
+ }
+ }
+ }
+
+ DisposableEffect(previous, current) {
+ if (initialContent) {
+ // There are no animations for showing the initial content, so mark transitions
+ // complete, allowing the navigation backstack entry to go to
+ // Lifecycle.State.RESUMED.
+ transitionsInProgress.forEach { entry -> wearNavigator.onTransitionComplete(entry) }
+ initialContent = false
+ }
+ onDispose {
+ transitionsInProgress.forEach { entry -> wearNavigator.onTransitionComplete(entry) }
+ }
+ }
+}
+
+@Composable
+private fun BoxedStackEntryContent(
+ entry: NavBackStackEntry?,
+ saveableStateHolder: SaveableStateHolder,
+ modifier: Modifier = Modifier,
+ layerColor: Color,
+ animatable: Animatable<Float, AnimationVector1D>
+) {
+ if (entry != null) {
+ val isRoundDevice = isRoundDevice()
+ var lifecycleState by remember { mutableStateOf(entry.lifecycle.currentState) }
+ DisposableEffect(entry.lifecycle) {
+ val observer = LifecycleEventObserver { _, event -> lifecycleState = event.targetState }
+ entry.lifecycle.addObserver(observer)
+ onDispose { entry.lifecycle.removeObserver(observer) }
+ }
+ if (lifecycleState.isAtLeast(Lifecycle.State.CREATED)) {
+ Box(modifier, propagateMinConstraints = true) {
+ val destination = entry.destination as WearNavigator.Destination
+ entry.LocalOwnersProvider(saveableStateHolder) { destination.content(entry) }
+ // Adding a flash effect when a new screen opens
+ if (layerColor != Color.Unspecified) {
+ Canvas(Modifier.fillMaxSize()) {
+ val absoluteProgression =
+ ((animatable.value - 0.25f) / 0.75f).coerceIn(0f, 1f)
+ val easedProgression =
+ NAV_HOST_ENTER_TRANSITION_EASING_STANDARD.transform(absoluteProgression)
+ val alpha = lerp(0.07f, 0f, easedProgression).coerceIn(0f, 1f)
+ if (isRoundDevice) {
+ drawCircle(color = layerColor.copy(alpha))
+ } else {
+ drawRect(color = layerColor.copy(alpha))
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+private const val NAV_HOST_ENTER_TRANSITION_DURATION_SHORT = 100
+private const val NAV_HOST_ENTER_TRANSITION_DURATION_MEDIUM = 300
+private val NAV_HOST_ENTER_TRANSITION_EASING_STANDARD = CubicBezierEasing(0.4f, 0f, 0.2f, 1f)
+private val FLASH_COLOR = Color.White
diff --git a/wear/compose/compose-navigation/src/main/java/androidx/wear/compose/navigation/PredictiveBackNavHost.kt b/wear/compose/compose-navigation/src/main/java/androidx/wear/compose/navigation/PredictiveBackNavHost.kt
new file mode 100644
index 0000000..e97ad67
--- /dev/null
+++ b/wear/compose/compose-navigation/src/main/java/androidx/wear/compose/navigation/PredictiveBackNavHost.kt
@@ -0,0 +1,286 @@
+/*
+ * 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.navigation
+
+import android.os.Build
+import android.util.Log
+import androidx.activity.compose.PredictiveBackHandler
+import androidx.annotation.RequiresApi
+import androidx.collection.mutableObjectFloatMapOf
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.ContentTransform
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.SeekableTransitionState
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animate
+import androidx.compose.animation.core.rememberTransition
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveableStateHolder
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
+import androidx.navigation.NavDestination
+import androidx.navigation.NavGraph
+import androidx.navigation.NavHostController
+import androidx.navigation.Navigator
+import androidx.navigation.compose.LocalOwnersProvider
+import androidx.navigation.get
+import androidx.wear.compose.foundation.LocalSwipeToDismissBackgroundScrimColor
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.launch
+
+@Composable
+@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+internal fun PredictiveBackNavHost(
+ navController: NavHostController,
+ graph: NavGraph,
+ modifier: Modifier = Modifier,
+ userSwipeEnabled: Boolean = true,
+) {
+ val lifecycleOwner = LocalLifecycleOwner.current
+ val viewModelStoreOwner =
+ checkNotNull(LocalViewModelStoreOwner.current) {
+ "SwipeDismissableNavHost requires a ViewModelStoreOwner to be provided " +
+ "via LocalViewModelStoreOwner"
+ }
+
+ navController.setViewModelStore(viewModelStoreOwner.viewModelStore)
+
+ // Then set the graph
+ navController.graph = graph
+
+ // Find the WearNavigator, returning early if it isn't found
+ // (such as is the case when using TestNavHostController).
+ val wearNavigator =
+ navController.navigatorProvider.get<Navigator<out NavDestination>>(WearNavigator.NAME)
+ as? WearNavigator ?: return
+
+ val backStack by wearNavigator.backStack.collectAsState()
+
+ val navigateBack: () -> Unit = { navController.popBackStack() }
+
+ DisposableEffect(lifecycleOwner) {
+ // Setup the navController with proper owners
+ navController.setLifecycleOwner(lifecycleOwner)
+ onDispose {}
+ }
+
+ val stateHolder = rememberSaveableStateHolder()
+
+ val previous = backStack.getOrNull(backStack.lastIndex - 1)
+ // Get the current navigation backstack entry. If the backstack is empty, it could be because
+ // no WearNavigator.Destinations were added to the navigation backstack (be sure to build
+ // the NavGraph using androidx.wear.compose.navigation.composable) or because the last entry
+ // was popped prior to navigating (instead, use navigate with popUpTo).
+ // If the activity is using FLAG_ACTIVITY_NEW_TASK then it also needs to set
+ // FLAG_ACTIVITY_CLEAR_TASK, otherwise the activity will be created twice,
+ // the first of these with an empty backstack.
+ val current = backStack.lastOrNull()
+
+ if (current == null) {
+ val warningText =
+ "Current backstack entry is empty. Please ensure: \n" +
+ "1. The current WearNavigator navigation backstack is not empty (e.g. by using " +
+ "androidx.wear.compose.navigation.composable to build your nav graph). \n" +
+ "2. The last entry is not popped prior to navigation " +
+ "(instead, use navigate with popUpTo). \n" +
+ "3. If the activity uses FLAG_ACTIVITY_NEW_TASK you should also set " +
+ "FLAG_ACTIVITY_CLEAR_TASK to maintain the backstack consistency."
+
+ Log.w(TAG, warningText)
+ // There's nothing to draw here, so we can return early to make sure "current" is always
+ // available below this line
+ return
+ }
+
+ val scrimColor = LocalSwipeToDismissBackgroundScrimColor.current
+ val isRoundDevice = isRoundDevice()
+
+ // Use PredictiveBackHandler instead of BackHandler on API >= 35
+ var progress by remember { mutableFloatStateOf(0f) }
+ var inPredictiveBack by remember { mutableStateOf(false) }
+ val transitionState = remember { SeekableTransitionState(current) }
+ PredictiveBackHandler(userSwipeEnabled && backStack.size > 1) { backEvent ->
+ inPredictiveBack = true
+ progress = 0f
+ try {
+ backEvent.collect { progress = it.progress }
+ Animatable(progress).animateTo(1f, TRANSITION_ANIMATION_SPEC) { progress = value }
+ inPredictiveBack = false
+ navigateBack()
+ } catch (e: CancellationException) {
+ inPredictiveBack = false
+ }
+ }
+
+ val zIndices = remember { mutableObjectFloatMapOf<String>() }
+ val transition = rememberTransition(transitionState, label = "entry")
+
+ if (inPredictiveBack && previous != null) {
+ LaunchedEffect(progress) { transitionState.seekTo(progress, previous) }
+ } else {
+ LaunchedEffect(current) {
+ if (transitionState.currentState != current) {
+ transitionState.animateTo(current)
+ } else {
+ animate(transitionState.fraction, 0f, animationSpec = TRANSITION_ANIMATION_SPEC) {
+ value,
+ _ ->
+ [email protected] {
+ if (value > 0) {
+ // Seek the original transition back to the currentState
+ transitionState.seekTo(value)
+ }
+ if (value == 0f) {
+ // Once we animate to the start, we need to snap to the right state.
+ transitionState.snapTo(current)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ transition.AnimatedContent(
+ modifier,
+ transitionSpec = {
+ val initialZIndex = zIndices.getOrPut(initialState.id) { 0f }
+ val targetZIndex =
+ when {
+ targetState.id == initialState.id -> initialZIndex
+ wearNavigator.isPop.value || inPredictiveBack ->
+ initialZIndex - 1f // Going to the previous page, so zIndex - 1
+ else -> initialZIndex + 1f // Going to the next page, so zIndex + 1
+ }.also { zIndices[targetState.id] = it }
+
+ ContentTransform(
+ targetContentEnter =
+ if (wearNavigator.isPop.value || inPredictiveBack) POP_ENTER_TRANSITION
+ else ENTER_TRANSITION,
+ initialContentExit =
+ if (wearNavigator.isPop.value || inPredictiveBack) POP_EXIT_TRANSITION
+ else EXIT_TRANSITION,
+ targetContentZIndex = targetZIndex,
+ sizeTransform = null
+ )
+ },
+ contentAlignment = Alignment.Center,
+ contentKey = { it.id }
+ ) {
+ // In some specific cases, such as popping your back stack or changing your
+ // start destination, AnimatedContent can contain an entry that is no longer
+ // part of visible entries since it was cleared from the back stack and is not
+ // animating.
+ val currentEntry =
+ if (wearNavigator.isPop.value || inPredictiveBack) {
+ // We have to do this because the previous entry might not show up in backStack
+ it
+ } else {
+ backStack.lastOrNull { entry -> it == entry }
+ }
+
+ if (currentEntry != null) {
+ Box(
+ modifier =
+ Modifier.background(
+ scrimColor,
+ if (isRoundDevice) CircleShape else RectangleShape
+ )
+ .fillMaxSize()
+ ) {
+ // while in the scope of the composable, we provide the navBackStackEntry as the
+ // ViewModelStoreOwner and LifecycleOwner
+ if (currentEntry.lifecycle.currentState != Lifecycle.State.DESTROYED) {
+ currentEntry.LocalOwnersProvider(stateHolder) {
+ (currentEntry.destination as WearNavigator.Destination).content(
+ currentEntry
+ )
+ }
+ }
+ if (currentEntry != current) {
+ Box(
+ modifier =
+ Modifier.clickable(
+ enabled = false,
+ indication = null,
+ interactionSource = remember { MutableInteractionSource() }
+ ) {
+ // Ignore taps on previous backstack entries
+ }
+ .fillMaxSize()
+ )
+ }
+ }
+ }
+ }
+ LaunchedEffect(transition.currentState, transition.targetState) {
+ if (transition.currentState == transition.targetState) {
+ backStack.forEach { entry -> wearNavigator.onTransitionComplete(entry) }
+ zIndices.forEach { key, _ ->
+ if (key != transition.targetState.id) zIndices.remove(key)
+ }
+ }
+ }
+}
+
+private val ENTER_TRANSITION =
+ slideInHorizontally(initialOffsetX = { it / 2 }, animationSpec = spring(0.8f, 300f)) +
+ scaleIn(initialScale = 0.8f, animationSpec = spring(1f, 500f)) +
+ fadeIn(animationSpec = spring(1f, 1500f))
+private val EXIT_TRANSITION =
+ scaleOut(targetScale = 0.85f, animationSpec = spring(1f, 150f)) +
+ slideOutHorizontally(targetOffsetX = { -it / 2 }, animationSpec = spring(0.8f, 200f)) +
+ fadeOut(targetAlpha = 0.6f, animationSpec = spring(1f, 1400f))
+private val POP_ENTER_TRANSITION =
+ scaleIn(initialScale = 0.8f, animationSpec = tween(easing = LinearEasing)) +
+ slideInHorizontally(
+ initialOffsetX = { -it / 2 },
+ animationSpec = tween(easing = LinearEasing)
+ ) +
+ fadeIn(initialAlpha = 0.5f, animationSpec = tween(easing = LinearEasing))
+private val POP_EXIT_TRANSITION =
+ slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(easing = LinearEasing)) +
+ scaleOut(targetScale = 0.8f, animationSpec = tween(easing = LinearEasing))
+
+private val TRANSITION_ANIMATION_SPEC =
+ spring<Float>(Spring.DampingRatioNoBouncy, Spring.StiffnessMedium)
diff --git a/wear/compose/compose-navigation/src/main/java/androidx/wear/compose/navigation/SwipeDismissableNavHost.kt b/wear/compose/compose-navigation/src/main/java/androidx/wear/compose/navigation/SwipeDismissableNavHost.kt
index 03c34ce..8d9d2db 100644
--- a/wear/compose/compose-navigation/src/main/java/androidx/wear/compose/navigation/SwipeDismissableNavHost.kt
+++ b/wear/compose/compose-navigation/src/main/java/androidx/wear/compose/navigation/SwipeDismissableNavHost.kt
@@ -16,52 +16,19 @@
package androidx.wear.compose.navigation
-import android.util.Log
-import androidx.activity.compose.BackHandler
-import androidx.compose.animation.core.Animatable
-import androidx.compose.animation.core.AnimationVector1D
-import androidx.compose.animation.core.CubicBezierEasing
-import androidx.compose.animation.core.LinearEasing
-import androidx.compose.animation.core.tween
-import androidx.compose.foundation.Canvas
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.shape.CircleShape
+import android.os.Build
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.saveable.SaveableStateHolder
-import androidx.compose.runtime.saveable.rememberSaveableStateHolder
-import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.RectangleShape
-import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalConfiguration
-import androidx.compose.ui.util.lerp
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.LifecycleEventObserver
-import androidx.lifecycle.compose.LocalLifecycleOwner
-import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
-import androidx.navigation.NavBackStackEntry
-import androidx.navigation.NavDestination
import androidx.navigation.NavGraph
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
-import androidx.navigation.Navigator
-import androidx.navigation.compose.LocalOwnersProvider
import androidx.navigation.createGraph
-import androidx.navigation.get
import androidx.wear.compose.foundation.BasicSwipeToDismissBox
-import androidx.wear.compose.foundation.LocalReduceMotion
import androidx.wear.compose.foundation.LocalSwipeToDismissBackgroundScrimColor
import androidx.wear.compose.foundation.LocalSwipeToDismissContentScrimColor
import androidx.wear.compose.foundation.SwipeToDismissBoxState
-import androidx.wear.compose.foundation.SwipeToDismissKeys
import androidx.wear.compose.foundation.edgeSwipeToDismiss
import androidx.wear.compose.foundation.rememberSwipeToDismissBoxState
@@ -75,11 +42,16 @@
* The builder passed into this method is [remember]ed. This means that for this NavHost, the
* contents of the builder cannot be changed.
*
- * Content is displayed within a [BasicSwipeToDismissBox], showing the current navigation level.
* During a swipe-to-dismiss gesture, the previous navigation level (if any) is shown in the
* background. BackgroundScrimColor and ContentScrimColor of it are taken from
* [LocalSwipeToDismissBackgroundScrimColor] and [LocalSwipeToDismissContentScrimColor].
*
+ * Below API level 35, content of the current navigation level is displayed within a
+ * [BasicSwipeToDismissBox] to detect swipe back gestures.
+ *
+ * API level 35 onwards, [SwipeDismissableNavHost] listens to platform predictive back events for
+ * navigation, and [BasicSwipeToDismissBox] is not used for swipe gesture detection.
+ *
* Example of a [SwipeDismissableNavHost] alternating between 2 screens:
*
* @sample androidx.wear.compose.navigation.samples.SimpleNavHost
@@ -91,12 +63,14 @@
* @param startDestination The route for the start destination
* @param modifier The modifier to be applied to the layout
* @param userSwipeEnabled [Boolean] Whether swipe-to-dismiss gesture is enabled.
- * @param state State containing information about ongoing swipe and animation.
+ * @param state State containing information about ongoing swipe and animation. This parameter is
+ * unused API level 35 onwards, because the platform supports predictive back and
+ * [SwipeDismissableNavHost] uses platform gestures to detect the back gestures.
* @param route The route for the graph
* @param builder The builder used to construct the graph
*/
@Composable
-public fun SwipeDismissableNavHost(
+fun SwipeDismissableNavHost(
navController: NavHostController,
startDestination: String,
modifier: Modifier = Modifier,
@@ -125,11 +99,16 @@
* The builder passed into this method is [remember]ed. This means that for this NavHost, the
* contents of the builder cannot be changed.
*
- * Content is displayed within a [BasicSwipeToDismissBox], showing the current navigation level.
* During a swipe-to-dismiss gesture, the previous navigation level (if any) is shown in the
* background. BackgroundScrimColor and ContentScrimColor of it are taken from
* [LocalSwipeToDismissBackgroundScrimColor] and [LocalSwipeToDismissContentScrimColor].
*
+ * Below API level 35, content of the current navigation level is displayed within a
+ * [BasicSwipeToDismissBox] to detect swipe back gestures.
+ *
+ * API level 35 onwards, [SwipeDismissableNavHost] listens to platform predictive back events for
+ * navigation, and [BasicSwipeToDismissBox] is not used for swipe gesture detection.
+ *
* Example of a [SwipeDismissableNavHost] alternating between 2 screens:
*
* @sample androidx.wear.compose.navigation.samples.SimpleNavHost
@@ -141,173 +120,34 @@
* @param graph Graph for this host
* @param modifier [Modifier] to be applied to the layout
* @param userSwipeEnabled [Boolean] Whether swipe-to-dismiss gesture is enabled.
- * @param state State containing information about ongoing swipe and animation.
+ * @param state State containing information about ongoing swipe and animation. This parameter is
+ * unused API level 35 onwards, because the platform supports predictive back and
+ * [SwipeDismissableNavHost] uses platform gestures to detect the back gestures.
* @throws IllegalArgumentException if no WearNavigation.Destination is on the navigation backstack.
*/
@Composable
-public fun SwipeDismissableNavHost(
+fun SwipeDismissableNavHost(
navController: NavHostController,
graph: NavGraph,
modifier: Modifier = Modifier,
userSwipeEnabled: Boolean = true,
state: SwipeDismissableNavHostState = rememberSwipeDismissableNavHostState(),
) {
- val lifecycleOwner = LocalLifecycleOwner.current
- val viewModelStoreOwner =
- checkNotNull(LocalViewModelStoreOwner.current) {
- "SwipeDismissableNavHost requires a ViewModelStoreOwner to be provided " +
- "via LocalViewModelStoreOwner"
- }
-
- navController.setViewModelStore(viewModelStoreOwner.viewModelStore)
-
- // Then set the graph
- navController.graph = graph
-
- // Find the WearNavigator, returning early if it isn't found
- // (such as is the case when using TestNavHostController).
- val wearNavigator =
- navController.navigatorProvider.get<Navigator<out NavDestination>>(WearNavigator.NAME)
- as? WearNavigator ?: return
-
- val backStack by wearNavigator.backStack.collectAsState()
-
- val navigateBack: () -> Unit = { navController.popBackStack() }
- BackHandler(enabled = backStack.size > 1, onBack = navigateBack)
-
- val transitionsInProgress by wearNavigator.transitionsInProgress.collectAsState()
- var initialContent by remember { mutableStateOf(true) }
-
- DisposableEffect(lifecycleOwner) {
- // Setup the navController with proper owners
- navController.setLifecycleOwner(lifecycleOwner)
- onDispose {}
- }
-
- val stateHolder = rememberSaveableStateHolder()
-
- val previous = if (backStack.size <= 1) null else backStack[backStack.lastIndex - 1]
- // Get the current navigation backstack entry. If the backstack is empty, it could be because
- // no WearNavigator.Destinations were added to the navigation backstack (be sure to build
- // the NavGraph using androidx.wear.compose.navigation.composable) or because the last entry
- // was popped prior to navigating (instead, use navigate with popUpTo).
- // If the activity is using FLAG_ACTIVITY_NEW_TASK then it also needs to set
- // FLAG_ACTIVITY_CLEAR_TASK, otherwise the activity will be created twice,
- // the first of these with an empty backstack.
- val current = backStack.lastOrNull()
-
- if (current == null) {
- val warningText =
- "Current backstack entry is empty. Please ensure: \n" +
- "1. The current WearNavigator navigation backstack is not empty (e.g. by using " +
- "androidx.wear.compose.navigation.composable to build your nav graph). \n" +
- "2. The last entry is not popped prior to navigation " +
- "(instead, use navigate with popUpTo). \n" +
- "3. If the activity uses FLAG_ACTIVITY_NEW_TASK you should also set " +
- "FLAG_ACTIVITY_CLEAR_TASK to maintain the backstack consistency."
-
- Log.w(TAG, warningText)
- }
-
- val swipeState = state.swipeToDismissBoxState
- LaunchedEffect(swipeState.isAnimationRunning) {
- // This effect marks the transitions completed when swipe animations finish,
- // so that the navigation backstack entries can go to Lifecycle.State.RESUMED.
- if (!swipeState.isAnimationRunning) {
- transitionsInProgress.forEach { entry -> wearNavigator.onTransitionComplete(entry) }
- }
- }
-
- val isRoundDevice = isRoundDevice()
- val reduceMotionEnabled = LocalReduceMotion.current.enabled()
-
- val animationProgress =
- remember(current) {
- if (!wearNavigator.isPop.value) {
- Animatable(0f)
- } else {
- Animatable(1f)
- }
- }
-
- LaunchedEffect(animationProgress.isRunning) {
- if (!animationProgress.isRunning) {
- transitionsInProgress.forEach { entry -> wearNavigator.onTransitionComplete(entry) }
- }
- }
-
- LaunchedEffect(current) {
- if (!wearNavigator.isPop.value) {
- if (reduceMotionEnabled) {
- animationProgress.snapTo(1f)
- } else {
- animationProgress.animateTo(
- targetValue = 1f,
- animationSpec =
- tween(
- durationMillis =
- NAV_HOST_ENTER_TRANSITION_DURATION_MEDIUM +
- NAV_HOST_ENTER_TRANSITION_DURATION_SHORT,
- easing = LinearEasing
- )
- )
- }
- }
- }
-
- BasicSwipeToDismissBox(
- onDismissed = navigateBack,
- state = swipeState,
- modifier = Modifier,
- userSwipeEnabled = userSwipeEnabled && previous != null,
- backgroundKey = previous?.id ?: SwipeToDismissKeys.Background,
- contentKey = current?.id ?: SwipeToDismissKeys.Content
- ) { isBackground ->
- BoxedStackEntryContent(
- entry = if (isBackground) previous else current,
- saveableStateHolder = stateHolder,
- modifier =
- modifier.then(
- if (isBackground) {
- Modifier // Not applying graphicsLayer on the background.
- } else {
- Modifier.graphicsLayer {
- val scaleProgression =
- NAV_HOST_ENTER_TRANSITION_EASING_STANDARD.transform(
- (animationProgress.value / 0.75f)
- )
- val opacityProgression =
- NAV_HOST_ENTER_TRANSITION_EASING_STANDARD.transform(
- (animationProgress.value / 0.25f)
- )
- val scale = lerp(0.75f, 1f, scaleProgression).coerceAtMost(1f)
- val opacity = lerp(0.1f, 1f, opacityProgression).coerceIn(0f, 1f)
- scaleX = scale
- scaleY = scale
- alpha = opacity
- clip = true
- shape = if (isRoundDevice) CircleShape else RectangleShape
- }
- }
- ),
- layerColor =
- if (isBackground || wearNavigator.isPop.value) {
- Color.Unspecified
- } else FLASH_COLOR,
- animatable = animationProgress
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
+ PredictiveBackNavHost(
+ navController = navController,
+ graph = graph,
+ modifier = modifier,
+ userSwipeEnabled = userSwipeEnabled,
)
- }
-
- DisposableEffect(previous, current) {
- if (initialContent) {
- // There are no animations for showing the initial content, so mark transitions
- // complete, allowing the navigation backstack entry to go to Lifecycle.State.RESUMED.
- transitionsInProgress.forEach { entry -> wearNavigator.onTransitionComplete(entry) }
- initialContent = false
- }
- onDispose {
- transitionsInProgress.forEach { entry -> wearNavigator.onTransitionComplete(entry) }
- }
+ } else {
+ BasicSwipeToDismissBoxNavHost(
+ navController = navController,
+ graph = graph,
+ modifier = modifier,
+ userSwipeEnabled = userSwipeEnabled,
+ state = state,
+ )
}
}
@@ -346,7 +186,7 @@
level = DeprecationLevel.HIDDEN
)
@Composable
-public fun SwipeDismissableNavHost(
+fun SwipeDismissableNavHost(
navController: NavHostController,
startDestination: String,
modifier: Modifier = Modifier,
@@ -398,7 +238,7 @@
level = DeprecationLevel.HIDDEN
)
@Composable
-public fun SwipeDismissableNavHost(
+fun SwipeDismissableNavHost(
navController: NavHostController,
graph: NavGraph,
modifier: Modifier = Modifier,
@@ -419,9 +259,7 @@
* swipe-to-dismiss gesture in [SwipeDismissableNavHost] and can also be used to support
* edge-swiping, using [edgeSwipeToDismiss].
*/
-public class SwipeDismissableNavHostState(
- internal val swipeToDismissBoxState: SwipeToDismissBoxState
-) {
+class SwipeDismissableNavHostState(internal val swipeToDismissBoxState: SwipeToDismissBoxState) {
@Suppress("DEPRECATION")
@Deprecated(
"This overload is provided for backward compatibility. " +
@@ -438,10 +276,12 @@
*
* @param swipeToDismissBoxState State for [BasicSwipeToDismissBox], which is used to support the
* swipe-to-dismiss gesture in [SwipeDismissableNavHost] and can also be used to support
- * edge-swiping, using [edgeSwipeToDismiss].
+ * edge-swiping, using [edgeSwipeToDismiss]. This parameter is unused after API 35, because the
+ * platform supports edge-swiping via predictive back gesture, and [SwipeDismissableNavHost] drops
+ * the use of [BasicSwipeToDismissBox] in favour of predictive back based navigation.
*/
@Composable
-public fun rememberSwipeDismissableNavHostState(
+fun rememberSwipeDismissableNavHostState(
swipeToDismissBoxState: SwipeToDismissBoxState = rememberSwipeToDismissBoxState()
): SwipeDismissableNavHostState {
return remember(swipeToDismissBoxState) { SwipeDismissableNavHostState(swipeToDismissBoxState) }
@@ -454,7 +294,7 @@
level = DeprecationLevel.HIDDEN
)
@Composable
-public fun rememberSwipeDismissableNavHostState(
+fun rememberSwipeDismissableNavHostState(
swipeToDismissBoxState: androidx.wear.compose.material.SwipeToDismissBoxState =
androidx.wear.compose.material.rememberSwipeToDismissBoxState()
): SwipeDismissableNavHostState {
@@ -462,53 +302,9 @@
}
@Composable
-private fun BoxedStackEntryContent(
- entry: NavBackStackEntry?,
- saveableStateHolder: SaveableStateHolder,
- modifier: Modifier = Modifier,
- layerColor: Color,
- animatable: Animatable<Float, AnimationVector1D>
-) {
- if (entry != null) {
- val isRoundDevice = isRoundDevice()
- var lifecycleState by remember { mutableStateOf(entry.lifecycle.currentState) }
- DisposableEffect(entry.lifecycle) {
- val observer = LifecycleEventObserver { _, event -> lifecycleState = event.targetState }
- entry.lifecycle.addObserver(observer)
- onDispose { entry.lifecycle.removeObserver(observer) }
- }
- if (lifecycleState.isAtLeast(Lifecycle.State.CREATED)) {
- Box(modifier, propagateMinConstraints = true) {
- val destination = entry.destination as WearNavigator.Destination
- entry.LocalOwnersProvider(saveableStateHolder) { destination.content(entry) }
- // Adding a flash effect when a new screen opens
- if (layerColor != Color.Unspecified) {
- Canvas(Modifier.fillMaxSize()) {
- val absoluteProgression =
- ((animatable.value - 0.25f) / 0.75f).coerceIn(0f, 1f)
- val easedProgression =
- NAV_HOST_ENTER_TRANSITION_EASING_STANDARD.transform(absoluteProgression)
- val alpha = lerp(0.07f, 0f, easedProgression).coerceIn(0f, 1f)
- if (isRoundDevice) {
- drawCircle(color = layerColor.copy(alpha))
- } else {
- drawRect(color = layerColor.copy(alpha))
- }
- }
- }
- }
- }
- }
-}
-
-@Composable
-private fun isRoundDevice(): Boolean {
+internal fun isRoundDevice(): Boolean {
val configuration = LocalConfiguration.current
return remember(configuration) { configuration.isScreenRound }
}
-private const val TAG = "SwipeDismissableNavHost"
-private const val NAV_HOST_ENTER_TRANSITION_DURATION_SHORT = 100
-private const val NAV_HOST_ENTER_TRANSITION_DURATION_MEDIUM = 300
-private val NAV_HOST_ENTER_TRANSITION_EASING_STANDARD = CubicBezierEasing(0.4f, 0f, 0.2f, 1f)
-private val FLASH_COLOR = Color.White
+internal const val TAG = "SwipeDismissableNavHost"
diff --git a/wear/compose/integration-tests/navigation/src/main/java/androidx/wear/compose/integration/navigation/MainActivity.kt b/wear/compose/integration-tests/navigation/src/main/java/androidx/wear/compose/integration/navigation/MainActivity.kt
index 21ba986..fe2e2ee 100644
--- a/wear/compose/integration-tests/navigation/src/main/java/androidx/wear/compose/integration/navigation/MainActivity.kt
+++ b/wear/compose/integration-tests/navigation/src/main/java/androidx/wear/compose/integration/navigation/MainActivity.kt
@@ -80,10 +80,16 @@
modifier = Modifier.fillMaxSize(),
) {
Text(text = "Screen 2", color = MaterialTheme.colors.onSurface)
+ Spacer(modifier = Modifier.fillMaxWidth().height(4.dp))
CompactChip(
onClick = { navController.navigate(SCREEN3) },
label = { Text("Click for next screen") },
)
+ Spacer(modifier = Modifier.fillMaxWidth().height(4.dp))
+ CompactChip(
+ onClick = { navController.popBackStack() },
+ label = { Text("Go Back") },
+ )
}
}
composable(SCREEN3) {
@@ -93,10 +99,16 @@
modifier = Modifier.fillMaxSize(),
) {
Text(text = "Screen 3", color = MaterialTheme.colors.onSurface)
+ Spacer(modifier = Modifier.fillMaxWidth().height(4.dp))
Text(
text = "Swipe right to go back",
color = MaterialTheme.colors.onSurface,
)
+ Spacer(modifier = Modifier.fillMaxWidth().height(4.dp))
+ CompactChip(
+ onClick = { navController.popBackStack() },
+ label = { Text("Go Back") },
+ )
}
}
composable(EDGE_SWIPE_SCREEN) {